09

Identity, Tenant Isolation, and Service Authority

Bind every human and workload request to one tenant and one explicit permission before it reaches ParcelFlow data.

The enterprise problem and today’s slice

Enterprise problem: ParcelFlow now crosses several services, so trusting a tenant ID from a URL, sharing one database role, or giving every process the same secret can turn an ordinary coding mistake into cross-customer disclosure or an unauthorized payment action.

Whole-course context: The incoming system already has durable orders, service contracts, an outbox, idempotent consumers, and bounded failure recovery; this day adds the authority evidence required before those mechanisms can safely serve multiple organizations.

Today’s slice: Authenticate human sessions and service workloads separately, authorize every action against one verified tenant, enforce tenant ownership in PostgreSQL, and deliver secrets without treating possession as unlimited authority.

End-of-day evidence: A positive order query, a forged-tenant denial, a wrong-audience workload-token denial, and a cross-tenant insert rejection share immutable request, policy, actor, tenant, and deployment identifiers.

Still unsolved: Centralized operational evidence, service-level objectives, production deployment, canary rollback, export, and retirement remain for the final three days.

Customer use cases

Identity controls matter because customers need normal work to remain available while hostile or mistaken requests fail closed. The two cases cover a human reading an order and a workload reserving stock without allowing either authority to imply the other.

Use case IDActorCustomer jobSuccess outcomeDenial or recovery evidence
D09-UC-01ParcelFlow customer memberRead an order belonging to the active tenantThe API returns only the requested tenant's order and records the verified subject, tenant, permission, policy version, and request IDA forged tenant selector or missing membership returns 403 without querying another tenant's rows; an authorized control request still succeeds
D09-UC-02Fulfillment workloadReserve stock for one authorized orderInventory accepts a short-lived workload token for the exact service audience and tenant-scoped commandAn expired, wrong-audience, or over-scoped token returns 401 or 403; PostgreSQL independently rejects a cross-tenant relationship

Actor-centred user stories

An authentication checkbox does not say what a person or process can do, so each story names both the authority and its observable boundary.

Story IDUse case IDsUser storyObservable acceptance conditions
D09-US-01D09-UC-01As a customer member, I want my active organization derived from verified membership, so that changing a URL cannot expose another customer's ordersValid issuer, signature, audience, expiry, subject, membership, and permission are observed before a transaction receives tenant_id; the forged selector is denied and produces no cross-tenant result
D09-US-02D09-UC-02As an inventory owner, I want workload identity checked independently from human identity, so that an order-service process receives only reserve-stock authorityThe token audience is Inventory, scope is inventory:reserve, tenant and order references are bounded, expiry is short, and the same token is denied by Payment

End-to-end product flows

Authorization is an end-to-end path, not middleware alone. These flows start at a visible action and end with positive and negative evidence from the enforcement points that actually own the decision.

Flow IDUse case IDsPathTriggerNumbered stepsTerminal evidence
D09-FLOW-01D09-UC-01HappyCustomer opens an order in tenant north-shop1. Edge verifies the OpenID Connect token.<br>2. Membership service confirms orders:read for north-shop.<br>3. API discards any untrusted tenant claim from the body.<br>4. Transaction binds the verified tenant.<br>5. Row-level policy filters the query.<br>6. Audit receipt records the decision.HTTP 200, subject, tenant, permission, policy revision, request ID, query outcome, environment, timestamp, and audit event ID
D09-FLOW-02D09-UC-01DeniedSame customer changes the route tenant to south-shop1. Edge verifies the identity.<br>2. Membership lookup finds no active membership.<br>3. Authorization returns 403.<br>4. No south-shop data transaction begins.<br>5. Authorized north-shop control remains healthy.Denial decision with requested and authorized tenant sets, zero foreign rows, unaffected positive control, timestamp, and trace ID
D09-FLOW-03D09-UC-02HappyOrder service requests an inventory reservation1. Runtime obtains its workload credential.<br>2. Token broker issues a short-lived Inventory-audience token.<br>3. Inventory verifies issuer, audience, expiry, service subject, scope, and tenant.<br>4. Database constraint preserves tenant ownership.<br>5. Reservation receipt returns.Reservation ID, workload subject, audience, scope, tenant, order ID, policy revision, deployment digest, timestamp, and trace ID
D09-FLOW-04D09-UC-02DeniedInventory token is replayed against Payment or with another tenant1. Receiving service checks its own audience.<br>2. Scope and tenant intersection fails.<br>3. Request is denied before mutation.<br>4. Cross-tenant SQL probe is rejected independently.<br>5. Valid Inventory control still succeeds.401 or 403, database constraint error where applicable, no payment call, positive control, environment, timestamp, and immutable run ID

System design derived from the flows

If every component can both establish identity and grant permissions, revocation and review become inconsistent. The design separates token verification, membership policy, service-token issuance, and database enforcement while preserving one correlated decision record.

