HR-ATS-Portal/docs/architecture/adr/0009-permission-enforcement...

27 KiB
Raw Blame History

ADR 0009 — Permission enforcement strategy: application service layer primary, RLS as Phase 2 defence in depth

Status: Accepted — 2026-07-29

Scope: How an authorization decision is made and where it is enforced, for the internal Utopia Brands ATS. Not multi-tenant: brand and business unit are scope dimensions on one data model in one database, never tenants.

Deciders: Talha Ahmed (the identity module, can()/scope(), the scope predicates, the Phase 2 database roles). Ahmed Mujtaba (the (role, verb, relationship) test matrix, the route-coverage test, the pii_classification CI completeness check, the access-grant admin screen) — each item independently demonstrable, each with a Talha review checkpoint.


Context

What exists today: nothing

Fact Evidence
No authentication of any kind — no login screen, no token, no session findings §D
The RBAC permission matrix is a display widget. Clicking a cell mutates an in-memory array; nothing reads it to gate behaviour js/rbac.js:78, js/rbac.js:83-85, js/rbac.js:111-112
There is no can(), hasPermission() or equivalent anywhere in the repository repo-wide grep, findings §D
Security settings (2FA, SSO, session timeout, password policy) are inert UI chrome with no handlers and no persistence js/settings.js:148-154
Roles and permissions are demo data: 8 roles, 13 modules, 8 permission types, matrix derived from a single level cutoff index js/data.js:425-446
localStorage holds the theme preference only. No session state js/app.js:64,193,198

So this is a greenfield authorization layer, and the prototype's 8 permission types are demo data that is not carried over. There is nothing to preserve and nothing to migrate — but also no incumbent behaviour to validate against, which is why the decision has to be mechanically enforceable rather than reviewed by eye.

What the authorization model has to express

  • Capability — a (module, verb) pair over 10 verbs (view, create, edit, transition, approve, assign, configure, export, delete, administer), seeded as reference data so a typo cannot mint a permission that silently grants nothing.
  • Scopewhich rows. Eight dimensions: global, business_unit, department, region, job, application, interview, explicit_grant. Dimensions 57 resolve through interval tables (job_assignment, job_application_assignment with tstzrange and GiST EXCLUDE constraints, interview_participant), because the flexible and historical recruiter-assignment constraint forbids the prototype's single scalar recruiter / recruiterId (js/data.js:96, js/data.js:123). (Note: 05-security-rbac-ai-governance.md §2.3 prose says "seven" while its table lists eight. The table is correct; the prose needs a one-word fix.)
  • Sensitive-field access — a second, narrower gate over compensation, CV text, scorecards and AI evidence, all classified sensitive_personal in pii_classification.
  • Delegated AI access — the chatbot constraint. Whatever mechanism is chosen has to be the same mechanism the assistant uses, or there are two policies to keep in agreement.

The constraints that narrow the choice

Two developers, one of them junior, with one reviewer. 66 named seats (BRD §4), of which 24 are interviewers who touch only their own interviews. One relational database. No Kubernetes, no microservices, no separate AI service in Phase 1. The authorization layer is being written from scratch at the same time as the schema, the intake pipeline and the frontend migration — so its risk budget is small.


Options considered

Option A — Application service layer as the primary boundary (chosen)

One identity module exposing can(actor, verb, resource) and scope(queryset, actor, verb). Every read and write of a scoped entity resolves through it. RLS deferred to Phase 2 on database roles that are not the application role.

Pros Cons
One authorization implementation, shared by REST API, assistant, worker and analytics — which is the only cheap way to satisfy the chatbot constraint Bypassable by anything that reaches the database without going through the service layer: raw SQL, a management command, a psql data fix, a badly written migration, a future BI tool
Interval-shaped and relational scope predicates are expressible as ordinary ORM/SQL joins that the team can read and EXPLAIN Correctness depends on developers never writing Model.objects.filter() in a view — a discipline problem, mitigated by tooling rather than eliminated
Directly testable at the module facade, which Part 1 already commits to as the test seam: one test per (role, verb, resource-relationship) triple Adds per-request scope-resolution queries across five tables
Denials are auditable with a structured reason, because the decision point is in our code No database-level backstop in Phase 1
Cheapest thing to build correctly in a Phase-1 window

Option B — PostgreSQL Row-Level Security as the primary boundary

Policies on candidate, job_application, ats_result, interview, offer keyed to current_setting('app.actor_user_id'), enforced for every connection including the application role.

