HR-ATS-Portal/backend/migrations/seed/rbac_ingest.sql

221 lines
9.9 KiB
PL/PgSQL

-- 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;