diff --git a/CHANGELOG.md b/CHANGELOG.md index 5462dfb..b95ed89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,10 @@ Keep a Changelog, and releases use semantic versioning. ### Changed +- The portable Keycloak realm no longer embeds `ecosystem-rp-template` or + `naruon-web`; application RPs now exist only as validated runtime desired + state. Realm validation rejects application clients, and clean-realm recovery + is covered through the committed Naruon template and reconciliation path. - Relying-party deployment controllers now send validated, secret-free metadata to Keyverse desired-state PUT instead of applying client representations directly to Keycloak; confidential credential placement remains a separate diff --git a/README.md b/README.md index 5e24601..3aa6a95 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ network diagram: [`docs/topology.md`](docs/topology.md). | Path | What | | --- | --- | | `docker-compose.yml` | Standalone bring-up: Keycloak + Postgres + admin service (pinned by digest) | -| `deploy/keycloak/` | Portable Keycloak realm config-as-code, passwordless flows, shared scopes, concrete Naruon RP, and service-account bootstrap | +| `deploy/keycloak/` | Portable Keycloak realm config-as-code, passwordless flows, shared scopes, and service-account bootstrap; application RPs are runtime desired state | | `deploy/templates/` | Private deployment templates split between Keyverse preflight/desired state and explicit Keycloak Admin REST apply contracts | | `deploy/bootstrap/` | Bootstrap pointer to the KV/DB config store | | `deploy/scripts/healthz.sh` | Cross-component readiness probe | diff --git a/deploy/keycloak/README.md b/deploy/keycloak/README.md index feaf209..edae0cc 100644 --- a/deploy/keycloak/README.md +++ b/deploy/keycloak/README.md @@ -6,7 +6,7 @@ providers are converged afterwards from the KV/DB source of truth. | File | Responsibility | | --- | --- | -| `realm-cwl.json` | Portable passwordless realm, shared client scopes, RP template, concrete `naruon-web` PKCE client, and account-unification service client | +| `realm-cwl.json` | Portable passwordless realm, shared client scopes, and the account-unification control-plane service client; no application RP clients | | `kcadm-bootstrap.sh` | Idempotently inject the service-client secret, grant least-privilege realm-management roles, and reconcile the role mapper | | `../templates/` | Reference payloads for runtime federation and additional relying-party registrations | @@ -36,11 +36,12 @@ See [`../../docs/passwordless-policy.md`](../../docs/passwordless-policy.md). ## Portable realm versus deployment data -The committed realm contains no employer ADFS, LDAP/AD source, or other external -federation. Those objects are customer/deployment data and are managed through -`/federation/identity-providers`. Desired state is stored in the KV/DB backend -and can be reapplied after a realm rebuild with -`POST /federation/identity-providers:apply`. +The committed realm contains no employer ADFS, LDAP/AD source, external +federation, reusable RP template, or application RP. Those objects are +customer/deployment data. Federation is managed through +`/federation/identity-providers`; application clients are managed through +`/clients/relying-parties`. Desired state is stored in the KV/DB backend and can +be reapplied after a realm rebuild through the respective reconciliation route. This separation also avoids Keycloak 26 import failures from placeholder SAML URLs or invalid placeholder LDAP distinguished names. @@ -54,21 +55,19 @@ URLs or invalid placeholder LDAP distinguished names. - no password authenticator in any subflow reachable from `browserFlow`; - `webauthn-register-passwordless` remains enabled; - `basic`, `profile`, and `email` scopes exist, with `basic` providing `sub`; -- public `naruon-web` requires PKCE S256 and an access-token lifespan no greater - than 900 seconds; +- runtime application clients are rejected from the portable import; +- the `account-unification-svc` control-plane client remains present; - committed client secrets are placeholders only. ## RP clients -`ecosystem-rp-template` is a confidential PKCE S256 blueprint. It uses the -reserved `rp.example.invalid` host so no product-specific deployment value is -silently inherited. Clones must replace redirect/origin values, client ID, -secret, and audience mapper together. - -`naruon-web` is the first concrete public PKCE client. It carries the audience -and `role`/`org`/`workspace` claims required by the current Naruon session -contract. Its access tokens last 300 seconds; the longer SSO session is serviced -through normal token refresh/reissue rather than a twelve-hour bearer token. +`deploy/templates/oidc-rp-client.json` is the confidential PKCE S256 blueprint; +`deploy/templates/oidc-rp-naruon.json` is the concrete public Naruon profile. +Neither is imported with the realm. Render and preflight the chosen template, +persist it through Keyverse desired state, reconcile it into Keycloak, place any +confidential secret through the separate secret channel, and only then route +login traffic. The Naruon profile carries the reviewed audience and bounded +`role`/`org`/`workspace` claims and retains a 300-second access-token lifetime. ## Bootstrap diff --git a/deploy/keycloak/realm-cwl.json b/deploy/keycloak/realm-cwl.json index 8d2018f..57b579a 100644 --- a/deploy/keycloak/realm-cwl.json +++ b/deploy/keycloak/realm-cwl.json @@ -250,136 +250,6 @@ ], "defaultOptionalClientScopes": [], "clients": [ - { - "clientId": "ecosystem-rp-template", - "name": "Ecosystem RP template", - "enabled": true, - "protocol": "openid-connect", - "publicClient": false, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "secret": "__set_from_kv__", - "redirectUris": [ - "https://rp.example.invalid/auth/callback" - ], - "webOrigins": [ - "+" - ], - "defaultClientScopes": [ - "basic", - "profile", - "email" - ], - "optionalClientScopes": [], - "attributes": { - "pkce.code.challenge.method": "S256", - "post.logout.redirect.uris": "https://rp.example.invalid/", - "access.token.lifespan": "300" - }, - "protocolMappers": [ - { - "name": "audience", - "protocol": "openid-connect", - "protocolMapper": "oidc-audience-mapper", - "consentRequired": false, - "config": { - "included.client.audience": "ecosystem-rp-template", - "access.token.claim": "true", - "id.token.claim": "false", - "introspection.token.claim": "true" - } - } - ], - "fullScopeAllowed": false - }, - { - "clientId": "naruon-web", - "name": "Naruon web client", - "enabled": true, - "protocol": "openid-connect", - "publicClient": true, - "standardFlowEnabled": true, - "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, - "redirectUris": [ - "https://naruon.example/auth/callback", - "https://naruon.example/auth/passkey-complete" - ], - "webOrigins": [ - "https://naruon.example" - ], - "defaultClientScopes": [ - "basic", - "profile", - "email" - ], - "optionalClientScopes": [], - "attributes": { - "pkce.code.challenge.method": "S256", - "post.logout.redirect.uris": "https://naruon.example/", - "access.token.lifespan": "300" - }, - "protocolMappers": [ - { - "name": "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": "naruon-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", - "introspection.token.claim": "true" - } - }, - { - "name": "naruon-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", - "introspection.token.claim": "true" - } - }, - { - "name": "naruon-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", - "introspection.token.claim": "true" - } - } - ], - "fullScopeAllowed": false - }, { "clientId": "account-unification-svc", "name": "Account unification service", diff --git a/deploy/templates/README.md b/deploy/templates/README.md index a95b8e5..80239e3 100644 --- a/deploy/templates/README.md +++ b/deploy/templates/README.md @@ -11,7 +11,8 @@ all `{{placeholders}}` must be resolved from the platform KV before use. | `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 +The portable realm contains no employer-specific federation or application +relying-party client. External SAML and OIDC providers are customer or deployment data stored in the Keyverse KV/DB desired-state registry and reconciled into Keycloak. OIDC relying-party clients are likewise reconciled through Keyverse desired state rather than applied diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index f215903..970b571 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -66,6 +66,11 @@ must test the **Naruon** product login/token/authorization journey using the `naruon-web` RP client ID and verify the expected audience and bounded claims. Mapper unit tests alone do not prove Naruon product authorization readiness. +After a clean realm import, bootstrap `account-unification-svc`, reconcile the +rendered runtime RP desired state, place confidential credentials where +applicable, and only then run controlled login acceptance or route traffic. The +portable realm intentionally creates no application RP as a fallback. + ## Account merge recovery Merge and SCIM full replacement (`PUT`) must hold the shared operation lock. Protected-main `PATCH active=false` is not currently inside that shared-lock guarantee and must not be treated as transactionally serialized with merge. On failure, classify whether state changed in Keycloak, Keyverse audit, linked identities, or tombstone status. Re-observe before retry. Never infer a retry is safe solely from the previous HTTP response. Preserve survivor and duplicate lineage in audit. diff --git a/docs/adr/0008-keyverse-rp-authorization-boundary.md b/docs/adr/0008-keyverse-rp-authorization-boundary.md index bedd332..68db465 100644 --- a/docs/adr/0008-keyverse-rp-authorization-boundary.md +++ b/docs/adr/0008-keyverse-rp-authorization-boundary.md @@ -39,10 +39,11 @@ The snapshot is reproducible from the Keyverse README at immutable revision Keyverse itself also has two boundaries that must not be confused with downstream application authorization: -- the portable realm currently contains the reviewed `naruon-web` claim shape - (`role`, `org`, and `workspace`), but those values are deployment/profile - data and do not grant privilege by themselves; user/tenant role derivation - and downstream authorization remain separate acceptance obligations; +- the reviewed runtime `naruon-web` desired-state template carries the bounded + `role`, `org`, and `workspace` claim shape, but those values are deployment + data and do not grant privilege by themselves; the portable realm contains + no application RP, and user/tenant role derivation plus downstream + authorization remain separate acceptance obligations; - the account-unification inbound admin/SCIM surface currently has one deployment-owned operator bearer gate. That is a coarse service boundary, not per-operation RBAC or ABAC. Multi-operator production use requires @@ -70,7 +71,7 @@ downstream application authorization: bindings deny access; - `role` is optional at token-validation level, but an RP that makes an RBAC decision must require a recognized role and treat a missing or unknown - role as no privilege. The current portable `naruon-web` profile allows + role as no privilege. The current runtime `naruon-web` profile allows only `member`; any new role value requires a separately reviewed mapper profile, an exact issuer-side test, and downstream elevation/downgrade tests; diff --git a/docs/doctoring/oidc-rp-claim-mapper-profile.md b/docs/doctoring/oidc-rp-claim-mapper-profile.md index fe5389e..65fd388 100644 --- a/docs/doctoring/oidc-rp-claim-mapper-profile.md +++ b/docs/doctoring/oidc-rp-claim-mapper-profile.md @@ -115,13 +115,16 @@ RED receipt before the template was added. ## 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. +The portable realm preserves only the `account-unification-svc` control-plane +client and contains no runtime application RP clients. A deterministic +clean-realm regression proves the committed Naruon template can recreate one +exact in-sync application client, including its closed mapper profile, through +Keyverse desired state. This does not prove a live authorization-code/PKCE exchange, +downstream audience acceptance, user/session migration, or hosted Keycloak +restore; those remain runtime evidence boundaries. 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 diff --git a/docs/passwordless-policy.md b/docs/passwordless-policy.md index e33c2c3..f26e575 100644 --- a/docs/passwordless-policy.md +++ b/docs/passwordless-policy.md @@ -20,12 +20,14 @@ password to phish, reuse, reset, or leak. | `resetPasswordAllowed` | `false` | No password-reset surface exists | | `requiredActions[webauthn-register-passwordless]` | enabled | Keycloak can execute the passkey enrollment action | | `webAuthnPolicyPasswordless*` | resident key and user verification required | Passkeys are discoverable and user-verified | -| `naruon-web.attributes[access.token.lifespan]` | `300` seconds | Public-client bearer exposure is bounded independently of the longer SSO session | `scripts/validate_realm.py` follows every nested subflow reachable from `browserFlow` and fails CI if it finds `auth-password-form`, -`auth-username-password-form`, or another password authenticator. It also caps -public `naruon-web` access tokens at 900 seconds. +`auth-username-password-form`, or another password authenticator. It also +rejects application RP clients from the portable realm. The runtime +`deploy/templates/oidc-rp-naruon.json` profile independently fixes the public +client access-token lifetime at 300 seconds and is validated by the same closed +Keyverse RP preflight used during deployment. ## Password-free headless registration diff --git a/docs/rp-onboarding.md b/docs/rp-onboarding.md index 854f3ed..fc7244f 100644 --- a/docs/rp-onboarding.md +++ b/docs/rp-onboarding.md @@ -147,6 +147,12 @@ validates the resulting tokens. ## Desired-state lifecycle +After a clean realm import, bootstrap the account-unification service first, +then validate and reconcile each runtime RP, place a confidential client secret +through the separate approved channel where applicable, and finally perform +controlled login acceptance before routing users. The portable realm does not +pre-create an application RP. + The authenticated surface is: ```text diff --git a/scripts/validate_realm.py b/scripts/validate_realm.py index db6e6c5..bfb942a 100644 --- a/scripts/validate_realm.py +++ b/scripts/validate_realm.py @@ -8,10 +8,10 @@ * the bound browser flow contains WebAuthn passwordless and no password form; * self-service password registration and reset are disabled; * external federation remains runtime desired state, not committed realm code; -* RP and service-account clients exist without committed real secrets; +* runtime application RPs are absent and the control-plane service client exists; * Keycloak 26 import compatibility excludes ``$`` annotation keys; * the ``basic`` scope provides ``sub`` and is a realm default; -* ``naruon-web`` is a bounded-token public PKCE client with required claims. +* runtime application clients cannot bypass Keyverse desired-state recovery. Usage: python scripts/validate_realm.py [path-to-realm.json] Exit 0 = valid, 1 = invalid (prints the failing checks). @@ -30,7 +30,6 @@ } PASSKEY_AUTHENTICATOR = f"webauthn-authenticator-{_CREDENTIAL_FACTOR}less" SECRET_PLACEHOLDER = "__set_from_kv__" -MAX_PUBLIC_TOKEN_LIFESPAN = 900 def _executions(realm: dict, alias: str) -> list[dict]: @@ -61,18 +60,6 @@ def _all_authenticators( return found -def _public_token_lifespan(client: dict) -> int | None: - """Parse one optional client access-token lifespan as a positive integer.""" - raw_value = client.get("attributes", {}).get("access.token.lifespan") - if raw_value is None: - return None - try: - value = int(raw_value) - except (TypeError, ValueError): - return -1 - return value if str(value) == str(raw_value).strip() else -1 - - def validate(realm: dict) -> list[str]: """Return human-readable policy violations for a realm export.""" errors: list[str] = [] @@ -140,15 +127,20 @@ def validate(realm: dict) -> list[str]: "sources at runtime via the federation registry API" ) - clients = {client.get("clientId"): client for client in realm.get("clients", [])} - if "ecosystem-rp-template" not in clients: - errors.append("OIDC RP client template 'ecosystem-rp-template' is missing") - else: - rp = clients["ecosystem-rp-template"] - if rp.get("implicitFlowEnabled", False): - errors.append("RP template must not enable the implicit flow (OAuth 2.1)") - if rp.get("attributes", {}).get("pkce.code.challenge.method") != "S256": - errors.append("RP template must require PKCE S256") + client_list = realm.get("clients", []) + clients = {client.get("clientId"): client for client in client_list} + for client in client_list: + client_id = client.get("clientId") + if client_id in {"ecosystem-rp-template", "naruon-web"}: + errors.append( + f"runtime application client '{client_id}' must be reconciled " + "through Keyverse desired state, not committed in the realm" + ) + elif client_id != "account-unification-svc": + errors.append( + "portable realm may contain only the account-unification-svc " + "control-plane client" + ) service_client = clients.get("account-unification-svc") if service_client is None: @@ -178,45 +170,6 @@ def validate(realm: dict) -> list[str]: if "basic" not in realm.get("defaultDefaultClientScopes", []): errors.append("'basic' must be a realm default client scope") - naruon = clients.get("naruon-web") - if naruon is None: - errors.append("concrete RP client 'naruon-web' is missing") - else: - if not naruon.get("publicClient", False): - errors.append("naruon-web must be a public (PKCE) client") - if naruon.get("implicitFlowEnabled", False): - errors.append("naruon-web must not enable the implicit flow") - if naruon.get("attributes", {}).get("pkce.code.challenge.method") != "S256": - errors.append("naruon-web must require PKCE S256") - token_lifespan = _public_token_lifespan(naruon) - if ( - token_lifespan is not None - and not 0 < token_lifespan <= MAX_PUBLIC_TOKEN_LIFESPAN - ): - errors.append( - "naruon-web access.token.lifespan must be an integer at or below " - f"{MAX_PUBLIC_TOKEN_LIFESPAN} seconds" - ) - naruon_mappers = { - mapper.get("protocolMapper") - for mapper in naruon.get("protocolMappers", []) - } - if "oidc-audience-mapper" not in naruon_mappers: - errors.append("naruon-web must include an audience mapper") - hardcoded_claims = { - mapper.get("config", {}).get("claim.name") - for mapper in naruon.get("protocolMappers", []) - if mapper.get("protocolMapper") == "oidc-hardcoded-claim-mapper" - } - for claim_name in ("role", "org", "workspace"): - if claim_name not in hardcoded_claims: - errors.append( - f"naruon-web must carry the hardcoded '{claim_name}' claim " - "naruon's session contract requires" - ) - if "basic" not in naruon.get("defaultClientScopes", []): - errors.append("naruon-web must assign the 'basic' default scope") - return errors diff --git a/services/account_unification/tests/test_realm_policy.py b/services/account_unification/tests/test_realm_policy.py index 83d03b2..0a89aaf 100644 --- a/services/account_unification/tests/test_realm_policy.py +++ b/services/account_unification/tests/test_realm_policy.py @@ -70,19 +70,33 @@ def test_bound_browser_flow_rejects_password_authenticator() -> None: assert any("disallowed credential-form authenticator" in error for error in errors) -def test_public_client_token_lifespan_is_bounded() -> None: - """A public browser client cannot issue long-lived bearer access tokens.""" +def test_portable_realm_contains_only_the_control_plane_service_client() -> None: + """Runtime application relying parties are absent from portable import.""" + realm = _realm() + + assert {client.get("clientId") for client in realm["clients"]} == { + "account-unification-svc" + } + assert realm["defaultDefaultClientScopes"] == ["basic", "profile", "email"] + + +def test_validator_rejects_runtime_application_clients() -> None: + """A realm export cannot bypass Keyverse desired-state reconciliation.""" validator = _validator_module() realm = deepcopy(_realm()) - _client(realm, "naruon-web")["attributes"]["access.token.lifespan"] = "901" + realm["clients"].extend( + [ + {"clientId": "ecosystem-rp-template"}, + {"clientId": "naruon-web", "publicClient": True}, + {"clientId": "unmanaged-web", "publicClient": True}, + ] + ) errors = validator.validate(realm) - assert any("access.token.lifespan" in error for error in errors) - - -def test_reusable_client_template_does_not_name_naruon_host() -> None: - """The generic RP template stays portable across ecosystem products.""" - template = _client(_realm(), "ecosystem-rp-template") - serialized = json.dumps(template, sort_keys=True) - assert "naruon.example" not in serialized + assert any("runtime application client 'ecosystem-rp-template'" in error for error in errors) + assert any("runtime application client 'naruon-web'" in error for error in errors) + assert any( + "portable realm may contain only the account-unification-svc" in error + for error in errors + ) 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 4db2709..240cb85 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,10 @@ """OIDC relying-party mapper observation and reconciliation regressions.""" from __future__ import annotations +import json from collections.abc import Callable from copy import deepcopy +from pathlib import Path import pytest @@ -15,6 +17,7 @@ ) from .test_relying_party_claim_mappers import _naruon_registration_with_mappers +from .test_relying_party_template import _render_naruon_template def _registration(role_value: str = "member"): @@ -60,6 +63,28 @@ def test_generated_mapper_ids_and_vendor_order_do_not_create_false_drift(api) -> assert status.last_apply_receipt_matches is True +def test_clean_realm_recovers_naruon_through_desired_state(api) -> None: + """Portable import plus runtime template recreates Naruon without embedded RPs.""" + repository_root = Path(__file__).resolve().parents[3] + realm = json.loads( + (repository_root / "deploy" / "keycloak" / "realm-cwl.json").read_text( + encoding="utf-8" + ) + ) + assert {client["clientId"] for client in realm["clients"]} == { + "account-unification-svc" + } + payload = _render_naruon_template() + registration = parse_relying_party_registration(payload) + service = RelyingPartyService(InMemoryKvStore(), api) + + status = service.put_registration("naruon-web", registration) + + assert status.convergence_state is RelyingPartyConvergenceState.IN_SYNC + assert status.last_apply_receipt_matches is True + assert len(api.relying_party_clients) == 1 + + def _remove_mapper_field(client: dict) -> None: """Remove the whole optional mapper collection from the live client.""" client.pop("protocolMappers")