Pros Cons
Genuinely closes the gap Option A accepts. A developer at a production prompt, a stray management command, a BI tool and a bad migration are all safe by default. This is a real advantage and it is the reason Option B is not dismissed but deferred Correctness depends on SET LOCAL on every transaction across a pooled connection — middleware, background jobs, migrations, management commands, the queue consumer
Enforcement travels with the data, so it survives a new consumer nobody told us about The failure mode is asymmetric and hostile: a missing SET LOCAL is either a total lockout, or — if someone "fixes" the lockout with a permissive fallback — full visibility, silently
No way to forget a decorator: there is no code path to forget it on Scope 57 become correlated subqueries over interval tables, evaluated per row, on every table, with no hoisting. The plans are exactly the ones a two-person team cannot debug at 6pm
Would let the assistant run under its own role safely If RLS is primary, the assistant runs under a different role than the REST API, so there are two authorization implementations to keep in agreement — a direct hit on the chatbot constraint
Tests become per-role connection fixtures asserting policies, not product behaviour

Option C — DRF permission classes / object-level checks only, no data-query layer

Declarative required_permission at each view plus an object-level check on retrieve.

Pros Cons
The least code, and the idiom most familiar to a junior developer Catches the retrieve case and misses every list, filter, aggregate, export and search case — which is where mass PII disclosure actually happens
Nothing to learn beyond DRF's own documentation GET /candidates?department=…&salary_gte=… is unprotected by construction
Fast: no per-request scope resolution Search relevance ordering and facet counts leak the existence of out-of-scope rows even when their fields are hidden

Rejected outright. This is the pattern that produces a demo which passes review and leaks in production.

Option D — A database role per user or per role

66 database roles today, GRANT-based enforcement.

Pros Cons
Enforcement in the engine, no SET LOCAL fragility 66 roles with continuous churn as staff change; role lifecycle becomes an ops job nobody owns
Connection identity equals actor identity, so audit at the database is trivially attributable Destroys connection pooling — a pool per role, or a SET ROLE dance with the same fragility as Option B
Cannot express interval-scoped assignment at all: "recruiter on this job from March to June" is not a GRANT

Option E — External policy engine (OPA/Rego, Cedar, or an embedded library such as Oso)

Policy expressed in a dedicated policy language, evaluated by an engine.

Pros Cons
Policy becomes reviewable as a separate artefact, and a policy test suite is a real asset Data-dependent scope means either shipping the interval tables into the engine as facts (a second copy of the assignment model, kept in sync) or calling back into the application for every decision — which is Option A with an extra hop
Sidecar deployment gives one decision point for any future consumer A sidecar is a third deployable; the split-trigger table (02 §10.3) says no third deployable in Phases 04
Mature tooling for capability-style checks Two developers would be learning Rego/Cedar while writing their first authorization layer — the new-language cost lands exactly where the risk budget is smallest
Filtering (scope(qs)) is the hard half of this problem and is the half policy engines handle worst; partial evaluation to SQL exists but is advanced usage

Decision

The primary authorization boundary is the application service layer. A single identity module owns every authorization decision. PostgreSQL RLS is introduced in Phase 2 as defence in depth on three database roles, none of which is the application role.

Three mandatory checkpoints, all in identity

# Checkpoint Where Failure behaviour
1 Authentication Entra ID OIDC SSO; session cookie Secure; HttpOnly; SameSite=Lax; CSRF on all unsafe methods; absolute lifetime 12h, idle 60m (ASSUMPTION — to be confirmed with IT). Break-glass local system_admin with mandatory TOTP 401, denial_reason=unauthenticated
2 Capability — declarative, at the view Every DRF view declares required_permission = ("candidate", "view"). A base permission class denies any view that fails to declare one Undeclared view = startup error, not a runtime hole
3 Object / scope — at the service facade, not the view candidate.service.get(actor, public_id) performs the scope check itself 404 for candidate/application/document/offer; 403 elsewhere; denial_reason=scope

Putting checkpoint 3 in the service rather than the view is the load-bearing choice: the assistant calls the same function and therefore cannot skip a view-layer decorator (ADR 0010).

The data-query layer is the more important half

