diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84e52aa..3f4f9d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,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 @@ -59,7 +62,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 0a15d1d..a9e7ec7 100644 --- a/.github/workflows/hourly-product-development.yml +++ b/.github/workflows/hourly-product-development.yml @@ -9,7 +9,7 @@ on: default: false type: boolean schedule: - # Offset from the PR steward at minute 17 so protected merge evidence settles. + # Avoid the top-of-hour congestion window. - cron: "41 * * * *" concurrency: @@ -361,6 +361,9 @@ jobs: Inspect CLAUDE.md, README.md, CHANGELOG.md, docs/, deploy/templates/, services/account_unification/app/, and services/account_unification/tests/. + Read docs/product-technical-gap-baseline.md and the current exact-head open + PR/issue inventory before selecting a gap; preserve active stack dependencies + and never treat predecessor Checks or reviews as current evidence. Select exactly one highest-impact buyer-visible product gap that can be completed safely in one bounded pull request. If the protected branch is not healthy, restoring it is the only permitted objective. @@ -498,9 +501,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 +742,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 f68965d..4aa7a92 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -158,13 +158,29 @@ 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. The reserved `lineageweave-web` +client cannot use the static hardcoded profile or omit any account-derived +claim, including by submitting an empty or audience-only profile. The shared +validator rejects those downgrades before readiness or persistence. +Neither template contains a credential or proves a +live account login. + +The post-import `org` and `workspace` account attributes remain scalar and +administrator-managed but are optional during initial passwordless account +creation because Keycloak validates administrator-only required fields on its +Admin REST create path. An identity-only account cannot enter LineageWeave +routing until an operator assigns both values and the receiving application +accepts the resulting claims. Stateful reconciliation keys intent by validated `clientId`, classifies zero, one, or multiple exact Keycloak clients, and never mutates duplicates. Create or @@ -172,12 +188,14 @@ update is re-observed before a canonical receipt is written. Delete is remote- first. For mapper comparison, Keyverse ignores only a valid generated mapper `id`, canonicalizes the known mapper order, revalidates the closed shape, and treats unknown, malformed, duplicate, or semantically changed mappers as drift. +Keycloak's account-role mapper may omit an empty `rolePrefix` on read-back; +reconciliation restores only that exact empty default for the exact `role` +account-role mapper and continues to reject all other missing or changed fields. 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 @@ -221,6 +239,13 @@ explicitly documented deployment-controller responsibility. ## Automation boundaries +- GitHub Actions source files and the Actions workflow registry are separate + control-plane state. The central `.github` lifecycle inventory binds a + paginated registry observation to the exact protected default-branch tree and + reports dynamic identities separately from repository-path identities. The + inventory is read-only; any registry disablement requires a separately + reviewed, immediately revalidated operator action and a post-action + reconciliation. - The hourly PR steward advances only trusted same-repository PRs with exact-head approvals and required Checks. - The hourly product-development workflow runs OpenCode through diff --git a/CHANGELOG.md b/CHANGELOG.md index 4639f1a..1879359 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ Keep a Changelog, and releases use semantic versioning. ### Added +- The hourly product-development prompt now reads the exact-head gap baseline and + open PR/issue inventory before selecting an independent buyer-visible slice. +- 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 +19,17 @@ 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. +- The reserved `lineageweave-web` client now rejects hardcoded authorization + claims, while Compose keeps the account service fail-closed until its + post-import account-profile bootstrap succeeds. +- 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 +71,47 @@ Keep a Changelog, and releases use semantic versioning. ### Changed +- Separated PR #100 observation SHA `a1a65b26c1ebcd3ce964e56b1f0976e132d33cb9` + from the later inventory commit SHA in the gap baseline so docs-only binds + are not recursively re-named; pending Strix and Devin Review stay unverified. +- Rebound the product and technical gap baseline to live Keyverse PR #100 exact + head `655aaad57678e2503ac83a74fa8e19d6efc5f598`, recording zero unresolved + threads and treating pending Strix and Devin Review as unverified rather than + inheriting predecessor `84e0c75` Checks, then stopped further docs-only + pushes unless a source-fault Check fails. +- Rebound the product and technical gap baseline to live Keyverse PR #100 exact + head `84e0c759f9d757452f109b9c5c96253d54b85853`, recording zero unresolved + threads and treating pending Strix and Devin Review as unverified rather than + inheriting predecessor `25cf0e6` Checks. +- Bound the product and technical gap baseline to Keyverse PR #100 exact head + `25cf0e63760cf22cf73a1322eb1953b0dd2aada7` with zero unresolved threads and + an in-progress Strix Check recorded as unverified, and re-listed #113, #112, + #103, #101, #83 plus `.github` #1233/#1252 with no source-fault Check + failures. +- Refreshed the product and technical gap baseline to the 2026-08-23 exact-head + queue (#113, #112, #103, #101, #100, #83), recorded independent approval as + the remaining merge blocker, closed #110/#111 and stacked #115 as historical + rather than open-PR work, and named G0 then G4 as the next buyer-visible + order while the queue stays non-empty. +- 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. +- The post-import LineageWeave profile bootstrap now reports which required + `org` or `workspace` account attribute is missing from the read-back profile + before it stops the dependent service. - Federation PUT and apply now report `applied_to_keycloak: true` only after a fresh live Keycloak identity-provider observation matches the desired observable representation. Keycloak's fixed mask for the known @@ -111,6 +168,27 @@ Keep a Changelog, and releases use semantic versioning. ### Fixed +- Align workflow tests and current scheduler documentation with PR #140's + removal of the duplicate local PR steward; retain the central required + review and Check gates and prevent the retired workflow from returning. +- Reject incomplete LineageWeave account-claim profiles before readiness or + persistence, including omitted, empty, and audience-only mapper submissions. +- Separate the dated PR #100 owner observation from historical gap inventories + and bind documentation checks to that explicitly designated snapshot. +- Prevented Keycloak's omitted empty account-role `rolePrefix` read-back from + causing perpetual relying-party drift, while retaining fail-closed handling + for all other missing or changed mapper configuration. +- Allowed passwordless registration to create an identity-only account before + administrator assignment of `org` and `workspace`; routing remains blocked + until both claims are assigned and downstream validation accepts them. +- Disabled 37 orphaned active GitHub Actions registry identities whose + repository paths were absent from protected `main`, while preserving the + four supported workflow identities and two GitHub-owned dynamic Dependabot + identities; recorded exact before/after reconciliation and operational smoke + evidence for issue #99. +- 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 1961d5e..d0829fa 100644 --- a/README.md +++ b/README.md @@ -79,11 +79,22 @@ docker compose up -d # or: podman compose up -d - Keycloak console: `http://localhost:8080` - Admin service health: `http://localhost:8099/healthz` +The Compose account service waits for the post-import LineageWeave account +profile bootstrap. If Keycloak admin credentials or profile reconciliation fail, +the account service stays stopped rather than serving with an incomplete issuer +authorization contract; inspect `docker compose ps` and the one-shot bootstrap +logs before retrying. + 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. + Production-shaped clusters use [`helm/cwl-idp/`](helm/cwl-idp/). ### Optional parent include @@ -132,10 +143,28 @@ as the apply payload; apply the original private file only. The first directory profile is LDAPS-only, read-only, Kerberos-disabled, and `trustEmail=false`. -See [`docs/federation-onboarding.md`](docs/federation-onboarding.md) and -[`docs/ldap-directory-onboarding.md`](docs/ldap-directory-onboarding.md). +See [`docs/federation-onboarding.md`](docs/federation-onboarding.md), +[`docs/ldap-directory-onboarding.md`](docs/ldap-directory-onboarding.md), +[`deploy/keycloak/README.md`](deploy/keycloak/README.md), and +[`deploy/templates/README.md`](deploy/templates/README.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 + +```bash +cd services/account_unification +python -m venv .venv && . .venv/bin/activate +pip install -e '.[dev]' +pytest -q +``` -## Account unification Matching precedence is **exact `(identity_provider, subject)` → verified email → explicit operator link**. The engine **never merges on an unverified 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..74f539a --- /dev/null +++ b/deploy/keycloak/lineageweave-user-profile.json @@ -0,0 +1,110 @@ +{ + "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" + } + }, + "permissions": { + "view": ["admin"], + "edit": ["admin"] + }, + "multivalued": false + }, + { + "name": "workspace", + "displayName": "Workspace", + "validations": { + "length": { + "max": "64" + } + }, + "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..b9fc57e --- /dev/null +++ b/deploy/keycloak/reconcile-lineageweave-user-profile.sh @@ -0,0 +1,48 @@ +#!/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 + if ! printf '%s' "${profile}" \ + | grep -Eq '"name"[[:space:]]*:[[:space:]]*"'"${attribute}"'"'; then + echo "profile bootstrap failed: account attribute ${attribute} is missing" >&2 + exit 1 + fi +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/OPERABILITY.md b/docs/OPERABILITY.md index 9e5528a..20fe6c8 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -110,6 +110,26 @@ Back up Keycloak PostgreSQL and Keyverse-owned configuration/audit/intent/receip PR #74 demonstrates that a workflow can appear successful while doing no useful work if a GitHub API gate fails open. Scheduled governance must classify transport failure separately from a valid empty/unhealthy result, fit its time budget, keep provider secrets in the broker phase only, and require exact `success` for protected evidence. With PR #74 integrated, operational closure requires a real protected-main scheduled/manual run. +## Workflow registry lifecycle + +The GitHub Actions registry is control-plane state separate from YAML in the +repository tree. For an inventory, bind the observation to a freshly resolved +protected default-branch SHA, paginate the complete workflow registry, and +compare each repository-path identity with the exact tree at that SHA. Classify +GitHub-owned dynamic identities separately; do not treat a name, one-shot +convention, or missing historical file alone as proof that a workflow is safe +to disable. + +The centrally owned read-only inventory in `ContextualWisdomLab/.github#1026` +is the implementation authority. It must fail closed on incomplete pagination, +permission loss, ambiguous 404/5xx responses, malformed paths, workflow-ID +reuse, and branch movement. Any operator mutation is a separately reviewed +step: re-fetch the branch and registry immediately, exclude supported +identities, mutate by numeric workflow ID only, then reconcile the registry +and protected tree again. Record workflow IDs, paths, states, exact SHA, +observation time, pagination receipts, and the resulting classification; never +record credentials or private payloads. + ## Release gate Release only after protected-head CI/security/review, 100% coverage/docstrings, realm/package/deployment validation, migrations/rollback/backup, passkey/federation/SCIM/RP controlled acceptance, secret scan, SBOM/provenance/image digest, runbooks/support, and CHANGELOG/version artifacts are coherent. A merged PR is not a release by itself. diff --git a/docs/PRD.md b/docs/PRD.md index bcbfd54..e092e8a 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -86,6 +86,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 61c5e18..6302433 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-21 +**Last reviewed:** 2026-08-31 | 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,7 +14,9 @@ | 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-managed account attributes optional at initial creation; 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/PATCH shared operation lock | concurrency/data-integrity decision; ADR-0006 | merge + full-replacement + active=false PATCH lock/concurrency tests | active-PR | + | intent before mutation, receipt after re-observation | desired-state/recovery decision | federation/directory/RP reconciliation tests | implemented-main | | receipt bound to exact desired-state version/hash | threat/recovery contract; ERD | persistence/migration/idempotency evidence required | accepted-contract | | remote-first deletion | consistency/recovery decision | delete/reconciliation tests | implemented-main | @@ -29,6 +32,7 @@ | 100% production statement/branch/docstring | CWL quality contract | CI/pytest/interrogate | implemented-main | | product and technical gap baseline | PRD/TRD/ADR/operability and live exact-head 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 `docs/doctoring/`, `docs/papers/`, and `docs/operations/` are the authoritative research/standards/runbook record for OIDC/OAuth/JWT, SCIM, SAML, LDAP, WebAuthn/passkeys, Keycloak behavior, relying-party lifecycle, and automation changes. This matrix does not duplicate full bibliographic entries. diff --git a/docs/TRD.md b/docs/TRD.md index a252230..7760eda 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 cf35b72..0e5e792 100644 --- a/docs/adr/0001-keycloak-hub.md +++ b/docs/adr/0001-keycloak-hub.md @@ -48,6 +48,28 @@ parent include of this repo's Compose or Helm chart is allowed. A Keyverse checkout must not require naruon, gyeot, Orgmetra, or any other sibling repository. + +## 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. + ## Consequences - Ecosystem RPs implement OIDC client behavior against one issuer rather 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..32259b5 --- /dev/null +++ b/docs/adr/0009-lineageweave-account-derived-rp-claims.md @@ -0,0 +1,162 @@ +# ADR-0009: Bind LineageWeave relying-party claims to Keyverse accounts + +**Status:** Proposed +**Date:** 2026-08-13 + +**Review boundary (2026-09-07):** This unmerged owner proposal belongs to +[PR #100](https://github.com/ContextualWisdomLab/keyverse/pull/100). It is absent +from observed protected `main` at +`7d9151cd2da260e118020c938c7358e2ee75d541`; PR #100 at +`29a78ed8397f583479e9b81a985c1274865c0853` lacks qualifying current-head +acceptance. Local verification does not establish protected integration, +release, or authenticated runtime acceptance. + +## 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 intentionally +optional during initial account creation. Keycloak applies an administrator +role requirement to Admin REST user creation, so requiring either attribute +would make the passwordless registration endpoint unable to create an +unassigned account. Operators must assign both values before LineageWeave +routing; the receiving application rejects missing claims before authorization. +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. Proposed 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-managed attributes and an allowed `lineageweave-web` client + role before user routing; an identity-only account may exist before that + assignment, but 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 a proposed 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). *Keycloak Admin REST API*. Retrieved August 21, 2026, +from https://www.keycloak.org/docs-api/latest/rest-api/index.html + +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 aad6a6a..7c29111 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -17,6 +17,7 @@ authorization boundary and is not rewritten by that expansion. | [0006](0006-user-operation-lock.md) | Merge/link, SCIM full replacement, and supported `PATCH active=false` 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 | | [0013](0013-mcp-oauth-client-authorization.md) | Use Keycloak-backed authorization code plus PKCE and exact resource binding for MCP clients | Proposed | ADR numbering note: protected `main` currently ends at ADR-0008. ADR-0009 is @@ -26,6 +27,7 @@ the next intended number without renumbering parallel work; it must be reconciled after those PRs land, and none of the absent records is accepted architecture on protected `main` yet. + ## ADR triggers Create or update an ADR for changes to authenticator policy, federation hub ownership, identity matching evidence, merge/tombstone semantics, SCIM authority, directory write/trust policy, RP credential/claim ownership, desired-state mutation order, persistent state, secret handling, or autonomous/release authority. diff --git a/docs/doctoring/hourly-opencode-product-development.md b/docs/doctoring/hourly-opencode-product-development.md index 25dbb16..18b7a28 100644 --- a/docs/doctoring/hourly-opencode-product-development.md +++ b/docs/doctoring/hourly-opencode-product-development.md @@ -17,6 +17,7 @@ patch across jobs, and independently re-runs the repository acceptance suite. | Supply-chain integrity | OpenCode and GitHub Actions are commit/digest pinned; generated patches are SHA-256 sealed and reverified on fresh checkouts. | | Verification | Realistic regression tests, 100% production docstrings, 100% statement and branch coverage, package/deployment validation, and exact-base race checks. | | Operational containment | One non-cancelling hourly decision; zero-open-PR and healthy-exact-main gates; one draft PR maximum; no approval, merge, tag, or release authority. | +| Gap selection | The prompt reads the exact-head product/technical baseline and open PR/issue inventory before selecting work; predecessor evidence is non-transferable. | | Modularity | Generated work must preserve standalone Keyverse operation and CWL/Naruon module contracts. | ## Standards interpretation diff --git a/docs/doctoring/oidc-rp-claim-mapper-profile.md b/docs/doctoring/oidc-rp-claim-mapper-profile.md index fe5389e..68b7b77 100644 --- a/docs/doctoring/oidc-rp-claim-mapper-profile.md +++ b/docs/doctoring/oidc-rp-claim-mapper-profile.md @@ -60,6 +60,40 @@ orders known mapper identities canonically, and then performs semantic drift comparison. Unknown, malformed, or duplicate live mappers remain drift rather than being silently discarded. +Measured on August 21, 2026 against a local Keycloak 26.3.2 Admin API read-back, +the account-role mapper omitted `usermodel.clientRoleMapping.rolePrefix` when +its value was empty. Keyverse normalizes that one vendor representation detail +to the reviewed empty default only when the mapper is the exact account-derived +`role` profile. A non-empty prefix, an unexpected field, or any other missing +configuration remains drift. This is measured vendor behavior and a product +normalization choice, not an authorization or token-validation guarantee. + +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 provisioning + +The LineageWeave `org` and `workspace` attributes are scalar, administrator- +visible, administrator-editable, and intentionally optional during initial +account creation. Keycloak's Admin REST user-creation path validates the same +user-profile requirements as other management contexts; an administrator-only +required attribute would therefore reject the passwordless registration request +before an operator can assign the account's ABAC values. Operators must assign +both values before routing, and the receiving application must reject missing, +empty, or non-scalar claims before authorization. + +Measured on August 21, 2026 against the rebuilt local Keycloak 26.3.2 image, +the reviewed profile rejected a no-`org`/`workspace` Admin REST create with HTTP +400. After removing the administrator-only `required` entries while retaining +administrator-only permissions and closed unmanaged-attribute policy, the same +non-PII probe created successfully and was deleted. This is measured runtime +evidence, not proof of downstream token validation or authorization. + ## Stricter Keyverse product policy The product policy is intentionally narrower than the vendor representation: @@ -75,6 +109,9 @@ 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, reserves `lineageweave-web` for that dynamic profile, + 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 @@ -82,6 +119,19 @@ reviewed authorization model and token-validation policy. ## Measured repository evidence +The September 7, 2026 owner repair enforces the existing +[ADR-0009](../adr/0009-lineageweave-account-derived-rp-claims.md) policy; it adds +no mapper, credential, or authentication mechanism. Synthetic omitted, empty, +and audience-only `lineageweave-web` cases each failed before the fix because +validation returned readiness. The shared validation boundary now rejects +these cases before readiness or desired-state persistence. Regressions also +assert no store write or Keycloak call, preserve valid full-profile acceptance, +and retain other clients' audience-only behavior. Path validation reuses the +existing bounded client-ID pattern rather than constructing a mapper-free +dummy registration, so valid reserved-client persistence remains reachable. +This is local contract evidence, not hosted review or authenticated runtime +acceptance. + The implementation is covered by production-shaped tests that exercise: - the first Naruon mapper payload being rejected before mapper support existed; @@ -89,10 +139,15 @@ The implementation is covered by production-shaped tests that exercise: - wrong/duplicate audience and claim mappers; - unsupported mapper classes and claim names; - canonical mapper ordering and bounded claim values; +- rejection of hardcoded claims for the reserved `lineageweave-web` client; - Keycloak-generated mapper IDs and vendor reordering; - 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 and omitted-empty-prefix 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 +167,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 +185,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 +204,14 @@ 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). *Keycloak Admin REST API*. Retrieved August 21, 2026, +from https://www.keycloak.org/docs-api/latest/rest-api/index.html + +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 index b104e7c..663b5e2 100644 --- a/docs/doctoring/product-technical-gap-baseline.md +++ b/docs/doctoring/product-technical-gap-baseline.md @@ -1,72 +1,568 @@ # Product-technical gap baseline doctoring record -**Date:** 2026-08-21T16:47:10Z +## 2026-08-31 merge of protected main `2e00633e` into PR #100 + +This merge brings protected main (PR #112 uv lockfile plus ADR-0013 MCP OAuth / SCIM lock / Helm notes already present on that history) onto the live LineageWeave ADR-0009 RP-profile branch without force-push. Predecessor-head Checks on `d978e9a` are non-passing for the merge commit. Docs-only SHA rebinds from `a1a65b2` / `655aaad` / `84e0c75` stay historical; they do not transfer evidence onto this merge commit. + +**Date:** 2026-08-23 **Scope:** Keyverse product, trust-boundary, PR queue, and release evidence +## 2026-08-23 observation SHA `a1a65b2` versus inventory commit SHA + +Live Keyverse PR #100 observation SHA is +`a1a65b26c1ebcd3ce964e56b1f0976e132d33cb9` against protected `main` +`ce207dfd42975db61c82a5963e206fc1db14ac2b`. Threads on that SHA: 44, unresolved +0. Hosted Checks: 22 successful, 7 skipped, 2 pending (`strix` in-progress, +Devin Review pending). Pending results are unverified. The inventory commit +SHA that writes this paragraph is later than the observation SHA and is not +recursively named. Docs-only SHA rebinds stop here unless a source-fault Check +fails. + +## 2026-08-23 exact-head `655aaad` refresh + +Live Keyverse PR #100 is `655aaad57678e2503ac83a74fa8e19d6efc5f598` against +protected `main` `ce207dfd42975db61c82a5963e206fc1db14ac2b`. Exact-head review +threads on that SHA are zero unresolved. Hosted Checks on that SHA are 22 +successful, 7 skipped, and 2 pending (`strix` in-progress and Devin Review +pending). Pending and skipped results remain unverified. Predecessor +`84e0c759f9d757452f109b9c5c96253d54b85853` results are not current merge +evidence. Independent approval remains absent. After this inventory bind, no +further docs-only push is made unless a source-fault Check fails. + +## 2026-08-23 exact-head `84e0c75` refresh + +Live Keyverse PR #100 is `84e0c759f9d757452f109b9c5c96253d54b85853` against +protected `main` `ce207dfd42975db61c82a5963e206fc1db14ac2b`. Exact-head review +threads on that SHA are zero unresolved. Hosted Checks on that SHA are 22 +successful, 7 skipped, and 2 pending (`strix` in-progress and Devin Review +pending). Pending and skipped results remain unverified. Predecessor +`25cf0e63760cf22cf73a1322eb1953b0dd2aada7` results are not current merge +evidence. Independent approval remains absent. No competing Keyverse product +PR was opened. + +## 2026-08-23 exact-head `25cf0e6` refresh + +The live Keyverse queue is unchanged in membership. PR #100 now stands at +exact head `25cf0e63760cf22cf73a1322eb1953b0dd2aada7` against protected `main` +`ce207dfd42975db61c82a5963e206fc1db14ac2b`. That head has zero unresolved +review threads after the Devin Compose `cwl-idp/keycloak:local` analysis was +dispositioned as the documented standalone import-realm contract (no source +change). Hosted Checks on that head are 23 successful, 7 skipped, and 1 +in-progress (`strix`). The in-progress Strix run is unverified and is not +treated as success or as a source-fault failure. Independent approval remains +absent. + +Re-listed sibling heads #113, #112, #103, #101, and #83 still have zero +unresolved threads and no terminal failed Checks. `.github` #1252 remains +merge-`CLEAN` without formal approval. `.github` #1233 remains +`CHANGES_REQUESTED` with an in-progress Strix rerun after a provider +fail-closed job; both `jq` parses are already guarded on that head. No +competing Keyverse product PR was opened. + +## 2026-08-23 live PR and control-plane refresh + +The protected Keyverse `main` head remains +`ce207dfd42975db61c82a5963e206fc1db14ac2b`. The live open queue is #83, #100, +#101, #103, #112, and #113. Their current exact heads are respectively +`dd1ab7444a75342b42e3af013ccda6d1dbfb359d`, +`f809c912108f59508ee1c0e29fb61a9f1ee66307`, +`50dd9c96cab5c230f775685e8baea939fba390dd`, +`77b8f4ea9995329f1c55b916d110b460b4bc7649`, +`ec34ac14fd38c9c7c463cddbd0ced04b4dfccafd`, and +`9bd33ee0d00ef1874fd5efabac3462f678a256ed`. All report `REVIEW_REQUIRED` with +zero unresolved review threads and zero formal approvals. Independent review +absence is a recorded blocker, not a merge license. This documentation refresh +creates a later #100 head, so predecessor Checks are not transferable merge +evidence. + +Current Keyverse Checks are 22 successful and 8 skipped on every open PR. +Skipped, queued, and pending results remain unverified rather than green. No +source-fault Check failure required a RED-to-GREEN repair on this observation. +Because the queue is non-empty, this run must not open a competing product PR. +The next buyer-visible gap after G0 (protected queue convergence) is G4 (PR +#113 SCIM deactivation lock). + +The earlier CodeQL pair #110/#111 is closed and is not a current open-PR +blocker. PR #115 squash-merged ADR-0013 into the #112 stack and is no longer +an open Keyverse PR; MCP runtime authorization remains `gap-not-claimed`. + +Current control-plane evidence is protected `.github` main +`885f2cd251999f21cf562cab3e2d9cc3cc3ec737`. Representative open items include +#1252 at `f7b0fd3253747efeef590859df150fd759aaf306` and #1233 at +`dfb8e261c81705841111dd4ad1712a9fb6c767d1`. These are sibling observations, +not Keyverse merge evidence. All predecessor evidence remains +non-transferable. + +## 2026-08-22 live PR and control-plane refresh + +The protected Keyverse `main` head remains +`ce207dfd42975db61c82a5963e206fc1db14ac2b`. The live open queue is #83, #100, +#101, #103, #112, and #113. Their current exact heads are respectively +`dd1ab7444a75342b42e3af013ccda6d1dbfb359d`, +`19ebd86500d17f3aebcfec6e65965c0a79fab6c0`, +`50dd9c96cab5c230f775685e8baea939fba390dd`, +`77b8f4ea9995329f1c55b916d110b460b4bc7649`, +`ec34ac14fd38c9c7c463cddbd0ced04b4dfccafd`, and +`9bd33ee0d00ef1874fd5efabac3462f678a256ed`. All report +`REVIEW_REQUIRED`; #112 has zero valid unresolved threads after the current +masked-secret disposition. No PR has a formal approval satisfying the +protected merge gate. + +Current Keyverse Checks are: #83 22 successful/8 skipped; #100 22 +successful/8 skipped; #101 22 successful/8 skipped; +#103 22 successful/8 skipped; #112 22 successful/8 skipped; and #113 22 +successful/8 skipped. The #112 code fix for Keycloak's fixed masked +`clientSecret` read-back is locally verified at 100% production statement and +branch coverage, and its hosted Checks are terminal-success; independent +approval remains absent. No emergency bypass is justified. + +Current control-plane evidence is protected `.github` main +`0156282022134484ea9d7541d5ba0730ba14fd96`. The OSV cross-fork +result-isolation root #1209 is at +`d3a3f4e6211a56d503b783d8784d1d79a262ca68` with 28 successful, 21 skipped, +1 cancelled, 3 neutral, and 2 queued Checks. The hourly OIDC caller repair +#1188 is at `2c05f05f5fbb923099e0e228d616ab9974dbd327` with 28 successful, 18 +skipped, 1 cancelled, 3 neutral, and 1 queued Check. The combined security +and scheduler root #1198 is at +`801c2f1bc43e99d89ab3745ea8722779f7081b95` with 3 successful, 15 skipped, +1 cancelled, 2 in-progress, and 16 queued Checks after its exact +merge-preview repair; replay-guard repair #1166 is at +`e6c03f618d54497b98eaf96afa21724b19847bd2` with 26 successful, 29 skipped, +6 cancelled, 3 neutral, and 1 queued Check. Scheduler repair #1203 was +normally merged into the #1198 feature branch at +`4d3d24aa404959f5067735fec0558d5924ade590`; review repair #1002 was then +normally merged into that same feature branch at +`3016543f735bb24db760cfaa768e64f95f408473`; OSV repair #1208 is closed +without merge; #1172 is normally restacked at +`edab578feca63c223368aef17c175bb52ce22e5a` with 26 successful, 20 skipped, +2 cancelled, 3 neutral, and 1 queued Check; and #1026 is at +`1be76989887ab772e3ce0d2e0c7f22d3ca98dd94` with 28 successful, 22 skipped, +2 cancelled, 3 neutral, and 1 queued Check. The dependency failure and +hosted-gate state are source/hosted-gate problems, not demonstrated +control-plane deadlocks. All predecessor evidence remains non-transferable. + ## Interpretation -This baseline separates deterministic repository evidence from live protocol, -consumer authorization, deployment, and release acceptance. Source/tests can -prove validators, reconciliation, locking, and documentation contracts; they -cannot prove a controlled passwordless login, downstream token acceptance, -production sizing, or immutable release provenance without an approved runtime -lane. Missing evidence is therefore `gap-not-claimed`, not synthetic success. - -The mapper policy remains closed: `role`, `org`, and `workspace` are issuer-side -claims, not tenant authorization. Consumers must independently validate issuer, -signature/JWKS, expiry, audience, resource, tenant, purpose, and RBAC. The -LineageWeave profile keeps `org` as one opaque tenant key and `workspace` as one -child namespace; ambiguous membership denies before authorization. - -## Current evidence interpretation - -- The protected-main head observed for this snapshot is - `ce207dfd42975db61c82a5963e206fc1db14ac2b`. -- The #112 root stack is at exact head `44c2adb18687f8df457bd4bafade551533cee5b9` - with 14 queued and 7 skipped Checks, six valid unresolved review threads, - and no exact-head formal approval; local 100% evidence does not replace - hosted checks or independent approval. -- #104 is closed by squash merge at `44c2adb18687f8df457bd4bafade551533cee5b9` - on the feature base, while protected `main` remains - `ce207dfd42975db61c82a5963e206fc1db14ac2b`. This is audit evidence of a - feature-base governance gap, not protected-main approval evidence. -- Central `.github#1203` has a cancelled scheduler predecessor and a queued - retry. Cancellation is normal concurrency behavior; the observed age and - evidence do not satisfy D1–D5 emergency bypass criteria. -- Open issues #114, #102, #99, #71, and #2 remain tracked. No issue is treated - as implemented-main evidence merely because a design PR exists. +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 -OIDC exact issuer and audience validation, RFC 8725 token validation, RFC 9700 -authorization-code and PKCE guidance, RFC 8707 resource indicators, RFC 9728 -protected-resource metadata, RFC 9068 JWT access-token requirements, and RFC -9207 authorization-response issuer comparison are interpreted as consumer or -resource-server acceptance requirements where applicable. Keycloak mapper -configuration remains projection evidence only. +- 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. +- RFC 9068 JWT access-token requirements (`typ=at+jwt`, `iat`, `jti`) and RFC + 9207 authorization-response issuer comparison are consumer or resource-server + acceptance requirements. Mapper configuration does not satisfy them. +- 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 MCP OAuth design evidence + +- Issue #114 remains an active buyer gap. Keyverse must use Keycloak as the + authorization server, not add a static MCP API key or a second user/token + authority. +- Keyverse PR #115 is open at exact head + `7281c3d961f40bf47383b8cddeae750af1298ad5`. It adds proposed ADR-0013 and + `docs/doctoring/mcp-oauth-authorization.md`, covering OIDC/RFC 8414 + discovery, public-client authorization code plus `S256` PKCE, exact + redirects, RFC 8707 resource binding, RFC 9728 LineageWeave metadata, + centralized revocation/audit, and negative evidence. +- PR #115 is documentation-only. Its initial post-publication rollup had 2 pending, 14 queued, + and 7 skipped Checks with review required; no MCP browser flow, resource + metadata endpoint, resource-bound token, revocation check, or LineageWeave + end-to-end result is claimed. RFC 8628 remains deferred until a real + callback-less client requires it. +- After its review corrections, PR #115's current exact head is + `0e0c15c4ba7d631660693c549dcbb7e863d6287b`; its current rollup is 2 + successful, 14 queued, and 7 skipped Checks. This remains documentation + evidence only, not a protected merge or runtime acceptance. + +## 2026-08-21 local runtime probe + +This is partial protocol-readiness evidence, not login or release acceptance. -No new frontend behavior is claimed by this baseline. A future UI change must -record its Figma File ID and Storybook scene/edge-event, accessibility, -interaction, performance, responsive, typography/color, animation, form, -navigation, and chart acceptance in the owning ADR. +- 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. -## Verification rule +**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. -Every refresh must re-query the exact PR head, base, open review threads, formal -review decision, required CheckRun conclusions, and merge state. A changed head -invalidates prior evidence. Protected merges remain normal PR merges; guarded -force merge is permitted only after all D1–D5 and emergency acceptance criteria -are independently proven. No bypass, force push, direct protected push, fake -status, or self-approval is part of this record. +## 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 + `1f2b93ead7205b33712de1865d84c004d93be7ed` for routing OpenCode reviews + through the contextual gateway. Its current hosted rollup has 5 successful, + 16 pending, and 13 skipped Checks; no terminal source failure or qualifying + formal approval is recorded. Its current-head review threads are resolved. + The current head removes inherited GitHub + credentials and workflow-file channels from the child gateway process and + has passed the focused contract test plus full local central verification. +- Central `.github` PR #1178 is the canonical contextual-orchestrator hourly + caller, open at exact head + `97b084ac28b5ccf6de7f68fd2e019d8da6f80143`. Its current rollup has 25 + successful, 3 neutral, 2 pending, 1 cancelled, and 19 skipped Checks, with + 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 23 successful, 3 neutral, 6 pending, and + 15 skipped Checks, with no qualifying formal approval. +- Central `.github` PR #1187 is open at exact head + `91c16ebf5187daad749ae57ec01d16cb7afec7b3` for scoped Rust coverage + evidence. Its current rollup has 6 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 1 successful and 16 pending Checks, with 13 skipped and + no terminal source failure or qualifying formal approval. +- Central `.github` PR #1174 is open at exact head + `11f397988f871b7566e6e1c5dcf5fd82be905dc0` for the mention-router + acknowledgement recovery path. Its current rollup has 26 terminal + successes, 3 neutral, 1 queued, and 15 skipped Checks, with no terminal + source failure or qualifying formal approval. It is the normal + source fix for recent main-branch `Review Agent Mention Router` failures + (`32438800573`, `32438736861`, and `32438190241`) where a target + acknowledgement reaction returned HTTP 403 after durable dispatch. The + current head preserves the durable dispatch and retries only the cosmetic + acknowledgement without creating a duplicate dispatch. +- Central `.github` PR #1189 is open at exact head + `07cdefca207e8bc09e714e33740a47809cb5d9a4` to close the pre-existing + repository-wide docstring gap in the organization commercial-readiness + coordinator. The one-line behavior-neutral fix passed the full local suite, + 100% statement/branch coverage, and 100% interrogate verification. Its + current hosted rollup has 3 successful, 16 queued, 1 cancelled, and 15 + skipped Checks, with no terminal source failure or qualifying formal approval + or protected merge. +- Central `.github` PR #1155 is open at exact head + `4b9a933d77a1d68459bf2c51abfbdba9e2d03d8b` for stable deduplication of + unscoped scheduler dispatches and bounded stale-review input. Its current + rollup has 26 successful, 3 neutral, 1 queued, and 15 skipped Checks, with + no qualifying formal approval. Historical run `32434533013` remains a + fail-closed malformed targeted dispatch for `ContextualWisdomLab/TEPP` + without a PR number; it is not treated as successful scheduler evidence or + as permission to weaken target validation. +- Central `.github` PR #1171 is open at exact head + `ff65c16063f7b687df09253d31d84dac6517c0ea` to refuse scheduler head + mutations when required checks cannot be started. Its current rollup has 5 + successful, 16 queued, and 13 skipped Checks, with no qualifying formal + approval. The exact head passed the full local central suite and scheduler + 100% statement/branch coverage; hosted queued evidence remains unverified. +- Central `.github` PR #1188 is open at exact head + `7f9f9f0606ac5c88df3857eb5e5367d5bdbad420` to grant the DiskSage and + Clearfolio hourly reusable-workflow callers job-scoped OIDC permission. Its + current rollup has 4 successful, 16 queued, 15 skipped, and 1 cancelled + Check, with no qualifying formal approval or protected merge. +- The scheduled central `.github` Organization Commercial Readiness Loop run + `32437647976` failed before coordination because the configured + `PR_REVIEW_MERGE_TOKEN` was unavailable. This is a fail-closed credential + configuration gap, not permission to substitute `GITHUB_TOKEN` or bypass the + reviewer credential boundary; remediation requires the owning secret + configuration or an explicit owner decision. +- 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 -- Internet Engineering Task Force. (2020). *JSON Web Token best current practices* (RFC 8725). https://www.rfc-editor.org/rfc/rfc8725.html -- Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current practice for OAuth 2.0 security* (RFC 9700). https://www.rfc-editor.org/rfc/rfc9700.html -- Internet Engineering Task Force. (2018). *OAuth 2.0 authorization server metadata* (RFC 8414). https://doi.org/10.17487/RFC8414 -- Internet Engineering Task Force. (2020). *Resource indicators for OAuth 2.0* (RFC 8707). https://doi.org/10.17487/RFC8707 -- Bertocci, V. (2021). *JSON Web Token (JWT) profile for OAuth 2.0 access tokens* (RFC 9068). https://doi.org/10.17487/RFC9068 -- Meyer zu Selhausen, K., & Fett, D. (2022). *OAuth 2.0 authorization server issuer identification* (RFC 9207). https://doi.org/10.17487/RFC9207 -- Internet Engineering Task Force. (2025). *OAuth 2.0 protected resource metadata* (RFC 9728). https://doi.org/10.17487/RFC9728 -- Model Context Protocol. (2026, July 28). *Authorization*. https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization +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 + +Internet Engineering Task Force. (2018). *OAuth 2.0 authorization server +metadata* (RFC 8414). https://doi.org/10.17487/RFC8414 + +Internet Engineering Task Force. (2020). *Resource indicators for OAuth 2.0* +(RFC 8707). https://doi.org/10.17487/RFC8707 + +Bertocci, V. (2021). *JSON Web Token (JWT) profile for OAuth 2.0 access tokens* +(RFC 9068). https://doi.org/10.17487/RFC9068 + +Meyer zu Selhausen, K., & Fett, D. (2022). *OAuth 2.0 authorization server +issuer identification* (RFC 9207). https://doi.org/10.17487/RFC9207 + +Internet Engineering Task Force. (2025). *OAuth 2.0 protected resource +metadata* (RFC 9728). https://doi.org/10.17487/RFC9728 + +Model Context Protocol. (2025, November 25). *Authorization*. +https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization + +GitHub. (2026). *REST API endpoints for workflows*. +https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28 + +## 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, ADR-0009, and proposed ADR-0013 plus their related specification, + plan, operations, and doctoring records. +- The live open-Issue query performed on 2026-08-21 found five open issues; + newly tracked Issue #114 defines the buyer gap for MCP-compatible OAuth client + authorization for headless agents. PR #115 proposes its design contract; + implementation remains deferred until that contract is independently + reviewed and the PR/Issue queue permits a bounded runtime change. +- LineageWeave PRs #333 and #334 are both currently closed without merge; + their managed/static MCP API-key direction is superseded by Issue #114's + centralized Keyverse OAuth boundary and must not be revived as a second + identity, issuance, revocation, or audit system. +- Exact-head GitHub PR, review, issue, check-run, ruleset, and scheduled-run + queries performed on 2026-08-21. Fifteen Keyverse PRs are open: #112, #101, + and #83 each have 23 successful and 8 skipped Checks with no pending run; + #108 and #107 each have 21 successful, 1 queued, and 8 skipped; #110 and + #109 each have 20 successful, 1 queued, and 8 skipped; #111 has 19 + successful, 2 queued, and 7 skipped; #106 and #105 each have 20 successful, + 1 queued, and 8 skipped; #104 has 21 successful, 1 queued, and 8 skipped; + #103 and #100 each have 2 successful, 14 queued, and 7 skipped; #113 has 2 + successful, 14 queued, and 7 skipped; and #115 has 2 pending, 14 queued, + and 7 skipped Checks. No current open PR has a qualifying formal approval or + terminal failure. Pending and queued Checks remain unverified. + PR #113's current SCIM lock head + `49136c24fb07e3a8ed01171785e6946c559ea2a5` includes the normal prerequisite + lockfile history, a realistic SCIM PatchOp race, and the corrected valid + root-level deactivation payload. Its hosted Checks remain pending with no + terminal failure. Local focused/full verification and 100% statement/branch + coverage passed, but hosted security and independent approval remain + unverified. The earlier Strix job could not pull + `ghcr.io/usestrix/strix-sandbox:1.3.0` because GHCR returned HTTP 500/EOF and + produced no structured vulnerability report, so that run failed closed. + 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 successful and 8 skipped + 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 remains unverified. Its + first Strix attempt also failed on contradictory model-generated Compose + evidence; the neighboring #110 check used its neutral backend fallback and + #112 passed. A normal exact-head attempt 2 is queued. It remains coupled to + #112 and #110. PR #110's current head + `c3e307fc3d4f6d98ec5a0514f35aa8038b2737b7` remains on the #112 base with + 20 successful, 1 queued, and 8 skipped 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 20 + successful, 1 queued, and 8 skipped Checks with no terminal failure. The + current #106 head `e7fafd4192cc3cc344b8f8e536bc0495afaa739f` likewise has + 20 successful, 1 queued, and 8 skipped Checks. PR #109's head + `7b726b16d38ce16d13d00c946b5c8bc0c406191f` has 20 successful, 1 queued, + and 8 skipped + 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 21 successful, 1 queued, and 8 skipped for #107 and 21 successful, + 1 queued, and 8 skipped 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 `e765f4860177af47b80b05ee3a918a4dc2cb4450` 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 + earlier documentation head was + `f331938a4f3cd6808101b8888b76c0f87b1eb841`, with 2 successful, 14 queued, + and 7 skipped Checks; its changes-requested review state is not approval. PR #104's updated + head `7da9d43087d5647fefb946eb154ee1e5c10c576d` is based on #112's lockfile + head and has 21 successful, 1 queued, and 8 skipped Checks. + After the baseline/reference update, PR #100 advanced normally to + `ee6c13f059b6074f990f51ae7cf26de62d2c6d67`; its then-observed rollup was 1 + successful, 1 pending, 14 queued, and 7 skipped Checks with changes + requested. The new exact head remains unverified for protected merge. + A subsequent docs-only push advanced PR #100 to + `630cd320f6bfe305bc79c54755f6215a276bca9f`; its latest observed rollup is 2 + successful, 14 queued, and 7 skipped Checks with changes requested. The + product baseline table supersedes the earlier #100 sub-record; this + documentation refresh creates a later head that requires fresh hosted + revalidation. + 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. This is the immutable + pre-mutation record for Keyverse issue #99; no credential, private payload, or + PII is recorded here. + +## 2026-09-07 current-main integration and retired steward contracts + +PR #100's claims repair was integrated normally with protected `main` +`7d9151cd2da260e118020c938c7358e2ee75d541` at merge commit +`16e903db229e60a24cfde5cc2697819582104713`. The production application tree +remained `9f7cda504b0de7ccc46badea95dbb95e6d573714`, identical to the repair +verified by 831 service tests and 100% statement/branch coverage before merging. + +The integration exposed six obsolete contracts: five tests tried to inspect +the local PR steward removed by PR #140, and the gap-baseline test and current +operations text still promised its minute-17 schedule. All six failed on the +missing file; restoring the duplicate workflow would contradict protected main. +The source-specific tests were retired, the existing central-workflow absence +contract now also guards the removed steward path, and current loop text points +to the central required PR scheduler. Historical lifecycle evidence below is +retained as dated evidence, not rewritten into a current runtime claim. + +This integration changes workflow contracts and documentation only. It does +not relax the independent approval or required-Check rules, alter the remaining +product-development schedule, or establish protected merge/release acceptance. + +Post-repair local verification passed 829 service tests and all 10 root +documentation contracts. The count reconciles the earlier 831 tests with +three current-main workflow contracts and five retired source-specific tests. +Production coverage remained 100% across 2,813 statements and 778 branches, +with zero missed statements or partial branches; Ruff, 100% docstring coverage, +and Actionlint also passed. The full run used +`uv run --locked --extra dev coverage run --branch --source=app -m pytest -o addopts= -q`; +the coverage report used `--show-missing --fail-under=100`. These results +describe local integration evidence; fresh hosted checks and independent +approval are still required on the pushed head. + +## 2026-08-21 workflow registry lifecycle remediation + +- The protected-main ref was re-fetched immediately before mutation and + remained `ce207dfd42975db61c82a5963e206fc1db14ac2b`. +- The four exact tree workflows (`ci.yml`, `codeql.yml`, + `hourly-pr-steward.yml`, and `hourly-product-development.yml`) were checked + for membership before any action and were excluded from mutation. +- The GitHub Actions workflow endpoint was used by numeric workflow ID with + the recommended JSON accept header. The 37 active repository-path identities + absent from the protected tree were set to `disabled_manually`; the two + `dynamic/*` Dependabot identities were not changed. GitHub documents that + this operation changes a workflow's state to `disabled_manually` and returns + HTTP 204 (GitHub, 2026). +- Fresh post-action reconciliation found 43 identities: 41 repository paths, + 4 active supported workflows, 37 `disabled_manually` orphan records, and 2 + unchanged active dynamic records. Active repository-path records absent from + the exact protected tree: zero. +- Operational smoke evidence remained intact: protected-main + `Hourly product development` run `32443743245` succeeded, the latest + completed `Hourly PR steward` run `32425875355` succeeded, and CodeQL run + `31783415830` succeeded on the protected SHA. The latest `ci.yml` success + (`31555831037`) predates the protected SHA and is recorded as such rather + than promoted to current-head evidence. +- This closes the live orphan cleanup portion of issue #99. A read-only + recurrence detector with pagination, path/ID reuse, branch movement, + permission-loss, transient HTTP failure, dynamic-workflow, and active-PR + cases remains required before the issue can close. diff --git a/docs/operations/hourly-product-development.md b/docs/operations/hourly-product-development.md index abb366a..7bfab2b 100644 --- a/docs/operations/hourly-product-development.md +++ b/docs/operations/hourly-product-development.md @@ -1,14 +1,20 @@ # Hourly product-development loop Keyverse separates protected pull-request maintenance from autonomous product -development. The schedules are offset so the merge loop has time to settle the -repository before a new product slice is considered. +development. The central required workflow handles PR events; the repository +retains one hourly product-development schedule. -| Minute (UTC) | Workflow | Responsibility | +| Trigger (UTC) | Workflow | Responsibility | | --- | --- | --- | -| `17 * * * *` | `hourly-pr-steward.yml` | Update trusted PR branches, require approval and required Checks, then arm exact-head auto-merge. | +| PR events | Central `pr-review-merge-scheduler.yml` | Advance PRs only through the protected review and required-Check path. | | `41 * * * *` | `hourly-product-development.yml` | When the PR queue is empty and exact `main` is healthy, use OpenCode with NVIDIA NIM to produce one bounded buyer-visible draft PR. | +[PR #140](https://github.com/ContextualWisdomLab/keyverse/pull/140) removed +the redundant local PR steward in protected-main commit +`d8ff4eded4f82ad5e72deec940cd73e1583b4640`. Its old minute-17 schedule and +source-specific tests are retired; central review, approval, and Check gates +remain required. The local absence contract prevents reintroducing that duplicate. + The development scheduler never approves or merges its own work and never publishes a release. The existing review-agent workflows and their credentials remain unchanged. Review, repair, revalidation, and merge stay owned by the @@ -120,6 +126,8 @@ and publication credentials are absent. The model receives a repository-specific contract requiring: - exactly one highest-impact buyer-visible product gap; +- reading `docs/product-technical-gap-baseline.md` and the current exact-head + open PR/issue inventory before selecting a gap; - Superpowers design, test-driven development, systematic debugging, and verification-before-completion; - a real failing regression before production implementation; @@ -182,7 +190,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 816970a..cfe8171 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. @@ -57,6 +59,55 @@ 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. + +The reserved client ID is rejected if it is submitted with the generic static +claim profile, omitted or empty mappers, or an audience-only profile. Preflight +and desired-state PUT both require the complete four-mapper account profile +before any persistence or Keycloak call. This prevents an operator from +silently replacing account-derived +authorization attributes with visible constants while retaining the same +LineageWeave audience. In the standalone Compose path, the post-import profile +bootstrap is a required one-shot prerequisite for the account service; a failed +bootstrap intentionally leaves that service stopped until the issuer profile is +repaired. The bootstrap script reports the specific missing `org` or `workspace` +attribute in its failure output so operators can repair the read-back profile +without exposing credentials. + +### 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 @@ -152,6 +203,8 @@ semantic comparison: string; - known mapper identities are sorted into the canonical audience, role, org, workspace order; +- an omitted empty `usermodel.clientRoleMapping.rolePrefix` is restored only + for the exact account-derived `role` mapper; - the remaining mapper shape is revalidated against the same closed product policy used by preflight. 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 index 9604326..01629b5 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,173 +1,752 @@ # Keyverse product and technical gap baseline -**Evidence snapshot:** 2026-08-21T16:47:10Z (UTC) +**Evidence snapshot:** 2026-09-07T01:25:45+00:00 (bounded PR #100 owner audit) **Repository:** `ContextualWisdomLab/keyverse` -**Protected-main head observed:** `ce207dfd42975db61c82a5963e206fc1db14ac2b` -**Status:** live inventory and gap register; not a release acceptance record +**Protected-main head observed:** `7d9151cd2da260e118020c938c7358e2ee75d541` +**Status:** dated observation with retained historical inventories; not release acceptance -This baseline joins the product, architecture, ADR, standards, operations, -and exact-head GitHub evidence into one executable backlog. It distinguishes -protected-main evidence from open-PR work, accepted contracts, and claims that -remain intentionally unverified. +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 and authority boundary +## Historical merge — 2026-08-31 main `2e00633e` into PR #100 -Keyverse is a standalone and embeddable identity control plane for CWL, Naruon, -and sibling products. It 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 own token signature/issuer/audience validation, -tenant/resource/purpose ABAC, and bounded RBAC. A Keycloak mapper receipt is -issuer-side configuration evidence, never proof that a relying party accepts a -token or enforces authorization. +This merge brings protected main (PR #112 uv lockfile plus ADR-0013 MCP OAuth / SCIM lock / Helm notes already present on that history) onto the live LineageWeave ADR-0009 RP-profile branch without force-push. Predecessor-head Checks on `d978e9a` are non-passing for the merge commit. Docs-only SHA rebinds from `a1a65b2` / `655aaad` / `84e0c75` stay historical; they do not transfer evidence onto this merge commit. -## Evidence vocabulary +## Product contract -| Classification | Meaning | +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 representative tests are on protected `main`. | -| `active-PR` | Work exists only in an open PR and is not released evidence. | -| `active-issue` | An open issue records a product or operational gap. | -| `accepted-contract` | An ADR or standard defines policy; runtime acceptance may still be absent. | -| `gap-not-claimed` | The repository makes no success claim until stronger evidence exists. | - -Queued, cancelled, skipped-required, stale, predecessor-head, and -rate-limited checks are not successful evidence. Formal approval must bind to -the exact current head and satisfy the latest-pusher and independent-review -rules. - -## Capability and buyer acceptance map - -| Capability | Current maturity | Buyer-visible boundary | +| `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. | +| `active-issue` | A buyer or operational gap is tracked in an open issue and is not implemented 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 live queue snapshot + +Observed at `2026-09-07T01:25:45+00:00` through read-only GitHub REST and +GraphQL queries. "Current" designates this document's latest recorded +observation; it is not a continuously refreshed claim. This bounded audit +covers [#100](https://github.com/ContextualWisdomLab/keyverse/pull/100), its +protected base, and adjacent owner PRs. The full historical PR/issue inventories +below were not re-audited. Source observation head: +`29a78ed8397f583479e9b81a985c1274865c0853`. Live protected `main`: +`7d9151cd2da260e118020c938c7358e2ee75d541`. GitHub's PR base/merge-base snapshot +remains `2e00633e5485905ecc608708eb9f57e2779ea5e2`; comparison with live main +reports 130 commits ahead and five behind. + +PR #100 is open, non-draft, mechanically mergeable, and `BLOCKED`. Its 31 +exact-head check runs comprise 19 successful, eight skipped, three failed, and +one cancelled. Four review threads remain unresolved. The only reviews on +this source observation head are two `COMMENTED` reviews; predecessor approval +does not establish independent current-head acceptance. + +- `account-unification-tests` fails its documentation step because + `## Current live queue snapshot` is absent at the observed source head + (job `99531496280`, one failed and nine passed). Service tests, coverage, + and the distribution build were skipped after that failure. +- `strix` reports provider/backend unavailability (job `99545303857`), not a + completed authoritative vulnerability assessment. +- `noema-review` terminates with a timeout (job `99531483277`). +- `opencode-review` was cancelled (job `99941036943`). + +Ruleset `18156473` requires 1 approving review, dismiss stale reviews on push, +review-thread resolution, and the central required workflows. Last-push and +code-owner approval requirements are false in this observation. Organization +admin bypass must not be used. Failed, cancelled, skipped, queued, or pending +checks are never promoted to successful acceptance. These gates remain a +blocker; a local fix is not a merge license. + +There are 23 open Keyverse PRs. The claims owner remains #100. Adjacent #128 +at `e1cf0807d6b15e8d8300eb252533aa05b20b93c9` is Draft and owns Naruon's +unimplemented replacement authentication contract, stacked on Draft #146 at +`e6da5dd3762b45acf4e0a70b672327f38f4ba04b`. Draft #103 at +`5ac33256229321e9fccbb14a460c7d6de984444a` owns the proposed start-login helper. +None supplies released product-owned login, enrollment, or recovery ceremonies +for this claims repair; #100 adds no issuer or authentication ceremony. + +The owner repair starts from the recorded source observation head in an +isolated worktree. Its later local commit has not been pushed by this audit. +This dated observation does not recursively rename itself to the commit that +records it, and its checks/reviews never transfer to a later head. Before any +normal push or protected integration, recheck the remote head, protected main, +required checks, independent review, and unresolved threads. + +## Historical queue snapshot — 2026-08-23T21:56:27+09:00 + +This section was the current-state observation on August 23 and is retained +as historical evidence only. The protected `main` head observed then was +`ce207dfd42975db61c82a5963e206fc1db14ac2b`. Counts are exact-head GitHub REST +check-runs; pending, queued, skipped, and failed results are never promoted to +green. The current exact-head review audit reports `REVIEW_REQUIRED` for every +open Keyverse PR. Every open PR has zero unresolved review threads. No open PR +has a formal approval that satisfies the protected merge gate. Independent +review absence is a recorded blocker, not a merge license. + +| PR | Exact head | Base | Checks | Safe disposition | +|---:|---|---|---|---| +| [#113](https://github.com/ContextualWisdomLab/keyverse/pull/113) | `9bd33ee0d00ef1874fd5efabac3462f678a256ed` | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | 24 success, 8 skipped | Source-clean; 0 unresolved threads; await independent approval; no merge claim. | +| [#112](https://github.com/ContextualWisdomLab/keyverse/pull/112) | `ec34ac14fd38c9c7c463cddbd0ced04b4dfccafd` | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | 24 success, 8 skipped | Source-clean; 0 unresolved threads; await independent approval. | +| [#103](https://github.com/ContextualWisdomLab/keyverse/pull/103) | `77b8f4ea9995329f1c55b916d110b460b4bc7649` | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | 24 success, 8 skipped | Source-clean; 0 unresolved threads; await independent approval. | +| [#101](https://github.com/ContextualWisdomLab/keyverse/pull/101) | `50dd9c96cab5c230f775685e8baea939fba390dd` | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | 23 success, 8 skipped | Source-clean; 0 unresolved threads; await independent approval. | +| [#100](https://github.com/ContextualWisdomLab/keyverse/pull/100) | `a1a65b26c1ebcd3ce964e56b1f0976e132d33cb9` | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | 22 success, 7 skipped, 2 pending (`strix` in-progress, Devin Review pending) | Observation SHA for this snapshot. Zero unresolved threads. Pending `strix` and Devin Review are unverified. Independent approval is absent. | +| [#83](https://github.com/ContextualWisdomLab/keyverse/pull/83) | `dd1ab7444a75342b42e3af013ccda6d1dbfb359d` | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | 23 success, 8 skipped | Source-clean; 0 unresolved threads; await independent approval. | + +The #100 row records observation SHA +`a1a65b26c1ebcd3ce964e56b1f0976e132d33cb9` (the live GitHub head at inventory +time). The inventory commit SHA that records this table is later than the +observation SHA, is not recursively named, and creates a later #100 head. +Predecessor `655aaad57678e2503ac83a74fa8e19d6efc5f598` Checks are not current. +Pending `strix` and Devin Review remain unverified. No source-fault Check +failure was present on the re-listed Keyverse heads. After this observation +bind, docs-only SHA rebinds stop unless a source-fault Check fails. + +The current central control-plane queue is also not merge-ready: protected +`.github` main is `885f2cd251999f21cf562cab3e2d9cc3cc3ec737`. Docs refresh +[#1252](https://github.com/ContextualWisdomLab/.github/pull/1252) remains +merge-`CLEAN` at `f7b0fd3253747efeef590859df150fd759aaf306` with 26 successful, +18 skipped, and 3 neutral Checks, zero unresolved threads, and zero formal +approvals. Fleet coordination +[#1233](https://github.com/ContextualWisdomLab/.github/pull/1233) remains +`CHANGES_REQUESTED` at `dfb8e261c81705841111dd4ad1712a9fb6c767d1` with 36 +successful, 21 skipped, 3 neutral, and 1 in-progress (`strix`) Check; that +`strix` rerun is unverified provider-infrastructure evidence, not a product +source line. This run does not claim or perform a `.github` merge. + +All queue statements below this section are historical snapshots. They do not +override the exact-head evidence above. + +The organization ruleset `CWL Central required workflows` (`18156473`) is +active and requires two approving reviews, latest-push approval, resolved +threads, and required workflows. Its live `bypass_actors` list is empty. No +Keyverse PR therefore qualifies for an emergency bypass or a protected merge +until the normal review and hosted gates are satisfied. + +Because the Keyverse open-PR queue is non-empty, this run must not open a +competing product PR. The next buyer-visible gap after queue convergence (G0) +is G4 (PR #113 SCIM deactivation lock). + +## Live queue refresh — 2026-08-22T06:15:13+09:00 + +Historical snapshot recorded 2026-08-22. The 2026-08-23 refresh above is +authoritative for current-state decisions. The protected `main` head is +`ce207dfd42975db61c82a5963e206fc1db14ac2b`. Counts are exact-head GitHub REST +check-runs; pending, queued, and failed results are never promoted to green. +The current exact-head review audit reports `REVIEW_REQUIRED` for every open +Keyverse PR. #112 has zero valid unresolved threads after its current review +disposition; no open PR has a formal approval that satisfies the protected +merge gate. + +| PR | Exact head | Base | Checks | Safe disposition | +|---:|---|---|---|---| +| [#113](https://github.com/ContextualWisdomLab/keyverse/pull/113) | `9bd33ee0d00ef1874fd5efabac3462f678a256ed` | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | 22 success, 8 skipped | Await independent approval; no merge claim. | +| [#112](https://github.com/ContextualWisdomLab/keyverse/pull/112) | `ec34ac14fd38c9c7c463cddbd0ced04b4dfccafd` | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | 22 success, 8 skipped | Masked-secret finding fixed and current threads resolved; await independent approval. | +| [#103](https://github.com/ContextualWisdomLab/keyverse/pull/103) | `77b8f4ea9995329f1c55b916d110b460b4bc7649` | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | 22 success, 8 skipped | Await independent approval; no merge claim. | +| [#101](https://github.com/ContextualWisdomLab/keyverse/pull/101) | `50dd9c96cab5c230f775685e8baea939fba390dd` | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | 22 success, 8 skipped | Await independent approval; no merge claim. | +| [#100](https://github.com/ContextualWisdomLab/keyverse/pull/100) | `19ebd86500d17f3aebcfec6e65965c0a79fab6c0` | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | 22 success, 8 skipped | Documentation head is current at observation time; hosted Checks are terminal-success and independent approval is absent. | +| [#83](https://github.com/ContextualWisdomLab/keyverse/pull/83) | `dd1ab7444a75342b42e3af013ccda6d1dbfb359d` | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | 22 success, 8 skipped | Await independent approval; no merge claim. | + +The #100 row records the source head observed immediately before this +documentation snapshot. The documentation-only commit carrying this table +creates a later #100 head, so its predecessor Checks and review evidence are +deliberately not treated as current merge evidence. + +The current central control-plane queue is also not merge-ready: protected +`.github` main is `0156282022134484ea9d7541d5ba0730ba14fd96`. The OSV +cross-fork result-isolation root #1209 is at +`d3a3f4e6211a56d503b783d8784d1d79a262ca68` with 28 successful, 21 skipped, +1 cancelled, 3 neutral, and 2 queued Checks. The hourly OIDC caller repair +#1188 is at `2c05f05f5fbb923099e0e228d616ab9974dbd327` with 28 successful, 18 +skipped, 1 cancelled, 3 neutral, and 1 queued Check. The combined security +and scheduler root #1198 is at +`801c2f1bc43e99d89ab3745ea8722779f7081b95` with 3 successful, 15 skipped, +1 cancelled, 2 in-progress, and 16 queued Checks after its exact merge-preview +repair; replay-guard repair #1166 is at +`e6c03f618d54497b98eaf96afa21724b19847bd2` with 26 successful, 29 skipped, +6 cancelled, 3 neutral, and 1 queued Check. Scheduler repair #1203 was +normally merged into the #1198 feature branch at +`4d3d24aa404959f5067735fec0558d5924ade590` from child head +`c627d4ae7a26222ed3d2ee1ded19e270930aa1f2`; review repair #1002 was then +normally merged into that same feature branch at +`3016543f735bb24db760cfaa768e64f95f408473`; OSV repair #1208 is closed +without merge; #1172 is normally restacked at +`edab578feca63c223368aef17c175bb52ce22e5a` with 26 successful, 20 skipped, +2 cancelled, 3 neutral, and 1 queued Check; and #1026 is at +`1be76989887ab772e3ce0d2e0c7f22d3ca98dd94` with 28 successful, 22 skipped, +2 cancelled, 3 neutral, and 1 queued Check. These are normal source/hosted- +gate or dependency-order problems, not D1–D5 emergency deadlocks. The +central heads can move again through normal scheduler restacks; all listed +evidence is observation-time only. + +All queue statements below this section are historical snapshots. They do not +override the exact-head evidence above. + +The organization ruleset `CWL Central required workflows` (`18156473`) is +active and requires two approving reviews, latest-push approval, resolved +threads, and required workflows. Its live `bypass_actors` list is empty. No +Keyverse PR therefore qualifies for an emergency bypass or a protected merge +until the normal review and hosted gates are satisfied. + +PR #104 is now closed by squash merge at +`44c2adb18687f8df457bd4bafade551533cee5b9` (2026-08-21T16:24:25Z), with the +feature-base parent `31dd486cb97ca215da451151f618a954a07b0ea5`; protected +`main` remains unchanged. Its feature-base merge was outside ruleset +`18156473`, whose live ref condition is only `~DEFAULT_BRANCH`, so the merge +did not establish a default-branch protected approval. This is a governance +gap, not a force push or direct protected-branch push, and is retained here as +audit evidence rather than as normal protected-merge evidence. + +The relevant central control-plane PRs are also not merge-ready: `.github` +#1153 (`ebda81f832261489289447778b0e0e7726f9741e`) has 26 successful, 3 +neutral, 13 skipped, 1 failed, and 1 queued Check; #1203 +(`94c09152a843db1a0d3a3463900ef4d30467f085`) has 27 successful, 3 neutral, 21 +skipped, 2 failed, 2 cancelled, and 1 queued Check; #1198 +(`d2490ad594bd2ab8cccd5ff9e0b6f2a3fa8e23d4`) has 27 successful, 3 neutral, 17 +skipped, and 1 queued Check; #1189 +(`5838e0ae10d5cfbd7d7d6766cb0197fad9ffd641`) has 25 successful, 3 neutral, 17 +skipped, 1 failed, and 1 queued Check; and #1026 +(`71c0cc890bd06a0ff97aa10267cb075b02c62f9e`) has 4 successful, 15 skipped, 1 +cancelled, and 16 queued Checks. None has an exact-head formal approval. OSV #1158 +(`6ea77b1c59265e6f708d71128fa726cf447d427b`) has one exact-head failure: both +base and head scans found vulnerable `pip==26.1.2` (`PYSEC-2026-3721`), which +is owned by the dependency root #1198 and must be revalidated after that root +normally merges. Their local verification does not substitute for protected +hosted evidence. + +Fleet-lifecycle PR #1026 is now normally synchronized at +`71c0cc890bd06a0ff97aa10267cb075b02c62f9e`. Its local RED-to-GREEN repair +fails closed on partial workflow inventories; its hosted Checks remain queued +and the PR remains pending until #1198 and all current hosted gates settle. + +## Current capability map + +Inventories and gap states below are historical, retained from the August 2026 +audit. They are not the September 7 live queue or a new runtime acceptance +claim. Use the bounded snapshot above for the #100 owner repair. + +| Capability | Current state | Evidence boundary | |---|---|---| -| Passwordless local identity | `implemented-main` | Realm validators and tests protect WebAuthn/passwordless policy; live login remains separate evidence. | -| Federation and LDAP preflight | `implemented-main` | Validators are side-effect-free; external bind/discovery and apply remain separate. | -| Account merge and SCIM full replacement | `implemented-main` | Verified identity matching, tombstones, audit, and shared merge/PUT locking are covered on main. | -| SCIM `PATCH active=false` lock parity | `active-PR` | PR #113 is not protected-main evidence until its current head passes all gates and merges. | -| Closed RP mapper profile | `implemented-main` / `accepted-contract` | Canonical `role`, `org`, and `workspace` claims remain closed; consumers must prove their own authorization. | -| Real login and token acceptance | `gap-not-claimed` | No live controlled passwordless browser flow, token exchange, downstream ABAC/RBAC, or revocation acceptance is claimed. | -| Standalone Compose/Helm operation | `implemented-main` / `gap-not-claimed` | Repository validators exist; deployment secret/configuration, rollback, and immutable artifact evidence remain required. | -| Product loop and protected merge | `active-PR` | The scheduler and review path must bind every decision to a current exact head. | -| Release artifact acceptance | `gap-not-claimed` | Version, immutable image digest, SBOM/provenance, rollback, and exact-main regression are still release gates. | - -## Current exact-head PR inventory - -This table was queried from the live GitHub state at the snapshot time. Counts -exclude informational CodeRabbit/Devin contexts and count only CheckRun -success, skipped, or non-terminal results. - -| PR | Scope | Base | Exact head | Checks | Gate / next safe action | -|---:|---|---|---|---|---| -| [#113](https://github.com/ContextualWisdomLab/keyverse/pull/113) | SCIM deactivation shared lock | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | `9bd33ee0d00ef1874fd5efabac3462f678a256ed` | 22 success / 8 skipped | `REVIEW_REQUIRED`; obtain exact-head independent approval. | -| [#112](https://github.com/ContextualWisdomLab/keyverse/pull/112) | Account-unification lockfile and stacked contract updates | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | `44c2adb18687f8df457bd4bafade551533cee5b9` | 14 queued / 7 skipped | `REVIEW_REQUIRED`; six valid unresolved review threads were observed on this head and are being dispositioned; no approval. | -| [#103](https://github.com/ContextualWisdomLab/keyverse/pull/103) | Hierarchical authorization, login helper, and PATs | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | `77b8f4ea9995329f1c55b916d110b460b4bc7649` | 22 success / 8 skipped | `REVIEW_REQUIRED`; retain fail-closed security boundary and obtain current approval. | -| [#101](https://github.com/ContextualWisdomLab/keyverse/pull/101) | Coupled Python dependency updates | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | `50dd9c96cab5c230f775685e8baea939fba390dd` | 22 success / 8 skipped | `REVIEW_REQUIRED`; obtain exact-head approval. | -| [#100](https://github.com/ContextualWisdomLab/keyverse/pull/100) | LineageWeave account-derived RP profile | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | `2fd5a77cf3765f933debd244f457e13241726929` | 14 queued / 7 skipped | `REVIEW_REQUIRED`; downstream issuer/audience/tenant acceptance remains unclaimed. | -| [#83](https://github.com/ContextualWisdomLab/keyverse/pull/83) | Remove runtime application RPs from portable realm | `ce207dfd42975db61c82a5963e206fc1db14ac2b` | `dd1ab7444a75342b42e3af013ccda6d1dbfb359d` | 22 success / 8 skipped | `REVIEW_REQUIRED`; confirm exact-head approval and latest-pusher policy before merge. | - -PR #104 is closed by squash merge at -`44c2adb18687f8df457bd4bafade551533cee5b9`, which advanced the #112 feature -base without changing protected `main`. Its feature-base merge was outside the -default-branch ruleset scope, so it is retained as governance audit evidence, -not as a protected approval or force-merge precedent. - -The central coordination PR [`.github#1203`](https://github.com/ContextualWisdomLab/.github/pull/1203) -is open at exact head `94c09152a843db1a0d3a3463900ef4d30467f085` against -`dd58a88391e44a32fb399f7407f508d8e73cc1c7`; `pip-audit` and `strix` are failed -while the provider outage and shared pip root are remediated. Central #1198 is -at `d2490ad594bd2ab8cccd5ff9e0b6f2a3fa8e23d4` with no failed Checks and its -normal auto-merge armed, but it still lacks required approval. Central #1026 -is at `71c0cc890bd06a0ff97aa10267cb075b02c62f9e` with no failed Checks and -running/queued jobs. None supplies D1–D5 emergency evidence. - -This review update is prepared from exact #112 head `44c2adb`; its successor -will invalidate the table's predecessor Checks and review evidence and must be -re-queried before any merge decision. - -## Open issue inventory - -| Issue | Signal | Classification | Required outcome | -|---:|---|---|---| -| [#114](https://github.com/ContextualWisdomLab/keyverse/issues/114) | MCP-compatible OAuth authorization for headless agents | `active-issue` | Independently review the design, then prove a real resource-bound client flow before runtime implementation. | -| [#102](https://github.com/ContextualWisdomLab/keyverse/issues/102) | Hierarchical authorization plane and PATs | `active-PR` | Prove tenant/resource fail-closed behavior and current-head security review. | -| [#99](https://github.com/ContextualWisdomLab/keyverse/issues/99) | Orphaned federation and product-loop identities | `active-issue` | Preserve the registry recurrence detector and central coordination evidence. | -| [#71](https://github.com/ContextualWisdomLab/keyverse/issues/71) | Remove runtime application RPs from portable import | `active-PR` | Merge #83 only after exact protected evidence. | -| [#2](https://github.com/ContextualWisdomLab/keyverse/issues/2) | Central IdP and external-IdP federation | `accepted-contract` | Complete approved-environment acceptance without weakening preflight boundaries. | - -## Buyer-visible gap order - -### G0 — Protected queue convergence - -The repository must distinguish current, reviewed, passing artifacts from stale -or coupled proposals. The loop is inventory, review disposition, focused fix, -exact-head local and hosted checks, independent approval, protected merge, merge -SHA verification, and re-listing. Never self-approve, force-push, admin-merge, -publish fake status, or reuse predecessor evidence. - -### G1 — Controlled real login and authorization acceptance - -In an approved environment, prove discovery/issuer, JWKS signature and allowed -algorithm, authorization-code + PKCE `S256`, passwordless browser login, token -`iss`/`sub`/`aud`/time claims, logout, tenant/resource ABAC, role/scope RBAC, -cross-tenant denial, and verifier-unavailable fail-closed behavior. An -unavailable issuer stays `unavailable`; it is never replaced with a synthetic +| 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 +`28b69f676e593333bc2ddf6cd492a4027355ea77`; the documentation commit that +updates this snapshot is intentionally not recursively named. The audit also +includes the current central `.github` coordination handoff for issue #99: +PR #1026 is normally updated to exact head +`8d141d51d5b891fda7e8638164f64d5f6fed5ea8` against base +`731af58e954901c4f1cc853231c592abb1eaf617`, with local central verification +complete but hosted Checks and independent approval still pending. The +organization-level `CWL Central required workflows` ruleset remains active, +now targets only `~DEFAULT_BRANCH`, and has no bypass actors; required workflow, +review, deletion, and non-fast-forward protections remain enabled. The live PR +record is authoritative for the exact hash and Checks; predecessor evidence +remains non-transferable. + +At this snapshot, 15 PRs are open and none has a qualifying formal approval. +#112, #101, and #83 each have 23 successful and 8 skipped Checks with no +queued Check; #107 and #108 each have 22 successful, 1 queued, and 8 skipped +Checks; #104 has 22 successful, 1 queued, and 8 skipped Checks; #105, #106, +#109, #110, and #111 each have 21 successful, 1 queued, and 8 skipped Checks; +#115 and #113 each have 2 successful, 14 pending, and 7 skipped Checks; PR #103 +has 14 queued and 7 skipped check runs, with Devin and CodeRabbit status +contexts pending; PR #100 has 14 queued and 6 skipped check runs, with Devin +and CodeRabbit status contexts pending. The exact rollup is +listed per PR below; pending and queued Checks remain unverified rather than +green. +There is no current terminal failure bucket in this inventory. Historical +terminal failures are recorded separately and are not current-head green +evidence. + +### Post-snapshot exact-head delta + +The live queue changed again after the snapshot recorded below. PR #115 is now +normally stacked on #112's exact branch head +`fix/account-unification-lock-20260819@f02acf93367a40dbfb23a73985017dca8d42ff39`. +Its earlier `account-unification-tests` failure was the inherited stale-lock +failure at `main@ce207dfd42975db61c82a5963e206fc1db14ac2b`, not a documentation +test failure; the failed job was rerun after the base transition and remains +unverified while queued. PR #112 remains a normal protected-merge candidate +with all terminal Checks successful, auto-merge armed, and no qualifying +approval. PRs #83 and #101 also have successful exact-head coverage evidence; +fresh current-head reviews were requested because their old OpenCode +`CHANGES_REQUESTED` verdicts were bound to predecessor heads. No approval or +merge is claimed for any of these states. + +The current exact-head audit confirms PR #115 at +`e7ad4524712b18809d8c409371142071270b2ea0`, #113 at +`9bd33ee0d00ef1874fd5efabac3462f678a256ed`, #103 at +`77b8f4ea9995329f1c55b916d110b460b4bc7649`, and #100 at +`28b69f676e593333bc2ddf6cd492a4027355ea77`. #103's current Devin finding +about separate audit-failure compensation writes was fixed with an atomic +upsert/delete store operation and a RED-to-GREEN regression. #100's current +audience-only mapper observation was checked against the generic optional +profile contract and dispositioned without a source change; its dynamic +account-derived exception still requires all three claims. Both PRs remain +without formal approval and hosted gates are pending. This documentation +commit creates a later #100 head, so that resulting head requires a fresh +hosted recheck before any protected-merge claim. + +### Post-fix exact-head delta — PR #100 + +Before this baseline refresh, PR #100 was at exact head +`44f0f7420d4b02d11c8f870bd0415aaa4a486b39` against base +`ce207dfd42975db61c82a5963e206fc1db14ac2b`, pushed through the normal feature +branch path. Its live Check run currently has 14 queued and 7 completed runs; +CodeRabbit is successful, Devin is pending, and no formal approval exists. This +is pending hosted evidence, not a merge claim, and no D1-D5 emergency deadlock +has been established. + +The three current Devin observations were processed against this exact head: + +- The admin-required `org`/`workspace` concern reproduced as an HTTP 400 on a + rebuilt local Keycloak 26.3.2 Admin REST create. The profile now keeps the + attributes scalar and administrator-managed but optional at initial creation; + the no-attribute probe then succeeded and was deleted. Operator assignment + and downstream fail-closed routing remain required. +- The live account-role mapper read-back omitted an empty + `usermodel.clientRoleMapping.rolePrefix`; reconciliation now normalizes only + that exact vendor default and keeps all other missing/changed configuration + fail-closed. +- The local-only `cwl-idp/keycloak:local` bootstrap image observation was + verified as the documented Compose build/dependency contract and required no + source change. + +Local exact-head evidence is 799 tests passed, 100% production statement and +branch coverage, 100% `validate_realm.py` statement and branch coverage, 100% +interrogate coverage, service/test Ruff success, Semgrep 151-rule success, +package build, Compose config, dependency, and diff checks. Hosted Checks and +independent protected review remain required. +This baseline commit creates a later documentation-only #100 head, so hosted +evidence must be refreshed against that later exact head. + +### Live queue refresh — 2026-08-21T10:54:08Z + +Immediately before this documentation refresh, the exact Keyverse queue was +rechecked from the GitHub REST API. Every current open PR had zero terminal +failed check-runs: + +- #100 `40923074b1d7a395aa3d83c854a07fe3060af682`: 14 pending, 0 failed; + CodeRabbit successful, Devin pending, no formal approval. +- #103 `77b8f4ea9995329f1c55b916d110b460b4bc7649`: 14 pending, 0 failed. +- #115 `e7ad4524712b18809d8c409371142071270b2ea0` and #113 + `9bd33ee0d00ef1874fd5efabac3462f678a256ed`: 2 pending each, 0 failed. +- #112, #108, #107, #101, and #83: 22 successful, 0 pending, 0 failed. +- #111, #110, #109, #106, #105, and #104: 21 successful, 1 pending, + 0 failed each. + +The central lifecycle owner PR `.github#1026` is currently at exact head +`84b84aededaf25d88441121fd8c171a94e13eac9`, against +`731af58e954901c4f1cc853231c592abb1eaf617`; its live checks show 4 successes, +14 non-terminal results, and 12 skipped results, with no exact-head approval. +The oldest queued Keyverse workflow observed was created at 09:54 UTC. This +does not satisfy the six-hour/two-observation D2 threshold, and no D1, D3, D4, +or D5 evidence exists. No bypass, direct protected push, force push, fake +status, or self-approval was used. + +This refresh itself is documentation-only and creates a later #100 head; +fresh hosted Checks and independent review must bind to that later SHA. + +| PR | Scope | Exact-head Checks | Review state | Next safe action | +|---:|---|---|---|---| +| [#115](https://github.com/ContextualWisdomLab/keyverse/pull/115) | Proposed ADR/doctoring for MCP-compatible OAuth client authorization | PENDING: 2 successful, 14 pending, 7 skipped on `e7ad4524712b18809d8c409371142071270b2ea0` | review required | The token-audience, metadata-member, scope-array, and missing-reference findings are fixed; obtain independent ADR review and do not treat the design PR as runtime MCP evidence or begin implementation before the trust boundary is accepted. | +| [#113](https://github.com/ContextualWisdomLab/keyverse/pull/113) | SCIM `PATCH active=false` and `DELETE` shared operation lock, stacked on #112 lockfile refresh | PENDING: 2 successful, 14 pending, 7 skipped on `9bd33ee0d00ef1874fd5efabac3462f678a256ed` | review required | The valid SCIM PatchOp, deterministic-race, DELETE-lock, value-object coverage, and documentation fixes are complete; wait for fresh exact-head hosted Checks and independent review. | +| [#112](https://github.com/ContextualWisdomLab/keyverse/pull/112) | Resync account-unification lockfile | PASS: 23 successful, 8 skipped, 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: 21 successful, 1 queued, 8 skipped on `032f730b0239d062cf9803525ba66c740e0b2d2e` | review required | The normal branch update merged #112's lockfile base into the CodeQL branch; resolve the queued Strix retry and remaining coverage Check, then obtain independent review. | +| [#110](https://github.com/ContextualWisdomLab/keyverse/pull/110) | CodeQL analyze 4.37.7, stacked on #112 lockfile refresh | PENDING: 21 successful, 1 queued, 8 skipped on updated head `c3e307fc3d4f6d98ec5a0514f35aa8038b2737b7` | review required | The normal branch update includes #112's lock-refresh base; wait for exact-head Checks and independent review. | +| [#109](https://github.com/ContextualWisdomLab/keyverse/pull/109) | `typing-inspection` update, stacked on #112 lockfile refresh | PENDING: 21 successful, 1 queued, 8 skipped on `7b726b16d38ce16d13d00c946b5c8bc0c406191f` | review required | The normal merge and local locked-install/full-suite verification are complete; wait for fresh hosted Checks and independent review. | +| [#108](https://github.com/ContextualWisdomLab/keyverse/pull/108) | Ruff update, stacked on #112 lockfile refresh | PENDING: 22 successful, 1 queued, 8 skipped on `538cead991a7c1bed32f2dcb5413b5fc56f53e93` | review required | The conflicting lockfile base was rebased cleanly onto #112; wait for remaining exact-head Checks and independent review. | +| [#107](https://github.com/ContextualWisdomLab/keyverse/pull/107) | Uvicorn update, stacked on #112 lockfile refresh | PENDING: 22 successful, 1 queued, 8 skipped on `53842560d397aa20309a6b16aceb560540611686` | review required | The conflicting lockfile base was rebased cleanly onto #112; wait for the remaining exact-head Check and independent review. | +| [#106](https://github.com/ContextualWisdomLab/keyverse/pull/106) | `setup-uv` update, stacked on #112 lockfile refresh | PENDING: 21 successful, 1 queued, 8 skipped on updated head `e7fafd4192cc3cc344b8f8e536bc0495afaa739f` | review required | The normal branch update includes #112's lockfile base; wait for exact-head Checks and independent review. | +| [#105](https://github.com/ContextualWisdomLab/keyverse/pull/105) | `harden-runner` update, stacked on #112 lockfile refresh | PENDING: 21 successful, 1 queued, 8 skipped on updated head `77f83dfb2c4611345c0d48f92fceaa6195b4630c` | changes requested | The normal branch update includes #112's lockfile base; obtain current-head independent review. | +| [#104](https://github.com/ContextualWisdomLab/keyverse/pull/104) | ADR and buyer README expansion, stacked on #112 | PENDING: 22 successful, 1 queued, 8 skipped on `7da9d43087d5647fefb946eb154ee1e5c10c576d` | review required | Base remains `fix/account-unification-lock-20260819`; obtain independent review and terminal stacked-head Checks. | +| [#103](https://github.com/ContextualWisdomLab/keyverse/pull/103) | Hierarchical authorization, login helper, PATs | PENDING: 14 queued, 7 skipped; Devin and CodeRabbit pending on `77b8f4ea9995329f1c55b916d110b460b4bc7649` | changes requested | Menu inheritance metadata, tenant-scoped grant GET/DELETE, and atomic forward/compensation token rotation storage are fixed with RED-to-GREEN tests; wait for fresh hosted security Checks and current-head independent review before any merge claim. | +| [#101](https://github.com/ContextualWisdomLab/keyverse/pull/101) | Atomic coupled Python dependency updates | PASS: 23 successful, 8 skipped, 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, 6 skipped; Devin and CodeRabbit pending on observed head `28b69f676e593333bc2ddf6cd492a4027355ea77` | changes requested | The current audience-only observation was dispositioned against the generic optional profile; the reserved dynamic account-derived profile still requires all three claims, and the prior static-claim mismatch is fixed by RED-to-GREEN coverage. This docs refresh creates a later head, then fresh exact-head Checks and independent review are required without self-approval. | +| [#83](https://github.com/ContextualWisdomLab/keyverse/pull/83) | Remove runtime application RPs from portable realm | PASS: 23 successful, 8 skipped, 0 queued on `dd1ab7444a75342b42e3af013ccda6d1dbfb359d` | changes requested | Reconfirm current-head approval and latest-pusher rule before protected merge. | + +### Live queue refresh — 2026-08-21T11:22:53Z + +This later audit supersedes the older snapshot rows above for current-state +decisions. PR #115 normally squash-merged into its stacked base at +`2026-08-21T11:15:50Z`, producing merge commit +`ec91eb3af436ac7fa5e682d31d78cd5b01782d01` from source head +`e7ad4524712b18809d8c409371142071270b2ea0`. It was not a forced merge or a +protected-main merge. The detached merge-result verification passed the root +documentation contract (5 tests), the account-unification suite (742 tests), +100% statement/branch coverage (2734/738), interrogate 100%, Ruff, and +`git diff --check`. Hosted delayed runs remain queued and are not reported as success. -### G2 — Downstream tenant semantics - -For `lineageweave-web`, `org` is one opaque external tenant key and `workspace` -is one child namespace. Ambiguous or missing membership denies before ABAC/RBAC; -membership changes require a new token or session. Generic tenant claims must -not be added to the closed mapper profile. - -### G3 — SCIM concurrency and database evidence - -After #113, prove real concurrent PATCH/merge behavior on protected main. For -production storage, add PostgreSQL migration/rollback, tenant-qualified -constraints, concentrated-tenant skew measurements, partition/index decisions, -backup/restore, and recovery evidence. Local SQLite tests are not that proof. - -### G4 — MCP resource authorization - -The design-only ADR requires Keycloak authorization code + PKCE, exact redirects, -RFC 8707 resource binding, RFC 9728 protected-resource metadata, RFC 9207 -callback issuer comparison, RFC 9068 JWT validation, revocation, and negative -evidence. Runtime MCP acceptance remains `gap-not-claimed`. - -### G5 — Release and module acceptance - -On exact protected main, complete regression and controlled deployment -acceptance, publish immutable image digest plus SBOM/provenance, and prove -rollback. A green feature PR is not a release. - -## Loop and design boundary +The normal stacked merge advanced the #112 root branch to exact successor head +`ec91eb3af436ac7fa5e682d31d78cd5b01782d01` against protected main +`ce207dfd42975db61c82a5963e206fc1db14ac2b`. Its predecessor `f02acf9` Checks +and review evidence are invalid. The successor currently has 14 queued and 7 +skipped Checks, no terminal failure, auto-merge armed, and zero exact-head +formal approvals. Fresh independent review and hosted verification were +requested for the combined lockfile plus #115 documentation diff. + +At this refresh, #100 and #103 each have 14 queued and 7 skipped Checks with +no terminal failure; #113 has 18 successful, 1 in-progress, 1 queued, and 7 +skipped; #101 and #83 each have 22 successful and 8 skipped Checks; and +#107/#108 each have 22 successful and 8 skipped Checks on their downstream +stack base. All inspected merge candidates have zero exact-head formal +approvals. Central `.github#1026` has 4 successful, 16 queued, and 13 skipped +Checks with no exact-head approval. Queued or in-progress results remain +unverified hosted evidence. No D1-D5 emergency deadlock classification is +made; no bypass, direct protected push, force push, fake status, or +self-approval occurred. + +### Live queue refresh — 2026-08-21T11:58:44Z + +This refresh is the current read-only GitHub inventory for Keyverse and +supersedes earlier queue rows for merge decisions. The protected-main base is +`ce207dfd42975db61c82a5963e206fc1db14ac2b`; the local feature worktree used for +this documentation update was clean and remote branch +`codex/per-account-rp-claims` was at `a92fdcb038e26788eea17d8b77dbae60982b3e04` +before this commit. Fourteen Keyverse PRs are open. The numeric Check columns +count only terminal `success`/`skipped` and non-terminal status results; +blank-provider contexts such as CodeRabbit or Devin are not promoted to +success. No current open PR had a terminal failed Check in this observation. + +| PR | Base | Exact head | Checks (`success / skipped / pending`) | Live gate | +|---:|---|---|---:|---| +| [#113](https://github.com/ContextualWisdomLab/keyverse/pull/113) | `main` | `9bd33ee0d00ef1874fd5efabac3462f678a256ed` | `19 / 8 / 1` | protected, review required; `coverage-source-tree` pending | +| [#112](https://github.com/ContextualWisdomLab/keyverse/pull/112) | `main` | `d2f48232fff1505c3274fe4296ca21cece5db102` | `0 / 7 / 14` | protected, review required; auto-merge armed | +| [#111](https://github.com/ContextualWisdomLab/keyverse/pull/111) | `fix/account-unification-lock-20260819` | `032f730b0239d062cf9803525ba66c740e0b2d2e` | `22 / 8 / 0` | downstream stack; no qualifying formal approval | +| [#110](https://github.com/ContextualWisdomLab/keyverse/pull/110) | `fix/account-unification-lock-20260819` | `c3e307fc3d4f6d98ec5a0514f35aa8038b2737b7` | `21 / 8 / 1` | downstream stack; one pending Check | +| [#109](https://github.com/ContextualWisdomLab/keyverse/pull/109) | `fix/account-unification-lock-20260819` | `7b726b16d38ce16d13d00c946b5c8bc0c406191f` | `21 / 8 / 1` | downstream stack; one pending Check | +| [#108](https://github.com/ContextualWisdomLab/keyverse/pull/108) | `fix/account-unification-lock-20260819` | `538cead991a7c1bed32f2dcb5413b5fc56f53e93` | `22 / 8 / 0` | downstream stack; no qualifying formal approval | +| [#107](https://github.com/ContextualWisdomLab/keyverse/pull/107) | `fix/account-unification-lock-20260819` | `53842560d397aa20309a6b16aceb560540611686` | `22 / 8 / 0` | downstream stack; no qualifying formal approval | +| [#106](https://github.com/ContextualWisdomLab/keyverse/pull/106) | `fix/account-unification-lock-20260819` | `e7fafd4192cc3cc344b8f8e536bc0495afaa739f` | `21 / 8 / 1` | downstream stack; one pending Check | +| [#105](https://github.com/ContextualWisdomLab/keyverse/pull/105) | `fix/account-unification-lock-20260819` | `77f83dfb2c4611345c0d48f92fceaa6195b4630c` | `21 / 8 / 1` | downstream stack; current review must be re-established | +| [#104](https://github.com/ContextualWisdomLab/keyverse/pull/104) | `fix/account-unification-lock-20260819` | `7da9d43087d5647fefb946eb154ee1e5c10c576d` | `21 / 8 / 1` | downstream stack; `DIRTY`, no merge | +| [#103](https://github.com/ContextualWisdomLab/keyverse/pull/103) | `main` | `77b8f4ea9995329f1c55b916d110b460b4bc7649` | `0 / 7 / 14` | protected, review required; auto-merge armed | +| [#101](https://github.com/ContextualWisdomLab/keyverse/pull/101) | `main` | `50dd9c96cab5c230f775685e8baea939fba390dd` | `22 / 8 / 0` | protected, review required; auto-merge armed | +| [#100](https://github.com/ContextualWisdomLab/keyverse/pull/100) | `main` | `a92fdcb038e26788eea17d8b77dbae60982b3e04` | `0 / 7 / 14` | protected, review required; auto-merge armed | +| [#83](https://github.com/ContextualWisdomLab/keyverse/pull/83) | `main` | `dd1ab7444a75342b42e3af013ccda6d1dbfb359d` | `22 / 8 / 0` | protected, review required; auto-merge armed | + +The central lifecycle owner [`.github#1026`](https://github.com/ContextualWisdomLab/.github/pull/1026) +was independently observed at exact head +`84b84aededaf25d88441121fd8c171a94e13eac9`, base +`731af58e954901c4f1cc853231c592abb1eaf617`, with `4 / 13 / 16` +`success / skipped / pending`, zero failed Checks, and no formal approval. +The queue contains no D1, D3, D4, or D5 evidence; the newly queued runs are +also below the D2 six-hour and two-observation threshold. Stale +infrastructure/predecessor `CHANGES_REQUESTED` reviews were dismissed with +audit reasons, not converted into approvals. No bypass, direct protected +push, force push, fake status, or self-approval was used. + +### 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 #113's predecessor exact head + `49136c24fb07e3a8ed01171785e6946c559ea2a5` contained the valid SCIM + PatchOp review correction and passed focused/full local verification with + 100% statement and branch coverage. The current exact head + `4a501a6ff9cb65a1e894e05513462fe89733d48e` adds only the doctoring + whitespace cleanup; its hosted Checks remain pending with no terminal + failure, and independent approval is absent. + +- 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. + +- PR #111's first Strix attempt on the unchanged CodeQL-only diff failed after + the Nemotron provider emitted one critical Compose configuration finding. + The neighboring #110 check completed through its neutral backend fallback + and #112 passed; the report contradicted the checked configuration by describing an unset + password variable as a default credential. The failure is retained as + historical model evidence rather than silently converted to green; a normal + exact-head Strix retry is queued as run attempt 2. + +- 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 `77b8f4ea9995329f1c55b916d110b460b4bc7649` + 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, untrusted public issuers, exact-org menu + inheritance metadata, tenant-scoped grant administration, and atomic token + rotation forward and audit-compensation storage. Local + focused/full tests, Ruff, Interrogate, package/build, deployment, realm, + Compose, Semgrep, 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 | +|---:|---|---|---| +| [#114](https://github.com/ContextualWisdomLab/keyverse/issues/114) | MCP-compatible OAuth client authorization for headless agents | `active-issue` / design merged in stacked PR #115 | PR #115's ADR-0013 and doctoring design merged normally into the #112 stack at `ec91eb3`; runtime implementation and real browser/client evidence remain unclaimed. Evaluate RFC 8628 only for clients that cannot use a callback. | +| [#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 | `active-issue` | The exact protected-main cleanup completed: 43 registry identities remain, with 4 supported repository paths active, 37 orphan repository-path identities `disabled_manually`, and 2 GitHub-owned dynamic identities unchanged. The recurrence detector, adversarial tests, and central coordination acceptance remain open. | +| [#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. | -The hourly PR steward may advance only trusted same-repository PRs with exact -head, independent approval, and required Checks. The hourly product loop may -create at most one bounded draft product-gap PR only after the open queue is -empty and protected-main evidence is healthy. GitHub review/check waiting is not -a reason to stop independent review, documentation, or test design, but queued -results are never promoted to success. +## Gap register and buyer-visible order -This repository has no current frontend change in this baseline. Therefore no -Figma file or Storybook inventory is claimed. If a future buyer gap changes a -web surface, its ADR must record the Figma File ID, design tokens, reusable -components, Storybook scene/edge events, and accessibility/interaction/ -performance/responsive/form/navigation/chart acceptance before implementation -is claimed. +### G0 — Protected queue convergence -## APA 7th references +**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 lockfile updates + +**State:** `active-PR` +**Root cause:** the current queue contains a lockfile consistency fix (#112) +and its policy companion for atomic coupled Python dependency updates (#101). +The earlier CodeQL action pair (#110/#111) is closed and is not a current +open-PR blocker. + +**Acceptance:** #112 passes current hosted Checks and receives independent +approval; #101 is then re-evaluated on the current base; `uv sync --locked` +succeeds; 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. + +### G8 — MCP-compatible OAuth resource authorization + +**State:** `accepted-contract` design; runtime remains `gap-not-claimed` +**Buyer impact:** An MCP client currently has no protected-main evidence for a +passwordless authorization-code path that is bound to one LineageWeave resource +and centrally revocable. + +**Current action:** PR #115 squash-merged ADR-0013 and its doctoring record into +the #112 stack and is no longer an open Keyverse PR. The design keeps Keycloak +as the authorization server, assigns RFC 9728 protected-resource metadata to +LineageWeave, requires exact public-client redirects and `S256` PKCE, binds one +canonical RFC 8707 resource URI to the token audience and least-privilege scope +set, requires RFC 9207 callback issuer comparison and RFC 9068 JWT access-token +validation, and defers RFC 8628 until a real callback-less client requires it. +The merged design is documentation-only; runtime MCP authorization remains +unclaimed. + +**Acceptance:** after ADR review, run a real browser/client flow and record +discovery agreement, exact redirect/PKCE/resource/scope checks, wrong +issuer/audience/resource/redirect/expiry/revocation denials, cross-tenant and +cross-workspace denials, and no static MCP API-key path. Keep discovery +side-effect-free in existing preflight tests and avoid bearer material in logs. + +### 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 current maintenance paths are: + +- **Central required PR scheduler:** `pr-review-merge-scheduler.yml` handles PR + events under independent approval and required Checks. PR #140 removed the + redundant local hourly steward; there is no local minute-17 schedule. +- **Hourly product development:** UTC minute `41`; create at most one bounded + draft product-gap PR only when the open + PR queue is empty and protected-main evidence is healthy. +- **Grok hourly product loop:** interval `1h`, inventory, steward, and baseline + refresh even while Keyverse PRs remain open. It must not open a competing + product PR while the queue is non-empty, must not self-approve or bypass + protected gates, and must not use `COPILOT_GITHUB_TOKEN`. Existing GitHub + hourly workflows and review-agent credentials stay separate. + +The older run inventory below the historical snapshot headings records the +retired PR steward. It does not describe the current scheduler. On 2026-09-07, +ruleset `18156473` still required the central PR scheduler; queued or delayed +runs permit independent work but do not establish 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, OAuth Security BCP's authorization-code + PKCE and exact redirect +guidance, RFC 9068 JWT access-token validation, and RFC 9207 authorization- +response issuer comparison. 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 -- Internet Engineering Task Force. (2020). *JSON Web Token best current practices* (RFC 8725). https://www.rfc-editor.org/rfc/rfc8725.html -- Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current practice for OAuth 2.0 security* (RFC 9700). https://www.rfc-editor.org/rfc/rfc9700.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 - Internet Engineering Task Force. (2018). *OAuth 2.0 authorization server metadata* (RFC 8414). https://doi.org/10.17487/RFC8414 -- Internet Engineering Task Force. (2020). *Resource indicators for OAuth 2.0* (RFC 8707). https://doi.org/10.17487/RFC8707 +- Internet Engineering Task Force. (2024). *Resource indicators for OAuth 2.0* (RFC 8707). https://doi.org/10.17487/RFC8707 - Bertocci, V. (2021). *JSON Web Token (JWT) profile for OAuth 2.0 access tokens* (RFC 9068). https://doi.org/10.17487/RFC9068 - Meyer zu Selhausen, K., & Fett, D. (2022). *OAuth 2.0 authorization server issuer identification* (RFC 9207). https://doi.org/10.17487/RFC9207 - Internet Engineering Task Force. (2025). *OAuth 2.0 protected resource metadata* (RFC 9728). https://doi.org/10.17487/RFC9728 -- Model Context Protocol. (2026, July 28). *Authorization*. https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization +- Model Context Protocol. (2025, November 25). *Authorization*. https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization -Interpretations and evidence boundaries are maintained in +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..42ad2e1 --- /dev/null +++ b/docs/superpowers/specs/2026-08-13-keyverse-lineageweave-account-claim-profile.md @@ -0,0 +1,47 @@ +# 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. Passwordless registration may create the identity-only account without + `org` or `workspace`; that incomplete account is not eligible for routing. +2. An identity operator verifies the actual Keyverse account and assigns its + `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..d52abb8 100644 --- a/scripts/validate_realm.py +++ b/scripts/validate_realm.py @@ -12,8 +12,11 @@ * 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] +Usage: python scripts/validate_realm.py [path-to-realm.json] [path-to-user-profile.json] +The profile path defaults to a sibling ``lineageweave-user-profile.json``; +validation fails closed when that artifact is absent or invalid. Exit 0 = valid, 1 = invalid (prints the failing checks). """ from __future__ import annotations @@ -31,6 +34,9 @@ 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"} +EXPECTED_USER_PROFILE_ATTRIBUTES = BUILTIN_USER_PROFILE_ATTRIBUTES | {"org", "workspace"} def _executions(realm: dict, alias: str) -> list[dict]: @@ -220,6 +226,77 @@ def validate(realm: dict) -> list[str]: return errors +def validate_user_profile(profile: object) -> list[str]: + """Return violations for the exact administrator-managed profile contract.""" + errors: list[str] = [] + profile_is_object = isinstance(profile, dict) + errors.extend(["user profile must be a JSON object"] * int(not profile_is_object)) + profile_object = {True: profile, False: {}}[profile_is_object] + + # 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_object.get("unmanagedAttributePolicy") is not None: + errors.append( + "user profile must omit unmanagedAttributePolicy so Keycloak 26 " + "disables unmanaged attributes" + ) + + raw_attributes = profile_object.get("attributes", []) + attributes_are_array = isinstance(raw_attributes, list) + errors.extend( + ["user profile attributes must be an array"] * int(not attributes_are_array) + ) + attribute_items = {True: raw_attributes, False: []}[attributes_are_array] + normalized_attributes: list[dict] = [] + for item in attribute_items: + item_is_object = isinstance(item, dict) + errors.extend( + ["user profile attribute entries must be JSON objects"] + * int(not item_is_object) + ) + normalized_attributes.append({True: item, False: {}}[item_is_object]) + + attribute_names = [str(item.get("name")) for item in normalized_attributes] + attribute_name_set = set(attribute_names) + errors.extend( + ["user profile attributes must match the reviewed attribute-name set"] + * int(attribute_name_set != EXPECTED_USER_PROFILE_ATTRIBUTES) + ) + errors.extend( + ["user profile attribute names must not be duplicated"] + * int(len(attribute_names) != len(attribute_name_set)) + ) + attributes = { + str(item.get("name")): item + for item in normalized_attributes + } + 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 "required" in attribute: + errors.append( + f"user profile '{name}' must remain optional during account creation" + ) + 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 +313,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 +332,19 @@ 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: + if errors: + print(f"INVALID: {path}", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + 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..3a17b01 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,17 +500,90 @@ 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.""" + """Require the reserved account profile; keep other clients' mappers optional.""" mappers = registration.protocol_mappers - if not mappers: + if not mappers and registration.client_id != "lineageweave-web": return if len(mappers) > _MAX_MAPPER_COUNT: _client_error("protocolMappers", "must contain at most 4 entries") 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,48 @@ 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 ( + registration.client_id == "lineageweave-web" + and account_claim_names != _ACCOUNT_CLAIMS + ): + _client_error( + "protocolMappers", + "lineageweave-web must use 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 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..93b1c0a 100644 --- a/services/account_unification/app/relying_party_state.py +++ b/services/account_unification/app/relying_party_state.py @@ -21,6 +21,7 @@ from .kv_store import KvStore from .relying_party import ( RelyingPartyRegistration, + _CLIENT_ID, _parse_registration, validate_relying_party_registration, ) @@ -390,38 +391,11 @@ def parse_relying_party_registration(payload: Any) -> RelyingPartyRegistration: def _validate_client_id(client_id: str) -> None: """Require the same bounded lowercase slug accepted by preflight.""" - try: - registration = _parse_registration( - { - "clientId": client_id, - "name": client_id, - "enabled": True, - "protocol": "openid-connect", - "publicClient": True, - "clientAuthenticatorType": "none", - "standardFlowEnabled": True, - "implicitFlowEnabled": False, - "directAccessGrantsEnabled": False, - "serviceAccountsEnabled": False, - "redirectUris": ["https://path-validation.invalid/callback"], - "webOrigins": ["https://path-validation.invalid"], - "attributes": { - "pkce.code.challenge.method": "S256", - "post.logout.redirect.uris": "https://path-validation.invalid/logout", - "access.token.lifespan": "300", - "backchannel.logout.session.required": "true", - "require.pushed.authorization.requests": "false", - }, - "fullScopeAllowed": False, - "defaultClientScopes": ["basic", "profile", "email"], - } - ) - validate_relying_party_registration(registration) - except HTTPException: + if not isinstance(client_id, str) or _CLIENT_ID.fullmatch(client_id) is None: raise HTTPException( status_code=400, detail="client_id must be a lowercase ASCII slug", - ) from None + ) def _desired_digest(registration: RelyingPartyRegistration) -> str: @@ -464,9 +438,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") @@ -509,6 +489,12 @@ def _normalized_observed_mappers( rank = _observed_mapper_rank(mapper) if rank is None or rank in seen_ranks: return None + if ( + mapper["protocolMapper"] == "oidc-usermodel-client-role-mapper" + and mapper["config"].get("claim.name") == "role" + and "usermodel.clientRoleMapping.rolePrefix" not in mapper["config"] + ): + mapper["config"]["usermodel.clientRoleMapping.rolePrefix"] = "" seen_ranks.add(rank) ranked_mappers.append((rank, mapper)) 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_pr_steward.py b/services/account_unification/tests/test_hourly_pr_steward.py deleted file mode 100644 index 910133e..0000000 --- a/services/account_unification/tests/test_hourly_pr_steward.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Static contract tests for the hourly protected PR steward.""" -from __future__ import annotations - -from pathlib import Path - - -def _workflow_source() -> str: - """Return the repository's hourly PR stewardship workflow source.""" - repository_root = Path(__file__).resolve().parents[3] - return ( - repository_root / ".github" / "workflows" / "hourly-pr-steward.yml" - ).read_text(encoding="utf-8") - - -def _permissions_block(source: str, marker: str, terminator: str) -> str: - """Return one indentation-sensitive workflow permissions block.""" - block_start = source.index(marker) - block_end = source.index(terminator, block_start) - return source[block_start:block_end] - - -def test_hourly_steward_runs_once_per_hour_with_bounded_concurrency() -> None: - """The schedule is hourly and overlapping steward runs are serialized.""" - workflow = _workflow_source() - assert 'cron: "17 * * * *"' in workflow - assert "group: hourly-pr-steward" in workflow - assert "cancel-in-progress: false" in workflow - assert "timeout-minutes: 10" in workflow - - -def test_hourly_steward_uses_read_only_workflow_token_defaults() -> None: - """Only the steward job receives its narrowly required write scopes.""" - workflow = _workflow_source() - top_level_permissions = _permissions_block( - workflow, - "permissions:\n", - "\nconcurrency:", - ) - job_permissions = _permissions_block( - workflow, - " permissions:\n", - " steps:", - ) - - assert "contents: read" in top_level_permissions - assert "write" not in top_level_permissions - assert "contents: write" in job_permissions - assert "pull-requests: write" in job_permissions - assert "checks: read" in job_permissions - assert "security-events: write" not in workflow - assert "actions: write" not in workflow - - -def test_hourly_steward_is_fail_closed_on_trust_review_and_checks() -> None: - """Untrusted, unapproved, pending, or failed pull requests remain untouched.""" - workflow = _workflow_source() - assert 'head_owner" != "ContextualWisdomLab"' in workflow - assert 'trusted_author" != "true"' in workflow - assert 'review_decision" != "APPROVED"' in workflow - assert 'gh pr checks "$number" --repo "$REPOSITORY" --required' in workflow - assert "--admin" not in workflow - - -def test_hourly_steward_invalidates_old_evidence_after_branch_update() -> None: - """A branch update exits the current iteration before merging stale evidence.""" - workflow = _workflow_source() - update_position = workflow.index("gh pr update-branch") - continue_position = workflow.index("continue", update_position) - approval_position = workflow.index('review_decision" != "APPROVED"') - assert update_position < continue_position < approval_position - - -def test_hourly_steward_binds_auto_merge_to_the_checked_head() -> None: - """GitHub auto-merge is armed only for the enumerated exact head SHA.""" - workflow = _workflow_source() - assert '--auto \\' in workflow - assert '--squash \\' in workflow - assert '--match-head-commit "$head_sha"' in workflow diff --git a/services/account_unification/tests/test_hourly_product_development.py b/services/account_unification/tests/test_hourly_product_development.py index eb50768..7f9e65d 100644 --- a/services/account_unification/tests/test_hourly_product_development.py +++ b/services/account_unification/tests/test_hourly_product_development.py @@ -240,6 +240,7 @@ def test_product_prompt_preserves_commercial_and_engineering_invariants() -> Non required_prompt_fragments = ( "Select exactly one highest-impact buyer-visible product gap", + "Read docs/product-technical-gap-baseline.md and the current exact-head open PR/issue inventory", "Superpowers design, test-driven development, systematic debugging", "realistic identity-control-plane", "100% production docstring coverage", 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_product_gap_baseline.py b/services/account_unification/tests/test_product_gap_baseline.py new file mode 100644 index 0000000..ba0c780 --- /dev/null +++ b/services/account_unification/tests/test_product_gap_baseline.py @@ -0,0 +1,154 @@ +"""CI-executed contracts for the live product and technical gap baseline.""" + +from __future__ import annotations + +import re +from pathlib import Path + + +_HISTORICAL_OPEN_ISSUES = (114, 102, 99, 71, 2) +_CURRENT_QUEUE_MARKER = "## Current live queue snapshot" + + +def _repository_root() -> Path: + """Return the Keyverse repository root from this test module.""" + + return Path(__file__).resolve().parents[3] + + +def _read(relative_path: str) -> str: + """Read one repository document using the canonical UTF-8 encoding.""" + + return (_repository_root() / relative_path).read_text(encoding="utf-8") + + +def _current_refresh(baseline: str) -> str: + """Return only the latest recorded snapshot, excluding historical sections.""" + + start = baseline.find(_CURRENT_QUEUE_MARKER) + if start < 0: + raise AssertionError("missing explicit current live queue snapshot") + rest = baseline[start:] + return rest.split("\n## ", 1)[0] + + +def _gap_section(baseline: str, heading: str, terminator: str) -> str: + """Return one gap-register section bounded by the next heading.""" + + start = baseline.find(heading) + if start < 0: + raise AssertionError(f"missing gap heading: {heading}") + end = baseline.find(terminator, start + len(heading)) + if end < 0: + return baseline[start:] + return baseline[start:end] + + +def test_gap_baseline_keeps_required_product_and_loop_headings() -> None: + """CI must see the buyer-facing baseline sections on every account-unification run.""" + + baseline = _read("docs/product-technical-gap-baseline.md") + for heading in ( + "## Product contract", + "## Evidence classification", + "## Live PR inventory", + "## Open Issue inventory", + "## Gap register and buyer-visible order", + "## Hourly loop contract", + ): + assert heading in baseline, f"missing baseline heading: {heading}" + for classification in ( + "implemented-main", + "active-PR", + "active-issue", + "accepted-contract", + "gap-not-claimed", + ): + assert f"`{classification}`" in baseline + + +def test_recorded_queue_snapshot_does_not_promote_pending_or_stale_checks() -> None: + """A dated observation cannot claim that a later local head passed hosted checks.""" + + baseline = _read("docs/product-technical-gap-baseline.md") + refresh = " ".join(_current_refresh(baseline).split()) + lowered = refresh.lower() + + assert "[#100](" in refresh + assert "source observation head" in lowered + assert "observed at" in lowered + assert "historical" in lowered + assert "later head" in lowered + assert len(re.findall(r"`[0-9a-f]{40}`", refresh)) >= 2 + assert "1 approving review" in lowered + assert "dismiss stale reviews" in lowered + assert "review-thread resolution" in lowered + assert "organization admin bypass" in lowered + assert "must not be used" in lowered + assert "queued" in lowered + assert "pending" in lowered + assert "skipped" in lowered + assert "never promoted" in lowered + assert "independent" in lowered + assert "blocker" in lowered + assert "not a merge license" in lowered + assert "observation" in lowered + assert "does not recursively rename" in lowered + + +def test_historical_issue_inventory_and_gap_order_remain_traceable() -> None: + """Retain the earlier inventory without presenting it as a new live audit.""" + + baseline = _read("docs/product-technical-gap-baseline.md") + assert "Inventories and gap states below are historical" in baseline + issues = _gap_section( + baseline, + "## Open Issue inventory", + "## Gap register and buyer-visible order", + ) + for number in _HISTORICAL_OPEN_ISSUES: + assert f"[#{number}](" in issues, f"missing open issue #{number}" + gap_g1 = " ".join(_gap_section(baseline, "### G1 —", "### G2 —").split()) + assert "#112" in gap_g1 + assert "#101" in gap_g1 + assert "closed" in gap_g1.lower() + assert "not a current open-PR" in gap_g1 + gap_g8 = " ".join(_gap_section(baseline, "### G8 —", "### G4 —").split()) + assert ( + "not an open Keyverse PR" in gap_g8 + or "no longer an open Keyverse PR" in gap_g8 + ) + gap_g0 = _gap_section(baseline, "### G0 —", "### G1 —") + assert "`active-PR`" in gap_g0 + gap_g4 = _gap_section(baseline, "### G4 —", "### G5 —") + assert "#113" in gap_g4 + assert "`active-PR`" in gap_g4 + + +def test_traceability_and_documentation_map_link_the_gap_baseline() -> None: + """Operators must reach the baseline from the map and the evidence matrix.""" + + documentation = _read("DOCUMENTATION.md") + assert "](docs/product-technical-gap-baseline.md)" in documentation + traceability = _read("docs/TRACEABILITY.md") + assert "](product-technical-gap-baseline.md)" in traceability + assert "](doctoring/product-technical-gap-baseline.md)" in traceability + + +def test_hourly_loop_keeps_github_workflows_separate_from_copilot_token() -> None: + """The documented loop must not reuse Copilot review credentials.""" + + baseline = _read("docs/product-technical-gap-baseline.md") + loop = _gap_section( + baseline, + "## Hourly loop contract", + "## Standards interpretation and design tooling boundary", + ) + assert "Central required PR scheduler" in loop + assert "pr-review-merge-scheduler.yml" in loop + assert "Hourly product development" in loop + assert "COPILOT_GITHUB_TOKEN" in loop + assert "must not use `COPILOT_GITHUB_TOKEN`" in loop + product = _read(".github/workflows/hourly-product-development.yml") + assert "COPILOT_GITHUB_TOKEN" not in product + assert 'cron: "41 * * * *"' in product diff --git a/services/account_unification/tests/test_realm_policy.py b/services/account_unification/tests/test_realm_policy.py index 83d03b2..fac55f5 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,121 @@ 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) + + +def test_product_account_attributes_allow_unassigned_registration() -> None: + """Registration may create an account before operator ABAC assignment.""" + validator = _validator_module() + profile = _user_profile() + for attribute in profile["attributes"]: + if attribute["name"] in {"org", "workspace"}: + attribute.pop("required", None) + + assert validator.validate_user_profile(profile) == [] + + +@pytest.mark.parametrize("attribute_name", ("org", "workspace")) +@pytest.mark.parametrize( + "required", + ({}, {"roles": ["user"]}, {"roles": ["admin", "user"]}), +) +def test_product_account_attributes_cannot_be_required_at_creation( + attribute_name: str, required: dict[str, object] +) -> None: + """Product ABAC attributes cannot become user-required at creation time.""" + 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 remain optional during account creation" 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 remain optional during account creation" 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..323f0a1 100644 --- a/services/account_unification/tests/test_relying_party_claim_mappers.py +++ b/services/account_unification/tests/test_relying_party_claim_mappers.py @@ -7,17 +7,23 @@ from fastapi import HTTPException from fastapi.testclient import TestClient +from app.kv_store import InMemoryKvStore from app.main import create_app from app.relying_party import ( RelyingPartyRegistration, _parse_registration, validate_relying_party_registration, ) +from app.relying_party_state import ( + RELYING_PARTY_NAMESPACE, + RelyingPartyService, + _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 +31,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 +58,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 +209,249 @@ 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(api) -> None: + """The complete account profile passes preflight and desired-state persistence.""" + 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:]) + + store = InMemoryKvStore() + service = RelyingPartyService(store, api) + status = service.put_registration("lineageweave-web", result.registration) + assert status.convergence_state.value == "in_sync" + assert store.get(RELYING_PARTY_NAMESPACE, "lineageweave-web") is not None + + +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 + + +def test_account_role_observation_allows_keycloak_omitted_empty_prefix() -> None: + """Keycloak may omit the account-role mapper's empty default prefix.""" + payload = _lineageweave_registration_with_account_claims() + desired_mappers = payload["protocolMappers"] + assert isinstance(desired_mappers, list) + observed_mappers = deepcopy(desired_mappers) + role_mapper = observed_mappers[1] + assert isinstance(role_mapper, dict) + config = role_mapper["config"] + assert isinstance(config, dict) + config.pop("usermodel.clientRoleMapping.rolePrefix") + + 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) + + +@pytest.mark.parametrize( + "mapper_count", (None, 0, 1, 3), ids=("omitted", "empty", "audience-only", "partial") +) +def test_account_claim_profile_requires_every_dynamic_claim(mapper_count, api) -> None: + """Incomplete reserved-client profiles fail before readiness or persistence.""" + payload = _lineageweave_registration_with_account_claims() + mappers = payload["protocolMappers"] + assert isinstance(mappers, list) + if mapper_count is None: + payload.pop("protocolMappers") + else: + payload["protocolMappers"] = mappers[:mapper_count] + + _assert_policy_error(payload, "protocolMappers") + + store = InMemoryKvStore() + service = RelyingPartyService(store, api) + with pytest.raises(HTTPException) as raised: + service.put_registration("lineageweave-web", _parse_registration(payload)) + assert raised.value.status_code == 400 + assert str(raised.value.detail).startswith("protocolMappers") + assert store.get_all(RELYING_PARTY_NAMESPACE) == {} + assert api.calls == [] + + +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_lineageweave_profile_rejects_static_claims() -> None: + """The reserved LineageWeave client must use account-derived claims.""" + payload = _lineageweave_registration_with_account_claims() + mappers = payload["protocolMappers"] + assert isinstance(mappers, list) + payload["protocolMappers"] = [ + mappers[0], + _claim_mapper("role", "member"), + _claim_mapper("org", "org-cwl"), + _claim_mapper("workspace", "workspace-org-cwl"), + ] + + _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_desired_state.py b/services/account_unification/tests/test_relying_party_desired_state.py index 96d5bbb..f4d7d5e 100644 --- a/services/account_unification/tests/test_relying_party_desired_state.py +++ b/services/account_unification/tests/test_relying_party_desired_state.py @@ -421,7 +421,8 @@ def test_missing_or_malformed_live_uuid_fails_closed(api) -> None: assert error.value.detail == "keycloak client omitted its identifier" -def test_path_body_mismatch_and_invalid_path_are_rejected_before_storage(api) -> None: +@pytest.mark.parametrize("client_id", ("Bad Client!", "", "a" * 64, "client\n", "café", None)) +def test_path_body_mismatch_and_invalid_path_are_rejected_before_storage(api, client_id) -> None: """A path cannot redirect desired state or contain unsafe client syntax.""" store = InMemoryKvStore() service = RelyingPartyService(store, api) @@ -432,8 +433,11 @@ def test_path_body_mismatch_and_invalid_path_are_rejected_before_storage(api) -> assert store.get(RELYING_PARTY_NAMESPACE, "other-web") is None with pytest.raises(HTTPException) as invalid: - service.get_registration("Bad Client!") + service.get_registration(client_id) assert invalid.value.status_code == 400 + assert invalid.value.detail == "client_id must be a lowercase ASCII slug" + assert store.get_all(RELYING_PARTY_NAMESPACE) == {} + assert api.calls == [] def test_blocked_network_call_does_not_hold_desired_state_lock( 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..c1bc5e7 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,289 @@ 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 optional at creation.""" + 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 remain optional during account creation", + "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_main_reports_realm_policy_errors_when_profile_parse_also_fails( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A missing profile does not hide independently detected realm errors.""" + validator = _load_validate_realm() + realm_path = tmp_path / "invalid-realm.json" + profile_path = tmp_path / "missing-profile.json" + realm_path.write_text(json.dumps({"realm": "wrong"}), encoding="utf-8") + + assert validator.main(["validate_realm.py", str(realm_path), str(profile_path)]) == 1 + + stderr = capsys.readouterr().err + assert "realm name must be 'cwl'" in stderr + assert f"cannot parse {profile_path}" 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/tests/test_validate_realm_fail_closed_shapes.py b/services/account_unification/tests/test_validate_realm_fail_closed_shapes.py new file mode 100644 index 0000000..ebe64a4 --- /dev/null +++ b/services/account_unification/tests/test_validate_realm_fail_closed_shapes.py @@ -0,0 +1,98 @@ +"""Fail-closed regression tests for Keycloak user-profile validation.""" +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + + +def _repository_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _load_validate_realm(): + script_path = _repository_root() / "scripts" / "validate_realm.py" + spec = importlib.util.spec_from_file_location("validate_realm_shapes", script_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _committed_realm() -> dict: + return json.loads( + (_repository_root() / "deploy/keycloak/cwl-realm.json").read_text(encoding="utf-8") + ) + + +def _committed_profile() -> dict: + return json.loads( + (_repository_root() / "deploy/keycloak/lineageweave-user-profile.json").read_text( + encoding="utf-8" + ) + ) + + +@pytest.mark.parametrize("profile", [None, [], "profile", 7]) +def test_user_profile_rejects_non_object_top_level_without_raising(profile: object) -> None: + validator = _load_validate_realm() + + errors = validator.validate_user_profile(profile) + + assert "user profile must be a JSON object" in errors + + +@pytest.mark.parametrize("attributes", [None, {}, "attributes", 7]) +def test_user_profile_rejects_non_array_attributes_without_raising(attributes: object) -> None: + validator = _load_validate_realm() + profile = _committed_profile() + profile["attributes"] = attributes + + errors = validator.validate_user_profile(profile) + + assert "user profile attributes must be an array" in errors + + +def test_user_profile_rejects_non_object_attribute_entries() -> None: + validator = _load_validate_realm() + profile = _committed_profile() + profile["attributes"].append(None) + + errors = validator.validate_user_profile(profile) + + assert "user profile attribute entries must be JSON objects" in errors + + +def test_user_profile_rejects_extra_and_duplicate_attribute_names() -> None: + validator = _load_validate_realm() + profile = _committed_profile() + profile["attributes"].append({"name": "department"}) + profile["attributes"].append(dict(profile["attributes"][0])) + + errors = validator.validate_user_profile(profile) + + assert "user profile attributes must match the reviewed attribute-name set" in errors + assert "user profile attribute names must not be duplicated" in errors + + +@pytest.mark.parametrize("profile", [None, [], "profile", 7, {"attributes": None}]) +def test_cli_reports_malformed_profile_as_invalid_without_traceback( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + profile: object, +) -> None: + validator = _load_validate_realm() + realm_path = tmp_path / "cwl-realm.json" + profile_path = tmp_path / "lineageweave-user-profile.json" + realm_path.write_text(json.dumps(_committed_realm()), encoding="utf-8") + profile_path.write_text(json.dumps(profile), encoding="utf-8") + + result = validator.main(["validate_realm.py", str(realm_path), str(profile_path)]) + + captured = capsys.readouterr() + assert result == 1 + assert "INVALID:" in captured.err + assert "Traceback" not in captured.err diff --git a/services/account_unification/tests/test_workflow_concurrency_contract.py b/services/account_unification/tests/test_workflow_concurrency_contract.py index 116062f..1e77fdb 100644 --- a/services/account_unification/tests/test_workflow_concurrency_contract.py +++ b/services/account_unification/tests/test_workflow_concurrency_contract.py @@ -32,5 +32,7 @@ def test_ci_skips_draft_and_closed_pull_requests() -> None: assert workflow.count(admission) == 3 -def test_central_codeql_is_not_duplicated_locally() -> None: +def test_central_pr_workflows_are_not_duplicated_locally() -> None: + """Keep retired local copies of central required workflows absent.""" assert not (WORKFLOWS / "codeql.yml").exists() + assert not (WORKFLOWS / "hourly-pr-steward.yml").exists() diff --git a/tests/test_documentation_contract.py b/tests/test_documentation_contract.py index b472c59..6679ad1 100644 --- a/tests/test_documentation_contract.py +++ b/tests/test_documentation_contract.py @@ -24,6 +24,7 @@ "AGENTS.md", "CLAUDE.md", "CHANGELOG.md", + "docs/product-technical-gap-baseline.md", ) GOVERNING_ADRS = ( "0001-keycloak-hub.md", @@ -34,6 +35,7 @@ "0006-user-operation-lock.md", "0007-automation-authority.md", "0008-keyverse-rp-authorization-boundary.md", + "0009-lineageweave-account-derived-rp-claims.md", ) @@ -147,3 +149,75 @@ 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}" + ) + + +def test_gap_baseline_documents_product_evidence_and_hourly_loop() -> None: + """Keep the latest recorded snapshot distinct from retained historical evidence.""" + + baseline = _read("docs/product-technical-gap-baseline.md") + for heading in ( + "## Product contract", + "## Evidence classification", + "## Current live queue snapshot", + "## Live PR inventory", + "## Open Issue inventory", + "## Gap register and buyer-visible order", + "## Hourly loop contract", + ): + assert heading in baseline, f"missing baseline heading: {heading}" + for classification in ( + "implemented-main", + "active-PR", + "active-issue", + "accepted-contract", + "gap-not-claimed", + ): + assert f"`{classification}`" in baseline, ( + f"baseline is missing evidence class {classification}" + ) + snapshot = baseline.split("## Current live queue snapshot\n", 1)[1] + lowered = snapshot.split("\n## ", 1)[0].lower() + assert "observed at" in lowered + assert "historical" in lowered + assert "later head" in lowered + assert "never promoted" in lowered + assert "queued" in lowered + assert "pending" in lowered + assert "skipped" in lowered + assert "review-thread resolution" in lowered + assert "source observation head" in lowered + assert "does not recursively rename" in lowered + + +def test_traceability_links_gap_baseline_and_doctoring() -> None: + """Keep the gap baseline and its doctoring companion discoverable.""" + + traceability = _read("docs/TRACEABILITY.md") + assert "](product-technical-gap-baseline.md)" in traceability + assert "](doctoring/product-technical-gap-baseline.md)" in traceability + row = _row_with(traceability, "product and technical gap baseline") + assert "active-PR" in row