_private/qwestly-hire-docs/features/job-permission-control.md
Table of Contents
Job Permission Control
Centralized permission model for job access in qwestly-hire. Ensures users only see and interact with jobs they are allowed to access.
Overview
- View access: Who can see a job (detail page, matches, etc.)
- Edit access: Who can modify a job (edit, publish, delete, assign editors)
Both are enforced consistently across pages, API routes, and list filtering.
Permission Model
View Access (canView)
A user can view a job if any of the following is true:
| Condition | Description |
|---|---|
| Owner | User created the job (job.user_id matches user's MongoDB _id) |
| Admin | Primary @qwestly.com email (no + in local part) |
| Editor | User is in job.editor_ids |
| Viewer | User is in job.viewer_ids (explicit view-only access) |
| Same org | User belongs to the same company, parent org, or org unit as the job (only when job is not private) |
Private jobs (is_private: true): Org-based access is disabled. Only owner, admin, editor_ids, and viewer_ids can view. Anyone explicitly added to view or edit list still has access.
Non-private jobs: Org-based view access applies. The user's company_ids or parent_org_ids must overlap with the job's company_id, parent_org_id, or org_unit_id (including companies derived from parent orgs).
Edit Access (canEdit)
A user can edit a job if any of the following is true:
| Condition | Description |
|---|---|
| Owner | User created the job |
| Admin | Primary @qwestly.com email |
| Editor | User's MongoDB _id is in job.editor_ids |
Org membership alone does not grant edit access. Only owner, admin, or editor can edit.
Architecture
Centralized Service
src/services/job-permission.service.ts provides:
canViewJob(jobId, auth0UserId, userEmail?)– boolean, view accesscanEditJob(jobId, auth0UserId, userEmail?)– boolean, edit accesscheckJobAccess(jobId, auth0UserId, userEmail?)– fullJobAccessResultwithcanView,canEdit,isOwner,isAdmin
Use these functions for all permission checks.
Listing vs Detail Consistency
- Listing (
buildUserAccessibleJobFilterinsrc/services/db/jobs.ts): Builds a MongoDB filter for jobs a user can access. Used by dashboard, jobs page, etc. Always includes: jobs owned by user, jobs where user is in editor_ids or viewer_ids. Org-based filters (company, parent org, org unit) apply only to non-private jobs. - Detail (
job-permission.service.ts): Checks access for a specific job when opening a page or calling an API.
Both rely on the same org model (company_ids, parent_org_ids, org unit membership). If a job appears in a list, the user can open it.
Job Model Fields
| Field | Type | Purpose |
|---|---|---|
user_id |
ObjectId | Creator (owner) |
editor_ids |
[ObjectId] | Users who can edit (in addition to owner) |
viewer_ids |
[ObjectId] | Users who can view only (for private jobs) |
is_private |
boolean | When true, org-based access is disabled; only owner, admin, editor_ids, viewer_ids can access |
company_id |
ObjectId | Job's company |
parent_org_id |
ObjectId | Job's parent organization |
org_unit_id |
ObjectId | Job's org unit |
Usage
Pages
| Route | Required Access | Notes |
|---|---|---|
/jobs/[id] |
View | Main job detail page |
/jobs/[id]/matches |
View | Matches list |
/jobs/[id]/edit |
Edit | Edit form |
/jobs/[id]/review |
Edit | Draft review / publish flow |
/jobs/[id]/confirmed |
Edit | Post-publish confirmation |
/jobs/[id]/summary-edit |
Edit | LLM summary edit |
API
The jobs API (src/app/api/jobs/[[...slug]]/route.ts) uses verifyJobAccess(jobId, requireEdit):
requireEdit: false– usescanViewJob(e.g. GET job details)requireEdit: true– usescanEditJob(e.g. PATCH, DELETE)
Adding a New Permission Check
import { canViewJob, canEditJob } from '@/services/job-permission.service';
// For view-only access (e.g. viewing matches)
const allowed = await canViewJob(jobId, session.user.sub, session.user.email);
if (!allowed) redirect('/unauthorized');
// For edit access (e.g. edit page)
const allowed = await canEditJob(jobId, session.user.sub, session.user.email);
if (!allowed) redirect('/unauthorized');
Implementation Status
Implemented:
- Model fields (
editor_ids,viewer_ids,is_private) in Job schema - Permission logic (
canViewJob,canEditJob) including private-job behavior - Listing filter (
buildUserAccessibleJobFilter) – private jobs excluded from org-based access; editor_ids/viewer_ids always included - Page and API access checks using the permission service
Not yet implemented:
- UI form controls: No form fields exist to set
is_privateor to add/remove users ineditor_idsorviewer_ids. Jobs default tois_private: falseand empty editor/viewer lists. - API:
PATCH /api/jobs/[id]does not accept or persisteditor_ids,viewer_ids, oris_private.
To add these later:
- UI: Add an "Editors" and "Viewers" section on the job edit page; add a "Private" toggle for
is_private. - API: Extend
PATCH /api/jobs/[id]to accepteditor_ids,viewer_ids, andis_private. Validation: only owner or admin may update these fields.
Admin Definition
Admins are users with primary @qwestly.com emails (no + in the local part), as determined by isPrimaryQwestlyEmail() in src/lib/auth-utils.ts. Emails like user+e2e@qwestly.com are not treated as admins and must complete normal signup.
Related Files
src/services/job-permission.service.ts– Permission logicsrc/services/db/jobs.ts–buildUserAccessibleJobFilter, job queriessrc/lib/auth-utils.ts–isPrimaryQwestlyEmailsrc/models/Job.ts– Job schema (user_id, editor_ids)