-- 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;