Reworks the app from a two-role (hr_admin/manager) model to a single HR-only role gated by profiles.is_active, fixes transfer/promote/karenz/ reorg RPCs to actually defer future-dated changes via a new pending_org_changes table instead of writing them immediately (applied by a daily Vercel Cron route), makes reorg undo append-only instead of deleting history, adds Karenz-return and history-date integrity guards, deprecates the salary column, and adds explicit schema grants + perf indexes needed to run against a fresh (non-hosted) Postgres instance. Adds vitest unit + integration test suites (the latter against a real local Supabase instance) covering all of the above, plus lint/typecheck/ build wiring (`npm run check`).
173 lines
8.2 KiB
PL/PgSQL
173 lines
8.2 KiB
PL/PgSQL
-- HR-only access model (spec §2)
|
|
--
|
|
-- Rationale: the app previously had two profile roles (hr_admin / manager),
|
|
-- with "manager" intended as a read-only, salary-masked role. The revised
|
|
-- scope is HR-only: nobody except an active, explicitly-provisioned HR user
|
|
-- may open the app at all. This migration:
|
|
-- 1. Collapses profiles.role to the single allowed value 'hr'.
|
|
-- 2. Adds profiles.is_active (default false — new users get zero access
|
|
-- until an existing HR user explicitly activates them; see §2.3).
|
|
-- 3. Renames the authorization gate from is_hr_admin() to is_hr_user()
|
|
-- (checks role='hr' AND is_active=true) to match §2.4's naming and
|
|
-- semantics, and repoints every RLS policy at it.
|
|
-- 4. Fixes a real gap: several tables (divisions/departments/teams/
|
|
-- locations, employee_history, positions, audit_log, reorg_scenarios/
|
|
-- reorg_moves) had read policies scoped to `auth.role() = 'authenticated'`
|
|
-- — i.e. ANY signed-in Supabase Auth user, not just HR. Every one of
|
|
-- those is tightened to is_hr_user().
|
|
-- 5. Drops the employees_directory salary-masking view: with the
|
|
-- "manager" role gone and salary out of MVP scope (see the salary
|
|
-- deprecation migration), there is nothing left to mask and no second
|
|
-- role to mask it from. All reads now go through the base `employees`
|
|
-- table, gated by the same is_hr_user()-only policy as writes.
|
|
--
|
|
-- The application layer (app/(app)/layout.tsx) previously let any
|
|
-- authenticated Supabase user reach the shell (it only checked for a
|
|
-- session, not profiles.role/is_active) and merely hid the edit UI for
|
|
-- non-admins. That check is being replaced in the app code alongside this
|
|
-- migration — this migration is what makes that enforceable at the data
|
|
-- layer regardless of what the UI does.
|
|
|
|
-- ── profiles: single role, explicit activation ──────────────────────
|
|
alter table profiles drop constraint if exists profiles_role_check;
|
|
update profiles set role = 'hr' where role <> 'hr';
|
|
alter table profiles add constraint profiles_role_check check (role = 'hr');
|
|
alter table profiles alter column role set default 'hr';
|
|
|
|
alter table profiles add column if not exists is_active boolean not null default false;
|
|
alter table profiles add column if not exists created_by uuid references auth.users(id);
|
|
alter table profiles add column if not exists updated_at timestamptz not null default now();
|
|
|
|
-- Any *existing* profile row (i.e. someone already explicitly provisioned
|
|
-- before this migration) keeps working — activation is only "off by
|
|
-- default" for rows created from here on.
|
|
update profiles set is_active = true where is_active = false;
|
|
|
|
create or replace function fn_touch_profiles_updated_at()
|
|
returns trigger language plpgsql as $$
|
|
begin
|
|
new.updated_at = now();
|
|
return new;
|
|
end;
|
|
$$;
|
|
|
|
drop trigger if exists trg_profiles_touch_updated_at on profiles;
|
|
create trigger trg_profiles_touch_updated_at
|
|
before update on profiles
|
|
for each row execute function fn_touch_profiles_updated_at();
|
|
|
|
-- ── Authorization gate: is_hr_user() replaces is_hr_admin() ─────────
|
|
create or replace function is_hr_user()
|
|
returns boolean
|
|
language sql
|
|
security definer
|
|
set search_path = public
|
|
stable
|
|
as $$
|
|
select exists (
|
|
select 1 from profiles p where p.id = auth.uid() and p.role = 'hr' and p.is_active = true
|
|
);
|
|
$$;
|
|
|
|
create or replace function current_hr_user_id()
|
|
returns uuid
|
|
language sql
|
|
security definer
|
|
set search_path = public
|
|
stable
|
|
as $$
|
|
select p.id from profiles p where p.id = auth.uid() and p.role = 'hr' and p.is_active = true;
|
|
$$;
|
|
|
|
-- Back-compat shim so any not-yet-migrated call site (or a function defined
|
|
-- in an older addendum file not touched by this migration) keeps working;
|
|
-- new code should call is_hr_user() directly. Safe to drop once nothing
|
|
-- references is_hr_admin() anymore (tracked in docs/decisions).
|
|
create or replace function is_hr_admin()
|
|
returns boolean
|
|
language sql
|
|
stable
|
|
as $$
|
|
select is_hr_user();
|
|
$$;
|
|
|
|
create or replace function require_hr_admin()
|
|
returns void language plpgsql as $$
|
|
begin
|
|
if not is_hr_user() then
|
|
raise exception 'Nicht berechtigt: nur aktive HR-Benutzer:innen dürfen diese Aktion ausführen.';
|
|
end if;
|
|
end;
|
|
$$;
|
|
|
|
-- ── Re-scope every "any authenticated user" read policy to HR-only ──
|
|
drop policy if exists "org_read" on divisions;
|
|
create policy "org_read" on divisions for select using (is_hr_user());
|
|
drop policy if exists "org_write" on divisions;
|
|
create policy "org_write" on divisions for all using (is_hr_user()) with check (is_hr_user());
|
|
|
|
drop policy if exists "org_read" on departments;
|
|
create policy "org_read" on departments for select using (is_hr_user());
|
|
drop policy if exists "org_write" on departments;
|
|
create policy "org_write" on departments for all using (is_hr_user()) with check (is_hr_user());
|
|
|
|
drop policy if exists "org_read" on teams;
|
|
create policy "org_read" on teams for select using (is_hr_user());
|
|
drop policy if exists "org_write" on teams;
|
|
create policy "org_write" on teams for all using (is_hr_user()) with check (is_hr_user());
|
|
|
|
drop policy if exists "org_read" on locations;
|
|
create policy "org_read" on locations for select using (is_hr_user());
|
|
drop policy if exists "org_write" on locations;
|
|
create policy "org_write" on locations for all using (is_hr_user()) with check (is_hr_user());
|
|
|
|
drop policy if exists "history_read" on employee_history;
|
|
create policy "history_read" on employee_history for select using (is_hr_user());
|
|
drop policy if exists "history_insert_admin" on employee_history;
|
|
create policy "history_insert_admin" on employee_history for insert with check (is_hr_user());
|
|
|
|
drop policy if exists "positions_read" on positions;
|
|
create policy "positions_read" on positions for select using (is_hr_user());
|
|
drop policy if exists "positions_write_admin" on positions;
|
|
create policy "positions_write_admin" on positions for all using (is_hr_user()) with check (is_hr_user());
|
|
|
|
drop policy if exists "audit_read" on audit_log;
|
|
create policy "audit_read" on audit_log for select using (is_hr_user());
|
|
drop policy if exists "audit_insert_admin" on audit_log;
|
|
create policy "audit_insert_admin" on audit_log for insert with check (is_hr_user());
|
|
|
|
drop policy if exists "reorg_scenarios_read" on reorg_scenarios;
|
|
create policy "reorg_scenarios_read" on reorg_scenarios for select using (is_hr_user());
|
|
drop policy if exists "reorg_scenarios_write_admin" on reorg_scenarios;
|
|
create policy "reorg_scenarios_write_admin" on reorg_scenarios for all using (is_hr_user()) with check (is_hr_user());
|
|
|
|
drop policy if exists "reorg_moves_read" on reorg_moves;
|
|
create policy "reorg_moves_read" on reorg_moves for select using (is_hr_user());
|
|
drop policy if exists "reorg_moves_write_admin" on reorg_moves;
|
|
create policy "reorg_moves_write_admin" on reorg_moves for all using (is_hr_user()) with check (is_hr_user());
|
|
|
|
drop policy if exists "employees_admin_all" on employees;
|
|
create policy "employees_hr_all" on employees for all using (is_hr_user()) with check (is_hr_user());
|
|
|
|
-- profiles: users may always read their own row (needed to determine their
|
|
-- own HR status before is_hr_user() would otherwise apply); HR manages all.
|
|
drop policy if exists "profiles_select_admin" on profiles;
|
|
create policy "profiles_select_admin" on profiles for select using (is_hr_user());
|
|
drop policy if exists "profiles_write_admin" on profiles;
|
|
create policy "profiles_write_admin" on profiles for insert with check (is_hr_user());
|
|
drop policy if exists "profiles_update_admin" on profiles;
|
|
create policy "profiles_update_admin" on profiles for update using (is_hr_user()) with check (is_hr_user());
|
|
|
|
-- hire_drafts / saved_reports stay owner-scoped (unchanged) — but an owner
|
|
-- who is no longer an active HR user should not retain access either.
|
|
drop policy if exists "hire_drafts_owner" on hire_drafts;
|
|
create policy "hire_drafts_owner" on hire_drafts for all
|
|
using (created_by = auth.uid() and is_hr_user()) with check (created_by = auth.uid() and is_hr_user());
|
|
|
|
drop policy if exists "saved_reports_owner" on saved_reports;
|
|
create policy "saved_reports_owner" on saved_reports for all
|
|
using (created_by = auth.uid() and is_hr_user()) with check (created_by = auth.uid() and is_hr_user());
|
|
|
|
-- ── Drop the salary-masking view: no second role left to mask from ──
|
|
drop view if exists employees_directory;
|