diff --git a/DB_SCHEMA_MAP.md b/DB_SCHEMA_MAP.md index f852090..1ff54fb 100644 --- a/DB_SCHEMA_MAP.md +++ b/DB_SCHEMA_MAP.md @@ -395,10 +395,11 @@ Default channels created by trigger on space INSERT: `general`, `announcements`, | 027 | `space_onboarding_steps.step_type` CHECK adds `'form'` (onboarding can embed a form/waiver; form id in step `config.form_id`) | | 028 | Form slug unique PER SPACE (`UNIQUE(space_id, slug)`, drops global `UNIQUE(slug)`); public URL `/f/[space]/[slug]` | | 029 | `space_invites.role` (member_role enum, default `member`); invites can grant a role; admin-granting invites are admin-only (app-enforced) | +| 030 | `certifications`, `member_certifications` + `certifications.manage` / `certifications.grant` permissions (the latter = Instructor). Additive default-deny RLS; grants soft-revoked + immutable (no DELETE policy); expiry snapshotted at grant; no anonymous path | --- -## Tables added by migrations 016-029 (quick map) +## Tables added by migrations 016-030 (quick map) | Table | Key columns | Purpose | |-------|-------------|---------| @@ -417,6 +418,8 @@ Default channels created by trigger on space INSERT: `general`, `announcements`, | `ops_acl` | space_id, entity_type, entity_id, role | Per-item Ops access list (secret/kb/process/area_lead) | | `forms` | space_id, slug (UNIQUE per space_id), kind (form/waiver), visibility (public_anon/public_auth/members), status (draft/published/closed), schema jsonb, legal_text, version | Custom forms + waivers. Write-gated by `forms.manage`; public page `/f/[space]/[slug]` served via service-client server action (no anon grant) | | `form_submissions` | form_id, space_id, member_id, submitter_email, answers jsonb, form_snapshot jsonb, legal_text_snapshot, form_version, ip, user_agent | Append-only submissions. Per-row snapshot = immutable waiver record. SELECT = `forms.manage`; NO write policy (service-client only; immutable) | +| `certifications` | space_id, name (UNIQUE per space, case-insensitive), description, validity_months (null = never expires), is_active | Per-space certification types. SELECT = any space member; write-gated by `certifications.manage` | +| `member_certifications` | space_id, member_id, certification_id, granted_by, granted_at, expires_at (snapshotted at grant), revoked_at, revoked_by, revoked_reason, note | Per-member grants. Partial UNIQUE = one active grant per member+cert. SELECT = `certifications.manage`/`certifications.grant` (all) or member (own); INSERT/UPDATE = `certifications.grant`; NO DELETE policy (immutable history) | Column additions: `space_members.tier_id`, `onboarding_completed_at`, `onboarding_progress`; `secrets.encrypted_value`, `encryption_version`; `knowledge_base.render_markdown`, `is_meeting_minutes`, `meeting_date`. New enum `comment_entity_type`. diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 8dfc2ee..0a808e0 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -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`. diff --git a/lib/permissions-catalog.ts b/lib/permissions-catalog.ts index 304a6e9..ac479d0 100644 --- a/lib/permissions-catalog.ts +++ b/lib/permissions-catalog.ts @@ -22,6 +22,8 @@ export const PERMISSIONS = [ { code: 'governance.manage', group: 'Governance', label: 'Manage proposals, incidents, policies' }, { code: 'forum.moderate', group: 'Community', label: 'Moderate forum and comments' }, { code: 'forms.manage', group: 'Community', label: 'Manage forms and waivers' }, + { code: 'certifications.manage', group: 'Certifications', label: 'Manage certification types' }, + { code: 'certifications.grant', group: 'Certifications', label: 'Award and revoke certifications (Instructor)' }, { code: 'customize.manage', group: 'Admin', label: 'Customize roles, tiers, areas, invites, onboarding' }, { code: 'settings.manage', group: 'Admin', label: 'Space settings, integrations, webhooks' }, ] as const @@ -40,7 +42,9 @@ export const DEFAULT_ROLE_PERMISSIONS: Record = { 'ops.kb.read', 'ops.kb.write', 'ops.process.read', 'ops.process.write', 'ops.secrets.read', 'ops.secrets.write', 'ops.arealeads.manage', 'members.manage', 'payments.manage', 'governance.manage', - 'forum.moderate', 'forms.manage', 'customize.manage', 'settings.manage', + 'forum.moderate', 'forms.manage', + 'certifications.manage', 'certifications.grant', + 'customize.manage', 'settings.manage', ], treasurer: ['payments.manage', 'ops.kb.read', 'ops.process.read'], member: ['ops.kb.read', 'ops.process.read'], diff --git a/scripts/030_certifications.sql b/scripts/030_certifications.sql new file mode 100644 index 0000000..5c6a387 --- /dev/null +++ b/scripts/030_certifications.sql @@ -0,0 +1,168 @@ +-- ============================================================================= +-- 030: Certifications + Instructor capability +-- ============================================================================= +-- Two tables: +-- +-- certifications a per-space certification *type* (e.g. "Laser +-- Cutter", "Forklift"). Optional validity_months: if +-- set, a grant expires that many months after it is +-- awarded; null = never expires. +-- member_certifications a single award of a certification to a member. +-- expires_at is computed and stored AT GRANT TIME +-- from the cert's validity_months, so later edits to +-- the cert type never retroactively change an +-- existing grant's expiry (audit stability, same +-- spirit as the forms snapshot). A grant is never +-- hard-deleted by clients: revoking sets revoked_at +-- (soft revoke) so the safety record is preserved. +-- +-- Two new permission codes (lib/permissions-catalog.ts, group "Certifications"): +-- +-- certifications.manage create/edit/archive certification *types*. +-- certifications.grant award/revoke a certification to a member. This code +-- IS the "Instructor" capability: it is assignable to +-- any role or area-lead through the existing additive +-- space_role_permissions model. No new built-in role. +-- +-- RLS posture (additive, default-deny; the guarded surface): +-- +-- * certifications SELECT: any member of the space (members need to see +-- which certs exist; managers are members too). anon (auth.uid() NULL) +-- matches nothing. +-- * certifications INSERT/UPDATE/DELETE: certifications.manage. Additive: +-- with no space_role_permissions rows only admin (implicit-all) manages, +-- which is the pre-feature behavior. +-- * member_certifications SELECT: a manager (certifications.manage) or a +-- granter (certifications.grant) sees every grant in the space; a member +-- sees their OWN grants. No cross-tenant exposure (space_id scoped). +-- * member_certifications INSERT/UPDATE: certifications.grant. UPDATE is how +-- a revoke or a renew is written. +-- * member_certifications has NO DELETE policy. With RLS enabled that hard- +-- denies deletes for every non-service client, so a grant/revoke history +-- is immutable at the storage layer (same stance as form_submissions). +-- FK ON DELETE CASCADE from space_members / certifications still works: +-- cascades run at the engine level, independent of RLS. +-- +-- There is intentionally NO anonymous path here (unlike forms). All writes go +-- through certifications.grant holders; nothing is world-readable. +-- +-- This migration extends seed_default_role_permissions() so new spaces seed +-- both new codes to board, and backfills board -> both for existing spaces. +-- +-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS, DROP POLICY IF EXISTS then +-- CREATE, CREATE OR REPLACE FUNCTION, ON CONFLICT DO NOTHING backfill. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS public.certifications ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE, + name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 200), + description text, + validity_months integer CHECK (validity_months IS NULL OR validity_months > 0), + is_active boolean NOT NULL DEFAULT true, + created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +-- One certification type per name per space, case-insensitive. +CREATE UNIQUE INDEX IF NOT EXISTS idx_certifications_space_name + ON public.certifications (space_id, lower(name)); +CREATE INDEX IF NOT EXISTS idx_certifications_space + ON public.certifications (space_id, is_active); + +CREATE TABLE IF NOT EXISTS public.member_certifications ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE, + member_id uuid NOT NULL REFERENCES public.space_members(id) ON DELETE CASCADE, + certification_id uuid NOT NULL REFERENCES public.certifications(id) ON DELETE CASCADE, + granted_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL, + granted_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz, + revoked_at timestamptz, + revoked_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL, + revoked_reason text, + note text, + created_at timestamptz NOT NULL DEFAULT now() +); +-- At most one ACTIVE (non-revoked) grant of a given cert to a given member. +-- Revoked rows stay as history and a re-grant is allowed. +CREATE UNIQUE INDEX IF NOT EXISTS idx_member_certifications_active + ON public.member_certifications (member_id, certification_id) + WHERE revoked_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_member_certifications_space + ON public.member_certifications (space_id); +CREATE INDEX IF NOT EXISTS idx_member_certifications_member + ON public.member_certifications (member_id); +CREATE INDEX IF NOT EXISTS idx_member_certifications_cert + ON public.member_certifications (certification_id); + +ALTER TABLE public.certifications ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.member_certifications ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS certifications_select ON public.certifications; +DROP POLICY IF EXISTS certifications_insert ON public.certifications; +DROP POLICY IF EXISTS certifications_update ON public.certifications; +DROP POLICY IF EXISTS certifications_delete ON public.certifications; +CREATE POLICY certifications_select ON public.certifications FOR SELECT + USING (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); +CREATE POLICY certifications_insert ON public.certifications FOR INSERT + WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'certifications.manage')); +CREATE POLICY certifications_update ON public.certifications FOR UPDATE + USING (public.user_has_permission(auth.uid(), space_id, 'certifications.manage')); +CREATE POLICY certifications_delete ON public.certifications FOR DELETE + USING (public.user_has_permission(auth.uid(), space_id, 'certifications.manage')); + +DROP POLICY IF EXISTS member_certifications_select ON public.member_certifications; +DROP POLICY IF EXISTS member_certifications_insert ON public.member_certifications; +DROP POLICY IF EXISTS member_certifications_update ON public.member_certifications; +-- Managers and granters see all grants in the space; a member sees their own. +CREATE POLICY member_certifications_select ON public.member_certifications FOR SELECT + USING ( + public.user_has_permission(auth.uid(), space_id, 'certifications.manage') + OR public.user_has_permission(auth.uid(), space_id, 'certifications.grant') + OR member_id IN ( + SELECT id FROM public.space_members + WHERE user_id = auth.uid() AND space_id = member_certifications.space_id + ) + ); +CREATE POLICY member_certifications_insert ON public.member_certifications FOR INSERT + WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'certifications.grant')); +CREATE POLICY member_certifications_update ON public.member_certifications FOR UPDATE + USING (public.user_has_permission(auth.uid(), space_id, 'certifications.grant')); +-- No DELETE policy: grants/revocations are immutable to non-service clients. + +DROP TRIGGER IF EXISTS trg_certifications_touch ON public.certifications; +CREATE TRIGGER trg_certifications_touch + BEFORE UPDATE ON public.certifications + FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at(); + +-- Extend the per-space default-permission seed with the two new codes. +CREATE OR REPLACE FUNCTION public.seed_default_role_permissions() + RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public +AS $$ +BEGIN + INSERT INTO public.space_role_permissions (space_id, subject, permission) + SELECT NEW.id, d.subject, d.permission + FROM (VALUES + ('board','ops.kb.read'),('board','ops.kb.write'),('board','ops.process.read'), + ('board','ops.process.write'),('board','ops.secrets.read'),('board','ops.secrets.write'), + ('board','ops.arealeads.manage'),('board','members.manage'),('board','payments.manage'), + ('board','governance.manage'),('board','forum.moderate'),('board','forms.manage'), + ('board','certifications.manage'),('board','certifications.grant'), + ('board','customize.manage'),('board','settings.manage'), + ('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'), + ('member','ops.kb.read'),('member','ops.process.read'), + ('associate','ops.kb.read') + ) AS d(subject, permission) + ON CONFLICT (space_id, subject, permission) DO NOTHING; + RETURN NEW; +END; +$$; + +-- Backfill the new grants for spaces that already exist. +INSERT INTO public.space_role_permissions (space_id, subject, permission) +SELECT id, 'board', 'certifications.manage' FROM public.spaces +ON CONFLICT (space_id, subject, permission) DO NOTHING; +INSERT INTO public.space_role_permissions (space_id, subject, permission) +SELECT id, 'board', 'certifications.grant' FROM public.spaces +ON CONFLICT (space_id, subject, permission) DO NOTHING; diff --git a/scripts/schema.sql b/scripts/schema.sql index 309c6cd..c39f834 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -1823,6 +1823,7 @@ BEGIN ('board','ops.process.write'),('board','ops.secrets.read'),('board','ops.secrets.write'), ('board','ops.arealeads.manage'),('board','members.manage'),('board','payments.manage'), ('board','governance.manage'),('board','forum.moderate'),('board','forms.manage'), + ('board','certifications.manage'),('board','certifications.grant'), ('board','customize.manage'),('board','settings.manage'), ('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'), ('member','ops.kb.read'),('member','ops.process.read'), @@ -1976,3 +1977,105 @@ CREATE TRIGGER trg_forms_touch INSERT INTO public.space_role_permissions (space_id, subject, permission) SELECT id, 'board', 'forms.manage' FROM public.spaces ON CONFLICT (space_id, subject, permission) DO NOTHING; + + +-- ============================================================================= +-- 18. Certifications + Instructor capability +-- (equivalent to scripts/030_certifications.sql). +-- +-- certification *types* per space (optional validity_months); per-member +-- grants whose expires_at is snapshotted at grant time; soft revoke only. +-- Permissions: certifications.manage (cert types) and certifications.grant +-- (award/revoke = the Instructor capability, assignable to any role via +-- the additive space_role_permissions model). RLS is additive/default-deny: +-- members read cert types and their own grants; managers/granters read all; +-- member_certifications has no DELETE policy so the history is immutable. +-- There is no anonymous path. seed_default_role_permissions() above already +-- seeds both codes to board; the backfill below covers existing spaces. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS public.certifications ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE, + name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 200), + description text, + validity_months integer CHECK (validity_months IS NULL OR validity_months > 0), + is_active boolean NOT NULL DEFAULT true, + created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_certifications_space_name + ON public.certifications (space_id, lower(name)); +CREATE INDEX IF NOT EXISTS idx_certifications_space + ON public.certifications (space_id, is_active); + +CREATE TABLE IF NOT EXISTS public.member_certifications ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE, + member_id uuid NOT NULL REFERENCES public.space_members(id) ON DELETE CASCADE, + certification_id uuid NOT NULL REFERENCES public.certifications(id) ON DELETE CASCADE, + granted_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL, + granted_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz, + revoked_at timestamptz, + revoked_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL, + revoked_reason text, + note text, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_member_certifications_active + ON public.member_certifications (member_id, certification_id) + WHERE revoked_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_member_certifications_space + ON public.member_certifications (space_id); +CREATE INDEX IF NOT EXISTS idx_member_certifications_member + ON public.member_certifications (member_id); +CREATE INDEX IF NOT EXISTS idx_member_certifications_cert + ON public.member_certifications (certification_id); + +ALTER TABLE public.certifications ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.member_certifications ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS certifications_select ON public.certifications; +DROP POLICY IF EXISTS certifications_insert ON public.certifications; +DROP POLICY IF EXISTS certifications_update ON public.certifications; +DROP POLICY IF EXISTS certifications_delete ON public.certifications; +CREATE POLICY certifications_select ON public.certifications FOR SELECT + USING (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); +CREATE POLICY certifications_insert ON public.certifications FOR INSERT + WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'certifications.manage')); +CREATE POLICY certifications_update ON public.certifications FOR UPDATE + USING (public.user_has_permission(auth.uid(), space_id, 'certifications.manage')); +CREATE POLICY certifications_delete ON public.certifications FOR DELETE + USING (public.user_has_permission(auth.uid(), space_id, 'certifications.manage')); + +DROP POLICY IF EXISTS member_certifications_select ON public.member_certifications; +DROP POLICY IF EXISTS member_certifications_insert ON public.member_certifications; +DROP POLICY IF EXISTS member_certifications_update ON public.member_certifications; +CREATE POLICY member_certifications_select ON public.member_certifications FOR SELECT + USING ( + public.user_has_permission(auth.uid(), space_id, 'certifications.manage') + OR public.user_has_permission(auth.uid(), space_id, 'certifications.grant') + OR member_id IN ( + SELECT id FROM public.space_members + WHERE user_id = auth.uid() AND space_id = member_certifications.space_id + ) + ); +CREATE POLICY member_certifications_insert ON public.member_certifications FOR INSERT + WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'certifications.grant')); +CREATE POLICY member_certifications_update ON public.member_certifications FOR UPDATE + USING (public.user_has_permission(auth.uid(), space_id, 'certifications.grant')); +-- No DELETE policy: grant/revoke history is immutable to non-service clients. + +DROP TRIGGER IF EXISTS trg_certifications_touch ON public.certifications; +CREATE TRIGGER trg_certifications_touch + BEFORE UPDATE ON public.certifications + FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at(); + +INSERT INTO public.space_role_permissions (space_id, subject, permission) +SELECT id, 'board', 'certifications.manage' FROM public.spaces +ON CONFLICT (space_id, subject, permission) DO NOTHING; +INSERT INTO public.space_role_permissions (space_id, subject, permission) +SELECT id, 'board', 'certifications.grant' FROM public.spaces +ON CONFLICT (space_id, subject, permission) DO NOTHING; diff --git a/types/database.ts b/types/database.ts index 3372312..149ccf8 100644 --- a/types/database.ts +++ b/types/database.ts @@ -106,6 +106,18 @@ export type Database = { Update: { id?: string; form_id?: string; space_id?: string; member_id?: string | null; submitter_email?: string | null; answers?: Json; form_snapshot?: Json; legal_text_snapshot?: string | null; form_version?: number; ip?: string | null; user_agent?: string | null; created_at?: string } Relationships: [] } + certifications: { + Row: { id: string; space_id: string; name: string; description: string | null; validity_months: number | null; is_active: boolean; created_by: string | null; created_at: string; updated_at: string } + Insert: { id?: string; space_id: string; name: string; description?: string | null; validity_months?: number | null; is_active?: boolean; created_by?: string | null; created_at?: string; updated_at?: string } + Update: { id?: string; space_id?: string; name?: string; description?: string | null; validity_months?: number | null; is_active?: boolean; created_by?: string | null; created_at?: string; updated_at?: string } + Relationships: [] + } + member_certifications: { + Row: { id: string; space_id: string; member_id: string; certification_id: string; granted_by: string | null; granted_at: string; expires_at: string | null; revoked_at: string | null; revoked_by: string | null; revoked_reason: string | null; note: string | null; created_at: string } + Insert: { id?: string; space_id: string; member_id: string; certification_id: string; granted_by?: string | null; granted_at?: string; expires_at?: string | null; revoked_at?: string | null; revoked_by?: string | null; revoked_reason?: string | null; note?: string | null; created_at?: string } + Update: { id?: string; space_id?: string; member_id?: string; certification_id?: string; granted_by?: string | null; granted_at?: string; expires_at?: string | null; revoked_at?: string | null; revoked_by?: string | null; revoked_reason?: string | null; note?: string | null; created_at?: string } + Relationships: [] + } activity_log: { Row: { action: string