HR-ATS-Portal/RBAC_CONTEXT_PROMPT.md

47 KiB
Raw Permalink Blame History

RBAC Context Prompt — HR-ATS-Portal (TalentFlow)

Paste this whole file as context for an engineer or LLM that must reproduce this system's role-based access control exactly. Everything below is taken from the code on branch Department_Module. File references point back to the source of truth. Where the code has a quirk or gap, it is written down as-is under Known behaviour to reproduce (or consciously fix). Do not tidy these away silently.


0. Your task

You are implementing an access-control layer that must behave identically to the one described here. That means the same:

  • data model (tags → bundles → roles → users),
  • permission vocabulary (136 module.action tags),
  • resolution algorithm (live, per request, deny-by-default),
  • enforcement order and HTTP status codes and error strings,
  • rules against privilege escalation,
  • row-level data scoping (who sees which jobs, candidates, offers, requisitions),
  • role-name-based business rules (tasks, assignments, hiring-manager portal),
  • frontend gating (routes, sidebar, buttons) and the Access Control matrix editor.

When this document and your instincts disagree, follow this document.


1. Core concepts in one paragraph

A permission tag is one atomic module.action string, such as candidates.view. A permission bundle (table permissions) is a named JSONB array of tag ids. A role is a named JSONB array of bundle ids. A user has zero or one role (users.role_id, nullable). On every authenticated request the server walks role → bundles → tags and builds a flat list of tag names. Route guards check that list. Service code then narrows which rows are visible using a few helper predicates, some of which look at tags and some at the role name. Tags never go into the JWT, so a permission change takes effect on the server on the very next request.


2. Data model

PostgreSQL, schema app. SQLModel/SQLAlchemy async. Every table soft-deletes (is_deleted) and has an is_active flag.

2.1 permission_tagsbackend/role/models.py

column type notes
id int PK seed order matters: it sets matrix ordering and resolution ordering
tag_name varchar(64) unique, indexed "{module}.{action}"
module varchar(32) indexed
action varchar(32)
description text null
is_active / is_deleted bool
created_at / updated_at timestamptz

Unique constraint uq_permission_tags_module_action on (module, action).

2.2 permissions (bundles)

column type notes
id int PK
name varchar(64) unique
description text null
permission_tags JSONB int[] ids from permission_tags.id, no FK
is_system bool system bundles cannot be renamed
is_active / is_deleted / timestamps

2.3 roles

column type notes
id int PK
role_name varchar(64) unique free text (initial Alembic revision used an enum; the model is a varchar)
description text null
permissions JSONB int[] ids from permissions.id, no FK
is_system bool system roles cannot be renamed or deleted
is_active / is_deleted / timestamps

2.4 usersbackend/users/models.py

Only the RBAC-relevant columns:

column type notes
id uuid PK the JWT sub
email unique
role_id int FK → roles.id, nullable role relationship is lazy="selectin"
is_active bool, default false set true by email confirmation
is_approved bool, default false set true by an admin (or at admin creation)
is_deleted bool

2.5 System role keys — EnumRoles

system_administrator  hr_administrator  recruiter  hiring_manager
department_head       interviewer       ceo        candidate

Seed ids follow that order: 1 system_administrator, 2 hr_administrator, 3 recruiter, 4 hiring_manager, … , 8 candidate. Some code hardcodes ids 4 and 8 (see §12).

Migration 026 soft-deletes hr_administrator, interviewer and ceo when no live user holds them. The organisation runs four staff roles (system_administrator, recruiter, hiring_manager, department_head) plus candidate. Migration 027 moves members of a hand-made role named Manager onto hiring_manager and soft-deletes it. Code still accepts the names manager and admin (see §6).


3. Permission vocabulary — 17 modules × 8 actions = 136 tags

Source: PermissionModule, PermissionAction, PermissionTag in backend/users/permissions.py.

Modules: dashboard, inbox, jobs, candidates, pipeline, department, interviews, assessments, offers, reports, analytics, job_board, settings, rbac_users, tasks, talent, requisitions

Actions: view, create, edit, delete, approve, export, manage, configure

Rules:

  1. PermissionTag is a str Enum listing every combination explicitly. At import time, _assert_vocabulary_complete() raises RuntimeError("PermissionTag vocabulary drift: missing=[...] extra=[...]") unless the enum equals the full modules × actions cross-product. The server will not boot with a partial vocabulary.
  2. Always serialise with .value. f"{PermissionTag.X}" renders the enum repr.
  3. DB rows are seeded idempotently (ON CONFLICT (tag_name) DO NOTHING) by manual SQL migrations: 001 (first 13 modules = 104 tags), 004 tasks, 007 talent, 019 requisitions, 038 department. Comments in the code that say "104" or "120" tags are stale. The real total is 136.
  4. Two actions carry special meaning beyond "may use the screen":
    • *.manage on requisitions, candidates and offers removes row scoping. See §6.
    • requisitions.configure is an opt-in to scoping, not a screen permission. See §6.

4. Resolution algorithm — Roles.resolve_tags(session, role)

