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 ID | Actor | Customer job | Success outcome | Denial or recovery evidence |
|---|---|---|---|---|
| D09-UC-01 | ParcelFlow customer member | Read an order belonging to the active tenant | The API returns only the requested tenant's order and records the verified subject, tenant, permission, policy version, and request ID | A forged tenant selector or missing membership returns 403 without querying another tenant's rows; an authorized control request still succeeds |
| D09-UC-02 | Fulfillment workload | Reserve stock for one authorized order | Inventory accepts a short-lived workload token for the exact service audience and tenant-scoped command | An 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 ID | Use case IDs | User story | Observable acceptance conditions |
|---|---|---|---|
| D09-US-01 | D09-UC-01 | As a customer member, I want my active organization derived from verified membership, so that changing a URL cannot expose another customer's orders | Valid 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-02 | D09-UC-02 | As an inventory owner, I want workload identity checked independently from human identity, so that an order-service process receives only reserve-stock authority | The 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 ID | Use case IDs | Path | Trigger | Numbered steps | Terminal evidence |
|---|---|---|---|---|---|
| D09-FLOW-01 | D09-UC-01 | Happy | Customer opens an order in tenant north-shop | 1. 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-02 | D09-UC-01 | Denied | Same customer changes the route tenant to south-shop | 1. 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-03 | D09-UC-02 | Happy | Order service requests an inventory reservation | 1. 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-04 | D09-UC-02 | Denied | Inventory token is replayed against Payment or with another tenant | 1. 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 ID | Entry point | Responsible services | Authoritative store | Failure evidence |
|---|---|---|---|---|
| D09-UC-01 | GET /tenants/:tenantId/orders/:orderId | Edge verifier, membership authorizer, Orders API, transaction wrapper, PostgreSQL row-level policy, audit writer | Identity provider owns authentication; membership store owns tenant roles; Orders PostgreSQL owns order rows | Invalid issuer/audience/expiry, inactive membership, tenant mismatch, empty row result, policy rejection, or unavailable audit receipt |
| D09-UC-02 | POST /internal/reservations | Runtime identity provider, token broker, Inventory verifier, reservation service, PostgreSQL constraints, audit writer | Workload platform owns runtime identity; broker owns grants; Inventory PostgreSQL owns stock and reservations | Wrong 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 entity | Store and owner | Primary key | Foreign key or opaque reference | Tenant key | Material constraint | Lifecycle and deletion | Use case IDs |
|---|---|---|---|---|---|---|---|
| TenantMembership | Authorization store owned by membership service | membership_id | Opaque identity-provider subject; local tenant_id | tenant_id | Active role and permission version required; token claims cannot create membership | Create on invitation, suspend or revoke immediately, retain bounded decision history, delete personal profile under policy | D09-UC-01 |
| Order | Orders PostgreSQL owned by Orders service | (tenant_id, order_id) | Opaque customer subject and payment references | tenant_id | Tenant participates in identity and every child reference; row policy enforced | Retain through fulfillment and customer retention; export then delete or tombstone by tenant lifecycle | D09-UC-01, D09-UC-02 |
| WorkloadGrant | Token-broker store owned by platform security | workload_grant_id | Opaque runtime identity plus target service/audience | tenant_id | Exact audience, scopes, expiry ceiling, deployment digest, and revocation state required | Version, revoke, and expire; destroy credential material while retaining minimal decision evidence | D09-UC-02 |
| InventoryReservation | Inventory PostgreSQL owned by Inventory service | (tenant_id, reservation_id) | Local tenant-aware stock FK; opaque order_id | tenant_id | Reservation cannot reference another tenant's stock and idempotency key is unique per tenant | Confirm or release; expire abandoned holds; delete with tenant after evidence retention | D09-UC-02 |
| AuthorizationReceipt | Append-only security evidence store owned by audit service | authorization_receipt_id | Opaque request, policy, actor, workload, order, and reservation references | tenant_id | Expected/observed decision, enforcement point, environment, time, and immutable IDs required; no secret values | Append and seal; redact payloads; retain by audit policy; expire independently from domain rows | D09-UC-01, D09-UC-02 |
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.
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.