T-HUB/apps/api/migrations/017_invite_links.sql
glass beadian aa5e262f3a 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/.
2026-10-10 11:56:42 +00:00

64 lines
3.3 KiB
PL/PgSQL

-- 017: multi-use, expiring workspace invite links (Telegram-style).
-- One link, many claimants: anyone holding the token may join with the link's
-- role until the link expires, is revoked, or exhausts max_uses (null =
-- unlimited until expiry). Claimers arrive unauthenticated, so the claim path
-- is the SECURITY DEFINER analogue of login (see invite_link_for_claim below):
-- workspace_invite_links is FORCE RLS and no tenant context exists pre-join.
CREATE TABLE workspace_invite_links (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
role text NOT NULL,
token text NOT NULL UNIQUE,
created_by uuid NOT NULL REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
max_uses int CHECK (max_uses IS NULL OR max_uses > 0),
use_count int NOT NULL DEFAULT 0 CHECK (use_count >= 0),
revoked_at timestamptz
);
CREATE TRIGGER trg_invite_link_ws_immutable BEFORE UPDATE ON workspace_invite_links
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
-- Same fail-closed tenant isolation as every tenant table (001/006 style):
-- no app.workspace_id => NULL => no rows. FORCE binds sp_app too.
ALTER TABLE workspace_invite_links ENABLE ROW LEVEL SECURITY;
ALTER TABLE workspace_invite_links FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON workspace_invite_links
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON workspace_invite_links TO sp_app;
-- Claim runs with NO tenant context (the claimer has no membership yet), and
-- the table is FORCE RLS: ordinary SELECTs see zero rows. Like
-- auth_user_memberships (007) and publishing_schedule_workspace (006), this
-- narrow SECURITY DEFINER lookup exposes exactly one row keyed by the secret
-- token — workspace_id discovery only; the caller enters the tenant context
-- afterwards and every subsequent statement is RLS-bound as sp_app.
CREATE FUNCTION invite_link_for_claim(p_token text)
RETURNS TABLE(id uuid, workspace_id uuid, role text, expires_at timestamptz,
max_uses int, use_count int, revoked_at timestamptz)
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
SELECT l.id, l.workspace_id, l.role, l.expires_at, l.max_uses, l.use_count, l.revoked_at
FROM workspace_invite_links l
WHERE l.token = p_token
$$;
REVOKE ALL ON FUNCTION invite_link_for_claim(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION invite_link_for_claim(text) TO sp_app;
-- Token lookup at claim time: point lookup served by the backing index of the
-- inline UNIQUE constraint on token (auto-named workspace_invite_links_token_key).
-- Statement for documentation/parity with 016's users_username_key — do NOT run twice:
-- CREATE UNIQUE INDEX workspace_invite_links_token_key ON workspace_invite_links (token);
-- Workspace listing (admin GET /team/invite-links), newest first:
CREATE INDEX workspace_invite_links_ws_created
ON workspace_invite_links (workspace_id, created_at DESC);
-- Expiry sweep / active-link diagnostics, mirroring the partial-index style of
-- audit_log (001) and scheduled_publications_due (006):
CREATE INDEX workspace_invite_links_expiry
ON workspace_invite_links (expires_at) WHERE revoked_at IS NULL;