if role is None or not role.is_active or role.is_deleted:      return ()
perm_ids = role.permissions
if not perm_ids or not a list:                                 return ()
bundles  = SELECT permissions WHERE id IN perm_ids AND is_active AND NOT is_deleted
tag_ids  = concat(bundle.permission_tags for each bundle whose permission_tags is a non-empty list)
if not tag_ids:                                                return ()
tags     = SELECT permission_tags WHERE id IN tag_ids AND is_active AND NOT is_deleted
sort tags by (id, tag_name); de-duplicate by tag_name keeping first
return tuple(tag_name ...)

Properties you must preserve:

  • Deny by default, never error. Dangling ids, inactive or deleted bundles, and inactive or deleted tags simply add nothing.
  • Union. Tags from every attached bundle are merged. There are no negative grants.
  • Live. It runs on every request inside get_current_user. Nothing is cached server-side and nothing is put in the token.
  • Stable order. The output is ordered by tag id, which is seed order.

5. Authentication and enforcement pipeline

5.1 Tokens — backend/users/plugins.py

PyJWT HS256. Every token carries sub, type, iat, exp, jti. decode_token(token, expected_type=...) rejects a token whose type does not match.

type default lifetime extra claims
access 30 min email, role_id
refresh 7 days
reset 10 min crid

The token's role_id is informational only. Authorization always reloads the user from the DB. There is no logout endpoint, no denylist and no jti tracking.

Login, signup, refresh and /users/create return: {access_token, refresh_token, token_type:"bearer", expires_in, data: <user without permissions>, status_code}.

5.2 get_current_user (dependency alias CurrentUser)

The checks run in this order:

  1. HTTP Bearer header is missing → FastAPI HTTPBearer returns 401 "Not authenticated" (FastAPI 0.136.1 behaviour).
  2. Decode fails or type is not access401 "Could not validate credentials" with WWW-Authenticate: Bearer.
  3. Load the user by sub via Users.get_user_by_id. That query excludes role_id = 8 (candidate). If the user is missing, is_deleted, or !is_active401 "User is inactive or does not exist".
  4. !is_approved403 "Your Approval is at Pending".
  5. permissions = resolve_tags(user.role).
  6. Return serialize_user(user, with_permissions=True, permissions=...):
{ "id": "uuid", "name": "...", "email": "...", "role_id": 3, "role_name": "recruiter",
  "role_description": "...", "linkedin_url": null, "is_active": true, "is_approved": true,
  "is_deleted": false, "created_at": "...", "updated_at": "...",
  "permissions": ["dashboard.view", "..."] }

GET /users/me returns exactly this and requires only CurrentUser, with no tag. That way a user with no role can still discover their state.

5.3 require_permission(*tags, require_all=True)

A FastAPI dependency factory that runs after get_current_user:

  1. current_user.role_id is None403 "User has no role assigned". This check runs before any tag check.
  2. has_permission(granted, *tags, require_all):
    • require_all=True → required ⊆ granted (AND)
    • require_all=False → required ∩ granted ≠ ∅ (OR)
  3. On failure → 403 with one of these exact details:
    • one tag, AND: "Missing required permission: candidates.view"
    • several tags, AND: "Missing required permissions: a, b"
    • OR: "Missing any of required permissions: a, b"
  4. On success it returns current_user, and handlers use it as current_user: dict.

A user whose role is soft-deleted or inactive still has a role_id. They pass step 1, resolve to zero tags, and fail step 3.

5.4 Login and account-state rules — backend/users/views.py

  • authenticate_user: the email lookup excludes role 8. A bad email or password returns 401 "Incorrect email or password". Then is_deleted → 401 "User is inactive", then !is_active → 401 "Please confirm your email address to activate your account", then !is_approved → 403 "Your Approval is at Pending".
  • refresh_access_token: runs the same active, deleted and approved checks, with 401 "Invalid or expired refresh token" on a decode failure.
  • Signup (POST /users/signup, public): hardcodes role_id = 4 and is_approved = false, lands with is_active = false, and emails a confirmation link that sets is_active. An admin must then approve the account.
  • Admin create (POST /users/create, needs rbac_users.create): sets is_approved = true. is_active comes from the payload (default true). The escalation check in §5.5 applies.
  • Approval queue: GET /users/pending-approvals (needs settings.view) lists users who are active, not approved, not deleted and not role 8. PUT /users/approve?record_id= (needs rbac_users.edit) returns 400 when the user is not active: "User must confirm their email before approval".
  • Candidate accounts (role 8) cannot log in or resolve through get_current_user. They are data records for applicants, not portal users.

5.5 Anti-escalation on role assignment — User._check_role_assignment

Used by POST /users/create, PUT /users/assign-role and PUT /users/remove-role. Both assign and remove are also route-guarded by rbac_users.edit.

if new_role_id == existing_role_id:          return            # no-op, no checks
if 'rbac_users.manage' not in caller.perms:  403 "Assigning a role requires rbac_users.manage"
if new_role_id is None:                      return            # removal needs only manage
role = roles[new_role_id]
if role missing or is_deleted:               404 "Role not found"
if not role.is_active:                       400 "Role is not active"
missing = resolve_tags(role) - caller.perms
if missing:                                  403 "Cannot assign a role with permissions you do not hold: a, b"

