approved permission #70
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.'}
|
||||
</EmptyState>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -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.{' '}
|
||||
<b>Requisitions → Configure</b> limits Jobs and Candidates to
|
||||
requisitions that user created; it is not implied by Create.
|
||||
</p>
|
||||
|
||||
{tagsQuery.isPending && (
|
||||
|
|
@ -319,12 +331,13 @@ export default function Rbac() {
|
|||
return <td key={action}><span className="text-muted">·</span></td>
|
||||
}
|
||||
const on = draft.has(tag)
|
||||
const help = TAG_HELP[tag]
|
||||
return (
|
||||
<td key={action}>
|
||||
<button
|
||||
type="button"
|
||||
className={`perm-check${on ? ' on' : ''}`}
|
||||
title={tag}
|
||||
title={help ? `${tag} — ${help}` : tag}
|
||||
disabled={!canEdit || saveMatrix.isPending}
|
||||
aria-pressed={on}
|
||||
aria-label={`${humanise(mod)} ${humanise(action)}: ${on ? 'granted' : 'not granted'}`}
|
||||
|
|
|
|||
Loading…
Reference in New Issue