identity.scope(qs, actor, verb) is the only sanctioned way to build a multi-row read of a scoped entity.

  • Scope resolved once per request into a small struct {global, business_unit_ids, department_ids, region_ids, job_ids, application_ids, interview_ids, grants} from role_assignment, job_assignment, job_application_assignment, interview_participant and access_grant. Cached for the request lifetime only — never across requests, so a revoked assignment takes effect on the next request.
  • Per-entity predicate translation. For job_application: job_id IN job_ids OR id IN application_ids OR job__department_id IN department_ids OR job__business_unit_id IN business_unit_ids OR job__current_version__location__region_id IN region_ids OR id IN grants[job_application]. For candidate: reachable only through a visible job_application, a visible interview, or a visible talent_pool — a candidate is never visible "directly" except to hr_admin.
  • Search and aggregates go through the same filter, before ranking. candidate_search_index is scoped then ranked, so relevance order and result counts cannot leak out-of-scope rows. Facet counts are GROUP BY over the scoped set. management_viewer aggregates suppress cells below a minimum size of 5 (ASSUMPTION on the threshold).
  • Type-level gate. Selectors return Scoped[QuerySet[Model]]; the base serialiser accepts only Scoped[...]. Candidate.objects.filter(...) in a view is a mypy failure in CI.
  • Row limits everywhere. Default page 25, hard maximum 200, no unbounded list endpoint. Export is a separate capped, audited verb — deliberately not implied by view.

Resolution semantics, pinned

  1. Capability is a union across role assignments. There are no DENY rules; a deny-override matrix is how a two-person team ships a hole it cannot reason about.
  2. Scope is a union of the granting assignments' scopes only — evaluated per granting assignment. A permission held at department scope does not become global because the same user holds an unrelated permission globally. This is the rule that stops interviewer + a departmental read from becoming a departmental interviewer.
  3. Sensitive-field access is an AND, not a union: a role-level field capability and a qualifying relationship to the resource. Because it is a conjunction, no amount of role stacking produces field access neither role independently authorises.
  4. Scope is evaluated as of now() against open intervals (valid_to IS NULL). scopes_for(user, ts) exists for audit reconstruction and never authorises a live request.
  5. Row-state gates apply after scope: deleted_at IS NULL via the v_*_live views; merged_into_candidate_id → 301 to the survivor; retention-pseudonymised rows returned as skeletons.
  6. Denials are audited with outcome='denied' and denial_reason ∈ {unauthenticated, capability, scope, field, state}. The response never says which scope failed.

Time-boxed exceptions instead of permanent ones

access_grant (additive — not in _decisions.md) carries expires_at NOT NULL with a 30-day ceiling, a mandatory reason, and CHECK (grantee_user_id <> granted_by_user_id). Every long-lived exception in every access model started life as a temporary grant nobody revoked; the NOT NULL is the whole point, and the self-grant CHECK closes the simplest privilege-escalation path in the design.

Write-path guards live in the service, never the serializer

application.transition() refuses terminal-negative transitions unless actor_kind='user'; offer.issue() requires an explicit human confirmation token; duplicate_review.confirm_merge() requires hr_admin; identity.assign_role() refuses self-elevation.

RLS in Phase 2 — three roles, none of them the application role

Role Purpose Posture
ats_ai_reader The only role an ad-hoc AI query path may ever use, if Phase 2 concludes fixed tool intents are insufficient (ADR 0010) RLS keyed to current_setting('app.actor_user_id') via helpers mirroring scopes_for; column privileges exclude every sensitive_personal column; SELECT only
ats_report_reader BI / spreadsheet access, if the business ever demands direct connectivity SELECT on analytics views only; RLS on the views; no contact, document or money columns
ats_support_readonly Production psql for debugging SELECT only; RLS restricting contact columns, candidate_document.extracted_text and all money columns to zero rows; every connection logged

ats_support_readonly is the role that closes the honest gap named below — a developer at a production prompt.

Who can reach the data, and what stops them

The point of this diagram is the dashed arrows: they are the paths that are unguarded in Phase 1 and are exactly what the Phase 2 roles exist to close.

flowchart LR
  subgraph CONSUMERS["Consumers"]
    UI["React SPA"]
    BOT["Assistant"]
    WRK["Worker jobs"]
    ANA["analytics module"]
    PSQL["Developer at psql"]
    BI["Future BI tool"]
    MIG["Migration or management command"]
  end
  IDENT["identity.can + identity.scope<br/>THE single decision point"]
  FACADE["Module service facades"]
  VIEWS["Read-only analytics SQL views<br/>declared in migrations"]
  DB[("PostgreSQL<br/>one database")]
  UI --> IDENT
  BOT --> IDENT
  WRK --> IDENT
  ANA --> VIEWS
  IDENT --> FACADE
  FACADE --> DB
  VIEWS --> DB
  PSQL -.->|"Phase 1: UNGUARDED<br/>Phase 2: ats_support_readonly + RLS"| DB
  BI -.->|"must not be enabled before<br/>ats_report_reader + RLS"| DB
  MIG -.->|"UNGUARDED. Review is the only control"| DB

