-- ============================================================ -- RBAC Migration — adds workspaces, users, channels, and -- per-role / per-user permission tables. -- -- Safe to run multiple times (all statements are idempotent). -- Run AFTER the base schema.sql. -- ============================================================ -- ── Workspaces (company-level isolation) ──────────────────── create table if not exists workspaces ( id uuid primary key default gen_random_uuid(), slug text not null unique, name text not null, created_at timestamptz not null default now() ); -- Seed the default workspace that matches existing data insert into workspaces (id, slug, name) values ('00000000-0000-0000-0000-000000000001', 'default', 'Default Workspace') on conflict (slug) do nothing; -- ── Users (replaces hardcoded credentials) ────────────────── create table if not exists users ( id uuid primary key default gen_random_uuid(), workspace_id uuid not null references workspaces(id) on delete cascade, email text not null, name text not null, -- bcrypt hash; null = SSO-only account password_hash text, -- engineer | manager | admin | org_admin role text not null default 'engineer', is_new_hire boolean not null default false, -- auto-clear new_hire flag after this date (null = never auto-clear) new_hire_until date, is_active boolean not null default true, invited_by uuid references users(id), created_at timestamptz not null default now(), last_active_at timestamptz, unique(workspace_id, email) ); create index if not exists users_workspace_email_idx on users (workspace_id, email); create index if not exists users_role_idx on users (role); -- ── Teams table ────────────────────────────────────────────── -- Create teams with team_id PK if it doesn't exist at all. create table if not exists teams ( team_id text primary key, cag_snapshot text, snapshot_at timestamptz, created_at timestamptz not null default now() ); -- If teams existed before with a different PK name, add team_id as a column. do $$ begin if not exists ( select 1 from information_schema.columns where table_schema = 'public' and table_name = 'teams' and column_name = 'team_id' ) then alter table teams add column team_id text; create unique index if not exists teams_team_id_idx on teams (team_id); end if; end $$; -- Extend teams table with RBAC columns. alter table teams add column if not exists workspace_id uuid references workspaces(id) on delete cascade; alter table teams add column if not exists name text; alter table teams add column if not exists slug text; -- Ensure the default team row exists. insert into teams (team_id, workspace_id, name, slug) values ('default', '00000000-0000-0000-0000-000000000001', 'Engineering', 'engineering') on conflict (team_id) do update set workspace_id = excluded.workspace_id, name = excluded.name, slug = excluded.slug; -- ── User → Team memberships ────────────────────────────────── create table if not exists user_teams ( user_id uuid not null references users(id) on delete cascade, team_id text not null references teams(team_id) on delete cascade, -- member | lead role text not null default 'member', primary key (user_id, team_id) ); -- ── Channels (scoped data sources within a workspace/team) ─── -- Examples: "Backend Confluence", "HR Payroll Docs", "Public GitHub" create table if not exists channels ( id uuid primary key default gen_random_uuid(), workspace_id uuid not null references workspaces(id) on delete cascade, -- null = workspace-wide channel (not team-specific) team_id text references teams(team_id) on delete set null, name text not null, source_type text, -- github | confluence | notion | slack | file | url | null -- public | internal | confidential | restricted sensitivity text not null default 'internal', created_at timestamptz not null default now() ); create index if not exists channels_workspace_idx on channels (workspace_id); create index if not exists channels_team_idx on channels (team_id); -- Seed the default channel that covers all existing 'default' team_id data insert into channels (id, workspace_id, team_id, name, source_type, sensitivity) values ( '00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000001', 'default', 'General', null, 'internal' ) on conflict do nothing; -- ── Role-level channel access defaults ─────────────────────── -- Defines which roles can access a channel without an explicit override. create table if not exists channel_role_grants ( channel_id uuid not null references channels(id) on delete cascade, role text not null, primary key (channel_id, role) ); -- All roles can access the default general channel insert into channel_role_grants (channel_id, role) values ('00000000-0000-0000-0000-000000000002', 'engineer'), ('00000000-0000-0000-0000-000000000002', 'manager'), ('00000000-0000-0000-0000-000000000002', 'admin'), ('00000000-0000-0000-0000-000000000002', 'org_admin') on conflict do nothing; -- ── Per-user channel permission overrides ──────────────────── -- Grants or revokes a specific user's access to a channel, -- overriding their role's default grant. create table if not exists user_channel_permissions ( user_id uuid not null references users(id) on delete cascade, channel_id uuid not null references channels(id) on delete cascade, can_read boolean not null default true, granted_by uuid references users(id), granted_at timestamptz not null default now(), primary key (user_id, channel_id) ); -- ── Add channel_id to existing document tables ─────────────── alter table documents add column if not exists channel_id uuid references channels(id) on delete set null; alter table chunks add column if not exists channel_id uuid references channels(id) on delete set null; create index if not exists documents_channel_id_idx on documents (channel_id); create index if not exists chunks_channel_id_idx on chunks (channel_id); -- Backfill: existing rows with team_id='default' map to the default channel update documents set channel_id = '00000000-0000-0000-0000-000000000002' where team_id = 'default' and channel_id is null; update chunks set channel_id = '00000000-0000-0000-0000-000000000002' where team_id = 'default' and channel_id is null; -- ── Audit log ──────────────────────────────────────────────── -- Append-only record of permission changes and sensitive admin actions. create table if not exists rbac_audit_log ( id uuid primary key default gen_random_uuid(), actor_id uuid references users(id), action text not null, -- grant_channel | revoke_channel | change_role | invite_user | deactivate_user target_type text not null, -- user | channel | team target_id text not null, metadata jsonb not null default '{}', created_at timestamptz not null default now() ); create index if not exists rbac_audit_log_actor_idx on rbac_audit_log (actor_id); create index if not exists rbac_audit_log_target_idx on rbac_audit_log (target_type, target_id); create index if not exists rbac_audit_log_time_idx on rbac_audit_log (created_at desc); -- ── RLS policies ───────────────────────────────────────────── alter table users enable row level security; alter table channels enable row level security; alter table user_channel_permissions enable row level security; alter table rbac_audit_log enable row level security; -- Service role bypasses RLS; these cover anon/authenticated -- Users can only see their own record (service role sees all) create policy "users: own record only" on users for select using (id::text = current_setting('app.user_id', true)); -- Channels visible only if user has a role grant or explicit permission create policy "channels: visible to granted users" on channels for select using ( id in ( select crg.channel_id from channel_role_grants crg where crg.role = current_setting('app.role', true) ) or id in ( select ucp.channel_id from user_channel_permissions ucp where ucp.user_id::text = current_setting('app.user_id', true) and ucp.can_read = true ) );