From c69b08bece79159a972520610428cda80097b0a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 15:07:32 +0900 Subject: [PATCH 01/30] docs(spec): define closed OIDC RP mapper profile --- ...7-keyverse-oidc-rp-claim-profile-design.md | 303 ++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-07-keyverse-oidc-rp-claim-profile-design.md diff --git a/docs/superpowers/specs/2026-08-07-keyverse-oidc-rp-claim-profile-design.md b/docs/superpowers/specs/2026-08-07-keyverse-oidc-rp-claim-profile-design.md new file mode 100644 index 0000000..8983ebd --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-keyverse-oidc-rp-claim-profile-design.md @@ -0,0 +1,303 @@ +# OIDC Relying-Party Claim Mapper Profile Design + +**Status:** Approved for bounded implementation under the protected autonomous +product-development loop. + +**Issue:** #70 + +## Problem + +Keyverse now owns a closed, secret-free OIDC relying-party representation and a +durable reconciliation lifecycle. That representation intentionally excludes +Keycloak protocol mappers. The portable realm therefore still embeds +`naruon-web` because its audience and `role`, `org`, and `workspace` session +claims cannot yet be reconstructed from runtime desired state. + +Keeping an application client in the portable realm creates two competing +sources of truth: + +1. realm import may recreate the client during a rebuild; +2. Keyverse desired state may independently create or update the same client. + +The missing product boundary is not a generic Keycloak mapper editor. It is one +small, auditable mapper profile sufficient for ecosystem applications that need +an access-token audience and bounded session-routing claims. + +## Goals + +- Add an optional `protocolMappers` field to the closed Keycloak + `ClientRepresentation` accepted by preflight and desired-state PUT. +- Accept exactly one reviewed mapper family: one audience mapper plus optional + hardcoded `role`, `org`, and `workspace` claims. +- Preserve alias-shaped Keycloak JSON while rejecting arbitrary protocol mapper + plugins and arbitrary nested configuration. +- Canonicalize mapper order so equivalent desired state has one receipt. +- Ignore Keycloak-generated mapper IDs and returned ordering when observing + drift, while comparing every product-owned field. +- Keep all accepted data secret-free and locally validated. +- Provide a realistic Naruon runtime template that can replace the committed + realm client in follow-up issue #71. + +## Non-goals + +- Generic protocol-mapper administration. +- Script mappers, regex transforms, user-attribute mappers, group mappers, + address mappers, pairwise-subject configuration, or arbitrary claim names. +- Client-secret generation or retrieval. +- Claim-value authorization or tenant-directory lookup. +- Removal of application RPs from the realm in this PR; that is #71. +- A formal OpenID Connect, JWT access-token, or Keycloak conformance claim. + +## Closed data contract + +`protocolMappers` is optional for backward compatibility. When absent, it is +canonicalized to an empty list. When non-empty, it must contain exactly one +audience mapper followed by zero or more hardcoded claim mappers in the fixed +claim order `role`, `org`, `workspace`. + +### Audience mapper + +```json +{ + "name": "keyverse-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "naruon-web", + "access.token.claim": "true", + "id.token.claim": "false", + "introspection.token.claim": "true" + } +} +``` + +The included audience must equal the registration `clientId`. Keyverse does not +use this mapper to modify the ID Token audience; OpenID Connect Core already +requires an ID Token audience containing the relying party client ID. The +mapper exists for the Keycloak access-token resource-audience contract. + +### Hardcoded session claim mapper + +```json +{ + "name": "keyverse-claim-role", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "claim.name": "role", + "claim.value": "member", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "false", + "introspection.token.claim": "true" + } +} +``` + +The claim name is one of `role`, `org`, or `workspace`. Mapper names are derived +from the claim name and therefore cannot be freely chosen. Claim values are +trimmed UTF-8 strings of 1–128 Unicode scalar values. Raw C0 controls, DEL, +unresolved template markers, line separators, and leading or trailing +whitespace are rejected. The value is product data, not a secret, and remains +visible in the desired-state response and audit evidence. + +## Validation architecture + +```mermaid +flowchart LR + A[Untrusted rendered JSON] --> B[Manual non-reflective shape parser] + B --> C[Closed Pydantic models] + C --> D[Base RP policy] + D --> E[Mapper policy] + E --> F[Canonical mapper order] + F --> G[Side-effect-free readiness receipt] + F --> H[Secret-free KV desired state] + H --> I[Keycloak client reconciliation] + I --> J[Normalize observed mapper IDs/order] + J --> K[Observable comparison] + K --> L[Canonical apply receipt] +``` + +The manual parser remains the first boundary so malformed nested values are not +reflected by framework validation errors. The parser validates object/list/key +and scalar types before constructing nested Pydantic models. + +## Mapper policy + +### Shared fields + +Every mapper requires exactly: + +- `name`; +- `protocol`; +- `protocolMapper`; +- `consentRequired`; +- `config`. + +No mapper-level `id` is accepted in desired state. `protocol` is exactly +`openid-connect`, and `consentRequired` is exactly `false`. + +### Audience policy + +- Exactly one audience mapper exists when the list is non-empty. +- Its name is `keyverse-audience`. +- `protocolMapper` is exactly `oidc-audience-mapper`. +- Its configuration has exactly four fields. +- `included.client.audience` equals the registration `clientId`. +- The mapper writes only access-token and introspection-token audience data. +- ID-token emission remains false. + +### Hardcoded-claim policy + +- Claim names are limited to `role`, `org`, and `workspace`. +- Each claim appears at most once. +- Mapper name is exactly `keyverse-claim-{claim.name}`. +- `jsonType.label` is exactly `String`. +- Access-token, ID-token, and introspection-token emission are true. +- UserInfo emission is false to avoid expanding the first product profile. +- The configuration has exactly seven fields. + +### Ordering + +The accepted canonical order is: + +```text +keyverse-audience +keyverse-claim-role +keyverse-claim-org +keyverse-claim-workspace +``` + +Input in another order is rejected rather than silently rewritten. This makes a +reviewed JSON artifact byte-stable and avoids a hidden mutation between +preflight and apply. + +## Reconciliation comparison + +Keycloak may add an opaque `id` to each mapper and may return mappers in a +different order. Observable comparison therefore: + +1. validates each returned mapper as an object; +2. removes only the vendor-generated `id` field; +3. selects exactly the product-owned fields; +4. rejects duplicate or unsupported product-owned mapper identities; +5. sorts by the same canonical mapper rank; +6. compares the normalized list to desired state. + +Unknown live mappers are observable drift. Keyverse does not delete them in this +slice by issuing separate mapper API calls; a whole-client update is used, and +post-mutation observation must match the exact closed representation before a +receipt is recorded. + +## Failure behavior + +- Malformed request shape: bounded HTTP 422 with field-only detail. +- Valid shape but disallowed mapper policy: bounded HTTP 400. +- Duplicate or unsupported live mapper representation: observable drift or + apply failure; never a false `in_sync` state. +- Keycloak outage: desired intent remains stored and status is `unavailable`. +- Create/update success without exact re-observation: `apply_failed`, no receipt. +- Multiple exact clients: `ambiguous`, no mutation. + +No error includes a submitted claim value, bearer token, client secret, or raw +Keycloak response. + +## Modularity + +The pure parser and validator remain independent of storage and transport. +`RelyingPartyService` depends only on `KvStore` and `RelyingPartyAdminApi`. +Standalone Keyverse, CWL deployment controllers, and Naruon use the same JSON +contract. The feature introduces no database schema; existing multi-word +`snake_case` namespaces remain authoritative. + +## Documentation and operations + +- Add `deploy/templates/oidc-rp-naruon.json` as a secret-free runtime artifact. +- Update `docs/rp-onboarding.md` and the reconciliation runbook with the mapper + policy and acceptance-test boundary. +- Update `ARCHITECTURE.md`, `AGENTS.md`, `CLAUDE.md`, and `CHANGELOG.md` when + implementation changes behavior. +- Add an APA 7th doctoring record separating standard requirements, Keycloak + vendor representation, product restrictions, measured evidence, assumptions, + and limitations. + +## Testing strategy + +### RED baseline + +A realistic Naruon payload with `protocolMappers` must fail against the current +closed parser because the field is unsupported. This proves the feature is not +already present. + +### Parser and policy tests + +- valid audience-only profile; +- valid Naruon audience plus three claims; +- missing and extra nested fields; +- non-object mapper and non-string config values; +- duplicate audience; +- audience not equal to `clientId`; +- duplicate claim name; +- unsupported claim name; +- unsupported mapper type; +- arbitrary mapper name; +- wrong claim destinations or JSON type; +- hostile, unresolved, control-bearing, empty, or oversized claim values; +- noncanonical mapper order; +- no storage, DNS, HTTP, Keycloak, or file side effects. + +### Reconciliation tests + +- live mapper IDs do not create false drift; +- live mapper order does not create false drift; +- unknown, duplicate, or malformed live mappers remain drifted; +- canonical receipt is independent of JSON object key order but sensitive to + mapper order and claim values; +- realm rebuild recreates exact mappers; +- changed claim value repairs drift; +- post-update mapper mismatch writes no receipt; +- existing outage, concurrency, duplicate-client, and remote-first delete tests + remain green. + +### Deployment tests + +- Naruon template parses as JSON; +- template contains no secret or unresolved mapper type; +- after rendering placeholders, the template passes preflight; +- portable realm removal remains a separate failing/green cycle in #71. + +## Merge and release gates + +The exact final head must pass: + +- locked dependency installation; +- Ruff; +- Python compilation; +- production docstrings 100%; +- complete pytest; +- production statement coverage 100%; +- production branch coverage 100%; +- wheel and source-distribution build; +- realm, Compose, and every deployment-template validation; +- CodeQL, Semgrep, and Security Scan; +- current-head independent review and zero unresolved threads; +- protected merge without administrator bypass. + +The change remains under `[Unreleased]`. It does not by itself justify a version, +tag, package publication, or GitHub Release. + +## Standards interpretation + +- OpenID Connect Core defines ID Token audience semantics and permits additional + claims. It does not define Keycloak mapper JSON. +- RFC 9068 defines audience validation expectations for JWT access tokens where + that profile is used; Keyverse records it as security guidance, not as proof + that Keycloak tokens conform to RFC 9068. +- RFC 8725 provides general JWT implementation guidance, including explicit + validation and mutually exclusive validation rules. +- Keycloak Admin REST defines `ClientRepresentation` and + `ProtocolMapperRepresentation`. Exact mapper plugin names and configuration + keys are vendor behavior and are isolated behind this closed product profile. From 87c41376c8db88a8e3063d215cb896c2bb213757 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 15:12:07 +0900 Subject: [PATCH 02/30] docs(plan): plan closed OIDC RP mapper profile --- ...26-08-07-keyverse-oidc-rp-claim-profile.md | 382 ++++++++++++++++++ 1 file changed, 382 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-07-keyverse-oidc-rp-claim-profile.md diff --git a/docs/superpowers/plans/2026-08-07-keyverse-oidc-rp-claim-profile.md b/docs/superpowers/plans/2026-08-07-keyverse-oidc-rp-claim-profile.md new file mode 100644 index 0000000..9cdb3e1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-keyverse-oidc-rp-claim-profile.md @@ -0,0 +1,382 @@ +# OIDC Relying-Party Claim Mapper Profile Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> `superpowers:subagent-driven-development` or `superpowers:executing-plans` and +> preserve the RED→GREEN→REFACTOR evidence for every behavior change. + +**Goal:** Add one closed, secret-free Keycloak audience and session-claim mapper +profile to OIDC relying-party preflight and desired-state reconciliation. + +**Architecture:** Extend the manually parsed alias-shaped +`RelyingPartyRegistration` with optional nested mapper models. Keep all policy in +the pure preflight module, and add one normalization seam in the reconciliation +module so Keycloak-generated mapper IDs and ordering do not create false drift. +The Keycloak transport remains unchanged because protocol mappers travel inside +the existing `ClientRepresentation` create/update body. + +**Tech stack:** Python 3.11+, FastAPI, Pydantic v2, httpx, pytest, coverage, +Ruff, Keycloak Admin REST, JSON deployment templates. + +## Global constraints + +- No LLM is used for deterministic validation or reconciliation. +- No `COPILOT_GITHUB_TOKEN` or review-agent credential changes. +- Preflight performs no storage, DNS, socket, HTTP, Keycloak, secret, or file + side effect. +- Desired state remains secret-free and uses existing multi-word `snake_case` + namespaces. +- Production docstrings, statement coverage, and branch coverage are 100%. +- Existing standalone, CWL, and Naruon module contracts remain compatible. +- Every behavior change updates `CHANGELOG.md` and APA 7th doctoring. + +--- + +### Task 1: Establish the missing-feature RED receipt + +**Files:** +- Create: `services/account_unification/tests/test_relying_party_claim_mappers.py` + +**Interfaces:** +- Consumes: `create_app(wire=False)`, operator authentication, the existing + `/clients/relying-parties:validate` route. +- Produces: `_naruon_registration_with_mappers() -> dict` reused by later tests. + +- [ ] **Step 1: Add a production-shaped Naruon request fixture** + +The fixture starts from the existing valid public RP payload and adds exactly +four mappers: audience, role, org, and workspace. + +- [ ] **Step 2: Add the first behavior test** + +```python +def test_naruon_claim_mapper_profile_is_accepted(client, auth_header): + response = client.post( + "/clients/relying-parties:validate", + headers=auth_header, + json=_naruon_registration_with_mappers(), + ) + assert response.status_code == 200 + assert response.json()["ready_to_apply"] is True +``` + +- [ ] **Step 3: Run the focused test and retain the RED evidence** + +```bash +uv run pytest -q tests/test_relying_party_claim_mappers.py::test_naruon_claim_mapper_profile_is_accepted +``` + +Expected failure: HTTP 422 because `protocolMappers` is an unsupported field. + +- [ ] **Step 4: Commit only the failing test** + +```bash +git add tests/test_relying_party_claim_mappers.py +git commit -m "test(clients): specify closed RP claim mapper profile" +``` + +### Task 2: Add non-reflective nested mapper parsing + +**Files:** +- Modify: `services/account_unification/app/relying_party.py` +- Modify: `services/account_unification/tests/test_relying_party_claim_mappers.py` + +**Interfaces:** +- Produces: + - `RelyingPartyProtocolMapper` + - optional `protocol_mappers: list[RelyingPartyProtocolMapper]` + - `_parse_protocol_mappers(value: Any) -> list[RelyingPartyProtocolMapper]` + +- [ ] **Step 1: Add failing shape tests** + +Cover non-array `protocolMappers`, non-object entries, non-string keys, missing +fields, extra fields, non-boolean `consentRequired`, non-object `config`, and +non-string config values. Each assertion requires bounded field-only HTTP 422 +output that does not contain hostile submitted values. + +- [ ] **Step 2: Run the focused shape tests and verify the expected failures** + +```bash +uv run pytest -q tests/test_relying_party_claim_mappers.py -k shape +``` + +- [ ] **Step 3: Add the closed nested model and manual parser** + +Use exact mapper fields `name`, `protocol`, `protocolMapper`, +`consentRequired`, and `config`. Add `protocolMappers` to allowed fields but not +to required fields; absent input canonicalizes to `[]`. + +- [ ] **Step 4: Run the shape tests and original preflight suite** + +```bash +uv run pytest -q tests/test_relying_party_claim_mappers.py -k shape +uv run pytest -q tests/test_relying_party_preflight.py +``` + +- [ ] **Step 5: Commit** + +```bash +git add app/relying_party.py tests/test_relying_party_claim_mappers.py +git commit -m "feat(clients): parse closed RP protocol mapper objects" +``` + +### Task 3: Enforce the audience mapper policy + +**Files:** +- Modify: `services/account_unification/app/relying_party.py` +- Modify: `services/account_unification/tests/test_relying_party_claim_mappers.py` + +**Interfaces:** +- Produces `_validate_protocol_mappers(registration) -> None`. + +- [ ] **Step 1: Add failing audience-policy tests** + +Test valid audience-only, duplicate audience, missing audience when any mapper is +present, wrong mapper name, wrong protocol, wrong mapper type, consent enabled, +extra/missing config, audience unequal to `clientId`, and wrong token +claim-destination flags. + +- [ ] **Step 2: Verify failures** + +```bash +uv run pytest -q tests/test_relying_party_claim_mappers.py -k audience +``` + +- [ ] **Step 3: Implement the exact audience policy** + +Require `keyverse-audience`, `openid-connect`, `oidc-audience-mapper`, +`consentRequired=false`, and exactly: + +```text +included.client.audience = registration.client_id +access.token.claim = true +id.token.claim = false +introspection.token.claim = true +``` + +- [ ] **Step 4: Verify focused and full preflight tests** + +```bash +uv run pytest -q tests/test_relying_party_claim_mappers.py -k audience +uv run pytest -q tests/test_relying_party_preflight.py tests/test_relying_party_claim_mappers.py +``` + +- [ ] **Step 5: Commit** + +```bash +git add app/relying_party.py tests/test_relying_party_claim_mappers.py +git commit -m "feat(clients): pin the RP access-token audience mapper" +``` + +### Task 4: Enforce bounded hardcoded session claims and canonical order + +**Files:** +- Modify: `services/account_unification/app/relying_party.py` +- Modify: `services/account_unification/tests/test_relying_party_claim_mappers.py` + +**Interfaces:** +- Produces a canonical mapper rank for audience, role, org, and workspace. + +- [ ] **Step 1: Add failing claim-policy tests** + +Cover valid role/org/workspace subsets, all three Naruon claims, duplicate claim, +unsupported claim, noncanonical mapper name, wrong mapper class, wrong JSON type, +wrong token destinations, empty/oversized/control/unresolved/trim-ambiguous claim +values, and noncanonical list order. + +- [ ] **Step 2: Verify failures** + +```bash +uv run pytest -q tests/test_relying_party_claim_mappers.py -k claim +``` + +- [ ] **Step 3: Implement minimal closed claim policy** + +Require exact `oidc-hardcoded-claim-mapper` objects for only `role`, `org`, and +`workspace`. Values are 1–128 Unicode scalar values, trimmed, non-control, +non-template text. Require canonical list order. + +- [ ] **Step 4: Verify all mapper and existing preflight tests** + +```bash +uv run pytest -q tests/test_relying_party_claim_mappers.py tests/test_relying_party_preflight.py +``` + +- [ ] **Step 5: Commit** + +```bash +git add app/relying_party.py tests/test_relying_party_claim_mappers.py +git commit -m "feat(clients): validate bounded RP session claims" +``` + +### Task 5: Normalize live Keycloak mappers for drift comparison + +**Files:** +- Modify: `services/account_unification/app/relying_party_state.py` +- Modify: `services/account_unification/tests/test_relying_party_desired_state.py` +- Modify: `services/account_unification/tests/mock_product_keycloak.py` only if + the mock must simulate generated mapper IDs or ordering. + +**Interfaces:** +- Produces: + - `_normalized_observed_mappers(value: object) -> list[dict] | None` + - mapper-aware `_observable_client_matches(...)`. + +- [ ] **Step 1: Add failing reconciliation tests** + +After a successful apply, inject generated mapper `id` fields and reverse the +live mapper order. Status must remain `in_sync`. Add separate tests proving an +unknown mapper, duplicate mapper, malformed mapper, or changed claim value is +`drifted` and repaired by reconciliation. + +- [ ] **Step 2: Verify RED** + +```bash +uv run pytest -q tests/test_relying_party_desired_state.py -k mapper +``` + +- [ ] **Step 3: Implement mapper normalization** + +Ignore only live `id`; select product-owned fields, validate shape, rank known +mapper identity, and compare the canonical list. Any unknown or duplicate live +mapper returns a mismatch rather than raising raw vendor data. + +- [ ] **Step 4: Verify lifecycle tests** + +```bash +uv run pytest -q tests/test_relying_party_desired_state.py tests/test_relying_party_state_integrity.py +``` + +- [ ] **Step 5: Commit** + +```bash +git add app/relying_party_state.py tests/test_relying_party_desired_state.py tests/mock_product_keycloak.py +git commit -m "fix(clients): normalize observed RP mapper state" +``` + +### Task 6: Add the Naruon runtime template and operator evidence + +**Files:** +- Create: `deploy/templates/oidc-rp-naruon.json` +- Create or modify: `services/account_unification/tests/test_relying_party_template.py` +- Modify: `deploy/templates/README.md` +- Modify: `docs/rp-onboarding.md` +- Modify: `docs/operations/oidc-rp-reconciliation.md` + +**Interfaces:** +- Produces one secret-free rendered artifact accepted by preflight and desired + state after placeholder substitution. + +- [ ] **Step 1: Add failing template tests** + +Require valid JSON, no secret-bearing field, canonical four-mapper order, +audience pinned to `naruon-web`, and a rendered form accepted by the production +parser/validator. + +- [ ] **Step 2: Add the template** + +Use exact HTTPS placeholders for redirect/origin/logout and bounded placeholders +for `role`, `org`, and `workspace` values. Do not include a client secret. + +- [ ] **Step 3: Update operator documentation** + +Document render → preflight → desired-state PUT → exact status → controlled +login acceptance. State that claim values are visible product routing data and +must not carry credentials or personal secrets. + +- [ ] **Step 4: Verify** + +```bash +uv run pytest -q tests/test_relying_party_template.py tests/test_relying_party_claim_mappers.py +python - <<'PY' +import json +from pathlib import Path +for path in Path('deploy/templates').glob('*.json'): + json.loads(path.read_text(encoding='utf-8')) +PY +``` + +- [ ] **Step 5: Commit** + +```bash +git add deploy/templates docs/rp-onboarding.md docs/operations/oidc-rp-reconciliation.md tests/test_relying_party_template.py +git commit -m "docs(clients): add the Naruon runtime RP claim profile" +``` + +### Task 7: Complete architecture, changelog, and APA 7th doctoring + +**Files:** +- Create: `docs/doctoring/oidc-rp-claim-mapper-profile.md` +- Modify: `ARCHITECTURE.md` +- Modify: `AGENTS.md` +- Modify: `CLAUDE.md` +- Modify: `CHANGELOG.md` + +**Interfaces:** None; these files define the operational and review contract. + +- [ ] **Step 1: Record standards and vendor interpretation** + +Separate OIDC Core ID-token audience, RFC 9068 access-token guidance, RFC 8725 +JWT guidance, Keycloak mapper representation, stricter product policy, measured +evidence, assumptions, and limitations. Use APA 7th references. + +- [ ] **Step 2: Update architecture and agent rules** + +Add the mapper-profile boundary and preserve the follow-up requirement to remove +runtime application clients from the portable realm under #71. + +- [ ] **Step 3: Update `[Unreleased]`** + +Record the optional mapper profile, normalization, template, and remaining realm +migration boundary. Do not create a release section. + +- [ ] **Step 4: Commit** + +```bash +git add ARCHITECTURE.md AGENTS.md CLAUDE.md CHANGELOG.md docs/doctoring +git commit -m "docs(clients): trace the closed RP claim mapper profile" +``` + +### Task 8: Close complete coverage and package/deployment verification + +**Files:** +- Modify production/tests only when the measured report identifies a real + uncovered branch or defect. + +- [ ] **Step 1: Run the full exact-tree acceptance suite** + +```bash +cd services/account_unification +uv sync --locked --extra dev +uv run ruff check app tests tools +uv run interrogate . +uv run python -m compileall -q app tests tools +uv run coverage erase +uv run coverage run --branch --source=app -m pytest -q +uv run coverage report --show-missing --fail-under=100 +uv build --out-dir dist +cd ../.. +python scripts/validate_realm.py deploy/keycloak/realm-cwl.json +docker compose -f docker-compose.yml config +python - <<'PY' +import json +from pathlib import Path +for path in Path('deploy/templates').glob('*.json'): + json.loads(path.read_text(encoding='utf-8')) +PY +git diff --check main...HEAD +``` + +- [ ] **Step 2: Fix only evidence-backed gaps with TDD** + +For each failure, preserve the failing test or command output, make one focused +change, and rerun the focused command before repeating the full suite. + +- [ ] **Step 3: Update the PR body with exact-head evidence** + +Include the RED commit/run, final head, complete verification commands, measured +statement/branch totals, and residual risks. + +- [ ] **Step 4: Request current-head review and arm protected auto-merge** + +No self-approval or administrator bypass. Merge only after exact-current-head +CI, CodeQL, Semgrep, Security Scan, review, and unresolved-thread gates pass. From 3d1dc17dea701f0d3d4ba7471fbf7a6235aa9b39 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 15:14:24 +0900 Subject: [PATCH 03/30] test(clients): specify closed RP claim mapper profile --- .../tests/test_relying_party_claim_mappers.py | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 services/account_unification/tests/test_relying_party_claim_mappers.py diff --git a/services/account_unification/tests/test_relying_party_claim_mappers.py b/services/account_unification/tests/test_relying_party_claim_mappers.py new file mode 100644 index 0000000..b0c9bcb --- /dev/null +++ b/services/account_unification/tests/test_relying_party_claim_mappers.py @@ -0,0 +1,106 @@ +"""Closed OIDC relying-party audience and session-claim mapper tests.""" +from __future__ import annotations + +from copy import deepcopy + +from fastapi.testclient import TestClient + +from app.main import create_app + +from .test_relying_party_preflight import _confidential_web_client + + +def _naruon_registration_with_mappers() -> dict[str, object]: + """Return a production-shaped Naruon client with its closed claim profile.""" + payload = deepcopy(_confidential_web_client()) + payload.update( + { + "publicClient": True, + "clientAuthenticatorType": "none", + "protocolMappers": [ + { + "name": "keyverse-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": False, + "config": { + "included.client.audience": "naruon-web", + "access.token.claim": "true", + "id.token.claim": "false", + "introspection.token.claim": "true", + }, + }, + { + "name": "keyverse-claim-role", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": False, + "config": { + "claim.name": "role", + "claim.value": "member", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "false", + "introspection.token.claim": "true", + }, + }, + { + "name": "keyverse-claim-org", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": False, + "config": { + "claim.name": "org", + "claim.value": "org-cwl", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "false", + "introspection.token.claim": "true", + }, + }, + { + "name": "keyverse-claim-workspace", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": False, + "config": { + "claim.name": "workspace", + "claim.value": "workspace-org-cwl", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "false", + "introspection.token.claim": "true", + }, + }, + ], + } + ) + return payload + + +def test_naruon_claim_mapper_profile_is_accepted( + api, + auth_header: dict[str, str], + operator_token: str, +) -> None: + """A reviewed Naruon mapper profile receives a side-effect-free receipt.""" + app = create_app(wire=False) + app.state.operator_api_token = operator_token + app.state.keycloak_api = api + payload = _naruon_registration_with_mappers() + + with TestClient(app, headers=auth_header) as client: + response = client.post( + "/clients/relying-parties:validate", + json=payload, + ) + + assert response.status_code == 200 + assert response.json() == { + "registration": payload, + "ready_to_apply": True, + } + assert api.calls == [] From a9bdbb18ab9ee48a27c3f2eb97186c0b220a40e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 15:26:46 +0900 Subject: [PATCH 04/30] feat(clients): validate a closed RP audience and claim profile --- .../account_unification/app/relying_party.py | 276 ++++++++++++++++-- 1 file changed, 247 insertions(+), 29 deletions(-) diff --git a/services/account_unification/app/relying_party.py b/services/account_unification/app/relying_party.py index 7b3348a..54bfeb8 100644 --- a/services/account_unification/app/relying_party.py +++ b/services/account_unification/app/relying_party.py @@ -18,6 +18,9 @@ _MAX_CLIENT_ID_LENGTH = 63 _MAX_URI_LENGTH = 2_048 _MAX_URI_COUNT = 16 +_MAX_MAPPER_COUNT = 4 +_MAX_MAPPER_NAME_LENGTH = 64 +_MAX_CLAIM_VALUE_LENGTH = 128 _UNRESOLVED_TEMPLATE_MARKERS = ("{{", "}}") _RAW_CONTROL = re.compile(r"[\x00-\x1F\x7F]") _INVALID_PERCENT_ESCAPE = re.compile(r"%(?![0-9A-Fa-f]{2})") @@ -26,7 +29,7 @@ ) _CLIENT_ID = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") _DNS_LABEL = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$") -_ALLOWED_FIELDS = frozenset( +_REQUIRED_FIELDS = frozenset( { "clientId", "name", @@ -45,6 +48,11 @@ "defaultClientScopes", } ) +_OPTIONAL_FIELDS = frozenset({"protocolMappers"}) +_ALLOWED_FIELDS = _REQUIRED_FIELDS | _OPTIONAL_FIELDS +_MAPPER_FIELDS = frozenset( + {"name", "protocol", "protocolMapper", "consentRequired", "config"} +) _ALLOWED_ATTRIBUTES = frozenset( { "pkce.code.challenge.method", @@ -54,7 +62,43 @@ "require.pushed.authorization.requests", } ) +_AUDIENCE_CONFIG_FIELDS = frozenset( + { + "included.client.audience", + "access.token.claim", + "id.token.claim", + "introspection.token.claim", + } +) +_CLAIM_CONFIG_FIELDS = frozenset( + { + "claim.name", + "claim.value", + "jsonType.label", + "access.token.claim", + "id.token.claim", + "userinfo.token.claim", + "introspection.token.claim", + } +) _REQUIRED_SCOPES = frozenset({"basic", "profile", "email"}) +_CLAIM_ORDER = ("role", "org", "workspace") +_CLAIM_RANK = {claim_name: index + 1 for index, claim_name in enumerate(_CLAIM_ORDER)} +_AUDIENCE_MAPPER_NAME = "keyverse-audience" +_AUDIENCE_MAPPER_TYPE = "oidc-audience-mapper" +_CLAIM_MAPPER_TYPE = "oidc-hardcoded-claim-mapper" + + +class RelyingPartyProtocolMapper(BaseModel): + """Closed Keycloak protocol-mapper representation accepted by preflight.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + name: StrictStr + protocol: StrictStr + protocol_mapper: StrictStr = Field(alias="protocolMapper") + consent_required: StrictBool = Field(alias="consentRequired") + config: dict[StrictStr, StrictStr] class RelyingPartyRegistration(BaseModel): @@ -77,6 +121,10 @@ class RelyingPartyRegistration(BaseModel): attributes: dict[StrictStr, StrictStr] full_scope_allowed: StrictBool = Field(alias="fullScopeAllowed") default_client_scopes: list[StrictStr] = Field(alias="defaultClientScopes") + protocol_mappers: list[RelyingPartyProtocolMapper] = Field( + default_factory=list, + alias="protocolMappers", + ) class RelyingPartyValidationResult(BaseModel): @@ -122,6 +170,53 @@ def _require_string_list(payload: dict[str, Any], field_name: str) -> list[str]: return list(cast(list[str], value)) +def _require_string_mapping(value: Any, field_name: str) -> dict[str, str]: + """Return a JSON object with only string keys and string values.""" + if not isinstance(value, dict): + _shape_error(field_name, "must be a JSON object") + raw_mapping = cast(dict[Any, Any], value) + if any(not isinstance(key, str) for key in raw_mapping): + _shape_error(field_name, "contains a non-string key") + if any(not isinstance(item, str) for item in raw_mapping.values()): + _shape_error(field_name, "must contain only string values") + return cast(dict[str, str], dict(raw_mapping)) + + +def _parse_protocol_mappers(value: Any) -> list[RelyingPartyProtocolMapper]: + """Parse a bounded array of closed protocol-mapper objects.""" + if not isinstance(value, list): + _shape_error("protocolMappers", "must be an array") + if len(value) > _MAX_MAPPER_COUNT: + _shape_error("protocolMappers", "must contain at most 4 entries") + parsed: list[RelyingPartyProtocolMapper] = [] + for item in value: + if not isinstance(item, dict): + _shape_error("protocolMappers", "must contain only JSON objects") + raw_item = cast(dict[Any, Any], item) + if any(not isinstance(key, str) for key in raw_item): + _shape_error("protocolMappers", "contains a non-string field name") + mapper = cast(dict[str, Any], raw_item) + fields = set(mapper) + if fields.difference(_MAPPER_FIELDS): + _shape_error("protocolMappers", "contains unsupported mapper fields") + missing = sorted(_MAPPER_FIELDS.difference(fields)) + if missing: + _shape_error( + f"protocolMappers.{missing[0]}", + "is required", + ) + parsed.append( + RelyingPartyProtocolMapper( + name=_require_string(mapper, "name"), + protocol=_require_string(mapper, "protocol"), + protocolMapper=_require_string(mapper, "protocolMapper"), + consentRequired=_require_boolean(mapper, "consentRequired"), + config=_require_string_mapping(mapper["config"], "protocolMappers.config"), + ) + ) + return parsed + + def _parse_registration(payload: Any) -> RelyingPartyRegistration: """Manually parse untrusted JSON before constructing the response model.""" if not isinstance(payload, dict): @@ -133,37 +228,35 @@ def _parse_registration(payload: Any) -> RelyingPartyRegistration: fields = set(body) if fields.difference(_ALLOWED_FIELDS): _shape_error("body", "contains unsupported fields") - missing = sorted(_ALLOWED_FIELDS.difference(fields)) + missing = sorted(_REQUIRED_FIELDS.difference(fields)) if missing: _shape_error(missing[0], "is required") - attributes_input = body["attributes"] - if not isinstance(attributes_input, dict): - _shape_error("attributes", "must be a JSON object") - raw_attributes = cast(dict[Any, Any], attributes_input) - if any(not isinstance(key, str) for key in raw_attributes): - _shape_error("attributes", "contains a non-string key") - if any(not isinstance(value, str) for value in raw_attributes.values()): - _shape_error("attributes", "must contain only string values") - attributes = cast(dict[str, str], dict(raw_attributes)) - - return RelyingPartyRegistration( - clientId=_require_string(body, "clientId"), - name=_require_string(body, "name"), - enabled=_require_boolean(body, "enabled"), - protocol=_require_string(body, "protocol"), - publicClient=_require_boolean(body, "publicClient"), - clientAuthenticatorType=_require_string(body, "clientAuthenticatorType"), - standardFlowEnabled=_require_boolean(body, "standardFlowEnabled"), - implicitFlowEnabled=_require_boolean(body, "implicitFlowEnabled"), - directAccessGrantsEnabled=_require_boolean(body, "directAccessGrantsEnabled"), - serviceAccountsEnabled=_require_boolean(body, "serviceAccountsEnabled"), - redirectUris=_require_string_list(body, "redirectUris"), - webOrigins=_require_string_list(body, "webOrigins"), - attributes=attributes, - fullScopeAllowed=_require_boolean(body, "fullScopeAllowed"), - defaultClientScopes=_require_string_list(body, "defaultClientScopes"), - ) + registration_fields: dict[str, Any] = { + "clientId": _require_string(body, "clientId"), + "name": _require_string(body, "name"), + "enabled": _require_boolean(body, "enabled"), + "protocol": _require_string(body, "protocol"), + "publicClient": _require_boolean(body, "publicClient"), + "clientAuthenticatorType": _require_string(body, "clientAuthenticatorType"), + "standardFlowEnabled": _require_boolean(body, "standardFlowEnabled"), + "implicitFlowEnabled": _require_boolean(body, "implicitFlowEnabled"), + "directAccessGrantsEnabled": _require_boolean( + body, + "directAccessGrantsEnabled", + ), + "serviceAccountsEnabled": _require_boolean(body, "serviceAccountsEnabled"), + "redirectUris": _require_string_list(body, "redirectUris"), + "webOrigins": _require_string_list(body, "webOrigins"), + "attributes": _require_string_mapping(body["attributes"], "attributes"), + "fullScopeAllowed": _require_boolean(body, "fullScopeAllowed"), + "defaultClientScopes": _require_string_list(body, "defaultClientScopes"), + } + if "protocolMappers" in body: + registration_fields["protocolMappers"] = _parse_protocol_mappers( + body["protocolMappers"] + ) + return RelyingPartyRegistration(**registration_fields) def _require_clean_text(value: str, field_name: str, *, maximum: int) -> None: @@ -288,6 +381,129 @@ def _validate_scopes(scopes: list[str]) -> None: ) +def _require_exact_config( + mapper: RelyingPartyProtocolMapper, + expected_fields: frozenset[str], +) -> None: + """Require one mapper configuration to have an exact closed key set.""" + fields = set(mapper.config) + if fields != expected_fields: + _client_error("protocolMappers.config", "must use the exact closed field set") + for key, value in mapper.config.items(): + _require_clean_text(value, f"protocolMappers.config.{key}", maximum=128) + + +def _validate_audience_mapper( + mapper: RelyingPartyProtocolMapper, + client_id: str, +) -> int: + """Validate the single closed access-token audience mapper.""" + if mapper.name != _AUDIENCE_MAPPER_NAME: + _client_error("protocolMappers.name", "must be keyverse-audience") + _require_exact_config(mapper, _AUDIENCE_CONFIG_FIELDS) + if mapper.config["included.client.audience"] != client_id: + _client_error( + "protocolMappers.config.included.client.audience", + "must exactly match clientId", + ) + expected_flags = { + "access.token.claim": "true", + "id.token.claim": "false", + "introspection.token.claim": "true", + } + if any(mapper.config[key] != value for key, value in expected_flags.items()): + _client_error( + "protocolMappers.config", + "must use the closed audience claim destinations", + ) + return 0 + + +def _validate_claim_value(value: str) -> None: + """Require one bounded visible hardcoded product-claim value.""" + _require_clean_text( + value, + "protocolMappers.config.claim.value", + maximum=_MAX_CLAIM_VALUE_LENGTH, + ) + if "\u2028" in value or "\u2029" in value: + _client_error( + "protocolMappers.config.claim.value", + "must not contain Unicode line separators", + ) + + +def _validate_hardcoded_claim_mapper( + mapper: RelyingPartyProtocolMapper, +) -> tuple[int, str]: + """Validate one allowlisted hardcoded session-routing claim mapper.""" + _require_exact_config(mapper, _CLAIM_CONFIG_FIELDS) + claim_name = mapper.config["claim.name"] + if claim_name not in _CLAIM_RANK: + _client_error( + "protocolMappers.config.claim.name", + "must be role, org, or workspace", + ) + if mapper.name != f"keyverse-claim-{claim_name}": + _client_error( + "protocolMappers.name", + "must be canonical for the claim name", + ) + _validate_claim_value(mapper.config["claim.value"]) + expected_values = { + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "false", + "introspection.token.claim": "true", + } + if any(mapper.config[key] != value for key, value in expected_values.items()): + _client_error( + "protocolMappers.config", + "must use the closed session-claim destinations", + ) + return _CLAIM_RANK[claim_name], claim_name + + +def _validate_protocol_mappers(registration: RelyingPartyRegistration) -> None: + """Validate the optional closed audience and session-claim mapper profile.""" + mappers = registration.protocol_mappers + if not mappers: + return + if len(mappers) > _MAX_MAPPER_COUNT: + _client_error("protocolMappers", "must contain at most 4 entries") + + ranks: list[int] = [] + audience_count = 0 + claim_names: set[str] = set() + for mapper in mappers: + _require_clean_text( + mapper.name, + "protocolMappers.name", + maximum=_MAX_MAPPER_NAME_LENGTH, + ) + if mapper.protocol != "openid-connect": + _client_error("protocolMappers.protocol", "must be openid-connect") + if mapper.consent_required: + _client_error("protocolMappers.consentRequired", "must be false") + if mapper.protocol_mapper == _AUDIENCE_MAPPER_TYPE: + audience_count += 1 + ranks.append(_validate_audience_mapper(mapper, registration.client_id)) + elif mapper.protocol_mapper == _CLAIM_MAPPER_TYPE: + rank, claim_name = _validate_hardcoded_claim_mapper(mapper) + if claim_name in claim_names: + _client_error("protocolMappers", "must not duplicate claim names") + claim_names.add(claim_name) + ranks.append(rank) + else: + _client_error("protocolMappers.protocolMapper", "is not supported") + + if audience_count != 1: + _client_error("protocolMappers", "must contain exactly one audience mapper") + if ranks != sorted(ranks) or len(set(ranks)) != len(ranks): + _client_error("protocolMappers", "must use canonical mapper order") + + def validate_relying_party_registration( registration: RelyingPartyRegistration, ) -> RelyingPartyValidationResult: @@ -334,6 +550,7 @@ def validate_relying_party_registration( ) logout_uri = _validate_attributes(registration.attributes) _validate_scopes(registration.default_client_scopes) + _validate_protocol_mappers(registration) redirect_origin_keys = {_origin_key(uri) for uri in redirect_uris} web_origin_keys = {_origin_key(uri) for uri in web_origins} @@ -354,6 +571,7 @@ def validate_relying_party_registration( "/relying-parties:validate", response_model=RelyingPartyValidationResult, response_model_by_alias=True, + response_model_exclude_unset=True, ) def validate_relying_party( payload: Any = Body(...), From 5fee5898037f914590cc4fb6bc2a7680fd9c855e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 15:35:29 +0900 Subject: [PATCH 05/30] fix(clients): retain the readiness flag in sparse responses --- services/account_unification/app/relying_party.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/account_unification/app/relying_party.py b/services/account_unification/app/relying_party.py index 54bfeb8..09173c3 100644 --- a/services/account_unification/app/relying_party.py +++ b/services/account_unification/app/relying_party.py @@ -577,4 +577,7 @@ def validate_relying_party( payload: Any = Body(...), ) -> RelyingPartyValidationResult: """Return a readiness receipt for one closed OIDC client representation.""" - return validate_relying_party_registration(_parse_registration(payload)) + return RelyingPartyValidationResult( + registration=_parse_registration(payload), + ready_to_apply=True, + ) From 2193eb558b408fbeaf84c1660131066db3a18947 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 15:39:50 +0900 Subject: [PATCH 06/30] test(clients): cover every closed RP mapper boundary --- .../tests/test_relying_party_claim_mappers.py | 387 +++++++++++++++--- 1 file changed, 330 insertions(+), 57 deletions(-) diff --git a/services/account_unification/tests/test_relying_party_claim_mappers.py b/services/account_unification/tests/test_relying_party_claim_mappers.py index b0c9bcb..168aa29 100644 --- a/services/account_unification/tests/test_relying_party_claim_mappers.py +++ b/services/account_unification/tests/test_relying_party_claim_mappers.py @@ -3,13 +3,55 @@ from copy import deepcopy +import pytest +from fastapi import HTTPException from fastapi.testclient import TestClient from app.main import create_app +from app.relying_party import ( + RelyingPartyRegistration, + _parse_registration, + validate_relying_party_registration, +) from .test_relying_party_preflight import _confidential_web_client +def _audience_mapper() -> dict[str, object]: + """Return the canonical access-token audience mapper.""" + return { + "name": "keyverse-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": False, + "config": { + "included.client.audience": "naruon-web", + "access.token.claim": "true", + "id.token.claim": "false", + "introspection.token.claim": "true", + }, + } + + +def _claim_mapper(claim_name: str, claim_value: str) -> dict[str, object]: + """Return one canonical hardcoded product session-claim mapper.""" + return { + "name": f"keyverse-claim-{claim_name}", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": False, + "config": { + "claim.name": claim_name, + "claim.value": claim_value, + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "false", + "introspection.token.claim": "true", + }, + } + + def _naruon_registration_with_mappers() -> dict[str, object]: """Return a production-shaped Naruon client with its closed claim profile.""" payload = deepcopy(_confidential_web_client()) @@ -18,69 +60,48 @@ def _naruon_registration_with_mappers() -> dict[str, object]: "publicClient": True, "clientAuthenticatorType": "none", "protocolMappers": [ - { - "name": "keyverse-audience", - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-mapper", - "consentRequired": False, - "config": { - "included.client.audience": "naruon-web", - "access.token.claim": "true", - "id.token.claim": "false", - "introspection.token.claim": "true", - }, - }, - { - "name": "keyverse-claim-role", - "protocol": "openid-connect", - "protocolMapper": "oidc-hardcoded-claim-mapper", - "consentRequired": False, - "config": { - "claim.name": "role", - "claim.value": "member", - "jsonType.label": "String", - "access.token.claim": "true", - "id.token.claim": "true", - "userinfo.token.claim": "false", - "introspection.token.claim": "true", - }, - }, - { - "name": "keyverse-claim-org", - "protocol": "openid-connect", - "protocolMapper": "oidc-hardcoded-claim-mapper", - "consentRequired": False, - "config": { - "claim.name": "org", - "claim.value": "org-cwl", - "jsonType.label": "String", - "access.token.claim": "true", - "id.token.claim": "true", - "userinfo.token.claim": "false", - "introspection.token.claim": "true", - }, - }, - { - "name": "keyverse-claim-workspace", - "protocol": "openid-connect", - "protocolMapper": "oidc-hardcoded-claim-mapper", - "consentRequired": False, - "config": { - "claim.name": "workspace", - "claim.value": "workspace-org-cwl", - "jsonType.label": "String", - "access.token.claim": "true", - "id.token.claim": "true", - "userinfo.token.claim": "false", - "introspection.token.claim": "true", - }, - }, + _audience_mapper(), + _claim_mapper("role", "member"), + _claim_mapper("org", "org-cwl"), + _claim_mapper("workspace", "workspace-org-cwl"), ], } ) return payload +def _payload_with_mappers(*mappers: dict[str, object]) -> dict[str, object]: + """Return a public RP payload carrying the provided mapper list.""" + payload = deepcopy(_confidential_web_client()) + payload.update( + { + "publicClient": True, + "clientAuthenticatorType": "none", + "protocolMappers": list(mappers), + } + ) + return payload + + +def _assert_shape_error(payload: object, expected_detail: str) -> None: + """Assert a nested mapper shape error without reflecting submitted data.""" + with pytest.raises(HTTPException) as raised: + _parse_registration(payload) + assert raised.value.status_code == 422 + assert raised.value.detail == expected_detail + assert "private-attacker-value" not in str(raised.value.detail) + + +def _assert_policy_error(payload: dict[str, object], expected_field: str) -> None: + """Assert one parsed mapper profile fails a bounded policy field.""" + registration = _parse_registration(payload) + with pytest.raises(HTTPException) as raised: + validate_relying_party_registration(registration) + assert raised.value.status_code == 400 + assert str(raised.value.detail).startswith(expected_field) + assert "private-attacker-value" not in str(raised.value.detail) + + def test_naruon_claim_mapper_profile_is_accepted( api, auth_header: dict[str, str], @@ -104,3 +125,255 @@ def test_naruon_claim_mapper_profile_is_accepted( "ready_to_apply": True, } assert api.calls == [] + + +def test_audience_only_mapper_profile_is_accepted() -> None: + """The closed profile permits an audience without optional session claims.""" + registration = _parse_registration(_payload_with_mappers(_audience_mapper())) + + result = validate_relying_party_registration(registration) + + assert result.ready_to_apply is True + assert len(result.registration.protocol_mappers) == 1 + + +@pytest.mark.parametrize( + ("mapper_value", "detail"), + [ + ({}, "protocolMappers must be an array"), + (["private-attacker-value"], "protocolMappers must contain only JSON objects"), + ([{1: "private-attacker-value"}], "protocolMappers contains a non-string field name"), + ( + [{**_audience_mapper(), "secret": "private-attacker-value"}], + "protocolMappers contains unsupported mapper fields", + ), + ( + [ + { + key: value + for key, value in _audience_mapper().items() + if key != "config" + } + ], + "protocolMappers.config is required", + ), + ( + [{**_audience_mapper(), "name": 7}], + "name must be a string", + ), + ( + [{**_audience_mapper(), "protocol": 7}], + "protocol must be a string", + ), + ( + [{**_audience_mapper(), "protocolMapper": 7}], + "protocolMapper must be a string", + ), + ( + [{**_audience_mapper(), "consentRequired": "false"}], + "consentRequired must be a boolean", + ), + ( + [{**_audience_mapper(), "config": []}], + "protocolMappers.config must be a JSON object", + ), + ( + [{**_audience_mapper(), "config": {1: "private-attacker-value"}}], + "protocolMappers.config contains a non-string key", + ), + ( + [{**_audience_mapper(), "config": {"secret": 7}}], + "protocolMappers.config must contain only string values", + ), + ( + [_audience_mapper()] * 5, + "protocolMappers must contain at most 4 entries", + ), + ], +) +def test_mapper_shape_is_non_reflective(mapper_value: object, detail: str) -> None: + """Hostile nested mapper shapes fail with stable field-only diagnostics.""" + payload = _confidential_web_client() + payload["protocolMappers"] = mapper_value + _assert_shape_error(payload, detail) + + +@pytest.mark.parametrize( + ("mutate", "field"), + [ + (lambda mapper: mapper.update(name=""), "protocolMappers.name"), + (lambda mapper: mapper.update(protocol="saml"), "protocolMappers.protocol"), + ( + lambda mapper: mapper.update(consentRequired=True), + "protocolMappers.consentRequired", + ), + ( + lambda mapper: mapper.update(protocolMapper="oidc-script-based-protocol-mapper"), + "protocolMappers.protocolMapper", + ), + ], +) +def test_shared_mapper_policy_rejects_unsafe_values(mutate, field: str) -> None: + """Every mapper shares the same closed protocol and consent boundary.""" + mapper = _audience_mapper() + mutate(mapper) + _assert_policy_error(_payload_with_mappers(mapper), field) + + +@pytest.mark.parametrize( + ("mutate", "field"), + [ + (lambda mapper: mapper.update(name="audience"), "protocolMappers.name"), + ( + lambda mapper: mapper["config"].pop("id.token.claim"), + "protocolMappers.config", + ), + ( + lambda mapper: mapper["config"].update(extra="private-attacker-value"), + "protocolMappers.config", + ), + ( + lambda mapper: mapper["config"].update( + {"included.client.audience": "other-web"} + ), + "protocolMappers.config.included.client.audience", + ), + ( + lambda mapper: mapper["config"].update({"access.token.claim": "false"}), + "protocolMappers.config", + ), + ( + lambda mapper: mapper["config"].update( + {"included.client.audience": ""} + ), + "protocolMappers.config.included.client.audience", + ), + ], +) +def test_audience_mapper_policy_rejects_unsafe_values(mutate, field: str) -> None: + """Audience mapping is pinned to the client and exact token destinations.""" + mapper = _audience_mapper() + mutate(mapper) + _assert_policy_error(_payload_with_mappers(mapper), field) + + +@pytest.mark.parametrize( + ("mutate", "field"), + [ + ( + lambda mapper: mapper["config"].pop("userinfo.token.claim"), + "protocolMappers.config", + ), + ( + lambda mapper: mapper["config"].update(extra="private-attacker-value"), + "protocolMappers.config", + ), + ( + lambda mapper: mapper["config"].update({"claim.name": "department"}), + "protocolMappers.config.claim.name", + ), + ( + lambda mapper: mapper.update(name="arbitrary-role"), + "protocolMappers.name", + ), + ( + lambda mapper: mapper["config"].update({"claim.value": ""}), + "protocolMappers.config.claim.value", + ), + ( + lambda mapper: mapper["config"].update( + {"claim.value": " private-attacker-value"} + ), + "protocolMappers.config.claim.value", + ), + ( + lambda mapper: mapper["config"].update( + {"claim.value": "{{private-attacker-value}}"} + ), + "protocolMappers.config.claim.value", + ), + ( + lambda mapper: mapper["config"].update({"claim.value": "a\x00b"}), + "protocolMappers.config.claim.value", + ), + ( + lambda mapper: mapper["config"].update({"claim.value": "a\u2028b"}), + "protocolMappers.config.claim.value", + ), + ( + lambda mapper: mapper["config"].update({"claim.value": "x" * 129}), + "protocolMappers.config.claim.value", + ), + ( + lambda mapper: mapper["config"].update({"jsonType.label": "JSON"}), + "protocolMappers.config", + ), + ( + lambda mapper: mapper["config"].update({"id.token.claim": "false"}), + "protocolMappers.config", + ), + ], +) +def test_hardcoded_claim_policy_rejects_unsafe_values(mutate, field: str) -> None: + """Session claims are allowlisted, bounded, visible, and destination-pinned.""" + mapper = _claim_mapper("role", "member") + mutate(mapper) + _assert_policy_error(_payload_with_mappers(_audience_mapper(), mapper), field) + + +def test_mapper_profile_requires_one_audience() -> None: + """Hardcoded claims cannot exist without the pinned resource audience.""" + _assert_policy_error( + _payload_with_mappers(_claim_mapper("role", "member")), + "protocolMappers", + ) + + +def test_mapper_profile_rejects_duplicate_audience() -> None: + """Two audience mappers are ambiguous even when otherwise identical.""" + _assert_policy_error( + _payload_with_mappers(_audience_mapper(), _audience_mapper()), + "protocolMappers", + ) + + +def test_mapper_profile_rejects_duplicate_claim_name() -> None: + """A claim name may be produced by at most one hardcoded mapper.""" + _assert_policy_error( + _payload_with_mappers( + _audience_mapper(), + _claim_mapper("role", "member"), + _claim_mapper("role", "admin"), + ), + "protocolMappers", + ) + + +def test_mapper_profile_rejects_noncanonical_order() -> None: + """Reviewed artifacts use audience, role, org, workspace order.""" + _assert_policy_error( + _payload_with_mappers( + _audience_mapper(), + _claim_mapper("org", "org-cwl"), + _claim_mapper("role", "member"), + ), + "protocolMappers", + ) + + +def test_direct_model_still_rejects_more_than_four_mappers() -> None: + """Stored or internal models cannot bypass the HTTP parser's list bound.""" + payload = _payload_with_mappers( + _audience_mapper(), + _claim_mapper("role", "member"), + _claim_mapper("org", "org-cwl"), + _claim_mapper("workspace", "workspace-org-cwl"), + ) + payload["protocolMappers"] = [*_naruon_registration_with_mappers()["protocolMappers"], _claim_mapper("role", "other")] + registration = RelyingPartyRegistration.model_validate(payload) + + with pytest.raises(HTTPException) as raised: + validate_relying_party_registration(registration) + + assert raised.value.status_code == 400 + assert str(raised.value.detail).startswith("protocolMappers") From 01fbb3c85f1bf4c9173d7eace6490ff659b00365 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 19:10:59 +0900 Subject: [PATCH 07/30] test(clients): expose HTTP mapper policy bypass --- .../test_relying_party_endpoint_policy.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 services/account_unification/tests/test_relying_party_endpoint_policy.py diff --git a/services/account_unification/tests/test_relying_party_endpoint_policy.py b/services/account_unification/tests/test_relying_party_endpoint_policy.py new file mode 100644 index 0000000..6170c56 --- /dev/null +++ b/services/account_unification/tests/test_relying_party_endpoint_policy.py @@ -0,0 +1,46 @@ +"""HTTP boundary regressions for relying-party policy enforcement.""" +from __future__ import annotations + +from copy import deepcopy + +from fastapi.testclient import TestClient + +from app.main import create_app + +from .test_relying_party_preflight import _confidential_web_client + + +def test_http_preflight_rejects_policy_invalid_protocol_mapper( + api, + auth_header: dict[str, str], + operator_token: str, +) -> None: + """The HTTP endpoint enforces mapper policy instead of shape parsing alone.""" + app = create_app(wire=False) + app.state.operator_api_token = operator_token + app.state.keycloak_api = api + payload = deepcopy(_confidential_web_client()) + payload["protocolMappers"] = [ + { + "name": "private-attacker-value", + "protocol": "openid-connect", + "protocolMapper": "oidc-script-based-protocol-mapper", + "consentRequired": False, + "config": { + "script": "private-attacker-value", + }, + } + ] + + with TestClient(app, headers=auth_header) as client: + response = client.post( + "/clients/relying-parties:validate", + json=payload, + ) + + assert response.status_code == 400 + assert response.json() == { + "detail": "protocolMappers.protocolMapper is not supported", + } + assert "private-attacker-value" not in response.text + assert api.calls == [] From 32c9e988a0f35345df59be2f16cf79743404b730 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 19:16:24 +0900 Subject: [PATCH 08/30] ci: verify RP endpoint policy repair --- .../one-shot-fix-rp-endpoint-policy.yml | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 .github/workflows/one-shot-fix-rp-endpoint-policy.yml diff --git a/.github/workflows/one-shot-fix-rp-endpoint-policy.yml b/.github/workflows/one-shot-fix-rp-endpoint-policy.yml new file mode 100644 index 0000000..e85755a --- /dev/null +++ b/.github/workflows/one-shot-fix-rp-endpoint-policy.yml @@ -0,0 +1,96 @@ +name: One-shot fix RP endpoint policy + +on: + push: + branches: + - feat/oidc-rp-claim-profile + paths: + - .github/workflows/one-shot-fix-rp-endpoint-policy.yml + +permissions: + contents: write + +concurrency: + group: one-shot-fix-rp-endpoint-policy + cancel-in-progress: false + +jobs: + fix-and-verify: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout exact branch head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: feat/oidc-rp-claim-profile + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + + - name: Restore the policy validator at the HTTP boundary + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + path = Path("services/account_unification/app/relying_party.py") + source = path.read_text(encoding="utf-8") + + old_result = " return RelyingPartyValidationResult(registration=registration)\n" + new_result = """ return RelyingPartyValidationResult( + registration=registration, + ready_to_apply=True, + ) + """ + if source.count(old_result) != 1: + raise SystemExit("unexpected validation-result return shape") + source = source.replace(old_result, new_result, 1) + + old_endpoint = """ return RelyingPartyValidationResult( + registration=_parse_registration(payload), + ready_to_apply=True, + ) + """ + new_endpoint = ( + " return validate_relying_party_registration(" + "_parse_registration(payload))\n" + ) + if source.count(old_endpoint) != 1: + raise SystemExit("unexpected HTTP endpoint return shape") + source = source.replace(old_endpoint, new_endpoint, 1) + path.write_text(source, encoding="utf-8") + PY + + - name: Verify focused regression and full production contract + working-directory: services/account_unification + shell: bash + run: | + set -euo pipefail + uv sync --locked --extra dev + uv run pytest -q tests/test_relying_party_endpoint_policy.py + uv run ruff check app tests tools + uv run interrogate . + uv run coverage erase + uv run coverage run --branch --source=app -m pytest -q + uv run coverage report --show-missing --fail-under=100 + uv run python -m compileall -q app tests tools + uv build --out-dir dist + + - name: Commit verified fix and remove one-shot workflow + shell: bash + run: | + set -euo pipefail + rm .github/workflows/one-shot-fix-rp-endpoint-policy.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add services/account_unification/app/relying_party.py \ + .github/workflows/one-shot-fix-rp-endpoint-policy.yml + git commit -m "fix(clients): enforce mapper policy at HTTP preflight" + git push origin HEAD:feat/oidc-rp-claim-profile From c85ef29560bcf9a45afec03f8800680bd9c5bed3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 19:23:22 +0900 Subject: [PATCH 09/30] ci: repair RP endpoint policy patcher --- .../one-shot-fix-rp-endpoint-policy.yml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/one-shot-fix-rp-endpoint-policy.yml b/.github/workflows/one-shot-fix-rp-endpoint-policy.yml index e85755a..00e8e45 100644 --- a/.github/workflows/one-shot-fix-rp-endpoint-policy.yml +++ b/.github/workflows/one-shot-fix-rp-endpoint-policy.yml @@ -44,20 +44,22 @@ jobs: source = path.read_text(encoding="utf-8") old_result = " return RelyingPartyValidationResult(registration=registration)\n" - new_result = """ return RelyingPartyValidationResult( - registration=registration, - ready_to_apply=True, + new_result = ( + " return RelyingPartyValidationResult(\n" + " registration=registration,\n" + " ready_to_apply=True,\n" + " )\n" ) - """ if source.count(old_result) != 1: raise SystemExit("unexpected validation-result return shape") source = source.replace(old_result, new_result, 1) - old_endpoint = """ return RelyingPartyValidationResult( - registration=_parse_registration(payload), - ready_to_apply=True, + old_endpoint = ( + " return RelyingPartyValidationResult(\n" + " registration=_parse_registration(payload),\n" + " ready_to_apply=True,\n" + " )\n" ) - """ new_endpoint = ( " return validate_relying_party_registration(" "_parse_registration(payload))\n" From 6298f120a9da03f4cb55fcc64811c19986a8fb66 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:24:22 +0000 Subject: [PATCH 10/30] fix(clients): enforce mapper policy at HTTP preflight --- .../one-shot-fix-rp-endpoint-policy.yml | 98 ------------------- .../account_unification/app/relying_party.py | 10 +- 2 files changed, 5 insertions(+), 103 deletions(-) delete mode 100644 .github/workflows/one-shot-fix-rp-endpoint-policy.yml diff --git a/.github/workflows/one-shot-fix-rp-endpoint-policy.yml b/.github/workflows/one-shot-fix-rp-endpoint-policy.yml deleted file mode 100644 index 00e8e45..0000000 --- a/.github/workflows/one-shot-fix-rp-endpoint-policy.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: One-shot fix RP endpoint policy - -on: - push: - branches: - - feat/oidc-rp-claim-profile - paths: - - .github/workflows/one-shot-fix-rp-endpoint-policy.yml - -permissions: - contents: write - -concurrency: - group: one-shot-fix-rp-endpoint-policy - cancel-in-progress: false - -jobs: - fix-and-verify: - runs-on: ubuntu-24.04 - timeout-minutes: 20 - steps: - - name: Checkout exact branch head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: feat/oidc-rp-claim-profile - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 - with: - python-version: "3.12" - - - name: Set up uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 - - - name: Restore the policy validator at the HTTP boundary - shell: bash - run: | - set -euo pipefail - python3 - <<'PY' - from pathlib import Path - - path = Path("services/account_unification/app/relying_party.py") - source = path.read_text(encoding="utf-8") - - old_result = " return RelyingPartyValidationResult(registration=registration)\n" - new_result = ( - " return RelyingPartyValidationResult(\n" - " registration=registration,\n" - " ready_to_apply=True,\n" - " )\n" - ) - if source.count(old_result) != 1: - raise SystemExit("unexpected validation-result return shape") - source = source.replace(old_result, new_result, 1) - - old_endpoint = ( - " return RelyingPartyValidationResult(\n" - " registration=_parse_registration(payload),\n" - " ready_to_apply=True,\n" - " )\n" - ) - new_endpoint = ( - " return validate_relying_party_registration(" - "_parse_registration(payload))\n" - ) - if source.count(old_endpoint) != 1: - raise SystemExit("unexpected HTTP endpoint return shape") - source = source.replace(old_endpoint, new_endpoint, 1) - path.write_text(source, encoding="utf-8") - PY - - - name: Verify focused regression and full production contract - working-directory: services/account_unification - shell: bash - run: | - set -euo pipefail - uv sync --locked --extra dev - uv run pytest -q tests/test_relying_party_endpoint_policy.py - uv run ruff check app tests tools - uv run interrogate . - uv run coverage erase - uv run coverage run --branch --source=app -m pytest -q - uv run coverage report --show-missing --fail-under=100 - uv run python -m compileall -q app tests tools - uv build --out-dir dist - - - name: Commit verified fix and remove one-shot workflow - shell: bash - run: | - set -euo pipefail - rm .github/workflows/one-shot-fix-rp-endpoint-policy.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add services/account_unification/app/relying_party.py \ - .github/workflows/one-shot-fix-rp-endpoint-policy.yml - git commit -m "fix(clients): enforce mapper policy at HTTP preflight" - git push origin HEAD:feat/oidc-rp-claim-profile diff --git a/services/account_unification/app/relying_party.py b/services/account_unification/app/relying_party.py index 09173c3..1f83ef3 100644 --- a/services/account_unification/app/relying_party.py +++ b/services/account_unification/app/relying_party.py @@ -561,7 +561,10 @@ def validate_relying_party_registration( "post.logout.redirect.uris", "must use a registered web origin", ) - return RelyingPartyValidationResult(registration=registration) + return RelyingPartyValidationResult( + registration=registration, + ready_to_apply=True, + ) relying_party_router = APIRouter(prefix="/clients", tags=["relying-parties"]) @@ -577,7 +580,4 @@ def validate_relying_party( payload: Any = Body(...), ) -> RelyingPartyValidationResult: """Return a readiness receipt for one closed OIDC client representation.""" - return RelyingPartyValidationResult( - registration=_parse_registration(payload), - ready_to_apply=True, - ) + return validate_relying_party_registration(_parse_registration(payload)) From accb5458284d24ec2cb1bc9b150aba50c9f7b594 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 19:30:07 +0900 Subject: [PATCH 11/30] test(clients): expose mapper observation normalization gaps --- ...est_relying_party_mapper_reconciliation.py | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 services/account_unification/tests/test_relying_party_mapper_reconciliation.py diff --git a/services/account_unification/tests/test_relying_party_mapper_reconciliation.py b/services/account_unification/tests/test_relying_party_mapper_reconciliation.py new file mode 100644 index 0000000..77bc48f --- /dev/null +++ b/services/account_unification/tests/test_relying_party_mapper_reconciliation.py @@ -0,0 +1,191 @@ +"""OIDC relying-party mapper observation and reconciliation regressions.""" +from __future__ import annotations + +from copy import deepcopy +from collections.abc import Callable + +import pytest + +from app.kv_store import InMemoryKvStore +from app.relying_party_state import ( + RELYING_PARTY_RECEIPT_NAMESPACE, + RelyingPartyConvergenceState, + RelyingPartyService, + parse_relying_party_registration, +) + +from .test_relying_party_claim_mappers import _naruon_registration_with_mappers + + +def _registration(role_value: str = "member"): + """Return one valid Naruon registration with a selectable role value.""" + payload = _naruon_registration_with_mappers() + mappers = payload["protocolMappers"] + assert isinstance(mappers, list) + role_mapper = mappers[1] + assert isinstance(role_mapper, dict) + config = role_mapper["config"] + assert isinstance(config, dict) + config["claim.value"] = role_value + return parse_relying_party_registration(payload) + + +def _live_client(api, client_uuid: str) -> dict: + """Return the mutable live client held by the deterministic test double.""" + client = api.relying_party_clients[client_uuid] + assert isinstance(client, dict) + return client + + +def _live_mappers(api, client_uuid: str) -> list[dict]: + """Return the mutable mapper list for one deterministic live client.""" + mappers = _live_client(api, client_uuid)["protocolMappers"] + assert isinstance(mappers, list) + assert all(isinstance(mapper, dict) for mapper in mappers) + return mappers + + +def test_generated_mapper_ids_and_vendor_order_do_not_create_false_drift(api) -> None: + """Opaque mapper IDs and returned order are normalized before comparison.""" + service = RelyingPartyService(InMemoryKvStore(), api) + created = service.put_registration("naruon-web", _registration()) + mappers = _live_mappers(api, created.client_uuid) + for index, mapper in enumerate(mappers): + mapper["id"] = f"mapper-{index}" + mappers.reverse() + + status = service.get_registration("naruon-web") + + assert status.convergence_state is RelyingPartyConvergenceState.IN_SYNC + assert status.last_apply_receipt_matches is True + + +def _remove_mapper_field(client: dict) -> None: + """Remove the whole optional mapper collection from the live client.""" + client.pop("protocolMappers") + + +def _replace_with_non_list(client: dict) -> None: + """Replace the mapper collection with an invalid scalar.""" + client["protocolMappers"] = "not-a-list" + + +def _replace_with_non_object(client: dict) -> None: + """Replace one mapper with a non-object value.""" + client["protocolMappers"] = ["not-an-object"] + + +def _replace_with_too_many(client: dict) -> None: + """Exceed the closed four-mapper profile.""" + mapper = deepcopy(_live_mappers_from_client(client)[0]) + client["protocolMappers"] = [deepcopy(mapper) for _ in range(5)] + + +def _set_non_string_mapper_id(client: dict) -> None: + """Attach a malformed vendor-generated mapper identifier.""" + _live_mappers_from_client(client)[0]["id"] = 7 + + +def _add_unknown_mapper_field(client: dict) -> None: + """Attach an unowned mapper-level field that cannot be normalized away.""" + _live_mappers_from_client(client)[0]["private"] = "not-owned" + + +def _set_non_mapping_config(client: dict) -> None: + """Replace mapper configuration with an invalid scalar.""" + _live_mappers_from_client(client)[0]["config"] = [] + + +def _set_unsupported_mapper_type(client: dict) -> None: + """Replace a reviewed mapper plugin with an unsupported plugin.""" + _live_mappers_from_client(client)[0]["protocolMapper"] = ( + "oidc-script-based-protocol-mapper" + ) + + +def _duplicate_mapper_identity(client: dict) -> None: + """Make two live mappers claim the same canonical identity.""" + mappers = _live_mappers_from_client(client) + mappers[2] = deepcopy(mappers[1]) + + +def _live_mappers_from_client(client: dict) -> list[dict]: + """Return a type-checked mapper list from a mutable client object.""" + mappers = client["protocolMappers"] + assert isinstance(mappers, list) + assert all(isinstance(mapper, dict) for mapper in mappers) + return mappers + + +@pytest.mark.parametrize( + "mutate", + [ + _remove_mapper_field, + _replace_with_non_list, + _replace_with_non_object, + _replace_with_too_many, + _set_non_string_mapper_id, + _add_unknown_mapper_field, + _set_non_mapping_config, + _set_unsupported_mapper_type, + _duplicate_mapper_identity, + ], +) +def test_malformed_or_unowned_live_mapper_state_is_drift( + api, + mutate: Callable[[dict], None], +) -> None: + """Malformed, duplicate, or unsupported live mappers fail closed as drift.""" + service = RelyingPartyService(InMemoryKvStore(), api) + created = service.put_registration("naruon-web", _registration()) + mutate(_live_client(api, created.client_uuid)) + + status = service.get_registration("naruon-web") + + assert status.convergence_state is RelyingPartyConvergenceState.DRIFTED + + +def test_changed_claim_value_is_repaired_from_desired_state(api) -> None: + """A changed product-routing claim is restored by reconciliation.""" + service = RelyingPartyService(InMemoryKvStore(), api) + created = service.put_registration("naruon-web", _registration()) + role_config = _live_mappers(api, created.client_uuid)[1]["config"] + assert isinstance(role_config, dict) + role_config["claim.value"] = "administrator" + + before = service.get_registration("naruon-web") + repaired = service.reconcile_all()[0] + + assert before.convergence_state is RelyingPartyConvergenceState.DRIFTED + assert repaired.convergence_state is RelyingPartyConvergenceState.IN_SYNC + repaired_config = _live_mappers(api, created.client_uuid)[1]["config"] + assert isinstance(repaired_config, dict) + assert repaired_config["claim.value"] == "member" + + +def test_post_update_mapper_mismatch_withholds_new_receipt( + api, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A mismatched post-update observation returns failure without a receipt.""" + store = InMemoryKvStore() + service = RelyingPartyService(store, api) + created = service.put_registration("naruon-web", _registration()) + original_update = api.update_relying_party_client + + def corrupt_update(client_uuid: str, payload: dict) -> None: + """Apply the update and then corrupt the observed role claim.""" + original_update(client_uuid, payload) + config = _live_mappers(api, client_uuid)[1]["config"] + assert isinstance(config, dict) + config["claim.value"] = "unexpected" + + monkeypatch.setattr(api, "update_relying_party_client", corrupt_update) + + status = service.put_registration("naruon-web", _registration("editor")) + + assert status.convergence_state is RelyingPartyConvergenceState.APPLY_FAILED + assert status.last_convergence_error_code == "client_state_mismatch_after_apply" + assert status.last_apply_receipt_matches is False + assert store.get(RELYING_PARTY_RECEIPT_NAMESPACE, "naruon-web") is not None + assert status.client_uuid == created.client_uuid From 40ecfd74bdeff4a748a9a8389097ac0c53b4e839 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 19:34:23 +0900 Subject: [PATCH 12/30] ci: run RP mapper normalization red-green cycle --- .../one-shot-normalize-rp-mappers.yml | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 .github/workflows/one-shot-normalize-rp-mappers.yml diff --git a/.github/workflows/one-shot-normalize-rp-mappers.yml b/.github/workflows/one-shot-normalize-rp-mappers.yml new file mode 100644 index 0000000..b846141 --- /dev/null +++ b/.github/workflows/one-shot-normalize-rp-mappers.yml @@ -0,0 +1,200 @@ +name: One-shot normalize RP mapper observations + +on: + push: + branches: + - feat/oidc-rp-claim-profile + paths: + - .github/workflows/one-shot-normalize-rp-mappers.yml + +permissions: + contents: write + +concurrency: + group: one-shot-normalize-rp-mappers + cancel-in-progress: false + +jobs: + red-green-verify: + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - name: Checkout exact branch head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: feat/oidc-rp-claim-profile + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + + - name: Install locked dependencies + working-directory: services/account_unification + run: uv sync --locked --extra dev + + - name: Verify the mapper-order regression is RED + working-directory: services/account_unification + shell: bash + run: | + set +e + uv run pytest -q \ + tests/test_relying_party_mapper_reconciliation.py::test_generated_mapper_ids_and_vendor_order_do_not_create_false_drift + status=$? + set -e + if [ "$status" -eq 0 ]; then + echo "Expected mapper normalization regression to fail before implementation" >&2 + exit 1 + fi + + - name: Implement mapper observation normalization + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + path = Path("services/account_unification/app/relying_party_state.py") + source = path.read_text(encoding="utf-8") + marker = "\ndef _observable_client_matches(\n" + if source.count(marker) != 1: + raise SystemExit("unexpected observable comparison marker") + + helpers = r''' +_OBSERVED_MAPPER_FIELDS: Final = frozenset( + {"name", "protocol", "protocolMapper", "consentRequired", "config"} +) +_OBSERVED_CLAIM_RANKS: Final = {"role": 1, "org": 2, "workspace": 3} + + +def _observed_mapper_rank(mapper: dict) -> int | None: + """Return the canonical rank for one known live mapper identity.""" + mapper_type = mapper.get("protocolMapper") + if mapper_type == "oidc-audience-mapper": + return 0 + if mapper_type != "oidc-hardcoded-claim-mapper": + return None + config = mapper.get("config") + if not isinstance(config, dict): + return None + claim_name = config.get("claim.name") + return _OBSERVED_CLAIM_RANKS.get(claim_name) + + +def _normalized_observed_mappers( + value: object, + registration: RelyingPartyRegistration, +) -> list[dict] | None: + """Normalize safe Keycloak mapper output or return ``None`` on drift.""" + if value is None: + return [] + if not isinstance(value, list) or len(value) > 4: + return None + + ranked_mappers: list[tuple[int, dict]] = [] + seen_ranks: set[int] = set() + for raw_mapper in value: + if not isinstance(raw_mapper, dict): + return None + if any(not isinstance(key, str) for key in raw_mapper): + return None + mapper = dict(raw_mapper) + mapper_id = mapper.pop("id", None) + if mapper_id is not None and ( + not isinstance(mapper_id, str) or not mapper_id + ): + return None + if set(mapper) != _OBSERVED_MAPPER_FIELDS: + return None + config = mapper.get("config") + if not isinstance(config, dict): + return None + if any(not isinstance(key, str) for key in config): + return None + if any(not isinstance(item, str) for item in config.values()): + return None + mapper["config"] = dict(config) + rank = _observed_mapper_rank(mapper) + if rank is None or rank in seen_ranks: + return None + seen_ranks.add(rank) + ranked_mappers.append((rank, mapper)) + + ranked_mappers.sort(key=lambda item: item[0]) + ordered = [mapper for _, mapper in ranked_mappers] + candidate_data = registration.model_dump(by_alias=True) + candidate_data["protocolMappers"] = ordered + try: + candidate = RelyingPartyRegistration.model_validate(candidate_data) + validate_relying_party_registration(candidate) + except Exception: + return None + return [ + mapper.model_dump(by_alias=True) + for mapper in candidate.protocol_mappers + ] +''' + source = source.replace(marker, helpers + marker, 1) + + old = '''def _observable_client_matches( + registration: RelyingPartyRegistration, + client: dict, + ) -> bool: + """Compare every field in the closed secret-free client profile.""" + desired = registration.model_dump(by_alias=True) + return all(client.get(key) == value for key, value in desired.items()) + ''' + new = '''def _observable_client_matches( + registration: RelyingPartyRegistration, + client: dict, + ) -> bool: + """Compare closed client state after normalizing vendor mapper output.""" + desired = registration.model_dump(by_alias=True) + observed_mappers = _normalized_observed_mappers( + client.get("protocolMappers"), + registration, + ) + if observed_mappers is None: + return False + if observed_mappers != desired["protocolMappers"]: + return False + return all( + key == "protocolMappers" or client.get(key) == value + for key, value in desired.items() + ) + ''' + if source.count(old) != 1: + raise SystemExit("unexpected observable comparison implementation") + source = source.replace(old, new, 1) + path.write_text(source, encoding="utf-8") + PY + + - name: Verify focused and complete production contracts + working-directory: services/account_unification + shell: bash + run: | + set -euo pipefail + uv run pytest -q tests/test_relying_party_mapper_reconciliation.py + uv run ruff check app tests tools + uv run interrogate . + uv run coverage erase + uv run coverage run --branch --source=app -m pytest -q + uv run coverage report --show-missing --fail-under=100 + uv run python -m compileall -q app tests tools + uv build --out-dir dist + + - name: Commit verified implementation and remove one-shot workflow + shell: bash + run: | + set -euo pipefail + rm .github/workflows/one-shot-normalize-rp-mappers.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add services/account_unification/app/relying_party_state.py \ + .github/workflows/one-shot-normalize-rp-mappers.yml + git commit -m "fix(clients): normalize observed RP mapper state" + git push origin HEAD:feat/oidc-rp-claim-profile From 797c467b322c92aea6f8301a0ee4a15bd3115b1a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 19:37:54 +0900 Subject: [PATCH 13/30] ci: stage RP mapper normalization patcher --- scripts/ci/one_shot_normalize_rp_mappers.py | 132 ++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 scripts/ci/one_shot_normalize_rp_mappers.py diff --git a/scripts/ci/one_shot_normalize_rp_mappers.py b/scripts/ci/one_shot_normalize_rp_mappers.py new file mode 100644 index 0000000..227f261 --- /dev/null +++ b/scripts/ci/one_shot_normalize_rp_mappers.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Apply the reviewed RP mapper-observation normalization patch once.""" +from __future__ import annotations + +from pathlib import Path + + +STATE_PATH = Path("services/account_unification/app/relying_party_state.py") + + +HELPERS = r''' +_OBSERVED_MAPPER_FIELDS: Final = frozenset( + {"name", "protocol", "protocolMapper", "consentRequired", "config"} +) +_OBSERVED_CLAIM_RANKS: Final = {"role": 1, "org": 2, "workspace": 3} + + +def _observed_mapper_rank(mapper: dict) -> int | None: + """Return the canonical rank for one known live mapper identity.""" + mapper_type = mapper.get("protocolMapper") + if mapper_type == "oidc-audience-mapper": + return 0 + if mapper_type != "oidc-hardcoded-claim-mapper": + return None + config = mapper.get("config") + if not isinstance(config, dict): + return None + claim_name = config.get("claim.name") + return _OBSERVED_CLAIM_RANKS.get(claim_name) + + +def _normalized_observed_mappers( + value: object, + registration: RelyingPartyRegistration, +) -> list[dict] | None: + """Normalize safe Keycloak mapper output or return ``None`` on drift.""" + if value is None: + return [] + if not isinstance(value, list) or len(value) > 4: + return None + + ranked_mappers: list[tuple[int, dict]] = [] + seen_ranks: set[int] = set() + for raw_mapper in value: + if not isinstance(raw_mapper, dict): + return None + if any(not isinstance(key, str) for key in raw_mapper): + return None + mapper = dict(raw_mapper) + mapper_id = mapper.pop("id", None) + if mapper_id is not None and ( + not isinstance(mapper_id, str) or not mapper_id + ): + return None + if set(mapper) != _OBSERVED_MAPPER_FIELDS: + return None + config = mapper.get("config") + if not isinstance(config, dict): + return None + if any(not isinstance(key, str) for key in config): + return None + if any(not isinstance(item, str) for item in config.values()): + return None + mapper["config"] = dict(config) + rank = _observed_mapper_rank(mapper) + if rank is None or rank in seen_ranks: + return None + seen_ranks.add(rank) + ranked_mappers.append((rank, mapper)) + + ranked_mappers.sort(key=lambda item: item[0]) + ordered = [mapper for _, mapper in ranked_mappers] + candidate_data = registration.model_dump(by_alias=True) + candidate_data["protocolMappers"] = ordered + try: + candidate = RelyingPartyRegistration.model_validate(candidate_data) + validate_relying_party_registration(candidate) + except Exception: + return None + return [ + mapper.model_dump(by_alias=True) + for mapper in candidate.protocol_mappers + ] +''' + + +OLD_COMPARISON = '''def _observable_client_matches( + registration: RelyingPartyRegistration, + client: dict, +) -> bool: + """Compare every field in the closed secret-free client profile.""" + desired = registration.model_dump(by_alias=True) + return all(client.get(key) == value for key, value in desired.items()) +''' + + +NEW_COMPARISON = '''def _observable_client_matches( + registration: RelyingPartyRegistration, + client: dict, +) -> bool: + """Compare closed client state after normalizing vendor mapper output.""" + desired = registration.model_dump(by_alias=True) + observed_mappers = _normalized_observed_mappers( + client.get("protocolMappers"), + registration, + ) + if observed_mappers is None: + return False + if observed_mappers != desired["protocolMappers"]: + return False + return all( + key == "protocolMappers" or client.get(key) == value + for key, value in desired.items() + ) +''' + + +def main() -> None: + """Insert normalization helpers and replace the raw mapper comparison.""" + source = STATE_PATH.read_text(encoding="utf-8") + marker = "\ndef _observable_client_matches(\n" + if source.count(marker) != 1: + raise SystemExit("unexpected observable comparison marker") + if source.count(OLD_COMPARISON) != 1: + raise SystemExit("unexpected observable comparison implementation") + source = source.replace(marker, HELPERS + marker, 1) + source = source.replace(OLD_COMPARISON, NEW_COMPARISON, 1) + STATE_PATH.write_text(source, encoding="utf-8") + + +if __name__ == "__main__": + main() From afb9dc6f4375134e03b0300e3685c50a5e0e250c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 19:38:52 +0900 Subject: [PATCH 14/30] ci: repair RP mapper normalization workflow --- .../one-shot-normalize-rp-mappers.yml | 127 +----------------- 1 file changed, 5 insertions(+), 122 deletions(-) diff --git a/.github/workflows/one-shot-normalize-rp-mappers.yml b/.github/workflows/one-shot-normalize-rp-mappers.yml index b846141..fad6764 100644 --- a/.github/workflows/one-shot-normalize-rp-mappers.yml +++ b/.github/workflows/one-shot-normalize-rp-mappers.yml @@ -52,126 +52,7 @@ jobs: fi - name: Implement mapper observation normalization - shell: bash - run: | - set -euo pipefail - python3 - <<'PY' - from pathlib import Path - - path = Path("services/account_unification/app/relying_party_state.py") - source = path.read_text(encoding="utf-8") - marker = "\ndef _observable_client_matches(\n" - if source.count(marker) != 1: - raise SystemExit("unexpected observable comparison marker") - - helpers = r''' -_OBSERVED_MAPPER_FIELDS: Final = frozenset( - {"name", "protocol", "protocolMapper", "consentRequired", "config"} -) -_OBSERVED_CLAIM_RANKS: Final = {"role": 1, "org": 2, "workspace": 3} - - -def _observed_mapper_rank(mapper: dict) -> int | None: - """Return the canonical rank for one known live mapper identity.""" - mapper_type = mapper.get("protocolMapper") - if mapper_type == "oidc-audience-mapper": - return 0 - if mapper_type != "oidc-hardcoded-claim-mapper": - return None - config = mapper.get("config") - if not isinstance(config, dict): - return None - claim_name = config.get("claim.name") - return _OBSERVED_CLAIM_RANKS.get(claim_name) - - -def _normalized_observed_mappers( - value: object, - registration: RelyingPartyRegistration, -) -> list[dict] | None: - """Normalize safe Keycloak mapper output or return ``None`` on drift.""" - if value is None: - return [] - if not isinstance(value, list) or len(value) > 4: - return None - - ranked_mappers: list[tuple[int, dict]] = [] - seen_ranks: set[int] = set() - for raw_mapper in value: - if not isinstance(raw_mapper, dict): - return None - if any(not isinstance(key, str) for key in raw_mapper): - return None - mapper = dict(raw_mapper) - mapper_id = mapper.pop("id", None) - if mapper_id is not None and ( - not isinstance(mapper_id, str) or not mapper_id - ): - return None - if set(mapper) != _OBSERVED_MAPPER_FIELDS: - return None - config = mapper.get("config") - if not isinstance(config, dict): - return None - if any(not isinstance(key, str) for key in config): - return None - if any(not isinstance(item, str) for item in config.values()): - return None - mapper["config"] = dict(config) - rank = _observed_mapper_rank(mapper) - if rank is None or rank in seen_ranks: - return None - seen_ranks.add(rank) - ranked_mappers.append((rank, mapper)) - - ranked_mappers.sort(key=lambda item: item[0]) - ordered = [mapper for _, mapper in ranked_mappers] - candidate_data = registration.model_dump(by_alias=True) - candidate_data["protocolMappers"] = ordered - try: - candidate = RelyingPartyRegistration.model_validate(candidate_data) - validate_relying_party_registration(candidate) - except Exception: - return None - return [ - mapper.model_dump(by_alias=True) - for mapper in candidate.protocol_mappers - ] -''' - source = source.replace(marker, helpers + marker, 1) - - old = '''def _observable_client_matches( - registration: RelyingPartyRegistration, - client: dict, - ) -> bool: - """Compare every field in the closed secret-free client profile.""" - desired = registration.model_dump(by_alias=True) - return all(client.get(key) == value for key, value in desired.items()) - ''' - new = '''def _observable_client_matches( - registration: RelyingPartyRegistration, - client: dict, - ) -> bool: - """Compare closed client state after normalizing vendor mapper output.""" - desired = registration.model_dump(by_alias=True) - observed_mappers = _normalized_observed_mappers( - client.get("protocolMappers"), - registration, - ) - if observed_mappers is None: - return False - if observed_mappers != desired["protocolMappers"]: - return False - return all( - key == "protocolMappers" or client.get(key) == value - for key, value in desired.items() - ) - ''' - if source.count(old) != 1: - raise SystemExit("unexpected observable comparison implementation") - source = source.replace(old, new, 1) - path.write_text(source, encoding="utf-8") - PY + run: python3 scripts/ci/one_shot_normalize_rp_mappers.py - name: Verify focused and complete production contracts working-directory: services/account_unification @@ -187,14 +68,16 @@ def _normalized_observed_mappers( uv run python -m compileall -q app tests tools uv build --out-dir dist - - name: Commit verified implementation and remove one-shot workflow + - name: Commit verified implementation and remove one-shot artifacts shell: bash run: | set -euo pipefail rm .github/workflows/one-shot-normalize-rp-mappers.yml + rm scripts/ci/one_shot_normalize_rp_mappers.py git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add services/account_unification/app/relying_party_state.py \ - .github/workflows/one-shot-normalize-rp-mappers.yml + .github/workflows/one-shot-normalize-rp-mappers.yml \ + scripts/ci/one_shot_normalize_rp_mappers.py git commit -m "fix(clients): normalize observed RP mapper state" git push origin HEAD:feat/oidc-rp-claim-profile From de6aace982f1f08f106ef7967c3d6964cb73d8de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 19:43:35 +0900 Subject: [PATCH 15/30] test(clients): cover mapper normalization failure boundaries --- ...est_relying_party_mapper_reconciliation.py | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/services/account_unification/tests/test_relying_party_mapper_reconciliation.py b/services/account_unification/tests/test_relying_party_mapper_reconciliation.py index 77bc48f..4db2709 100644 --- a/services/account_unification/tests/test_relying_party_mapper_reconciliation.py +++ b/services/account_unification/tests/test_relying_party_mapper_reconciliation.py @@ -1,8 +1,8 @@ """OIDC relying-party mapper observation and reconciliation regressions.""" from __future__ import annotations -from copy import deepcopy from collections.abc import Callable +from copy import deepcopy import pytest @@ -81,11 +81,21 @@ def _replace_with_too_many(client: dict) -> None: client["protocolMappers"] = [deepcopy(mapper) for _ in range(5)] +def _add_non_string_mapper_key(client: dict) -> None: + """Attach a malformed non-string key to a live mapper object.""" + _live_mappers_from_client(client)[0][7] = "not-owned" + + def _set_non_string_mapper_id(client: dict) -> None: - """Attach a malformed vendor-generated mapper identifier.""" + """Attach a malformed non-string vendor-generated mapper identifier.""" _live_mappers_from_client(client)[0]["id"] = 7 +def _set_empty_mapper_id(client: dict) -> None: + """Attach an empty vendor-generated mapper identifier.""" + _live_mappers_from_client(client)[0]["id"] = "" + + def _add_unknown_mapper_field(client: dict) -> None: """Attach an unowned mapper-level field that cannot be normalized away.""" _live_mappers_from_client(client)[0]["private"] = "not-owned" @@ -96,6 +106,20 @@ def _set_non_mapping_config(client: dict) -> None: _live_mappers_from_client(client)[0]["config"] = [] +def _add_non_string_config_key(client: dict) -> None: + """Attach a malformed non-string mapper-configuration key.""" + config = _live_mappers_from_client(client)[0]["config"] + assert isinstance(config, dict) + config[7] = "not-owned" + + +def _add_non_string_config_value(client: dict) -> None: + """Attach a malformed non-string mapper-configuration value.""" + config = _live_mappers_from_client(client)[0]["config"] + assert isinstance(config, dict) + config["access.token.claim"] = 7 + + def _set_unsupported_mapper_type(client: dict) -> None: """Replace a reviewed mapper plugin with an unsupported plugin.""" _live_mappers_from_client(client)[0]["protocolMapper"] = ( @@ -109,6 +133,11 @@ def _duplicate_mapper_identity(client: dict) -> None: mappers[2] = deepcopy(mappers[1]) +def _set_policy_invalid_audience_name(client: dict) -> None: + """Keep a recognized mapper type but violate its closed product policy.""" + _live_mappers_from_client(client)[0]["name"] = "wrong-audience-name" + + def _live_mappers_from_client(client: dict) -> list[dict]: """Return a type-checked mapper list from a mutable client object.""" mappers = client["protocolMappers"] @@ -124,11 +153,16 @@ def _live_mappers_from_client(client: dict) -> list[dict]: _replace_with_non_list, _replace_with_non_object, _replace_with_too_many, + _add_non_string_mapper_key, _set_non_string_mapper_id, + _set_empty_mapper_id, _add_unknown_mapper_field, _set_non_mapping_config, + _add_non_string_config_key, + _add_non_string_config_value, _set_unsupported_mapper_type, _duplicate_mapper_identity, + _set_policy_invalid_audience_name, ], ) def test_malformed_or_unowned_live_mapper_state_is_drift( From 57da6e4eadaeadee893c1f458bc6d4a77e63d7f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 19:44:54 +0900 Subject: [PATCH 16/30] ci: align mapper patcher with reachable branches --- scripts/ci/one_shot_normalize_rp_mappers.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/scripts/ci/one_shot_normalize_rp_mappers.py b/scripts/ci/one_shot_normalize_rp_mappers.py index 227f261..bd24d73 100644 --- a/scripts/ci/one_shot_normalize_rp_mappers.py +++ b/scripts/ci/one_shot_normalize_rp_mappers.py @@ -16,15 +16,13 @@ def _observed_mapper_rank(mapper: dict) -> int | None: - """Return the canonical rank for one known live mapper identity.""" + """Return the canonical rank for one structurally valid live mapper.""" mapper_type = mapper.get("protocolMapper") if mapper_type == "oidc-audience-mapper": return 0 if mapper_type != "oidc-hardcoded-claim-mapper": return None - config = mapper.get("config") - if not isinstance(config, dict): - return None + config = mapper["config"] claim_name = config.get("claim.name") return _OBSERVED_CLAIM_RANKS.get(claim_name) From 5edd06818cc3a302f5e3f481bd878d5a02d5c708 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 19:46:07 +0900 Subject: [PATCH 17/30] ci: rerun RP mapper normalization verification --- .github/workflows/one-shot-normalize-rp-mappers.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/one-shot-normalize-rp-mappers.yml b/.github/workflows/one-shot-normalize-rp-mappers.yml index fad6764..df2eae2 100644 --- a/.github/workflows/one-shot-normalize-rp-mappers.yml +++ b/.github/workflows/one-shot-normalize-rp-mappers.yml @@ -1,4 +1,5 @@ name: One-shot normalize RP mapper observations +# Retry after expanding reachable-branch coverage. on: push: From 5d01511e866ff4167737923d75eba15c4a770ebe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:47:02 +0000 Subject: [PATCH 18/30] fix(clients): normalize observed RP mapper state --- .../one-shot-normalize-rp-mappers.yml | 84 ----------- scripts/ci/one_shot_normalize_rp_mappers.py | 130 ------------------ .../app/relying_party_state.py | 86 +++++++++++- 3 files changed, 84 insertions(+), 216 deletions(-) delete mode 100644 .github/workflows/one-shot-normalize-rp-mappers.yml delete mode 100644 scripts/ci/one_shot_normalize_rp_mappers.py diff --git a/.github/workflows/one-shot-normalize-rp-mappers.yml b/.github/workflows/one-shot-normalize-rp-mappers.yml deleted file mode 100644 index df2eae2..0000000 --- a/.github/workflows/one-shot-normalize-rp-mappers.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: One-shot normalize RP mapper observations -# Retry after expanding reachable-branch coverage. - -on: - push: - branches: - - feat/oidc-rp-claim-profile - paths: - - .github/workflows/one-shot-normalize-rp-mappers.yml - -permissions: - contents: write - -concurrency: - group: one-shot-normalize-rp-mappers - cancel-in-progress: false - -jobs: - red-green-verify: - runs-on: ubuntu-24.04 - timeout-minutes: 25 - steps: - - name: Checkout exact branch head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: feat/oidc-rp-claim-profile - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 - with: - python-version: "3.12" - - - name: Set up uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 - - - name: Install locked dependencies - working-directory: services/account_unification - run: uv sync --locked --extra dev - - - name: Verify the mapper-order regression is RED - working-directory: services/account_unification - shell: bash - run: | - set +e - uv run pytest -q \ - tests/test_relying_party_mapper_reconciliation.py::test_generated_mapper_ids_and_vendor_order_do_not_create_false_drift - status=$? - set -e - if [ "$status" -eq 0 ]; then - echo "Expected mapper normalization regression to fail before implementation" >&2 - exit 1 - fi - - - name: Implement mapper observation normalization - run: python3 scripts/ci/one_shot_normalize_rp_mappers.py - - - name: Verify focused and complete production contracts - working-directory: services/account_unification - shell: bash - run: | - set -euo pipefail - uv run pytest -q tests/test_relying_party_mapper_reconciliation.py - uv run ruff check app tests tools - uv run interrogate . - uv run coverage erase - uv run coverage run --branch --source=app -m pytest -q - uv run coverage report --show-missing --fail-under=100 - uv run python -m compileall -q app tests tools - uv build --out-dir dist - - - name: Commit verified implementation and remove one-shot artifacts - shell: bash - run: | - set -euo pipefail - rm .github/workflows/one-shot-normalize-rp-mappers.yml - rm scripts/ci/one_shot_normalize_rp_mappers.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add services/account_unification/app/relying_party_state.py \ - .github/workflows/one-shot-normalize-rp-mappers.yml \ - scripts/ci/one_shot_normalize_rp_mappers.py - git commit -m "fix(clients): normalize observed RP mapper state" - git push origin HEAD:feat/oidc-rp-claim-profile diff --git a/scripts/ci/one_shot_normalize_rp_mappers.py b/scripts/ci/one_shot_normalize_rp_mappers.py deleted file mode 100644 index bd24d73..0000000 --- a/scripts/ci/one_shot_normalize_rp_mappers.py +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env python3 -"""Apply the reviewed RP mapper-observation normalization patch once.""" -from __future__ import annotations - -from pathlib import Path - - -STATE_PATH = Path("services/account_unification/app/relying_party_state.py") - - -HELPERS = r''' -_OBSERVED_MAPPER_FIELDS: Final = frozenset( - {"name", "protocol", "protocolMapper", "consentRequired", "config"} -) -_OBSERVED_CLAIM_RANKS: Final = {"role": 1, "org": 2, "workspace": 3} - - -def _observed_mapper_rank(mapper: dict) -> int | None: - """Return the canonical rank for one structurally valid live mapper.""" - mapper_type = mapper.get("protocolMapper") - if mapper_type == "oidc-audience-mapper": - return 0 - if mapper_type != "oidc-hardcoded-claim-mapper": - return None - config = mapper["config"] - claim_name = config.get("claim.name") - return _OBSERVED_CLAIM_RANKS.get(claim_name) - - -def _normalized_observed_mappers( - value: object, - registration: RelyingPartyRegistration, -) -> list[dict] | None: - """Normalize safe Keycloak mapper output or return ``None`` on drift.""" - if value is None: - return [] - if not isinstance(value, list) or len(value) > 4: - return None - - ranked_mappers: list[tuple[int, dict]] = [] - seen_ranks: set[int] = set() - for raw_mapper in value: - if not isinstance(raw_mapper, dict): - return None - if any(not isinstance(key, str) for key in raw_mapper): - return None - mapper = dict(raw_mapper) - mapper_id = mapper.pop("id", None) - if mapper_id is not None and ( - not isinstance(mapper_id, str) or not mapper_id - ): - return None - if set(mapper) != _OBSERVED_MAPPER_FIELDS: - return None - config = mapper.get("config") - if not isinstance(config, dict): - return None - if any(not isinstance(key, str) for key in config): - return None - if any(not isinstance(item, str) for item in config.values()): - return None - mapper["config"] = dict(config) - rank = _observed_mapper_rank(mapper) - if rank is None or rank in seen_ranks: - return None - seen_ranks.add(rank) - ranked_mappers.append((rank, mapper)) - - ranked_mappers.sort(key=lambda item: item[0]) - ordered = [mapper for _, mapper in ranked_mappers] - candidate_data = registration.model_dump(by_alias=True) - candidate_data["protocolMappers"] = ordered - try: - candidate = RelyingPartyRegistration.model_validate(candidate_data) - validate_relying_party_registration(candidate) - except Exception: - return None - return [ - mapper.model_dump(by_alias=True) - for mapper in candidate.protocol_mappers - ] -''' - - -OLD_COMPARISON = '''def _observable_client_matches( - registration: RelyingPartyRegistration, - client: dict, -) -> bool: - """Compare every field in the closed secret-free client profile.""" - desired = registration.model_dump(by_alias=True) - return all(client.get(key) == value for key, value in desired.items()) -''' - - -NEW_COMPARISON = '''def _observable_client_matches( - registration: RelyingPartyRegistration, - client: dict, -) -> bool: - """Compare closed client state after normalizing vendor mapper output.""" - desired = registration.model_dump(by_alias=True) - observed_mappers = _normalized_observed_mappers( - client.get("protocolMappers"), - registration, - ) - if observed_mappers is None: - return False - if observed_mappers != desired["protocolMappers"]: - return False - return all( - key == "protocolMappers" or client.get(key) == value - for key, value in desired.items() - ) -''' - - -def main() -> None: - """Insert normalization helpers and replace the raw mapper comparison.""" - source = STATE_PATH.read_text(encoding="utf-8") - marker = "\ndef _observable_client_matches(\n" - if source.count(marker) != 1: - raise SystemExit("unexpected observable comparison marker") - if source.count(OLD_COMPARISON) != 1: - raise SystemExit("unexpected observable comparison implementation") - source = source.replace(marker, HELPERS + marker, 1) - source = source.replace(OLD_COMPARISON, NEW_COMPARISON, 1) - STATE_PATH.write_text(source, encoding="utf-8") - - -if __name__ == "__main__": - main() diff --git a/services/account_unification/app/relying_party_state.py b/services/account_unification/app/relying_party_state.py index c0976ec..4d52ebd 100644 --- a/services/account_unification/app/relying_party_state.py +++ b/services/account_unification/app/relying_party_state.py @@ -455,13 +455,95 @@ def _client_uuid(client: dict) -> str: ) from None +_OBSERVED_MAPPER_FIELDS: Final = frozenset( + {"name", "protocol", "protocolMapper", "consentRequired", "config"} +) +_OBSERVED_CLAIM_RANKS: Final = {"role": 1, "org": 2, "workspace": 3} + + +def _observed_mapper_rank(mapper: dict) -> int | None: + """Return the canonical rank for one structurally valid live mapper.""" + mapper_type = mapper.get("protocolMapper") + if mapper_type == "oidc-audience-mapper": + return 0 + if mapper_type != "oidc-hardcoded-claim-mapper": + return None + config = mapper["config"] + claim_name = config.get("claim.name") + return _OBSERVED_CLAIM_RANKS.get(claim_name) + + +def _normalized_observed_mappers( + value: object, + registration: RelyingPartyRegistration, +) -> list[dict] | None: + """Normalize safe Keycloak mapper output or return ``None`` on drift.""" + if value is None: + return [] + if not isinstance(value, list) or len(value) > 4: + return None + + ranked_mappers: list[tuple[int, dict]] = [] + seen_ranks: set[int] = set() + for raw_mapper in value: + if not isinstance(raw_mapper, dict): + return None + if any(not isinstance(key, str) for key in raw_mapper): + return None + mapper = dict(raw_mapper) + mapper_id = mapper.pop("id", None) + if mapper_id is not None and ( + not isinstance(mapper_id, str) or not mapper_id + ): + return None + if set(mapper) != _OBSERVED_MAPPER_FIELDS: + return None + config = mapper.get("config") + if not isinstance(config, dict): + return None + if any(not isinstance(key, str) for key in config): + return None + if any(not isinstance(item, str) for item in config.values()): + return None + mapper["config"] = dict(config) + rank = _observed_mapper_rank(mapper) + if rank is None or rank in seen_ranks: + return None + seen_ranks.add(rank) + ranked_mappers.append((rank, mapper)) + + ranked_mappers.sort(key=lambda item: item[0]) + ordered = [mapper for _, mapper in ranked_mappers] + candidate_data = registration.model_dump(by_alias=True) + candidate_data["protocolMappers"] = ordered + try: + candidate = RelyingPartyRegistration.model_validate(candidate_data) + validate_relying_party_registration(candidate) + except Exception: + return None + return [ + mapper.model_dump(by_alias=True) + for mapper in candidate.protocol_mappers + ] + def _observable_client_matches( registration: RelyingPartyRegistration, client: dict, ) -> bool: - """Compare every field in the closed secret-free client profile.""" + """Compare closed client state after normalizing vendor mapper output.""" desired = registration.model_dump(by_alias=True) - return all(client.get(key) == value for key, value in desired.items()) + observed_mappers = _normalized_observed_mappers( + client.get("protocolMappers"), + registration, + ) + if observed_mappers is None: + return False + if observed_mappers != desired["protocolMappers"]: + return False + return all( + key == "protocolMappers" or client.get(key) == value + for key, value in desired.items() + ) def _relying_party_status( From 842c762a535a26a3208911762294ab130b3ebb27 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 00:12:13 +0900 Subject: [PATCH 19/30] test(clients): require shipped Naruon RP claim template --- .../tests/test_relying_party_template.py | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/services/account_unification/tests/test_relying_party_template.py b/services/account_unification/tests/test_relying_party_template.py index 99e2d83..7f4ed68 100644 --- a/services/account_unification/tests/test_relying_party_template.py +++ b/services/account_unification/tests/test_relying_party_template.py @@ -7,9 +7,9 @@ from app.relying_party import _parse_registration, validate_relying_party_registration -_TEMPLATE_PATH = ( - Path(__file__).resolve().parents[3] / "deploy" / "templates" / "oidc-rp-client.json" -) +_TEMPLATE_ROOT = Path(__file__).resolve().parents[3] / "deploy" / "templates" +_TEMPLATE_PATH = _TEMPLATE_ROOT / "oidc-rp-client.json" +_NARUON_TEMPLATE_PATH = _TEMPLATE_ROOT / "oidc-rp-naruon.json" def _render_template() -> dict[str, object]: @@ -28,6 +28,24 @@ def _render_template() -> dict[str, object]: return payload +def _render_naruon_template() -> dict[str, object]: + """Render the committed public Naruon claim profile without a shell tool.""" + rendered = _NARUON_TEMPLATE_PATH.read_text(encoding="utf-8") + replacements = { + "{{naruon_redirect_uri}}": "https://naruon.example/auth/callback", + "{{naruon_web_origin}}": "https://naruon.example", + "{{naruon_post_logout_uri}}": "https://naruon.example/auth/logout", + "{{naruon_role}}": "member", + "{{naruon_org}}": "org-cwl", + "{{naruon_workspace}}": "workspace-org-cwl", + } + for marker, value in replacements.items(): + rendered = rendered.replace(marker, value) + payload = json.loads(rendered) + assert isinstance(payload, dict) + return payload + + def test_oidc_rp_template_is_closed_secret_free_and_preflight_ready() -> None: """The rendered template passes the same production preflight as operators.""" payload = _render_template() @@ -40,3 +58,27 @@ def test_oidc_rp_template_is_closed_secret_free_and_preflight_ready() -> None: assert "clientSecret" not in payload assert payload["webOrigins"] == ["https://naruon.example"] assert payload["defaultClientScopes"] == ["basic", "profile", "email"] + + +def test_naruon_runtime_template_has_the_closed_mapper_profile() -> None: + """The shipped Naruon artifact is secret-free and accepted by production.""" + payload = _render_naruon_template() + + result = validate_relying_party_registration(_parse_registration(payload)) + + assert result.ready_to_apply is True + assert payload["clientId"] == "naruon-web" + assert payload["publicClient"] is True + assert payload["clientAuthenticatorType"] == "none" + assert "secret" not in {str(key).lower() for key in payload} + assert "clientSecret" not in payload + mappers = payload["protocolMappers"] + assert isinstance(mappers, list) + assert [mapper["name"] for mapper in mappers] == [ + "keyverse-audience", + "keyverse-claim-role", + "keyverse-claim-org", + "keyverse-claim-workspace", + ] + audience = mappers[0] + assert audience["config"]["included.client.audience"] == "naruon-web" From 2c3f2c1ef7488299a4deb5cb3e22045608451c82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 00:14:04 +0900 Subject: [PATCH 20/30] feat(clients): ship Naruon RP claim template --- deploy/templates/oidc-rp-naruon.json | 82 ++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 deploy/templates/oidc-rp-naruon.json diff --git a/deploy/templates/oidc-rp-naruon.json b/deploy/templates/oidc-rp-naruon.json new file mode 100644 index 0000000..e1f0a2b --- /dev/null +++ b/deploy/templates/oidc-rp-naruon.json @@ -0,0 +1,82 @@ +{ + "clientId": "naruon-web", + "name": "naruon-web", + "enabled": true, + "protocol": "openid-connect", + "publicClient": true, + "clientAuthenticatorType": "none", + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "redirectUris": ["{{naruon_redirect_uri}}"], + "webOrigins": ["{{naruon_web_origin}}"], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "{{naruon_post_logout_uri}}", + "access.token.lifespan": "300", + "backchannel.logout.session.required": "true", + "require.pushed.authorization.requests": "false" + }, + "fullScopeAllowed": false, + "defaultClientScopes": ["basic", "profile", "email"], + "protocolMappers": [ + { + "name": "keyverse-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "naruon-web", + "access.token.claim": "true", + "id.token.claim": "false", + "introspection.token.claim": "true" + } + }, + { + "name": "keyverse-claim-role", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "claim.name": "role", + "claim.value": "{{naruon_role}}", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "false", + "introspection.token.claim": "true" + } + }, + { + "name": "keyverse-claim-org", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "claim.name": "org", + "claim.value": "{{naruon_org}}", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "false", + "introspection.token.claim": "true" + } + }, + { + "name": "keyverse-claim-workspace", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "claim.name": "workspace", + "claim.value": "{{naruon_workspace}}", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "false", + "introspection.token.claim": "true" + } + } + ] +} From 236d6edcd556bdeaf0fca1bf67adcd2c5ba4e6e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 00:16:09 +0900 Subject: [PATCH 21/30] docs(clients): doctor closed RP mapper evidence --- .../doctoring/oidc-rp-claim-mapper-profile.md | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 docs/doctoring/oidc-rp-claim-mapper-profile.md diff --git a/docs/doctoring/oidc-rp-claim-mapper-profile.md b/docs/doctoring/oidc-rp-claim-mapper-profile.md new file mode 100644 index 0000000..fe5389e --- /dev/null +++ b/docs/doctoring/oidc-rp-claim-mapper-profile.md @@ -0,0 +1,142 @@ +# OIDC RP Claim Mapper Profile — Evidence and Standards Doctoring + +## Scope + +This record documents the evidence used to define Keyverse's closed optional +`protocolMappers` profile for OIDC relying-party desired state. It separates +normative protocol requirements, Keycloak representation behavior, stricter +Keyverse policy, measured repository evidence, assumptions, and limitations. +It does **not** claim OpenID Connect, OAuth, JWT, or Keycloak conformance. + +## Normative protocol evidence + +### OpenID Connect ID-token audience + +OpenID Connect Core defines the ID Token `aud` claim as the audience for which +the token is intended and requires the RP's `client_id` to be present. Keyverse +does not use the custom `keyverse-audience` mapper to satisfy this ID-token +requirement: the mapper deliberately sets `id.token.claim=false`. ID-token +audience validation remains part of the normal OpenID Connect token-validation +boundary and must be proved in controlled login acceptance. + +### JWT access-token audience + +RFC 9068 requires a JWT access token recipient to reject a token whose `aud` +does not identify that resource server. Keyverse therefore treats the +`keyverse-audience` access-token mapper as an explicit deployment contract, not +as a generic rule that every RP `client_id` is automatically a valid resource +indicator. The Naruon profile pins `included.client.audience` to `naruon-web` +only because the deployment contract expects that exact audience. A deployment +with a distinct resource-server identifier requires a separately reviewed +profile rather than widening this mapper. + +### JWT recipient validation + +RFC 8725 requires applications to validate the audience when a JWT is intended +for a particular relying party or application. Mapper configuration is only +issuer-side evidence. It does not prove that Naruon validates issuer, signature, +algorithm, token type where applicable, expiry, and audience at its receiving +boundary. Controlled acceptance must verify those behaviors independently. + +## Keycloak representation evidence + +Keycloak 26 exposes protocol mappers as `ProtocolMapperRepresentation` objects +and includes a list of them on `ClientRepresentation`. Keyverse accepts only the +small subset needed for the reviewed Naruon profile: + +- exact fields: `name`, `protocol`, `protocolMapper`, `consentRequired`, and + `config`; +- exactly one `oidc-audience-mapper` whenever any mapper is present; +- optional `oidc-hardcoded-claim-mapper` entries only for `role`, `org`, and + `workspace`; +- canonical order: audience, role, org, workspace; +- no scripts, user-attribute lookup, groups, regex, arbitrary claim names, + unknown mapper classes, or credential-bearing configuration. + +Keycloak may add generated mapper `id` values or return mapper order differently +from the submitted representation. Keyverse therefore ignores only a valid +non-empty generated `id`, revalidates the remaining closed representation, +orders known mapper identities canonically, and then performs semantic drift +comparison. Unknown, malformed, or duplicate live mappers remain drift rather +than being silently discarded. + +## Stricter Keyverse product policy + +The product policy is intentionally narrower than the vendor representation: + +1. Mapper count is bounded to four. +2. The audience mapper is self-pinned to the validated registration + `clientId`; arbitrary audiences are rejected. +3. Hardcoded claim names are limited to `role`, `org`, and `workspace`. +4. Hardcoded values are bounded visible routing data, not a secret channel. +5. Mapper names and token destinations are canonical and exact. +6. `consentRequired` must be false and protocol must be `openid-connect`. +7. Preflight performs no DNS, HTTP, Keycloak, storage, file, or secret side + effect. +8. Desired state remains secret-free and write receipts are produced only after + post-mutation re-observation. + +The hardcoded claims are not, by themselves, proof of user entitlement. A +consumer that uses them for authorization must still apply its independently +reviewed authorization model and token-validation policy. + +## Measured repository evidence + +The implementation is covered by production-shaped tests that exercise: + +- the first Naruon mapper payload being rejected before mapper support existed; +- nested hostile shapes and non-reflective validation failures; +- wrong/duplicate audience and claim mappers; +- unsupported mapper classes and claim names; +- canonical mapper ordering and bounded claim values; +- Keycloak-generated mapper IDs and vendor reordering; +- semantic drift for unknown, malformed, duplicate, or changed mappers; +- the committed `deploy/templates/oidc-rp-naruon.json` artifact after + placeholder substitution; +- complete production statement and branch coverage in the repository CI gate. + +The template test was intentionally introduced before the template. Hosted CI +then failed with `FileNotFoundError` for +`deploy/templates/oidc-rp-naruon.json`, establishing the missing-runtime-artifact +RED receipt before the template was added. + +## Assumptions requiring operational evidence + +- `naruon-web` is the audience expected by the Naruon resource boundary for the + access token produced by this deployment profile. +- The deployment controller substitutes all HTTPS and routing-data placeholders + before preflight and does not persist rendered values in source control. +- `role`, `org`, and `workspace` values are product routing/authorization data + safe to disclose to the token holder and are not credentials or personal + secrets. +- Downstream Naruon token validation rejects invalid issuer, signature, + algorithm, expiry, and audience values. +- The deployed Keycloak version preserves the reviewed mapper semantics. + +## Limitations and follow-up + +This slice does not prove a live authorization-code/PKCE exchange, downstream +audience acceptance, user/session migration, or clean-realm recovery. Those are +runtime evidence boundaries. It also does not remove runtime application +clients from the portable realm; that migration remains a separate reviewed +change. Any new mapper type, claim name, token destination, resource audience, +or native-client redirect profile requires explicit design and regression +coverage rather than extension by configuration alone. + +## References + +Bertocci, V. (2021). *JSON Web Token (JWT) profile for OAuth 2.0 access tokens* +(RFC 9068). RFC Editor. https://www.rfc-editor.org/rfc/rfc9068 + +Jones, M. B., Hardt, D., & Campbell, B. (2020). *JSON Web Token best current +practices* (BCP 225, RFC 8725). RFC Editor. +https://www.rfc-editor.org/rfc/rfc8725 + +Keycloak Project. (2026). *ClientRepresentation* (Keycloak Docs Distribution +26.x API). https://www.keycloak.org/docs-api/latest/javadocs/org/keycloak/representations/idm/ClientRepresentation.html + +Keycloak Project. (2026). *ProtocolMapperRepresentation* (Keycloak Docs +Distribution 26.x API). https://www.keycloak.org/docs-api/latest/javadocs/org/keycloak/representations/idm/ProtocolMapperRepresentation.html + +OpenID Foundation. (2023). *OpenID Connect Core 1.0 incorporating errata set 2*. +https://openid.net/specs/openid-connect-core-1_0.html From 13281a83fd56363300e7f3c81faabef4552f7e07 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 00:17:15 +0900 Subject: [PATCH 22/30] docs(changelog): record closed RP mapper profile --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f18e854..5462dfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,10 @@ Keep a Changelog, and releases use semantic versioning. - ADR-0008 and the non-fork RP authorization matrix, requiring explicit Keyverse token validation, tenant/resource ABAC, bounded RBAC, and cross-tenant acceptance evidence per application. - +- A closed optional OIDC relying-party mapper profile with one self-pinned + access-token audience, bounded `role`, `org`, and `workspace` hardcoded claims, + canonical mapper ordering, Keycloak-generated-ID/order normalization, and a + secret-free `naruon-web` runtime desired-state template. - Durable, secret-free OIDC relying-party desired-state CRUD and reconciliation with exact `clientId` matching, duplicate fail-closed behavior, post-mutation re-observation, canonical apply receipts, realm-rebuild recovery, per-client From df907abc7bddd73e9f9ea057b366de8fe11828a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 00:18:00 +0900 Subject: [PATCH 23/30] docs(architecture): define closed RP mapper boundary --- ARCHITECTURE.md | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8ff5e23..8a54a23 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -153,14 +153,27 @@ side effect. It enforces authorization code plus PKCE `S256`, exact HTTPS redirect/origin/logout policy, public/confidential client consistency, bounded token metadata, and an exact portable scope set. +An optional closed `protocolMappers` profile carries exactly one self-pinned +`oidc-audience-mapper` plus zero to three canonical hardcoded claims named +`role`, `org`, and `workspace`. Mapper count, names, classes, destinations, +claim values, and ordering are bounded; scripts, user attributes, groups, regex, +arbitrary claims, unknown fields, and credential material are rejected. +`deploy/templates/oidc-rp-naruon.json` is the reviewed public-client instance of +that profile. Its routing claim values are deployment data and must not contain +credentials or personal secrets. + Stateful reconciliation keys intent by validated `clientId`, classifies zero, one, or multiple exact Keycloak clients, and never mutates duplicates. Create or update is re-observed before a canonical receipt is written. Delete is remote- -first. The accepted representation has no client-secret field; credential -provisioning remains an independent secret-management responsibility. +first. For mapper comparison, Keyverse ignores only a valid generated mapper +`id`, canonicalizes the known mapper order, revalidates the closed shape, and +treats unknown, malformed, duplicate, or semantically changed mappers as drift. +The accepted representation has no client-secret field; credential provisioning +remains an independent secret-management responsibility. -Native loopback/private-use redirects and deployment-specific claim expansion -remain separate reviewed profiles. +Native loopback/private-use redirects, different resource audiences, and claim +expansion beyond `role`, `org`, and `workspace` remain separate reviewed +profiles. Each downstream RP is a separate trust boundary. The RP must validate the Keyverse issuer, signature/algorithm, expiry, subject, and audience, map the @@ -183,6 +196,9 @@ non-fork application matrix and remediation gates. or desired-state templates. 8. Preflight readiness is not reported as deployment or login success. 9. Mutation receipts are written only after exact live re-observation. +10. Mapper configuration is issuer-side evidence only; downstream token + signature, issuer, expiry, and audience validation remain separate runtime + acceptance boundaries. ## Deployment modes From 6219c8c296dbcf73b99ac2187606053ef1335391 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 00:18:36 +0900 Subject: [PATCH 24/30] docs(agents): preserve closed RP mapper contract --- AGENTS.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 96a2746..7620bd2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,8 +35,20 @@ queue owner. - Never link or merge on an unverified email. - SAML/OIDC preflight performs no metadata/discovery fetch. LDAP preflight performs no DNS, socket, bind, search, storage, or Keycloak call. +- OIDC relying-party mapper support stays closed: one self-pinned audience plus + only canonical `role`, `org`, and `workspace` hardcoded claims. Do not add + scripts, user attributes, groups, regex, arbitrary claims, new audiences, or + extra token destinations without a separately reviewed profile and RED test. +- Treat generated Keycloak mapper IDs and vendor ordering as normalization-only + metadata. Unknown, malformed, duplicate, or semantically changed live mappers + are drift and must not be silently discarded. +- Mapper configuration is issuer-side evidence, not proof that a relying party + validates token signature, issuer, expiry, or audience. Keep controlled login + acceptance as a separate runtime evidence boundary. - Secrets do not appear in source, templates, responses, logs, command - arguments, screenshots, issues, PR text, or artifacts. + arguments, screenshots, issues, PR text, or artifacts. Hardcoded RP routing + claim values are visible product data and must not carry credentials or + personal secrets. - Database objects use descriptive two-word-or-longer snake_case names. - Preserve permissive licensing; do not add GPL/AGPL dependencies. - Preserve standalone Compose/Helm operation and stable module boundaries for From 468d729a833bb07e063daa68e717bae9ac98e747 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 00:19:19 +0900 Subject: [PATCH 25/30] docs(agents): document RP mapper implementation rules --- CLAUDE.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8e99a9d..bab58e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,8 @@ chart has the same shape): - **account_unification_service** — FastAPI admin service (Python ≥3.11) on port 8099. It talks to Keycloak only through the Admin REST API using a confidential service-account client. It provides account inspect/link/merge, inbound SCIM, - passwordless registration, SAML/OIDC desired state, and LDAP/AD preflight. + passwordless registration, SAML/OIDC desired state, LDAP/AD preflight, and + secret-free OIDC relying-party desired state. Networks: `idp_internal_network` (database, engine, and admin service; never public) and `idp_edge_network` (Keycloak OIDC endpoints and the admin/SCIM API @@ -104,9 +105,11 @@ is required by the normal suite. - `deploy/keycloak/` — portable realm config-as-code and `kcadm-bootstrap.sh`. The realm contains no employer-specific federation. - `deploy/templates/` — explicit private deployment contracts. SAML/OIDC use - Keyverse desired-state endpoints. LDAP is preflighted through Keyverse and - then applied through private Keycloak Admin REST in this release. All - `{{placeholders}}` are resolved from KV before use. + Keyverse desired-state endpoints. `oidc-rp-naruon.json` is the reviewed public + Naruon runtime RP profile with one audience mapper and bounded routing claims. + LDAP is preflighted through Keyverse and then applied through private Keycloak + Admin REST in this release. All `{{placeholders}}` are resolved from KV before + use. - `deploy/bootstrap/` — the bootstrap pointer locating the KV/DB config store. - `helm/cwl-idp/` — the same three components; Keycloak and Postgres may be disabled in favor of externally managed services. Secrets come from @@ -132,6 +135,16 @@ is required by the normal suite. single-valued config shape. Preflight performs no DNS lookup, socket, bind, search, storage write, or Keycloak call. Its redacted response is never an apply payload. +- **OIDC relying-party metadata is secret-free desired state.** Validate with + `POST /clients/relying-parties:validate`, persist with `PUT`, and require exact + post-mutation observation before accepting a receipt. The optional mapper + profile permits exactly one audience mapper plus only canonical `role`, `org`, + and `workspace` hardcoded claims. Never expand mapper classes, claim names, + resource audiences, or token destinations by configuration alone. +- **Treat mapper normalization narrowly.** Ignore only a valid generated mapper + `id` and canonicalize known mapper order. Unknown, malformed, duplicate, or + semantically changed live mapper state is drift. Mapper configuration does not + replace downstream token signature/issuer/expiry/audience acceptance tests. - **Never link or merge accounts on an unverified email.** Matching precedence is exact `(identity_provider, subject)` → verified email → explicit operator link. Merges are survivor-wins, tombstone the duplicate, and audit every step From b09dc6922b17b21f30b3697435b0e5ba59bf267b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 00:21:28 +0900 Subject: [PATCH 26/30] docs(clients): document Naruon mapper onboarding --- docs/rp-onboarding.md | 70 ++++++++++++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 21 deletions(-) diff --git a/docs/rp-onboarding.md b/docs/rp-onboarding.md index 5a7a99c..854f3ed 100644 --- a/docs/rp-onboarding.md +++ b/docs/rp-onboarding.md @@ -11,11 +11,17 @@ redirects require a separate reviewed profile and are not accepted here. ## Render, validate, then reconcile -Render `deploy/templates/oidc-rp-client.json` into a private temporary file. -Resolve every placeholder from deployment configuration or KV. For a public -browser client set `publicClient=true` and -`clientAuthenticatorType=none`; the committed template defaults to a -confidential web client with `client-secret` authentication. +Use `deploy/templates/oidc-rp-client.json` for the generic closed RP profile. +For the reviewed Naruon browser-client path, use +`deploy/templates/oidc-rp-naruon.json`. The Naruon template is already a public +client (`publicClient=true`, `clientAuthenticatorType=none`) and contains the +closed audience/session-claim mapper profile. + +Resolve every placeholder from deployment configuration or KV before preflight. +For Naruon this includes exact HTTPS redirect, web-origin, and post-logout URIs +plus the bounded `role`, `org`, and `workspace` routing values. Those claim +values are visible product routing/authorization data: they must not contain +credentials, bearer material, personal secrets, or unreviewed tenant data. The same original secret-free payload must pass pure preflight and then the durable Keyverse desired-state boundary: @@ -45,7 +51,7 @@ if [ "$XTRACE_WAS_ON" -eq 1 ]; then set -x fi -render deploy/templates/oidc-rp-client.json >"$PAYLOAD" +render deploy/templates/oidc-rp-naruon.json >"$PAYLOAD" curl --config "$AUTH_CONFIG" \ --fail-with-body \ @@ -96,14 +102,15 @@ state before attempting Keycloak convergence. It classifies exact `clientId` matches, creates or repairs one client, re-observes the live metadata, and writes a canonical receipt only after exact verification. -Neither response proves successful login. The deployment controller remains -responsible for private network routing, Keycloak availability, TLS trust, -credential placement, live authorization-code tests, rollback, and operating -SLO evidence. +Neither response proves successful login or correct token consumption. The +deployment controller remains responsible for private network routing, Keycloak +availability, TLS trust, confidential credential placement where applicable, +controlled authorization-code/PKCE login, downstream JWT validation, rollback, +and operating SLO evidence. ## Closed first-profile policy -The payload must satisfy all of the following: +Every RP payload must satisfy all of the following: - authorization code flow enabled; - implicit, password/direct-access, and service-account flows disabled; @@ -121,9 +128,22 @@ The payload must satisfy all of the following: - backchannel logout session handling enabled; - unresolved template markers rejected. -Role and audience claims remain server-owned realm/client mapper policy. The RP -template does not widen its default scope set to request deployment-specific -claims. +When `protocolMappers` is present, the mapper profile is additionally closed: + +- exactly one `oidc-audience-mapper` is required and its + `included.client.audience` must equal the validated `clientId`; +- optional hardcoded claims are limited to `role`, `org`, and `workspace`; +- mapper names, protocols, token destinations, nested fields, and list order are + canonical and exact; +- script, user-attribute, group, regex, arbitrary-claim, unknown mapper, and + credential-bearing configuration is rejected; +- generated Keycloak mapper IDs and vendor return ordering are normalized only + for observation; unknown, malformed, duplicate, or semantically changed live + mapper state is reported as drift rather than silently accepted. + +The mapper profile does not widen the portable default scope set. It is +issuer-side configuration evidence, not proof that the relying party correctly +validates the resulting tokens. ## Desired-state lifecycle @@ -155,30 +175,38 @@ a secret for a confidential client, but reading and placing it is a separate approved secret-management operation. Store it, for example, under: ```text -secret/idp/rp/naruon-web/client-id -secret/idp/rp/naruon-web/client-secret +secret/idp/rp/example-web/client-id +secret/idp/rp/example-web/client-secret ``` The RP receives a bootstrap reference or workload identity, not a literal secret in source, a checked-in environment file, a command argument, a -Keyverse desired-state record, or an operator response. Public clients have no -client secret. +Keyverse desired-state record, or an operator response. Public clients such as +the Naruon runtime profile have no client secret. ## Acceptance evidence Before routing production users, record the desired payload digest, canonical apply receipt, Keyverse version, Keycloak version, client UUID, operator identity, controlled authorization-code/PKCE login result, refresh result, -logout result, and rollback reference. Do not record bearer tokens, -authorization codes, code verifiers, or client-secret bytes. +logout result, and rollback reference. For the Naruon mapper profile, also prove +that the downstream boundary rejects invalid issuer/signature/expiry/audience +and accepts the exact reviewed `naruon-web` audience plus expected `role`, `org`, +and `workspace` values. Mapper configuration alone is not that proof. + +Do not record bearer tokens, authorization codes, code verifiers, client-secret +bytes, or private routing values beyond the minimum non-secret acceptance +evidence required by the deployment record. ## Checklist - [ ] placeholders resolved in a mode-0600 file +- [ ] Naruon routing values independently reviewed as non-secret product data - [ ] authenticated preflight returned exact HTTP 200 - [ ] `ready_to_apply=true` verified without applying the response body - [ ] original rendered file sent to Keyverse PUT, not directly to public Admin REST - [ ] `convergence_state=in_sync` and receipt match verified - [ ] confidential secret stored only through the approved secret-management port - [ ] exact redirect/origin/logout values independently reviewed -- [ ] controlled login, refresh, logout, and rollback evidence recorded +- [ ] expected mapper audience and claim profile re-observed without drift +- [ ] controlled login, downstream JWT acceptance/rejection, refresh, logout, and rollback evidence recorded From 73c48e34f2ab5d0f13df7a7dfaf5b849c102f02b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 00:22:22 +0900 Subject: [PATCH 27/30] docs(operations): cover Naruon mapper reconciliation --- docs/operations/oidc-rp-reconciliation.md | 94 +++++++++++++++++------ 1 file changed, 71 insertions(+), 23 deletions(-) diff --git a/docs/operations/oidc-rp-reconciliation.md b/docs/operations/oidc-rp-reconciliation.md index 0d84cad..629f031 100644 --- a/docs/operations/oidc-rp-reconciliation.md +++ b/docs/operations/oidc-rp-reconciliation.md @@ -3,24 +3,48 @@ ## Purpose Operate the Keyverse-owned, secret-free OIDC relying-party desired-state -lifecycle. This runbook deliberately separates metadata reconciliation from -confidential-client secret placement and from live authorization-code acceptance. +lifecycle. This runbook deliberately separates deterministic metadata and mapper +reconciliation from confidential-client secret placement and from live +authorization-code/JWT acceptance. ## Routine workflow -1. Render `deploy/templates/oidc-rp-client.json` into a private mode-0600 file. -2. Call `POST /clients/relying-parties:validate` and require HTTP 200 plus +1. Render `deploy/templates/oidc-rp-client.json` for a generic RP or + `deploy/templates/oidc-rp-naruon.json` for the reviewed Naruon public-client + mapper profile into a private mode-0600 file. +2. Resolve all HTTPS and routing placeholders. Treat Naruon `role`, `org`, and + `workspace` values as visible product data, never credentials or personal + secrets. +3. Call `POST /clients/relying-parties:validate` and require HTTP 200 plus `ready_to_apply=true`. -3. Call `PUT /clients/relying-parties/{client_id}` with the same original file. -4. Require `convergence_state=in_sync` and - `last_apply_receipt_matches=true`. -5. For a confidential client, provision or rotate its credential through the +4. Call `PUT /clients/relying-parties/{client_id}` with the same original file. +5. Require `convergence_state=in_sync` and + `last_apply_receipt_matches=true` after exact live re-observation. +6. For a confidential client, provision or rotate its credential through the separately approved secret-management channel; never add it to this payload. -6. Run controlled authorization-code plus PKCE login, refresh, logout, and - rollback tests. -7. Record only non-secret evidence: desired client ID, Keycloak UUID, desired - payload digest, apply receipt, versions, operator, test result, and rollback - reference. +7. Run controlled authorization-code plus PKCE login, downstream JWT + signature/issuer/expiry/audience acceptance and rejection, refresh, logout, + and rollback tests. +8. Record only non-secret evidence: desired client ID, Keycloak UUID, desired + payload digest, apply receipt, versions, operator, controlled acceptance + result, and rollback reference. + +## Naruon mapper contract + +The Naruon runtime artifact is a public `naruon-web` client with exactly four +canonical mappers: + +1. `keyverse-audience` — `oidc-audience-mapper`, access-token audience pinned to + `naruon-web`; +2. `keyverse-claim-role` — hardcoded `role`; +3. `keyverse-claim-org` — hardcoded `org`; +4. `keyverse-claim-workspace` — hardcoded `workspace`. + +The profile allows no script, user-attribute, group, regex, arbitrary claim, +unknown mapper class, extra nested field, or credential material. Token +configuration is issuer-side evidence only. The receiving Naruon boundary must +independently validate the token and must not infer authorization merely from the +presence of a hardcoded claim. ## Example @@ -49,7 +73,7 @@ if [ "$XTRACE_WAS_ON" -eq 1 ]; then set -x fi -render deploy/templates/oidc-rp-client.json >"$PAYLOAD" +render deploy/templates/oidc-rp-naruon.json >"$PAYLOAD" curl --config "$AUTH_CONFIG" \ --fail-with-body \ @@ -100,13 +124,31 @@ PY | State | Meaning | Safe action | |---|---|---| -| `in_sync` | One exact observable client matches the last verified desired revision | Run or retain live acceptance evidence | -| `drifted` | One client exists but observable metadata or receipt differs | Review drift, then PUT or reconcile | +| `in_sync` | One exact observable client, including the closed mapper profile, matches the last verified desired revision | Run or retain live acceptance evidence | +| `drifted` | One client exists but observable metadata, closed mapper semantics, or receipt differs | Review drift, then PUT or reconcile | | `absent` | Desired state exists but no exact client exists | Reconcile; investigate prior deletion | | `ambiguous` | More than one exact `clientId` exists | Stop mutation; remove duplicate manually with evidence | | `unavailable` | Keycloak observation failed | Restore connectivity; desired state remains durable | | `apply_failed` | Mutation or post-apply verification failed | Inspect bounded error code; repair and reconcile | +## Mapper observation and drift + +Keycloak may add generated mapper IDs or return known mappers in a different +order. Keyverse normalizes only those vendor representation details before +semantic comparison: + +- a generated mapper `id` may be ignored only when it is a valid non-empty + string; +- known mapper identities are sorted into the canonical audience, role, org, + workspace order; +- the remaining mapper shape is revalidated against the same closed product + policy used by preflight. + +An unknown mapper, malformed field/config, duplicate mapper identity, changed +claim value, changed audience, changed token destination, or other semantic +difference is `drifted`. Do not delete unknown live state or broaden the +allowlist merely to make the status green. Establish ownership and intent first. + ## Realm rebuild After Keycloak realm restoration or replacement: @@ -123,7 +165,9 @@ curl --config "$AUTH_CONFIG" \ Every stored key is re-read immediately before its own convergence decision. The operation cannot use a stale value snapshot to recreate a desired record -that was deleted while reconciliation was running. +that was deleted while reconciliation was running. For Naruon, require the +closed mapper profile to be re-observed `in_sync` and then rerun controlled +login and downstream token acceptance before restoring user traffic. ## Duplicate recovery @@ -136,14 +180,15 @@ matches, or delete any client automatically. 3. Stop new login traffic for the RP. 4. Delete or rename the unintended duplicate through an approved change. 5. Re-run GET and require `drifted` or `in_sync`, then reconcile as needed. -6. Run controlled login and rollback tests. +6. Run controlled login, downstream JWT validation, and rollback tests. ## Drift and out-of-band changes A canonical receipt proves that Keyverse once re-observed the exact desired revision. It is not a lease or continuous integrity monitor. If another operator changes Keycloak later, GET reports observable drift when the changed field is -within the closed profile. Reconcile restores the reviewed desired values. +within the closed profile. Reconcile restores only the reviewed desired values. +Unknown live mappers stay fail-closed; they are not silently filtered away. ## Deletion and rollback @@ -155,13 +200,14 @@ both records. Before deletion: - stop user routing; -- capture non-secret client and session evidence; +- capture non-secret client, mapper, and session evidence; - preserve the desired payload digest and rollback artifact; - revoke or escrow confidential credentials through the secret-management port; - confirm an owner and rollback window. Rollback uses the same validate and PUT sequence, followed by fresh credential -placement and live authorization-code acceptance. +placement when applicable, exact mapper/status re-observation, and live +authorization-code plus downstream JWT acceptance. ## What the API never proves @@ -170,9 +216,11 @@ The API does not prove: - DNS or TLS reachability; - successful authorization-code or PKCE exchange; - client-secret existence or equality; -- token audience or mapper correctness outside the closed profile; +- downstream JWT signature, issuer, expiry, token-type, or audience validation; +- authorization correctness for `role`, `org`, or `workspace`; - user/session migration; - absence of out-of-band changes after the observation; - production SLO compliance. -These require separate controlled evidence. +These require separate controlled evidence. Do not interpret `in_sync` or a +successful mapper receipt as authentication or authorization acceptance. From cc22a96312acbe73b4e4cc7c82907d6ea25d131a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 00:23:35 +0900 Subject: [PATCH 28/30] docs(templates): route Naruon RP through desired state --- deploy/templates/README.md | 73 +++++++++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 20 deletions(-) diff --git a/deploy/templates/README.md b/deploy/templates/README.md index c4b3faf..a95b8e5 100644 --- a/deploy/templates/README.md +++ b/deploy/templates/README.md @@ -8,13 +8,16 @@ all `{{placeholders}}` must be resolved from the platform KV before use. | `saml-idp-employer-adfs.json` | Keyverse desired-state API | external IdP → Keyverse | `POST /federation/identity-providers:validate` | `PUT /federation/identity-providers/employer-adfs` | | `oidc-idp-partner.json` | Keyverse desired-state API | external OIDC IdP → Keyverse | `POST /federation/identity-providers:validate` | `PUT /federation/identity-providers/partner-oidc` | | `ldap-source.json` | Keycloak component contract | external directory → Keycloak | `POST /federation/user-directories:validate` | `POST /admin/realms/{realm}/components` | -| `oidc-rp-client.json` | Keyverse RP preflight | Keyverse → RP | `POST /clients/relying-parties:validate` | `POST /admin/realms/{realm}/clients` | +| `oidc-rp-client.json` | Keyverse RP desired-state API | Keyverse → RP | `POST /clients/relying-parties:validate` | `PUT /clients/relying-parties/{client_id}` | +| `oidc-rp-naruon.json` | Keyverse RP desired-state API | Keyverse → Naruon | `POST /clients/relying-parties:validate` | `PUT /clients/relying-parties/naruon-web` | The portable realm contains no employer-specific federation. External SAML and OIDC providers are customer or deployment data stored in the Keyverse KV/DB -desired-state registry and reconciled into Keycloak. LDAP is still applied as a -Keycloak user-storage component in this release, but its rendered payload must -first pass the authenticated Keyverse directory preflight described below. +desired-state registry and reconciled into Keycloak. OIDC relying-party clients +are likewise reconciled through Keyverse desired state rather than applied +straight from a public deployment path. LDAP is still applied as a Keycloak +user-storage component in this release, but its rendered payload must first pass +the authenticated Keyverse directory preflight described below. ## Employer ADFS apply pattern @@ -116,22 +119,24 @@ JWKS, and optional UserInfo endpoints explicitly; runtime discovery import is not accepted. Every network endpoint is HTTPS, token signatures and JWKS retrieval are enabled, PKCE is fixed to `S256`, and `openid` is mandatory. Keep `trust_email=false` until the upstream verification and claim-mapping -contract has been independently reviewed. `oidc-rp-client.json` is a -different artifact: it registers an ecosystem application as an RP of Keyverse. -The rendered payload must pass `POST /clients/relying-parties:validate` before -the deployment controller sends the original private file to Keycloak Admin -REST. See [`../../docs/rp-onboarding.md`](../../docs/rp-onboarding.md). - - -## OIDC relying-party client preflight pattern - -`oidc-rp-client.json` is a closed, secret-free Keycloak client representation. -Render its four placeholders into a private file, call the authenticated -Keyverse `POST /clients/relying-parties:validate` route, require exact HTTP 200 -and `ready_to_apply=true`, then apply the **original rendered file** through the -private Keycloak administration channel. - -The first profile requires authorization code plus PKCE `S256`, exact HTTPS +contract has been independently reviewed. The OIDC RP templates are different +artifacts: they register ecosystem applications as relying parties of Keyverse. +Their rendered payloads pass `POST /clients/relying-parties:validate` and are +then reconciled through the Keyverse RP desired-state `PUT`. See +[`../../docs/rp-onboarding.md`](../../docs/rp-onboarding.md). + +## OIDC relying-party desired-state pattern + +`oidc-rp-client.json` is the generic closed, secret-free Keycloak client +representation. Render its placeholders into a private file, call the +authenticated Keyverse `POST /clients/relying-parties:validate` route, require +exact HTTP 200 and `ready_to_apply=true`, then send the **same original rendered +file** to `PUT /clients/relying-parties/{client_id}`. Require +`convergence_state=in_sync` and `last_apply_receipt_matches=true` after Keyverse +re-observes the live client. Do not apply the representation directly from the +public deployment path to Keycloak Admin REST. + +The base profile requires authorization code plus PKCE `S256`, exact HTTPS redirects and origins, public/confidential authentication consistency, a bounded access-token lifetime, backchannel logout, and exactly the portable `basic`, `profile`, and `email` scopes. Wildcards, `+`, queries, fragments, userinfo, @@ -139,6 +144,34 @@ encoded path delimiters, unresolved placeholders, credential fields, and broad scope expansion fail closed. Preflight performs no client creation, secret generation, KV write, DNS lookup, HTTP request, or Keycloak call. +### Naruon runtime mapper profile + +`oidc-rp-naruon.json` is the reviewed public `naruon-web` runtime artifact. It +adds six deployment placeholders: exact redirect, web-origin, and post-logout +URIs plus bounded `role`, `org`, and `workspace` routing values. The claim values +are visible product routing/authorization data and must not carry credentials, +bearer material, personal secrets, or unreviewed tenant data. + +The template carries this exact mapper order: + +1. `keyverse-audience` using `oidc-audience-mapper`, with + `included.client.audience=naruon-web`; +2. `keyverse-claim-role`; +3. `keyverse-claim-org`; +4. `keyverse-claim-workspace`. + +The three claim entries use only `oidc-hardcoded-claim-mapper`. The closed policy +rejects scripts, user attributes, groups, regex, arbitrary claim names, unknown +mapper types, extra nested fields, and credential material. Keycloak-generated +mapper IDs and vendor return ordering may be normalized for observation, but an +unknown, malformed, duplicate, or semantically changed live mapper is drift. + +Render → preflight → Keyverse desired-state PUT → exact `in_sync` receipt is the +configuration path. It is not authentication or authorization proof. Before +routing users, run controlled authorization-code/PKCE acceptance and verify that +the downstream boundary validates token signature, issuer, expiry, the reviewed +`naruon-web` audience, and expected `role`, `org`, and `workspace` semantics. + ## LDAP and Active Directory preflight pattern `ldap-source.json` is a private Keycloak component payload, not a Keyverse From fbf3cfcfcb415c650e5c5c7f6f688edbd7b139ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 00:25:29 +0900 Subject: [PATCH 29/30] ci: enforce package and deployment-template validation --- .github/workflows/ci.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bfbd0a..2a1eca6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,10 +36,14 @@ jobs: run: uv run ruff check app tests tools - name: Docstrings run: uv run interrogate . + - name: Compile Python sources + run: uv run python -m compileall -q app tests tools - name: Test and enforce production coverage run: | uv run coverage run --branch --source=app -m pytest -q uv run coverage report --show-missing --fail-under=100 + - name: Build distribution + run: uv build --out-dir dist realm-config-validates: runs-on: ubuntu-latest @@ -50,6 +54,15 @@ jobs: python-version: "3.12" - name: Validate Keycloak realm config-as-code run: python scripts/validate_realm.py deploy/keycloak/realm-cwl.json + - name: Validate deployment template JSON + run: | + python - <<'PY' + import json + from pathlib import Path + + for path in sorted(Path("deploy/templates").glob("*.json")): + json.loads(path.read_text(encoding="utf-8")) + PY compose-config-validates: runs-on: ubuntu-latest From 6f87299b33a65c3d5d09736e5499ec6aef6dadea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 00:26:46 +0900 Subject: [PATCH 30/30] docs(ci): align exact-head completion gates --- CLAUDE.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bab58e6..f53ebe3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,8 +43,10 @@ Per-service commands matching CI, from `services/account_unification/`: uv sync --locked --extra dev uv run ruff check app tests tools uv run interrogate . +uv run python -m compileall -q app tests tools uv run coverage run --branch --source=app -m pytest -q uv run coverage report --show-missing --fail-under=100 +uv build --out-dir dist uv run pytest tests/test_directory_federation_preflight.py -q ``` @@ -59,13 +61,15 @@ uvicorn app.main:app --port 8099 ## CI gates (`.github/workflows/ci.yml`) 1. **account-unification-tests** — locked dependencies, Ruff, 100% interrogate - docstring coverage, complete pytest, and 100% production statement and branch - coverage on Python 3.12. -2. **realm-config-validates** — validates the portable realm export. The bound - browser flow must contain WebAuthn passwordless and no password - authenticator; registration and reset-password remain off; no external IdP or - user-storage federation may be committed; public RP access-token lifetime is - bounded; real client secrets are forbidden. + docstring coverage, Python compilation, complete pytest, 100% production + statement and branch coverage, and a clean `uv build` distribution on Python + 3.12. +2. **realm-config-validates** — validates the portable realm export and parses + every committed deployment-template JSON artifact. The bound browser flow + must contain WebAuthn passwordless and no password authenticator; + registration and reset-password remain off; no external IdP or user-storage + federation may be committed; public RP access-token lifetime is bounded; real + client secrets are forbidden. 3. **compose-config-validates** — validates `docker-compose.yml` with placeholder bootstrap passwords.