Justification

Four reasons, in order of weight.

  1. The chatbot constraint forces one implementation. "The chatbot must never bypass access controls" is only a guarantee if the assistant has no capability the UI does not have — the same can(), the same scope(), the same facades. Under Option B the assistant runs as a different database role, so the same policy exists twice. Two implementations of one policy diverge, and that divergence is the vulnerability.
  2. The scope predicates are relational and interval-shaped. Scopes 57 resolve through tstzrange interval tables and a participant table. As an RLS USING clause that is a per-row correlated subquery on every table for every query. Postgres will not always inline it well, and the resulting plans are undebuggable by this team under pressure.
  3. RLS depends on SET LOCAL discipline across a pooled connection. Part 2 already records this hazard for history triggers, where a missing SET LOCAL degrades visibly to actor_unknown = true. For RLS it degrades either to a lockout or — after someone "fixes" the lockout permissively — to full visibility, silently. Adopting that fragility in the same phase as the first authorization layer is compounding risk, not managing it.
  4. Testability against the committed test strategy. Part 1 commits to pytest against a real Postgres with the module facade as the seam. Service-level enforcement is directly testable there, one test per (role, verb, relationship) triple asserting 403/404.

The tradeoff, stated plainly

Application enforcement is bypassable by anything that talks to the database directly. We accept that and buy it back with six mitigations rather than pretending otherwise:

Mitigation Mechanism Enforced by
One authorization module Only identity computes permissions import-linter forbidden-import contract in CI
No repository access from views Views may only call <module>.service import-linter layered contract
Every read is scope-typed Scoped[QuerySet]; base serialiser refuses an unwrapped queryset mypy + ScopedModelViewSet
Route coverage test A test enumerates every registered DRF route and fails on any candidate/application/offer-touching route that does not resolve through identity pytest
Column-level GRANTs ats_result and audit_event append-only; no UPDATE on version tables Part 2 migrations
Analytics is the single declared exception Read-only SQL views declared in migrations, reviewable in a diff, scope-filtered on the way out Part 1 boundary rule 2

Consequences

Positive

  • One place to review, one place to fix, one place to test. With one senior reviewer that is the difference between an auditable claim and a hope.
  • The assistant is safe by construction rather than by a parallel policy (ADR 0010).
  • Denial telemetry is structured: authz_denials_total{action,role} is a real signal, and a spike is either a permission bug or an attack.
  • The junior gets a well-shaped, independently demonstrable workstream — the (role, verb, relationship) test matrix, the route-coverage test, and the pii_classification CI completeness check — none of which is CRUD.
  • Interval-based scope means the flexible-and-historical assignment requirement and the authorization model are satisfied by the same tables, not two mechanisms.

Negative — the costs being accepted

Cost Detail
No database backstop until Phase 2 A raw query, a management command, a psql fix or a bad migration reads everything. This is a real exposure, not a theoretical one, and it lasts for the whole of Phase 1
CI is now a security control mypy, import-linter and the route-coverage test are load-bearing. Disabling or skipping one is a security regression, and it will not look like one in a diff
Revocation is eventually consistent within one request Request-lifetime scope caching means the maximum staleness window is a single in-flight request. Acceptable, but it must not be "optimised" into a cross-request cache
Per-request scope resolution cost Five tables joined or queried once per request. Cheap at 2025 concurrent users; it is a real cost at 10× and the first thing to profile if read p95 drifts past 300 ms
404-not-403 hurts support "Candidate not found" is indistinguishable from "you cannot see this candidate", by design. The recruiter-facing consequence is a support call with no self-service resolution; the mitigation is that the real reason is in audit_event.denial_reason, retrievable by an admin
access_grant and ref.region are additive Both are required by this decision and absent from _decisions.md. ref.location needs a region_id FK and a ref.region table. These must land in the Phase 1 schema, not be discovered in Phase 2
Sensitive-field policy lives in serialisation Excluded fields are omitted from the serialiser rather than nulled in place. Anything that bypasses the serialiser bypasses the field policy — which is precisely why text-to-SQL is prohibited (ADR 0010)

Risks

