_private/qwestly-hire-docs/features/job-permission-control.md

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 access
  • canEditJob(jobId, auth0UserId, userEmail?) – boolean, edit access
  • checkJobAccess(jobId, auth0UserId, userEmail?) – full JobAccessResult with canView, canEdit, isOwner, isAdmin

Use these functions for all permission checks.

Listing vs Detail Consistency

  1. Listing (buildUserAccessibleJobFilter in src/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.
  2. 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 – uses canViewJob (e.g. GET job details)
  • requireEdit: true – uses canEditJob (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_private or to add/remove users in editor_ids or viewer_ids. Jobs default to is_private: false and empty editor/viewer lists.
  • API: PATCH /api/jobs/[id] does not accept or persist editor_ids, viewer_ids, or is_private.

To add these later:

  1. UI: Add an "Editors" and "Viewers" section on the job edit page; add a "Private" toggle for is_private.
  2. API: Extend PATCH /api/jobs/[id] to accept editor_ids, viewer_ids, and is_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.

  • src/services/job-permission.service.ts – Permission logic
  • src/services/db/jobs.tsbuildUserAccessibleJobFilter, job queries
  • src/lib/auth-utils.tsisPrimaryQwestlyEmail
  • src/models/Job.ts – Job schema (user_id, editor_ids)