first commit
This commit is contained in:
102
docs/api-stack/api/api-tenant-resolver-header/README.md
Normal file
102
docs/api-stack/api/api-tenant-resolver-header/README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# @suaveplan/api-tenant-resolver-header — header-based tenant resolver for the Suaveplan API plugin system. Implements the TenantResolver contract from @suaveplan/api-types by reading the tenant identifier from a configurable HTTP header (default X-Tenant-Id), validating against a regex (default ^[a-z0-9-]{1,64}$) and an optional registry (a `lookup`-shaped TenantLookup compatible with @suaveplan/api-tenant-provider), and enforcing trusted-source metadata so end-user clients cannot spoof tenant headers. Strict mode raises namespaced TENANT_HEADER_INVALID / NOT_FOUND_IN_REGISTRY / UNTRUSTED_SOURCE errors; non-strict mode returns null so the resolver chain advances. Emits api.tenant.resolves_total{resolver="header"} counters via the OTel global meter.
|
||||
|
||||
Stage-8 tenant resolver. Implements the `TenantResolver` contract from
|
||||
`@suaveplan/api-types/tenant-resolver` by reading the tenant identifier from
|
||||
a configurable HTTP header (default `X-Tenant-Id`), validating its shape
|
||||
against a regex (default `^[a-z0-9-]{1,64}$`), optionally probing a
|
||||
registry, and enforcing trusted-source metadata so end-user clients cannot
|
||||
spoof the tenant.
|
||||
|
||||
```ts
|
||||
import { createHeaderTenantResolver } from "@suaveplan/api-tenant-resolver-header";
|
||||
|
||||
const resolver = createHeaderTenantResolver();
|
||||
// service-mesh middleware stamps request.metadata.source = "service-mesh"
|
||||
const ctx = await resolver.resolve(request);
|
||||
// → { id: "acme", slug: "acme" } | null
|
||||
```
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry for `@suaveplan/api-tenant-resolver-header`. Every public failure path raises a…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry for `@suaveplan/api-tenant-resolver-header`. Every public failure path raises a…
|
||||
- [instruments](./src/instruments/instruments.md) — Builds the OTel instrument bundle the header tenant resolver emits on every `resolve()` invocation. Centralising instru…
|
||||
- [instruments](./src/instruments/instruments.md) — Builds the OTel instrument bundle the header tenant resolver emits on every `resolve()` invocation. Centralising instru…
|
||||
- [options](./src/options/options.md) — Defines the Zod-validated options schema accepted by `createHeaderTenantResolver`. Construction-time validation guarant…
|
||||
- [options](./src/options/options.md) — Defines the Zod-validated options schema accepted by `createHeaderTenantResolver`. Construction-time validation guarant…
|
||||
- [resolver](./src/resolver/resolver.md) — Implements the Stage-8 tenant resolver factory `createHeaderTenantResolver`. The resolver reads the tenant identifier f…
|
||||
- [resolver](./src/resolver/resolver.md) — Implements the Stage-8 tenant resolver factory `createHeaderTenantResolver`. The resolver reads the tenant identifier f…
|
||||
- [trust](./src/trust/trust.md) — Encapsulates the trusted-source enforcement consulted by the header tenant resolver before it acts on a header value. T…
|
||||
- [trust](./src/trust/trust.md) — Encapsulates the trusted-source enforcement consulted by the header tenant resolver before it acts on a header value. T…
|
||||
- [validate](./src/validate/validate.md) — Owns the default tenant-id validator regex and the validator / registry function signatures consumed by `createHeaderTe…
|
||||
- [validate](./src/validate/validate.md) — Owns the default tenant-id validator regex and the validator / registry function signatures consumed by `createHeaderTe…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Why a dedicated package
|
||||
|
||||
The tenant-resolver chain (design doc §6 step 4) runs after the session
|
||||
resolver. Service-to-service traffic frequently carries the tenant in an
|
||||
HTTP header (`X-Tenant-Id`, `X-Acme-Tenant`, …) because no end-user session
|
||||
exists to resolve it from. This adapter handles that case while preventing
|
||||
header spoofing from public traffic via the trusted-source gate.
|
||||
|
||||
## Chain interaction
|
||||
|
||||
The host runs resolvers left-to-right; the first non-`null` result wins:
|
||||
|
||||
```text
|
||||
[ api-tenant-resolver-jwt, api-tenant-resolver-header, api-tenant-resolver-subdomain ]
|
||||
| | |
|
||||
reads principal reads X-Tenant-Id reads sub.domain
|
||||
(typical bearer) (admin / S2S calls) (public site routes)
|
||||
```
|
||||
|
||||
`api-tenant-resolver-header` returns `null` when the header is absent, when
|
||||
the request source is untrusted (non-strict), when the value fails
|
||||
validation (non-strict), or when the value isn't in the registry
|
||||
(non-strict). In strict mode each of those paths raises the corresponding
|
||||
`TENANT_HEADER_*` code.
|
||||
|
||||
## Modules
|
||||
|
||||
- `./resolver` — `createHeaderTenantResolver`,
|
||||
`CreateHeaderTenantResolverOptions`.
|
||||
- `./options` — `HeaderTenantResolverOptions`,
|
||||
`parseHeaderTenantResolverOptions`, `ResolvedHeaderTenantResolverOptions`.
|
||||
- `./validate` — `defaultTenantIdValidator`, `DEFAULT_TENANT_ID_PATTERN`,
|
||||
`TenantIdValidator`, `TenantRegistryProbe`.
|
||||
- `./trust` — `defaultMetadataExtractor`, `isTrustedSource`,
|
||||
`RequestMetadataExtractor`, `RequestTrustMetadata`.
|
||||
- `./instruments` — `createHeaderResolverInstruments`,
|
||||
`HeaderResolverInstruments`, `HeaderResolveOutcome`.
|
||||
- `./error-codes` — `HeaderTenantResolverError`,
|
||||
`HeaderTenantResolverErrorCode`, `TENANT_HEADER_CODES`.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Default | Effect |
|
||||
| ---------------- | -------------------- | ---------------------------------------------------------------------------- |
|
||||
| `id` | `"header"` | Resolver id used in chain logs and metric tags. |
|
||||
| `headerName` | `"X-Tenant-Id"` | HTTP header consulted; case-insensitive. |
|
||||
| `strict` | `false` | When `true`, failures raise typed errors. When `false`, failures yield `null`. |
|
||||
| `trustedSources` | `["service-mesh"]` | Values for `request.metadata.source` that may set the tenant header. |
|
||||
| `validate` | `^[a-z0-9-]{1,64}$` | Validator applied to the header value. |
|
||||
| `registry` | `undefined` | Optional sync-or-async membership probe; runs only after validator passes. |
|
||||
| `getMetadata` | reads `req.metadata` | Override for the trust-metadata extractor. |
|
||||
| `instruments` | OTel global meter | Override for the instrument bundle. |
|
||||
|
||||
## Error codes
|
||||
|
||||
- `TENANT_HEADER.INVALID` — header value failed validator (strict-only).
|
||||
- `TENANT_HEADER.NOT_FOUND_IN_REGISTRY` — value not in registry (strict-only).
|
||||
- `TENANT_HEADER.UNTRUSTED_SOURCE` — request source not in `trustedSources`
|
||||
(strict-only).
|
||||
- `TENANT_HEADER.INVALID_OPTIONS` — construction-time Zod failure.
|
||||
|
||||
## Observability
|
||||
|
||||
- Counter `api.tenant.resolves_total{resolver="header", outcome}` — exactly
|
||||
one increment per `resolve()` call. `outcome` is one of `resolved`,
|
||||
`absent`, `invalid`, `not_found_in_registry`, `untrusted_source`.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable error-code registry for
|
||||
`@suaveplan/api-tenant-resolver-header`. Every public failure path raises a
|
||||
{@link HeaderTenantResolverError} carrying one of the namespaced
|
||||
`TENANT_HEADER.*` codes so consumers branch on a string value rather than a
|
||||
brittle `instanceof` check on a third-party concrete error class.
|
||||
|
||||
The registry is the single source of truth for the resolver's wire-level
|
||||
failure modes. Strict-mode hosts map the codes to HTTP responses (`INVALID`,
|
||||
`NOT_FOUND_IN_REGISTRY`, `UNTRUSTED_SOURCE` all surface as HTTP 400 unless
|
||||
the host overrides the mapping); non-strict-mode hosts swallow the codes by
|
||||
returning `null` from the resolver so the chain advances.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen four-entry registry: `INVALID`, `NOT_FOUND_IN_REGISTRY`,
|
||||
`UNTRUSTED_SOURCE`, `INVALID_OPTIONS`.
|
||||
- Namespaced `TENANT_HEADER.*` codes built via `defineCodes` from
|
||||
`@suaveplan/error`.
|
||||
- `HeaderTenantResolverError` subclass narrows `code` for downstream
|
||||
pattern-matching and carries a `headerTenantResolverCode` mirror field for
|
||||
ergonomic switch statements.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
HeaderTenantResolverError,
|
||||
TENANT_HEADER_CODES,
|
||||
} from "@suaveplan/api-tenant-resolver-header/error-codes";
|
||||
|
||||
throw new HeaderTenantResolverError(
|
||||
TENANT_HEADER_CODES.INVALID,
|
||||
"X-Tenant-Id 'Acme!' does not match ^[a-z0-9-]{1,64}$"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
HeaderTenantResolverError,
|
||||
TENANT_HEADER_CODES,
|
||||
} from "@suaveplan/api-tenant-resolver-header/error-codes";
|
||||
|
||||
function toHttpStatus(error: HeaderTenantResolverError): number {
|
||||
switch (error.code) {
|
||||
case TENANT_HEADER_CODES.INVALID:
|
||||
case TENANT_HEADER_CODES.NOT_FOUND_IN_REGISTRY:
|
||||
case TENANT_HEADER_CODES.UNTRUSTED_SOURCE:
|
||||
return 400;
|
||||
case TENANT_HEADER_CODES.INVALID_OPTIONS:
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `TENANT_HEADER_CODES` — frozen registry with keys `INVALID`,
|
||||
`NOT_FOUND_IN_REGISTRY`, `UNTRUSTED_SOURCE`, `INVALID_OPTIONS`.
|
||||
- `type HeaderTenantResolverErrorCode` — string union of the four code
|
||||
values, e.g. `"TENANT_HEADER.INVALID"`.
|
||||
- `class HeaderTenantResolverError extends SuaveplanError<HeaderTenantResolverErrorCode>`
|
||||
— accepts `(code, message, { cause? })` and exposes
|
||||
`headerTenantResolverCode` alongside the inherited `code` field.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is intentionally minimal — four codes covering the resolver's
|
||||
four distinct outcomes. Adding a code requires a spec delta because the codes
|
||||
are part of the package's wire contract. Hosts that need finer-grained
|
||||
diagnostics attach detail via `error.cause`; the codes themselves stay coarse
|
||||
so consumers' switch statements don't proliferate when new validators are
|
||||
plugged in.
|
||||
|
||||
`defineCodes` freezes the returned object so accidental mutation of the
|
||||
registry at runtime is impossible — the four `TENANT_HEADER.*` strings are
|
||||
the only values that will ever flow through the public surface.
|
||||
@@ -0,0 +1,74 @@
|
||||
# `instruments`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds the OTel instrument bundle the header tenant resolver emits on every
|
||||
`resolve()` invocation. Centralising instrument creation keeps the resolver
|
||||
factory free of meter plumbing — the resolver simply records against the
|
||||
frozen counter returned here. The counter name (`api.tenant.resolves_total`)
|
||||
follows the API plugin system design doc §6 step 4 so a missing entry
|
||||
is a spec deviation a CI gate could detect.
|
||||
|
||||
The instrument is wired against the OTel global meter provider by default,
|
||||
which falls back to a no-op implementation when no SDK is installed. Tests
|
||||
need not configure a provider; production hosts register their SDK once at
|
||||
boot and the resolver picks it up automatically.
|
||||
|
||||
## Features
|
||||
|
||||
- One counter: `api.tenant.resolves_total` labelled
|
||||
`{ resolver: "header", outcome }`.
|
||||
- `outcome` attribute drawn from a closed five-value union
|
||||
(`resolved`, `absent`, `invalid`, `not_found_in_registry`,
|
||||
`untrusted_source`) so dashboards can break down resolver behaviour.
|
||||
- Optional `meterProvider` injection for tests that want a captive
|
||||
provider; defaults to the OTel global.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createHeaderResolverInstruments } from "@suaveplan/api-tenant-resolver-header/instruments";
|
||||
|
||||
const instruments = createHeaderResolverInstruments();
|
||||
instruments.resolvesTotal.add(1, { resolver: "header", outcome: "resolved" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import { createHeaderResolverInstruments } from "@suaveplan/api-tenant-resolver-header/instruments";
|
||||
|
||||
const provider = new MeterProvider();
|
||||
const instruments = createHeaderResolverInstruments({
|
||||
meterProvider: provider,
|
||||
});
|
||||
// hook instruments.meter into a Prometheus exporter to scrape
|
||||
// `api_tenant_resolves_total{resolver="header",outcome="invalid"}` etc.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface HeaderResolverInstruments` — `{ meter, resolvesTotal }`.
|
||||
- `type HeaderResolveOutcome` — five-value union of outcome attribute
|
||||
values.
|
||||
- `function createHeaderResolverInstruments(config?: { meterProvider? })`
|
||||
— returns a frozen instrument bundle.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bundle is frozen with `Object.freeze` so consumers cannot accidentally
|
||||
swap the counter instance at runtime. The counter is created lazily-per-
|
||||
resolver-instance rather than module-level so a host that wants different
|
||||
meter providers per host instance (multi-tenant test harness) can wire each
|
||||
resolver separately.
|
||||
|
||||
A counter — not a histogram — is the right primitive here because the
|
||||
quantity being observed is the count of resolver invocations broken down by
|
||||
outcome, not a duration. Resolver latency is dominated by the registry
|
||||
probe; hosts that need probe latency wire a histogram around their
|
||||
`TenantRegistryProbe` rather than expecting this package to time it.
|
||||
@@ -0,0 +1,91 @@
|
||||
# `options`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the Zod-validated options schema accepted by
|
||||
`createHeaderTenantResolver`. Construction-time validation guarantees a host
|
||||
mis-config raises a stable `TENANT_HEADER.INVALID_OPTIONS` code rather than a
|
||||
runtime `TypeError` on the first request, and downstream code consumes the
|
||||
resolved option bundle (`ResolvedHeaderTenantResolverOptions`) which has
|
||||
every field populated with a default.
|
||||
|
||||
The schema is `.strict()` so unknown keys raise — typos in `headername`,
|
||||
`trusted_sources`, or `requirePrincipal` (a JWT-resolver option that has no
|
||||
meaning here) fail loudly at construction.
|
||||
|
||||
## Features
|
||||
|
||||
- Zero-config defaults: `id="header"`, `headerName="x-tenant-id"`,
|
||||
`strict=false`, `trustedSources=["service-mesh"]`.
|
||||
- Strict Zod object (unknown keys rejected) so option name typos surface as
|
||||
`INVALID_OPTIONS` rather than silently no-oping.
|
||||
- `headerName` normalised to lowercase at parse time so the resolver's
|
||||
`Headers.get(...)` lookup is case-insensitive in a single place.
|
||||
- `trustedSources` defensively frozen so the resolver never mutates the
|
||||
caller's array.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseHeaderTenantResolverOptions } from "@suaveplan/api-tenant-resolver-header/options";
|
||||
|
||||
const opts = parseHeaderTenantResolverOptions(undefined);
|
||||
// → { id: "header", headerName: "x-tenant-id", strict: false,
|
||||
// trustedSources: ["service-mesh"] }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
HeaderTenantResolverError,
|
||||
TENANT_HEADER_CODES,
|
||||
} from "@suaveplan/api-tenant-resolver-header/error-codes";
|
||||
import { parseHeaderTenantResolverOptions } from "@suaveplan/api-tenant-resolver-header/options";
|
||||
|
||||
try {
|
||||
parseHeaderTenantResolverOptions({
|
||||
id: "primary",
|
||||
headerName: "X-Acme-Tenant",
|
||||
strict: true,
|
||||
trustedSources: ["service-mesh", "internal-gateway"],
|
||||
});
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof HeaderTenantResolverError &&
|
||||
e.code === TENANT_HEADER_CODES.INVALID_OPTIONS
|
||||
) {
|
||||
// surface to operator as a 5xx
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface HeaderTenantResolverOptions` — all fields optional; describes
|
||||
user input.
|
||||
- `interface ResolvedHeaderTenantResolverOptions` — every field required;
|
||||
describes the parsed bundle the resolver consumes internally.
|
||||
- `function parseHeaderTenantResolverOptions(input)
|
||||
: ResolvedHeaderTenantResolverOptions`
|
||||
— throws `HeaderTenantResolverError(INVALID_OPTIONS)` with the underlying
|
||||
`ZodError` attached as `cause` on any validation failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`headerName` is lowercased because the WHATWG `Headers` object's `get`
|
||||
method is case-insensitive — keeping the configured value lowercase means
|
||||
direct string comparison stays correct in the trust-source path (where the
|
||||
resolver references `resolved.headerName` for diagnostic messages).
|
||||
`trustedSources` is frozen after parsing so the resolver's runtime path can
|
||||
share the same array across requests without defensive copying.
|
||||
|
||||
The default `trustedSources` of `["service-mesh"]` aligns with the design
|
||||
intent that a tenant header is a service-to-service contract, never an
|
||||
end-user-controllable input. Operators who need to widen the trust set must
|
||||
do so explicitly in host config; there is no default that admits browser
|
||||
traffic.
|
||||
@@ -0,0 +1,108 @@
|
||||
# `resolver`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements The tenant resolver factory `createHeaderTenantResolver`.
|
||||
The resolver reads the tenant identifier from a configurable HTTP header
|
||||
(default `X-Tenant-Id`), enforces trusted-source metadata, runs a
|
||||
configurable validator, and (optionally) consults a registry probe before
|
||||
projecting the value into a `TenantContext` for the host's per-request
|
||||
pipeline. Each invocation emits exactly one
|
||||
`api.tenant.resolves_total{resolver="header", outcome}` counter increment.
|
||||
|
||||
The resolver short-circuits in the order: absent → untrusted → invalid →
|
||||
registry → resolved. The order matters because untrusted-source enforcement
|
||||
must happen BEFORE the value is examined so a misconfigured client never
|
||||
sees the validator's regex pattern echoed back in an error message.
|
||||
|
||||
## Features
|
||||
|
||||
- `createHeaderTenantResolver(opts?)` factory returning a `TenantResolver`
|
||||
that conforms to `@suaveplan/api-types/tenant-resolver`.
|
||||
- Configurable `headerName` (case-insensitive); defaults to `X-Tenant-Id`.
|
||||
- Injectable `validate` function — defaults to `^[a-z0-9-]{1,64}$`.
|
||||
- Optional `registry` probe (sync or async) — fired only after validator
|
||||
passes so an attacker probing for tenant existence cannot bypass shape
|
||||
enforcement.
|
||||
- Trusted-source gate via injectable `getMetadata` extractor; defaults to
|
||||
reading `request.metadata.source`.
|
||||
- Strict-mode toggle: raise `TENANT_HEADER_*` errors vs return `null` so a
|
||||
downstream resolver may take over.
|
||||
- OTel counter `api.tenant.resolves_total{resolver="header",outcome}` —
|
||||
every code path increments exactly once per `resolve()` call.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createHeaderTenantResolver } from "@suaveplan/api-tenant-resolver-header/resolver";
|
||||
|
||||
const resolver = createHeaderTenantResolver();
|
||||
// caller wires the request after ingress middleware stamps the metadata:
|
||||
(request as Request & { metadata: { source: string } }).metadata = {
|
||||
source: "service-mesh",
|
||||
};
|
||||
const ctx = await resolver.resolve(request);
|
||||
// → { id: "acme", slug: "acme" } | null
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { TenantProvider } from "@suaveplan/api-tenant-provider/provider";
|
||||
import { createHeaderTenantResolver } from "@suaveplan/api-tenant-resolver-header/resolver";
|
||||
import type { TenantRegistryProbe } from "@suaveplan/api-tenant-resolver-header/validate";
|
||||
|
||||
declare const provider: TenantProvider;
|
||||
const registry: TenantRegistryProbe = async (tid) =>
|
||||
(await provider.lookup(tid as never)) !== null;
|
||||
|
||||
const resolver = createHeaderTenantResolver({
|
||||
id: "header-primary",
|
||||
headerName: "X-Acme-Tenant",
|
||||
strict: true,
|
||||
trustedSources: ["service-mesh", "internal-gateway"],
|
||||
validate: (v) => /^acme-[a-z0-9]{8}$/.test(v),
|
||||
registry,
|
||||
getMetadata: (req) => sidecar.get(req) ?? null,
|
||||
});
|
||||
|
||||
const ctx = await resolver.resolve(request);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface CreateHeaderTenantResolverOptions extends HeaderTenantResolverOptions`:
|
||||
- `validate?: TenantIdValidator`
|
||||
- `registry?: TenantRegistryProbe`
|
||||
- `getMetadata?: RequestMetadataExtractor`
|
||||
- `instruments?: HeaderResolverInstruments`
|
||||
- `function createHeaderTenantResolver(options?): TenantResolver` — returns
|
||||
`{ id, resolve(request) }`. `resolve` returns
|
||||
`Promise<TenantContext | null>`; throws
|
||||
`HeaderTenantResolverError(INVALID | UNTRUSTED_SOURCE |
|
||||
NOT_FOUND_IN_REGISTRY)` only when `strict: true`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The resolver checks **trusted-source before validation** so a public client
|
||||
sending `X-Tenant-Id: <attack-payload>` never reaches the validator's
|
||||
diagnostic path. This is a defence-in-depth choice — the validator itself
|
||||
should not be exploitable, but minimising the surface that untrusted input
|
||||
reaches narrows the audit footprint.
|
||||
|
||||
The OTel counter increments **exactly once** per `resolve()` call. Every
|
||||
branch — including the `absent` and `null`-return non-strict paths — calls
|
||||
`increment` exactly once. The `outcome` attribute makes the breakdown
|
||||
queryable from dashboards: a sudden spike in `outcome="invalid"` signals
|
||||
either a misconfigured client or a probing attacker, and a spike in
|
||||
`outcome="untrusted_source"` signals that an ingress adapter has stopped
|
||||
stamping `source` correctly.
|
||||
|
||||
The brand-cast to `TenantId` happens at the trust boundary: the value has
|
||||
passed the validator (shape) and the registry (existence) when the cast
|
||||
fires. Consumers downstream get an opaque branded string they cannot have
|
||||
fabricated from user input.
|
||||
@@ -0,0 +1,86 @@
|
||||
# `trust`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Encapsulates the trusted-source enforcement consulted by the header tenant
|
||||
resolver before it acts on a header value. The check exists because an
|
||||
HTTP header is end-user-controllable on public traffic; without a
|
||||
trusted-source gate, any browser could spoof the tenant by sending
|
||||
`X-Tenant-Id: competitor`.
|
||||
|
||||
Upstream middleware (a service-mesh adapter, an mTLS-verified ingress,
|
||||
internal-network admission gate) stamps a `source` value on
|
||||
`request.metadata`. The resolver consults that stamp via `isTrustedSource`;
|
||||
when the source is missing or absent from the configured allow-list, the
|
||||
header is ignored (strict-mode raises `TENANT_HEADER.UNTRUSTED_SOURCE`,
|
||||
non-strict-mode returns `null` so the chain advances).
|
||||
|
||||
## Features
|
||||
|
||||
- `RequestTrustMetadata` shape — `{ source?: string }` minimal contract.
|
||||
- `RequestMetadataExtractor` injectable extractor with a fail-closed
|
||||
default that reads `request.metadata`.
|
||||
- `isTrustedSource(metadata, trustedSources)` — fail-closed boolean.
|
||||
Missing metadata, missing `source`, or a `source` not in the allow-list
|
||||
all yield `false`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
defaultMetadataExtractor,
|
||||
isTrustedSource,
|
||||
} from "@suaveplan/api-tenant-resolver-header/trust";
|
||||
|
||||
const metadata = defaultMetadataExtractor(request);
|
||||
if (isTrustedSource(metadata, ["service-mesh"])) {
|
||||
// header may be consulted
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
RequestMetadataExtractor,
|
||||
RequestTrustMetadata,
|
||||
} from "@suaveplan/api-tenant-resolver-header/trust";
|
||||
|
||||
const sidecar = new WeakMap<Request, RequestTrustMetadata>();
|
||||
|
||||
const fromSidecar: RequestMetadataExtractor = (req) =>
|
||||
sidecar.get(req) ?? null;
|
||||
|
||||
// ingress middleware stamps a frozen Request via a WeakMap rather than
|
||||
// mutating the request itself:
|
||||
sidecar.set(request, { source: "service-mesh" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface RequestTrustMetadata` — `{ readonly source?: string }`.
|
||||
- `type RequestMetadataExtractor = (request: Request)
|
||||
=> RequestTrustMetadata | null | undefined`.
|
||||
- `const defaultMetadataExtractor: RequestMetadataExtractor` — reads
|
||||
`request.metadata`.
|
||||
- `function isTrustedSource(metadata, trustedSources): boolean` —
|
||||
fail-closed allow-list check.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`isTrustedSource` returns `false` for absent metadata so the policy is
|
||||
fail-closed by default. The alternative — treating "no metadata" as
|
||||
"trust by default" — would mean a single misconfigured ingress (one that
|
||||
forgets to set `source`) opens the entire fleet to header spoofing. The
|
||||
trade-off is that hosts MUST wire an ingress adapter that stamps `source`
|
||||
before the resolver runs; the resolver's contract is explicit about this
|
||||
expectation.
|
||||
|
||||
The default extractor reads a mutable property because that is the simplest
|
||||
host wiring. Frozen-Request runtimes (Workers, certain edge platforms) pass
|
||||
an explicit extractor backed by a `WeakMap`. Either way, the trust check is
|
||||
identical — only the I/O changes.
|
||||
@@ -0,0 +1,75 @@
|
||||
# `validate`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the default tenant-id validator regex and the validator / registry
|
||||
function signatures consumed by `createHeaderTenantResolver`. Splitting the
|
||||
validator surface out of `resolver.ts` keeps the resolver implementation a
|
||||
pure orchestrator (regex check → registry probe → trust check → return) and
|
||||
lets hosts substitute alternative validators without monkey-patching the
|
||||
factory.
|
||||
|
||||
The default validator (`^[a-z0-9-]{1,64}$`) is deliberately conservative —
|
||||
it admits the shape of a typical slug-style identifier and rejects
|
||||
everything else. Hosts whose tenant ids are UUIDs, prefixed strings, or
|
||||
arbitrary unicode must pass an explicit `validate` option.
|
||||
|
||||
## Features
|
||||
|
||||
- `DEFAULT_TENANT_ID_PATTERN` exported as a `RegExp` so hosts can extend
|
||||
rather than re-derive (`new RegExp(...|extra-form)`).
|
||||
- `defaultTenantIdValidator` — boolean function backed by the pattern.
|
||||
- `TenantIdValidator` and `TenantRegistryProbe` type aliases so host wiring
|
||||
is explicit.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { defaultTenantIdValidator } from "@suaveplan/api-tenant-resolver-header/validate";
|
||||
|
||||
defaultTenantIdValidator("acme"); // true
|
||||
defaultTenantIdValidator("ACME"); // false (uppercase rejected)
|
||||
defaultTenantIdValidator(""); // false (zero-length rejected)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
TenantIdValidator,
|
||||
TenantRegistryProbe,
|
||||
} from "@suaveplan/api-tenant-resolver-header/validate";
|
||||
|
||||
const uuidValidator: TenantIdValidator = (v) =>
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(v);
|
||||
|
||||
const registry: TenantRegistryProbe = async (tid) =>
|
||||
(await tenantProvider.lookup(tid as never)) !== null;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `const DEFAULT_TENANT_ID_PATTERN: RegExp` — `^[a-z0-9-]{1,64}$`.
|
||||
- `const defaultTenantIdValidator: TenantIdValidator` — applies the default
|
||||
pattern; returns boolean.
|
||||
- `type TenantIdValidator = (value: string) => boolean` — synchronous
|
||||
validator contract.
|
||||
- `type TenantRegistryProbe = (tenantId: string) => boolean | Promise<boolean>`
|
||||
— sync-or-async registry membership probe.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The validator is intentionally synchronous: the regex check is the
|
||||
zero-I/O happy path, and a synchronous boolean lets the resolver avoid an
|
||||
`await` micro-task in the common-case good-tenant path. Registry probes can
|
||||
be async because the host may back them with a real `TenantProvider.lookup`
|
||||
that hits a database; the resolver awaits the probe only after the regex
|
||||
check has already passed so a bad value never leaks into the registry call.
|
||||
|
||||
The default pattern's 64-character cap is the same maximum used by
|
||||
`api-tenant-provider`'s seed schema; widening it here without widening it
|
||||
there would produce values the resolver accepts but the registry refuses.
|
||||
Reference in New Issue
Block a user