# Risk Likelihood Mitigation
R1 Phase 1 candidate PII protection rests entirely on a brand-new authorization layer with no database backstop. If that layer slips, there is nothing behind it Medium The six mitigations above; identity is Talha's, built in Phase 0 before any real data lands; route-coverage test is a merge blocker
R2 A developer writes a scoped read that bypasses identity.scope() and mypy is satisfied because the annotation was cast Medium Scoped is a NewType over the queryset with no public constructor outside identity; a cast is grep-able and reviewed
R3 Scope-union semantics (rule 2) implemented as a naive union of all scopes, silently widening access Medium-high — this is the subtlest rule in the model A dedicated test class for role-stacking cases, starting with interviewer + departmental read; the rule is stated in the ADR precisely so the test can quote it
R4 access_grant becomes the normal path instead of the exception Medium 30-day ceiling, mandatory reason, a weekly report of active grants by grantor; a grant rate above ~5/week for the same (module, verb) is a signal the scope model is wrong, not that more grants are needed
R5 Entra ID SSO depends on corporate IT (assumption A1). If Utopia Brands is not on M365, authentication becomes a separate integration on the Phase 1 critical path Medium Start the Entra app registration request in Phase 0; the break-glass local admin path means the platform is not blocked on IT for development
R6 Denial audit volume: every list denial writes a row, and a misconfigured client can flood audit_event Low Denials are audited per request, not per row; audit_event is partitioned monthly (Part 2)
R7 Interviewer scope ends when the interview reaches a terminal status. A scorecard submitted late, or an interview reopened, produces a legitimate access failure that looks like a bug Medium Explicit grace semantics on interview_participant closure, plus access_grant as the sanctioned exception path

Revisit conditions

Reopen this decision when any of the following is measured, not when it is argued.

# Trigger Threshold What changes
V1 A confirmed authorization defect reaches production that RLS would have prevented Any single occurrence involving candidate or offer data RLS on candidate, job_application, ats_result, interview, offer is promoted from Phase 2 defence in depth to a Phase-blocking requirement
V2 A non-application consumer gets direct database connectivity Any BI tool, spreadsheet connector or reporting product connected to production ats_report_reader with RLS ships before the connection is enabled. No exceptions — this is the exposure Option A explicitly accepts
V3 Ad-hoc AI querying is approved (ADR 0010 V-triggers) Business sign-off on ad-hoc assistant querying ats_ai_reader with RLS helper functions mirroring scopes_for, plus its own test suite, before the first query
V4 Direct psql data fixes touching candidate PII More than 2 in any rolling quarter ats_support_readonly is pulled forward from Phase 2, and production psql under the application role is revoked
V5 A CI security gate is disabled, skipped or made non-blocking Any occurrence of import-linter, mypy, or the route-coverage test being bypassed Immediate review; if it happens twice, the enforcement mechanism is not viable and the database must carry the guarantee
V6 Scope resolution becomes a performance problem scopes_for p95 > 50 ms, or > 8 queries per request, or read endpoint p95 > 300 ms attributable to scope resolution Materialise a per-user scope table refreshed on assignment change, before considering any structural change
V7 Population growth Named seats > 250, or distinct roles > 25, or scope dimensions > 10 Re-evaluate against a policy engine (Option E); at that size a reviewable policy artefact starts to pay for its learning cost
V8 More than one module is found computing permissions Any import-linter violation of the identity-only contract that is merged rather than fixed The single-decision-point premise has failed; escalate to Talha and treat as a design regression
V9 Denial-rate anomaly authz_denials_total sustained > 3× the 7-day mean for 24h Investigate as either a permission-model bug or an attack; a model bug reopens the scope predicates for that entity
V10 special_category data enters scope Any decision to store diversity, health or accommodation data (excluded in Phase 1) Field-level enforcement must move below the serialiser — a column-privilege or RLS mechanism — because serialiser-level omission is not sufficient for special-category data

  • _decisions.md Part 1 — module boundary rules, import-linter contracts, testing strategy.
  • _decisions.md Part 2 — "URL-guessability is not authorization"; soft delete and v_*_live views; pii_classification; audit outcome / denial_reason; chatbot isolation.
  • 05-security-rbac-ai-governance.md §2 — the full role × module × permission matrix, the sensitive-field matrix, and the permission-evaluation flow diagram.
  • ADR 0010 — the chatbot consumes this decision; it is the reason checkpoint 3 sits in the service facade.
  • ADR 0012 — the Phase 2 database roles are provisioned as part of the deployment topology.