836 lines
47 KiB
Markdown
836 lines
47 KiB
Markdown
# 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 |
|