Cybersecurity Engineering Handbook / Chapter 24
Authorization Implementation
Carry authorization from the request to the final effect, then prove that every wrong actor, tenant, object, field, and job path is denied.
Preparing audio…
Audio edition
Authorization Implementation
A support engineer has an approved case for Acme’s account. The case permits the engineer to view one failed invoice, but not the customer’s payment details and not any other tenant’s records. The engineer requests a diagnostic export. The API checks the case, places a job on a queue, and returns success. Ten minutes later the case expires. The worker starts with a service credential, queries the warehouse, writes a CSV to object storage, and emails a download link.
Whose authority governs that file?
If the answer is merely “the API already authorized it,” the system has lost the decision before reaching the effect. The worker may export too many rows, the query may omit the tenant predicate, the serializer may include a restricted field, or an unexpired link may outlive the case. Each component can behave as designed while the system discloses data nobody was allowed to receive.
Authorization is complete only when the authority approved at the request survives every path to the protected effect. The implementation must carry a specific actor, action, resource, tenant, and relevant context to explicit enforcement points; default to denial when any input or rule is absent; and exercise each sensitive path with evidence from rejection as well as success.
Name the effect before placing the check
“Can support use the export endpoint?” is too coarse a policy question. It collapses the customer, case, invoice set, fields, volume, purpose, destination, and time into one route permission. A useful decision names what will happen:
decide(
subject = support_agent_42,
action = export_invoice_diagnostics,
resource = invoices_for_tenant_acme,
context = {
case: CASE-1842,
approved_fields: [invoice_id, failure_code, attempted_at],
expires_at: 2026-07-19T06:00:00Z,
destination: approved_support_bucket
}
) -> allow | deny(reason_category, policy_version)
The shape is illustrative, not a universal interface. Its important property is that the decision cannot quietly mean “support role: yes.” The subject may be a person, workload, delegated actor, or automation. The resource may be an object, collection, field, configuration, or tenant. Context may include workflow state, recent authentication, approval, risk, environment, purpose, or break-glass status. Every input that changes the answer must be available to the policy and obtained from a trustworthy source.
Do not accept a tenant, role, or approval supplied by the client merely because it has the expected syntax. Derive identity and authentication context from the validated session or workload credential; load ownership, tenant, and workflow state from authoritative records; and bind approvals to the subject, action, resource, and lifetime they actually cover.
A central policy component is valuable when it gives these decisions consistent meaning, versioning, ownership, and testability. It does not remove local enforcement. A policy nobody invokes on the export worker is documentation, not protection. Conversely, duplicating improvised role checks in handlers, queries, and templates creates several policies that happen to share a name.
Put enforcement beside every way to cause the effect
The request boundary should authenticate the caller, reject malformed input, establish tenant context, and apply coarse permission where that can fail cheaply. It should not be the final authority for data it has not loaded or an operation another component will execute.
The service boundary decides the business action. For the support export, it loads the case, proves that this engineer is assigned, checks that diagnostic export is approved, fixes the permitted invoice scope and fields, and creates a job description that cannot be widened by the caller. Alternate entry points—an administrator tool, internal RPC, repair command, or webhook—must enter through the same decision or an explicitly narrower one.
The data boundary constrains what can be selected or changed. Every object read should be bound to Acme and to the approved invoice set. Where row-level policy, separate schemas, or tenant-scoped credentials provide useful defense in depth, use them; do not let their presence excuse a missing application decision. Field projection belongs in the query or trusted data-access component when possible, before a broad record reaches caches, logs, serializers, or templates.
The background-job boundary needs both its workload authority and the delegated
authority for the requested action. A queue message containing only
invoice_export_requested and a tenant ID forces the worker to guess the rest.
Carry an immutable job identifier and a reference to a server-side authorization
record that names the actor, scope, policy version, approval, and expiry. Sign or
otherwise protect messages against tampering as the queue threat model requires.
At execution, decide explicitly whether authority is checked once or again. Rechecking protects long-delayed and high-consequence work from an expired case, revoked delegation, role change, or newly locked object. Snapshot authorization may be correct for some committed workflows, but then the snapshot, its allowed effect, and its maximum lifetime must be deliberate. “It was allowed when queued” is not a policy until the system defines which later changes it ignores.
The file and delivery boundary is also enforcement. The worker writes only the approved fields to a tenant-scoped destination, gives the artifact a short lifetime, and binds retrieval to the intended recipient or a fresh authorization decision. The support case does not become permission for anyone possessing a long-lived URL.
This placement leaves several checks by design. They are not redundant copies of one route annotation. Each guards a distinct way the effect can exceed the decision: entry, business action, selected rows, selected fields, deferred execution, and delivery.
Make object and field scope inseparable from the operation
Object-level authorization must be evaluated after the requested identifier has
been resolved to its authoritative tenant, owner, parent, delegation, and
workflow state. A query such as GET /invoices/{id} is unsafe when it loads by
ID and checks only that the caller is an invoice viewer. It should either fetch
through an already authorized tenant scope or make the resource relationship
part of the decision before returning data.
For an object the caller may not know exists, choose a response policy that does not reveal existence through status, body, timing, cache behavior, or different downstream calls. This does not require every denied request to take exactly the same time. It requires the design to identify when existence is sensitive and avoid an obvious authorization oracle.
Parent-child relationships deserve their own negative cases. Permission to an invoice does not follow from presenting an Acme account ID beside an invoice ID; the loaded invoice must actually belong to that account. Delegated access must be bounded to its delegator, purpose, operations, and lifetime. Support access must remain connected to its case or approval. Archived, transferred, merged, or workflow-locked objects may change the allowed action even when ownership is unchanged.
Fields create a second resource boundary inside an object. Billing details,
support notes, internal risk flags, abuse signals, personal or health data,
secrets metadata, administrator configuration, and retrieved AI context often
have different readers. Protect them across detail and list endpoints, GraphQL
selection, search, export, debug APIs, cache payloads, logs, analytics copies,
and support tools. Filtering the invoice page while leaving card_fingerprint
in the diagnostic CSV is still an authorization failure.
Prefer allow-listed projections for sensitive responses and exports. Removing known-secret fields from an ever-growing record is fragile: the next field is exposed until somebody remembers every serializer. An allow list makes a new field absent until a policy and response contract deliberately include it.
Turn the permission model into rejected executions
A permission matrix is useful only when it becomes executable tests. Define
each case with a subject, authentication context, tenant, resource state,
action, entry path, expected decision, and expected observable effect. Keep the
fixtures explicit enough that a failing test identifies which relationship was
wrong rather than merely reporting 403.
For the support-export path, begin with the one allowed case: the assigned support engineer, an active approved case, an Acme invoice, the three approved diagnostic fields, and the approved destination. Then change one dimension at a time:
- use an engineer not assigned to the case;
- use an expired or revoked case;
- keep the case but request an unapproved action or field;
- substitute an invoice from another Acme account, then another tenant;
- present an archived, transferred, or workflow-locked invoice;
- call the internal API, search path, bulk endpoint, and export path directly;
- tamper with the queued tenant, object set, fields, destination, or approval;
- let authority expire after enqueue but before execution;
- replay the job or retrieval link;
- run the worker with a valid identity for the wrong environment or queue.
Assert more than the HTTP result. No unauthorized row should be read into an
export buffer, no restricted field should enter the file, no object should be
written, no notification should be sent, and no durable job should continue
after denial. Where practical, inspect the query predicate, selected columns,
storage writes, and security event. A test that receives 403 after a worker
has already written the CSV proves only that the response was denied.
Apply the same pattern to every sensitive route and data access method. Fuzz or property-test identifiers and relationship combinations when the space is too large for examples alone, but retain named boundary cases for cross-tenant, cross-role, delegated, support, stale-session, and lifecycle behavior. Drive tests through actual middleware, services, workers, and repositories as well as through the isolated policy function. The policy unit suite proves rule logic; the end-to-end rejection suite proves that the system remembered to ask.
Record decisions without copying protected data
Sensitive denials and privileged allows should leave evidence sufficient to reconstruct the decision. Record the subject or workload, action, stable resource reference, tenant, result, safe reason category, policy version, request or trace ID, relevant approval reference, enforcement point, and time. Record policy and role changes, support access, delegation changes, break-glass use, export approval and retrieval, repeated cross-tenant attempts, and other privilege-escalation signals the threat model identifies.
Do not place full resources, tokens, secrets, query results, or unrestricted
policy context in the event merely to make it explainable. Logs have their own
readers, retention, export paths, and compromises. A safe reason such as
case_expired or tenant_mismatch is usually more useful than a dump of the
invoice and submitted claims.
Denial volume also needs operational judgment. Recording every low-value probe at the highest severity can make the meaningful sequence disappear. Preserve the events needed for investigation and assurance, aggregate where appropriate, and alert on patterns such as repeated object substitution, cross-tenant access, support use without a case, policy edits followed by privileged access, or a worker repeatedly rejecting widened job scope.
Change policy as production code
Policy migration creates a dangerous interval in which old and new rules both exist. Inventory every route, RPC, query helper, worker, export, administrator tool, and repair path that can reach the protected effect. Give each one an owner and migration state. Fail closed when a caller lacks a rule or required input; do not retain a permissive legacy fallback so the rollout appears smooth.
Before a policy change, review:
- the actors, actions, resources, fields, tenants, and environments affected;
- newly allowed and newly denied cases, including inherited or wildcard rules;
- required authentication context, approval, purpose, and expiry;
- every enforcement point and the behavior of old application versions;
- policy and data migration order, rollback behavior, and cache invalidation;
- negative, cross-role, cross-tenant, job, export, and stale-authority tests;
- decision-event changes, alert impact, owner, reviewer, and emergency reversal.
Roll out with comparison or shadow evaluation when it can be done without exposing protected data or allowing the shadow result to become authority. Investigate disagreements before enforcement moves. Afterward, remove obsolete rules and call sites; two policy systems left indefinitely in “migration” become an attacker-selected fallback.
Release the whole authorization path
Before release, a reviewer should be able to follow one allowed and several denied actions from entry to final effect. The implementation record should identify:
- each route, service, data, policy, job, file, and delivery enforcement point;
- the authoritative source for subject, tenant, role, ownership, delegation, workflow state, approval, purpose, and authentication context;
- deny-by-default behavior, explicit allow rules, reason categories, policy version, owner, and exceptional powers such as support or break-glass;
- object relationships and sensitive-field projections across detail, list, search, export, debug, cache, analytics, and background paths;
- an executable permission matrix with allowed cases and negative cases for every sensitive route and data access method;
- evidence for privileged allows, sensitive denies, policy changes, support access, exports, and suspicious cross-tenant or escalation attempts;
- delegated-job scope, reauthorization or snapshot semantics, replay behavior, artifact lifetime, and retrieval authorization;
- policy-migration coverage and proof that obsolete checks and permissive fallbacks have been removed.
Return to the support export. The assigned engineer and active case may justify the request, but they do not authorize an unrestricted warehouse query, a service credential acting without delegated scope, restricted fields in a CSV, or a download that survives indefinitely. The test is complete when expiring the case before execution produces no file and leaves a safe, attributable denial event. At that point the system has carried authority to the effect instead of trusting the first component that said yes.
Chapter 25 follows that decision into the stores, queries, derived copies, and backups that must preserve its tenant and field boundaries.
Continue reading
Full table of contents