-- 008: groups — sub-workspace sharing scopes for connections and assets. -- Group membership gates which connections a non-Owner/Admin can publish through. -- -- Child tables carry a denormalised workspace_id so that: -- * RLS is the same direct column predicate as every other tenant table -- (no subquery policies); -- * composite FKs pin every row to ONE workspace — a group cannot hold a -- connection or a member from another tenant, even via the owner pool; -- * the epoch bump can read workspace_id from the row itself. Looking it up -- in groups would miss cascaded deletes (DELETE FROM groups -> its -- group_members rows), because the parent row is already gone when the -- child's AFTER trigger runs — deleting a group would silently skip the bump. -- Composite FK targets. ALTER TABLE connections ADD CONSTRAINT connections_id_workspace_key UNIQUE (id, workspace_id); CREATE TABLE groups ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, name text NOT NULL CHECK (btrim(name) <> ''), description text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT now(), UNIQUE (workspace_id, name), UNIQUE (id, workspace_id) ); CREATE TABLE group_members ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL, group_id uuid NOT NULL, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, added_by uuid REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE (group_id, user_id), FOREIGN KEY (group_id, workspace_id) REFERENCES groups(id, workspace_id) ON DELETE CASCADE, -- Only workspace members can join a group; leaving the workspace leaves its groups. FOREIGN KEY (workspace_id, user_id) REFERENCES memberships(workspace_id, user_id) ON DELETE CASCADE ); CREATE TABLE group_connections ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL, group_id uuid NOT NULL, connection_id uuid NOT NULL, assigned_by uuid REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE (group_id, connection_id), FOREIGN KEY (group_id, workspace_id) REFERENCES groups(id, workspace_id) ON DELETE CASCADE, FOREIGN KEY (connection_id, workspace_id) REFERENCES connections(id, workspace_id) ON DELETE CASCADE ); CREATE TABLE group_assets ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL, group_id uuid NOT NULL, asset_id text NOT NULL CHECK (btrim(asset_id) <> ''), -- media asset storage key or id assigned_by uuid REFERENCES users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE (group_id, asset_id), FOREIGN KEY (group_id, workspace_id) REFERENCES groups(id, workspace_id) ON DELETE CASCADE ); CREATE INDEX group_members_user ON group_members (workspace_id, user_id); CREATE INDEX group_connections_connection ON group_connections (connection_id); CREATE TRIGGER trg_group_ws_immutable BEFORE UPDATE ON groups FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); CREATE TRIGGER trg_group_member_ws_immutable BEFORE UPDATE ON group_members FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); CREATE TRIGGER trg_group_connection_ws_immutable BEFORE UPDATE ON group_connections FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); CREATE TRIGGER trg_group_asset_ws_immutable BEFORE UPDATE ON group_assets FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); -- Continuous authz: group membership is an authorization input (it gates -- publishing), so changes bump the workspace epoch exactly like memberships. -- Reuses bump_authz_epoch() (001/005): it reads the row's own workspace_id, -- which also covers cascades from DELETE FROM groups / memberships. CREATE TRIGGER trg_group_member_bump_epoch AFTER INSERT OR UPDATE OR DELETE ON group_members FOR EACH ROW EXECUTE FUNCTION bump_authz_epoch(); ALTER TABLE groups ENABLE ROW LEVEL SECURITY; ALTER TABLE group_members ENABLE ROW LEVEL SECURITY; ALTER TABLE group_connections ENABLE ROW LEVEL SECURITY; ALTER TABLE group_assets ENABLE ROW LEVEL SECURITY; ALTER TABLE groups FORCE ROW LEVEL SECURITY; ALTER TABLE group_members FORCE ROW LEVEL SECURITY; ALTER TABLE group_connections FORCE ROW LEVEL SECURITY; ALTER TABLE group_assets FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON groups USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); CREATE POLICY tenant_isolation ON group_members USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); CREATE POLICY tenant_isolation ON group_connections USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); CREATE POLICY tenant_isolation ON group_assets 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 groups, group_members, group_connections, group_assets TO sp_app;