Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
212
apps/api/migrations/001_tenants.sql
Normal file
212
apps/api/migrations/001_tenants.sql
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
-- 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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue