From 8f1c8984c51481597049a77548706c914dff4ad4 Mon Sep 17 00:00:00 2001 From: sjungwon03 Date: Sun, 11 Oct 2026 14:03:41 +0900 Subject: [PATCH] identity: resolve pre-registered SSO humans Refs: #486 Assisted-by: Codex Signed-off-by: sjungwon03 --- CONTEXT.md | 3 + contracts/sso-identity-bindings.md | 15 + contracts/sso-management-foundation.md | 6 +- docs/PRD.md | 10 +- docs/acceptance.md | 8 +- .../adr/0008-pre-registered-sso-management.md | 3 + docs/architecture.md | 6 +- docs/openrouter-compatibility.md | 4 + docs/plans/105-sso-management-foundation.md | 8 +- docs/plans/486-sso-identity-bindings.md | 45 +++ migrations/011_sso_identity_bindings.sql | 7 + .../postgres-sso-identity-bindings.ts | 105 ++++++ src/storage/postgres-migration-sources.ts | 1 + test/postgres-gateway-runtime.test.ts | 2 +- test/postgres-migrations.test.ts | 42 ++- test/sso-identity-binding-storage.test.ts | 99 ++++++ test/sso-identity-bindings.test.ts | 322 ++++++++++++++++++ 17 files changed, 670 insertions(+), 16 deletions(-) create mode 100644 contracts/sso-identity-bindings.md create mode 100644 docs/adr/0008-pre-registered-sso-management.md create mode 100644 docs/plans/486-sso-identity-bindings.md create mode 100644 migrations/011_sso_identity_bindings.sql create mode 100644 src/identity/postgres-sso-identity-bindings.ts create mode 100644 test/sso-identity-binding-storage.test.ts create mode 100644 test/sso-identity-bindings.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index 88451d9..851ea79 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -80,3 +80,6 @@ Optional retention of LLM prompts and responses linked to audit events. **Usage ledger**: Per-attempt records of token usage and cost attribution, linked to the client request they served. + +**SSO identity binding**: +An administrator-approved association between a company SSO issuer and subject identity and an OpenGranter principal. diff --git a/contracts/sso-identity-bindings.md b/contracts/sso-identity-bindings.md new file mode 100644 index 0000000..f4c537c --- /dev/null +++ b/contracts/sso-identity-bindings.md @@ -0,0 +1,15 @@ +# Pre-registered SSO identity binding read foundation + +Issue #486. Plan: [486-sso-identity-bindings](../docs/plans/486-sso-identity-bindings.md). + +The contributor selected administrator-pre-registered OIDC issuer/subject bindings and API-specific SSO JWT access tokens for the first human-only management API. Migration `011` stores exact issuer/subject keys mapped to existing IAM principals. Keys use `C` collation, a composite primary key and a restricting principal foreign key. Multiple bindings may name one principal; no binding or human account is created during resolution. + +`createPostgresSsoIdentityBindingReader` exposes `resolveHumanPrincipal({ issuer, subject })` to trusted application code after a future access-token verifier establishes identity. This is not JWT verification or authentication. A single parameterized joined query reads current binding/principal kind/active state. Only an active human returns its principal ID; missing mappings, inactive humans and services return `undefined`. No email/username fallback, trimming, case folding, URL normalization or cross-issuer subject matching occurs. + +Own required issuer/subject values are captured once before SQL. Local opaque-string bounds are 1–2048 UTF-16 units for issuer and 1–256 for subject, excluding NUL and ill-formed Unicode; exact blanks/Unicode remain identity data. Storage uses character-count checks as a coarse bound; the reader enforces the stricter UTF-16 bound. These are local read bounds, not the selected JWT/access-token claim or issuer-trust profile. Extra caller principal/kind/active/token fields are ignored; projected parameters are frozen and only the validated principal ID is returned. + +Invalid inputs throw `InvalidSsoIdentityBindingInput` with message `Invalid SSO identity binding input` before SQL. Duplicate/malformed/inconsistent results, mismatched returned binding keys, malformed principal data or query/result getter failures throw `SsoIdentityBindingUnavailable` with message `SSO identity binding unavailable`. Required result fields must be own properties and are captured once; principal IDs must satisfy the same 1–256 UTF-16, non-NUL, well-formed bound. Errors expose neither raw issuer/subject/driver messages nor causes. Read results are not cached; next calls see changes. Concurrent changes after the statement are not revalidated. + +The reader makes no HTTP/key/secret/inference/mutation/audit/usage calls and grants no policy rights. Existing IAM and required decision audit remain necessary at management boundaries. Signature, issuer/audience/algorithm/JWKS/rotation/token-type/expiry/skew checks, access-token versus ID-token rejection, public HTTP authentication/errors/audit and audited binding provisioning remain separate implementation. Current proxy authentication, human/service token lifetimes, discovery/routing and all three OpenRouter pins stay unchanged. + +Sources: [OIDC issuer+subject stability](https://openid.net/specs/openid-connect-core-1_0.html#ClaimStability), [JWT access-token profile](https://www.rfc-editor.org/rfc/rfc9068.html). The OIDC ID-token subject bound is not silently applied as an access-token profile. diff --git a/contracts/sso-management-foundation.md b/contracts/sso-management-foundation.md index fc60572..74973ba 100644 --- a/contracts/sso-management-foundation.md +++ b/contracts/sso-management-foundation.md @@ -1,12 +1,12 @@ # SSO management foundation: planning status -SSO requirements remain pending implementation. The lifetime caps are now enforced at the existing internal service under the [lifetime contract](proxy-token-lifetimes.md). This is not an implemented public HTTP contract: +SSO JWT verification and public management authentication remain pending implementation. The binding read foundation is delivered under #486, and lifetime caps are enforced at the existing internal service under the [lifetime contract](proxy-token-lifetimes.md). This is not an implemented public HTTP contract: - Company SSO uses OIDC. - The first management API authenticates SSO human users only. Service accounts continue using the LLM proxy; service management credentials require a later decision. - Newly issued proxy tokens have a mandatory expiry and maximum lifetime of 30 days for human owners or 90 days for service owners. - Existing iam:Manage policy evaluation on the target principal and required metadata decision audit remain in force. SSO authentication supplies identity, not an authorization bypass. -Pending decisions: pre-registration versus first-login identity creation; JWT access-token management authentication versus browser login/server sessions. HTTP paths, authentication profiles, migration handling for existing credentials, and implementation error contracts remain to be specified before their implementation. Recommendations are not requirements until confirmed. +Confirmed on 2026-10-11: administrator-pre-registered issuer/subject bindings and API-specific SSO JWT access tokens; no automatic first-login creation. The [binding read foundation](sso-identity-bindings.md) is implemented under #486. HTTP paths, authentication profiles, migration handling for existing credentials, and implementation error contracts remain to be specified before their implementation. Recommendations are not requirements until confirmed. -The current internal facade, opaque credential primitives, and proxy HTTP endpoints remain as documented in [token management](token-management.md). SSO and the public management API remain pending; internal lifetime enforcement is delivered separately. +The current internal facade, opaque credential primitives, and proxy HTTP endpoints remain as documented in [token management](token-management.md). JWT verification and the public management API remain pending; the binding read foundation and internal lifetime enforcement are delivered separately. diff --git a/docs/PRD.md b/docs/PRD.md index ef98f59..0573535 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -21,7 +21,7 @@ Success means unauthorized principals cannot obtain provider credentials or invo ## 3. Initial release scope 1. **Catalog and routes:** Register an OpenRouter upstream and direct OpenAI, Anthropic, and Google Gemini upstreams, their secret references, approved model aliases and routes, and versioned price information. Never redisplay a raw upstream credential in the UI. -2. **Identity:** Distinguish human users from service accounts. Human users sign in through company SSO; issue and revoke service-account credentials. Proxy tokens use an opaque versioned format with a random lookup ID and secret, a stored digest, explicit expiry, and immediate revocation. A trusted internal management coordinator evaluates `iam:Manage` on the token owner's `principal:` before issue or revoke. The SSO protocol, HTTP management API, and maximum token lifetime are undecided. +2. **Identity:** Distinguish human users from service accounts. Human users sign in through company SSO; issue and revoke service-account credentials. Proxy tokens use an opaque versioned format with a random lookup ID and secret, a stored digest, explicit expiry, and immediate revocation. A trusted internal management coordinator evaluates `iam:Manage` on the token owner's `principal:` before issue or revoke. OIDC, pre-registered issuer/subject bindings, API-specific SSO JWT access tokens and owner-specific lifetime caps are selected; the exact token-verification and HTTP management profiles remain pending. 3. **Authorization:** Attach policies to principals and roles. Evaluate principal, action, resource, and supported conditions on every request. Deny by default and give explicit Deny precedence. A PostgreSQL reader can load a principal's direct and inherited policy snapshot from one consistent SQL statement; an internal policy simulator can evaluate a current persisted principal/action/resource and return only the decision and policy versions. Simulation checks IAM only and does not grant execution; public simulator authentication, reader visibility, and access audit remain undecided. Policy-management writes remain separate work. 4. **Gateway and routing:** Provide the core OpenAI-compatible `POST /v1/chat/completions` and `GET /v1/models` shapes. A delegated route sends an authorized request to OpenRouter; a managed route has OpenGranter choose among registered direct OpenAI, Anthropic, and Google Gemini adapters. Both use the same proxy-token, policy, limit, and audit path. The routing controls resemble OpenRouter's: administrator-approved provider allowlists and order, price/latency/throughput preferences, health-aware selection, optional Jev-assisted selection, and bounded fallback. Jev may recommend only a destination that passed all OpenGranter eligibility checks. Fallback may try another authorized candidate of the same route kind; it cannot silently switch between delegated and managed routes. Reject unsupported fields and routing overrides clearly. Delegated text streaming is implemented; direct-provider, tool and complete external-client streaming remain required release work. 5. **Usage:** Record request ID, principal, credential ID, requested alias, route kind, upstream, actual model and provider when known, timestamp, status, latency, input and output tokens, estimated cost, and upstream-reported cost separately. Represent unavailable token usage as unknown. @@ -70,7 +70,7 @@ Unrestricted cross-mode fallback, credit resale, every provider-specific API par | Jev-assisted managed selection | Optional TypeSafe Jev decision over already eligible direct candidates; OpenGranter validates the choice and makes the provider call. The authorized fallback policy is confirmed; the TypeSafe integration target and prompt-disclosure default remain provisional in [the routing contract](routing.md). | | Delegated IAM | Require permission for the model and every eligible final inference provider; restrict OpenRouter to the authorized provider set before sending | | Fallback boundary | Within delegated routes or within managed routes, never silently between the two kinds | -| Human sign-in | OIDC company SSO; identity binding and management authentication profile pending | +| Human sign-in | OIDC company SSO; pre-registered issuer/subject binding reader; API-specific JWT verification/HTTP profile pending | | Deployment | AWS and on-premises support; release order TBD | | Content audit | Configurable; default, scope, retention, and readers TBD | | Streaming and monthly limits | Streaming required for external-client compatibility; failure/accounting contracts and monthly limits remain to specify | @@ -115,7 +115,7 @@ Require the exact case-insensitive application/json type, preserving existing pa ## Agreed SSO management foundation (implementation pending) -Company SSO uses OIDC. The first management API authenticates SSO human users only; services remain proxy users. New proxy-token lifetime caps are 30 days for human owners and 90 days for service owners, with expiry mandatory. SSO identity binding and management API authentication remain pending decisions. Existing IAM and mandatory decision audit apply. See [planning contract](../contracts/sso-management-foundation.md) and [plan](plans/105-sso-management-foundation.md). +Company SSO uses OIDC. The first management API authenticates SSO human users only; services remain proxy users. New proxy-token lifetime caps are 30 days for human owners and 90 days for service owners, with expiry mandatory. Administrator-pre-registered issuer/subject bindings and API-specific SSO JWT access tokens were confirmed on 2026-10-11; exact token verification and public HTTP authentication remain pending. Existing IAM and mandatory decision audit apply. See [planning contract](../contracts/sso-management-foundation.md) and [plan](plans/105-sso-management-foundation.md). ## Internal proxy-token lifetime enforcement @@ -1007,3 +1007,7 @@ Complete administrator snapshots may include own optional non-null benchmarks, r ## Informational model pricing adjustments (#484) Complete administrator pricing snapshots may include optional finite discount and bounded override rows with optional exact price strings, finite token/time conditions and open UTC day labels. Preserve omission, empty/duplicate rows, exact spelling and order; supplied day arrays require at least one item. Existing nonnegative decimal/scientific price restrictions remain. Only compatible rich discovery publishes these values after complete validation, enabled model/final-provider IAM, existing queries/pages and required private audit. They never apply discounts/conditions, rank routes, calculate billed cost or certify pricing freshness. Publication/refresh, pricing application policy, broader discovery/conformance and #116/#7 remain open. See [plan](plans/484-model-pricing-adjustments.md) and [contract](../contracts/model-pricing-adjustments.md). + +## Pre-registered SSO identity read foundation (#486) + +Exact issuer/subject bindings resolve only to existing active human principals. Missing mappings, inactive humans and services yield no identity; no account creation or email fallback occurs. A persisted internal reader captures bounded opaque fields once and reloads current state each operation. It does not verify JWTs, authenticate public requests, grant IAM rights or mutate configuration. Existing policy/audit and proxy behavior remain required. See [plan](plans/486-sso-identity-bindings.md) and [contract](../contracts/sso-identity-bindings.md). diff --git a/docs/acceptance.md b/docs/acceptance.md index 4a5e25c..f236ba5 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -279,14 +279,14 @@ The PostgreSQL migration runner applies versions `001` through `008` in order, r - OIDC-authenticated human management callers still pass existing iam:Manage policy checks; authentication does not grant management authority. Service-account management authentication is excluded from the first slice. - New human-owner proxy tokens cannot exceed 30 days; new service-owner tokens cannot exceed 90 days. Expiry remains mandatory. -- Identity binding and management authentication scenarios await the remaining planning answers. SSO scenarios remain pending implementation; internal lifetime cap scenarios are covered separately below. +- Administrator-pre-registered issuer/subject bindings and API-specific SSO JWT access tokens are confirmed. Binding reader scenarios are implemented under #486; JWT verification/public HTTP authentication remain pending. Internal lifetime caps are covered separately below. ## Internal proxy-token lifetime caps - For both persisted owner kinds, exact maximum future expiry succeeds and one millisecond beyond fails without credential or lifecycle writes; caller kind/cap fields cannot widen it. - Policy Deny and decision-audit failure stop before target-kind lookup. Missing/malformed/duplicate/unavailable owner data fails safely after authorized decision persistence. - Cap validation and credential creation use one issuance timestamp. Previously issued long-lived credentials remain revocable. -- These scenarios concern the trusted internal service; SSO identity binding and public management authentication remain pending. +- These scenarios concern the trusted internal service; the SSO binding reader is delivered under #486, while JWT verification and public management authentication remain pending. ## Token-management operation snapshots @@ -1418,3 +1418,7 @@ Root/nested/row/index getters and array length are captured once; inherited opti Public and actual OpenRouter1.4.18/OpenAI7.23.0 sockets preserve finite signed/fractional/extreme discounts/conditions, exact decimal spelling, all override fields, empty/duplicate rows, blank/future/duplicate day labels, omission and SDK field mappings. Verify 64-row/day, 128-unit price and 256-UTF-16 day boundaries; reject supplied empty day arrays, null/undefined/nonfinite/malformed/unknown fields, holes and inherited required base prices privately, including disabled/denied catalog entries. Authentication precedes catalog; model/provider/implicit Deny, fresh page policy, required failures, private events/errors and basic/legacy/query/order behavior remain unchanged. Root/price/condition/row/day/index getters and array lengths are read once; inherited optional getters stay unread, custom iterators never execute, inherited hole fillers reject, and audit mutation cannot alter output. Non-enumerable own required prices remain captured rather than omitted. PostgreSQL list/route snapshots remain deeply frozen, retain approved routes and fixed failures. Migrated JSONB preserves spelling/duplicates and no-backfill behavior; oversized aggregate snapshots reject without replacing the previous row. See [plan](plans/484-model-pricing-adjustments.md) and [contract](../contracts/model-pricing-adjustments.md). + +## Pre-registered SSO identity binding cases (#486) + +Verify migrated uniqueness/FK/restricted deletion, exact issuer/subject case/Unicode/whitespace matching, cross-issuer separation and multiple bindings for one principal without automatic creation. Current missing/inactive/service identities yield no human; next-call mapping/kind/active changes take effect. Public reader tests cover own field/getter capture, source mutation across SQL awaits, frozen parameterization, ignored forged caller state, malformed/duplicate/mismatched results and fixed claim-free errors before SQL for invalid input. No JWT verification, policy grant, audit append or HTTP management flow is certified. See [plan](plans/486-sso-identity-bindings.md) and [contract](../contracts/sso-identity-bindings.md). diff --git a/docs/adr/0008-pre-registered-sso-management.md b/docs/adr/0008-pre-registered-sso-management.md new file mode 100644 index 0000000..b3ba370 --- /dev/null +++ b/docs/adr/0008-pre-registered-sso-management.md @@ -0,0 +1,3 @@ +# Pre-registered SSO identities and API access tokens + +The contributor selected administrator-pre-registered issuer/subject bindings and API-specific SSO JWT access tokens for the first human-only management API on 2026-10-11. This trades automatic first-login onboarding and browser session state for explicit identity enrollment and an API authentication boundary; changing it later would change provisioning and client login flows. SSO identity supplies no IAM bypass. Exact token verification and HTTP profiles remain separate decisions. See [plan](../plans/486-sso-identity-bindings.md). diff --git a/docs/architecture.md b/docs/architecture.md index f82867f..c717cbc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -312,7 +312,7 @@ The JSON reader compares the normalized type segment exactly with application/js ## Agreed management foundation (implementation pending) -OIDC is selected for company SSO. Only SSO human users authenticate to the first management API; new proxy-token lifetimes are capped by owner kind at 30 days for human principals and 90 days for service principals. The existing internal coordinator still requires a trusted authenticated actor and fresh IAM evaluation; no SSO adapter or management HTTP endpoint exists yet. Identity binding and JWT access-token versus browser-session authentication remain pending. See [planning contract](../contracts/sso-management-foundation.md). +OIDC is selected for company SSO. Only SSO human users authenticate to the first management API; new proxy-token lifetimes are capped by owner kind at 30 days for human principals and 90 days for service principals. The existing internal coordinator still requires a trusted authenticated actor and fresh IAM evaluation; no SSO adapter or management HTTP endpoint exists yet. Administrator-pre-registered issuer/subject bindings and API-specific SSO JWT access tokens were confirmed on 2026-10-11; exact verification and public HTTP profiles remain pending. See [planning contract](../contracts/sso-management-foundation.md). ## Internal owner-specific lifetime guard @@ -1218,3 +1218,7 @@ HTTP and PostgreSQL list/route boundaries reuse the captured snapshot without ch Extend the shared pricing snapshot with explicit required own prompt/completion, optional own discount and overrides. Capture each known own price once using the existing 128-unit nonnegative decimal/scientific validator. Capture finite optional discount/token/time numbers without inferred ranges. Override arrays contain 0..64 known-field objects; optional UTC day arrays contain 1..64 open strings of 0..256 UTF-16 units. Empty override objects and duplicate rows/days are preserved. Capture validated lengths and bounded own indices once, reject inherited holes and ignore supplied iterators; deeply freeze all pricing/override/day objects and arrays before handoffs. Inherited optional fields are omitted and thrown getters fail privately. HTTP projection and PostgreSQL list/route validation reuse this snapshot before filtering, preserving IAM/Deny, required audit, existing queries/pages, approved routes and nullable JSONB object/64 KiB cap with no migration. Independently valid bounds can exceed the aggregate cap. No price fetching, discount arithmetic, condition matching, inherited-price calculation, ranking or accounting authority is added. See [plan](plans/484-model-pricing-adjustments.md) and [contract](../contracts/model-pricing-adjustments.md). + +## Pre-registered SSO identity bindings (#486) + +Migration011 adds C-collated exact issuer/subject keys, a composite primary key and a restricting FK to existing IAM principals; multiple bindings may name one principal. The bundled TypeScript reader captures own bounded well-formed opaque strings and freezes projected parameters before one joined binding/principal query. Return only an active human ID; missing/inactive/service state yields no identity, and malformed/ambiguous/mismatched/dependency failures expose fixed errors without claims or causes. No cache, normalization, email fallback, automatic account creation, JWT/key fetching or public authentication is introduced. State changed after this read is not revalidated; later verification and current policy/audit boundaries remain necessary. See [plan](plans/486-sso-identity-bindings.md) and [contract](../contracts/sso-identity-bindings.md). diff --git a/docs/openrouter-compatibility.md b/docs/openrouter-compatibility.md index 6d8715d..f95a6f1 100644 --- a/docs/openrouter-compatibility.md +++ b/docs/openrouter-compatibility.md @@ -1021,3 +1021,7 @@ Local 64-row/256-UTF-16 bounds, own-required/unknown-key rules and the existing Compatible rich discovery adds own optional discount:number and overrides:PricingOverride[] to existing scalar prices. Fresh 2026-10-11 source/SDK inspection confirms PublicPricing/PricingOverride match the unchanged model-response pin. All override fields are optional/non-null; preserve finite numeric values without unencoded ranges, exact price strings, empty/duplicate rows and open/duplicate UTC day labels. OpenRouter1.4.18 maps inputAudioCache/inputCacheRead/inputCacheWrite/inputCacheWrite1h/minPromptTokens/utcDays/utcStart/utcEnd; OpenAI7.23.0 preserves raw extra JSON. Enforce the official utc_days minItems=1 even though this SDK accepts empty arrays. Existing nonnegative decimal/scientific price grammar, 128-unit prices, local 64-row/day and 256-UTF-16 day bounds, own-required/known-field checks and aggregate JSONB cap are explicit restrictions; the SDK accepts arbitrary price strings and strips unknown fields. Projection neither applies discount/conditions nor establishes billed cost, ranking or accuracy/freshness. All three pins and migrations remain byte-identical. Publication/refresh, pricing application policy, broader discovery/conformance, full #116 and unresolved #7 remain open. See [plan](plans/484-model-pricing-adjustments.md) and [contract](../contracts/model-pricing-adjustments.md). + +## SSO management identity read foundation (#486) + +The contributor confirmed pre-registered issuer/subject bindings and API-specific SSO JWT access tokens for human-only management. An internal persisted binding reader now resolves existing active humans with exact keys and safe failures; it neither verifies tokens nor exposes a management API or changes proxy credentials, discovery, IAM, audit, limits or usage. All three OpenRouter pins remain unchanged. JWT/HTTP management, catalog provisioning/refresh, full #116 and unresolved #7 remain open. See [plan](plans/486-sso-identity-bindings.md) and [contract](../contracts/sso-identity-bindings.md). diff --git a/docs/plans/105-sso-management-foundation.md b/docs/plans/105-sso-management-foundation.md index 306db7f..df4c691 100644 --- a/docs/plans/105-sso-management-foundation.md +++ b/docs/plans/105-sso-management-foundation.md @@ -17,9 +17,9 @@ - Planned integration boundaries: verified SSO identity to internal human principal, then existing fresh actor policy resolution and issue/revoke coordinator. Issuance must resolve the owner kind before enforcing its lifetime cap. - OIDC was selected over SAML. Human-only management was selected over introducing service management credentials in the first slice. The contributor selected separate human/service caps over a common cap or no maximum. -- Pending Q4: administrator-pre-registered issuer/subject identity binding versus first-login account creation. -- Pending Q5: API-specific SSO JWT access-token verification versus browser login and server sessions. -- Subsequent authentication profile, HTTP contracts, lifecycle edge cases, and final shared understanding must be completed before coding that new boundary. Do not treat recommendations for Q4/Q5 as requirements. +- Q4 confirmed by the contributor on 2026-10-11: administrator-pre-registered issuer/subject bindings; no automatic first-login account creation. +- Q5 confirmed by the contributor on 2026-10-11: API-specific SSO JWT access tokens. The exact verification profile remains pending. +- Subsequent authentication profile, HTTP contracts, lifecycle edge cases, and final shared understanding must be completed before coding that new boundary. The confirmed Q4/Q5 choices are recorded in [plan486](486-sso-identity-bindings.md); do not infer the remaining token/HTTP profile. - See [PRD](../PRD.md), [architecture](../architecture.md), [acceptance](../acceptance.md), and [planning contract](../../contracts/sso-management-foundation.md). ## TDD plan @@ -38,4 +38,4 @@ - Documentation-only change; no production behavior or tests changed. - python3 scripts/check.py passed planning documents, links, contracts, and fixture secret scanning; git diff --check passed. -- CLAUDE.md remains a symbolic link to AGENTS.md. The pending decisions are explicitly recorded rather than implemented. +- CLAUDE.md remains a symbolic link to AGENTS.md. The remaining profile decisions are explicitly recorded rather than implemented; Q4/Q5 were subsequently confirmed under #486. diff --git a/docs/plans/486-sso-identity-bindings.md b/docs/plans/486-sso-identity-bindings.md new file mode 100644 index 0000000..9b51f33 --- /dev/null +++ b/docs/plans/486-sso-identity-bindings.md @@ -0,0 +1,45 @@ +# Pre-registered SSO identity bindings plan + +## Issue and problem + +- Issue #486. The contributor confirmed administrator-pre-registered issuer/subject bindings and API-specific SSO JWT access tokens on 2026-10-11, resolving Q4/Q5 in the [earlier plan](105-sso-management-foundation.md). +- Persisted IAM principals exist, but no SSO binding table/reader does. The trusted internal management service requires an authenticated principal ID. Public management authentication is still unimplemented. + +## Scope and expected behavior + +- Migration011 adds exact issuer/subject -> existing principal bindings with a composite primary key and FK. A principal may have multiple bindings; no automatic creation, email/username fallback or normalized/cross-issuer lookup. +- Internal reader captures own issuer/subject once before a single parameterized consistent binding/principal query per operation. Only an active human returns its principal ID; missing mapping, inactive human or service yields undefined. Duplicate/inconsistent/malformed SQL results and driver/getter failures yield fixed safe availability errors without claims/causes. +- Local input bounds: opaque issuer1..2048 and subject1..256 UTF-16 units, excluding NUL/ill-formed Unicode. Preserve exact whitespace/case/Unicode/URL spelling; do not conflate this reader with the pending JWT issuer URI/trust or access-token claim profile. Invalid input fails before SQL. Freeze projected SQL parameters and ignore extra caller fields; reload current binding/kind/active state each call without a cache. +- No JWT decoding/verification, issuer/JWKS fetching, public HTTP authentication, principal/policy creation, mutation, audit append, secret/inference or usage work. Existing IAM/Deny/required audit, token lifetimes and proxy/discovery/routing behavior remain unchanged. + +## Design + +- Add TypeScript createPostgresSsoIdentityBindingReader with resolveHumanPrincipal, narrow SQL port and safe InvalidSsoIdentityBindingInput/SsoIdentityBindingUnavailable errors. Select exact binding fields plus principal ID/kind/active; validate the complete result before returning only a string ID. Use explicit C collation on binding key columns so database locale cannot merge exact identities. FK deletion remains restricted like existing IAM attachment tables. +- Add migration011 to the strict bundled manifest; preserve every historical SQL file. Nonempty bounded text checks are storage guards, with UTF-16/well-formed validation at the reader. No unique principal-only constraint is implied by pre-registration. +- [OIDC Core claim stability](https://openid.net/specs/openid-connect-core-1_0.html#ClaimStability) supports issuer+subject identity rather than email/name. Its ID-token subject bounds are not silently adopted as the selected access-token profile. [RFC9068](https://www.rfc-editor.org/rfc/rfc9068.html) is relevant to later token verification, not implemented here. +- Prefer a read foundation over prematurely adding account creation or a public authentication boundary. Record the agreed term in [CONTEXT](../../CONTEXT.md) and the enrollment/API-auth trade-off in [ADR0008](../adr/0008-pre-registered-sso-management.md). +- Update [PRD](../PRD.md), [architecture](../architecture.md), [acceptance](../acceptance.md), [SSO foundation contract](../../contracts/sso-management-foundation.md), [binding contract](../../contracts/sso-identity-bindings.md) and [compatibility inventory](../openrouter-compatibility.md). +- Still open: JWT issuer/audience/algorithm/key/rotation/token-type/expiry/skew profile, public HTTP paths/errors/authentication audit, management binding/provisioning lifecycle and policy concurrency. Binding resolution grants no management permission or durable authentication guarantee. + +## TDD plan + +- First migrated-storage tests expect binding storage, uniqueness and exact read semantics; current migrations lack the relation and must fail before production changes. Add reader tests before implementation for success, denial and failure boundaries, then minimal migration/reader. +- Test exact cross-issuer/case/Unicode/whitespace identities, multi-binding principals, uniqueness/FK/deletion behavior, no account creation, missing/inactive/service denial and next-call state changes. +- Test own input getters once, asynchronous mutation, fixed errors without claims/causes, malformed/duplicate/mismatched SQL data, thrown driver/result getters, ignored extra fields and immutable parameterization. Test forged caller state cannot replace persisted human kind/active state. +- Format changed files; run focused tests, migration bundle regressions and full npm run check including unchanged OpenRouter pins. + +## Delivery + +- Issue/new branch/plan before production code; exact-head sjungwon03-ai review/approval and both CI checks before sjungwon03 squash merge/main sync. PR reports red/green, full checks, plan/contract and remaining risks. +- Additive empty table needs no backfill and does not change existing readers. Trusted administrator provisioning/audit is separate; rollback must deliberately remove bindings before dropping the new table. No edits to historical migrations. Read-time state may change after this query; later management operations still need current policy evaluation. Public JWT/management, catalog publication/refresh, full #116 and unresolved #7 remain open. + +## Verification evidence + +Persisted IAM principals lacked an SSO identity mapping. Following the contributor's confirmed pre-registration and API-specific JWT choices, add migration 011 and an internal resolveHumanPrincipal reader. One parameterized joined query returns only an existing active human ID for an exact issuer/subject binding; missing mappings, inactive humans and service principals return undefined. Multiple bindings can name one principal. No automatic account creation, email fallback or identity normalization occurs. + +Capture own required inputs and persisted result fields once, freeze projected SQL parameters, validate complete results and sanitize input/driver/getter/malformed/duplicate/mismatched failures without claims or causes. Reload binding/kind/active state each operation. C-collated composite keys and a restricting foreign key preserve exact identities. Historical migrations and all three OpenRouter pins remain byte-identical. This foundation does not verify JWTs, authenticate public requests, write bindings, grant policy rights or call audit/secrets/inference/usage ports. + +TDD: before production changes, three storage tests failed for the absent table and old migration manifest. After the reader tests were written, the initial deny-only typed skeleton produced 54 expected behavioral failures and four denial passes: active human resolution returned undefined, invalid inputs/results did not raise their required errors, and the relation was still absent. The minimal migration/reader made the initial 67 focused cases green. Full validation revealed an existing gateway startup assertion pinned to migration 010; update its expected history and the migration-runner fixture to include 011, and verify an existing populated 010 database upgrades without backfill or historical checksum changes. All 92 focused cases pass, including 62 new cases. Full npm run check passes strict types, lint, 6193 tests with one existing PostgreSQL skip, planning/contracts/fixture scans and offline schema integrity. + +Remaining scope: local opaque-string limits (issuer 1–2048, subject/principal ID 1–256 UTF-16 units, no NUL/ill-formed Unicode) are read bounds, not a JWT issuer-trust/access-token profile. SQL character-count guards are coarser than UTF-16 reader bounds. State changes after the joined read are not revalidated. Signature, issuer/audience/algorithm/key/JWKS/rotation/token-type/expiry/skew validation, public HTTP authentication/errors/audit, and audited administrator binding provisioning remain separate work. Current IAM and mandatory management decision audit still apply; no production login path is enabled here. Catalog publication/refresh, complete #116 and unresolved #7 remain open. Additive rollout requires shipping migration 011; rollback must deliberately handle its bindings before table removal. + diff --git a/migrations/011_sso_identity_bindings.sql b/migrations/011_sso_identity_bindings.sql new file mode 100644 index 0000000..b035617 --- /dev/null +++ b/migrations/011_sso_identity_bindings.sql @@ -0,0 +1,7 @@ +-- Exact pre-registered SSO identities; authentication and audited provisioning are separate. +CREATE TABLE iam_sso_identity_bindings ( + issuer text COLLATE "C" NOT NULL CHECK (char_length(issuer) BETWEEN 1 AND 2048), + subject text COLLATE "C" NOT NULL CHECK (char_length(subject) BETWEEN 1 AND 256), + principal_id text NOT NULL REFERENCES iam_principals(principal_id), + PRIMARY KEY (issuer, subject) +); diff --git a/src/identity/postgres-sso-identity-bindings.ts b/src/identity/postgres-sso-identity-bindings.ts new file mode 100644 index 0000000..bc94eeb --- /dev/null +++ b/src/identity/postgres-sso-identity-bindings.ts @@ -0,0 +1,105 @@ +export interface SsoIdentityBindingSqlClient { + readonly query: ( + sql: string, + params: readonly unknown[], + ) => Promise<{ readonly rows: readonly unknown[] }>; +} + +export class InvalidSsoIdentityBindingInput extends Error { + constructor() { + super('Invalid SSO identity binding input'); + this.name = 'InvalidSsoIdentityBindingInput'; + } +} + +export class SsoIdentityBindingUnavailable extends Error { + constructor() { + super('SSO identity binding unavailable'); + this.name = 'SsoIdentityBindingUnavailable'; + } +} + +const BINDING_SQL = ` + SELECT b.issuer, b.subject, p.principal_id, p.kind, p.active + FROM iam_sso_identity_bindings b + JOIN iam_principals p ON p.principal_id = b.principal_id + WHERE b.issuer = $1 AND b.subject = $2`; + +function record(value: unknown, keys: readonly string[]): Record { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + keys.some((key) => !Object.hasOwn(value, key)) + ) + throw new SsoIdentityBindingUnavailable(); + return value as Record; +} + +function opaqueString(value: unknown, maximumLength: number): value is string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > maximumLength || + value.includes('\0') + ) + return false; + for (const character of value) { + const code = character.charCodeAt(0); + if (character.length === 1 && code >= 0xd800 && code <= 0xdfff) return false; + } + return true; +} + +/** Resolve an already verified SSO identity; this reader does not authenticate or authorize. */ +export function createPostgresSsoIdentityBindingReader(client: SsoIdentityBindingSqlClient): { + readonly resolveHumanPrincipal: (input: { + readonly issuer: string; + readonly subject: string; + }) => Promise; +} { + return { + async resolveHumanPrincipal(input) { + let issuer: string; + let subject: string; + try { + const identity = record(input, ['issuer', 'subject']); + const rawIssuer = identity.issuer; + const rawSubject = identity.subject; + if (!opaqueString(rawIssuer, 2048) || !opaqueString(rawSubject, 256)) + throw new InvalidSsoIdentityBindingInput(); + issuer = rawIssuer; + subject = rawSubject; + } catch { + throw new InvalidSsoIdentityBindingInput(); + } + try { + const result = record(await client.query(BINDING_SQL, Object.freeze([issuer, subject])), [ + 'rows', + ]); + const rows = result.rows; + if (!Array.isArray(rows)) throw new SsoIdentityBindingUnavailable(); + const length = rows.length; + if (length === 0) return undefined; + if (length !== 1 || !Object.hasOwn(rows, 0)) throw new SsoIdentityBindingUnavailable(); + const row = record(rows[0], ['issuer', 'subject', 'principal_id', 'kind', 'active']); + const rowIssuer = row.issuer; + const rowSubject = row.subject; + const principalId = row.principal_id; + const kind = row.kind; + const active = row.active; + if ( + rowIssuer !== issuer || + rowSubject !== subject || + !opaqueString(principalId, 256) || + (kind !== 'human' && kind !== 'service') || + typeof active !== 'boolean' + ) + throw new SsoIdentityBindingUnavailable(); + return kind === 'human' && active ? principalId : undefined; + } catch { + throw new SsoIdentityBindingUnavailable(); + } + }, + }; +} diff --git a/src/storage/postgres-migration-sources.ts b/src/storage/postgres-migration-sources.ts index 608cd6f..c926f6c 100644 --- a/src/storage/postgres-migration-sources.ts +++ b/src/storage/postgres-migration-sources.ts @@ -15,6 +15,7 @@ const migrationFiles = [ '008_token_management_decisions.sql', '009_openrouter_provider_mappings.sql', '010_model_discovery_metadata.sql', + '011_sso_identity_bindings.sql', ] as const; export class MigrationSourceUnavailable extends Error { diff --git a/test/postgres-gateway-runtime.test.ts b/test/postgres-gateway-runtime.test.ts index 12f598e..5bd89f2 100644 --- a/test/postgres-gateway-runtime.test.ts +++ b/test/postgres-gateway-runtime.test.ts @@ -310,7 +310,7 @@ test('dual runtime migrates before configuration and invokes both persisted rout ); assert.deepEqual( history.rows.map((row) => row.version), - ['001', '002', '003', '004', '005', '006', '007', '008', '009', '010'], + ['001', '002', '003', '004', '005', '006', '007', '008', '009', '010', '011'], ); await db.exec(` INSERT INTO iam_principals VALUES ('service-1', 'service', true); diff --git a/test/postgres-migrations.test.ts b/test/postgres-migrations.test.ts index 6c0b16c..dd6bbde 100644 --- a/test/postgres-migrations.test.ts +++ b/test/postgres-migrations.test.ts @@ -18,6 +18,7 @@ const names = [ '008_token_management_decisions.sql', '009_openrouter_provider_mappings.sql', '010_model_discovery_metadata.sql', + '011_sso_identity_bindings.sql', ]; const migrations = await Promise.all( names.map(async (name) => ({ @@ -49,7 +50,19 @@ test('apply all migration files once and retain matching checksums', async () => const db = new PGlite(); try { const first = await applyPostgresMigrations(connection(db), migrations, () => 1_000); - assert.deepEqual(first, ['001', '002', '003', '004', '005', '006', '007', '008', '009', '010']); + assert.deepEqual(first, [ + '001', + '002', + '003', + '004', + '005', + '006', + '007', + '008', + '009', + '010', + '011', + ]); const second = await applyPostgresMigrations(connection(db), migrations, () => 2_000); assert.deepEqual(second, []); const result = await db.query<{ version: string; checksum: string; applied_at_ms: string }>( @@ -57,7 +70,7 @@ test('apply all migration files once and retain matching checksums', async () => ); assert.deepEqual( result.rows.map((row) => row.version), - ['001', '002', '003', '004', '005', '006', '007', '008', '009', '010'], + ['001', '002', '003', '004', '005', '006', '007', '008', '009', '010', '011'], ); assert.equal( result.rows.every((row) => /^[a-f0-9]{64}$/u.test(row.checksum)), @@ -72,6 +85,31 @@ test('apply all migration files once and retain matching checksums', async () => } }); +test('SSO migration upgrades populated historical storage without backfill or checksum changes', async () => { + const db = new PGlite(); + try { + await applyPostgresMigrations(connection(db), migrations.slice(0, 10), () => 1_000); + await db.exec("INSERT INTO iam_principals VALUES ('existing-human', 'human', true)"); + const before = await db.query( + 'SELECT version, checksum, applied_at_ms FROM schema_migrations ORDER BY version', + ); + assert.deepEqual(await applyPostgresMigrations(connection(db), migrations, () => 2_000), [ + '011', + ]); + const history = await db.query( + 'SELECT version, checksum, applied_at_ms FROM schema_migrations ORDER BY version', + ); + assert.deepEqual(history.rows.slice(0, 10), before.rows); + assert.deepEqual((await db.query('SELECT * FROM iam_sso_identity_bindings')).rows, []); + assert.deepEqual((await db.query('SELECT * FROM iam_principals')).rows, [ + { principal_id: 'existing-human', kind: 'human', active: true }, + ]); + assert.deepEqual(await applyPostgresMigrations(connection(db), migrations, () => 3_000), []); + } finally { + await db.close(); + } +}); + test('changed or missing historical sources stop before later migrations', async () => { const db = new PGlite(); try { diff --git a/test/sso-identity-binding-storage.test.ts b/test/sso-identity-binding-storage.test.ts new file mode 100644 index 0000000..312d027 --- /dev/null +++ b/test/sso-identity-binding-storage.test.ts @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { PGlite } from '@electric-sql/pglite'; +import { loadPostgresMigrationSources } from '../src/storage/postgres-migration-sources.ts'; + +async function migrated(run: (db: PGlite) => Promise) { + const db = new PGlite(); + try { + for (const source of await loadPostgresMigrationSources()) await db.exec(source.sql); + await run(db); + } finally { + await db.close(); + } +} +test('migrated SSO binding storage starts empty and keeps exact issuer/subject identities', async () => { + await migrated(async (db) => { + const empty = await db.query( + 'SELECT issuer, subject, principal_id FROM iam_sso_identity_bindings', + ); + assert.deepEqual(empty.rows, []); + await db.exec( + "INSERT INTO iam_principals VALUES ('human-one','human',true),('human-two','human',true)", + ); + for (const [issuer, subject, principal] of [ + ['https://sso.example', 'Alpha', 'human-one'], + ['https://sso.example', 'alpha', 'human-two'], + ['https://other.example', 'Alpha', 'human-two'], + ['https://sso.example/', 'Alpha', 'human-two'], + ['https://sso.example', ' 유저😀 ', 'human-one'], + ]) + await db.query('INSERT INTO iam_sso_identity_bindings VALUES ($1,$2,$3)', [ + issuer, + subject, + principal, + ]); + const rows = await db.query( + 'SELECT principal_id FROM iam_sso_identity_bindings WHERE issuer=$1 AND subject=$2', + ['https://sso.example', 'Alpha'], + ); + assert.deepEqual(rows.rows, [{ principal_id: 'human-one' }]); + const humans = await db.query('SELECT count(*)::int AS count FROM iam_principals'); + assert.deepEqual(humans.rows, [{ count: 2 }]); + const bindings = await db.query('SELECT count(*)::int AS count FROM iam_sso_identity_bindings'); + assert.deepEqual(bindings.rows, [{ count: 5 }]); + const collation = await db.query( + "SELECT attname,collname FROM pg_attribute JOIN pg_collation ON attcollation=pg_collation.oid WHERE attrelid='iam_sso_identity_bindings'::regclass AND attname IN ('issuer','subject') ORDER BY attname", + ); + assert.deepEqual(collation.rows, [ + { attname: 'issuer', collname: 'C' }, + { attname: 'subject', collname: 'C' }, + ]); + }); +}); +test('SSO binding keys enforce uniqueness/FK/restricted deletion and bounded nonempty strings', async () => { + await migrated(async (db) => { + await db.exec("INSERT INTO iam_principals VALUES ('human','human',true)"); + await db.query('INSERT INTO iam_sso_identity_bindings VALUES ($1,$2,$3)', [ + 'issuer', + 'subject', + 'human', + ]); + for (const [issuer, subject, principal, code] of [ + ['issuer', 'subject', 'human', '23505'], + ['missing', 'subject', 'missing', '23503'], + ['', 'subject', 'human', '23514'], + ['issuer', '', 'human', '23514'], + ['x'.repeat(2049), 'subject', 'human', '23514'], + ['issuer', 'x'.repeat(257), 'human', '23514'], + ]) + await assert.rejects( + db.query('INSERT INTO iam_sso_identity_bindings VALUES ($1,$2,$3)', [ + issuer, + subject, + principal, + ]), + (error) => { + assert.ok(error instanceof Error && 'code' in error); + assert.equal(error.code, code); + return true; + }, + ); + await assert.rejects( + db.query('DELETE FROM iam_principals WHERE principal_id=$1', ['human']), + (error) => { + assert.ok(error instanceof Error && 'code' in error); + assert.equal(error.code, '23503'); + return true; + }, + ); + const left = await db.query('SELECT principal_id FROM iam_sso_identity_bindings'); + assert.deepEqual(left.rows, [{ principal_id: 'human' }]); + }); +}); +test('trusted migration bundle includes the new SSO binding migration without editing historical sources', async () => { + const sources = await loadPostgresMigrationSources(); + assert.equal(sources.length, 11); + assert.equal(sources.at(-1)?.version, '011'); + assert.match(sources.at(-1)?.sql ?? '', /CREATE TABLE iam_sso_identity_bindings/u); +}); diff --git a/test/sso-identity-bindings.test.ts b/test/sso-identity-bindings.test.ts new file mode 100644 index 0000000..adb261c --- /dev/null +++ b/test/sso-identity-bindings.test.ts @@ -0,0 +1,322 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { PGlite } from '@electric-sql/pglite'; +import { + createPostgresSsoIdentityBindingReader, + type SsoIdentityBindingSqlClient, +} from '../src/identity/postgres-sso-identity-bindings.ts'; +import { loadPostgresMigrationSources } from '../src/storage/postgres-migration-sources.ts'; + +const identity = { issuer: 'https://sso.example', subject: 'user' }; +const human = { ...identity, principal_id: 'human', kind: 'human', active: true }; +type Input = Parameters< + ReturnType['resolveHumanPrincipal'] +>[0]; +type SqlResult = Awaited>; +function readerReturning(value: unknown) { + return createPostgresSsoIdentityBindingReader({ query: async () => value as SqlResult }); +} +function safeError(name: string, message: string) { + return (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal(error.name, name); + assert.equal(error.message, message); + assert.equal(error.cause, undefined); + assert.deepEqual(Object.keys(error), ['name']); + return true; + }; +} +const invalid = safeError('InvalidSsoIdentityBindingInput', 'Invalid SSO identity binding input'); +const unavailable = safeError('SsoIdentityBindingUnavailable', 'SSO identity binding unavailable'); + +test('binding reader projects exact parameters into one joined read and returns only the human ID', async () => { + let reads = 0; + const raw = { issuer: " https://sso.example/' ", subject: ' 유저😀 ' }; + const reader = createPostgresSsoIdentityBindingReader({ + async query(sql, params) { + reads++; + assert.match(sql, /JOIN iam_principals/u); + assert.match(sql, /b\.issuer = \$1 AND b\.subject = \$2/u); + assert.ok(!sql.includes(raw.issuer) && !sql.includes(raw.subject)); + assert.deepEqual(params, [raw.issuer, raw.subject]); + assert.ok(Object.isFrozen(params)); + assert.throws(() => (params as unknown[]).push('forged')); + return { rows: [{ ...raw, principal_id: 'human', kind: 'human', active: true }] }; + }, + }); + assert.equal(await reader.resolveHumanPrincipal(raw), 'human'); + assert.equal(reads, 1); +}); + +for (const [name, rows] of [ + ['missing binding', []], + ['inactive human', [{ ...human, active: false }]], + ['active service', [{ ...human, kind: 'service' }]], + ['inactive service', [{ ...human, kind: 'service', active: false }]], +] as const) { + test(`binding reader denies ${name} despite forged extra caller state`, async () => { + const reader = readerReturning({ rows }); + const input = { + ...identity, + principalId: 'admin', + kind: 'human', + active: true, + email: 'user@example.test', + token: 'fixture-only', + }; + assert.equal(await reader.resolveHumanPrincipal(input), undefined); + }); +} + +test('binding reader captures own required claims once before asynchronous query mutation', async () => { + let issuerReads = 0; + let subjectReads = 0; + let value = 'https://sso.example'; + const input = { + get issuer() { + assert.equal(++issuerReads, 1); + return value; + }, + get subject() { + assert.equal(++subjectReads, 1); + return 'user'; + }, + get principalId() { + throw Error('Extra caller field must not be read'); + }, + }; + const reader = createPostgresSsoIdentityBindingReader({ + async query(_sql, params) { + value = 'https://other.example'; + await Promise.resolve(); + assert.deepEqual(params, [identity.issuer, identity.subject]); + return { rows: [human] }; + }, + }); + assert.equal(await reader.resolveHumanPrincipal(input), 'human'); + assert.equal(issuerReads, 1); + assert.equal(subjectReads, 1); +}); + +test('binding reader reads result properties once and ignores unneeded result fields', async () => { + const counts = new Map(); + const row = {}; + for (const [key, value] of Object.entries(human)) { + Object.defineProperty(row, key, { + get() { + const count = (counts.get(key) ?? 0) + 1; + counts.set(key, count); + assert.equal(count, 1); + return value; + }, + }); + } + Object.defineProperty(row, 'email', { + get() { + throw Error('Unneeded field'); + }, + }); + let rowsReads = 0; + const result = { + get rows() { + assert.equal(++rowsReads, 1); + return [row]; + }, + }; + assert.equal(await readerReturning(result).resolveHumanPrincipal(identity), 'human'); + assert.equal(rowsReads, 1); + assert.equal(counts.size, 5); +}); + +const invalidInputs: readonly [string, unknown][] = [ + ['null', null], + ['array', []], + ['string', 'raw-private-fixture'], + ['missing issuer', { subject: 'user' }], + ['missing subject', { issuer: identity.issuer }], + ['inherited claims', Object.create(identity)], + ['empty issuer', { ...identity, issuer: '' }], + ['empty subject', { ...identity, subject: '' }], + ['oversized issuer', { ...identity, issuer: 'x'.repeat(2049) }], + ['oversized subject', { ...identity, subject: 'x'.repeat(257) }], + ['non-string issuer', { ...identity, issuer: 1 }], + ['non-string subject', { ...identity, subject: true }], + ['NUL issuer', { ...identity, issuer: 'x\0y' }], + ['NUL subject', { ...identity, subject: 'x\0y' }], + ['high surrogate issuer', { ...identity, issuer: '\ud800' }], + ['low surrogate issuer', { ...identity, issuer: '\udfff' }], + ['high surrogate subject', { ...identity, subject: '\ud800x' }], + ['low surrogate subject', { ...identity, subject: 'x\udfff' }], + ['overlong UTF-16 subject', { ...identity, subject: '😀'.repeat(129) }], + [ + 'throwing issuer', + { + get issuer() { + throw Error('private claim fixture'); + }, + subject: 'user', + }, + ], + [ + 'throwing subject', + { + issuer: identity.issuer, + get subject() { + throw Error('private claim fixture'); + }, + }, + ], +]; +for (const [name, input] of invalidInputs) { + test(`invalid binding input fails before SQL: ${name}`, async () => { + let reads = 0; + const reader = createPostgresSsoIdentityBindingReader({ + query: async () => { + reads++; + return { rows: [human] }; + }, + }); + await assert.rejects(reader.resolveHumanPrincipal(input as Input), invalid); + assert.equal(reads, 0); + }); +} + +for (const [name, input] of [ + ['maximum lengths', { issuer: 'x'.repeat(2048), subject: 'x'.repeat(256) }], + ['maximum UTF-16 subject', { issuer: 'https://sso.example/', subject: '😀'.repeat(128) }], + ['exact whitespace', { issuer: ' ', subject: ' ' }], + ['combining Unicode', { issuer: 'https://sso.example', subject: 'e\u0301' }], +] as const) { + test(`binding reader preserves valid opaque identity: ${name}`, async () => { + assert.equal( + await readerReturning({ rows: [{ ...human, ...input }] }).resolveHumanPrincipal(input), + 'human', + ); + }); +} + +const malformedResults: readonly [string, unknown][] = [ + ['null result', null], + ['missing rows', {}], + ['non-array rows', { rows: {} }], + ['null rows', { rows: null }], + ['hole', { rows: new Array(1) }], + ['duplicates', { rows: [human, human] }], + ['null row', { rows: [null] }], + ['array row', { rows: [[]] }], + ['primitive row', { rows: ['private row fixture'] }], + ['inherited row', { rows: [Object.create(human)] }], + ['wrong issuer', { rows: [{ ...human, issuer: 'https://other.example' }] }], + ['wrong subject', { rows: [{ ...human, subject: 'User' }] }], + [ + 'missing principal', + { rows: [{ issuer: identity.issuer, subject: identity.subject, kind: 'human', active: true }] }, + ], + ['empty principal', { rows: [{ ...human, principal_id: '' }] }], + ['oversized principal', { rows: [{ ...human, principal_id: 'x'.repeat(257) }] }], + ['invalid Unicode principal', { rows: [{ ...human, principal_id: '\ud800' }] }], + ['NUL principal', { rows: [{ ...human, principal_id: 'x\0y' }] }], + ['non-string principal', { rows: [{ ...human, principal_id: 7 }] }], + ['invalid kind', { rows: [{ ...human, kind: 'admin' }] }], + ['string active', { rows: [{ ...human, active: 'true' }] }], + ['missing active', { rows: [{ ...identity, principal_id: 'human', kind: 'human' }] }], + ['malformed denial', { rows: [{ ...human, principal_id: '', kind: 'service', active: false }] }], + [ + 'throwing rows', + { + get rows() { + throw Error('private result fixture'); + }, + }, + ], + [ + 'throwing principal', + { + rows: [ + { + ...human, + get principal_id() { + throw Error('private result fixture'); + }, + }, + ], + }, + ], +]; +for (const [name, result] of malformedResults) { + test(`binding reader rejects unsafe persistence result: ${name}`, async () => { + await assert.rejects(readerReturning(result).resolveHumanPrincipal(identity), unavailable); + }); +} + +test('binding reader sanitizes driver failure without claims, messages or cause', async () => { + const reader = createPostgresSsoIdentityBindingReader({ + query: async () => { + throw Error('private query/issuer/token fixture'); + }, + }); + await assert.rejects(reader.resolveHumanPrincipal(identity), unavailable); +}); + +test('migrated binding reader sees current exact identities and principal state without account or policy writes', async () => { + const db = new PGlite(); + try { + for (const source of await loadPostgresMigrationSources()) await db.exec(source.sql); + await db.exec( + "INSERT INTO iam_principals VALUES ('human','human',true),('other','human',true),('service','service',true)", + ); + for (const [issuer, subject, principal] of [ + [identity.issuer, identity.subject, 'human'], + [identity.issuer, 'User', 'other'], + ['https://other.example', identity.subject, 'other'], + [identity.issuer, ' 유저😀 ', 'human'], + [identity.issuer, 'service', 'service'], + ]) + await db.query('INSERT INTO iam_sso_identity_bindings VALUES ($1,$2,$3)', [ + issuer, + subject, + principal, + ]); + const reader = createPostgresSsoIdentityBindingReader({ + query: (sql, params) => db.query(sql, [...params]), + }); + assert.equal(await reader.resolveHumanPrincipal(identity), 'human'); + assert.equal(await reader.resolveHumanPrincipal({ ...identity, subject: 'User' }), 'other'); + assert.equal( + await reader.resolveHumanPrincipal({ ...identity, issuer: 'https://other.example' }), + 'other', + ); + assert.equal(await reader.resolveHumanPrincipal({ ...identity, subject: ' 유저😀 ' }), 'human'); + for (const input of [ + { ...identity, issuer: `${identity.issuer}/` }, + { ...identity, subject: ' user' }, + { ...identity, subject: 'missing' }, + { ...identity, subject: 'service' }, + ]) + assert.equal(await reader.resolveHumanPrincipal(input), undefined); + await db.exec("UPDATE iam_principals SET active=false WHERE principal_id='human'"); + assert.equal(await reader.resolveHumanPrincipal(identity), undefined); + await db.exec( + "UPDATE iam_principals SET active=true,kind='service' WHERE principal_id='human'", + ); + assert.equal(await reader.resolveHumanPrincipal(identity), undefined); + await db.query( + 'UPDATE iam_sso_identity_bindings SET principal_id=$3 WHERE issuer=$1 AND subject=$2', + [identity.issuer, identity.subject, 'other'], + ); + assert.equal(await reader.resolveHumanPrincipal(identity), 'other'); + await db.query('DELETE FROM iam_sso_identity_bindings WHERE issuer=$1 AND subject=$2', [ + identity.issuer, + identity.subject, + ]); + assert.equal(await reader.resolveHumanPrincipal(identity), undefined); + assert.deepEqual((await db.query('SELECT count(*)::int AS count FROM iam_principals')).rows, [ + { count: 3 }, + ]); + assert.deepEqual((await db.query('SELECT count(*)::int AS count FROM iam_policies')).rows, [ + { count: 0 }, + ]); + } finally { + await db.close(); + } +});