Initial Commit
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
# `request-pipeline-steps` — §6 step gate functions
|
||||
|
||||
## Purpose
|
||||
|
||||
This module hosts the six per-request gate functions the §6 pipeline runs after route match and before plugin handler invocation: tenant resolution, session resolution, CSRF check, RBAC scope gate, rate-limit consume, and idempotency-key replay check. Each function returns a discriminated `GateOutcome` so the orchestrator can chain `pass → next step` and `fail → failure response` without inheriting the LOC weight of the inline implementations.
|
||||
|
||||
The functions were extracted from `request-pipeline.ts` verbatim during the file-size refactor. No behaviour, code path, telemetry counter, or audit envelope was changed; every existing end-to-end pipeline test passes against the extracted forms without modification.
|
||||
|
||||
## Features
|
||||
|
||||
- `runTenantStep` resolves the tenant via the configured chain, applies the `defaultTenant` fallback for public routes, and emits `api.tenant.unresolved_total` on `auth`-required misses.
|
||||
- `runSessionStep` resolves the session principal through the boundary; throws map to `HOST.SESSION_REQUIRED` and increment `api.auth.failures_total{reason}`.
|
||||
- `runCsrfStep` (post-session) returns `pass` for GET/HEAD, streaming routes, non-cookie public requests, and `csrf` flag unset; otherwise consults the validator.
|
||||
- `runRbacStep` compares `principal.scopes` to `route.options.auth`; failures emit `api.rbac.denials_total{plugin, route, missing_scope}`.
|
||||
- `runRateLimitStep` buckets by principal id, else tenant id, else `"anon"`; failures emit `api.rate_limit.exceeded_total{key_kind}`.
|
||||
- `runIdempotencyStep` consults the configured store for `idempotent: true` routes carrying an `Idempotency-Key`; replays emit `api.idempotency.replays_total`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
runRbacStep,
|
||||
runTenantStep,
|
||||
} from "@suaveplan/api-host/request-pipeline-steps";
|
||||
|
||||
const tenantOutcome = await runTenantStep({
|
||||
boundary,
|
||||
tenant: tenantChain,
|
||||
route,
|
||||
span,
|
||||
request,
|
||||
defaultTenant,
|
||||
instruments,
|
||||
});
|
||||
if (tenantOutcome.kind === "fail") {
|
||||
return failResult(config, { ...args, failure: tenantOutcome.failure });
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
`GateOutcome` is a discriminated union shared by every step in this module, so the orchestrator can collapse multiple gates with a uniform fail-side closure:
|
||||
|
||||
```ts
|
||||
const onFail = (g: Extract<GateOutcome<unknown>, { kind: "fail" }>) =>
|
||||
failResult(config, { ...carry, failure: g.failure });
|
||||
|
||||
const csrf = runCsrfStep({ csrf, route, request, principal });
|
||||
if (csrf.kind === "fail") return onFail(csrf);
|
||||
const rbac = runRbacStep({ route, principal, instruments });
|
||||
if (rbac.kind === "fail") return onFail(rbac);
|
||||
```
|
||||
|
||||
Note: side-effecting gates (`runRateLimitStep` consumes a bucket token; `runIdempotencyStep` writes the key entry) MUST run sequentially with early-return on `fail` — running them in parallel changes observable outcomes.
|
||||
|
||||
## API Reference
|
||||
|
||||
```ts
|
||||
type GateOutcome<T> =
|
||||
| { kind: "pass"; value: T }
|
||||
| { kind: "fail"; failure: BoundaryFailure };
|
||||
|
||||
function runTenantStep(args): Promise<GateOutcome<TenantContext | null>>;
|
||||
function runSessionStep(args): Promise<GateOutcome<Principal | null>>;
|
||||
function runCsrfStep(args): GateOutcome<undefined>;
|
||||
function runRbacStep(args): GateOutcome<undefined>;
|
||||
function runRateLimitStep(args): GateOutcome<undefined>;
|
||||
function runIdempotencyStep(args): GateOutcome<undefined>;
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Tenant + session both go through `boundary.runStep` because their resolver implementations can throw (network failure, upstream auth down). CSRF / RBAC / rate-limit / idempotency are synchronous and do not need boundary wrapping — they emit dedicated counters but never throw.
|
||||
|
||||
`runRbacStep` short-circuits to `pass` when `route.options.auth` is `null` or `undefined`. That is the public-route signal; the call-site does NOT need to gate on it. A request that reaches `runRbacStep` with `principal === null` and a non-public route raises `HOST.SESSION_REQUIRED` (401), not `HOST.RBAC_DENIED` — the audit log shows that the access was rejected for missing identity, not missing scope.
|
||||
|
||||
The `idempotency` step writes the TTL entry only on `pass`. A replay returns `fail` without touching the store, preserving the original entry's expiry. Routes that are `idempotent: true` but receive no `Idempotency-Key` short-circuit to `pass` without storing anything, matching the spec's "key is optional on idempotent routes" provision.
|
||||
Reference in New Issue
Block a user