So a caller can only hand out a role whose effective tags are a subset of their own.


6. Row-level scoping (data visibility on top of tags)

Tags decide whether a user may call an endpoint. These predicates decide which rows they get back. They are pure functions of the current_user dict. Backend: backend/users/permissions.py. The frontend mirror is in frontend/src/auth/permissions.js and must stay byte-for-byte equivalent in logic.

def is_hiring_manager(u):             # "hiring-manager portal" user
    return lower(strip(u.role_name)) in {"hiring_manager", "manager"}

def is_admin(u):                      # org-wide staff
    return lower(strip(u.role_name)) in {"system_administrator", "hr_administrator", "admin"} \
        or "requisitions.manage" in u.permissions

def sees_all_candidates(u):
    return is_admin(u) or "candidates.manage" in u.permissions

def sees_all_offers(u):
    return is_admin(u) or "offers.manage" in u.permissions

def scopes_to_own_requisitions(u):    # evaluate in this exact order
    if is_hiring_manager(u):                     return True    # wins even over admin tags
    if is_admin(u) or sees_all_candidates(u):    return False
    return "requisitions.configure" in u.permissions

Design rule stated in the code: custom roles must be able to opt in through Access Control tags. Never key scoping off role_id. Role-name checks exist only for the seeded hiring-manager and admin identities.

6.1 Job ownership sets — backend/job/job_post/models.py

  • JobPosts.ids_for_manager(user_id) returns non-deleted job posts where hiring_manager_id = user, unioned with jobs whose requisition_id points at a non-deleted requisition with created_by = user.
  • JobPosts.ids_for_creator(user_id, created_by=False):
    • created_by=True returns jobs with created_by = user.
    • Otherwise it returns jobs where the user is in current_recruiter_ids or is current_recruiter_id, or (the job has no recruiters and created_by = user).

6.2 owned_job_ids_for_candidate_scope(session, user, created_by=False)backend/job/candidate/views.py

if scopes_to_own_requisitions(user): return ids_for_manager(user.id)
if sees_all_candidates(user):        return None          # None = unscoped
return ids_for_creator(user.id, created_by)

job_post_ids_for_candidate_list intersects a caller-requested job filter with that set. None means unscoped and [] means nothing is visible.

6.3 assert_manager_candidate_access(session, user, user_id|job_post_id|inbox_id|manual_id)

if sees_all_candidates(user) and not scopes_to_own_requisitions(user): allow
owned = owned_job_ids_for_candidate_scope(...) or []
if not owned: 403 <scope detail>
resolve job_id (and candidate uid) from inbox_id / manual_id when not given
if job_id is None and uid is not None:
    allow if any job the candidate is assigned to ∈ owned, else 403
if job_id not in owned: 403 <scope detail>

The scope detail is MANAGER_SCOPE_DETAIL when requisition-scoped and CREATOR_SCOPE_DETAIL otherwise.

6.4 Where scoping is applied

Area Rule
Candidates list / detail / applications / notes / forms owned_job_ids_for_candidate_scope + assert_manager_candidate_access
GET /candidate/fetch/users and /count Hiring-manager users get 403 "Hiring managers can only list candidates on their requisitions"
Candidate detail without user_id Hiring manager → 403 MANAGER_SCOPE_DETAIL
Job posts list (fetch_job_posts) If scopes_to_own_requisitions, restrict to ids_for_manager. Requested ids outside that set are dropped, and an empty set returns []
Offers (candidate picker, create, sent) sees_all_offers → unscoped. Otherwise use owned job ids, and an out-of-scope job returns 403 "This offer is outside your assigned jobs"
Requisition forms (get_form_by_id) is_admin → all rows. Otherwise created_by = me
Candidate hiring forms list Hiring manager with no form_id, inbox_id, manual_upload_candidate_id or job_post_id → 403 "Hiring managers can only load forms for candidates on their requisitions". Otherwise assert_manager_candidate_access

7. Role-name business rules (not tag-driven)

These rules look up role names and resolve ids from the roles table at request time.

Rule Where Behaviour
Task creators backend/tasks/views.py Route requires tasks.create and the caller's role_id must be one of the ids for system_administrator, hr_administrator or recruiter, else 403 "Only system administrators, HR administrators and recruiters can create tasks"
Task assignee tasks Must be an existing, non-deleted user with role recruiter, else 422 "Tasks can only be assigned to recruiter accounts". Omitting the assignee is allowed only when the caller is a recruiter, who then self-assigns
Task assignee picker GET /tasks/assignees/fetch (tasks.view) All recruiter users, so the caller does not need rbac_users.view
Job assignment roles backend/job/assignment/views.py primary_recruiter → user must hold recruiter; hiring_manager → user must hold hiring_manager. Otherwise 422 "{field} must be a {role}". The user must also be active and not deleted
Application assignment same Assignee must be recruiter
Job post recruiters / HM backend/job/job_post/views.py current_recruiter_ids must be recruiters; hiring_manager_id must be a hiring_manager
Inbox assign-recruiter backend/inbox/views.py recruiter_id must be a recruiter, else 422
Hiring-manager directory GET /managers/fetch (jobs.view OR candidates.view OR job_board.create) Users with role hiring_manager. Returns 500 if that role is not seeded
Recruiter performance analytics Iterates users whose role is recruiter
Admin notifications backend/notifications/views.py Recipients are users with role system_administrator
Candidate identity inbox / candidate / search models Applicants are users with role candidate

