From c5d603c621153b58b180ee947fc15c4f41ba3da4 Mon Sep 17 00:00:00 2001
From: "ahmed.mujtaba"
Date: Fri, 4 Sep 2026 17:58:19 +0500
Subject: [PATCH] approved permission
---
backend/job/app.py | 22 +++++---
backend/job/candidate/views.py | 47 +++++++++--------
backend/job/job_post/models.py | 32 ++++++++++--
backend/job/job_post/views.py | 27 +++++++++-
backend/job/notes/views.py | 14 ++---
backend/users/permissions.py | 19 +++++++
frontend/permissions-scope.test.mjs | 81 +++++++++++++++++++++++++++++
frontend/src/auth/permissions.js | 12 +++++
frontend/src/screens/Candidates.jsx | 21 +++++---
frontend/src/screens/Rbac.jsx | 17 +++++-
10 files changed, 240 insertions(+), 52 deletions(-)
create mode 100644 frontend/permissions-scope.test.mjs
diff --git a/backend/job/app.py b/backend/job/app.py
index 3920218..6bfc255 100644
--- a/backend/job/app.py
+++ b/backend/job/app.py
@@ -736,6 +736,7 @@ async def fetch_job_posts(
skip=skip,
ids=id_list,
active_only=active_only,
+ current_user=current_user,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
@@ -865,7 +866,7 @@ async def fetch_jobs(
data,total=await service.fetch_jobs(
search=search,department=department,requisition_status=requisition_status,
employment_type=employment_type,hiring_manager_id=hiring_manager_id,
- top=top,skip=skip,active_only=active_only,
+ top=top,skip=skip,active_only=active_only,current_user=current_user,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
@@ -891,7 +892,7 @@ async def export_jobs(
data,_=await service.fetch_jobs(
search=search,department=department,requisition_status=requisition_status,
employment_type=employment_type,hiring_manager_id=hiring_manager_id,
- top=None,skip=0,active_only=active_only,
+ top=None,skip=0,active_only=active_only,current_user=current_user,
)
filename=f"jobs-export-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.xlsx"
return Response(
@@ -947,18 +948,19 @@ async def fetch_candidate(
assigned_job_post_id:UUID=Query(None),
offset:int=Query(0,ge=0),
search:str=Query(None),
+ created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=CandidateView(session=session)
data=await service.get_candidate(
- user_id=user_id,limit=limit,offset=offset,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,
+ user_id=user_id,limit=limit,offset=offset,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
)
total=await service.count_candidates(
- user_id=user_id,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,
+ user_id=user_id,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
) if isinstance(data,list) else 1
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
@@ -971,12 +973,13 @@ async def fetch_candidate(
async def update_candidate(
user_id:str=Query(...),
payload:CandidateUpdate=...,
+ created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=CandidateView(session=session)
- data=await service.update_candidate(user_id,payload.model_dump(exclude_unset=True),current_user)
+ data=await service.update_candidate(user_id,payload.model_dump(exclude_unset=True),current_user,created_by=created_by)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
@@ -1074,12 +1077,13 @@ async def update_interview(
async def fetch_notes(
note_id:str=Query(None),
user_id:str=Query(None),
+ created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Note(session=session)
- data=await service.get_note(note_id=note_id,user_id=user_id,current_user=current_user)
+ data=await service.get_note(note_id=note_id,user_id=user_id,current_user=current_user,created_by=created_by)
total=1 if isinstance(data,dict) else len(data)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
@@ -1091,12 +1095,13 @@ async def fetch_notes(
@router.post("/notes/create")
async def create_note(
payload:NoteCreate,
+ created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
service=Note(session=session)
- data=await service.create_note(payload.model_dump(exclude_unset=True),current_user)
+ data=await service.create_note(payload.model_dump(exclude_unset=True),current_user,created_by=created_by)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
@@ -1108,12 +1113,13 @@ async def create_note(
async def update_note(
note_id:str=Query(...),
payload:NoteUpdate=...,
+ created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=Note(session=session)
- data=await service.update_note(note_id,payload.model_dump(exclude_unset=True),current_user)
+ data=await service.update_note(note_id,payload.model_dump(exclude_unset=True),current_user,created_by=created_by)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py
index 8a7e1a8..8e08abf 100644
--- a/backend/job/candidate/views.py
+++ b/backend/job/candidate/views.py
@@ -32,7 +32,7 @@ from job.history.views import HistoryRecorder
from job.notes.serializers import serialize_note
from job.candidate.plugins import extract_candidate_email
from users.models import Users
-from users.permissions import is_hiring_manager,sees_all_candidates
+from users.permissions import is_hiring_manager,sees_all_candidates,scopes_to_own_requisitions
from employment_agent.plugins import parse_phone
load_dotenv()
@@ -78,23 +78,26 @@ async def job_id_for_application(session,inbox_id=None,manual_id=None):
return None,None
-async def owned_job_ids_for_candidate_scope(session,current_user):
+async def owned_job_ids_for_candidate_scope(session,current_user,created_by=False):
"""Job ids this user may see, or None when the list is unscoped.
- Hiring manager → requisition / assigned-manager jobs.
- candidates.manage or admin → None (all applications).
- Otherwise → job_posts.created_by = this user. Never role_id.
+ requisitions.configure (or hiring-manager portal) → jobs on their requisitions
+ / assigned hiring_manager_id. Recruiter assignment on the job does not hide
+ those candidates. candidates.manage or admin → None (all applications).
+ Otherwise → current_recruiter_id when set, else created_by. Never role_id.
+ created_by=True skips current_recruiter_id and matches job_posts.created_by
+ to the session user (ignored when the user is requisition-scoped).
"""
- if is_hiring_manager(current_user):
+ if scopes_to_own_requisitions(current_user):
return await JobPosts.ids_for_manager(session,current_user.get("id"))
if sees_all_candidates(current_user):
return None
- return await JobPosts.ids_for_creator(session,current_user.get("id"))
+ return await JobPosts.ids_for_creator(session,current_user.get("id"),created_by=created_by)
-async def job_post_ids_for_candidate_list(session,current_user,assigned_job_post_id=None):
+async def job_post_ids_for_candidate_list(session,current_user,assigned_job_post_id=None,created_by=False):
"""None = unscoped list. [] = nothing visible. Else UUID list for the query."""
- owned=await owned_job_ids_for_candidate_scope(session,current_user)
+ owned=await owned_job_ids_for_candidate_scope(session,current_user,created_by=created_by)
requested=JobPosts._as_uuid(assigned_job_post_id) if assigned_job_post_id is not None else None
if owned is None:
return [requested] if requested else None
@@ -104,18 +107,18 @@ async def job_post_ids_for_candidate_list(session,current_user,assigned_job_post
def _scope_detail(current_user):
- if is_hiring_manager(current_user):
+ if scopes_to_own_requisitions(current_user):
return MANAGER_SCOPE_DETAIL
return CREATOR_SCOPE_DETAIL
async def assert_manager_candidate_access(
- session,current_user,*,user_id=None,job_post_id=None,inbox_id=None,manual_id=None,
+ session,current_user,*,user_id=None,job_post_id=None,inbox_id=None,manual_id=None,created_by=False,
):
- """Row access: hiring-manager jobs, unscoped (manage/admin), or jobs this user created."""
- if not is_hiring_manager(current_user) and sees_all_candidates(current_user):
+ """Row access: requisition-owned jobs, unscoped (manage/admin), or jobs this user created."""
+ if sees_all_candidates(current_user) and not scopes_to_own_requisitions(current_user):
return
- owned=await owned_job_ids_for_candidate_scope(session,current_user)
+ owned=await owned_job_ids_for_candidate_scope(session,current_user,created_by=created_by)
owned=set(owned or [])
detail=_scope_detail(current_user)
if not owned:
@@ -819,19 +822,19 @@ class CandidateView:
cap=max(1,int(limit or 50))
return merged[start:start+cap],total
- async def get_candidate(self,user_id=None,limit=10,offset=0,search=None,current_user=None,assigned_job_post_id=None):
+ async def get_candidate(self,user_id=None,limit=10,offset=0,search=None,current_user=None,assigned_job_post_id=None,created_by=False):
try:
if not user_id and is_hiring_manager(current_user):
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
if user_id:
await assert_manager_candidate_access(
- self.session,current_user,user_id=user_id,
+ self.session,current_user,user_id=user_id,created_by=created_by,
)
detail=bool(user_id)
list_job_ids=None
if not detail:
list_job_ids=await job_post_ids_for_candidate_list(
- self.session,current_user,assigned_job_post_id=assigned_job_post_id,
+ self.session,current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
)
if list_job_ids is not None and not list_job_ids:
return []
@@ -841,7 +844,7 @@ class CandidateView:
)
if detail:
records=rows if isinstance(rows,list) else ([rows] if rows else [])
- owned=await owned_job_ids_for_candidate_scope(self.session,current_user)
+ owned=await owned_job_ids_for_candidate_scope(self.session,current_user,created_by=created_by)
if records and owned is not None:
owned_set=set(owned)
kept=[]
@@ -947,12 +950,12 @@ class CandidateView:
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
- async def count_candidates(self,user_id=None,search=None,current_user=None,assigned_job_post_id=None):
+ async def count_candidates(self,user_id=None,search=None,current_user=None,assigned_job_post_id=None,created_by=False):
try:
job_post_ids=None
if not user_id:
job_post_ids=await job_post_ids_for_candidate_list(
- self.session,current_user,assigned_job_post_id=assigned_job_post_id,
+ self.session,current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
)
return await Inbox.count_candidate_profiles(
session=self.session,user_id=user_id,search=search,job_post_ids=job_post_ids,
@@ -962,11 +965,11 @@ class CandidateView:
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
- async def update_candidate(self,user_id,payload,current_user=None):
+ async def update_candidate(self,user_id,payload,current_user=None,created_by=False):
try:
if not user_id:
raise HTTPException(status_code=400,detail="user_id is required")
- await assert_manager_candidate_access(self.session,current_user,user_id=user_id)
+ await assert_manager_candidate_access(self.session,current_user,user_id=user_id,created_by=created_by)
fields={k:v for k,v in (payload or {}).items() if k in ("favorite","rating") and v is not None}
if not fields:
raise HTTPException(status_code=400,detail="favorite or rating is required")
diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py
index 086d24e..a8ca4fe 100644
--- a/backend/job/job_post/models.py
+++ b/backend/job/job_post/models.py
@@ -2,7 +2,7 @@ import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Optional
-from sqlalchemy import DateTime, JSON, Index, String, case, cast, func, or_, union_all
+from sqlalchemy import DateTime, JSON, Index, String, and_, case, cast, func, or_, union_all
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from sqlmodel import Field, Relationship, SQLModel, select
@@ -209,6 +209,7 @@ class JobPosts(SQLModel, table=True):
requisition_status: str | None = None,
employment_type: str | None = None,
hiring_manager_id: uuid.UUID | None = None,
+ restrict_ids: list | None = None,
):
if ids:
rows = await cls.get_by_ids(session, ids, active_only=active_only)
@@ -219,6 +220,15 @@ class JobPosts(SQLModel, table=True):
statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712
elif not include_deleted:
statement = statement.where(cls.is_deleted == False) # noqa: E712
+ if restrict_ids is not None:
+ uids = []
+ for raw in restrict_ids:
+ uid = raw if isinstance(raw, uuid.UUID) else cls._as_uuid(raw)
+ if uid is not None:
+ uids.append(uid)
+ if not uids:
+ return [], 0
+ statement = statement.where(cls.id.in_(uids))
if search:
like = f"%{search.strip()}%"
statement = statement.where(
@@ -482,15 +492,27 @@ class JobPosts(SQLModel, table=True):
return out
@classmethod
- async def ids_for_creator(cls, session: AsyncSession, user_id):
- """Job posts this user created. Recruiter Candidates scope (when they
- lack candidates.manage) follows job_posts.created_by, not role_id."""
+ async def ids_for_creator(cls, session: AsyncSession, user_id, created_by=False):
+ """Jobs this recruiter should see on Candidates (when they lack
+ candidates.manage). created_by=True → created_by = session user only.
+ Otherwise: current_recruiter_id when set, else created_by."""
uid = cls._as_uuid(user_id)
if uid is None:
return []
+ if created_by:
+ result = await session.execute(
+ select(cls.id).where(
+ cls.created_by == uid,
+ cls.is_deleted == False, # noqa: E712
+ )
+ )
+ return list(result.scalars().all())
result = await session.execute(
select(cls.id).where(
- cls.created_by == uid,
+ or_(
+ and_(cls.current_recruiter_id.is_not(None), cls.current_recruiter_id == uid),
+ and_(cls.current_recruiter_id.is_(None), cls.created_by == uid),
+ ),
cls.is_deleted == False, # noqa: E712
)
)
diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py
index f3c29bf..de109e5 100644
--- a/backend/job/job_post/views.py
+++ b/backend/job/job_post/views.py
@@ -223,7 +223,24 @@ class JobPost:
except (httpx.HTTPError,BufferError,RuntimeError) as e:
raise HTTPException(status_code=502,detail="Failed to list Buffer channels") from e
- async def fetch_job_posts(self,search=None,top=None,skip=0,ids=None,active_only=True):
+ async def _restrict_ids_for_requisition_scope(self,current_user):
+ """None = unscoped. Empty list = no jobs. Else owned job-post ids."""
+ from users.permissions import scopes_to_own_requisitions
+ if not scopes_to_own_requisitions(current_user):
+ return None
+ return await JobPosts.ids_for_manager(self.session,current_user.get("id") if current_user else None)
+
+ async def fetch_job_posts(self,search=None,top=None,skip=0,ids=None,active_only=True,current_user=None):
+ restrict=await self._restrict_ids_for_requisition_scope(current_user)
+ if restrict is not None:
+ owned={str(i) for i in restrict}
+ if ids:
+ ids=[i for i in ids if str(i) in owned]
+ if not ids:
+ return [],0
+ restrict=None
+ elif not restrict:
+ return [],0
rows,total=await JobPosts.fetch_job_posts(
self.session,
search=search,
@@ -231,6 +248,7 @@ class JobPost:
skip=skip,
ids=ids,
active_only=active_only,
+ restrict_ids=restrict,
)
return [serialize_job_post(r) for r in rows],total
@@ -274,7 +292,11 @@ class JobPost:
]
async def fetch_jobs(self,search=None,department=None,requisition_status=None,
- employment_type=None,hiring_manager_id=None,top=None,skip=0,active_only=True):
+ employment_type=None,hiring_manager_id=None,top=None,skip=0,active_only=True,
+ current_user=None):
+ restrict=await self._restrict_ids_for_requisition_scope(current_user)
+ if restrict is not None and not restrict:
+ return [],0
hm_uid=None
if hiring_manager_id:
hm_uid=JobPosts._as_uuid(hiring_manager_id)
@@ -284,6 +306,7 @@ class JobPost:
self.session,search=search,top=top,skip=skip,active_only=active_only,
department=department,requisition_status=requisition_status,
employment_type=employment_type,hiring_manager_id=hm_uid,
+ restrict_ids=restrict,
)
names=await Users.names_by_ids(
self.session,
diff --git a/backend/job/notes/views.py b/backend/job/notes/views.py
index b9a5b3d..459a19c 100644
--- a/backend/job/notes/views.py
+++ b/backend/job/notes/views.py
@@ -15,13 +15,13 @@ class Note:
async def _load(self,record_id):
return await Notes.get_note_by_id(self.session,record_id)
- async def get_note(self,note_id=None,user_id=None,current_user=None):
+ async def get_note(self,note_id=None,user_id=None,current_user=None,created_by=False):
if note_id:
row=await self._load(note_id)
if not row:
raise HTTPException(status_code=404,detail="Note not found")
await assert_manager_candidate_access(
- self.session,current_user,user_id=row.user_id,
+ self.session,current_user,user_id=row.user_id,created_by=created_by,
)
return serialize_note(row)
if not user_id:
@@ -29,11 +29,11 @@ class Note:
uid=Notes._as_uuid(user_id)
if uid is None:
raise HTTPException(status_code=400,detail="Invalid user_id")
- await assert_manager_candidate_access(self.session,current_user,user_id=uid)
+ await assert_manager_candidate_access(self.session,current_user,user_id=uid,created_by=created_by)
rows=await Notes.get_notes_by_user(self.session,uid)
return [serialize_note(r) for r in rows]
- async def create_note(self,payload,current_user):
+ async def create_note(self,payload,current_user,created_by=False):
fields={
"note":payload.get("note") or "",
"user_id":payload.get("user_id"),
@@ -42,7 +42,7 @@ class Note:
if not fields["user_id"]:
raise HTTPException(status_code=400,detail="user_id is required")
await assert_manager_candidate_access(
- self.session,current_user,user_id=fields["user_id"],
+ self.session,current_user,user_id=fields["user_id"],created_by=created_by,
)
row=await Notes.insert_note(self.session,fields)
await HistoryRecorder(self.session).record(
@@ -54,7 +54,7 @@ class Note:
row=await self._load(row.id)
return serialize_note(row)
- async def update_note(self,note_id,payload,current_user=None):
+ async def update_note(self,note_id,payload,current_user=None,created_by=False):
fields={k:v for k,v in payload.items() if v is not None and k in ("note",)}
if not fields:
raise HTTPException(status_code=400,detail="No fields to update")
@@ -62,7 +62,7 @@ class Note:
if not before:
raise HTTPException(status_code=404,detail="Note not found")
await assert_manager_candidate_access(
- self.session,current_user,user_id=before.user_id,
+ self.session,current_user,user_id=before.user_id,created_by=created_by,
)
old_note=before.note or ""
row=await Notes.update_note(self.session,note_id,fields)
diff --git a/backend/users/permissions.py b/backend/users/permissions.py
index 83e26bc..56d83d5 100644
--- a/backend/users/permissions.py
+++ b/backend/users/permissions.py
@@ -247,6 +247,25 @@ def sees_all_candidates(current_user: dict | None) -> bool:
return PermissionTag.CANDIDATES_MANAGE.value in granted
+def scopes_to_own_requisitions(current_user: dict | None) -> bool:
+ """Jobs and candidates limited to requisitions this user created (or is assigned).
+
+ Opt-in from Access Control: tick Requisitions → Configure
+ (`requisitions.configure`). That is independent of Create (who may open a
+ requisition) and of Manage (which already means admin and unscopes).
+
+ Hiring-manager portal roles still use this scope so the locked sidebar
+ keeps a matching candidate list. candidates.manage / admin still see every
+ job. Do not key custom roles off a name such as AI_TEAM_MANAGER.
+ """
+ if is_hiring_manager(current_user):
+ return True
+ if is_admin(current_user) or sees_all_candidates(current_user):
+ return False
+ granted = (current_user or {}).get("permissions") or []
+ return PermissionTag.REQUISITIONS_CONFIGURE.value in granted
+
+
def has_permission(
granted: set[str] | list[str] | tuple[str, ...],
*required: PermissionTag,
diff --git a/frontend/permissions-scope.test.mjs b/frontend/permissions-scope.test.mjs
new file mode 100644
index 0000000..45890f8
--- /dev/null
+++ b/frontend/permissions-scope.test.mjs
@@ -0,0 +1,81 @@
+/**
+ * scopesToOwnRequisitions — mirrors backend users.permissions.scopes_to_own_requisitions.
+ *
+ * node permissions-scope.test.mjs
+ */
+import {
+ isAdmin,
+ isHiringManager,
+ seesAllCandidates,
+ scopesToOwnRequisitions,
+} from './src/auth/permissions.js'
+
+let failed = 0
+function ok(name, cond, extra) {
+ if (cond) {
+ console.log(`ok ${name}`)
+ if (extra) console.log(` ${extra}`)
+ } else {
+ failed += 1
+ console.log(`FAIL ${name}`)
+ if (extra) console.log(` ${extra}`)
+ }
+}
+
+const recruiter = {
+ id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
+ role_name: 'recruiter',
+ permissions: ['candidates.view', 'jobs.view'],
+}
+ok('recruiter is not requisition-scoped', !scopesToOwnRequisitions(recruiter))
+ok('recruiter does not see all candidates', !seesAllCandidates(recruiter))
+
+const custom = {
+ ...recruiter,
+ role_name: 'AI_TEAM_MANAGER',
+ permissions: ['candidates.view', 'jobs.view', 'requisitions.create'],
+}
+ok('requisitions.create alone does not scope jobs/candidates', !scopesToOwnRequisitions(custom))
+ok('custom role is not hiring-manager portal', !isHiringManager(custom))
+ok('custom role is not admin', !isAdmin(custom))
+
+ok(
+ 'Access Control requisitions.configure enables the scope',
+ scopesToOwnRequisitions({
+ ...custom,
+ permissions: ['candidates.view', 'jobs.view', 'requisitions.create', 'requisitions.configure'],
+ }),
+)
+
+const manage = {
+ ...custom,
+ permissions: ['candidates.view', 'candidates.manage', 'requisitions.configure'],
+}
+ok(
+ 'candidates.manage wins over requisitions.configure',
+ !scopesToOwnRequisitions(manage) && seesAllCandidates(manage),
+)
+
+ok(
+ 'admin is not requisition-scoped',
+ !scopesToOwnRequisitions({ role_name: 'admin', permissions: ['requisitions.create'] }) &&
+ isAdmin({ role_name: 'admin' }),
+)
+
+ok(
+ 'hiring_manager stays portal-locked and requisition-scoped',
+ isHiringManager({ role_name: 'hiring_manager' }) &&
+ scopesToOwnRequisitions({ role_name: 'hiring_manager', permissions: ['candidates.view'] }),
+)
+
+ok(
+ 'requisitions.manage is admin, not this scope',
+ isAdmin({ role_name: 'ops_lead', permissions: ['requisitions.manage'] }) &&
+ !scopesToOwnRequisitions({ role_name: 'ops_lead', permissions: ['requisitions.manage'] }),
+)
+
+if (failed) {
+ console.log(`\n${failed} failed`)
+ process.exit(1)
+}
+console.log('\nall passed')
diff --git a/frontend/src/auth/permissions.js b/frontend/src/auth/permissions.js
index 095a912..c42fe03 100644
--- a/frontend/src/auth/permissions.js
+++ b/frontend/src/auth/permissions.js
@@ -62,3 +62,15 @@ export function seesAllCandidates(user) {
if (isAdmin(user)) return true
return (user?.permissions || []).includes('candidates.manage')
}
+
+/** Jobs/candidates limited to requisitions this user created (or is assigned).
+
+ Matches backend `scopes_to_own_requisitions`. Custom roles opt in from
+ Access Control by ticking Requisitions → Configure (`requisitions.configure`),
+ not Create. `requisitions.manage` already means admin. Do not expand
+ `isHiringManager` for this; that helper still locks the sidebar. */
+export function scopesToOwnRequisitions(user) {
+ if (isHiringManager(user)) return true
+ if (isAdmin(user) || seesAllCandidates(user)) return false
+ return (user?.permissions || []).includes('requisitions.configure')
+}
diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx
index e44c395..b89b39b 100644
--- a/frontend/src/screens/Candidates.jsx
+++ b/frontend/src/screens/Candidates.jsx
@@ -4,8 +4,10 @@
Recruiter rows come from GET /candidate/fetch (inbox + manual), one row
per application, so score / stage / job / recruiter have a source.
Without candidates.manage the server scopes that list to jobs the user
- created (job_posts.created_by). Hiring managers use GET
- /candidate/manager/fetch (jobs on their requisitions). Adding a candidate
+ owns as recruiter (or created). Tick Requisitions → Configure in Access
+ Control to see candidates on jobs opened from that user's requisitions;
+ recruiter assignment on the job does not hide them. Hiring managers use GET
+ /candidate/manager/fetch (same job chain). Adding a candidate
still goes through CV Import or the Add Candidate modal — both run the CV
through persisted ATS scoring.
============================================================ */
@@ -20,7 +22,7 @@ import PageHeader from '../ui/PageHeader'
import { Avatar, Badge, EmptyState, FieldError, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
-import { isHiringManager, seesAllCandidates } from '../auth/permissions'
+import { isHiringManager, seesAllCandidates, scopesToOwnRequisitions } from '../auth/permissions'
import CandidateProfile from './CandidateProfile'
import { useJobTitles } from './ScoredCandidateProfile'
import { qk } from '../lib/queryKeys'
@@ -297,6 +299,7 @@ function RecruiterCandidates() {
const { user } = useAuth()
const updateCandidates = useSeedMutation('candidates')
const unscoped = seesAllCandidates(user)
+ const requisitionScoped = scopesToOwnRequisitions(user)
const [q, setQ] = useState('')
const [jobId, setJobId] = useState('')
@@ -331,8 +334,12 @@ function RecruiterCandidates() {
}),
})
const jobsQuery = useQuery({
- queryKey: qk.jobPosts.list({ createdBy: unscoped ? 'all' : (user?.id ?? null) }),
- queryFn: () => fetchJobs({ createdBy: unscoped ? undefined : user?.id }),
+ queryKey: qk.jobPosts.list({
+ createdBy: unscoped ? 'all' : requisitionScoped ? 'requisition' : (user?.id ?? null),
+ }),
+ queryFn: () => fetchJobs({
+ createdBy: unscoped || requisitionScoped ? undefined : user?.id,
+ }),
})
const candidates = useMemo(() => candidatesQuery.data?.rows ?? [], [candidatesQuery.data])
const jobsById = useMemo(
@@ -629,7 +636,9 @@ function RecruiterCandidates() {
? 'Try a different search, job, stage, or band filter.'
: unscoped
? 'Import a CV or add a candidate to see score, stage, and recruiter on this table.'
- : 'Candidates appear here when they are allocated to a job you created.'}
+ : requisitionScoped
+ ? 'Candidates appear here when they are allocated to jobs opened from your requisitions.'
+ : 'Candidates appear here when they are allocated to a job you created.'}
diff --git a/frontend/src/screens/Rbac.jsx b/frontend/src/screens/Rbac.jsx
index 75da20c..e218958 100644
--- a/frontend/src/screens/Rbac.jsx
+++ b/frontend/src/screens/Rbac.jsx
@@ -42,6 +42,16 @@ function humanise(slug) {
.join(' ')
}
+/** Extra tooltip copy for tags whose matrix cell is an opt-in, not a screen. */
+const TAG_HELP = {
+ 'requisitions.configure':
+ 'Limit Jobs and Candidates to requisitions this user created. Independent of Create. Untick to use the default recruiter list.',
+ 'candidates.manage':
+ 'See every candidate, not only jobs this user owns.',
+ 'requisitions.manage':
+ 'Org-wide requisition list (admin).',
+}
+
export default function Rbac() {
const { toast } = useToast()
const { can } = useAuth()
@@ -286,7 +296,9 @@ export default function Rbac() {
{role.bundles?.length
? ` (currently ${role.bundles.map((b) => b.name ?? b).join(', ')})`
: ''}
- . Shared system bundles are not rewritten.
+ . Shared system bundles are not rewritten.{' '}
+ Requisitions → Configure limits Jobs and Candidates to
+ requisitions that user created; it is not implied by Create.
{tagsQuery.isPending && (
@@ -319,12 +331,13 @@ export default function Rbac() {
return · |
}
const on = draft.has(tag)
+ const help = TAG_HELP[tag]
return (
|