Merge pull request 'approved permission' (#70) from SQS_BROKER into main
Deploy to S3 / deploy (push) Successful in 32s Details

Reviewed-on: #70
pull/71/head^2
ahmed.mujtaba 2026-09-04 13:00:19 +00:00
commit 14a2817afd
10 changed files with 240 additions and 52 deletions

View File

@ -736,6 +736,7 @@ async def fetch_job_posts(
skip=skip, skip=skip,
ids=id_list, ids=id_list,
active_only=active_only, active_only=active_only,
current_user=current_user,
) )
return JSONResponse(content={"data":data,"total":total,"status_code":200}) return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException: except HTTPException:
@ -865,7 +866,7 @@ async def fetch_jobs(
data,total=await service.fetch_jobs( data,total=await service.fetch_jobs(
search=search,department=department,requisition_status=requisition_status, search=search,department=department,requisition_status=requisition_status,
employment_type=employment_type,hiring_manager_id=hiring_manager_id, 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}) return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException: except HTTPException:
@ -891,7 +892,7 @@ async def export_jobs(
data,_=await service.fetch_jobs( data,_=await service.fetch_jobs(
search=search,department=department,requisition_status=requisition_status, search=search,department=department,requisition_status=requisition_status,
employment_type=employment_type,hiring_manager_id=hiring_manager_id, 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" filename=f"jobs-export-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.xlsx"
return Response( return Response(
@ -947,18 +948,19 @@ async def fetch_candidate(
assigned_job_post_id:UUID=Query(None), assigned_job_post_id:UUID=Query(None),
offset:int=Query(0,ge=0), offset:int=Query(0,ge=0),
search:str=Query(None), search:str=Query(None),
created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
try: try:
service=CandidateView(session=session) service=CandidateView(session=session)
data=await service.get_candidate( 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( 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 ) if isinstance(data,list) else 1
return JSONResponse(content={"data":data,"total":total,"status_code":200}) return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException: except HTTPException:
@ -971,12 +973,13 @@ async def fetch_candidate(
async def update_candidate( async def update_candidate(
user_id:str=Query(...), user_id:str=Query(...),
payload:CandidateUpdate=..., payload:CandidateUpdate=...,
created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
try: try:
service=CandidateView(session=session) 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}) return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException: except HTTPException:
raise raise
@ -1074,12 +1077,13 @@ async def update_interview(
async def fetch_notes( async def fetch_notes(
note_id:str=Query(None), note_id:str=Query(None),
user_id:str=Query(None), user_id:str=Query(None),
created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
try: try:
service=Note(session=session) 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) total=1 if isinstance(data,dict) else len(data)
return JSONResponse(content={"data":data,"total":total,"status_code":200}) return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException: except HTTPException:
@ -1091,12 +1095,13 @@ async def fetch_notes(
@router.post("/notes/create") @router.post("/notes/create")
async def create_note( async def create_note(
payload:NoteCreate, payload:NoteCreate,
created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
try: try:
service=Note(session=session) 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}) return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException: except HTTPException:
raise raise
@ -1108,12 +1113,13 @@ async def create_note(
async def update_note( async def update_note(
note_id:str=Query(...), note_id:str=Query(...),
payload:NoteUpdate=..., payload:NoteUpdate=...,
created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
try: try:
service=Note(session=session) 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}) return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException: except HTTPException:
raise raise

View File

@ -32,7 +32,7 @@ from job.history.views import HistoryRecorder
from job.notes.serializers import serialize_note from job.notes.serializers import serialize_note
from job.candidate.plugins import extract_candidate_email from job.candidate.plugins import extract_candidate_email
from users.models import Users 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 from employment_agent.plugins import parse_phone
load_dotenv() load_dotenv()
@ -78,23 +78,26 @@ async def job_id_for_application(session,inbox_id=None,manual_id=None):
return None,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. """Job ids this user may see, or None when the list is unscoped.
Hiring manager requisition / assigned-manager jobs. requisitions.configure (or hiring-manager portal) jobs on their requisitions
candidates.manage or admin None (all applications). / assigned hiring_manager_id. Recruiter assignment on the job does not hide
Otherwise job_posts.created_by = this user. Never role_id. 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")) return await JobPosts.ids_for_manager(session,current_user.get("id"))
if sees_all_candidates(current_user): if sees_all_candidates(current_user):
return None 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.""" """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 requested=JobPosts._as_uuid(assigned_job_post_id) if assigned_job_post_id is not None else None
if owned is None: if owned is None:
return [requested] if requested else 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): def _scope_detail(current_user):
if is_hiring_manager(current_user): if scopes_to_own_requisitions(current_user):
return MANAGER_SCOPE_DETAIL return MANAGER_SCOPE_DETAIL
return CREATOR_SCOPE_DETAIL return CREATOR_SCOPE_DETAIL
async def assert_manager_candidate_access( 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.""" """Row access: requisition-owned jobs, unscoped (manage/admin), or jobs this user created."""
if not is_hiring_manager(current_user) and sees_all_candidates(current_user): if sees_all_candidates(current_user) and not scopes_to_own_requisitions(current_user):
return 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 []) owned=set(owned or [])
detail=_scope_detail(current_user) detail=_scope_detail(current_user)
if not owned: if not owned:
@ -819,19 +822,19 @@ class CandidateView:
cap=max(1,int(limit or 50)) cap=max(1,int(limit or 50))
return merged[start:start+cap],total 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: try:
if not user_id and is_hiring_manager(current_user): if not user_id and is_hiring_manager(current_user):
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL) raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
if user_id: if user_id:
await assert_manager_candidate_access( 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) detail=bool(user_id)
list_job_ids=None list_job_ids=None
if not detail: if not detail:
list_job_ids=await job_post_ids_for_candidate_list( 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: if list_job_ids is not None and not list_job_ids:
return [] return []
@ -841,7 +844,7 @@ class CandidateView:
) )
if detail: if detail:
records=rows if isinstance(rows,list) else ([rows] if rows else []) 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: if records and owned is not None:
owned_set=set(owned) owned_set=set(owned)
kept=[] kept=[]
@ -947,12 +950,12 @@ class CandidateView:
except Exception as e: except Exception as e:
raise HTTPException(status_code=500,detail=str(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: try:
job_post_ids=None job_post_ids=None
if not user_id: if not user_id:
job_post_ids=await job_post_ids_for_candidate_list( 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( return await Inbox.count_candidate_profiles(
session=self.session,user_id=user_id,search=search,job_post_ids=job_post_ids, 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: except Exception as e:
raise HTTPException(status_code=500,detail=str(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: try:
if not user_id: if not user_id:
raise HTTPException(status_code=400,detail="user_id is required") 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} fields={k:v for k,v in (payload or {}).items() if k in ("favorite","rating") and v is not None}
if not fields: if not fields:
raise HTTPException(status_code=400,detail="favorite or rating is required") raise HTTPException(status_code=400,detail="favorite or rating is required")

View File

@ -2,7 +2,7 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import TYPE_CHECKING, Optional 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.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from sqlmodel import Field, Relationship, SQLModel, select from sqlmodel import Field, Relationship, SQLModel, select
@ -209,6 +209,7 @@ class JobPosts(SQLModel, table=True):
requisition_status: str | None = None, requisition_status: str | None = None,
employment_type: str | None = None, employment_type: str | None = None,
hiring_manager_id: uuid.UUID | None = None, hiring_manager_id: uuid.UUID | None = None,
restrict_ids: list | None = None,
): ):
if ids: if ids:
rows = await cls.get_by_ids(session, ids, active_only=active_only) 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 statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712
elif not include_deleted: elif not include_deleted:
statement = statement.where(cls.is_deleted == False) # noqa: E712 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: if search:
like = f"%{search.strip()}%" like = f"%{search.strip()}%"
statement = statement.where( statement = statement.where(
@ -482,12 +492,14 @@ class JobPosts(SQLModel, table=True):
return out return out
@classmethod @classmethod
async def ids_for_creator(cls, session: AsyncSession, user_id): async def ids_for_creator(cls, session: AsyncSession, user_id, created_by=False):
"""Job posts this user created. Recruiter Candidates scope (when they """Jobs this recruiter should see on Candidates (when they lack
lack candidates.manage) follows job_posts.created_by, not role_id.""" 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) uid = cls._as_uuid(user_id)
if uid is None: if uid is None:
return [] return []
if created_by:
result = await session.execute( result = await session.execute(
select(cls.id).where( select(cls.id).where(
cls.created_by == uid, cls.created_by == uid,
@ -495,6 +507,16 @@ class JobPosts(SQLModel, table=True):
) )
) )
return list(result.scalars().all()) return list(result.scalars().all())
result = await session.execute(
select(cls.id).where(
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
)
)
return list(result.scalars().all())
@classmethod @classmethod
async def count_open_reqs_by_hiring_managers(cls, session: AsyncSession, user_ids): async def count_open_reqs_by_hiring_managers(cls, session: AsyncSession, user_ids):

View File

@ -223,7 +223,24 @@ class JobPost:
except (httpx.HTTPError,BufferError,RuntimeError) as e: except (httpx.HTTPError,BufferError,RuntimeError) as e:
raise HTTPException(status_code=502,detail="Failed to list Buffer channels") from 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( rows,total=await JobPosts.fetch_job_posts(
self.session, self.session,
search=search, search=search,
@ -231,6 +248,7 @@ class JobPost:
skip=skip, skip=skip,
ids=ids, ids=ids,
active_only=active_only, active_only=active_only,
restrict_ids=restrict,
) )
return [serialize_job_post(r) for r in rows],total 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, 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 hm_uid=None
if hiring_manager_id: if hiring_manager_id:
hm_uid=JobPosts._as_uuid(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, self.session,search=search,top=top,skip=skip,active_only=active_only,
department=department,requisition_status=requisition_status, department=department,requisition_status=requisition_status,
employment_type=employment_type,hiring_manager_id=hm_uid, employment_type=employment_type,hiring_manager_id=hm_uid,
restrict_ids=restrict,
) )
names=await Users.names_by_ids( names=await Users.names_by_ids(
self.session, self.session,

View File

@ -15,13 +15,13 @@ class Note:
async def _load(self,record_id): async def _load(self,record_id):
return await Notes.get_note_by_id(self.session,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: if note_id:
row=await self._load(note_id) row=await self._load(note_id)
if not row: if not row:
raise HTTPException(status_code=404,detail="Note not found") raise HTTPException(status_code=404,detail="Note not found")
await assert_manager_candidate_access( 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) return serialize_note(row)
if not user_id: if not user_id:
@ -29,11 +29,11 @@ class Note:
uid=Notes._as_uuid(user_id) uid=Notes._as_uuid(user_id)
if uid is None: if uid is None:
raise HTTPException(status_code=400,detail="Invalid user_id") 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) rows=await Notes.get_notes_by_user(self.session,uid)
return [serialize_note(r) for r in rows] 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={ fields={
"note":payload.get("note") or "", "note":payload.get("note") or "",
"user_id":payload.get("user_id"), "user_id":payload.get("user_id"),
@ -42,7 +42,7 @@ class Note:
if not fields["user_id"]: if not fields["user_id"]:
raise HTTPException(status_code=400,detail="user_id is required") raise HTTPException(status_code=400,detail="user_id is required")
await assert_manager_candidate_access( 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) row=await Notes.insert_note(self.session,fields)
await HistoryRecorder(self.session).record( await HistoryRecorder(self.session).record(
@ -54,7 +54,7 @@ class Note:
row=await self._load(row.id) row=await self._load(row.id)
return serialize_note(row) 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",)} fields={k:v for k,v in payload.items() if v is not None and k in ("note",)}
if not fields: if not fields:
raise HTTPException(status_code=400,detail="No fields to update") raise HTTPException(status_code=400,detail="No fields to update")
@ -62,7 +62,7 @@ class Note:
if not before: if not before:
raise HTTPException(status_code=404,detail="Note not found") raise HTTPException(status_code=404,detail="Note not found")
await assert_manager_candidate_access( 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 "" old_note=before.note or ""
row=await Notes.update_note(self.session,note_id,fields) row=await Notes.update_note(self.session,note_id,fields)

View File

@ -247,6 +247,25 @@ def sees_all_candidates(current_user: dict | None) -> bool:
return PermissionTag.CANDIDATES_MANAGE.value in granted 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( def has_permission(
granted: set[str] | list[str] | tuple[str, ...], granted: set[str] | list[str] | tuple[str, ...],
*required: PermissionTag, *required: PermissionTag,

View File

@ -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')

View File

@ -62,3 +62,15 @@ export function seesAllCandidates(user) {
if (isAdmin(user)) return true if (isAdmin(user)) return true
return (user?.permissions || []).includes('candidates.manage') 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')
}

View File

@ -4,8 +4,10 @@
Recruiter rows come from GET /candidate/fetch (inbox + manual), one row Recruiter rows come from GET /candidate/fetch (inbox + manual), one row
per application, so score / stage / job / recruiter have a source. per application, so score / stage / job / recruiter have a source.
Without candidates.manage the server scopes that list to jobs the user Without candidates.manage the server scopes that list to jobs the user
created (job_posts.created_by). Hiring managers use GET owns as recruiter (or created). Tick Requisitions Configure in Access
/candidate/manager/fetch (jobs on their requisitions). Adding a candidate 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 still goes through CV Import or the Add Candidate modal both run the CV
through persisted ATS scoring. 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 { Avatar, Badge, EmptyState, FieldError, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { isHiringManager, seesAllCandidates } from '../auth/permissions' import { isHiringManager, seesAllCandidates, scopesToOwnRequisitions } from '../auth/permissions'
import CandidateProfile from './CandidateProfile' import CandidateProfile from './CandidateProfile'
import { useJobTitles } from './ScoredCandidateProfile' import { useJobTitles } from './ScoredCandidateProfile'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
@ -297,6 +299,7 @@ function RecruiterCandidates() {
const { user } = useAuth() const { user } = useAuth()
const updateCandidates = useSeedMutation('candidates') const updateCandidates = useSeedMutation('candidates')
const unscoped = seesAllCandidates(user) const unscoped = seesAllCandidates(user)
const requisitionScoped = scopesToOwnRequisitions(user)
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [jobId, setJobId] = useState('') const [jobId, setJobId] = useState('')
@ -331,8 +334,12 @@ function RecruiterCandidates() {
}), }),
}) })
const jobsQuery = useQuery({ const jobsQuery = useQuery({
queryKey: qk.jobPosts.list({ createdBy: unscoped ? 'all' : (user?.id ?? null) }), queryKey: qk.jobPosts.list({
queryFn: () => fetchJobs({ createdBy: unscoped ? undefined : user?.id }), 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 candidates = useMemo(() => candidatesQuery.data?.rows ?? [], [candidatesQuery.data])
const jobsById = useMemo( const jobsById = useMemo(
@ -629,6 +636,8 @@ function RecruiterCandidates() {
? 'Try a different search, job, stage, or band filter.' ? 'Try a different search, job, stage, or band filter.'
: unscoped : unscoped
? 'Import a CV or add a candidate to see score, stage, and recruiter on this table.' ? 'Import a CV or add a candidate to see score, stage, and recruiter on this table.'
: 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.'} : 'Candidates appear here when they are allocated to a job you created.'}
</EmptyState> </EmptyState>
</td> </td>

View File

@ -42,6 +42,16 @@ function humanise(slug) {
.join(' ') .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() { export default function Rbac() {
const { toast } = useToast() const { toast } = useToast()
const { can } = useAuth() const { can } = useAuth()
@ -286,7 +296,9 @@ export default function Rbac() {
{role.bundles?.length {role.bundles?.length
? ` (currently ${role.bundles.map((b) => b.name ?? b).join(', ')})` ? ` (currently ${role.bundles.map((b) => b.name ?? b).join(', ')})`
: ''} : ''}
. Shared system bundles are not rewritten. . Shared system bundles are not rewritten.{' '}
<b>Requisitions Configure</b> limits Jobs and Candidates to
requisitions that user created; it is not implied by Create.
</p> </p>
{tagsQuery.isPending && ( {tagsQuery.isPending && (
@ -319,12 +331,13 @@ export default function Rbac() {
return <td key={action}><span className="text-muted">·</span></td> return <td key={action}><span className="text-muted">·</span></td>
} }
const on = draft.has(tag) const on = draft.has(tag)
const help = TAG_HELP[tag]
return ( return (
<td key={action}> <td key={action}>
<button <button
type="button" type="button"
className={`perm-check${on ? ' on' : ''}`} className={`perm-check${on ? ' on' : ''}`}
title={tag} title={help ? `${tag}${help}` : tag}
disabled={!canEdit || saveMatrix.isPending} disabled={!canEdit || saveMatrix.isPending}
aria-pressed={on} aria-pressed={on}
aria-label={`${humanise(mod)} ${humanise(action)}: ${on ? 'granted' : 'not granted'}`} aria-label={`${humanise(mod)} ${humanise(action)}: ${on ? 'granted' : 'not granted'}`}