HR-ATS-Portal/RBAC_CONTEXT_PROMPT.md

836 lines
47 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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_tags` — [backend/role/models.py](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](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](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](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 `access`**401** `"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_active`**401** `"User is inactive or does not exist"`.
4. `!is_approved`**403** `"Your Approval is at Pending"`.
5. `permissions = resolve_tags(user.role)`.
6. Return `serialize_user(user, with_permissions=True, permissions=...)`:
```json
{ "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 None`**403** `"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](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](backend/users/permissions.py). The frontend mirror is
in [frontend/src/auth/permissions.js](frontend/src/auth/permissions.js) and must stay
byte-for-byte equivalent in logic.
```python
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](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](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](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](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](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](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](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/app.py), [backend/role/views.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](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:
```sql
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](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](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](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](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](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 |