Use case IDEntry pointResponsible servicesAuthoritative storeFailure evidence
D09-UC-01GET /tenants/:tenantId/orders/:orderIdEdge verifier, membership authorizer, Orders API, transaction wrapper, PostgreSQL row-level policy, audit writerIdentity provider owns authentication; membership store owns tenant roles; Orders PostgreSQL owns order rowsInvalid issuer/audience/expiry, inactive membership, tenant mismatch, empty row result, policy rejection, or unavailable audit receipt
D09-UC-02POST /internal/reservationsRuntime identity provider, token broker, Inventory verifier, reservation service, PostgreSQL constraints, audit writerWorkload platform owns runtime identity; broker owns grants; Inventory PostgreSQL owns stock and reservationsWrong audience, missing scope, expired credential, tenant mismatch, cross-tenant FK failure, or absent positive control

Data model and ownership

Tenant isolation fails when ownership is only an application convention, because one forgotten predicate can cross a customer boundary. Stable tenant keys, tenant-aware relationships, row policy, and separately owned identity records make the same invariant independently enforceable.

Generated-application database: Required in this slice — Orders and Inventory PostgreSQL stores own tenant-scoped order and reservation records that human and workload authorization protects.

Record or entityStore and ownerPrimary keyForeign key or opaque referenceTenant keyMaterial constraintLifecycle and deletionUse case IDs
TenantMembershipAuthorization store owned by membership servicemembership_idOpaque identity-provider subject; local tenant_idtenant_idActive role and permission version required; token claims cannot create membershipCreate on invitation, suspend or revoke immediately, retain bounded decision history, delete personal profile under policyD09-UC-01
OrderOrders PostgreSQL owned by Orders service(tenant_id, order_id)Opaque customer subject and payment referencestenant_idTenant participates in identity and every child reference; row policy enforcedRetain through fulfillment and customer retention; export then delete or tombstone by tenant lifecycleD09-UC-01, D09-UC-02
WorkloadGrantToken-broker store owned by platform securityworkload_grant_idOpaque runtime identity plus target service/audiencetenant_idExact audience, scopes, expiry ceiling, deployment digest, and revocation state requiredVersion, revoke, and expire; destroy credential material while retaining minimal decision evidenceD09-UC-02
InventoryReservationInventory PostgreSQL owned by Inventory service(tenant_id, reservation_id)Local tenant-aware stock FK; opaque order_idtenant_idReservation cannot reference another tenant's stock and idempotency key is unique per tenantConfirm or release; expire abandoned holds; delete with tenant after evidence retentionD09-UC-02
AuthorizationReceiptAppend-only security evidence store owned by audit serviceauthorization_receipt_idOpaque request, policy, actor, workload, order, and reservation referencestenant_idExpected/observed decision, enforcement point, environment, time, and immutable IDs required; no secret valuesAppend and seal; redact payloads; retain by audit policy; expire independently from domain rowsD09-UC-01, D09-UC-02

The smallest complete authorization model

The general rule is that identity answers who or what presented a credential, authorization decides whether that subject may perform this action, and the owning service enforces the decision against its own state. A valid token alone is never permission.

In the simple human case, the edge verifies a signed token and Orders checks current tenant membership. In the realistic service case, the order workload gets a different short-lived token whose audience is Inventory and whose scope is only inventory:reserve. The failure case is credential reuse: if Payment accepts the Inventory token, the audience boundary is broken. The reusable decision rule is: every receiving service validates its own audience and intersects current subject, tenant, action, resource, and policy before mutation.

Verify identity and derive trusted context

Unverified token fields are attacker-controlled text, so authorization must consume claims only after signature, issuer, audience, and time validation. OpenID Connect defines identity on top of OAuth 2.0, while JWT defines the signed claim format; neither standard says a tenant selector from a request body should be trusted.

import { createRemoteJWKSet, jwtVerify } from "jose";

type HumanContext = Readonly<{
  subject: string;
  tenantId: string;
  permissions: readonly string[];
}>;

const keys = createRemoteJWKSet(new URL(process.env.OIDC_JWKS_URL!));

export async function authorizeHuman(
  token: string,
  requestedTenant: string,
  loadMembership: (subject: string, tenantId: string) => Promise<readonly string[] | null>,
): Promise<HumanContext> {
  const { payload } = await jwtVerify(token, keys, {
    issuer: process.env.OIDC_ISSUER!,
    audience: "parcelflow-api",
  });
  if (typeof payload.sub !== "string") throw new Error("missing_subject");
  const permissions = await loadMembership(payload.sub, requestedTenant);
  if (!permissions?.includes("orders:read")) throw new Error("tenant_forbidden");
  return { subject: payload.sub, tenantId: requestedTenant, permissions };
}

The handler passes HumanContext, not the raw token, into the use case. Tests supply a context directly; production composition supplies the verifier. This is IoC applied to security: policy code depends on a narrow verified-context port rather than ambient headers.

