Consolidation pass: HR-only access, effective-dated mutations, data integrity guards, test suite
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`).
This commit is contained in:
172
supabase/migrations/20260714120000_hr_only_access.sql
Normal file
172
supabase/migrations/20260714120000_hr_only_access.sql
Normal file
@@ -0,0 +1,172 @@
|
||||
-- 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;
|
||||
Reference in New Issue
Block a user