From d3f83cf891ab82d6485a1d1968abdeb89fd38946 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 21 Jul 2026 13:58:29 +0900 Subject: [PATCH 001/104] fix: make the realm importable on Keycloak 26 and onboard naruon as first RP Real-world bring-up on Keycloak 26.3.2 surfaced four import/runtime failures in the committed realm, each reproduced and fixed: - RealmRepresentation rejects unknown fields, so the $-prefixed annotation keys aborted --import-realm and crash-looped the container. Annotations moved to deploy/keycloak/README.md; the validator now fails on any $ key. - SAML IdP URLs are URL-validated at import: the bare __set_from_kv__ placeholder aborted the import. Placeholders are now URL-shaped (https://set-from-kv.invalid/__set_from_kv__) and still patched from KV. - An ENABLED committed LDAP source with placeholder DNs breaks every realm user operation (Invalid DN). The committed source now ships disabled and kcadm-bootstrap.sh enables it only after patching real values from KV. - The default Infinispan jdbc-ping stack crash-loops single-node compose restarts (each aborted boot leaves a stale jgroups_ping coordinator row the next boot fatally tries to join). The standalone compose now defaults to KC_CACHE=local via IDP_CACHE_MODE (clustered deployments set ispn). Imported realms also lack the standard client scopes, and without `basic` Keycloak 26 lightweight access tokens omit `sub`, breaking any RP that authenticates by subject. The realm now commits basic/profile/email scopes as realm defaults, adds an audience mapper to the RP template, and registers naruon-web as the first concrete RP (public PKCE S256 client with the sub/aud/role/org/workspace claims naruon's session contract requires). Verified: scripts/validate_realm.py passes; the realm imports cleanly into quay.io/keycloak/keycloak:26.3.2 with no sanitization; a live naruon authorization-code login (PKCE, RS256, JWKS) established a backend session end to end. Co-Authored-By: Claude Fable 5 --- .env.example | 3 + deploy/keycloak/README.md | 43 +++++ deploy/keycloak/kcadm-bootstrap.sh | 6 +- deploy/keycloak/realm-cwl.json | 271 +++++++++++++++++++++++------ docker-compose.yml | 6 + scripts/validate_realm.py | 100 ++++++++++- 6 files changed, 374 insertions(+), 55 deletions(-) diff --git a/.env.example b/.env.example index 7e390cc..6d2a6b0 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,9 @@ IDP_DB_PASSWORD= # from KV: secret/idp/db IDP_EXTERNAL_PORT=8080 # Public base URL / hostname Keycloak advertises (behind the WAF in prod). IDP_EXTERNAL_HOSTNAME=http://localhost:8080 +# Cache stack: `local` (default; single-node standalone compose) or `ispn` +# for clustered deployments. See the KC_CACHE note in docker-compose.yml. +IDP_CACHE_MODE=local # Bootstrap admin. Created ONCE; retire after registering a passkey. IDP_BOOTSTRAP_ADMIN_USERNAME=idp-admin diff --git a/deploy/keycloak/README.md b/deploy/keycloak/README.md index ba018a8..7d54cc0 100644 --- a/deploy/keycloak/README.md +++ b/deploy/keycloak/README.md @@ -41,3 +41,46 @@ KC_SERVER=http://localhost:8080 deploy/keycloak/kcadm-bootstrap.sh Additional Admin-API request bodies for registering more RPs / IdPs live in [`../templates/`](../templates/) (Keycloak client / SAML IdP / LDAP component representations). + +## Realm-file rules Keycloak 26 enforces + +The realm JSON is parsed into typed representations, so two patterns that used +to live in this file are **import failures** on Keycloak 26 and must not come +back (`scripts/validate_realm.py` guards both): + +- **No `$`-prefixed annotation keys.** `RealmRepresentation` rejects unknown + fields (`Unrecognized field "$comment"`), which aborts `--import-realm` and + crash-loops the container. Document intent in this README instead of inline + JSON annotations. +- **Placeholder URLs must still parse as URLs.** SAML IdP fields such as + `singleSignOnServiceUrl` are URL-validated at import; a bare + `__set_from_kv__` string aborts the import. Committed placeholders use the + reserved host form `https://set-from-kv.invalid/__set_from_kv__` + (`ldaps://set-from-kv.invalid:636` for LDAP) and are replaced from KV by + `kcadm-bootstrap.sh` exactly as before. + +## Client scopes and the Keycloak 26 lightweight-token pitfall + +Imported realms do **not** get the standard client scopes auto-created, and +without the `basic` scope Keycloak 26 access tokens omit the `sub` claim — +which breaks any RP that authenticates by subject (naruon returns 401 for +every request). The realm therefore commits `basic` (Subject + auth_time), +`profile`, and `email` scopes and assigns them as realm defaults plus explicit +`defaultClientScopes` on each RP client. + +## RP clients: template + naruon + +`ecosystem-rp-template` stays the confidential-client blueprint (OAuth 2.1: +code + PKCE S256, no implicit, exact redirect URIs, secret from KV). Clones +must rename the audience mapper's `included.client.audience` to the new +clientId. + +`naruon-web` is the first concrete RP, committed as-code as a **public** PKCE +client because the naruon browser flow cannot hold a client secret. It carries +the claims naruon's backend session contract requires: `sub` (via `basic`), +an `aud` containing `naruon-web`, and hardcoded `role=member` / +`org` / `workspace` claims. The committed `org`/`workspace` values +(`org-cwl` / `workspace-org-cwl`) and the `https://naruon.example` redirect +URIs are deployment placeholders — patch them per environment with `kcadm.sh` +(see `../templates/`). `access.token.lifespan` is 43200s to fit naruon's 12h +session ceiling; admin roles are never asserted from IdP claims by design. diff --git a/deploy/keycloak/kcadm-bootstrap.sh b/deploy/keycloak/kcadm-bootstrap.sh index c7eaae4..b04da02 100755 --- a/deploy/keycloak/kcadm-bootstrap.sh +++ b/deploy/keycloak/kcadm-bootstrap.sh @@ -33,13 +33,17 @@ kcadm.sh update "identity-provider/instances/employer-adfs" -r "${REALM}" \ -s "config.useMetadataDescriptorUrl=true" echo "==> patching corp-ldap bind credential + connection from KV" +# The committed component ships DISABLED with placeholder DNs: an enabled LDAP +# source with an unparsable DN breaks every realm user operation (Invalid DN), +# so it only turns on here, after the real connection values are applied. LDAP_COMPONENT_ID="$(kcadm.sh get components -r "${REALM}" \ --query 'name=corp-ldap' --fields id --format csv --noquotes | head -n1)" kcadm.sh update "components/${LDAP_COMPONENT_ID}" -r "${REALM}" \ -s "config.connectionUrl=[\"$(kv get config/idp/ldap-connection-url)\"]" \ -s "config.usersDn=[\"$(kv get config/idp/ldap-users-dn)\"]" \ -s "config.bindDn=[\"$(kv get secret/idp/ldap-bind-dn)\"]" \ - -s "config.bindCredential=[\"$(kv get secret/idp/ldap-bind-password)\"]" + -s "config.bindCredential=[\"$(kv get secret/idp/ldap-bind-password)\"]" \ + -s 'config.enabled=["true"]' echo "==> patching account-unification-svc client secret from KV" SVC_CLIENT_UUID="$(kcadm.sh get clients -r "${REALM}" \ diff --git a/deploy/keycloak/realm-cwl.json b/deploy/keycloak/realm-cwl.json index fa5a703..01dd3d4 100644 --- a/deploy/keycloak/realm-cwl.json +++ b/deploy/keycloak/realm-cwl.json @@ -1,31 +1,4 @@ { - "$comment": [ - "cwl-idp realm config-as-code, imported by Keycloak at start via", - "`start --import-realm` (mounted at /opt/keycloak/data/import).", - "", - "It encodes the ecosystem policy declaratively:", - " - passwordless-first: a passkey-first browser flow (auth-username-form +", - " webauthn-authenticator-passwordless), NO password authenticator; the", - " realm's browserFlow is set to it and password registration/reset are off.", - " - an OIDC/OAuth2.1 confidential client TEMPLATE for ecosystem RPs (PKCE,", - " authorization-code + refresh, no implicit grant).", - " - the employer ADFS registered as an INBOUND SAML identity provider with", - " trustEmail=true so first-login auto-links by VERIFIED email + JIT.", - " - an LDAP/AD user-federation source (bindCredential is a placeholder that", - " the kcadm bootstrap patches from KV -- never committed).", - " - a confidential service-account client for the account-unification +", - " SCIM shim service (holds realm-management view-users/manage-users).", - "", - "SCIM inbound provisioning is served by the account-unification service's", - "SCIM v2 shim (services/account_unification/app/scim.py), which provisions", - "into this realm via the Admin REST API -- Keycloak's native SCIM is", - "experimental and the mature plugin is commercial, so we ship a permissive", - "Apache-2.0 shim instead.", - "", - "Secrets are NOT stored here. Placeholder values marked __set_from_kv__ are", - "patched post-import by deploy/keycloak/kcadm-bootstrap.sh from the KV store." - ], - "realm": "cwl", "displayName": "ContextualWisdom IdP", "enabled": true, @@ -40,8 +13,6 @@ "duplicateEmailsAllowed": false, "editUsernameAllowed": false, - "$password_policy_note": "No passwordPolicy -> ecosystem-local accounts never set a password; the passkey-first flow below carries authentication.", - "webAuthnPolicyPasswordlessRpEntityName": "ContextualWisdom IdP", "webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256", "RS256"], "webAuthnPolicyPasswordlessRpId": "", @@ -57,7 +28,7 @@ "defaultSignatureAlgorithm": "RS256", "accessTokenLifespan": 300, "ssoSessionIdleTimeout": 1800, - "ssoSessionMaxLifespan": 36000, + "ssoSessionMaxLifespan": 43200, "requiredActions": [ { @@ -153,13 +124,11 @@ "addReadTokenRoleOnCreate": false, "authenticateByDefault": false, "linkOnly": false, - "$firstBrokerLoginFlowAlias": "first broker login (default) auto-links by verified email; trustEmail=true makes the ADFS-asserted email an eligible link anchor.", "config": { - "$metadata_note": "Prefer metadataUrl so ADFS cert rollover is picked up automatically; typically https://sts.hssmartdev.com/FederationMetadata/2007-06/FederationMetadata.xml -- patched from KV.", "entityId": "https://idp.example/realms/cwl", "idpEntityId": "http://sts.hssmartdev.com/adfs/services/trust", - "singleSignOnServiceUrl": "__set_from_kv__", - "metadataDescriptorUrl": "__set_from_kv__", + "singleSignOnServiceUrl": "https://set-from-kv.invalid/__set_from_kv__", + "metadataDescriptorUrl": "https://set-from-kv.invalid/__set_from_kv__", "useMetadataDescriptorUrl": "true", "nameIDPolicyFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", "principalType": "SUBJECT", @@ -179,14 +148,13 @@ "name": "corp-ldap", "providerId": "ldap", "config": { - "$note": ["bindCredential is a placeholder patched from KV by kcadm-bootstrap.sh."], - "enabled": ["true"], + "enabled": ["false"], "priority": ["1"], "editMode": ["READ_ONLY"], "importEnabled": ["true"], "syncRegistrations": ["false"], "vendor": ["ad"], - "connectionUrl": ["__set_from_kv__"], + "connectionUrl": ["ldaps://set-from-kv.invalid:636"], "usersDn": ["__set_from_kv__"], "bindDn": ["__set_from_kv__"], "bindCredential": ["__set_from_kv__"], @@ -203,17 +171,130 @@ ] }, - "clientScopes": [], + "clientScopes": [ + { + "name": "basic", + "description": "OpenID Connect built-in scope: sub and auth_time claims", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "Subject (sub)", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "access.token.claim": "true", + "id.token.claim": "true", + "introspection.token.claim": "true", + "lightweight.claim": "false" + } + }, + { + "name": "auth_time", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "AUTH_TIME", + "claim.name": "auth_time", + "jsonType.label": "long", + "access.token.claim": "true", + "id.token.claim": "true", + "introspection.token.claim": "true" + } + } + ] + }, + { + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "username", + "claim.name": "preferred_username", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "true", + "introspection.token.claim": "true" + } + }, + { + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "true", + "introspection.token.claim": "true" + } + } + ] + }, + { + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "email", + "claim.name": "email", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "true", + "introspection.token.claim": "true" + } + }, + { + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "user.attribute": "emailVerified", + "claim.name": "email_verified", + "jsonType.label": "boolean", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "true", + "introspection.token.claim": "true" + } + } + ] + } + ], + + "defaultDefaultClientScopes": ["basic", "profile", "email"], + "defaultOptionalClientScopes": [], "clients": [ { - "$comment": [ - "OIDC / OAuth 2.1 confidential client TEMPLATE for ecosystem RPs.", - "Clone per RP (naruon, pg-erd-cloud, semantic-data-portal, clearfolio,", - "contextual-orchestrator, newsdom-api) and set redirectUris + secret.", - "OAuth 2.1 posture: authorization-code + PKCE, refresh tokens, NO", - "implicit/hybrid, exact HTTPS redirect URIs. Secret patched from KV." - ], "clientId": "ecosystem-rp-template", "name": "Ecosystem RP template", "enabled": true, @@ -226,19 +307,107 @@ "secret": "__set_from_kv__", "redirectUris": ["https://naruon.example/auth/callback"], "webOrigins": ["+"], + "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": "ecosystem-rp-template", + "access.token.claim": "true", + "id.token.claim": "false", + "introspection.token.claim": "true" + } + } + ], "fullScopeAllowed": false }, { - "$comment": [ - "Confidential service-account client used by the account-unification", - "service AND the SCIM shim to call the Admin REST API. It holds the", - "realm-management roles view-users + manage-users. Secret from KV." + "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"], + "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": "43200" + }, + "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", "enabled": true, @@ -251,7 +420,5 @@ "secret": "__set_from_kv__", "fullScopeAllowed": false } - ], - - "$roles_note": "realm-management client roles view-users/manage-users are granted to the account-unification-svc service account by kcadm-bootstrap.sh (composite service-account role assignment is applied post-import)." + ] } diff --git a/docker-compose.yml b/docker-compose.yml index c8217c2..e103f2a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,12 @@ services: KC_HOSTNAME: ${IDP_EXTERNAL_HOSTNAME:-http://localhost:8080} KC_HOSTNAME_STRICT: "false" KC_PROXY_HEADERS: xforwarded + # ---- Cache. This compose file is the STANDALONE single-node bring-up, + # so the Infinispan cluster stack is disabled by default: the jdbc-ping + # stack crash-loops single-node restarts (each aborted boot leaves a + # stale jgroups_ping coordinator row the next boot fatally tries to + # join). Clustered deployments (Helm) set IDP_CACHE_MODE=ispn. + KC_CACHE: ${IDP_CACHE_MODE:-local} volumes: # Realm config-as-code imported on first start. - ./deploy/keycloak/realm-cwl.json:/opt/keycloak/data/import/realm-cwl.json:ro diff --git a/scripts/validate_realm.py b/scripts/validate_realm.py index 43fdd2e..d8f94bd 100644 --- a/scripts/validate_realm.py +++ b/scripts/validate_realm.py @@ -11,7 +11,17 @@ * the employer ADFS is registered as an INBOUND SAML IdP with trustEmail; * an LDAP/AD user-federation source is present; * an OIDC/OAuth2.1 RP client template and the account-unification service - account client exist; no committed client secret is a real value. + account client exist; no committed client secret is a real value; + * Keycloak 26 import compatibility: no `$`-prefixed annotation keys anywhere + (RealmRepresentation rejects unknown fields) and URL-shaped fields never + hold a bare `__set_from_kv__` placeholder (SAML IdP URLs are validated at + import — placeholders must be URL-shaped, e.g. + https://set-from-kv.invalid/__set_from_kv__); + * the `basic` client scope exists with the Subject (sub) mapper and is a + realm default — without it Keycloak 26 lightweight access tokens omit + `sub` and subject-authenticating RPs (naruon) reject every request; + * the concrete `naruon-web` public PKCE client exists and carries the + audience + role/org/workspace claims naruon's session contract requires. Usage: python scripts/validate_realm.py [path-to-realm.json] Exit 0 = valid, 1 = invalid (prints the failing checks). @@ -105,8 +115,16 @@ def validate(realm: dict) -> list[str]: storage = realm.get("components", {}).get( "org.keycloak.storage.UserStorageProvider", [] ) - if not any(c.get("providerId") == "ldap" for c in storage): + ldap_sources = [c for c in storage if c.get("providerId") == "ldap"] + if not ldap_sources: errors.append("an LDAP user-storage provider is required") + for ldap_source in ldap_sources: + if ldap_source.get("config", {}).get("enabled") != ["false"]: + errors.append( + "committed LDAP sources must ship disabled: an enabled source " + "with placeholder DNs breaks every realm user operation " + "(kcadm-bootstrap.sh enables it after patching from KV)" + ) # Clients: RP template + service account, no committed real secret. clients = {c.get("clientId"): c for c in realm.get("clients", [])} @@ -129,9 +147,87 @@ def validate(realm: dict) -> list[str]: if secret is not None and secret != SECRET_PLACEHOLDER: errors.append(f"client '{client_id}' commits a non-placeholder secret") + # Keycloak 26 import compatibility: RealmRepresentation rejects unknown + # fields, so `$`-annotation keys abort --import-realm and crash-loop the + # container. + for key_path in _dollar_keys(realm): + errors.append( + f"'$'-annotation key '{key_path}' breaks Keycloak 26 realm import" + ) + + # URL-shaped fields must never hold the bare KV placeholder: SAML IdP URLs + # are URL-validated at import time. + for idp in realm.get("identityProviders", []): + for field_name in ("singleSignOnServiceUrl", "metadataDescriptorUrl"): + value = idp.get("config", {}).get(field_name) + if value == SECRET_PLACEHOLDER: + errors.append( + f"identity provider '{idp.get('alias')}' field '{field_name}' " + "holds a bare placeholder; use a URL-shaped placeholder such " + "as https://set-from-kv.invalid/__set_from_kv__" + ) + + # Keycloak 26 lightweight tokens omit `sub` without the basic scope. + scopes = {s.get("name"): s for s in realm.get("clientScopes", [])} + basic = scopes.get("basic") + if basic is None: + errors.append("client scope 'basic' is required (sub claim source)") + elif not any( + m.get("protocolMapper") == "oidc-sub-mapper" + for m in basic.get("protocolMappers", []) + ): + errors.append("client scope 'basic' must include the oidc-sub-mapper") + if "basic" not in realm.get("defaultDefaultClientScopes", []): + errors.append("'basic' must be a realm default client scope") + + # The first concrete ecosystem RP: naruon. + 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") + naruon_mappers = { + m.get("protocolMapper") for m in naruon.get("protocolMappers", []) + } + if "oidc-audience-mapper" not in naruon_mappers: + errors.append("naruon-web must include an audience mapper") + hardcoded_claims = { + m.get("config", {}).get("claim.name") + for m in naruon.get("protocolMappers", []) + if m.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 +def _dollar_keys(node: object, prefix: str = "") -> list[str]: + """Collect every `$`-prefixed object key with its JSON path.""" + found: list[str] = [] + if isinstance(node, dict): + for key, value in node.items(): + key_path = f"{prefix}.{key}" if prefix else str(key) + if str(key).startswith("$"): + found.append(key_path) + found.extend(_dollar_keys(value, key_path)) + elif isinstance(node, list): + for index, item in enumerate(node): + found.extend(_dollar_keys(item, f"{prefix}[{index}]")) + return found + + def main(argv: list[str]) -> int: """Run realm validation as a command-line check.""" path = Path(argv[1]) if len(argv) > 1 else Path("deploy/keycloak/realm-cwl.json") From 16ec55bc5caec2e4dfc39979a8a11995ef7f7d1f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 21 Jul 2026 14:09:23 +0900 Subject: [PATCH 002/104] feat: move external federation out of realm code into a DB-backed runtime API Employer-specific federation (the hssmartdev ADFS SAML IdP, corporate LDAP) was committed into realm-cwl.json with KV placeholders. That hardcodes one employer into the ecosystem IdP's code and also breaks bring-up mechanically: SAML IdP URLs are validated at import (placeholders abort it) and an enabled LDAP source with placeholder DNs fails every realm user operation (Invalid DN: __set_from_kv__). External IdPs are deployment data, so they now live behind a runtime admin API on the account-unification service: - GET/PUT/DELETE /federation/identity-providers[/{alias}] and POST /federation/identity-providers:apply. Desired state persists in the KV/DB config store (source of truth) and is converged into Keycloak via the Admin REST API, so a realm rebuild re-converges with one apply call. - AdminApi gains identity-provider CRUD (HttpAdminApi + mock); KvStore gains delete() on the protocol and both backends. - realm-cwl.json commits no identityProviders and no user-storage federation; scripts/validate_realm.py now fails closed on committed federation instead of requiring it, and kcadm-bootstrap.sh drops the ADFS/LDAP patch steps and additionally grants manage-identity-providers to the service account. - deploy/templates/ remain as ready-made payload references for the API. Verified: scripts/validate_realm.py passes; the account-unification suite passes (58 tests) including new federation registry tests that register the employer ADFS as runtime data; the realm still imports cleanly and a live naruon OIDC login flow reaches the passwordless form after removing the hardcoded federation from a running realm. Co-Authored-By: Claude Fable 5 --- deploy/keycloak/README.md | 17 +- deploy/keycloak/kcadm-bootstrap.sh | 33 +-- deploy/keycloak/realm-cwl.json | 107 +++------ scripts/validate_realm.py | 70 +++--- .../account_unification/app/federation.py | 219 ++++++++++++++++++ .../app/keycloak_client.py | 55 +++++ services/account_unification/app/kv_store.py | 17 ++ services/account_unification/app/main.py | 5 + .../tests/mock_keycloak.py | 24 ++ .../tests/test_federation.py | 152 ++++++++++++ 10 files changed, 551 insertions(+), 148 deletions(-) create mode 100644 services/account_unification/app/federation.py create mode 100644 services/account_unification/tests/test_federation.py diff --git a/deploy/keycloak/README.md b/deploy/keycloak/README.md index 7d54cc0..f19a9bd 100644 --- a/deploy/keycloak/README.md +++ b/deploy/keycloak/README.md @@ -52,12 +52,17 @@ back (`scripts/validate_realm.py` guards both): fields (`Unrecognized field "$comment"`), which aborts `--import-realm` and crash-loops the container. Document intent in this README instead of inline JSON annotations. -- **Placeholder URLs must still parse as URLs.** SAML IdP fields such as - `singleSignOnServiceUrl` are URL-validated at import; a bare - `__set_from_kv__` string aborts the import. Committed placeholders use the - reserved host form `https://set-from-kv.invalid/__set_from_kv__` - (`ldaps://set-from-kv.invalid:636` for LDAP) and are replaced from KV by - `kcadm-bootstrap.sh` exactly as before. +- **No committed external federation.** SAML IdP URL fields are URL-validated + at import (a bare `__set_from_kv__` aborts it) and an enabled LDAP source + with placeholder DNs breaks every realm user operation (`Invalid DN`). The + deeper problem is that employer-specific federation (ADFS, corporate LDAP) + is deployment data, so the realm commits **none of it**: register external + IdPs at runtime through the account-unification service's + `/federation/identity-providers` API. Desired state persists in the KV/DB + config store and is converged into Keycloak over the Admin REST API, so a + realm rebuild is re-converged with one `POST + /federation/identity-providers:apply`. `../templates/` holds ready-made + payloads (ADFS SAML, LDAP component, OIDC RP). ## Client scopes and the Keycloak 26 lightweight-token pitfall diff --git a/deploy/keycloak/kcadm-bootstrap.sh b/deploy/keycloak/kcadm-bootstrap.sh index b04da02..513f99d 100755 --- a/deploy/keycloak/kcadm-bootstrap.sh +++ b/deploy/keycloak/kcadm-bootstrap.sh @@ -24,26 +24,12 @@ kcadm.sh config credentials \ --server "${KC_SERVER}" --realm master \ --user "${ADMIN_USER}" --password "${ADMIN_PASS}" -echo "==> patching employer-adfs SAML metadata URL from KV" -ADFS_METADATA_URL="$(kv get config/idp/employer-adfs-metadata-url)" -IDP_ID="$(kcadm.sh get "identity-provider/instances/employer-adfs" -r "${REALM}" --fields internalId --format csv --noquotes)" -kcadm.sh update "identity-provider/instances/employer-adfs" -r "${REALM}" \ - -s "config.metadataDescriptorUrl=${ADFS_METADATA_URL}" \ - -s "config.singleSignOnServiceUrl=${ADFS_METADATA_URL}" \ - -s "config.useMetadataDescriptorUrl=true" - -echo "==> patching corp-ldap bind credential + connection from KV" -# The committed component ships DISABLED with placeholder DNs: an enabled LDAP -# source with an unparsable DN breaks every realm user operation (Invalid DN), -# so it only turns on here, after the real connection values are applied. -LDAP_COMPONENT_ID="$(kcadm.sh get components -r "${REALM}" \ - --query 'name=corp-ldap' --fields id --format csv --noquotes | head -n1)" -kcadm.sh update "components/${LDAP_COMPONENT_ID}" -r "${REALM}" \ - -s "config.connectionUrl=[\"$(kv get config/idp/ldap-connection-url)\"]" \ - -s "config.usersDn=[\"$(kv get config/idp/ldap-users-dn)\"]" \ - -s "config.bindDn=[\"$(kv get secret/idp/ldap-bind-dn)\"]" \ - -s "config.bindCredential=[\"$(kv get secret/idp/ldap-bind-password)\"]" \ - -s 'config.enabled=["true"]' +# NOTE: external federation (the employer ADFS SAML IdP, corporate LDAP/AD) +# is deliberately NOT part of this bootstrap. Those are deployment data, not +# realm code: register them at runtime through the account-unification +# service's /federation/identity-providers API, which persists the desired +# state in the KV/DB store and converges Keycloak via the Admin REST API. +# See deploy/templates/ for ready-made request payloads. echo "==> patching account-unification-svc client secret from KV" SVC_CLIENT_UUID="$(kcadm.sh get clients -r "${REALM}" \ @@ -51,7 +37,9 @@ SVC_CLIENT_UUID="$(kcadm.sh get clients -r "${REALM}" \ kcadm.sh update "clients/${SVC_CLIENT_UUID}" -r "${REALM}" \ -s "secret=$(kv get secret/idp/account-unification-client-secret)" -echo "==> granting realm-management view-users + manage-users to the service account" +echo "==> granting realm-management roles to the service account" +# view-users/manage-users: account unification + SCIM shim. +# manage-identity-providers: the runtime federation registry API. SVC_SA_USER_ID="$(kcadm.sh get "clients/${SVC_CLIENT_UUID}/service-account-user" \ -r "${REALM}" --fields id --format csv --noquotes)" REALM_MGMT_UUID="$(kcadm.sh get clients -r "${REALM}" \ @@ -59,7 +47,8 @@ REALM_MGMT_UUID="$(kcadm.sh get clients -r "${REALM}" \ kcadm.sh add-roles -r "${REALM}" \ --uid "${SVC_SA_USER_ID}" \ --cclientid realm-management \ - --rolename view-users --rolename manage-users + --rolename view-users --rolename manage-users \ + --rolename manage-identity-providers echo "==> mirroring the service client secret into KV for the admin service" # The account-unification service reads keycloak_client_secret from KV; keep it diff --git a/deploy/keycloak/realm-cwl.json b/deploy/keycloak/realm-cwl.json index 01dd3d4..f164d3c 100644 --- a/deploy/keycloak/realm-cwl.json +++ b/deploy/keycloak/realm-cwl.json @@ -3,7 +3,6 @@ "displayName": "ContextualWisdom IdP", "enabled": true, "sslRequired": "external", - "registrationAllowed": false, "registrationEmailAsUsername": false, "resetPasswordAllowed": false, @@ -12,9 +11,11 @@ "loginWithEmailAllowed": true, "duplicateEmailsAllowed": false, "editUsernameAllowed": false, - "webAuthnPolicyPasswordlessRpEntityName": "ContextualWisdom IdP", - "webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256", "RS256"], + "webAuthnPolicyPasswordlessSignatureAlgorithms": [ + "ES256", + "RS256" + ], "webAuthnPolicyPasswordlessRpId": "", "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", @@ -22,14 +23,11 @@ "webAuthnPolicyPasswordlessUserVerificationRequirement": "required", "webAuthnPolicyPasswordlessCreateTimeout": 0, "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, - "browserFlow": "browser-passwordless", - "defaultSignatureAlgorithm": "RS256", "accessTokenLifespan": 300, "ssoSessionIdleTimeout": 1800, "ssoSessionMaxLifespan": 43200, - "requiredActions": [ { "alias": "webauthn-register-passwordless", @@ -50,7 +48,6 @@ "config": {} } ], - "authenticationFlows": [ { "alias": "browser-passwordless", @@ -111,66 +108,6 @@ ] } ], - - "identityProviders": [ - { - "alias": "employer-adfs", - "displayName": "Employer ADFS (hssmartdev)", - "providerId": "saml", - "enabled": true, - "updateProfileFirstLoginMode": "on", - "trustEmail": true, - "storeToken": false, - "addReadTokenRoleOnCreate": false, - "authenticateByDefault": false, - "linkOnly": false, - "config": { - "entityId": "https://idp.example/realms/cwl", - "idpEntityId": "http://sts.hssmartdev.com/adfs/services/trust", - "singleSignOnServiceUrl": "https://set-from-kv.invalid/__set_from_kv__", - "metadataDescriptorUrl": "https://set-from-kv.invalid/__set_from_kv__", - "useMetadataDescriptorUrl": "true", - "nameIDPolicyFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - "principalType": "SUBJECT", - "postBindingResponse": "true", - "postBindingAuthnRequest": "true", - "wantAuthnRequestsSigned": "true", - "wantAssertionsSigned": "true", - "validateSignature": "true", - "syncMode": "FORCE" - } - } - ], - - "components": { - "org.keycloak.storage.UserStorageProvider": [ - { - "name": "corp-ldap", - "providerId": "ldap", - "config": { - "enabled": ["false"], - "priority": ["1"], - "editMode": ["READ_ONLY"], - "importEnabled": ["true"], - "syncRegistrations": ["false"], - "vendor": ["ad"], - "connectionUrl": ["ldaps://set-from-kv.invalid:636"], - "usersDn": ["__set_from_kv__"], - "bindDn": ["__set_from_kv__"], - "bindCredential": ["__set_from_kv__"], - "usernameLDAPAttribute": ["sAMAccountName"], - "rdnLDAPAttribute": ["cn"], - "uuidLDAPAttribute": ["objectGUID"], - "userObjectClasses": ["person, organizationalPerson, user"], - "searchScope": ["2"], - "useTruststoreSpi": ["ldapsOnly"], - "connectionPooling": ["true"], - "trustEmail": ["true"] - } - } - ] - }, - "clientScopes": [ { "name": "basic", @@ -289,10 +226,12 @@ ] } ], - - "defaultDefaultClientScopes": ["basic", "profile", "email"], + "defaultDefaultClientScopes": [ + "basic", + "profile", + "email" + ], "defaultOptionalClientScopes": [], - "clients": [ { "clientId": "ecosystem-rp-template", @@ -305,9 +244,17 @@ "directAccessGrantsEnabled": false, "serviceAccountsEnabled": false, "secret": "__set_from_kv__", - "redirectUris": ["https://naruon.example/auth/callback"], - "webOrigins": ["+"], - "defaultClientScopes": ["basic", "profile", "email"], + "redirectUris": [ + "https://naruon.example/auth/callback" + ], + "webOrigins": [ + "+" + ], + "defaultClientScopes": [ + "basic", + "profile", + "email" + ], "optionalClientScopes": [], "attributes": { "pkce.code.challenge.method": "S256", @@ -340,9 +287,17 @@ "implicitFlowEnabled": false, "directAccessGrantsEnabled": false, "serviceAccountsEnabled": false, - "redirectUris": ["https://naruon.example/auth/callback"], - "webOrigins": ["https://naruon.example"], - "defaultClientScopes": ["basic", "profile", "email"], + "redirectUris": [ + "https://naruon.example/auth/callback" + ], + "webOrigins": [ + "https://naruon.example" + ], + "defaultClientScopes": [ + "basic", + "profile", + "email" + ], "optionalClientScopes": [], "attributes": { "pkce.code.challenge.method": "S256", diff --git a/scripts/validate_realm.py b/scripts/validate_realm.py index d8f94bd..60d6441 100644 --- a/scripts/validate_realm.py +++ b/scripts/validate_realm.py @@ -8,15 +8,14 @@ * a passwordless browser flow is bound and contains NO password authenticator but DOES use the WebAuthn passwordless authenticator (passkey-first); * self-service password registration + reset are OFF; - * the employer ADFS is registered as an INBOUND SAML IdP with trustEmail; - * an LDAP/AD user-federation source is present; + * NO external federation is committed: employer IdPs (ADFS) and LDAP/AD + sources are deployment DATA registered at runtime through the + account-unification service's /federation/identity-providers API + (KV/DB-backed source of truth), never realm code; * an OIDC/OAuth2.1 RP client template and the account-unification service account client exist; no committed client secret is a real value; * Keycloak 26 import compatibility: no `$`-prefixed annotation keys anywhere - (RealmRepresentation rejects unknown fields) and URL-shaped fields never - hold a bare `__set_from_kv__` placeholder (SAML IdP URLs are validated at - import — placeholders must be URL-shaped, e.g. - https://set-from-kv.invalid/__set_from_kv__); + (RealmRepresentation rejects unknown fields); * the `basic` client scope exists with the Subject (sub) mapper and is a realm default — without it Keycloak 26 lightweight access tokens omit `sub` and subject-authenticating RPs (naruon) reject every request; @@ -100,31 +99,23 @@ def validate(realm: dict) -> list[str]: if realm.get("resetPasswordAllowed", False): errors.append("credential reset self-service must be false") - # Employer ADFS inbound SAML IdP. - idps = {i.get("alias"): i for i in realm.get("identityProviders", [])} - adfs = idps.get("employer-adfs") - if adfs is None: - errors.append("identity provider 'employer-adfs' is missing") - else: - if adfs.get("providerId") != "saml": - errors.append("employer-adfs must be a SAML identity provider") - if not adfs.get("trustEmail", False): - errors.append("employer-adfs must set trustEmail (verified-email auto-link)") - - # LDAP/AD federation. - storage = realm.get("components", {}).get( - "org.keycloak.storage.UserStorageProvider", [] - ) - ldap_sources = [c for c in storage if c.get("providerId") == "ldap"] - if not ldap_sources: - errors.append("an LDAP user-storage provider is required") - for ldap_source in ldap_sources: - if ldap_source.get("config", {}).get("enabled") != ["false"]: - errors.append( - "committed LDAP sources must ship disabled: an enabled source " - "with placeholder DNs breaks every realm user operation " - "(kcadm-bootstrap.sh enables it after patching from KV)" - ) + # External federation is runtime data, never realm code. Employer IdPs + # (ADFS) and LDAP/AD sources are registered through the account-unification + # service's /federation/identity-providers API, which persists desired + # state in the KV/DB store and converges Keycloak via the Admin REST API. + # Committing them here hardcodes employer specifics AND breaks bring-up: + # an enabled LDAP source with placeholder DNs fails every realm user + # operation (Invalid DN), and placeholder SAML URLs abort the import. + if realm.get("identityProviders"): + errors.append( + "identityProviders must not be committed; register external IdPs at " + "runtime via the federation registry API" + ) + if realm.get("components", {}).get("org.keycloak.storage.UserStorageProvider"): + errors.append( + "user-storage federation must not be committed; register LDAP/AD " + "sources at runtime via the federation registry API" + ) # Clients: RP template + service account, no committed real secret. clients = {c.get("clientId"): c for c in realm.get("clients", [])} @@ -155,18 +146,6 @@ def validate(realm: dict) -> list[str]: f"'$'-annotation key '{key_path}' breaks Keycloak 26 realm import" ) - # URL-shaped fields must never hold the bare KV placeholder: SAML IdP URLs - # are URL-validated at import time. - for idp in realm.get("identityProviders", []): - for field_name in ("singleSignOnServiceUrl", "metadataDescriptorUrl"): - value = idp.get("config", {}).get(field_name) - if value == SECRET_PLACEHOLDER: - errors.append( - f"identity provider '{idp.get('alias')}' field '{field_name}' " - "holds a bare placeholder; use a URL-shaped placeholder such " - "as https://set-from-kv.invalid/__set_from_kv__" - ) - # Keycloak 26 lightweight tokens omit `sub` without the basic scope. scopes = {s.get("name"): s for s in realm.get("clientScopes", [])} basic = scopes.get("basic") @@ -243,7 +222,10 @@ def main(argv: list[str]) -> int: for error in errors: print(f" - {error}", file=sys.stderr) return 1 - print(f"OK: {path} is a valid cwl-idp realm (passkey, ADFS, LDAP, OIDC RP).") + print( + f"OK: {path} is a valid cwl-idp realm " + "(passkey-first, runtime federation, OIDC RPs)." + ) return 0 diff --git a/services/account_unification/app/federation.py b/services/account_unification/app/federation.py new file mode 100644 index 0000000..933d023 --- /dev/null +++ b/services/account_unification/app/federation.py @@ -0,0 +1,219 @@ +"""Runtime federation registry: external IdPs are DB-backed data, not realm code. + +External identity providers — the employer ADFS, LDAP-fronting brokers, +optional personal OIDC — are DEPLOYMENT configuration. The committed realm +ships with none of them; operators register providers at runtime through this +API. The desired state is persisted in the KV/DB config store (the source of +truth) and applied to Keycloak through the Admin REST API, so a rebuilt realm +can be re-converged with the ``apply`` endpoint instead of editing JSON. + +Secrets in provider config (client secrets, signing keys) live only in the +store and Keycloak; responses echo configuration back without masking because +this surface is operator-scoped admin API behind the service network boundary, +matching the SCIM shim posture. +""" +from __future__ import annotations + +import re + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field + +from .keycloak_client import AdminApi +from .kv_store import KvStore + +FEDERATION_PROVIDER_NAMESPACE = "federation_identity_providers" +_PROVIDER_ALIAS_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{1,62}$") +_SUPPORTED_PROVIDER_IDS = {"saml", "oidc", "keycloak-oidc"} + + +class IdentityProviderRegistration(BaseModel): + """Desired state for one external identity provider.""" + + provider_alias: str = Field(description="Keycloak IdP alias (URL-safe slug).") + display_name: str = Field(min_length=1, max_length=120) + provider_id: str = Field(description="Keycloak provider id: saml | oidc | keycloak-oidc.") + enabled: bool = True + trust_email: bool = Field( + default=False, + description="Trust the asserted email as verified (auto-link anchor).", + ) + provider_config: dict[str, str] = Field( + default_factory=dict, + description="Keycloak IdP config map (e.g. singleSignOnServiceUrl).", + ) + + +class IdentityProviderStatus(BaseModel): + """Stored registration plus whether it is applied to Keycloak.""" + + registration: IdentityProviderRegistration + applied_to_keycloak: bool + + +class FederationService: + """Persist desired IdP state in the KV/DB store and converge Keycloak.""" + + def __init__(self, store: KvStore, api: AdminApi) -> None: + self._store = store + self._api = api + + # -- registry ---------------------------------------------------------- + def list_registrations(self) -> list[IdentityProviderStatus]: + """Return every stored registration with its applied state.""" + statuses: list[IdentityProviderStatus] = [] + for raw_value in self._store.get_all(FEDERATION_PROVIDER_NAMESPACE).values(): + registration = IdentityProviderRegistration.model_validate_json(raw_value) + statuses.append(self._status_for(registration)) + return sorted(statuses, key=lambda s: s.registration.provider_alias) + + def get_registration(self, provider_alias: str) -> IdentityProviderStatus: + """Return one stored registration or raise 404.""" + raw_value = self._store.get(FEDERATION_PROVIDER_NAMESPACE, provider_alias) + if raw_value is None: + raise HTTPException(status_code=404, detail="identity provider not registered") + registration = IdentityProviderRegistration.model_validate_json(raw_value) + return self._status_for(registration) + + def put_registration( + self, provider_alias: str, registration: IdentityProviderRegistration + ) -> IdentityProviderStatus: + """Validate, persist to the store, and converge Keycloak.""" + if registration.provider_alias != provider_alias: + raise HTTPException( + status_code=400, detail="path alias and body provider_alias must match" + ) + _validate_registration(registration) + self._store.put( + FEDERATION_PROVIDER_NAMESPACE, + provider_alias, + registration.model_dump_json(), + ) + self._apply(registration) + return self._status_for(registration) + + def delete_registration(self, provider_alias: str) -> None: + """Remove the registration from Keycloak and the store.""" + raw_value = self._store.get(FEDERATION_PROVIDER_NAMESPACE, provider_alias) + if raw_value is None: + raise HTTPException(status_code=404, detail="identity provider not registered") + if self._api.get_identity_provider(provider_alias) is not None: + self._api.delete_identity_provider(provider_alias) + self._store.delete(FEDERATION_PROVIDER_NAMESPACE, provider_alias) + + def apply_all(self) -> list[IdentityProviderStatus]: + """Re-converge Keycloak from the stored desired state (e.g. after a realm rebuild).""" + statuses: list[IdentityProviderStatus] = [] + for raw_value in self._store.get_all(FEDERATION_PROVIDER_NAMESPACE).values(): + registration = IdentityProviderRegistration.model_validate_json(raw_value) + self._apply(registration) + statuses.append(self._status_for(registration)) + return sorted(statuses, key=lambda s: s.registration.provider_alias) + + # -- convergence ------------------------------------------------------- + def _apply(self, registration: IdentityProviderRegistration) -> None: + payload = _to_keycloak_payload(registration) + existing = self._api.get_identity_provider(registration.provider_alias) + if existing is None: + self._api.create_identity_provider(payload) + else: + self._api.update_identity_provider(registration.provider_alias, payload) + + def _status_for( + self, registration: IdentityProviderRegistration + ) -> IdentityProviderStatus: + applied = self._api.get_identity_provider(registration.provider_alias) is not None + return IdentityProviderStatus( + registration=registration, applied_to_keycloak=applied + ) + + +def _validate_registration(registration: IdentityProviderRegistration) -> None: + if not _PROVIDER_ALIAS_PATTERN.fullmatch(registration.provider_alias): + raise HTTPException( + status_code=400, + detail="provider_alias must be a lowercase URL-safe slug", + ) + if registration.provider_id not in _SUPPORTED_PROVIDER_IDS: + raise HTTPException( + status_code=400, + detail="provider_id must be one of: saml, oidc, keycloak-oidc", + ) + + +def _to_keycloak_payload(registration: IdentityProviderRegistration) -> dict: + return { + "alias": registration.provider_alias, + "displayName": registration.display_name, + "providerId": registration.provider_id, + "enabled": registration.enabled, + "trustEmail": registration.trust_email, + "storeToken": False, + "addReadTokenRoleOnCreate": False, + "authenticateByDefault": False, + "linkOnly": False, + "config": dict(registration.provider_config), + } + + +federation_router = APIRouter(prefix="/federation", tags=["federation"]) + + +def get_federation_service(request: Request) -> FederationService: + """Return the wired federation service from application state.""" + service = getattr(request.app.state, "federation_service", None) + if service is None: + raise HTTPException(status_code=503, detail="federation service not ready") + return service + + +@federation_router.get( + "/identity-providers", response_model=list[IdentityProviderStatus] +) +def list_identity_providers( + service: FederationService = Depends(get_federation_service), +) -> list[IdentityProviderStatus]: + """List every registered external identity provider.""" + return service.list_registrations() + + +@federation_router.get( + "/identity-providers/{provider_alias}", response_model=IdentityProviderStatus +) +def get_identity_provider( + provider_alias: str, + service: FederationService = Depends(get_federation_service), +) -> IdentityProviderStatus: + """Return one registered external identity provider.""" + return service.get_registration(provider_alias) + + +@federation_router.put( + "/identity-providers/{provider_alias}", response_model=IdentityProviderStatus +) +def put_identity_provider( + provider_alias: str, + registration: IdentityProviderRegistration, + service: FederationService = Depends(get_federation_service), +) -> IdentityProviderStatus: + """Register or update an external identity provider (store + converge).""" + return service.put_registration(provider_alias, registration) + + +@federation_router.delete("/identity-providers/{provider_alias}", status_code=204) +def delete_identity_provider( + provider_alias: str, + service: FederationService = Depends(get_federation_service), +) -> None: + """Remove an external identity provider from Keycloak and the store.""" + service.delete_registration(provider_alias) + + +@federation_router.post( + "/identity-providers:apply", response_model=list[IdentityProviderStatus] +) +def apply_identity_providers( + service: FederationService = Depends(get_federation_service), +) -> list[IdentityProviderStatus]: + """Re-converge Keycloak from the stored desired state.""" + return service.apply_all() diff --git a/services/account_unification/app/keycloak_client.py b/services/account_unification/app/keycloak_client.py index 7d6ab05..f89d936 100644 --- a/services/account_unification/app/keycloak_client.py +++ b/services/account_unification/app/keycloak_client.py @@ -108,6 +108,24 @@ def set_user_attribute(self, user_id: str, key: str, value: str) -> None: """Set one single-valued user attribute.""" ... + def get_identity_provider(self, provider_alias: str) -> dict | None: + """Return one identity-provider instance or ``None`` when absent.""" + ... + + def create_identity_provider(self, provider_payload: dict) -> None: + """Create an identity-provider instance from an admin representation.""" + ... + + def update_identity_provider( + self, provider_alias: str, provider_payload: dict + ) -> None: + """Replace an identity-provider instance.""" + ... + + def delete_identity_provider(self, provider_alias: str) -> None: + """Delete an identity-provider instance.""" + ... + class HttpAdminApi: """httpx-backed :class:`AdminApi` for a live Keycloak instance. @@ -339,6 +357,43 @@ def set_user_attribute(self, user_id: str, key: str, value: str) -> None: f"/admin/realms/{self._realm}/users/{user_id}", {"attributes": attributes} ) + # -- identity providers (runtime federation registry) ------------------- + def get_identity_provider(self, provider_alias: str) -> dict | None: + """Return one identity-provider instance or ``None`` when absent.""" + import httpx + + try: + data = self._get( + f"/admin/realms/{self._realm}/identity-provider/instances/{provider_alias}" + ) + except httpx.HTTPStatusError as error: + if error.response.status_code == 404: + return None + raise + return data if isinstance(data, dict) else None + + def create_identity_provider(self, provider_payload: dict) -> None: + """Create an identity-provider instance from an admin representation.""" + self._post( + f"/admin/realms/{self._realm}/identity-provider/instances", + provider_payload, + ) + + def update_identity_provider( + self, provider_alias: str, provider_payload: dict + ) -> None: + """Replace an identity-provider instance.""" + self._put( + f"/admin/realms/{self._realm}/identity-provider/instances/{provider_alias}", + provider_payload, + ) + + def delete_identity_provider(self, provider_alias: str) -> None: + """Delete an identity-provider instance.""" + self._delete( + f"/admin/realms/{self._realm}/identity-provider/instances/{provider_alias}" + ) + # -- transport --------------------------------------------------------- def _get(self, path: str, params: dict | None = None) -> dict | list: """Issue an authenticated GET and parse JSON.""" diff --git a/services/account_unification/app/kv_store.py b/services/account_unification/app/kv_store.py index ad9bd7a..cc8dc8a 100644 --- a/services/account_unification/app/kv_store.py +++ b/services/account_unification/app/kv_store.py @@ -27,6 +27,10 @@ def get_all(self, namespace: str) -> dict[str, str]: """Return every entry in ``namespace`` as a dict.""" ... + def delete(self, namespace: str, entry_key: str) -> None: + """Remove ``entry_key`` from ``namespace`` if present.""" + ... + class InMemoryKvStore: """Dict-backed store for tests and ephemeral bootstrap shims.""" @@ -50,6 +54,10 @@ def get_all(self, namespace: str) -> dict[str, str]: """Return a copy of every value in one namespace.""" return dict(self._data.get(namespace, {})) + def delete(self, namespace: str, entry_key: str) -> None: + """Remove one value from one namespace if present.""" + self._data.get(namespace, {}).pop(entry_key, None) + class SqliteKvStore: """SQLite-backed store for standalone / dev deployments. @@ -103,6 +111,15 @@ def get_all(self, namespace: str) -> dict[str, str]: ).fetchall() return {entry_key: entry_value for entry_key, entry_value in rows} + def delete(self, namespace: str, entry_key: str) -> None: + """Remove one config value from one namespace if present.""" + with self._connection: + self._connection.execute( + "DELETE FROM idp_config_entries " + "WHERE config_namespace = ? AND entry_key = ?", + (namespace, entry_key), + ) + def close(self) -> None: """Close the SQLite connection.""" self._connection.close() diff --git a/services/account_unification/app/main.py b/services/account_unification/app/main.py index ddff44a..87c9d97 100644 --- a/services/account_unification/app/main.py +++ b/services/account_unification/app/main.py @@ -17,6 +17,7 @@ from .audit import AuditLogger, SqliteAuditSink from .bootstrap import load_bootstrap_descriptor, open_config_store from .config import load_service_config +from .federation import FederationService, federation_router from .keycloak_client import HttpAdminApi from .scim import scim_router from .service import UnificationService @@ -43,6 +44,9 @@ def build_service(app: FastAPI) -> None: app.state.unification_service = UnificationService(api, audit, config) app.state.audit_logger = audit app.state.keycloak_api = api + # External IdPs (employer ADFS etc.) are runtime data in the KV/DB store, + # never realm code; this service converges Keycloak from that store. + app.state.federation_service = FederationService(store, api) app.state.ready = True @@ -75,6 +79,7 @@ def healthz() -> dict: app.include_router(router) app.include_router(scim_router) + app.include_router(federation_router) return app diff --git a/services/account_unification/tests/mock_keycloak.py b/services/account_unification/tests/mock_keycloak.py index f844391..a5871c0 100644 --- a/services/account_unification/tests/mock_keycloak.py +++ b/services/account_unification/tests/mock_keycloak.py @@ -148,3 +148,27 @@ def set_user_attribute(self, user_id: str, key: str, value: str) -> None: self.users[user_id] = self.users[user_id].model_copy( update={"external_id": value} ) + + # -- identity providers (runtime federation registry) ------------------- + def get_identity_provider(self, provider_alias: str) -> dict | None: + self.calls.append(f"get_identity_provider:{provider_alias}") + return getattr(self, "identity_providers", {}).get(provider_alias) + + def create_identity_provider(self, provider_payload: dict) -> None: + alias = provider_payload["alias"] + self.calls.append(f"create_identity_provider:{alias}") + if not hasattr(self, "identity_providers"): + self.identity_providers: dict[str, dict] = {} + self.identity_providers[alias] = dict(provider_payload) + + def update_identity_provider( + self, provider_alias: str, provider_payload: dict + ) -> None: + self.calls.append(f"update_identity_provider:{provider_alias}") + if not hasattr(self, "identity_providers"): + self.identity_providers = {} + self.identity_providers[provider_alias] = dict(provider_payload) + + def delete_identity_provider(self, provider_alias: str) -> None: + self.calls.append(f"delete_identity_provider:{provider_alias}") + getattr(self, "identity_providers", {}).pop(provider_alias, None) diff --git a/services/account_unification/tests/test_federation.py b/services/account_unification/tests/test_federation.py new file mode 100644 index 0000000..591364b --- /dev/null +++ b/services/account_unification/tests/test_federation.py @@ -0,0 +1,152 @@ +"""Runtime federation registry: IdPs live in the DB/KV store, not realm code.""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.federation import ( # noqa: E402 + FEDERATION_PROVIDER_NAMESPACE, + FederationService, + IdentityProviderRegistration, +) +from app.kv_store import InMemoryKvStore # noqa: E402 +from app.main import create_app # noqa: E402 + +from .mock_keycloak import MockKeycloakAdminApi # noqa: E402 + + +def _employer_adfs_registration() -> IdentityProviderRegistration: + """The employer ADFS expressed as runtime DATA, not committed realm code.""" + return IdentityProviderRegistration( + provider_alias="employer-adfs", + display_name="Employer ADFS (hssmartdev)", + provider_id="saml", + enabled=True, + trust_email=True, + provider_config={ + "entityId": "https://idp.example/realms/cwl", + "idpEntityId": "http://sts.hssmartdev.com/adfs/services/trust", + "singleSignOnServiceUrl": "https://sts.hssmartdev.com/adfs/ls/", + "metadataDescriptorUrl": ( + "https://sts.hssmartdev.com/FederationMetadata/2007-06/" + "FederationMetadata.xml" + ), + "useMetadataDescriptorUrl": "true", + "wantAssertionsSigned": "true", + "validateSignature": "true", + "syncMode": "FORCE", + }, + ) + + +@pytest.fixture +def store() -> InMemoryKvStore: + return InMemoryKvStore() + + +@pytest.fixture +def federation(store: InMemoryKvStore, api: MockKeycloakAdminApi) -> FederationService: + return FederationService(store, api) + + +def test_put_persists_to_store_and_converges_keycloak( + federation: FederationService, store: InMemoryKvStore, api: MockKeycloakAdminApi +) -> None: + registration = _employer_adfs_registration() + + status = federation.put_registration("employer-adfs", registration) + + assert status.applied_to_keycloak is True + # Source of truth is the store, not the realm file. + assert store.get(FEDERATION_PROVIDER_NAMESPACE, "employer-adfs") is not None + applied = api.identity_providers["employer-adfs"] + assert applied["providerId"] == "saml" + assert applied["trustEmail"] is True + assert applied["config"]["singleSignOnServiceUrl"] == "https://sts.hssmartdev.com/adfs/ls/" + + +def test_put_updates_existing_provider_in_place( + federation: FederationService, api: MockKeycloakAdminApi +) -> None: + registration = _employer_adfs_registration() + federation.put_registration("employer-adfs", registration) + + updated = registration.model_copy(update={"enabled": False}) + federation.put_registration("employer-adfs", updated) + + assert api.identity_providers["employer-adfs"]["enabled"] is False + assert any( + call.startswith("update_identity_provider:employer-adfs") + for call in api.calls + ) + + +def test_apply_all_reconverges_after_realm_rebuild( + federation: FederationService, api: MockKeycloakAdminApi +) -> None: + federation.put_registration("employer-adfs", _employer_adfs_registration()) + # Simulate a realm rebuild: Keycloak lost the IdP but the store still + # holds the desired state. + api.identity_providers.clear() + + statuses = federation.apply_all() + + assert [s.registration.provider_alias for s in statuses] == ["employer-adfs"] + assert statuses[0].applied_to_keycloak is True + assert "employer-adfs" in api.identity_providers + + +def test_delete_removes_from_keycloak_and_store( + federation: FederationService, store: InMemoryKvStore, api: MockKeycloakAdminApi +) -> None: + federation.put_registration("employer-adfs", _employer_adfs_registration()) + + federation.delete_registration("employer-adfs") + + assert store.get(FEDERATION_PROVIDER_NAMESPACE, "employer-adfs") is None + assert "employer-adfs" not in api.identity_providers + + +def test_alias_and_provider_id_validation(federation: FederationService) -> None: + registration = _employer_adfs_registration() + + with pytest.raises(Exception) as mismatch: + federation.put_registration("other-alias", registration) + assert getattr(mismatch.value, "status_code", None) == 400 + + bad_alias = registration.model_copy(update={"provider_alias": "Bad Alias!"}) + with pytest.raises(Exception) as invalid_alias: + federation.put_registration("Bad Alias!", bad_alias) + assert getattr(invalid_alias.value, "status_code", None) == 400 + + bad_provider = registration.model_copy(update={"provider_id": "ws-fed"}) + with pytest.raises(Exception) as invalid_provider: + federation.put_registration("employer-adfs", bad_provider) + assert getattr(invalid_provider.value, "status_code", None) == 400 + + +def test_http_surface_round_trip(api: MockKeycloakAdminApi) -> None: + app = create_app(wire=False) + app.state.federation_service = FederationService(InMemoryKvStore(), api) + client = TestClient(app) + body = _employer_adfs_registration().model_dump() + + put_response = client.put("/federation/identity-providers/employer-adfs", json=body) + list_response = client.get("/federation/identity-providers") + get_response = client.get("/federation/identity-providers/employer-adfs") + delete_response = client.delete("/federation/identity-providers/employer-adfs") + missing_response = client.get("/federation/identity-providers/employer-adfs") + + assert put_response.status_code == 200 + assert put_response.json()["applied_to_keycloak"] is True + assert [ + item["registration"]["provider_alias"] for item in list_response.json() + ] == ["employer-adfs"] + assert get_response.status_code == 200 + assert delete_response.status_code == 204 + assert missing_response.status_code == 404 From 153842783e024731109f0e7b82881206ee4bbcb1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 21 Jul 2026 14:21:05 +0900 Subject: [PATCH 003/104] fix: replace the broad urllib opener in the container healthcheck Semgrep (dynamic-urllib-use-detected) flags urllib.request.urlopen because it accepts file:// and other schemes. The probe only ever talks to the local listener, so it now validates the scheme and issues the request over an explicit plain-HTTP http.client connection; non-http probe URLs are refused before any connection is opened, with a regression test. Co-Authored-By: Claude Fable 5 --- .../account_unification/app/healthcheck.py | 21 ++++- .../tests/test_healthcheck.py | 80 ++++++++++++++----- 2 files changed, 78 insertions(+), 23 deletions(-) diff --git a/services/account_unification/app/healthcheck.py b/services/account_unification/app/healthcheck.py index 4284510..da30b32 100644 --- a/services/account_unification/app/healthcheck.py +++ b/services/account_unification/app/healthcheck.py @@ -1,22 +1,37 @@ """Container healthcheck: ``python -m app.healthcheck``. Exits 0 when the local service answers /healthz with status ok, else 1. -Uses only the stdlib so it works inside a minimal image. +Uses only the stdlib so it works inside a minimal image, and deliberately +avoids the broad ``urllib.request.urlopen`` opener (which accepts ``file://`` +and other schemes): the probe target is scheme-validated and requested over an +explicit plain-HTTP client because it only ever talks to the local listener. """ from __future__ import annotations +import http.client import json import sys -import urllib.request +from urllib.parse import urlsplit DEFAULT_URL = "http://127.0.0.1:8099/healthz" def main(url: str = DEFAULT_URL) -> int: """Check the configured health endpoint and return a shell status code.""" + parsed = urlsplit(url) + if parsed.scheme != "http" or not parsed.hostname: + print(f"healthcheck failed: unsupported probe url {url!r}", file=sys.stderr) + return 1 try: - with urllib.request.urlopen(url, timeout=5) as response: # noqa: S310 + connection = http.client.HTTPConnection( + parsed.hostname, parsed.port or 80, timeout=5 + ) + try: + connection.request("GET", parsed.path or "/") + response = connection.getresponse() body = json.loads(response.read().decode("utf-8")) + finally: + connection.close() except Exception as exc: # pragma: no cover - network failure path print(f"healthcheck failed: {exc}", file=sys.stderr) return 1 diff --git a/services/account_unification/tests/test_healthcheck.py b/services/account_unification/tests/test_healthcheck.py index e538b16..9bcdfc3 100644 --- a/services/account_unification/tests/test_healthcheck.py +++ b/services/account_unification/tests/test_healthcheck.py @@ -8,43 +8,83 @@ class _Response: def __init__(self, payload: bytes) -> None: self._payload = payload - def __enter__(self) -> "_Response": - return self - - def __exit__(self, *args: object) -> None: - return None - def read(self) -> bytes: return self._payload -def test_healthcheck_returns_zero_for_ok_status(monkeypatch, capsys): - def fake_urlopen(url: str, *, timeout: int) -> _Response: - assert url == "http://service/healthz" - assert timeout == 5 - return _Response(b'{"status":"ok"}') +class _Connection: + """Recorded stand-in for the explicit plain-HTTP probe client.""" + + last: "_Connection | None" = None + + def __init__( + self, host: str, port: int, timeout: int, payload: bytes, error: Exception | None + ) -> None: + self.host = host + self.port = port + self.timeout = timeout + self.requested_path: str | None = None + self.closed = False + self._payload = payload + self._error = error + _Connection.last = self + + def request(self, method: str, path: str) -> None: + assert method == "GET" + self.requested_path = path + if self._error is not None: + raise self._error + + def getresponse(self) -> _Response: + return _Response(self._payload) + + def close(self) -> None: + self.closed = True + - monkeypatch.setattr(healthcheck.urllib.request, "urlopen", fake_urlopen) +def _patch_connection( + monkeypatch, payload: bytes = b"{}", error: Exception | None = None +) -> None: + def factory(host: str, port: int, timeout: int) -> _Connection: + return _Connection(host, port, timeout, payload, error) + + monkeypatch.setattr(healthcheck.http.client, "HTTPConnection", factory) + + +def test_healthcheck_returns_zero_for_ok_status(monkeypatch, capsys): + _patch_connection(monkeypatch, payload=b'{"status":"ok"}') assert healthcheck.main("http://service/healthz") == 0 assert capsys.readouterr().out == "ok\n" + connection = _Connection.last + assert connection is not None + assert (connection.host, connection.port) == ("service", 80) + assert connection.timeout == 5 + assert connection.requested_path == "/healthz" + assert connection.closed is True def test_healthcheck_returns_one_for_non_ok_status(monkeypatch, capsys): - def fake_urlopen(url: str, *, timeout: int) -> _Response: - return _Response(b'{"status":"starting"}') - - monkeypatch.setattr(healthcheck.urllib.request, "urlopen", fake_urlopen) + _patch_connection(monkeypatch, payload=b'{"status":"starting"}') assert healthcheck.main("http://service/healthz") == 1 assert "not ready" in capsys.readouterr().err def test_healthcheck_returns_one_for_request_error(monkeypatch, capsys): - def fake_urlopen(url: str, *, timeout: int) -> _Response: - raise OSError("connection refused") - - monkeypatch.setattr(healthcheck.urllib.request, "urlopen", fake_urlopen) + _patch_connection(monkeypatch, error=OSError("connection refused")) assert healthcheck.main("http://service/healthz") == 1 assert "healthcheck failed: connection refused" in capsys.readouterr().err + + +def test_healthcheck_rejects_non_http_probe_schemes(monkeypatch, capsys): + # The probe must never act as a broad URL opener: file:// and other + # schemes are refused before any request is issued. + def unexpected_factory(*args: object, **kwargs: object) -> None: + raise AssertionError("no connection may be opened for non-http URLs") + + monkeypatch.setattr(healthcheck.http.client, "HTTPConnection", unexpected_factory) + + assert healthcheck.main("file:///etc/passwd") == 1 + assert "unsupported probe url" in capsys.readouterr().err From 446c8a08675ec6ff61dcdf7b9e480654cdc52107 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 21 Jul 2026 14:54:14 +0900 Subject: [PATCH 004/104] fix(security): authenticate the admin API and harden identifier + secret handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strix flagged the account-unification service (which holds realm-management privileges) as reachable unauthenticated, plus path-traversal and secret-in-argv issues. Remediated without suppression: - Operator bearer auth (app/auth.py) now gates every privileged router — merge, identity reads, SCIM provisioning/deactivation, and the federation registry — via a shared operator token from the KV/DB config store (constant-time compare, fails closed when unconfigured). /healthz stays open for probes. (VULN-0001 merge, VULN-0002 federation, VULN-0003 SCIM, VULN-0007 reads) - Path-segment identifier validation (app/identifiers.py) rejects '/', '\\', '.', '..', percent-encoding, and control characters. Applied at the API/SCIM boundary (400) and as a centralized chokepoint inside the Admin REST client before any URL is built, so a user_id like '../users/victim' or '%2e%2e' cannot escape the intended resource. (VULN-0006) - kcadm bootstrap no longer passes the admin password on argv: it fetches a short-lived admin token via curl with the password sourced from a 0600 temp file (--data-urlencode "@file"), then configures kcadm with that bearer token. (VULN-0005) - The Helm chart supports and documents an immutable image digest for the privileged account-unification image, rendered as tag@sha256 when set. (VULN-0004) New tests: operator-auth gating (missing/wrong/valid token, fail-closed, /healthz open), path-segment validation + Admin-client traversal rejection via MockTransport. Full suite passes (73 tests); ruff clean; interrogate 97.4%; validate_realm.py OK. Co-Authored-By: Claude Fable 5 --- deploy/keycloak/kcadm-bootstrap.sh | 31 ++++++- .../templates/account-unification.yaml | 2 +- helm/cwl-idp/values.yaml | 6 ++ services/account_unification/app/api.py | 11 +++ services/account_unification/app/auth.py | 51 ++++++++++++ services/account_unification/app/config.py | 6 ++ .../account_unification/app/identifiers.py | 43 ++++++++++ .../app/keycloak_client.py | 38 ++++++++- services/account_unification/app/main.py | 11 ++- services/account_unification/app/scim.py | 13 +++ .../account_unification/tests/conftest.py | 15 ++++ .../account_unification/tests/test_api.py | 5 +- .../account_unification/tests/test_audit.py | 1 + .../account_unification/tests/test_auth.py | 65 +++++++++++++++ .../account_unification/tests/test_config.py | 2 + .../tests/test_federation.py | 5 +- .../tests/test_identifiers.py | 83 +++++++++++++++++++ .../account_unification/tests/test_scim.py | 5 +- 18 files changed, 378 insertions(+), 15 deletions(-) create mode 100644 services/account_unification/app/auth.py create mode 100644 services/account_unification/app/identifiers.py create mode 100644 services/account_unification/tests/test_auth.py create mode 100644 services/account_unification/tests/test_identifiers.py diff --git a/deploy/keycloak/kcadm-bootstrap.sh b/deploy/keycloak/kcadm-bootstrap.sh index 513f99d..b3aa6a8 100755 --- a/deploy/keycloak/kcadm-bootstrap.sh +++ b/deploy/keycloak/kcadm-bootstrap.sh @@ -20,9 +20,38 @@ KC_SERVER="${KC_SERVER:-http://localhost:8080}" ADMIN_USER="$(kv get secret/idp/bootstrap-admin-username)" ADMIN_PASS="$(kv get secret/idp/bootstrap-admin-password)" +# Do NOT pass the admin password on the kcadm.sh command line: argv is visible +# to any same-host process via /proc//cmdline. Obtain a short-lived admin +# token by handing the password to curl through a restricted temp file +# (--data-urlencode "@file", never argv), then configure kcadm with that +# bearer token. The reusable password never appears in any process's argv. +_pass_file="$(mktemp)" +chmod 600 "${_pass_file}" +_kcadm_token="" +cleanup() { + rm -f "${_pass_file}" + unset ADMIN_PASS _kcadm_token +} +trap cleanup EXIT +printf '%s' "${ADMIN_PASS}" > "${_pass_file}" +unset ADMIN_PASS + +_kcadm_token="$(curl -sf \ + --data-urlencode "grant_type=password" \ + --data-urlencode "client_id=admin-cli" \ + --data-urlencode "username=${ADMIN_USER}" \ + --data-urlencode "password@${_pass_file}" \ + "${KC_SERVER}/realms/master/protocol/openid-connect/token" \ + | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')" +rm -f "${_pass_file}" +if [ -z "${_kcadm_token}" ]; then + echo "ERROR: failed to obtain a bootstrap admin token" >&2 + exit 1 +fi + kcadm.sh config credentials \ --server "${KC_SERVER}" --realm master \ - --user "${ADMIN_USER}" --password "${ADMIN_PASS}" + --token "${_kcadm_token}" # NOTE: external federation (the employer ADFS SAML IdP, corporate LDAP/AD) # is deliberately NOT part of this bootstrap. Those are deployment data, not diff --git a/helm/cwl-idp/templates/account-unification.yaml b/helm/cwl-idp/templates/account-unification.yaml index 26c6d5b..8ee66fe 100644 --- a/helm/cwl-idp/templates/account-unification.yaml +++ b/helm/cwl-idp/templates/account-unification.yaml @@ -27,7 +27,7 @@ spec: type: RuntimeDefault containers: - name: account-unification - image: "{{ .Values.accountUnification.image.repository }}:{{ .Values.accountUnification.image.tag }}" + image: "{{ .Values.accountUnification.image.repository }}:{{ .Values.accountUnification.image.tag }}{{ if .Values.accountUnification.image.digest }}@{{ .Values.accountUnification.image.digest }}{{ end }}" imagePullPolicy: {{ .Values.accountUnification.image.pullPolicy }} securityContext: allowPrivilegeEscalation: false diff --git a/helm/cwl-idp/values.yaml b/helm/cwl-idp/values.yaml index f30514a..014ef7c 100644 --- a/helm/cwl-idp/values.yaml +++ b/helm/cwl-idp/values.yaml @@ -7,6 +7,12 @@ accountUnification: image: repository: cwl-idp/account-unification tag: "0.2.0" + # Pin an immutable digest in production so a mutable-tag replacement of this + # privileged (realm-management) service is impossible. The keycloak and + # postgres images below already pin one; set this to the built image's + # sha256 digest (rendered as repository:tag@sha256:...). Left empty here + # because the image is built locally in dev/CI; deployments must set it. + digest: "" pullPolicy: IfNotPresent replicaCount: 1 service: diff --git a/services/account_unification/app/api.py b/services/account_unification/app/api.py index 3e8119b..b974e33 100644 --- a/services/account_unification/app/api.py +++ b/services/account_unification/app/api.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request from .audit import AuditLogger +from .identifiers import InvalidIdentifierError, validate_path_segment from .errors import ( InactiveAccountError, NoMatchError, @@ -33,9 +34,18 @@ def get_audit(request: Request) -> AuditLogger: return audit +def _safe_identifier(value: str, field_name: str) -> str: + """Validate a path-segment identifier at the API boundary (400 on failure).""" + try: + return validate_path_segment(value, field_name=field_name) + except InvalidIdentifierError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.get("/users/{user_id}", response_model=UserAccount, tags=["identities"]) def get_user(user_id: str, service: UnificationService = Depends(get_service)) -> UserAccount: """Return one account and its merge-relevant identity state.""" + user_id = _safe_identifier(user_id, "user_id") try: return service.get_account(user_id) except UserNotFoundError as exc: @@ -51,6 +61,7 @@ def list_identities( user_id: str, service: UnificationService = Depends(get_service) ) -> list[FederatedIdentity]: """List one user's external identities (federated identities).""" + user_id = _safe_identifier(user_id, "user_id") try: return service.list_identities(user_id) except UserNotFoundError as exc: diff --git a/services/account_unification/app/auth.py b/services/account_unification/app/auth.py new file mode 100644 index 0000000..8ff2a8f --- /dev/null +++ b/services/account_unification/app/auth.py @@ -0,0 +1,51 @@ +"""Operator bearer-token authentication for the admin API surface. + +The account-unification service exposes privileged operations — account +merge, SCIM provisioning/deactivation, and the federation registry — that must +never be reachable unauthenticated. Every mutating and identity-reading route +requires an operator bearer token; ``/healthz`` stays open for probes. + +The token is a shared operator secret loaded from the KV/DB config store +(``operator_api_token``), compared in constant time. This is deliberately a +coarse operator gate: the service already runs behind the ecosystem network +boundary and holds realm-management privileges, so the token gates *access to +the service*, and finer per-action authorization remains Keycloak's job. +""" +from __future__ import annotations + +import hmac + +from fastapi import Depends, Header, HTTPException, Request + + +def _configured_operator_token(request: Request) -> str | None: + """Return the operator token wired into application state, if any.""" + return getattr(request.app.state, "operator_api_token", None) + + +def require_operator_token( + request: Request, + authorization: str | None = Header(default=None), +) -> None: + """Authenticate an operator bearer token; raise 401/403 otherwise. + + Fails closed: a service started without a configured operator token rejects + every authenticated request rather than allowing open access. + """ + expected = _configured_operator_token(request) + if not expected: + # No token configured => the privileged surface is unavailable, never + # implicitly open. + raise HTTPException(status_code=503, detail="operator authentication unavailable") + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException( + status_code=401, + detail="operator bearer token required", + headers={"WWW-Authenticate": "Bearer"}, + ) + presented = authorization[len("Bearer ") :].strip() + if not hmac.compare_digest(presented, expected): + raise HTTPException(status_code=403, detail="invalid operator token") + + +operator_auth_dependency = Depends(require_operator_token) diff --git a/services/account_unification/app/config.py b/services/account_unification/app/config.py index 4061bd7..90ce59e 100644 --- a/services/account_unification/app/config.py +++ b/services/account_unification/app/config.py @@ -19,6 +19,7 @@ KEY_MERGE_CONFLICT_POLICY = "merge_conflict_policy" KEY_ALLOW_UNVERIFIED_LINK = "allow_unverified_email_link" KEY_REQUEST_TIMEOUT_SECONDS = "request_timeout_seconds" +KEY_OPERATOR_API_TOKEN = "operator_api_token" @dataclass(frozen=True) @@ -32,6 +33,10 @@ class ServiceConfig: keycloak_realm: str keycloak_client_id: str keycloak_client_secret: str + # Shared operator bearer token gating the privileged admin API surface + # (merge, SCIM, federation, identity reads). Required: the service must not + # start with an open privileged surface. + operator_api_token: str merge_conflict_policy: str = "survivor_wins" # Hard default False: the ecosystem policy forbids linking/merging on an # unverified email. Present as config only so audits can prove it is off. @@ -63,6 +68,7 @@ def load_service_config(store: KvStore, namespace: str) -> ServiceConfig: keycloak_realm=_require(store, namespace, KEY_KEYCLOAK_REALM), keycloak_client_id=_require(store, namespace, KEY_KEYCLOAK_CLIENT_ID), keycloak_client_secret=_require(store, namespace, KEY_KEYCLOAK_CLIENT_SECRET), + operator_api_token=_require(store, namespace, KEY_OPERATOR_API_TOKEN), merge_conflict_policy=store.get(namespace, KEY_MERGE_CONFLICT_POLICY) or "survivor_wins", allow_unverified_email_link=_as_bool( diff --git a/services/account_unification/app/identifiers.py b/services/account_unification/app/identifiers.py new file mode 100644 index 0000000..a6c4f14 --- /dev/null +++ b/services/account_unification/app/identifiers.py @@ -0,0 +1,43 @@ +"""Opaque-identifier validation for Keycloak Admin REST path segments. + +Every caller-supplied identifier (user id, provider alias, audit id) is +interpolated into an Admin API URL path. A value like ``../users/victim`` or a +percent-encoded separator would let a caller escape the intended resource, so +identifiers are validated as a single opaque path segment before any URL is +built. This is the defense-in-depth layer applied inside the Admin client +itself, independent of any boundary validation. +""" +from __future__ import annotations + +# Reject empty, oversized, path-separator, dot-navigation, percent-encoded, and +# control-character identifiers. Keycloak ids are UUIDs and aliases are slugs, +# so a conservative allowlist is safe. +_MAX_IDENTIFIER_LENGTH = 255 + + +class InvalidIdentifierError(ValueError): + """Raised when an identifier is not a single safe path segment.""" + + +def validate_path_segment(value: str, *, field_name: str = "identifier") -> str: + """Return ``value`` if it is one safe opaque path segment, else raise. + + Rejects empty/oversized values, ``/`` and ``\\`` separators, ``.``/``..`` + navigation, percent-encoding (``%``), and control characters. + """ + if not isinstance(value, str) or not value: + raise InvalidIdentifierError(f"{field_name} must be a non-empty string") + if len(value) > _MAX_IDENTIFIER_LENGTH: + raise InvalidIdentifierError(f"{field_name} is too long") + if value in {".", ".."}: + raise InvalidIdentifierError(f"{field_name} must not be a path navigation token") + for character in value: + if character in {"/", "\\", "%"}: + raise InvalidIdentifierError( + f"{field_name} must not contain path separators or percent-encoding" + ) + if ord(character) < 0x20 or ord(character) == 0x7F: + raise InvalidIdentifierError( + f"{field_name} must not contain control characters" + ) + return value diff --git a/services/account_unification/app/keycloak_client.py b/services/account_unification/app/keycloak_client.py index f89d936..efec72b 100644 --- a/services/account_unification/app/keycloak_client.py +++ b/services/account_unification/app/keycloak_client.py @@ -29,6 +29,7 @@ from typing import Protocol +from .identifiers import InvalidIdentifierError from .models import FederatedIdentity, GroupMembership, RoleMapping, UserAccount @@ -395,27 +396,56 @@ def delete_identity_provider(self, provider_alias: str) -> None: ) # -- transport --------------------------------------------------------- + @staticmethod + def _guard_path(path: str) -> str: + """Reject a request path that shows path-traversal or encoding. + + Caller-controlled ids (user_id, provider_alias, group_id, ...) are + interpolated into these paths. This centralized chokepoint rejects any + ``.``/``..`` segment and any percent-encoding regardless of which id + introduced it, so an id like ``../users/victim`` or ``%2e%2e`` cannot + escape the intended Admin REST resource. Every static segment here is a + UUID/slug/literal, so ``%`` never legitimately appears. + """ + if "%" in path: + raise InvalidIdentifierError("request path must not contain percent-encoding") + segments = path.split("/") + for index, segment in enumerate(segments): + if segment in {".", ".."}: + raise InvalidIdentifierError("request path must not navigate directories") + # A middle segment is never legitimately empty (only a trailing + # slash may be), so an empty middle segment means an empty id. + if segment == "" and 0 < index < len(segments) - 1: + raise InvalidIdentifierError("request path must not contain empty segments") + return path + def _get(self, path: str, params: dict | None = None) -> dict | list: """Issue an authenticated GET and parse JSON.""" - response = self._client.get(path, params=params, headers=self._auth_header()) + response = self._client.get( + self._guard_path(path), params=params, headers=self._auth_header() + ) response.raise_for_status() return response.json() def _post(self, path: str, body) -> dict: """Issue an authenticated POST and parse optional JSON.""" - response = self._client.post(path, json=body, headers=self._auth_header()) + response = self._client.post( + self._guard_path(path), json=body, headers=self._auth_header() + ) response.raise_for_status() return response.json() if response.content else {} def _put(self, path: str, body: dict) -> None: """Issue an authenticated PUT.""" - response = self._client.put(path, json=body, headers=self._auth_header()) + response = self._client.put( + self._guard_path(path), json=body, headers=self._auth_header() + ) response.raise_for_status() def _delete(self, path: str, body=None) -> None: """Issue an authenticated DELETE with an optional JSON body.""" request = self._client.build_request( - "DELETE", path, json=body, headers=self._auth_header() + "DELETE", self._guard_path(path), json=body, headers=self._auth_header() ) response = self._client.send(request) response.raise_for_status() diff --git a/services/account_unification/app/main.py b/services/account_unification/app/main.py index 87c9d97..2ec049f 100644 --- a/services/account_unification/app/main.py +++ b/services/account_unification/app/main.py @@ -15,6 +15,7 @@ from . import __version__ from .api import router from .audit import AuditLogger, SqliteAuditSink +from .auth import operator_auth_dependency from .bootstrap import load_bootstrap_descriptor, open_config_store from .config import load_service_config from .federation import FederationService, federation_router @@ -47,6 +48,8 @@ def build_service(app: FastAPI) -> None: # External IdPs (employer ADFS etc.) are runtime data in the KV/DB store, # never realm code; this service converges Keycloak from that store. app.state.federation_service = FederationService(store, api) + # Gate the privileged admin surface on the operator bearer token. + app.state.operator_api_token = config.operator_api_token app.state.ready = True @@ -77,9 +80,11 @@ def healthz() -> dict: "version": __version__, } - app.include_router(router) - app.include_router(scim_router) - app.include_router(federation_router) + # Every privileged router requires the operator bearer token; /healthz is + # registered directly on the app above and stays open for probes. + app.include_router(router, dependencies=[operator_auth_dependency]) + app.include_router(scim_router, dependencies=[operator_auth_dependency]) + app.include_router(federation_router, dependencies=[operator_auth_dependency]) return app diff --git a/services/account_unification/app/scim.py b/services/account_unification/app/scim.py index 2cb7609..08767d2 100644 --- a/services/account_unification/app/scim.py +++ b/services/account_unification/app/scim.py @@ -18,6 +18,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response +from .identifiers import InvalidIdentifierError, validate_path_segment from .keycloak_client import AdminApi from .models import UserAccount @@ -46,6 +47,14 @@ def _scim_error(status: int, detail: str) -> HTTPException: ) +def _safe_user_id(user_id: str) -> str: + """Validate a SCIM user id as one safe path segment (400 on failure).""" + try: + return validate_path_segment(user_id, field_name="user_id") + except InvalidIdentifierError as exc: + raise _scim_error(400, str(exc)) from exc + + def _primary_email(resource: dict[str, Any]) -> str | None: """Return the primary SCIM email, falling back to the first email.""" emails = resource.get("emails") or [] @@ -146,6 +155,7 @@ def get_user( user_id: str, provisioner: AdminApi = Depends(get_provisioner) ) -> Response: """Return one provisioned user as a SCIM resource.""" + user_id = _safe_user_id(user_id) try: user = provisioner.get_user(user_id) except KeyError as exc: @@ -188,6 +198,7 @@ def replace_user( provisioner: AdminApi = Depends(get_provisioner), ) -> Response: """Replace a provisioned user from a SCIM PUT request.""" + user_id = _safe_user_id(user_id) try: provisioner.get_user(user_id) except KeyError as exc: @@ -204,6 +215,7 @@ def patch_user( provisioner: AdminApi = Depends(get_provisioner), ) -> Response: """Apply the supported SCIM PATCH operations to one user.""" + user_id = _safe_user_id(user_id) try: provisioner.get_user(user_id) except KeyError as exc: @@ -227,6 +239,7 @@ def delete_user( user_id: str, provisioner: AdminApi = Depends(get_provisioner) ) -> Response: """Soft-delete a user by disabling the Keycloak account.""" + user_id = _safe_user_id(user_id) try: provisioner.get_user(user_id) except KeyError as exc: diff --git a/services/account_unification/tests/conftest.py b/services/account_unification/tests/conftest.py index 3deba76..02efe34 100644 --- a/services/account_unification/tests/conftest.py +++ b/services/account_unification/tests/conftest.py @@ -30,6 +30,20 @@ def audit(audit_sink: InMemoryAuditSink) -> AuditLogger: return AuditLogger(audit_sink) +OPERATOR_TOKEN = "test-operator-token" + + +@pytest.fixture +def operator_token() -> str: + return OPERATOR_TOKEN + + +@pytest.fixture +def auth_header(operator_token: str) -> dict[str, str]: + """Default operator bearer header for authenticated admin requests.""" + return {"Authorization": f"Bearer {operator_token}"} + + @pytest.fixture def config() -> ServiceConfig: return ServiceConfig( @@ -37,6 +51,7 @@ def config() -> ServiceConfig: keycloak_realm="cwl", keycloak_client_id="account-unification-svc", keycloak_client_secret="test-secret", + operator_api_token=OPERATOR_TOKEN, merge_conflict_policy="survivor_wins", allow_unverified_email_link=False, ) diff --git a/services/account_unification/tests/test_api.py b/services/account_unification/tests/test_api.py index 51c5693..c2a754c 100644 --- a/services/account_unification/tests/test_api.py +++ b/services/account_unification/tests/test_api.py @@ -9,14 +9,15 @@ @pytest.fixture -def client(api, audit, config): +def client(api, audit, config, auth_header): from app.service import UnificationService app = create_app(wire=False) app.state.unification_service = UnificationService(api, audit, config) app.state.audit_logger = audit app.state.keycloak_api = api - with TestClient(app) as test_client: + app.state.operator_api_token = config.operator_api_token + with TestClient(app, headers=auth_header) as test_client: yield test_client diff --git a/services/account_unification/tests/test_audit.py b/services/account_unification/tests/test_audit.py index a12de6d..5e9163a 100644 --- a/services/account_unification/tests/test_audit.py +++ b/services/account_unification/tests/test_audit.py @@ -69,6 +69,7 @@ def test_sqlite_audit_sink_persists(tmp_path): keycloak_realm="cwl", keycloak_client_id="svc", keycloak_client_secret="secret", + operator_api_token="op-token", ) service = UnificationService(api, audit, config) result = service.merge_accounts( diff --git a/services/account_unification/tests/test_auth.py b/services/account_unification/tests/test_auth.py new file mode 100644 index 0000000..95dd9d0 --- /dev/null +++ b/services/account_unification/tests/test_auth.py @@ -0,0 +1,65 @@ +"""Operator bearer auth gates the privileged admin surface; /healthz is open.""" +from __future__ import annotations + +import sys +from pathlib import Path + +from fastapi.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.federation import FederationService # noqa: E402 +from app.kv_store import InMemoryKvStore # noqa: E402 +from app.main import create_app # noqa: E402 +from app.service import UnificationService # noqa: E402 + +from .mock_keycloak import MockKeycloakAdminApi # noqa: E402 + + +def _wired_app(api: MockKeycloakAdminApi, audit, config): + app = create_app(wire=False) + app.state.unification_service = UnificationService(api, audit, config) + app.state.audit_logger = audit + app.state.keycloak_api = api + app.state.federation_service = FederationService(InMemoryKvStore(), api) + app.state.operator_api_token = config.operator_api_token + return app + + +def test_healthz_is_open_without_a_token(api, audit, config): + client = TestClient(_wired_app(api, audit, config)) + response = client.get("/healthz") + assert response.status_code == 200 + + +def test_privileged_routes_reject_missing_token(api, audit, config): + client = TestClient(_wired_app(api, audit, config)) + + assert client.get("/users/u1").status_code == 401 + assert client.post("/merges", json={}).status_code == 401 + assert client.get("/federation/identity-providers").status_code == 401 + assert client.post("/scim/v2/Users", json={"userName": "x"}).status_code == 401 + + +def test_privileged_routes_reject_wrong_token(api, audit, config): + client = TestClient( + _wired_app(api, audit, config), + headers={"Authorization": "Bearer not-the-token"}, + ) + assert client.get("/federation/identity-providers").status_code == 403 + + +def test_privileged_routes_accept_valid_token(api, audit, config, auth_header): + client = TestClient(_wired_app(api, audit, config), headers=auth_header) + # 200 with the correct token (empty registry list), not 401/403. + assert client.get("/federation/identity-providers").status_code == 200 + + +def test_service_without_configured_token_fails_closed(api, audit, config): + app = create_app(wire=False) + app.state.unification_service = UnificationService(api, audit, config) + app.state.audit_logger = audit + app.state.keycloak_api = api + # No operator_api_token wired: privileged surface is unavailable, never open. + client = TestClient(app, headers={"Authorization": "Bearer anything"}) + assert client.get("/users/u1").status_code == 503 diff --git a/services/account_unification/tests/test_config.py b/services/account_unification/tests/test_config.py index 2620fd4..2d1f11a 100644 --- a/services/account_unification/tests/test_config.py +++ b/services/account_unification/tests/test_config.py @@ -39,6 +39,7 @@ def test_config_loads_from_kv(): "keycloak_realm": "cwl", "keycloak_client_id": "svc", "keycloak_client_secret": "secret", + "operator_api_token": "op-token", } } ) @@ -63,6 +64,7 @@ def test_bootstrap_points_at_sqlite_store(tmp_path): seed.put("account_unification", "keycloak_realm", "cwl") seed.put("account_unification", "keycloak_client_id", "svc") seed.put("account_unification", "keycloak_client_secret", "secret") + seed.put("account_unification", "operator_api_token", "op-token") bootstrap = tmp_path / "bootstrap.yaml" bootstrap.write_text( diff --git a/services/account_unification/tests/test_federation.py b/services/account_unification/tests/test_federation.py index 591364b..b68b7f0 100644 --- a/services/account_unification/tests/test_federation.py +++ b/services/account_unification/tests/test_federation.py @@ -130,10 +130,11 @@ def test_alias_and_provider_id_validation(federation: FederationService) -> None assert getattr(invalid_provider.value, "status_code", None) == 400 -def test_http_surface_round_trip(api: MockKeycloakAdminApi) -> None: +def test_http_surface_round_trip(api: MockKeycloakAdminApi, auth_header) -> None: app = create_app(wire=False) app.state.federation_service = FederationService(InMemoryKvStore(), api) - client = TestClient(app) + app.state.operator_api_token = "test-operator-token" + client = TestClient(app, headers=auth_header) body = _employer_adfs_registration().model_dump() put_response = client.put("/federation/identity-providers/employer-adfs", json=body) diff --git a/services/account_unification/tests/test_identifiers.py b/services/account_unification/tests/test_identifiers.py new file mode 100644 index 0000000..d4ee532 --- /dev/null +++ b/services/account_unification/tests/test_identifiers.py @@ -0,0 +1,83 @@ +"""Path-segment identifier validation blocks Admin REST path traversal.""" +from __future__ import annotations + +import sys +from pathlib import Path + +import httpx +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.identifiers import ( # noqa: E402 + InvalidIdentifierError, + validate_path_segment, +) +from app.keycloak_client import HttpAdminApi # noqa: E402 + + +@pytest.mark.parametrize( + "bad_value", + [ + "", + ".", + "..", + "../victim", + "a/b", + "a\\b", + "%2e%2e", + "a%2fb", + "line\nbreak", + "null\x00byte", + ], +) +def test_validate_path_segment_rejects_unsafe(bad_value): + with pytest.raises(InvalidIdentifierError): + validate_path_segment(bad_value, field_name="user_id") + + +def test_validate_path_segment_accepts_uuid_and_slug(): + assert validate_path_segment("f70ac86c-dbc9-4b55-bace-c3486827a136") == ( + "f70ac86c-dbc9-4b55-bace-c3486827a136" + ) + assert validate_path_segment("employer-adfs") == "employer-adfs" + + +def test_admin_client_guard_rejects_traversal_before_request(): + seen: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.url.path) + if request.url.path.endswith("/token"): + return httpx.Response(200, json={"access_token": "t"}) + return httpx.Response(200, json={"id": "x"}) + + api = HttpAdminApi( + server_url="http://keycloak.test", + realm="cwl", + client_id="account-unification-svc", + client_secret="secret", + transport=httpx.MockTransport(handler), + ) + + # A traversal id must be rejected before any user request is issued. + with pytest.raises(InvalidIdentifierError): + api.get_user("../users/victim") + assert not any("victim" in path for path in seen) + + +def test_admin_client_allows_safe_id(): + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/token"): + return httpx.Response(200, json={"access_token": "t"}) + return httpx.Response(200, json={"id": "safe-id", "username": "u"}) + + api = HttpAdminApi( + server_url="http://keycloak.test", + realm="cwl", + client_id="account-unification-svc", + client_secret="secret", + transport=httpx.MockTransport(handler), + ) + user = api.get_user("safe-id") + assert user.user_id == "safe-id" diff --git a/services/account_unification/tests/test_scim.py b/services/account_unification/tests/test_scim.py index bdcef85..89450eb 100644 --- a/services/account_unification/tests/test_scim.py +++ b/services/account_unification/tests/test_scim.py @@ -8,10 +8,11 @@ @pytest.fixture -def client(api): +def client(api, config, auth_header): app = create_app(wire=False) app.state.keycloak_api = api - with TestClient(app) as test_client: + app.state.operator_api_token = config.operator_api_token + with TestClient(app, headers=auth_header) as test_client: yield test_client From 8cf239ffdc53c5297acdc01197c7babcf6860de7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 21 Jul 2026 15:10:43 +0900 Subject: [PATCH 005/104] chore: re-trigger required reviews after enabling keyverse in the robot-review dispatch allowlist Co-Authored-By: Claude Fable 5 From a2155744b751a27ae57a7bd75135379336e04b24 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 22 Jul 2026 12:11:59 +0900 Subject: [PATCH 006/104] feat(realm): email-first self-registration under the passkey contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The login page offered no way to create an account, so a fresh deployment could onboard nobody without operator kcadm surgery. Registration is now allowed with the email address as the account identity; the throwaway registration password never becomes a usable credential because the browser flow has no password authenticator and the default webauthn-register-passwordless required action enrolls a passkey in the first session. verifyEmail stays false while the realm has no smtpServer — the validator now enforces both pairings fail-closed (email-first + default passkey enrollment when registration is on; verifyEmail only with SMTP), each proven by mutation tests. Evidence: scripts/validate_realm.py OK on the shipped realm and rejects all three mutations; account-unification pytest 77 passed. Co-Authored-By: Claude Fable 5 --- deploy/keycloak/README.md | 9 +++++++-- deploy/keycloak/realm-cwl.json | 6 +++--- docs/passwordless-policy.md | 4 +++- scripts/validate_realm.py | 30 +++++++++++++++++++++++++++++- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/deploy/keycloak/README.md b/deploy/keycloak/README.md index f19a9bd..bce2753 100644 --- a/deploy/keycloak/README.md +++ b/deploy/keycloak/README.md @@ -13,9 +13,14 @@ imported at container start; secrets are patched afterwards from the KV store. `realm-cwl.json` defines an authentication flow **`browser-passwordless`** with `auth-username-form` → `webauthn-authenticator-passwordless` and **no password authenticator**, and binds it as the realm `browserFlow`. Combined with -`resetPasswordAllowed:false`, `registrationAllowed:false`, and a default +`resetPasswordAllowed:false` and a default `webauthn-register-passwordless` required action, ecosystem-local accounts -authenticate with a **passkey (FIDO2/WebAuthn)**, never a password. See +authenticate with a **passkey (FIDO2/WebAuthn)**, never a password. Self-service +signup is allowed (`registrationAllowed:true`, `registrationEmailAsUsername:true`): +the registration form's throwaway password never becomes a usable login +credential, because the first session immediately enrolls a passkey and the +browser flow has no password authenticator. `verifyEmail` stays `false` until a +realm `smtpServer` is configured (the validator enforces that pairing). See [`../../docs/passwordless-policy.md`](../../docs/passwordless-policy.md). ## What is committed vs. patched from KV diff --git a/deploy/keycloak/realm-cwl.json b/deploy/keycloak/realm-cwl.json index f164d3c..23929ad 100644 --- a/deploy/keycloak/realm-cwl.json +++ b/deploy/keycloak/realm-cwl.json @@ -3,11 +3,11 @@ "displayName": "ContextualWisdom IdP", "enabled": true, "sslRequired": "external", - "registrationAllowed": false, - "registrationEmailAsUsername": false, + "registrationAllowed": true, + "registrationEmailAsUsername": true, "resetPasswordAllowed": false, "rememberMe": false, - "verifyEmail": true, + "verifyEmail": false, "loginWithEmailAllowed": true, "duplicateEmailsAllowed": false, "editUsernameAllowed": false, diff --git a/docs/passwordless-policy.md b/docs/passwordless-policy.md index ef43b9a..528e88b 100644 --- a/docs/passwordless-policy.md +++ b/docs/passwordless-policy.md @@ -16,7 +16,9 @@ Set once at realm import from `deploy/keycloak/realm-cwl.json`: | --- | --- | --- | | `browserFlow` | `browser-passwordless` | Custom flow: username form → **WebAuthn passwordless**, no password authenticator | | `authenticationFlows[browser-passwordless-forms]` | `auth-username-form` + `webauthn-authenticator-passwordless` | Passkey is the primary (and only) knowledge-free factor | -| `registrationAllowed` | `false` | No self-service signup | +| `registrationAllowed` | `true` | Email-first self-service signup; the registration password is throwaway because login never accepts passwords | +| `registrationEmailAsUsername` | `true` | The email address is the account identity | +| `verifyEmail` | `false` (until SMTP) | Must stay `false` while the realm has no `smtpServer`; the validator enforces the pairing | | `resetPasswordAllowed` | `false` | No password-reset surface | | `requiredActions[webauthn-register-passwordless]` | `defaultAction: true` | New users are prompted to enrol a passkey | | `webAuthnPolicyPasswordless*` | RP name / ES256,RS256 / resident key / UV required | Passkey relying-party policy | diff --git a/scripts/validate_realm.py b/scripts/validate_realm.py index 60d6441..fc83960 100644 --- a/scripts/validate_realm.py +++ b/scripts/validate_realm.py @@ -94,8 +94,36 @@ def validate(realm: dict) -> list[str]: "ecosystem policy" ) + # Self-registration is allowed, but only under the email-first passkey + # contract: the account identity is the email address, and the default + # webauthn-register-passwordless required action enrolls a passkey on the + # first session so the throwaway registration password never becomes a + # usable credential (the browser flow has no password authenticator). if realm.get("registrationAllowed", False): - errors.append("registrationAllowed must be false") + if not realm.get("registrationEmailAsUsername", False): + errors.append( + "self-registration requires registrationEmailAsUsername so new " + "accounts keep the email-first identity contract" + ) + passkey_enrollment_is_default = any( + action.get("providerId") == "webauthn-register-passwordless" + and action.get("enabled", False) + and action.get("defaultAction", False) + for action in realm.get("requiredActions", []) + ) + if not passkey_enrollment_is_default: + errors.append( + "self-registration requires the webauthn-register-passwordless " + "required action as an enabled default so every new account " + "enrolls a passkey" + ) + # verifyEmail without a mail server strands every new account on a + # verification screen whose email never arrives. + if realm.get("verifyEmail", False) and not realm.get("smtpServer"): + errors.append( + "verifyEmail requires a realm smtpServer; configure SMTP or disable " + "verifyEmail" + ) if realm.get("resetPasswordAllowed", False): errors.append("credential reset self-service must be false") From 9eacf74497c6f05cbab62da02ca9783209ade9ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 22 Jul 2026 12:39:21 +0900 Subject: [PATCH 007/104] feat(registration): headless signup API with bootstrap-password contract Product frontends now own the signup UX: POST /registration/accounts on the account-unification service (own bearer token, distinct from the operator credential) creates the Keycloak account via the Admin API with an initial password and the webauthn-register-passwordless required action. The IdP-hosted registration form goes back off (registrationAllowed:false). The browser flow gains a browser-passwordless-credentials subflow where the passkey and the credential form are ALTERNATIVE siblings: the form is offered only while the account has no passkey, and the in-process password janitor (plus /registration/password-janitor:run) revokes the bootstrap password after enrollment, keeping the steady state passwordless. The validator enforces this exact bootstrap shape and still bans every other credential-form authenticator. Fresh-bring-up fixes found while wiring this live: the audit sink wrote into the read-only /bootstrap mount (now a separate writable path with a Dockerfile-owned directory), and kcadm-bootstrap.sh granted realm-management roles without scope mappings or a client-role protocol mapper, so the fullScopeAllowed:false service-account token never carried them and every Admin call failed 403. Evidence: validate_realm.py OK plus REQUIRED-password-form and username-password-form mutations rejected; service pytest 90 passed; live compose bring-up serves /healthz ok and POST /registration/accounts returns 201 with the account id. Co-Authored-By: Claude Fable 5 --- deploy/keycloak/README.md | 16 +- deploy/keycloak/kcadm-bootstrap.sh | 13 ++ deploy/keycloak/realm-cwl.json | 31 ++- docs/passwordless-policy.md | 3 +- scripts/validate_realm.py | 42 +++- services/account_unification/Dockerfile | 5 +- services/account_unification/app/config.py | 19 ++ .../app/keycloak_client.py | 54 +++++ services/account_unification/app/main.py | 56 ++++- .../account_unification/app/registration.py | 221 ++++++++++++++++++ .../tests/mock_keycloak.py | 39 ++++ .../tests/test_registration.py | 164 +++++++++++++ .../tools/seed_config_store.py | 6 + 13 files changed, 652 insertions(+), 17 deletions(-) create mode 100644 services/account_unification/app/registration.py create mode 100644 services/account_unification/tests/test_registration.py diff --git a/deploy/keycloak/README.md b/deploy/keycloak/README.md index bce2753..2c85600 100644 --- a/deploy/keycloak/README.md +++ b/deploy/keycloak/README.md @@ -15,12 +15,16 @@ imported at container start; secrets are patched afterwards from the KV store. authenticator**, and binds it as the realm `browserFlow`. Combined with `resetPasswordAllowed:false` and a default `webauthn-register-passwordless` required action, ecosystem-local accounts -authenticate with a **passkey (FIDO2/WebAuthn)**, never a password. Self-service -signup is allowed (`registrationAllowed:true`, `registrationEmailAsUsername:true`): -the registration form's throwaway password never becomes a usable login -credential, because the first session immediately enrolls a passkey and the -browser flow has no password authenticator. `verifyEmail` stays `false` until a -realm `smtpServer` is configured (the validator enforces that pairing). See +authenticate with a **passkey (FIDO2/WebAuthn)** in the steady state. +Self-service signup is **headless**: product frontends (e.g. Naruon) own the +signup page and create accounts through the account-unification service's +`/registration/accounts` API (`registrationAllowed` stays `false`, so the +IdP-hosted registration form never appears). API-registered accounts carry a +bootstrap password that the `browser-passwordless-credentials` subflow offers +ONLY while no passkey exists; the first session enrolls a passkey and the +registration password janitor then revokes the password credential. +`verifyEmail` stays `false` until a realm `smtpServer` is configured (the +validator enforces that pairing). See [`../../docs/passwordless-policy.md`](../../docs/passwordless-policy.md). ## What is committed vs. patched from KV diff --git a/deploy/keycloak/kcadm-bootstrap.sh b/deploy/keycloak/kcadm-bootstrap.sh index b3aa6a8..8368a77 100755 --- a/deploy/keycloak/kcadm-bootstrap.sh +++ b/deploy/keycloak/kcadm-bootstrap.sh @@ -79,6 +79,19 @@ kcadm.sh add-roles -r "${REALM}" \ --rolename view-users --rolename manage-users \ --rolename manage-identity-providers +echo "==> scoping the granted roles into the service-account access token" +# The client is fullScopeAllowed:false (least privilege), so a granted role +# only reaches the token when it is ALSO in the client's scope mappings AND a +# client-role protocol mapper emits resource_access. Without both, every +# Admin REST call from the service fails 403 on a fresh bring-up. +REALM_MGMT_ROLE_JSON="$(kcadm.sh get "clients/${REALM_MGMT_UUID}/roles" -r "${REALM}" \ + --fields id,name \ + | python3 -c 'import json,sys; roles=json.load(sys.stdin); print(json.dumps([r for r in roles if r["name"] in ("view-users","manage-users","manage-identity-providers")]))')" +kcadm.sh create "clients/${SVC_CLIENT_UUID}/scope-mappings/clients/${REALM_MGMT_UUID}" \ + -r "${REALM}" -b "${REALM_MGMT_ROLE_JSON}" +kcadm.sh create "clients/${SVC_CLIENT_UUID}/protocol-mappers/models" -r "${REALM}" \ + -b '{"name":"realm-management roles","protocol":"openid-connect","protocolMapper":"oidc-usermodel-client-role-mapper","config":{"usermodel.clientRoleMapping.clientId":"realm-management","claim.name":"resource_access.realm-management.roles","multivalued":"true","jsonType.label":"String","access.token.claim":"true","id.token.claim":"false","userinfo.token.claim":"false"}}' + echo "==> mirroring the service client secret into KV for the admin service" # The account-unification service reads keycloak_client_secret from KV; keep it # in sync so both sides use the same credential. diff --git a/deploy/keycloak/realm-cwl.json b/deploy/keycloak/realm-cwl.json index 23929ad..d38c9ff 100644 --- a/deploy/keycloak/realm-cwl.json +++ b/deploy/keycloak/realm-cwl.json @@ -3,7 +3,7 @@ "displayName": "ContextualWisdom IdP", "enabled": true, "sslRequired": "external", - "registrationAllowed": true, + "registrationAllowed": false, "registrationEmailAsUsername": true, "resetPasswordAllowed": false, "rememberMe": false, @@ -84,7 +84,7 @@ }, { "alias": "browser-passwordless-forms", - "description": "Username identification then a passwordless passkey (WebAuthn) assertion.", + "description": "Username identification then a passkey assertion, with a password form offered only to bootstrap accounts that have not enrolled a passkey yet.", "providerId": "basic-flow", "topLevel": false, "builtIn": false, @@ -97,10 +97,35 @@ "autheticatorFlow": false, "userSetupAllowed": false }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "browser-passwordless-credentials", + "autheticatorFlow": true, + "userSetupAllowed": false + } + ] + }, + { + "alias": "browser-passwordless-credentials", + "description": "Passkey (steady state) or the bootstrap password (only while the account has no passkey; the janitor revokes it after enrollment).", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": false, + "authenticationExecutions": [ { "authenticator": "webauthn-authenticator-passwordless", "authenticatorFlow": false, - "requirement": "REQUIRED", + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-password-form", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", "priority": 20, "autheticatorFlow": false, "userSetupAllowed": false diff --git a/docs/passwordless-policy.md b/docs/passwordless-policy.md index 528e88b..e555cbe 100644 --- a/docs/passwordless-policy.md +++ b/docs/passwordless-policy.md @@ -16,10 +16,11 @@ Set once at realm import from `deploy/keycloak/realm-cwl.json`: | --- | --- | --- | | `browserFlow` | `browser-passwordless` | Custom flow: username form → **WebAuthn passwordless**, no password authenticator | | `authenticationFlows[browser-passwordless-forms]` | `auth-username-form` + `webauthn-authenticator-passwordless` | Passkey is the primary (and only) knowledge-free factor | -| `registrationAllowed` | `true` | Email-first self-service signup; the registration password is throwaway because login never accepts passwords | +| `registrationAllowed` | `false` | Signup happens on product pages via the account-unification `/registration/accounts` API, never on IdP-hosted forms | | `registrationEmailAsUsername` | `true` | The email address is the account identity | | `verifyEmail` | `false` (until SMTP) | Must stay `false` while the realm has no `smtpServer`; the validator enforces the pairing | | `resetPasswordAllowed` | `false` | No password-reset surface | +| `authenticationFlows[browser-passwordless-credentials]` | passkey + credential form, both ALTERNATIVE | The credential form is a **bootstrap-only** path: it is offered solely to API-registered accounts that have not enrolled a passkey yet, and the registration password janitor revokes the credential after enrollment | | `requiredActions[webauthn-register-passwordless]` | `defaultAction: true` | New users are prompted to enrol a passkey | | `webAuthnPolicyPasswordless*` | RP name / ES256,RS256 / resident key / UV required | Passkey relying-party policy | diff --git a/scripts/validate_realm.py b/scripts/validate_realm.py index fc83960..a5fe04f 100644 --- a/scripts/validate_realm.py +++ b/scripts/validate_realm.py @@ -65,6 +65,36 @@ def _all_authenticators(realm: dict, alias: str, seen: set[str] | None = None) - return found +def _bootstrap_form_violations(realm: dict, bootstrap_form: str) -> list[str]: + """Check every credential-form execution matches the bootstrap shape.""" + violations: list[str] = [] + for flow in realm.get("authenticationFlows", []): + executions = flow.get("authenticationExecutions", []) + for execution in executions: + if execution.get("authenticator") != bootstrap_form: + continue + passkey_sibling = next( + ( + sibling + for sibling in executions + if sibling.get("authenticator") == PASSKEY_AUTHENTICATOR + ), + None, + ) + if ( + execution.get("requirement") != "ALTERNATIVE" + or passkey_sibling is None + or passkey_sibling.get("requirement") != "ALTERNATIVE" + or passkey_sibling.get("priority", 0) >= execution.get("priority", 0) + ): + violations.append( + "the credential-form authenticator is only allowed as an " + "ALTERNATIVE sibling below the passkey authenticator " + f"(bootstrap shape) in flow '{flow.get('alias')}'" + ) + return violations + + def validate(realm: dict) -> list[str]: """Return human-readable policy violations for a realm export.""" errors: list[str] = [] @@ -82,12 +112,22 @@ def validate(realm: dict) -> list[str]: authenticators = _all_authenticators(realm, browser_flow) if not authenticators: errors.append(f"browserFlow '{browser_flow}' has no executions defined") - disallowed_credential_used = authenticators & DISALLOWED_CREDENTIAL_AUTHENTICATORS + # The plain credential form is tolerated ONLY in the bootstrap shape: + # an ALTERNATIVE sibling of the passkey authenticator with lower + # priority, so it is offered solely to accounts that have not enrolled + # a passkey yet (the registration janitor then revokes it). Every + # other credential-form authenticator stays banned outright. + bootstrap_form = f"auth-{_CREDENTIAL_FACTOR}-form" + disallowed_credential_used = ( + authenticators & DISALLOWED_CREDENTIAL_AUTHENTICATORS + ) - {bootstrap_form} if disallowed_credential_used: errors.append( "browserFlow includes a disallowed credential-form authenticator; " "ecosystem policy requires passkeys" ) + if bootstrap_form in authenticators: + errors.extend(_bootstrap_form_violations(realm, bootstrap_form)) if PASSKEY_AUTHENTICATOR not in authenticators: errors.append( "browserFlow must include the passkey authenticator required by " diff --git a/services/account_unification/Dockerfile b/services/account_unification/Dockerfile index 792df93..ba5872b 100644 --- a/services/account_unification/Dockerfile +++ b/services/account_unification/Dockerfile @@ -13,7 +13,10 @@ WORKDIR /srv COPY pyproject.toml uv.lock ./ RUN uv sync --locked --no-dev --no-install-project \ - && adduser --system --no-create-home appuser + && adduser --system --no-create-home appuser \ + # Writable home for the audit database; /bootstrap stays read-only. + && mkdir -p /var/lib/account-unification \ + && chown appuser /var/lib/account-unification COPY app ./app diff --git a/services/account_unification/app/config.py b/services/account_unification/app/config.py index 90ce59e..33f155a 100644 --- a/services/account_unification/app/config.py +++ b/services/account_unification/app/config.py @@ -20,6 +20,9 @@ KEY_ALLOW_UNVERIFIED_LINK = "allow_unverified_email_link" KEY_REQUEST_TIMEOUT_SECONDS = "request_timeout_seconds" KEY_OPERATOR_API_TOKEN = "operator_api_token" +KEY_REGISTRATION_API_TOKEN = "registration_api_token" +KEY_AUDIT_DATABASE_PATH = "audit_database_path" +KEY_PASSWORD_JANITOR_INTERVAL_SECONDS = "password_janitor_interval_seconds" @dataclass(frozen=True) @@ -37,6 +40,16 @@ class ServiceConfig: # (merge, SCIM, federation, identity reads). Required: the service must not # start with an open privileged surface. operator_api_token: str + # Bearer token for the headless self-registration surface, held by product + # frontend backends (e.g. Naruon). Optional: deployments without + # self-signup leave it unset and the surface answers 503, never open. + registration_api_token: str | None = None + # Seconds between bootstrap-password janitor passes; 0 disables the + # periodic task (the operator endpoint still runs passes on demand). + password_janitor_interval_seconds: float = 300.0 + # Audit sink location. Must NOT live inside the read-only /bootstrap + # mount: the config store and the audit trail have different write needs. + audit_database_path: str = "/var/lib/account-unification/audit.db" merge_conflict_policy: str = "survivor_wins" # Hard default False: the ecosystem policy forbids linking/merging on an # unverified email. Present as config only so audits can prove it is off. @@ -69,6 +82,12 @@ def load_service_config(store: KvStore, namespace: str) -> ServiceConfig: keycloak_client_id=_require(store, namespace, KEY_KEYCLOAK_CLIENT_ID), keycloak_client_secret=_require(store, namespace, KEY_KEYCLOAK_CLIENT_SECRET), operator_api_token=_require(store, namespace, KEY_OPERATOR_API_TOKEN), + registration_api_token=store.get(namespace, KEY_REGISTRATION_API_TOKEN) or None, + password_janitor_interval_seconds=float( + store.get(namespace, KEY_PASSWORD_JANITOR_INTERVAL_SECONDS) or "300" + ), + audit_database_path=store.get(namespace, KEY_AUDIT_DATABASE_PATH) + or "/var/lib/account-unification/audit.db", merge_conflict_policy=store.get(namespace, KEY_MERGE_CONFLICT_POLICY) or "survivor_wins", allow_unverified_email_link=_as_bool( diff --git a/services/account_unification/app/keycloak_client.py b/services/account_unification/app/keycloak_client.py index efec72b..dc129e2 100644 --- a/services/account_unification/app/keycloak_client.py +++ b/services/account_unification/app/keycloak_client.py @@ -109,6 +109,26 @@ def set_user_attribute(self, user_id: str, key: str, value: str) -> None: """Set one single-valued user attribute.""" ... + def list_users(self, first_result: int, max_results: int) -> list[UserAccount]: + """Return one page of realm users.""" + ... + + def reset_user_password(self, user_id: str, password_value: str) -> None: + """Set a non-temporary password credential on a user.""" + ... + + def set_user_required_actions(self, user_id: str, action_aliases: list[str]) -> None: + """Replace the pending required actions on a user.""" + ... + + def list_user_credentials(self, user_id: str) -> list[dict]: + """List stored credential representations for a user.""" + ... + + def delete_user_credential(self, user_id: str, credential_id: str) -> None: + """Delete one stored credential from a user.""" + ... + def get_identity_provider(self, provider_alias: str) -> dict | None: """Return one identity-provider instance or ``None`` when absent.""" ... @@ -358,6 +378,40 @@ def set_user_attribute(self, user_id: str, key: str, value: str) -> None: f"/admin/realms/{self._realm}/users/{user_id}", {"attributes": attributes} ) + # -- self-registration support ------------------------------------------ + def list_users(self, first_result: int, max_results: int) -> list[UserAccount]: + """Return one page of realm users (GET /users?first&max).""" + data = self._get( + f"/admin/realms/{self._realm}/users", + params={"first": first_result, "max": max_results}, + ) + return [_parse_user(item) for item in data] + + def reset_user_password(self, user_id: str, password_value: str) -> None: + """Set a non-temporary password (PUT /users/{id}/reset-password).""" + self._put( + f"/admin/realms/{self._realm}/users/{user_id}/reset-password", + {"type": "password", "value": password_value, "temporary": False}, + ) + + def set_user_required_actions(self, user_id: str, action_aliases: list[str]) -> None: + """Replace pending required actions (PUT /users/{id}).""" + self._put( + f"/admin/realms/{self._realm}/users/{user_id}", + {"requiredActions": list(action_aliases)}, + ) + + def list_user_credentials(self, user_id: str) -> list[dict]: + """List credential representations (GET /users/{id}/credentials).""" + data = self._get(f"/admin/realms/{self._realm}/users/{user_id}/credentials") + return [item for item in data if isinstance(item, dict)] + + def delete_user_credential(self, user_id: str, credential_id: str) -> None: + """Delete one credential (DELETE /users/{id}/credentials/{cid}).""" + self._delete( + f"/admin/realms/{self._realm}/users/{user_id}/credentials/{credential_id}" + ) + # -- identity providers (runtime federation registry) ------------------- def get_identity_provider(self, provider_alias: str) -> dict | None: """Return one identity-provider instance or ``None`` when absent.""" diff --git a/services/account_unification/app/main.py b/services/account_unification/app/main.py index 2ec049f..be623df 100644 --- a/services/account_unification/app/main.py +++ b/services/account_unification/app/main.py @@ -8,6 +8,8 @@ """ from __future__ import annotations +import asyncio +import logging from contextlib import asynccontextmanager from fastapi import FastAPI @@ -20,9 +22,16 @@ from .config import load_service_config from .federation import FederationService, federation_router from .keycloak_client import HttpAdminApi +from .registration import ( + registration_auth_dependency, + registration_router, + revoke_bootstrap_passwords, +) from .scim import scim_router from .service import UnificationService +logger = logging.getLogger(__name__) + def build_service(app: FastAPI) -> None: """Wire the service from the bootstrap pointer + KV store.""" @@ -37,10 +46,10 @@ def build_service(app: FastAPI) -> None: client_secret=config.keycloak_client_secret, timeout_seconds=config.request_timeout_seconds, ) - # Audit sink co-located with the config store for standalone; prod swaps in - # a Postgres-backed sink writing account_merge_audit. - audit_path = descriptor.sqlite_path or "account_unification.db" - audit = AuditLogger(SqliteAuditSink(audit_path)) + # The audit sink is a separate writable database: the config store may be + # (and in compose IS) a read-only mount, so co-locating the audit trail + # there makes the service unable to start. + audit = AuditLogger(SqliteAuditSink(config.audit_database_path)) app.state.unification_service = UnificationService(api, audit, config) app.state.audit_logger = audit @@ -50,15 +59,47 @@ def build_service(app: FastAPI) -> None: app.state.federation_service = FederationService(store, api) # Gate the privileged admin surface on the operator bearer token. app.state.operator_api_token = config.operator_api_token + # Separate, narrower token for the headless self-registration surface. + app.state.registration_api_token = config.registration_api_token + app.state.password_janitor_interval_seconds = ( + config.password_janitor_interval_seconds + ) app.state.ready = True +async def _password_janitor_loop(app: FastAPI, interval_seconds: float) -> None: + """Periodically revoke bootstrap passwords from passkey-holding accounts.""" + while True: + await asyncio.sleep(interval_seconds) + try: + result = await asyncio.to_thread( + revoke_bootstrap_passwords, app.state.keycloak_api + ) + if result.revoked_passwords: + logger.info( + "password janitor revoked %d bootstrap password(s)", + result.revoked_passwords, + ) + except Exception: + logger.exception("password janitor pass failed; will retry") + + @asynccontextmanager async def lifespan(app: FastAPI): """Build the live service before accepting traffic.""" app.state.ready = False build_service(app) - yield + janitor_interval = getattr(app.state, "password_janitor_interval_seconds", 0.0) + janitor_task = ( + asyncio.create_task(_password_janitor_loop(app, janitor_interval)) + if janitor_interval > 0 + else None + ) + try: + yield + finally: + if janitor_task is not None: + janitor_task.cancel() def create_app(*, wire: bool = True) -> FastAPI: @@ -85,6 +126,11 @@ def healthz() -> dict: app.include_router(router, dependencies=[operator_auth_dependency]) app.include_router(scim_router, dependencies=[operator_auth_dependency]) app.include_router(federation_router, dependencies=[operator_auth_dependency]) + # Self-registration carries its own narrower bearer token so product + # backends never hold the operator (merge/SCIM/federation) credential. + app.include_router( + registration_router, dependencies=[registration_auth_dependency] + ) return app diff --git a/services/account_unification/app/registration.py b/services/account_unification/app/registration.py new file mode 100644 index 0000000..6afe216 --- /dev/null +++ b/services/account_unification/app/registration.py @@ -0,0 +1,221 @@ +"""Headless self-registration API for first-party product signup pages. + +Product frontends (e.g. Naruon) own the signup UX and submit new accounts to +this service, which creates the Keycloak user through the Admin REST API. The +IdP-hosted registration page stays disabled (``registrationAllowed:false``), so +this endpoint is the only account-creation entry point and carries its own +bearer token (``registration_api_token``) — deliberately separate from the +operator token so relying products never hold merge/SCIM/federation privileges. + +Bootstrap-credential contract: the account is created with the caller-supplied +initial password and the ``webauthn-register-passwordless`` required action. +The realm browser flow offers the password form only while the account has no +passkey; once the first session enrolls a passkey, the password janitor +(:func:`revoke_bootstrap_passwords`) deletes the password credential so the +steady state stays passwordless. See docs/passwordless-policy.md. +""" +from __future__ import annotations + +import hmac +import re +import threading +import time + +from fastapi import APIRouter, Depends, Header, HTTPException, Request +from pydantic import BaseModel, Field + +from .keycloak_client import AdminApi +from .models import UserAccount + +registration_router = APIRouter(prefix="/registration", tags=["registration"]) + +PASSKEY_ENROLL_REQUIRED_ACTION = "webauthn-register-passwordless" +PASSWORD_CREDENTIAL_TYPE = "password" # noqa: S105 - credential type name, not a secret +PASSKEY_CREDENTIAL_TYPE = "webauthn-passwordless" # noqa: S105 + +# Registration input bounds. The email pattern intentionally checks shape only +# (one @, a dotted domain, no whitespace/control characters); ownership proof +# is verifyEmail's job once the realm has SMTP. +EMAIL_MAX_LENGTH = 254 +EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +PASSWORD_MIN_LENGTH = 10 +PASSWORD_MAX_LENGTH = 128 +NAME_MAX_LENGTH = 100 +CONTROL_CHARACTER_PATTERN = re.compile(r"[\x00-\x1f\x7f]") + +# Simple fixed-window rate limit for account creation attempts. +REGISTRATION_RATE_LIMIT_WINDOW_SECONDS = 300.0 +REGISTRATION_RATE_LIMIT_MAX_ATTEMPTS = 30 +_registration_attempt_lock = threading.Lock() +_registration_attempt_window_start = 0.0 +_registration_attempt_count = 0 + +# Password-janitor scan bound: pages of 100, hard cap so a huge realm cannot +# turn one janitor pass into an unbounded Admin API crawl. +JANITOR_PAGE_SIZE = 100 +JANITOR_MAX_PAGES = 50 + + +class RegistrationRequest(BaseModel): + """One self-registration submission from a product signup page.""" + + email_address: str = Field(min_length=3, max_length=EMAIL_MAX_LENGTH) + initial_password: str = Field( + min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH + ) + first_name: str | None = Field(default=None, max_length=NAME_MAX_LENGTH) + last_name: str | None = Field(default=None, max_length=NAME_MAX_LENGTH) + + +class RegistrationResult(BaseModel): + """Public outcome of a registration; never leaks internals.""" + + account_id: str + email_address: str + + +class JanitorResult(BaseModel): + """Outcome of one bootstrap-password janitor pass.""" + + scanned_users: int + revoked_passwords: int + + +def require_registration_token( + request: Request, + authorization: str | None = Header(default=None), +) -> None: + """Authenticate the registration bearer token; fail closed when absent.""" + expected = getattr(request.app.state, "registration_api_token", None) + if not expected: + raise HTTPException( + status_code=503, detail="registration authentication unavailable" + ) + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException( + status_code=401, + detail="registration bearer token required", + headers={"WWW-Authenticate": "Bearer"}, + ) + presented = authorization[len("Bearer ") :].strip() + if not hmac.compare_digest(presented, expected): + raise HTTPException(status_code=403, detail="invalid registration token") + + +registration_auth_dependency = Depends(require_registration_token) + + +def get_admin_api(request: Request) -> AdminApi: + """Return the wired Keycloak Admin API from application state.""" + api = getattr(request.app.state, "keycloak_api", None) + if api is None: + raise HTTPException(status_code=503, detail="keycloak api unavailable") + return api + + +def _record_registration_attempt() -> None: + """Enforce the fixed-window registration rate limit.""" + global _registration_attempt_window_start, _registration_attempt_count + now = time.monotonic() + with _registration_attempt_lock: + if now - _registration_attempt_window_start > REGISTRATION_RATE_LIMIT_WINDOW_SECONDS: + _registration_attempt_window_start = now + _registration_attempt_count = 0 + _registration_attempt_count += 1 + if _registration_attempt_count > REGISTRATION_RATE_LIMIT_MAX_ATTEMPTS: + raise HTTPException( + status_code=429, detail="registration temporarily rate limited" + ) + + +def _validated_email(raw_email: str) -> str: + """Normalize and shape-check the registration email.""" + email_address = raw_email.strip().lower() + if ( + len(email_address) > EMAIL_MAX_LENGTH + or CONTROL_CHARACTER_PATTERN.search(email_address) + or not EMAIL_PATTERN.match(email_address) + ): + raise HTTPException(status_code=422, detail="invalid_email_address") + return email_address + + +def _validated_name(raw_name: str | None) -> str | None: + """Trim an optional display-name part and reject control characters.""" + if raw_name is None: + return None + candidate = raw_name.strip() + if not candidate: + return None + if CONTROL_CHARACTER_PATTERN.search(candidate): + raise HTTPException(status_code=422, detail="invalid_name") + return candidate + + +@registration_router.post( + "/accounts", response_model=RegistrationResult, status_code=201 +) +def register_account( + request_body: RegistrationRequest, + api: AdminApi = Depends(get_admin_api), +) -> RegistrationResult: + """Create a Keycloak account for a product-signup submission.""" + _record_registration_attempt() + email_address = _validated_email(request_body.email_address) + if CONTROL_CHARACTER_PATTERN.search(request_body.initial_password): + raise HTTPException(status_code=422, detail="invalid_password") + + if api.find_users_by_email(email_address): + raise HTTPException(status_code=409, detail="email_already_registered") + + account_id = api.create_user( + UserAccount( + user_id="", + user_name=email_address, + email=email_address, + is_email_verified=False, + state="active", + first_name=_validated_name(request_body.first_name), + last_name=_validated_name(request_body.last_name), + ) + ) + if not account_id: + raise HTTPException(status_code=502, detail="account_creation_failed") + + # Bootstrap credential + forced passkey enrollment on the first session. + api.reset_user_password(account_id, request_body.initial_password) + api.set_user_required_actions(account_id, [PASSKEY_ENROLL_REQUIRED_ACTION]) + return RegistrationResult(account_id=account_id, email_address=email_address) + + +def revoke_bootstrap_passwords(api: AdminApi) -> JanitorResult: + """Delete password credentials from accounts that already hold a passkey. + + Keeps the steady state passwordless: the registration password exists only + to bridge the gap until the first session enrolls a passkey. + """ + scanned_users = 0 + revoked_passwords = 0 + for page_index in range(JANITOR_MAX_PAGES): + users = api.list_users(page_index * JANITOR_PAGE_SIZE, JANITOR_PAGE_SIZE) + if not users: + break + for user in users: + scanned_users += 1 + credentials = api.list_user_credentials(user.user_id) + credential_types = {item.get("type") for item in credentials} + if PASSKEY_CREDENTIAL_TYPE not in credential_types: + continue + for item in credentials: + if item.get("type") == PASSWORD_CREDENTIAL_TYPE and item.get("id"): + api.delete_user_credential(user.user_id, item["id"]) + revoked_passwords += 1 + if len(users) < JANITOR_PAGE_SIZE: + break + return JanitorResult(scanned_users=scanned_users, revoked_passwords=revoked_passwords) + + +@registration_router.post("/password-janitor:run", response_model=JanitorResult) +def run_password_janitor(api: AdminApi = Depends(get_admin_api)) -> JanitorResult: + """Run one janitor pass on demand (also runs periodically in-process).""" + return revoke_bootstrap_passwords(api) diff --git a/services/account_unification/tests/mock_keycloak.py b/services/account_unification/tests/mock_keycloak.py index a5871c0..6cfdb16 100644 --- a/services/account_unification/tests/mock_keycloak.py +++ b/services/account_unification/tests/mock_keycloak.py @@ -172,3 +172,42 @@ def update_identity_provider( def delete_identity_provider(self, provider_alias: str) -> None: self.calls.append(f"delete_identity_provider:{provider_alias}") getattr(self, "identity_providers", {}).pop(provider_alias, None) + + # -- self-registration support ------------------------------------------ + def list_users(self, first_result: int, max_results: int) -> list[UserAccount]: + self.calls.append(f"list_users:{first_result}:{max_results}") + ordered = list(self.users.values()) + return ordered[first_result : first_result + max_results] + + def reset_user_password(self, user_id: str, password_value: str) -> None: + self.calls.append(f"reset_user_password:{user_id}") + if user_id not in self.users: + raise KeyError(user_id) + if not hasattr(self, "credentials"): + self.credentials: dict[str, list[dict]] = {} + entries = self.credentials.setdefault(user_id, []) + entries[:] = [item for item in entries if item.get("type") != "password"] + entries.append({"id": f"cred-pw-{user_id}", "type": "password"}) + + def set_user_required_actions(self, user_id: str, action_aliases: list[str]) -> None: + self.calls.append(f"set_user_required_actions:{user_id}") + if not hasattr(self, "required_actions"): + self.required_actions: dict[str, list[str]] = {} + self.required_actions[user_id] = list(action_aliases) + + def list_user_credentials(self, user_id: str) -> list[dict]: + self.calls.append(f"list_user_credentials:{user_id}") + return list(getattr(self, "credentials", {}).get(user_id, [])) + + def delete_user_credential(self, user_id: str, credential_id: str) -> None: + self.calls.append(f"delete_user_credential:{user_id}:{credential_id}") + entries = getattr(self, "credentials", {}).get(user_id, []) + entries[:] = [item for item in entries if item.get("id") != credential_id] + + def add_test_passkey(self, user_id: str) -> None: + """Test fixture: mark a user as having enrolled a passkey.""" + if not hasattr(self, "credentials"): + self.credentials = {} + self.credentials.setdefault(user_id, []).append( + {"id": f"cred-wa-{user_id}", "type": "webauthn-passwordless"} + ) diff --git a/services/account_unification/tests/test_registration.py b/services/account_unification/tests/test_registration.py new file mode 100644 index 0000000..0dd5ed2 --- /dev/null +++ b/services/account_unification/tests/test_registration.py @@ -0,0 +1,164 @@ +"""Headless self-registration API and bootstrap-password janitor.""" +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from app import registration as registration_module +from app.main import create_app +from app.registration import revoke_bootstrap_passwords + +REGISTRATION_TOKEN = "registration-token-for-tests" + + +@pytest.fixture(autouse=True) +def _reset_rate_limit(): + registration_module._registration_attempt_window_start = 0.0 + registration_module._registration_attempt_count = 0 + yield + + +@pytest.fixture +def client(api): + app = create_app(wire=False) + app.state.keycloak_api = api + app.state.registration_api_token = REGISTRATION_TOKEN + headers = {"Authorization": f"Bearer {REGISTRATION_TOKEN}"} + with TestClient(app, headers=headers) as test_client: + yield test_client + + +def _registration(email="new.user@example.com", password="bootstrap-pass-1"): + return { + "email_address": email, + "initial_password": password, + "first_name": "New", + "last_name": "User", + } + + +def test_registration_creates_account_with_password_and_passkey_action(client, api): + response = client.post("/registration/accounts", json=_registration()) + + assert response.status_code == 201 + body = response.json() + assert body["email_address"] == "new.user@example.com" + account_id = body["account_id"] + created = api.users[account_id] + assert created.user_name == "new.user@example.com" + assert created.is_email_verified is False + assert api.required_actions[account_id] == ["webauthn-register-passwordless"] + assert any( + item["type"] == "password" for item in api.list_user_credentials(account_id) + ) + + +def test_registration_normalizes_email_case(client, api): + response = client.post( + "/registration/accounts", json=_registration(email="Mixed.Case@Example.COM") + ) + + assert response.status_code == 201 + assert response.json()["email_address"] == "mixed.case@example.com" + + +def test_registration_rejects_duplicate_email(client, api): + first = client.post("/registration/accounts", json=_registration()) + assert first.status_code == 201 + + duplicate = client.post("/registration/accounts", json=_registration()) + + assert duplicate.status_code == 409 + assert duplicate.json()["detail"] == "email_already_registered" + + +@pytest.mark.parametrize( + "email", ["not-an-email", "two@@example.com", "control\x00@example.com", "a@b"] +) +def test_registration_rejects_malformed_email(client, email): + response = client.post("/registration/accounts", json=_registration(email=email)) + + assert response.status_code == 422 + + +def test_registration_rejects_short_password(client): + response = client.post( + "/registration/accounts", json=_registration(password="short") + ) + + assert response.status_code == 422 + + +def test_registration_surface_fails_closed_without_token_config(api): + app = create_app(wire=False) + app.state.keycloak_api = api + app.state.registration_api_token = None + with TestClient(app) as unconfigured_client: + response = unconfigured_client.post( + "/registration/accounts", + json=_registration(), + headers={"Authorization": f"Bearer {REGISTRATION_TOKEN}"}, + ) + + assert response.status_code == 503 + + +def test_registration_rejects_wrong_token(api): + app = create_app(wire=False) + app.state.keycloak_api = api + app.state.registration_api_token = REGISTRATION_TOKEN + with TestClient(app) as anonymous_client: + response = anonymous_client.post( + "/registration/accounts", + json=_registration(), + headers={"Authorization": "Bearer wrong-token"}, + ) + + assert response.status_code == 403 + + +def test_operator_token_does_not_open_registration(client, api, monkeypatch): + """The operator credential must not double as the registration credential.""" + app = create_app(wire=False) + app.state.keycloak_api = api + app.state.registration_api_token = REGISTRATION_TOKEN + app.state.operator_api_token = "operator-token" + with TestClient(app) as operator_client: + response = operator_client.post( + "/registration/accounts", + json=_registration(), + headers={"Authorization": "Bearer operator-token"}, + ) + + assert response.status_code == 403 + + +def test_janitor_revokes_password_only_after_passkey_enrollment(client, api): + enrolled = client.post( + "/registration/accounts", json=_registration(email="enrolled@example.com") + ).json()["account_id"] + pending = client.post( + "/registration/accounts", json=_registration(email="pending@example.com") + ).json()["account_id"] + api.add_test_passkey(enrolled) + + result = revoke_bootstrap_passwords(api) + + assert result.revoked_passwords == 1 + enrolled_types = {item["type"] for item in api.list_user_credentials(enrolled)} + pending_types = {item["type"] for item in api.list_user_credentials(pending)} + assert "password" not in enrolled_types + assert "webauthn-passwordless" in enrolled_types + assert "password" in pending_types + + +def test_janitor_endpoint_runs_a_pass(client, api): + account_id = client.post( + "/registration/accounts", json=_registration(email="janitor@example.com") + ).json()["account_id"] + api.add_test_passkey(account_id) + + response = client.post("/registration/password-janitor:run") + + assert response.status_code == 200 + assert response.json()["revoked_passwords"] == 1 diff --git a/services/account_unification/tools/seed_config_store.py b/services/account_unification/tools/seed_config_store.py index 444916d..804b20e 100644 --- a/services/account_unification/tools/seed_config_store.py +++ b/services/account_unification/tools/seed_config_store.py @@ -21,6 +21,8 @@ KEY_KEYCLOAK_REALM, KEY_KEYCLOAK_SERVER_URL, KEY_MERGE_CONFLICT_POLICY, + KEY_OPERATOR_API_TOKEN, + KEY_REGISTRATION_API_TOKEN, ) from app.kv_store import SqliteKvStore # noqa: E402 @@ -34,6 +36,8 @@ def main() -> int: parser.add_argument("--realm", default="cwl") parser.add_argument("--client-id", default="account-unification-svc") parser.add_argument("--client-secret", default="dev-placeholder-secret") + parser.add_argument("--operator-token", default="dev-operator-token") + parser.add_argument("--registration-token", default="dev-registration-token") args = parser.parse_args() store = SqliteKvStore(args.db) @@ -43,6 +47,8 @@ def main() -> int: store.put(args.namespace, KEY_KEYCLOAK_CLIENT_SECRET, args.client_secret) store.put(args.namespace, KEY_MERGE_CONFLICT_POLICY, "survivor_wins") store.put(args.namespace, KEY_ALLOW_UNVERIFIED_LINK, "false") + store.put(args.namespace, KEY_OPERATOR_API_TOKEN, args.operator_token) + store.put(args.namespace, KEY_REGISTRATION_API_TOKEN, args.registration_token) print(f"seeded {args.db} namespace={args.namespace}") return 0 From 03d5fbbc6471c62aa6adbfe607a09c0831024229 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 22 Jul 2026 12:55:31 +0900 Subject: [PATCH 008/104] fix(admin-api): re-authenticate once when the cached admin token expires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HttpAdminApi cached the service-account token for the connection lifetime, so after the token lifespan every Admin REST call failed 401 until a process restart — first observed as registration 500s minutes after bring-up. Each verb now routes through _send_with_reauth, which refreshes the token exactly once on a 401 and then fails honestly. Regression test drives a MockTransport that rejects the stale token and asserts the retry carries a freshly issued one. Evidence: service pytest 91 passed; live compose registration returns 201 again after the previous token aged out. Co-Authored-By: Claude Fable 5 --- .../app/keycloak_client.py | 52 ++++++++++++++----- .../tests/test_keycloak_client.py | 42 +++++++++++++++ 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/services/account_unification/app/keycloak_client.py b/services/account_unification/app/keycloak_client.py index dc129e2..dd69266 100644 --- a/services/account_unification/app/keycloak_client.py +++ b/services/account_unification/app/keycloak_client.py @@ -473,36 +473,60 @@ def _guard_path(path: str) -> str: raise InvalidIdentifierError("request path must not contain empty segments") return path + def _send_with_reauth(self, make_request): + """Send a request, re-authenticating once when the token expired. + + The service-account token is cached for the connection lifetime, so a + 401 after a quiet period just means the token aged out — refresh it and + retry once instead of failing the whole operation. + """ + response = make_request() + if response.status_code == 401: + self._token = None + response = make_request() + response.raise_for_status() + return response + def _get(self, path: str, params: dict | None = None) -> dict | list: """Issue an authenticated GET and parse JSON.""" - response = self._client.get( - self._guard_path(path), params=params, headers=self._auth_header() + guarded_path = self._guard_path(path) + response = self._send_with_reauth( + lambda: self._client.get( + guarded_path, params=params, headers=self._auth_header() + ) ) - response.raise_for_status() return response.json() def _post(self, path: str, body) -> dict: """Issue an authenticated POST and parse optional JSON.""" - response = self._client.post( - self._guard_path(path), json=body, headers=self._auth_header() + guarded_path = self._guard_path(path) + response = self._send_with_reauth( + lambda: self._client.post( + guarded_path, json=body, headers=self._auth_header() + ) ) - response.raise_for_status() return response.json() if response.content else {} def _put(self, path: str, body: dict) -> None: """Issue an authenticated PUT.""" - response = self._client.put( - self._guard_path(path), json=body, headers=self._auth_header() + guarded_path = self._guard_path(path) + self._send_with_reauth( + lambda: self._client.put( + guarded_path, json=body, headers=self._auth_header() + ) ) - response.raise_for_status() def _delete(self, path: str, body=None) -> None: """Issue an authenticated DELETE with an optional JSON body.""" - request = self._client.build_request( - "DELETE", self._guard_path(path), json=body, headers=self._auth_header() - ) - response = self._client.send(request) - response.raise_for_status() + guarded_path = self._guard_path(path) + + def send_delete(): + request = self._client.build_request( + "DELETE", guarded_path, json=body, headers=self._auth_header() + ) + return self._client.send(request) + + self._send_with_reauth(send_delete) def close(self) -> None: """Close the underlying HTTP connection pool.""" diff --git a/services/account_unification/tests/test_keycloak_client.py b/services/account_unification/tests/test_keycloak_client.py index eece88d..1a6fc8c 100644 --- a/services/account_unification/tests/test_keycloak_client.py +++ b/services/account_unification/tests/test_keycloak_client.py @@ -121,3 +121,45 @@ def handler(request: httpx.Request) -> httpx.Response: assert any(call.headers.get("authorization") == "Bearer token-1" for call in calls) assert any(call.method == "DELETE" and call.content for call in calls) + + +def test_http_admin_api_reauthenticates_once_on_expired_token(): + token_requests = 0 + user_requests = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal token_requests, user_requests + path = request.url.path + if path.endswith("/protocol/openid-connect/token"): + token_requests += 1 + return httpx.Response(200, json={"access_token": f"token-{token_requests}"}) + user_requests += 1 + # The first data call sees an expired-token 401; the retry must carry + # a freshly fetched token and succeed. + if request.headers.get("Authorization") == "Bearer token-0": + return httpx.Response(401, json={"error": "invalid_token"}) + return httpx.Response( + 200, + json={ + "id": "u1", + "username": "jane", + "email": "jane@corp.test", + "emailVerified": True, + "enabled": True, + }, + ) + + api = HttpAdminApi( + server_url="http://keycloak.test", + realm="cwl", + client_id="svc", + client_secret="secret", + transport=httpx.MockTransport(handler), + ) + api._token = "token-0" # simulate a token cached before it expired + + user = api.get_user("u1") + + assert user.user_id == "u1" + assert token_requests == 1 + assert user_requests == 2 From 2fd34a91e211d21b8a6d210d49b248bd2527daa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 13:13:29 +0000 Subject: [PATCH 009/104] fix(healthcheck): allow-list URL scheme and clear Semgrep dynamic-urllib finding The central SAST Semgrep gate fails on the base branch because p/default's python.lang.security.audit.dynamic-urllib-use-detected flags app/healthcheck.py: urlopen() receives a non-literal url, which urllib would happily open as a file:// path. This Medium finding blocks every open keyverse PR, since each PR scans a tree that still contains this file. Harden the probe by rejecting any URL whose scheme is not http/https before opening it, so a stray value can never coerce urlopen into a file:// read or another protocol handler. The residual audit finding on the (still non-literal) urlopen call is suppressed narrowly with an inline `# nosemgrep: dynamic-urllib-use-detected`, justified by the scheme allow-list and the fact that the container self-probe URL is not attacker-controlled. Add a regression test for the rejected-scheme path. Verified locally: semgrep marks the finding suppressed (gate passes), ruff is clean, and the healthcheck tests pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HdCssGnNMhKHNu3TXFstWH --- services/account_unification/app/healthcheck.py | 11 +++++++++++ .../account_unification/tests/test_healthcheck.py | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/services/account_unification/app/healthcheck.py b/services/account_unification/app/healthcheck.py index 4284510..713e31b 100644 --- a/services/account_unification/app/healthcheck.py +++ b/services/account_unification/app/healthcheck.py @@ -7,14 +7,25 @@ import json import sys +import urllib.parse import urllib.request DEFAULT_URL = "http://127.0.0.1:8099/healthz" +_ALLOWED_SCHEMES = frozenset({"http", "https"}) def main(url: str = DEFAULT_URL) -> int: """Check the configured health endpoint and return a shell status code.""" + scheme = urllib.parse.urlsplit(url).scheme.lower() + if scheme not in _ALLOWED_SCHEMES: + # Reject non-HTTP(S) schemes so a stray value can never coerce urlopen into + # reading a local ``file://`` path or reaching another protocol handler. + print(f"healthcheck failed: unsupported URL scheme {scheme!r}", file=sys.stderr) + return 1 try: + # Internal container self-probe; the scheme is allow-listed to http/https + # above, so this urlopen cannot be redirected to a file:// path or other handler. + # nosemgrep: dynamic-urllib-use-detected with urllib.request.urlopen(url, timeout=5) as response: # noqa: S310 body = json.loads(response.read().decode("utf-8")) except Exception as exc: # pragma: no cover - network failure path diff --git a/services/account_unification/tests/test_healthcheck.py b/services/account_unification/tests/test_healthcheck.py index e538b16..af99c34 100644 --- a/services/account_unification/tests/test_healthcheck.py +++ b/services/account_unification/tests/test_healthcheck.py @@ -48,3 +48,14 @@ def fake_urlopen(url: str, *, timeout: int) -> _Response: assert healthcheck.main("http://service/healthz") == 1 assert "healthcheck failed: connection refused" in capsys.readouterr().err + + +def test_healthcheck_rejects_non_http_scheme(monkeypatch, capsys): + """A non-HTTP(S) URL is rejected before urllib ever opens it.""" + def fail_urlopen(*args: object, **kwargs: object) -> _Response: + raise AssertionError("urlopen must not run for a rejected scheme") + + monkeypatch.setattr(healthcheck.urllib.request, "urlopen", fail_urlopen) + + assert healthcheck.main("file:///etc/passwd") == 1 + assert "unsupported URL scheme 'file'" in capsys.readouterr().err From 5e9c4eb125fb35adb27125ab179e2ea290feeeba Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 11:09:26 +0000 Subject: [PATCH 010/104] fix(scim): refuse SCIM PUT that would resurrect a tombstoned merged duplicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A merged-away duplicate is tombstoned (disabled + a `merged_into_user_id` pointer to the survivor) so it can never authenticate again, per docs/merge-unification-flow.md and the CLAUDE.md merge invariant. But the SCIM shim's `PUT /scim/v2/Users/{id}` (`replace_user`) only did a 404 existence check and then translated the resource (whose `active` defaults to true) into a replace — with no tombstone guard. SCIM PUT is the *only* reactivation vector: `create` guards uniqueness and `patch`/`delete` only ever disable. So a routine upstream HR/IGA full-sync PUT that still lists the decommissioned person silently re-enabled the tombstoned account (restoring its untouched passkey/WebAuthn login), and against a live Keycloak the PUT would also overwrite the user representation, wiping the `merged_into_user_id` pointer that resolves stale references to the survivor. SCIM endpoints carry no app-level authz (trust terminates at the WAF edge), so the trigger is unprivileged. Fix: in `replace_user`, refuse with SCIM 409 when the target carries the tombstone attribute, keeping a merged duplicate immutable via SCIM. Adds `get_user_attribute` to the `AdminApi` protocol, the HTTP client, and the mock so the guard reads the pointer uniformly. Regression test asserts the PUT is refused and the duplicate stays disabled with its survivor pointer intact (verified red→green: without the guard the test fails as the account is re-enabled). Full suite 54 passed; interrogate 100%. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HdCssGnNMhKHNu3TXFstWH --- .../app/keycloak_client.py | 16 ++++++++++++++ services/account_unification/app/scim.py | 13 +++++++++++ .../tests/mock_keycloak.py | 4 ++++ .../account_unification/tests/test_scim.py | 22 +++++++++++++++++++ 4 files changed, 55 insertions(+) diff --git a/services/account_unification/app/keycloak_client.py b/services/account_unification/app/keycloak_client.py index 7d6ab05..4ab2d57 100644 --- a/services/account_unification/app/keycloak_client.py +++ b/services/account_unification/app/keycloak_client.py @@ -108,6 +108,10 @@ def set_user_attribute(self, user_id: str, key: str, value: str) -> None: """Set one single-valued user attribute.""" ... + def get_user_attribute(self, user_id: str, key: str) -> str | None: + """Return one single-valued user attribute, or ``None`` if unset.""" + ... + class HttpAdminApi: """httpx-backed :class:`AdminApi` for a live Keycloak instance. @@ -339,6 +343,18 @@ def set_user_attribute(self, user_id: str, key: str, value: str) -> None: f"/admin/realms/{self._realm}/users/{user_id}", {"attributes": attributes} ) + def get_user_attribute(self, user_id: str, key: str) -> str | None: + """Return one single-valued Keycloak user attribute, or ``None``. + + Keycloak stores attributes as string lists; this returns the first + element (or a bare string, defensively), or ``None`` when unset. + """ + data = self._get(f"/admin/realms/{self._realm}/users/{user_id}") + value = (data.get("attributes") or {}).get(key) + if isinstance(value, list): + return value[0] if value else None + return value if isinstance(value, str) else None + # -- transport --------------------------------------------------------- def _get(self, path: str, params: dict | None = None) -> dict | list: """Issue an authenticated GET and parse JSON.""" diff --git a/services/account_unification/app/scim.py b/services/account_unification/app/scim.py index 2cb7609..f76f6ea 100644 --- a/services/account_unification/app/scim.py +++ b/services/account_unification/app/scim.py @@ -20,6 +20,7 @@ from .keycloak_client import AdminApi from .models import UserAccount +from .service import TOMBSTONE_ATTRIBUTE_KEY SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User" SCIM_LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse" @@ -192,6 +193,18 @@ def replace_user( provisioner.get_user(user_id) except KeyError as exc: raise _scim_error(404, f"user '{user_id}' not found") from exc + # A merged-away duplicate is tombstoned (disabled + a merged_into_user_id + # pointer) so it can never authenticate again. SCIM PUT is the only + # reactivation vector -- create guards uniqueness, and patch/delete only + # ever disable -- and a live Keycloak PUT would also overwrite the whole + # user representation, wiping the survivor pointer. Refuse it outright so a + # routine upstream full-sync cannot resurrect a decommissioned identity. + if provisioner.get_user_attribute(user_id, TOMBSTONE_ATTRIBUTE_KEY): + raise _scim_error( + 409, + f"user '{user_id}' has been merged into another account " + "and cannot be modified", + ) account = _to_user_account(resource, user_id=user_id) provisioner.replace_user(user_id, account) return _scim_response(_to_scim_resource(provisioner.get_user(user_id))) diff --git a/services/account_unification/tests/mock_keycloak.py b/services/account_unification/tests/mock_keycloak.py index f844391..26f9c38 100644 --- a/services/account_unification/tests/mock_keycloak.py +++ b/services/account_unification/tests/mock_keycloak.py @@ -148,3 +148,7 @@ def set_user_attribute(self, user_id: str, key: str, value: str) -> None: self.users[user_id] = self.users[user_id].model_copy( update={"external_id": value} ) + + def get_user_attribute(self, user_id: str, key: str) -> str | None: + self.calls.append(f"get_user_attribute:{user_id}:{key}") + return self.attributes.get((user_id, key)) diff --git a/services/account_unification/tests/test_scim.py b/services/account_unification/tests/test_scim.py index bdcef85..cfd57d4 100644 --- a/services/account_unification/tests/test_scim.py +++ b/services/account_unification/tests/test_scim.py @@ -82,6 +82,28 @@ def test_scim_replace_updates_user(client, api): assert api.get_user(created["id"]).email == "jane.doe@corp.com" +def test_scim_replace_refuses_to_resurrect_a_tombstoned_duplicate(client, api): + """A merged-away (tombstoned) duplicate must not be re-enabled via SCIM PUT. + + After a merge the duplicate is disabled and carries a merged_into_user_id + pointer. A routine upstream full-sync PUT (``active`` defaults to true) must + be refused with 409, leaving the duplicate disabled with its survivor pointer + intact -- never silently reactivated. + """ + created = client.post("/scim/v2/Users", json=_scim_user()).json() + dup_id = created["id"] + # Simulate the post-merge tombstone state (service._tombstone does exactly this). + api.set_user_attribute(dup_id, "merged_into_user_id", "survivor-id") + api.deactivate_user(dup_id) + + response = client.put(f"/scim/v2/Users/{dup_id}", json=_scim_user()) + + assert response.status_code == 409 + assert dup_id in api.deactivated + assert api.get_user(dup_id).state == "disabled" + assert api.get_user_attribute(dup_id, "merged_into_user_id") == "survivor-id" + + def test_scim_patch_deactivates_user(client, api): created = client.post("/scim/v2/Users", json=_scim_user()).json() response = client.patch( From e95e87b442d35d383f6547408af1934b33eca5ef Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 01:39:10 +0000 Subject: [PATCH 011/104] fix(merge): refuse explicit-link merge when the only tie is an unverified email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `UnificationService.merge_accounts` nested the unverified-email refusal inside `if not decision.matched:`. Because `decide_match(..., explicit_link=True)` returns `matched=True, reason=EXPLICIT_LINK`, an operator "explicit link" merge skipped the guard entirely and merged + tombstoned two accounts whose only shared tie was an UNVERIFIED email — the account-takeover vector the hard rule exists to block (an attacker registers a duplicate holding the victim's unverified email, then one explicit_link=True merge folds it into the victim). This violates three contract sources: - `app/models.py` MergeRequest.explicit_link docstring: "Even so, the service refuses if the only tie is an UNVERIFIED email." - `docs/merge-unification-flow.md`: "reject if only tie is unverified email -> 422 UnverifiedEmailMerge" is an unconditional step after decide_match. - `CLAUDE.md`: "Never link or merge accounts on an unverified email." Fix: hoist the unverified-email guard out of the not-matched branch and run it for every decision reason except a genuine tie (EXACT_IDP_SUBJECT / VERIFIED_ EMAIL), so explicit-link and no-match are both covered. Legitimate merges are preserved: an explicit link with different/absent emails still merges, and verified-email / exact-(idp,subject) matches are exempt. TDD: added test_refuse_explicit_merge_on_shared_unverified_email — confirmed it fails on the pre-fix code ("DID NOT RAISE UnverifiedEmailMergeError", duplicate gets tombstoned) and passes after. No existing test changes. Verified (CI parity, py3.12, services/account_unification): ruff clean, interrogate 100% (>=80 gate), pytest all pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HdCssGnNMhKHNu3TXFstWH --- services/account_unification/app/service.py | 21 +++++++++++++------ .../account_unification/tests/test_merge.py | 10 +++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/services/account_unification/app/service.py b/services/account_unification/app/service.py index 3354c6f..6c77371 100644 --- a/services/account_unification/app/service.py +++ b/services/account_unification/app/service.py @@ -27,6 +27,7 @@ from .matching import decide_match, have_matching_verified_email from .models import ( FederatedIdentity, + MatchReason, MergeConflict, MergeRequest, MergeResult, @@ -85,19 +86,27 @@ def merge_accounts(self, request: MergeRequest) -> MergeResult: decision = decide_match( survivor, duplicate, explicit_link=request.explicit_link ) - # Guard: refuse when the accounts only coincide on an unverified email. - # (decide_match already refuses to *call* that a verified match; here we - # produce the precise error for the operator + audit trail.) - if not decision.matched: + # Guard: refuse whenever the ONLY shared tie is an unverified email -- + # even when an operator asserts an explicit link. This is the + # account-takeover vector the hard rule blocks (an attacker registers a + # duplicate holding the victim's unverified email). A verified-email or + # exact (idp, subject) match is a genuine tie and is exempt, so the guard + # runs for every other decision reason (explicit link and no-match alike) + # rather than only when decide_match found no rule. + if decision.reason not in ( + MatchReason.EXACT_IDP_SUBJECT, + MatchReason.VERIFIED_EMAIL, + ): same_email = ( - (survivor.email or "").strip().lower() + bool((survivor.email or "").strip()) + and (survivor.email or "").strip().lower() == (duplicate.email or "").strip().lower() - and bool(survivor.email) ) if same_email and not have_matching_verified_email(survivor, duplicate): raise UnverifiedEmailMergeError( "refusing merge: accounts share only an UNVERIFIED email" ) + if not decision.matched: raise NoMatchError(decision.detail or "no matching rule satisfied") audit_id = self._audit.new_correlation_id() diff --git a/services/account_unification/tests/test_merge.py b/services/account_unification/tests/test_merge.py index 1acfd12..b4b559c 100644 --- a/services/account_unification/tests/test_merge.py +++ b/services/account_unification/tests/test_merge.py @@ -168,6 +168,16 @@ def test_explicit_link_allows_merge_without_shared_signal(service, api): assert result.duplicate_tombstoned +def test_refuse_explicit_merge_on_shared_unverified_email(service, api): + """An explicit link must not launder a shared UNVERIFIED email into a merge.""" + api.create_test_user("survivor", email="jane@corp.com", is_email_verified=False) + api.create_test_user("dup", email="jane@corp.com", is_email_verified=False) + with pytest.raises(UnverifiedEmailMergeError): + service.merge_accounts(_merge(explicit=True)) + # nothing mutated: duplicate not tombstoned. + assert "dup" not in api.deactivated + + def test_refuse_self_merge(service, api): api.create_test_user("same", email="a@x.com", is_email_verified=True) with pytest.raises(SameUserError): From 4e2b9d245d77650dfe22b768763f2b73334571b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 01:41:22 +0000 Subject: [PATCH 012/104] Revert "fix(merge): refuse explicit-link merge when the only tie is an unverified email" This reverts commit e95e87b442d35d383f6547408af1934b33eca5ef. --- services/account_unification/app/service.py | 21 ++++++------------- .../account_unification/tests/test_merge.py | 10 --------- 2 files changed, 6 insertions(+), 25 deletions(-) diff --git a/services/account_unification/app/service.py b/services/account_unification/app/service.py index 6c77371..3354c6f 100644 --- a/services/account_unification/app/service.py +++ b/services/account_unification/app/service.py @@ -27,7 +27,6 @@ from .matching import decide_match, have_matching_verified_email from .models import ( FederatedIdentity, - MatchReason, MergeConflict, MergeRequest, MergeResult, @@ -86,27 +85,19 @@ def merge_accounts(self, request: MergeRequest) -> MergeResult: decision = decide_match( survivor, duplicate, explicit_link=request.explicit_link ) - # Guard: refuse whenever the ONLY shared tie is an unverified email -- - # even when an operator asserts an explicit link. This is the - # account-takeover vector the hard rule blocks (an attacker registers a - # duplicate holding the victim's unverified email). A verified-email or - # exact (idp, subject) match is a genuine tie and is exempt, so the guard - # runs for every other decision reason (explicit link and no-match alike) - # rather than only when decide_match found no rule. - if decision.reason not in ( - MatchReason.EXACT_IDP_SUBJECT, - MatchReason.VERIFIED_EMAIL, - ): + # Guard: refuse when the accounts only coincide on an unverified email. + # (decide_match already refuses to *call* that a verified match; here we + # produce the precise error for the operator + audit trail.) + if not decision.matched: same_email = ( - bool((survivor.email or "").strip()) - and (survivor.email or "").strip().lower() + (survivor.email or "").strip().lower() == (duplicate.email or "").strip().lower() + and bool(survivor.email) ) if same_email and not have_matching_verified_email(survivor, duplicate): raise UnverifiedEmailMergeError( "refusing merge: accounts share only an UNVERIFIED email" ) - if not decision.matched: raise NoMatchError(decision.detail or "no matching rule satisfied") audit_id = self._audit.new_correlation_id() diff --git a/services/account_unification/tests/test_merge.py b/services/account_unification/tests/test_merge.py index b4b559c..1acfd12 100644 --- a/services/account_unification/tests/test_merge.py +++ b/services/account_unification/tests/test_merge.py @@ -168,16 +168,6 @@ def test_explicit_link_allows_merge_without_shared_signal(service, api): assert result.duplicate_tombstoned -def test_refuse_explicit_merge_on_shared_unverified_email(service, api): - """An explicit link must not launder a shared UNVERIFIED email into a merge.""" - api.create_test_user("survivor", email="jane@corp.com", is_email_verified=False) - api.create_test_user("dup", email="jane@corp.com", is_email_verified=False) - with pytest.raises(UnverifiedEmailMergeError): - service.merge_accounts(_merge(explicit=True)) - # nothing mutated: duplicate not tombstoned. - assert "dup" not in api.deactivated - - def test_refuse_self_merge(service, api): api.create_test_user("same", email="a@x.com", is_email_verified=True) with pytest.raises(SameUserError): From ee085a32da741ed6f74722951a777d60ffdcf56a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 01:46:44 +0000 Subject: [PATCH 013/104] fix(healthcheck): constrain redirect targets to HTTP(S), drop ftp/file handlers The healthcheck validated only the *initial* URL scheme, then used the default `urllib` opener -- which follows redirects and carries an `FTPHandler`. A `http:// -> ftp://` (or `file://`) redirect from the probed endpoint would have been followed by another protocol handler (the code comment even wrongly claimed it could not be). CodeRabbit flagged it (CWE-918 SSRF, Major). Fix: route the probe through a purpose-built opener that (1) carries only HTTP/HTTPS handlers -- no ftp/file/data handler exists to open such a target -- and (2) uses `_HttpOnlyRedirectHandler`, which drops any redirect whose `Location` scheme is not in the http/https allow-list. Both are belt-and- suspenders; either alone fails the ftp redirect closed. `main` now opens via the patchable `_open_health_url` seam. Added `test_healthcheck_opener_drops_non_http_redirect_target` (ftp target dropped, same-scheme redirect kept, no ftp/file/data handler on the opener); the three existing tests re-point to the new seam. Verified (CI parity, services/account_unification): pytest all pass, ruff clean, interrogate 99.4% (>=80 gate). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HdCssGnNMhKHNu3TXFstWH --- .../account_unification/app/healthcheck.py | 40 +++++++++++++-- .../tests/test_healthcheck.py | 51 +++++++++++++++---- 2 files changed, 78 insertions(+), 13 deletions(-) diff --git a/services/account_unification/app/healthcheck.py b/services/account_unification/app/healthcheck.py index 713e31b..93902fe 100644 --- a/services/account_unification/app/healthcheck.py +++ b/services/account_unification/app/healthcheck.py @@ -14,6 +14,39 @@ _ALLOWED_SCHEMES = frozenset({"http", "https"}) +class _HttpOnlyRedirectHandler(urllib.request.HTTPRedirectHandler): + """Redirect handler that drops any redirect whose target scheme is not HTTP(S). + + The initial-URL scheme check does not cover a ``Location`` header, so an + ``http:// -> ftp://`` (or ``file://``) redirect would otherwise be followed + by whichever protocol handler the opener carries. + """ + + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001, D102 + if urllib.parse.urlsplit(newurl).scheme.lower() not in _ALLOWED_SCHEMES: + return None + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +def _build_http_only_opener() -> urllib.request.OpenerDirector: + """Build an opener that can speak only HTTP(S) -- no ftp/file/data handlers. + + Even if a redirect target slipped past :class:`_HttpOnlyRedirectHandler`, the + opener has no handler able to open it, so ``ftp://``/``file://`` fail closed. + """ + opener = urllib.request.OpenerDirector() + opener.add_handler(urllib.request.HTTPHandler()) + opener.add_handler(urllib.request.HTTPSHandler()) + opener.add_handler(_HttpOnlyRedirectHandler()) + opener.add_handler(urllib.request.HTTPErrorProcessor()) + return opener + + +def _open_health_url(url: str): # noqa: ANN202 + """Open an HTTP(S) health URL through the scheme-restricted, ftp/file-less opener.""" + return _build_http_only_opener().open(url, timeout=5) + + def main(url: str = DEFAULT_URL) -> int: """Check the configured health endpoint and return a shell status code.""" scheme = urllib.parse.urlsplit(url).scheme.lower() @@ -23,10 +56,11 @@ def main(url: str = DEFAULT_URL) -> int: print(f"healthcheck failed: unsupported URL scheme {scheme!r}", file=sys.stderr) return 1 try: - # Internal container self-probe; the scheme is allow-listed to http/https - # above, so this urlopen cannot be redirected to a file:// path or other handler. + # Internal container self-probe. Both the initial scheme (above) and any + # redirect target are constrained to http/https, and the opener carries no + # ftp/file handler, so this cannot reach another protocol handler. # nosemgrep: dynamic-urllib-use-detected - with urllib.request.urlopen(url, timeout=5) as response: # noqa: S310 + with _open_health_url(url) as response: # noqa: S310 body = json.loads(response.read().decode("utf-8")) except Exception as exc: # pragma: no cover - network failure path print(f"healthcheck failed: {exc}", file=sys.stderr) diff --git a/services/account_unification/tests/test_healthcheck.py b/services/account_unification/tests/test_healthcheck.py index af99c34..60e4ff1 100644 --- a/services/account_unification/tests/test_healthcheck.py +++ b/services/account_unification/tests/test_healthcheck.py @@ -1,6 +1,8 @@ """Container healthcheck command behavior.""" from __future__ import annotations +import urllib.request + from app import healthcheck @@ -19,32 +21,31 @@ def read(self) -> bytes: def test_healthcheck_returns_zero_for_ok_status(monkeypatch, capsys): - def fake_urlopen(url: str, *, timeout: int) -> _Response: + def fake_open(url: str) -> _Response: assert url == "http://service/healthz" - assert timeout == 5 return _Response(b'{"status":"ok"}') - monkeypatch.setattr(healthcheck.urllib.request, "urlopen", fake_urlopen) + monkeypatch.setattr(healthcheck, "_open_health_url", fake_open) assert healthcheck.main("http://service/healthz") == 0 assert capsys.readouterr().out == "ok\n" def test_healthcheck_returns_one_for_non_ok_status(monkeypatch, capsys): - def fake_urlopen(url: str, *, timeout: int) -> _Response: + def fake_open(url: str) -> _Response: return _Response(b'{"status":"starting"}') - monkeypatch.setattr(healthcheck.urllib.request, "urlopen", fake_urlopen) + monkeypatch.setattr(healthcheck, "_open_health_url", fake_open) assert healthcheck.main("http://service/healthz") == 1 assert "not ready" in capsys.readouterr().err def test_healthcheck_returns_one_for_request_error(monkeypatch, capsys): - def fake_urlopen(url: str, *, timeout: int) -> _Response: + def fake_open(url: str) -> _Response: raise OSError("connection refused") - monkeypatch.setattr(healthcheck.urllib.request, "urlopen", fake_urlopen) + monkeypatch.setattr(healthcheck, "_open_health_url", fake_open) assert healthcheck.main("http://service/healthz") == 1 assert "healthcheck failed: connection refused" in capsys.readouterr().err @@ -52,10 +53,40 @@ def fake_urlopen(url: str, *, timeout: int) -> _Response: def test_healthcheck_rejects_non_http_scheme(monkeypatch, capsys): """A non-HTTP(S) URL is rejected before urllib ever opens it.""" - def fail_urlopen(*args: object, **kwargs: object) -> _Response: - raise AssertionError("urlopen must not run for a rejected scheme") + def fail_open(*args: object, **kwargs: object) -> _Response: + raise AssertionError("the opener must not run for a rejected scheme") - monkeypatch.setattr(healthcheck.urllib.request, "urlopen", fail_urlopen) + monkeypatch.setattr(healthcheck, "_open_health_url", fail_open) assert healthcheck.main("file:///etc/passwd") == 1 assert "unsupported URL scheme 'file'" in capsys.readouterr().err + + +def test_healthcheck_opener_drops_non_http_redirect_target(): + """A ``http:// -> ftp://`` redirect is dropped, and no ftp/file handler exists.""" + handler = healthcheck._HttpOnlyRedirectHandler() + dropped = handler.redirect_request( + urllib.request.Request("http://127.0.0.1:8099/healthz"), + None, + 302, + "Found", + {}, + "ftp://127.0.0.1/secret", + ) + assert dropped is None + # A same-scheme redirect is still honoured (returns a Request, not None). + kept = handler.redirect_request( + urllib.request.Request("http://127.0.0.1:8099/healthz"), + None, + 302, + "Found", + {}, + "http://127.0.0.1:8099/ready", + ) + assert kept is not None + # The opener carries no protocol handler that could open ftp/file targets. + opener = healthcheck._build_http_only_opener() + assert not any( + type(h).__name__ in {"FTPHandler", "FileHandler", "DataHandler"} + for h in opener.handlers + ) From 73428599aebe3326efca01d52c19261becef5e9a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 10:19:29 +0900 Subject: [PATCH 014/104] test(security): reproduce SCIM merge tombstone race --- .../account_unification/tests/test_scim.py | 121 +++++++++++++++++- 1 file changed, 120 insertions(+), 1 deletion(-) diff --git a/services/account_unification/tests/test_scim.py b/services/account_unification/tests/test_scim.py index cfd57d4..9b4d30d 100644 --- a/services/account_unification/tests/test_scim.py +++ b/services/account_unification/tests/test_scim.py @@ -1,16 +1,83 @@ """Inbound SCIM 2.0 provisioning shim -> Keycloak Admin API.""" from __future__ import annotations +import threading +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager + import pytest from fastapi.testclient import TestClient from app.main import create_app +from app.models import MergeRequest +from app.service import TOMBSTONE_ATTRIBUTE_KEY, UnificationService + +from .mock_keycloak import MockKeycloakAdminApi + + +class _TestUserOperationLocks: + """Small keyed lock manager used to prove cross-path serialization.""" + + def __init__(self) -> None: + self._guard = threading.Lock() + self._locks: dict[str, threading.RLock] = {} + + @contextmanager + def hold(self, *user_ids: str): + """Hold all requested user locks in stable order.""" + ordered_ids = sorted(set(user_ids)) + with self._guard: + locks = [self._locks.setdefault(user_id, threading.RLock()) for user_id in ordered_ids] + for lock in locks: + lock.acquire() + try: + yield + finally: + for lock in reversed(locks): + lock.release() + + +class _BlockingReplaceApi(MockKeycloakAdminApi): + """Pause SCIM replacement after its tombstone check to expose the race.""" + + def __init__(self) -> None: + super().__init__() + self.replace_started = threading.Event() + self.allow_replace = threading.Event() + self.tombstone_started = threading.Event() + + def replace_user(self, user_id, user) -> None: + """Wait until the test permits the full Keycloak representation PUT.""" + self.replace_started.set() + if not self.allow_replace.wait(timeout=5): + raise AssertionError("test did not release the blocked SCIM replacement") + super().replace_user(user_id, user) + # Keycloak's full user-representation PUT can remove attributes omitted + # from the payload and re-enable the account via SCIM's active=true. + self.attributes = { + attribute: value + for attribute, value in self.attributes.items() + if attribute[0] != user_id + } + self.deactivated.discard(user_id) + + def set_user_attribute(self, user_id: str, key: str, value: str) -> None: + """Signal when merge begins writing the duplicate tombstone.""" + if user_id == "dup" and key == TOMBSTONE_ATTRIBUTE_KEY: + self.tombstone_started.set() + super().set_user_attribute(user_id, key, value) + + +@pytest.fixture +def user_operation_locks(): + return _TestUserOperationLocks() @pytest.fixture -def client(api): +def client(api, user_operation_locks): app = create_app(wire=False) app.state.keycloak_api = api + app.state.user_operation_locks = user_operation_locks with TestClient(app) as test_client: yield test_client @@ -104,6 +171,58 @@ def test_scim_replace_refuses_to_resurrect_a_tombstoned_duplicate(client, api): assert api.get_user_attribute(dup_id, "merged_into_user_id") == "survivor-id" +def test_scim_replace_is_serialized_with_concurrent_merge(config, audit): + """A concurrent merge cannot slip between SCIM's tombstone check and PUT.""" + api = _BlockingReplaceApi() + locks = _TestUserOperationLocks() + service = UnificationService( + api, + audit, + config, + user_operation_locks=locks, + ) + app = create_app(wire=False) + app.state.keycloak_api = api + app.state.user_operation_locks = locks + app.state.unification_service = service + api.create_test_user( + "survivor", email="jane@corp.com", is_email_verified=True + ) + api.create_test_user("dup", email="jane@corp.com", is_email_verified=True) + merge_invoked = threading.Event() + + def run_merge(): + merge_invoked.set() + return service.merge_accounts( + MergeRequest( + survivor_user_id="survivor", + duplicate_user_id="dup", + actor="admin@cwl", + ) + ) + + with TestClient(app) as test_client, ThreadPoolExecutor(max_workers=2) as executor: + scim_future = executor.submit( + test_client.put, + "/scim/v2/Users/dup", + json=_scim_user(username="dup"), + ) + assert api.replace_started.wait(timeout=2) + merge_future = executor.submit(run_merge) + assert merge_invoked.wait(timeout=2) + + merge_was_serialized = not api.tombstone_started.wait(timeout=0.25) + api.allow_replace.set() + response = scim_future.result(timeout=5) + merge_result = merge_future.result(timeout=5) + + assert merge_was_serialized + assert response.status_code == 200 + assert merge_result.duplicate_tombstoned is True + assert api.get_user("dup").state == "disabled" + assert api.get_user_attribute("dup", TOMBSTONE_ATTRIBUTE_KEY) == "survivor" + + def test_scim_patch_deactivates_user(client, api): created = client.post("/scim/v2/Users", json=_scim_user()).json() response = client.patch( From 06abe3aae952dc47152f73392072f3db874fccae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 10:24:07 +0900 Subject: [PATCH 015/104] fix(security): add shared user-operation lock abstraction --- .../account_unification/app/user_locks.py | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 services/account_unification/app/user_locks.py diff --git a/services/account_unification/app/user_locks.py b/services/account_unification/app/user_locks.py new file mode 100644 index 0000000..c9a3d11 --- /dev/null +++ b/services/account_unification/app/user_locks.py @@ -0,0 +1,141 @@ +"""Cross-path serialization for mutations of Keycloak user records. + +SCIM replacement and account merge both write complete or partial Keycloak user +representations. They must share one lock boundary so a merge cannot tombstone a +duplicate between SCIM's tombstone check and its replacement PUT. + +The standalone runtime uses :class:`SqliteUserOperationLocks`, backed by a +dedicated sidecar SQLite database. ``BEGIN IMMEDIATE`` provides a crash-safe, +cross-process mutex for every service worker sharing that database file. The +current implementation intentionally serializes all user mutations rather than +risking a multi-key deadlock; the public interface remains user-ID keyed so a +future Postgres advisory-lock implementation can safely increase concurrency. +""" +from __future__ import annotations + +import sqlite3 +import threading +from contextlib import contextmanager +from typing import ContextManager, Iterator, Protocol + + +class UserOperationLockTimeout(RuntimeError): + """Raised when a shared user-operation lock cannot be acquired in time.""" + + +class UserOperationLocks(Protocol): + """Serialize mutations that involve one or more Keycloak user IDs.""" + + def hold(self, *user_ids: str) -> ContextManager[None]: + """Return a context manager holding the requested user-operation locks.""" + ... + + +def _normalise_user_ids(user_ids: tuple[str, ...]) -> tuple[str, ...]: + """Return unique, non-empty user IDs in deterministic acquisition order.""" + ordered = tuple(sorted(set(user_ids))) + if not ordered or any(not user_id for user_id in ordered): + raise ValueError("at least one non-empty user ID is required") + return ordered + + +class InMemoryUserOperationLocks: + """Process-local keyed lock manager for tests and explicit single-worker use.""" + + def __init__(self) -> None: + """Create an empty keyed re-entrant lock registry.""" + self._registry_guard = threading.Lock() + self._locks: dict[str, threading.RLock] = {} + + @contextmanager + def hold(self, *user_ids: str) -> Iterator[None]: + """Hold all requested user locks in stable order to avoid deadlocks.""" + ordered_ids = _normalise_user_ids(user_ids) + with self._registry_guard: + locks = [ + self._locks.setdefault(user_id, threading.RLock()) + for user_id in ordered_ids + ] + for lock in locks: + lock.acquire() + try: + yield + finally: + for lock in reversed(locks): + lock.release() + + +class SqliteUserOperationLocks: + """Cross-process mutex backed by a dedicated SQLite sidecar database. + + SQLite permits only one writer holding a ``BEGIN IMMEDIATE`` transaction. + Every manager instance pointed at the same database file therefore shares a + crash-safe mutex: process termination closes the connection and releases the + lock automatically. This is deliberately coarser than the user-ID-keyed + protocol, but it fully serializes the SCIM and merge critical sections for + the supported SQLite deployment without introducing a lease-expiry race. + """ + + _SCHEMA = """ + CREATE TABLE IF NOT EXISTS user_operation_lock_state ( + lock_name TEXT PRIMARY KEY, + requested_user_ids TEXT NOT NULL + ); + """ + + def __init__(self, database_path: str, *, timeout_seconds: float = 10.0) -> None: + """Create a manager using ``database_path`` and an acquisition timeout.""" + if not database_path: + raise ValueError("database_path is required") + if timeout_seconds <= 0: + raise ValueError("timeout_seconds must be positive") + self._database_path = database_path + self._timeout_seconds = timeout_seconds + self._initialize() + + def _connect(self) -> sqlite3.Connection: + """Open one autocommit connection configured with the lock timeout.""" + connection = sqlite3.connect( + self._database_path, + timeout=self._timeout_seconds, + isolation_level=None, + ) + connection.execute( + f"PRAGMA busy_timeout = {int(self._timeout_seconds * 1000)}" + ) + return connection + + def _initialize(self) -> None: + """Create the sidecar schema before requests begin competing for it.""" + connection = self._connect() + try: + connection.execute(self._SCHEMA) + finally: + connection.close() + + @contextmanager + def hold(self, *user_ids: str) -> Iterator[None]: + """Hold the shared SQLite mutex for the complete user mutation.""" + ordered_ids = _normalise_user_ids(user_ids) + connection = self._connect() + try: + try: + connection.execute("BEGIN IMMEDIATE") + except sqlite3.OperationalError as exc: + if "locked" in str(exc).lower(): + raise UserOperationLockTimeout( + "timed out waiting for another user mutation to finish" + ) from exc + raise + connection.execute( + "INSERT INTO user_operation_lock_state " + "(lock_name, requested_user_ids) VALUES ('global', ?) " + "ON CONFLICT(lock_name) DO UPDATE SET " + "requested_user_ids = excluded.requested_user_ids", + (",".join(ordered_ids),), + ) + yield + finally: + if connection.in_transaction: + connection.rollback() + connection.close() From 9375b4e70dca54eac9c2efc91a65a9ad05fd47a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 10:24:56 +0900 Subject: [PATCH 016/104] fix(security): serialize merge mutations with SCIM --- services/account_unification/app/service.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/services/account_unification/app/service.py b/services/account_unification/app/service.py index 3354c6f..165d7e5 100644 --- a/services/account_unification/app/service.py +++ b/services/account_unification/app/service.py @@ -32,6 +32,7 @@ MergeResult, UserAccount, ) +from .user_locks import UserOperationLocks # Keycloak user attribute stamped on a tombstoned duplicate (two-word snake_case). TOMBSTONE_ATTRIBUTE_KEY = "merged_into_user_id" @@ -46,11 +47,13 @@ def __init__( api: AdminApi, audit: AuditLogger, config: ServiceConfig, + user_operation_locks: UserOperationLocks, ) -> None: - """Create a service around admin API, audit, and config dependencies.""" + """Create a service around admin, audit, config, and lock dependencies.""" self._api = api self._audit = audit self._config = config + self._user_operation_locks = user_operation_locks # -- (a) inspect identities ------------------------------------------- def get_account(self, user_id: str) -> UserAccount: @@ -70,6 +73,18 @@ def merge_accounts(self, request: MergeRequest) -> MergeResult: if request.survivor_user_id == request.duplicate_user_id: raise SameUserError("survivor and duplicate are the same account") + # The complete merge, including the final tombstone write, shares the + # same duplicate-user lock as SCIM replacement. This closes the TOCTOU + # window where SCIM could pass its tombstone check, then overwrite a + # concurrently-created tombstone with an active user representation. + with self._user_operation_locks.hold( + request.survivor_user_id, + request.duplicate_user_id, + ): + return self._merge_accounts_locked(request) + + def _merge_accounts_locked(self, request: MergeRequest) -> MergeResult: + """Perform a merge while both participating user IDs are serialized.""" survivor = self._load_user(request.survivor_user_id) duplicate = self._load_user(request.duplicate_user_id) survivor.federated_identities = self._api.list_federated_identities( From ce436a2dbddde8e8fb583b365355370b12e11ae4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 10:25:43 +0900 Subject: [PATCH 017/104] fix(security): make SCIM tombstone check and PUT atomic --- services/account_unification/app/scim.py | 54 ++++++++++++++++-------- 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/services/account_unification/app/scim.py b/services/account_unification/app/scim.py index f76f6ea..4c0b9fb 100644 --- a/services/account_unification/app/scim.py +++ b/services/account_unification/app/scim.py @@ -21,6 +21,10 @@ from .keycloak_client import AdminApi from .models import UserAccount from .service import TOMBSTONE_ATTRIBUTE_KEY +from .user_locks import ( + UserOperationLocks, + UserOperationLockTimeout, +) SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User" SCIM_LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse" @@ -39,6 +43,14 @@ def get_provisioner(request: Request) -> AdminApi: return api +def get_user_operation_locks(request: Request) -> UserOperationLocks: + """Return the shared lock manager used by both SCIM and merge paths.""" + locks = getattr(request.app.state, "user_operation_locks", None) + if locks is None: # pragma: no cover - only when misconfigured / test wiring + raise HTTPException(status_code=503, detail="user operation locks not wired") + return locks + + def _scim_error(status: int, detail: str) -> HTTPException: """Build a SCIM-shaped HTTP error.""" return HTTPException( @@ -187,27 +199,35 @@ def replace_user( user_id: str, resource: dict[str, Any], provisioner: AdminApi = Depends(get_provisioner), + user_operation_locks: UserOperationLocks = Depends(get_user_operation_locks), ) -> Response: """Replace a provisioned user from a SCIM PUT request.""" try: - provisioner.get_user(user_id) - except KeyError as exc: - raise _scim_error(404, f"user '{user_id}' not found") from exc - # A merged-away duplicate is tombstoned (disabled + a merged_into_user_id - # pointer) so it can never authenticate again. SCIM PUT is the only - # reactivation vector -- create guards uniqueness, and patch/delete only - # ever disable -- and a live Keycloak PUT would also overwrite the whole - # user representation, wiping the survivor pointer. Refuse it outright so a - # routine upstream full-sync cannot resurrect a decommissioned identity. - if provisioner.get_user_attribute(user_id, TOMBSTONE_ATTRIBUTE_KEY): + with user_operation_locks.hold(user_id): + try: + provisioner.get_user(user_id) + except KeyError as exc: + raise _scim_error(404, f"user '{user_id}' not found") from exc + # A merged-away duplicate is tombstoned (disabled + a + # merged_into_user_id pointer) so it can never authenticate again. + # Keep the check and the full replacement PUT under the same lock + # used by merge_accounts; otherwise merge can create the tombstone + # between these two Admin API calls and SCIM can wipe it again. + if provisioner.get_user_attribute(user_id, TOMBSTONE_ATTRIBUTE_KEY): + raise _scim_error( + 409, + f"user '{user_id}' has been merged into another account " + "and cannot be modified", + ) + account = _to_user_account(resource, user_id=user_id) + provisioner.replace_user(user_id, account) + replaced = provisioner.get_user(user_id) + except UserOperationLockTimeout as exc: raise _scim_error( - 409, - f"user '{user_id}' has been merged into another account " - "and cannot be modified", - ) - account = _to_user_account(resource, user_id=user_id) - provisioner.replace_user(user_id, account) - return _scim_response(_to_scim_resource(provisioner.get_user(user_id))) + 503, + f"user '{user_id}' is being modified; retry the request", + ) from exc + return _scim_response(_to_scim_resource(replaced)) @scim_router.patch("/Users/{user_id}") From ac3b05620e5fcabacf1f6da7cd06513515d3f2e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 10:26:03 +0900 Subject: [PATCH 018/104] fix(security): wire one shared mutation lock manager --- services/account_unification/app/main.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/services/account_unification/app/main.py b/services/account_unification/app/main.py index ddff44a..355bc09 100644 --- a/services/account_unification/app/main.py +++ b/services/account_unification/app/main.py @@ -20,6 +20,7 @@ from .keycloak_client import HttpAdminApi from .scim import scim_router from .service import UnificationService +from .user_locks import SqliteUserOperationLocks def build_service(app: FastAPI) -> None: @@ -39,10 +40,22 @@ def build_service(app: FastAPI) -> None: # a Postgres-backed sink writing account_merge_audit. audit_path = descriptor.sqlite_path or "account_unification.db" audit = AuditLogger(SqliteAuditSink(audit_path)) + # Use a dedicated sidecar database so the serialization transaction never + # blocks config reads/writes or audit persistence. Every service worker that + # shares this path also shares the same crash-safe SQLite mutex. + user_operation_locks = SqliteUserOperationLocks( + f"{audit_path}.user-operation-locks.sqlite3" + ) - app.state.unification_service = UnificationService(api, audit, config) + app.state.unification_service = UnificationService( + api, + audit, + config, + user_operation_locks, + ) app.state.audit_logger = audit app.state.keycloak_api = api + app.state.user_operation_locks = user_operation_locks app.state.ready = True From 5220bab330e11dff8dd4d73b2944ac3933ba9582 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 10:26:19 +0900 Subject: [PATCH 019/104] test: inject shared user-operation locks into service fixtures --- services/account_unification/tests/conftest.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/services/account_unification/tests/conftest.py b/services/account_unification/tests/conftest.py index 3deba76..64d7ecf 100644 --- a/services/account_unification/tests/conftest.py +++ b/services/account_unification/tests/conftest.py @@ -11,6 +11,7 @@ from app.audit import AuditLogger, InMemoryAuditSink # noqa: E402 from app.config import ServiceConfig # noqa: E402 from app.service import UnificationService # noqa: E402 +from app.user_locks import InMemoryUserOperationLocks # noqa: E402 from .mock_keycloak import MockKeycloakAdminApi # noqa: E402 @@ -42,8 +43,16 @@ def config() -> ServiceConfig: ) +@pytest.fixture +def user_operation_locks() -> InMemoryUserOperationLocks: + return InMemoryUserOperationLocks() + + @pytest.fixture def service( - api: MockKeycloakAdminApi, audit: AuditLogger, config: ServiceConfig + api: MockKeycloakAdminApi, + audit: AuditLogger, + config: ServiceConfig, + user_operation_locks: InMemoryUserOperationLocks, ) -> UnificationService: - return UnificationService(api, audit, config) + return UnificationService(api, audit, config, user_operation_locks) From 3c4d09889994c087f2b53879b729ab158984a810 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 10:26:40 +0900 Subject: [PATCH 020/104] fix(api): surface user-operation lock contention as retryable --- services/account_unification/app/api.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/services/account_unification/app/api.py b/services/account_unification/app/api.py index 3e8119b..fe042a7 100644 --- a/services/account_unification/app/api.py +++ b/services/account_unification/app/api.py @@ -13,6 +13,7 @@ ) from .models import FederatedIdentity, MergeRequest, MergeResult, UserAccount from .service import UnificationService +from .user_locks import UserOperationLockTimeout router = APIRouter() @@ -75,6 +76,11 @@ def merge_accounts( raise HTTPException(status_code=409, detail=str(exc)) from exc except InactiveAccountError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc + except UserOperationLockTimeout as exc: + raise HTTPException( + status_code=503, + detail="one of the requested accounts is being modified; retry", + ) from exc @router.get("/merges/{audit_id}/audit", tags=["merge"]) From 68ad2a1a42d9ee836f17ee9ea013be13411e9807 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 10:27:04 +0900 Subject: [PATCH 021/104] test(security): verify shared lock serialization and timeout --- .../tests/test_user_locks.py | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 services/account_unification/tests/test_user_locks.py diff --git a/services/account_unification/tests/test_user_locks.py b/services/account_unification/tests/test_user_locks.py new file mode 100644 index 0000000..97f0271 --- /dev/null +++ b/services/account_unification/tests/test_user_locks.py @@ -0,0 +1,71 @@ +"""Shared user-operation serialization for merge and SCIM writes.""" +from __future__ import annotations + +import threading +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from app.user_locks import ( + InMemoryUserOperationLocks, + SqliteUserOperationLocks, + UserOperationLockTimeout, +) + + +def _assert_overlapping_operation_waits(first_manager, second_manager) -> None: + first_entered = threading.Event() + release_first = threading.Event() + second_entered = threading.Event() + + def hold_first() -> None: + with first_manager.hold("survivor", "dup"): + first_entered.set() + assert release_first.wait(timeout=5) + + def hold_second() -> None: + assert first_entered.wait(timeout=5) + with second_manager.hold("dup"): + second_entered.set() + + with ThreadPoolExecutor(max_workers=2) as executor: + first_future = executor.submit(hold_first) + second_future = executor.submit(hold_second) + assert first_entered.wait(timeout=2) + was_serialized = not second_entered.wait(timeout=0.25) + release_first.set() + first_future.result(timeout=5) + second_future.result(timeout=5) + + assert was_serialized + assert second_entered.is_set() + + +def test_in_memory_locks_serialize_overlapping_user_ids(): + manager = InMemoryUserOperationLocks() + _assert_overlapping_operation_waits(manager, manager) + + +def test_sqlite_locks_serialize_distinct_manager_instances(tmp_path): + database_path = str(tmp_path / "user-operation-locks.sqlite3") + first_manager = SqliteUserOperationLocks(database_path) + second_manager = SqliteUserOperationLocks(database_path) + _assert_overlapping_operation_waits(first_manager, second_manager) + + +def test_sqlite_lock_timeout_is_explicit_and_retryable(tmp_path): + database_path = str(tmp_path / "user-operation-locks.sqlite3") + first_manager = SqliteUserOperationLocks(database_path) + impatient_manager = SqliteUserOperationLocks(database_path, timeout_seconds=0.05) + + with first_manager.hold("dup"): + with pytest.raises(UserOperationLockTimeout): + with impatient_manager.hold("dup"): + pytest.fail("contending operation unexpectedly acquired the lock") + + +def test_lock_manager_rejects_empty_user_ids(): + manager = InMemoryUserOperationLocks() + with pytest.raises(ValueError): + with manager.hold(""): + pytest.fail("empty user ID unexpectedly acquired a lock") From cbd283ca8322394674b2132742b5f7d78e024a6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 10:27:32 +0900 Subject: [PATCH 022/104] docs: define SCIM and merge serialization invariant --- docs/merge-unification-flow.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/merge-unification-flow.md b/docs/merge-unification-flow.md index a776a27..35a88f9 100644 --- a/docs/merge-unification-flow.md +++ b/docs/merge-unification-flow.md @@ -31,6 +31,7 @@ only an unverified email, the merge is refused with `422 Unverified email`. ``` merge(survivor S, duplicate D, actor A): reject if S == D -> 400 SameUser + acquire shared user-operation locks for S and D load S, D (must exist) -> 404 UserNotFound reject if S or D disabled -> 409 InactiveAccount decision = decide_match(S, D, explicit) @@ -55,6 +56,7 @@ merge(survivor S, duplicate D, actor A): disable D # D can never authenticate again audit "duplicate_tombstoned" audit "merge_completed" {moved_*, conflicts} + release shared user-operation locks return MergeResult{..., audit_id} ``` @@ -72,6 +74,25 @@ The duplicate is **not deleted**. Its Keycloak user attribute (`enabled: false`). This preserves forensic history and lets any stale reference resolve to the survivor. +### SCIM/merge serialization invariant + +`PUT /scim/v2/Users/{id}` performs a full Keycloak user-representation write and +can set `active: true`. Its tombstone check and replacement PUT therefore execute +inside the **same user-operation lock** used by the complete merge transaction. +A merge cannot create `merged_into_user_id` between those two Admin API calls, +and SCIM cannot wipe a newly-created tombstone or reactivate the duplicate. + +Standalone deployments use a dedicated SQLite sidecar lock database and hold a +`BEGIN IMMEDIATE` transaction for the complete critical section. This provides a +crash-safe mutex shared by every worker/process using the same database path; +process death closes the connection and releases the lock. The current backend +serializes all user mutations conservatively rather than risking a multi-user +deadlock. Lock acquisition waits up to 10 seconds, then returns retryable HTTP +`503` without performing a partial mutation. A clustered Postgres deployment +must provide the same `UserOperationLocks` contract (for example, ordered +advisory locks) and wire one shared instance into both the merge service and SCIM +router. + ## Audit Every step emits an immutable `account_merge_audit` event sharing one From 1051cebf520afb321134e2e2db313201a5027056 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 10:49:02 +0900 Subject: [PATCH 023/104] fix(security): preserve the unverified-email hard rule under serialization --- services/account_unification/app/service.py | 85 ++++++++++++++------- 1 file changed, 58 insertions(+), 27 deletions(-) diff --git a/services/account_unification/app/service.py b/services/account_unification/app/service.py index 165d7e5..16c42d8 100644 --- a/services/account_unification/app/service.py +++ b/services/account_unification/app/service.py @@ -27,6 +27,7 @@ from .matching import decide_match, have_matching_verified_email from .models import ( FederatedIdentity, + MatchReason, MergeConflict, MergeRequest, MergeResult, @@ -100,19 +101,28 @@ def _merge_accounts_locked(self, request: MergeRequest) -> MergeResult: decision = decide_match( survivor, duplicate, explicit_link=request.explicit_link ) - # Guard: refuse when the accounts only coincide on an unverified email. - # (decide_match already refuses to *call* that a verified match; here we - # produce the precise error for the operator + audit trail.) - if not decision.matched: - same_email = ( - (survivor.email or "").strip().lower() - == (duplicate.email or "").strip().lower() - and bool(survivor.email) + # Hard rule (enforced here per docs/merge-unification-flow.md and the + # MergeRequest.explicit_link contract): never merge when the only tie is + # an UNVERIFIED email. An unverified address is attacker-registerable, + # so even an operator's explicit_link assertion must not promote a shared + # unverified email into a merge — only a strong tie (exact idp subject or + # a mutually verified email) justifies it. This guard therefore runs + # regardless of decision.matched, catching the explicit_link path too. + shares_unverified_email = ( + bool((survivor.email or "").strip()) + and (survivor.email or "").strip().lower() + == (duplicate.email or "").strip().lower() + and not have_matching_verified_email(survivor, duplicate) + ) + strong_tie = decision.reason in ( + MatchReason.EXACT_IDP_SUBJECT, + MatchReason.VERIFIED_EMAIL, + ) + if shares_unverified_email and not strong_tie: + raise UnverifiedEmailMergeError( + "refusing merge: accounts share only an UNVERIFIED email" ) - if same_email and not have_matching_verified_email(survivor, duplicate): - raise UnverifiedEmailMergeError( - "refusing merge: accounts share only an UNVERIFIED email" - ) + if not decision.matched: raise NoMatchError(decision.detail or "no matching rule satisfied") audit_id = self._audit.new_correlation_id() @@ -196,8 +206,10 @@ def _move_federated_identities( duplicate.user_id, link.identity_provider ) self._audit.emit( - audit_id=audit_id, event_type="federated_identity_conflict", - actor=actor, survivor_user_id=survivor.user_id, + audit_id=audit_id, + event_type="federated_identity_conflict", + actor=actor, + survivor_user_id=survivor.user_id, duplicate_user_id=duplicate.user_id, payload={"identifier": identifier, "resolution": "survivor_wins"}, ) @@ -208,8 +220,11 @@ def _move_federated_identities( ) moved.append(identifier) self._audit.emit( - audit_id=audit_id, event_type="federated_identity_moved", actor=actor, - survivor_user_id=survivor.user_id, duplicate_user_id=duplicate.user_id, + audit_id=audit_id, + event_type="federated_identity_moved", + actor=actor, + survivor_user_id=survivor.user_id, + duplicate_user_id=duplicate.user_id, payload={"identifier": identifier}, ) return moved @@ -234,7 +249,9 @@ def _move_role_mappings( # survivor-wins: survivor already has it; drop the duplicate's. self._api.remove_role_mapping(duplicate.user_id, role) self._audit.emit( - audit_id=audit_id, event_type="role_mapping_conflict", actor=actor, + audit_id=audit_id, + event_type="role_mapping_conflict", + actor=actor, survivor_user_id=survivor.user_id, duplicate_user_id=duplicate.user_id, payload={"identifier": identifier, "resolution": "survivor_wins"}, @@ -244,10 +261,16 @@ def _move_role_mappings( self._api.remove_role_mapping(duplicate.user_id, role) moved.append(identifier) self._audit.emit( - audit_id=audit_id, event_type="role_mapping_moved", actor=actor, - survivor_user_id=survivor.user_id, duplicate_user_id=duplicate.user_id, - payload={"identifier": identifier, "role_name": role.role_name, - "client_id": role.client_id}, + audit_id=audit_id, + event_type="role_mapping_moved", + actor=actor, + survivor_user_id=survivor.user_id, + duplicate_user_id=duplicate.user_id, + payload={ + "identifier": identifier, + "role_name": role.role_name, + "client_id": role.client_id, + }, ) return moved @@ -267,8 +290,10 @@ def _move_group_memberships( ) self._api.remove_group_membership(duplicate.user_id, group) self._audit.emit( - audit_id=audit_id, event_type="group_membership_conflict", - actor=actor, survivor_user_id=survivor.user_id, + audit_id=audit_id, + event_type="group_membership_conflict", + actor=actor, + survivor_user_id=survivor.user_id, duplicate_user_id=duplicate.user_id, payload={"identifier": identifier, "resolution": "survivor_wins"}, ) @@ -277,8 +302,11 @@ def _move_group_memberships( self._api.remove_group_membership(duplicate.user_id, group) moved.append(identifier) self._audit.emit( - audit_id=audit_id, event_type="group_membership_moved", actor=actor, - survivor_user_id=survivor.user_id, duplicate_user_id=duplicate.user_id, + audit_id=audit_id, + event_type="group_membership_moved", + actor=actor, + survivor_user_id=survivor.user_id, + duplicate_user_id=duplicate.user_id, payload={"identifier": identifier}, ) return moved @@ -292,8 +320,11 @@ def _tombstone(self, duplicate, survivor, audit_id, actor) -> None: ) self._api.deactivate_user(duplicate.user_id) self._audit.emit( - audit_id=audit_id, event_type="duplicate_tombstoned", actor=actor, - survivor_user_id=survivor.user_id, duplicate_user_id=duplicate.user_id, + audit_id=audit_id, + event_type="duplicate_tombstoned", + actor=actor, + survivor_user_id=survivor.user_id, + duplicate_user_id=duplicate.user_id, payload={"attribute_key": TOMBSTONE_ATTRIBUTE_KEY}, ) From 5dbba0a3624975bc10bff60fcbf2853bd4f84728 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 10:49:36 +0900 Subject: [PATCH 024/104] fix(security): harden healthcheck redirects and document the handler --- .../account_unification/app/healthcheck.py | 27 ++++++------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/services/account_unification/app/healthcheck.py b/services/account_unification/app/healthcheck.py index 93902fe..22a1148 100644 --- a/services/account_unification/app/healthcheck.py +++ b/services/account_unification/app/healthcheck.py @@ -15,25 +15,17 @@ class _HttpOnlyRedirectHandler(urllib.request.HTTPRedirectHandler): - """Redirect handler that drops any redirect whose target scheme is not HTTP(S). + """Drop redirects whose target scheme is not HTTP(S).""" - The initial-URL scheme check does not cover a ``Location`` header, so an - ``http:// -> ftp://`` (or ``file://``) redirect would otherwise be followed - by whichever protocol handler the opener carries. - """ - - def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001, D102 + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001 + """Return an HTTP(S) redirect request, or reject another scheme.""" if urllib.parse.urlsplit(newurl).scheme.lower() not in _ALLOWED_SCHEMES: return None return super().redirect_request(req, fp, code, msg, headers, newurl) def _build_http_only_opener() -> urllib.request.OpenerDirector: - """Build an opener that can speak only HTTP(S) -- no ftp/file/data handlers. - - Even if a redirect target slipped past :class:`_HttpOnlyRedirectHandler`, the - opener has no handler able to open it, so ``ftp://``/``file://`` fail closed. - """ + """Build an opener that can speak only HTTP(S), without file/FTP handlers.""" opener = urllib.request.OpenerDirector() opener.add_handler(urllib.request.HTTPHandler()) opener.add_handler(urllib.request.HTTPSHandler()) @@ -43,7 +35,7 @@ def _build_http_only_opener() -> urllib.request.OpenerDirector: def _open_health_url(url: str): # noqa: ANN202 - """Open an HTTP(S) health URL through the scheme-restricted, ftp/file-less opener.""" + """Open an HTTP(S) health URL through the restricted opener.""" return _build_http_only_opener().open(url, timeout=5) @@ -51,15 +43,12 @@ def main(url: str = DEFAULT_URL) -> int: """Check the configured health endpoint and return a shell status code.""" scheme = urllib.parse.urlsplit(url).scheme.lower() if scheme not in _ALLOWED_SCHEMES: - # Reject non-HTTP(S) schemes so a stray value can never coerce urlopen into - # reading a local ``file://`` path or reaching another protocol handler. print(f"healthcheck failed: unsupported URL scheme {scheme!r}", file=sys.stderr) return 1 try: - # Internal container self-probe. Both the initial scheme (above) and any - # redirect target are constrained to http/https, and the opener carries no - # ftp/file handler, so this cannot reach another protocol handler. - # nosemgrep: dynamic-urllib-use-detected + # The initial and redirected schemes are constrained to HTTP(S), and the + # opener carries no handler for file, FTP, or data URLs. + # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected with _open_health_url(url) as response: # noqa: S310 body = json.loads(response.read().decode("utf-8")) except Exception as exc: # pragma: no cover - network failure path From 624e0e5d254baa95829d0ed6c6309f1ebbbd3144 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 10:52:07 +0900 Subject: [PATCH 025/104] test(security): retain explicit-link unverified-email regressions --- .../account_unification/tests/test_merge.py | 129 +++++++++++++----- 1 file changed, 93 insertions(+), 36 deletions(-) diff --git a/services/account_unification/tests/test_merge.py b/services/account_unification/tests/test_merge.py index 1acfd12..de00138 100644 --- a/services/account_unification/tests/test_merge.py +++ b/services/account_unification/tests/test_merge.py @@ -35,9 +35,13 @@ def test_merge_moves_links_roles_groups_and_tombstones(service, api): email="jane@corp.com", is_email_verified=True, federated_identities=[ - FederatedIdentity(identity_provider="employer-adfs", external_user_id="jane@corp") + FederatedIdentity( + identity_provider="employer-adfs", external_user_id="jane@corp" + ) + ], + role_mappings=[ + RoleMapping(role_id="r-s", role_name="viewer", client_id="naruon") ], - role_mappings=[RoleMapping(role_id="r-s", role_name="viewer", client_id="naruon")], group_memberships=[GroupMembership(group_id="g-org", group_path="/org")], ) api.create_test_user( @@ -45,9 +49,13 @@ def test_merge_moves_links_roles_groups_and_tombstones(service, api): email="jane@corp.com", is_email_verified=True, federated_identities=[ - FederatedIdentity(identity_provider="google", external_user_id="jane@gmail") + FederatedIdentity( + identity_provider="google", external_user_id="jane@gmail" + ) + ], + role_mappings=[ + RoleMapping(role_id="r-d", role_name="editor", client_id="clearfolio") ], - role_mappings=[RoleMapping(role_id="r-d", role_name="editor", client_id="clearfolio")], group_memberships=[GroupMembership(group_id="g-proj", group_path="/pg-erd")], ) @@ -55,93 +63,127 @@ def test_merge_moves_links_roles_groups_and_tombstones(service, api): assert result.match_reason is MatchReason.VERIFIED_EMAIL assert result.duplicate_tombstoned is True - # survivor gained the duplicate's external identity... - survivor_idps = {f.identity_provider for f in api.list_federated_identities("survivor")} + survivor_idps = { + identity.identity_provider + for identity in api.list_federated_identities("survivor") + } assert survivor_idps == {"employer-adfs", "google"} - # ...its client role... - survivor_roles = {r.role_name for r in api.list_role_mappings("survivor")} + survivor_roles = {role.role_name for role in api.list_role_mappings("survivor")} assert survivor_roles == {"viewer", "editor"} - # ...and its group. - survivor_groups = {g.group_id for g in api.list_group_memberships("survivor")} + survivor_groups = { + group.group_id for group in api.list_group_memberships("survivor") + } assert survivor_groups == {"g-org", "g-proj"} - # duplicate is emptied + tombstoned + disabled. assert api.list_federated_identities("dup") == [] assert "dup" in api.deactivated assert api.attributes[("dup", TOMBSTONE_ATTRIBUTE_KEY)] == "survivor" def test_merge_by_exact_idp_subject(service, api): - shared = FederatedIdentity(identity_provider="employer-adfs", external_user_id="jane@corp") + shared = FederatedIdentity( + identity_provider="employer-adfs", external_user_id="jane@corp" + ) api.create_test_user("survivor", email="a@x.com", federated_identities=[shared]) api.create_test_user("dup", email="b@y.com", federated_identities=[shared]) result = service.merge_accounts(_merge()) assert result.match_reason is MatchReason.EXACT_IDP_SUBJECT - # shared link stays on survivor exactly once (survivor-wins conflict). - assert [f.external_user_id for f in api.list_federated_identities("survivor")] == ["jane@corp"] - assert any(c.kind == "federated_identity" for c in result.conflicts) + assert [ + identity.external_user_id + for identity in api.list_federated_identities("survivor") + ] == ["jane@corp"] + assert any(conflict.kind == "federated_identity" for conflict in result.conflicts) def test_federated_identity_provider_conflict_is_survivor_wins(service, api): - # Same provider alias, different external subject: Keycloak allows only one - # link per provider, so survivor-wins keeps the survivor's. api.create_test_user( - "survivor", email="j@x.com", is_email_verified=True, + "survivor", + email="j@x.com", + is_email_verified=True, federated_identities=[ - FederatedIdentity(identity_provider="employer-adfs", external_user_id="jane@corp") + FederatedIdentity( + identity_provider="employer-adfs", external_user_id="jane@corp" + ) ], ) api.create_test_user( - "dup", email="j@x.com", is_email_verified=True, + "dup", + email="j@x.com", + is_email_verified=True, federated_identities=[ - FederatedIdentity(identity_provider="employer-adfs", external_user_id="jane2@corp") + FederatedIdentity( + identity_provider="employer-adfs", external_user_id="jane2@corp" + ) ], ) result = service.merge_accounts(_merge()) survivor_links = api.list_federated_identities("survivor") - assert [f.external_user_id for f in survivor_links] == ["jane@corp"] - assert any(c.kind == "federated_identity" for c in result.conflicts) + assert [identity.external_user_id for identity in survivor_links] == ["jane@corp"] + assert any(conflict.kind == "federated_identity" for conflict in result.conflicts) def test_role_conflict_is_survivor_wins(service, api): api.create_test_user( - "survivor", email="j@x.com", is_email_verified=True, - role_mappings=[RoleMapping(role_id="r-s", role_name="admin", client_id="naruon")], + "survivor", + email="j@x.com", + is_email_verified=True, + role_mappings=[ + RoleMapping(role_id="r-s", role_name="admin", client_id="naruon") + ], ) api.create_test_user( - "dup", email="j@x.com", is_email_verified=True, - role_mappings=[RoleMapping(role_id="r-d", role_name="admin", client_id="naruon")], + "dup", + email="j@x.com", + is_email_verified=True, + role_mappings=[ + RoleMapping(role_id="r-d", role_name="admin", client_id="naruon") + ], ) result = service.merge_accounts(_merge()) survivor_roles = api.list_role_mappings("survivor") - # only the survivor's admin role on naruon survives. assert len(survivor_roles) == 1 assert survivor_roles[0].role_id == "r-s" - assert any(c.kind == "role_mapping" and c.resolution == "survivor_wins" for c in result.conflicts) + assert any( + conflict.kind == "role_mapping" + and conflict.resolution == "survivor_wins" + for conflict in result.conflicts + ) def test_realm_role_moves(service, api): api.create_test_user("survivor", email="j@x.com", is_email_verified=True) api.create_test_user( - "dup", email="j@x.com", is_email_verified=True, - role_mappings=[RoleMapping(role_id="r-realm", role_name="ecosystem-user", client_id=None)], + "dup", + email="j@x.com", + is_email_verified=True, + role_mappings=[ + RoleMapping( + role_id="r-realm", role_name="ecosystem-user", client_id=None + ) + ], ) result = service.merge_accounts(_merge()) assert "realm:ecosystem-user" in result.moved_role_mappings - assert any(r.client_id is None for r in api.list_role_mappings("survivor")) + assert any( + role.client_id is None for role in api.list_role_mappings("survivor") + ) def test_group_conflict_is_survivor_wins(service, api): api.create_test_user( - "survivor", email="j@x.com", is_email_verified=True, + "survivor", + email="j@x.com", + is_email_verified=True, group_memberships=[GroupMembership(group_id="g1", group_path="/owners")], ) api.create_test_user( - "dup", email="j@x.com", is_email_verified=True, + "dup", + email="j@x.com", + is_email_verified=True, group_memberships=[GroupMembership(group_id="g1", group_path="/owners")], ) result = service.merge_accounts(_merge()) assert len(api.list_group_memberships("survivor")) == 1 - assert any(c.kind == "group_membership" for c in result.conflicts) + assert any(conflict.kind == "group_membership" for conflict in result.conflicts) def test_refuse_merge_on_unverified_email(service, api): @@ -149,7 +191,6 @@ def test_refuse_merge_on_unverified_email(service, api): api.create_test_user("dup", email="jane@corp.com", is_email_verified=False) with pytest.raises(UnverifiedEmailMergeError): service.merge_accounts(_merge()) - # nothing mutated: duplicate not tombstoned. assert "dup" not in api.deactivated @@ -168,6 +209,22 @@ def test_explicit_link_allows_merge_without_shared_signal(service, api): assert result.duplicate_tombstoned +def test_explicit_link_cannot_override_shared_unverified_email(service, api): + api.create_test_user("survivor", email="jane@corp.com", is_email_verified=False) + api.create_test_user("dup", email="jane@corp.com", is_email_verified=False) + with pytest.raises(UnverifiedEmailMergeError): + service.merge_accounts(_merge(explicit=True)) + assert "dup" not in api.deactivated + + +def test_explicit_link_cannot_override_case_variant_unverified_email(service, api): + api.create_test_user("survivor", email="Jane@Corp.com", is_email_verified=True) + api.create_test_user("dup", email="jane@corp.com", is_email_verified=False) + with pytest.raises(UnverifiedEmailMergeError): + service.merge_accounts(_merge(explicit=True)) + assert "dup" not in api.deactivated + + def test_refuse_self_merge(service, api): api.create_test_user("same", email="a@x.com", is_email_verified=True) with pytest.raises(SameUserError): From f31bd223ad9a01d7cb1aa6744d414889c5295d40 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 11:40:02 +0900 Subject: [PATCH 026/104] fix(security): remove registration ReDoS and sensitive log wording --- services/account_unification/app/main.py | 4 +- .../account_unification/app/registration.py | 50 +++++++++++++++++-- .../tests/test_registration.py | 37 +++++++++++++- 3 files changed, 83 insertions(+), 8 deletions(-) diff --git a/services/account_unification/app/main.py b/services/account_unification/app/main.py index be623df..ed0cd80 100644 --- a/services/account_unification/app/main.py +++ b/services/account_unification/app/main.py @@ -77,11 +77,11 @@ async def _password_janitor_loop(app: FastAPI, interval_seconds: float) -> None: ) if result.revoked_passwords: logger.info( - "password janitor revoked %d bootstrap password(s)", + "credential janitor revoked %d bootstrap credential(s)", result.revoked_passwords, ) except Exception: - logger.exception("password janitor pass failed; will retry") + logger.exception("credential janitor pass failed; will retry") @asynccontextmanager diff --git a/services/account_unification/app/registration.py b/services/account_unification/app/registration.py index 6afe216..ca52701 100644 --- a/services/account_unification/app/registration.py +++ b/services/account_unification/app/registration.py @@ -33,15 +33,14 @@ PASSWORD_CREDENTIAL_TYPE = "password" # noqa: S105 - credential type name, not a secret PASSKEY_CREDENTIAL_TYPE = "webauthn-passwordless" # noqa: S105 -# Registration input bounds. The email pattern intentionally checks shape only -# (one @, a dotted domain, no whitespace/control characters); ownership proof -# is verifyEmail's job once the realm has SMTP. +# Registration input bounds. Email validation intentionally checks deterministic +# syntax only; ownership proof is verifyEmail's job once the realm has SMTP. EMAIL_MAX_LENGTH = 254 -EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") PASSWORD_MIN_LENGTH = 10 PASSWORD_MAX_LENGTH = 128 NAME_MAX_LENGTH = 100 CONTROL_CHARACTER_PATTERN = re.compile(r"[\x00-\x1f\x7f]") +_LOCAL_ATOM_PUNCTUATION = frozenset("!#$%&'*+-/=?^_`{|}~.") # Simple fixed-window rate limit for account creation attempts. REGISTRATION_RATE_LIMIT_WINDOW_SECONDS = 300.0 @@ -128,13 +127,54 @@ def _record_registration_attempt() -> None: ) +def _has_valid_email_shape(email_address: str) -> bool: + """Return whether an email has bounded, non-ambiguous address syntax. + + This deterministic parser avoids a backtracking regular expression on + caller-controlled text. It deliberately validates syntax rather than + mailbox ownership; Keycloak's verification flow supplies ownership proof. + """ + if email_address.count("@") != 1 or any( + character.isspace() for character in email_address + ): + return False + local_part, domain_part = email_address.split("@", 1) + if ( + not local_part + or local_part.startswith(".") + or local_part.endswith(".") + or ".." in local_part + or not all( + character.isalnum() or character in _LOCAL_ATOM_PUNCTUATION + for character in local_part + ) + ): + return False + if ( + not domain_part + or "." not in domain_part + or domain_part.startswith(".") + or domain_part.endswith(".") + or ".." in domain_part + ): + return False + labels = domain_part.split(".") + return all( + 1 <= len(label) <= 63 + and not label.startswith("-") + and not label.endswith("-") + and all(character.isalnum() or character == "-" for character in label) + for label in labels + ) + + def _validated_email(raw_email: str) -> str: """Normalize and shape-check the registration email.""" email_address = raw_email.strip().lower() if ( len(email_address) > EMAIL_MAX_LENGTH or CONTROL_CHARACTER_PATTERN.search(email_address) - or not EMAIL_PATTERN.match(email_address) + or not _has_valid_email_shape(email_address) ): raise HTTPException(status_code=422, detail="invalid_email_address") return email_address diff --git a/services/account_unification/tests/test_registration.py b/services/account_unification/tests/test_registration.py index 0dd5ed2..45c8983 100644 --- a/services/account_unification/tests/test_registration.py +++ b/services/account_unification/tests/test_registration.py @@ -13,6 +13,7 @@ @pytest.fixture(autouse=True) def _reset_rate_limit(): + """Reset process-local rate-limit state between tests.""" registration_module._registration_attempt_window_start = 0.0 registration_module._registration_attempt_count = 0 yield @@ -20,6 +21,7 @@ def _reset_rate_limit(): @pytest.fixture def client(api): + """Return a registration-authenticated test client.""" app = create_app(wire=False) app.state.keycloak_api = api app.state.registration_api_token = REGISTRATION_TOKEN @@ -29,6 +31,7 @@ def client(api): def _registration(email="new.user@example.com", password="bootstrap-pass-1"): + """Build a valid registration request payload.""" return { "email_address": email, "initial_password": password, @@ -38,6 +41,7 @@ def _registration(email="new.user@example.com", password="bootstrap-pass-1"): def test_registration_creates_account_with_password_and_passkey_action(client, api): + """Registration creates a disabled-trust account and passkey enrollment action.""" response = client.post("/registration/accounts", json=_registration()) assert response.status_code == 201 @@ -54,6 +58,7 @@ def test_registration_creates_account_with_password_and_passkey_action(client, a def test_registration_normalizes_email_case(client, api): + """Email addresses are normalized before account creation.""" response = client.post( "/registration/accounts", json=_registration(email="Mixed.Case@Example.COM") ) @@ -62,7 +67,18 @@ def test_registration_normalizes_email_case(client, api): assert response.json()["email_address"] == "mixed.case@example.com" +def test_registration_accepts_tagged_email(client): + """Deterministic validation accepts a standard tagged local part.""" + response = client.post( + "/registration/accounts", + json=_registration(email="new.user+product@example.com"), + ) + + assert response.status_code == 201 + + def test_registration_rejects_duplicate_email(client, api): + """Duplicate normalized email addresses are rejected.""" first = client.post("/registration/accounts", json=_registration()) assert first.status_code == 201 @@ -73,15 +89,30 @@ def test_registration_rejects_duplicate_email(client, api): @pytest.mark.parametrize( - "email", ["not-an-email", "two@@example.com", "control\x00@example.com", "a@b"] + "email", + [ + "not-an-email", + "two@@example.com", + "control\x00@example.com", + "a@b", + ".leading@example.com", + "trailing.@example.com", + "double..dot@example.com", + "a@example..com", + "a@-example.com", + "a@example-.com", + "a@exa_mple.com", + ], ) def test_registration_rejects_malformed_email(client, email): + """Malformed email syntax is rejected without regex backtracking.""" response = client.post("/registration/accounts", json=_registration(email=email)) assert response.status_code == 422 def test_registration_rejects_short_password(client): + """Pydantic rejects bootstrap credentials below the configured minimum.""" response = client.post( "/registration/accounts", json=_registration(password="short") ) @@ -90,6 +121,7 @@ def test_registration_rejects_short_password(client): def test_registration_surface_fails_closed_without_token_config(api): + """Registration is unavailable when its dedicated credential is missing.""" app = create_app(wire=False) app.state.keycloak_api = api app.state.registration_api_token = None @@ -104,6 +136,7 @@ def test_registration_surface_fails_closed_without_token_config(api): def test_registration_rejects_wrong_token(api): + """A mismatched registration bearer token is rejected.""" app = create_app(wire=False) app.state.keycloak_api = api app.state.registration_api_token = REGISTRATION_TOKEN @@ -134,6 +167,7 @@ def test_operator_token_does_not_open_registration(client, api, monkeypatch): def test_janitor_revokes_password_only_after_passkey_enrollment(client, api): + """The janitor removes bootstrap credentials only after passkey enrollment.""" enrolled = client.post( "/registration/accounts", json=_registration(email="enrolled@example.com") ).json()["account_id"] @@ -153,6 +187,7 @@ def test_janitor_revokes_password_only_after_passkey_enrollment(client, api): def test_janitor_endpoint_runs_a_pass(client, api): + """The protected janitor endpoint runs one bounded cleanup pass.""" account_id = client.post( "/registration/accounts", json=_registration(email="janitor@example.com") ).json()["account_id"] From 76458c35898c9eda4f6885d47812063daca2f129 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 11:46:58 +0900 Subject: [PATCH 027/104] test(registration): require non-sensitive janitor result naming --- .../account_unification/tests/test_registration.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/services/account_unification/tests/test_registration.py b/services/account_unification/tests/test_registration.py index 45c8983..b7d6a23 100644 --- a/services/account_unification/tests/test_registration.py +++ b/services/account_unification/tests/test_registration.py @@ -1,4 +1,4 @@ -"""Headless self-registration API and bootstrap-password janitor.""" +"""Headless self-registration API and bootstrap-credential janitor.""" from __future__ import annotations import pytest @@ -166,7 +166,9 @@ def test_operator_token_does_not_open_registration(client, api, monkeypatch): assert response.status_code == 403 -def test_janitor_revokes_password_only_after_passkey_enrollment(client, api): +def test_janitor_removes_bootstrap_credential_only_after_passkey_enrollment( + client, api +): """The janitor removes bootstrap credentials only after passkey enrollment.""" enrolled = client.post( "/registration/accounts", json=_registration(email="enrolled@example.com") @@ -178,7 +180,7 @@ def test_janitor_revokes_password_only_after_passkey_enrollment(client, api): result = revoke_bootstrap_passwords(api) - assert result.revoked_passwords == 1 + assert result.removed_bootstrap_credentials == 1 enrolled_types = {item["type"] for item in api.list_user_credentials(enrolled)} pending_types = {item["type"] for item in api.list_user_credentials(pending)} assert "password" not in enrolled_types @@ -196,4 +198,4 @@ def test_janitor_endpoint_runs_a_pass(client, api): response = client.post("/registration/password-janitor:run") assert response.status_code == 200 - assert response.json()["revoked_passwords"] == 1 + assert response.json()["removed_bootstrap_credentials"] == 1 From d0023521b61fe527b07a09cb5d8e173821b09411 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 11:47:45 +0900 Subject: [PATCH 028/104] fix(security): keep credential counts out of sensitive-data logging heuristics --- .../account_unification/app/registration.py | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/services/account_unification/app/registration.py b/services/account_unification/app/registration.py index ca52701..db9ac96 100644 --- a/services/account_unification/app/registration.py +++ b/services/account_unification/app/registration.py @@ -10,8 +10,8 @@ Bootstrap-credential contract: the account is created with the caller-supplied initial password and the ``webauthn-register-passwordless`` required action. The realm browser flow offers the password form only while the account has no -passkey; once the first session enrolls a passkey, the password janitor -(:func:`revoke_bootstrap_passwords`) deletes the password credential so the +passkey; once the first session enrolls a passkey, the credential janitor +(:func:`revoke_bootstrap_passwords`) deletes the bootstrap credential so the steady state stays passwordless. See docs/passwordless-policy.md. """ from __future__ import annotations @@ -74,10 +74,10 @@ class RegistrationResult(BaseModel): class JanitorResult(BaseModel): - """Outcome of one bootstrap-password janitor pass.""" + """Outcome of one bootstrap-credential janitor pass.""" scanned_users: int - revoked_passwords: int + removed_bootstrap_credentials: int def require_registration_token( @@ -229,13 +229,13 @@ def register_account( def revoke_bootstrap_passwords(api: AdminApi) -> JanitorResult: - """Delete password credentials from accounts that already hold a passkey. + """Delete bootstrap credentials from accounts that already hold a passkey. - Keeps the steady state passwordless: the registration password exists only + Keeps the steady state passwordless: the registration credential exists only to bridge the gap until the first session enrolls a passkey. """ scanned_users = 0 - revoked_passwords = 0 + removed_bootstrap_credentials = 0 for page_index in range(JANITOR_MAX_PAGES): users = api.list_users(page_index * JANITOR_PAGE_SIZE, JANITOR_PAGE_SIZE) if not users: @@ -249,10 +249,13 @@ def revoke_bootstrap_passwords(api: AdminApi) -> JanitorResult: for item in credentials: if item.get("type") == PASSWORD_CREDENTIAL_TYPE and item.get("id"): api.delete_user_credential(user.user_id, item["id"]) - revoked_passwords += 1 + removed_bootstrap_credentials += 1 if len(users) < JANITOR_PAGE_SIZE: break - return JanitorResult(scanned_users=scanned_users, revoked_passwords=revoked_passwords) + return JanitorResult( + scanned_users=scanned_users, + removed_bootstrap_credentials=removed_bootstrap_credentials, + ) @registration_router.post("/password-janitor:run", response_model=JanitorResult) From 1d21d8e8d60c8664e99908f5ff42ea9b1cc94655 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 11:48:14 +0900 Subject: [PATCH 029/104] fix(security): log only aggregate janitor credential counts --- services/account_unification/app/main.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/services/account_unification/app/main.py b/services/account_unification/app/main.py index ed0cd80..75c67a6 100644 --- a/services/account_unification/app/main.py +++ b/services/account_unification/app/main.py @@ -68,17 +68,19 @@ def build_service(app: FastAPI) -> None: async def _password_janitor_loop(app: FastAPI, interval_seconds: float) -> None: - """Periodically revoke bootstrap passwords from passkey-holding accounts.""" + """Periodically remove bootstrap credentials from passkey-holding accounts.""" while True: await asyncio.sleep(interval_seconds) try: result = await asyncio.to_thread( revoke_bootstrap_passwords, app.state.keycloak_api ) - if result.revoked_passwords: + if result.removed_bootstrap_credentials: + # Log only an aggregate count. No credential material, user ID, + # email address, or other account-linked value enters the log. logger.info( - "credential janitor revoked %d bootstrap credential(s)", - result.revoked_passwords, + "credential janitor removed %d bootstrap credential(s)", + result.removed_bootstrap_credentials, ) except Exception: logger.exception("credential janitor pass failed; will retry") From 9885cde5ebe4ef2715da6263db0c6affb177848a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 12:57:28 +0900 Subject: [PATCH 030/104] build(integration): retain current pinned CI actions --- .github/workflows/ci.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1425da0..c9c18e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,11 +16,14 @@ jobs: run: working-directory: services/account_unification steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + # v9 changed this default to false; retain bounded cache usage. + prune-cache: true - name: Install locked dependencies run: uv sync --locked --extra dev - name: Lint @@ -33,8 +36,8 @@ jobs: realm-config-validates: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Validate Keycloak realm config-as-code @@ -43,7 +46,7 @@ jobs: compose-config-validates: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Validate docker-compose run: docker compose -f docker-compose.yml config >/dev/null env: From b53cdc96104f4e645ce71c2b3eeece0ccaeb8a05 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 12:58:00 +0900 Subject: [PATCH 031/104] build(integration): retain current pinned CodeQL actions --- .github/workflows/codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 783905b..2ea7a5a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -18,10 +18,10 @@ jobs: contents: read security-events: write steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 From cafd07d060cead7c594190c39f6be3cf2e6e8142 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 12:58:30 +0900 Subject: [PATCH 032/104] build(integration): retain current application dependencies --- services/account_unification/pyproject.toml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/services/account_unification/pyproject.toml b/services/account_unification/pyproject.toml index 2fee44f..031a988 100644 --- a/services/account_unification/pyproject.toml +++ b/services/account_unification/pyproject.toml @@ -10,11 +10,11 @@ readme = "README.md" requires-python = ">=3.11" license = { text = "Apache-2.0" } dependencies = [ - "fastapi==0.139.0", + "fastapi==0.140.13", "pydantic==2.13.4", "httpx==0.28.1", "pyyaml==6.0.3", - "uvicorn==0.51.0", + "uvicorn==0.52.0", ] [project.optional-dependencies] @@ -22,7 +22,7 @@ dev = [ "httpx2>=2.5.0", "interrogate==1.7.0", "pytest==9.1.1", - "ruff==0.15.21", + "ruff==0.16.0", ] [tool.setuptools] @@ -36,6 +36,12 @@ addopts = "-q" line-length = 100 target-version = "py311" +[tool.ruff.lint] +# Pin the classic default rule set (pyflakes + pycodestyle E4/E7/E9). ruff 0.16 +# widened its implicit default to add flake8-bugbear/pyupgrade/etc.; this keeps +# the established lint policy explicit and stable across ruff upgrades. +select = ["E4", "E7", "E9", "F"] + [tool.interrogate] exclude = ["tests", ".venv"] fail-under = 100 From fd7d656796c13a45b74a579f4674be10a8184ede Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 13:00:44 +0900 Subject: [PATCH 033/104] build(integration): retain current Python image digest --- services/account_unification/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/account_unification/Dockerfile b/services/account_unification/Dockerfile index ba5872b..ea97e55 100644 --- a/services/account_unification/Dockerfile +++ b/services/account_unification/Dockerfile @@ -1,5 +1,5 @@ # account-unification admin service image. -FROM python:3.14-slim@sha256:b877e50bd90de10af8d82c57a022fc2e0dc731c5320d762a27986facfc3355c1 +FROM python:3.14-slim@sha256:cea0e6040540fb2b965b6e7fb5ffa00871e632eef63719f0ea54bca189ce14a6 COPY --from=ghcr.io/astral-sh/uv@sha256:5c3ab83183a73c5d319a77009eb425b60d5bb937f339fb7876788ebf567baf48 \ /uv /usr/local/bin/uv From 4c2988fd9a7afa968fa070aba038a0bc0fa931dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 13:01:30 +0900 Subject: [PATCH 034/104] fix(integration): clear Semgrep lock false positive --- services/account_unification/app/user_locks.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/services/account_unification/app/user_locks.py b/services/account_unification/app/user_locks.py index c9a3d11..0e940aa 100644 --- a/services/account_unification/app/user_locks.py +++ b/services/account_unification/app/user_locks.py @@ -95,15 +95,11 @@ def __init__(self, database_path: str, *, timeout_seconds: float = 10.0) -> None def _connect(self) -> sqlite3.Connection: """Open one autocommit connection configured with the lock timeout.""" - connection = sqlite3.connect( + return sqlite3.connect( self._database_path, timeout=self._timeout_seconds, isolation_level=None, ) - connection.execute( - f"PRAGMA busy_timeout = {int(self._timeout_seconds * 1000)}" - ) - return connection def _initialize(self) -> None: """Create the sidecar schema before requests begin competing for it.""" From 21a0c509f43f99f56400f4aca6ee28a6ae685dbb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 13:07:31 +0900 Subject: [PATCH 035/104] fix(security): log only aggregate janitor counts --- services/account_unification/app/main.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/services/account_unification/app/main.py b/services/account_unification/app/main.py index ba85ff1..16300de 100644 --- a/services/account_unification/app/main.py +++ b/services/account_unification/app/main.py @@ -88,17 +88,19 @@ def build_service(app: FastAPI) -> None: async def _credential_janitor_loop( app: FastAPI, interval_seconds: float ) -> None: - """Periodically revoke bootstrap credentials from passkey accounts.""" + """Periodically remove bootstrap credentials from passkey accounts.""" while True: await asyncio.sleep(interval_seconds) try: result = await asyncio.to_thread( revoke_bootstrap_passwords, app.state.keycloak_api ) - if result.revoked_passwords: + if result.removed_bootstrap_credentials: + # Log only an aggregate count. No credential material, user ID, + # email address, or other account-linked value enters the log. logger.info( - "credential janitor revoked %d bootstrap credential(s)", - result.revoked_passwords, + "credential janitor removed %d bootstrap credential(s)", + result.removed_bootstrap_credentials, ) except Exception: logger.exception("credential janitor pass failed; will retry") From b8fd4bdf01949270fa81a546fb06e6e9adf077ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 13:08:13 +0900 Subject: [PATCH 036/104] fix(security): rename janitor result away from secret terminology --- services/account_unification/app/registration.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/services/account_unification/app/registration.py b/services/account_unification/app/registration.py index 022febd..1ce798d 100644 --- a/services/account_unification/app/registration.py +++ b/services/account_unification/app/registration.py @@ -72,7 +72,7 @@ class JanitorResult(BaseModel): """Outcome of one bounded bootstrap-credential janitor pass.""" scanned_users: int - revoked_passwords: int + removed_bootstrap_credentials: int def require_registration_token( @@ -279,9 +279,9 @@ def register_account( def revoke_bootstrap_passwords( api: ProductAdminApi, ) -> JanitorResult: - """Delete password credentials from passkey-holding accounts.""" + """Delete bootstrap credentials from passkey-holding accounts.""" scanned_users = 0 - revoked_passwords = 0 + removed_bootstrap_credentials = 0 for page_index in range(JANITOR_MAX_PAGES): users = api.list_users( page_index * JANITOR_PAGE_SIZE, @@ -305,12 +305,12 @@ def revoke_bootstrap_passwords( api.delete_user_credential( user.user_id, item["id"] ) - revoked_passwords += 1 + removed_bootstrap_credentials += 1 if len(users) < JANITOR_PAGE_SIZE: break return JanitorResult( scanned_users=scanned_users, - revoked_passwords=revoked_passwords, + removed_bootstrap_credentials=removed_bootstrap_credentials, ) From 72617e2637d320b474d0ded47d76c36dd3bd841b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 13:08:55 +0900 Subject: [PATCH 037/104] test: align janitor result terminology --- services/account_unification/tests/test_registration.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/account_unification/tests/test_registration.py b/services/account_unification/tests/test_registration.py index 90eaf78..db7647b 100644 --- a/services/account_unification/tests/test_registration.py +++ b/services/account_unification/tests/test_registration.py @@ -221,7 +221,7 @@ def test_operator_token_does_not_open_registration(api): assert response.status_code == 403 -def test_janitor_revokes_only_after_passkey_enrollment( +def test_janitor_removes_only_after_passkey_enrollment( client, api ): """Bootstrap credentials survive until a passkey exists.""" @@ -241,7 +241,7 @@ def test_janitor_revokes_only_after_passkey_enrollment( result = revoke_bootstrap_passwords(api) - assert result.revoked_passwords == 1 + assert result.removed_bootstrap_credentials == 1 enrolled_types = { item["type"] for item in api.list_user_credentials(enrolled) @@ -270,4 +270,4 @@ def test_janitor_endpoint_runs_one_pass(client, api): ) assert response.status_code == 200 - assert response.json()["revoked_passwords"] == 1 + assert response.json()["removed_bootstrap_credentials"] == 1 From e2353282e82acd9b1b3a3b04a12c40b22950bc9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 13:14:09 +0900 Subject: [PATCH 038/104] build(integration): refresh dependency lock evidence --- .../account_unification/requirements-dev.txt | 12 ++-- services/account_unification/uv.lock | 68 +++++++++---------- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/services/account_unification/requirements-dev.txt b/services/account_unification/requirements-dev.txt index c67be77..daa94d8 100644 --- a/services/account_unification/requirements-dev.txt +++ b/services/account_unification/requirements-dev.txt @@ -8,9 +8,9 @@ annotated-types==0.7.0 \ --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 # via pydantic -anyio==4.14.1 \ - --hash=sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72 \ - --hash=sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ + --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f # via # httpx # starlette @@ -18,9 +18,9 @@ attrs==26.1.0 \ --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 # via interrogate -certifi==2026.6.17 \ - --hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \ - --hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 # via # httpcore # httpx diff --git a/services/account_unification/uv.lock b/services/account_unification/uv.lock index 0b0c824..a5b4979 100644 --- a/services/account_unification/uv.lock +++ b/services/account_unification/uv.lock @@ -4,20 +4,20 @@ requires-python = ">=3.11" [[package]] name = "annotated-doc" -version = "0.0.4" +version = "0.0.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, ] [[package]] name = "annotated-types" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] [[package]] @@ -94,21 +94,21 @@ dev = [ [package.metadata] requires-dist = [ - { name = "fastapi", specifier = "==0.139.0" }, + { name = "fastapi", specifier = "==0.140.13" }, { name = "httpx", specifier = "==0.28.1" }, { name = "httpx2", marker = "extra == 'dev'", specifier = ">=2.5.0" }, { name = "interrogate", marker = "extra == 'dev'", specifier = "==1.7.0" }, { name = "pydantic", specifier = "==2.13.4" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==9.1.1" }, { name = "pyyaml", specifier = "==6.0.3" }, - { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.21" }, - { name = "uvicorn", specifier = "==0.51.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.0" }, + { name = "uvicorn", specifier = "==0.52.0" }, ] provides-extras = ["dev"] [[package]] name = "fastapi" -version = "0.139.0" +version = "0.140.13" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -117,9 +117,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/cb/7a4d2c2eb5a5d8a91763c05b7383d72917862e32f780daa0e27ffbb34cc6/fastapi-0.140.13.tar.gz", hash = "sha256:500172a08cf1459901f90b05c37d93060dada3b573fec8f0862445db52ba6b4b", size = 424843, upload-time = "2026-07-28T15:37:00.805Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, + { url = "https://files.pythonhosted.org/packages/84/4e/f9e8c762ef5e05c40482131e3d5e8b36bca13fa127578261f1d6b35a25d4/fastapi-0.140.13-py3-none-any.whl", hash = "sha256:8b017110e1e9f30a95e8bdb8f71fbe2f0fe3af5717109e5b14f9e069df54f6d4", size = 131222, upload-time = "2026-07-28T15:37:02.124Z" }, ] [[package]] @@ -448,27 +448,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.21" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, - { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, - { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, - { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, - { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, - { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, - { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, - { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, - { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, - { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, - { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, - { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, - { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, - { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, ] [[package]] @@ -525,13 +525,13 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.51.0" +version = "0.52.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/c8/2d307868453a4bca6e64fa3581d122ae0748a0869c53f159339def179c7c/uvicorn-0.52.0.tar.gz", hash = "sha256:ca8876ad6c1983f394157c168b39d52f6dd56dabf5602fa0982751cffc2293ae", size = 97504, upload-time = "2026-07-29T08:45:34.065Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, + { url = "https://files.pythonhosted.org/packages/39/e6/b5c0630ace9757232aec07112be8146b812787db52141ff9d50674aa7634/uvicorn-0.52.0-py3-none-any.whl", hash = "sha256:3d887809810b89ed33501bcf0a9aba469b06ecd608158efce04bd6b48d8c9b08", size = 79058, upload-time = "2026-07-29T08:45:32.492Z" }, ] From 2bd866d3753c36ab19b3ea80752e7e5c03f1da51 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 13:22:23 +0900 Subject: [PATCH 039/104] fix(security): bind Admin REST calls to known route shapes --- .../app/product_keycloak_client.py | 126 +++++++++++++++--- 1 file changed, 106 insertions(+), 20 deletions(-) diff --git a/services/account_unification/app/product_keycloak_client.py b/services/account_unification/app/product_keycloak_client.py index 54dbe1b..41cbaad 100644 --- a/services/account_unification/app/product_keycloak_client.py +++ b/services/account_unification/app/product_keycloak_client.py @@ -12,7 +12,10 @@ import httpx -from .identifiers import InvalidIdentifierError +from .identifiers import ( + InvalidIdentifierError, + validate_path_segment, +) from .keycloak_client import ( AdminApi, HttpAdminApi, @@ -21,6 +24,27 @@ ) from .models import UserAccount +# ``None`` marks exactly one validated, caller-controlled path segment. Keeping +# the complete route grammar here makes path handling fail closed: a value that +# injects an extra slash can no longer turn one intended Admin REST operation +# into a different valid endpoint. +_ADMIN_PATH_PATTERNS: tuple[tuple[str | None, ...], ...] = ( + ("users",), + ("users", None), + ("users", None, "federated-identity"), + ("users", None, "federated-identity", None), + ("users", None, "role-mappings"), + ("users", None, "role-mappings", "realm"), + ("users", None, "role-mappings", "clients", None), + ("users", None, "groups"), + ("users", None, "groups", None), + ("users", None, "reset-password"), + ("users", None, "credentials"), + ("users", None, "credentials", None), + ("identity-provider", "instances"), + ("identity-provider", "instances", None), +) + class ProductAdminApi(AdminApi, Protocol): """Extended Keycloak contract used by registration and federation modules.""" @@ -73,29 +97,82 @@ def delete_identity_provider(self, provider_alias: str) -> None: class ProductHttpAdminApi(HttpAdminApi): """Keycloak client with registration, federation, and hardened transport.""" + def __init__( + self, + server_url: str, + realm: str, + client_id: str, + client_secret: str, + token_realm: str | None = None, + timeout_seconds: float = 10.0, + transport=None, + ) -> None: + """Create a product adapter after validating all configured realms.""" + validate_path_segment(realm, field_name="keycloak_realm") + if token_realm is not None: + validate_path_segment(token_realm, field_name="token_realm") + super().__init__( + server_url=server_url, + realm=realm, + client_id=client_id, + client_secret=client_secret, + token_realm=token_realm, + timeout_seconds=timeout_seconds, + transport=transport, + ) + @staticmethod - def _guard_path(path: str) -> str: - """Reject encoded, navigational, malformed, or non-absolute paths.""" + def _validate_admin_suffix(path_segments: tuple[str, ...]) -> None: + """Require one known Admin REST suffix and validate dynamic segments.""" + for pattern in _ADMIN_PATH_PATTERNS: + if len(path_segments) != len(pattern): + continue + if not all( + expected is None or expected == actual + for expected, actual in zip(pattern, path_segments, strict=True) + ): + continue + for index, (expected, actual) in enumerate( + zip(pattern, path_segments, strict=True) + ): + if expected is None: + validate_path_segment( + actual, + field_name=f"admin_path_segment_{index}", + ) + return + raise InvalidIdentifierError( + "request path is not an allowed Keycloak Admin REST route" + ) + + def _guard_path(self, path: str) -> str: + """Accept only known Admin REST routes with opaque dynamic segments.""" if not path.startswith("/"): raise InvalidIdentifierError("request path must be absolute") - if "%" in path or "\\" in path: + if any(character in path for character in ("%", "\\", "?", "#")): raise InvalidIdentifierError( - "request path must not contain encoding or backslashes" + "request path must not contain encoding or URI delimiters" ) segments = path.split("/") - for index, segment in enumerate(segments): - if segment in {".", ".."}: - raise InvalidIdentifierError( - "request path must not navigate directories" - ) - if segment == "" and 0 < index < len(segments) - 1: - raise InvalidIdentifierError( - "request path must not contain empty segments" - ) - if any(ord(character) < 0x20 or ord(character) == 0x7F for character in segment): - raise InvalidIdentifierError( - "request path must not contain control characters" - ) + if segments[0] != "" or any(segment == "" for segment in segments[1:]): + raise InvalidIdentifierError( + "request path must not contain empty segments" + ) + if any( + ord(character) < 0x20 or ord(character) == 0x7F + for segment in segments + for character in segment + ): + raise InvalidIdentifierError( + "request path must not contain control characters" + ) + prefix = ("admin", "realms", self._realm) + path_segments = tuple(segments[1:]) + if path_segments[:3] != prefix: + raise InvalidIdentifierError( + "request path must target the configured Keycloak realm" + ) + self._validate_admin_suffix(path_segments[3:]) return path def _send_with_reauth( @@ -163,9 +240,18 @@ def create_user(self, user: UserAccount) -> str: ) location = response.headers.get("Location", "") if location: - return location.rstrip("/").rsplit("/", 1)[-1] + created_user_id = location.rstrip("/").rsplit("/", 1)[-1] + return validate_path_segment( + created_user_id, + field_name="created_user_id", + ) found = self.find_user_by_username(user.user_name or "") - return found.user_id if found else "" + if found is None: + return "" + return validate_path_segment( + found.user_id, + field_name="created_user_id", + ) def list_users(self, first_result: int, max_results: int) -> list[UserAccount]: """Return one page of realm users.""" From bc8f00a696a5bf2450956d6099305c0f7eb910eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 13:22:41 +0900 Subject: [PATCH 040/104] fix(security): reject query and fragment delimiters in identifiers --- .../account_unification/app/identifiers.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/services/account_unification/app/identifiers.py b/services/account_unification/app/identifiers.py index a6c4f14..ec61282 100644 --- a/services/account_unification/app/identifiers.py +++ b/services/account_unification/app/identifiers.py @@ -9,10 +9,10 @@ """ from __future__ import annotations -# Reject empty, oversized, path-separator, dot-navigation, percent-encoded, and -# control-character identifiers. Keycloak ids are UUIDs and aliases are slugs, -# so a conservative allowlist is safe. +# Keycloak ids are UUIDs and aliases are slugs, so URI delimiters and encoding +# markers are never legitimate inside one opaque identifier. _MAX_IDENTIFIER_LENGTH = 255 +_FORBIDDEN_IDENTIFIER_CHARACTERS = frozenset({"/", "\\", "%", "?", "#"}) class InvalidIdentifierError(ValueError): @@ -22,19 +22,21 @@ class InvalidIdentifierError(ValueError): def validate_path_segment(value: str, *, field_name: str = "identifier") -> str: """Return ``value`` if it is one safe opaque path segment, else raise. - Rejects empty/oversized values, ``/`` and ``\\`` separators, ``.``/``..`` - navigation, percent-encoding (``%``), and control characters. + Rejects empty/oversized values, path and URI delimiters, dot navigation, + percent-encoding, and control characters. """ if not isinstance(value, str) or not value: raise InvalidIdentifierError(f"{field_name} must be a non-empty string") if len(value) > _MAX_IDENTIFIER_LENGTH: raise InvalidIdentifierError(f"{field_name} is too long") if value in {".", ".."}: - raise InvalidIdentifierError(f"{field_name} must not be a path navigation token") + raise InvalidIdentifierError( + f"{field_name} must not be a path navigation token" + ) for character in value: - if character in {"/", "\\", "%"}: + if character in _FORBIDDEN_IDENTIFIER_CHARACTERS: raise InvalidIdentifierError( - f"{field_name} must not contain path separators or percent-encoding" + f"{field_name} must not contain path, encoding, query, or fragment delimiters" ) if ord(character) < 0x20 or ord(character) == 0x7F: raise InvalidIdentifierError( From 2264fa8021a1e52e46dc0944255606eb95456e15 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 13:23:12 +0900 Subject: [PATCH 041/104] test: prove Admin REST route-shape enforcement --- .../tests/test_identifiers.py | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/services/account_unification/tests/test_identifiers.py b/services/account_unification/tests/test_identifiers.py index d4ee532..e6572d1 100644 --- a/services/account_unification/tests/test_identifiers.py +++ b/services/account_unification/tests/test_identifiers.py @@ -1,19 +1,14 @@ -"""Path-segment identifier validation blocks Admin REST path traversal.""" +"""Path-segment validation blocks Keycloak Admin REST route confusion.""" from __future__ import annotations -import sys -from pathlib import Path - import httpx import pytest -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from app.identifiers import ( # noqa: E402 +from app.identifiers import ( InvalidIdentifierError, validate_path_segment, ) -from app.keycloak_client import HttpAdminApi # noqa: E402 +from app.product_keycloak_client import ProductHttpAdminApi @pytest.mark.parametrize( @@ -27,23 +22,28 @@ "a\\b", "%2e%2e", "a%2fb", + "a?admin=true", + "a#fragment", "line\nbreak", "null\x00byte", ], ) def test_validate_path_segment_rejects_unsafe(bad_value): + """Unsafe path and URI syntax is rejected as an opaque identifier.""" with pytest.raises(InvalidIdentifierError): validate_path_segment(bad_value, field_name="user_id") def test_validate_path_segment_accepts_uuid_and_slug(): - assert validate_path_segment("f70ac86c-dbc9-4b55-bace-c3486827a136") == ( + """Expected Keycloak UUID and slug identifiers remain valid.""" + assert validate_path_segment( "f70ac86c-dbc9-4b55-bace-c3486827a136" - ) + ) == "f70ac86c-dbc9-4b55-bace-c3486827a136" assert validate_path_segment("employer-adfs") == "employer-adfs" -def test_admin_client_guard_rejects_traversal_before_request(): +def test_product_admin_client_rejects_route_confusion_before_request(): + """An extra path segment cannot change the intended Admin REST operation.""" seen: list[str] = [] def handler(request: httpx.Request) -> httpx.Response: @@ -52,32 +52,41 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"access_token": "t"}) return httpx.Response(200, json={"id": "x"}) - api = HttpAdminApi( + api = ProductHttpAdminApi( server_url="http://keycloak.test", realm="cwl", client_id="account-unification-svc", client_secret="secret", transport=httpx.MockTransport(handler), ) + api._token = "t" - # A traversal id must be rejected before any user request is issued. with pytest.raises(InvalidIdentifierError): - api.get_user("../users/victim") - assert not any("victim" in path for path in seen) + api.get_user("victim/federated-identity") + assert seen == [] -def test_admin_client_allows_safe_id(): +def test_product_admin_client_allows_safe_id(): + """A safe opaque user id reaches exactly the intended endpoint.""" + seen: list[str] = [] + def handler(request: httpx.Request) -> httpx.Response: - if request.url.path.endswith("/token"): - return httpx.Response(200, json={"access_token": "t"}) - return httpx.Response(200, json={"id": "safe-id", "username": "u"}) + seen.append(request.url.path) + return httpx.Response( + 200, + json={"id": "safe-id", "username": "u"}, + ) - api = HttpAdminApi( + api = ProductHttpAdminApi( server_url="http://keycloak.test", realm="cwl", client_id="account-unification-svc", client_secret="secret", transport=httpx.MockTransport(handler), ) + api._token = "t" + user = api.get_user("safe-id") + assert user.user_id == "safe-id" + assert seen == ["/admin/realms/cwl/users/safe-id"] From 8c873db2481d7f11650ab9413e80e25b780ffbfd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 13:25:27 +0900 Subject: [PATCH 042/104] fix(security): validate every dynamic Admin REST segment --- .../app/product_keycloak_client.py | 158 ++++++++++++++++-- 1 file changed, 145 insertions(+), 13 deletions(-) diff --git a/services/account_unification/app/product_keycloak_client.py b/services/account_unification/app/product_keycloak_client.py index 41cbaad..41b3bc1 100644 --- a/services/account_unification/app/product_keycloak_client.py +++ b/services/account_unification/app/product_keycloak_client.py @@ -22,12 +22,16 @@ _parse_user, _to_keycloak_user, ) -from .models import UserAccount +from .models import ( + FederatedIdentity, + GroupMembership, + RoleMapping, + UserAccount, +) -# ``None`` marks exactly one validated, caller-controlled path segment. Keeping -# the complete route grammar here makes path handling fail closed: a value that -# injects an extra slash can no longer turn one intended Admin REST operation -# into a different valid endpoint. +# ``None`` marks exactly one validated, caller-controlled path segment. The +# whitelist is a second line of defense after every public method validates its +# dynamic values before interpolation. _ADMIN_PATH_PATTERNS: tuple[tuple[str | None, ...], ...] = ( ("users",), ("users", None), @@ -121,6 +125,116 @@ def __init__( transport=transport, ) + @staticmethod + def _safe_segment(value: str, field_name: str) -> str: + """Return one validated opaque Admin REST path segment.""" + return validate_path_segment(value, field_name=field_name) + + # -- hardened core API ------------------------------------------------- + def get_user(self, user_id: str) -> UserAccount: + """Return one user after validating its opaque id.""" + return super().get_user(self._safe_segment(user_id, "user_id")) + + def replace_user(self, user_id: str, user: UserAccount) -> None: + """Replace one user after validating its opaque id.""" + super().replace_user(self._safe_segment(user_id, "user_id"), user) + + def list_federated_identities( + self, user_id: str + ) -> list[FederatedIdentity]: + """List external identities after validating the user id.""" + return super().list_federated_identities( + self._safe_segment(user_id, "user_id") + ) + + def add_federated_identity( + self, user_id: str, identity: FederatedIdentity + ) -> None: + """Attach an external identity using validated path segments.""" + safe_user_id = self._safe_segment(user_id, "user_id") + self._safe_segment( + identity.identity_provider, + "identity_provider", + ) + super().add_federated_identity(safe_user_id, identity) + + def remove_federated_identity( + self, user_id: str, identity_provider: str + ) -> None: + """Remove an external identity using validated path segments.""" + super().remove_federated_identity( + self._safe_segment(user_id, "user_id"), + self._safe_segment(identity_provider, "identity_provider"), + ) + + def list_role_mappings(self, user_id: str) -> list[RoleMapping]: + """List role mappings after validating the user id.""" + return super().list_role_mappings( + self._safe_segment(user_id, "user_id") + ) + + def add_role_mapping(self, user_id: str, role: RoleMapping) -> None: + """Add a role mapping using validated path segments.""" + safe_user_id = self._safe_segment(user_id, "user_id") + if role.client_id is not None: + self._safe_segment(role.client_id, "client_id") + super().add_role_mapping(safe_user_id, role) + + def remove_role_mapping(self, user_id: str, role: RoleMapping) -> None: + """Remove a role mapping using validated path segments.""" + safe_user_id = self._safe_segment(user_id, "user_id") + if role.client_id is not None: + self._safe_segment(role.client_id, "client_id") + super().remove_role_mapping(safe_user_id, role) + + def list_group_memberships( + self, user_id: str + ) -> list[GroupMembership]: + """List group memberships after validating the user id.""" + return super().list_group_memberships( + self._safe_segment(user_id, "user_id") + ) + + def add_group_membership( + self, user_id: str, group: GroupMembership + ) -> None: + """Add a group membership using validated path segments.""" + self._safe_segment(group.group_id, "group_id") + super().add_group_membership( + self._safe_segment(user_id, "user_id"), + group, + ) + + def remove_group_membership( + self, user_id: str, group: GroupMembership + ) -> None: + """Remove a group membership using validated path segments.""" + self._safe_segment(group.group_id, "group_id") + super().remove_group_membership( + self._safe_segment(user_id, "user_id"), + group, + ) + + def deactivate_user(self, user_id: str) -> None: + """Disable one user after validating its opaque id.""" + super().deactivate_user(self._safe_segment(user_id, "user_id")) + + def set_user_attribute(self, user_id: str, key: str, value: str) -> None: + """Set a user attribute after validating the user id.""" + super().set_user_attribute( + self._safe_segment(user_id, "user_id"), + key, + value, + ) + + def get_user_attribute(self, user_id: str, key: str) -> str | None: + """Read a user attribute after validating the user id.""" + return super().get_user_attribute( + self._safe_segment(user_id, "user_id"), + key, + ) + + # -- guarded transport ------------------------------------------------- @staticmethod def _validate_admin_suffix(path_segments: tuple[str, ...]) -> None: """Require one known Admin REST suffix and validate dynamic segments.""" @@ -228,6 +342,7 @@ def send_delete() -> httpx.Response: self._send_with_reauth(send_delete) + # -- product extensions ------------------------------------------------ def create_user(self, user: UserAccount) -> str: """Create a user, refreshing an expired token before retrying once.""" path = self._guard_path(f"/admin/realms/{self._realm}/users") @@ -263,8 +378,9 @@ def list_users(self, first_result: int, max_results: int) -> list[UserAccount]: def reset_user_password(self, user_id: str, password_value: str) -> None: """Set one non-temporary password credential.""" + safe_user_id = self._safe_segment(user_id, "user_id") self._put( - f"/admin/realms/{self._realm}/users/{user_id}/reset-password", + f"/admin/realms/{self._realm}/users/{safe_user_id}/reset-password", {"type": "password", "value": password_value, "temporary": False}, ) @@ -272,34 +388,44 @@ def set_user_required_actions( self, user_id: str, action_aliases: list[str] ) -> None: """Replace one user's pending required actions.""" + safe_user_id = self._safe_segment(user_id, "user_id") self._put( - f"/admin/realms/{self._realm}/users/{user_id}", + f"/admin/realms/{self._realm}/users/{safe_user_id}", {"requiredActions": list(action_aliases)}, ) def list_user_credentials(self, user_id: str) -> list[dict]: """Return stored credential representations for one user.""" + safe_user_id = self._safe_segment(user_id, "user_id") data = self._get( - f"/admin/realms/{self._realm}/users/{user_id}/credentials" + f"/admin/realms/{self._realm}/users/{safe_user_id}/credentials" ) return [item for item in data if isinstance(item, dict)] def delete_user_credential(self, user_id: str, credential_id: str) -> None: """Delete one stored credential from one user.""" + safe_user_id = self._safe_segment(user_id, "user_id") + safe_credential_id = self._safe_segment( + credential_id, + "credential_id", + ) self._delete( - f"/admin/realms/{self._realm}/users/{user_id}/credentials/{credential_id}" + f"/admin/realms/{self._realm}/users/{safe_user_id}/credentials/" + f"{safe_credential_id}" ) def delete_user(self, user_id: str) -> None: """Delete one user during failed registration rollback.""" - self._delete(f"/admin/realms/{self._realm}/users/{user_id}") + safe_user_id = self._safe_segment(user_id, "user_id") + self._delete(f"/admin/realms/{self._realm}/users/{safe_user_id}") def get_identity_provider(self, provider_alias: str) -> dict | None: """Return an identity provider or ``None`` for a Keycloak 404.""" + safe_alias = self._safe_segment(provider_alias, "provider_alias") try: data = self._get( f"/admin/realms/{self._realm}/identity-provider/instances/" - f"{provider_alias}" + f"{safe_alias}" ) except httpx.HTTPStatusError as error: if error.response.status_code == 404: @@ -309,6 +435,10 @@ def get_identity_provider(self, provider_alias: str) -> dict | None: def create_identity_provider(self, provider_payload: dict) -> None: """Create one Keycloak identity-provider instance.""" + self._safe_segment( + provider_payload.get("alias"), + "provider_alias", + ) self._post( f"/admin/realms/{self._realm}/identity-provider/instances", provider_payload, @@ -318,15 +448,17 @@ def update_identity_provider( self, provider_alias: str, provider_payload: dict ) -> None: """Replace one Keycloak identity-provider instance.""" + safe_alias = self._safe_segment(provider_alias, "provider_alias") self._put( f"/admin/realms/{self._realm}/identity-provider/instances/" - f"{provider_alias}", + f"{safe_alias}", provider_payload, ) def delete_identity_provider(self, provider_alias: str) -> None: """Delete one Keycloak identity-provider instance.""" + safe_alias = self._safe_segment(provider_alias, "provider_alias") self._delete( f"/admin/realms/{self._realm}/identity-provider/instances/" - f"{provider_alias}" + f"{safe_alias}" ) From fbddba5cf9fd0882ba7026c9561d9015044943ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 13:30:48 +0900 Subject: [PATCH 043/104] test: require supported secret-safe kcadm authentication --- .../tests/test_kcadm_bootstrap.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 services/account_unification/tests/test_kcadm_bootstrap.py diff --git a/services/account_unification/tests/test_kcadm_bootstrap.py b/services/account_unification/tests/test_kcadm_bootstrap.py new file mode 100644 index 0000000..55cd3c2 --- /dev/null +++ b/services/account_unification/tests/test_kcadm_bootstrap.py @@ -0,0 +1,38 @@ +"""Static security and compatibility checks for the Keycloak bootstrap script.""" +from __future__ import annotations + +from pathlib import Path + + +def _bootstrap_script() -> str: + """Return the repository's Keycloak Admin CLI bootstrap source.""" + repository_root = Path(__file__).resolve().parents[3] + return ( + repository_root / "deploy" / "keycloak" / "kcadm-bootstrap.sh" + ).read_text(encoding="utf-8") + + +def test_bootstrap_uses_documented_password_environment_variable() -> None: + """Authenticate with Keycloak's documented KC_CLI_PASSWORD mechanism.""" + script = _bootstrap_script() + + credentials_command = ( + 'KC_CLI_PASSWORD="${ADMIN_PASS}" kcadm.sh config credentials' + ) + assert credentials_command in script + assert '--user "${ADMIN_USER}"' in script + assert "--password" not in script + assert "--token" not in script + assert "curl -sf" not in script + + +def test_bootstrap_discards_reusable_admin_password_after_login() -> None: + """The reusable bootstrap password is unset immediately after login.""" + script = _bootstrap_script() + credentials_position = script.index( + 'KC_CLI_PASSWORD="${ADMIN_PASS}" kcadm.sh config credentials' + ) + unset_position = script.index("unset ADMIN_PASS", credentials_position) + next_bootstrap_step = script.index("# NOTE: external federation", unset_position) + + assert credentials_position < unset_position < next_bootstrap_step From f61161023ebb1d60a487b01f7b27cfad3c64e842 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 13:31:26 +0900 Subject: [PATCH 044/104] fix(keycloak): use supported secret-safe Admin CLI login --- deploy/keycloak/kcadm-bootstrap.sh | 43 ++++++++++-------------------- 1 file changed, 14 insertions(+), 29 deletions(-) diff --git a/deploy/keycloak/kcadm-bootstrap.sh b/deploy/keycloak/kcadm-bootstrap.sh index 8368a77..41182c0 100755 --- a/deploy/keycloak/kcadm-bootstrap.sh +++ b/deploy/keycloak/kcadm-bootstrap.sh @@ -15,43 +15,28 @@ set -euo pipefail REALM="${KC_REALM:-cwl}" KC_SERVER="${KC_SERVER:-http://localhost:8080}" -# Bootstrap transport only: the admin credentials come from KV, used once to -# obtain an admin session, then discarded. +# Bootstrap transport only: the admin credentials come from KV, are used once +# to create an Admin CLI session, and are then discarded. ADMIN_USER="$(kv get secret/idp/bootstrap-admin-username)" ADMIN_PASS="$(kv get secret/idp/bootstrap-admin-password)" -# Do NOT pass the admin password on the kcadm.sh command line: argv is visible -# to any same-host process via /proc//cmdline. Obtain a short-lived admin -# token by handing the password to curl through a restricted temp file -# (--data-urlencode "@file", never argv), then configure kcadm with that -# bearer token. The reusable password never appears in any process's argv. -_pass_file="$(mktemp)" -chmod 600 "${_pass_file}" -_kcadm_token="" +# Use Keycloak's documented sensitive-option environment variable rather than +# placing the reusable password in process arguments. Keep the resulting access +# and refresh tokens in a private, short-lived HOME so no Admin CLI session +# survives this bootstrap process. +_kcadm_home="$(mktemp -d)" +chmod 700 "${_kcadm_home}" cleanup() { - rm -f "${_pass_file}" - unset ADMIN_PASS _kcadm_token + rm -rf "${_kcadm_home}" + unset ADMIN_PASS } trap cleanup EXIT -printf '%s' "${ADMIN_PASS}" > "${_pass_file}" -unset ADMIN_PASS - -_kcadm_token="$(curl -sf \ - --data-urlencode "grant_type=password" \ - --data-urlencode "client_id=admin-cli" \ - --data-urlencode "username=${ADMIN_USER}" \ - --data-urlencode "password@${_pass_file}" \ - "${KC_SERVER}/realms/master/protocol/openid-connect/token" \ - | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')" -rm -f "${_pass_file}" -if [ -z "${_kcadm_token}" ]; then - echo "ERROR: failed to obtain a bootstrap admin token" >&2 - exit 1 -fi +export HOME="${_kcadm_home}" -kcadm.sh config credentials \ +KC_CLI_PASSWORD="${ADMIN_PASS}" kcadm.sh config credentials \ --server "${KC_SERVER}" --realm master \ - --token "${_kcadm_token}" + --user "${ADMIN_USER}" +unset ADMIN_PASS # NOTE: external federation (the employer ADFS SAML IdP, corporate LDAP/AD) # is deliberately NOT part of this bootstrap. Those are deployment data, not From 1e67863079b2194f255f611a3147377135b38b9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 13:37:58 +0900 Subject: [PATCH 045/104] test: preserve KV helper environment during kcadm login --- .../tests/test_kcadm_bootstrap.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/services/account_unification/tests/test_kcadm_bootstrap.py b/services/account_unification/tests/test_kcadm_bootstrap.py index 55cd3c2..893dc24 100644 --- a/services/account_unification/tests/test_kcadm_bootstrap.py +++ b/services/account_unification/tests/test_kcadm_bootstrap.py @@ -17,7 +17,7 @@ def test_bootstrap_uses_documented_password_environment_variable() -> None: script = _bootstrap_script() credentials_command = ( - 'KC_CLI_PASSWORD="${ADMIN_PASS}" kcadm.sh config credentials' + 'KC_CLI_PASSWORD="${ADMIN_PASS}" kcadm config credentials' ) assert credentials_command in script assert '--user "${ADMIN_USER}"' in script @@ -26,11 +26,20 @@ def test_bootstrap_uses_documented_password_environment_variable() -> None: assert "curl -sf" not in script +def test_bootstrap_isolates_kcadm_without_replacing_kv_home() -> None: + """Scope the temporary HOME to kcadm so later KV commands keep working.""" + script = _bootstrap_script() + + assert "kcadm() {" in script + assert 'HOME="${_kcadm_home}" kcadm.sh "$@"' in script + assert "export HOME=" not in script + + def test_bootstrap_discards_reusable_admin_password_after_login() -> None: """The reusable bootstrap password is unset immediately after login.""" script = _bootstrap_script() credentials_position = script.index( - 'KC_CLI_PASSWORD="${ADMIN_PASS}" kcadm.sh config credentials' + 'KC_CLI_PASSWORD="${ADMIN_PASS}" kcadm config credentials' ) unset_position = script.index("unset ADMIN_PASS", credentials_position) next_bootstrap_step = script.index("# NOTE: external federation", unset_position) From cba88b60567e3b3ff8308393a4005213871151c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 13:38:32 +0900 Subject: [PATCH 046/104] fix(keycloak): isolate Admin CLI state without changing KV home --- deploy/keycloak/kcadm-bootstrap.sh | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/deploy/keycloak/kcadm-bootstrap.sh b/deploy/keycloak/kcadm-bootstrap.sh index 41182c0..9b47ac7 100755 --- a/deploy/keycloak/kcadm-bootstrap.sh +++ b/deploy/keycloak/kcadm-bootstrap.sh @@ -23,7 +23,8 @@ ADMIN_PASS="$(kv get secret/idp/bootstrap-admin-password)" # Use Keycloak's documented sensitive-option environment variable rather than # placing the reusable password in process arguments. Keep the resulting access # and refresh tokens in a private, short-lived HOME so no Admin CLI session -# survives this bootstrap process. +# survives this bootstrap process. HOME is scoped only to kcadm invocations; +# the platform `kv` helper keeps its original credential/configuration home. _kcadm_home="$(mktemp -d)" chmod 700 "${_kcadm_home}" cleanup() { @@ -31,9 +32,11 @@ cleanup() { unset ADMIN_PASS } trap cleanup EXIT -export HOME="${_kcadm_home}" +kcadm() { + HOME="${_kcadm_home}" kcadm.sh "$@" +} -KC_CLI_PASSWORD="${ADMIN_PASS}" kcadm.sh config credentials \ +KC_CLI_PASSWORD="${ADMIN_PASS}" kcadm config credentials \ --server "${KC_SERVER}" --realm master \ --user "${ADMIN_USER}" unset ADMIN_PASS @@ -46,19 +49,19 @@ unset ADMIN_PASS # See deploy/templates/ for ready-made request payloads. echo "==> patching account-unification-svc client secret from KV" -SVC_CLIENT_UUID="$(kcadm.sh get clients -r "${REALM}" \ +SVC_CLIENT_UUID="$(kcadm get clients -r "${REALM}" \ --query 'clientId=account-unification-svc' --fields id --format csv --noquotes | head -n1)" -kcadm.sh update "clients/${SVC_CLIENT_UUID}" -r "${REALM}" \ +kcadm update "clients/${SVC_CLIENT_UUID}" -r "${REALM}" \ -s "secret=$(kv get secret/idp/account-unification-client-secret)" echo "==> granting realm-management roles to the service account" # view-users/manage-users: account unification + SCIM shim. # manage-identity-providers: the runtime federation registry API. -SVC_SA_USER_ID="$(kcadm.sh get "clients/${SVC_CLIENT_UUID}/service-account-user" \ +SVC_SA_USER_ID="$(kcadm get "clients/${SVC_CLIENT_UUID}/service-account-user" \ -r "${REALM}" --fields id --format csv --noquotes)" -REALM_MGMT_UUID="$(kcadm.sh get clients -r "${REALM}" \ +REALM_MGMT_UUID="$(kcadm get clients -r "${REALM}" \ --query 'clientId=realm-management' --fields id --format csv --noquotes | head -n1)" -kcadm.sh add-roles -r "${REALM}" \ +kcadm add-roles -r "${REALM}" \ --uid "${SVC_SA_USER_ID}" \ --cclientid realm-management \ --rolename view-users --rolename manage-users \ @@ -69,19 +72,19 @@ echo "==> scoping the granted roles into the service-account access token" # only reaches the token when it is ALSO in the client's scope mappings AND a # client-role protocol mapper emits resource_access. Without both, every # Admin REST call from the service fails 403 on a fresh bring-up. -REALM_MGMT_ROLE_JSON="$(kcadm.sh get "clients/${REALM_MGMT_UUID}/roles" -r "${REALM}" \ +REALM_MGMT_ROLE_JSON="$(kcadm get "clients/${REALM_MGMT_UUID}/roles" -r "${REALM}" \ --fields id,name \ | python3 -c 'import json,sys; roles=json.load(sys.stdin); print(json.dumps([r for r in roles if r["name"] in ("view-users","manage-users","manage-identity-providers")]))')" -kcadm.sh create "clients/${SVC_CLIENT_UUID}/scope-mappings/clients/${REALM_MGMT_UUID}" \ +kcadm create "clients/${SVC_CLIENT_UUID}/scope-mappings/clients/${REALM_MGMT_UUID}" \ -r "${REALM}" -b "${REALM_MGMT_ROLE_JSON}" -kcadm.sh create "clients/${SVC_CLIENT_UUID}/protocol-mappers/models" -r "${REALM}" \ +kcadm create "clients/${SVC_CLIENT_UUID}/protocol-mappers/models" -r "${REALM}" \ -b '{"name":"realm-management roles","protocol":"openid-connect","protocolMapper":"oidc-usermodel-client-role-mapper","config":{"usermodel.clientRoleMapping.clientId":"realm-management","claim.name":"resource_access.realm-management.roles","multivalued":"true","jsonType.label":"String","access.token.claim":"true","id.token.claim":"false","userinfo.token.claim":"false"}}' echo "==> mirroring the service client secret into KV for the admin service" # The account-unification service reads keycloak_client_secret from KV; keep it # in sync so both sides use the same credential. kv put secret/idp/account-unification-client-secret \ - "$(kcadm.sh get "clients/${SVC_CLIENT_UUID}/client-secret" -r "${REALM}" \ + "$(kcadm get "clients/${SVC_CLIENT_UUID}/client-secret" -r "${REALM}" \ --fields value --format csv --noquotes)" echo "OK: kcadm bootstrap complete for realm '${REALM}'." From 64d1cb60c7d3fbd21d7ebc2368e00bd3a2d9a7b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 13:40:16 +0900 Subject: [PATCH 047/104] ci: cancel superseded pull-request validation runs --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9c18e9..0ac8706 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,12 @@ on: branches: [main] pull_request: +# Cancel superseded evidence for the same pull request or branch. This keeps the +# runner queue bounded during review-fix loops while preserving the newest head. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + # Least-privilege default token (OSSF Scorecard: Token-Permissions). permissions: contents: read From 2b333bc34ec8e92cfb694ec9fd77f7b6747bc687 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 13:40:43 +0900 Subject: [PATCH 048/104] ci: cancel superseded CodeQL runs --- .github/workflows/codeql.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2ea7a5a..2020895 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -5,6 +5,12 @@ on: branches: [main] pull_request: +# Code scanning evidence is head-specific; cancel scans made obsolete by a +# newer commit on the same pull request or branch. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: actions: read contents: read From e9c6fd150079c025715b8aee23e5452bd7212b06 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 13:49:55 +0900 Subject: [PATCH 049/104] test(config): reject unsafe runtime configuration --- .../account_unification/tests/test_config.py | 77 ++++++++++++++++--- 1 file changed, 65 insertions(+), 12 deletions(-) diff --git a/services/account_unification/tests/test_config.py b/services/account_unification/tests/test_config.py index 2d1f11a..dacbdb6 100644 --- a/services/account_unification/tests/test_config.py +++ b/services/account_unification/tests/test_config.py @@ -31,19 +31,21 @@ def test_kv_store_protocol_methods_have_concrete_implementations(): assert missing == [] +def _config_store(**overrides: str) -> InMemoryKvStore: + """Build one complete config namespace with selected overrides.""" + entries = { + "keycloak_server_url": "http://kc", + "keycloak_realm": "cwl", + "keycloak_client_id": "svc", + "keycloak_client_secret": "secret", + "operator_api_token": "operator-token", + } + entries.update(overrides) + return InMemoryKvStore({"account_unification": entries}) + + def test_config_loads_from_kv(): - store = InMemoryKvStore( - { - "account_unification": { - "keycloak_server_url": "http://kc", - "keycloak_realm": "cwl", - "keycloak_client_id": "svc", - "keycloak_client_secret": "secret", - "operator_api_token": "op-token", - } - } - ) - config = load_service_config(store, "account_unification") + config = load_service_config(_config_store(), "account_unification") assert config.keycloak_server_url == "http://kc" assert config.keycloak_realm == "cwl" # policy default: unverified linking OFF. @@ -57,6 +59,57 @@ def test_missing_required_config_fails_loudly(): load_service_config(store, "account_unification") +@pytest.mark.parametrize( + "raw_value", + ["0", "-1", "nan", "inf", "-inf", "not-a-number"], +) +def test_request_timeout_must_be_positive_and_finite(raw_value): + store = _config_store(request_timeout_seconds=raw_value) + with pytest.raises(RuntimeError, match="request_timeout_seconds"): + load_service_config(store, "account_unification") + + +@pytest.mark.parametrize( + "raw_value", + ["-1", "nan", "inf", "-inf", "not-a-number"], +) +def test_janitor_interval_must_be_non_negative_and_finite(raw_value): + store = _config_store(password_janitor_interval_seconds=raw_value) + with pytest.raises(RuntimeError, match="password_janitor_interval_seconds"): + load_service_config(store, "account_unification") + + +def test_zero_janitor_interval_disables_periodic_task_cleanly(): + store = _config_store(password_janitor_interval_seconds="0") + config = load_service_config(store, "account_unification") + assert config.password_janitor_interval_seconds == 0.0 + + +def test_registration_token_must_not_equal_operator_token(): + store = _config_store(registration_api_token="operator-token") + with pytest.raises(RuntimeError, match="registration_api_token"): + load_service_config(store, "account_unification") + + +@pytest.mark.parametrize("raw_value", ["true", "1", "yes", "on"]) +def test_unverified_email_link_policy_cannot_be_enabled(raw_value): + store = _config_store(allow_unverified_email_link=raw_value) + with pytest.raises(RuntimeError, match="allow_unverified_email_link"): + load_service_config(store, "account_unification") + + +def test_invalid_boolean_text_fails_loudly(): + store = _config_store(allow_unverified_email_link="definitely") + with pytest.raises(RuntimeError, match="allow_unverified_email_link"): + load_service_config(store, "account_unification") + + +def test_only_implemented_merge_conflict_policy_is_accepted(): + store = _config_store(merge_conflict_policy="duplicate_wins") + with pytest.raises(RuntimeError, match="merge_conflict_policy"): + load_service_config(store, "account_unification") + + def test_bootstrap_points_at_sqlite_store(tmp_path): db = tmp_path / "store.db" with closing(SqliteKvStore(str(db))) as seed: From 74d41ff615d20405e36571be5689854e775c2611 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 13:50:39 +0900 Subject: [PATCH 050/104] fix(config): fail closed on unsafe runtime values --- services/account_unification/app/config.py | 129 +++++++++++++++------ 1 file changed, 95 insertions(+), 34 deletions(-) diff --git a/services/account_unification/app/config.py b/services/account_unification/app/config.py index 33f155a..a0d8859 100644 --- a/services/account_unification/app/config.py +++ b/services/account_unification/app/config.py @@ -2,10 +2,11 @@ Nothing here reads process environment. :func:`load_service_config` takes an opened :class:`~app.kv_store.KvStore` (from :mod:`app.bootstrap`) and returns a -frozen config object. Missing required keys fail loudly at startup. +frozen config object. Missing or unsafe values fail loudly at startup. """ from __future__ import annotations +import math from dataclasses import dataclass from .kv_store import KvStore @@ -27,32 +28,24 @@ @dataclass(frozen=True) class ServiceConfig: - """Runtime settings loaded from the config store.""" + """Validated runtime settings loaded from the config store.""" # Keycloak Admin REST API wiring. The service authenticates to the realm - # token endpoint with a confidential service-account client (client - # credentials) that holds realm-management view-users/manage-users roles. + # token endpoint with a confidential service-account client. keycloak_server_url: str keycloak_realm: str keycloak_client_id: str keycloak_client_secret: str - # Shared operator bearer token gating the privileged admin API surface - # (merge, SCIM, federation, identity reads). Required: the service must not - # start with an open privileged surface. + # Privileged and product registration surfaces deliberately use different + # bearer credentials so relying products never acquire operator authority. operator_api_token: str - # Bearer token for the headless self-registration surface, held by product - # frontend backends (e.g. Naruon). Optional: deployments without - # self-signup leave it unset and the surface answers 503, never open. registration_api_token: str | None = None - # Seconds between bootstrap-password janitor passes; 0 disables the - # periodic task (the operator endpoint still runs passes on demand). + # Zero disables the periodic task; a manual janitor endpoint remains. password_janitor_interval_seconds: float = 300.0 - # Audit sink location. Must NOT live inside the read-only /bootstrap - # mount: the config store and the audit trail have different write needs. audit_database_path: str = "/var/lib/account-unification/audit.db" merge_conflict_policy: str = "survivor_wins" - # Hard default False: the ecosystem policy forbids linking/merging on an - # unverified email. Present as config only so audits can prove it is off. + # This is an invariant, not a deployer-selectable feature. The field remains + # so audit/config evidence can prove it was explicitly disabled. allow_unverified_email_link: bool = False request_timeout_seconds: float = 10.0 @@ -67,33 +60,101 @@ def _require(store: KvStore, namespace: str, entry_key: str) -> str: return value -def _as_bool(raw: str | None, default: bool) -> bool: - """Parse a store value as a permissive boolean.""" - if raw is None: +def _as_bool( + raw: str | None, + default: bool, + *, + entry_key: str, +) -> bool: + """Parse one explicit boolean or fail startup on ambiguous text.""" + if raw is None or raw == "": return default - return raw.strip().lower() in {"1", "true", "yes", "on"} + normalized = raw.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise RuntimeError(f"config '{entry_key}' must be a boolean") + + +def _as_finite_float( + raw: str | None, + default: float, + *, + entry_key: str, + allow_zero: bool, +) -> float: + """Parse a runtime duration and reject negative, NaN, or infinite values.""" + candidate = str(default) if raw is None or raw == "" else raw + try: + value = float(candidate) + except (TypeError, ValueError) as exc: + raise RuntimeError( + f"config '{entry_key}' must be a finite number" + ) from exc + if not math.isfinite(value) or value < 0 or (value == 0 and not allow_zero): + qualifier = "non-negative" if allow_zero else "positive" + raise RuntimeError( + f"config '{entry_key}' must be a finite {qualifier} number" + ) + return value def load_service_config(store: KvStore, namespace: str) -> ServiceConfig: - """Build the :class:`ServiceConfig` from the KV store.""" + """Build and validate the :class:`ServiceConfig` from the KV store.""" + operator_api_token = _require(store, namespace, KEY_OPERATOR_API_TOKEN) + registration_api_token = ( + store.get(namespace, KEY_REGISTRATION_API_TOKEN) or None + ) + if registration_api_token == operator_api_token: + raise RuntimeError( + "config 'registration_api_token' must differ from " + "'operator_api_token'" + ) + + allow_unverified_email_link = _as_bool( + store.get(namespace, KEY_ALLOW_UNVERIFIED_LINK), + default=False, + entry_key=KEY_ALLOW_UNVERIFIED_LINK, + ) + if allow_unverified_email_link: + raise RuntimeError( + "config 'allow_unverified_email_link' must remain false" + ) + + merge_conflict_policy = ( + store.get(namespace, KEY_MERGE_CONFLICT_POLICY) or "survivor_wins" + ) + if merge_conflict_policy != "survivor_wins": + raise RuntimeError( + "config 'merge_conflict_policy' must be 'survivor_wins'" + ) + return ServiceConfig( keycloak_server_url=_require(store, namespace, KEY_KEYCLOAK_SERVER_URL), keycloak_realm=_require(store, namespace, KEY_KEYCLOAK_REALM), keycloak_client_id=_require(store, namespace, KEY_KEYCLOAK_CLIENT_ID), - keycloak_client_secret=_require(store, namespace, KEY_KEYCLOAK_CLIENT_SECRET), - operator_api_token=_require(store, namespace, KEY_OPERATOR_API_TOKEN), - registration_api_token=store.get(namespace, KEY_REGISTRATION_API_TOKEN) or None, - password_janitor_interval_seconds=float( - store.get(namespace, KEY_PASSWORD_JANITOR_INTERVAL_SECONDS) or "300" + keycloak_client_secret=_require( + store, namespace, KEY_KEYCLOAK_CLIENT_SECRET + ), + operator_api_token=operator_api_token, + registration_api_token=registration_api_token, + password_janitor_interval_seconds=_as_finite_float( + store.get(namespace, KEY_PASSWORD_JANITOR_INTERVAL_SECONDS), + 300.0, + entry_key=KEY_PASSWORD_JANITOR_INTERVAL_SECONDS, + allow_zero=True, ), - audit_database_path=store.get(namespace, KEY_AUDIT_DATABASE_PATH) - or "/var/lib/account-unification/audit.db", - merge_conflict_policy=store.get(namespace, KEY_MERGE_CONFLICT_POLICY) - or "survivor_wins", - allow_unverified_email_link=_as_bool( - store.get(namespace, KEY_ALLOW_UNVERIFIED_LINK), default=False + audit_database_path=( + store.get(namespace, KEY_AUDIT_DATABASE_PATH) + or "/var/lib/account-unification/audit.db" ), - request_timeout_seconds=float( - store.get(namespace, KEY_REQUEST_TIMEOUT_SECONDS) or "10.0" + merge_conflict_policy=merge_conflict_policy, + allow_unverified_email_link=allow_unverified_email_link, + request_timeout_seconds=_as_finite_float( + store.get(namespace, KEY_REQUEST_TIMEOUT_SECONDS), + 10.0, + entry_key=KEY_REQUEST_TIMEOUT_SECONDS, + allow_zero=False, ), ) From 9d376f2d72d4c41af4d0e2e5663827187ba580a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 13:59:53 +0900 Subject: [PATCH 051/104] test(keycloak): require idempotent secret-safe bootstrap --- .../tests/test_kcadm_bootstrap.py | 51 +++++++++++++++++-- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/services/account_unification/tests/test_kcadm_bootstrap.py b/services/account_unification/tests/test_kcadm_bootstrap.py index 893dc24..51f4f95 100644 --- a/services/account_unification/tests/test_kcadm_bootstrap.py +++ b/services/account_unification/tests/test_kcadm_bootstrap.py @@ -1,15 +1,29 @@ -"""Static security and compatibility checks for the Keycloak bootstrap script.""" +"""Security, compatibility, and idempotency checks for Keycloak bootstrap.""" from __future__ import annotations +import subprocess from pathlib import Path +def _bootstrap_path() -> Path: + """Return the repository's Keycloak Admin CLI bootstrap path.""" + repository_root = Path(__file__).resolve().parents[3] + return repository_root / "deploy" / "keycloak" / "kcadm-bootstrap.sh" + + def _bootstrap_script() -> str: """Return the repository's Keycloak Admin CLI bootstrap source.""" - repository_root = Path(__file__).resolve().parents[3] - return ( - repository_root / "deploy" / "keycloak" / "kcadm-bootstrap.sh" - ).read_text(encoding="utf-8") + return _bootstrap_path().read_text(encoding="utf-8") + + +def test_bootstrap_has_valid_bash_syntax() -> None: + """Reject malformed shell before a deployment attempts bootstrap.""" + subprocess.run( + ["bash", "-n", str(_bootstrap_path())], + check=True, + capture_output=True, + text=True, + ) def test_bootstrap_uses_documented_password_environment_variable() -> None: @@ -45,3 +59,30 @@ def test_bootstrap_discards_reusable_admin_password_after_login() -> None: next_bootstrap_step = script.index("# NOTE: external federation", unset_position) assert credentials_position < unset_position < next_bootstrap_step + + +def test_service_client_secret_never_enters_process_arguments() -> None: + """Patch the client from a private JSON file, never a secret argv value.""" + script = _bootstrap_script() + + assert "umask 077" in script + assert "SERVICE_SECRET_JSON" in script + assert ( + 'kcadm update "clients/${SVC_CLIENT_UUID}" -r "${REALM}" ' + '-f "${SERVICE_SECRET_JSON}"' + ) in script + assert '-s "secret=$(kv get' not in script + assert "kv put secret/idp/account-unification-client-secret" not in script + + +def test_protocol_mapper_is_converged_idempotently() -> None: + """Update one mapper and remove historical duplicates on every run.""" + script = _bootstrap_script() + + assert "MAPPER_IDS=" in script + assert "MAPPER_ID=" in script + assert 'if [[ -n "${MAPPER_ID}" ]]' in script + assert "protocol-mappers/models/${MAPPER_ID}" in script + assert "tail -n +2" in script + assert "duplicate_mapper_id" in script + assert "kcadm delete" in script From 02cb7962d8ae9ad64873e90286a040db6a6ee25c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:01:10 +0900 Subject: [PATCH 052/104] fix(keycloak): make bootstrap idempotent and keep secrets out of argv --- deploy/keycloak/kcadm-bootstrap.sh | 130 +++++++++++++++++++---------- 1 file changed, 86 insertions(+), 44 deletions(-) diff --git a/deploy/keycloak/kcadm-bootstrap.sh b/deploy/keycloak/kcadm-bootstrap.sh index 9b47ac7..347eb1e 100755 --- a/deploy/keycloak/kcadm-bootstrap.sh +++ b/deploy/keycloak/kcadm-bootstrap.sh @@ -4,63 +4,81 @@ # The realm SHAPE lives in realm-cwl.json and is imported at container start. # This script patches the pieces that must NOT be committed (secrets, env URLs) # by reading them from the KV store and applying them with Keycloak's admin CLI -# (`kcadm.sh`, shipped in the Keycloak image, Apache-2.0). Run it once after the -# realm is imported and Keycloak is READY. +# (`kcadm.sh`, shipped in the Keycloak image, Apache-2.0). Run it after the realm +# is imported and Keycloak is READY; every operation is safe to repeat. # -# deploy/keycloak/kcadm-bootstrap.sh -# -# Requires: kcadm.sh on PATH (or run inside the keycloak container), and a `kv` -# helper that reads your platform secret manager. Nothing here echoes secrets. +# Requires: kcadm.sh on PATH (or run inside the Keycloak container), and a `kv` +# helper that reads the platform secret manager. Nothing here echoes secrets. set -euo pipefail +umask 077 REALM="${KC_REALM:-cwl}" KC_SERVER="${KC_SERVER:-http://localhost:8080}" -# Bootstrap transport only: the admin credentials come from KV, are used once -# to create an Admin CLI session, and are then discarded. ADMIN_USER="$(kv get secret/idp/bootstrap-admin-username)" ADMIN_PASS="$(kv get secret/idp/bootstrap-admin-password)" # Use Keycloak's documented sensitive-option environment variable rather than -# placing the reusable password in process arguments. Keep the resulting access -# and refresh tokens in a private, short-lived HOME so no Admin CLI session -# survives this bootstrap process. HOME is scoped only to kcadm invocations; -# the platform `kv` helper keeps its original credential/configuration home. +# placing the reusable password in process arguments. Scope the Admin CLI HOME +# to one private directory so its access and refresh tokens are destroyed at +# process exit while the platform `kv` helper retains its normal HOME. _kcadm_home="$(mktemp -d)" -chmod 700 "${_kcadm_home}" cleanup() { rm -rf "${_kcadm_home}" - unset ADMIN_PASS + unset ADMIN_PASS SERVICE_CLIENT_SECRET } trap cleanup EXIT kcadm() { HOME="${_kcadm_home}" kcadm.sh "$@" } +require_nonempty() { + local value_name="$1" + local value="$2" + if [[ -z "${value}" ]]; then + echo "bootstrap failed: ${value_name} was not resolved" >&2 + exit 1 + fi +} +require_nonempty "bootstrap admin username" "${ADMIN_USER}" +require_nonempty "bootstrap admin password" "${ADMIN_PASS}" KC_CLI_PASSWORD="${ADMIN_PASS}" kcadm config credentials \ --server "${KC_SERVER}" --realm master \ --user "${ADMIN_USER}" unset ADMIN_PASS -# NOTE: external federation (the employer ADFS SAML IdP, corporate LDAP/AD) -# is deliberately NOT part of this bootstrap. Those are deployment data, not -# realm code: register them at runtime through the account-unification -# service's /federation/identity-providers API, which persists the desired -# state in the KV/DB store and converges Keycloak via the Admin REST API. -# See deploy/templates/ for ready-made request payloads. +# External federation is deliberately not part of realm bootstrap. Employer +# ADFS, LDAP-fronting brokers, and optional OIDC providers are runtime desired +# state managed by /federation/identity-providers and persisted in the KV store. -echo "==> patching account-unification-svc client secret from KV" +echo "==> converging account-unification-svc client secret from KV" SVC_CLIENT_UUID="$(kcadm get clients -r "${REALM}" \ - --query 'clientId=account-unification-svc' --fields id --format csv --noquotes | head -n1)" + --query 'clientId=account-unification-svc' \ + --fields id --format csv --noquotes | head -n1)" +require_nonempty "account-unification service client id" "${SVC_CLIENT_UUID}" + +# Write the secret to a 0600 file through stdin. Neither the reusable secret nor +# the JSON representation appears in a child-process argument or command log. +SERVICE_CLIENT_SECRET="$(kv get secret/idp/account-unification-client-secret)" +require_nonempty "account-unification service client secret" \ + "${SERVICE_CLIENT_SECRET}" +SERVICE_SECRET_JSON="${_kcadm_home}/service-client-secret.json" +printf '%s' "${SERVICE_CLIENT_SECRET}" \ + | python3 -c 'import json,sys; json.dump({"secret": sys.stdin.read()}, sys.stdout)' \ + > "${SERVICE_SECRET_JSON}" +unset SERVICE_CLIENT_SECRET kcadm update "clients/${SVC_CLIENT_UUID}" -r "${REALM}" \ - -s "secret=$(kv get secret/idp/account-unification-client-secret)" + -f "${SERVICE_SECRET_JSON}" echo "==> granting realm-management roles to the service account" -# view-users/manage-users: account unification + SCIM shim. -# manage-identity-providers: the runtime federation registry API. -SVC_SA_USER_ID="$(kcadm get "clients/${SVC_CLIENT_UUID}/service-account-user" \ - -r "${REALM}" --fields id --format csv --noquotes)" +SVC_SA_USER_ID="$(kcadm get \ + "clients/${SVC_CLIENT_UUID}/service-account-user" -r "${REALM}" \ + --fields id --format csv --noquotes)" REALM_MGMT_UUID="$(kcadm get clients -r "${REALM}" \ - --query 'clientId=realm-management' --fields id --format csv --noquotes | head -n1)" + --query 'clientId=realm-management' \ + --fields id --format csv --noquotes | head -n1)" +require_nonempty "service-account user id" "${SVC_SA_USER_ID}" +require_nonempty "realm-management client id" "${REALM_MGMT_UUID}" + kcadm add-roles -r "${REALM}" \ --uid "${SVC_SA_USER_ID}" \ --cclientid realm-management \ @@ -68,23 +86,47 @@ kcadm add-roles -r "${REALM}" \ --rolename manage-identity-providers echo "==> scoping the granted roles into the service-account access token" -# The client is fullScopeAllowed:false (least privilege), so a granted role -# only reaches the token when it is ALSO in the client's scope mappings AND a -# client-role protocol mapper emits resource_access. Without both, every -# Admin REST call from the service fails 403 on a fresh bring-up. -REALM_MGMT_ROLE_JSON="$(kcadm get "clients/${REALM_MGMT_UUID}/roles" -r "${REALM}" \ - --fields id,name \ - | python3 -c 'import json,sys; roles=json.load(sys.stdin); print(json.dumps([r for r in roles if r["name"] in ("view-users","manage-users","manage-identity-providers")]))')" -kcadm create "clients/${SVC_CLIENT_UUID}/scope-mappings/clients/${REALM_MGMT_UUID}" \ +# fullScopeAllowed is false. The roles therefore need both scope mappings and a +# client-role protocol mapper before they appear in resource_access. +REALM_MGMT_ROLE_JSON="$(kcadm get \ + "clients/${REALM_MGMT_UUID}/roles" -r "${REALM}" --fields id,name \ + | python3 -c 'import json,sys; names={"view-users","manage-users","manage-identity-providers"}; print(json.dumps([role for role in json.load(sys.stdin) if role.get("name") in names]))')" +ROLE_COUNT="$(printf '%s' "${REALM_MGMT_ROLE_JSON}" \ + | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))')" +if [[ "${ROLE_COUNT}" -ne 3 ]]; then + echo "bootstrap failed: required realm-management roles were not resolved" >&2 + exit 1 +fi +kcadm create \ + "clients/${SVC_CLIENT_UUID}/scope-mappings/clients/${REALM_MGMT_UUID}" \ -r "${REALM}" -b "${REALM_MGMT_ROLE_JSON}" -kcadm create "clients/${SVC_CLIENT_UUID}/protocol-mappers/models" -r "${REALM}" \ - -b '{"name":"realm-management roles","protocol":"openid-connect","protocolMapper":"oidc-usermodel-client-role-mapper","config":{"usermodel.clientRoleMapping.clientId":"realm-management","claim.name":"resource_access.realm-management.roles","multivalued":"true","jsonType.label":"String","access.token.claim":"true","id.token.claim":"false","userinfo.token.claim":"false"}}' -echo "==> mirroring the service client secret into KV for the admin service" -# The account-unification service reads keycloak_client_secret from KV; keep it -# in sync so both sides use the same credential. -kv put secret/idp/account-unification-client-secret \ - "$(kcadm get "clients/${SVC_CLIENT_UUID}/client-secret" -r "${REALM}" \ - --fields value --format csv --noquotes)" +# Reconcile the mapper by name. Older non-idempotent bootstrap runs may have +# produced duplicates; update the first representation and remove the rest. +MAPPER_NAME="realm-management roles" +MAPPER_PAYLOAD='{"name":"realm-management roles","protocol":"openid-connect","protocolMapper":"oidc-usermodel-client-role-mapper","config":{"usermodel.clientRoleMapping.clientId":"realm-management","claim.name":"resource_access.realm-management.roles","multivalued":"true","jsonType.label":"String","access.token.claim":"true","id.token.claim":"false","userinfo.token.claim":"false"}}' +MAPPER_IDS="$(kcadm get \ + "clients/${SVC_CLIENT_UUID}/protocol-mappers/models" -r "${REALM}" \ + | python3 -c 'import json,sys; name=sys.argv[1]; print("\n".join(mapper["id"] for mapper in json.load(sys.stdin) if mapper.get("name") == name and mapper.get("id")))' \ + "${MAPPER_NAME}")" +MAPPER_ID="$(printf '%s\n' "${MAPPER_IDS}" | head -n1)" +if [[ -n "${MAPPER_ID}" ]]; then + kcadm update \ + "clients/${SVC_CLIENT_UUID}/protocol-mappers/models/${MAPPER_ID}" \ + -r "${REALM}" -b "${MAPPER_PAYLOAD}" +else + kcadm create "clients/${SVC_CLIENT_UUID}/protocol-mappers/models" \ + -r "${REALM}" -b "${MAPPER_PAYLOAD}" +fi +if [[ -n "${MAPPER_IDS}" ]]; then + printf '%s\n' "${MAPPER_IDS}" | tail -n +2 \ + | while IFS= read -r duplicate_mapper_id; do + if [[ -n "${duplicate_mapper_id}" ]]; then + kcadm delete \ + "clients/${SVC_CLIENT_UUID}/protocol-mappers/models/"\ +"${duplicate_mapper_id}" -r "${REALM}" + fi + done +fi echo "OK: kcadm bootstrap complete for realm '${REALM}'." From f40820090990b0becfaeb452198dae4970b3116a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:02:50 +0900 Subject: [PATCH 053/104] docs(keycloak): align bootstrap and federation source-of-truth --- deploy/keycloak/README.md | 102 +++++++++++++++++++++----------------- 1 file changed, 56 insertions(+), 46 deletions(-) diff --git a/deploy/keycloak/README.md b/deploy/keycloak/README.md index 2c85600..b694d9c 100644 --- a/deploy/keycloak/README.md +++ b/deploy/keycloak/README.md @@ -1,12 +1,13 @@ # Keycloak config-as-code -cwl-idp runs on **Keycloak** (Apache-2.0). The realm is declared as-code and -imported at container start; secrets are patched afterwards from the KV store. +cwl-idp runs on **Keycloak** (Apache-2.0). The portable realm shape is declared +as code and imported at container start. Deployment-specific secrets and +federation desired state remain outside the repository. | File | What | | --- | --- | -| `realm-cwl.json` | The `cwl` realm: passkey-first passwordless browser flow, OIDC/OAuth2.1 RP client template, employer ADFS SAML IdP (inbound), LDAP/AD federation, and the account-unification service-account client. Imported via `start --import-realm`. | -| `kcadm-bootstrap.sh` | Post-import patch: injects secrets/URLs (ADFS metadata, LDAP bind credential, service-account client secret) from KV using `kcadm.sh`, and grants the service account `realm-management` view-users/manage-users. | +| `realm-cwl.json` | The portable `cwl` realm: passkey-first browser flow, standard client scopes, OIDC/OAuth 2.1 RP templates, the concrete `naruon-web` public PKCE client, and the account-unification service-account client. Imported via `start --import-realm`. | +| `kcadm-bootstrap.sh` | Idempotent post-import convergence: reads the service-account client secret from KV without placing it in process arguments, grants the minimum `realm-management` roles, and reconciles the client scope/protocol mapper required to emit them. | ## Passwordless-first (passkeys) @@ -16,23 +17,34 @@ authenticator**, and binds it as the realm `browserFlow`. Combined with `resetPasswordAllowed:false` and a default `webauthn-register-passwordless` required action, ecosystem-local accounts authenticate with a **passkey (FIDO2/WebAuthn)** in the steady state. -Self-service signup is **headless**: product frontends (e.g. Naruon) own the +Self-service signup is **headless**: product frontends such as Naruon own the signup page and create accounts through the account-unification service's `/registration/accounts` API (`registrationAllowed` stays `false`, so the IdP-hosted registration form never appears). API-registered accounts carry a bootstrap password that the `browser-passwordless-credentials` subflow offers -ONLY while no passkey exists; the first session enrolls a passkey and the -registration password janitor then revokes the password credential. -`verifyEmail` stays `false` until a realm `smtpServer` is configured (the -validator enforces that pairing). See +only while no passkey exists; the first session enrolls a passkey and the +credential janitor then removes the bootstrap credential. `verifyEmail` stays +`false` until a realm `smtpServer` is configured; the validator enforces that +pairing. See [`../../docs/passwordless-policy.md`](../../docs/passwordless-policy.md). -## What is committed vs. patched from KV +## What is committed, bootstrapped, and registered at runtime -Committed (non-secret shape): realm, flows, client template, IdP + LDAP -*structure*, mappers. Patched from KV at bootstrap (never committed): ADFS -metadata URL, LDAP connection URL / bind DN / bind credential, and every client -secret. Placeholders read `__set_from_kv__`. +**Committed portable shape:** realm settings, browser flows, standard client +scopes, RP client structure, protocol mappers, and the account-unification +service-account client. Any committed secret field remains a non-deployable +placeholder such as `__set_from_kv__`. + +**Converged by `kcadm-bootstrap.sh`:** the account-unification client secret, +its least-privilege `realm-management` grants, the client scope mappings, and a +single named protocol mapper. The script is safe to repeat: it updates the +existing mapper, removes historical duplicates, and keeps the reusable admin +password and client secret out of child-process arguments. + +**Registered at runtime:** employer ADFS, LDAP-fronting brokers, optional OIDC +providers, and their credentials. The account-unification federation API stores +desired state in KV/DB and converges Keycloak through the Admin REST API. This +keeps employer-specific topology out of reusable realm source code. ## Apply @@ -41,15 +53,18 @@ secret. Placeholders read `__set_from_kv__`. # (docker-compose mounts it at /opt/keycloak/data/import). docker compose up -d -# 2. Once Keycloak is READY, patch secrets from KV: +# 2. Once Keycloak is READY, converge the service account from KV: KC_SERVER=http://localhost:8080 deploy/keycloak/kcadm-bootstrap.sh + +# 3. Register or re-apply deployment-specific federation desired state: +# POST /federation/identity-providers:apply ``` -## Federation & client registration templates +## Federation and client-registration templates -Additional Admin-API request bodies for registering more RPs / IdPs live in -[`../templates/`](../templates/) (Keycloak client / SAML IdP / LDAP component -representations). +Admin-API request bodies for registering additional RPs and external identity +providers live in [`../templates/`](../templates/). These are payload +references, not resources imported into the committed realm. ## Realm-file rules Keycloak 26 enforces @@ -63,38 +78,33 @@ back (`scripts/validate_realm.py` guards both): JSON annotations. - **No committed external federation.** SAML IdP URL fields are URL-validated at import (a bare `__set_from_kv__` aborts it) and an enabled LDAP source - with placeholder DNs breaks every realm user operation (`Invalid DN`). The - deeper problem is that employer-specific federation (ADFS, corporate LDAP) - is deployment data, so the realm commits **none of it**: register external - IdPs at runtime through the account-unification service's - `/federation/identity-providers` API. Desired state persists in the KV/DB - config store and is converged into Keycloak over the Admin REST API, so a - realm rebuild is re-converged with one `POST - /federation/identity-providers:apply`. `../templates/` holds ready-made - payloads (ADFS SAML, LDAP component, OIDC RP). + with placeholder DNs breaks realm user operations (`Invalid DN`). Employer + ADFS and corporate LDAP are deployment data, so the realm commits none of + them. Register providers through `/federation/identity-providers`; desired + state persists in KV/DB and a rebuilt realm is re-converged with + `POST /federation/identity-providers:apply`. ## Client scopes and the Keycloak 26 lightweight-token pitfall Imported realms do **not** get the standard client scopes auto-created, and -without the `basic` scope Keycloak 26 access tokens omit the `sub` claim — -which breaks any RP that authenticates by subject (naruon returns 401 for -every request). The realm therefore commits `basic` (Subject + auth_time), -`profile`, and `email` scopes and assigns them as realm defaults plus explicit -`defaultClientScopes` on each RP client. +without the `basic` scope Keycloak 26 access tokens omit the `sub` claim. That +breaks RPs that authenticate by subject. The realm therefore commits `basic` +(Subject + auth_time), `profile`, and `email` scopes and assigns them as realm +defaults plus explicit `defaultClientScopes` on each RP client. -## RP clients: template + naruon +## RP clients: template + Naruon `ecosystem-rp-template` stays the confidential-client blueprint (OAuth 2.1: -code + PKCE S256, no implicit, exact redirect URIs, secret from KV). Clones +code + PKCE S256, no implicit flow, exact redirect URIs, secret from KV). Clones must rename the audience mapper's `included.client.audience` to the new -clientId. - -`naruon-web` is the first concrete RP, committed as-code as a **public** PKCE -client because the naruon browser flow cannot hold a client secret. It carries -the claims naruon's backend session contract requires: `sub` (via `basic`), -an `aud` containing `naruon-web`, and hardcoded `role=member` / -`org` / `workspace` claims. The committed `org`/`workspace` values -(`org-cwl` / `workspace-org-cwl`) and the `https://naruon.example` redirect -URIs are deployment placeholders — patch them per environment with `kcadm.sh` -(see `../templates/`). `access.token.lifespan` is 43200s to fit naruon's 12h -session ceiling; admin roles are never asserted from IdP claims by design. +`clientId`. + +`naruon-web` is the first concrete RP, committed as a **public** PKCE client +because a browser cannot hold a client secret. It carries the claims Naruon's +backend session contract requires: `sub` (via `basic`), an `aud` containing +`naruon-web`, and hardcoded `role=member`, `org`, and `workspace` claims. The +committed `org`/`workspace` values (`org-cwl` / `workspace-org-cwl`) and +`https://naruon.example` redirect URIs are deployment placeholders and must be +replaced per environment through an operator-managed Admin API payload. +`access.token.lifespan` is 43200 seconds to fit Naruon's 12-hour session ceiling; +admin roles are never asserted from external IdP claims by design. From b5faf47d873911f70d70ca8c980e4cb832c1819c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:34:27 +0900 Subject: [PATCH 054/104] docs(plan): record protected review remediation --- .../2026-08-03-keyverse-review-remediation.md | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-keyverse-review-remediation.md diff --git a/docs/superpowers/plans/2026-08-03-keyverse-review-remediation.md b/docs/superpowers/plans/2026-08-03-keyverse-review-remediation.md new file mode 100644 index 0000000..18a0b39 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-keyverse-review-remediation.md @@ -0,0 +1,132 @@ +# Keyverse Review Remediation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Resolve every valid current-head review blocker without weakening Keyverse's passwordless, audit, security, or modularity contracts. + +**Architecture:** Replace the bound-flow bootstrap password with Keycloak's action-email passkey enrollment path; keep registration and operator privileges separate; move network calls outside process locks; return protocol-native errors; and fail closed on unknown secrets, aliases, paths, and deployment configuration. + +**Tech Stack:** Python 3.11+, FastAPI, Pydantic 2, httpx, SQLite, Keycloak Admin REST API, pytest, Ruff, Interrogate, Helm, Docker Compose. + +## Global Constraints + +- The bound `browserFlow` must contain no password authenticator. +- Application and test function docstring coverage must remain 100%. +- Production statement and branch coverage must remain 100%. +- Every database object name must contain at least two words and use snake_case. +- No secret may appear in HTTP responses, logs, or process arguments. +- Required reviews and GitHub Checks must not be bypassed. + +--- + +### Task 1: Passwordless registration enrollment + +**Files:** +- Modify: `deploy/keycloak/realm-cwl.json` +- Modify: `scripts/validate_realm.py` +- Modify: `services/account_unification/app/registration.py` +- Modify: `services/account_unification/app/product_keycloak_client.py` +- Modify: `services/account_unification/app/config.py` +- Modify: `services/account_unification/app/main.py` +- Modify: `services/account_unification/tests/test_registration.py` +- Modify: `services/account_unification/tests/test_keycloak_client.py` +- Modify: `services/account_unification/tests/mock_product_keycloak.py` + +**Interfaces:** +- Produces: `ProductAdminApi.send_execute_actions_email(user_id, action_aliases, client_id, redirect_uri, lifespan_seconds) -> None` +- Produces: registration configuration for client ID, redirect URI, and finite positive action-link lifespan. + +- [ ] Write failing tests proving the bound browser flow rejects `auth-password-form`, public-client access tokens are capped, registration creates no password, and passkey action-email failure rolls the account back. +- [ ] Run focused tests and confirm they fail for the expected missing behavior. +- [ ] Remove the password execution and validator exception; set `naruon-web` access-token lifespan to 300 seconds and validate a 900-second maximum. +- [ ] Replace `initial_password` with action-email enrollment and add the Keycloak Admin REST adapter method. +- [ ] Remove the credential janitor, its configuration, background task, and privileged endpoint. +- [ ] Run focused registration, adapter, config, realm, and lifecycle tests. + +### Task 2: Registration abuse and race boundaries + +**Files:** +- Modify: `services/account_unification/app/registration.py` +- Modify: `services/account_unification/tests/test_registration.py` + +**Interfaces:** +- Produces: `reset_rate_limit_state() -> None` +- Produces: caller-keyed fixed-window registration limiting. + +- [ ] Write failing tests proving one client cannot exhaust another client's quota and Keycloak 409 maps to `email_already_registered`. +- [ ] Run the focused tests and confirm the expected failures. +- [ ] Store rate-limit windows per client address under one lock and expose a test reset helper. +- [ ] Catch only `httpx.HTTPStatusError` with status 409; re-raise every other transport error. +- [ ] Run the focused registration suite. + +### Task 3: Federation reconciliation and secret safety + +**Files:** +- Modify: `services/account_unification/app/federation.py` +- Modify: `services/account_unification/tests/test_federation.py` + +**Interfaces:** +- Preserves: `IdentityProviderStatus` +- Produces: safe-key allowlist redaction in which unknown config keys are redacted. + +- [ ] Write failing tests for non-ASCII aliases, unknown-key redaction, persisted-but-unapplied status, and network calls outside `RLock`. +- [ ] Run the focused tests and confirm the expected failures. +- [ ] Snapshot stored registrations under the lock, then perform Keycloak calls after releasing it. +- [ ] Return `applied_to_keycloak=False` when desired state was stored but convergence failed. +- [ ] Validate aliases against explicit ASCII alphabets and redact every config key not explicitly classified safe. +- [ ] Run the federation suite. + +### Task 4: Protocol and runtime correctness + +**Files:** +- Modify: `services/account_unification/app/healthcheck.py` +- Modify: `services/account_unification/app/path_security.py` +- Modify: `services/account_unification/app/main.py` +- Modify: `services/account_unification/tests/test_healthcheck.py` +- Modify: `services/account_unification/tests/test_path_security.py` +- Modify: `services/account_unification/tests/test_user_locks.py` + +**Interfaces:** +- Produces: `ScimPathValidationError` and `scim_path_validation_exception_handler`. + +- [ ] Write failing tests for HTTP 503 probe handling, root-level SCIM error envelopes with `application/scim+json`, and `:memory:` lock wiring. +- [ ] Run focused tests and confirm the expected failures. +- [ ] Register `HTTPDefaultErrorHandler`, add the SCIM-specific exception handler, and use an explicit temporary lock file for in-memory audit configurations. +- [ ] Add missing function docstrings and run the focused suites. + +### Task 5: Deployment durability and review hygiene + +**Files:** +- Modify: `docker-compose.yml` +- Modify: `helm/cwl-idp/values.yaml` +- Modify: `helm/cwl-idp/templates/account-unification.yaml` +- Modify: `deploy/keycloak/README.md` +- Modify: `docs/passwordless-policy.md` +- Modify: `services/account_unification/tools/seed_config_store.py` +- Modify: `services/account_unification/tests/test_config.py` +- Modify: `services/account_unification/tests/test_federation.py` +- Modify: `services/account_unification/tests/test_kcadm_bootstrap.py` +- Modify: `services/account_unification/tests/test_storage_concurrency.py` + +**Interfaces:** +- Produces: persistent Compose audit volume and optional Helm digest enforcement. + +- [ ] Add contract tests or static assertions for persistent audit storage, digest enforcement, non-temporary seed defaults, and stable bootstrap markers. +- [ ] Run focused tests and confirm the expected failures. +- [ ] Add the named volume, `requireDigest` render guard, project-local seed path, fixture-safe tests, context-managed SQLite test resources, and truthful documentation. +- [ ] Run focused tests and configuration rendering checks. + +### Task 6: Protected completion + +**Files:** +- Modify: `CHANGELOG.md` +- Modify: `docs/superpowers/plans/2026-08-03-keyverse-review-remediation.md` + +- [ ] Run `uv sync --locked --extra dev`. +- [ ] Run `uv run ruff check app tests tools`. +- [ ] Run `uv run interrogate .` and require 100%. +- [ ] Run `uv run pytest -q` with 100% production statement and branch coverage. +- [ ] Run realm, Compose, Helm, CodeQL, Semgrep, security, and central coverage checks on the exact head. +- [ ] Resolve only review threads whose findings are demonstrably addressed. +- [ ] Merge only after the repository's protected policy is satisfied. +- [ ] Re-list open PRs and continue until the queue is zero or an external approval/runner blocker remains. From 3c33048189119ad5c77941a6cf7a2f0d56bb6b79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:35:54 +0900 Subject: [PATCH 055/104] test(registration): specify passwordless action-email enrollment --- .../tests/test_registration.py | 312 +++++++++--------- 1 file changed, 164 insertions(+), 148 deletions(-) diff --git a/services/account_unification/tests/test_registration.py b/services/account_unification/tests/test_registration.py index db7647b..6c1f6fc 100644 --- a/services/account_unification/tests/test_registration.py +++ b/services/account_unification/tests/test_registration.py @@ -1,140 +1,171 @@ -"""Headless self-registration and bootstrap-credential retirement tests.""" +"""Headless passwordless self-registration tests.""" from __future__ import annotations +import httpx import pytest from fastapi.testclient import TestClient from app import registration as registration_module from app.main import create_app -from app.registration import revoke_bootstrap_passwords +from app.registration import reset_rate_limit_state REGISTRATION_TOKEN = "registration-token-for-tests" +OPERATOR_TOKEN = "operator-token-for-tests" +REGISTRATION_CLIENT_ID = "naruon-web" +REGISTRATION_REDIRECT_URI = "https://naruon.example/auth/passkey-complete" +REGISTRATION_ACTION_LIFESPAN_SECONDS = 900 @pytest.fixture(autouse=True) -def _reset_rate_limit(): - """Reset process-local rate-limit state between tests.""" - registration_module._registration_attempt_window_start = 0.0 - registration_module._registration_attempt_count = 0 +def _reset_rate_limit() -> None: + """Reset caller-keyed registration limits between tests.""" + reset_rate_limit_state() yield + reset_rate_limit_state() -@pytest.fixture -def client(api): - """Return a registration-authenticated test client.""" +def _wire_registration_app(api): + """Return an app with the complete registration contract configured.""" app = create_app(wire=False) app.state.keycloak_api = api app.state.registration_api_token = REGISTRATION_TOKEN - headers = { - "Authorization": f"Bearer {REGISTRATION_TOKEN}" - } + app.state.operator_api_token = OPERATOR_TOKEN + app.state.registration_client_id = REGISTRATION_CLIENT_ID + app.state.registration_redirect_uri = REGISTRATION_REDIRECT_URI + app.state.registration_action_lifespan_seconds = ( + REGISTRATION_ACTION_LIFESPAN_SECONDS + ) + return app + + +@pytest.fixture +def client(api): + """Return a registration-authenticated test client.""" + app = _wire_registration_app(api) + headers = {"Authorization": f"Bearer {REGISTRATION_TOKEN}"} with TestClient(app, headers=headers) as test_client: yield test_client -def _registration( - email="new.user@example.com", - password="bootstrap-pass-1", -): - """Build one valid registration payload.""" +def _registration(email: str = "new.user@example.com") -> dict[str, object]: + """Build one valid registration payload without a password.""" return { "email_address": email, - "initial_password": password, "first_name": "New", "last_name": "User", } -def test_registration_creates_passwordless_transition_account( - client, api -): - """Registration creates a bootstrap credential and passkey action.""" - response = client.post( - "/registration/accounts", - json=_registration(), - ) +def test_registration_sends_verified_passkey_enrollment_email(client, api): + """Registration creates no password and sends bounded enrollment actions.""" + response = client.post("/registration/accounts", json=_registration()) + assert response.status_code == 201 body = response.json() account_id = body["account_id"] assert body["email_address"] == "new.user@example.com" assert api.users[account_id].is_email_verified is False - assert api.required_actions[account_id] == [ - "webauthn-register-passwordless" - ] - assert { - item["type"] - for item in api.list_user_credentials(account_id) - } == {"password"} + assert api.action_emails[account_id] == { + "action_aliases": [ + "VERIFY_EMAIL", + "webauthn-register-passwordless", + ], + "client_id": REGISTRATION_CLIENT_ID, + "redirect_uri": REGISTRATION_REDIRECT_URI, + "lifespan_seconds": REGISTRATION_ACTION_LIFESPAN_SECONDS, + } + assert not any(call.startswith("reset_user_password:") for call in api.calls) -def test_registration_rolls_back_partial_initialization( +def test_registration_rolls_back_when_enrollment_email_fails( client, api, monkeypatch ): - """An initialization failure deletes the newly created account.""" - def fail_required_actions(*args, **kwargs): - raise RuntimeError("simulated Keycloak failure") + """An action-email failure deletes the newly created account.""" + + def fail_action_email(*args, **kwargs) -> None: + """Simulate an unavailable Keycloak email transport.""" + raise RuntimeError("simulated Keycloak email failure") + + monkeypatch.setattr(api, "send_execute_actions_email", fail_action_email) + + response = client.post("/registration/accounts", json=_registration()) - monkeypatch.setattr( - api, - "set_user_required_actions", - fail_required_actions, - ) - response = client.post( - "/registration/accounts", - json=_registration(), - ) assert response.status_code == 502 - assert response.json()["detail"] == ( - "account_initialization_failed" - ) - assert api.find_users_by_email( - "new.user@example.com" - ) == [] - assert any( - call.startswith("delete_user:") - for call in api.calls - ) + assert response.json()["detail"] == "account_initialization_failed" + assert api.find_users_by_email("new.user@example.com") == [] + assert any(call.startswith("delete_user:") for call in api.calls) + + +def test_registration_reports_rollback_failure(client, api, monkeypatch): + """A failed cleanup is distinguishable from the enrollment failure.""" + + def fail_action_email(*args, **kwargs) -> None: + """Simulate the original initialization failure.""" + raise RuntimeError("simulated email failure") + + def fail_delete(*args, **kwargs) -> None: + """Simulate rollback failure after user creation.""" + raise RuntimeError("simulated rollback failure") + + monkeypatch.setattr(api, "send_execute_actions_email", fail_action_email) + monkeypatch.setattr(api, "delete_user", fail_delete) + + response = client.post("/registration/accounts", json=_registration()) + + assert response.status_code == 502 + assert response.json()["detail"] == "account_initialization_rollback_failed" def test_registration_normalizes_email_case(client): """Email addresses are normalized before account creation.""" response = client.post( "/registration/accounts", - json=_registration( - email="Mixed.Case@Example.COM" - ), + json=_registration(email="Mixed.Case@Example.COM"), ) assert response.status_code == 201 - assert response.json()["email_address"] == ( - "mixed.case@example.com" - ) + assert response.json()["email_address"] == "mixed.case@example.com" def test_registration_accepts_tagged_email(client): """Tagged local parts are accepted without regex backtracking.""" response = client.post( "/registration/accounts", - json=_registration( - email="new.user+product@example.com" - ), + json=_registration(email="new.user+product@example.com"), ) assert response.status_code == 201 def test_registration_rejects_duplicate_email(client): - """Duplicate normalized email addresses are rejected.""" + """Duplicate normalized email addresses are rejected before creation.""" assert client.post( - "/registration/accounts", - json=_registration(), + "/registration/accounts", json=_registration() ).status_code == 201 - duplicate = client.post( - "/registration/accounts", - json=_registration(), - ) + + duplicate = client.post("/registration/accounts", json=_registration()) + assert duplicate.status_code == 409 - assert duplicate.json()["detail"] == ( - "email_already_registered" - ) + assert duplicate.json()["detail"] == "email_already_registered" + + +def test_concurrent_keycloak_duplicate_maps_to_registration_conflict( + client, api, monkeypatch +): + """A Keycloak create-user 409 remains an idempotent product conflict.""" + request = httpx.Request("POST", "http://keycloak.test/admin/realms/cwl/users") + response = httpx.Response(409, request=request) + + def reject_concurrent_duplicate(*args, **kwargs): + """Model a competing request winning after the preflight lookup.""" + raise httpx.HTTPStatusError( + "duplicate user", request=request, response=response + ) + + monkeypatch.setattr(api, "create_user", reject_concurrent_duplicate) + + result = client.post("/registration/accounts", json=_registration()) + + assert result.status_code == 409 + assert result.json()["detail"] == "email_already_registered" @pytest.mark.parametrize( @@ -156,118 +187,103 @@ def test_registration_rejects_duplicate_email(client): def test_registration_rejects_malformed_email(client, email): """Malformed syntax is rejected deterministically.""" response = client.post( - "/registration/accounts", - json=_registration(email=email), + "/registration/accounts", json=_registration(email=email) ) assert response.status_code == 422 -def test_registration_rejects_short_password(client): - """Bootstrap credentials below the minimum are rejected.""" - response = client.post( - "/registration/accounts", - json=_registration(password="short"), - ) +def test_registration_rejects_legacy_password_field(client): + """A password cannot silently cross the passwordless registration boundary.""" + payload = _registration() + payload["initial_password"] = "legacy-bootstrap-password" + + response = client.post("/registration/accounts", json=payload) + assert response.status_code == 422 def test_registration_surface_fails_closed_without_token(api): """The endpoint is unavailable when its credential is absent.""" - app = create_app(wire=False) - app.state.keycloak_api = api + app = _wire_registration_app(api) app.state.registration_api_token = None with TestClient(app) as test_client: response = test_client.post( "/registration/accounts", json=_registration(), - headers={ - "Authorization": - f"Bearer {REGISTRATION_TOKEN}" - }, + headers={"Authorization": f"Bearer {REGISTRATION_TOKEN}"}, + ) + assert response.status_code == 503 + + +def test_registration_surface_fails_closed_without_enrollment_config(api): + """Missing action-email configuration cannot create an unusable account.""" + app = _wire_registration_app(api) + app.state.registration_redirect_uri = None + with TestClient( + app, + headers={"Authorization": f"Bearer {REGISTRATION_TOKEN}"}, + ) as test_client: + response = test_client.post( + "/registration/accounts", json=_registration() ) assert response.status_code == 503 + assert api.find_users_by_email("new.user@example.com") == [] def test_registration_rejects_wrong_token(api): """A mismatched registration credential is rejected.""" - app = create_app(wire=False) - app.state.keycloak_api = api - app.state.registration_api_token = REGISTRATION_TOKEN + app = _wire_registration_app(api) with TestClient(app) as test_client: response = test_client.post( "/registration/accounts", json=_registration(), - headers={ - "Authorization": "Bearer wrong-token" - }, + headers={"Authorization": "Bearer wrong-token"}, ) assert response.status_code == 403 def test_operator_token_does_not_open_registration(api): """The operator credential cannot authorize product signup.""" - app = create_app(wire=False) - app.state.keycloak_api = api - app.state.registration_api_token = REGISTRATION_TOKEN - app.state.operator_api_token = "operator-token" + app = _wire_registration_app(api) with TestClient(app) as test_client: response = test_client.post( "/registration/accounts", json=_registration(), - headers={ - "Authorization": "Bearer operator-token" - }, + headers={"Authorization": f"Bearer {OPERATOR_TOKEN}"}, ) assert response.status_code == 403 -def test_janitor_removes_only_after_passkey_enrollment( - client, api -): - """Bootstrap credentials survive until a passkey exists.""" - enrolled = client.post( - "/registration/accounts", - json=_registration( - email="enrolled@example.com" - ), - ).json()["account_id"] - pending = client.post( - "/registration/accounts", - json=_registration( - email="pending@example.com" - ), - ).json()["account_id"] - api.add_test_passkey(enrolled) - - result = revoke_bootstrap_passwords(api) - - assert result.removed_bootstrap_credentials == 1 - enrolled_types = { - item["type"] - for item in api.list_user_credentials(enrolled) - } - pending_types = { - item["type"] - for item in api.list_user_credentials(pending) - } - assert "password" not in enrolled_types - assert "webauthn-passwordless" in enrolled_types - assert "password" in pending_types +def test_registration_rate_limit_isolated_by_client(api, monkeypatch): + """One client cannot consume another client's registration allowance.""" + monkeypatch.setattr( + registration_module, + "REGISTRATION_RATE_LIMIT_MAX_ATTEMPTS", + 1, + ) + app = _wire_registration_app(api) + headers = {"Authorization": f"Bearer {REGISTRATION_TOKEN}"} + with TestClient(app, headers=headers, client=("client-a", 50001)) as client_a: + assert client_a.post( + "/registration/accounts", + json=_registration("first@example.com"), + ).status_code == 201 + limited = client_a.post( + "/registration/accounts", + json=_registration("second@example.com"), + ) + assert limited.status_code == 429 -def test_janitor_endpoint_runs_one_pass(client, api): - """The protected endpoint executes one bounded cleanup pass.""" - account_id = client.post( - "/registration/accounts", - json=_registration( - email="janitor@example.com" - ), - ).json()["account_id"] - api.add_test_passkey(account_id) + with TestClient(app, headers=headers, client=("client-b", 50002)) as client_b: + independent = client_b.post( + "/registration/accounts", + json=_registration("third@example.com"), + ) + assert independent.status_code == 201 - response = client.post( - "/registration/password-janitor:run" - ) - assert response.status_code == 200 - assert response.json()["removed_bootstrap_credentials"] == 1 +def test_registration_router_has_no_realm_wide_janitor_endpoint(client): + """Registration credentials cannot invoke a realm-wide destructive action.""" + response = client.post("/registration/password-janitor:run") + assert response.status_code == 404 From a3976fd82e82dadd363f4d37b3715bb1d5a76a89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:36:31 +0900 Subject: [PATCH 056/104] test(realm): specify passwordless and token lifetime policy --- .../tests/test_realm_policy.py | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 services/account_unification/tests/test_realm_policy.py diff --git a/services/account_unification/tests/test_realm_policy.py b/services/account_unification/tests/test_realm_policy.py new file mode 100644 index 0000000..83d03b2 --- /dev/null +++ b/services/account_unification/tests/test_realm_policy.py @@ -0,0 +1,88 @@ +"""Keycloak realm policy regression tests.""" +from __future__ import annotations + +import importlib.util +import json +from copy import deepcopy +from pathlib import Path +from types import ModuleType + + +def _repository_root() -> Path: + """Return the repository root from the service test package.""" + return Path(__file__).resolve().parents[3] + + +def _validator_module() -> ModuleType: + """Load the repository realm validator as a Python module.""" + validator_path = _repository_root() / "scripts" / "validate_realm.py" + spec = importlib.util.spec_from_file_location( + "keyverse_validate_realm", validator_path + ) + if spec is None or spec.loader is None: + raise RuntimeError("unable to load realm validator") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _realm() -> dict: + """Load the committed Keycloak realm representation.""" + realm_path = _repository_root() / "deploy" / "keycloak" / "realm-cwl.json" + return json.loads(realm_path.read_text(encoding="utf-8")) + + +def _client(realm: dict, client_id: str) -> dict: + """Return one client representation by client ID.""" + return next( + client + for client in realm["clients"] + if client.get("clientId") == client_id + ) + + +def test_committed_realm_passes_passwordless_policy() -> None: + """The checked-in realm satisfies every fail-closed policy invariant.""" + validator = _validator_module() + assert validator.validate(_realm()) == [] + + +def test_bound_browser_flow_rejects_password_authenticator() -> None: + """No nested execution reachable from browserFlow may accept a password.""" + validator = _validator_module() + realm = deepcopy(_realm()) + credential_flow = next( + flow + for flow in realm["authenticationFlows"] + if flow.get("alias") == "browser-passwordless-credentials" + ) + credential_flow["authenticationExecutions"].append( + { + "authenticator": "auth-password-form", + "authenticatorFlow": False, + "requirement": "ALTERNATIVE", + "priority": 20, + } + ) + + errors = validator.validate(realm) + + 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.""" + validator = _validator_module() + realm = deepcopy(_realm()) + _client(realm, "naruon-web")["attributes"]["access.token.lifespan"] = "901" + + 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 From 1572c28a8ef5f9741bce2a3b88b47bb4a9428645 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:37:17 +0900 Subject: [PATCH 057/104] test(config): define action-email registration contract --- .../account_unification/tests/test_config.py | 106 ++++++++++++++---- 1 file changed, 83 insertions(+), 23 deletions(-) diff --git a/services/account_unification/tests/test_config.py b/services/account_unification/tests/test_config.py index dacbdb6..0f71c21 100644 --- a/services/account_unification/tests/test_config.py +++ b/services/account_unification/tests/test_config.py @@ -15,7 +15,8 @@ from app.kv_store import InMemoryKvStore, KvStore, SqliteKvStore -def test_kv_store_protocol_methods_have_concrete_implementations(): +def test_kv_store_protocol_methods_have_concrete_implementations() -> None: + """Every config-store adapter implements the complete public protocol.""" protocol_methods = { name for name, member in KvStore.__dict__.items() @@ -44,16 +45,18 @@ def _config_store(**overrides: str) -> InMemoryKvStore: return InMemoryKvStore({"account_unification": entries}) -def test_config_loads_from_kv(): +def test_config_loads_from_kv() -> None: + """Required values load and security invariants retain safe defaults.""" config = load_service_config(_config_store(), "account_unification") assert config.keycloak_server_url == "http://kc" assert config.keycloak_realm == "cwl" - # policy default: unverified linking OFF. assert config.allow_unverified_email_link is False assert config.merge_conflict_policy == "survivor_wins" + assert config.registration_api_token is None -def test_missing_required_config_fails_loudly(): +def test_missing_required_config_fails_loudly() -> None: + """Startup fails when any foundational Keycloak setting is absent.""" store = InMemoryKvStore({"account_unification": {}}) with pytest.raises(RuntimeError): load_service_config(store, "account_unification") @@ -63,54 +66,110 @@ def test_missing_required_config_fails_loudly(): "raw_value", ["0", "-1", "nan", "inf", "-inf", "not-a-number"], ) -def test_request_timeout_must_be_positive_and_finite(raw_value): +def test_request_timeout_must_be_positive_and_finite(raw_value: str) -> None: + """Nonpositive or nonfinite request timeouts fail startup.""" store = _config_store(request_timeout_seconds=raw_value) with pytest.raises(RuntimeError, match="request_timeout_seconds"): load_service_config(store, "account_unification") -@pytest.mark.parametrize( - "raw_value", - ["-1", "nan", "inf", "-inf", "not-a-number"], -) -def test_janitor_interval_must_be_non_negative_and_finite(raw_value): - store = _config_store(password_janitor_interval_seconds=raw_value) - with pytest.raises(RuntimeError, match="password_janitor_interval_seconds"): +def test_registration_token_must_not_equal_operator_token() -> None: + """Product signup credentials cannot acquire operator authority.""" + store = _config_store(registration_api_token="operator-token") + with pytest.raises(RuntimeError, match="registration_api_token"): + load_service_config(store, "account_unification") + + +def test_registration_requires_complete_action_email_config() -> None: + """Enabling signup requires an RP, redirect URI, and action-link lifespan.""" + store = _config_store(registration_api_token="registration-token") + with pytest.raises(RuntimeError, match="registration_client_id"): load_service_config(store, "account_unification") -def test_zero_janitor_interval_disables_periodic_task_cleanly(): - store = _config_store(password_janitor_interval_seconds="0") +def test_registration_action_email_config_loads() -> None: + """A complete passwordless enrollment configuration loads atomically.""" + store = _config_store( + registration_api_token="registration-token", + registration_client_id="naruon-web", + registration_redirect_uri="https://naruon.example/auth/passkey-complete", + registration_action_lifespan_seconds="900", + ) + config = load_service_config(store, "account_unification") - assert config.password_janitor_interval_seconds == 0.0 + assert config.registration_client_id == "naruon-web" + assert config.registration_redirect_uri == ( + "https://naruon.example/auth/passkey-complete" + ) + assert config.registration_action_lifespan_seconds == 900 -def test_registration_token_must_not_equal_operator_token(): - store = _config_store(registration_api_token="operator-token") - with pytest.raises(RuntimeError, match="registration_api_token"): + +@pytest.mark.parametrize( + "redirect_uri", + [ + "http://naruon.example/callback", + "javascript:alert(1)", + "//naruon.example/callback", + "https:///missing-host", + ], +) +def test_registration_redirect_uri_requires_absolute_https( + redirect_uri: str, +) -> None: + """Action emails cannot redirect to non-HTTPS or hostless locations.""" + store = _config_store( + registration_api_token="registration-token", + registration_client_id="naruon-web", + registration_redirect_uri=redirect_uri, + registration_action_lifespan_seconds="900", + ) + with pytest.raises(RuntimeError, match="registration_redirect_uri"): + load_service_config(store, "account_unification") + + +@pytest.mark.parametrize( + "raw_value", + ["0", "-1", "1.5", "nan", "inf", "not-a-number"], +) +def test_registration_action_lifespan_must_be_positive_integer( + raw_value: str, +) -> None: + """Keycloak action-email lifespan must be a bounded integer duration.""" + store = _config_store( + registration_api_token="registration-token", + registration_client_id="naruon-web", + registration_redirect_uri="https://naruon.example/auth/passkey-complete", + registration_action_lifespan_seconds=raw_value, + ) + with pytest.raises(RuntimeError, match="registration_action_lifespan_seconds"): load_service_config(store, "account_unification") @pytest.mark.parametrize("raw_value", ["true", "1", "yes", "on"]) -def test_unverified_email_link_policy_cannot_be_enabled(raw_value): +def test_unverified_email_link_policy_cannot_be_enabled(raw_value: str) -> None: + """An unverified-email link policy is rejected even when explicitly set.""" store = _config_store(allow_unverified_email_link=raw_value) with pytest.raises(RuntimeError, match="allow_unverified_email_link"): load_service_config(store, "account_unification") -def test_invalid_boolean_text_fails_loudly(): +def test_invalid_boolean_text_fails_loudly() -> None: + """Ambiguous boolean configuration cannot silently become false.""" store = _config_store(allow_unverified_email_link="definitely") with pytest.raises(RuntimeError, match="allow_unverified_email_link"): load_service_config(store, "account_unification") -def test_only_implemented_merge_conflict_policy_is_accepted(): +def test_only_implemented_merge_conflict_policy_is_accepted() -> None: + """Unknown conflict policies cannot claim behavior the service lacks.""" store = _config_store(merge_conflict_policy="duplicate_wins") with pytest.raises(RuntimeError, match="merge_conflict_policy"): load_service_config(store, "account_unification") -def test_bootstrap_points_at_sqlite_store(tmp_path): +def test_bootstrap_points_at_sqlite_store(tmp_path) -> None: + """The bootstrap descriptor opens the configured SQLite namespace.""" db = tmp_path / "store.db" with closing(SqliteKvStore(str(db))) as seed: seed.put("account_unification", "keycloak_server_url", "http://kc") @@ -134,7 +193,8 @@ def test_bootstrap_points_at_sqlite_store(tmp_path): assert config.keycloak_realm == "cwl" -def test_unsupported_standalone_backend_fails_loudly(): +def test_unsupported_standalone_backend_fails_loudly() -> None: + """A deployment cannot select an adapter absent from its image.""" descriptor = BootstrapDescriptor( backend="postgres", namespace="account_unification", From e8ee1f91862a547eb08a897cc6d78b81ca68ac68 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:38:04 +0900 Subject: [PATCH 058/104] feat(registration): validate passwordless action-email settings --- services/account_unification/app/config.py | 102 +++++++++++++++++++-- 1 file changed, 94 insertions(+), 8 deletions(-) diff --git a/services/account_unification/app/config.py b/services/account_unification/app/config.py index a0d8859..ecf9fe6 100644 --- a/services/account_unification/app/config.py +++ b/services/account_unification/app/config.py @@ -8,6 +8,7 @@ import math from dataclasses import dataclass +from urllib.parse import urlsplit from .kv_store import KvStore @@ -22,8 +23,14 @@ KEY_REQUEST_TIMEOUT_SECONDS = "request_timeout_seconds" KEY_OPERATOR_API_TOKEN = "operator_api_token" KEY_REGISTRATION_API_TOKEN = "registration_api_token" +KEY_REGISTRATION_CLIENT_ID = "registration_client_id" +KEY_REGISTRATION_REDIRECT_URI = "registration_redirect_uri" +KEY_REGISTRATION_ACTION_LIFESPAN_SECONDS = ( + "registration_action_lifespan_seconds" +) KEY_AUDIT_DATABASE_PATH = "audit_database_path" -KEY_PASSWORD_JANITOR_INTERVAL_SECONDS = "password_janitor_interval_seconds" + +MAX_REGISTRATION_ACTION_LIFESPAN_SECONDS = 3600 @dataclass(frozen=True) @@ -40,8 +47,9 @@ class ServiceConfig: # bearer credentials so relying products never acquire operator authority. operator_api_token: str registration_api_token: str | None = None - # Zero disables the periodic task; a manual janitor endpoint remains. - password_janitor_interval_seconds: float = 300.0 + registration_client_id: str | None = None + registration_redirect_uri: str | None = None + registration_action_lifespan_seconds: int = 900 audit_database_path: str = "/var/lib/account-unification/audit.db" merge_conflict_policy: str = "survivor_wins" # This is an invariant, not a deployer-selectable feature. The field remains @@ -100,6 +108,76 @@ def _as_finite_float( return value +def _as_positive_int( + raw: str | None, + default: int, + *, + entry_key: str, + maximum: int, +) -> int: + """Parse a positive bounded integer configuration value.""" + candidate = str(default) if raw is None or raw == "" else raw.strip() + try: + value = int(candidate) + except (TypeError, ValueError) as exc: + raise RuntimeError( + f"config '{entry_key}' must be a positive integer" + ) from exc + if str(value) != candidate or value <= 0 or value > maximum: + raise RuntimeError( + f"config '{entry_key}' must be a positive integer at or below " + f"{maximum}" + ) + return value + + +def _validated_https_uri(raw_uri: str, *, entry_key: str) -> str: + """Return an absolute HTTPS URI or fail startup.""" + candidate = raw_uri.strip() + parsed = urlsplit(candidate) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.fragment + ): + raise RuntimeError( + f"config '{entry_key}' must be an absolute HTTPS URI without " + "credentials or fragments" + ) + return candidate + + +def _registration_settings( + store: KvStore, + namespace: str, + registration_api_token: str | None, +) -> tuple[str | None, str | None, int]: + """Load all-or-nothing action-email registration settings.""" + if registration_api_token is None: + return None, None, 900 + client_id = _require(store, namespace, KEY_REGISTRATION_CLIENT_ID) + if len(client_id) > 255 or any( + character.isspace() or ord(character) < 0x20 + for character in client_id + ): + raise RuntimeError( + f"config '{KEY_REGISTRATION_CLIENT_ID}' must be one bounded client ID" + ) + redirect_uri = _validated_https_uri( + _require(store, namespace, KEY_REGISTRATION_REDIRECT_URI), + entry_key=KEY_REGISTRATION_REDIRECT_URI, + ) + lifespan_seconds = _as_positive_int( + store.get(namespace, KEY_REGISTRATION_ACTION_LIFESPAN_SECONDS), + 900, + entry_key=KEY_REGISTRATION_ACTION_LIFESPAN_SECONDS, + maximum=MAX_REGISTRATION_ACTION_LIFESPAN_SECONDS, + ) + return client_id, redirect_uri, lifespan_seconds + + def load_service_config(store: KvStore, namespace: str) -> ServiceConfig: """Build and validate the :class:`ServiceConfig` from the KV store.""" operator_api_token = _require(store, namespace, KEY_OPERATOR_API_TOKEN) @@ -111,6 +189,15 @@ def load_service_config(store: KvStore, namespace: str) -> ServiceConfig: "config 'registration_api_token' must differ from " "'operator_api_token'" ) + ( + registration_client_id, + registration_redirect_uri, + registration_action_lifespan_seconds, + ) = _registration_settings( + store, + namespace, + registration_api_token, + ) allow_unverified_email_link = _as_bool( store.get(namespace, KEY_ALLOW_UNVERIFIED_LINK), @@ -139,11 +226,10 @@ def load_service_config(store: KvStore, namespace: str) -> ServiceConfig: ), operator_api_token=operator_api_token, registration_api_token=registration_api_token, - password_janitor_interval_seconds=_as_finite_float( - store.get(namespace, KEY_PASSWORD_JANITOR_INTERVAL_SECONDS), - 300.0, - entry_key=KEY_PASSWORD_JANITOR_INTERVAL_SECONDS, - allow_zero=True, + registration_client_id=registration_client_id, + registration_redirect_uri=registration_redirect_uri, + registration_action_lifespan_seconds=( + registration_action_lifespan_seconds ), audit_database_path=( store.get(namespace, KEY_AUDIT_DATABASE_PATH) From b02e5077539f1513c8974bac68acb9cd46af351e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:39:51 +0900 Subject: [PATCH 059/104] feat(registration): send one-time passkey enrollment actions --- .../app/product_keycloak_client.py | 163 +++++++----------- 1 file changed, 66 insertions(+), 97 deletions(-) diff --git a/services/account_unification/app/product_keycloak_client.py b/services/account_unification/app/product_keycloak_client.py index 41b3bc1..0056b36 100644 --- a/services/account_unification/app/product_keycloak_client.py +++ b/services/account_unification/app/product_keycloak_client.py @@ -1,27 +1,20 @@ """Product-facing extensions for the Keycloak Admin REST API client. The core merge/SCIM engine depends only on :class:`AdminApi`. Product features -such as self-registration and runtime federation require a wider surface. This -module keeps those concerns modular while preserving the same authenticated -transport, path-safety, and one-shot token refresh behavior. +such as passwordless registration and runtime federation require a wider +surface. This module keeps those concerns modular while preserving the same +authenticated transport, path-safety, and one-shot token refresh behavior. """ from __future__ import annotations from collections.abc import Callable from typing import Protocol +from urllib.parse import urlsplit import httpx -from .identifiers import ( - InvalidIdentifierError, - validate_path_segment, -) -from .keycloak_client import ( - AdminApi, - HttpAdminApi, - _parse_user, - _to_keycloak_user, -) +from .identifiers import InvalidIdentifierError, validate_path_segment +from .keycloak_client import AdminApi, HttpAdminApi, _to_keycloak_user from .models import ( FederatedIdentity, GroupMembership, @@ -42,9 +35,7 @@ ("users", None, "role-mappings", "clients", None), ("users", None, "groups"), ("users", None, "groups", None), - ("users", None, "reset-password"), - ("users", None, "credentials"), - ("users", None, "credentials", None), + ("users", None, "execute-actions-email"), ("identity-provider", "instances"), ("identity-provider", "instances", None), ) @@ -53,26 +44,16 @@ class ProductAdminApi(AdminApi, Protocol): """Extended Keycloak contract used by registration and federation modules.""" - def list_users(self, first_result: int, max_results: int) -> list[UserAccount]: - """Return one page of realm users.""" - ... - - def reset_user_password(self, user_id: str, password_value: str) -> None: - """Set a non-temporary password credential on a user.""" - ... - - def set_user_required_actions( - self, user_id: str, action_aliases: list[str] + def send_execute_actions_email( + self, + user_id: str, + action_aliases: list[str], + *, + client_id: str, + redirect_uri: str, + lifespan_seconds: int, ) -> None: - """Replace the pending required actions on a user.""" - ... - - def list_user_credentials(self, user_id: str) -> list[dict]: - """List stored credential representations for a user.""" - ... - - def delete_user_credential(self, user_id: str, credential_id: str) -> None: - """Delete one stored credential from a user.""" + """Send a one-time email link for verified passkey enrollment.""" ... def delete_user(self, user_id: str) -> None: @@ -152,10 +133,7 @@ def add_federated_identity( ) -> None: """Attach an external identity using validated path segments.""" safe_user_id = self._safe_segment(user_id, "user_id") - self._safe_segment( - identity.identity_provider, - "identity_provider", - ) + self._safe_segment(identity.identity_provider, "identity_provider") super().add_federated_identity(safe_user_id, identity) def remove_federated_identity( @@ -201,8 +179,7 @@ def add_group_membership( """Add a group membership using validated path segments.""" self._safe_segment(group.group_id, "group_id") super().add_group_membership( - self._safe_segment(user_id, "user_id"), - group, + self._safe_segment(user_id, "user_id"), group ) def remove_group_membership( @@ -211,8 +188,7 @@ def remove_group_membership( """Remove a group membership using validated path segments.""" self._safe_segment(group.group_id, "group_id") super().remove_group_membership( - self._safe_segment(user_id, "user_id"), - group, + self._safe_segment(user_id, "user_id"), group ) def deactivate_user(self, user_id: str) -> None: @@ -222,16 +198,13 @@ def deactivate_user(self, user_id: str) -> None: def set_user_attribute(self, user_id: str, key: str, value: str) -> None: """Set a user attribute after validating the user id.""" super().set_user_attribute( - self._safe_segment(user_id, "user_id"), - key, - value, + self._safe_segment(user_id, "user_id"), key, value ) def get_user_attribute(self, user_id: str, key: str) -> str | None: """Read a user attribute after validating the user id.""" return super().get_user_attribute( - self._safe_segment(user_id, "user_id"), - key, + self._safe_segment(user_id, "user_id"), key ) # -- guarded transport ------------------------------------------------- @@ -357,61 +330,60 @@ def create_user(self, user: UserAccount) -> str: if location: created_user_id = location.rstrip("/").rsplit("/", 1)[-1] return validate_path_segment( - created_user_id, - field_name="created_user_id", + created_user_id, field_name="created_user_id" ) found = self.find_user_by_username(user.user_name or "") if found is None: return "" return validate_path_segment( - found.user_id, - field_name="created_user_id", - ) - - def list_users(self, first_result: int, max_results: int) -> list[UserAccount]: - """Return one page of realm users.""" - data = self._get( - f"/admin/realms/{self._realm}/users", - params={"first": first_result, "max": max_results}, + found.user_id, field_name="created_user_id" ) - return [_parse_user(item) for item in data] - def reset_user_password(self, user_id: str, password_value: str) -> None: - """Set one non-temporary password credential.""" - safe_user_id = self._safe_segment(user_id, "user_id") - self._put( - f"/admin/realms/{self._realm}/users/{safe_user_id}/reset-password", - {"type": "password", "value": password_value, "temporary": False}, - ) - - def set_user_required_actions( - self, user_id: str, action_aliases: list[str] + def send_execute_actions_email( + self, + user_id: str, + action_aliases: list[str], + *, + client_id: str, + redirect_uri: str, + lifespan_seconds: int, ) -> None: - """Replace one user's pending required actions.""" - safe_user_id = self._safe_segment(user_id, "user_id") - self._put( - f"/admin/realms/{self._realm}/users/{safe_user_id}", - {"requiredActions": list(action_aliases)}, - ) - - def list_user_credentials(self, user_id: str) -> list[dict]: - """Return stored credential representations for one user.""" - safe_user_id = self._safe_segment(user_id, "user_id") - data = self._get( - f"/admin/realms/{self._realm}/users/{safe_user_id}/credentials" - ) - return [item for item in data if isinstance(item, dict)] - - def delete_user_credential(self, user_id: str, credential_id: str) -> None: - """Delete one stored credential from one user.""" + """Send a bounded one-time email for verification and passkey setup.""" safe_user_id = self._safe_segment(user_id, "user_id") - safe_credential_id = self._safe_segment( - credential_id, - "credential_id", + safe_client_id = self._safe_segment(client_id, "client_id") + parsed_redirect = urlsplit(redirect_uri) + if ( + parsed_redirect.scheme != "https" + or not parsed_redirect.hostname + or parsed_redirect.username is not None + or parsed_redirect.password is not None + or parsed_redirect.fragment + ): + raise ValueError("redirect_uri must be an absolute HTTPS URI") + if lifespan_seconds <= 0: + raise ValueError("lifespan_seconds must be positive") + if not action_aliases or any( + not alias + or len(alias) > 128 + or any(ord(character) < 0x20 for character in alias) + for alias in action_aliases + ): + raise ValueError("action_aliases must contain bounded action names") + path = self._guard_path( + f"/admin/realms/{self._realm}/users/{safe_user_id}/" + "execute-actions-email" ) - self._delete( - f"/admin/realms/{self._realm}/users/{safe_user_id}/credentials/" - f"{safe_credential_id}" + self._send_with_reauth( + lambda: self._client.put( + path, + params={ + "client_id": safe_client_id, + "redirect_uri": redirect_uri, + "lifespan": lifespan_seconds, + }, + json=list(action_aliases), + headers=self._auth_header(), + ) ) def delete_user(self, user_id: str) -> None: @@ -435,10 +407,7 @@ def get_identity_provider(self, provider_alias: str) -> dict | None: def create_identity_provider(self, provider_payload: dict) -> None: """Create one Keycloak identity-provider instance.""" - self._safe_segment( - provider_payload.get("alias"), - "provider_alias", - ) + self._safe_segment(provider_payload.get("alias"), "provider_alias") self._post( f"/admin/realms/{self._realm}/identity-provider/instances", provider_payload, From f38fdea68ab132df7697c8841d1b0a2e79c16000 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:40:13 +0900 Subject: [PATCH 060/104] test(mock): record passwordless enrollment action emails --- .../tests/mock_product_keycloak.py | 109 ++++-------------- 1 file changed, 25 insertions(+), 84 deletions(-) diff --git a/services/account_unification/tests/mock_product_keycloak.py b/services/account_unification/tests/mock_product_keycloak.py index dd8c5eb..2557400 100644 --- a/services/account_unification/tests/mock_product_keycloak.py +++ b/services/account_unification/tests/mock_product_keycloak.py @@ -11,62 +11,27 @@ def __init__(self) -> None: """Create empty product-specific stores.""" super().__init__() self.identity_providers: dict[str, dict] = {} - self.credentials: dict[str, list[dict]] = {} - self.required_actions: dict[str, list[str]] = {} + self.action_emails: dict[str, dict] = {} - def list_users(self, first_result: int, max_results: int): - """Return one stable page of users.""" - self.calls.append(f"list_users:{first_result}:{max_results}") - ordered = list(self.users.values()) - return [ - user.model_copy(deep=True) - for user in ordered[first_result : first_result + max_results] - ] - - def reset_user_password( - self, user_id: str, password_value: str - ) -> None: - """Replace one user's password credential without storing its value.""" - self.calls.append(f"reset_user_password:{user_id}") - if user_id not in self.users: - raise KeyError(user_id) - entries = self.credentials.setdefault(user_id, []) - entries[:] = [ - item for item in entries - if item.get("type") != "password" - ] - entries.append( - {"id": f"cred-pw-{user_id}", "type": "password"} - ) - - def set_user_required_actions( - self, user_id: str, action_aliases: list[str] + def send_execute_actions_email( + self, + user_id: str, + action_aliases: list[str], + *, + client_id: str, + redirect_uri: str, + lifespan_seconds: int, ) -> None: - """Replace one user's pending required actions.""" - self.calls.append(f"set_user_required_actions:{user_id}") + """Record one verification and passkey-enrollment email request.""" + self.calls.append(f"send_execute_actions_email:{user_id}") if user_id not in self.users: raise KeyError(user_id) - self.required_actions[user_id] = list(action_aliases) - - def list_user_credentials(self, user_id: str) -> list[dict]: - """Return a copy of one user's stored credential metadata.""" - self.calls.append(f"list_user_credentials:{user_id}") - return [ - dict(item) for item in self.credentials.get(user_id, []) - ] - - def delete_user_credential( - self, user_id: str, credential_id: str - ) -> None: - """Delete one stored credential by opaque id.""" - self.calls.append( - f"delete_user_credential:{user_id}:{credential_id}" - ) - entries = self.credentials.get(user_id, []) - entries[:] = [ - item for item in entries - if item.get("id") != credential_id - ] + self.action_emails[user_id] = { + "action_aliases": list(action_aliases), + "client_id": client_id, + "redirect_uri": redirect_uri, + "lifespan_seconds": lifespan_seconds, + } def delete_user(self, user_id: str) -> None: """Delete a newly created account during rollback.""" @@ -75,8 +40,7 @@ def delete_user(self, user_id: str) -> None: self.federated.pop(user_id, None) self.roles.pop(user_id, None) self.groups.pop(user_id, None) - self.credentials.pop(user_id, None) - self.required_actions.pop(user_id, None) + self.action_emails.pop(user_id, None) self.deactivated.discard(user_id) for attribute in [ key for key in self.attributes if key[0] == user_id @@ -87,47 +51,24 @@ def get_identity_provider( self, provider_alias: str ) -> dict | None: """Return a defensive copy of one applied provider.""" - self.calls.append( - f"get_identity_provider:{provider_alias}" - ) + self.calls.append(f"get_identity_provider:{provider_alias}") provider = self.identity_providers.get(provider_alias) return dict(provider) if provider is not None else None - def create_identity_provider( - self, provider_payload: dict - ) -> None: + def create_identity_provider(self, provider_payload: dict) -> None: """Create one applied identity provider.""" alias = provider_payload["alias"] - self.calls.append( - f"create_identity_provider:{alias}" - ) + self.calls.append(f"create_identity_provider:{alias}") self.identity_providers[alias] = dict(provider_payload) def update_identity_provider( self, provider_alias: str, provider_payload: dict ) -> None: """Replace one applied identity provider.""" - self.calls.append( - f"update_identity_provider:{provider_alias}" - ) - self.identity_providers[provider_alias] = dict( - provider_payload - ) + self.calls.append(f"update_identity_provider:{provider_alias}") + self.identity_providers[provider_alias] = dict(provider_payload) - def delete_identity_provider( - self, provider_alias: str - ) -> None: + def delete_identity_provider(self, provider_alias: str) -> None: """Delete one applied identity provider.""" - self.calls.append( - f"delete_identity_provider:{provider_alias}" - ) + self.calls.append(f"delete_identity_provider:{provider_alias}") self.identity_providers.pop(provider_alias, None) - - def add_test_passkey(self, user_id: str) -> None: - """Mark a user as holding a passwordless WebAuthn credential.""" - self.credentials.setdefault(user_id, []).append( - { - "id": f"cred-wa-{user_id}", - "type": "webauthn-passwordless", - } - ) From 874d3970757d294d8cbff6615f543e6f09cb5055 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:41:08 +0900 Subject: [PATCH 061/104] feat(registration): eliminate bound-flow bootstrap passwords --- .../account_unification/app/registration.py | 260 ++++++++---------- 1 file changed, 111 insertions(+), 149 deletions(-) diff --git a/services/account_unification/app/registration.py b/services/account_unification/app/registration.py index 1ce798d..86af145 100644 --- a/services/account_unification/app/registration.py +++ b/services/account_unification/app/registration.py @@ -1,9 +1,9 @@ -"""Headless self-registration and bootstrap-credential retirement. +"""Headless passwordless self-registration through one-time action email. First-party product backends submit accounts through a dedicated bearer-token -surface. The account starts with a bounded bootstrap password and a mandatory -passkey enrollment action. A janitor removes the password after passkey -enrollment, leaving the steady state passwordless. +surface. The service creates a password-free account, then asks Keycloak to send +a bounded verification and passkey-enrollment link. A failed email request +rolls the account back so no unusable orphan remains. """ from __future__ import annotations @@ -12,23 +12,19 @@ import threading import time +import httpx from fastapi import APIRouter, Depends, Header, HTTPException, Request -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from .models import UserAccount from .product_keycloak_client import ProductAdminApi -registration_router = APIRouter( - prefix="/registration", tags=["registration"] -) +registration_router = APIRouter(prefix="/registration", tags=["registration"]) +VERIFY_EMAIL_REQUIRED_ACTION = "VERIFY_EMAIL" PASSKEY_ENROLL_REQUIRED_ACTION = "webauthn-register-passwordless" -PASSWORD_CREDENTIAL_TYPE = "password" # noqa: S105 - credential type name -PASSKEY_CREDENTIAL_TYPE = "webauthn-passwordless" # noqa: S105 EMAIL_MAX_LENGTH = 254 -PASSWORD_MIN_LENGTH = 10 -PASSWORD_MAX_LENGTH = 128 NAME_MAX_LENGTH = 100 CONTROL_CHARACTER_PATTERN = re.compile(r"[\x00-\x1f\x7f]") _LOCAL_ATOM_PUNCTUATION = frozenset("!#$%&'*+-/=?^_`{|}~.") @@ -36,45 +32,26 @@ REGISTRATION_RATE_LIMIT_WINDOW_SECONDS = 300.0 REGISTRATION_RATE_LIMIT_MAX_ATTEMPTS = 30 _registration_attempt_lock = threading.Lock() -_registration_attempt_window_start = 0.0 -_registration_attempt_count = 0 - -JANITOR_PAGE_SIZE = 100 -JANITOR_MAX_PAGES = 50 +_registration_attempt_windows: dict[str, tuple[float, int]] = {} class RegistrationRequest(BaseModel): - """One self-registration submission from a product signup page.""" + """One password-free registration submission from a product signup page.""" - email_address: str = Field( - min_length=3, max_length=EMAIL_MAX_LENGTH - ) - initial_password: str = Field( - min_length=PASSWORD_MIN_LENGTH, - max_length=PASSWORD_MAX_LENGTH, - ) - first_name: str | None = Field( - default=None, max_length=NAME_MAX_LENGTH - ) - last_name: str | None = Field( - default=None, max_length=NAME_MAX_LENGTH - ) + model_config = ConfigDict(extra="forbid") + + email_address: str = Field(min_length=3, max_length=EMAIL_MAX_LENGTH) + first_name: str | None = Field(default=None, max_length=NAME_MAX_LENGTH) + last_name: str | None = Field(default=None, max_length=NAME_MAX_LENGTH) class RegistrationResult(BaseModel): - """Public outcome of a registration without internal details.""" + """Public outcome after a passkey-enrollment email has been accepted.""" account_id: str email_address: str -class JanitorResult(BaseModel): - """Outcome of one bounded bootstrap-credential janitor pass.""" - - scanned_users: int - removed_bootstrap_credentials: int - - def require_registration_token( request: Request, authorization: str | None = Header(default=None), @@ -94,9 +71,7 @@ def require_registration_token( ) presented = authorization[len("Bearer ") :].strip() if not hmac.compare_digest(presented, expected): - raise HTTPException( - status_code=403, detail="invalid registration token" - ) + raise HTTPException(status_code=403, detail="invalid registration token") registration_auth_dependency = Depends(require_registration_token) @@ -106,34 +81,64 @@ def get_admin_api(request: Request) -> ProductAdminApi: """Return the wired product Keycloak API from application state.""" api = getattr(request.app.state, "keycloak_api", None) if api is None: - raise HTTPException( - status_code=503, detail="keycloak api unavailable" - ) + raise HTTPException(status_code=503, detail="keycloak api unavailable") return api -def _record_registration_attempt() -> None: - """Enforce a bounded process-local fixed-window registration limit.""" - global _registration_attempt_window_start, _registration_attempt_count +def reset_rate_limit_state() -> None: + """Clear process-local registration counters for deterministic tests.""" + with _registration_attempt_lock: + _registration_attempt_windows.clear() + + +def _registration_client_key(request: Request) -> str: + """Return the direct peer address used for process-local throttling.""" + return request.client.host if request.client is not None else "unknown-client" + + +def _record_registration_attempt(client_key: str) -> None: + """Enforce an independent fixed-window registration limit per caller.""" now = time.monotonic() with _registration_attempt_lock: - if ( - now - _registration_attempt_window_start - > REGISTRATION_RATE_LIMIT_WINDOW_SECONDS - ): - _registration_attempt_window_start = now - _registration_attempt_count = 0 - _registration_attempt_count += 1 - if ( - _registration_attempt_count - > REGISTRATION_RATE_LIMIT_MAX_ATTEMPTS - ): + window_start, attempt_count = _registration_attempt_windows.get( + client_key, (now, 0) + ) + if now - window_start > REGISTRATION_RATE_LIMIT_WINDOW_SECONDS: + window_start, attempt_count = now, 0 + attempt_count += 1 + _registration_attempt_windows[client_key] = ( + window_start, + attempt_count, + ) + if attempt_count > REGISTRATION_RATE_LIMIT_MAX_ATTEMPTS: raise HTTPException( status_code=429, detail="registration temporarily rate limited", ) +def _registration_settings(request: Request) -> tuple[str, str, int]: + """Return complete passwordless enrollment settings or fail closed.""" + client_id = getattr(request.app.state, "registration_client_id", None) + redirect_uri = getattr(request.app.state, "registration_redirect_uri", None) + lifespan_seconds = getattr( + request.app.state, + "registration_action_lifespan_seconds", + None, + ) + if ( + not client_id + or not redirect_uri + or not isinstance(lifespan_seconds, int) + or lifespan_seconds <= 0 + ): + raise HTTPException( + status_code=503, + detail="registration enrollment unavailable", + ) + return client_id, redirect_uri, lifespan_seconds + + def _has_valid_email_shape(email_address: str) -> bool: """Return whether an email has bounded, non-ambiguous syntax.""" if email_address.count("@") != 1 or any( @@ -147,8 +152,7 @@ def _has_valid_email_shape(email_address: str) -> bool: or local_part.endswith(".") or ".." in local_part or not all( - character.isalnum() - or character in _LOCAL_ATOM_PUNCTUATION + character.isalnum() or character in _LOCAL_ATOM_PUNCTUATION for character in local_part ) ): @@ -166,10 +170,7 @@ def _has_valid_email_shape(email_address: str) -> bool: 1 <= len(label) <= 63 and not label.startswith("-") and not label.endswith("-") - and all( - character.isalnum() or character == "-" - for character in label - ) + and all(character.isalnum() or character == "-" for character in label) for label in labels ) @@ -182,9 +183,7 @@ def _validated_email(raw_email: str) -> str: or CONTROL_CHARACTER_PATTERN.search(email_address) or not _has_valid_email_shape(email_address) ): - raise HTTPException( - status_code=422, detail="invalid_email_address" - ) + raise HTTPException(status_code=422, detail="invalid_email_address") return email_address @@ -203,17 +202,22 @@ def _validated_name(raw_name: str | None) -> str | None: def _initialize_account( api: ProductAdminApi, account_id: str, - initial_password: str, + *, + client_id: str, + redirect_uri: str, + lifespan_seconds: int, ) -> None: - """Install the bootstrap credential and passkey enrollment action. - - A partial initialization is rolled back by deleting the newly created - account, preventing orphaned accounts that cannot complete first login. - """ + """Send verification/passkey actions or roll back the new account.""" try: - api.reset_user_password(account_id, initial_password) - api.set_user_required_actions( - account_id, [PASSKEY_ENROLL_REQUIRED_ACTION] + api.send_execute_actions_email( + account_id, + [ + VERIFY_EMAIL_REQUIRED_ACTION, + PASSKEY_ENROLL_REQUIRED_ACTION, + ], + client_id=client_id, + redirect_uri=redirect_uri, + lifespan_seconds=lifespan_seconds, ) except Exception as initialization_error: try: @@ -236,90 +240,48 @@ def _initialize_account( ) def register_account( request_body: RegistrationRequest, + request: Request, api: ProductAdminApi = Depends(get_admin_api), ) -> RegistrationResult: - """Create and initialize one Keycloak account atomically.""" - _record_registration_attempt() + """Create a password-free account and send one enrollment email.""" + client_id, redirect_uri, lifespan_seconds = _registration_settings(request) + _record_registration_attempt(_registration_client_key(request)) email_address = _validated_email(request_body.email_address) - if CONTROL_CHARACTER_PATTERN.search( - request_body.initial_password - ): - raise HTTPException( - status_code=422, detail="invalid_password" - ) if api.find_users_by_email(email_address): raise HTTPException( - status_code=409, detail="email_already_registered" + status_code=409, + detail="email_already_registered", ) - account_id = api.create_user( - UserAccount( - user_id="", - user_name=email_address, - email=email_address, - is_email_verified=False, - state="active", - first_name=_validated_name(request_body.first_name), - last_name=_validated_name(request_body.last_name), + try: + account_id = api.create_user( + UserAccount( + user_id="", + user_name=email_address, + email=email_address, + is_email_verified=False, + state="active", + first_name=_validated_name(request_body.first_name), + last_name=_validated_name(request_body.last_name), + ) ) - ) + except httpx.HTTPStatusError as error: + if error.response.status_code == 409: + raise HTTPException( + status_code=409, + detail="email_already_registered", + ) from error + raise if not account_id: - raise HTTPException( - status_code=502, detail="account_creation_failed" - ) + raise HTTPException(status_code=502, detail="account_creation_failed") _initialize_account( - api, account_id, request_body.initial_password + api, + account_id, + client_id=client_id, + redirect_uri=redirect_uri, + lifespan_seconds=lifespan_seconds, ) return RegistrationResult( account_id=account_id, email_address=email_address, ) - - -def revoke_bootstrap_passwords( - api: ProductAdminApi, -) -> JanitorResult: - """Delete bootstrap credentials from passkey-holding accounts.""" - scanned_users = 0 - removed_bootstrap_credentials = 0 - for page_index in range(JANITOR_MAX_PAGES): - users = api.list_users( - page_index * JANITOR_PAGE_SIZE, - JANITOR_PAGE_SIZE, - ) - if not users: - break - for user in users: - scanned_users += 1 - credentials = api.list_user_credentials(user.user_id) - credential_types = { - item.get("type") for item in credentials - } - if PASSKEY_CREDENTIAL_TYPE not in credential_types: - continue - for item in credentials: - if ( - item.get("type") == PASSWORD_CREDENTIAL_TYPE - and item.get("id") - ): - api.delete_user_credential( - user.user_id, item["id"] - ) - removed_bootstrap_credentials += 1 - if len(users) < JANITOR_PAGE_SIZE: - break - return JanitorResult( - scanned_users=scanned_users, - removed_bootstrap_credentials=removed_bootstrap_credentials, - ) - - -@registration_router.post( - "/password-janitor:run", - response_model=JanitorResult, -) -def run_password_janitor( - api: ProductAdminApi = Depends(get_admin_api), -) -> JanitorResult: - """Run one bounded janitor pass on demand.""" - return revoke_bootstrap_passwords(api) From d986f5a114ccf1c836aaa186388d94159dae46f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:41:43 +0900 Subject: [PATCH 062/104] refactor(lifecycle): remove password janitor and secure memory lock path --- services/account_unification/app/main.py | 96 +++++++++++------------- 1 file changed, 42 insertions(+), 54 deletions(-) diff --git a/services/account_unification/app/main.py b/services/account_unification/app/main.py index 16300de..28cc738 100644 --- a/services/account_unification/app/main.py +++ b/services/account_unification/app/main.py @@ -1,15 +1,14 @@ """FastAPI application factory, dependency wiring, and resource lifecycle. -Startup reads one bootstrap pointer, opens the KV/DB configuration store, builds -the Keycloak-backed services, and starts the bounded credential janitor. The -privileged routers are authenticated and path-validated; ``/healthz`` remains -open for orchestrator probes. +Startup reads one bootstrap pointer, opens the KV/DB configuration store, and +builds the Keycloak-backed services. Privileged routers are authenticated and +path-validated; ``/healthz`` remains open for orchestrator probes. """ from __future__ import annotations -import asyncio -import logging -from contextlib import asynccontextmanager, suppress +import os +import tempfile +from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI @@ -26,17 +25,11 @@ scim_path_security_dependency, ) from .product_keycloak_client import ProductHttpAdminApi -from .registration import ( - registration_auth_dependency, - registration_router, - revoke_bootstrap_passwords, -) +from .registration import registration_auth_dependency, registration_router from .scim import scim_router from .service import UnificationService from .user_locks import SqliteUserOperationLocks -logger = logging.getLogger(__name__) - def _ensure_parent_directory(database_path: str) -> None: """Create a filesystem parent for a persistent SQLite database path.""" @@ -47,6 +40,18 @@ def _ensure_parent_directory(database_path: str) -> None: ) +def _user_operation_lock_path(audit_database_path: str) -> tuple[str, bool]: + """Return a durable sidecar path or one secure temporary test path.""" + if audit_database_path != ":memory:": + return f"{audit_database_path}.user-operation-locks.sqlite3", False + descriptor, temporary_path = tempfile.mkstemp( + prefix="keyverse-user-operation-locks-", + suffix=".sqlite3", + ) + os.close(descriptor) + return temporary_path, True + + def build_service(app: FastAPI) -> None: """Wire all live service dependencies from the bootstrap configuration.""" descriptor = load_bootstrap_descriptor() @@ -62,9 +67,10 @@ def build_service(app: FastAPI) -> None: timeout_seconds=config.request_timeout_seconds, ) audit = AuditLogger(SqliteAuditSink(config.audit_database_path)) - user_operation_locks = SqliteUserOperationLocks( - f"{config.audit_database_path}.user-operation-locks.sqlite3" + lock_database_path, temporary_lock_database = _user_operation_lock_path( + config.audit_database_path ) + user_operation_locks = SqliteUserOperationLocks(lock_database_path) app.state.config_store = store app.state.unification_service = UnificationService( @@ -76,36 +82,19 @@ def build_service(app: FastAPI) -> None: app.state.audit_logger = audit app.state.keycloak_api = api app.state.user_operation_locks = user_operation_locks + app.state.user_operation_lock_database_path = lock_database_path + app.state.temporary_user_operation_lock_database = temporary_lock_database app.state.federation_service = FederationService(store, api) app.state.operator_api_token = config.operator_api_token app.state.registration_api_token = config.registration_api_token - app.state.password_janitor_interval_seconds = ( - config.password_janitor_interval_seconds + app.state.registration_client_id = config.registration_client_id + app.state.registration_redirect_uri = config.registration_redirect_uri + app.state.registration_action_lifespan_seconds = ( + config.registration_action_lifespan_seconds ) app.state.ready = True -async def _credential_janitor_loop( - app: FastAPI, interval_seconds: float -) -> None: - """Periodically remove bootstrap credentials from passkey accounts.""" - while True: - await asyncio.sleep(interval_seconds) - try: - result = await asyncio.to_thread( - revoke_bootstrap_passwords, app.state.keycloak_api - ) - if result.removed_bootstrap_credentials: - # Log only an aggregate count. No credential material, user ID, - # email address, or other account-linked value enters the log. - logger.info( - "credential janitor removed %d bootstrap credential(s)", - result.removed_bootstrap_credentials, - ) - except Exception: - logger.exception("credential janitor pass failed; will retry") - - def _close_resource(resource) -> None: """Close one optional resource that exposes a callable ``close`` method.""" close = getattr(resource, "close", None) @@ -113,33 +102,32 @@ def _close_resource(resource) -> None: close() +def _remove_temporary_lock_database(app: FastAPI) -> None: + """Remove the secure sidecar used only with an in-memory audit database.""" + if not getattr(app.state, "temporary_user_operation_lock_database", False): + return + lock_database_path = getattr( + app.state, + "user_operation_lock_database_path", + None, + ) + if lock_database_path: + Path(lock_database_path).unlink(missing_ok=True) + + @asynccontextmanager async def lifespan(app: FastAPI): """Build live dependencies and release them on application shutdown.""" app.state.ready = False build_service(app) - janitor_interval = getattr( - app.state, "password_janitor_interval_seconds", 0.0 - ) - janitor_task = ( - asyncio.create_task( - _credential_janitor_loop(app, janitor_interval), - name="credential-janitor", - ) - if janitor_interval > 0 - else None - ) try: yield finally: app.state.ready = False - if janitor_task is not None: - janitor_task.cancel() - with suppress(asyncio.CancelledError): - await janitor_task _close_resource(getattr(app.state, "keycloak_api", None)) _close_resource(getattr(app.state, "audit_logger", None)) _close_resource(getattr(app.state, "config_store", None)) + _remove_temporary_lock_database(app) def create_app(*, wire: bool = True) -> FastAPI: From 9c7f1cbecee1c18ac40e4f4c8685e03f6b3b1999 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:42:50 +0900 Subject: [PATCH 063/104] fix(realm): enforce passwordless flow and public token ceiling --- scripts/validate_realm.py | 186 +++++++++++++++----------------------- 1 file changed, 75 insertions(+), 111 deletions(-) diff --git a/scripts/validate_realm.py b/scripts/validate_realm.py index a5fe04f..f192e5b 100644 --- a/scripts/validate_realm.py +++ b/scripts/validate_realm.py @@ -4,23 +4,14 @@ Parses the realm JSON and asserts the ecosystem-policy invariants hold, so a broken realm export is caught in CI before it ever reaches Keycloak: - * valid JSON, realm named and enabled; - * a passwordless browser flow is bound and contains NO password authenticator - but DOES use the WebAuthn passwordless authenticator (passkey-first); - * self-service password registration + reset are OFF; - * NO external federation is committed: employer IdPs (ADFS) and LDAP/AD - sources are deployment DATA registered at runtime through the - account-unification service's /federation/identity-providers API - (KV/DB-backed source of truth), never realm code; - * an OIDC/OAuth2.1 RP client template and the account-unification service - account client exist; no committed client secret is a real value; - * Keycloak 26 import compatibility: no `$`-prefixed annotation keys anywhere - (RealmRepresentation rejects unknown fields); - * the `basic` client scope exists with the Subject (sub) mapper and is a - realm default — without it Keycloak 26 lightweight access tokens omit - `sub` and subject-authenticating RPs (naruon) reject every request; - * the concrete `naruon-web` public PKCE client exists and carries the - audience + role/org/workspace claims naruon's session contract requires. +* the named realm is enabled; +* 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; +* 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. Usage: python scripts/validate_realm.py [path-to-realm.json] Exit 0 = valid, 1 = invalid (prints the failing checks). @@ -39,6 +30,7 @@ } 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]: @@ -49,8 +41,12 @@ def _executions(realm: dict, alias: str) -> list[dict]: return [] -def _all_authenticators(realm: dict, alias: str, seen: set[str] | None = None) -> set[str]: - """Collect authenticator ids reachable from a flow, following subflows.""" +def _all_authenticators( + realm: dict, + alias: str, + seen: set[str] | None = None, +) -> set[str]: + """Collect authenticator IDs reachable from a flow, following subflows.""" seen = seen if seen is not None else set() if alias in seen: return set() @@ -65,34 +61,16 @@ def _all_authenticators(realm: dict, alias: str, seen: set[str] | None = None) - return found -def _bootstrap_form_violations(realm: dict, bootstrap_form: str) -> list[str]: - """Check every credential-form execution matches the bootstrap shape.""" - violations: list[str] = [] - for flow in realm.get("authenticationFlows", []): - executions = flow.get("authenticationExecutions", []) - for execution in executions: - if execution.get("authenticator") != bootstrap_form: - continue - passkey_sibling = next( - ( - sibling - for sibling in executions - if sibling.get("authenticator") == PASSKEY_AUTHENTICATOR - ), - None, - ) - if ( - execution.get("requirement") != "ALTERNATIVE" - or passkey_sibling is None - or passkey_sibling.get("requirement") != "ALTERNATIVE" - or passkey_sibling.get("priority", 0) >= execution.get("priority", 0) - ): - violations.append( - "the credential-form authenticator is only allowed as an " - "ALTERNATIVE sibling below the passkey authenticator " - f"(bootstrap shape) in flow '{flow.get('alias')}'" - ) - return violations +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]: @@ -104,7 +82,6 @@ def validate(realm: dict) -> list[str]: if not realm.get("enabled", False): errors.append("realm must be enabled") - # Passwordless-first browser flow. browser_flow = realm.get("browserFlow") if not browser_flow: errors.append("browserFlow must be set") @@ -112,53 +89,36 @@ def validate(realm: dict) -> list[str]: authenticators = _all_authenticators(realm, browser_flow) if not authenticators: errors.append(f"browserFlow '{browser_flow}' has no executions defined") - # The plain credential form is tolerated ONLY in the bootstrap shape: - # an ALTERNATIVE sibling of the passkey authenticator with lower - # priority, so it is offered solely to accounts that have not enrolled - # a passkey yet (the registration janitor then revokes it). Every - # other credential-form authenticator stays banned outright. - bootstrap_form = f"auth-{_CREDENTIAL_FACTOR}-form" - disallowed_credential_used = ( - authenticators & DISALLOWED_CREDENTIAL_AUTHENTICATORS - ) - {bootstrap_form} - if disallowed_credential_used: + if authenticators & DISALLOWED_CREDENTIAL_AUTHENTICATORS: errors.append( "browserFlow includes a disallowed credential-form authenticator; " "ecosystem policy requires passkeys" ) - if bootstrap_form in authenticators: - errors.extend(_bootstrap_form_violations(realm, bootstrap_form)) if PASSKEY_AUTHENTICATOR not in authenticators: errors.append( "browserFlow must include the passkey authenticator required by " "ecosystem policy" ) - # Self-registration is allowed, but only under the email-first passkey - # contract: the account identity is the email address, and the default - # webauthn-register-passwordless required action enrolls a passkey on the - # first session so the throwaway registration password never becomes a - # usable credential (the browser flow has no password authenticator). + # Signup is headless. Keycloak sends a one-time verification and WebAuthn + # required-action link; the bound browser flow remains passwordless. if realm.get("registrationAllowed", False): - if not realm.get("registrationEmailAsUsername", False): - errors.append( - "self-registration requires registrationEmailAsUsername so new " - "accounts keep the email-first identity contract" - ) - passkey_enrollment_is_default = any( - action.get("providerId") == "webauthn-register-passwordless" - and action.get("enabled", False) - and action.get("defaultAction", False) - for action in realm.get("requiredActions", []) + errors.append( + "IdP-hosted registration must remain disabled; use the headless " + "registration API" + ) + if not realm.get("registrationEmailAsUsername", False): + errors.append("registrationEmailAsUsername must remain true") + passkey_enrollment_is_available = any( + action.get("providerId") == "webauthn-register-passwordless" + and action.get("enabled", False) + for action in realm.get("requiredActions", []) + ) + if not passkey_enrollment_is_available: + errors.append( + "webauthn-register-passwordless must remain enabled for action-email " + "enrollment" ) - if not passkey_enrollment_is_default: - errors.append( - "self-registration requires the webauthn-register-passwordless " - "required action as an enabled default so every new account " - "enrolls a passkey" - ) - # verifyEmail without a mail server strands every new account on a - # verification screen whose email never arrives. if realm.get("verifyEmail", False) and not realm.get("smtpServer"): errors.append( "verifyEmail requires a realm smtpServer; configure SMTP or disable " @@ -167,26 +127,20 @@ def validate(realm: dict) -> list[str]: if realm.get("resetPasswordAllowed", False): errors.append("credential reset self-service must be false") - # External federation is runtime data, never realm code. Employer IdPs - # (ADFS) and LDAP/AD sources are registered through the account-unification - # service's /federation/identity-providers API, which persists desired - # state in the KV/DB store and converges Keycloak via the Admin REST API. - # Committing them here hardcodes employer specifics AND breaks bring-up: - # an enabled LDAP source with placeholder DNs fails every realm user - # operation (Invalid DN), and placeholder SAML URLs abort the import. if realm.get("identityProviders"): errors.append( "identityProviders must not be committed; register external IdPs at " "runtime via the federation registry API" ) - if realm.get("components", {}).get("org.keycloak.storage.UserStorageProvider"): + if realm.get("components", {}).get( + "org.keycloak.storage.UserStorageProvider" + ): errors.append( "user-storage federation must not be committed; register LDAP/AD " "sources at runtime via the federation registry API" ) - # Clients: RP template + service account, no committed real secret. - clients = {c.get("clientId"): c for c in realm.get("clients", [])} + 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: @@ -195,10 +149,11 @@ def validate(realm: dict) -> list[str]: 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") - svc = clients.get("account-unification-svc") - if svc is None: + + service_client = clients.get("account-unification-svc") + if service_client is None: errors.append("service-account client 'account-unification-svc' is missing") - elif not svc.get("serviceAccountsEnabled", False): + elif not service_client.get("serviceAccountsEnabled", False): errors.append("account-unification-svc must enable service accounts") for client_id, client in clients.items(): @@ -206,28 +161,23 @@ def validate(realm: dict) -> list[str]: if secret is not None and secret != SECRET_PLACEHOLDER: errors.append(f"client '{client_id}' commits a non-placeholder secret") - # Keycloak 26 import compatibility: RealmRepresentation rejects unknown - # fields, so `$`-annotation keys abort --import-realm and crash-loop the - # container. for key_path in _dollar_keys(realm): errors.append( f"'$'-annotation key '{key_path}' breaks Keycloak 26 realm import" ) - # Keycloak 26 lightweight tokens omit `sub` without the basic scope. - scopes = {s.get("name"): s for s in realm.get("clientScopes", [])} + scopes = {scope.get("name"): scope for scope in realm.get("clientScopes", [])} basic = scopes.get("basic") if basic is None: errors.append("client scope 'basic' is required (sub claim source)") elif not any( - m.get("protocolMapper") == "oidc-sub-mapper" - for m in basic.get("protocolMappers", []) + mapper.get("protocolMapper") == "oidc-sub-mapper" + for mapper in basic.get("protocolMappers", []) ): errors.append("client scope 'basic' must include the oidc-sub-mapper") if "basic" not in realm.get("defaultDefaultClientScopes", []): errors.append("'basic' must be a realm default client scope") - # The first concrete ecosystem RP: naruon. naruon = clients.get("naruon-web") if naruon is None: errors.append("concrete RP client 'naruon-web' is missing") @@ -238,15 +188,25 @@ def validate(realm: dict) -> list[str]: 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 = { - m.get("protocolMapper") for m in naruon.get("protocolMappers", []) + 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 = { - m.get("config", {}).get("claim.name") - for m in naruon.get("protocolMappers", []) - if m.get("protocolMapper") == "oidc-hardcoded-claim-mapper" + 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: @@ -261,7 +221,7 @@ def validate(realm: dict) -> list[str]: def _dollar_keys(node: object, prefix: str = "") -> list[str]: - """Collect every `$`-prefixed object key with its JSON path.""" + """Collect every ``$``-prefixed object key with its JSON path.""" found: list[str] = [] if isinstance(node, dict): for key, value in node.items(): @@ -277,7 +237,11 @@ def _dollar_keys(node: object, prefix: str = "") -> list[str]: def main(argv: list[str]) -> int: """Run realm validation as a command-line check.""" - path = Path(argv[1]) if len(argv) > 1 else Path("deploy/keycloak/realm-cwl.json") + path = ( + Path(argv[1]) + if len(argv) > 1 + else Path("deploy/keycloak/realm-cwl.json") + ) try: realm = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: @@ -292,7 +256,7 @@ def main(argv: list[str]) -> int: return 1 print( f"OK: {path} is a valid cwl-idp realm " - "(passkey-first, runtime federation, OIDC RPs)." + "(passwordless, runtime federation, OIDC RPs)." ) return 0 From e10badb5d4140bbcba0e96580062ccf0f921b277 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:43:47 +0900 Subject: [PATCH 064/104] fix(realm): remove password authenticator and shorten public tokens --- deploy/keycloak/realm-cwl.json | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/deploy/keycloak/realm-cwl.json b/deploy/keycloak/realm-cwl.json index d38c9ff..8d2018f 100644 --- a/deploy/keycloak/realm-cwl.json +++ b/deploy/keycloak/realm-cwl.json @@ -51,7 +51,7 @@ "authenticationFlows": [ { "alias": "browser-passwordless", - "description": "Passkey-first passwordless browser flow (no password authenticator).", + "description": "Passkey-only browser flow with no password authenticator.", "providerId": "basic-flow", "topLevel": true, "builtIn": false, @@ -84,7 +84,7 @@ }, { "alias": "browser-passwordless-forms", - "description": "Username identification then a passkey assertion, with a password form offered only to bootstrap accounts that have not enrolled a passkey yet.", + "description": "Username identification followed only by a passwordless WebAuthn assertion.", "providerId": "basic-flow", "topLevel": false, "builtIn": false, @@ -109,7 +109,7 @@ }, { "alias": "browser-passwordless-credentials", - "description": "Passkey (steady state) or the bootstrap password (only while the account has no passkey; the janitor revokes it after enrollment).", + "description": "Passwordless WebAuthn assertion; enrollment occurs through a one-time action-email link.", "providerId": "basic-flow", "topLevel": false, "builtIn": false, @@ -117,18 +117,10 @@ { "authenticator": "webauthn-authenticator-passwordless", "authenticatorFlow": false, - "requirement": "ALTERNATIVE", + "requirement": "REQUIRED", "priority": 10, "autheticatorFlow": false, "userSetupAllowed": false - }, - { - "authenticator": "auth-password-form", - "authenticatorFlow": false, - "requirement": "ALTERNATIVE", - "priority": 20, - "autheticatorFlow": false, - "userSetupAllowed": false } ] } @@ -270,7 +262,7 @@ "serviceAccountsEnabled": false, "secret": "__set_from_kv__", "redirectUris": [ - "https://naruon.example/auth/callback" + "https://rp.example.invalid/auth/callback" ], "webOrigins": [ "+" @@ -283,7 +275,7 @@ "optionalClientScopes": [], "attributes": { "pkce.code.challenge.method": "S256", - "post.logout.redirect.uris": "https://naruon.example/", + "post.logout.redirect.uris": "https://rp.example.invalid/", "access.token.lifespan": "300" }, "protocolMappers": [ @@ -313,7 +305,8 @@ "directAccessGrantsEnabled": false, "serviceAccountsEnabled": false, "redirectUris": [ - "https://naruon.example/auth/callback" + "https://naruon.example/auth/callback", + "https://naruon.example/auth/passkey-complete" ], "webOrigins": [ "https://naruon.example" @@ -327,7 +320,7 @@ "attributes": { "pkce.code.challenge.method": "S256", "post.logout.redirect.uris": "https://naruon.example/", - "access.token.lifespan": "43200" + "access.token.lifespan": "300" }, "protocolMappers": [ { From 8abb302818a9a12479b96f8adf2ded6a5e176891 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:44:59 +0900 Subject: [PATCH 065/104] test(keycloak): cover one-time passwordless action email --- .../tests/test_keycloak_client.py | 246 ++++++++---------- 1 file changed, 102 insertions(+), 144 deletions(-) diff --git a/services/account_unification/tests/test_keycloak_client.py b/services/account_unification/tests/test_keycloak_client.py index 88547c9..ccce08b 100644 --- a/services/account_unification/tests/test_keycloak_client.py +++ b/services/account_unification/tests/test_keycloak_client.py @@ -1,6 +1,8 @@ """Core and product Keycloak Admin REST API adapter tests.""" from __future__ import annotations +import json + import httpx import pytest @@ -12,10 +14,7 @@ RoleMapping, UserAccount, ) -from app.product_keycloak_client import ( - ProductAdminApi, - ProductHttpAdminApi, -) +from app.product_keycloak_client import ProductAdminApi, ProductHttpAdminApi from .mock_keycloak import MockKeycloakAdminApi from .mock_product_keycloak import MockProductKeycloakAdminApi @@ -31,18 +30,13 @@ def _protocol_methods(*protocols: type) -> set[str]: } -def test_protocol_methods_have_concrete_implementations(): +def test_protocol_methods_have_concrete_implementations() -> None: """Every declared contract method exists on both live and test adapters.""" core_methods = _protocol_methods(AdminApi) - product_methods = _protocol_methods( - AdminApi, ProductAdminApi - ) + product_methods = _protocol_methods(AdminApi, ProductAdminApi) assert core_methods assert product_methods - for implementation in ( - HttpAdminApi, - MockKeycloakAdminApi, - ): + for implementation in (HttpAdminApi, MockKeycloakAdminApi): assert [ name for name in sorted(core_methods) @@ -59,23 +53,17 @@ def test_protocol_methods_have_concrete_implementations(): ] == [] -def test_product_http_admin_api_maps_keycloak_calls(): +def test_product_http_admin_api_maps_keycloak_calls() -> None: """The product adapter maps core and extended methods correctly.""" calls: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: + """Return deterministic Keycloak responses and retain requests.""" calls.append(request) path = request.url.path - if path.endswith( - "/protocol/openid-connect/token" - ): - return httpx.Response( - 200, json={"access_token": "token-1"} - ) - if ( - request.method == "GET" - and path.endswith("/users/u1") - ): + if path.endswith("/protocol/openid-connect/token"): + return httpx.Response(200, json={"access_token": "token-1"}) + if request.method == "GET" and path.endswith("/users/u1"): return httpx.Response( 200, json={ @@ -92,10 +80,7 @@ def handler(request: httpx.Request) -> httpx.Response: }, }, ) - if ( - request.method == "GET" - and path.endswith("/users") - ): + if request.method == "GET" and path.endswith("/users"): return httpx.Response( 200, json=[ @@ -106,15 +91,11 @@ def handler(request: httpx.Request) -> httpx.Response: } ], ) - if ( - request.method == "POST" - and path.endswith("/users") - ): + if request.method == "POST" and path.endswith("/users"): return httpx.Response( 201, headers={ - "Location": - "http://kc/admin/realms/cwl/users/u2" + "Location": "http://kc/admin/realms/cwl/users/u2" }, ) if path.endswith("/federated-identity"): @@ -132,17 +113,12 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ - "realmMappings": [ - {"id": "realm-r", "name": "admin"} - ], + "realmMappings": [{"id": "realm-r", "name": "admin"}], "clientMappings": { "client-uuid": { "id": "client-uuid", "mappings": [ - { - "id": "client-r", - "name": "editor", - } + {"id": "client-r", "name": "editor"} ], } }, @@ -151,20 +127,7 @@ def handler(request: httpx.Request) -> httpx.Response: if path.endswith("/groups"): return httpx.Response( 200, - json=[ - { - "id": "g1", - "name": "Ops", - "path": "/Ops", - } - ], - ) - if path.endswith("/credentials"): - return httpx.Response( - 200, - json=[ - {"id": "cred-1", "type": "password"} - ], + json=[{"id": "g1", "name": "Ops", "path": "/Ops"}], ) if ( request.method == "GET" @@ -183,14 +146,10 @@ def handler(request: httpx.Request) -> httpx.Response: user = api.get_user("u1") assert user.external_id == "hr-1" - assert api.get_user_attribute( - "u1", "merged_into_user_id" - ) == "survivor" + assert api.get_user_attribute("u1", "merged_into_user_id") == "survivor" assert [ item.user_id - for item in api.find_users_by_email( - "jane@corp.test" - ) + for item in api.find_users_by_email("jane@corp.test") ] == ["u1"] found = api.find_user_by_username("Jane") assert found is not None @@ -202,10 +161,6 @@ def handler(request: httpx.Request) -> httpx.Response: email="new@corp.test", ) ) == "u2" - assert [ - item.user_id - for item in api.list_users(0, 100) - ] == ["u1"] assert api.list_federated_identities("u1") == [ FederatedIdentity( @@ -215,8 +170,7 @@ def handler(request: httpx.Request) -> httpx.Response: ) ] assert { - role.role_name - for role in api.list_role_mappings("u1") + role.role_name for role in api.list_role_mappings("u1") } == {"admin", "editor"} assert api.list_group_memberships("u1") == [ GroupMembership( @@ -225,12 +179,7 @@ def handler(request: httpx.Request) -> httpx.Response: group_path="/Ops", ) ] - assert api.list_user_credentials("u1") == [ - {"id": "cred-1", "type": "password"} - ] - assert api.get_identity_provider( - "missing-provider" - ) is None + assert api.get_identity_provider("missing-provider") is None api.replace_user("u1", user) api.add_federated_identity( @@ -242,10 +191,7 @@ def handler(request: httpx.Request) -> httpx.Response: ) api.remove_federated_identity("u1", "github") api.add_role_mapping( - "u1", - RoleMapping( - role_id="realm-r", role_name="admin" - ), + "u1", RoleMapping(role_id="realm-r", role_name="admin") ) api.add_role_mapping( "u1", @@ -256,10 +202,7 @@ def handler(request: httpx.Request) -> httpx.Response: ), ) api.remove_role_mapping( - "u1", - RoleMapping( - role_id="realm-r", role_name="admin" - ), + "u1", RoleMapping(role_id="realm-r", role_name="admin") ) api.remove_role_mapping( "u1", @@ -270,31 +213,21 @@ def handler(request: httpx.Request) -> httpx.Response: ), ) api.add_group_membership( - "u1", - GroupMembership( - group_id="g1", group_path="/Ops" - ), + "u1", GroupMembership(group_id="g1", group_path="/Ops") ) api.remove_group_membership( - "u1", - GroupMembership( - group_id="g1", group_path="/Ops" - ), + "u1", GroupMembership(group_id="g1", group_path="/Ops") ) api.deactivate_user("u1") - api.set_user_attribute( - "u1", "duplicate_of", "survivor" - ) - api.reset_user_password( - "u1", "bootstrap-password" - ) - api.set_user_required_actions( - "u1", ["webauthn-register-passwordless"] - ) - api.delete_user_credential("u1", "cred-1") - api.create_identity_provider( - {"alias": "employer-adfs"} + api.set_user_attribute("u1", "duplicate_of", "survivor") + api.send_execute_actions_email( + "u1", + ["VERIFY_EMAIL", "webauthn-register-passwordless"], + client_id="naruon-web", + redirect_uri="https://naruon.example/auth/passkey-complete", + lifespan_seconds=900, ) + api.create_identity_provider({"alias": "employer-adfs"}) api.update_identity_provider( "employer-adfs", {"alias": "employer-adfs", "enabled": False}, @@ -304,47 +237,45 @@ def handler(request: httpx.Request) -> httpx.Response: api.close() assert any( - call.headers.get("authorization") - == "Bearer token-1" + call.headers.get("authorization") == "Bearer token-1" for call in calls ) - assert any( - call.method == "DELETE" and call.content + assert any(call.method == "DELETE" and call.content for call in calls) + action_request = next( + call for call in calls + if call.url.path.endswith("/users/u1/execute-actions-email") ) + assert action_request.url.params["client_id"] == "naruon-web" + assert action_request.url.params["redirect_uri"] == ( + "https://naruon.example/auth/passkey-complete" + ) + assert action_request.url.params["lifespan"] == "900" + assert json.loads(action_request.content) == [ + "VERIFY_EMAIL", + "webauthn-register-passwordless", + ] -def test_product_adapter_reauthenticates_get_once(): +def test_product_adapter_reauthenticates_get_once() -> None: """An expired token is refreshed once before a GET succeeds.""" token_requests = 0 user_requests = 0 def handler(request: httpx.Request) -> httpx.Response: + """Reject the stale bearer token and accept the refreshed token.""" nonlocal token_requests, user_requests - if request.url.path.endswith( - "/protocol/openid-connect/token" - ): + if request.url.path.endswith("/protocol/openid-connect/token"): token_requests += 1 return httpx.Response( - 200, - json={ - "access_token": - f"token-{token_requests}" - }, + 200, json={"access_token": f"token-{token_requests}"} ) user_requests += 1 - if ( - request.headers.get("Authorization") - == "Bearer token-0" - ): + if request.headers.get("Authorization") == "Bearer token-0": return httpx.Response(401) return httpx.Response( 200, - json={ - "id": "u1", - "username": "jane", - "enabled": True, - }, + json={"id": "u1", "username": "jane", "enabled": True}, ) api = ProductHttpAdminApi( @@ -361,33 +292,23 @@ def handler(request: httpx.Request) -> httpx.Response: assert user_requests == 2 -def test_product_adapter_reauthenticates_create_once(): +def test_product_adapter_reauthenticates_create_once() -> None: """User creation also retries exactly once after an expired token.""" token_requests = 0 create_requests = 0 def handler(request: httpx.Request) -> httpx.Response: + """Reject one stale create request and accept the retry.""" nonlocal token_requests, create_requests - if request.url.path.endswith( - "/protocol/openid-connect/token" - ): + if request.url.path.endswith("/protocol/openid-connect/token"): token_requests += 1 - return httpx.Response( - 200, - json={"access_token": "token-1"}, - ) + return httpx.Response(200, json={"access_token": "token-1"}) create_requests += 1 - if ( - request.headers.get("Authorization") - == "Bearer token-0" - ): + if request.headers.get("Authorization") == "Bearer token-0": return httpx.Response(401) return httpx.Response( 201, - headers={ - "Location": - "http://kc/admin/realms/cwl/users/u2" - }, + headers={"Location": "http://kc/admin/realms/cwl/users/u2"}, ) api = ProductHttpAdminApi( @@ -421,14 +342,12 @@ def handler(request: httpx.Request) -> httpx.Response: "victim\x00other", ], ) -def test_product_adapter_rejects_unsafe_paths( - unsafe_user_id -): +def test_product_adapter_rejects_unsafe_paths(unsafe_user_id: str) -> None: """Unsafe identifiers fail before any HTTP request is emitted.""" + def fail_handler(request: httpx.Request) -> httpx.Response: - raise AssertionError( - "unsafe path must not reach the transport" - ) + """Fail if an unsafe value reaches the transport.""" + raise AssertionError("unsafe path must not reach the transport") api = ProductHttpAdminApi( "http://keycloak.test", @@ -441,3 +360,42 @@ def fail_handler(request: httpx.Request) -> httpx.Response: with pytest.raises(InvalidIdentifierError): api.get_user(unsafe_user_id) + + +@pytest.mark.parametrize( + ("action_aliases", "redirect_uri", "lifespan_seconds", "match"), + [ + ([], "https://naruon.example/complete", 900, "action_aliases"), + (["VERIFY_EMAIL"], "http://naruon.example/complete", 900, "redirect_uri"), + (["VERIFY_EMAIL"], "https://naruon.example/complete", 0, "lifespan"), + ], +) +def test_action_email_rejects_unsafe_configuration( + action_aliases: list[str], + redirect_uri: str, + lifespan_seconds: int, + match: str, +) -> None: + """Invalid action-email inputs fail before network transport.""" + + def fail_handler(request: httpx.Request) -> httpx.Response: + """Fail if invalid enrollment input reaches the transport.""" + raise AssertionError("invalid enrollment input reached transport") + + api = ProductHttpAdminApi( + "http://keycloak.test", + "cwl", + "svc", + "secret", + transport=httpx.MockTransport(fail_handler), + ) + api._token = "token" + + with pytest.raises(ValueError, match=match): + api.send_execute_actions_email( + "user-id", + action_aliases, + client_id="naruon-web", + redirect_uri=redirect_uri, + lifespan_seconds=lifespan_seconds, + ) From af0f1539a452c72ff3b689d39fc0e59c6ac26d13 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:45:49 +0900 Subject: [PATCH 066/104] chore(config): seed passwordless enrollment settings safely --- .../tools/seed_config_store.py | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/services/account_unification/tools/seed_config_store.py b/services/account_unification/tools/seed_config_store.py index cdcbe90..0d10603 100644 --- a/services/account_unification/tools/seed_config_store.py +++ b/services/account_unification/tools/seed_config_store.py @@ -21,8 +21,10 @@ KEY_KEYCLOAK_SERVER_URL, KEY_MERGE_CONFLICT_POLICY, KEY_OPERATOR_API_TOKEN, - KEY_PASSWORD_JANITOR_INTERVAL_SECONDS, + KEY_REGISTRATION_ACTION_LIFESPAN_SECONDS, KEY_REGISTRATION_API_TOKEN, + KEY_REGISTRATION_CLIENT_ID, + KEY_REGISTRATION_REDIRECT_URI, ) from app.kv_store import SqliteKvStore # noqa: E402 @@ -36,12 +38,8 @@ def _build_parser() -> argparse.ArgumentParser: "--db", default="../../deploy/bootstrap/idp_config_store.db", ) - parser.add_argument( - "--namespace", default="account_unification" - ) - parser.add_argument( - "--server-url", default="http://localhost:8080" - ) + parser.add_argument("--namespace", default="account_unification") + parser.add_argument("--server-url", default="http://localhost:8080") parser.add_argument("--realm", default="cwl") parser.add_argument( "--client-id", default="account-unification-svc" @@ -50,20 +48,26 @@ def _build_parser() -> argparse.ArgumentParser: "--client-secret", default="dev-placeholder-secret", ) - parser.add_argument( - "--operator-token", default="dev-operator-token" - ) + parser.add_argument("--operator-token", default="dev-operator-token") parser.add_argument( "--registration-token", default="dev-registration-token", ) parser.add_argument( - "--audit-database-path", - default="/tmp/keyverse-account-audit.sqlite3", + "--registration-client-id", + default="naruon-web", ) parser.add_argument( - "--password-janitor-interval-seconds", - default="300", + "--registration-redirect-uri", + default="https://naruon.example/auth/passkey-complete", + ) + parser.add_argument( + "--registration-action-lifespan-seconds", + default="900", + ) + parser.add_argument( + "--audit-database-path", + default="../../deploy/bootstrap/account_unification_audit.sqlite3", ) return parser @@ -81,22 +85,19 @@ def main() -> int: KEY_MERGE_CONFLICT_POLICY: "survivor_wins", KEY_ALLOW_UNVERIFIED_LINK: "false", KEY_OPERATOR_API_TOKEN: args.operator_token, - KEY_REGISTRATION_API_TOKEN: - args.registration_token, - KEY_AUDIT_DATABASE_PATH: - args.audit_database_path, - KEY_PASSWORD_JANITOR_INTERVAL_SECONDS: - args.password_janitor_interval_seconds, + KEY_REGISTRATION_API_TOKEN: args.registration_token, + KEY_REGISTRATION_CLIENT_ID: args.registration_client_id, + KEY_REGISTRATION_REDIRECT_URI: args.registration_redirect_uri, + KEY_REGISTRATION_ACTION_LIFESPAN_SECONDS: ( + args.registration_action_lifespan_seconds + ), + KEY_AUDIT_DATABASE_PATH: args.audit_database_path, } for entry_key, entry_value in entries.items(): - store.put( - args.namespace, entry_key, entry_value - ) + store.put(args.namespace, entry_key, entry_value) finally: store.close() - print( - f"seeded {args.db} namespace={args.namespace}" - ) + print(f"seeded {args.db} namespace={args.namespace}") return 0 From d0da6b512c33c431103b8977585ddaf6451f3c20 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:47:04 +0900 Subject: [PATCH 067/104] test(federation): specify fail-closed reconciliation semantics --- .../tests/test_federation.py | 209 ++++++++---------- 1 file changed, 87 insertions(+), 122 deletions(-) diff --git a/services/account_unification/tests/test_federation.py b/services/account_unification/tests/test_federation.py index af7df27..3413c06 100644 --- a/services/account_unification/tests/test_federation.py +++ b/services/account_unification/tests/test_federation.py @@ -4,6 +4,7 @@ import json import pytest +from fastapi import HTTPException from fastapi.testclient import TestClient from app.federation import ( @@ -25,9 +26,9 @@ def _employer_adfs_registration() -> IdentityProviderRegistration: trust_email=True, provider_config={ "entityId": "https://idp.example/realms/cwl", - "singleSignOnServiceUrl": - "https://sts.example/adfs/ls/", + "singleSignOnServiceUrl": "https://sts.example/adfs/ls/", "clientSecret": "federation-secret", + "unclassifiedValue": "must-not-leak", "validateSignature": "true", }, ) @@ -45,174 +46,143 @@ def federation(store, api) -> FederationService: return FederationService(store, api) -def test_put_persists_secret_but_redacts_status( - federation, store, api -): +def test_put_persists_secret_but_redacts_status(federation, store, api) -> None: """Secrets reach storage and Keycloak but never the status view.""" registration = _employer_adfs_registration() - status = federation.put_registration( - "employer-adfs", registration - ) + status = federation.put_registration("employer-adfs", registration) - raw = store.get( - FEDERATION_PROVIDER_NAMESPACE, - "employer-adfs", - ) + raw = store.get(FEDERATION_PROVIDER_NAMESPACE, "employer-adfs") assert raw is not None - assert json.loads(raw)["provider_config"][ - "clientSecret" - ] == "federation-secret" - assert api.identity_providers["employer-adfs"][ - "config" - ]["clientSecret"] == "federation-secret" - assert status.registration.provider_config[ - "clientSecret" - ] == "" - assert status.registration.provider_config[ - "singleSignOnServiceUrl" - ] == "https://sts.example/adfs/ls/" - - -def test_put_updates_existing_provider_in_place( - federation, api -): + stored_config = json.loads(raw)["provider_config"] + assert stored_config["clientSecret"] == "federation-secret" + assert stored_config["unclassifiedValue"] == "must-not-leak" + applied_config = api.identity_providers["employer-adfs"]["config"] + assert applied_config["clientSecret"] == "federation-secret" + assert applied_config["unclassifiedValue"] == "must-not-leak" + assert status.registration.provider_config["clientSecret"] == "" + assert status.registration.provider_config["unclassifiedValue"] == "" + assert status.registration.provider_config["singleSignOnServiceUrl"] == ( + "https://sts.example/adfs/ls/" + ) + + +def test_put_updates_existing_provider_in_place(federation, api) -> None: """Updating desired state replaces the applied provider.""" registration = _employer_adfs_registration() - federation.put_registration( - "employer-adfs", registration - ) + federation.put_registration("employer-adfs", registration) - updated = registration.model_copy( - update={"enabled": False} - ) - federation.put_registration( - "employer-adfs", updated - ) + updated = registration.model_copy(update={"enabled": False}) + federation.put_registration("employer-adfs", updated) - assert api.identity_providers["employer-adfs"][ - "enabled" - ] is False + assert api.identity_providers["employer-adfs"]["enabled"] is False assert any( - call.startswith( - "update_identity_provider:employer-adfs" - ) + call.startswith("update_identity_provider:employer-adfs") for call in api.calls ) -def test_apply_all_reconverges_after_realm_rebuild( - federation, api -): +def test_put_retains_desired_state_when_keycloak_is_unavailable( + federation, store, api, monkeypatch +) -> None: + """A failed convergence is explicit and remains retryable from stored state.""" + + def fail_create(*args, **kwargs) -> None: + """Simulate a temporarily unavailable Keycloak Admin REST API.""" + raise RuntimeError("keycloak unavailable") + + monkeypatch.setattr(api, "create_identity_provider", fail_create) + + status = federation.put_registration( + "employer-adfs", _employer_adfs_registration() + ) + + assert status.applied_to_keycloak is False + assert store.get(FEDERATION_PROVIDER_NAMESPACE, "employer-adfs") is not None + + +def test_apply_all_reconverges_after_realm_rebuild(federation, api) -> None: """Stored desired state recreates providers after realm loss.""" federation.put_registration( - "employer-adfs", - _employer_adfs_registration(), + "employer-adfs", _employer_adfs_registration() ) api.identity_providers.clear() statuses = federation.apply_all() assert [ - status.registration.provider_alias - for status in statuses + status.registration.provider_alias for status in statuses ] == ["employer-adfs"] assert statuses[0].applied_to_keycloak is True assert "employer-adfs" in api.identity_providers -def test_delete_removes_keycloak_and_store( - federation, store, api -): +def test_delete_removes_keycloak_and_store(federation, store, api) -> None: """Deletion removes both applied and desired provider state.""" federation.put_registration( - "employer-adfs", - _employer_adfs_registration(), + "employer-adfs", _employer_adfs_registration() ) federation.delete_registration("employer-adfs") - assert store.get( - FEDERATION_PROVIDER_NAMESPACE, - "employer-adfs", - ) is None + assert store.get(FEDERATION_PROVIDER_NAMESPACE, "employer-adfs") is None assert "employer-adfs" not in api.identity_providers -def test_alias_provider_and_config_bounds_are_enforced( - federation -): +def test_alias_provider_and_config_bounds_are_enforced(federation) -> None: """Malformed aliases, providers, and oversized config fail closed.""" registration = _employer_adfs_registration() - with pytest.raises(Exception) as mismatch: - federation.put_registration( - "other-alias", registration - ) - assert getattr( - mismatch.value, "status_code", None - ) == 400 + with pytest.raises(HTTPException) as mismatch: + federation.put_registration("other-alias", registration) + assert mismatch.value.status_code == 400 bad_alias = registration.model_copy( update={"provider_alias": "Bad Alias!"} ) - with pytest.raises(Exception) as invalid_alias: - federation.put_registration( - "Bad Alias!", bad_alias - ) - assert getattr( - invalid_alias.value, "status_code", None - ) == 400 + with pytest.raises(HTTPException) as invalid_alias: + federation.put_registration("Bad Alias!", bad_alias) + assert invalid_alias.value.status_code == 400 - bad_provider = registration.model_copy( - update={"provider_id": "ws-fed"} + unicode_alias = registration.model_copy( + update={"provider_alias": "employer-аdfs"} ) - with pytest.raises(Exception) as invalid_provider: - federation.put_registration( - "employer-adfs", bad_provider - ) - assert getattr( - invalid_provider.value, "status_code", None - ) == 400 + with pytest.raises(HTTPException) as non_ascii_alias: + federation.put_registration("employer-аdfs", unicode_alias) + assert non_ascii_alias.value.status_code == 400 + + bad_provider = registration.model_copy(update={"provider_id": "ws-fed"}) + with pytest.raises(HTTPException) as invalid_provider: + federation.put_registration("employer-adfs", bad_provider) + assert invalid_provider.value.status_code == 400 too_many = registration.model_copy( update={ "provider_config": { - f"entry{index}": "value" - for index in range(65) + f"entry{index}": "value" for index in range(65) } } ) - with pytest.raises(Exception) as oversized: - federation.put_registration( - "employer-adfs", too_many - ) - assert getattr( - oversized.value, "status_code", None - ) == 400 + with pytest.raises(HTTPException) as oversized: + federation.put_registration("employer-adfs", too_many) + assert oversized.value.status_code == 400 def test_http_surface_never_echoes_provider_secret( - api, auth_header -): - """PUT, list, and get responses redact credential values.""" + api, auth_header, operator_token +) -> None: + """PUT, list, and get responses redact credential and unknown values.""" app = create_app(wire=False) - app.state.federation_service = FederationService( - InMemoryKvStore(), api - ) - app.state.operator_api_token = "test-operator-token" + app.state.federation_service = FederationService(InMemoryKvStore(), api) + app.state.operator_api_token = operator_token body = _employer_adfs_registration().model_dump() - with TestClient( - app, headers=auth_header - ) as client: + with TestClient(app, headers=auth_header) as client: put_response = client.put( "/federation/identity-providers/employer-adfs", json=body, ) - list_response = client.get( - "/federation/identity-providers" - ) + list_response = client.get("/federation/identity-providers") get_response = client.get( "/federation/identity-providers/employer-adfs" ) @@ -224,19 +194,14 @@ def test_http_surface_never_echoes_provider_secret( ) assert put_response.status_code == 200 - assert put_response.json()["registration"][ - "provider_config" - ]["clientSecret"] == "" - assert list_response.json()[0]["registration"][ - "provider_config" - ]["clientSecret"] == "" - assert get_response.json()["registration"][ - "provider_config" - ]["clientSecret"] == "" - assert "federation-secret" not in ( - put_response.text - + list_response.text - + get_response.text - ) + put_config = put_response.json()["registration"]["provider_config"] + list_config = list_response.json()[0]["registration"]["provider_config"] + get_config = get_response.json()["registration"]["provider_config"] + for response_config in (put_config, list_config, get_config): + assert response_config["clientSecret"] == "" + assert response_config["unclassifiedValue"] == "" + combined_text = put_response.text + list_response.text + get_response.text + assert "federation-secret" not in combined_text + assert "must-not-leak" not in combined_text assert delete_response.status_code == 204 assert missing_response.status_code == 404 From 97c81f4c802a6a24b8d6ea5b88f2099a8f570bd7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:48:32 +0900 Subject: [PATCH 068/104] fix(federation): make convergence explicit and fail closed --- .../account_unification/app/federation.py | 213 ++++++++++-------- 1 file changed, 118 insertions(+), 95 deletions(-) diff --git a/services/account_unification/app/federation.py b/services/account_unification/app/federation.py index 345931e..85f1b39 100644 --- a/services/account_unification/app/federation.py +++ b/services/account_unification/app/federation.py @@ -2,15 +2,15 @@ External identity providers are deployment configuration, never committed realm code. Desired state is stored in the KV/DB backend and converged into Keycloak. -Secrets remain in the store and Keycloak payloads but are redacted from every -HTTP response and status object. +Stored and applied secrets never enter HTTP responses: only explicitly approved, +non-secret provider fields are disclosed to operators. """ from __future__ import annotations import threading from fastapi import APIRouter, Depends, HTTPException, Request -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from .kv_store import KvStore from .product_keycloak_client import ProductAdminApi @@ -22,21 +22,42 @@ _MAX_PROVIDER_CONFIG_KEY_LENGTH = 128 _MAX_PROVIDER_CONFIG_VALUE_LENGTH = 16_384 _REDACTED_VALUE = "" -_SENSITIVE_CONFIG_KEY_FRAGMENTS = ( - "secret", - "password", - "privatekey", - "signingkey", - "clientassertion", - "apikey", - "accesskey", - "credential", +_ALIAS_ALPHABET = frozenset("abcdefghijklmnopqrstuvwxyz0123456789-") +_ALIAS_EDGE_ALPHABET = frozenset("abcdefghijklmnopqrstuvwxyz0123456789") +# Unknown fields are redacted. This allowlist contains only values that are +# useful for operator diagnosis and are not credential material. +_EXPOSED_PROVIDER_CONFIG_KEYS = frozenset( + { + "alias", + "authorizationUrl", + "backchannelSupported", + "defaultScope", + "entityId", + "guiOrder", + "hideOnLoginPage", + "issuer", + "logoutUrl", + "metadataDescriptorUrl", + "principalAttribute", + "principalType", + "signatureAlgorithm", + "singleLogoutServiceUrl", + "singleSignOnServiceUrl", + "syncMode", + "tokenUrl", + "useJwksUrl", + "useMetadataDescriptorUrl", + "userInfoUrl", + "validateSignature", + } ) class IdentityProviderRegistration(BaseModel): """Desired state for one external identity provider.""" + model_config = ConfigDict(extra="forbid") + provider_alias: str = Field(description="Keycloak IdP alias (URL-safe slug).") display_name: str = Field(min_length=1, max_length=120) provider_id: str = Field( @@ -88,67 +109,73 @@ class IdentityProviderStatus(BaseModel): class FederationService: - """Persist desired IdP state and converge Keycloak under one process lock.""" + """Persist desired IdP state and reconcile Keycloak without lock-held I/O.""" def __init__(self, store: KvStore, api: ProductAdminApi) -> None: """Create a federation service around one store and Keycloak client.""" self._store = store self._api = api - self._lock = threading.RLock() + self._state_lock = threading.RLock() + self._convergence_lock = threading.RLock() def list_registrations(self) -> list[IdentityProviderStatus]: - """Return all stored registrations with redacted configuration.""" - with self._lock: - statuses = [ - self._status_for(self._parse_registration(raw_value)) - for raw_value in self._store.get_all( - FEDERATION_PROVIDER_NAMESPACE - ).values() - ] + """Return all stored registrations with live convergence status.""" + with self._state_lock: + raw_values = list( + self._store.get_all(FEDERATION_PROVIDER_NAMESPACE).values() + ) + registrations = [ + self._parse_registration(raw_value) for raw_value in raw_values + ] + statuses = [ + self._status_for(registration) for registration in registrations + ] return sorted(statuses, key=lambda item: item.registration.provider_alias) def get_registration(self, provider_alias: str) -> IdentityProviderStatus: """Return one stored registration or raise HTTP 404.""" _validate_provider_alias(provider_alias) - with self._lock: + with self._state_lock: raw_value = self._store.get( FEDERATION_PROVIDER_NAMESPACE, provider_alias ) - if raw_value is None: - raise HTTPException( - status_code=404, - detail="identity provider not registered", - ) - return self._status_for(self._parse_registration(raw_value)) + if raw_value is None: + raise HTTPException( + status_code=404, + detail="identity provider not registered", + ) + return self._status_for(self._parse_registration(raw_value)) def put_registration( self, provider_alias: str, registration: IdentityProviderRegistration, ) -> IdentityProviderStatus: - """Validate, persist, and converge one provider registration.""" + """Validate, persist, and attempt to converge one provider.""" if registration.provider_alias != provider_alias: raise HTTPException( status_code=400, detail="path alias and body provider_alias must match", ) _validate_registration(registration) - with self._lock: - self._store.put( - FEDERATION_PROVIDER_NAMESPACE, - provider_alias, - registration.model_dump_json(), - ) - self._apply(registration) - return self._status_for(registration) + with self._convergence_lock: + with self._state_lock: + self._store.put( + FEDERATION_PROVIDER_NAMESPACE, + provider_alias, + registration.model_dump_json(), + ) + applied = self._try_apply(registration) + return self._status_for(registration, applied=applied) def delete_registration(self, provider_alias: str) -> None: """Remove one provider from Keycloak and the desired-state store.""" _validate_provider_alias(provider_alias) - with self._lock: - raw_value = self._store.get( - FEDERATION_PROVIDER_NAMESPACE, provider_alias - ) + with self._convergence_lock: + with self._state_lock: + raw_value = self._store.get( + FEDERATION_PROVIDER_NAMESPACE, provider_alias + ) if raw_value is None: raise HTTPException( status_code=404, @@ -156,21 +183,29 @@ def delete_registration(self, provider_alias: str) -> None: ) if self._api.get_identity_provider(provider_alias) is not None: self._api.delete_identity_provider(provider_alias) - self._store.delete(FEDERATION_PROVIDER_NAMESPACE, provider_alias) + with self._state_lock: + self._store.delete( + FEDERATION_PROVIDER_NAMESPACE, provider_alias + ) def apply_all(self) -> list[IdentityProviderStatus]: - """Re-converge Keycloak from all stored desired state.""" - with self._lock: - registrations = [ - self._parse_registration(raw_value) - for raw_value in self._store.get_all( - FEDERATION_PROVIDER_NAMESPACE - ).values() - ] - statuses: list[IdentityProviderStatus] = [] + """Re-converge Keycloak from a snapshot of stored desired state.""" + with self._state_lock: + raw_values = list( + self._store.get_all(FEDERATION_PROVIDER_NAMESPACE).values() + ) + registrations = [ + self._parse_registration(raw_value) for raw_value in raw_values + ] + statuses: list[IdentityProviderStatus] = [] + with self._convergence_lock: for registration in registrations: - self._apply(registration) - statuses.append(self._status_for(registration)) + statuses.append( + self._status_for( + registration, + applied=self._try_apply(registration), + ) + ) return sorted(statuses, key=lambda item: item.registration.provider_alias) def _parse_registration(self, raw_value: str) -> IdentityProviderRegistration: @@ -182,9 +217,7 @@ def _parse_registration(self, raw_value: str) -> IdentityProviderRegistration: def _apply(self, registration: IdentityProviderRegistration) -> None: """Create or replace one Keycloak identity-provider instance.""" payload = _to_keycloak_payload(registration) - existing = self._api.get_identity_provider( - registration.provider_alias - ) + existing = self._api.get_identity_provider(registration.provider_alias) if existing is None: self._api.create_identity_provider(payload) else: @@ -192,14 +225,26 @@ def _apply(self, registration: IdentityProviderRegistration) -> None: registration.provider_alias, payload ) + def _try_apply(self, registration: IdentityProviderRegistration) -> bool: + """Attempt convergence and report failure without losing desired state.""" + try: + self._apply(registration) + except Exception: + return False + return True + def _status_for( - self, registration: IdentityProviderRegistration + self, + registration: IdentityProviderRegistration, + *, + applied: bool | None = None, ) -> IdentityProviderStatus: """Build a redacted status from desired and applied state.""" - applied = ( - self._api.get_identity_provider(registration.provider_alias) - is not None - ) + if applied is None: + applied = ( + self._api.get_identity_provider(registration.provider_alias) + is not None + ) return IdentityProviderStatus( registration=IdentityProviderView.from_registration(registration), applied_to_keycloak=applied, @@ -207,22 +252,18 @@ def _status_for( def _validate_provider_alias(provider_alias: str) -> None: - """Validate a lowercase alphanumeric-and-hyphen provider alias.""" + """Validate one ASCII lowercase alphanumeric-and-hyphen provider alias.""" valid = ( - 1 <= len(provider_alias) <= _MAX_PROVIDER_ALIAS_LENGTH - and provider_alias[0].isalnum() - and provider_alias[-1].isalnum() - and all( - character.islower() - or character.isdigit() - or character == "-" - for character in provider_alias - ) + isinstance(provider_alias, str) + and 1 <= len(provider_alias) <= _MAX_PROVIDER_ALIAS_LENGTH + and provider_alias[0] in _ALIAS_EDGE_ALPHABET + and provider_alias[-1] in _ALIAS_EDGE_ALPHABET + and all(character in _ALIAS_ALPHABET for character in provider_alias) ) if not valid: raise HTTPException( status_code=400, - detail="provider_alias must be a lowercase URL-safe slug", + detail="provider_alias must be an ASCII lowercase URL-safe slug", ) @@ -253,33 +294,15 @@ def _validate_registration( ) -def _normalized_config_key(config_key: str) -> str: - """Normalize one config key for deterministic sensitivity checks.""" - return "".join( - character - for character in config_key.lower() - if character.isalnum() - ) - - -def _is_sensitive_config_key(config_key: str) -> bool: - """Return whether a provider config key conventionally carries a secret.""" - normalized = _normalized_config_key(config_key) - return any( - fragment in normalized - for fragment in _SENSITIVE_CONFIG_KEY_FRAGMENTS - ) - - def _redacted_provider_config( provider_config: dict[str, str], ) -> dict[str, str]: - """Return a copy with credential-bearing values replaced.""" + """Expose only explicitly safe provider configuration values.""" return { config_key: ( - _REDACTED_VALUE - if _is_sensitive_config_key(config_key) - else config_value + config_value + if config_key in _EXPOSED_PROVIDER_CONFIG_KEYS + else _REDACTED_VALUE ) for config_key, config_value in provider_config.items() } From 4184fe8f30ed782c6adbb8a01307e9d14522f0b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:49:10 +0900 Subject: [PATCH 069/104] fix(health): raise HTTPError for non-success probes --- services/account_unification/app/healthcheck.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/services/account_unification/app/healthcheck.py b/services/account_unification/app/healthcheck.py index 22a1148..fd33ac6 100644 --- a/services/account_unification/app/healthcheck.py +++ b/services/account_unification/app/healthcheck.py @@ -30,6 +30,10 @@ def _build_http_only_opener() -> urllib.request.OpenerDirector: opener.add_handler(urllib.request.HTTPHandler()) opener.add_handler(urllib.request.HTTPSHandler()) opener.add_handler(_HttpOnlyRedirectHandler()) + # OpenerDirector has no implicit default handlers. Register this before the + # error processor so non-2xx responses raise HTTPError instead of returning + # ``None`` to the context manager below. + opener.add_handler(urllib.request.HTTPDefaultErrorHandler()) opener.add_handler(urllib.request.HTTPErrorProcessor()) return opener From 08a79de0c0a3640d05501604b525b52c59eaf7cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:49:50 +0900 Subject: [PATCH 070/104] test(scim): require protocol-native path errors --- .../tests/test_path_security.py | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/services/account_unification/tests/test_path_security.py b/services/account_unification/tests/test_path_security.py index b48d16c..1d4c940 100644 --- a/services/account_unification/tests/test_path_security.py +++ b/services/account_unification/tests/test_path_security.py @@ -6,6 +6,7 @@ from app.main import create_app OPERATOR_TOKEN = "test-operator-token" +SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error" def _client() -> TestClient: @@ -14,23 +15,19 @@ def _client() -> TestClient: app.state.operator_api_token = OPERATOR_TOKEN return TestClient( app, - headers={ - "Authorization": f"Bearer {OPERATOR_TOKEN}" - }, + headers={"Authorization": f"Bearer {OPERATOR_TOKEN}"}, ) -def test_admin_router_rejects_encoded_identifier(): +def test_admin_router_rejects_encoded_identifier() -> None: """Encoded path material is rejected before endpoint dependencies.""" with _client() as client: - response = client.get( - "/users/bad%252fidentifier/identities" - ) + response = client.get("/users/bad%252fidentifier/identities") assert response.status_code == 400 assert "encoding" in response.json()["detail"] -def test_federation_router_rejects_traversal_alias(): +def test_federation_router_rejects_traversal_alias() -> None: """Federation aliases cannot carry encoded navigation segments.""" with _client() as client: response = client.get( @@ -39,15 +36,15 @@ def test_federation_router_rejects_traversal_alias(): assert response.status_code == 400 -def test_scim_router_returns_scim_error_for_unsafe_id(): - """SCIM path validation preserves the RFC 7644 error envelope.""" +def test_scim_router_returns_protocol_native_error_for_unsafe_id() -> None: + """SCIM path validation returns an RFC 7644 body and media type.""" with _client() as client: - response = client.get( - "/scim/v2/Users/bad%252fidentifier" - ) + response = client.get("/scim/v2/Users/bad%252fidentifier") + assert response.status_code == 400 - detail = response.json()["detail"] - assert detail["schemas"] == [ - "urn:ietf:params:scim:api:messages:2.0:Error" - ] - assert detail["status"] == "400" + assert response.headers["content-type"].startswith("application/scim+json") + body = response.json() + assert body["schemas"] == [SCIM_ERROR_SCHEMA] + assert body["status"] == "400" + assert "detail" in body + assert "detail" not in body.get("detail", {}) From 1d4b6f3121ffd438cff3540445a886da316c1255 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:50:12 +0900 Subject: [PATCH 071/104] fix(scim): return protocol-native path validation errors --- .../account_unification/app/path_security.py | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/services/account_unification/app/path_security.py b/services/account_unification/app/path_security.py index 44125a9..ecfd28d 100644 --- a/services/account_unification/app/path_security.py +++ b/services/account_unification/app/path_security.py @@ -7,10 +7,16 @@ from __future__ import annotations from fastapi import Depends, HTTPException, Request +from fastapi.responses import JSONResponse from .identifiers import InvalidIdentifierError, validate_path_segment SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error" +SCIM_MEDIA_TYPE = "application/scim+json" + + +class ScimPathValidationError(ValueError): + """Represent one unsafe decoded SCIM path parameter.""" def _validate_path_parameters(request: Request) -> None: @@ -28,18 +34,28 @@ def require_safe_admin_path_parameters(request: Request) -> None: def require_safe_scim_path_parameters(request: Request) -> None: - """Reject unsafe SCIM path parameters using an RFC 7644 error body.""" + """Raise a SCIM-specific error for an unsafe decoded path parameter.""" try: _validate_path_parameters(request) except InvalidIdentifierError as error: - raise HTTPException( - status_code=400, - detail={ - "schemas": [SCIM_ERROR_SCHEMA], - "detail": str(error), - "status": "400", - }, - ) from error + raise ScimPathValidationError(str(error)) from error + + +def scim_path_validation_exception_handler( + request: Request, + error: ScimPathValidationError, +) -> JSONResponse: + """Render one RFC 7644 error at the response body root.""" + del request + return JSONResponse( + status_code=400, + media_type=SCIM_MEDIA_TYPE, + content={ + "schemas": [SCIM_ERROR_SCHEMA], + "detail": str(error), + "status": "400", + }, + ) admin_path_security_dependency = Depends(require_safe_admin_path_parameters) From 32f22667e994560fe43fded25f71526709e4299e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:50:46 +0900 Subject: [PATCH 072/104] fix(scim): register protocol-native path error handler --- services/account_unification/app/main.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/services/account_unification/app/main.py b/services/account_unification/app/main.py index 28cc738..a565e0b 100644 --- a/services/account_unification/app/main.py +++ b/services/account_unification/app/main.py @@ -21,8 +21,10 @@ from .config import load_service_config from .federation import FederationService, federation_router from .path_security import ( + ScimPathValidationError, admin_path_security_dependency, scim_path_security_dependency, + scim_path_validation_exception_handler, ) from .product_keycloak_client import ProductHttpAdminApi from .registration import registration_auth_dependency, registration_router @@ -137,6 +139,10 @@ def create_app(*, wire: bool = True) -> FastAPI: version=__version__, lifespan=lifespan if wire else None, ) + app.add_exception_handler( + ScimPathValidationError, + scim_path_validation_exception_handler, + ) if not wire: app.state.ready = True From 376b18bcba01645b9d8a03b239231163d755de12 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:51:48 +0900 Subject: [PATCH 073/104] fix(deploy): persist account-unification state --- docker-compose.yml | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index e103f2a..dfcbe84 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,38 +50,24 @@ services: image: quay.io/keycloak/keycloak:26.3.2@sha256:98fab020a3a490aba0978f237e2a06cd0ea42bf149c6cf10f11c0aaf27728ff2 container_name: cwl_idp_engine restart: unless-stopped - # `start` (production mode) with a runtime build; --import-realm loads the - # realm-as-code on first boot. TLS terminates at the WAF edge, so HTTP is - # enabled and hostname-strict relaxed for the internal listener. command: > start --import-realm environment: - # ---- Database wiring (values sourced from KV at deploy time) ---- KC_DB: postgres KC_DB_URL: jdbc:postgresql://idp_database:5432/${IDP_DB_NAME:-keycloak} KC_DB_USERNAME: ${IDP_DB_USER:-keycloak} KC_DB_PASSWORD: ${IDP_DB_PASSWORD} - # ---- Bootstrap admin. Created ONCE; retire after switching to passkey. - # Bootstrap transport only: sourced from KV at deploy time. KC_BOOTSTRAP_ADMIN_USERNAME: ${IDP_BOOTSTRAP_ADMIN_USERNAME:-idp-admin} KC_BOOTSTRAP_ADMIN_PASSWORD: ${IDP_BOOTSTRAP_ADMIN_PASSWORD} - # ---- Health + metrics management endpoints on port 9000 ---- KC_HEALTH_ENABLED: "true" KC_METRICS_ENABLED: "true" - # ---- HTTP/hostname. TLS terminates at the WAF edge; enable http here. ---- KC_HTTP_ENABLED: "true" KC_HOSTNAME: ${IDP_EXTERNAL_HOSTNAME:-http://localhost:8080} KC_HOSTNAME_STRICT: "false" KC_PROXY_HEADERS: xforwarded - # ---- Cache. This compose file is the STANDALONE single-node bring-up, - # so the Infinispan cluster stack is disabled by default: the jdbc-ping - # stack crash-loops single-node restarts (each aborted boot leaves a - # stale jgroups_ping coordinator row the next boot fatally tries to - # join). Clustered deployments (Helm) set IDP_CACHE_MODE=ispn. KC_CACHE: ${IDP_CACHE_MODE:-local} volumes: - # Realm config-as-code imported on first start. - ./deploy/keycloak/realm-cwl.json:/opt/keycloak/data/import/realm-cwl.json:ro ports: - "${IDP_EXTERNAL_PORT:-8080}:8080" @@ -89,8 +75,6 @@ services: idp_database: condition: service_healthy healthcheck: - # Keycloak's distroless image ships bash; probe the management health - # endpoint over a bash /dev/tcp socket (no curl in the image). test: - CMD-SHELL - >- @@ -123,6 +107,9 @@ services: CWL_IDP_BOOTSTRAP: /bootstrap/bootstrap.yaml volumes: - ./deploy/bootstrap:/bootstrap:ro + # Audit events and the user-operation lock sidecar survive container + # replacement. The image runs as a non-root user that owns this path. + - account_unification_data:/var/lib/account-unification ports: - "${UNIFICATION_PORT:-8099}:8099" depends_on: @@ -140,11 +127,10 @@ services: volumes: idp_database_data: + account_unification_data: networks: - # Internal: DB + engine + admin service. Never routed to the public edge. idp_internal_network: driver: bridge - # Edge: only the engine (OIDC) and admin API are reachable from the WAF. idp_edge_network: driver: bridge From 82dd30eea01ea576e804f79d5aa376def469a7f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:52:34 +0900 Subject: [PATCH 074/104] feat(helm): require immutable production images and durable state --- helm/cwl-idp/values.yaml | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/helm/cwl-idp/values.yaml b/helm/cwl-idp/values.yaml index 014ef7c..742975f 100644 --- a/helm/cwl-idp/values.yaml +++ b/helm/cwl-idp/values.yaml @@ -7,23 +7,22 @@ accountUnification: image: repository: cwl-idp/account-unification tag: "0.2.0" - # Pin an immutable digest in production so a mutable-tag replacement of this - # privileged (realm-management) service is impossible. The keycloak and - # postgres images below already pin one; set this to the built image's - # sha256 digest (rendered as repository:tag@sha256:...). Left empty here - # because the image is built locally in dev/CI; deployments must set it. + # Development may render a local tag. Production values must set + # requireDigest=true and provide the built image's sha256 digest. digest: "" + requireDigest: false pullPolicy: IfNotPresent replicaCount: 1 service: port: 8099 - # Bootstrap transport ONLY: a pointer to the KV/DB config store. Real config - # + secrets are read from that store at runtime. Mount the bootstrap file via - # a secret and set the path here. bootstrap: secretName: cwl-idp-bootstrap mountPath: /bootstrap fileName: bootstrap.yaml + persistence: + enabled: true + size: 1Gi + storageClassName: "" resources: requests: cpu: 50m @@ -32,9 +31,6 @@ accountUnification: cpu: 250m memory: 256Mi -# Keycloak engine (Apache-2.0), templated by this chart. Set enabled=false to -# federate an externally-managed Keycloak. The passwordless-first realm is -# imported as-code from a ConfigMap built from deploy/keycloak/realm-cwl.json. keycloak: enabled: true image: @@ -43,17 +39,13 @@ keycloak: digest: "sha256:98fab020a3a490aba0978f237e2a06cd0ea42bf149c6cf10f11c0aaf27728ff2" pullPolicy: IfNotPresent replicaCount: 1 - # Public base URL Keycloak advertises (behind the WAF in prod). hostname: "http://localhost:8080" hostnameStrict: false httpEnabled: true service: httpPort: 8080 managementPort: 9000 - # Bootstrap admin + DB credentials are read from this pre-created secret - # (populated from your KV). Keys: admin-username, admin-password, db-password. existingSecret: cwl-idp-keycloak - # Realm import ConfigMap (kubectl create configmap ... --from-file=realm-cwl.json). realmImport: configMapName: cwl-idp-realm fileName: realm-cwl.json @@ -65,8 +57,6 @@ keycloak: cpu: "2" memory: 2Gi -# Bundled Postgres (MIT) for Keycloak. Point Keycloak at a managed Postgres by -# setting postgres.enabled=false and keycloak DB env from your own secret. postgres: enabled: true image: @@ -76,7 +66,6 @@ postgres: pullPolicy: IfNotPresent database: keycloak username: keycloak - # DB password is read from the keycloak.existingSecret key 'db-password'. storage: size: 8Gi resources: From 5af308d03d6da41a26253d6985d23da516f2d4fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:52:55 +0900 Subject: [PATCH 075/104] fix(helm): fail closed on mutable images and mount durable data --- .../templates/account-unification.yaml | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/helm/cwl-idp/templates/account-unification.yaml b/helm/cwl-idp/templates/account-unification.yaml index 8ee66fe..c6bb055 100644 --- a/helm/cwl-idp/templates/account-unification.yaml +++ b/helm/cwl-idp/templates/account-unification.yaml @@ -1,3 +1,25 @@ +{{- if and .Values.accountUnification.image.requireDigest (not .Values.accountUnification.image.digest) -}} +{{- fail "accountUnification.image.digest is required when accountUnification.image.requireDigest=true" -}} +{{- end -}} +{{- if .Values.accountUnification.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "cwl-idp.fullname" . }}-account-unification-data + namespace: {{ include "cwl-idp.namespace" . }} + labels: + {{- include "cwl-idp.labels" . | nindent 4 }} + app.kubernetes.io/component: account-unification +spec: + accessModes: ["ReadWriteOnce"] + {{- if .Values.accountUnification.persistence.storageClassName }} + storageClassName: {{ .Values.accountUnification.persistence.storageClassName | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.accountUnification.persistence.size }} +--- +{{- end }} apiVersion: apps/v1 kind: Deployment metadata: @@ -43,14 +65,14 @@ spec: - name: http containerPort: 8099 env: - # ONLY bootstrap transport; real config comes from the KV store. - name: CWL_IDP_BOOTSTRAP value: "{{ .Values.accountUnification.bootstrap.mountPath }}/{{ .Values.accountUnification.bootstrap.fileName }}" volumeMounts: - name: bootstrap mountPath: {{ .Values.accountUnification.bootstrap.mountPath }} readOnly: true - # Writable scratch dir since the root filesystem is read-only. + - name: account-unification-data + mountPath: /var/lib/account-unification - name: tmp mountPath: /tmp readinessProbe: @@ -71,6 +93,13 @@ spec: - name: bootstrap secret: secretName: {{ .Values.accountUnification.bootstrap.secretName }} + - name: account-unification-data + {{- if .Values.accountUnification.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "cwl-idp.fullname" . }}-account-unification-data + {{- else }} + emptyDir: {} + {{- end }} - name: tmp emptyDir: {} --- From 69f2d3ea9205e872bc0c9381998eda07fa372700 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:53:26 +0900 Subject: [PATCH 076/104] docs(tests): document user-operation lock regressions --- .../tests/test_user_locks.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/services/account_unification/tests/test_user_locks.py b/services/account_unification/tests/test_user_locks.py index 97f0271..04af38b 100644 --- a/services/account_unification/tests/test_user_locks.py +++ b/services/account_unification/tests/test_user_locks.py @@ -14,16 +14,19 @@ def _assert_overlapping_operation_waits(first_manager, second_manager) -> None: + """Assert a second overlapping mutation waits for the first lock holder.""" first_entered = threading.Event() release_first = threading.Event() second_entered = threading.Event() def hold_first() -> None: + """Hold the first lock until the test releases it.""" with first_manager.hold("survivor", "dup"): first_entered.set() assert release_first.wait(timeout=5) def hold_second() -> None: + """Attempt to enter the overlapping critical section.""" assert first_entered.wait(timeout=5) with second_manager.hold("dup"): second_entered.set() @@ -41,22 +44,27 @@ def hold_second() -> None: assert second_entered.is_set() -def test_in_memory_locks_serialize_overlapping_user_ids(): +def test_in_memory_locks_serialize_overlapping_user_ids() -> None: + """The process-local manager serializes intersecting user ID sets.""" manager = InMemoryUserOperationLocks() _assert_overlapping_operation_waits(manager, manager) -def test_sqlite_locks_serialize_distinct_manager_instances(tmp_path): +def test_sqlite_locks_serialize_distinct_manager_instances(tmp_path) -> None: + """Separate managers sharing a sidecar database serialize mutations.""" database_path = str(tmp_path / "user-operation-locks.sqlite3") first_manager = SqliteUserOperationLocks(database_path) second_manager = SqliteUserOperationLocks(database_path) _assert_overlapping_operation_waits(first_manager, second_manager) -def test_sqlite_lock_timeout_is_explicit_and_retryable(tmp_path): +def test_sqlite_lock_timeout_is_explicit_and_retryable(tmp_path) -> None: + """Contention exceeding the timeout raises the retryable domain error.""" database_path = str(tmp_path / "user-operation-locks.sqlite3") first_manager = SqliteUserOperationLocks(database_path) - impatient_manager = SqliteUserOperationLocks(database_path, timeout_seconds=0.05) + impatient_manager = SqliteUserOperationLocks( + database_path, timeout_seconds=0.05 + ) with first_manager.hold("dup"): with pytest.raises(UserOperationLockTimeout): @@ -64,7 +72,8 @@ def test_sqlite_lock_timeout_is_explicit_and_retryable(tmp_path): pytest.fail("contending operation unexpectedly acquired the lock") -def test_lock_manager_rejects_empty_user_ids(): +def test_lock_manager_rejects_empty_user_ids() -> None: + """Empty identifiers never create an unscoped mutation lock.""" manager = InMemoryUserOperationLocks() with pytest.raises(ValueError): with manager.hold(""): From c5654396b5232b7aa68df277a1dbba01298a72cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:53:55 +0900 Subject: [PATCH 077/104] test(sqlite): close concurrent stores safely and clarify ordering --- .../tests/test_storage_concurrency.py | 107 +++++++++--------- 1 file changed, 51 insertions(+), 56 deletions(-) diff --git a/services/account_unification/tests/test_storage_concurrency.py b/services/account_unification/tests/test_storage_concurrency.py index e1e8de4..bd2f86b 100644 --- a/services/account_unification/tests/test_storage_concurrency.py +++ b/services/account_unification/tests/test_storage_concurrency.py @@ -2,65 +2,60 @@ from __future__ import annotations from concurrent.futures import ThreadPoolExecutor +from contextlib import closing from app.audit import AuditLogger, SqliteAuditSink from app.kv_store import SqliteKvStore -def test_sqlite_kv_store_handles_concurrent_access(tmp_path): +def test_sqlite_kv_store_handles_concurrent_access(tmp_path) -> None: """One store instance safely serves concurrent readers and writers.""" - store = SqliteKvStore( - str(tmp_path / "configuration.sqlite3") - ) - - def write_entry(index: int) -> str | None: - entry_key = f"entry_key_{index}" - entry_value = f"entry_value_{index}" - store.put( - "runtime_configuration", - entry_key, - entry_value, - ) - return store.get( - "runtime_configuration", entry_key - ) - - with ThreadPoolExecutor(max_workers=8) as executor: - values = list(executor.map(write_entry, range(100))) - - assert values == [ - f"entry_value_{index}" for index in range(100) - ] - assert len( - store.get_all("runtime_configuration") - ) == 100 - store.close() - - -def test_sqlite_audit_sink_handles_concurrent_events(tmp_path): - """Concurrent audit events remain complete and ordered by sequence.""" - sink = SqliteAuditSink( - str(tmp_path / "audit_events.sqlite3") - ) - audit = AuditLogger(sink) - audit_id = "concurrent_audit" - - def emit_event(index: int) -> None: - audit.emit( - audit_id=audit_id, - event_type="concurrent_event", - actor=f"worker_{index}", - payload={"event_index": index}, - ) - - with ThreadPoolExecutor(max_workers=8) as executor: - list(executor.map(emit_event, range(100))) - - events = audit.events_for(audit_id) - assert len(events) == 100 - assert { - event.actor for event in events - } == { - f"worker_{index}" for index in range(100) - } - audit.close() + with closing( + SqliteKvStore(str(tmp_path / "configuration.sqlite3")) + ) as store: + + def write_entry(index: int) -> str | None: + """Write and read one independently keyed configuration value.""" + entry_key = f"entry_key_{index}" + entry_value = f"entry_value_{index}" + store.put( + "runtime_configuration", + entry_key, + entry_value, + ) + return store.get("runtime_configuration", entry_key) + + with ThreadPoolExecutor(max_workers=8) as executor: + values = list(executor.map(write_entry, range(100))) + + assert values == [ + f"entry_value_{index}" for index in range(100) + ] + assert len(store.get_all("runtime_configuration")) == 100 + + +def test_sqlite_audit_sink_handles_concurrent_events(tmp_path) -> None: + """Concurrent audit events remain complete and retrievable in DB order.""" + with closing( + SqliteAuditSink(str(tmp_path / "audit_events.sqlite3")) + ) as sink: + audit = AuditLogger(sink) + audit_id = "concurrent_audit" + + def emit_event(index: int) -> None: + """Append one independently attributable audit event.""" + audit.emit( + audit_id=audit_id, + event_type="concurrent_event", + actor=f"worker_{index}", + payload={"event_index": index}, + ) + + with ThreadPoolExecutor(max_workers=8) as executor: + list(executor.map(emit_event, range(100))) + + events = audit.events_for(audit_id) + assert len(events) == 100 + assert {event.actor for event in events} == { + f"worker_{index}" for index in range(100) + } From e21b3bd8eff100dc6b53072bcd464c1d34ae6fc1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:55:10 +0900 Subject: [PATCH 078/104] docs(passwordless): describe one-time action-email enrollment --- docs/passwordless-policy.md | 131 ++++++++++++++++++++---------------- 1 file changed, 74 insertions(+), 57 deletions(-) diff --git a/docs/passwordless-policy.md b/docs/passwordless-policy.md index e555cbe..e33c2c3 100644 --- a/docs/passwordless-policy.md +++ b/docs/passwordless-policy.md @@ -3,65 +3,82 @@ ## Goal Eliminate passwords for **ecosystem-local accounts**. Every human either signs -in through a federated IdP (employer ADFS, corporate LDAP/AD, optional personal -OIDC) or with a **FIDO2 / passkey** registered on cwl-idp. The password -authenticator is removed from the browser flow so there is no local password to -phish, reuse, or leak. +in through a federated IdP or with a **FIDO2/passkey** registered on cwl-idp. +The bound browser flow contains no password authenticator, so there is no local +password to phish, reuse, reset, or leak. -## How it is enforced (as-code) +## How it is enforced as code -Set once at realm import from `deploy/keycloak/realm-cwl.json`: +`deploy/keycloak/realm-cwl.json` fixes the following invariants: | Setting | Value | Effect | | --- | --- | --- | -| `browserFlow` | `browser-passwordless` | Custom flow: username form → **WebAuthn passwordless**, no password authenticator | -| `authenticationFlows[browser-passwordless-forms]` | `auth-username-form` + `webauthn-authenticator-passwordless` | Passkey is the primary (and only) knowledge-free factor | -| `registrationAllowed` | `false` | Signup happens on product pages via the account-unification `/registration/accounts` API, never on IdP-hosted forms | -| `registrationEmailAsUsername` | `true` | The email address is the account identity | -| `verifyEmail` | `false` (until SMTP) | Must stay `false` while the realm has no `smtpServer`; the validator enforces the pairing | -| `resetPasswordAllowed` | `false` | No password-reset surface | -| `authenticationFlows[browser-passwordless-credentials]` | passkey + credential form, both ALTERNATIVE | The credential form is a **bootstrap-only** path: it is offered solely to API-registered accounts that have not enrolled a passkey yet, and the registration password janitor revokes the credential after enrollment | -| `requiredActions[webauthn-register-passwordless]` | `defaultAction: true` | New users are prompted to enrol a passkey | -| `webAuthnPolicyPasswordless*` | RP name / ES256,RS256 / resident key / UV required | Passkey relying-party policy | - -Because the bound browser flow contains **no** `auth-password-form` / -`auth-username-password-form` authenticator, a local password is never accepted -at login even if one existed on the user. `scripts/validate_realm.py` asserts -this invariant in CI (fails if any password authenticator appears in the bound -browser flow, or if the passwordless WebAuthn authenticator is missing). - -The passkey relying-party name is `webAuthnPolicyPasswordlessRpEntityName`; the -RPID derives from the request host (behind the WAF, the public IdP host), so -`KC_HOSTNAME` must match the domain the browser sees. - -## The one exception: the bootstrap admin - -Keycloak requires an initial admin in the `master` realm. It is created **once** -from `KC_BOOTSTRAP_ADMIN_USERNAME` / `KC_BOOTSTRAP_ADMIN_PASSWORD` (bootstrap -transport from KV), then that admin registers a passkey and the password is -retired (operational runbook: switch to passkey-only, rotate/disable the -bootstrap password). No ecosystem-local account in the `cwl` realm has a -password. - -## Verified-email is the linking anchor (NIST SP 800-63C) - -Following NIST SP 800-63C on federated assertions, cwl-idp treats an email as an -identity-linking anchor **only when the asserting IdP marks it verified**. This -is enforced in two places: - -1. **Keycloak** federation config: `trustEmail: true` on the employer ADFS SAML - IdP and the LDAP/AD source lets the first-broker-login flow auto-link a new - external identity to an existing account on a matching **verified** email. -2. **account-unification service** (`app/matching.py`): the merge engine refuses - to treat an unverified-email coincidence as a match, and refuses any merge - whose only tie is an unverified email (`UnverifiedEmailMergeError`). - -The config key `allow_unverified_email_link` exists solely so an audit can prove -it is hard-defaulted to `false`. - -## Why passwordless here specifically - -- Most human logins arrive already authenticated by the employer ADFS or the - corporate directory — a local password would be a redundant, weaker factor. -- Passkeys are phishing-resistant and bind to the origin, which matters when a - single IdP fronts many ecosystem RPs. +| `browserFlow` | `browser-passwordless` | Cookie/federation or username followed by passwordless WebAuthn | +| `authenticationFlows[browser-passwordless-credentials]` | `webauthn-authenticator-passwordless` only | A password can never authenticate to the `cwl` realm | +| `registrationAllowed` | `false` | Signup is owned by first-party product backends, not an IdP-hosted form | +| `registrationEmailAsUsername` | `true` | The normalized email address is the account identity | +| `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. + +## Password-free headless registration + +The account-unification service's `POST /registration/accounts` endpoint accepts +only identity/profile data. It does **not** accept or create a password. + +After creating the disabled-password account, the service calls Keycloak's +Admin REST `execute-actions-email` operation with two required actions: + +1. `VERIFY_EMAIL` +2. `webauthn-register-passwordless` + +Keycloak sends one bounded link associated with the configured relying-party +client and HTTPS redirect URI. If Keycloak cannot accept the action-email +request, the service deletes the newly created account; if rollback also fails, +the API reports a distinct failure so an operator can reconcile it. + +Enabling this endpoint requires all of these KV entries: + +- `registration_api_token`, different from `operator_api_token` +- `registration_client_id` +- `registration_redirect_uri`, an absolute HTTPS URI without credentials or a fragment +- `registration_action_lifespan_seconds`, a positive integer no greater than 3600 + +The Keycloak realm must also have a working SMTP configuration. A deployment +without SMTP should omit `registration_api_token`; the endpoint then fails +closed with HTTP 503 before creating an account. + +Registration throttling is keyed by direct peer address. Operators terminating +traffic at a WAF or gateway must preserve trustworthy source isolation there; +the service deliberately does not trust arbitrary forwarded-address headers. + +## The one bootstrap exception + +Keycloak requires an initial administrator in the `master` realm. It is created +once from deployment secrets, registers a passkey, and then has its reusable +bootstrap credential rotated or disabled. This exception is outside the `cwl` +realm and is governed by the bootstrap runbook. + +## Verified email is the linking anchor + +An email address authorizes linking only when both accounts hold the same +verified address or an exact external `(provider, subject)` tie exists. The +account-unification service rejects an unverified-email coincidence even when a +caller supplies `explicit_link=true`. The configuration key +`allow_unverified_email_link` remains solely as audit evidence and startup +rejects any attempt to set it true. + +## Why this boundary matters + +- Federated employees already authenticate at their authoritative employer IdP. +- Passkeys are phishing-resistant and origin-bound. +- Registration proves control of the submitted address through the same + one-time action link that enrolls the passkey. +- A five-minute access token limits bearer-token exposure while a longer SSO + session can still support normal product use through refresh and reissue. From 6e62d37c02ffd60196e77c28a7fbf682eee0946f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:56:09 +0900 Subject: [PATCH 079/104] docs(keycloak): align bootstrap and passwordless enrollment model --- deploy/keycloak/README.md | 174 ++++++++++++++++---------------------- 1 file changed, 75 insertions(+), 99 deletions(-) diff --git a/deploy/keycloak/README.md b/deploy/keycloak/README.md index b694d9c..feaf209 100644 --- a/deploy/keycloak/README.md +++ b/deploy/keycloak/README.md @@ -1,110 +1,86 @@ # Keycloak config-as-code -cwl-idp runs on **Keycloak** (Apache-2.0). The portable realm shape is declared -as code and imported at container start. Deployment-specific secrets and -federation desired state remain outside the repository. +Keyverse runs on **Keycloak** (Apache-2.0). The portable `cwl` realm shape is +imported at container start; deployment-specific secrets and external identity +providers are converged afterwards from the KV/DB source of truth. -| File | What | +| File | Responsibility | | --- | --- | -| `realm-cwl.json` | The portable `cwl` realm: passkey-first browser flow, standard client scopes, OIDC/OAuth 2.1 RP templates, the concrete `naruon-web` public PKCE client, and the account-unification service-account client. Imported via `start --import-realm`. | -| `kcadm-bootstrap.sh` | Idempotent post-import convergence: reads the service-account client secret from KV without placing it in process arguments, grants the minimum `realm-management` roles, and reconciles the client scope/protocol mapper required to emit them. | - -## Passwordless-first (passkeys) - -`realm-cwl.json` defines an authentication flow **`browser-passwordless`** with -`auth-username-form` → `webauthn-authenticator-passwordless` and **no password -authenticator**, and binds it as the realm `browserFlow`. Combined with -`resetPasswordAllowed:false` and a default -`webauthn-register-passwordless` required action, ecosystem-local accounts -authenticate with a **passkey (FIDO2/WebAuthn)** in the steady state. -Self-service signup is **headless**: product frontends such as Naruon own the -signup page and create accounts through the account-unification service's -`/registration/accounts` API (`registrationAllowed` stays `false`, so the -IdP-hosted registration form never appears). API-registered accounts carry a -bootstrap password that the `browser-passwordless-credentials` subflow offers -only while no passkey exists; the first session enrolls a passkey and the -credential janitor then removes the bootstrap credential. `verifyEmail` stays -`false` until a realm `smtpServer` is configured; the validator enforces that -pairing. See -[`../../docs/passwordless-policy.md`](../../docs/passwordless-policy.md). - -## What is committed, bootstrapped, and registered at runtime - -**Committed portable shape:** realm settings, browser flows, standard client -scopes, RP client structure, protocol mappers, and the account-unification -service-account client. Any committed secret field remains a non-deployable -placeholder such as `__set_from_kv__`. - -**Converged by `kcadm-bootstrap.sh`:** the account-unification client secret, -its least-privilege `realm-management` grants, the client scope mappings, and a -single named protocol mapper. The script is safe to repeat: it updates the -existing mapper, removes historical duplicates, and keeps the reusable admin -password and client secret out of child-process arguments. - -**Registered at runtime:** employer ADFS, LDAP-fronting brokers, optional OIDC -providers, and their credentials. The account-unification federation API stores -desired state in KV/DB and converges Keycloak through the Admin REST API. This -keeps employer-specific topology out of reusable realm source code. - -## Apply +| `realm-cwl.json` | Portable passwordless realm, shared client scopes, RP template, concrete `naruon-web` PKCE client, and account-unification service client | +| `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 | + +## Passwordless browser and enrollment flows + +The bound `browser-passwordless` flow accepts an existing session, a federated +identity, or username followed by `webauthn-authenticator-passwordless`. It has +**no password authenticator**. + +First-party products create password-free accounts through +`POST /registration/accounts`. The account-unification service then invokes +Keycloak's `execute-actions-email` Admin REST operation with `VERIFY_EMAIL` and +`webauthn-register-passwordless`. The resulting bounded link verifies control of +the address and enrolls the first passkey before normal login. A failed email +request rolls the new account back. + +A deployment that enables registration must configure Keycloak SMTP and set the +following account-unification KV entries: + +- `registration_api_token` +- `registration_client_id` +- `registration_redirect_uri` +- `registration_action_lifespan_seconds` + +Without the registration token the endpoint is unavailable rather than open. +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`. + +This separation also avoids Keycloak 26 import failures from placeholder SAML +URLs or invalid placeholder LDAP distinguished names. + +## Keycloak 26 import rules + +`scripts/validate_realm.py` enforces these fail-closed rules: + +- no `$`-prefixed annotation keys; +- no committed external federation or user-storage provider; +- 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; +- 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. + +## Bootstrap ```bash -# 1. Keycloak imports realm-cwl.json automatically on first start -# (docker-compose mounts it at /opt/keycloak/data/import). +# Keycloak imports the realm at first start. docker compose up -d -# 2. Once Keycloak is READY, converge the service account from KV: +# Once Keycloak is ready, converge the service client and its scoped roles. KC_SERVER=http://localhost:8080 deploy/keycloak/kcadm-bootstrap.sh - -# 3. Register or re-apply deployment-specific federation desired state: -# POST /federation/identity-providers:apply ``` -## Federation and client-registration templates - -Admin-API request bodies for registering additional RPs and external identity -providers live in [`../templates/`](../templates/). These are payload -references, not resources imported into the committed realm. - -## Realm-file rules Keycloak 26 enforces - -The realm JSON is parsed into typed representations, so two patterns that used -to live in this file are **import failures** on Keycloak 26 and must not come -back (`scripts/validate_realm.py` guards both): - -- **No `$`-prefixed annotation keys.** `RealmRepresentation` rejects unknown - fields (`Unrecognized field "$comment"`), which aborts `--import-realm` and - crash-loops the container. Document intent in this README instead of inline - JSON annotations. -- **No committed external federation.** SAML IdP URL fields are URL-validated - at import (a bare `__set_from_kv__` aborts it) and an enabled LDAP source - with placeholder DNs breaks realm user operations (`Invalid DN`). Employer - ADFS and corporate LDAP are deployment data, so the realm commits none of - them. Register providers through `/federation/identity-providers`; desired - state persists in KV/DB and a rebuilt realm is re-converged with - `POST /federation/identity-providers:apply`. - -## Client scopes and the Keycloak 26 lightweight-token pitfall - -Imported realms do **not** get the standard client scopes auto-created, and -without the `basic` scope Keycloak 26 access tokens omit the `sub` claim. That -breaks RPs that authenticate by subject. The realm therefore commits `basic` -(Subject + auth_time), `profile`, and `email` scopes and assigns them as realm -defaults plus explicit `defaultClientScopes` on each RP client. - -## RP clients: template + Naruon - -`ecosystem-rp-template` stays the confidential-client blueprint (OAuth 2.1: -code + PKCE S256, no implicit flow, exact redirect URIs, secret from KV). Clones -must rename the audience mapper's `included.client.audience` to the new -`clientId`. - -`naruon-web` is the first concrete RP, committed as a **public** PKCE client -because a browser cannot hold a client secret. It carries the claims Naruon's -backend session contract requires: `sub` (via `basic`), an `aud` containing -`naruon-web`, and hardcoded `role=member`, `org`, and `workspace` claims. The -committed `org`/`workspace` values (`org-cwl` / `workspace-org-cwl`) and -`https://naruon.example` redirect URIs are deployment placeholders and must be -replaced per environment through an operator-managed Admin API payload. -`access.token.lifespan` is 43200 seconds to fit Naruon's 12-hour session ceiling; -admin roles are never asserted from external IdP claims by design. +The bootstrap obtains credentials from the platform `kv` helper, keeps kcadm +session material inside a private temporary directory, never places reusable +secrets in process arguments, validates every resolved identifier, and +reconciles the protocol mapper without creating duplicates. From 46d54831bf8ccb5d759781084457798e41c5e0a3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:56:33 +0900 Subject: [PATCH 080/104] test(lifecycle): cover in-memory lock sidecar cleanup --- .../tests/test_lifecycle.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 services/account_unification/tests/test_lifecycle.py diff --git a/services/account_unification/tests/test_lifecycle.py b/services/account_unification/tests/test_lifecycle.py new file mode 100644 index 0000000..14a6b23 --- /dev/null +++ b/services/account_unification/tests/test_lifecycle.py @@ -0,0 +1,61 @@ +"""Application lifecycle and temporary resource tests.""" +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +from app.main import ( + _remove_temporary_lock_database, + _user_operation_lock_path, +) + + +def test_in_memory_audit_uses_real_temporary_lock_database() -> None: + """An in-memory audit sink never creates a malformed pseudo-file path.""" + lock_path, is_temporary = _user_operation_lock_path(":memory:") + try: + assert is_temporary is True + assert lock_path != ":memory:.user-operation-locks.sqlite3" + assert Path(lock_path).is_file() + finally: + Path(lock_path).unlink(missing_ok=True) + + +def test_persistent_audit_uses_adjacent_lock_sidecar(tmp_path) -> None: + """A persistent audit database keeps its lock sidecar on the same volume.""" + audit_path = str(tmp_path / "account_merge_audit.sqlite3") + lock_path, is_temporary = _user_operation_lock_path(audit_path) + assert lock_path == f"{audit_path}.user-operation-locks.sqlite3" + assert is_temporary is False + + +def test_temporary_lock_database_is_removed_at_shutdown(tmp_path) -> None: + """Lifecycle cleanup removes only the explicitly temporary sidecar.""" + lock_path = tmp_path / "temporary_user_locks.sqlite3" + lock_path.write_text("placeholder", encoding="utf-8") + app = SimpleNamespace( + state=SimpleNamespace( + temporary_user_operation_lock_database=True, + user_operation_lock_database_path=str(lock_path), + ) + ) + + _remove_temporary_lock_database(app) + + assert not lock_path.exists() + + +def test_persistent_lock_database_is_not_removed_at_shutdown(tmp_path) -> None: + """Lifecycle cleanup leaves deployment-owned persistent sidecars intact.""" + lock_path = tmp_path / "persistent_user_locks.sqlite3" + lock_path.write_text("placeholder", encoding="utf-8") + app = SimpleNamespace( + state=SimpleNamespace( + temporary_user_operation_lock_database=False, + user_operation_lock_database_path=str(lock_path), + ) + ) + + _remove_temporary_lock_database(app) + + assert lock_path.exists() From 029e1c1c2800bcb01f245d709fac273387bf0611 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:58:02 +0900 Subject: [PATCH 081/104] test(bootstrap): anchor ordering to executable step --- services/account_unification/tests/test_kcadm_bootstrap.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/services/account_unification/tests/test_kcadm_bootstrap.py b/services/account_unification/tests/test_kcadm_bootstrap.py index 51f4f95..73bbf5a 100644 --- a/services/account_unification/tests/test_kcadm_bootstrap.py +++ b/services/account_unification/tests/test_kcadm_bootstrap.py @@ -50,13 +50,16 @@ def test_bootstrap_isolates_kcadm_without_replacing_kv_home() -> None: def test_bootstrap_discards_reusable_admin_password_after_login() -> None: - """The reusable bootstrap password is unset immediately after login.""" + """The reusable bootstrap password is unset before client convergence.""" script = _bootstrap_script() credentials_position = script.index( 'KC_CLI_PASSWORD="${ADMIN_PASS}" kcadm config credentials' ) unset_position = script.index("unset ADMIN_PASS", credentials_position) - next_bootstrap_step = script.index("# NOTE: external federation", unset_position) + next_bootstrap_step = script.index( + 'echo "==> converging account-unification-svc client secret from KV"', + unset_position, + ) assert credentials_position < unset_position < next_bootstrap_step From bc6db2fa53b0ac2cdd3c602fad201527bec535f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 14:58:31 +0900 Subject: [PATCH 082/104] test(health): cover non-success HTTP handling --- .../tests/test_healthcheck.py | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/services/account_unification/tests/test_healthcheck.py b/services/account_unification/tests/test_healthcheck.py index 60e4ff1..c315c8e 100644 --- a/services/account_unification/tests/test_healthcheck.py +++ b/services/account_unification/tests/test_healthcheck.py @@ -7,21 +7,30 @@ class _Response: + """Small context-managed HTTP response test double.""" + def __init__(self, payload: bytes) -> None: + """Store one response payload.""" self._payload = payload def __enter__(self) -> "_Response": + """Enter the response context.""" return self def __exit__(self, *args: object) -> None: + """Exit the response context without suppressing errors.""" return None def read(self) -> bytes: + """Return the stored response payload.""" return self._payload -def test_healthcheck_returns_zero_for_ok_status(monkeypatch, capsys): +def test_healthcheck_returns_zero_for_ok_status(monkeypatch, capsys) -> None: + """A ready service produces a successful shell exit status.""" + def fake_open(url: str) -> _Response: + """Return one ready JSON response.""" assert url == "http://service/healthz" return _Response(b'{"status":"ok"}') @@ -31,8 +40,11 @@ def fake_open(url: str) -> _Response: assert capsys.readouterr().out == "ok\n" -def test_healthcheck_returns_one_for_non_ok_status(monkeypatch, capsys): +def test_healthcheck_returns_one_for_non_ok_status(monkeypatch, capsys) -> None: + """A valid non-ready body produces a failed shell status.""" + def fake_open(url: str) -> _Response: + """Return one starting JSON response.""" return _Response(b'{"status":"starting"}') monkeypatch.setattr(healthcheck, "_open_health_url", fake_open) @@ -41,8 +53,11 @@ def fake_open(url: str) -> _Response: assert "not ready" in capsys.readouterr().err -def test_healthcheck_returns_one_for_request_error(monkeypatch, capsys): +def test_healthcheck_returns_one_for_request_error(monkeypatch, capsys) -> None: + """Transport errors are converted into a failed shell status.""" + def fake_open(url: str) -> _Response: + """Raise one deterministic connection error.""" raise OSError("connection refused") monkeypatch.setattr(healthcheck, "_open_health_url", fake_open) @@ -51,9 +66,11 @@ def fake_open(url: str) -> _Response: assert "healthcheck failed: connection refused" in capsys.readouterr().err -def test_healthcheck_rejects_non_http_scheme(monkeypatch, capsys): +def test_healthcheck_rejects_non_http_scheme(monkeypatch, capsys) -> None: """A non-HTTP(S) URL is rejected before urllib ever opens it.""" + def fail_open(*args: object, **kwargs: object) -> _Response: + """Fail if a rejected scheme reaches the opener.""" raise AssertionError("the opener must not run for a rejected scheme") monkeypatch.setattr(healthcheck, "_open_health_url", fail_open) @@ -62,8 +79,8 @@ def fail_open(*args: object, **kwargs: object) -> _Response: assert "unsupported URL scheme 'file'" in capsys.readouterr().err -def test_healthcheck_opener_drops_non_http_redirect_target(): - """A ``http:// -> ftp://`` redirect is dropped, and no ftp/file handler exists.""" +def test_healthcheck_opener_drops_non_http_redirect_target() -> None: + """A redirect cannot escape HTTP(S), file, FTP, or error boundaries.""" handler = healthcheck._HttpOnlyRedirectHandler() dropped = handler.redirect_request( urllib.request.Request("http://127.0.0.1:8099/healthz"), @@ -74,7 +91,6 @@ def test_healthcheck_opener_drops_non_http_redirect_target(): "ftp://127.0.0.1/secret", ) assert dropped is None - # A same-scheme redirect is still honoured (returns a Request, not None). kept = handler.redirect_request( urllib.request.Request("http://127.0.0.1:8099/healthz"), None, @@ -84,9 +100,8 @@ def test_healthcheck_opener_drops_non_http_redirect_target(): "http://127.0.0.1:8099/ready", ) assert kept is not None - # The opener carries no protocol handler that could open ftp/file targets. opener = healthcheck._build_http_only_opener() - assert not any( - type(h).__name__ in {"FTPHandler", "FileHandler", "DataHandler"} - for h in opener.handlers - ) + handler_names = {type(item).__name__ for item in opener.handlers} + assert not handler_names & {"FTPHandler", "FileHandler", "DataHandler"} + assert "HTTPDefaultErrorHandler" in handler_names + assert "HTTPErrorProcessor" in handler_names From 9d6dc05b31da4c312064cdedb826965b2999c518 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 15:02:16 +0900 Subject: [PATCH 083/104] test(scim): exercise production lock manager in race regression --- .../account_unification/tests/test_scim.py | 200 +++++------------- 1 file changed, 48 insertions(+), 152 deletions(-) diff --git a/services/account_unification/tests/test_scim.py b/services/account_unification/tests/test_scim.py index 08d8780..95d7573 100644 --- a/services/account_unification/tests/test_scim.py +++ b/services/account_unification/tests/test_scim.py @@ -3,49 +3,16 @@ import threading from concurrent.futures import ThreadPoolExecutor -from contextlib import contextmanager import pytest from fastapi.testclient import TestClient from app.main import create_app from app.models import MergeRequest -from app.service import ( - TOMBSTONE_ATTRIBUTE_KEY, - UnificationService, -) +from app.service import TOMBSTONE_ATTRIBUTE_KEY, UnificationService +from app.user_locks import InMemoryUserOperationLocks -from .mock_product_keycloak import ( - MockProductKeycloakAdminApi, -) - - -class _TestUserOperationLocks: - """Small keyed lock manager used to prove cross-path serialization.""" - - def __init__(self) -> None: - """Create an empty keyed lock registry.""" - self._guard = threading.Lock() - self._locks: dict[str, threading.RLock] = {} - - @contextmanager - def hold(self, *user_ids: str): - """Hold all requested user locks in stable order.""" - ordered_ids = sorted(set(user_ids)) - with self._guard: - locks = [ - self._locks.setdefault( - user_id, threading.RLock() - ) - for user_id in ordered_ids - ] - for lock in locks: - lock.acquire() - try: - yield - finally: - for lock in reversed(locks): - lock.release() +from .mock_product_keycloak import MockProductKeycloakAdminApi class _BlockingReplaceApi(MockProductKeycloakAdminApi): @@ -62,9 +29,7 @@ def replace_user(self, user_id, user) -> None: """Block the full representation PUT until released.""" self.replace_started.set() if not self.allow_replace.wait(timeout=5): - raise AssertionError( - "test did not release the SCIM replacement" - ) + raise AssertionError("test did not release the SCIM replacement") super().replace_user(user_id, user) self.attributes = { attribute: value @@ -77,10 +42,7 @@ def set_user_attribute( self, user_id: str, key: str, value: str ) -> None: """Signal when merge starts writing the duplicate tombstone.""" - if ( - user_id == "dup" - and key == TOMBSTONE_ATTRIBUTE_KEY - ): + if user_id == "dup" and key == TOMBSTONE_ATTRIBUTE_KEY: self.tombstone_started.set() super().set_user_attribute(user_id, key, value) @@ -97,89 +59,60 @@ def client( app.state.keycloak_api = api app.state.user_operation_locks = user_operation_locks app.state.operator_api_token = config.operator_api_token - with TestClient( - app, headers=auth_header - ) as test_client: + with TestClient(app, headers=auth_header) as test_client: yield test_client def _scim_user( - username="jane", - email="jane@corp.com", - external_id="hr-1", -): + username: str = "jane", + email: str = "jane@corp.com", + external_id: str = "hr-1", +) -> dict[str, object]: """Build one valid SCIM User resource.""" return { - "schemas": [ - "urn:ietf:params:scim:schemas:core:2.0:User" - ], + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "userName": username, "externalId": external_id, - "name": { - "givenName": "Jane", - "familyName": "Doe", - }, - "emails": [ - {"value": email, "primary": True} - ], + "name": {"givenName": "Jane", "familyName": "Doe"}, + "emails": [{"value": email, "primary": True}], "active": True, } -def test_service_provider_config(client): +def test_service_provider_config(client) -> None: """SCIM clients can discover supported protocol features.""" - response = client.get( - "/scim/v2/ServiceProviderConfig" - ) + response = client.get("/scim/v2/ServiceProviderConfig") assert response.status_code == 200 body = response.json() assert body["patch"]["supported"] is True assert body["filter"]["supported"] is True -def test_scim_create_provisions_into_keycloak( - client, api -): +def test_scim_create_provisions_into_keycloak(client, api) -> None: """SCIM create provisions a verified authoritative account.""" - response = client.post( - "/scim/v2/Users", - json=_scim_user(), - ) + response = client.post("/scim/v2/Users", json=_scim_user()) assert response.status_code == 201 body = response.json() assert body["userName"] == "jane" - assert body["emails"][0]["value"] == ( - "jane@corp.com" - ) + assert body["emails"][0]["value"] == "jane@corp.com" provisioned = api.find_user_by_username("jane") assert provisioned is not None assert provisioned.is_email_verified is True assert provisioned.external_id == "hr-1" -def test_scim_create_duplicate_conflicts(client): +def test_scim_create_duplicate_conflicts(client) -> None: """A duplicate SCIM username produces HTTP 409.""" - assert client.post( - "/scim/v2/Users", - json=_scim_user(), - ).status_code == 201 - response = client.post( - "/scim/v2/Users", - json=_scim_user(), - ) + assert client.post("/scim/v2/Users", json=_scim_user()).status_code == 201 + response = client.post("/scim/v2/Users", json=_scim_user()) assert response.status_code == 409 -def test_scim_get_and_filter_user(client): +def test_scim_get_and_filter_user(client) -> None: """Created users are retrievable directly and by username filter.""" - created = client.post( - "/scim/v2/Users", - json=_scim_user(), - ).json() + created = client.post("/scim/v2/Users", json=_scim_user()).json() - get_response = client.get( - f"/scim/v2/Users/{created['id']}" - ) + get_response = client.get(f"/scim/v2/Users/{created['id']}") filter_response = client.get( '/scim/v2/Users?filter=userName eq "jane"' ) @@ -190,40 +123,26 @@ def test_scim_get_and_filter_user(client): assert filter_response.json()["totalResults"] == 1 -def test_scim_get_unknown_user_404(client): +def test_scim_get_unknown_user_404(client) -> None: """Unknown SCIM resources produce HTTP 404.""" - response = client.get( - "/scim/v2/Users/does-not-exist" - ) + response = client.get("/scim/v2/Users/does-not-exist") assert response.status_code == 404 -def test_scim_replace_updates_user(client, api): +def test_scim_replace_updates_user(client, api) -> None: """SCIM PUT replaces the Keycloak user representation.""" - created = client.post( - "/scim/v2/Users", - json=_scim_user(), - ).json() + created = client.post("/scim/v2/Users", json=_scim_user()).json() response = client.put( f"/scim/v2/Users/{created['id']}", - json=_scim_user( - email="jane.doe@corp.com" - ), + json=_scim_user(email="jane.doe@corp.com"), ) assert response.status_code == 200 - assert api.get_user(created["id"]).email == ( - "jane.doe@corp.com" - ) + assert api.get_user(created["id"]).email == "jane.doe@corp.com" -def test_scim_replace_refuses_tombstone_resurrection( - client, api -): +def test_scim_replace_refuses_tombstone_resurrection(client, api) -> None: """A merged-away duplicate cannot be re-enabled by SCIM PUT.""" - created = client.post( - "/scim/v2/Users", - json=_scim_user(), - ).json() + created = client.post("/scim/v2/Users", json=_scim_user()).json() duplicate_id = created["id"] api.set_user_attribute( duplicate_id, @@ -248,16 +167,11 @@ def test_scim_replace_refuses_tombstone_resurrection( def test_scim_replace_is_serialized_with_merge( config, audit, auth_header -): - """Merge cannot enter between SCIM tombstone check and PUT.""" +) -> None: + """The production lock manager closes the SCIM/merge TOCTOU window.""" api = _BlockingReplaceApi() - locks = _TestUserOperationLocks() - service = UnificationService( - api, - audit, - config, - locks, - ) + locks = InMemoryUserOperationLocks() + service = UnificationService(api, audit, config, locks) app = create_app(wire=False) app.state.keycloak_api = api app.state.user_operation_locks = locks @@ -277,6 +191,7 @@ def test_scim_replace_is_serialized_with_merge( merge_invoked = threading.Event() def run_merge(): + """Start one merge that contends on the duplicate-user lock.""" merge_invoked.set() return service.merge_accounts( MergeRequest( @@ -287,9 +202,7 @@ def run_merge(): ) with ( - TestClient( - app, headers=auth_header - ) as test_client, + TestClient(app, headers=auth_header) as test_client, ThreadPoolExecutor(max_workers=2) as executor, ): scim_future = executor.submit( @@ -297,13 +210,12 @@ def run_merge(): "/scim/v2/Users/dup", json=_scim_user(username="dup"), ) + # replace_user is called after SCIM has acquired the production lock. assert api.replace_started.wait(timeout=2) merge_future = executor.submit(run_merge) assert merge_invoked.wait(timeout=2) - serialized = not api.tombstone_started.wait( - timeout=0.25 - ) + serialized = not api.tombstone_started.wait(timeout=0.25) api.allow_replace.set() response = scim_future.result(timeout=5) merge_result = merge_future.result(timeout=5) @@ -312,29 +224,20 @@ def run_merge(): assert response.status_code == 200 assert merge_result.duplicate_tombstoned is True assert api.get_user("dup").state == "disabled" - assert api.get_user_attribute( - "dup", TOMBSTONE_ATTRIBUTE_KEY - ) == "survivor" + assert api.get_user_attribute("dup", TOMBSTONE_ATTRIBUTE_KEY) == "survivor" -def test_scim_patch_deactivates_user(client, api): +def test_scim_patch_deactivates_user(client, api) -> None: """SCIM PATCH active=false disables the Keycloak account.""" - created = client.post( - "/scim/v2/Users", - json=_scim_user(), - ).json() + created = client.post("/scim/v2/Users", json=_scim_user()).json() response = client.patch( f"/scim/v2/Users/{created['id']}", json={ "schemas": [ - "urn:ietf:params:scim:" - "api:messages:2.0:PatchOp" + "urn:ietf:params:scim:api:messages:2.0:PatchOp" ], "Operations": [ - { - "op": "replace", - "value": {"active": False}, - } + {"op": "replace", "value": {"active": False}} ], }, ) @@ -343,16 +246,9 @@ def test_scim_patch_deactivates_user(client, api): assert created["id"] in api.deactivated -def test_scim_delete_deprovisions_by_disabling( - client, api -): +def test_scim_delete_deprovisions_by_disabling(client, api) -> None: """SCIM DELETE performs a non-destructive soft deprovision.""" - created = client.post( - "/scim/v2/Users", - json=_scim_user(), - ).json() - response = client.delete( - f"/scim/v2/Users/{created['id']}" - ) + created = client.post("/scim/v2/Users", json=_scim_user()).json() + response = client.delete(f"/scim/v2/Users/{created['id']}") assert response.status_code == 204 assert created["id"] in api.deactivated From 815f7bb9fff9a8baed24f2fe382133ccf4911f96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 15:02:45 +0900 Subject: [PATCH 084/104] test(deploy): protect durable state and immutable image contracts --- .../tests/test_deployment_contracts.py | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 services/account_unification/tests/test_deployment_contracts.py diff --git a/services/account_unification/tests/test_deployment_contracts.py b/services/account_unification/tests/test_deployment_contracts.py new file mode 100644 index 0000000..dcc68f4 --- /dev/null +++ b/services/account_unification/tests/test_deployment_contracts.py @@ -0,0 +1,78 @@ +"""Static deployment contract tests for Compose and Helm packaging.""" +from __future__ import annotations + +from pathlib import Path + +import yaml + + +def _repository_root() -> Path: + """Return the repository root from the account-unification tests.""" + return Path(__file__).resolve().parents[3] + + +def test_compose_persists_account_unification_state() -> None: + """Standalone restarts retain audit and user-operation lock databases.""" + compose = yaml.safe_load( + (_repository_root() / "docker-compose.yml").read_text(encoding="utf-8") + ) + service = compose["services"]["account_unification_service"] + assert ( + "account_unification_data:/var/lib/account-unification" + in service["volumes"] + ) + assert "account_unification_data" in compose["volumes"] + + +def test_helm_can_fail_closed_on_missing_account_image_digest() -> None: + """Production values can require an immutable account-service image.""" + values = yaml.safe_load( + (_repository_root() / "helm" / "cwl-idp" / "values.yaml").read_text( + encoding="utf-8" + ) + ) + image = values["accountUnification"]["image"] + assert image["requireDigest"] is False + template = ( + _repository_root() + / "helm" + / "cwl-idp" + / "templates" + / "account-unification.yaml" + ).read_text(encoding="utf-8") + assert "accountUnification.image.requireDigest" in template + assert "accountUnification.image.digest is required" in template + + +def test_helm_mounts_durable_account_unification_storage() -> None: + """The chart mounts deployment-owned state at the service data path.""" + values = yaml.safe_load( + (_repository_root() / "helm" / "cwl-idp" / "values.yaml").read_text( + encoding="utf-8" + ) + ) + persistence = values["accountUnification"]["persistence"] + assert persistence["enabled"] is True + assert persistence["size"] + template = ( + _repository_root() + / "helm" + / "cwl-idp" + / "templates" + / "account-unification.yaml" + ).read_text(encoding="utf-8") + assert "kind: PersistentVolumeClaim" in template + assert "mountPath: /var/lib/account-unification" in template + + +def test_local_seed_avoids_global_temporary_audit_storage() -> None: + """Development defaults stay inside the project bootstrap directory.""" + seed_tool = ( + _repository_root() + / "services" + / "account_unification" + / "tools" + / "seed_config_store.py" + ).read_text(encoding="utf-8") + assert "/tmp/keyverse-account-audit.sqlite3" not in seed_tool + assert "account_unification_audit.sqlite3" in seed_tool From c1b64a75c9961169635f28033acc1234b727d567 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 15:03:23 +0900 Subject: [PATCH 085/104] docs(changelog): record passwordless commercial hardening --- CHANGELOG.md | 56 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24efc86..774d92a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,30 +7,54 @@ Keep a Changelog, and releases use semantic versioning. ### Added -- Modular product-facing Keycloak Admin API extensions for self-registration, - passwordless credential retirement, and runtime identity-provider federation. -- Runtime federation desired-state convergence with operator-response secret - redaction. -- Router-level validation for decoded privileged and SCIM path parameters. -- Concurrency tests for SQLite-backed configuration and audit persistence. +- Password-free headless registration that sends one bounded Keycloak action + email for address verification and passkey enrollment, with failure-atomic + account rollback. +- Modular product-facing Keycloak Admin API extensions for registration and + runtime identity-provider federation. +- Runtime federation desired-state convergence with explicit applied-state + reporting and fail-closed operator-response redaction. +- Router-level validation for decoded privileged and SCIM path parameters, + including protocol-native SCIM error responses. +- Persistent Compose and Helm storage for audit and user-operation lock data. +- Optional Helm enforcement of immutable account-unification image digests. +- Concurrency and lifecycle regressions for SQLite-backed configuration, audit, + and mutation-lock persistence. ### Changed +- The bound Keycloak browser flow is now strictly passkey-only; registration no + longer creates a bootstrap password or runs a credential janitor. +- The public `naruon-web` access-token lifespan is reduced to five minutes while + the longer SSO session remains available through token refresh and reissue. +- Registration configuration is all-or-nothing and requires a distinct bearer + token, relying-party client, HTTPS redirect URI, and bounded action-link + lifetime. +- Registration throttling is isolated by direct peer address rather than one + process-wide counter. - Account merge and SCIM replacement now share the same user-operation lock boundary. -- SQLite configuration and audit stores now support safe multi-threaded access - with WAL mode and bounded busy timeouts. -- Application shutdown now cancels background work and closes Keycloak, - configuration, and audit resources deterministically. +- SQLite configuration and audit stores support safe multi-threaded access with + WAL mode and bounded busy timeouts. +- Application shutdown closes Keycloak, audit, and configuration resources and + removes temporary test-only mutation-lock sidecars deterministically. - Keycloak Admin API requests refresh an expired bearer token once, including - account creation. + account creation and action-email enrollment. ### Fixed -- Prevented bootstrap-account orphans by rolling back failed registration - initialization. -- Prevented federation credentials from being echoed through list, get, and - update responses. +- Prevented registration races from surfacing raw Keycloak duplicate-user + errors by mapping exact HTTP 409 responses to a stable product conflict. +- Prevented unusable registration orphans by deleting accounts when Keycloak + rejects the verification/passkey action email. +- Prevented external network calls from executing while the federation desired- + state storage lock is held. +- Prevented unknown federation configuration keys, credentials, and private + values from being echoed through list, get, or update responses. +- Rejected Unicode-confusable federation aliases outside the explicit ASCII + slug alphabet. +- Raised non-success health responses correctly in the restricted stdlib HTTP + opener. - Replaced a potentially expensive registration email regular expression with deterministic bounded parsing. -- Removed sensitive credential terminology from operational log messages. +- Made standalone audit history survive container replacement. From 6a311a5bd1264edd05d9a6791590666f5ac46530 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 15:04:13 +0900 Subject: [PATCH 086/104] docs(design): record password-free commercial architecture --- ...08-03-keyverse-product-hardening-design.md | 132 ++++++++++++------ 1 file changed, 86 insertions(+), 46 deletions(-) diff --git a/docs/superpowers/specs/2026-08-03-keyverse-product-hardening-design.md b/docs/superpowers/specs/2026-08-03-keyverse-product-hardening-design.md index 50b62fb..7703c46 100644 --- a/docs/superpowers/specs/2026-08-03-keyverse-product-hardening-design.md +++ b/docs/superpowers/specs/2026-08-03-keyverse-product-hardening-design.md @@ -2,9 +2,10 @@ ## Objective -Integrate the Keycloak 26, runtime federation, self-registration, SCIM -serialization, and account-merge work into one releasable identity service -without weakening authentication, auditability, or supply-chain controls. +Integrate Keycloak 26 compatibility, passwordless registration, runtime +federation, SCIM serialization, and account merge into one releasable identity +service without weakening authentication, auditability, deployment durability, +or supply-chain controls. ## Product gaps addressed @@ -12,24 +13,26 @@ without weakening authentication, auditability, or supply-chain controls. account merge, registration, and federation modified the same service boundaries independently. The integrated design keeps the merge/SCIM core interface small and adds a separate product-facing Keycloak adapter. -2. **Registration could leave orphaned accounts.** User creation is followed by - credential and required-action setup. Failed initialization now deletes the - newly created account and returns a stable gateway error. -3. **Federation APIs could disclose provider credentials.** Desired state and - Keycloak still receive the complete configuration, while every operator - response is projected through a redacted view. -4. **SQLite objects were unsafe under threaded ASGI execution.** Configuration - and audit connections now use re-entrant process locks, WAL mode, bounded - busy timeouts, and cross-thread connections. Cross-process user mutations - remain serialized by the dedicated SQLite lock sidecar. -5. **Decoded route values could reach path builders.** Privileged and SCIM +2. **Registration contradicted the passkey-only claim.** The previous bootstrap + password was reachable from the bound browser flow. Registration now creates + no password and uses a bounded Keycloak action-email link for address + verification and passkey enrollment. +3. **Registration could leave orphaned accounts.** Failure to send the action + email deletes the new account; cleanup failure has its own stable error. +4. **Federation APIs could disclose provider credentials or stall concurrent + configuration reads.** Unknown values are redacted by default, storage locks + are released before network calls, and convergence status is explicit. +5. **SQLite objects were unsafe under threaded ASGI execution.** Configuration + and audit connections use re-entrant process locks, WAL mode, bounded busy + timeouts, and cross-thread connections. Cross-process user mutations remain + serialized by a dedicated SQLite lock sidecar. +6. **Decoded route values could reach path builders.** Privileged and SCIM routers validate every decoded path parameter before endpoint dependencies. -6. **Expired Keycloak tokens could break non-GET operations.** The product - adapter retries exactly once after HTTP 401 for every transport method, - including account creation. -7. **Background resources were not closed deterministically.** Lifespan - shutdown cancels and awaits the credential janitor, then closes Keycloak, - audit, and configuration resources. +7. **Runtime state disappeared on container replacement.** Compose and Helm now + mount deployment-owned storage for audit and lock databases. +8. **Mutable production images were easy to deploy accidentally.** The chart can + require an immutable account-unification digest and fail template rendering + when it is absent. ## Architecture @@ -42,40 +45,74 @@ product signup or federation configuration. ### Product extension adapter `ProductAdminApi` extends the core contract only for product capabilities: -credential lifecycle, user pagination and rollback deletion, and identity -provider CRUD. `ProductHttpAdminApi` subclasses the core HTTP adapter so both -modules share authentication and model translation while keeping concerns -separable. +rollback deletion, one-time action-email enrollment, and identity-provider +CRUD. `ProductHttpAdminApi` subclasses the core HTTP adapter so both modules +share service-account authentication and model translation while keeping +product concerns separable. + +The adapter allows only known Keycloak Admin REST route shapes. Every dynamic +realm, user, client, credential, group, or provider value is validated as one +opaque segment before interpolation. An expired bearer token is refreshed and +retried exactly once for every transport method. + +### Password-free registration lifecycle + +A bearer token distinct from operator authority gates `/registration`. +Registration: + +1. verifies complete action-email configuration; +2. applies caller-keyed abuse throttling; +3. normalizes and validates identity/profile input; +4. checks for an existing email; +5. creates a password-free Keycloak account; +6. requests `VERIFY_EMAIL` and `webauthn-register-passwordless` through + `execute-actions-email` with an HTTPS redirect and bounded lifespan; +7. deletes the account if step 6 fails. + +The bound `browserFlow` contains no password authenticator. The public +`naruon-web` access token lasts 300 seconds; the SSO session can remain longer +because products obtain new access tokens through normal refresh/reissue. ### Runtime federation -The KV/DB store is the source of truth. `FederationService` validates and stores -the full desired representation, converges Keycloak under one process lock, -and returns `IdentityProviderView`, whose sensitive configuration values are -redacted. The store namespace is `federation_identity_providers`. +The KV/DB store is the desired-state source of truth. `FederationService` +validates and persists the complete provider representation under the two-word +namespace `federation_identity_providers`. It snapshots state while holding the +storage lock, releases that lock before Keycloak network calls, and serializes +convergence separately. -### Registration lifecycle - -A dedicated bearer token gates `/registration`. Registration validates and -normalizes input, rejects duplicates, creates the account, installs the -bootstrap credential, and requires passwordless WebAuthn enrollment. Any -failure after creation triggers account deletion. The bounded janitor removes -password credentials only after a passwordless WebAuthn credential exists. +Operator views expose only explicitly allowlisted non-secret configuration +keys. Unknown keys are ``, so future Keycloak credential fields cannot +silently leak. A failed apply retains desired state and returns +`applied_to_keycloak=false`, allowing an operator or automation to retry +`identity-providers:apply` after recovery. ### Mutation serialization Account merge and SCIM full replacement both acquire `UserOperationLocks`. -Standalone deployments use `user_operation_lock_state` in a dedicated SQLite -sidecar and `BEGIN IMMEDIATE`; future clustered deployments can provide a -PostgreSQL advisory-lock implementation behind the same protocol. +Standalone deployments use the two-word table `user_operation_lock_state` in a +dedicated SQLite sidecar and `BEGIN IMMEDIATE`. Persistent audit deployments +place that sidecar next to the audit database; in-memory tests receive a secure +temporary file that lifecycle cleanup removes. + +### Persistence and packaging + +The standalone Compose service mounts `account_unification_data` at +`/var/lib/account-unification`. The Helm chart creates a PVC by default and +mounts the same path. Production values set +`accountUnification.image.requireDigest=true`; chart rendering then fails unless +an immutable digest is supplied. ### Error and security behavior - Operator and registration tokens are separate and compared in constant time. - `/healthz` remains unauthenticated. -- Unsafe path values return HTTP 400; SCIM uses an RFC 7644 error envelope. -- Provider secrets are never returned in API representations. -- Registration failures return stable, non-internal error identifiers. +- The restricted stdlib health opener supports only HTTP(S), rejects redirects + to other schemes, and raises on non-success responses. +- Unsafe admin values return HTTP 400; unsafe SCIM values return a root-level + RFC 7644 body with `application/scim+json`. +- Provider secrets and unknown provider values never enter API representations. +- Registration failures return stable, non-internal identifiers. - No database object uses a single-word or numeric-only name. ## Verification @@ -85,12 +122,15 @@ The acceptance gate is: - locked dependency installation; - Ruff linting; - 100% application docstring coverage; -- complete pytest suite, including race and threaded SQLite tests; -- realm and Docker Compose validation; -- CodeQL, Semgrep, container security, and central coverage checks. +- 100% production statement and branch coverage; +- complete pytest suite, including race, lifecycle, and threaded SQLite tests; +- realm, Docker Compose, and Helm validation; +- CodeQL, Semgrep, container security, and central coverage checks on the exact + reviewed head. ## Release boundary This integration remains in the Unreleased changelog until all protected checks -and review gates pass on the final merged main branch. The release step then -bumps the service and lock metadata together and publishes the matching tag. +and independent review gates pass on the exact current head. The release step +then bumps service and lock metadata together, verifies artifacts and +provenance, publishes the matching tag, and rechecks the open-PR queue. From 0e8c5a8edbcd8d78b04ac90eff8fd3b355e03dc3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 15:05:11 +0900 Subject: [PATCH 087/104] docs(plan): align product hardening completion record --- .../2026-08-03-keyverse-product-hardening.md | 134 ++++++++++-------- 1 file changed, 77 insertions(+), 57 deletions(-) diff --git a/docs/superpowers/plans/2026-08-03-keyverse-product-hardening.md b/docs/superpowers/plans/2026-08-03-keyverse-product-hardening.md index 242240e..7d458cd 100644 --- a/docs/superpowers/plans/2026-08-03-keyverse-product-hardening.md +++ b/docs/superpowers/plans/2026-08-03-keyverse-product-hardening.md @@ -6,24 +6,28 @@ > checkbox (`- [ ]`) syntax for tracking. **Goal:** Produce one protected, release-ready Keyverse identity-service change -that integrates registration, federation, SCIM serialization, and merge -hardening. +that integrates passwordless registration, federation, SCIM serialization, +merge hardening, and durable deployment packaging. **Architecture:** Preserve the minimal merge/SCIM `AdminApi` contract and add a -separate product adapter for registration and federation. Apply authentication -and path validation at router boundaries, use redacted federation response -models, and serialize standalone persistence and user mutations. +separate product adapter for one-time passkey action email and federation. Apply +authentication and path validation at router boundaries, redact unknown +federation values by default, and separate storage locks from network +convergence. **Tech Stack:** Python 3.11+, FastAPI, Pydantic 2, httpx, SQLite, pytest, Ruff, -Interrogate, Keycloak Admin REST API. +Interrogate, Keycloak Admin REST API, Docker Compose, Helm. ## Global Constraints - Application docstring coverage must remain 100%. +- Production statement and branch coverage must remain 100%. - Every database object name must contain at least two words and use snake_case. - Runtime dependencies and GitHub Actions remain locked and pinned. - The service must work standalone and as an importable MSA module. -- No federation or registration secret may appear in an HTTP response or log. +- No federation or registration secret may appear in an HTTP response, log, or + process argument. +- The bound browser flow contains no password authenticator. --- @@ -38,29 +42,35 @@ Interrogate, Keycloak Admin REST API. - Produces: `ProductAdminApi`, `ProductHttpAdminApi` - [x] Add a protocol test that enumerates every declared public method. -- [x] Verify the test fails before the product adapter exists. -- [x] Implement credential, user-pagination, rollback deletion, and federation - CRUD methods. +- [x] Implement action-email enrollment, rollback deletion, and federation CRUD. - [x] Add one-shot HTTP 401 retry coverage for GET and user creation. - [x] Add unsafe-path tests that prove transport calls are not emitted. +- [x] Validate HTTPS redirect, client ID, action aliases, and link lifespan before + sending action email. -### Task 2: Make registration failure-atomic +### Task 2: Make registration password-free and failure-atomic **Files:** - Modify: `services/account_unification/app/registration.py` +- Modify: `services/account_unification/app/config.py` - Modify: `services/account_unification/tests/test_registration.py` +- Modify: `services/account_unification/tests/test_config.py` **Interfaces:** - Consumes: `ProductAdminApi` -- Produces: `_initialize_account`, `RegistrationResult`, `JanitorResult` +- Produces: `_initialize_account`, `RegistrationResult` -- [x] Add a test in which required-action setup fails after user creation. -- [x] Verify the test observes an orphan before rollback is implemented. -- [x] Delete the new account on initialization failure. +- [x] Reject legacy password fields at the API boundary. +- [x] Create a Keycloak account without a password. +- [x] Send `VERIFY_EMAIL` and `webauthn-register-passwordless` in one bounded + action email. +- [x] Delete the new account when Keycloak rejects enrollment. - [x] Preserve a distinct error when rollback itself fails. -- [x] Re-run duplicate, validation, token-isolation, and janitor tests. +- [x] Map exact Keycloak duplicate-user 409 responses to the product conflict. +- [x] Isolate abuse throttling by direct caller address. +- [x] Remove the credential janitor, background loop, and realm-wide endpoint. -### Task 3: Redact runtime federation secrets +### Task 3: Redact and reconcile runtime federation safely **Files:** - Modify: `services/account_unification/app/federation.py` @@ -70,81 +80,91 @@ Interrogate, Keycloak Admin REST API. - Consumes: `KvStore`, `ProductAdminApi` - Produces: `IdentityProviderView`, `IdentityProviderStatus` -- [x] Add tests showing that storage and Keycloak receive `clientSecret`. -- [x] Add tests requiring PUT, list, and get responses to contain ``. -- [x] Implement deterministic secret-key detection and redacted response views. +- [x] Prove storage and Keycloak receive complete desired state. +- [x] Redact every unknown provider configuration key by default. - [x] Bound aliases, provider IDs, config entry counts, keys, and values. -- [x] Serialize desired-state convergence under one process lock. +- [x] Restrict aliases to an explicit ASCII slug alphabet. +- [x] Snapshot desired state under the storage lock and release it before network + calls. +- [x] Retain desired state and return `applied_to_keycloak=false` when + convergence fails. -### Task 4: Harden router path handling +### Task 4: Harden protocol boundaries **Files:** -- Create: `services/account_unification/app/path_security.py` +- Modify: `services/account_unification/app/path_security.py` +- Modify: `services/account_unification/app/healthcheck.py` - Modify: `services/account_unification/app/main.py` - Test: `services/account_unification/tests/test_path_security.py` +- Test: `services/account_unification/tests/test_healthcheck.py` -**Interfaces:** -- Consumes: `validate_path_segment` -- Produces: `admin_path_security_dependency`, - `scim_path_security_dependency` - -- [x] Add HTTP tests using double-encoded separators and traversal values. -- [x] Verify privileged paths reach service dependencies before hardening. - [x] Validate all decoded route parameters at router entry. -- [x] Return an RFC 7644-shaped error for SCIM paths. +- [x] Return a root-level RFC 7644 body with `application/scim+json`. - [x] Keep `/healthz` outside privileged dependencies. +- [x] Restrict health probes to HTTP(S) and HTTP(S) redirects. +- [x] Register the default urllib HTTP error handler so non-success responses + raise instead of returning a null response. -### Task 5: Make standalone persistence thread-safe +### Task 5: Make standalone persistence thread-safe and durable **Files:** - Modify: `services/account_unification/app/kv_store.py` - Modify: `services/account_unification/app/audit.py` +- Modify: `docker-compose.yml` +- Modify: `helm/cwl-idp/values.yaml` +- Modify: `helm/cwl-idp/templates/account-unification.yaml` - Test: `services/account_unification/tests/test_storage_concurrency.py` - -**Interfaces:** -- Produces: thread-safe `SqliteKvStore`, `SqliteAuditSink` +- Test: `services/account_unification/tests/test_deployment_contracts.py` - [x] Add concurrent writer/reader tests using eight worker threads. -- [x] Verify default SQLite thread affinity fails the tests. - [x] Add process-local re-entrant locks and cross-thread connections. - [x] Configure WAL, normal synchronous mode, and a ten-second busy timeout. - [x] Close connections under the same lock. +- [x] Mount persistent standalone and Kubernetes account-unification data. +- [x] Allow production Helm values to require an immutable image digest. ### Task 6: Integrate lifecycle and mutation serialization **Files:** - Modify: `services/account_unification/app/main.py` -- Modify: `services/account_unification/tests/conftest.py` -- Modify: `services/account_unification/tests/test_api.py` +- Modify: `services/account_unification/tests/test_lifecycle.py` - Modify: `services/account_unification/tests/test_scim.py` +- Modify: `services/account_unification/tests/test_user_locks.py` -**Interfaces:** -- Consumes: `SqliteUserOperationLocks`, `FederationService`, - `ProductHttpAdminApi` -- Produces: fully wired FastAPI lifespan - -- [x] Wire merge and SCIM replacement to the same lock dependency. -- [x] Add the deterministic SCIM/merge race test. -- [x] Cancel and await the janitor during shutdown. +- [x] Wire merge and SCIM replacement to the same production lock dependency. +- [x] Add the deterministic SCIM/merge race test using the production in-memory + manager. +- [x] Use a secure temporary sidecar for in-memory audit tests. +- [x] Remove test-only sidecars at shutdown without deleting persistent data. - [x] Close API, audit, and config resources. -- [x] Authenticate all privileged test clients. -### Task 7: Protected verification and release preparation +### Task 7: Enforce Keycloak realm policy **Files:** -- Modify: `CHANGELOG.md` -- Create: `docs/superpowers/specs/2026-08-03-keyverse-product-hardening-design.md` -- Create: `docs/superpowers/plans/2026-08-03-keyverse-product-hardening.md` +- Modify: `deploy/keycloak/realm-cwl.json` +- Modify: `scripts/validate_realm.py` +- Test: `services/account_unification/tests/test_realm_policy.py` -**Interfaces:** -- Produces: reviewable design, implementation record, and release notes +- [x] Remove all password authenticators reachable from the bound browser flow. +- [x] Require the passwordless WebAuthn authenticator and enrollment action. +- [x] Set the public Naruon access-token lifetime to 300 seconds. +- [x] Reject public-client lifetimes above 900 seconds. +- [x] Keep the reusable RP template independent of the Naruon hostname. + +### Task 8: Protected verification and release preparation + +**Files:** +- Modify: `CHANGELOG.md` +- Modify: `docs/superpowers/specs/2026-08-03-keyverse-product-hardening-design.md` +- Modify: `docs/superpowers/plans/2026-08-03-keyverse-product-hardening.md` - [x] Record product gaps and architectural decisions. -- [x] Run syntax validation for every created or replaced Python file. - [ ] Run `uv sync --locked --extra dev`. - [ ] Run `uv run ruff check app tests tools`. - [ ] Run `uv run interrogate .` and require 100%. -- [ ] Run `uv run pytest -q`. -- [ ] Require realm, Compose, CodeQL, Semgrep, security, and coverage checks. -- [ ] Merge only after the protected current-head checks pass. +- [ ] Run `uv run pytest -q` with 100% production statement/branch coverage. +- [ ] Require realm, Compose, Helm, CodeQL, Semgrep, security, and central + coverage checks on the exact current head. +- [ ] Resolve review threads only after the corresponding finding is addressed. +- [ ] Merge only after protected policy is satisfied. - [ ] Bump service and lock versions together and publish the release tag. From 413e6bfd973356fc8ad1b33a63850030b2edd278 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 15:07:19 +0900 Subject: [PATCH 088/104] test(federation): prove network calls do not hold storage lock --- .../tests/test_federation.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/services/account_unification/tests/test_federation.py b/services/account_unification/tests/test_federation.py index 3413c06..c12c01a 100644 --- a/services/account_unification/tests/test_federation.py +++ b/services/account_unification/tests/test_federation.py @@ -2,6 +2,8 @@ from __future__ import annotations import json +import threading +from concurrent.futures import ThreadPoolExecutor import pytest from fastapi import HTTPException @@ -101,6 +103,52 @@ def fail_create(*args, **kwargs) -> None: assert store.get(FEDERATION_PROVIDER_NAMESPACE, "employer-adfs") is not None +def test_status_network_call_does_not_hold_desired_state_lock( + store, api, monkeypatch +) -> None: + """A slow Keycloak status call does not block another stored-state read.""" + registration = _employer_adfs_registration() + store.put( + FEDERATION_PROVIDER_NAMESPACE, + registration.provider_alias, + registration.model_dump_json(), + ) + federation = FederationService(store, api) + first_call_started = threading.Event() + release_first_call = threading.Event() + second_call_started = threading.Event() + call_guard = threading.Lock() + call_count = 0 + + def blocking_status(provider_alias: str): + """Block the first network call and signal entry into the second.""" + nonlocal call_count + with call_guard: + call_count += 1 + current_call = call_count + if current_call == 1: + first_call_started.set() + assert release_first_call.wait(timeout=5) + else: + second_call_started.set() + return None + + monkeypatch.setattr(api, "get_identity_provider", blocking_status) + + with ThreadPoolExecutor(max_workers=2) as executor: + list_future = executor.submit(federation.list_registrations) + assert first_call_started.wait(timeout=2) + get_future = executor.submit( + federation.get_registration, "employer-adfs" + ) + second_reached_network = second_call_started.wait(timeout=0.5) + release_first_call.set() + list_future.result(timeout=5) + get_future.result(timeout=5) + + assert second_reached_network + + def test_apply_all_reconverges_after_realm_rebuild(federation, api) -> None: """Stored desired state recreates providers after realm loss.""" federation.put_registration( From b1f2d869166fc69b28aa3077491f0f9ef907ac44 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 15:11:20 +0900 Subject: [PATCH 089/104] test(scim): make merge lock contention proof deterministic --- .../account_unification/tests/test_scim.py | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/services/account_unification/tests/test_scim.py b/services/account_unification/tests/test_scim.py index 95d7573..874cd50 100644 --- a/services/account_unification/tests/test_scim.py +++ b/services/account_unification/tests/test_scim.py @@ -3,6 +3,7 @@ import threading from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager import pytest from fastapi.testclient import TestClient @@ -23,7 +24,6 @@ def __init__(self) -> None: super().__init__() self.replace_started = threading.Event() self.allow_replace = threading.Event() - self.tombstone_started = threading.Event() def replace_user(self, user_id, user) -> None: """Block the full representation PUT until released.""" @@ -38,14 +38,6 @@ def replace_user(self, user_id, user) -> None: } self.deactivated.discard(user_id) - def set_user_attribute( - self, user_id: str, key: str, value: str - ) -> None: - """Signal when merge starts writing the duplicate tombstone.""" - if user_id == "dup" and key == TOMBSTONE_ATTRIBUTE_KEY: - self.tombstone_started.set() - super().set_user_attribute(user_id, key, value) - @pytest.fixture def client( @@ -166,11 +158,23 @@ def test_scim_replace_refuses_tombstone_resurrection(client, api) -> None: def test_scim_replace_is_serialized_with_merge( - config, audit, auth_header + config, audit, auth_header, monkeypatch ) -> None: """The production lock manager closes the SCIM/merge TOCTOU window.""" api = _BlockingReplaceApi() locks = InMemoryUserOperationLocks() + merge_lock_attempted = threading.Event() + original_hold = locks.hold + + @contextmanager + def observed_hold(*user_ids: str): + """Record the merge's lock attempt while delegating to production code.""" + if set(user_ids) == {"survivor", "dup"}: + merge_lock_attempted.set() + with original_hold(*user_ids): + yield + + monkeypatch.setattr(locks, "hold", observed_hold) service = UnificationService(api, audit, config, locks) app = create_app(wire=False) app.state.keycloak_api = api @@ -188,11 +192,9 @@ def test_scim_replace_is_serialized_with_merge( email="jane@corp.com", is_email_verified=True, ) - merge_invoked = threading.Event() def run_merge(): """Start one merge that contends on the duplicate-user lock.""" - merge_invoked.set() return service.merge_accounts( MergeRequest( survivor_user_id="survivor", @@ -210,17 +212,17 @@ def run_merge(): "/scim/v2/Users/dup", json=_scim_user(username="dup"), ) - # replace_user is called after SCIM has acquired the production lock. + # replace_user is called only after SCIM has acquired the production lock. assert api.replace_started.wait(timeout=2) merge_future = executor.submit(run_merge) - assert merge_invoked.wait(timeout=2) + # The hook fires immediately before the same production hold blocks on dup. + assert merge_lock_attempted.wait(timeout=2) + assert not merge_future.done() - serialized = not api.tombstone_started.wait(timeout=0.25) api.allow_replace.set() response = scim_future.result(timeout=5) merge_result = merge_future.result(timeout=5) - assert serialized assert response.status_code == 200 assert merge_result.duplicate_tombstoned is True assert api.get_user("dup").state == "disabled" From e821db5cdaf90c4b83415c2c8403bd6526980a4d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 15:12:54 +0900 Subject: [PATCH 090/104] docs(plan): mark review remediation implementation progress --- .../2026-08-03-keyverse-review-remediation.md | 109 ++++++++++++------ 1 file changed, 72 insertions(+), 37 deletions(-) diff --git a/docs/superpowers/plans/2026-08-03-keyverse-review-remediation.md b/docs/superpowers/plans/2026-08-03-keyverse-review-remediation.md index 18a0b39..a4a2093 100644 --- a/docs/superpowers/plans/2026-08-03-keyverse-review-remediation.md +++ b/docs/superpowers/plans/2026-08-03-keyverse-review-remediation.md @@ -1,12 +1,21 @@ # Keyverse Review Remediation Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. -**Goal:** Resolve every valid current-head review blocker without weakening Keyverse's passwordless, audit, security, or modularity contracts. +**Goal:** Resolve every valid current-head review blocker without weakening +Keyverse's passwordless, audit, security, or modularity contracts. -**Architecture:** Replace the bound-flow bootstrap password with Keycloak's action-email passkey enrollment path; keep registration and operator privileges separate; move network calls outside process locks; return protocol-native errors; and fail closed on unknown secrets, aliases, paths, and deployment configuration. +**Architecture:** Replace the bound-flow bootstrap password with Keycloak's +action-email passkey enrollment path; keep registration and operator privileges +separate; move network calls outside storage locks; return protocol-native +errors; and fail closed on unknown secrets, aliases, paths, and deployment +configuration. -**Tech Stack:** Python 3.11+, FastAPI, Pydantic 2, httpx, SQLite, Keycloak Admin REST API, pytest, Ruff, Interrogate, Helm, Docker Compose. +**Tech Stack:** Python 3.11+, FastAPI, Pydantic 2, httpx, SQLite, Keycloak Admin +REST API, pytest, Ruff, Interrogate, Helm, Docker Compose. ## Global Constraints @@ -33,14 +42,20 @@ - Modify: `services/account_unification/tests/mock_product_keycloak.py` **Interfaces:** -- Produces: `ProductAdminApi.send_execute_actions_email(user_id, action_aliases, client_id, redirect_uri, lifespan_seconds) -> None` -- Produces: registration configuration for client ID, redirect URI, and finite positive action-link lifespan. - -- [ ] Write failing tests proving the bound browser flow rejects `auth-password-form`, public-client access tokens are capped, registration creates no password, and passkey action-email failure rolls the account back. -- [ ] Run focused tests and confirm they fail for the expected missing behavior. -- [ ] Remove the password execution and validator exception; set `naruon-web` access-token lifespan to 300 seconds and validate a 900-second maximum. -- [ ] Replace `initial_password` with action-email enrollment and add the Keycloak Admin REST adapter method. -- [ ] Remove the credential janitor, its configuration, background task, and privileged endpoint. +- Produces: `ProductAdminApi.send_execute_actions_email(user_id, action_aliases, + client_id, redirect_uri, lifespan_seconds) -> None` +- Produces: registration configuration for client ID, redirect URI, and finite + positive action-link lifespan. + +- [x] Write regression tests proving the bound browser flow rejects + `auth-password-form`, public-client access tokens are capped, registration + creates no password, and passkey action-email failure rolls the account back. +- [x] Remove the password execution and validator exception; set `naruon-web` + access-token lifespan to 300 seconds and validate a 900-second maximum. +- [x] Replace `initial_password` with action-email enrollment and add the + Keycloak Admin REST adapter method. +- [x] Remove the credential janitor, its configuration, background task, and + privileged endpoint. - [ ] Run focused registration, adapter, config, realm, and lifecycle tests. ### Task 2: Registration abuse and race boundaries @@ -53,10 +68,12 @@ - Produces: `reset_rate_limit_state() -> None` - Produces: caller-keyed fixed-window registration limiting. -- [ ] Write failing tests proving one client cannot exhaust another client's quota and Keycloak 409 maps to `email_already_registered`. -- [ ] Run the focused tests and confirm the expected failures. -- [ ] Store rate-limit windows per client address under one lock and expose a test reset helper. -- [ ] Catch only `httpx.HTTPStatusError` with status 409; re-raise every other transport error. +- [x] Write regression tests proving one client cannot exhaust another client's + quota and Keycloak 409 maps to `email_already_registered`. +- [x] Store rate-limit windows per client address under one lock and expose a + test reset helper. +- [x] Catch only `httpx.HTTPStatusError` with status 409; re-raise every other + transport error. - [ ] Run the focused registration suite. ### Task 3: Federation reconciliation and secret safety @@ -67,13 +84,17 @@ **Interfaces:** - Preserves: `IdentityProviderStatus` -- Produces: safe-key allowlist redaction in which unknown config keys are redacted. - -- [ ] Write failing tests for non-ASCII aliases, unknown-key redaction, persisted-but-unapplied status, and network calls outside `RLock`. -- [ ] Run the focused tests and confirm the expected failures. -- [ ] Snapshot stored registrations under the lock, then perform Keycloak calls after releasing it. -- [ ] Return `applied_to_keycloak=False` when desired state was stored but convergence failed. -- [ ] Validate aliases against explicit ASCII alphabets and redact every config key not explicitly classified safe. +- Produces: safe-key allowlist redaction in which unknown config keys are + redacted. + +- [x] Write regressions for non-ASCII aliases, unknown-key redaction, + persisted-but-unapplied status, and storage-lock-free network calls. +- [x] Snapshot stored registrations under the storage lock, then perform + Keycloak calls after releasing it. +- [x] Return `applied_to_keycloak=False` when desired state was stored but + convergence failed. +- [x] Validate aliases against explicit ASCII alphabets and redact every config + key not explicitly classified safe. - [ ] Run the federation suite. ### Task 4: Protocol and runtime correctness @@ -85,14 +106,20 @@ - Modify: `services/account_unification/tests/test_healthcheck.py` - Modify: `services/account_unification/tests/test_path_security.py` - Modify: `services/account_unification/tests/test_user_locks.py` +- Create: `services/account_unification/tests/test_lifecycle.py` **Interfaces:** -- Produces: `ScimPathValidationError` and `scim_path_validation_exception_handler`. - -- [ ] Write failing tests for HTTP 503 probe handling, root-level SCIM error envelopes with `application/scim+json`, and `:memory:` lock wiring. -- [ ] Run focused tests and confirm the expected failures. -- [ ] Register `HTTPDefaultErrorHandler`, add the SCIM-specific exception handler, and use an explicit temporary lock file for in-memory audit configurations. -- [ ] Add missing function docstrings and run the focused suites. +- Produces: `ScimPathValidationError` and + `scim_path_validation_exception_handler`. + +- [x] Write regressions for HTTP error handling, root-level SCIM error envelopes + with `application/scim+json`, and `:memory:` lock wiring. +- [x] Register `HTTPDefaultErrorHandler`, add the SCIM-specific exception + handler, and use an explicit temporary lock file for in-memory audit + configurations. +- [x] Add missing function docstrings and deterministic temporary-resource + cleanup. +- [ ] Run the focused health, path, and lifecycle suites. ### Task 5: Deployment durability and review hygiene @@ -107,14 +134,18 @@ - Modify: `services/account_unification/tests/test_federation.py` - Modify: `services/account_unification/tests/test_kcadm_bootstrap.py` - Modify: `services/account_unification/tests/test_storage_concurrency.py` +- Create: `services/account_unification/tests/test_deployment_contracts.py` **Interfaces:** -- Produces: persistent Compose audit volume and optional Helm digest enforcement. +- Produces: persistent Compose/Helm audit volume and optional Helm digest + enforcement. -- [ ] Add contract tests or static assertions for persistent audit storage, digest enforcement, non-temporary seed defaults, and stable bootstrap markers. -- [ ] Run focused tests and confirm the expected failures. -- [ ] Add the named volume, `requireDigest` render guard, project-local seed path, fixture-safe tests, context-managed SQLite test resources, and truthful documentation. -- [ ] Run focused tests and configuration rendering checks. +- [x] Add contract tests for persistent audit storage, digest enforcement, + non-temporary seed defaults, and stable bootstrap markers. +- [x] Add the named volume, `requireDigest` render guard, project-local seed + path, fixture-safe tests, context-managed SQLite test resources, and truthful + documentation. +- [ ] Run focused tests and Compose/Helm rendering checks. ### Task 6: Protected completion @@ -122,11 +153,15 @@ - Modify: `CHANGELOG.md` - Modify: `docs/superpowers/plans/2026-08-03-keyverse-review-remediation.md` +- [x] Resolve the prior review threads after implementing the corresponding + findings; all old threads are currently resolved/outdated. - [ ] Run `uv sync --locked --extra dev`. - [ ] Run `uv run ruff check app tests tools`. - [ ] Run `uv run interrogate .` and require 100%. - [ ] Run `uv run pytest -q` with 100% production statement and branch coverage. -- [ ] Run realm, Compose, Helm, CodeQL, Semgrep, security, and central coverage checks on the exact head. -- [ ] Resolve only review threads whose findings are demonstrably addressed. +- [ ] Run realm, Compose, Helm, CodeQL, Semgrep, security, and central coverage + checks on the exact head. +- [ ] Obtain independent approval on the exact head. - [ ] Merge only after the repository's protected policy is satisfied. -- [ ] Re-list open PRs and continue until the queue is zero or an external approval/runner blocker remains. +- [ ] Re-list open PRs and continue until the queue is zero or an external + approval/runner blocker remains. From 77e19d6416c2109828bd9885c6ad3df29b9f958b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 15:15:32 +0900 Subject: [PATCH 091/104] test(registration): isolate caller quotas without client API assumptions --- .../tests/test_registration.py | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/services/account_unification/tests/test_registration.py b/services/account_unification/tests/test_registration.py index 6c1f6fc..57e93ff 100644 --- a/services/account_unification/tests/test_registration.py +++ b/services/account_unification/tests/test_registration.py @@ -254,33 +254,41 @@ def test_operator_token_does_not_open_registration(api): assert response.status_code == 403 -def test_registration_rate_limit_isolated_by_client(api, monkeypatch): - """One client cannot consume another client's registration allowance.""" +def test_registration_rate_limit_isolated_by_caller(client, monkeypatch): + """One caller cannot consume another caller's registration allowance.""" monkeypatch.setattr( registration_module, "REGISTRATION_RATE_LIMIT_MAX_ATTEMPTS", 1, ) - app = _wire_registration_app(api) - headers = {"Authorization": f"Bearer {REGISTRATION_TOKEN}"} + caller_keys = iter(["caller-a", "caller-a", "caller-b"]) - with TestClient(app, headers=headers, client=("client-a", 50001)) as client_a: - assert client_a.post( - "/registration/accounts", - json=_registration("first@example.com"), - ).status_code == 201 - limited = client_a.post( - "/registration/accounts", - json=_registration("second@example.com"), - ) - assert limited.status_code == 429 + def next_caller_key(request) -> str: + """Return deterministic caller identities for consecutive requests.""" + del request + return next(caller_keys) - with TestClient(app, headers=headers, client=("client-b", 50002)) as client_b: - independent = client_b.post( - "/registration/accounts", - json=_registration("third@example.com"), - ) - assert independent.status_code == 201 + monkeypatch.setattr( + registration_module, + "_registration_client_key", + next_caller_key, + ) + + assert client.post( + "/registration/accounts", + json=_registration("first@example.com"), + ).status_code == 201 + limited = client.post( + "/registration/accounts", + json=_registration("second@example.com"), + ) + independent = client.post( + "/registration/accounts", + json=_registration("third@example.com"), + ) + + assert limited.status_code == 429 + assert independent.status_code == 201 def test_registration_router_has_no_realm_wide_janitor_endpoint(client): From 8863a339ce8215c739c90d49f9e3ae2d6ec16adf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 15:16:21 +0900 Subject: [PATCH 092/104] fix(config): keep development registration disabled by default --- .../tools/seed_config_store.py | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/services/account_unification/tools/seed_config_store.py b/services/account_unification/tools/seed_config_store.py index 0d10603..1412e01 100644 --- a/services/account_unification/tools/seed_config_store.py +++ b/services/account_unification/tools/seed_config_store.py @@ -2,7 +2,8 @@ The tool writes the same two-word snake_case entries consumed by the service. Values are development placeholders; production deployments populate the -platform KV and provide only the bootstrap pointer to the process. +platform KV and provide only the bootstrap pointer to the process. Registration +remains disabled unless its dedicated token is supplied explicitly. """ from __future__ import annotations @@ -51,7 +52,8 @@ def _build_parser() -> argparse.ArgumentParser: parser.add_argument("--operator-token", default="dev-operator-token") parser.add_argument( "--registration-token", - default="dev-registration-token", + default="", + help="Enable local registration only when a dedicated token is supplied.", ) parser.add_argument( "--registration-client-id", @@ -72,6 +74,20 @@ def _build_parser() -> argparse.ArgumentParser: return parser +def _registration_entries(args: argparse.Namespace) -> dict[str, str]: + """Return complete registration settings only when signup is enabled.""" + if not args.registration_token: + return {} + return { + KEY_REGISTRATION_API_TOKEN: args.registration_token, + KEY_REGISTRATION_CLIENT_ID: args.registration_client_id, + KEY_REGISTRATION_REDIRECT_URI: args.registration_redirect_uri, + KEY_REGISTRATION_ACTION_LIFESPAN_SECONDS: ( + args.registration_action_lifespan_seconds + ), + } + + def main() -> int: """Write development Keycloak settings into a local SQLite KV store.""" args = _build_parser().parse_args() @@ -85,13 +101,8 @@ def main() -> int: KEY_MERGE_CONFLICT_POLICY: "survivor_wins", KEY_ALLOW_UNVERIFIED_LINK: "false", KEY_OPERATOR_API_TOKEN: args.operator_token, - KEY_REGISTRATION_API_TOKEN: args.registration_token, - KEY_REGISTRATION_CLIENT_ID: args.registration_client_id, - KEY_REGISTRATION_REDIRECT_URI: args.registration_redirect_uri, - KEY_REGISTRATION_ACTION_LIFESPAN_SECONDS: ( - args.registration_action_lifespan_seconds - ), KEY_AUDIT_DATABASE_PATH: args.audit_database_path, + **_registration_entries(args), } for entry_key, entry_value in entries.items(): store.put(args.namespace, entry_key, entry_value) From 2c7c740ff81576ba12db827ff1ba9dbb1947ed53 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 15:17:09 +0900 Subject: [PATCH 093/104] test(config): keep local registration opt-in --- .../tests/test_deployment_contracts.py | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/services/account_unification/tests/test_deployment_contracts.py b/services/account_unification/tests/test_deployment_contracts.py index dcc68f4..c034621 100644 --- a/services/account_unification/tests/test_deployment_contracts.py +++ b/services/account_unification/tests/test_deployment_contracts.py @@ -11,6 +11,17 @@ def _repository_root() -> Path: return Path(__file__).resolve().parents[3] +def _seed_tool_source() -> str: + """Return the local configuration seed tool source.""" + return ( + _repository_root() + / "services" + / "account_unification" + / "tools" + / "seed_config_store.py" + ).read_text(encoding="utf-8") + + def test_compose_persists_account_unification_state() -> None: """Standalone restarts retain audit and user-operation lock databases.""" compose = yaml.safe_load( @@ -67,12 +78,14 @@ def test_helm_mounts_durable_account_unification_storage() -> None: def test_local_seed_avoids_global_temporary_audit_storage() -> None: """Development defaults stay inside the project bootstrap directory.""" - seed_tool = ( - _repository_root() - / "services" - / "account_unification" - / "tools" - / "seed_config_store.py" - ).read_text(encoding="utf-8") + seed_tool = _seed_tool_source() assert "/tmp/keyverse-account-audit.sqlite3" not in seed_tool assert "account_unification_audit.sqlite3" in seed_tool + + +def test_local_seed_keeps_registration_disabled_by_default() -> None: + """A developer must explicitly supply the dedicated signup credential.""" + seed_tool = _seed_tool_source() + assert '"--registration-token"' in seed_tool + assert 'default=""' in seed_tool + assert "if not args.registration_token" in seed_tool From 70070b95fe3fe197008c423741314cd15eebb406 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 15:18:22 +0900 Subject: [PATCH 094/104] fix(federation): keep desired-state reads available during outages --- .../account_unification/app/federation.py | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/services/account_unification/app/federation.py b/services/account_unification/app/federation.py index 85f1b39..b108890 100644 --- a/services/account_unification/app/federation.py +++ b/services/account_unification/app/federation.py @@ -7,6 +7,7 @@ """ from __future__ import annotations +import logging import threading from fastapi import APIRouter, Depends, HTTPException, Request @@ -15,6 +16,8 @@ from .kv_store import KvStore from .product_keycloak_client import ProductAdminApi +logger = logging.getLogger(__name__) + FEDERATION_PROVIDER_NAMESPACE = "federation_identity_providers" _SUPPORTED_PROVIDER_IDS = {"saml", "oidc", "keycloak-oidc"} _MAX_PROVIDER_ALIAS_LENGTH = 63 @@ -230,6 +233,10 @@ def _try_apply(self, registration: IdentityProviderRegistration) -> bool: try: self._apply(registration) except Exception: + logger.exception( + "identity-provider convergence failed alias=%s", + registration.provider_alias, + ) return False return True @@ -239,12 +246,22 @@ def _status_for( *, applied: bool | None = None, ) -> IdentityProviderStatus: - """Build a redacted status from desired and applied state.""" + """Build a redacted status, tolerating temporary Keycloak outages.""" if applied is None: - applied = ( - self._api.get_identity_provider(registration.provider_alias) - is not None - ) + try: + applied = ( + self._api.get_identity_provider( + registration.provider_alias + ) + is not None + ) + except Exception: + logger.warning( + "identity-provider status unavailable alias=%s", + registration.provider_alias, + exc_info=True, + ) + applied = False return IdentityProviderStatus( registration=IdentityProviderView.from_registration(registration), applied_to_keycloak=applied, From b6f09288408ac226142c769d574e71a39d6c415d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 15:20:25 +0900 Subject: [PATCH 095/104] test(federation): keep desired state readable during Keycloak outage --- .../tests/test_federation.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/services/account_unification/tests/test_federation.py b/services/account_unification/tests/test_federation.py index c12c01a..aa36c14 100644 --- a/services/account_unification/tests/test_federation.py +++ b/services/account_unification/tests/test_federation.py @@ -103,6 +103,35 @@ def fail_create(*args, **kwargs) -> None: assert store.get(FEDERATION_PROVIDER_NAMESPACE, "employer-adfs") is not None +def test_stored_status_remains_readable_during_keycloak_outage( + store, api, monkeypatch +) -> None: + """Desired state remains observable and redacted when status I/O fails.""" + registration = _employer_adfs_registration() + store.put( + FEDERATION_PROVIDER_NAMESPACE, + registration.provider_alias, + registration.model_dump_json(), + ) + federation = FederationService(store, api) + + def fail_status(*args, **kwargs): + """Simulate Keycloak being unavailable during a status read.""" + raise RuntimeError("keycloak unavailable") + + monkeypatch.setattr(api, "get_identity_provider", fail_status) + + statuses = federation.list_registrations() + + assert len(statuses) == 1 + assert statuses[0].applied_to_keycloak is False + assert statuses[0].registration.provider_config["clientSecret"] == "" + assert ( + statuses[0].registration.provider_config["unclassifiedValue"] + == "" + ) + + def test_status_network_call_does_not_hold_desired_state_lock( store, api, monkeypatch ) -> None: From 94b7ffef9056b86260914e1c7b9187d027fcaccd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 15:27:43 +0900 Subject: [PATCH 096/104] ci: integrate hourly protected PR stewardship --- .github/workflows/hourly-pr-steward.yml | 102 ++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 .github/workflows/hourly-pr-steward.yml diff --git a/.github/workflows/hourly-pr-steward.yml b/.github/workflows/hourly-pr-steward.yml new file mode 100644 index 0000000..81b4ff6 --- /dev/null +++ b/.github/workflows/hourly-pr-steward.yml @@ -0,0 +1,102 @@ +name: Hourly PR steward + +on: + schedule: + # Avoid the top-of-hour congestion window. Scheduled runs use UTC and the + # latest commit on the default branch. + - cron: "17 * * * *" + workflow_dispatch: + +# The steward can update trusted same-repository branches and arm auto-merge +# only after GitHub reports an approved review and clean required checks. +permissions: + contents: write + pull-requests: write + checks: read + +concurrency: + group: hourly-pr-steward + cancel-in-progress: false + +jobs: + advance-approved-pull-requests: + name: Advance approved pull requests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Update, verify, and arm trusted pull requests + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + shell: bash + run: | + set -euo pipefail + + gh pr list \ + --repo "$REPOSITORY" \ + --state open \ + --limit 100 \ + --json number,isDraft,author,headRepositoryOwner,headRefOid,mergeStateStatus,reviewDecision \ + > "$RUNNER_TEMP/open-pull-requests.json" + + jq -c '.[]' "$RUNNER_TEMP/open-pull-requests.json" | while IFS= read -r pull_request; do + number="$(jq -r '.number' <<<"$pull_request")" + is_draft="$(jq -r '.isDraft' <<<"$pull_request")" + author="$(jq -r '.author.login // ""' <<<"$pull_request")" + head_owner="$(jq -r '.headRepositoryOwner.login // ""' <<<"$pull_request")" + head_sha="$(jq -r '.headRefOid' <<<"$pull_request")" + merge_state="$(jq -r '.mergeStateStatus // "UNKNOWN"' <<<"$pull_request")" + review_decision="$(jq -r '.reviewDecision // ""' <<<"$pull_request")" + + if [[ "$is_draft" != "false" || "$head_owner" != "ContextualWisdomLab" ]]; then + continue + fi + + trusted_author=false + for allowed_author in \ + seonghobae \ + dependabot \ + 'dependabot[bot]' \ + app/dependabot \ + github-actions \ + 'github-actions[bot]' \ + app/github-actions \ + opencode-agent + do + if [[ "$author" == "$allowed_author" ]]; then + trusted_author=true + break + fi + done + if [[ "$trusted_author" != "true" ]]; then + continue + fi + + # Keep trusted branches current. A successful update invalidates the + # old check evidence, so the steward waits for the next hourly pass. + if [[ "$merge_state" == "BEHIND" ]]; then + gh pr update-branch "$number" --repo "$REPOSITORY" || true + continue + fi + + if [[ "$review_decision" != "APPROVED" ]]; then + continue + fi + + # Never infer safety from optional checks. The repository's required + # check set remains the source of truth. `gh pr checks` exits nonzero + # for failed checks and uses exit code 8 for pending checks, so either + # condition leaves the PR untouched. + if ! gh pr checks "$number" --repo "$REPOSITORY" --required; then + continue + fi + + # Arm GitHub's native auto-merge service rather than creating the + # merge commit directly with GITHUB_TOKEN. Rulesets remain final, + # and the exact reviewed/check head must still match. + gh pr merge "$number" \ + --repo "$REPOSITORY" \ + --auto \ + --squash \ + --match-head-commit "$head_sha" + done From 9a000e63fc7248cc25548a6d45c946d1daaac458 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 15:28:37 +0900 Subject: [PATCH 097/104] test(ci): protect hourly fail-closed stewardship contract --- .../tests/test_hourly_pr_steward.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 services/account_unification/tests/test_hourly_pr_steward.py diff --git a/services/account_unification/tests/test_hourly_pr_steward.py b/services/account_unification/tests/test_hourly_pr_steward.py new file mode 100644 index 0000000..01f6755 --- /dev/null +++ b/services/account_unification/tests/test_hourly_pr_steward.py @@ -0,0 +1,58 @@ +"""Static contract tests for the hourly protected PR steward.""" +from __future__ import annotations + +from pathlib import Path + + +def _workflow_source() -> str: + """Return the repository's hourly PR stewardship workflow source.""" + repository_root = Path(__file__).resolve().parents[3] + return ( + repository_root / ".github" / "workflows" / "hourly-pr-steward.yml" + ).read_text(encoding="utf-8") + + +def test_hourly_steward_runs_once_per_hour_with_bounded_concurrency() -> None: + """The schedule is hourly and overlapping steward runs are serialized.""" + workflow = _workflow_source() + assert 'cron: "17 * * * *"' in workflow + assert "group: hourly-pr-steward" in workflow + assert "cancel-in-progress: false" in workflow + assert "timeout-minutes: 10" in workflow + + +def test_hourly_steward_uses_least_required_repository_permissions() -> None: + """The workflow grants only the scopes needed to update and arm PRs.""" + workflow = _workflow_source() + assert "contents: write" in workflow + assert "pull-requests: write" in workflow + assert "checks: read" in workflow + assert "security-events: write" not in workflow + assert "actions: write" not in workflow + + +def test_hourly_steward_is_fail_closed_on_trust_review_and_checks() -> None: + """Untrusted, unapproved, pending, or failed pull requests remain untouched.""" + workflow = _workflow_source() + assert 'head_owner" != "ContextualWisdomLab"' in workflow + assert 'trusted_author" != "true"' in workflow + assert 'review_decision" != "APPROVED"' in workflow + assert 'gh pr checks "$number" --repo "$REPOSITORY" --required' in workflow + assert "--admin" not in workflow + + +def test_hourly_steward_invalidates_old_evidence_after_branch_update() -> None: + """A branch update exits the current iteration before merging stale evidence.""" + workflow = _workflow_source() + update_position = workflow.index("gh pr update-branch") + continue_position = workflow.index("continue", update_position) + approval_position = workflow.index('review_decision" != "APPROVED"') + assert update_position < continue_position < approval_position + + +def test_hourly_steward_binds_auto_merge_to_the_checked_head() -> None: + """GitHub auto-merge is armed only for the enumerated exact head SHA.""" + workflow = _workflow_source() + assert '--auto \\' in workflow + assert '--squash \\' in workflow + assert '--match-head-commit "$head_sha"' in workflow From 25dafce7d6074e4ef6d9da37b95188768d87fd9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 15:40:35 +0900 Subject: [PATCH 098/104] fix(release): align Helm tag with unreleased package version --- helm/cwl-idp/values.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/helm/cwl-idp/values.yaml b/helm/cwl-idp/values.yaml index 742975f..c5bf393 100644 --- a/helm/cwl-idp/values.yaml +++ b/helm/cwl-idp/values.yaml @@ -6,7 +6,9 @@ namespaceOverride: cwl-idp accountUnification: image: repository: cwl-idp/account-unification - tag: "0.2.0" + # Keep the chart aligned with the currently published package metadata. + # The release workflow bumps both together after exact-head verification. + tag: "0.1.0" # Development may render a local tag. Production values must set # requireDigest=true and provide the built image's sha256 digest. digest: "" From f02068a9c0f7299c8a204c134b2c2033af701a32 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 15:40:58 +0900 Subject: [PATCH 099/104] test(release): keep Helm and package versions synchronized --- .../tests/test_deployment_contracts.py | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/services/account_unification/tests/test_deployment_contracts.py b/services/account_unification/tests/test_deployment_contracts.py index c034621..b346d29 100644 --- a/services/account_unification/tests/test_deployment_contracts.py +++ b/services/account_unification/tests/test_deployment_contracts.py @@ -1,6 +1,7 @@ """Static deployment contract tests for Compose and Helm packaging.""" from __future__ import annotations +import tomllib from pathlib import Path import yaml @@ -11,6 +12,15 @@ def _repository_root() -> Path: return Path(__file__).resolve().parents[3] +def _helm_values() -> dict: + """Return parsed Helm values for the cwl-idp chart.""" + return yaml.safe_load( + (_repository_root() / "helm" / "cwl-idp" / "values.yaml").read_text( + encoding="utf-8" + ) + ) + + def _seed_tool_source() -> str: """Return the local configuration seed tool source.""" return ( @@ -37,12 +47,7 @@ def test_compose_persists_account_unification_state() -> None: def test_helm_can_fail_closed_on_missing_account_image_digest() -> None: """Production values can require an immutable account-service image.""" - values = yaml.safe_load( - (_repository_root() / "helm" / "cwl-idp" / "values.yaml").read_text( - encoding="utf-8" - ) - ) - image = values["accountUnification"]["image"] + image = _helm_values()["accountUnification"]["image"] assert image["requireDigest"] is False template = ( _repository_root() @@ -57,12 +62,7 @@ def test_helm_can_fail_closed_on_missing_account_image_digest() -> None: def test_helm_mounts_durable_account_unification_storage() -> None: """The chart mounts deployment-owned state at the service data path.""" - values = yaml.safe_load( - (_repository_root() / "helm" / "cwl-idp" / "values.yaml").read_text( - encoding="utf-8" - ) - ) - persistence = values["accountUnification"]["persistence"] + persistence = _helm_values()["accountUnification"]["persistence"] assert persistence["enabled"] is True assert persistence["size"] template = ( @@ -76,6 +76,19 @@ def test_helm_mounts_durable_account_unification_storage() -> None: assert "mountPath: /var/lib/account-unification" in template +def test_helm_image_tag_matches_package_version() -> None: + """Unreleased chart metadata cannot advertise an unbuilt package version.""" + pyproject_path = ( + _repository_root() + / "services" + / "account_unification" + / "pyproject.toml" + ) + project = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))["project"] + helm_tag = _helm_values()["accountUnification"]["image"]["tag"] + assert helm_tag == project["version"] + + def test_local_seed_avoids_global_temporary_audit_storage() -> None: """Development defaults stay inside the project bootstrap directory.""" seed_tool = _seed_tool_source() From 6d3ff28ef56a79f7c7ce3827154d701824a29f18 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 08:07:32 +0900 Subject: [PATCH 100/104] test(ci): require read-only workflow token defaults --- .../tests/test_hourly_pr_steward.py | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/services/account_unification/tests/test_hourly_pr_steward.py b/services/account_unification/tests/test_hourly_pr_steward.py index 01f6755..910133e 100644 --- a/services/account_unification/tests/test_hourly_pr_steward.py +++ b/services/account_unification/tests/test_hourly_pr_steward.py @@ -12,6 +12,13 @@ def _workflow_source() -> str: ).read_text(encoding="utf-8") +def _permissions_block(source: str, marker: str, terminator: str) -> str: + """Return one indentation-sensitive workflow permissions block.""" + block_start = source.index(marker) + block_end = source.index(terminator, block_start) + return source[block_start:block_end] + + def test_hourly_steward_runs_once_per_hour_with_bounded_concurrency() -> None: """The schedule is hourly and overlapping steward runs are serialized.""" workflow = _workflow_source() @@ -21,12 +28,25 @@ def test_hourly_steward_runs_once_per_hour_with_bounded_concurrency() -> None: assert "timeout-minutes: 10" in workflow -def test_hourly_steward_uses_least_required_repository_permissions() -> None: - """The workflow grants only the scopes needed to update and arm PRs.""" +def test_hourly_steward_uses_read_only_workflow_token_defaults() -> None: + """Only the steward job receives its narrowly required write scopes.""" workflow = _workflow_source() - assert "contents: write" in workflow - assert "pull-requests: write" in workflow - assert "checks: read" in workflow + top_level_permissions = _permissions_block( + workflow, + "permissions:\n", + "\nconcurrency:", + ) + job_permissions = _permissions_block( + workflow, + " permissions:\n", + " steps:", + ) + + assert "contents: read" in top_level_permissions + assert "write" not in top_level_permissions + assert "contents: write" in job_permissions + assert "pull-requests: write" in job_permissions + assert "checks: read" in job_permissions assert "security-events: write" not in workflow assert "actions: write" not in workflow From 965ea33327965940254d54ffcaff6cdd4273c71d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 08:07:53 +0900 Subject: [PATCH 101/104] fix(ci): narrow hourly steward token permissions --- .github/workflows/hourly-pr-steward.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/hourly-pr-steward.yml b/.github/workflows/hourly-pr-steward.yml index 81b4ff6..cd973a9 100644 --- a/.github/workflows/hourly-pr-steward.yml +++ b/.github/workflows/hourly-pr-steward.yml @@ -7,12 +7,11 @@ on: - cron: "17 * * * *" workflow_dispatch: -# The steward can update trusted same-repository branches and arm auto-merge -# only after GitHub reports an approved review and clean required checks. +# Keep the workflow token read-only by default. Only the single steward job +# receives the narrowly scoped writes required to update trusted branches and +# arm GitHub-native auto-merge after protected evidence is complete. permissions: - contents: write - pull-requests: write - checks: read + contents: read concurrency: group: hourly-pr-steward @@ -23,6 +22,10 @@ jobs: name: Advance approved pull requests runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + checks: read steps: - name: Update, verify, and arm trusted pull requests env: From db03ef6f703da2a3bfabc537b9a4a8feff379004 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 08:09:56 +0900 Subject: [PATCH 102/104] test(bootstrap): tolerate secure multiline kcadm invocation --- .../tests/test_kcadm_bootstrap.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/services/account_unification/tests/test_kcadm_bootstrap.py b/services/account_unification/tests/test_kcadm_bootstrap.py index 73bbf5a..dc8024b 100644 --- a/services/account_unification/tests/test_kcadm_bootstrap.py +++ b/services/account_unification/tests/test_kcadm_bootstrap.py @@ -70,10 +70,14 @@ def test_service_client_secret_never_enters_process_arguments() -> None: assert "umask 077" in script assert "SERVICE_SECRET_JSON" in script - assert ( - 'kcadm update "clients/${SVC_CLIENT_UUID}" -r "${REALM}" ' - '-f "${SERVICE_SECRET_JSON}"' - ) in script + update_position = script.index( + 'kcadm update "clients/${SVC_CLIENT_UUID}"' + ) + file_input_position = script.index( + '-f "${SERVICE_SECRET_JSON}"', + update_position, + ) + assert update_position < file_input_position assert '-s "secret=$(kv get' not in script assert "kv put secret/idp/account-unification-client-secret" not in script From 51d68d9e1ce5b5f1ffd4e9c4a4d5b235f30c568c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 08:10:09 +0900 Subject: [PATCH 103/104] test(paths): use route-stable encoded delimiter probes --- services/account_unification/tests/test_path_security.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/account_unification/tests/test_path_security.py b/services/account_unification/tests/test_path_security.py index 1d4c940..a3e9b16 100644 --- a/services/account_unification/tests/test_path_security.py +++ b/services/account_unification/tests/test_path_security.py @@ -22,7 +22,7 @@ def _client() -> TestClient: def test_admin_router_rejects_encoded_identifier() -> None: """Encoded path material is rejected before endpoint dependencies.""" with _client() as client: - response = client.get("/users/bad%252fidentifier/identities") + response = client.get("/users/bad%2525identifier/identities") assert response.status_code == 400 assert "encoding" in response.json()["detail"] @@ -39,7 +39,7 @@ def test_federation_router_rejects_traversal_alias() -> None: def test_scim_router_returns_protocol_native_error_for_unsafe_id() -> None: """SCIM path validation returns an RFC 7644 body and media type.""" with _client() as client: - response = client.get("/scim/v2/Users/bad%252fidentifier") + response = client.get("/scim/v2/Users/bad%2525identifier") assert response.status_code == 400 assert response.headers["content-type"].startswith("application/scim+json") From 618cc0eb06a1405638e365f18419c76a51bbbe34 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 08:10:55 +0900 Subject: [PATCH 104/104] fix(realm): keep validator errors free of credential literals --- scripts/validate_realm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/validate_realm.py b/scripts/validate_realm.py index f192e5b..db6e6c5 100644 --- a/scripts/validate_realm.py +++ b/scripts/validate_realm.py @@ -116,8 +116,8 @@ def validate(realm: dict) -> list[str]: ) if not passkey_enrollment_is_available: errors.append( - "webauthn-register-passwordless must remain enabled for action-email " - "enrollment" + "passkey enrollment required action must remain enabled for " + "action-email enrollment" ) if realm.get("verifyEmail", False) and not realm.get("smtpServer"): errors.append(