-- Authoritative MediaRouter tenancy and canonical media ownership. -- Apply after 0001 with the normal production migration process. -- API keys remain credentials; api_key_principals binds them to a persisted -- active membership. Do not grant these tables directly to browser clients. begin; create extension if not exists pgcrypto; create table if not exists users ( id text primary key default gen_random_uuid()::text, subject text not null unique, display_name text, status text not null default 'active' check (status in ('active', 'disabled')), metadata jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create table if not exists workspaces ( id text primary key default gen_random_uuid()::text, slug text not null unique, name text not null, status text not null default 'active' check (status in ('active', 'suspended', 'disabled')), metadata jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create table if not exists workspace_memberships ( id text primary key default gen_random_uuid()::text, workspace_id text not null references workspaces(id) on delete cascade, user_id text not null references users(id) on delete cascade, role text not null default 'member' check (role in ('owner', 'admin', 'member', 'viewer', 'service')), status text not null default 'active' check (status in ('active', 'disabled')), created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint uq_workspace_membership unique (workspace_id, user_id) ); create index if not exists ix_workspace_memberships_user on workspace_memberships(user_id); create index if not exists ix_workspace_memberships_workspace on workspace_memberships(workspace_id); create table if not exists api_key_principals ( id text primary key default gen_random_uuid()::text, api_key_id text not null unique references api_keys(id) on delete cascade, workspace_id text not null references workspaces(id) on delete cascade, user_id text not null references users(id) on delete cascade, membership_id text not null references workspace_memberships(id) on delete restrict, status text not null default 'active' check (status in ('active', 'disabled')), created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists ix_api_key_principals_workspace on api_key_principals(workspace_id); create index if not exists ix_api_key_principals_user on api_key_principals(user_id); create table if not exists media_assets ( id text primary key default gen_random_uuid()::text, workspace_id text not null references workspaces(id) on delete restrict, request_id text not null, filename text not null, mime_type text not null, file_size bigint not null check (file_size >= 0), sha256 char(64) not null check (sha256 ~ '^[0-9a-f]{64}$'), metadata jsonb not null default '{}'::jsonb, created_by_user_id text references users(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint uq_media_asset_output unique (request_id, filename) ); create index if not exists ix_media_assets_workspace_created on media_assets(workspace_id, created_at desc); create index if not exists ix_media_assets_workspace_request on media_assets(workspace_id, request_id); create table if not exists media_asset_variants ( id text primary key default gen_random_uuid()::text, workspace_id text not null references workspaces(id) on delete restrict, asset_id text not null references media_assets(id) on delete cascade, request_id text not null, filename text not null, mime_type text not null, file_size bigint not null check (file_size >= 0), sha256 char(64) not null check (sha256 ~ '^[0-9a-f]{64}$'), metadata jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), constraint uq_media_asset_variant_output unique (asset_id, request_id, filename) ); create index if not exists ix_media_asset_variants_workspace_asset on media_asset_variants(workspace_id, asset_id); create or replace function mediarouter_tenant_touch_updated_at() returns trigger language plpgsql as $$ begin new.updated_at = now(); return new; end; $$; do $$ declare table_name text; begin foreach table_name in array array['users','workspaces','workspace_memberships','api_key_principals','media_assets'] loop execute format('drop trigger if exists mediarouter_tenant_touch_updated_at on %I', table_name); execute format('create trigger mediarouter_tenant_touch_updated_at before update on %I for each row execute function mediarouter_tenant_touch_updated_at()', table_name); end loop; end $$; -- A tenant API connection must set both values transaction-locally. Backend -- authentication/administration and service workers use separate credentials, -- never an exposed browser or SDK connection. alter table users enable row level security; alter table users force row level security; drop policy if exists users_self on users; create policy users_self on users using (id = current_setting('app.user_id', true)) with check (id = current_setting('app.user_id', true)); alter table workspaces enable row level security; alter table workspaces force row level security; drop policy if exists workspace_membership_read on workspaces; create policy workspace_membership_read on workspaces using (id = current_setting('app.workspace_id', true) and exists ( select 1 from workspace_memberships m where m.workspace_id = workspaces.id and m.user_id = current_setting('app.user_id', true) and m.status = 'active' )) with check (false); alter table workspace_memberships enable row level security; alter table workspace_memberships force row level security; drop policy if exists workspace_membership_self on workspace_memberships; create policy workspace_membership_self on workspace_memberships using (workspace_id = current_setting('app.workspace_id', true) and user_id = current_setting('app.user_id', true)) with check (false); alter table api_key_principals enable row level security; alter table api_key_principals force row level security; drop policy if exists api_key_principal_self on api_key_principals; create policy api_key_principal_self on api_key_principals using (workspace_id = current_setting('app.workspace_id', true) and user_id = current_setting('app.user_id', true)) with check (false); do $$ declare table_name text; begin foreach table_name in array array['media_assets','media_asset_variants'] loop execute format('alter table %I enable row level security', table_name); execute format('alter table %I force row level security', table_name); execute format('drop policy if exists canonical_asset_workspace_isolation on %I', table_name); execute format( 'create policy canonical_asset_workspace_isolation on %I using (workspace_id = current_setting(''app.workspace_id'', true)) with check (workspace_id = current_setting(''app.workspace_id'', true))', table_name ); end loop; end $$; create or replace function mediarouter_assert_canonical_variant_workspace() returns trigger language plpgsql as $$ declare asset_workspace text; begin select workspace_id into asset_workspace from media_assets where id = new.asset_id; if asset_workspace is null or asset_workspace is distinct from new.workspace_id then raise exception 'media variant must belong to its source asset workspace' using errcode = '23503'; end if; return new; end; $$; drop trigger if exists mediarouter_canonical_variant_workspace on media_asset_variants; create trigger mediarouter_canonical_variant_workspace before insert or update of workspace_id, asset_id on media_asset_variants for each row execute function mediarouter_assert_canonical_variant_workspace(); commit;