harden(forms): self-audit fixes for Phase 1 RLS and indexes
- drop redundant idx_forms_slug (UNIQUE constraint already indexes slug) - tighten forms_select: forms.manage sees all; ordinary members see only published forms (drafts/closed no longer exposed via PostgREST) - make submitter_email and member_id submission indexes partial (both columns are frequently NULL)
This commit is contained in:
@@ -705,7 +705,7 @@ migration is mirrored as a section in it. Tables/columns added:
|
|||||||
| 023 | `space_role_permissions`, `ops_acl`; `user_effective_roles()`, `user_has_permission()` (SECURITY DEFINER); `secrets`/`knowledge_base` SELECT rewritten as `existing-rule OR ops_acl-match` (additive) |
|
| 023 | `space_role_permissions`, `ops_acl`; `user_effective_roles()`, `user_has_permission()` (SECURITY DEFINER); `secrets`/`knowledge_base` SELECT rewritten as `existing-rule OR ops_acl-match` (additive) |
|
||||||
| 024 | `prevent_member_self_role_change()` extended to also block self-change of `tier_id` and `onboarding_completed_at` |
|
| 024 | `prevent_member_self_role_change()` extended to also block self-change of `tier_id` and `onboarding_completed_at` |
|
||||||
| 025 | Re-asserts the hardened `incidents_insert` policy verbatim (production convergence; access-neutral, no schema change) |
|
| 025 | Re-asserts the hardened `incidents_insert` policy verbatim (production convergence; access-neutral, no schema change) |
|
||||||
| 026 | `forms`, `form_submissions` (custom forms + waivers). Globally-unique form slug; jsonb field schema; per-submission snapshot of schema/legal-text/version for immutable waiver records. RLS additive + default-deny: forms SELECT = space members, write = `user_has_permission(..., 'forms.manage')`; form_submissions SELECT = `forms.manage` only, NO write policy (every submission is written by one validated service-client server action, and submissions are immutable). New `forms.manage` permission seeded to board + backfilled |
|
| 026 | `forms`, `form_submissions` (custom forms + waivers). Globally-unique form slug; jsonb field schema; per-submission snapshot of schema/legal-text/version for immutable waiver records. RLS additive + default-deny: forms SELECT = `forms.manage` holders (all) or members (published only), write = `user_has_permission(..., 'forms.manage')`; form_submissions SELECT = `forms.manage` only, NO write policy (every submission is written by one validated service-client server action, and submissions are immutable). New `forms.manage` permission seeded to board + backfilled |
|
||||||
|
|
||||||
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.
|
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ Branch: `main`. Migration: `026` (not yet deployed). `pnpm build` + `pnpm exec v
|
|||||||
|
|
||||||
- New `scripts/026_forms.sql` (idempotent) + mirrored as `schema.sql` Section 17 + Section 15 seed-function updated.
|
- New `scripts/026_forms.sql` (idempotent) + mirrored as `schema.sql` Section 17 + Section 15 seed-function updated.
|
||||||
- Tables: `forms` (space_id, globally-UNIQUE `slug`, `kind` form/waiver, `visibility` public_anon/public_auth/members, `status` draft/published/closed, `schema` jsonb, `legal_text`, `version`, `created_by`) and `form_submissions` (form_id, denormalized space_id, member_id, submitter_email, answers, `form_snapshot`/`legal_text_snapshot`/`form_version` immutable snapshots, ip, user_agent). Indexes on space/slug/form/email/member.
|
- Tables: `forms` (space_id, globally-UNIQUE `slug`, `kind` form/waiver, `visibility` public_anon/public_auth/members, `status` draft/published/closed, `schema` jsonb, `legal_text`, `version`, `created_by`) and `form_submissions` (form_id, denormalized space_id, member_id, submitter_email, answers, `form_snapshot`/`legal_text_snapshot`/`form_version` immutable snapshots, ip, user_agent). Indexes on space/slug/form/email/member.
|
||||||
- RLS (additive, default-deny): `forms` SELECT = space members; INSERT/UPDATE/DELETE = `user_has_permission(..., 'forms.manage')`. `form_submissions` SELECT = `forms.manage` only; **no write policy at all** → RLS hard-denies every non-service client and makes submissions immutable. Every submission funnels through one validated service-client server action (Phase 2). Public `/f/[slug]` will be served by a service-client server action, so `anon` gets no grant on `forms` (user-chosen option).
|
- RLS (additive, default-deny): `forms` SELECT = `forms.manage` holders (all forms) or ordinary members (published only — drafts/closed hidden from non-managers); INSERT/UPDATE/DELETE = `user_has_permission(..., 'forms.manage')`. `form_submissions` SELECT = `forms.manage` only; **no write policy at all** → RLS hard-denies every non-service client and makes submissions immutable. Every submission funnels through one validated service-client server action (Phase 2). Public `/f/[slug]` will be served by a service-client server action, so `anon` gets no grant on `forms` (user-chosen option).
|
||||||
- New `forms.manage` permission added to `lib/permissions-catalog.ts` (group Community) + board default; `seed_default_role_permissions()` extended; backfill `board -> forms.manage` for existing spaces (ON CONFLICT DO NOTHING).
|
- New `forms.manage` permission added to `lib/permissions-catalog.ts` (group Community) + board default; `seed_default_role_permissions()` extended; backfill `board -> forms.manage` for existing spaces (ON CONFLICT DO NOTHING).
|
||||||
- `types/database.ts`: `forms` + `form_submissions` added. Docs updated: DATABASE_SCHEMA (026 row), DB_SCHEMA_MAP (quick-map + migrations), ARCHITECTURE (forms note).
|
- `types/database.ts`: `forms` + `form_submissions` added. Docs updated: DATABASE_SCHEMA (026 row), DB_SCHEMA_MAP (quick-map + migrations), ARCHITECTURE (forms note).
|
||||||
|
|
||||||
|
|||||||
+19
-8
@@ -14,10 +14,11 @@
|
|||||||
-- RLS posture (this is the security-sensitive part, kept additive and
|
-- RLS posture (this is the security-sensitive part, kept additive and
|
||||||
-- default-deny):
|
-- default-deny):
|
||||||
--
|
--
|
||||||
-- * forms SELECT: any member of the space (powers the admin directory and
|
-- * forms SELECT: forms.manage holders see every form in the space; ordinary
|
||||||
-- the in-app fill page). The public unauthenticated /f/[slug] page does
|
-- members see only published ones (drafts/closed are not exposed to
|
||||||
-- NOT read this table directly; it is served by a server action using the
|
-- non-managers). The public unauthenticated /f/[slug] page does NOT read
|
||||||
-- service client, so the anon Postgres role gets no grant here.
|
-- this table directly; it is served by a server action using the service
|
||||||
|
-- client, so the anon Postgres role gets no grant here.
|
||||||
-- * forms INSERT/UPDATE/DELETE: user_has_permission(..., 'forms.manage').
|
-- * forms INSERT/UPDATE/DELETE: user_has_permission(..., 'forms.manage').
|
||||||
-- Additive — with no space_role_permissions rows, only admin (implicit-all)
|
-- Additive — with no space_role_permissions rows, only admin (implicit-all)
|
||||||
-- can manage, which is the pre-feature behavior.
|
-- can manage, which is the pre-feature behavior.
|
||||||
@@ -61,8 +62,9 @@ CREATE TABLE IF NOT EXISTS public.forms (
|
|||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
-- slug already has a UNIQUE btree index from the column constraint; no extra
|
||||||
|
-- index on slug is needed.
|
||||||
CREATE INDEX IF NOT EXISTS idx_forms_space ON public.forms (space_id, status);
|
CREATE INDEX IF NOT EXISTS idx_forms_space ON public.forms (space_id, status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_forms_slug ON public.forms (slug);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public.form_submissions (
|
CREATE TABLE IF NOT EXISTS public.form_submissions (
|
||||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
@@ -80,8 +82,8 @@ CREATE TABLE IF NOT EXISTS public.form_submissions (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_form_submissions_form ON public.form_submissions (form_id, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_form_submissions_form ON public.form_submissions (form_id, created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_form_submissions_space ON public.form_submissions (space_id);
|
CREATE INDEX IF NOT EXISTS idx_form_submissions_space ON public.form_submissions (space_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_form_submissions_email ON public.form_submissions (submitter_email);
|
CREATE INDEX IF NOT EXISTS idx_form_submissions_email ON public.form_submissions (submitter_email) WHERE submitter_email IS NOT NULL;
|
||||||
CREATE INDEX IF NOT EXISTS idx_form_submissions_member ON public.form_submissions (member_id);
|
CREATE INDEX IF NOT EXISTS idx_form_submissions_member ON public.form_submissions (member_id) WHERE member_id IS NOT NULL;
|
||||||
|
|
||||||
ALTER TABLE public.forms ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE public.forms ENABLE ROW LEVEL SECURITY;
|
||||||
ALTER TABLE public.form_submissions ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE public.form_submissions ENABLE ROW LEVEL SECURITY;
|
||||||
@@ -90,8 +92,17 @@ DROP POLICY IF EXISTS forms_select ON public.forms;
|
|||||||
DROP POLICY IF EXISTS forms_insert ON public.forms;
|
DROP POLICY IF EXISTS forms_insert ON public.forms;
|
||||||
DROP POLICY IF EXISTS forms_update ON public.forms;
|
DROP POLICY IF EXISTS forms_update ON public.forms;
|
||||||
DROP POLICY IF EXISTS forms_delete ON public.forms;
|
DROP POLICY IF EXISTS forms_delete ON public.forms;
|
||||||
|
-- Managers (forms.manage) see every form in the space; ordinary members see
|
||||||
|
-- only published ones. Drafts/closed forms are not exposed to non-managers via
|
||||||
|
-- raw PostgREST. anon (auth.uid() NULL) matches neither branch.
|
||||||
CREATE POLICY forms_select ON public.forms FOR SELECT
|
CREATE POLICY forms_select ON public.forms FOR SELECT
|
||||||
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
USING (
|
||||||
|
public.user_has_permission(auth.uid(), space_id, 'forms.manage')
|
||||||
|
OR (
|
||||||
|
space_id IN (SELECT public.get_user_space_ids(auth.uid()))
|
||||||
|
AND status = 'published'
|
||||||
|
)
|
||||||
|
);
|
||||||
CREATE POLICY forms_insert ON public.forms FOR INSERT
|
CREATE POLICY forms_insert ON public.forms FOR INSERT
|
||||||
WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'forms.manage'));
|
WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'forms.manage'));
|
||||||
CREATE POLICY forms_update ON public.forms FOR UPDATE
|
CREATE POLICY forms_update ON public.forms FOR UPDATE
|
||||||
|
|||||||
+12
-5
@@ -1884,7 +1884,8 @@ CREATE TRIGGER trg_prevent_member_self_role_change
|
|||||||
-- existing one.
|
-- existing one.
|
||||||
--
|
--
|
||||||
-- RLS is additive and default-deny:
|
-- RLS is additive and default-deny:
|
||||||
-- * forms SELECT = any member of the space. The public unauthenticated
|
-- * forms SELECT = forms.manage holders see all forms in the space;
|
||||||
|
-- ordinary members see only published ones. The public unauthenticated
|
||||||
-- /f/[slug] page is served by a service-client server action, so the
|
-- /f/[slug] page is served by a service-client server action, so the
|
||||||
-- anon role gets no grant on this table.
|
-- anon role gets no grant on this table.
|
||||||
-- * forms write = user_has_permission(..., 'forms.manage').
|
-- * forms write = user_has_permission(..., 'forms.manage').
|
||||||
@@ -1916,8 +1917,8 @@ CREATE TABLE IF NOT EXISTS public.forms (
|
|||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
-- slug already has a UNIQUE btree index from the column constraint.
|
||||||
CREATE INDEX IF NOT EXISTS idx_forms_space ON public.forms (space_id, status);
|
CREATE INDEX IF NOT EXISTS idx_forms_space ON public.forms (space_id, status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_forms_slug ON public.forms (slug);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public.form_submissions (
|
CREATE TABLE IF NOT EXISTS public.form_submissions (
|
||||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
@@ -1935,8 +1936,8 @@ CREATE TABLE IF NOT EXISTS public.form_submissions (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_form_submissions_form ON public.form_submissions (form_id, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_form_submissions_form ON public.form_submissions (form_id, created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_form_submissions_space ON public.form_submissions (space_id);
|
CREATE INDEX IF NOT EXISTS idx_form_submissions_space ON public.form_submissions (space_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_form_submissions_email ON public.form_submissions (submitter_email);
|
CREATE INDEX IF NOT EXISTS idx_form_submissions_email ON public.form_submissions (submitter_email) WHERE submitter_email IS NOT NULL;
|
||||||
CREATE INDEX IF NOT EXISTS idx_form_submissions_member ON public.form_submissions (member_id);
|
CREATE INDEX IF NOT EXISTS idx_form_submissions_member ON public.form_submissions (member_id) WHERE member_id IS NOT NULL;
|
||||||
|
|
||||||
ALTER TABLE public.forms ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE public.forms ENABLE ROW LEVEL SECURITY;
|
||||||
ALTER TABLE public.form_submissions ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE public.form_submissions ENABLE ROW LEVEL SECURITY;
|
||||||
@@ -1946,7 +1947,13 @@ DROP POLICY IF EXISTS forms_insert ON public.forms;
|
|||||||
DROP POLICY IF EXISTS forms_update ON public.forms;
|
DROP POLICY IF EXISTS forms_update ON public.forms;
|
||||||
DROP POLICY IF EXISTS forms_delete ON public.forms;
|
DROP POLICY IF EXISTS forms_delete ON public.forms;
|
||||||
CREATE POLICY forms_select ON public.forms FOR SELECT
|
CREATE POLICY forms_select ON public.forms FOR SELECT
|
||||||
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
USING (
|
||||||
|
public.user_has_permission(auth.uid(), space_id, 'forms.manage')
|
||||||
|
OR (
|
||||||
|
space_id IN (SELECT public.get_user_space_ids(auth.uid()))
|
||||||
|
AND status = 'published'
|
||||||
|
)
|
||||||
|
);
|
||||||
CREATE POLICY forms_insert ON public.forms FOR INSERT
|
CREATE POLICY forms_insert ON public.forms FOR INSERT
|
||||||
WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'forms.manage'));
|
WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'forms.manage'));
|
||||||
CREATE POLICY forms_update ON public.forms FOR UPDATE
|
CREATE POLICY forms_update ON public.forms FOR UPDATE
|
||||||
|
|||||||
Reference in New Issue
Block a user