Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / db/migrations/0001_foundation.sql |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 39df94f92b2a33f852481fe1954dabed841524bb |
| Branch | w0-aedt-compliance |
| Path or archive member | db/migrations/0001_foundation.sql |
| Original SHA-256 | 0e46571283a0f675f5ee6baa517e8755161a3235e7ee687cc34fe565427acfe7 |
| Git blob SHA | ca1fa811c0d2f459abc68ae920e334c72de1c165 |
| Reading-copy SHA-256 | 0e46571283a0f675f5ee6baa517e8755161a3235e7ee687cc34fe565427acfe7 |
Recovered source transcript
Download complete source text ↓111 lines · 5.9 KB
-- +goose Up
-- Foundation: extensions, tenancy root, users, and the append-only audit log.
-- Run as the BYPASSRLS migrator role. See docs/STACK_DECISIONS.md §2/§4.
CREATE EXTENSION IF NOT EXISTS vector; -- Azure: the binary/extension name is "vector", NOT "pgvector"
-- ---------------------------------------------------------------------------
-- organizations — the tenant root. RLS scopes a session to its own org row.
-- INSERT (provisioning a new org at signup) is PRIVILEGED (migrator/provisioning
-- path), so the app role gets no INSERT grant. status/slug (org lifecycle) are
-- NOT app-editable — only `name` is (column-level grant below), so a tenant
-- cannot self-un-suspend by flipping status back to 'active' [review RLS-2].
-- ---------------------------------------------------------------------------
CREATE TABLE organizations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
slug text NOT NULL UNIQUE,
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'suspended')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE organizations FORCE ROW LEVEL SECURITY;
CREATE POLICY org_self_select ON organizations FOR SELECT
USING (id = NULLIF(current_setting('app.current_org_id', true), '')::uuid);
CREATE POLICY org_self_update ON organizations FOR UPDATE
USING (id = NULLIF(current_setting('app.current_org_id', true), '')::uuid)
WITH CHECK (id = NULLIF(current_setting('app.current_org_id', true), '')::uuid);
-- ---------------------------------------------------------------------------
-- users — tenant-scoped. UNIQUE (organization_id, id) lets other tables form a
-- COMPOSITE FK that pins created_by/decided_by to the same org [review RLS-1/INT-4].
-- ---------------------------------------------------------------------------
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL REFERENCES organizations(id),
email text NOT NULL,
full_name text NOT NULL,
role text NOT NULL DEFAULT 'member'
CHECK (role IN ('owner', 'admin', 'recruiter', 'member', 'viewer')),
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'invited', 'disabled')),
last_login_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_users_org_id UNIQUE (organization_id, id)
);
CREATE UNIQUE INDEX uq_users_org_email ON users (organization_id, lower(email));
CREATE INDEX idx_users_org ON users (organization_id, created_at DESC); -- composite leads with org
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE users FORCE ROW LEVEL SECURITY;
CREATE POLICY users_tenant_isolation ON users
USING (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid)
WITH CHECK (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid);
-- ---------------------------------------------------------------------------
-- audit_logs — OUTSIDE RLS, APPEND-ONLY. Defense in depth: (1) app role granted
-- INSERT/SELECT only; (2) a row trigger blocks UPDATE/DELETE; (3) a statement
-- trigger blocks TRUNCATE [review AUDIT-1]; (4) explicit REVOKEs survive default-
-- privilege drift [review AUDIT-5]. Reads MUST filter by org (no RLS here).
-- ---------------------------------------------------------------------------
CREATE TABLE audit_logs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
organization_id uuid, -- nullable: pre-auth events (e.g. failed login) have no org yet
actor_user_id uuid, -- nullable: system/anonymous actor
action text NOT NULL, -- 'candidate.create','slate.generate','export','login','candidate.search',...
entity_type text,
entity_id text,
old_values jsonb,
new_values jsonb,
ip_address text,
user_agent text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_audit_org_time ON audit_logs (organization_id, created_at DESC);
CREATE INDEX idx_audit_entity ON audit_logs (entity_type, entity_id);
-- Privileged read path for NULL-org (platform/security) events so they aren't a black hole [review AUDIT-3].
CREATE INDEX idx_audit_system ON audit_logs (created_at DESC) WHERE organization_id IS NULL;
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION forbid_mutation() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'append-only table: % not permitted', TG_OP;
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd
CREATE TRIGGER audit_logs_append_only
BEFORE UPDATE OR DELETE ON audit_logs
FOR EACH ROW EXECUTE FUNCTION forbid_mutation();
CREATE TRIGGER audit_logs_no_truncate
BEFORE TRUNCATE ON audit_logs
FOR EACH STATEMENT EXECUTE FUNCTION forbid_mutation();
-- ---------------------------------------------------------------------------
-- Grants. organizations: SELECT + UPDATE(name) ONLY (no status/slug self-edit).
-- audit_logs: INSERT/SELECT only, with explicit REVOKEs as belt-and-suspenders.
-- ---------------------------------------------------------------------------
GRANT SELECT ON organizations TO missionmatch_app;
GRANT UPDATE (name) ON organizations TO missionmatch_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON users TO missionmatch_app;
GRANT SELECT, INSERT ON audit_logs TO missionmatch_app;
REVOKE ALL ON audit_logs FROM PUBLIC;
REVOKE UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER ON audit_logs FROM missionmatch_app;
-- +goose Down
DROP TABLE IF EXISTS audit_logs;
DROP FUNCTION IF EXISTS forbid_mutation();
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS organizations;