8. Seeded bundles and who gets them

The initial roles and the original bundle set, including all_access for system_administrator (a fixed id list), were seeded outside this repo. Do not assume their contents; export them (see §10). The manual migrations below are in the repo and all run idempotently at startup via alembic_setup.run_manual_sql().

Bundle (is_system=true) Tags Attached to
analytics_dashboard (001) all dashboard.*, analytics.*, offers.* + interviews.view sysadmin, hr_admin, recruiter, hiring_manager, department_head, ceo
tasks_management (004) all tasks.* sysadmin, hr_admin, recruiter (005 removed it from hiring_manager, department_head, ceo)
tasks_viewer (005) tasks.view, tasks.export hiring_manager, department_head, ceo
talent_sourcing (007) all talent.* the six staff roles
hiring_forms (008) interviews.create, interviews.edit, interviews.delete the six staff roles
requisitions_management (019) all requisitions.* (includes .manage and .configure) the six staff roles
manager_candidates (024/025) candidates.view, candidates.create, candidates.edit hiring_manager (and a legacy manager role)
requisitions_self (028) requisitions.view, requisitions.create, requisitions.edit none. Meant for custom roles
interviews_tab (028) interviews.view, interviews.create, interviews.edit none. Meant for custom roles
department_management (038) all department.* sysadmin, hr_admin

"The six staff roles" means system_administrator, hr_administrator, recruiter, hiring_manager, department_head, ceo.

Pattern for adding a module (copy it exactly):

  1. Add the module to PermissionModule and all 8 PermissionTag members (the startup assertion enforces this).
  2. Add the module to MODULES in frontend/src/auth/permissions.js.
  3. Write a manual SQL migration that inserts the 8 tags (ON CONFLICT DO NOTHING), creates a <module>_management bundle with jsonb_agg(id ORDER BY id) over that module, and appends the bundle id to the chosen roles guarded by NOT (permissions @> jsonb_build_array(id)).
  4. Guard the routes with require_permission(PermissionTag.<MODULE>_<ACTION>).
  5. Add the route to frontend/src/app/routes.js with permission: '<module>.view'.
  6. Users must re-fetch /users/me (log in again) before the UI reflects the change.

Consequence of the seed, if nobody has edited the matrix: requisitions_management gives requisitions.manage to recruiter, hiring_manager and department_head. is_admin() is therefore true for recruiter and department_head, so they see every candidate, offer and requisition. Hiring managers stay scoped only because is_hiring_manager is checked first in scopes_to_own_requisitions. Verify this against the live export before relying on it.


9. Access Control editing (how grants change at runtime)

9.1 Endpoints — backend/role/app.py, backend/role/views.py

Method Path Tag Behaviour
GET /roles/fetch[?record_id] rbac_users.view Each role is expanded to {..., permissions:[bundle ids], bundles:[bundle payloads with tag_names], effective_permissions:[resolved tag names]}
POST /roles/create rbac_users.create role_name required (400); duplicate → 409 "Role name already exists"; always is_system=false
PUT /roles/update?record_id rbac_users.edit Partial update. Renaming a system role → 409 "System roles cannot be renamed". May replace permissions (bundle ids)
DELETE /roles/delete?record_id rbac_users.delete Soft delete. System role → 409 "System roles cannot be deleted"
PUT /roles/matrix/update?record_id rbac_users.edit Matrix save, see §9.2
GET /permissions/fetch rbac_users.view Bundles with tag_names
POST /permissions/create rbac_users.manage Always is_system=false; name clash → 409
PUT /permissions/update?record_id rbac_users.manage Renaming a system bundle → 409
PUT /roles/permission-tags/update rbac_users.manage Body {id: bundleId, permission_tags:[...], name?, description?, is_active?} sets the exact tag set on one shared bundle. Unknown or inactive tag ids → 422 "Unknown or inactive permission tag ids: [...]". Every role holding the bundle is affected immediately
GET /permission-tags/fetch rbac_users.view Ordered by module, action

All list endpoints accept search, top, skip and return {data, total, status_code}. 404s: "Role not found", "Permission bundle not found", "Permission tag not found".

9.2 Matrix save — Role.set_role_matrix(role_id, tag_ids)

role must exist and not be deleted (404)
tag_ids = sorted(unique(tag_ids))
unknown or inactive ids → 422 "Unknown or inactive permission tag ids: [...]"
overlay = bundle named f"role_{role.id}_matrix"
if overlay is missing: create it (is_system=false, description "Access Control matrix for {role_name}")
else: overwrite its permission_tags
role.permissions = [overlay.id]     # REPLACES every other bundle on the role
return the role payload