Enforce tenant ownership in PostgreSQL

Application filtering is necessary but insufficient because a future query can omit it. PostgreSQL row-level security (RLS) applies a policy inside each service database, and tenant-aware local keys prevent a reservation from referencing another tenant's stock. The cross-service order_id remains opaque because PostgreSQL cannot and should not enforce a foreign key across independently owned service databases.

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;

CREATE POLICY orders_by_tenant ON orders
USING (tenant_id = current_setting('app.tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));

ALTER TABLE inventory_reservations
  ADD CONSTRAINT reservation_stock_tenant_fk
  FOREIGN KEY (tenant_id, sku)
  REFERENCES inventory_items (tenant_id, sku);

Bind the verified tenant for one transaction, never for the pooled session:

await sql.begin(async (tx) => {
  await tx`SELECT set_config('app.tenant_id', ${context.tenantId}, true)`;
  return tx`SELECT * FROM orders WHERE order_id = ${orderId}`;
});

The failure test deliberately omits the application predicate and still observes no foreign order row. Another test attempts to reserve a stock row owned by another tenant and expects Inventory's local composite foreign key to reject it; no Inventory migration references an Orders table.

Separate service authority and secret delivery

A secret answers how a workload authenticates; it should not decide every action the workload may perform. Runtime identity selects a narrowly scoped grant, the broker issues a short-lived target-audience token, and each service authorizes again.

type ServiceClaims = Readonly<{
  service: "orders";
  audience: "inventory";
  scope: readonly ["inventory:reserve"];
  tenantId: string;
  deploymentDigest: string;
  expiresAt: number;
}>;

export function assertInventoryAuthority(claims: ServiceClaims, now: number): void {
  if (claims.audience !== "inventory") throw new Error("wrong_audience");
  if (!claims.scope.includes("inventory:reserve")) throw new Error("missing_scope");
  if (claims.expiresAt <= now) throw new Error("expired_workload_token");
}

Environment variables are a delivery mechanism, not a safe place to print or persist secrets. Production deployment uses a managed secret store and task identity; local tests use disposable values. Logs retain secret references and versions, never values.

Run negative probes before accepting the boundary

Security evidence must include an unaffected positive control, otherwise an outage can masquerade as a safe denial. The following test script exercises both human and workload boundaries:

bun test tests/security/tenant-boundary.test.ts
bun test tests/security/workload-audience.test.ts
bun run scripts/probe-auth.ts --case authorized-order-read
bun run scripts/probe-auth.ts --case forged-tenant-read
bun run scripts/probe-auth.ts --case wrong-audience-reserve
bun run scripts/probe-auth.ts --case cross-tenant-insert

Reject the day if a denial lacks actor, requested resource, verified tenant, enforcement point, expected and observed result, environment, timestamp, immutable ID, or healthy control. A 403 alone is not enough evidence.

Failure modes, trade-offs, and decision rule

Cached membership improves latency but delays revocation; short-lived service tokens reduce credential lifetime but increase broker dependency; RLS adds defense but cannot repair an incorrectly trusted tenant context. Prefer short caches with explicit invalidation, audience-specific credentials, transaction-scoped tenant binding, and database constraints. Never fail open on a cross-tenant read or privileged mutation.

Decision rule: authorize at every owning boundary from verified current context, grant the smallest action and lifetime, enforce tenant ownership again in storage, and accept the design only after positive and adversarial probes both produce immutable evidence.

Primary sources

Security behavior changes across standards, runtimes, and database versions, so unsupported memory can produce an invalid boundary. These primary specifications and vendor references anchor the claims that the implementation and negative probes must re-verify.

Key takeaways

Security controls become misleading when their responsibilities blur, because a valid credential can be mistaken for tenant or service permission. Keep these conclusions attached to the independent enforcement evidence.

  • Authentication, human authorization, workload authorization, and database tenant enforcement are separate decisions.
  • A token's audience and scope are enforced by the receiving service; possession is not ambient authority.
  • Tenant ownership belongs in keys, relationships, transactions, and row policy, not only endpoint filters.
  • Every denial needs an authorized positive control and immutable decision evidence.

Checklist

An authorization design is not ready when only its positive demo works, because cross-tenant and wrong-audience failures are the decisive safety evidence. Use this list to review both allow and denial paths before accepting the slice.

  • [ ] Human token issuer, signature, audience, expiry, subject, membership, and permission are verified.
  • [ ] Workload tokens are short-lived, audience-specific, tenant-bounded, and independently revocable.
  • [ ] Tenant context is transaction-scoped and derived from verified authority.
  • [ ] Composite relationships and RLS reject cross-tenant access.
  • [ ] Secret values never enter logs or evidence records.
  • [ ] Positive, forged-tenant, wrong-audience, and cross-tenant SQL probes are retained with immutable IDs.