-- rbac_ingest.sql -- One-shot ingestion of the RBAC tables: roles, permissions (bundles) and -- permission_tags. Produces the same end state as manual migrations -- 001, 004, 005, 007, 008, 019, 024, 026, 028 and 038 combined, so a fresh -- database can be seeded in one pass. -- -- Deliberately NOT under migrations/manual/ — run_manual_sql() only globs that -- folder, so this file never auto-applies at startup. Run it by hand: -- psql "$DATABASE_URL" -f migrations/seed/rbac_ingest.sql -- -- Idempotent: every insert is ON CONFLICT DO NOTHING and bundle ids are only -- appended to a role when missing, so re-running it is a no-op. -- Users must log in again afterwards — the frontend caches /users/me permissions. BEGIN; -- ============================================================================= -- 1. permission_tags — every module x action (17 x 8 = 136), matches -- users/permissions.py PermissionModule / PermissionAction -- ============================================================================= INSERT INTO app.permission_tags (tag_name, module, action, description, created_at, updated_at, is_active, is_deleted) SELECT m.module || '.' || a.action, m.module, a.action, NULL, NOW(), NOW(), true, false FROM unnest(ARRAY[ 'dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments', 'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks', 'talent', 'requisitions', 'department' ]) WITH ORDINALITY AS m(module, m_ord) CROSS JOIN unnest(ARRAY[ 'view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure' ]) WITH ORDINALITY AS a(action, a_ord) ORDER BY m.m_ord, a.a_ord ON CONFLICT (tag_name) DO NOTHING; -- ============================================================================= -- 2. roles — explicit ids: the app hardcodes candidate = 8 and -- hiring_manager = 4 (inbox/models.py, users/models.py) -- ============================================================================= INSERT INTO app.roles (id, role_name, description, permissions, is_system, created_at, updated_at, is_active, is_deleted) VALUES (1, 'system_administrator', 'Full system access', '[]'::jsonb, true, NOW(), NOW(), true, false), (2, 'hr_administrator', 'HR administration', '[]'::jsonb, true, NOW(), NOW(), false, true), (3, 'recruiter', 'Recruiting staff', '[]'::jsonb, true, NOW(), NOW(), true, false), (4, 'hiring_manager', 'Hiring manager for own requisitions', '[]'::jsonb, true, NOW(), NOW(), true, false), (5, 'department_head', 'Head of a department', '[]'::jsonb, true, NOW(), NOW(), true, false), (6, 'interviewer', 'Interview panel member', '[]'::jsonb, true, NOW(), NOW(), false, true), (7, 'ceo', 'Chief executive', '[]'::jsonb, true, NOW(), NOW(), false, true), (8, 'candidate', 'Applicant account', '[]'::jsonb, true, NOW(), NOW(), true, false) ON CONFLICT DO NOTHING; -- Explicit ids bypass the sequence; move it past them so UI-created roles don't collide. SELECT setval( pg_get_serial_sequence('app.roles', 'id'), GREATEST((SELECT MAX(id) FROM app.roles), 1) ); -- ============================================================================= -- 3. permissions — named bundles; each resolves to permission_tags ids by -- module list and/or explicit tag names -- ============================================================================= INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted) SELECT b.name, b.description, ( SELECT COALESCE(jsonb_agg(t.id ORDER BY t.id), '[]'::jsonb) FROM app.permission_tags t WHERE t.is_deleted = false AND (t.module = ANY(b.modules) OR t.tag_name = ANY(b.tags)) ), true, NOW(), NOW(), true, false FROM (VALUES ('all_access', 'Every permission in the original 13 modules', ARRAY['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments', 'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users']::text[], ARRAY[]::text[]), ('analytics_dashboard', 'Dashboard KPI tiles, analytics charts, offers, and interview list', ARRAY['dashboard', 'analytics', 'offers']::text[], ARRAY['interviews.view']::text[]), ('tasks_management', 'Recruiting task list: view, create, complete and manage tasks', ARRAY['tasks']::text[], ARRAY[]::text[]), ('tasks_viewer', 'Recruiting task list: read-only access', ARRAY[]::text[], ARRAY['tasks.view', 'tasks.export']::text[]), ('talent_sourcing', 'LinkedIn talent sourcing: run Apify searches and view sourced profiles', ARRAY['talent']::text[], ARRAY[]::text[]), ('hiring_forms', 'Fill and amend candidate hiring forms (requisition, interview analysis, cultural fit)', ARRAY[]::text[], ARRAY['interviews.create', 'interviews.edit', 'interviews.delete']::text[]), ('requisitions_management', 'Employee requisition forms: view, create, edit and manage requisitions', ARRAY['requisitions']::text[], ARRAY[]::text[]), ('manager_candidates', 'Hiring manager: list candidates on own requisition jobs, view profiles, write notes', ARRAY[]::text[], ARRAY['candidates.view', 'candidates.create', 'candidates.edit']::text[]), ('requisitions_self', 'Own employee requisition forms: view, create, edit (not org-wide manage)', ARRAY[]::text[], ARRAY['requisitions.view', 'requisitions.create', 'requisitions.edit']::text[]), ('interviews_tab', 'Interviews and Calendar tabs: list, schedule, reschedule', ARRAY[]::text[], ARRAY['interviews.view', 'interviews.create', 'interviews.edit']::text[]), ('department_management', 'Departments: view, create, edit and manage departments', ARRAY['department']::text[], ARRAY[]::text[]) ) AS b(name, description, modules, tags) ON CONFLICT (name) DO NOTHING; -- ============================================================================= -- 4. roles.permissions — attach bundle ids (append only what is missing). -- requisitions_self and interviews_tab are for custom roles, so unattached. -- ============================================================================= WITH role_bundles(role_name, bundle_name) AS ( VALUES ('system_administrator', 'all_access'), ('system_administrator', 'analytics_dashboard'), ('system_administrator', 'tasks_management'), ('system_administrator', 'talent_sourcing'), ('system_administrator', 'hiring_forms'), ('system_administrator', 'requisitions_management'), ('system_administrator', 'department_management'), ('hr_administrator', 'analytics_dashboard'), ('hr_administrator', 'tasks_management'), ('hr_administrator', 'talent_sourcing'), ('hr_administrator', 'hiring_forms'), ('hr_administrator', 'requisitions_management'), ('hr_administrator', 'department_management'), ('recruiter', 'analytics_dashboard'), ('recruiter', 'tasks_management'), ('recruiter', 'talent_sourcing'), ('recruiter', 'hiring_forms'), ('recruiter', 'requisitions_management'), ('hiring_manager', 'analytics_dashboard'), ('hiring_manager', 'tasks_viewer'), ('hiring_manager', 'talent_sourcing'), ('hiring_manager', 'hiring_forms'), ('hiring_manager', 'requisitions_management'), ('hiring_manager', 'manager_candidates'), ('department_head', 'analytics_dashboard'), ('department_head', 'tasks_viewer'), ('department_head', 'talent_sourcing'), ('department_head', 'hiring_forms'), ('department_head', 'requisitions_management'), ('ceo', 'analytics_dashboard'), ('ceo', 'tasks_viewer'), ('ceo', 'talent_sourcing'), ('ceo', 'hiring_forms'), ('ceo', 'requisitions_management') ), wanted AS ( SELECT rb.role_name, p.id AS permission_id FROM role_bundles rb JOIN app.permissions p ON p.name = rb.bundle_name AND p.is_deleted = false ) UPDATE app.roles r SET permissions = ( SELECT COALESCE(jsonb_agg(ids.id ORDER BY ids.id), '[]'::jsonb) FROM ( SELECT value::int AS id FROM jsonb_array_elements_text(COALESCE(r.permissions, '[]'::jsonb)) UNION SELECT w.permission_id FROM wanted w WHERE w.role_name = r.role_name ) ids ), updated_at = NOW() WHERE EXISTS ( SELECT 1 FROM wanted w WHERE w.role_name = r.role_name AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(w.permission_id)) ); COMMIT; -- ============================================================================= -- 5. Verify: role -> bundles -> resolved tag count -- ============================================================================= SELECT r.id, r.role_name, r.is_deleted, string_agg(DISTINCT p.name, ', ' ORDER BY p.name) AS bundles, COUNT(DISTINCT t.id) AS tag_count FROM app.roles r LEFT JOIN LATERAL jsonb_array_elements_text(COALESCE(r.permissions, '[]'::jsonb)) rp(pid) ON true LEFT JOIN app.permissions p ON p.id = rp.pid::int AND p.is_deleted = false LEFT JOIN LATERAL jsonb_array_elements_text(COALESCE(p.permission_tags, '[]'::jsonb)) pt(tid) ON true LEFT JOIN app.permission_tags t ON t.id = pt.tid::int AND t.is_deleted = false GROUP BY r.id, r.role_name, r.is_deleted ORDER BY r.id;