diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a1eca6..7a05705 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,9 @@ jobs: run: | uv run coverage run --branch --source=app -m pytest -q uv run coverage report --show-missing --fail-under=100 + uv run coverage erase + uv run coverage run --branch -m pytest -q tests/test_validate_realm.py + uv run coverage report --include='*/scripts/validate_realm.py' --show-missing --fail-under=100 - name: Build distribution run: uv build --out-dir dist @@ -53,7 +56,7 @@ jobs: with: python-version: "3.12" - name: Validate Keycloak realm config-as-code - run: python scripts/validate_realm.py deploy/keycloak/realm-cwl.json + run: python scripts/validate_realm.py deploy/keycloak/cwl-realm.json - name: Validate deployment template JSON run: | python - <<'PY' diff --git a/.github/workflows/hourly-product-development.yml b/.github/workflows/hourly-product-development.yml index a612bdc..85bf1f7 100644 --- a/.github/workflows/hourly-product-development.yml +++ b/.github/workflows/hourly-product-development.yml @@ -498,9 +498,9 @@ jobs: install -d -m 0750 "$agent_workspace" "$agent_home" "$agent_home/tmp" git archive HEAD | tar -x -C "$agent_workspace" - cat >"${agent_workspace}/opencode.json" <<'CONFIG' + cat >"${agent_workspace}/opencode.json" </dev/null 2>&1 || true successful_workspace="$agent_workspace" @@ -739,9 +739,12 @@ jobs: uv run coverage erase uv run coverage run --branch --source=app -m pytest -q uv run coverage report --show-missing --fail-under=100 + uv run coverage erase + uv run coverage run --branch -m pytest -q tests/test_validate_realm.py + uv run coverage report --include='*/scripts/validate_realm.py' --show-missing --fail-under=100 uv build --out-dir dist ) - python scripts/validate_realm.py deploy/keycloak/realm-cwl.json + python scripts/validate_realm.py deploy/keycloak/cwl-realm.json docker compose -f docker-compose.yml config >/dev/null while IFS= read -r -d '' template; do python -m json.tool "$template" >/dev/null diff --git a/AGENTS.md b/AGENTS.md index 7620bd2..5fa1302 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,8 +36,10 @@ queue owner. - SAML/OIDC preflight performs no metadata/discovery fetch. LDAP preflight performs no DNS, socket, bind, search, storage, or Keycloak call. - OIDC relying-party mapper support stays closed: one self-pinned audience plus - only canonical `role`, `org`, and `workspace` hardcoded claims. Do not add - scripts, user attributes, groups, regex, arbitrary claims, new audiences, or + only canonical `role`, `org`, and `workspace` hardcoded claims, except the + ADR-0009 `lineageweave-web` profile which maps only a same-client account role + and the exact `org`/`workspace` account attributes. Do not add scripts, + other user attributes, groups, regex, arbitrary claims, new audiences, or extra token destinations without a separately reviewed profile and RED test. - Treat generated Keycloak mapper IDs and vendor ordering as normalization-only metadata. Unknown, malformed, duplicate, or semantically changed live mappers diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8a54a23..abb98b0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -154,13 +154,18 @@ redirect/origin/logout policy, public/confidential client consistency, bounded token metadata, and an exact portable scope set. An optional closed `protocolMappers` profile carries exactly one self-pinned -`oidc-audience-mapper` plus zero to three canonical hardcoded claims named -`role`, `org`, and `workspace`. Mapper count, names, classes, destinations, -claim values, and ordering are bounded; scripts, user attributes, groups, regex, -arbitrary claims, unknown fields, and credential material are rejected. -`deploy/templates/oidc-rp-naruon.json` is the reviewed public-client instance of -that profile. Its routing claim values are deployment data and must not contain -credentials or personal secrets. +`oidc-audience-mapper` plus either zero to three canonical hardcoded claims or +the ADR-0009 LineageWeave account-derived trio. The latter is limited to a +same-client `oidc-usermodel-client-role-mapper` for multivalued `role` and two +scalar `oidc-usermodel-attribute-mapper` entries from exact `org` and +`workspace` account attributes. The three dynamic claims must appear together +and cannot mix with hardcoded claims. Mapper count, names, classes, +destinations, claim values, and ordering are bounded; scripts, other user +attributes, groups, regex, arbitrary claims, unknown fields, and credential +material are rejected. `deploy/templates/oidc-rp-naruon.json` is the reviewed +public-client instance of the static profile; `deploy/templates/oidc-rp-lineageweave.json` +is the confidential account-derived contract. Neither template contains a +credential or proves a live account login. Stateful reconciliation keys intent by validated `clientId`, classifies zero, one, or multiple exact Keycloak clients, and never mutates duplicates. Create or @@ -172,8 +177,7 @@ The accepted representation has no client-secret field; credential provisioning remains an independent secret-management responsibility. Native loopback/private-use redirects, different resource audiences, and claim -expansion beyond `role`, `org`, and `workspace` remain separate reviewed -profiles. +expansion beyond the two closed profiles remain separate reviewed profiles. Each downstream RP is a separate trust boundary. The RP must validate the Keyverse issuer, signature/algorithm, expiry, subject, and audience, map the diff --git a/CHANGELOG.md b/CHANGELOG.md index 5462dfb..46e4033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ Keep a Changelog, and releases use semantic versioning. ### Added +- A dated product and technical gap baseline that records the live PR/Issue + queue, exact-head Check evidence, buyer-visible authorization and runtime + acceptance gaps, and the protected hourly loop. - ADR-0008 and the non-fork RP authorization matrix, requiring explicit Keyverse token validation, tenant/resource ABAC, bounded RBAC, and cross-tenant acceptance evidence per application. @@ -14,6 +17,14 @@ Keep a Changelog, and releases use semantic versioning. access-token audience, bounded `role`, `org`, and `workspace` hardcoded claims, canonical mapper ordering, Keycloak-generated-ID/order normalization, and a secret-free `naruon-web` runtime desired-state template. +- ADR-0009's confidential `lineageweave-web` account-derived mapper profile: + same-client roles plus exact scalar `org` and `workspace` account attributes, + with no static/dynamic mixing, a secret-free deployment template, and + reconciliation regression coverage. +- A normative LineageWeave tenant mapping: `org` is the opaque external tenant + key, `workspace` is its child namespace, ambiguous or multi-membership + resolution fails closed, and lifecycle changes require a new token or + session renewal; no generic `tenant` mapper was introduced. - Durable, secret-free OIDC relying-party desired-state CRUD and reconciliation with exact `clientId` matching, duplicate fail-closed behavior, post-mutation re-observation, canonical apply receipts, realm-rebuild recovery, per-client @@ -55,6 +66,22 @@ Keep a Changelog, and releases use semantic versioning. ### Changed +- Refreshed the product and technical gap baseline with the current exact-head + PR inventory, including the lockfile repair review gate and the requeued + `lineageweave-web` Checks; predecessor evidence remains non-transferable. +- Added the active PR #113 SCIM deactivation-lock state and the current PR #103 + Strix/IDOR evidence to the gap baseline; neither is represented as protected + main until exact-head review and merge evidence exists. +- Refreshed the PR #103 Strix evidence with its exact failed run/job and kept + the contradictory operator-admin IDOR interpretation fail-closed pending + independent security validation. +- The Helm realm-import operator runbook now migrates the legacy + `realm-cwl.json` ConfigMap key to `cwl-realm.json` before rollout, preserving + a rollback copy and requiring post-rollout realm discovery verification. +- Account-derived OIDC claim mappers are now limited to the ADR-0009 + `lineageweave-web` profile, and a non-string observed mapper type is treated + as reconciliation drift rather than causing an exception. Operator guides now + consistently name issued `org` (company) and `workspace` (PU) claims. - Relying-party deployment controllers now send validated, secret-free metadata to Keyverse desired-state PUT instead of applying client representations directly to Keycloak; confidential credential placement remains a separate @@ -89,6 +116,9 @@ Keep a Changelog, and releases use semantic versioning. ### Fixed +- Packaged the portable Keycloak realm under the required `cwl-realm.json` + directory-import name in Compose and mapped it in Helm, with a deployment + contract that prevents a healthy-but-empty identity realm. - Prevented relying-party inventory from silently accepting a KV key/body identity mismatch, rejected unsafe live or `Location`-derived client UUIDs, and aligned exact client discovery with Keycloak's documented diff --git a/CLAUDE.md b/CLAUDE.md index f53ebe3..90e0db4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ make ready # poll readiness (deploy/scripts/healthz.sh) make install # install the admin service development environment make test # run account-unification unit tests make lint # run Ruff + interrogate docstring coverage -make validate-realm # validate deploy/keycloak/realm-cwl.json +make validate-realm # validate deploy/keycloak/cwl-realm.json make seed-bootstrap # create a local SQLite KV bootstrap store ``` @@ -110,10 +110,11 @@ is required by the normal suite. `kcadm-bootstrap.sh`. The realm contains no employer-specific federation. - `deploy/templates/` — explicit private deployment contracts. SAML/OIDC use Keyverse desired-state endpoints. `oidc-rp-naruon.json` is the reviewed public - Naruon runtime RP profile with one audience mapper and bounded routing claims. - LDAP is preflighted through Keyverse and then applied through private Keycloak - Admin REST in this release. All `{{placeholders}}` are resolved from KV before - use. + Naruon runtime profile; `oidc-rp-lineageweave.json` is the ADR-0009 + confidential profile that projects an account's same-client role and exact + `org`/`workspace` attributes. LDAP is preflighted through Keyverse and then + applied through private Keycloak Admin REST in this release. All + `{{placeholders}}` are resolved from KV before use. - `deploy/bootstrap/` — the bootstrap pointer locating the KV/DB config store. - `helm/cwl-idp/` — the same three components; Keycloak and Postgres may be disabled in favor of externally managed services. Secrets come from @@ -142,9 +143,10 @@ is required by the normal suite. - **OIDC relying-party metadata is secret-free desired state.** Validate with `POST /clients/relying-parties:validate`, persist with `PUT`, and require exact post-mutation observation before accepting a receipt. The optional mapper - profile permits exactly one audience mapper plus only canonical `role`, `org`, - and `workspace` hardcoded claims. Never expand mapper classes, claim names, - resource audiences, or token destinations by configuration alone. + profile permits static canonical claims, plus the separately reviewed + ADR-0009 `lineageweave-web` account-derived profile. Never expand mapper + classes, claim names, resource audiences, or token destinations by + configuration alone. - **Treat mapper normalization narrowly.** Ignore only a valid generated mapper `id` and canonicalize known mapper order. Unknown, malformed, duplicate, or semantically changed live mapper state is drift. Mapper configuration does not diff --git a/Makefile b/Makefile index 79dec87..a73f111 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ seed-bootstrap: ## Create a local sqlite KV bootstrap store for dev python $(SERVICE_DIR)/tools/seed_config_store.py validate-realm: ## Validate the Keycloak realm config-as-code - python scripts/validate_realm.py deploy/keycloak/realm-cwl.json + python scripts/validate_realm.py deploy/keycloak/cwl-realm.json install: ## Install the admin service with dev extras cd $(SERVICE_DIR) && python -m pip install -e '.[dev]' diff --git a/README.md b/README.md index 5e24601..1bcb284 100644 --- a/README.md +++ b/README.md @@ -67,10 +67,15 @@ docker compose up -d # or: podman compose up -d - Admin service health: `http://localhost:8099/healthz` The stack imports the **passwordless-first** realm at first start -(`deploy/keycloak/realm-cwl.json`): a `browser-passwordless` flow with a +(`deploy/keycloak/cwl-realm.json`): a `browser-passwordless` flow with a WebAuthn passwordless authenticator and **no password authenticator**, plus `registrationAllowed:false` / `resetPasswordAllowed:false`. +For Helm installations created with the former `realm-cwl.json` ConfigMap key, +perform the zero-gap key migration in +[`deploy/keycloak/README.md`](deploy/keycloak/README.md#helm-configmap-key-migration) +before upgrading the chart. + ### Register external federation The portable realm contains no employer ADFS, LDAP/AD source, or other @@ -96,6 +101,10 @@ See [`docs/federation-onboarding.md`](docs/federation-onboarding.md), ### Onboard a relying party See [`docs/rp-onboarding.md`](docs/rp-onboarding.md). +The ADR-0009 LineageWeave profile uses real account-derived `role`, `org` +(company), and `workspace` (PU) claims; it is not enabled until private Keyverse +apply and controlled +downstream authorization evidence are recorded. ## Account unification & merge diff --git a/deploy/keycloak/Dockerfile b/deploy/keycloak/Dockerfile new file mode 100644 index 0000000..b2c1523 --- /dev/null +++ b/deploy/keycloak/Dockerfile @@ -0,0 +1,7 @@ +FROM quay.io/keycloak/keycloak:26.3.2@sha256:98fab020a3a490aba0978f237e2a06cd0ea42bf149c6cf10f11c0aaf27728ff2 + +# Keycloak directory import recognizes -realm.json at container start. +COPY cwl-realm.json /opt/keycloak/data/import/cwl-realm.json +COPY lineageweave-user-profile.json /opt/keycloak/lineageweave-user-profile.json +COPY --chmod=755 reconcile-lineageweave-user-profile.sh /opt/keycloak/reconcile-lineageweave-user-profile.sh +USER 1000 diff --git a/deploy/keycloak/README.md b/deploy/keycloak/README.md index feaf209..3b5bee9 100644 --- a/deploy/keycloak/README.md +++ b/deploy/keycloak/README.md @@ -6,7 +6,7 @@ providers are converged afterwards from the KV/DB source of truth. | File | Responsibility | | --- | --- | -| `realm-cwl.json` | Portable passwordless realm, shared client scopes, RP template, concrete `naruon-web` PKCE client, and account-unification service client | +| `cwl-realm.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 | @@ -58,6 +58,45 @@ URLs or invalid placeholder LDAP distinguished names. than 900 seconds; - committed client secrets are placeholders only. +## Helm ConfigMap key migration + +Older Helm installations used the ConfigMap data key `realm-cwl.json`. The +current chart requires the source key `cwl-realm.json` and mounts it at the +Keycloak-required target filename of the same name. Add the new key **before** +upgrading the StatefulSet so an existing Pod never references a missing +ConfigMap item. + +```bash +NAMESPACE=identity +RELEASE=cwl-idp +CONFIG_MAP=cwl-idp-realm + +# Keep a rollback copy of the dedicated realm-import ConfigMap. +kubectl -n "$NAMESPACE" get configmap "$CONFIG_MAP" -o yaml \ + > "${CONFIG_MAP}.before-cwl-realm.yaml" + +# Server-side apply adds the new data key without deleting the legacy key first. +kubectl -n "$NAMESPACE" create configmap "$CONFIG_MAP" \ + --from-file=cwl-realm.json=deploy/keycloak/cwl-realm.json \ + --dry-run=client -o yaml \ + | kubectl -n "$NAMESPACE" apply --server-side \ + --field-manager=cwl-idp-realm-migration -f - + +helm upgrade "$RELEASE" helm/cwl-idp -n "$NAMESPACE" \ + --set keycloak.realmImport.configMapName="$CONFIG_MAP" \ + --set keycloak.realmImport.fileName=cwl-realm.json +kubectl -n "$NAMESPACE" rollout status statefulset/"${RELEASE}-keycloak" + +# Only after the new StatefulSet is healthy may the obsolete source key go. +kubectl -n "$NAMESPACE" patch configmap "$CONFIG_MAP" --type=json \ + --patch='[{"op":"remove","path":"/data/realm-cwl.json"}]' +``` + +This ConfigMap is dedicated to realm import. If a deployment added unrelated +keys, preserve or relocate them under its own ownership before the migration. +Verify the `cwl` realm discovery endpoint after rollout; container health alone +does not prove import succeeded. + ## RP clients `ecosystem-rp-template` is a confidential PKCE S256 blueprint. It uses the diff --git a/deploy/keycloak/realm-cwl.json b/deploy/keycloak/cwl-realm.json similarity index 100% rename from deploy/keycloak/realm-cwl.json rename to deploy/keycloak/cwl-realm.json diff --git a/deploy/keycloak/kcadm-bootstrap.sh b/deploy/keycloak/kcadm-bootstrap.sh index 347eb1e..fb00a7b 100755 --- a/deploy/keycloak/kcadm-bootstrap.sh +++ b/deploy/keycloak/kcadm-bootstrap.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # cwl-idp — Keycloak post-import bootstrap (config-as-code, secrets from KV). # -# The realm SHAPE lives in realm-cwl.json and is imported at container start. +# The realm SHAPE lives in cwl-realm.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 after the realm diff --git a/deploy/keycloak/lineageweave-user-profile.json b/deploy/keycloak/lineageweave-user-profile.json new file mode 100644 index 0000000..f2b5bc0 --- /dev/null +++ b/deploy/keycloak/lineageweave-user-profile.json @@ -0,0 +1,116 @@ +{ + "attributes": [ + { + "name": "username", + "displayName": "${username}", + "validations": { + "length": { + "min": 3, + "max": 255 + }, + "username-prohibited-characters": {}, + "up-username-not-idn-homograph": {} + }, + "permissions": { + "view": ["admin", "user"], + "edit": ["admin", "user"] + }, + "multivalued": false + }, + { + "name": "email", + "displayName": "${email}", + "validations": { + "email": {}, + "length": { + "max": 255 + } + }, + "required": { + "roles": ["user"] + }, + "permissions": { + "view": ["admin", "user"], + "edit": ["admin", "user"] + }, + "multivalued": false + }, + { + "name": "firstName", + "displayName": "${firstName}", + "validations": { + "length": { + "max": 255 + }, + "person-name-prohibited-characters": {} + }, + "required": { + "roles": ["user"] + }, + "permissions": { + "view": ["admin", "user"], + "edit": ["admin", "user"] + }, + "multivalued": false + }, + { + "name": "lastName", + "displayName": "${lastName}", + "validations": { + "length": { + "max": 255 + }, + "person-name-prohibited-characters": {} + }, + "required": { + "roles": ["user"] + }, + "permissions": { + "view": ["admin", "user"], + "edit": ["admin", "user"] + }, + "multivalued": false + }, + { + "name": "org", + "displayName": "Organization", + "validations": { + "length": { + "max": "64" + } + }, + "required": { + "roles": ["admin"] + }, + "permissions": { + "view": ["admin"], + "edit": ["admin"] + }, + "multivalued": false + }, + { + "name": "workspace", + "displayName": "Workspace", + "validations": { + "length": { + "max": "64" + } + }, + "required": { + "roles": ["admin"] + }, + "permissions": { + "view": ["admin"], + "edit": ["admin"] + }, + "multivalued": false + } + ], + "groups": [ + { + "name": "user-metadata", + "displayHeader": "User metadata", + "displayDescription": "Attributes, which refer to user metadata" + } + ] +} diff --git a/deploy/keycloak/reconcile-lineageweave-user-profile.sh b/deploy/keycloak/reconcile-lineageweave-user-profile.sh new file mode 100644 index 0000000..bc18a6f --- /dev/null +++ b/deploy/keycloak/reconcile-lineageweave-user-profile.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Reconcile the closed LineageWeave account-attribute profile after realm import. +set -euo pipefail +umask 077 + +REALM="${KC_REALM:-cwl}" +KC_SERVER="${KC_SERVER:-http://idp_engine:8080}" +ADMIN_USER="${KC_BOOTSTRAP_ADMIN_USERNAME:?bootstrap admin username is required}" +ADMIN_PASS="${KC_BOOTSTRAP_ADMIN_PASSWORD:?bootstrap admin password is required}" +KCADM_HOME="$(mktemp -d)" +cleanup() { + rm -rf "${KCADM_HOME}" + unset ADMIN_PASS KC_CLI_PASSWORD KC_BOOTSTRAP_ADMIN_PASSWORD +} +trap cleanup EXIT +kcadm() { + HOME="${KCADM_HOME}" /opt/keycloak/bin/kcadm.sh "$@" +} + +for attempt in $(seq 1 30); do + if KC_CLI_PASSWORD="${ADMIN_PASS}" kcadm config credentials \ + --server "${KC_SERVER}" --realm master --user "${ADMIN_USER}"; then + break + fi + if [[ "${attempt}" -eq 30 ]]; then + echo "profile bootstrap failed: Keycloak admin login did not become ready" >&2 + exit 1 + fi + sleep 1 +done +unset ADMIN_PASS KC_BOOTSTRAP_ADMIN_PASSWORD + +kcadm update "realms/${REALM}/users/profile" \ + -f /opt/keycloak/lineageweave-user-profile.json +profile="$(kcadm get "realms/${REALM}/users/profile")" +for attribute in org workspace; do + printf '%s' "${profile}" \ + | grep -Eq '"name"[[:space:]]*:[[:space:]]*"'"${attribute}"'"' +done +# Keycloak 26.3.2 represents the closed unmanaged-attribute policy as null, +# which is omitted from the Admin API JSON; DISABLED is not an accepted enum. +if printf '%s' "${profile}" | grep -Eq '"unmanagedAttributePolicy"'; then + echo "profile bootstrap failed: unmanaged attributes must stay disabled" >&2 + exit 1 +fi diff --git a/deploy/templates/README.md b/deploy/templates/README.md index a95b8e5..1d9ab06 100644 --- a/deploy/templates/README.md +++ b/deploy/templates/README.md @@ -10,6 +10,7 @@ all `{{placeholders}}` must be resolved from the platform KV before use. | `ldap-source.json` | Keycloak component contract | external directory → Keycloak | `POST /federation/user-directories:validate` | `POST /admin/realms/{realm}/components` | | `oidc-rp-client.json` | Keyverse RP desired-state API | Keyverse → RP | `POST /clients/relying-parties:validate` | `PUT /clients/relying-parties/{client_id}` | | `oidc-rp-naruon.json` | Keyverse RP desired-state API | Keyverse → Naruon | `POST /clients/relying-parties:validate` | `PUT /clients/relying-parties/naruon-web` | +| `oidc-rp-lineageweave.json` | Keyverse RP desired-state API | Keyverse → LineageWeave | `POST /clients/relying-parties:validate` | `PUT /clients/relying-parties/lineageweave-web` | The portable realm contains no employer-specific federation. External SAML and OIDC providers are customer or deployment data stored in the Keyverse KV/DB @@ -172,6 +173,21 @@ routing users, run controlled authorization-code/PKCE acceptance and verify that the downstream boundary validates token signature, issuer, expiry, the reviewed `naruon-web` audience, and expected `role`, `org`, and `workspace` semantics. +### LineageWeave account-derived mapper profile + +`oidc-rp-lineageweave.json` is the ADR-0009 confidential `lineageweave-web` +artifact. It renders only the exact HTTPS redirect, origin, and post-logout URI. +The fixed mapper order projects a self-pinned audience, client roles from +`lineageweave-web`, and the exact `org` (company) and `workspace` (PU) Keyverse +account attributes. The template never contains a role value, `org`/`workspace` +attribute value, or client secret. + +Before apply, provision a real account with both attributes and a recognized +client role through the approved Keyverse identity lifecycle. Preflight and +reconciliation validate issuer-side metadata only. Record a real +authorization-code/PKCE login and downstream tenant/resource ABAC, role +downgrade, logout, and rollback evidence before enabling production routing. + ## LDAP and Active Directory preflight pattern `ldap-source.json` is a private Keycloak component payload, not a Keyverse diff --git a/deploy/templates/oidc-rp-lineageweave.json b/deploy/templates/oidc-rp-lineageweave.json new file mode 100644 index 0000000..c13c289 --- /dev/null +++ b/deploy/templates/oidc-rp-lineageweave.json @@ -0,0 +1,86 @@ +{ + "clientId": "lineageweave-web", + "name": "lineageweave-web", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "clientAuthenticatorType": "client-secret", + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "redirectUris": ["{{lineageweave_redirect_uri}}"], + "webOrigins": ["{{lineageweave_web_origin}}"], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "{{lineageweave_post_logout_uri}}", + "access.token.lifespan": "300", + "backchannel.logout.session.required": "true", + "require.pushed.authorization.requests": "false" + }, + "fullScopeAllowed": false, + "defaultClientScopes": ["basic", "profile", "email"], + "protocolMappers": [ + { + "name": "keyverse-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "lineageweave-web", + "access.token.claim": "true", + "id.token.claim": "false", + "introspection.token.claim": "true" + } + }, + { + "name": "keyverse-account-role", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "usermodel.clientRoleMapping.clientId": "lineageweave-web", + "usermodel.clientRoleMapping.rolePrefix": "", + "multivalued": "true", + "claim.name": "role", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "false", + "introspection.token.claim": "true" + } + }, + { + "name": "keyverse-account-org", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "org", + "claim.name": "org", + "jsonType.label": "String", + "multivalued": "false", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "false", + "introspection.token.claim": "true" + } + }, + { + "name": "keyverse-account-workspace", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "workspace", + "claim.name": "workspace", + "jsonType.label": "String", + "multivalued": "false", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "false", + "introspection.token.claim": "true" + } + } + ] +} diff --git a/docker-compose.yml b/docker-compose.yml index dfcbe84..5d2a6bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,10 +44,14 @@ services: # --------------------------------------------------------------------- # # Keycloak — the IdP engine. The passwordless-first realm (WebAuthn # passwordless flow, passwords disabled) is imported as-code at start from - # deploy/keycloak/realm-cwl.json via --import-realm. + # deploy/keycloak/cwl-realm.json. Keycloak requires the target name + # -realm.json, so the pinned runtime image packages that exact file. # --------------------------------------------------------------------- # idp_engine: - image: quay.io/keycloak/keycloak:26.3.2@sha256:98fab020a3a490aba0978f237e2a06cd0ea42bf149c6cf10f11c0aaf27728ff2 + build: + context: ./deploy/keycloak + dockerfile: Dockerfile + image: cwl-idp/keycloak:local container_name: cwl_idp_engine restart: unless-stopped command: > @@ -67,8 +71,6 @@ services: KC_HOSTNAME_STRICT: "false" KC_PROXY_HEADERS: xforwarded KC_CACHE: ${IDP_CACHE_MODE:-local} - volumes: - - ./deploy/keycloak/realm-cwl.json:/opt/keycloak/data/import/realm-cwl.json:ro ports: - "${IDP_EXTERNAL_PORT:-8080}:8080" depends_on: @@ -89,6 +91,23 @@ services: - idp_internal_network - idp_edge_network + # The realm representation cannot carry declarative user-profile settings. + # Reconcile the closed LineageWeave attributes only after cwl is imported. + idp_profile_bootstrap: + image: cwl-idp/keycloak:local + restart: "no" + entrypoint: ["/opt/keycloak/reconcile-lineageweave-user-profile.sh"] + environment: + KC_SERVER: http://idp_engine:8080 + KC_REALM: cwl + KC_BOOTSTRAP_ADMIN_USERNAME: ${IDP_BOOTSTRAP_ADMIN_USERNAME:-idp-admin} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${IDP_BOOTSTRAP_ADMIN_PASSWORD} + depends_on: + idp_engine: + condition: service_healthy + networks: + - idp_internal_network + # --------------------------------------------------------------------- # # account-unification admin service (this repo). Fills the gap Keycloak # does not cover natively: MERGE two pre-existing accounts into one, and a @@ -115,6 +134,8 @@ services: depends_on: idp_engine: condition: service_healthy + idp_profile_bootstrap: + condition: service_completed_successfully healthcheck: test: ["CMD", "python", "-m", "app.healthcheck"] interval: 15s diff --git a/docs/PRD.md b/docs/PRD.md index 87278a0..68daa6a 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -84,6 +84,11 @@ RP registration SHALL validate exact HTTPS redirect/origin/logout and authorizat Optional claim expansion SHALL be closed and least-privilege. New audience/claim mapper profiles require explicit typed policy, no script/user-attribute/group/regex arbitrary mapper classes unless separately accepted, and downstream authorization acceptance tests before claiming application readiness. +ADR-0009 is the separately accepted `lineageweave-web` exception: one +self-pinned audience, roles from that client only, and scalar `org`/`workspace` +account attributes. It remains deployment-restricted until real-account Keyverse +apply and downstream tenant/resource ABAC plus role-lifecycle evidence exist. + ### PRD-FR-007 Downstream authorization boundary Every RP SHALL maintain an explicit Keyverse integration profile and SHALL diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 4713bf5..164798d 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -1,11 +1,12 @@ # Keyverse Requirements and Evidence Traceability **Status:** Accepted cross-cutting baseline -**Last reviewed:** 2026-08-12 +**Last reviewed:** 2026-08-20 | Requirement / decision | Standards / authoritative basis | Source/evidence boundary | Maturity | |---|---|---|---| | passwordless local accounts | WebAuthn/FIDO2 + Keycloak supported flow; research/standards records | realm validator + deployment tests | implemented-main | +| Keycloak portable realm import | Keycloak 26 directory import naming contract | Compose/Helm filename mapping contract plus realm-discovery acceptance | implemented-main | | exact subject then verified-email match | OIDC federation / NIST federation guidance; merge documentation | account-unification matching/merge tests | implemented-main | | unverified email never auto-links | security/product invariant | merge/federation tests | implemented-main | | SCIM inbound lifecycle | RFC 7643/7644; protocol documentation | SCIM service/lifecycle tests | implemented-main | @@ -13,6 +14,7 @@ | LDAPS directory profile | LDAP RFC 4511–4515 + Keycloak component docs | directory preflight/reconciliation tests | implemented-main | | secret-free RP desired state | OAuth/OIDC/PKCE/Keycloak client docs | RP preflight/reconciliation/integrity tests | implemented-main | | RP audience/role/org/workspace mapper profile | OIDC/JWT audience + Keycloak mapper docs | PR #72 protected-main source/tests; downstream RP acceptance remains required | implemented-main | +| LineageWeave account-derived RP claims | ADR-0009; Keycloak Protocol Mappers and post-import declarative user profile | scalar, administrator-required account attributes; exact validator statement/branch coverage; Compose profile reconciliation/template tests; real account, Keyverse apply, and downstream ABAC/RBAC acceptance remain required | accepted-contract | | merge/SCIM PUT shared operation lock | concurrency/data-integrity decision; ADR-0006 | merge + full-replacement lock/concurrency tests | implemented-main | | SCIM PATCH active=false shared-lock parity | ADR-0006 boundary | current PATCH source has no shared-lock proof | gap-not-claimed | | intent before mutation, receipt after re-observation | desired-state/recovery decision | federation/directory/RP reconciliation tests | implemented-main | @@ -27,6 +29,7 @@ | contextual-orchestrator Keyverse identity and tenant boundary | ADR-0008; deployment-owned OIDC validation plus downstream scope RBAC and org/workspace ABAC | PR #109 `32ba3a9` adds the injected verifier seam; stacked PR #110 `8607eba` requires `VerifiedIdentity`, rejects boolean-only authorization, binds workflow/evaluation/batch resources to secret-free tenant context, and denies cross-tenant or ownerless reads; main remains unchanged until both protected PRs merge | active-PR | | sidecar anonymous-access boundary | ADR-0008; private service-boundary and least-privilege policy | newsdom-api protected `develop` `3d0426b` (PR #595) fail-closed token gate, startup credential registry, explicit anonymous opt-in, review-fixed authenticated examples/healthcheck/401 contract, and pypdf Trivy remediation; Keyverse-aware gateway evidence remains required for exposure | implemented-main | | 100% production statement/branch/docstring | CWL quality contract | CI/pytest/interrogate | implemented-main | +| product and technical gap baseline | PRD/TRD/ADR-0008/operability and current GitHub evidence | [`docs/product-technical-gap-baseline.md`](product-technical-gap-baseline.md) plus [`docs/doctoring/product-technical-gap-baseline.md`](doctoring/product-technical-gap-baseline.md) | active-PR | ## Research, standards, and operations records diff --git a/docs/TRD.md b/docs/TRD.md index 1ffc105..43fcba8 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -51,6 +51,12 @@ The PR #72 claim mapper profile is integrated in protected main. Downstream authorization acceptance remains deployment-specific and is not implied by Keycloak client reconciliation. +ADR-0009 defines a separate `lineageweave-web` account-derived profile: one +self-pinned audience, same-client role assignments, and scalar `org`/`workspace` +account attributes. It is an accepted contract on its implementation branch, +not protected-main or live-login evidence; controlled Keyverse and downstream +authorization acceptance remain required. + The per-application authorization matrix and remediation directions are governed by ADR-0008. Keyverse client reconciliation does not imply downstream authorization readiness. diff --git a/docs/adr/0001-keycloak-hub.md b/docs/adr/0001-keycloak-hub.md index 1dd0af9..d4ebfb5 100644 --- a/docs/adr/0001-keycloak-hub.md +++ b/docs/adr/0001-keycloak-hub.md @@ -3,4 +3,25 @@ **Status:** Accepted **Date:** 2026-08-09 -Keyverse uses Keycloak as the standards-based identity engine and adds CWL-owned control services around it. Employer/customer ADFS, LDAP/AD, external OIDC, and HR/IGA are federation/provisioning sources rather than peer hubs. CWL relying parties trust the Keyverse/Keycloak boundary instead of administering those external systems directly. Customer-specific federation remains deployment data, not portable realm code. \ No newline at end of file +Keyverse uses Keycloak as the standards-based identity engine and adds CWL-owned control services around it. Employer/customer ADFS, LDAP/AD, external OIDC, and HR/IGA are federation/provisioning sources rather than peer hubs. CWL relying parties trust the Keyverse/Keycloak boundary instead of administering those external systems directly. Customer-specific federation remains deployment data, not portable realm code. + +## Compose and Helm realm-import invariant + +Keycloak directory import discovers a realm only when its target is named +`-realm.json`. The portable file is therefore `cwl-realm.json`. Compose +packages that file in a derivative of the pinned Keycloak image instead of +bind-mounting a leaf below `/opt/keycloak/data`; Docker Desktop can present such +a leaf mount as a directory and make the import fail. Helm maps its ConfigMap +key to the same filename. A container health check alone is insufficient: it can +be healthy while the intended realm was never imported. Deployment acceptance +therefore verifies the realm discovery endpoint, and a static deployment contract +locks the filename mapping in both packaging paths. + +## Keycloak runtime-user invariant + +The derivative image must explicitly run as the non-root UID supplied by the +pinned Keycloak base image. Build-time file copies remain readable and +executable by that runtime user, while the Keycloak server and the post-import +profile reconciliation script do not receive root authority. The deployment +contract locks `USER 1000`, and acceptance also inspects a locally built image +before it can satisfy the image-security gate. diff --git a/docs/adr/0008-keyverse-rp-authorization-boundary.md b/docs/adr/0008-keyverse-rp-authorization-boundary.md index 51a65b7..763d260 100644 --- a/docs/adr/0008-keyverse-rp-authorization-boundary.md +++ b/docs/adr/0008-keyverse-rp-authorization-boundary.md @@ -123,7 +123,10 @@ downstream application authorization: `role`, `org`, and `workspace` values may identify the reviewed deployment profile, but they must not be used as an unverified privilege escalation channel. Moving them to user/tenant-derived Keycloak roles or groups requires - a separate mapper and downstream authorization design with tests. + a separate mapper and downstream authorization design with tests. ADR-0009 + is the sole accepted exception: its `lineageweave-web` profile maps only + same-client roles and exact scalar `org`/`workspace` account attributes; it + does not authorize generic mapper expansion or downstream privilege bypass. 5. The account-unification operator token remains deployment-only and coarse-grained until per-operation RBAC/ABAC is implemented. No downstream application receives Keycloak Admin credentials to compensate for that gap. diff --git a/docs/adr/0009-lineageweave-account-derived-rp-claims.md b/docs/adr/0009-lineageweave-account-derived-rp-claims.md new file mode 100644 index 0000000..0076e0e --- /dev/null +++ b/docs/adr/0009-lineageweave-account-derived-rp-claims.md @@ -0,0 +1,147 @@ +# ADR-0009: Bind LineageWeave relying-party claims to Keyverse accounts + +**Status:** Accepted +**Date:** 2026-08-13 + +## Context + +LineageWeave requires real Keyverse accounts. Its company and PU dimensions are +authorization attributes, not substitute login identities. The existing +Keyverse relying-party mapper profile can emit only static routing claims, which +cannot prove that a current session belongs to the account represented by its +`sub` claim. + +Keycloak documents separate built-in mappers for a user's client roles and for +custom user attributes. A generic mapper editor would expose unnecessary +issuer-side authority, including cross-client roles, arbitrary user attributes, +groups, scripts, claim names, and token destinations. That conflicts with the +closed desired-state boundary and with the downstream ABAC-before-RBAC contract +in ADR-0008. + +## Decision + +Keyverse accepts one separately reviewed confidential relying-party profile for +`lineageweave-web`. The profile contains exactly these four ordered mappers: + +1. `keyverse-audience`: an audience mapper self-pinned to `lineageweave-web`. +2. `keyverse-account-role`: a client-role mapper pinned to the same client, + with no role prefix and a multivalued `role` claim. +3. `keyverse-account-org`: a scalar user-attribute mapper from `org` to `org`. +4. `keyverse-account-workspace`: a scalar user-attribute mapper from + `workspace` to `workspace`. + +All four mappers use the exact reviewed access-token, ID-token, +introspection-token, and UserInfo destinations. The three account-derived +claims are atomic: they must all be present, must not mix with hardcoded claims, +and cannot be extended by configuration. The desired-state representation has +no client-secret field; confidential-secret placement remains a separate +approved secret-management operation. + +The Keyverse post-import declarative user profile declares `org` and +`workspace` as product authorization attributes, alongside the Keycloak +built-in account attributes required because its Admin API replaces the whole +profile rather than patching it. Both product attributes are scalar, maximum +64 characters, visible/editable only to administrators, and required with +`{"roles":["admin"]}`. The administrative requiredness is deliberate: an +end user cannot repair an attribute that the same policy makes +administrator-managed. In the pinned Keycloak 26.3.2 runtime, the closed +unmanaged-attribute policy is represented by an omitted/null value; its enum +does not accept the documented `DISABLED` string, and its implementation denies +unmanaged attributes when that value is null. Keycloak's realm-import +representation does not accept this profile, so a one-shot Compose bootstrap +reconciles it only after the realm is healthy. This keeps the issuer from +silently accepting arbitrary account metadata while preserving operators' +ability to assign the two reviewed ABAC dimensions. + +The receiving application must validate issuer, signature/algorithm, expiry, +subject, and audience before reading these claims. It must reject a missing, +empty, or non-scalar `org` or `workspace` claim before any tenant/resource ABAC +or recognized-role RBAC decision, then bind both values to the requested +resource. A green Keyverse preflight or apply receipt is not controlled login +or authorization evidence. + +### Normative tenant mapping + +For this profile, `org` is the opaque external tenant key. It has exactly one +trimmed scalar value per token and is mapped by the receiving application to +exactly one local tenant record through a verified configuration or membership +lookup; it is never inferred from client ID, subject, email, or role. +`workspace` is a child namespace under `org`, also with exactly one trimmed +scalar value per token. It is not a replacement tenant key: a consumer must +prove that the workspace belongs to the resolved organization before resource +lookup. + +The profile does not represent multiple memberships. Multiple memberships are +not represented by comma-separated values, arrays, or delimiter conventions. +If membership resolution is ambiguous, missing, or maps either claim to more +than one local record, the consumer must deny before ABAC or RBAC. Operators +must issue a new token or session renewal after an organization, workspace, or +membership change; existing tokens remain bounded by their configured expiry +and must never be reinterpreted as a new tenant binding. + +This is a normative mapping from the existing `org` and `workspace` claims, +not a new `tenant` mapper. A future multi-membership or scalar-tenant profile +requires a separate ADR, RED regression, and downstream acceptance evidence. + +```mermaid +flowchart LR + A["Verified Keyverse account"] --> B["Same-client role assignment"] + A --> C["org account attribute"] + A --> D["workspace account attribute"] + B --> E["Closed LineageWeave mapper profile"] + C --> E + D --> E + E --> F["Verified token claims"] + F --> G["Tenant and resource ABAC"] + G --> H["Bounded role RBAC"] +``` + +## Options considered + +1. Keep static `role`, `org`, and `workspace` claims. Rejected because they do + not bind the current authenticated account to company or PU attributes. +2. Permit generic Keycloak user/role/group mappers. Rejected because arbitrary + issuer-side mappings expand authorization authority and cannot be reviewed + from a stable desired-state contract. +3. Use the exact four-mapper account-derived profile. Accepted because it binds + the needed claims to one Keyverse account and one client while retaining + deterministic validation and reconciliation. + +## Consequences + +- Identity operators must provision a real Keyverse account with the two named + administrator-required attributes and an allowed `lineageweave-web` client + role before user routing; a missing attribute is a failed provisioning state, + not a downstream authorization default. +- Account and role changes take effect through Keycloak session/token lifecycle; + operators must test downgrade and revocation behavior in controlled runtime + acceptance. +- The profile does not authorize a resource on its own. LineageWeave must retain + tenant/resource ABAC and only then apply its bounded role map. +- Any extra attribute, group, mapper type, audience, claim name, or token + destination requires a new ADR, RED regression, and downstream acceptance + evidence. + +## Acceptance evidence + +The implementation has local RED-to-GREEN validation, mapper-observation, and +secret-free-template tests. It also has a live Keycloak 26.3.2 API acceptance: +the full profile PUT returns success, returns both reviewed attributes, and +omits `unmanagedAttributePolicy` after reconciliation. Before production use, +record authenticated Keyverse preflight and reconciliation receipts, private +credential placement, a real account authorization-code/PKCE exchange, token +claim shape, cross-tenant denial, role downgrade, logout, and rollback +evidence. Until then the profile is an accepted contract, not a deployed-login +claim. + +## References + +Keycloak Project. (2026). *Protocol mappers*. Retrieved August 13, 2026, from +https://www.keycloak.org/admin-api/protocol-mappers + +Keycloak Project. (2026). *Server Administration Guide* (User profile). +Retrieved August 14, 2026, from https://www.keycloak.org/docs/latest/server_admin/ + +Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current +practice for OAuth 2.0 security* (RFC 9700). Internet Engineering Task Force. +https://doi.org/10.17487/RFC9700 diff --git a/docs/adr/README.md b/docs/adr/README.md index e53fc6d..66f733d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -12,6 +12,7 @@ | [0006](0006-user-operation-lock.md) | Merge and SCIM full replacement share one user-operation lock boundary | Accepted | | [0007](0007-automation-authority.md) | Autonomous development remains separate from review/merge/release authority | Accepted | | [0008](0008-keyverse-rp-authorization-boundary.md) | Every non-fork RP explicitly validates Keyverse identity and manages ABAC/RBAC at its own boundary | Accepted | +| [0009](0009-lineageweave-account-derived-rp-claims.md) | Bind LineageWeave claims to explicit, administrator-managed Keyverse account attributes | Accepted | ## ADR triggers diff --git a/docs/doctoring/oidc-rp-claim-mapper-profile.md b/docs/doctoring/oidc-rp-claim-mapper-profile.md index fe5389e..9627b70 100644 --- a/docs/doctoring/oidc-rp-claim-mapper-profile.md +++ b/docs/doctoring/oidc-rp-claim-mapper-profile.md @@ -60,6 +60,24 @@ orders known mapper identities canonically, and then performs semantic drift comparison. Unknown, malformed, or duplicate live mappers remain drift rather than being silently discarded. +ADR-0009 adds one separate, exact `lineageweave-web` profile. It permits a +client-role mapper whose configured client ID equals the registration client ID, +has no role prefix, and emits multivalued `role`; it also permits two scalar +user-attribute mappers from `org` to `org` and `workspace` to `workspace`. +Keycloak documents these mapper IDs and their configuration properties. Keyverse +intentionally rejects every other user attribute, role source, aggregation, +group, script, audience, claim name, and destination. + +### Account-profile requiredness + +Keycloak's declarative user profile permits a required role of `admin` or +`user`. The LineageWeave `org` and `workspace` attributes use +`{"roles":["admin"]}` because the same profile makes those fields viewable and +editable only in administrator context. A user-context requirement would direct +an end user to repair attributes they cannot edit. This Keyverse policy proves +only the issuer-side provisioning constraint; receiving-application claim +validation is a separate operational-acceptance requirement. + ## Stricter Keyverse product policy The product policy is intentionally narrower than the vendor representation: @@ -75,6 +93,8 @@ The product policy is intentionally narrower than the vendor representation: effect. 8. Desired state remains secret-free and write receipts are produced only after post-mutation re-observation. +9. The account-derived exception requires all three dynamic claims, forbids + static/dynamic mixing, and retains the same four-mapper maximum. The hardcoded claims are not, by themselves, proof of user entitlement. A consumer that uses them for authorization must still apply its independently @@ -93,6 +113,10 @@ The implementation is covered by production-shaped tests that exercise: - semantic drift for unknown, malformed, duplicate, or changed mappers; - the committed `deploy/templates/oidc-rp-naruon.json` artifact after placeholder substitution; +- the LineageWeave account-role, account-attribute, non-mixing, and + generated-ID/vendor-order reconciliation paths; +- the committed `deploy/templates/oidc-rp-lineageweave.json` artifact after + HTTPS placeholder substitution; - complete production statement and branch coverage in the repository CI gate. The template test was intentionally introduced before the template. Hosted CI @@ -112,6 +136,13 @@ RED receipt before the template was added. - Downstream Naruon token validation rejects invalid issuer, signature, algorithm, expiry, and audience values. - The deployed Keycloak version preserves the reviewed mapper semantics. +- A LineageWeave account has exactly one scalar `org` and `workspace` value and + a least-privilege set of client roles for `lineageweave-web`. +- After normal token verification, LineageWeave must reject an absent, empty, + or non-scalar `org` or `workspace` claim before tenant/resource ABAC and its + bounded product-role mapping. This downstream behavior requires its own + implementation and runtime acceptance evidence; it is not established by + Keyverse mapper validation or reconciliation alone. ## Limitations and follow-up @@ -123,6 +154,10 @@ change. Any new mapper type, claim name, token destination, resource audience, or native-client redirect profile requires explicit design and regression coverage rather than extension by configuration alone. +The account-derived profile also does not prove that a real Keyverse account +has been provisioned, its confidential credential has been placed, or its +LineageWeave login/tenant/role lifecycle has been accepted in production. + ## References Bertocci, V. (2021). *JSON Web Token (JWT) profile for OAuth 2.0 access tokens* @@ -138,5 +173,11 @@ Keycloak Project. (2026). *ClientRepresentation* (Keycloak Docs Distribution Keycloak Project. (2026). *ProtocolMapperRepresentation* (Keycloak Docs Distribution 26.x API). https://www.keycloak.org/docs-api/latest/javadocs/org/keycloak/representations/idm/ProtocolMapperRepresentation.html +Keycloak Project. (2026). *Protocol mappers*. Retrieved August 13, 2026, from +https://www.keycloak.org/admin-api/protocol-mappers + +Keycloak Project. (2026). *Server Administration Guide* (User profile). +Retrieved August 14, 2026, from https://www.keycloak.org/docs/latest/server_admin/ + OpenID Foundation. (2023). *OpenID Connect Core 1.0 incorporating errata set 2*. https://openid.net/specs/openid-connect-core-1_0.html diff --git a/docs/doctoring/oidc-rp-client-desired-state.md b/docs/doctoring/oidc-rp-client-desired-state.md index ed39826..4d86904 100644 --- a/docs/doctoring/oidc-rp-client-desired-state.md +++ b/docs/doctoring/oidc-rp-client-desired-state.md @@ -100,6 +100,12 @@ client secret. Confidential-client credential provisioning remains a separate secret-management responsibility and must not be added to this lifecycle by loosening the model. +ADR-0009's `lineageweave-web` template follows the same boundary. Its dynamic +claims are mapper configuration, not embedded user data or credential material: +the role and two account attributes are resolved by Keycloak only after a real +account authenticates. A receipt still does not prove that account provisioning, +credential placement, or controlled login succeeded. + ## Locking and failure semantics A state lock protects KV access only. A process-local keyed lock serializes diff --git a/docs/doctoring/product-technical-gap-baseline.md b/docs/doctoring/product-technical-gap-baseline.md new file mode 100644 index 0000000..85aa7a0 --- /dev/null +++ b/docs/doctoring/product-technical-gap-baseline.md @@ -0,0 +1,256 @@ +# Product-technical gap baseline doctoring record + +**Date:** 2026-08-21 +**Scope:** Keyverse product, trust-boundary, PR queue, and release evidence + +## Interpretation + +The baseline classifies facts by evidence boundary. Repository source/tests can +prove deterministic validation and reconciliation behavior. They cannot prove a +live issuer, browser login, token signature acceptance, downstream tenant +authorization, or release provenance without an approved runtime lane. This +record therefore treats missing runtime evidence as `gap-not-claimed`, not as a +failed implementation and not as success. + +The current mapper policy remains closed. `role`, `org`, and `workspace` are +issuer-side product claims; a relying party must define and verify its own +tenant/resource/purpose semantics before RBAC. Adding a generic tenant claim +would be a new authorization profile and requires a separate ADR, red tests, +consumer evidence, and traceability update. + +The follow-up LineageWeave contract makes the existing mapping explicit without +expanding that profile: `org` is one opaque external tenant key, `workspace` is +one child namespace under that organization, multiple memberships have no +comma-separated or array encoding, and ambiguous membership resolution denies +before ABAC/RBAC. A changed membership requires a new token or session renewal. +This closes the contract ambiguity only; real login, token validation, local +tenant binding, cross-tenant denial, and resource authorization remain runtime +evidence gaps. + +## Standards interpretation + +- OpenID Connect Core requires exact issuer matching, client audience + validation, signature validation, and expiration processing during ID Token + validation. These are consumer acceptance requirements, not evidence supplied + by an issuer-side mapper configuration. +- RFC 8725 requires applications to validate issuer ownership, issuer/subject + validity, and audience association, and to reject invalid tokens. This + supports keeping tenant and resource authorization after cryptographic token + validation. +- RFC 9700 is the current OAuth 2.0 Security BCP used here. It supports exact + redirect matching and authorization-code + PKCE protection, with `S256` as + the interoperable code-challenge method for this profile. +- Keycloak's current administration guide documents protocol mappers as the + mechanism that projects roles and user/session data into tokens. That vendor + behavior does not establish that a receiving application enforces ABAC/RBAC. + +## 2026-08-21 local runtime probe + +This is partial protocol-readiness evidence, not login or release acceptance. + +- Docker Engine 29.5.2 was available. The existing Compose runtime reported + `idp_database` healthy, `idp_engine` healthy, and the one-shot + `idp_profile_bootstrap` completed successfully. The account-unification + service was not running. +- `docker compose config --quiet` remained structurally valid but warned that + the deployment-only `IDP_DB_PASSWORD` and + `IDP_BOOTSTRAP_ADMIN_PASSWORD` values were unset. The ignored + `deploy/bootstrap/bootstrap.yaml` was absent; only the secret-free example + pointer exists. No secret or credential value was recorded. +- The live realm discovery endpoint returned HTTP 200 with issuer + `http://localhost:28080/realms/cwl`, authorization/token/JWKS endpoints, and + advertised `S256` among the realm-wide code-challenge methods. +- A real authorization request for the committed `naruon-web` client, its + committed `https://naruon.example/auth/callback` redirect, and a valid + `S256` challenge reached the Keycloak login page with HTTP 200. The earlier + intentionally invalid localhost redirect returned HTTP 400, confirming + redirect enforcement at the live client boundary. +- No account was created, no password or passkey credential was entered, no + authorization code was exchanged, and no token signature/issuer/audience/ + tenant/resource acceptance was claimed. Browser automation was unavailable + in this environment, so browser-clicked passwordless E2E remains absent. + +**Result:** the live issuer and authorization-start boundary are reachable, +but controlled passwordless login, token validation, downstream authorization, +and account-service runtime acceptance remain `gap-not-claimed`. The missing +bootstrap/config-store path is an actionable standalone-Compose deployment gap +that requires deployment-owned secret/config setup before a safe service start; +placeholder credentials must not be committed to close it. + +## 2026-08-21 storage evidence + +- The focused exact-tree run `uv run pytest -q + tests/test_storage_concurrency.py tests/test_lifecycle.py` passed 6 tests. + This is evidence for the SQLite sidecar's local lock contention and lifecycle + behavior only. +- No PostgreSQL migration/rollback, concentrated-tenant skew, partition-key, + backup/restore, or production recovery evidence was observed. G5 therefore + remains `gap-not-claimed`; the local SQLite result must not be promoted into a + production database acceptance claim. + +## 2026-08-21 physical PostgreSQL probe + +- The running Compose `idp_database` container uses the pinned PostgreSQL 17 + image. A read-only catalog probe found 88 non-system tables, 3,981,312 + relation bytes, zero partitioned tables, and `pg_is_in_recovery=false`. + Observed settings were `max_connections=100`, `shared_buffers=163848kB`, + `work_mem=4096kB`, `wal_level=replica`, and `archive_mode=off`. +- This is local Keycloak system-of-record smoke evidence only. The + account-unification service uses its SQLite sidecar for local state, and + neither runtime path proves tenant-concentration behavior, application-owned + partitioning, backup/restore, failover, or production sizing. G5 therefore + remains `gap-not-claimed`. + +## 2026-08-21 exact local CI contract verification + +- The repository CI-scoped command passed the full test suite with 2,786 + application statements and 770 branches at 100% coverage, with no missing + statements or branches. +- The committed validator-path command passed with 181 statements and 114 + branches at 100% coverage. Interrogate, Ruff, compileall, and diff checks + also passed on the same local tree. +- These results are exact local evidence for the current PR head only. Hosted + GitHub Checks, independent approval, latest-pusher compliance, and protected + merge evidence remain separate gates. + +## 2026-08-21 cross-repository cadence dependency + +- Contextual-orchestrator PR #797 is closed without merge and superseded. Its + minute-07 caller duplicated the canonical central caller now proposed in + `.github` PR #1178; it must not be reopened or merged while #1178 owns this + dispatch boundary. Central PR #1183 is also closed without merge. +- Central `.github` PR #1170 is open at exact head + `01b8cacc155993f20306589ebbf17a1114cf7344` for routing OpenCode reviews + through the contextual gateway. Its current hosted rollup has 1 successful, + 2 pending, 17 cancelled, and 13 skipped Checks; no terminal source failure + or qualifying formal approval is recorded. +- Central `.github` PR #1178 is the canonical contextual-orchestrator hourly + caller, open at exact head + `97b084ac28b5ccf6de7f68fd2e019d8da6f80143`. Its current rollup has 26 + successful and 2 pending Checks, with 18 skipped and no terminal source + failure. Neither #1170 nor #1178 has qualifying formal approval or protected + merge evidence. +- Central `.github` PR #1176 is open at exact head + `33b85a8cf48d5b6e0880d5071b360ffa46f83457` to require central reviews for + stacked PRs. Its current rollup has 25 successful and 6 pending Checks, with + 18 skipped and no qualifying formal approval. +- Central `.github` PR #1187 is open at exact head + `91c16ebf5187daad749ae57ec01d16cb7afec7b3` for scoped Rust coverage + evidence. Its current rollup has 8 successful, 16 pending, 15 skipped, and + 1 cancelled Check, with no terminal source failure; the cancelled + `scan-pr-queue` job has a newer queued retry. It has no qualifying formal + approval or protected merge evidence. +- Central `.github` PR #1152 is open at exact head + `11491068712859e936e7ce4ed7f204f5c1157f0c` for the OpenCode retry path. Its + current rollup has 3 successful and 16 pending Checks, with 13 skipped and + no terminal source failure or qualifying formal approval. +- Keyverse's existing `Hourly product development` workflow remains active at + `41 * * * *`; its latest observed scheduled runs succeeded. No duplicate + scheduler was added. Activation of the central caller remains conditional on + independent approval and terminal exact-head evidence for #1170 and #1178. + +## APA 7th references + +OpenID Foundation. (2014). *OpenID Connect Core 1.0*. +https://openid.net/specs/openid-connect-core-1_0-18.html + +Keycloak. (2026). *Server administration guide*. +https://www.keycloak.org/docs/latest/server_admin/ + +Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current +practice for OAuth 2.0 security* (RFC 9700). Internet Engineering Task Force. +https://www.rfc-editor.org/rfc/rfc9700.html + +Sheffer, Y., Hardt, D., & Jones, M. (2020). *JSON Web Token best current +practices* (RFC 8725). Internet Engineering Task Force. +https://www.rfc-editor.org/rfc/rfc8725.html + +## Evidence sources + +- `docs/PRD.md`, `docs/TRD.md`, `ARCHITECTURE.md`, `docs/OPERABILITY.md`, + `docs/THREAT_MODEL.md`, `docs/TEST_STRATEGY.md`, and `docs/TRACEABILITY.md`. +- ADR-0008 and ADR-0009 plus their related specification, plan, operations, + and doctoring records. +- Exact-head GitHub PR, review, issue, check-run, ruleset, and scheduled-run + queries performed on 2026-08-21. Fourteen Keyverse PRs are open: #112, #101, + and #83 each have 23 successful Checks with no queued run. #111's normal + branch update created current head `032f730b0239d062cf9803525ba66c740e0b2d2e` + with 14 queued Checks and one completed success; #113 has 22 successful and + one queued Check; #108 has 14 successful and six queued, #107 has 19 + successful and two queued, and #100 has two successful and 14 queued Checks. + No current open PR has a qualifying + formal approval. Queued Checks + remain unverified. + PR #113's current SCIM lock head + `50f19ec6338fb8eb959b8c797bdfa938e1071c87` includes the normal prerequisite + lockfile history and a realistic SCIM PatchOp race plus spawned-process + SQLite lock regression; its hosted Checks now have 22 successful runs and + one queued rerun with no terminal failure. The earlier Strix job could not + pull `ghcr.io/usestrix/strix-sandbox:1.3.0` because GHCR returned HTTP + 500/EOF, and it produced no structured vulnerability report, so that run + failed closed; the fresh exact-head rerun remains unverified while queued. + Its local RED-to-GREEN, root-level SCIM error-wire, and cross-process + sidecar evidence are not protected-main evidence. PR #112's lockfile head + `f02acf93367a40dbfb23a73985017dca8d42ff39` has 23 terminal-success Checks + but still requires independent review. PR #111's current head + `032f730b0239d062cf9803525ba66c740e0b2d2e` now contains #112's lockfile + through a normal branch update. Its prior `account-unification-tests` + failure occurred before that update; the fresh run is queued and remains + unverified. It remains coupled to #112 and #110. PR #110's current head + `c3e307fc3d4f6d98ec5a0514f35aa8038b2737b7` remains on the #112 base with + one completed success and 14 queued Checks; its hosted reruns remain + unverified. + The historical PR #105 exact head + `72de5499d6e97ae7f7bd804ab78b3e1644dd5a4f` had a failed + `account-unification-tests` Check: `uv 0.12.5` reproduced + `uv sync --locked` refusing the stale `coverage==7.15.2` and + `setuptools==83.0.0` lock entries while the current `pyproject.toml` required + `7.15.4` and `84.0.0`; the current #105 head + `77f83dfb2c4611345c0d48f92fceaa6195b4630c` is stacked on #112 and has one + completed success plus 14 queued Checks with no terminal failure. The + current #106 head `e7fafd4192cc3cc344b8f8e536bc0495afaa739f` likewise has + one completed success and 14 queued Checks. PR #109's merged head + `7b726b16d38ce16d13d00c946b5c8bc0c406191f` has one completed success and 14 + queued Checks; local + locked-install, full pytest, Ruff, Interrogate, and compileall verification + passed before its normal merge commit was pushed. + PR #107 was rebased cleanly onto #112 at + `53842560d397aa20309a6b16aceb560540611686`, and PR #108 was rebased cleanly + onto #112 at `538cead991a7c1bed32f2dcb5413b5fc56f53e93`; the latest rollups + are 19 successful plus two queued for #107 and 14 successful plus six queued + for #108, with no terminal failure. Local + `uv sync --locked --extra dev` plus the full service pytest suite passed on + both rebased trees. Their fresh hosted Checks remain unverified and #112 + remains the lock-refresh prerequisite. + PR #103's historical terminal Strix run 32092025335 / job 95576032571 + emitted a MEDIUM IDOR report with contradictory model text. Its current exact + head `9274d3184443fba6c6294e08dff20734f3ae6fb4` adds RED-to-GREEN regressions + for percent-encoded discovery markers, inactive and expired token rotation, + invalid token rotation settings, KV/audit lifecycle failures, direct router + embedding, tenant isolation, software-unit ABAC, runtime authentication, and + untrusted public issuers. Local focused/full tests, Ruff, Interrogate, and + 100% application statement/branch coverage pass; required hosted Checks are + still pending or queued, so hosted security and independent approval success + are not claimed. The operator-admin trust boundary and the fresh security + changes still require independent exact-head validation. PR #100's + pre-refresh documentation head is + `58c6bb5b8ba5dd84dd7e3c73f4f629520ad0110c`, with two successful and 14 + queued Checks; its prior review state is not approval. PR #104's updated + head `7da9d43087d5647fefb946eb154ee1e5c10c576d` is based on #112's lockfile + head and has two successful plus 14 queued Checks. The resulting #100 head + from this documentation refresh must be re-audited after the commit; its + pre-refresh review state is not approval. + This record travels in these PRs, so the live PR records remain authoritative + for their changing exact hashes. + The active ruleset requires two approving reviews, resolved threads, and + latest-push approval, while its read-only audit exposes an + `OrganizationAdmin` always-bypass actor. The ordinary documentation push + emitted GitHub's server-side bypass warning; no explicit bypass option, + protected merge, or self-approval was used. A complete read-only Keyverse + Actions registry/tree reconciliation at protected `main` `ce207dfd` found 43 active identities: + four repository workflow paths present in the exact tree, 37 active + repository paths absent from it, and two `dynamic/*` GitHub-owned paths. The + workflow registry used one API page and the recursive protected-tree response + was not truncated. No workflow state was mutated; the evidence is recorded + on Keyverse issue #99 and central issue #945 for the owning lifecycle + operator. No credential, private payload, or PII is recorded here. diff --git a/docs/operations/hourly-product-development.md b/docs/operations/hourly-product-development.md index abb366a..e46bd11 100644 --- a/docs/operations/hourly-product-development.md +++ b/docs/operations/hourly-product-development.md @@ -182,7 +182,7 @@ uv run coverage run --branch --source=app -m pytest -q uv run coverage report --show-missing --fail-under=100 uv build --out-dir dist cd ../.. -python scripts/validate_realm.py deploy/keycloak/realm-cwl.json +python scripts/validate_realm.py deploy/keycloak/cwl-realm.json docker compose -f docker-compose.yml config python -m json.tool deploy/templates/.json git diff --check diff --git a/docs/operations/oidc-rp-reconciliation.md b/docs/operations/oidc-rp-reconciliation.md index 629f031..f4f28ae 100644 --- a/docs/operations/oidc-rp-reconciliation.md +++ b/docs/operations/oidc-rp-reconciliation.md @@ -9,12 +9,14 @@ authorization-code/JWT acceptance. ## Routine workflow -1. Render `deploy/templates/oidc-rp-client.json` for a generic RP or +1. Render `deploy/templates/oidc-rp-client.json` for a generic RP, `deploy/templates/oidc-rp-naruon.json` for the reviewed Naruon public-client - mapper profile into a private mode-0600 file. -2. Resolve all HTTPS and routing placeholders. Treat Naruon `role`, `org`, and - `workspace` values as visible product data, never credentials or personal - secrets. + profile, or `deploy/templates/oidc-rp-lineageweave.json` for the ADR-0009 + confidential account-derived profile into a private mode-0600 file. +2. Resolve only HTTPS and routing placeholders. Treat Naruon static values as + visible product data, never credentials or personal secrets. For + LineageWeave, provision the role and account attributes in Keyverse rather + than rendering them into the template. 3. Call `POST /clients/relying-parties:validate` and require HTTP 200 plus `ready_to_apply=true`. 4. Call `PUT /clients/relying-parties/{client_id}` with the same original file. @@ -46,6 +48,42 @@ configuration is issuer-side evidence only. The receiving Naruon boundary must independently validate the token and must not infer authorization merely from the presence of a hardcoded claim. +## LineageWeave account-claim contract + +The LineageWeave runtime artifact is a confidential `lineageweave-web` client +with exactly four canonical mappers: + +1. `keyverse-audience` — access-token audience pinned to `lineageweave-web`. +2. `keyverse-account-role` — an unprefixed, multivalued role mapper limited to + `lineageweave-web` client-role assignments. +3. `keyverse-account-org` — scalar `org` user attribute to `org` claim. +4. `keyverse-account-workspace` — scalar `workspace` user attribute to + `workspace` claim. + +Do not add a static claim, group, additional user attribute, role prefix, +aggregation setting, or client secret to this profile. Apply requires an actual +Keyverse account with the two attributes and an allowed client role. Controlled +acceptance must prove issuer/signature/expiry/audience validation, tenant and +resource ABAC denial, role downgrade, logout, and rollback. A compose-only IdP +or preflight receipt does not satisfy that evidence. + +### Normative tenant mapping for LineageWeave + +For the `lineageweave-web` profile, `org` is the opaque external tenant key +and must resolve to exactly one local tenant record. `workspace` is a child +namespace under `org` and must resolve to a workspace belonging to that tenant. +Both claims are one trimmed scalar value per token; neither may be inferred +from client ID, subject, email, or role. Multiple memberships are not +represented by comma-separated values, arrays, or delimiter conventions. + +Before resource access, reject missing, malformed, unmapped, or ambiguous +tenant/workspace resolution. If membership resolution is ambiguous, do not +continue to role or scope checks. Operators must issue a new token or session +renewal after organization, workspace, or membership changes; an existing +token is bounded by its expiry and must not be silently rebound to another +tenant. This mapping deliberately uses the existing `org` and `workspace` +claims and does not add a generic `tenant` mapper. + ## Example ```bash diff --git a/docs/passwordless-policy.md b/docs/passwordless-policy.md index e33c2c3..4dbdea1 100644 --- a/docs/passwordless-policy.md +++ b/docs/passwordless-policy.md @@ -9,7 +9,7 @@ password to phish, reuse, reset, or leak. ## How it is enforced as code -`deploy/keycloak/realm-cwl.json` fixes the following invariants: +`deploy/keycloak/cwl-realm.json` fixes the following invariants: | Setting | Value | Effect | | --- | --- | --- | diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md new file mode 100644 index 0000000..3008a0e --- /dev/null +++ b/docs/product-technical-gap-baseline.md @@ -0,0 +1,316 @@ +# Keyverse product and technical gap baseline + +**Evidence snapshot:** 2026-08-21 (Asia/Seoul) +**Repository:** `ContextualWisdomLab/keyverse` +**Protected-main head observed:** `ce207dfd42975db61c82a5963e206fc1db14ac2b` +**Status:** live inventory and gap register; not a release acceptance record + +This document turns the accepted PRD, TRD, architecture, ADRs, doctoring +records, and current GitHub state into an executable buyer-facing backlog. +It separates implementation on protected `main`, active PR work, accepted +contracts, and evidence that is still absent. A green unit or preflight check +does not promote a lower-level result into login, token, authorization, or +release acceptance. + +## Product contract + +Keyverse is a standalone and embeddable identity control plane for CWL, Naruon, +and sibling products. Its durable boundary owns passwordless-first Keycloak +policy, federation and directory preflight/reconciliation, account +unification, SCIM lifecycle, relying-party desired state, audit, and safe +deployment operations. Downstream applications still own token verification, +tenant/resource/purpose ABAC, and bounded RBAC. + +The trust order is: + +```text +portable realm policy + -> authenticated side-effect-free preflight + -> secret-free desired state + -> exact remote reconciliation + -> post-mutation observation and receipt + -> controlled protocol acceptance + -> downstream authorization acceptance +``` + +The final two stages are intentionally separate from Keycloak mapper +configuration. A relying-party registration or mapper receipt is never proof +that a receiving application validates issuer, signature, expiry, audience, +tenant, or resource ownership. + +## Evidence classification + +| Classification | Meaning in this baseline | +|---|---| +| `implemented-main` | Source and tests are on the observed protected-main head. | +| `active-PR` | Work exists in an open PR and is not part of protected-main evidence. | +| `accepted-contract` | An ADR/specification defines the policy, but runtime or buyer acceptance may still be absent. | +| `gap-not-claimed` | The repository deliberately makes no success claim until stronger evidence exists. | + +## Current capability map + +| Capability | Current state | Evidence boundary | +|---|---|---| +| Passwordless local identity | `implemented-main` | Portable realm validation requires WebAuthn passwordless flow and rejects a password authenticator. | +| Federation and LDAP preflight | `implemented-main` | Closed validators are side-effect-free; apply and external bind/discovery remain separate. | +| Account linking, merge, and SCIM full replacement | `implemented-main` | Verified identity evidence, tombstones, audit, and shared merge/`PUT` lock are covered. SCIM `PATCH active=false` is intentionally narrower. | +| Secret-free RP desired state | `implemented-main` | Exact client identity, duplicate fail-closed behavior, re-observation, receipt, and remote-first delete are implemented. | +| Closed RP mapper profile | `implemented-main` / `accepted-contract` | Canonical `role`, `org`, `workspace` policy is protected; ADR-0009 remains a separate `lineageweave-web` profile. | +| Downstream RP authorization | `gap-not-claimed` | Each consumer must independently prove issuer/JWKS/signature/expiry/audience, tenant/resource ABAC, and RBAC. | +| Real Keyverse login and token acceptance | `gap-not-claimed` | Local tests do not establish a live issuer, controlled account, browser flow, or consumer token acceptance. | +| Standalone Compose and Helm module boundaries | `implemented-main` | Repository contracts and validators exist; release-grade deployment/rollback evidence is still required. | +| Hourly product and PR loop | `active-PR` / `implemented-main` | Workflows and fail-closed guards exist; every scheduled run must be checked against exact external evidence. | +| Release artifact acceptance | `gap-not-claimed` | Version, immutable image digest, SBOM/provenance, rollback, and exact-main acceptance remain release gates. | + +## Live PR inventory + +The following is the current open-PR inventory. `Checks` means the exact head +rollup observed on 2026-08-21; it is not inherited from a predecessor commit. +This record was refreshed from a live exact-head audit while PR #100 stood at +`58c6bb5b8ba5dd84dd7e3c73f4f629520ad0110c`; the documentation commit that +updates this snapshot is intentionally not recursively named. The audit also +includes normal branch updates from the #112 lock-refresh base for the +dependency/documentation stack. The live PR record is authoritative for the +exact hash and Checks; predecessor evidence remains non-transferable. + +At this snapshot, 14 PRs are open and none has a qualifying formal approval. +#112, #101, and #83 each have 23 successful Checks with no queued Check; +#113 has 22 successful and one queued Check; #108 has 14 successful and six +queued Checks; #107 has 19 successful and two queued Checks; #100 has two +successful and 14 queued Checks; and the remaining PRs have queued Checks. +There is no current terminal failure bucket in this inventory. Historical +terminal failures are recorded separately and are not current-head green +evidence. Queued Checks remain unverified rather than green. + +| PR | Scope | Exact-head Checks | Review state | Next safe action | +|---:|---|---|---|---| +| [#113](https://github.com/ContextualWisdomLab/keyverse/pull/113) | SCIM `PATCH active=false` shared operation lock, stacked on #112 lockfile refresh | PENDING: 1 queued, 22 successful, 0 terminal failures on `50f19ec6338fb8eb959b8c797bdfa938e1071c87` | review required | The earlier GHCR Strix image-pull failure was rerun; wait for the fresh exact-head security result and independent review. | +| [#112](https://github.com/ContextualWisdomLab/keyverse/pull/112) | Resync account-unification lockfile | PASS: 23 Checks successful, 0 queued on `f02acf93367a40dbfb23a73985017dca8d42ff39` | review required | Obtain independent review, then let protected automation re-check and merge. | +| [#111](https://github.com/ContextualWisdomLab/keyverse/pull/111) | CodeQL init 4.37.7 | PENDING: 14 queued, 1 successful, 0 terminal failures on updated head `032f730b0239d062cf9803525ba66c740e0b2d2e` | review required | The normal branch update merged #112's lockfile base into the CodeQL branch; wait for fresh exact-head Checks and independent review. | +| [#110](https://github.com/ContextualWisdomLab/keyverse/pull/110) | CodeQL analyze 4.37.7, stacked on #112 lockfile refresh | PENDING: 14 queued, 1 successful, 0 terminal failures on updated head `c3e307fc3d4f6d98ec5a0514f35aa8038b2737b7` | review required | The normal branch update now includes #112's lock-refresh base; wait for fresh exact-head Checks and independent review. | +| [#109](https://github.com/ContextualWisdomLab/keyverse/pull/109) | `typing-inspection` update, stacked on #112 lockfile refresh | PENDING: 14 queued, 1 successful, 0 terminal failures on merged head `7b726b16d38ce16d13d00c946b5c8bc0c406191f` | review required | The lock conflict was resolved by a normal merge commit and local locked-install/full-suite verification; wait for fresh hosted Checks and independent review. | +| [#108](https://github.com/ContextualWisdomLab/keyverse/pull/108) | Ruff update, stacked on #112 lockfile refresh | PENDING: 6 queued, 14 successful, 0 terminal failures on `538cead991a7c1bed32f2dcb5413b5fc56f53e93` | review required | The conflicting lockfile base was rebased cleanly onto #112; wait for the remaining exact-head Checks and independent review. | +| [#107](https://github.com/ContextualWisdomLab/keyverse/pull/107) | Uvicorn update, stacked on #112 lockfile refresh | PENDING: 2 queued, 19 successful, 0 terminal failures on `53842560d397aa20309a6b16aceb560540611686` | review required | The conflicting lockfile base was rebased cleanly onto #112; wait for the remaining exact-head Checks and independent review. | +| [#106](https://github.com/ContextualWisdomLab/keyverse/pull/106) | `setup-uv` update, stacked on #112 lockfile refresh | PENDING: 14 queued, 1 successful, 0 terminal failures on updated head `e7fafd4192cc3cc344b8f8e536bc0495afaa739f` | review required | The normal branch update now includes #112's lock-refresh base; wait for fresh exact-head Checks and independent review. | +| [#105](https://github.com/ContextualWisdomLab/keyverse/pull/105) | `harden-runner` update, stacked on #112 lockfile refresh | PENDING: 14 queued, 1 successful, 0 terminal failures on updated head `77f83dfb2c4611345c0d48f92fceaa6195b4630c` | changes requested | The normal branch update now includes #112's lock-refresh base; obtain current-head independent review. | +| [#104](https://github.com/ContextualWisdomLab/keyverse/pull/104) | ADR and buyer README expansion, stacked on #112 | PENDING: 14 queued, 2 successful, 0 terminal failures on updated head `7da9d43087d5647fefb946eb154ee1e5c10c576d` | review required | Base is intentionally `fix/account-unification-lock-20260819`; the normal branch update now includes the lock-refresh base. Obtain independent review and terminal stacked-head Checks. | +| [#103](https://github.com/ContextualWisdomLab/keyverse/pull/103) | Hierarchical authorization, login helper, PATs | PENDING: 14 queued, 2 successful, 0 terminal failures on `9274d3184443fba6c6294e08dff20734f3ae6fb4` | changes requested; fresh review requested | Fresh head adds inactive/expired token-rotation denial, compensating token lifecycle recovery, separate runtime service authentication, tenant-qualified grant/combination isolation, software-unit ABAC, and trusted issuer binding; wait for exact-head security Checks and independent review before any merge claim. | +| [#101](https://github.com/ContextualWisdomLab/keyverse/pull/101) | Atomic coupled Python dependency updates | PASS: 23 Checks successful, 0 queued on `50dd9c96cab5c230f775685e8baea939fba390dd` | changes requested | Obtain fresh independent review; this is the policy companion to the lockfile gap. | +| [#100](https://github.com/ContextualWisdomLab/keyverse/pull/100) | `lineageweave-web` account-derived claims plus default validator-path coverage | PENDING: 14 queued, 2 successful, 0 terminal failures on the pre-refresh snapshot head `58c6bb5b8ba5dd84dd7e3c73f4f629520ad0110c` | changes requested; fresh review requested | The documentation successor records current #103 token-rotation evidence; re-audit the resulting documentation head without self-approval. | +| [#83](https://github.com/ContextualWisdomLab/keyverse/pull/83) | Remove runtime application RPs from portable realm | PASS: 23 Checks successful, 0 queued on `dd1ab7444a75342b42e3af013ccda6d1dbfb359d` | changes requested | Reconfirm current-head approval and latest-pusher rule before protected merge. | + +### Historical check failure root causes observed + +The evidence set contains two relevant terminal failures from earlier exact +heads. The following items explain those failures and earlier failures that still determine safe +sequencing; they must not be copied to another PR as if they were transferable +evidence. + +- PR #113's exact-head `strix` failure was an external image-pull failure, not + a source finding: `ghcr.io/usestrix/strix-sandbox:1.3.0` returned HTTP 500 + with an EOF while downloading from GHCR, and no structured vulnerability + report was produced. The workflow correctly failed closed. A fresh run is + required after the external image service recovers; no code change can make + that exact failed run green. + +- PR #111's prior exact-head `account-unification-tests` failure stopped before + tests at `uv sync --locked`: the branch tree did not contain the #112 lock + refresh even though its PR base had been retargeted. The normal, non-force + `gh pr update-branch 111` operation merged #112's base into the branch and + produced current head `032f730b`; its fresh `account-unification-tests` run + is queued, so the old failure is historical and the new result is unverified. + +- The representative account-test failure on Dependabot PRs stopped at + `uv sync --locked`: the checked-in lockfile needed updating. This is a + dependency-graph consistency failure, not evidence that the product tests + failed after installation. On PR #105 exact head + `72de5499d6e97ae7f7bd804ab78b3e1644dd5a4f`, `uv 0.12.5` reproduced the + mismatch between required `coverage==7.15.4` / `setuptools==84.0.0` and + locked `7.15.2` / `83.0.0`; PR #112 is the existing lock-refresh + prerequisite, so #105 must be rechecked after #112's protected merge. +- PRs #110 and #111 update the two coupled CodeQL actions separately. The + #111 run loaded configuration for 4.37.7 while running 4.37.6. They must be + evaluated as one compatible pair, with fresh exact-head Checks after the + lockfile queue is clear. +- PR #103 Strix run 32092025335 / job 95576032571 failed closed after emitting + a MEDIUM IDOR report that requests binding grant-management + actor_identity_id values to the authenticated principal. The same model + report also says the issue was already resolved, so the evidence is + contradictory. The routes are currently operator-admin gated and the + operator token does not expose distinct end-user principals; independent + security validation must resolve that trust-boundary interpretation. Until + then the failure remains blocking and is not converted into a pass. +- PR #103's current exact head `9274d3184443fba6c6294e08dff20734f3ae6fb4` + adds RED-to-GREEN regressions for percent-encoded discovery markers, + inactive and expired token rotation, invalid token rotation settings, KV/audit + lifecycle failures, direct router embedding, tenant isolation, software-unit + ABAC, runtime authentication, and untrusted public issuers. Local + focused/full tests, Ruff, Interrogate, and 100% application statement/branch + coverage pass; its required hosted Checks are still pending or queued, so no + hosted security or independent approval success is claimed. + +## Open Issue inventory + +| Issue | Product signal | Classification | Required outcome | +|---:|---|---|---| +| [#102](https://github.com/ContextualWisdomLab/keyverse/issues/102) | Hierarchical authorization plane, login helper, PATs | `active-PR` | Security-review the proposed authority model and prove fail-closed token/tenant/resource behavior. | +| [#99](https://github.com/ContextualWisdomLab/keyverse/issues/99) | Orphaned federation and product-loop workflow identities | `gap-not-claimed` | Live evidence at protected `main` `ce207dfd` found 43 active registry identities: 4 present repository paths, 37 missing repository paths, and 2 GitHub-owned dynamic paths. Disable only through the owning protected workflow/organization path and revalidate by workflow ID immediately before mutation. | +| [#71](https://github.com/ContextualWisdomLab/keyverse/issues/71) | Remove runtime application RPs from portable import | `active-PR` | Merge #83 only after current-head protected evidence. | +| [#2](https://github.com/ContextualWisdomLab/keyverse/issues/2) | Central IdP plus external-IdP federation | `accepted-contract` | Use the existing closed preflight/apply boundary and add approved-environment acceptance. | + +## Gap register and buyer-visible order + +### G0 — Protected queue convergence + +**State:** `active-PR` +**Buyer impact:** A buyer cannot rely on a controlled identity product if the +repository cannot distinguish a reviewed, current, passing artifact from a +stale or coupled dependency proposal. + +**Required loop:** inventory PRs; inspect review threads; fix the root cause; +verify the exact head; require independent approval; let the protected steward +arm normal auto-merge; verify merge SHA; then re-list. Never self-approve, +force-push, admin-merge, or treat queued/retrievable-missing Checks as green. + +### G1 — Coupled dependency and workflow updates + +**State:** `active-PR` +**Root cause:** the current queue contains a lockfile consistency fix and two +CodeQL action updates that are safe only as a coupled set. + +**Acceptance:** #112 passes the full current Checks and receives independent +approval; #101/#110/#111 are then re-evaluated on current bases; `uv sync +--locked` succeeds; both CodeQL actions resolve the same compatible version; +Strix and security checks are completed; no stale predecessor evidence is +counted. + +### G2 — Stable downstream tenant semantics + +**State:** `active-PR` contract clarification; runtime remains `gap-not-claimed` +**Root cause:** Keyverse emits a deliberately closed `role`, `org`, and +`workspace` profile, but a consumer must not guess that either account +dimension is an application-specific `tenant_id`. + +**Contract clarification:** for `lineageweave-web`, `org` is the one opaque +external tenant key and `workspace` is one child namespace under that `org`. +Multiple memberships have no comma-separated or array encoding; missing, +unmapped, or ambiguous membership resolution denies before ABAC/RBAC. A +membership change requires a new token or session renewal. The full consumer +acceptance still requires the negative vector in which a valid token for tenant +B cannot authorize tenant A, plus resource authorization evidence. +Until a consumer proves that runtime contract, it remains deployment-restricted. +Adding a generic tenant mapper to Keyverse is not an acceptable shortcut; it +would expand the closed mapper policy without a separately reviewed profile. + +### G3 — Controlled real login and authorization acceptance + +**State:** `gap-not-claimed` +**Buyer impact:** Static realm/template validation is not a buyer-observable +login or authorization guarantee. + +**Acceptance:** in an approved environment, record redacted evidence for +discovery/issuer, JWKS signature and algorithm, authorization-code + PKCE +`S256`, passwordless browser login, token `iss`/`sub`/`aud`/time claims, +controlled logout, tenant/resource ABAC, role/scope RBAC, cross-tenant denial, +and verifier-unavailable fail-closed behavior. Keep secrets and PII out of +repository artifacts. An unavailable issuer must remain `unavailable`, never +be converted into a synthetic success. + +### G4 — SCIM deactivation concurrency boundary + +**State:** `active-PR` with an explicit protected-main limitation +**Root cause:** protected `main` did not cover `PATCH active=false` with the +shared cross-process lock used by merge and full-replacement `PUT`. + +**Acceptance:** PR #113 adds the shared lock, root-level SCIM `503` +lock-timeout mapping, real concurrent deactivation/merge and cross-process +sidecar-lock regressions, and reconciled +PRD/TRD/UML/Threat/Test/Operability/doctoring records. The protected-main gap +closes only after exact-head hosted Checks, independent review, protected merge, +and a refreshed baseline prove the change on main. + +### G5 — Physical database and hot-partition evidence + +**State:** `gap-not-claimed` +**Root cause:** the ERD defines logical tenant-qualified uniqueness and +two-word-or-longer snake_case names, but a production claim needs migration, +index, partition-key, skew, and recovery evidence from the owning database. + +**Current repository evidence:** the focused local SQLite storage/lifecycle +regression run (`tests/test_storage_concurrency.py` and +`tests/test_lifecycle.py`) passed 6 tests on this tree. It proves only the +sidecar's local locking and lifecycle behavior; it is not PostgreSQL migration, +partition-skew, backup, restore, or production recovery evidence. + +**Current local PostgreSQL probe (2026-08-21):** the running Compose +`idp_database` uses the pinned PostgreSQL 17 image and reports 88 non-system +tables, 3,981,312 relation bytes, zero partitioned tables, and +`pg_is_in_recovery=false`. Observed settings were `max_connections=100`, +`shared_buffers=163848kB`, `work_mem=4096kB`, `wal_level=replica`, and +`archive_mode=off`. This is a local Keycloak system-of-record smoke probe; it +does not prove tenant skew tolerance, application-owned partitioning, backup/ +restore, failover, or production sizing. + +**Acceptance:** run PostgreSQL migration/rollback tests with tenant-scoped +composite constraints, measure skew under concentrated tenants, document the +chosen partition/index strategy, and prove backup/restore. Do not add a +partitioning abstraction before measured pressure requires it. + +### G6 — Release and module acceptance + +**State:** `gap-not-claimed` +**Acceptance:** on exact protected `main`, complete regression and controlled +deployment acceptance; publish immutable image digest, SBOM, provenance, +rollback/restore evidence, version consistency, and CHANGELOG entry. A green +feature PR is not a release. + +### G7 — Ecosystem consumer readiness + +**State:** `accepted-contract` / `gap-not-claimed` +**Root cause:** Keyverse exposes stable HTTP/protocol boundaries, but a module +boundary is only buyer-ready when each owned consumer proves its own +authorization and operational acceptance. + +**Acceptance order:** Keyverse issuer and RP contract first; then the highest +leverage owned consumer with a falsifiable browser/API acceptance lane; then +federation/SCIM connectors. Preserve import/REST boundaries and do not copy +private Keycloak internals into sibling repositories. + +## Hourly loop contract + +The repository currently schedules: + +- **Hourly PR steward:** UTC minute `17`, inventory and advance only trusted + same-repository PRs with exact-head independent approval and required Checks. +- **Hourly product development:** UTC minute `41`, after the steward's evidence + settles; create at most one bounded draft product-gap PR only when the open + PR queue is empty and protected-main evidence is healthy. + +The current live run inventory showed a successful product-development run at +the protected-main head and a queued PR-steward run. Queued or delayed runs are +not blockers for independent documentation, review analysis, standards work, +or test design, but they are not evidence of a merge or release. + +## Standards interpretation and design tooling boundary + +The baseline follows OpenID Connect's exact issuer/audience/time/signature +validation boundary, JWT Best Current Practices' issuer/subject/audience +validation, and OAuth Security BCP's authorization-code + PKCE and exact +redirect guidance. Keycloak protocol mappers are treated as claim projection +configuration, not as downstream authorization proof. + +This change adds no UI or frontend behavior, so no Figma file or Storybook +inventory is required for this baseline. If a future buyer gap changes a web +surface, the owning ADR must record the Figma File ID, design tokens, reusable +components, Storybook inventory, and interaction/accessibility acceptance +before implementation is claimed. + +## References + +- OpenID Foundation. (2014). *OpenID Connect Core 1.0*. https://openid.net/specs/openid-connect-core-1_0-18.html +- Keycloak. (2026). *Server administration guide*. https://www.keycloak.org/docs/latest/server_admin/ +- Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current practice for OAuth 2.0 security* (RFC 9700). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc9700.html +- Sheffer, Y., Hardt, D., & Jones, M. (2020). *JSON Web Token best current practices* (RFC 8725). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc8725.html + +Interpretations and repository evidence are maintained separately in +[`docs/doctoring/product-technical-gap-baseline.md`](doctoring/product-technical-gap-baseline.md). diff --git a/docs/rp-onboarding.md b/docs/rp-onboarding.md index 854f3ed..310b9d8 100644 --- a/docs/rp-onboarding.md +++ b/docs/rp-onboarding.md @@ -17,6 +17,14 @@ For the reviewed Naruon browser-client path, use client (`publicClient=true`, `clientAuthenticatorType=none`) and contains the closed audience/session-claim mapper profile. +For the ADR-0009 LineageWeave path, use +`deploy/templates/oidc-rp-lineageweave.json`. It is a confidential +`lineageweave-web` client whose issued `role`, `org` (company), and `workspace` +(PU) claims are derived from the authenticated Keyverse account. Its HTTPS +endpoint placeholders are the only values rendered into the client metadata; +the same-client role assignment and the `org` and `workspace` account +attributes are provisioned in Keyverse before controlled login. + Resolve every placeholder from deployment configuration or KV before preflight. For Naruon this includes exact HTTPS redirect, web-origin, and post-logout URIs plus the bounded `role`, `org`, and `workspace` routing values. Those claim @@ -133,10 +141,14 @@ When `protocolMappers` is present, the mapper profile is additionally closed: - exactly one `oidc-audience-mapper` is required and its `included.client.audience` must equal the validated `clientId`; - optional hardcoded claims are limited to `role`, `org`, and `workspace`; +- ADR-0009 additionally permits only the complete `lineageweave-web` + account-derived trio: same-client role mapping plus scalar `org` and + `workspace` user-attribute mappings; - mapper names, protocols, token destinations, nested fields, and list order are canonical and exact; -- script, user-attribute, group, regex, arbitrary-claim, unknown mapper, and - credential-bearing configuration is rejected; +- script, group, regex, arbitrary-claim, unknown mapper, and credential-bearing + configuration is rejected; user attributes are rejected except for the two + ADR-0009 mappings; - generated Keycloak mapper IDs and vendor return ordering are normalized only for observation; unknown, malformed, duplicate, or semantically changed live mapper state is reported as drift rather than silently accepted. @@ -198,6 +210,12 @@ Do not record bearer tokens, authorization codes, code verifiers, client-secret bytes, or private routing values beyond the minimum non-secret acceptance evidence required by the deployment record. +For LineageWeave, additionally prove with a real Keyverse account that the +verified `sub`, scalar `org`, scalar `workspace`, and list-valued same-client +`role` claims reach the application; invalid tenant, mismatched workspace, and +role-downgrade requests must deny before an RBAC allow. The Keyverse client +receipt is not a substitute for these downstream tests. + ## Checklist - [ ] placeholders resolved in a mode-0600 file 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 7d458cd..5f9c5e3 100644 --- a/docs/superpowers/plans/2026-08-03-keyverse-product-hardening.md +++ b/docs/superpowers/plans/2026-08-03-keyverse-product-hardening.md @@ -141,7 +141,7 @@ Interrogate, Keycloak Admin REST API, Docker Compose, Helm. ### Task 7: Enforce Keycloak realm policy **Files:** -- Modify: `deploy/keycloak/realm-cwl.json` +- Modify: `deploy/keycloak/cwl-realm.json` - Modify: `scripts/validate_realm.py` - Test: `services/account_unification/tests/test_realm_policy.py` 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 a4a2093..654c71a 100644 --- a/docs/superpowers/plans/2026-08-03-keyverse-review-remediation.md +++ b/docs/superpowers/plans/2026-08-03-keyverse-review-remediation.md @@ -31,7 +31,7 @@ REST API, pytest, Ruff, Interrogate, Helm, Docker Compose. ### Task 1: Passwordless registration enrollment **Files:** -- Modify: `deploy/keycloak/realm-cwl.json` +- Modify: `deploy/keycloak/cwl-realm.json` - Modify: `scripts/validate_realm.py` - Modify: `services/account_unification/app/registration.py` - Modify: `services/account_unification/app/product_keycloak_client.py` diff --git a/docs/superpowers/plans/2026-08-05-keyverse-ldap-directory-preflight.md b/docs/superpowers/plans/2026-08-05-keyverse-ldap-directory-preflight.md index de2ee77..9d7cc2f 100644 --- a/docs/superpowers/plans/2026-08-05-keyverse-ldap-directory-preflight.md +++ b/docs/superpowers/plans/2026-08-05-keyverse-ldap-directory-preflight.md @@ -84,7 +84,7 @@ Naruon deployment controllers. - [ ] `uv run coverage report --show-missing --fail-under=100` - [ ] `python -m compileall -q app tests tools` - [ ] package build and installed-wheel smoke test -- [ ] `python scripts/validate_realm.py deploy/keycloak/realm-cwl.json` +- [ ] `python scripts/validate_realm.py deploy/keycloak/cwl-realm.json` - [ ] `docker compose -f docker-compose.yml config` - [ ] validate every deployment JSON template - [ ] `git diff --check` diff --git a/docs/superpowers/plans/2026-08-07-keyverse-oidc-rp-claim-profile.md b/docs/superpowers/plans/2026-08-07-keyverse-oidc-rp-claim-profile.md index 9cdb3e1..6f87218 100644 --- a/docs/superpowers/plans/2026-08-07-keyverse-oidc-rp-claim-profile.md +++ b/docs/superpowers/plans/2026-08-07-keyverse-oidc-rp-claim-profile.md @@ -355,7 +355,7 @@ uv run coverage run --branch --source=app -m pytest -q uv run coverage report --show-missing --fail-under=100 uv build --out-dir dist cd ../.. -python scripts/validate_realm.py deploy/keycloak/realm-cwl.json +python scripts/validate_realm.py deploy/keycloak/cwl-realm.json docker compose -f docker-compose.yml config python - <<'PY' import json diff --git a/docs/superpowers/specs/2026-08-13-keyverse-lineageweave-account-claim-profile.md b/docs/superpowers/specs/2026-08-13-keyverse-lineageweave-account-claim-profile.md new file mode 100644 index 0000000..387259a --- /dev/null +++ b/docs/superpowers/specs/2026-08-13-keyverse-lineageweave-account-claim-profile.md @@ -0,0 +1,45 @@ +# Keyverse LineageWeave Account-Derived Claim Profile + +**Status:** Approved bounded implementation under ADR-0009. + +## Purpose + +Provide LineageWeave with claims derived from the real Keyverse account that +authenticated, while keeping Keyverse's relying-party desired-state surface +closed and secret-free. + +## Contract + +- Client ID is fixed to `lineageweave-web`; it is confidential and uses + authorization code plus PKCE `S256`. +- The first mapper is the existing self-pinned audience mapper. +- `role` comes only from client roles assigned to that same client. It is a + multivalued string claim with no prefix. +- `org` and `workspace` come only from user attributes bearing those exact + names. They are scalar string claims. +- All three account-derived claims must appear together and cannot mix with + hardcoded claims. +- The profile rejects scripts, groups, regex, arbitrary attributes, arbitrary + roles, extra audiences, extra destinations, aggregation, secrets, and client + secret generation/retrieval. + +## Runtime prerequisites + +1. An identity operator creates or verifies the actual Keyverse account. +2. The operator assigns the account's `org` and `workspace` values and one or + more recognized `lineageweave-web` client roles. +3. The private rendered template passes Keyverse preflight, is reconciled by + Keyverse, and receives an exact observable receipt. +4. The confidential client credential is placed through the approved + secret-management channel. +5. LineageWeave proves issuer/signature/expiry/audience validation, tenant and + resource denial, role downgrade, logout, and rollback using that real + account. + +## Non-goals + +- Generic Keycloak mapper administration. +- A claim-based bypass of LineageWeave resource ABAC. +- Static company or PU routing values. +- An assertion that a local or compose-only identity provider is Keyverse + production evidence. diff --git a/helm/cwl-idp/templates/keycloak.yaml b/helm/cwl-idp/templates/keycloak.yaml index 9b4ee2f..d769ddd 100644 --- a/helm/cwl-idp/templates/keycloak.yaml +++ b/helm/cwl-idp/templates/keycloak.yaml @@ -117,6 +117,10 @@ spec: - name: realm-import configMap: name: {{ .Values.keycloak.realmImport.configMapName }} + items: + # Keycloak directory import accepts -realm.json only. + - key: {{ .Values.keycloak.realmImport.fileName }} + path: cwl-realm.json --- apiVersion: v1 kind: Service diff --git a/helm/cwl-idp/values.yaml b/helm/cwl-idp/values.yaml index c5bf393..2a48c43 100644 --- a/helm/cwl-idp/values.yaml +++ b/helm/cwl-idp/values.yaml @@ -50,7 +50,8 @@ keycloak: existingSecret: cwl-idp-keycloak realmImport: configMapName: cwl-idp-realm - fileName: realm-cwl.json + # Source ConfigMap key; the chart mounts it as cwl-realm.json for Keycloak. + fileName: cwl-realm.json resources: requests: cpu: 500m diff --git a/scripts/validate_realm.py b/scripts/validate_realm.py index db6e6c5..e4d36ba 100644 --- a/scripts/validate_realm.py +++ b/scripts/validate_realm.py @@ -12,6 +12,7 @@ * 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. +* the post-import product authorization profile is explicit and admin-only. Usage: python scripts/validate_realm.py [path-to-realm.json] Exit 0 = valid, 1 = invalid (prints the failing checks). @@ -31,6 +32,8 @@ PASSKEY_AUTHENTICATOR = f"webauthn-authenticator-{_CREDENTIAL_FACTOR}less" SECRET_PLACEHOLDER = "__set_from_kv__" MAX_PUBLIC_TOKEN_LIFESPAN = 900 +USER_PROFILE_FILENAME = "lineageweave-user-profile.json" +BUILTIN_USER_PROFILE_ATTRIBUTES = {"username", "email", "firstName", "lastName"} def _executions(realm: dict, alias: str) -> list[dict]: @@ -220,6 +223,46 @@ def validate(realm: dict) -> list[str]: return errors +def validate_user_profile(profile: dict) -> list[str]: + """Return violations for closed, administrator-managed product attributes.""" + errors: list[str] = [] + # Keycloak 26.3.2 has no DISABLED enum member: a missing/null policy is its + # fail-closed representation. Sending the documented string makes the API + # reject the complete profile payload. + if profile.get("unmanagedAttributePolicy") is not None: + errors.append( + "user profile must omit unmanagedAttributePolicy so Keycloak 26 " + "disables unmanaged attributes" + ) + attributes = { + item.get("name"): item + for item in profile.get("attributes", []) + if isinstance(item, dict) + } + if not BUILTIN_USER_PROFILE_ATTRIBUTES <= attributes.keys(): + errors.append( + "user profile must retain Keycloak built-in account attributes when " + "the Admin API replaces the complete profile" + ) + for name in ("org", "workspace"): + attribute = attributes.get(name) + if not isinstance(attribute, dict): + errors.append(f"user profile must define '{name}'") + continue + if attribute.get("multivalued") is not False: + errors.append(f"user profile '{name}' must be scalar") + if attribute.get("permissions") != {"view": ["admin"], "edit": ["admin"]}: + errors.append(f"user profile '{name}' must be admin-managed") + if attribute.get("required") != {"roles": ["admin"]}: + errors.append(f"user profile '{name}' must require administrators") + validations = attribute.get("validations") + length = validations.get("length") if isinstance(validations, dict) else None + maximum = length.get("max") if isinstance(length, dict) else None + if not (maximum == "64" or (type(maximum) is int and maximum == 64)): + errors.append(f"user profile '{name}' must have a maximum length of 64") + return errors + + def _dollar_keys(node: object, prefix: str = "") -> list[str]: """Collect every ``$``-prefixed object key with its JSON path.""" found: list[str] = [] @@ -236,11 +279,17 @@ def _dollar_keys(node: object, prefix: str = "") -> list[str]: def main(argv: list[str]) -> int: - """Run realm validation as a command-line check.""" + """Run realm validation with an optional explicit user-profile artifact.""" + if len(argv) > 3: + print( + "USAGE: validate_realm.py [realm_path] [user_profile_path]", + file=sys.stderr, + ) + return 1 path = ( Path(argv[1]) if len(argv) > 1 - else Path("deploy/keycloak/realm-cwl.json") + else Path("deploy/keycloak/cwl-realm.json") ) try: realm = json.loads(path.read_text(encoding="utf-8")) @@ -249,6 +298,15 @@ def main(argv: list[str]) -> int: return 1 errors = validate(realm) + profile_path = ( + Path(argv[2]) if len(argv) > 2 else path.with_name(USER_PROFILE_FILENAME) + ) + try: + profile = json.loads(profile_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print(f"INVALID: cannot parse {profile_path}: {exc}", file=sys.stderr) + return 1 + errors.extend(validate_user_profile(profile)) if errors: print(f"INVALID: {path}", file=sys.stderr) for error in errors: diff --git a/services/account_unification/app/relying_party.py b/services/account_unification/app/relying_party.py index 1f83ef3..b9300bc 100644 --- a/services/account_unification/app/relying_party.py +++ b/services/account_unification/app/relying_party.py @@ -81,12 +81,44 @@ "introspection.token.claim", } ) +_ACCOUNT_ROLE_CONFIG_FIELDS = frozenset( + { + "usermodel.clientRoleMapping.clientId", + "usermodel.clientRoleMapping.rolePrefix", + "multivalued", + "claim.name", + "jsonType.label", + "access.token.claim", + "id.token.claim", + "userinfo.token.claim", + "introspection.token.claim", + } +) +_ACCOUNT_ROLE_EMPTY_CONFIG_FIELDS = frozenset( + {"usermodel.clientRoleMapping.rolePrefix"} +) +_ACCOUNT_ATTRIBUTE_CONFIG_FIELDS = frozenset( + { + "user.attribute", + "claim.name", + "jsonType.label", + "multivalued", + "access.token.claim", + "id.token.claim", + "userinfo.token.claim", + "introspection.token.claim", + } +) _REQUIRED_SCOPES = frozenset({"basic", "profile", "email"}) _CLAIM_ORDER = ("role", "org", "workspace") _CLAIM_RANK = {claim_name: index + 1 for index, claim_name in enumerate(_CLAIM_ORDER)} +_ACCOUNT_CLAIMS = frozenset(_CLAIM_ORDER) +_ACCOUNT_ATTRIBUTE_CLAIMS = frozenset({"org", "workspace"}) _AUDIENCE_MAPPER_NAME = "keyverse-audience" _AUDIENCE_MAPPER_TYPE = "oidc-audience-mapper" _CLAIM_MAPPER_TYPE = "oidc-hardcoded-claim-mapper" +_ACCOUNT_ROLE_MAPPER_TYPE = "oidc-usermodel-client-role-mapper" +_ACCOUNT_ATTRIBUTE_MAPPER_TYPE = "oidc-usermodel-attribute-mapper" class RelyingPartyProtocolMapper(BaseModel): @@ -384,13 +416,16 @@ def _validate_scopes(scopes: list[str]) -> None: def _require_exact_config( mapper: RelyingPartyProtocolMapper, expected_fields: frozenset[str], + *, + allow_empty_fields: frozenset[str] = frozenset(), ) -> None: """Require one mapper configuration to have an exact closed key set.""" fields = set(mapper.config) if fields != expected_fields: _client_error("protocolMappers.config", "must use the exact closed field set") for key, value in mapper.config.items(): - _require_clean_text(value, f"protocolMappers.config.{key}", maximum=128) + if value or key not in allow_empty_fields: + _require_clean_text(value, f"protocolMappers.config.{key}", maximum=128) def _validate_audience_mapper( @@ -465,6 +500,78 @@ def _validate_hardcoded_claim_mapper( return _CLAIM_RANK[claim_name], claim_name +def _validate_account_role_mapper( + mapper: RelyingPartyProtocolMapper, + client_id: str, +) -> tuple[int, str]: + """Validate the single account-derived client-role claim mapper.""" + if mapper.name != "keyverse-account-role": + _client_error("protocolMappers.name", "must be keyverse-account-role") + _require_exact_config( + mapper, + _ACCOUNT_ROLE_CONFIG_FIELDS, + allow_empty_fields=_ACCOUNT_ROLE_EMPTY_CONFIG_FIELDS, + ) + if mapper.config["usermodel.clientRoleMapping.clientId"] != client_id: + _client_error( + "protocolMappers.config.usermodel.clientRoleMapping.clientId", + "must exactly match clientId", + ) + expected_values = { + "usermodel.clientRoleMapping.rolePrefix": "", + "multivalued": "true", + "claim.name": "role", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "false", + "introspection.token.claim": "true", + } + if any(mapper.config[key] != value for key, value in expected_values.items()): + _client_error( + "protocolMappers.config", + "must use the closed account-role claim destinations", + ) + return _CLAIM_RANK["role"], "role" + + +def _validate_account_attribute_mapper( + mapper: RelyingPartyProtocolMapper, +) -> tuple[int, str]: + """Validate one scalar account-derived organization or workspace mapper.""" + _require_exact_config(mapper, _ACCOUNT_ATTRIBUTE_CONFIG_FIELDS) + claim_name = mapper.config["claim.name"] + if claim_name not in _ACCOUNT_ATTRIBUTE_CLAIMS: + _client_error( + "protocolMappers.config.claim.name", + "must be org or workspace", + ) + if mapper.name != f"keyverse-account-{claim_name}": + _client_error( + "protocolMappers.name", + "must be canonical for the claim name", + ) + if mapper.config["user.attribute"] != claim_name: + _client_error( + "protocolMappers.config.user.attribute", + "must exactly match claim.name", + ) + expected_values = { + "jsonType.label": "String", + "multivalued": "false", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "false", + "introspection.token.claim": "true", + } + if any(mapper.config[key] != value for key, value in expected_values.items()): + _client_error( + "protocolMappers.config", + "must use the closed account-attribute claim destinations", + ) + return _CLAIM_RANK[claim_name], claim_name + + def _validate_protocol_mappers(registration: RelyingPartyRegistration) -> None: """Validate the optional closed audience and session-claim mapper profile.""" mappers = registration.protocol_mappers @@ -475,7 +582,8 @@ def _validate_protocol_mappers(registration: RelyingPartyRegistration) -> None: ranks: list[int] = [] audience_count = 0 - claim_names: set[str] = set() + hardcoded_claim_names: set[str] = set() + account_claim_names: set[str] = set() for mapper in mappers: _require_clean_text( mapper.name, @@ -491,15 +599,45 @@ def _validate_protocol_mappers(registration: RelyingPartyRegistration) -> None: ranks.append(_validate_audience_mapper(mapper, registration.client_id)) elif mapper.protocol_mapper == _CLAIM_MAPPER_TYPE: rank, claim_name = _validate_hardcoded_claim_mapper(mapper) - if claim_name in claim_names: + if claim_name in hardcoded_claim_names: _client_error("protocolMappers", "must not duplicate claim names") - claim_names.add(claim_name) + hardcoded_claim_names.add(claim_name) + ranks.append(rank) + elif mapper.protocol_mapper == _ACCOUNT_ROLE_MAPPER_TYPE: + rank, claim_name = _validate_account_role_mapper( + mapper, + registration.client_id, + ) + if claim_name in account_claim_names: + _client_error("protocolMappers", "must not duplicate claim names") + account_claim_names.add(claim_name) + ranks.append(rank) + elif mapper.protocol_mapper == _ACCOUNT_ATTRIBUTE_MAPPER_TYPE: + rank, claim_name = _validate_account_attribute_mapper(mapper) + if claim_name in account_claim_names: + _client_error("protocolMappers", "must not duplicate claim names") + account_claim_names.add(claim_name) ranks.append(rank) else: _client_error("protocolMappers.protocolMapper", "is not supported") if audience_count != 1: _client_error("protocolMappers", "must contain exactly one audience mapper") + if hardcoded_claim_names and account_claim_names: + _client_error( + "protocolMappers", + "must not mix hardcoded and account-derived claims", + ) + if account_claim_names and registration.client_id != "lineageweave-web": + _client_error( + "protocolMappers", + "account-derived claims are only supported for lineageweave-web", + ) + if account_claim_names and account_claim_names != _ACCOUNT_CLAIMS: + _client_error( + "protocolMappers", + "must contain role, org, and workspace account claims", + ) if ranks != sorted(ranks) or len(set(ranks)) != len(ranks): _client_error("protocolMappers", "must use canonical mapper order") diff --git a/services/account_unification/app/relying_party_state.py b/services/account_unification/app/relying_party_state.py index 4d52ebd..5597395 100644 --- a/services/account_unification/app/relying_party_state.py +++ b/services/account_unification/app/relying_party_state.py @@ -464,9 +464,15 @@ def _client_uuid(client: dict) -> str: def _observed_mapper_rank(mapper: dict) -> int | None: """Return the canonical rank for one structurally valid live mapper.""" mapper_type = mapper.get("protocolMapper") + if not isinstance(mapper_type, str): + return None if mapper_type == "oidc-audience-mapper": return 0 - if mapper_type != "oidc-hardcoded-claim-mapper": + if mapper_type not in { + "oidc-hardcoded-claim-mapper", + "oidc-usermodel-client-role-mapper", + "oidc-usermodel-attribute-mapper", + }: return None config = mapper["config"] claim_name = config.get("claim.name") diff --git a/services/account_unification/tests/test_deployment_contracts.py b/services/account_unification/tests/test_deployment_contracts.py index b346d29..2aa82a4 100644 --- a/services/account_unification/tests/test_deployment_contracts.py +++ b/services/account_unification/tests/test_deployment_contracts.py @@ -45,6 +45,40 @@ def test_compose_persists_account_unification_state() -> None: assert "account_unification_data" in compose["volumes"] +def test_keycloak_import_packages_realm_and_profile_contracts() -> None: + """Keep Compose and Helm compatible with Keycloak's separate profile API.""" + root = _repository_root() + compose = yaml.safe_load((root / "docker-compose.yml").read_text(encoding="utf-8")) + engine = compose["services"]["idp_engine"] + assert engine["build"] == {"context": "./deploy/keycloak", "dockerfile": "Dockerfile"} + assert engine["image"] == "cwl-idp/keycloak:local" + dockerfile = (root / "deploy" / "keycloak" / "Dockerfile").read_text(encoding="utf-8") + assert "FROM quay.io/keycloak/keycloak:26.3.2@sha256:" in dockerfile + assert "COPY cwl-realm.json /opt/keycloak/data/import/cwl-realm.json" in dockerfile + assert "COPY lineageweave-user-profile.json" in dockerfile + assert "COPY --chmod=755 reconcile-lineageweave-user-profile.sh" in dockerfile + assert "\nUSER 1000\n" in dockerfile + bootstrap_script = ( + root / "deploy" / "keycloak" / "reconcile-lineageweave-user-profile.sh" + ).read_text(encoding="utf-8") + assert "/opt/keycloak/bin/kcadm.sh" in bootstrap_script + profile = compose["services"]["idp_profile_bootstrap"] + assert profile["depends_on"]["idp_engine"]["condition"] == "service_healthy" + assert profile["entrypoint"] == ["/opt/keycloak/reconcile-lineageweave-user-profile.sh"] + service = compose["services"]["account_unification_service"] + assert ( + service["depends_on"]["idp_profile_bootstrap"]["condition"] + == "service_completed_successfully" + ) + + keycloak = ( + root / "helm" / "cwl-idp" / "templates" / "keycloak.yaml" + ).read_text(encoding="utf-8") + assert _helm_values()["keycloak"]["realmImport"]["fileName"] == "cwl-realm.json" + assert "key: {{ .Values.keycloak.realmImport.fileName }}" in keycloak + assert "path: cwl-realm.json" in keycloak + + def test_helm_can_fail_closed_on_missing_account_image_digest() -> None: """Production values can require an immutable account-service image.""" image = _helm_values()["accountUnification"]["image"] diff --git a/services/account_unification/tests/test_hourly_product_guard.py b/services/account_unification/tests/test_hourly_product_guard.py index 8467fbb..00910b5 100644 --- a/services/account_unification/tests/test_hourly_product_guard.py +++ b/services/account_unification/tests/test_hourly_product_guard.py @@ -46,7 +46,7 @@ def test_guard_allows_product_files_and_rejects_control_plane_files() -> None: "services/account_unification/uv.lock", "docker-compose.yml", "helm/cwl-idp/values.yaml", - "deploy/keycloak/realm-cwl.json", + "deploy/keycloak/cwl-realm.json", "../outside.txt", ): assert not guard._path_allowed(forbidden_path) diff --git a/services/account_unification/tests/test_realm_policy.py b/services/account_unification/tests/test_realm_policy.py index 83d03b2..bb44d99 100644 --- a/services/account_unification/tests/test_realm_policy.py +++ b/services/account_unification/tests/test_realm_policy.py @@ -7,6 +7,8 @@ from pathlib import Path from types import ModuleType +import pytest + def _repository_root() -> Path: """Return the repository root from the service test package.""" @@ -28,10 +30,18 @@ def _validator_module() -> ModuleType: def _realm() -> dict: """Load the committed Keycloak realm representation.""" - realm_path = _repository_root() / "deploy" / "keycloak" / "realm-cwl.json" + realm_path = _repository_root() / "deploy" / "keycloak" / "cwl-realm.json" return json.loads(realm_path.read_text(encoding="utf-8")) +def _user_profile() -> dict: + """Load the closed post-import product account-attribute profile.""" + profile_path = ( + _repository_root() / "deploy" / "keycloak" / "lineageweave-user-profile.json" + ) + return json.loads(profile_path.read_text(encoding="utf-8")) + + def _client(realm: dict, client_id: str) -> dict: """Return one client representation by client ID.""" return next( @@ -45,6 +55,7 @@ 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()) == [] + assert validator.validate_user_profile(_user_profile()) == [] def test_bound_browser_flow_rejects_password_authenticator() -> None: @@ -81,6 +92,110 @@ def test_public_client_token_lifespan_is_bounded() -> None: assert any("access.token.lifespan" in error for error in errors) +@pytest.mark.parametrize( + ("field", "value", "expected"), + [ + ("multivalued", True, "must be scalar"), + ("permissions", {"view": ["admin"], "edit": ["admin", "user"]}, "admin-managed"), + ("validations", {"length": {"max": "65"}}, "maximum length of 64"), + ], +) +@pytest.mark.parametrize("attribute_name", ("org", "workspace")) +def test_product_account_attributes_are_constrained( + attribute_name: str, field: str, value: object, expected: str +) -> None: + """Authorization attributes stay scalar, bounded, and administrator-managed.""" + validator = _validator_module() + profile = deepcopy(_user_profile()) + attribute = next( + item for item in profile["attributes"] if item["name"] == attribute_name + ) + attribute[field] = value + + errors = validator.validate_user_profile(profile) + + assert any(expected in error for error in errors) + + +@pytest.mark.parametrize("attribute_name", ("org", "workspace")) +@pytest.mark.parametrize( + "required", + ({}, {"roles": ["user"]}, {"roles": ["admin", "user"]}), +) +def test_product_account_attributes_require_administrator_assignment( + attribute_name: str, required: dict[str, object] +) -> None: + """Product ABAC attributes cannot be omitted in administrator-managed updates.""" + validator = _validator_module() + profile = deepcopy(_user_profile()) + attribute = next( + item for item in profile["attributes"] if item["name"] == attribute_name + ) + attribute["required"] = required + + errors = validator.validate_user_profile(profile) + + assert any("must require administrators" in error for error in errors) + + +def test_product_account_attribute_policy_reports_every_independent_violation() -> None: + """One malformed attribute shows every repair an operator must make.""" + validator = _validator_module() + profile = deepcopy(_user_profile()) + attribute = next(item for item in profile["attributes"] if item["name"] == "org") + attribute["multivalued"] = True + attribute["permissions"] = {"view": ["admin"], "edit": ["admin", "user"]} + attribute["required"] = {"roles": ["user"]} + attribute["validations"] = {"length": {"max": 65}} + + errors = validator.validate_user_profile(profile) + + assert any("must be scalar" in error for error in errors) + assert any("must be admin-managed" in error for error in errors) + assert any("must require administrators" in error for error in errors) + assert any("maximum length of 64" in error for error in errors) + + +def test_product_account_attribute_policy_accepts_keycloak_numeric_length_limit() -> None: + """Keycloak's documented numeric JSON length maximum remains valid.""" + validator = _validator_module() + profile = deepcopy(_user_profile()) + for attribute in profile["attributes"]: + if attribute["name"] in {"org", "workspace"}: + attribute["validations"]["length"]["max"] = 64 + + assert validator.validate_user_profile(profile) == [] + + +def test_product_account_attributes_cannot_be_omitted() -> None: + """Every issued product claim has an explicit Keycloak account source.""" + validator = _validator_module() + profile = deepcopy(_user_profile()) + profile["attributes"] = [] + + errors = validator.validate_user_profile(profile) + + assert any("must define 'org'" in error for error in errors) + assert any("must define 'workspace'" in error for error in errors) + + +def test_keycloak_26_profile_uses_the_closed_policy_representation() -> None: + """The Admin API rejects a string DISABLED; null is Keycloak 26's closed mode.""" + validator = _validator_module() + profile = deepcopy(_user_profile()) + + assert "unmanagedAttributePolicy" not in profile + assert {"username", "email", "firstName", "lastName"} <= { + item["name"] for item in profile["attributes"] + } + + profile["unmanagedAttributePolicy"] = "ENABLED" + + errors = validator.validate_user_profile(profile) + + assert any("must omit unmanagedAttributePolicy" 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") diff --git a/services/account_unification/tests/test_relying_party_claim_mappers.py b/services/account_unification/tests/test_relying_party_claim_mappers.py index 168aa29..8f2b605 100644 --- a/services/account_unification/tests/test_relying_party_claim_mappers.py +++ b/services/account_unification/tests/test_relying_party_claim_mappers.py @@ -13,11 +13,12 @@ _parse_registration, validate_relying_party_registration, ) +from app.relying_party_state import _normalized_observed_mappers from .test_relying_party_preflight import _confidential_web_client -def _audience_mapper() -> dict[str, object]: +def _audience_mapper(client_id: str = "naruon-web") -> dict[str, object]: """Return the canonical access-token audience mapper.""" return { "name": "keyverse-audience", @@ -25,7 +26,7 @@ def _audience_mapper() -> dict[str, object]: "protocolMapper": "oidc-audience-mapper", "consentRequired": False, "config": { - "included.client.audience": "naruon-web", + "included.client.audience": client_id, "access.token.claim": "true", "id.token.claim": "false", "introspection.token.claim": "true", @@ -52,6 +53,72 @@ def _claim_mapper(claim_name: str, claim_value: str) -> dict[str, object]: } +def _account_role_mapper(client_id: str) -> dict[str, object]: + """Return the closed client-role mapper for a verified account role.""" + return { + "name": "keyverse-account-role", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": False, + "config": { + "usermodel.clientRoleMapping.clientId": client_id, + "usermodel.clientRoleMapping.rolePrefix": "", + "multivalued": "true", + "claim.name": "role", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "false", + "introspection.token.claim": "true", + }, + } + + +def _account_attribute_mapper(claim_name: str) -> dict[str, object]: + """Return one scalar account-attribute mapper for a tenant claim.""" + return { + "name": f"keyverse-account-{claim_name}", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": False, + "config": { + "user.attribute": claim_name, + "claim.name": claim_name, + "jsonType.label": "String", + "multivalued": "false", + "access.token.claim": "true", + "id.token.claim": "true", + "userinfo.token.claim": "false", + "introspection.token.claim": "true", + }, + } + + +def _lineageweave_registration_with_account_claims() -> dict[str, object]: + """Return a confidential RP using verified account-derived claim sources.""" + client_id = "lineageweave-web" + payload = deepcopy(_confidential_web_client()) + payload.update( + { + "clientId": client_id, + "name": client_id, + "redirectUris": ["https://lineageweave.example/auth/callback"], + "webOrigins": ["https://lineageweave.example"], + "attributes": { + **payload["attributes"], + "post.logout.redirect.uris": "https://lineageweave.example/logout", + }, + "protocolMappers": [ + _audience_mapper(client_id), + _account_role_mapper(client_id), + _account_attribute_mapper("org"), + _account_attribute_mapper("workspace"), + ], + } + ) + return payload + + def _naruon_registration_with_mappers() -> dict[str, object]: """Return a production-shaped Naruon client with its closed claim profile.""" payload = deepcopy(_confidential_web_client()) @@ -137,6 +204,193 @@ def test_audience_only_mapper_profile_is_accepted() -> None: assert len(result.registration.protocol_mappers) == 1 +def test_account_derived_claim_mapper_profile_is_accepted() -> None: + """Per-account role and tenant attributes may replace static RP claims.""" + payload = _lineageweave_registration_with_account_claims() + + result = validate_relying_party_registration(_parse_registration(payload)) + + assert result.ready_to_apply is True + mappers = payload["protocolMappers"] + assert isinstance(mappers, list) + assert [mapper["protocolMapper"] for mapper in mappers] == [ + "oidc-audience-mapper", + "oidc-usermodel-client-role-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-usermodel-attribute-mapper", + ] + assert all("claim.value" not in mapper["config"] for mapper in mappers[1:]) + + +def test_account_derived_claim_mapper_observation_is_reconciled() -> None: + """Keycloak mapper IDs and order do not create dynamic-claim drift.""" + payload = _lineageweave_registration_with_account_claims() + desired_mappers = payload["protocolMappers"] + assert isinstance(desired_mappers, list) + observed_mappers = deepcopy(desired_mappers) + for index, mapper in enumerate(observed_mappers): + mapper["id"] = f"mapper-{index}" + observed_mappers.reverse() + + normalized = _normalized_observed_mappers( + observed_mappers, + _parse_registration(payload), + ) + + assert normalized == desired_mappers + + +@pytest.mark.parametrize("protocol_mapper", ([], {})) +def test_observed_non_string_mapper_type_is_drift(protocol_mapper: object) -> None: + """Malformed vendor mapper types fail closed instead of raising unexpectedly.""" + payload = _lineageweave_registration_with_account_claims() + registration = _parse_registration(payload) + desired_mappers = payload["protocolMappers"] + assert isinstance(desired_mappers, list) + observed_mappers = deepcopy(desired_mappers) + first_mapper = observed_mappers[0] + assert isinstance(first_mapper, dict) + first_mapper["protocolMapper"] = protocol_mapper + + assert _normalized_observed_mappers(observed_mappers, registration) is None + + +@pytest.mark.parametrize( + ("mutate", "field"), + [ + (lambda mapper: mapper.update(name="account-role"), "protocolMappers.name"), + ( + lambda mapper: mapper["config"].update( + {"usermodel.clientRoleMapping.clientId": "other-web"} + ), + "protocolMappers.config.usermodel.clientRoleMapping.clientId", + ), + ( + lambda mapper: mapper["config"].update( + {"usermodel.clientRoleMapping.rolePrefix": "unexpected"} + ), + "protocolMappers.config", + ), + ( + lambda mapper: mapper["config"].update({"multivalued": "false"}), + "protocolMappers.config", + ), + ], +) +def test_account_role_mapper_policy_rejects_unsafe_values(mutate, field: str) -> None: + """The account role source is pinned to the relying-party client.""" + payload = _lineageweave_registration_with_account_claims() + mappers = payload["protocolMappers"] + assert isinstance(mappers, list) + mapper = mappers[1] + assert isinstance(mapper, dict) + mutate(mapper) + + _assert_policy_error(payload, field) + + +@pytest.mark.parametrize( + ("mutate", "field"), + [ + ( + lambda mapper: mapper["config"].update({"claim.name": "role"}), + "protocolMappers.config.claim.name", + ), + ( + lambda mapper: mapper.update(name="keyverse-account-other"), + "protocolMappers.name", + ), + ( + lambda mapper: mapper["config"].update({"user.attribute": "other"}), + "protocolMappers.config.user.attribute", + ), + ( + lambda mapper: mapper["config"].update({"multivalued": "true"}), + "protocolMappers.config", + ), + ], +) +def test_account_attribute_mapper_policy_rejects_unsafe_values( + mutate, + field: str, +) -> None: + """Only scalar organization and workspace account attributes may flow.""" + payload = _lineageweave_registration_with_account_claims() + mappers = payload["protocolMappers"] + assert isinstance(mappers, list) + mapper = mappers[2] + assert isinstance(mapper, dict) + mutate(mapper) + + _assert_policy_error(payload, field) + + +def test_account_claim_profile_requires_every_dynamic_claim() -> None: + """A partial dynamic profile cannot silently fall back to static routing.""" + payload = _lineageweave_registration_with_account_claims() + mappers = payload["protocolMappers"] + assert isinstance(mappers, list) + payload["protocolMappers"] = mappers[:-1] + + _assert_policy_error(payload, "protocolMappers") + + +def test_account_claim_profile_rejects_duplicate_dynamic_claim() -> None: + """One account attribute may map to only one canonical token claim.""" + payload = _lineageweave_registration_with_account_claims() + mappers = payload["protocolMappers"] + assert isinstance(mappers, list) + payload["protocolMappers"] = [mappers[0], mappers[1], mappers[2], deepcopy(mappers[2])] + + _assert_policy_error(payload, "protocolMappers") + + +def test_account_claim_profile_rejects_duplicate_account_role() -> None: + """A role source may not be duplicated under the same token claim.""" + payload = _lineageweave_registration_with_account_claims() + mappers = payload["protocolMappers"] + assert isinstance(mappers, list) + payload["protocolMappers"] = [mappers[0], mappers[1], deepcopy(mappers[1]), mappers[3]] + + _assert_policy_error(payload, "protocolMappers") + + +def test_account_claim_profile_rejects_static_claim_mixing() -> None: + """A client cannot combine per-account and hardcoded authorization claims.""" + payload = _lineageweave_registration_with_account_claims() + mappers = payload["protocolMappers"] + assert isinstance(mappers, list) + payload["protocolMappers"] = [ + mappers[0], + mappers[1], + _claim_mapper("org", "org-cwl"), + mappers[3], + ] + + _assert_policy_error(payload, "protocolMappers") + + +def test_account_claim_profile_rejects_other_client() -> None: + """Only the reviewed LineageWeave client may use account-derived claims.""" + payload = _lineageweave_registration_with_account_claims() + payload["clientId"] = "other-web" + payload["name"] = "other-web" + mappers = payload["protocolMappers"] + assert isinstance(mappers, list) + audience = mappers[0] + role = mappers[1] + assert isinstance(audience, dict) + assert isinstance(role, dict) + audience_config = audience["config"] + role_config = role["config"] + assert isinstance(audience_config, dict) + assert isinstance(role_config, dict) + audience_config["included.client.audience"] = "other-web" + role_config["usermodel.clientRoleMapping.clientId"] = "other-web" + + _assert_policy_error(payload, "protocolMappers") + + @pytest.mark.parametrize( ("mapper_value", "detail"), [ diff --git a/services/account_unification/tests/test_relying_party_mapper_reconciliation.py b/services/account_unification/tests/test_relying_party_mapper_reconciliation.py index 4db2709..291b24e 100644 --- a/services/account_unification/tests/test_relying_party_mapper_reconciliation.py +++ b/services/account_unification/tests/test_relying_party_mapper_reconciliation.py @@ -127,6 +127,16 @@ def _set_unsupported_mapper_type(client: dict) -> None: ) +def _set_sequence_mapper_type(client: dict) -> None: + """Make a live mapper type unhashable and therefore invalid.""" + _live_mappers_from_client(client)[0]["protocolMapper"] = [] + + +def _set_mapping_mapper_type(client: dict) -> None: + """Make a live mapper type an invalid object rather than a string.""" + _live_mappers_from_client(client)[0]["protocolMapper"] = {} + + def _duplicate_mapper_identity(client: dict) -> None: """Make two live mappers claim the same canonical identity.""" mappers = _live_mappers_from_client(client) @@ -161,6 +171,8 @@ def _live_mappers_from_client(client: dict) -> list[dict]: _add_non_string_config_key, _add_non_string_config_value, _set_unsupported_mapper_type, + _set_sequence_mapper_type, + _set_mapping_mapper_type, _duplicate_mapper_identity, _set_policy_invalid_audience_name, ], diff --git a/services/account_unification/tests/test_relying_party_template.py b/services/account_unification/tests/test_relying_party_template.py index 7f4ed68..cc5c673 100644 --- a/services/account_unification/tests/test_relying_party_template.py +++ b/services/account_unification/tests/test_relying_party_template.py @@ -10,6 +10,7 @@ _TEMPLATE_ROOT = Path(__file__).resolve().parents[3] / "deploy" / "templates" _TEMPLATE_PATH = _TEMPLATE_ROOT / "oidc-rp-client.json" _NARUON_TEMPLATE_PATH = _TEMPLATE_ROOT / "oidc-rp-naruon.json" +_LINEAGEWEAVE_TEMPLATE_PATH = _TEMPLATE_ROOT / "oidc-rp-lineageweave.json" def _render_template() -> dict[str, object]: @@ -46,6 +47,21 @@ def _render_naruon_template() -> dict[str, object]: return payload +def _render_lineageweave_template() -> dict[str, object]: + """Render the committed account-derived client profile without a shell tool.""" + rendered = _LINEAGEWEAVE_TEMPLATE_PATH.read_text(encoding="utf-8") + replacements = { + "{{lineageweave_redirect_uri}}": "https://lineageweave.example/auth/callback", + "{{lineageweave_web_origin}}": "https://lineageweave.example", + "{{lineageweave_post_logout_uri}}": "https://lineageweave.example/logout", + } + for marker, value in replacements.items(): + rendered = rendered.replace(marker, value) + payload = json.loads(rendered) + assert isinstance(payload, dict) + return payload + + def test_oidc_rp_template_is_closed_secret_free_and_preflight_ready() -> None: """The rendered template passes the same production preflight as operators.""" payload = _render_template() @@ -82,3 +98,26 @@ def test_naruon_runtime_template_has_the_closed_mapper_profile() -> None: ] audience = mappers[0] assert audience["config"]["included.client.audience"] == "naruon-web" + + +def test_lineageweave_runtime_template_uses_account_derived_claims() -> None: + """The shipped LineageWeave profile is confidential and secret-free.""" + payload = _render_lineageweave_template() + + result = validate_relying_party_registration(_parse_registration(payload)) + + assert result.ready_to_apply is True + assert payload["clientId"] == "lineageweave-web" + assert payload["publicClient"] is False + assert payload["clientAuthenticatorType"] == "client-secret" + assert "secret" not in {str(key).lower() for key in payload} + assert "clientSecret" not in payload + mappers = payload["protocolMappers"] + assert isinstance(mappers, list) + assert [mapper["name"] for mapper in mappers] == [ + "keyverse-audience", + "keyverse-account-role", + "keyverse-account-org", + "keyverse-account-workspace", + ] + assert all("claim.value" not in mapper["config"] for mapper in mappers[1:]) diff --git a/services/account_unification/tests/test_validate_realm.py b/services/account_unification/tests/test_validate_realm.py index e833950..718d462 100644 --- a/services/account_unification/tests/test_validate_realm.py +++ b/services/account_unification/tests/test_validate_realm.py @@ -2,13 +2,23 @@ from __future__ import annotations import importlib.util +import json +import runpy +import sys from pathlib import Path +import pytest + def _script_path() -> Path: return Path(__file__).resolve().parents[3] / "scripts" / "validate_realm.py" +def _repository_root() -> Path: + """Return the repository root from the account-unification tests.""" + return _script_path().parents[1] + + def _load_validate_realm(): script_path = _script_path() spec = importlib.util.spec_from_file_location("validate_realm", script_path) @@ -19,6 +29,21 @@ def _load_validate_realm(): return module +def _write_committed_policy_artifacts(realm_path: Path, profile_path: Path) -> None: + """Copy the reviewed realm and user profile into isolated CLI inputs.""" + root = _repository_root() + realm_path.write_text( + (root / "deploy" / "keycloak" / "cwl-realm.json").read_text(encoding="utf-8"), + encoding="utf-8", + ) + profile_path.write_text( + (root / "deploy" / "keycloak" / "lineageweave-user-profile.json").read_text( + encoding="utf-8" + ), + encoding="utf-8", + ) + + def test_realm_validation_errors_do_not_echo_sensitive_authenticator_terms(): validator = _load_validate_realm() realm = { @@ -69,3 +94,273 @@ def test_keycloak_authenticator_ids_are_preserved_without_log_taint_literals(): assert '"webauthn-authenticator-passwordless"' not in source assert '"auth-password-form"' not in source assert '"auth-username-password-form"' not in source + + +def test_validator_helpers_cover_missing_flows_cycles_and_token_inputs() -> None: + """Nested export flows and malformed lifespan values stay fail-closed.""" + validator = _load_validate_realm() + realm = { + "authenticationFlows": [ + { + "alias": "outer", + "authenticationExecutions": [ + {"authenticator": "first-factor"}, + {"flowAlias": "inner"}, + ], + }, + { + "alias": "inner", + "authenticationExecutions": [ + {"authenticator": "second-factor"}, + {"flowAlias": "outer"}, + ], + }, + ] + } + + assert validator._executions(realm, "missing") == [] + assert validator._all_authenticators(realm, "outer") == { + "first-factor", + "second-factor", + } + assert validator._public_token_lifespan({"attributes": {}}) is None + assert validator._public_token_lifespan( + {"attributes": {"access.token.lifespan": []}} + ) == -1 + assert validator._public_token_lifespan( + {"attributes": {"access.token.lifespan": "10.5"}} + ) == -1 + assert validator._public_token_lifespan( + {"attributes": {"access.token.lifespan": " 10 "}} + ) == 10 + + +def test_realm_validator_reports_independent_security_drift() -> None: + """A production-like export reports every independently dangerous drift.""" + validator = _load_validate_realm() + realm = json.loads( + (_repository_root() / "deploy/keycloak/cwl-realm.json").read_text( + encoding="utf-8" + ) + ) + clients = {client["clientId"]: client for client in realm["clients"]} + template = clients["ecosystem-rp-template"] + service_client = clients["account-unification-svc"] + naruon = clients["naruon-web"] + basic = next(scope for scope in realm["clientScopes"] if scope["name"] == "basic") + + realm["realm"] = "other" + realm["enabled"] = False + realm["registrationAllowed"] = True + realm["verifyEmail"] = True + realm.pop("smtpServer", None) + realm["$schema"] = "not-importable" + template["implicitFlowEnabled"] = True + template["attributes"]["pkce.code.challenge.method"] = "plain" + service_client["serviceAccountsEnabled"] = False + service_client["secret"] = "committed-secret" + basic["protocolMappers"] = [] + realm["defaultDefaultClientScopes"] = [ + scope for scope in realm["defaultDefaultClientScopes"] if scope != "basic" + ] + naruon["publicClient"] = False + naruon["implicitFlowEnabled"] = True + naruon["attributes"]["pkce.code.challenge.method"] = "plain" + naruon["attributes"]["access.token.lifespan"] = "901" + naruon["protocolMappers"] = [ + mapper + for mapper in naruon["protocolMappers"] + if mapper.get("protocolMapper") != "oidc-audience-mapper" + and mapper.get("config", {}).get("claim.name") != "workspace" + ] + naruon["defaultClientScopes"] = [ + scope for scope in naruon["defaultClientScopes"] if scope != "basic" + ] + + errors = set(validator.validate(realm)) + + assert { + "realm name must be 'cwl'", + "realm must be enabled", + "IdP-hosted registration must remain disabled; use the headless registration API", + "verifyEmail requires a realm smtpServer; configure SMTP or disable verifyEmail", + "RP template must not enable the implicit flow (OAuth 2.1)", + "RP template must require PKCE S256", + "account-unification-svc must enable service accounts", + "client 'account-unification-svc' commits a non-placeholder secret", + "'$'-annotation key '$schema' breaks Keycloak 26 realm import", + "client scope 'basic' must include the oidc-sub-mapper", + "'basic' must be a realm default client scope", + "naruon-web must be a public (PKCE) client", + "naruon-web must not enable the implicit flow", + "naruon-web must require PKCE S256", + "naruon-web access.token.lifespan must be an integer at or below 900 seconds", + "naruon-web must include an audience mapper", + "naruon-web must carry the hardcoded 'workspace' claim naruon's session contract requires", + "naruon-web must assign the 'basic' default scope", + } <= errors + + +def test_realm_validator_reports_missing_flow_and_required_clients() -> None: + """A malformed export cannot hide missing execution or relying-party policy.""" + validator = _load_validate_realm() + source = (_repository_root() / "deploy/keycloak/cwl-realm.json").read_text( + encoding="utf-8" + ) + + no_browser = json.loads(source) + no_browser.pop("browserFlow") + assert "browserFlow must be set" in validator.validate(no_browser) + + empty_browser = json.loads(source) + empty_browser["browserFlow"] = "empty-browser" + empty_browser["authenticationFlows"].append( + {"alias": "empty-browser", "authenticationExecutions": []} + ) + assert "browserFlow 'empty-browser' has no executions defined" in validator.validate( + empty_browser + ) + + required_clients_missing = json.loads(source) + required_clients_missing["clients"] = [ + client + for client in required_clients_missing["clients"] + if client["clientId"] + not in {"ecosystem-rp-template", "account-unification-svc"} + ] + errors = set(validator.validate(required_clients_missing)) + assert "OIDC RP client template 'ecosystem-rp-template' is missing" in errors + assert "service-account client 'account-unification-svc' is missing" in errors + + +def test_user_profile_validator_reports_all_administrator_attribute_drift() -> None: + """Closed account claims stay scalar, admin-controlled, and required.""" + validator = _load_validate_realm() + profile = json.loads( + ( + _repository_root() / "deploy/keycloak/lineageweave-user-profile.json" + ).read_text(encoding="utf-8") + ) + profile["unmanagedAttributePolicy"] = "ENABLED" + profile["attributes"] = [ + attribute + for attribute in profile["attributes"] + if attribute["name"] not in {"email", "workspace"} + ] + org = next(attribute for attribute in profile["attributes"] if attribute["name"] == "org") + org["multivalued"] = True + org["permissions"] = {"view": ["admin"]} + org["required"] = {} + org["validations"] = {} + + errors = set(validator.validate_user_profile(profile)) + + assert { + "user profile must omit unmanagedAttributePolicy so Keycloak 26 disables unmanaged attributes", + "user profile must retain Keycloak built-in account attributes when the Admin API replaces the complete profile", + "user profile 'org' must be scalar", + "user profile 'org' must be admin-managed", + "user profile 'org' must require administrators", + "user profile 'org' must have a maximum length of 64", + "user profile must define 'workspace'", + } <= errors + + +def test_main_accepts_an_explicit_profile_outside_the_realm_directory( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Arbitrary realm validation can name its reviewed sibling-independent profile.""" + validator = _load_validate_realm() + realm_path = tmp_path / "exported-realm.json" + profile_path = tmp_path / "reviewed-profile.json" + _write_committed_policy_artifacts(realm_path, profile_path) + + result = validator.main(["validate_realm.py", str(realm_path), str(profile_path)]) + + assert result == 0 + assert f"OK: {realm_path}" in capsys.readouterr().out + + +def test_main_uses_the_default_committed_realm_and_profile( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """The CLI default paths validate the committed policy artifacts.""" + validator = _load_validate_realm() + monkeypatch.chdir(_repository_root()) + + result = validator.main(["validate_realm.py"]) + + assert result == 0 + assert "OK: deploy/keycloak/cwl-realm.json" in capsys.readouterr().out + + +def test_main_names_an_explicit_invalid_profile_in_its_error( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A malformed supplied profile cannot be hidden by an implicit sibling lookup.""" + validator = _load_validate_realm() + realm_path = tmp_path / "exported-realm.json" + profile_path = tmp_path / "reviewed-profile.json" + _write_committed_policy_artifacts(realm_path, profile_path) + profile_path.write_text("{", encoding="utf-8") + + result = validator.main(["validate_realm.py", str(realm_path), str(profile_path)]) + + assert result == 1 + stderr = capsys.readouterr().err + assert str(profile_path) in stderr + assert "cannot parse" in stderr + + +def test_main_rejects_ambiguous_extra_path_arguments( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """The validator never silently ignores a second profile-like argument.""" + validator = _load_validate_realm() + realm_path = tmp_path / "cwl-realm.json" + profile_path = tmp_path / "lineageweave-user-profile.json" + _write_committed_policy_artifacts(realm_path, profile_path) + + result = validator.main( + ["validate_realm.py", str(realm_path), str(profile_path), "unexpected.json"] + ) + + assert result == 1 + assert "USAGE" in capsys.readouterr().err + + +def test_main_reports_realm_parse_and_profile_policy_errors( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """CLI users receive a bounded failure for unreadable and invalid artifacts.""" + validator = _load_validate_realm() + malformed_realm = tmp_path / "malformed-realm.json" + malformed_realm.write_text("{", encoding="utf-8") + + assert validator.main(["validate_realm.py", str(malformed_realm)]) == 1 + assert f"cannot parse {malformed_realm}" in capsys.readouterr().err + + realm_path = tmp_path / "cwl-realm.json" + profile_path = tmp_path / "lineageweave-user-profile.json" + _write_committed_policy_artifacts(realm_path, profile_path) + profile_path.write_text("{}", encoding="utf-8") + + assert validator.main(["validate_realm.py", str(realm_path)]) == 1 + stderr = capsys.readouterr().err + assert f"INVALID: {realm_path}" in stderr + assert "user profile must define 'org'" in stderr + + +def test_script_entrypoint_honors_the_explicit_profile_contract( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The executable entrypoint preserves the tested main-function behavior.""" + realm_path = tmp_path / "exported-realm.json" + profile_path = tmp_path / "reviewed-profile.json" + _write_committed_policy_artifacts(realm_path, profile_path) + monkeypatch.setattr(sys, "argv", [str(_script_path()), str(realm_path), str(profile_path)]) + + with pytest.raises(SystemExit) as raised: + runpy.run_path(str(_script_path()), run_name="__main__") + + assert raised.value.code == 0 diff --git a/services/account_unification/uv.lock b/services/account_unification/uv.lock index 1ed80ca..bf57988 100644 --- a/services/account_unification/uv.lock +++ b/services/account_unification/uv.lock @@ -172,86 +172,116 @@ wheels = [ [[package]] name = "coverage" -version = "7.15.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, - { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, - { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, - { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, - { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, - { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, - { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, - { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, - { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, - { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, - { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, - { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, - { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, - { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, - { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, - { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, - { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, - { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, - { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, - { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, - { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, - { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, - { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, - { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, - { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, - { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, - { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, - { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, - { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, - { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, - { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, - { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, - { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, - { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, - { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, - { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, - { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, - { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, - { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, - { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, - { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, - { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, - { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, - { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, - { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, - { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, - { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, - { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, - { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, - { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, - { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, - { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, - { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, - { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, - { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, - { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, - { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, - { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, - { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, - { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, - { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, - { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, - { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, - { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/66/edcec7d7a0b524aa8923e22925fde6fe50ce005a113dca13ae1581455c4c/coverage-7.15.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490", size = 222367, upload-time = "2026-08-06T13:47:15.578Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c6/ab8de429e2e8548faf58ec7e1674a4ce00414b4113942d3fe87109cf0f68/coverage-7.15.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e", size = 222874, upload-time = "2026-08-06T13:47:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/3b7b49587e8a6b9af79b3eb468d443d6042b6d65b47aa26586846a0d6566/coverage-7.15.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7", size = 253287, upload-time = "2026-08-06T13:47:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/fb/65/ec03b743a2a229c72cc1eff3e57be9d3564e9c6b4d5aba2d70744a3fc0d8/coverage-7.15.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6", size = 255199, upload-time = "2026-08-06T13:47:19.765Z" }, + { url = "https://files.pythonhosted.org/packages/41/4b/5163729e4b6582d61975cfd3ccab45b4ec53e21cf156d9941cb025188468/coverage-7.15.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d", size = 257308, upload-time = "2026-08-06T13:47:21.206Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/2167a0f08fb87d702fa423a48578a32865464b7c9e1db3911ad7812ab414/coverage-7.15.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce", size = 259268, upload-time = "2026-08-06T13:47:22.503Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e5/68eebae3053dbd48508edea559c21b23fbdf3460784f91370c83a86a6acd/coverage-7.15.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7", size = 253392, upload-time = "2026-08-06T13:47:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/1a/46/fd4ced40a2b691c774e515c9b69500bfa64c7960b67fcee4b2f6fad97fc3/coverage-7.15.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b", size = 255001, upload-time = "2026-08-06T13:47:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/53/25/ae2e5fa710bb6957a9aadeb9e3598d3b3e4af6587ce857ad42e8639a3f30/coverage-7.15.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc", size = 253061, upload-time = "2026-08-06T13:47:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/d7/31/67ddc0365db2c6e93ac8580bc4bbc50f65273262f973f63ebcdbc15c0495/coverage-7.15.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571", size = 256831, upload-time = "2026-08-06T13:47:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/82b8fd18f57fb13f12d98fe874995bb2c4f9f17be8aff762c426323fdb96/coverage-7.15.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719", size = 252781, upload-time = "2026-08-06T13:47:29.712Z" }, + { url = "https://files.pythonhosted.org/packages/0a/eb/6c74ef4dd12b252e573c49bdef9e2ac265bf3dbb79b8d7feb3266e084e9e/coverage-7.15.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7", size = 253692, upload-time = "2026-08-06T13:47:31.192Z" }, + { url = "https://files.pythonhosted.org/packages/5a/66/eb9aed1c3fd2d36ee00eb173f434b14fa607fc056739c9a89ff4244010ea/coverage-7.15.4-cp311-cp311-win32.whl", hash = "sha256:69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e", size = 224461, upload-time = "2026-08-06T13:47:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6d/81fa4161dfb3ed9d74e40d58647eff83a56b7612e78352581280fce2f477/coverage-7.15.4-cp311-cp311-win_amd64.whl", hash = "sha256:63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc", size = 224937, upload-time = "2026-08-06T13:47:34.205Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c1/d8dacf683c6cad3cf85ce68fd3774a6774ec402128822fdfaed920f11e6a/coverage-7.15.4-cp311-cp311-win_arm64.whl", hash = "sha256:ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890", size = 224479, upload-time = "2026-08-06T13:47:36.118Z" }, + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, ] [[package]] @@ -335,7 +365,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "coverage", marker = "extra == 'dev'", specifier = "==7.15.2" }, + { name = "coverage", marker = "extra == 'dev'", specifier = "==7.15.4" }, { name = "cryptography", specifier = "==50.0.0" }, { name = "fastapi", specifier = "==0.141.1" }, { name = "httpx", specifier = "==0.28.1" }, @@ -345,7 +375,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = "==9.1.1" }, { name = "pyyaml", specifier = "==6.0.3" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.1" }, - { name = "setuptools", marker = "extra == 'dev'", specifier = "==83.0.0" }, + { name = "setuptools", marker = "extra == 'dev'", specifier = "==84.0.0" }, { name = "uvicorn", specifier = "==0.52.1" }, ] provides-extras = ["dev"] @@ -726,11 +756,11 @@ wheels = [ [[package]] name = "setuptools" -version = "83.0.0" +version = "84.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, ] [[package]] diff --git a/tests/test_documentation_contract.py b/tests/test_documentation_contract.py index 47ef4cd..fc9100e 100644 --- a/tests/test_documentation_contract.py +++ b/tests/test_documentation_contract.py @@ -32,6 +32,7 @@ "0006-user-operation-lock.md", "0007-automation-authority.md", "0008-keyverse-rp-authorization-boundary.md", + "0009-lineageweave-account-derived-rp-claims.md", ) @@ -98,3 +99,27 @@ def test_adr_index_contains_governing_identity_decisions() -> None: adr_path = ROOT / "docs" / "adr" / adr assert adr_path.is_file(), f"ADR file is missing: {adr}" assert f"]({adr})" in index, f"ADR index does not link {adr}" + + +def test_lineageweave_tenant_contract_is_explicit() -> None: + """Keep the account-derived tenant mapping deterministic for consumers.""" + + adr = _read("docs/adr/0009-lineageweave-account-derived-rp-claims.md") + operations = _read("docs/operations/oidc-rp-reconciliation.md") + adr_contract = " ".join(adr.lower().split()) + operations_contract = " ".join(operations.lower().split()) + required_markers = ( + "`org` is the opaque external tenant key", + "`workspace` is a child namespace under `org`", + "multiple memberships are not represented by comma-separated values", + "membership resolution is ambiguous", + "new token or session renewal", + ) + for marker in required_markers: + assert marker in adr_contract, ( + f"ADR-0009 is missing tenant contract marker: {marker}" + ) + assert marker in operations_contract, ( + "OIDC reconciliation operations are missing tenant contract marker: " + f"{marker}" + )