Shared system bundles are never mutated by the matrix. After the first save, a role's grant is exactly the ticked cells. The seeded bundles no longer apply to that role, even though they still exist.

9.3 Access Control screen — frontend/src/screens/Rbac.jsx

  • Route rbac, gated on rbac_users.view.
  • The role list hides role_name === 'candidate'. System roles show a "System role" badge and have no delete action.
  • The matrix has rows = modules and columns = actions. Both are derived from /permission-tags/fetch in first-seen (id) order. A cell renders only if that tag exists; otherwise it shows ·.
  • The draft starts from role.effective_permissions. Toggles are disabled without rbac_users.edit. Save is enabled only when the draft differs; it maps names to ids and calls PUT /roles/matrix/update.
  • Tooltip help: requisitions.configure = "Limit Jobs and Candidates to requisitions this user created. Independent of Create."; candidates.manage = "See every candidate, not only jobs this user owns."; requisitions.manage = "Org-wide requisition list (admin)."
  • The New/Edit Role form edits name, description, active flag and bundle picks, and shows a live preview of the union of tag_names from the picked bundles.

Settings screen: the Approvals tab lists /users/pending-approvals and its Approve button requires rbac_users.edit. The Users tab shows Pending / Awaiting approval / Active badges.


10. Export the live grants before mirroring

Seeds and matrix edits diverge over time. Take the effective role → tag map from the database rather than from §8:

SELECT r.id, r.role_name, r.is_system, r.is_active, r.is_deleted,
       string_agg(DISTINCT p.name, ', ')          AS bundles,
       count(DISTINCT t.id)                        AS tag_count,
       string_agg(DISTINCT t.tag_name, ', ' ORDER BY t.tag_name) AS effective_tags
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_active AND NOT p.is_deleted
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_active AND NOT t.is_deleted
WHERE r.is_active AND NOT r.is_deleted
GROUP BY r.id
ORDER BY r.id;

Also dump app.permissions (id, name, is_system, permission_tags) and app.permission_tags (id, tag_name) with their ids. Bundle and role arrays reference ids, so the ids must be preserved.


11. Frontend mirror (cosmetic gating; the server is authoritative)

11.1 Session and permission bootstrap — frontend/src/auth/AuthProvider.jsx

  • The session (tokens + data) lives in lib/tokenStore. GET /users/me runs as a TanStack Query (staleTime 5 min, retry: false) whenever an access token exists. Its result is merged into the stored session so a page reload already has permissions.
  • status is anonymous (no token), error (me failed), authenticated (me data or cached permissions present), or loading.
  • can(tag) comes from makeCan(permissions): a null or undefined tag is always allowed, otherwise it is set membership.
  • Sign-in invalidates the me query so the previous user's permissions cannot leak. Sign-out only clears client state. When a refresh fails, the user is sent to /auth/login?expired=1.

11.2 Route guard — frontend/src/auth/RequireAuth.jsx

anonymous → redirect to /auth/login (keeping from); error/auth/login?expired=1; loading → full-page spinner, so the nav does not flash; a permission the user lacks → <Forbidden/> ("You don't have access to this page").

11.3 Route table — frontend/src/app/routes.js

path permission path permission
dashboard dashboard.view interviews interviews.view
inbox inbox.view requisitions requisitions.view
matching (hidden) candidates.view assessments assessments.view
jobs jobs.view offers offers.view
candidates candidates.view managers jobs.view
cvbank candidates.view departments department.view
pipeline pipeline.view calendar interviews.view
progress jobs.view reports reports.view
import candidates.create analytics analytics.view
jobboard job_board.view aistudio null
recruiterhub analytics.view notifications null
talent talent.view rbac rbac_users.view
tasks tasks.view settings settings.view
aiassistant null help null

Candidate detail sub-routes in App.jsx also require candidates.view.

11.4 Sidebar — frontend/src/app/Sidebar.jsx

A route is shown when !hidden && can(permission) && (!isHiringManager(user) || HIRING_MANAGER_NAV.has(path)), where HIRING_MANAGER_NAV = {candidates, requisitions, interviews, calendar, help, aiassistant, aistudio, notifications}. A group heading renders only if at least one of its items survives.

11.5 Hiring-manager portal behaviour

  • Dashboard redirects hiring managers to /candidates.
  • Candidates renders <HiringManagerCandidates/> for them; other users get the scoped or unscoped list via seesAllCandidates / scopesToOwnRequisitions.
  • The Favorite button on the candidate profile is hidden for hiring managers.

11.6 In-screen action gating (examples to mirror)

jobs.edit / jobs.delete on Jobs; job_board.create for Post Job; pipeline.edit to move a stage; candidates.create for notes and ATS re-run; candidates.edit for rating, favorite and matching assignment; interviews.create || candidates.create to schedule an interview; offers.create / offers.edit on the offer form; assessments.create|edit|delete; reports.create|delete|export; requisitions.create|edit; department.create|edit; inbox.edit; talent.edit; settings.configure; rbac_users.edit for approvals; tasks.view|edit on Recruiter Hub. Tasks "create" requires can('tasks.create') && ['system_administrator','hr_administrator','recruiter'].includes(user.role_name).


