Skip to content
Open
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
33bb930
docs(spec): define closed OIDC RP mapper profile
seonghobae Aug 7, 2026
0e9a400
docs(plan): plan closed OIDC RP mapper profile
seonghobae Aug 7, 2026
6985904
test(clients): specify closed RP claim mapper profile
seonghobae Aug 7, 2026
7f22961
feat(clients): validate a closed RP audience and claim profile
seonghobae Aug 7, 2026
24f469b
fix(clients): retain the readiness flag in sparse responses
seonghobae Aug 7, 2026
b6b61cf
test(clients): cover every closed RP mapper boundary
seonghobae Aug 7, 2026
639dcae
test(clients): expose HTTP mapper policy bypass
seonghobae Aug 7, 2026
ebd3cf0
ci: verify RP endpoint policy repair
seonghobae Aug 7, 2026
60b8ad8
ci: repair RP endpoint policy patcher
seonghobae Aug 7, 2026
2e5c484
fix(clients): enforce mapper policy at HTTP preflight
github-actions[bot] Aug 7, 2026
2d76b25
test(clients): expose mapper observation normalization gaps
seonghobae Aug 7, 2026
f589bd8
ci: run RP mapper normalization red-green cycle
seonghobae Aug 7, 2026
540f435
ci: stage RP mapper normalization patcher
seonghobae Aug 7, 2026
1d18e5c
ci: repair RP mapper normalization workflow
seonghobae Aug 7, 2026
cb25dba
test(clients): cover mapper normalization failure boundaries
seonghobae Aug 7, 2026
b481cde
ci: align mapper patcher with reachable branches
seonghobae Aug 7, 2026
ec3b59c
ci: rerun RP mapper normalization verification
seonghobae Aug 7, 2026
9b60846
fix(clients): normalize observed RP mapper state
github-actions[bot] Aug 7, 2026
5aaf959
test(clients): require shipped Naruon RP claim template
seonghobae Aug 8, 2026
2a379d3
feat(clients): ship Naruon RP claim template
seonghobae Aug 8, 2026
822655f
docs(clients): doctor closed RP mapper evidence
seonghobae Aug 8, 2026
d77a3e4
docs(changelog): record closed RP mapper profile
seonghobae Aug 8, 2026
80b7938
docs(architecture): define closed RP mapper boundary
seonghobae Aug 8, 2026
07d87ae
docs(agents): preserve closed RP mapper contract
seonghobae Aug 8, 2026
9ff28c3
docs(agents): document RP mapper implementation rules
seonghobae Aug 8, 2026
eb5da5b
docs(clients): document Naruon mapper onboarding
seonghobae Aug 8, 2026
f429fe3
docs(operations): cover Naruon mapper reconciliation
seonghobae Aug 8, 2026
7639b7e
docs(templates): route Naruon RP through desired state
seonghobae Aug 8, 2026
31047c3
ci: enforce package and deployment-template validation
seonghobae Aug 8, 2026
91a3a71
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.

## Account and provisioning invariants

Expand All @@ -176,6 +189,9 @@ remain separate reviewed profiles.
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ Keep a Changelog, and releases use semantic versioning.

### Added

- 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