diff --git a/.agents/skills/operations/SKILL.md b/.agents/skills/operations/SKILL.md index daa50e87c..3359a412c 100644 --- a/.agents/skills/operations/SKILL.md +++ b/.agents/skills/operations/SKILL.md @@ -5,9 +5,8 @@ description: Turn pending Database operator, provisioning, configuration, and pr # Operations -1. Run `npm run workflow:operator-closeout -- --write-evidence`. -2. Inventory pending actions from docs, evidence, logs, and current change without executing them. -3. Deduplicate by outcome and order prerequisites, local proof, approval, execution, verification, and rollback. -4. Separate local/offline actions from GitHub, Supabase, OpenAI, hosting, credentials, and production work. -5. Ask for approval only when the batch is precise enough to execute safely. -6. Record owner, command, expected result, evidence, rollback, and unresolved dependency for each item. +1. Inventory pending actions from docs, evidence, logs, and current change without executing them. +2. Deduplicate by outcome and order prerequisites, local proof, approval, execution, verification, and rollback. +3. Separate local/offline actions from GitHub, Supabase, OpenAI, hosting, credentials, and production work. +4. Ask for approval only when the batch is precise enough to execute safely. +5. After approval, run `npm run workflow:operator-closeout -- --write-evidence` and record owner, command, expected result, evidence, rollback, and unresolved dependency for each item. diff --git a/.agents/skills/plan/SKILL.md b/.agents/skills/plan/SKILL.md index 7d369c713..28e018b42 100644 --- a/.agents/skills/plan/SKILL.md +++ b/.agents/skills/plan/SKILL.md @@ -6,7 +6,7 @@ description: Plan safe risk-scoped Database work by inspecting the current chang # Plan 1. Complete the task-start preflight and preserve unrelated work. -2. Run `npm run workflow:flightplan -- --write-evidence`; add `--files pathA,pathB` for proposed paths. +2. Run `npm run workflow:flightplan`; add `--files pathA,pathB` for proposed paths. Add `--write-evidence` only when the user explicitly requests evidence capture. 3. Confirm the detected risk classes match behavior, not only filenames. 4. Start with the narrowest local check and widen only when warranted. 5. Never execute anything under `approvalRequired` without explicit confirmation. diff --git a/.agents/skills/review/SKILL.md b/.agents/skills/review/SKILL.md index 4ce0b4c30..17e03a35e 100644 --- a/.agents/skills/review/SKILL.md +++ b/.agents/skills/review/SKILL.md @@ -6,8 +6,8 @@ description: Review the current Database diff, branch, or explicitly approved PR # Review 1. Read `docs/codex-review-protocol.md` and `docs/branch-review-ledger.md` when present. -2. Resolve the local target SHA and check whether the same scope was already reviewed. +2. Resolve the target SHA/HEAD; skip merged, unchanged, or already-reviewed scopes. 3. Inspect changed behavior and realistic failure paths; prioritize reproducible P0-P2 findings. 4. Cite exact files and lines, trigger, impact, and the smallest proof or fix. -5. Do not call GitHub or hosted CI without explicit approval. -6. Record the completed local review in the ledger when repository instructions require it. +5. Do not run, modify, test, or otherwise interact with OpenAI, Supabase, GitHub/GitLab, hosted CI, production-like services, or provider-backed workflows without explicit user confirmation. +6. Record the completed local review in `docs/branch-review-ledger.md` whenever the ledger exists. diff --git a/.agents/skills/ui/SKILL.md b/.agents/skills/ui/SKILL.md index 6ba765344..f4689f835 100644 --- a/.agents/skills/ui/SKILL.md +++ b/.agents/skills/ui/SKILL.md @@ -6,8 +6,8 @@ description: Inspect and verify the live Database interface across routes, break # UI 1. Read the relevant Next.js guide under `node_modules/next/dist/docs/` before code changes. -2. Run `npm run workflow:design-sweep -- --write-evidence` and then `npm run ensure`. -3. Verify project identity before browser work; do not assume a port. +2. Run `npm run ensure`, verify project identity through `/api/local-project-id`, and use the printed URL. +3. Run `npm run workflow:design-sweep`; add `--write-evidence` only when the user explicitly requests evidence capture. 4. Inspect affected routes at phone and desktop widths plus keyboard, focus, reduced-motion, and forced-colors states. 5. Add the smallest focused browser proof, then use `npm run verify:ui` when proportionate. 6. Report routes, viewports, interactions, accessibility evidence, and residual visual risk. diff --git a/docs/operator-backlog.md b/docs/operator-backlog.md index d3c8a6259..8eda98d6a 100644 --- a/docs/operator-backlog.md +++ b/docs/operator-backlog.md @@ -34,7 +34,7 @@ Findings inventory for handover: [audit-handover-2026-07-14.md](audit-handover-2 | Redeploy worker (one always-on instance) | ✅ done | — | Worker deployment recorded live 2026-07-14; re-verify with `npm run reindex:health` | [worker-deploy-runbook.md](worker-deploy-runbook.md) | | Seed registry / differentials / medications (prod) | ⏳ pending | prod deploy | Services/Forms surfaces non-empty | [launch-operator-runbook.md §6](launch-operator-runbook.md) | | Switch auth connection cap 10-absolute → percentage-based (dashboard) | ⏳ pending | before first vertical scale-up | dashboard — not SQL/MCP settable | [auth-connection-cap-runbook.md](auth-connection-cap-runbook.md) · [capacity-review.md](capacity-review.md) | -| Wire SLO warn/page thresholds into a real alert channel | ⏳ pending | host metrics exist | nightly eval canary green from `main` (one `workflow_dispatch`) | [observability-slos.md](observability-slos.md) | +| Wire SLO warn/page thresholds into a real alert channel | ⏳ pending | host metrics exist | weekly eval canary green from `main` (one `workflow_dispatch`) | [observability-slos.md](observability-slos.md) | ## Standing secret / config placement (per environment) diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 928d261b1..facda1f1b 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -1798,7 +1798,7 @@ export function DocumentViewer({ const canViewSourceDocuments = localProjectReady; const canUsePrivateApis = localProjectReady && (clientDemoMode || authStatus === "authenticated"); const canUseAdministrativeApis = - localProjectReady && (serverDemoMode || (authStatus === "authenticated" && isAdministratorUser(session?.user))); + localProjectReady && !serverDemoMode && authStatus === "authenticated" && isAdministratorUser(session?.user); useEffect(() => { if (authStatus !== "loading") { @@ -2283,16 +2283,12 @@ export function DocumentViewer({ async function summarize() { if (!canSummarizeDocument) { setSummaryError( - !canUseAdministrativeApis - ? "Administrator access is required to summarise documents." + !canUsePrivateApis + ? "Sign in before summarising private documents." : "Load a source document before summarising.", ); return; } - if (!canUsePrivateApis) { - setSummaryError("Sign in before summarising private documents."); - return; - } const summaryMode = sourceSearch.trim().length === 0; const query = summaryMode ? documentSummaryQuestion : sourceSearch.trim(); const controller = new AbortController(); @@ -2411,11 +2407,11 @@ export function DocumentViewer({ ? `/?mode=documents&q=${encodeURIComponent(documentDisplayTitle(readyDocument))}&documentId=${encodeURIComponent(documentId)}` : documentHomeHref; const usefulPageHref = (page: number) => documentPageHref(documentId, page); - const canSummarizeDocument = viewerState === "ready" && !loadingSummary && canUseAdministrativeApis; + const canSummarizeDocument = viewerState === "ready" && !loadingSummary && canUsePrivateApis; const summarizeTitle = canSummarizeDocument ? "Answer from this document" - : !canUseAdministrativeApis - ? "Administrator access is required to answer from this document" + : !canUsePrivateApis + ? "Sign in required to answer from this document" : "Load a source document before answering"; const pageByNumber = useMemo(() => new Map(pages.map((page) => [page.page_number, page])), [pages]); const chunkById = useMemo(() => new Map(chunks.map((chunk) => [chunk.id, chunk])), [chunks]); diff --git a/src/components/clinical-dashboard/use-app-preferences.ts b/src/components/clinical-dashboard/use-app-preferences.ts index 4efe9e72a..d6ff4e981 100644 --- a/src/components/clinical-dashboard/use-app-preferences.ts +++ b/src/components/clinical-dashboard/use-app-preferences.ts @@ -1,8 +1,32 @@ "use client"; import { useCallback, useEffect, useSyncExternalStore } from "react"; +import { + DEFAULT_PREFERENCES, + normalizePreferences, + type AppPreferences, + type LandingPreference, +} from "@/lib/account-preferences"; import { useAuthSession } from "@/lib/supabase/client"; +export { + ANSWER_STYLE_OPTIONS, + DEFAULT_PREFERENCES, + DENSITY_OPTIONS, + JURISDICTION_OPTIONS, + LANDING_OPTIONS, + normalizePreferences, + POPULATION_OPTIONS, +} from "@/lib/account-preferences"; +export type { + AnswerStylePreference, + AppPreferences, + DensityPreference, + LandingPreference, + MotionPreference, + PopulationPreference, +} from "@/lib/account-preferences"; + const emptyAuthorizationHeader: Record = {}; const ignoreExpiredSession = () => undefined; @@ -22,83 +46,6 @@ function useAuthSessionIfAvailable() { * stays in sync between open tabs. Nothing here is PHI; values are plain enums. */ -export type DensityPreference = "comfortable" | "compact" | "spacious"; -export type MotionPreference = "system" | "reduced"; -export type PopulationPreference = "adults" | "older-adults" | "adolescents" | "all"; -export type AnswerStylePreference = "conservative" | "balanced" | "comprehensive"; -export type LandingPreference = "ask" | "search" | "browse"; - -export type AppPreferences = { - density: DensityPreference; - motion: MotionPreference; - jurisdiction: string; - population: PopulationPreference; - answerStyle: AnswerStylePreference; - landing: LandingPreference; - showRecentOnHome: boolean; - showProtocolsOnHome: boolean; - compactCitations: boolean; - notifyGuidelineUpdates: boolean; - notifyProductNews: boolean; - notifySavedChanges: boolean; -}; - -export const JURISDICTION_OPTIONS = [ - { value: "wa", label: "Western Australia" }, - { value: "nsw", label: "New South Wales" }, - { value: "vic", label: "Victoria" }, - { value: "qld", label: "Queensland" }, - { value: "sa", label: "South Australia" }, - { value: "tas", label: "Tasmania" }, - { value: "act", label: "Australian Capital Territory" }, - { value: "nt", label: "Northern Territory" }, - { value: "national", label: "National (Australia)" }, -] as const; - -export const POPULATION_OPTIONS: ReadonlyArray<{ value: PopulationPreference; label: string }> = [ - { value: "adults", label: "Adults" }, - { value: "older-adults", label: "Older adults" }, - { value: "adolescents", label: "Adolescents" }, - { value: "all", label: "All ages" }, -]; - -export const ANSWER_STYLE_OPTIONS: ReadonlyArray<{ - value: AnswerStylePreference; - label: string; - description: string; -}> = [ - { value: "conservative", label: "Conservative", description: "Guideline-first, cautious phrasing" }, - { value: "balanced", label: "Balanced", description: "Guidelines with practical context" }, - { value: "comprehensive", label: "Comprehensive", description: "Fuller detail and alternatives" }, -]; - -export const DENSITY_OPTIONS: ReadonlyArray<{ value: DensityPreference; label: string }> = [ - { value: "comfortable", label: "Comfortable" }, - { value: "compact", label: "Compact" }, - { value: "spacious", label: "Spacious" }, -]; - -export const LANDING_OPTIONS: ReadonlyArray<{ value: LandingPreference; label: string }> = [ - { value: "ask", label: "Ask" }, - { value: "search", label: "Search" }, - { value: "browse", label: "Browse" }, -]; - -export const DEFAULT_PREFERENCES: AppPreferences = { - density: "comfortable", - motion: "system", - jurisdiction: "wa", - population: "adults", - answerStyle: "conservative", - landing: "ask", - showRecentOnHome: true, - showProtocolsOnHome: true, - compactCitations: false, - notifyGuidelineUpdates: true, - notifyProductNews: false, - notifySavedChanges: true, -}; - const storageKey = "clinical-kb-preferences"; const changeEvent = "clinical-kb-preferences-change"; @@ -109,48 +56,6 @@ let inMemoryFallback: AppPreferences | null = null; let cachedRaw: string | null = null; let cachedValue: AppPreferences = DEFAULT_PREFERENCES; -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function coerceEnum(value: unknown, allowed: ReadonlyArray, fallback: T): T { - return typeof value === "string" && (allowed as ReadonlyArray).includes(value) ? (value as T) : fallback; -} - -function coerceBoolean(value: unknown, fallback: boolean): boolean { - return typeof value === "boolean" ? value : fallback; -} - -export function normalizePreferences(input: unknown): AppPreferences { - if (!isPlainObject(input)) return DEFAULT_PREFERENCES; - const jurisdiction = - typeof input.jurisdiction === "string" && JURISDICTION_OPTIONS.some((option) => option.value === input.jurisdiction) - ? input.jurisdiction - : DEFAULT_PREFERENCES.jurisdiction; - return { - density: coerceEnum(input.density, ["comfortable", "compact", "spacious"], DEFAULT_PREFERENCES.density), - motion: coerceEnum(input.motion, ["system", "reduced"], DEFAULT_PREFERENCES.motion), - jurisdiction, - population: coerceEnum( - input.population, - ["adults", "older-adults", "adolescents", "all"], - DEFAULT_PREFERENCES.population, - ), - answerStyle: coerceEnum( - input.answerStyle, - ["conservative", "balanced", "comprehensive"], - DEFAULT_PREFERENCES.answerStyle, - ), - landing: coerceEnum(input.landing, ["ask", "search", "browse"], DEFAULT_PREFERENCES.landing), - showRecentOnHome: coerceBoolean(input.showRecentOnHome, DEFAULT_PREFERENCES.showRecentOnHome), - showProtocolsOnHome: coerceBoolean(input.showProtocolsOnHome, DEFAULT_PREFERENCES.showProtocolsOnHome), - compactCitations: coerceBoolean(input.compactCitations, DEFAULT_PREFERENCES.compactCitations), - notifyGuidelineUpdates: coerceBoolean(input.notifyGuidelineUpdates, DEFAULT_PREFERENCES.notifyGuidelineUpdates), - notifyProductNews: coerceBoolean(input.notifyProductNews, DEFAULT_PREFERENCES.notifyProductNews), - notifySavedChanges: coerceBoolean(input.notifySavedChanges, DEFAULT_PREFERENCES.notifySavedChanges), - }; -} - function readStored(): AppPreferences { let raw: string | null = null; try { diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index ec7745dae..c60394ae8 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,8 +1,8 @@ { - "generated_at": "2026-07-19T10:43:08.249Z", + "generated_at": "2026-07-19T11:45:00.000Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", - "schema_sha256": "cf3e4a10899bbc691f21c1934be872ba01c9589302b9fd23528bb21e683b53ad", + "schema_sha256": "f6f54c9185ae5c0df0cfc3e4092c80d21bdd9d67ce965088648423ac7c7e5a96", "replay_seconds": 44, "snapshot": { "views": [ diff --git a/supabase/migrations/20260717161000_assert_postgres_default_privileges.sql b/supabase/migrations/20260717161000_assert_postgres_default_privileges.sql new file mode 100644 index 000000000..3d3ee4172 --- /dev/null +++ b/supabase/migrations/20260717161000_assert_postgres_default_privileges.sql @@ -0,0 +1,162 @@ +-- Catalog-level, fail-closed verification for future objects created by the +-- hosted migration role. A missing pg_default_acl row must be interpreted +-- through acldefault(), including PostgreSQL's built-in PUBLIC EXECUTE on +-- functions. + +create or replace function public.default_privileges_status( + p_role_name text default 'postgres', + p_schema_name text default 'public' +) +returns jsonb +language plpgsql +stable +security definer +set search_path = '' +as $$ +declare + v_role_oid oid; + v_namespace_oid oid; + v_entries text[] := '{}'::text[]; + v_safe boolean := false; + v_has_unexpected_grantee boolean := false; + v_has_grantable boolean := false; +begin + select oid into v_role_oid from pg_catalog.pg_roles where rolname = p_role_name; + select oid into v_namespace_oid from pg_catalog.pg_namespace where nspname = p_schema_name; + + if v_role_oid is null or v_namespace_oid is null then + return jsonb_build_object( + 'role_exists', v_role_oid is not null, + 'schema_exists', v_namespace_oid is not null, + 'safe', false, + 'entries', '[]'::jsonb + ); + end if; + + with object_types(object_type, object_code) as ( + values ('table'::text, 'r'::"char"), ('sequence'::text, 'S'::"char"), ('function'::text, 'f'::"char") + ), effective_acls as ( + select + ot.object_type, + coalesce(global_acl.defaclacl, pg_catalog.acldefault(ot.object_code, v_role_oid)) + || coalesce(schema_acl.defaclacl, '{}'::aclitem[]) as acl + from object_types ot + left join pg_catalog.pg_default_acl global_acl + on global_acl.defaclrole = v_role_oid + and global_acl.defaclnamespace = 0 + and global_acl.defaclobjtype = ot.object_code + left join pg_catalog.pg_default_acl schema_acl + on schema_acl.defaclrole = v_role_oid + and schema_acl.defaclnamespace = v_namespace_oid + and schema_acl.defaclobjtype = ot.object_code + ), exploded as ( + select distinct + ea.object_type, + case when privilege.grantee = 0 then 'PUBLIC' else grantee.rolname end as grantee, + lower(privilege.privilege_type) as privilege_type, + privilege.is_grantable + from effective_acls ea + cross join lateral pg_catalog.aclexplode(ea.acl) privilege + left join pg_catalog.pg_roles grantee on grantee.oid = privilege.grantee + ) + select + coalesce( + array_agg(format('%s:%s:%s', object_type, grantee, privilege_type) + order by object_type, grantee, privilege_type), + '{}'::text[] + ), + coalesce(bool_or(grantee not in (p_role_name, 'service_role')), false), + coalesce(bool_or(is_grantable), false) + into v_entries, v_has_unexpected_grantee, v_has_grantable + from exploded; + + v_safe := + not v_has_unexpected_grantee + and not v_has_grantable + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'table:PUBLIC:%' + or entry like 'table:anon:%' + or entry like 'table:authenticated:%' + or entry like 'sequence:PUBLIC:%' + or entry like 'sequence:anon:%' + or entry like 'sequence:authenticated:%' + or entry = 'function:PUBLIC:execute' + or entry like 'function:anon:%' + or entry like 'function:authenticated:%' + ) + and 'table:service_role:select' = any(v_entries) + and 'table:service_role:insert' = any(v_entries) + and 'table:service_role:update' = any(v_entries) + and 'table:service_role:delete' = any(v_entries) + and 'sequence:service_role:usage' = any(v_entries) + and 'sequence:service_role:select' = any(v_entries) + and 'function:service_role:execute' = any(v_entries) + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'table:service_role:%' + and entry <> all(array[ + 'table:service_role:select', 'table:service_role:insert', + 'table:service_role:update', 'table:service_role:delete' + ]) + ) + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'sequence:service_role:%' + and entry <> all(array['sequence:service_role:usage', 'sequence:service_role:select']) + ) + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'function:service_role:%' + and entry <> 'function:service_role:execute' + ); + + return jsonb_build_object( + 'role_exists', true, + 'schema_exists', true, + 'safe', v_safe, + 'entries', to_jsonb(v_entries) + ); +end; +$$; + +revoke all on function public.default_privileges_status(text, text) + from public, anon, authenticated; +grant execute on function public.default_privileges_status(text, text) + to service_role; + +-- Global revokes remove built-in/default grants that per-schema ACLs cannot +-- subtract. Explicit application grants remain scoped to schema public. +alter default privileges for role postgres + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + grant select, insert, update, delete on tables to service_role; +alter default privileges for role postgres in schema public + grant usage, select on sequences to service_role; +alter default privileges for role postgres in schema public + grant execute on functions to service_role; + +do $$ +declare + v_status jsonb; +begin + v_status := public.default_privileges_status('postgres', 'public'); + if not coalesce((v_status->>'safe')::boolean, false) then + raise exception using + errcode = '42501', + message = 'Unsafe postgres default privileges in schema public; migration blocked.', + detail = v_status::text, + hint = 'Reapply the postgres default-privilege repair and retry the migration.'; + end if; +end; +$$; diff --git a/supabase/migrations/20260717173000_reassert_postgres_default_privileges.sql b/supabase/migrations/20260717173000_reassert_postgres_default_privileges.sql new file mode 100644 index 000000000..f0c04bacf --- /dev/null +++ b/supabase/migrations/20260717173000_reassert_postgres_default_privileges.sql @@ -0,0 +1,37 @@ +-- Reassert the fail-closed default-ACL postcondition after later migrations. +-- Every statement is safe to rerun and is executable by hosted migrations, +-- which create public-schema objects as postgres. + +alter default privileges for role postgres + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + grant select, insert, update, delete on tables to service_role; +alter default privileges for role postgres in schema public + grant usage, select on sequences to service_role; +alter default privileges for role postgres in schema public + grant execute on functions to service_role; + +do $$ +declare + v_status jsonb; +begin + v_status := public.default_privileges_status('postgres', 'public'); + if not coalesce((v_status->>'safe')::boolean, false) then + raise exception using + errcode = '42501', + message = 'Unsafe postgres default privileges in schema public; reassertion blocked.', + detail = v_status::text, + hint = 'Reapply the postgres default-privilege repair and retry the migration.'; + end if; +end; +$$; diff --git a/supabase/migrations/20260719053532_repair_postgres_default_privileges.sql b/supabase/migrations/20260719053532_repair_postgres_default_privileges.sql new file mode 100644 index 000000000..6da082e90 --- /dev/null +++ b/supabase/migrations/20260719053532_repair_postgres_default_privileges.sql @@ -0,0 +1,159 @@ +-- Forward-only hosted repair for secure future-object defaults. This migration +-- is intentionally self-contained because the earlier assertion failed before +-- its function or ACL changes could commit in production. + +create or replace function public.default_privileges_status( + p_role_name text default 'postgres', + p_schema_name text default 'public' +) +returns jsonb +language plpgsql +stable +security definer +set search_path = '' +as $$ +declare + v_role_oid oid; + v_namespace_oid oid; + v_entries text[] := '{}'::text[]; + v_safe boolean := false; + v_has_unexpected_grantee boolean := false; + v_has_grantable boolean := false; +begin + select oid into v_role_oid from pg_catalog.pg_roles where rolname = p_role_name; + select oid into v_namespace_oid from pg_catalog.pg_namespace where nspname = p_schema_name; + + if v_role_oid is null or v_namespace_oid is null then + return jsonb_build_object( + 'role_exists', v_role_oid is not null, + 'schema_exists', v_namespace_oid is not null, + 'safe', false, + 'entries', '[]'::jsonb + ); + end if; + + with object_types(object_type, object_code) as ( + values ('table'::text, 'r'::"char"), ('sequence'::text, 'S'::"char"), ('function'::text, 'f'::"char") + ), effective_acls as ( + select + ot.object_type, + coalesce(global_acl.defaclacl, pg_catalog.acldefault(ot.object_code, v_role_oid)) + || coalesce(schema_acl.defaclacl, '{}'::aclitem[]) as acl + from object_types ot + left join pg_catalog.pg_default_acl global_acl + on global_acl.defaclrole = v_role_oid + and global_acl.defaclnamespace = 0 + and global_acl.defaclobjtype = ot.object_code + left join pg_catalog.pg_default_acl schema_acl + on schema_acl.defaclrole = v_role_oid + and schema_acl.defaclnamespace = v_namespace_oid + and schema_acl.defaclobjtype = ot.object_code + ), exploded as ( + select distinct + ea.object_type, + case when privilege.grantee = 0 then 'PUBLIC' else grantee.rolname end as grantee, + lower(privilege.privilege_type) as privilege_type, + privilege.is_grantable + from effective_acls ea + cross join lateral pg_catalog.aclexplode(ea.acl) privilege + left join pg_catalog.pg_roles grantee on grantee.oid = privilege.grantee + ) + select + coalesce( + array_agg(format('%s:%s:%s', object_type, grantee, privilege_type) + order by object_type, grantee, privilege_type), + '{}'::text[] + ), + coalesce(bool_or(grantee not in (p_role_name, 'service_role')), false), + coalesce(bool_or(is_grantable), false) + into v_entries, v_has_unexpected_grantee, v_has_grantable + from exploded; + + v_safe := + not v_has_unexpected_grantee + and not v_has_grantable + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'table:PUBLIC:%' + or entry like 'table:anon:%' + or entry like 'table:authenticated:%' + or entry like 'sequence:PUBLIC:%' + or entry like 'sequence:anon:%' + or entry like 'sequence:authenticated:%' + or entry = 'function:PUBLIC:execute' + or entry like 'function:anon:%' + or entry like 'function:authenticated:%' + ) + and 'table:service_role:select' = any(v_entries) + and 'table:service_role:insert' = any(v_entries) + and 'table:service_role:update' = any(v_entries) + and 'table:service_role:delete' = any(v_entries) + and 'sequence:service_role:usage' = any(v_entries) + and 'sequence:service_role:select' = any(v_entries) + and 'function:service_role:execute' = any(v_entries) + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'table:service_role:%' + and entry <> all(array[ + 'table:service_role:select', 'table:service_role:insert', + 'table:service_role:update', 'table:service_role:delete' + ]) + ) + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'sequence:service_role:%' + and entry <> all(array['sequence:service_role:usage', 'sequence:service_role:select']) + ) + and not exists ( + select 1 from unnest(v_entries) entry + where entry like 'function:service_role:%' + and entry <> 'function:service_role:execute' + ); + + return jsonb_build_object( + 'role_exists', true, + 'schema_exists', true, + 'safe', v_safe, + 'entries', to_jsonb(v_entries) + ); +end; +$$; + +revoke all on function public.default_privileges_status(text, text) + from public, anon, authenticated; +grant execute on function public.default_privileges_status(text, text) + to service_role; + +alter default privileges for role postgres + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on tables from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke all privileges on sequences from public, anon, authenticated, service_role; +alter default privileges for role postgres + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + revoke execute on functions from public, anon, authenticated, service_role; +alter default privileges for role postgres in schema public + grant select, insert, update, delete on tables to service_role; +alter default privileges for role postgres in schema public + grant usage, select on sequences to service_role; +alter default privileges for role postgres in schema public + grant execute on functions to service_role; + +do $$ +declare + v_status jsonb; +begin + v_status := public.default_privileges_status('postgres', 'public'); + if not coalesce((v_status->>'safe')::boolean, false) then + raise exception using + errcode = '42501', + message = 'Unsafe postgres default privileges in schema public; repair blocked.', + detail = v_status::text, + hint = 'Rerun this idempotent repair after correcting postgres default privileges.'; + end if; +end; +$$; diff --git a/supabase/migrations/20260719053533_enforce_public_title_word_scope.sql b/supabase/migrations/20260719053533_enforce_public_title_word_scope.sql new file mode 100644 index 000000000..4e133f1f6 --- /dev/null +++ b/supabase/migrations/20260719053533_enforce_public_title_word_scope.sql @@ -0,0 +1,160 @@ +-- Remove historical private/non-indexed title vocabulary and make the public +-- scope an enforced database invariant. +-- +-- 20260714180000 populated document_title_words from every indexed document. +-- 20260717171000 made future document-trigger writes public-only, but its +-- ON CONFLICT backfill did not remove the already-present private rows. The +-- SECURITY DEFINER corrector reads this table directly, so those rows remained +-- observable through query correction. + +set lock_timeout = '5s'; +set statement_timeout = '60s'; + +alter table public.document_title_words enable row level security; +revoke all on table public.document_title_words from public, anon, authenticated; +grant select, insert, update, delete on table public.document_title_words to service_role; + +do $$ +begin + if not exists ( + select 1 + from pg_catalog.pg_constraint + where conrelid = 'public.document_title_words'::pg_catalog.regclass + and conname = 'document_title_words_word_length' + ) then + alter table public.document_title_words + add constraint document_title_words_word_length + check (pg_catalog.length(word) between 4 and 40) not valid; + end if; + + if not exists ( + select 1 + from pg_catalog.pg_constraint + where conrelid = 'public.document_title_words'::pg_catalog.regclass + and conname = 'document_title_words_lowercase' + ) then + alter table public.document_title_words + add constraint document_title_words_lowercase + check (word = pg_catalog.lower(word)) not valid; + end if; +end; +$$; + +create or replace function public.enforce_document_title_word_scope() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + perform 1 + from public.documents d + where d.id = new.document_id + and d.owner_id is null + and d.status = 'indexed' + and pg_catalog.length(new.word) between 4 and 40 + and new.word = any ( + pg_catalog.regexp_split_to_array(pg_catalog.lower(d.title), '[^a-z]+') + ) + for share; + + if not found then + raise exception 'document_title_words rows require a current indexed public document title' + using errcode = '23514'; + end if; + + return new; +end; +$$; + +revoke execute on function public.enforce_document_title_word_scope() + from public, anon, authenticated, service_role; + +drop trigger if exists document_title_words_enforce_public_scope + on public.document_title_words; +create trigger document_title_words_enforce_public_scope + before insert or update on public.document_title_words + for each row execute function public.enforce_document_title_word_scope(); + +-- Purge every legacy row that is no longer an exact word from a current, +-- indexed, null-owner document title. This removes private/non-indexed rows as +-- well as any stale word left by historical trigger behavior. +delete from public.document_title_words dtw +where not exists ( + select 1 + from public.documents d + where d.id = dtw.document_id + and d.owner_id is null + and d.status = 'indexed' + and pg_catalog.length(dtw.word) between 4 and 40 + and dtw.word = any ( + pg_catalog.regexp_split_to_array(pg_catalog.lower(d.title), '[^a-z]+') + ) +); + +-- Repair any missing public vocabulary rows after cleanup. The scope trigger +-- above validates each inserted row before it can become visible. +insert into public.document_title_words (word, document_id) +select distinct pg_catalog.lower(title_word), d.id +from public.documents d +cross join lateral pg_catalog.unnest( + pg_catalog.regexp_split_to_array(pg_catalog.lower(d.title), '[^a-z]+') +) as title_word +where d.owner_id is null + and d.status = 'indexed' + and pg_catalog.length(title_word) between 4 and 40 +on conflict do nothing; + +alter table public.document_title_words + validate constraint document_title_words_word_length; +alter table public.document_title_words + validate constraint document_title_words_lowercase; + +do $$ +begin + if exists ( + select 1 + from public.document_title_words dtw + where not exists ( + select 1 + from public.documents d + where d.id = dtw.document_id + and d.owner_id is null + and d.status = 'indexed' + and pg_catalog.length(dtw.word) between 4 and 40 + and dtw.word = any ( + pg_catalog.regexp_split_to_array(pg_catalog.lower(d.title), '[^a-z]+') + ) + ) + ) then + raise exception 'document_title_words contains rows outside the indexed public title corpus' + using errcode = '23514'; + end if; +end; +$$; + +-- Preserve the trigger-function and corrector privilege posture established by +-- the earlier hardening migrations. +revoke execute on function public.sync_document_title_words() + from public, anon, authenticated, service_role; +revoke execute on function public.correct_clinical_query_terms(text, real) + from public, anon, authenticated; +grant execute on function public.correct_clinical_query_terms(text, real) + to service_role; + +-- The immediately preceding repair establishes this fail-closed default-ACL +-- invariant. This migration creates a function, so reassert it before commit. +do $$ +declare + v_status jsonb; +begin + v_status := public.default_privileges_status('postgres', 'public'); + if not coalesce((v_status->>'safe')::boolean, false) then + raise exception using + errcode = '42501', + message = 'Unsafe postgres default privileges in schema public; title-word privacy migration blocked.', + detail = v_status::text, + hint = 'Reapply migration 20260719053532_repair_postgres_default_privileges, then retry.'; + end if; +end; +$$; diff --git a/supabase/schema.sql b/supabase/schema.sql index 29d4a4e34..140fe4941 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -8452,6 +8452,56 @@ begin end; $$; +-- Account-owned application data. Public content remains anonymous-readable +-- through server routes; favourites and preferences require an authenticated +-- owner at both the API and RLS layers. +create table if not exists public.user_favourites ( + user_id uuid not null references auth.users(id) on delete cascade, + content_type text not null, + content_key text not null, + created_at timestamptz not null default now(), + primary key (user_id, content_type, content_key), + constraint user_favourites_content_type_check + check (content_type in ('service', 'form', 'differential')), + constraint user_favourites_content_key_check + check (content_key = btrim(content_key) and char_length(content_key) between 1 and 180) +); + +create table if not exists public.user_preferences ( + user_id uuid primary key references auth.users(id) on delete cascade, + preferences jsonb not null default '{}'::jsonb, + updated_at timestamptz not null default now(), + constraint user_preferences_object_check check (jsonb_typeof(preferences) = 'object'), + constraint user_preferences_size_check check (pg_column_size(preferences) <= 16384) +); + +alter table public.user_favourites enable row level security; +alter table public.user_preferences enable row level security; + +revoke all on table public.user_favourites from public, anon, authenticated; +revoke all on table public.user_preferences from public, anon, authenticated; +grant select, insert, update, delete on table public.user_favourites to service_role; +grant select, insert, update, delete on table public.user_preferences to service_role; + +create policy "users read own favourites" on public.user_favourites + for select to authenticated using ((select auth.uid()) = user_id); +create policy "users insert own favourites" on public.user_favourites + for insert to authenticated with check ((select auth.uid()) = user_id); +create policy "users delete own favourites" on public.user_favourites + for delete to authenticated using ((select auth.uid()) = user_id); +create policy "users read own preferences" on public.user_preferences + for select to authenticated using ((select auth.uid()) = user_id); +create policy "users insert own preferences" on public.user_preferences + for insert to authenticated with check ((select auth.uid()) = user_id); +create policy "users update own preferences" on public.user_preferences + for update to authenticated + using ((select auth.uid()) = user_id) + with check ((select auth.uid()) = user_id); +create policy "users delete own preferences" on public.user_preferences + for delete to authenticated using ((select auth.uid()) = user_id); + +revoke insert, update, delete on table storage.objects from anon, authenticated; + drop trigger if exists clinical_registry_records_delete_cleanup on public.clinical_registry_records; create trigger clinical_registry_records_delete_cleanup after delete on public.clinical_registry_records diff --git a/tests/account-access-model.test.ts b/tests/account-access-model.test.ts index 041e6dfdd..0df6ffe77 100644 --- a/tests/account-access-model.test.ts +++ b/tests/account-access-model.test.ts @@ -58,11 +58,12 @@ describe("public content and account authorization model", () => { expect(tableFactsRoute).toContain("withOwnerReadScope("); expect(tableFactsRoute.match(/administrator: true/g)?.length).toBe(1); expect(documentViewer).toContain("isAdministratorUser(session?.user)"); + expect(documentViewer).toContain("!serverDemoMode"); expect(documentViewer).toContain("{canUseAdministrativeApis ? ("); expect(documentViewer).toContain("canManage={canUseAdministrativeApis}"); expect(documentViewer).toContain("canReview={canUseAdministrativeApis}"); expect(documentViewer).toContain( - 'const canSummarizeDocument = viewerState === "ready" && !loadingSummary && canUseAdministrativeApis', + 'const canSummarizeDocument = viewerState === "ready" && !loadingSummary && canUsePrivateApis', ); }); diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 778a1c030..b4f537daf 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -613,11 +613,15 @@ describe("Supabase schema Data API grants", () => { it("does not introduce new duplicate migration stems", () => { const duplicateStemAllowlist = new Map([ ["api_rate_limits", 2], + ["assert_postgres_default_privileges", 2], ["audit_logs", 2], ["audit_logs_service_role_policy", 2], + ["enforce_public_title_word_scope", 2], ["indexing_reliability_recovery", 2], ["ingestion_jobs_one_open_per_document", 2], ["rag_queries_retention", 2], + ["reassert_postgres_default_privileges", 2], + ["repair_postgres_default_privileges", 2], ]); const stemCounts = new Map();