feat(certifications): schema + RLS + permissions (data layer)

Migration 030 adds certifications (per-space cert types, optional
validity_months, is_active archive) and member_certifications (per-member
grants; expires_at snapshotted at grant time; soft revoke; partial unique
on one active grant per member+cert). RLS is additive and default-deny:
cert types readable by any space member, write-gated by
certifications.manage; grants readable by managers/granters (all) or the
member (own), written by certifications.grant, with no DELETE policy so
the history is immutable. No anonymous path.

Two new permission codes in the catalog (group Certifications):
certifications.manage and certifications.grant (the Instructor
capability, assignable to any role via the existing additive model).
Seeded to board and backfilled. Mirrored idempotently in schema.sql;
types and schema docs updated.
This commit is contained in:
Moheeb Zara
2026-05-16 18:51:33 -07:00
parent 04f1a31999
commit e439b1130f
6 changed files with 294 additions and 3 deletions
+2 -1
View File
@@ -686,7 +686,7 @@ CREATE INDEX idx_activity_space ON activity_log(space_id, created_at);
---
## Migrations 014-029 (additions since the 13-table baseline)
## Migrations 014-030 (additions since the 13-table baseline)
`scripts/schema.sql` is the canonical idempotent schema; each numbered
migration is mirrored as a section in it. Tables/columns added:
@@ -709,6 +709,7 @@ migration is mirrored as a section in it. Tables/columns added:
| 027 | Adds `'form'` to the `space_onboarding_steps.step_type` CHECK so an onboarding step can embed a custom form/waiver (referenced form id stored in the step's existing `config` jsonb; no new column) |
| 028 | Form slug is unique PER SPACE: drops the global `UNIQUE(forms.slug)`, adds `UNIQUE(space_id, slug)`. Public form URL is `/f/[space]/[slug]` |
| 029 | `space_invites.role` (member_role enum, default `member`) so an invite can grant a chosen role; legacy invites and the spaces.invite_code path keep granting `member`. Who may create an admin-granting invite is enforced in-app (only an admin) |
| 030 | `certifications` (per-space cert types; optional `validity_months`; `is_active` archive) + `member_certifications` (per-member grants; `expires_at` snapshotted at grant time; soft `revoked_at`/`revoked_by`; partial unique on one active grant per member+cert). RLS additive + default-deny: certifications SELECT = any space member, write = `certifications.manage`; member_certifications SELECT = `certifications.manage`/`certifications.grant` (all) or member (own), INSERT/UPDATE = `certifications.grant`, NO DELETE policy (history immutable). New `certifications.manage` + `certifications.grant` permissions (the latter = the Instructor capability) seeded to board + backfilled. No anonymous path |
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.