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 (