12. Known behaviour to reproduce (or consciously fix)

Mirror these exactly unless you have been told to change them, and record any deviation.

  1. Unauthenticated data routes: GET /email/fetch and GET /inbox/fetch have no auth dependency. GET /jobs/alias is public.
  2. No escalation check on role or bundle edits: a holder of rbac_users.edit can set any tags on any role, including their own, through /roles/update or /roles/matrix/update. A holder of rbac_users.manage can do the same through the bundle endpoints. Only user↔role assignment is subset-checked (§5.5).
  3. POST /users/create without role_id skips the rbac_users.manage check entirely.
  4. Hardcoded ids: signup assigns role_id = 4. Users.get_users, count_users, get_user_by_id, get_user_by_email and get_pending_approvals exclude role_id = 8. Other code resolves roles by name.
  5. Name-based identities are matched case-insensitively and include legacy aliases: manager → hiring manager, admin → admin.
  6. requisitions.manage makes a user an admin for candidate, offer and requisition scoping, not only for requisitions.
  7. No token revocation. A refresh token stays valid for 7 days after sign-out. Permission changes apply server-side on the next request, but the UI updates only after /users/me is refetched (re-login).
  8. Users on a deleted or inactive role can still log in, but they hold zero tags.
  9. Route handlers wrap unexpected exceptions as HTTPException(500, detail=str(e)), and the frontend may present any error as a permissions problem.
  10. Comments in frontend/src/auth/permissions.js and routes.js saying enforcement is "cosmetic, only /users/, /roles/, /permissions/* are enforced" are stale. As the table below shows, almost every route is now guarded server-side.
  11. /email/sync accepts either a JWT whose user holds inbox.edit, or a static CRON_INBOX_SYNC_TOKEN compared in constant time, for the scheduler.

13. Acceptance checks for a faithful mirror

  • The server refuses to boot if the tag enum is not the full modules × actions product.
  • With no role → 403 "User has no role assigned" on any guarded route, while /users/me still returns 200.
  • Deactivating a bundle removes its tags from every role on the very next request, with no re-login.
  • Saving the matrix for role R creates or updates role_R_matrix and sets R.permissions = [that id].
  • Assigning a role that holds a tag the caller lacks → 403, and the message lists the missing tags.
  • A hiring manager with every admin tag is still requisition-scoped.
  • A custom role with requisitions.create alone is not scoped. Adding requisitions.configure scopes it; adding candidates.manage unscopes it.
  • A recruiter without candidates.manage or requisitions.manage sees only jobs where they are a current recruiter, or which they created and which have no recruiters.
  • Task creation by department_head, even when holding tasks.create, → 403.
  • A frontend can(null) is true, and hiring managers see only the 8 locked nav items.
  • The frontend predicate tests in frontend/permissions-scope.test.mjs pass against your implementation.

Appendix A — Every backend route and its guard

