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:
+4
-1
@@ -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`.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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<string, PermissionCode[]> = {
|
||||
'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'],
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user