47 KiB
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.actiontags), - 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_tags — backend/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 users — backend/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:
PermissionTagis astrEnum listing every combination explicitly. At import time,_assert_vocabulary_complete()raisesRuntimeError("PermissionTag vocabulary drift: missing=[...] extra=[...]")unless the enum equals the full modules × actions cross-product. The server will not boot with a partial vocabulary.- Always serialise with
.value.f"{PermissionTag.X}"renders the enum repr. - DB rows are seeded idempotently (
ON CONFLICT (tag_name) DO NOTHING) by manual SQL migrations:001(first 13 modules = 104 tags),004tasks,007talent,019requisitions,038department. Comments in the code that say "104" or "120" tags are stale. The real total is 136. - Two actions carry special meaning beyond "may use the screen":
*.manageonrequisitions,candidatesandoffersremoves row scoping. See §6.requisitions.configureis 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:
- HTTP Bearer header is missing → FastAPI
HTTPBearerreturns 401"Not authenticated"(FastAPI 0.136.1 behaviour). - Decode fails or type is not
access→ 401"Could not validate credentials"withWWW-Authenticate: Bearer. - Load the user by
subviaUsers.get_user_by_id. That query excludesrole_id = 8(candidate). If the user is missing,is_deleted, or!is_active→ 401"User is inactive or does not exist". !is_approved→ 403"Your Approval is at Pending".permissions = resolve_tags(user.role).- 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:
current_user.role_id is None→ 403"User has no role assigned". This check runs before any tag check.has_permission(granted, *tags, require_all):require_all=True→ required ⊆ granted (AND)require_all=False→ required ∩ granted ≠ ∅ (OR)
- 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"
- one tag, AND:
- On success it returns
current_user, and handlers use it ascurrent_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". Thenis_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): hardcodesrole_id = 4andis_approved = false, lands withis_active = false, and emails a confirmation link that setsis_active. An admin must then approve the account. - Admin create (
POST /users/create, needsrbac_users.create): setsis_approved = true.is_activecomes from the payload (default true). The escalation check in §5.5 applies. - Approval queue:
GET /users/pending-approvals(needssettings.view) lists users who are active, not approved, not deleted and not role 8.PUT /users/approve?record_id=(needsrbac_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 wherehiring_manager_id = user, unioned with jobs whoserequisition_idpoints at a non-deleted requisition withcreated_by = user.JobPosts.ids_for_creator(user_id, created_by=False):created_by=Truereturns jobs withcreated_by = user.- Otherwise it returns jobs where the user is in
current_recruiter_idsor iscurrent_recruiter_id, or (the job has no recruiters andcreated_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):
- Add the module to
PermissionModuleand all 8PermissionTagmembers (the startup assertion enforces this). - Add the module to
MODULESinfrontend/src/auth/permissions.js. - Write a manual SQL migration that inserts the 8 tags (
ON CONFLICT DO NOTHING), creates a<module>_managementbundle withjsonb_agg(id ORDER BY id)over that module, and appends the bundle id to the chosen roles guarded byNOT (permissions @> jsonb_build_array(id)). - Guard the routes with
require_permission(PermissionTag.<MODULE>_<ACTION>). - Add the route to
frontend/src/app/routes.jswithpermission: '<module>.view'. - 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 onrbac_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/fetchin 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 withoutrbac_users.edit. Save is enabled only when the draft differs; it maps names to ids and callsPUT /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_namesfrom 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 inlib/tokenStore.GET /users/meruns as a TanStack Query (staleTime5 min,retry: false) whenever an access token exists. Its result is merged into the stored session so a page reload already haspermissions. statusisanonymous(no token),error(me failed),authenticated(me data or cached permissions present), orloading.can(tag)comes frommakeCan(permissions): a null or undefined tag is always allowed, otherwise it is set membership.- Sign-in invalidates the
mequery 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
Dashboardredirects hiring managers to/candidates.Candidatesrenders<HiringManagerCandidates/>for them; other users get the scoped or unscoped list viaseesAllCandidates/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.
- Unauthenticated data routes:
GET /email/fetchandGET /inbox/fetchhave no auth dependency.GET /jobs/aliasis public. - No escalation check on role or bundle edits: a holder of
rbac_users.editcan set any tags on any role, including their own, through/roles/updateor/roles/matrix/update. A holder ofrbac_users.managecan do the same through the bundle endpoints. Only user↔role assignment is subset-checked (§5.5). POST /users/createwithoutrole_idskips therbac_users.managecheck entirely.- Hardcoded ids: signup assigns
role_id = 4.Users.get_users,count_users,get_user_by_id,get_user_by_emailandget_pending_approvalsexcluderole_id = 8. Other code resolves roles by name. - Name-based identities are matched case-insensitively and include legacy aliases:
manager→ hiring manager,admin→ admin. requisitions.managemakes a user an admin for candidate, offer and requisition scoping, not only for requisitions.- 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/meis refetched (re-login). - Users on a deleted or inactive role can still log in, but they hold zero tags.
- Route handlers wrap unexpected exceptions as
HTTPException(500, detail=str(e)), and the frontend may present any error as a permissions problem. - Comments in
frontend/src/auth/permissions.jsandroutes.jssaying enforcement is "cosmetic, only /users/, /roles/, /permissions/* are enforced" are stale. As the table below shows, almost every route is now guarded server-side. /email/syncaccepts either a JWT whose user holdsinbox.edit, or a staticCRON_INBOX_SYNC_TOKENcompared 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/mestill 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_matrixand setsR.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.createalone is not scoped. Addingrequisitions.configurescopes it; addingcandidates.manageunscopes it. - A recruiter without
candidates.manageorrequisitions.managesees only jobs where they are a current recruiter, or which they created and which have no recruiters. - Task creation by
department_head, even when holdingtasks.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 |