AND = require_all=True; OR = require_all=False. Row scoping (§6) and role-name rules (§7) apply on top of these guards. Generated from the @router decorators in backend/*/app.py.

Domain Method Path Required
analytics GET /analytics/kpis/fetch analytics.view
analytics GET /analytics/funnel/fetch analytics.view
analytics GET /analytics/hiring-trend/fetch analytics.view
analytics GET /analytics/source-performance/fetch analytics.view
analytics GET /analytics/applications-per-job/fetch analytics.view
analytics POST /analytics/ask analytics.view
analytics GET /analytics/recruiter-performance/fetch analytics.view
assessments GET /assessments/fetch assessments.view
assessments GET /assessments/counts assessments.view
assessments POST /assessments/create assessments.create
assessments PATCH /assessments/update assessments.edit
assessments DELETE /assessments/delete assessments.delete
assessments POST /assessments/remind assessments.edit
candidate_forms GET /forms/requisition/search requisitions.view OR job_board.create OR jobs.create
candidate_forms GET /forms/requisition/fetch requisitions.view
candidate_forms POST /forms/requisition/create requisitions.create
candidate_forms PATCH /forms/requisition/update requisitions.edit
candidate_forms GET /forms/definitions interviews.view
candidate_forms GET /forms/fetch interviews.view
candidate_forms POST /forms/create interviews.create
candidate_forms PATCH /forms/update interviews.edit
candidate_forms DELETE /forms/delete interviews.delete
department GET /department/fetch department.view
department POST /department/create department.create
department PUT /department/update department.edit
department GET /department/heads/fetch department.create OR department.edit
forget_password POST /users/forget-password PUBLIC
forget_password POST /users/forget-password/verify-code PUBLIC
forget_password POST /users/forget-password/new-password PUBLIC
g_sheet GET /sheet/health PUBLIC
g_sheet GET /sheet/metadata settings.view
g_sheet GET /sheet/tabs settings.view
g_sheet GET /sheet/fetch settings.view
g_sheet POST /sheet/import settings.edit
g_sheet POST /sheet/{tab}/import settings.edit
g_sheet GET /sheet/import/fetch settings.view
g_sheet GET /sheet/form-data/sheets inbox.view OR settings.view
g_sheet GET /sheet/form-data/fetch inbox.view OR settings.view
g_sheet GET /sheet/form-data/counts inbox.view OR settings.view
g_sheet GET /sheet/form-data/count inbox.view OR settings.view
g_sheet GET /sheet/form-data/{record_id} inbox.view OR settings.view
g_sheet PATCH /sheet/form-data/{record_id}/assign-job-post inbox.edit OR settings.edit
g_sheet PATCH /sheet/form-data/{record_id}/processing-state inbox.edit OR settings.edit
g_sheet PATCH /sheet/form-data/{record_id}/duplicate inbox.edit OR settings.edit
g_sheet DELETE /sheet/form-data/{tab}/delete settings.delete
g_sheet POST /sheet/{tab}/append settings.edit
g_sheet PATCH /sheet/{tab}/update settings.edit
g_sheet POST /sheet/{tab}/clear settings.edit
inbox GET /email/fetch PUBLIC
inbox POST /email/sync custom: inbox_sync_caller
inbox GET /email/sync/fetch inbox.view
inbox GET /inbox/fetch PUBLIC
inbox POST /inbox/{record_id}/match inbox.edit
inbox PATCH /inbox/{record_id}/assign-job-post inbox.edit
inbox PATCH /inbox/{record_id}/assign-recruiter inbox.edit
inbox POST /inbox/{record_id}/read inbox.edit
inbox PATCH /inbox/read inbox.edit
inbox PATCH /inbox/read-all inbox.edit
inbox GET /inbox/{record_id}/read-status inbox.edit
inbox GET /inbox/all-applications inbox.view
inbox GET /inbox/all-applications/count inbox.view
inbox GET /inbox/counts inbox.view
inbox GET /inbox/triage inbox.view
inbox PATCH /inbox/triage/{record_id}/override inbox.edit
inbox PATCH /inbox/{record_id}/processing-state inbox.edit
inbox PATCH /inbox/{record_id}/duplicate inbox.edit
inbox POST /email/send inbox.edit
inbox POST /email/reply inbox.edit
inbox POST /inbox/rescan-on-hold inbox.edit
inbox GET /inbox/rescan-on-hold inbox.view
interview POST /interview/{interview_id}/calendar-event interviews.create
interview PATCH /interview/{interview_id}/calendar-event/reschedule interviews.edit
interview POST /interview/{interview_id}/calendar-event/cancel interviews.edit
job GET /jobs/alias PUBLIC
job POST /candidate/create/candidate candidates.create
job GET /candidate/fetch/users candidates.view
job GET /candidate/fetch/users/count candidates.view
job POST /candidate/cv_upload candidates.create
job POST /candidate/cv-bank/upload candidates.create
job GET /candidate/cv-bank/fetch candidates.view
job POST /candidate/cv-bank/score candidates.create
job GET /candidate/cv-bank/suggestions candidates.view
job GET /candidate/cv-bank/file candidates.view
job DELETE /candidate/cv-bank/delete candidates.delete
job GET /candidate/matching/fetch candidates.view
job GET /candidate/matching/fetch_by_id candidates.view
job POST /candidate/matching/assign candidates.edit
job POST /candidate/inbox-match candidates.edit
job POST /job/post-job job_board.create
job POST /job/image/upload job_board.create OR jobs.edit
job GET /job/image/fetch jobs.view OR job_board.view
job POST /job/assist-field job_board.create OR jobs.edit
job GET /job/buffer/channels job_board.view
job POST /candidate/score candidates.create
job POST /candidate/score_inbox candidates.create
job POST /candidate/ats-rerun candidates.create
job GET /candidate/scored/fetch candidates.view
job GET /job/fetch job_board.view OR candidates.view OR talent.view
job GET /job/stats/fetch jobs.view OR pipeline.view
job GET /job/departments/fetch job_board.view OR candidates.view OR talent.view OR jobs.view
job GET /jobs/requisition-statuses/fetch jobs.view
job GET /jobs/status-history/fetch jobs.view
job GET /jobs/fetch jobs.view
job GET /jobs/export jobs.export
job GET /candidate/fetch_by_id candidates.view
job GET /candidate/manager/fetch candidates.view
job GET /candidate/fetch candidates.view
job GET /candidate/applications/fetch candidates.view
job PATCH /candidate/update candidates.edit
job GET /candidate/history/fetch candidates.view
job GET /interview/fetch interviews.view OR candidates.view
job POST /interview/create interviews.create OR candidates.create
job PATCH /interview/update interviews.edit OR candidates.edit
job GET /notes/fetch candidates.view
job POST /notes/create candidates.create
job PATCH /notes/update candidates.edit
job GET /activity/fetch candidates.view
job POST /activity/create candidates.create
job GET /feedback/fetch candidates.view
job POST /feedback/create candidates.create
job PATCH /feedback/update candidates.edit
job PATCH /candidate/stage pipeline.edit
job GET /pipeline/candidates/fetch pipeline.view
job GET /pipeline/candidate/score/fetch pipeline.view
job GET /pipeline/transitions/fetch pipeline.view
job GET /job/assignments/fetch jobs.view
job POST /job/assignments/create jobs.edit
job GET /candidate/assignments/fetch candidates.view
job POST /candidate/assignments/create candidates.edit
job GET /job/costs/fetch jobs.view
job GET /job/costs/source-channels/fetch jobs.view
job POST /job/costs/create jobs.edit
job PATCH /jobs/update jobs.edit
job DELETE /jobs/delete jobs.delete
job PATCH /jobs/status jobs.edit
job GET /feedback/templates/fetch candidates.view
job POST /feedback/templates/create candidates.create
job PATCH /feedback/templates/update candidates.edit
job DELETE /feedback/templates/delete candidates.delete
job GET /documents/download candidates.view
notifications POST /users/confirm-email PUBLIC
notifications POST /users/confirm-email/resend PUBLIC
notifications GET /notifications/fetch any authenticated user
notifications POST /notifications/{record_id}/read any authenticated user
notifications POST /notifications/read-all any authenticated user
notifications DELETE /notifications/delete any authenticated user
offer GET /offers/fetch offers.view
offer POST /offers/create offers.create
offer PATCH /offers/update offers.edit
offer POST /offers/issue offers.approve
offer GET /offers/jobs/candidates/lists offers.view
offer POST /offers/jobs/sent offers.create
org_settings GET /org-settings/fetch settings.view
org_settings PUT /org-settings/update settings.configure
org_settings GET /org-settings/exclude-university/fetch settings.view
org_settings POST /org-settings/exclude-university/create settings.configure
org_settings POST /org-settings/exclude-university/create-batch settings.configure
org_settings PATCH /org-settings/exclude-university/update settings.configure
org_settings DELETE /org-settings/exclude-university/delete settings.configure
org_settings GET /org-settings/exclude-company/fetch settings.view
org_settings POST /org-settings/exclude-company/create settings.configure
org_settings POST /org-settings/exclude-company/create-batch settings.configure
org_settings PATCH /org-settings/exclude-company/update settings.configure
org_settings DELETE /org-settings/exclude-company/delete settings.configure
reports GET /reports/fetch reports.view
reports POST /reports/create reports.create
reports PATCH /reports/update reports.edit
reports DELETE /reports/delete reports.delete
reports POST /reports/run reports.view
reports GET /reports/export reports.export
reports GET /reports/runs/fetch reports.view
role GET /roles/fetch rbac_users.view
role POST /roles/create rbac_users.create
role PUT /roles/update rbac_users.edit
role DELETE /roles/delete rbac_users.delete
role GET /permissions/fetch rbac_users.view
role POST /permissions/create rbac_users.manage
role PUT /permissions/update rbac_users.manage
role PUT /roles/matrix/update rbac_users.edit
role PUT /roles/permission-tags/update rbac_users.manage
role GET /permission-tags/fetch rbac_users.view
s3 GET /s3/health PUBLIC
s3 POST /s3/upload candidates.create OR settings.edit
s3 GET /s3/url candidates.view OR settings.view
s3 GET /s3/open candidates.view OR settings.view OR inbox.view
s3 GET /s3/download candidates.view OR settings.view OR inbox.view
s3 POST /s3/delete candidates.delete OR settings.delete
saved_search GET /saved-searches/fetch any authenticated user
saved_search POST /saved-searches/create any authenticated user
saved_search PATCH /saved-searches/update any authenticated user
saved_search DELETE /saved-searches/delete any authenticated user
search GET /search/fetch jobs.view OR candidates.view
talent POST /talent/runs/start talent.create
talent GET /talent/runs/status talent.view
talent GET /talent/account talent.view
talent GET /talent/runs/fetch talent.view
talent GET /talent/profiles/fetch talent.view
talent GET /talent/profiles/fetch_by_id talent.view
talent PATCH /talent/profiles/outreach talent.edit
talent DELETE /talent/profiles/delete talent.delete
tasks GET /tasks/fetch tasks.view
tasks GET /tasks/assignees/fetch tasks.view
tasks POST /tasks/create tasks.create
tasks PATCH /tasks/update tasks.edit
tasks DELETE /tasks/delete tasks.delete
users POST /users/login PUBLIC
users POST /users/signup PUBLIC
users POST /users/refresh PUBLIC
users GET /users/me any authenticated user
users POST /users/create rbac_users.create
users GET /users/pending-approvals settings.view
users PUT /users/approve rbac_users.edit
users GET /users/fetch rbac_users.view
users PUT /users/update rbac_users.edit
users PUT /users/assign-role rbac_users.edit
users PUT /users/remove-role rbac_users.edit
users DELETE /users/delete rbac_users.delete
users GET /managers/fetch jobs.view OR candidates.view OR job_board.create