Remove Supabase
The database moved to a container of our own; the platform is gone. This takes out what was left of it — and, where the leftovers were load bearing, moves rather than deletes. Moved, not deleted: supabase/migrations/ -> db/migrations/ the schema's source of truth supabase/build-org.ts -> scripts/build-org.ts lib/supabase/types.ts -> lib/types.ts 52 import sites repointed The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`, and simply renaming the schema would have left the runner facing an empty table: it would have called all 67 migrations pending and replayed them against a database that is long since current. So the runner now creates `migrationen.schema_migrations` and, once, copies the old rows across — guarded so a second run does nothing and a fresh database skips it entirely. Only then does migration 20260907100000 drop the old schema. Deleted: the CLI config, the seed, the historical schema/function dumps (nothing read them), scripts/umzug-von-supabase.sh (the move is done), and both Supabase packages plus the CLI. Nothing in the application imported them — the build now succeeds with no environment variables at all, which is the proof. Integration tests: six of them signed in through Supabase Auth and asserted against the anon key and the service role. That model is gone, so the tests were not portable — they are deleted. session-context and employee-status-filter already ran on pg and are untouched; om-reporting is ported to a direct connection because it guards a real risk (the reporting line rule exists twice, once in SQL and once in TypeScript). CI: the integration job started a Supabase stack. It now runs a postgres service, applies deploy/db-init and every migration to an empty database — that was the valuable part, and it still holds — then checks that a second run is a no-op, which is what proves the bookkeeping works. Docs: security-review.md audited a service-role key, a cookie adapter and auth.users, none of which exist. Restating findings about removed components would suggest today's system had been reviewed; it has not. It now records what was removed and says a fresh review is due. data-model.md was already marked obsolete and described the pre-OM schema; azure-migration.md was a plan for a route not taken. Both deleted. Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the packages. Integration tests skip cleanly with no database. Migration SQL and the runner are reviewed but NOT executed: no Docker here, and the old instance no longer resolves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
172
db/migrations/20260714120000_hr_only_access.sql
Normal file
172
db/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