Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c69b08b
docs(spec): define closed OIDC RP mapper profile
seonghobae Aug 7, 2026
87c4137
docs(plan): plan closed OIDC RP mapper profile
seonghobae Aug 7, 2026
3d1dc17
test(clients): specify closed RP claim mapper profile
seonghobae Aug 7, 2026
a9bdbb1
feat(clients): validate a closed RP audience and claim profile
seonghobae Aug 7, 2026
5fee589
fix(clients): retain the readiness flag in sparse responses
seonghobae Aug 7, 2026
2193eb5
test(clients): cover every closed RP mapper boundary
seonghobae Aug 7, 2026
01fbb3c
test(clients): expose HTTP mapper policy bypass
seonghobae Aug 7, 2026
32c9e98
ci: verify RP endpoint policy repair
seonghobae Aug 7, 2026
c85ef29
ci: repair RP endpoint policy patcher
seonghobae Aug 7, 2026
6298f12
fix(clients): enforce mapper policy at HTTP preflight
github-actions[bot] Aug 7, 2026
accb545
test(clients): expose mapper observation normalization gaps
seonghobae Aug 7, 2026
40ecfd7
ci: run RP mapper normalization red-green cycle
seonghobae Aug 7, 2026
797c467
ci: stage RP mapper normalization patcher
seonghobae Aug 7, 2026
afb9dc6
ci: repair RP mapper normalization workflow
seonghobae Aug 7, 2026
de6aace
test(clients): cover mapper normalization failure boundaries
seonghobae Aug 7, 2026
57da6e4
ci: align mapper patcher with reachable branches
seonghobae Aug 7, 2026
5edd068
ci: rerun RP mapper normalization verification
seonghobae Aug 7, 2026
5d01511
fix(clients): normalize observed RP mapper state
github-actions[bot] Aug 7, 2026
842c762
test(clients): require shipped Naruon RP claim template
seonghobae Aug 8, 2026
2c3f2c1
feat(clients): ship Naruon RP claim template
seonghobae Aug 8, 2026
236d6ed
docs(clients): doctor closed RP mapper evidence
seonghobae Aug 8, 2026
13281a8
docs(changelog): record closed RP mapper profile
seonghobae Aug 8, 2026
df907ab
docs(architecture): define closed RP mapper boundary
seonghobae Aug 8, 2026
6219c8c
docs(agents): preserve closed RP mapper contract
seonghobae Aug 8, 2026
468d729
docs(agents): document RP mapper implementation rules
seonghobae Aug 8, 2026
b09dc69
docs(clients): document Naruon mapper onboarding
seonghobae Aug 8, 2026
73c48e3
docs(operations): cover Naruon mapper reconciliation
seonghobae Aug 8, 2026
cc22a96
docs(templates): route Naruon RP through desired state
seonghobae Aug 8, 2026
fbf3cfc
ci: enforce package and deployment-template validation
seonghobae Aug 8, 2026
6f87299
docs(ci): align exact-head completion gates
seonghobae Aug 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
14 changes: 13 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 28 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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.

Expand All @@ -89,7 +93,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
Expand All @@ -104,9 +109,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
Expand All @@ -132,6 +139,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
Expand Down
73 changes: 53 additions & 20 deletions deploy/templates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -116,29 +119,59 @@ 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,
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
Expand Down
82 changes: 82 additions & 0 deletions deploy/templates/oidc-rp-naruon.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
}
Loading
Loading