-- AK-P4b · P4 Tasks + EntityLink — RLS ROLLOUT ARTIFACT (deferred, NOT in the auto-migration) -- ============================================================================= -- Companion to R-P2b's prisma/rls/tenant-isolation.sql and AK-P4a's -- prisma/rls/file-isolation.sql. The Task / TaskWatcher / EntityLink tables + -- enums + indexes + the single-assignee CHECK + the pg_trgm title index ship in -- the tracked migration prisma/migrations/_p4b_tasks_entitylinks. What lives -- in THIS file is the Row-Level-Security block, DELIBERATELY kept out of the -- auto-migration path (same stance as file-isolation.sql): turning RLS on before -- every access sets the GUC would break the feature, so activation is a -- coordinated cutover, not a schema diff. -- -- WHY DEFERRED: enabling RLS + FORCE makes every Task/TaskWatcher/EntityLink query -- WITHOUT `app.tenant_id` return ZERO rows. Per R-P2b's rule a table may only be -- RLS-enabled once ALL its accesses set the GUC. The app-level scoping -- (where: { tenantId }) carries isolation meanwhile. -- -- CODE-SIDE PRECONDITIONS — DONE (AK-P4b "Teil A", 2026-06-24): -- 1. [x] Task/EntityLink web services run every DB op inside withTenantRLS() -- (apps/web/app/utils/integrations/taskService.server.ts, -- apps/web/app/utils/integrations/entityLinkService.server.ts). -- The only unwrapped access is the GLOBAL User lookup (resolveUserLabels); -- User is not tenant-scoped, so it intentionally stays on the db singleton. -- 2. [x] Worker maintenance crons set the tenant GUC per tenant: the archive + -- reminder sweeps enumerate active tenants from the global Tenant -- registry and run the Task/TaskWatcher reads/writes inside -- withTenantRLS() (apps/worker/src/handlers.ts via -- apps/worker/src/db/tenantRls.ts). notify() runs outside the tx. -- Proven by apps/web/app/utils/db/.server/rls-task-isolation.integration.test.ts -- (own-tenant only, 0 cross-tenant, 0 without context, WITH CHECK blocks foreign -- insert; Task + TaskWatcher + EntityLink). -- -- WHAT REMAINS — "Teil B" = the org-wide P5 RLS cutover (Raffael / R-P2b): -- a. Ship this block as its own forward-only migration. -- b. Switch the runtime DB connection to the restricted role `hivegrid_app` -- (NOSUPERUSER, NOBYPASSRLS); otherwise FORCE is a no-op for a superuser -- and an empty-read trap for a non-superuser owner. The GRANTs below already -- cover that role for the three Task tables. -- c. MAINTENANCE-WORKER NOTE: the cross-tenant archive/reminder sweeps read the -- global "Tenant" registry to enumerate tenants. Under hivegrid_app that -- needs `GRANT SELECT ON "Tenant" TO hivegrid_app` (the registry holds no -- per-tenant payload, only tenant ids/slugs) OR the maintenance worker runs -- under a privileged connection. Decide with R-P2b at cutover. -- d. Staging cutover + live cross-tenant 404 verification. -- -- Forward-only (DoD 28). Inverse SQL as comment per block. Order-dependent DDL is -- DO-guarded (P3018 protection: reproducible from an empty DB). Idempotent. -- ============================================================================= -- Row-level security on the three Tasks/EntityLink tables --------------------- -- Pattern: tenant_id = current_setting('app.tenant_id', true). The second -- argument `true` returns NULL instead of erroring when the GUC is not (yet) -- set — the policy then matches nothing (fail-closed), and server-side scoping -- stays the hard path until the cutover. -- -- Inverse per table: -- REVOKE SELECT, INSERT, UPDATE, DELETE ON "" FROM hivegrid_app; -- DROP POLICY IF EXISTS "_tenant_isolation" ON ""; -- ALTER TABLE "" NO FORCE ROW LEVEL SECURITY; -- ALTER TABLE "" DISABLE ROW LEVEL SECURITY; DO $$ DECLARE tbl text; BEGIN FOREACH tbl IN ARRAY ARRAY['Task', 'TaskWatcher', 'EntityLink'] LOOP EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY;', tbl); -- FORCE so the table owner is subject to the policies too (otherwise the -- owner silently bypasses RLS). EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY;', tbl); IF NOT EXISTS ( SELECT 1 FROM pg_policies WHERE schemaname = current_schema() AND tablename = tbl AND policyname = tbl || '_tenant_isolation' ) THEN EXECUTE format( 'CREATE POLICY %I ON %I USING ("tenantId" = current_setting(''app.tenant_id'', true)) WITH CHECK ("tenantId" = current_setting(''app.tenant_id'', true));', tbl || '_tenant_isolation', tbl ); END IF; -- The restricted app role (R-P2b's hivegrid_app, NOBYPASSRLS) needs table -- privileges to reach the rows the policy then filters. Mirrors the GRANT in -- prisma/rls/tenant-isolation.sql for "TenantUser". EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON %I TO hivegrid_app;', tbl); END LOOP; END $$; -- ============================================================================= -- Verify green before the cutover migration (AGENTS.md §Prisma): -- pnpm prisma validate -- pnpm prisma migrate status -- CI: `prisma migrate deploy` against an EMPTY DB (reproducible from scratch, -- no order dependency on Raffael's P2 migration). -- Coordination: align ordering with R-P2b — P2 ships withTenantRLS() -- (SET LOCAL app.tenant_id); without a tenant context the policies match nothing -- (no data leak, just empty results) until the runtime role is hivegrid_app. -- =============================================================================