-- 001: tenant schema, RLS, audit chain, lifecycle triggers CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE TABLE workspaces ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL, slug text NOT NULL UNIQUE, authz_epoch bigint NOT NULL DEFAULT 1, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE users ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), external_id text NOT NULL UNIQUE, email text NOT NULL, display_name text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE memberships ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, role text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE (workspace_id, user_id) ); CREATE TABLE role_permissions ( role text NOT NULL, permission text NOT NULL, PRIMARY KEY (role, permission) ); CREATE TABLE connections ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, provider text NOT NULL, channel_id text NOT NULL, fields jsonb NOT NULL DEFAULT '{}', created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE credentials ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, connection_id uuid REFERENCES connections(id) ON DELETE CASCADE, kind text NOT NULL, ciphertext bytea NOT NULL, key_version int NOT NULL DEFAULT 1, created_at timestamptz NOT NULL DEFAULT now(), rotated_at timestamptz ); CREATE TABLE audit_log ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid REFERENCES workspaces(id) ON DELETE CASCADE, actor_user_id uuid, actor_role text, action text NOT NULL, target_type text NOT NULL, target_id text NOT NULL, diff jsonb, at timestamptz NOT NULL DEFAULT now(), seq bigint GENERATED ALWAYS AS IDENTITY, prev_hash text, hash text NOT NULL ); CREATE INDEX audit_log_ws_chain ON audit_log (workspace_id, seq DESC) WHERE workspace_id IS NOT NULL; CREATE INDEX audit_log_system_chain ON audit_log (seq DESC) WHERE workspace_id IS NULL; -- Tenancy guard for SECURITY DEFINER functions (audit finding #3): -- every cross-tenant function takes an explicit workspace parameter and -- asserts it against the session's tenant context. CREATE FUNCTION fn_assert_tenant(p_workspace_id uuid) RETURNS void LANGUAGE plpgsql STABLE AS $$ DECLARE v_ctx text; BEGIN v_ctx := current_setting('app.workspace_id', true); IF v_ctx IS NULL OR v_ctx = '' OR v_ctx::uuid IS DISTINCT FROM p_workspace_id THEN RAISE EXCEPTION 'tenant context mismatch' USING ERRCODE = '42501'; END IF; IF NOT EXISTS (SELECT 1 FROM workspaces w WHERE w.id = p_workspace_id) THEN RAISE EXCEPTION 'unknown workspace' USING ERRCODE = '42501'; END IF; END $$; -- Hash chain: tenant rows chain per workspace; system rows chain on the -- workspace_id IS NULL branch. Branch selection is by TENANT CONTEXT -- PRESENCE, never by role (audit findings #1/#6: sp_scheduler/vault callers -- with an active tenant context must append to that tenant's chain). CREATE FUNCTION audit_hash(p_prev text, p_actor uuid, p_action text, p_target_id text, p_at timestamptz) RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT encode(digest( coalesce(p_prev, '') || '|' || coalesce(p_actor::text, '') || '|' || p_action || '|' || p_target_id || '|' || extract(epoch FROM p_at)::text, 'sha256'), 'hex') $$; CREATE FUNCTION audit_append( p_workspace_id uuid, p_actor_user_id uuid, p_actor_role text, p_action text, p_target_type text, p_target_id text, p_diff jsonb DEFAULT NULL ) RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_id uuid; v_prev text; v_hash text; v_now timestamptz := clock_timestamp(); BEGIN PERFORM fn_assert_tenant(p_workspace_id); SELECT hash INTO v_prev FROM audit_log WHERE workspace_id = p_workspace_id ORDER BY seq DESC LIMIT 1; v_hash := audit_hash(v_prev, p_actor_user_id, p_action, p_target_id, v_now); INSERT INTO audit_log (workspace_id, actor_user_id, actor_role, action, target_type, target_id, diff, at, prev_hash, hash) VALUES (p_workspace_id, p_actor_user_id, p_actor_role, p_action, p_target_type, p_target_id, p_diff, v_now, v_prev, v_hash) RETURNING id INTO v_id; RETURN v_id; END $$; CREATE FUNCTION audit_append_system( p_actor_user_id uuid, p_action text, p_target_type text, p_target_id text, p_diff jsonb DEFAULT NULL ) RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_id uuid; v_prev text; v_hash text; v_now timestamptz := clock_timestamp(); BEGIN IF coalesce(current_setting('app.workspace_id', true), '') <> '' THEN RAISE EXCEPTION 'system append must not run inside a tenant context (use audit_append)'; END IF; SELECT hash INTO v_prev FROM audit_log WHERE workspace_id IS NULL ORDER BY seq DESC LIMIT 1; v_hash := audit_hash(v_prev, p_actor_user_id, p_action, p_target_id, v_now); INSERT INTO audit_log (workspace_id, actor_user_id, action, target_type, target_id, diff, at, prev_hash, hash) VALUES (NULL, p_actor_user_id, p_action, p_target_type, p_target_id, p_diff, v_now, v_prev, v_hash) RETURNING id INTO v_id; RETURN v_id; END $$; -- Tamper detection: recompute the chain. CREATE FUNCTION audit_verify_chain(p_workspace_id uuid DEFAULT NULL) RETURNS boolean LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public AS $$ DECLARE r record; v_prev text := NULL; BEGIN FOR r IN SELECT * FROM audit_log WHERE workspace_id IS NOT DISTINCT FROM p_workspace_id ORDER BY seq ASC LOOP IF r.prev_hash IS DISTINCT FROM v_prev THEN RETURN false; END IF; IF audit_hash(r.prev_hash, r.actor_user_id, r.action, r.target_id, r.at) <> r.hash THEN RETURN false; END IF; v_prev := r.hash; END LOOP; RETURN true; END $$; -- Lifecycle immutability (audit finding #8): tenant rows never change tenant. CREATE FUNCTION reject_workspace_change() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.workspace_id IS DISTINCT FROM OLD.workspace_id THEN RAISE EXCEPTION 'workspace_id is immutable' USING ERRCODE = '23514'; END IF; RETURN NEW; END $$; CREATE TRIGGER trg_conn_ws_immutable BEFORE UPDATE ON connections FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); CREATE TRIGGER trg_cred_ws_immutable BEFORE UPDATE ON credentials FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); CREATE TRIGGER trg_mem_ws_immutable BEFORE UPDATE ON memberships FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); -- Continuous authz (audit finding #2): membership/role changes bump the -- workspace authz epoch so live sessions fail closed on next check. -- COALESCE matters: OLD is NULL-valued on INSERT, NEW on DELETE. CREATE FUNCTION bump_authz_epoch() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN UPDATE workspaces SET authz_epoch = authz_epoch + 1 WHERE id = COALESCE(NEW.workspace_id, OLD.workspace_id); RETURN COALESCE(NEW, OLD); END $$; CREATE TRIGGER trg_mem_bump_epoch AFTER INSERT OR UPDATE OR DELETE ON memberships FOR EACH ROW EXECUTE FUNCTION bump_authz_epoch(); -- RLS: fail closed. Missing app.workspace_id => NULL => no tenant rows visible. -- FORCE: applies even to the table owner, so the single-role dev pool cannot -- silently bypass tenancy; tenant writes must go through withTenant(). ALTER TABLE memberships ENABLE ROW LEVEL SECURITY; ALTER TABLE connections ENABLE ROW LEVEL SECURITY; ALTER TABLE credentials ENABLE ROW LEVEL SECURITY; ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY; ALTER TABLE memberships FORCE ROW LEVEL SECURITY; ALTER TABLE connections FORCE ROW LEVEL SECURITY; ALTER TABLE credentials FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON memberships USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); CREATE POLICY tenant_isolation ON connections USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); CREATE POLICY tenant_isolation ON credentials USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); CREATE POLICY tenant_audit ON audit_log USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);