From aabe3dc05fb29c7820b36edc4d3af7385a6f1157 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 02:24:07 +0000 Subject: [PATCH 01/14] feat(authorization): add hierarchical PDP, start-login, and PATs Issue software-unit ACL, menu ABAC/RBAC, SSO combination scopes, and org-path inheritance from Orgmetra assignment snapshots without copying employment truth. Add a Keyverse-owned start-login helper and hashed programmable application tokens. ADR-0008 remains the RP PEP boundary; ADR-0009 stays reserved for the unmerged LineageWeave claim profile. Tracks #2. Implements #102. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 29 +- CHANGELOG.md | 13 + DOCUMENTATION.md | 1 + README.md | 8 +- docs/ERD.md | 57 +- docs/OPERABILITY.md | 18 +- docs/PRD.md | 29 +- docs/TEST_STRATEGY.md | 19 +- docs/THREAT_MODEL.md | 6 +- docs/TRACEABILITY.md | 8 +- docs/TRD.md | 7 +- docs/UML.md | 65 +- .../0010-hierarchical-authorization-plane.md | 65 ++ docs/adr/0011-app-start-login-helper.md | 30 + .../0012-programmable-application-tokens.md | 34 + docs/adr/README.md | 6 +- docs/authorization-onboarding.md | 93 +++ .../hierarchical-authorization-plane.md | 70 ++ .../programmable-application-tokens.md | 58 ++ docs/doctoring/start-login-helper.md | 52 ++ docs/federation-onboarding.md | 19 + docs/operations/authorization-plane.md | 38 ++ docs/papers/README.md | 13 +- docs/papers/citations.bib | 35 + docs/rp-onboarding.md | 2 + services/account_unification/README.md | 10 +- .../app/application_tokens.py | 592 +++++++++++++++++ .../app/authorization_plane.py | 509 +++++++++++++++ services/account_unification/app/errors.py | 9 + services/account_unification/app/main.py | 27 + .../app/org_authorization.py | 613 ++++++++++++++++++ .../account_unification/app/start_login.py | 268 ++++++++ .../tests/test_application_tokens.py | 314 +++++++++ .../tests/test_authorization_plane.py | 271 ++++++++ .../tests/test_org_authorization.py | 414 ++++++++++++ .../tests/test_start_login.py | 302 +++++++++ 36 files changed, 4080 insertions(+), 24 deletions(-) create mode 100644 docs/adr/0010-hierarchical-authorization-plane.md create mode 100644 docs/adr/0011-app-start-login-helper.md create mode 100644 docs/adr/0012-programmable-application-tokens.md create mode 100644 docs/authorization-onboarding.md create mode 100644 docs/doctoring/hierarchical-authorization-plane.md create mode 100644 docs/doctoring/programmable-application-tokens.md create mode 100644 docs/doctoring/start-login-helper.md create mode 100644 docs/operations/authorization-plane.md create mode 100644 services/account_unification/app/application_tokens.py create mode 100644 services/account_unification/app/authorization_plane.py create mode 100644 services/account_unification/app/org_authorization.py create mode 100644 services/account_unification/app/start_login.py create mode 100644 services/account_unification/tests/test_application_tokens.py create mode 100644 services/account_unification/tests/test_authorization_plane.py create mode 100644 services/account_unification/tests/test_org_authorization.py create mode 100644 services/account_unification/tests/test_start_login.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8a54a23..4fbf449 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,7 +64,11 @@ application relying-party registration. - SAML/OIDC identity-provider desired-state validation and reconciliation; - LDAP/Active Directory component preflight and desired-state reconciliation; - OIDC relying-party preflight and secret-free desired-state reconciliation; -- audit and user-operation lock boundaries. +- audit and user-operation lock boundaries; +- hierarchical software-unit, menu, inheritance, and SSO-combination + authorization decisions consumed from Orgmetra assignment snapshots; +- app start-login / IdP discovery helper for relying parties; +- hashed programmable application tokens scoped to one software unit and API. The core merge and SCIM layer depends on the narrow `AdminApi` protocol. Product extensions are isolated behind `ProductAdminApi`; relying-party client @@ -93,7 +97,11 @@ modules require neither protocol nor any network client. `relying_party_sources`, `relying_party_apply_receipts`; - merge audit: `account_merge_audit`; - cross-process user mutation lock sidecar: - `user_operation_lock_state`. + `user_operation_lock_state`; +- hierarchical authorization grants: + `authorization_software_unit_grants`, `authorization_menu_grants`; +- SSO combination scopes: `authorization_sso_combination_scopes`; +- hashed programmable tokens: `application_access_tokens`. Database objects and namespaces use descriptive two-word-or-longer snake_case names. @@ -178,9 +186,18 @@ profiles. Each downstream RP is a separate trust boundary. The RP must validate the Keyverse issuer, signature/algorithm, expiry, subject, and audience, map the verified tenant (`org`/deployment mapping), apply resource and purpose ABAC, -and then apply bounded role/scope/group RBAC. A registered client or accepted -mapper receipt never grants authorization by itself; see ADR-0008 for the -non-fork application matrix and remediation gates. +and then apply bounded role/scope/group RBAC. A registered client, accepted +mapper receipt, or Keyverse PDP decision never grants authorization by itself; +see ADR-0008 for the non-fork application matrix and remediation gates. +ADR-0010 adds issuer-side hierarchical attributes (`group_company`, +`legal_entity`, `business_unit`, `team`, `person`, `org_path`) and decisions. +Those names are distinct from the unmerged LineageWeave `role`/`org`/`workspace` +profile reserved as ADR-0009 on PR #100. Orgmetra remains employment truth; +Keyverse binds an opaque subject and does not copy the Orgmetra tree. + +Relying applications start brokered login through the Keyverse start-login +helper (ADR-0011) and may present software-unit-scoped programmable tokens +(ADR-0012) that are hashed at rest and never inherit org-tree grants. ## Account and provisioning invariants @@ -246,6 +263,8 @@ explicitly documented deployment-controller responsibility. Detailed decisions and evidence are maintained under: +- `docs/adr/` — accepted architecture decisions (0001–0008 plus 0010–0012; + 0009 reserved for the unmerged LineageWeave profile); - `docs/superpowers/specs/` — approved feature architecture; - `docs/superpowers/plans/` — executable implementation plans; - `docs/doctoring/` — standards interpretation and APA 7th traceability; diff --git a/CHANGELOG.md b/CHANGELOG.md index 5462dfb..b854dc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ Keep a Changelog, and releases use semantic versioning. ### Added +- Hierarchical authorization plane (ADR-0010): software-unit ACL, menu + ABAC/RBAC decisions, SSO combination scopes, and most-specific org-path + inheritance consumed from Orgmetra assignment snapshots. ADR-0008 stays + the PEP boundary. Hierarchical attributes use `group_company`, + `legal_entity`, `business_unit`, `team`, `person`, and `org_path` so they + do not collide with the unmerged LineageWeave `role`/`org`/`workspace` + profile reserved as ADR-0009 on PR #100. +- App start-login helper (ADR-0011) that discovers enabled brokered IdPs from + the local registry and returns a Keycloak `kc_idp_hint` authorization URL + without metadata or discovery fetch. +- Programmable application tokens (ADR-0012) hashed at rest, purpose-bound, + software-unit and API scoped, rotatable, auditable, and never a password + substitute or inherited secret. - 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. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6f7f97a..8a69aa2 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -16,6 +16,7 @@ Keyverse already has strong feature-specific specifications, doctoring, federati | Requirements/evidence traceability | [`docs/TRACEABILITY.md`](docs/TRACEABILITY.md) | | Architecture decisions | [`docs/adr/README.md`](docs/adr/README.md) | | Federation onboarding | [`docs/federation-onboarding.md`](docs/federation-onboarding.md) | +| Authorization onboarding | [`docs/authorization-onboarding.md`](docs/authorization-onboarding.md) | | RP onboarding | [`docs/rp-onboarding.md`](docs/rp-onboarding.md) | | Account merge/unification | [`docs/merge-unification-flow.md`](docs/merge-unification-flow.md) | | Standards/APA 7 evidence | [`docs/doctoring/`](docs/doctoring/) and [`docs/papers/`](docs/papers/) | diff --git a/README.md b/README.md index 5e24601..d7099e5 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,15 @@ See [`docs/federation-onboarding.md`](docs/federation-onboarding.md), [`deploy/keycloak/README.md`](deploy/keycloak/README.md), and [`deploy/templates/README.md`](deploy/templates/README.md). +An application starts brokered login through +`POST /federation/identity-providers:start-login` and then adds PKCE locally. +See [`docs/authorization-onboarding.md`](docs/authorization-onboarding.md). + ### Onboard a relying party -See [`docs/rp-onboarding.md`](docs/rp-onboarding.md). +See [`docs/rp-onboarding.md`](docs/rp-onboarding.md). Software-unit ACL, menu +decisions, SSO combinations, and programmable application tokens are documented +in [`docs/authorization-onboarding.md`](docs/authorization-onboarding.md). ## Account unification & merge diff --git a/docs/ERD.md b/docs/ERD.md index e5e5d93..9d3155d 100644 --- a/docs/ERD.md +++ b/docs/ERD.md @@ -1,9 +1,9 @@ # Keyverse Logical and Persistence ERD **Status:** Accepted cross-cutting data model. Exact Keycloak internal schema remains Keycloak-owned. -**Last reviewed:** 2026-08-09 +**Last reviewed:** 2026-08-18 -Keyverse persists its own configuration, desired-state, receipts, merge audit, and user-operation locks while Keycloak/PostgreSQL owns canonical IdP users/sessions/clients/federation runtime state. This ERD models Keyverse-owned durable records and their relation to external Keycloak identities without pretending to own Keycloak's internal tables. +Keyverse persists its own configuration, desired-state, receipts, merge audit, user-operation locks, authorization grants, SSO combination scopes, and hashed application tokens while Keycloak/PostgreSQL owns canonical IdP users/sessions/clients/federation runtime state. Orgmetra remains the employment-tree system of record; Keyverse stores grants against org-path nodes and does not persist Orgmetra `organization_unit` rows as source of record. This ERD models Keyverse-owned durable records and their relation to external Keycloak identities without pretending to own Keycloak's internal tables. ```mermaid erDiagram @@ -11,6 +11,10 @@ erDiagram FEDERATION_SOURCE }o--|| TENANT_DEPLOYMENT : scoped_to DIRECTORY_FEDERATION_SOURCE }o--|| TENANT_DEPLOYMENT : scoped_to RELYING_PARTY_SOURCE }o--|| TENANT_DEPLOYMENT : scoped_to + AUTHORIZATION_SOFTWARE_UNIT_GRANT }o--|| TENANT_DEPLOYMENT : scoped_to + AUTHORIZATION_MENU_GRANT }o--|| TENANT_DEPLOYMENT : scoped_to + SSO_COMBINATION_SCOPE }o--|| TENANT_DEPLOYMENT : scoped_to + APPLICATION_ACCESS_TOKEN }o--|| TENANT_DEPLOYMENT : scoped_to FEDERATION_SOURCE ||--o{ FEDERATION_APPLY_RECEIPT : produces DIRECTORY_FEDERATION_SOURCE ||--o{ DIRECTORY_FEDERATION_APPLY_RECEIPT : produces @@ -136,6 +140,49 @@ erDiagram timestamptz acquired_at timestamptz lease_expires_at } + + AUTHORIZATION_SOFTWARE_UNIT_GRANT { + text grant_key PK + uuid tenant_deployment_id FK + text org_path + text software_unit_id + text effect_code + text actor_identity_id + } + + AUTHORIZATION_MENU_GRANT { + text grant_key PK + uuid tenant_deployment_id FK + text org_path + text software_unit_id + text menu_path + text effect_code + jsonb capability_codes + jsonb attribute_constraints + text actor_identity_id + } + + SSO_COMBINATION_SCOPE { + text combination_name PK + uuid tenant_deployment_id FK + jsonb software_unit_ids + text actor_identity_id + } + + APPLICATION_ACCESS_TOKEN { + text application_token_id PK + uuid tenant_deployment_id FK + text software_unit_id + text token_prefix + text token_hash + text purpose_code + jsonb capability_codes + text lifecycle_status_code + timestamptz expires_at + timestamptz created_at + timestamptz revoked_at + text actor_identity_id + } ``` ## Logical uniqueness constraints @@ -148,6 +195,10 @@ UUID primary identifiers are globally unique. Human/provider identifiers are sco | `FEDERATION_SOURCE` | `(tenant_deployment_id, federation_alias)` | | `DIRECTORY_FEDERATION_SOURCE` | `(tenant_deployment_id, directory_alias)` | | `RELYING_PARTY_SOURCE` | `(tenant_deployment_id, client_id)` | +| `AUTHORIZATION_SOFTWARE_UNIT_GRANT` | `(tenant_deployment_id, org_path, software_unit_id)` | +| `AUTHORIZATION_MENU_GRANT` | `(tenant_deployment_id, org_path, software_unit_id, menu_path)` | +| `SSO_COMBINATION_SCOPE` | `(tenant_deployment_id, combination_name)` | +| `APPLICATION_ACCESS_TOKEN` | `(tenant_deployment_id, application_token_id)` and unique `token_hash` | | `KEYCLOAK_USER_REFERENCE` | `(tenant_deployment_id, keycloak_user_uuid)` | | `EXTERNAL_IDENTITY_LINK` | `(federation_source_id, external_subject_hash)` | @@ -178,6 +229,8 @@ or documentation labels are bypassed. - Exact external identity key is `(identity_provider, subject)`; verified email may support matching under policy but unverified email never authorizes linking. - `tenant_deployment_id` is explicit in Keyverse-owned records; deployment/customer separation must not be inferred from realm/resource names. - Secrets are referenced through protected values/handles where possible; secret-free desired-state tables must never gain client/bind credentials accidentally. +- Application access tokens store only `token_hash` and `token_prefix`. Plaintext tokens and org-tree secrets never appear on grant or combination rows. +- Hierarchical grant paths use `group_company` / `legal_entity` / `business_unit` / `team` / `person`. They do not persist Orgmetra trees and do not reuse LineageWeave `role` / `org` / `workspace` claim names. ## Desired-state and receipt invariant diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index f215903..28d129d 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -1,7 +1,7 @@ # Keyverse Operability, Recovery, and Release Guide **Status:** Accepted cross-cutting operating baseline -**Last reviewed:** 2026-08-11 +**Last reviewed:** 2026-08-18 Feature-specific procedures under `docs/operations/`, federation/RP onboarding, and deployment READMEs remain authoritative for their slices. This guide defines the shared operating model and evidence needed before declaring the identity platform healthy or release-ready. @@ -66,6 +66,22 @@ must test the **Naruon** product login/token/authorization journey using the `naruon-web` RP client ID and verify the expected audience and bounded claims. Mapper unit tests alone do not prove Naruon product authorization readiness. +## Authorization-plane and token runbook + +1. Obtain an Orgmetra assignment snapshot for the subject; do not copy the + Orgmetra tree into Keyverse. +2. PUT software-unit and menu grants at the intended org-path node. +3. PUT an SSO combination when several RPs should share one session. +4. Call the matching `:decide` endpoint and keep the RP as PEP. +5. For app login, call `POST /federation/identity-providers:start-login`, add + PKCE locally, and redirect. Do not fetch IdP metadata from the app. +6. Mint a PAT with `POST /application-tokens`, store the plaintext in the + application secret manager, and verify through Keyverse. Rotate or revoke + instead of sharing a password. + +See `docs/authorization-onboarding.md` and +`docs/operations/authorization-plane.md`. + ## Account merge recovery Merge and SCIM full replacement (`PUT`) must hold the shared operation lock. Protected-main `PATCH active=false` is not currently inside that shared-lock guarantee and must not be treated as transactionally serialized with merge. On failure, classify whether state changed in Keycloak, Keyverse audit, linked identities, or tombstone status. Re-observe before retry. Never infer a retry is safe solely from the previous HTTP response. Preserve survivor and duplicate lineage in audit. diff --git a/docs/PRD.md b/docs/PRD.md index 87278a0..c48720e 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1,7 +1,7 @@ # Keyverse Product Requirements Document **Status:** Accepted cross-cutting product baseline for protected `main` at `196814abe45ecf972a7776836af3933506d13fd5` -**Last reviewed:** 2026-08-11 +**Last reviewed:** 2026-08-18 ## 1. Product purpose @@ -24,6 +24,9 @@ Its job is to let CWL products consume stable standards-based identity without e - configuration/secret bootstrap via KV/DB boundary rather than application environment as runtime source of truth; - 100% production statement/branch/docstring quality gates and protected review/security workflows. - an explicit per-RP Keyverse token-validation and downstream ABAC/RBAC acceptance boundary; application login alone is not authorization readiness. +- an issuer-side hierarchical authorization plane for software-unit ACL, menu ABAC/RBAC decisions, SSO combination scopes, and org-path inheritance consumed from Orgmetra assignment snapshots; +- an app start-login helper that discovers enabled brokered IdPs and returns a `kc_idp_hint` authorization URL without metadata fetch; +- hashed, purpose-bound programmable application tokens scoped to one software unit and API capabilities. The current SCIM `PATCH active=false` deprovisioning path is not protected by the shared cross-process user-operation lock used by merge and full replacement. It must not be represented as transactionally serialized with merge until a source change and concurrency regression prove that boundary. @@ -104,6 +107,30 @@ Compose/Helm deployments SHALL expose component readiness that distinguishes Key Privileged identity and desired-state operations SHALL produce auditable intent/outcome evidence sufficient for reconciliation/rollback without exposing protected secret values. +### PRD-FR-011 Software-unit access control + +Keyverse SHALL decide whether an opaque Keyverse subject may use a named software unit / relying party from grants attached to a hierarchical org path. Employment truth SHALL remain in Orgmetra; Keyverse SHALL consume an assignment snapshot and SHALL NOT copy the Orgmetra tree as source of record. + +### PRD-FR-012 Menu ABAC and RBAC + +Keyverse SHALL decide menu access only after software-unit allow, applying closed ABAC constraints before remaining capability codes. Hierarchical attribute names SHALL be `group_company`, `legal_entity`, `business_unit`, `team`, `person`, and `org_path`, and SHALL NOT redefine LineageWeave `role`, `org`, or `workspace`. + +### PRD-FR-013 SSO combination scopes + +Keyverse SHALL authorize a named combination of software units to share one Keyverse session only when every member software unit is allowed for that snapshot. The Keycloak session remains Keycloak-owned. + +### PRD-FR-014 Higher-permission inheritance + +A grant at a higher org node SHALL apply to descendants unless a more-specific assignment restricts it. Default SHALL be deny. Secrets and programmable application tokens SHALL NOT inherit. + +### PRD-FR-015 App start-login helper + +Keyverse SHALL provide a start-login helper that discovers enabled identity providers from the local registry and returns a Keycloak authorization URL with `kc_idp_hint`. The helper SHALL NOT fetch metadata or discovery documents and SHALL NOT move federation ownership into the application. + +### PRD-FR-016 Programmable application tokens + +Keyverse SHALL issue hashed-at-rest, purpose-bound, software-unit and API scoped tokens that are rotatable, revocable, and auditable. Tokens SHALL NOT substitute for a password or WebAuthn authenticator and SHALL NOT inherit org-tree grants. + ## 7. Security/privacy requirements - passkey/federation/SCIM/OIDC/SAML/JWT behaviors follow current standards and Keycloak-supported contracts; diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index e2d3c55..77ed60c 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -1,7 +1,7 @@ # Keyverse Test Strategy **Status:** Accepted quality baseline -**Last reviewed:** 2026-08-11 +**Last reviewed:** 2026-08-18 ## Mandatory gates @@ -76,6 +76,21 @@ and rejection of scripts/arbitrary claims/classes. ADR-0008's application matrix remains deployment-restricted until each RP repository supplies its own exact token-validation and ABAC/RBAC evidence. +## Authorization-plane tests + +- contiguous Macro-to-Micro `org_path` parsing and reserved-name rejection; +- ancestor allow inheritance and more-specific deny restriction; +- default deny when no grant matches; +- software-unit ACL isolation per relying party; +- menu decisions require software-unit allow, then ABAC, then RBAC capabilities; +- SSO combinations allow only when every member software unit is allowed; +- start-login uses the local registry, rejects discovery/metadata URLs, and + performs no Keycloak or network I/O; +- PAT issue returns plaintext once; verify/revoke/rotate never echo secrets; +- PAT purposes cannot be password or authenticator substitutes; +- PATs do not inherit org-tree grants; +- Orgmetra trees are not persisted as source of record. + ## Deployment and persistence tests - PostgreSQL/KV migrations and rollback for Keyverse-owned records; @@ -93,7 +108,7 @@ Mirror `docs/THREAT_MODEL.md`: malicious IdP/LDAP URLs, path/resource IDs, dupli ## Documentation contract -CI should require PRD, TRD, Architecture, UML, ERD, Threat Model, Test Strategy, Operability, Traceability, ADR index, README, AGENTS, CLAUDE, CHANGELOG, and discoverable `docs/doctoring/`, `docs/papers/`, and `docs/operations/` research/standards/runbook records. It must assert PR #72/#74 are recorded as integrated protected-main changes and ADR-0008 remains indexed. +CI should require PRD, TRD, Architecture, UML, ERD, Threat Model, Test Strategy, Operability, Traceability, ADR index, README, AGENTS, CLAUDE, CHANGELOG, and discoverable `docs/doctoring/`, `docs/papers/`, and `docs/operations/` research/standards/runbook records. It must assert PR #72/#74 are recorded as integrated protected-main changes and ADR-0008 remains indexed. ADR-0009 is reserved for PR #100; ADR-0010–0012 index the authorization plane, start-login helper, and programmable tokens. ## Release acceptance diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 372e8fb..96c1d05 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -1,7 +1,7 @@ # Keyverse Threat Model **Status:** Accepted baseline for protected-main identity control plane -**Last reviewed:** 2026-08-11 +**Last reviewed:** 2026-08-18 ## Trust boundaries @@ -49,6 +49,10 @@ flowchart LR | automation credential exposure | repository/provider compromise | isolated OpenCode/broker/verification/publication and reviewer separation | | stale/false-green CI | unverified identity policy lands | exact-head checks, success-only evidence, fail-closed API gate | | RP accepts identity without authorization boundary | cross-tenant access or privilege elevation | explicit issuer/audience/JWKS profile, tenant/resource ABAC before bounded RBAC, cross-tenant denial tests, production fail-closed defaults | +| inherited grant applied too broadly | privilege elevation down the org tree | most-specific grant wins; default deny; more-specific deny restricts | +| PAT used as password | bypass of passwordless authenticator | closed purpose codes; reject password/WebAuthn/login purposes | +| PAT or grant secret leakage | credential theft | hash-at-rest; one-time plaintext; redacted list/verify; no org-tree inheritance of secrets | +| app fetches IdP metadata | SSRF / split federation ownership | start-login reads local registry only; discovery URLs rejected | ## STRIDE interpretation diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 4713bf5..3290b8b 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -1,7 +1,7 @@ # Keyverse Requirements and Evidence Traceability **Status:** Accepted cross-cutting baseline -**Last reviewed:** 2026-08-12 +**Last reviewed:** 2026-08-18 | Requirement / decision | Standards / authoritative basis | Source/evidence boundary | Maturity | |---|---|---|---| @@ -21,6 +21,10 @@ | secrets from KV/DB, env bootstrap only | architecture/security decision | config/bootstrap/template validation | implemented-main | | work-conserving fail-closed hourly API gate | automation safety decision | PR #74 protected-main workflow tests/exact-head evidence; scheduled/manual run remains required | implemented-main | | non-fork RP Keyverse authorization boundary | ADR-0008; OIDC/JWT recipient validation and least-privilege policy | six-app audit, per-RP issuer/audience/tenant/ABAC/RBAC evidence required | accepted-contract | +| hierarchical software-unit and menu PDP | ADR-0010; NIST SP 800-162 ABAC; Orgmetra assignment snapshot; issue #102 | account-unification authorization-plane tests; Orgmetra remains SoR | active-PR | +| SSO combination scopes | ADR-0010; OIDC session remains Keycloak-owned | combination decide tests require every member software unit | active-PR | +| app start-login helper | ADR-0011; OpenID Connect Core; Keycloak `kc_idp_hint`; no metadata fetch | start-login tests; local registry only | active-PR | +| programmable application tokens | ADR-0012; RFC 6750 bearer usage; hashed at rest | issue/verify/revoke/rotate tests; never a password substitute | active-PR | | naruon Keyverse OIDC acceptance boundary | ADR-0008; exact issuer/audience/JWKS validation and required OIDC NumericDate claims | naruon PR #1321 `ca6ccba` names the Keyverse issuer and `naruon-web` audience, requires verified `iat`, tests explicit org/workspace/role acceptance plus missing-`iat` denial, strips orphaned HTML comment terminators, and resolves the `develop` CHANGELOG conflict; protected-branch Checks/review remain required | active-PR | | semantic-data-portal Keyverse claim boundary | ADR-0008; bounded claim mapping and fail-closed tenant/role/JWT-header validation | semantic-data-portal PR #58 `47e2215` aliases `org`/`role`, validates every present tenant alias, rejects malformed/conflicting aliases before `ActorContext`, explicitly rejects unsupported JWT `crit` headers, and keeps the cryptography floor; protected-branch approval remains required | active-PR | | pg-erd-cloud Keyverse organization boundary | ADR-0008; verified tenant binding before project authorization | pg-erd-cloud PR #855 `e4b4771` exact `org`/audience/`iat` checks, single-tenant profile, API-key bypass denial; shared multi-tenant persistence remains unimplemented | active-PR | @@ -30,7 +34,7 @@ ## 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. +`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, hierarchical authorization, programmable application tokens, start-login, and automation changes. This matrix does not duplicate full bibliographic entries. ## Maturity rules diff --git a/docs/TRD.md b/docs/TRD.md index 1ffc105..8536e22 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -10,7 +10,7 @@ Keyverse separates portable Keycloak realm policy, Keyverse-owned identity contr ## 2. Runtime components - **Keycloak engine:** OIDC/OAuth, SAML brokering, WebAuthn, users/sessions/roles/groups, external IdP and LDAP component execution, RP clients. -- **Account-unification FastAPI service:** merge/link, SCIM, federation/directory/RP validation and desired-state/reconciliation, audit/locking boundaries. +- **Account-unification FastAPI service:** merge/link, SCIM, federation/directory/RP validation and desired-state/reconciliation, hierarchical authorization decisions, start-login helper, programmable application tokens, audit/locking boundaries. - **PostgreSQL/KV:** Keycloak state plus Keyverse configuration, intent, receipts, merge audit, and user-operation locks. - **Deployment controller:** private configuration rendering, egress/TLS policy, explicit apply, controlled acceptance, rollback. - **Compose/Helm:** standalone deployment topology and probes. @@ -23,7 +23,8 @@ Keyverse separates portable Keycloak realm policy, Keyverse-owned identity contr - Desired-state intent is persisted before external mutation where recovery requires it; receipt is persisted only after exact re-observation and binds the desired-state hash/version acted on. - RP desired state remains separate from confidential client material. - Deployment controller, not public API, owns private bind/client and certificate material. -- Each non-fork RP is a separate authorization boundary: verified Keyverse token validation, tenant/resource ABAC, and role/scope RBAC must be proven in the RP repository before production routing. +- Each non-fork RP is a separate authorization boundary: verified Keyverse token validation, tenant/resource ABAC, and role/scope RBAC must be proven in the RP repository before production routing. The Keyverse authorization-plane PDP issues attributes and decisions; it does not replace ADR-0008 PEP enforcement. +- Orgmetra remains the employment and org-tree system of record. Keyverse consumes assignment snapshots and persists only grants, combinations, and hashed application tokens. ## 4. Identity evidence @@ -61,7 +62,7 @@ Authenticated operator APIs accept closed versioned schemas. Errors must not ech ## 8. Persistence/data model -Current architecture owns PostgreSQL/KV state for configuration, desired-state sources, apply receipts, merge audit, and operation locks. Database objects use descriptive two-word-or-longer `snake_case` names. `docs/ERD.md` defines tenant-scoped uniqueness, receipt identity/version binding, relationships, and lifecycle; migrations must preserve tenant/identity/audit integrity. +Current architecture owns PostgreSQL/KV state for configuration, desired-state sources, apply receipts, merge audit, operation locks, authorization grants, SSO combination scopes, and hashed application access tokens. Database objects use descriptive two-word-or-longer `snake_case` names. `docs/ERD.md` defines tenant-scoped uniqueness, receipt identity/version binding, relationships, and lifecycle; migrations must preserve tenant/identity/audit integrity. ## 9. Security and privacy diff --git a/docs/UML.md b/docs/UML.md index 5a6ffcf..4b6030f 100644 --- a/docs/UML.md +++ b/docs/UML.md @@ -1,7 +1,7 @@ # Keyverse UML and Runtime Views **Status:** Accepted protected-main diagrams with integrated changes labelled. -**Last reviewed:** 2026-08-11 +**Last reviewed:** 2026-08-18 ## Component and authority view @@ -106,8 +106,67 @@ sequenceDiagram Policy-->>RP: authorization decision ``` -Authentication, client reconciliation, and mapper presence do not bypass the -RP policy sequence. ADR-0008 records the audited status of each non-fork RP. +Authentication, client reconciliation, mapper presence, and Keyverse PDP +receipts do not bypass the RP policy sequence. ADR-0008 records the audited +status of each non-fork RP. ADR-0010 adds an issuer-side decision that the RP +may consult after token validation. + +## Hierarchical authorization decision + +```mermaid +sequenceDiagram + participant Orgmetra + participant Operator + participant Keyverse as Keyverse PDP + participant Store as Grant store + participant RP as Relying-party PEP + + Orgmetra-->>Operator: assignment_record snapshot + Operator->>Keyverse: persist software-unit or menu grant + Keyverse->>Store: authorization grant + RP->>RP: validate iss/aud/sig/exp/sub + RP->>Keyverse: decide with org_path snapshot + Keyverse->>Store: load grants + Keyverse->>Keyverse: most-specific inherited grant + Keyverse-->>RP: attributes and effect + RP->>RP: enforce locally +``` + +Orgmetra remains employment SoR. Keyverse never copies the org tree. + +## App start-login helper + +```mermaid +sequenceDiagram + participant App as Relying application + participant Keyverse + participant Registry as Local IdP registry + participant Browser + participant Keycloak + + App->>Keyverse: POST start-login + Keyverse->>Registry: read enabled providers + Keyverse-->>App: kc_idp_hint URL, no metadata fetch + App->>Browser: redirect with PKCE + Browser->>Keycloak: authorization + kc_idp_hint +``` + +## Programmable application token + +```mermaid +sequenceDiagram + participant Operator + participant Keyverse + participant Store as Hashed token store + participant App as Software unit + + Operator->>Keyverse: issue PAT + Keyverse->>Store: token_hash only + Keyverse-->>Operator: plaintext once + Operator->>App: secret-manager placement + App->>Keyverse: verify token + software unit + APIs + Keyverse-->>App: allow or deny, no secret echo +``` ## Account merge state view diff --git a/docs/adr/0010-hierarchical-authorization-plane.md b/docs/adr/0010-hierarchical-authorization-plane.md new file mode 100644 index 0000000..514ad20 --- /dev/null +++ b/docs/adr/0010-hierarchical-authorization-plane.md @@ -0,0 +1,65 @@ +# ADR-0010: Issue hierarchical authorization attributes and decisions without owning employment truth + +**Status:** Accepted +**Date:** 2026-08-18 + +## Context + +Buyers need access control that follows the group-company, legal-entity, +business-unit, team, and person tree (Macro / Meso / Micro): + +1. which software unit / relying party a subject may use; +2. menu-level ABAC plus RBAC inside that software; +3. one Keyverse SSO session covering a selected combination of software units; +4. higher-node grants that inherit downward unless a more-specific assignment + restricts them. + +Employment and org-tree *truth* is Orgmetra (`organization_unit` / +`assignment_record`). Keyverse is the authentication home and binds an opaque +Keyverse subject. Copying Orgmetra's tree into Keyverse as a second source of +record would split authority and drift. + +Open PR #100 defines ADR-0009 and account-derived `role`, `org`, and +`workspace` claims for the unmerged LineageWeave profile. This plane must not +collide with or silently redefine those names. + +ADR-0008 already requires every non-fork RP to validate the Keyverse token and +enforce ABAC/RBAC at its own boundary. + +## Decision + +1. Keyverse is the issuer/PDP of authorization **attributes and decisions**. + Each relying party remains the PEP. ADR-0008 is unchanged: a decision + receipt is issuer-side evidence, not a substitute for issuer, audience, + signature, expiry, or subject validation at the RP. +2. Orgmetra remains employment SoR. Decision and grant APIs accept a caller- + supplied assignment snapshot (`keyverse_subject`, `org_path`, optional + `assignment_record_id`). Keyverse does not persist or synchronize the + Orgmetra tree. +3. Hierarchical attributes use distinct names: `group_company`, + `legal_entity`, `business_unit`, `team`, `person`, and structured + `org_path`. `role`, `org`, and `workspace` stay reserved for the + LineageWeave profile on ADR-0009 / PR #100. When that profile lands, the + claims compose: LineageWeave routing claims identify product tenant + context; `org_path` attributes describe Macro-to-Micro assignment + evidence. +4. Inheritance: the most specific grant whose org path (and, for menus, menu + path) is an ancestor of the snapshot wins. An ancestor allow applies to + descendants; a more-specific deny or replacement grant restricts that + subtree. Default is deny. Secrets and programmable application tokens + never inherit. +5. SSO combination scopes are named sets of software units. A combination is + allowed only when every member software unit is allowed for that snapshot. + The Keycloak session remains Keycloak-owned; this plane only authorizes + which RP set may share it. +6. Menu decisions apply software-unit ACL first, then ABAC constraints + (`purpose`, `sensitivity`, `clearance`, `residency`), then remaining RBAC + capability codes. + +## Consequences + +- Operators persist grants and combinations through authenticated Keyverse + admin APIs and evaluate decisions without contacting Orgmetra or Keycloak. +- Downstream RPs must still prove ADR-0008 token validation. This slice does + not claim production login or federation acceptance. +- ADR-0009 remains reserved for the unmerged LineageWeave claim profile. diff --git a/docs/adr/0011-app-start-login-helper.md b/docs/adr/0011-app-start-login-helper.md new file mode 100644 index 0000000..fef30a9 --- /dev/null +++ b/docs/adr/0011-app-start-login-helper.md @@ -0,0 +1,30 @@ +# ADR-0011: Offer app start-login as a Keyverse-owned federation helper + +**Status:** Accepted +**Date:** 2026-08-18 + +## Context + +Relying applications need a convenient way to start brokered login (IdP +discovery and a start URL) without each product becoming an identity provider +or fetching SAML/OIDC metadata itself. Federation ownership stays in Keyverse. +SAML/OIDC preflight already forbids metadata and discovery fetches. + +## Decision + +1. Keyverse exposes `POST /federation/identity-providers:start-login`. +2. The helper reads the local federation desired-state registry only. It + performs no DNS, socket, Keycloak Admin, metadata, or discovery call. +3. The response is a redacted enabled-provider list plus, when a provider can + be selected, a Keycloak authorization URL that includes `kc_idp_hint`. +4. The RP must add PKCE `S256`, `state`, and `nonce` locally, then redirect + the browser. The helper does not mint secrets or replace the OIDC client. +5. A discovery-document or metadata URL in the request is rejected. + +## Consequences + +- Applications start federation through Keyverse without owning IdP + registration, secrets, or metadata retrieval. +- Operators still register identity providers through the existing desired- + state lifecycle. This helper is not a new IdP and is not production + federation acceptance evidence. diff --git a/docs/adr/0012-programmable-application-tokens.md b/docs/adr/0012-programmable-application-tokens.md new file mode 100644 index 0000000..42f55b1 --- /dev/null +++ b/docs/adr/0012-programmable-application-tokens.md @@ -0,0 +1,34 @@ +# ADR-0012: Issue hashed, purpose-bound programmable application tokens + +**Status:** Accepted +**Date:** 2026-08-18 + +## Context + +Buyers need machine credentials scoped to one software unit and specific API +capabilities (PAT / API key). These must not become a password substitute, must +not live in an RP environment as Keycloak secrets (ADR-0005), and must not +inherit down the org tree (ADR-0010). + +## Decision + +1. Keyverse issues programmable application tokens (`kvt__`). + Only the SHA-256 hash, prefix, purpose, software unit, and capability codes + are stored. +2. Closed purposes are `machine_api`, `integration_sync`, and + `operator_export`. Password, WebAuthn, browser-login, and authenticator + purposes are rejected. +3. Tokens are software-unit and API-capability scoped, time-bounded (60 + seconds to 90 days), rotatable, revocable, and auditable. +4. The plaintext secret is returned only at issue or rotate time. List, get, + verify, and revoke responses never include the secret or hash. +5. Verification does not consult org-tree grants. Tokens never inherit. +6. A token is not an authenticator. Browser passwordless policy (ADR-0002) + remains unchanged. + +## Consequences + +- Relying applications store the plaintext token in their own secret manager + and present it only to `POST /application-tokens:verify`. +- Keycloak client secrets and operator bearers remain separate credentials. +- This slice does not replace confidential RP client-secret placement. diff --git a/docs/adr/README.md b/docs/adr/README.md index e53fc6d..a219182 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -12,9 +12,13 @@ | [0006](0006-user-operation-lock.md) | Merge and SCIM full replacement share one user-operation lock boundary | Accepted | | [0007](0007-automation-authority.md) | Autonomous development remains separate from review/merge/release authority | Accepted | | [0008](0008-keyverse-rp-authorization-boundary.md) | Every non-fork RP explicitly validates Keyverse identity and manages ABAC/RBAC at its own boundary | Accepted | +| 0009 | Reserved for the unmerged LineageWeave `role`/`org`/`workspace` profile on PR #100 | Reserved | +| [0010](0010-hierarchical-authorization-plane.md) | Hierarchical software-unit, menu, inheritance, and SSO-combination PDP; Orgmetra remains employment SoR; ADR-0008 PEP boundary unchanged | Accepted | +| [0011](0011-app-start-login-helper.md) | App start-login / IdP discovery helper owned by Keyverse; no metadata fetch | Accepted | +| [0012](0012-programmable-application-tokens.md) | Hashed, purpose-bound, software-unit and API scoped programmable application tokens | Accepted | ## 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. +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, hierarchical authorization attributes, programmable application tokens, start-login ownership, or autonomous/release authority. Each implementation PR should reconcile PRD/TRD/Architecture/UML/ERD/Threat/Test/Operability/Traceability and the relevant `docs/doctoring/`, `docs/papers/`, or `docs/operations/` research/standards/runbook record when those contracts move. diff --git a/docs/authorization-onboarding.md b/docs/authorization-onboarding.md new file mode 100644 index 0000000..fdbce67 --- /dev/null +++ b/docs/authorization-onboarding.md @@ -0,0 +1,93 @@ +# Authorization-plane onboarding + +Keyverse issues identity plus authorization attributes and decisions. Orgmetra +remains the employment-tree system of record. Each relying party remains the +PEP and must validate the Keyverse token (ADR-0008) before enforcing a local +decision. + +This page is the buyer-facing next action for the six capabilities in +ADR-0010, ADR-0011, and ADR-0012. It does not claim production federation or +login acceptance. + +## 1. Bind a subject, do not copy the org tree + +Ask Orgmetra for the current `assignment_record` and send Keyverse a snapshot: + +```json +{ + "keyverse_subject": "opaque-keyverse-subject", + "org_path": "/group_company/acme/legal_entity/holdco/business_unit/sales/team/alpha/person/jdoe", + "assignment_record_id": "assignment-record-77", + "request_attributes": {"purpose": "hr-review"} +} +``` + +Hierarchical names are `group_company`, `legal_entity`, `business_unit`, +`team`, `person`, and `org_path`. Do not send LineageWeave `role`, `org`, or +`workspace` as path levels; those names stay reserved for PR #100. + +## 2. Software-unit ACL + +```bash +curl --config "$AUTH_CONFIG" --request PUT \ + --header "Content-Type: application/json" \ + --data-binary @software-unit-grant.json \ + "$KEYVERSE_ADMIN/authorization/software-unit-grants/acme-naruon" + +curl --config "$AUTH_CONFIG" --request POST \ + --header "Content-Type: application/json" \ + --data-binary @software-unit-decide.json \ + "$KEYVERSE_ADMIN/authorization/software-units:decide" +``` + +A grant at `/group_company/acme` allows descendants unless a more-specific +deny exists. Default is deny. + +## 3. Menu ABAC + RBAC + +PUT a menu grant with `capability_codes` and optional `purpose` / +`sensitivity` / `clearance` / `residency` constraints, then +`POST /authorization/menus:decide`. Software-unit allow is required first. +The RP still enforces the decision locally. + +## 4. SSO combination + +PUT `/authorization/sso-combination-scopes/finance-suite` with two or more +software units. `POST /authorization/sso-combinations:decide` allows the +combination only when every member software unit is allowed. The Keycloak +session stays in Keycloak; this only authorizes the selected RP set. + +## 5. How an RP starts federation + +1. Register the employer IdP through the existing federation desired-state + APIs (`docs/federation-onboarding.md`). +2. From the application (or its deployment helper) call: + +```bash +curl --config "$AUTH_CONFIG" --request POST \ + --header "Content-Type: application/json" \ + --data '{"software_unit_id":"naruon-web","client_id":"naruon-web","redirect_uri":"https://naruon.example/callback","provider_alias_hint":"employer-adfs"}' \ + "$KEYVERSE_ADMIN/federation/identity-providers:start-login" +``` + +3. Add PKCE `S256`, `state`, and `nonce` in the application. +4. Redirect the browser to `start_login_url`. Do not fetch IdP metadata from + the app. Federation ownership stays in Keyverse. + +## 6. How a PAT is minted and scoped + +```bash +curl --config "$AUTH_CONFIG" --request POST \ + --header "Content-Type: application/json" \ + --data '{"software_unit_id":"naruon-web","purpose_code":"machine_api","capability_codes":["api.invoices.read"],"lifetime_seconds":3600,"actor_identity_id":"operator-ida"}' \ + "$KEYVERSE_ADMIN/application-tokens" +``` + +Store `plaintext_token` in the application's secret manager and discard the +response. Present the token only to `POST /application-tokens:verify` with +the same software unit and requested API capabilities. Rotate or revoke +instead of treating the token as a password. Tokens never inherit org-tree +grants. + +Keep bearer tokens out of `curl` process arguments; use a private `--config` +file as in `docs/rp-onboarding.md`. diff --git a/docs/doctoring/hierarchical-authorization-plane.md b/docs/doctoring/hierarchical-authorization-plane.md new file mode 100644 index 0000000..5a62153 --- /dev/null +++ b/docs/doctoring/hierarchical-authorization-plane.md @@ -0,0 +1,70 @@ +# Hierarchical Authorization Plane — Evidence and Standards Doctoring + +## Scope + +This record documents the evidence used to define Keyverse's issuer-side +hierarchical authorization plane. It separates standards requirements, vendor +behavior, measured repository evidence, policy choices, assumptions, and +limitations. It does not claim XACML, NIST, or OIDC conformance. + +## Normative and authoritative evidence + +NIST SP 800-162 describes attribute-based access control as a decision that +combines subject, resource, action, and environment attributes (Hu et al., +2014). Keyverse uses that structure for menu decisions: the subject is the +opaque Keyverse subject plus org-path attributes, the resource is the +software unit and menu path, and environment attributes are the closed +`purpose` / `sensitivity` / `clearance` / `residency` set. + +NIST SP 800-63C requires federation to keep identity proofing and +authentication distinct from relying-party authorization (Grassi et al., +2017). Orgmetra therefore remains employment truth; Keyverse issues +attributes and decisions and does not become a second HR system of record. + +RFC 8725 requires JWT recipients to validate audience and other registered +claims (Jones et al., 2020). ADR-0008 already places that duty on each RP. +The PDP API does not relax that requirement. + +## Vendor behavior + +Keycloak remains the session and token issuer. This plane does not add +Keycloak group mappings for the org tree and does not embed application +clients in the portable realm. + +## Stricter Keyverse policy + +1. Hierarchical claim names are not `role`, `org`, or `workspace`. +2. Inheritance is most-specific-wins with default deny. +3. Secrets and PATs never inherit. +4. Decision evaluation performs no Orgmetra, DNS, or Keycloak I/O. + +## Measured repository evidence + +`services/account_unification/tests/test_org_authorization.py` and +`tests/test_authorization_plane.py` cover inheritance, restriction, menu +ABAC/RBAC, SSO combinations, reserved-name rejection, and fail-closed +storage. + +## Assumptions and limitations + +Callers supply a current Orgmetra snapshot. This slice does not subscribe to +Orgmetra change feeds. Production login acceptance remains a separate +runtime evidence boundary. + +## References + +Grassi, P. A., Nadeau, E. M., Richer, J. P., Squire, S. K., Fenton, J. L., +Lefkovitz, N. B., Danker, J. M., Choong, Y.-Y., Greene, K. K., & Theofanos, +M. F. (2017). *Digital identity guidelines: Federation and assertions* +(NIST Special Publication 800-63C). National Institute of Standards and +Technology. https://doi.org/10.6028/NIST.SP.800-63c + +Hu, V. C., Ferraiolo, D., Kuhn, R., Schnitzer, A., Sandlin, K., Miller, R., +& Scarfone, K. (2014). *Guide to attribute based access control (ABAC) +definition and considerations* (NIST Special Publication 800-162). +National Institute of Standards and Technology. +https://doi.org/10.6028/NIST.SP.800-162 + +Jones, M. B., Hardt, D., & Campbell, B. (2020). *JSON Web Token best current +practices* (BCP 225, RFC 8725). RFC Editor. +https://www.rfc-editor.org/rfc/rfc8725 diff --git a/docs/doctoring/programmable-application-tokens.md b/docs/doctoring/programmable-application-tokens.md new file mode 100644 index 0000000..75b4927 --- /dev/null +++ b/docs/doctoring/programmable-application-tokens.md @@ -0,0 +1,58 @@ +# Programmable Application Tokens — Evidence and Standards Doctoring + +## Scope + +This record documents the evidence used to define Keyverse programmable +application tokens. It does not claim OAuth access-token profile conformance +and does not treat a PAT as an OpenID Connect access token. + +## Normative and authoritative evidence + +RFC 6750 describes bearer credentials presented to a resource server (Jones +& Hardt, 2012). Keyverse stores only a SHA-256 hash and verifies equality +with a compare-digest so the secret is not reconstructed from storage. + +NIST SP 800-63B distinguishes authenticators used to prove a subscriber +account from other secrets (Grassi et al., 2017). Password and WebAuthn +purposes are therefore forbidden. A PAT is a machine credential for a +software unit and API capability set, not a browser authenticator +(ADR-0002). + +RFC 8725 warns against leaking tokens in logs and responses (Jones et al., +2020). Issue returns plaintext once; list, get, verify, and revoke omit +both plaintext and hash. + +## Stricter Keyverse policy + +1. Closed purposes: `machine_api`, `integration_sync`, `operator_export`. +2. Lifetime bounded to 60 seconds–90 days. +3. At least one API capability is required. +4. Verification ignores org-tree grants; tokens never inherit. +5. Rotation revokes the previous hash and issues a replacement. + +## Measured repository evidence + +`services/account_unification/tests/test_application_tokens.py` covers issue, +verify, revoke, rotate, expiry, capability denial, software-unit mismatch, +password-purpose rejection, and secret omission. + +## Assumptions and limitations + +This slice does not replace confidential OIDC client-secret placement +(ADR-0005). Production API acceptance at each RP remains a separate +evidence boundary. + +## References + +Grassi, P. A., Garcia, M. E., & Fenton, J. L. (2017). *Digital identity +guidelines: Authentication and lifecycle management* (NIST Special +Publication 800-63B). National Institute of Standards and Technology. +https://doi.org/10.6028/NIST.SP.800-63b + +Jones, M. B., & Hardt, D. (2012). *The OAuth 2.0 authorization framework: +Bearer token usage* (RFC 6750). RFC Editor. +https://www.rfc-editor.org/rfc/rfc6750 + +Jones, M. B., Hardt, D., & Campbell, B. (2020). *JSON Web Token best current +practices* (BCP 225, RFC 8725). RFC Editor. +https://www.rfc-editor.org/rfc/rfc8725 diff --git a/docs/doctoring/start-login-helper.md b/docs/doctoring/start-login-helper.md new file mode 100644 index 0000000..764eef2 --- /dev/null +++ b/docs/doctoring/start-login-helper.md @@ -0,0 +1,52 @@ +# App Start-Login Helper — Evidence and Standards Doctoring + +## Scope + +This record documents why Keyverse offers a start-login helper instead of +moving federation ownership into each relying application. It does not claim +OpenID Connect or Keycloak brokering conformance. + +## Normative and authoritative evidence + +OpenID Connect Core defines the authorization endpoint and requires the RP +to perform the authorization-code flow, including PKCE when public (OpenID +Foundation, 2023). The helper only composes that endpoint with `client_id`, +`redirect_uri`, `response_type=code`, `scope=openid`, and Keycloak's +`kc_idp_hint` parameter (Keycloak Project, 2026). The RP must still add +PKCE, `state`, and `nonce`. + +SAML and OIDC preflight in this repository already forbid metadata and +discovery fetches. The helper preserves that boundary: it reads the local +desired-state registry and rejects `.well-known` or metadata URLs. + +NIST SP 800-63C treats the federation authority as distinct from the +application (Grassi et al., 2017). The helper therefore stays Keyverse-owned +and does not become a new IdP. + +## Measured repository evidence + +`services/account_unification/tests/test_start_login.py` proves single-IdP +auto-selection, multi-IdP hinting, disabled-provider omission, discovery-URL +rejection, HTTPS redirect policy, empty-registry behavior, and the +`metadata_fetch_performed=false` contract. + +## Assumptions and limitations + +The constructed authorization URL is not production login evidence. Controlled +authorization-code acceptance still belongs to the RP and deployment +controller. + +## References + +Grassi, P. A., Nadeau, E. M., Richer, J. P., Squire, S. K., Fenton, J. L., +Lefkovitz, N. B., Danker, J. M., Choong, Y.-Y., Greene, K. K., & Theofanos, +M. F. (2017). *Digital identity guidelines: Federation and assertions* +(NIST Special Publication 800-63C). National Institute of Standards and +Technology. https://doi.org/10.6028/NIST.SP.800-63c + +Keycloak Project. (2026). *Identity brokering* (Keycloak Server +Administration Guide 26.x). +https://www.keycloak.org/docs/latest/server_admin/#_identity_broker + +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/federation-onboarding.md b/docs/federation-onboarding.md index 28324ae..3fdeb3b 100644 --- a/docs/federation-onboarding.md +++ b/docs/federation-onboarding.md @@ -195,6 +195,25 @@ upstream no longer signs with the previous key, render, preflight, and `PUT` the payload again with only `next_certificate_body`; storing the previous certificate separately does not preserve active trust. +## App start-login helper + +After the identity provider is registered, a relying application starts +brokered login through Keyverse rather than fetching metadata itself: + +```bash +curl --config "$AUTH_CONFIG" \ + --fail-with-body \ + --silent \ + --show-error \ + --header "Content-Type: application/json" \ + --data '{"software_unit_id":"naruon-web","client_id":"naruon-web","redirect_uri":"https://naruon.example/callback","provider_alias_hint":"employer-adfs"}' \ + "$BASE/federation/identity-providers:start-login" +``` + +The helper reads the local registry only. Add PKCE, `state`, and `nonce` in +the application, then redirect to `start_login_url`. See +[`docs/authorization-onboarding.md`](authorization-onboarding.md). + ## Standards basis - OASIS Security Services Technical Committee. (2019). *SAML V2.0 Metadata diff --git a/docs/operations/authorization-plane.md b/docs/operations/authorization-plane.md new file mode 100644 index 0000000..0d53e4a --- /dev/null +++ b/docs/operations/authorization-plane.md @@ -0,0 +1,38 @@ +# Authorization-plane operations + +## When to use this runbook + +Use this procedure after Orgmetra assignment data is available and the +Keyverse operator token is in the deployment secret store. It covers grant +changes, start-login troubleshooting, and PAT rotation. It does not replace +federation or RP desired-state apply. + +## Persist grants + +1. Confirm the org path is contiguous from `group_company`. +2. PUT the software-unit grant, then any menu grants. +3. Decide with a current Orgmetra snapshot. If the effect is unexpected, + inspect winning_org_path and whether a more-specific deny exists. +4. Do not persist Orgmetra organization units into Keyverse. + +## Start-login failures + +- Empty `identity_providers`: the local federation registry has no enabled + IdP. Register one through desired state; do not point the helper at a + discovery URL. +- HTTP 404 on `provider_alias_hint`: the alias is missing or disabled. +- Multiple providers and a null `start_login_url`: supply an explicit hint. +- Never treat a green start-login response as production login acceptance. + +## PAT rotation + +1. POST `/application-tokens/{id}:rotate` with the same software unit. +2. Place the new plaintext in the application secret manager. +3. Confirm the old token verifies as `revoked_token`. +4. Revoke unused tokens instead of extending them as login credentials. + +## Recovery + +Corrupt grant or token rows fail closed with HTTP 500. Restore the KV/DB +namespace from backup and re-apply reviewed grants. Do not reconstruct +plaintext PATs from hashes. diff --git a/docs/papers/README.md b/docs/papers/README.md index cef02f7..c070ece 100644 --- a/docs/papers/README.md +++ b/docs/papers/README.md @@ -35,6 +35,15 @@ Full BibTeX in [`citations.bib`](./citations.bib). `account linking` via verified `email`/`sub` claims.) - OASIS (2005). *Assertions and Protocols for the OASIS Security Assertion Markup Language (SAML) V2.0.* (Employer ADFS federation via SAML/WS-Fed.) +- Hu, V. C., Ferraiolo, D., Kuhn, R., Schnitzer, A., Sandlin, K., Miller, R., + & Scarfone, K. (2014). *Guide to Attribute Based Access Control (ABAC) + Definition and Considerations.* NIST Special Publication 800-162. + https://doi.org/10.6028/NIST.SP.800-162 +- Grassi, P. A., Garcia, M. E., & Fenton, J. L. (2017). *Digital Identity + Guidelines: Authentication and Lifecycle Management.* NIST Special + Publication 800-63B. https://doi.org/10.6028/NIST.SP.800-63b +- Jones, M. B., & Hardt, D. (2012). *The OAuth 2.0 Authorization Framework: + Bearer Token Usage.* RFC 6750, IETF. https://doi.org/10.17487/RFC6750 ## How these map to the build @@ -42,5 +51,7 @@ Full BibTeX in [`citations.bib`](./citations.bib). | --- | --- | | NIST SP 800-63C | `docs/passwordless-policy.md`, verified-email auto-link rule in `app/matching.py` | | RFC 7644 (SCIM) | SCIM v2 server shim `services/account_unification/app/scim.py` | -| OIDC Core | `deploy/templates/oidc-rp-client.json`, `docs/rp-onboarding.md` | +| OIDC Core | `deploy/templates/oidc-rp-client.json`, `docs/rp-onboarding.md`, start-login helper | | SAML V2.0 | `deploy/templates/saml-idp-employer-adfs.json`, `docs/topology.md` | +| NIST SP 800-162 | hierarchical menu ABAC in `app/org_authorization.py` | +| NIST SP 800-63B / RFC 6750 | programmable application tokens in `app/application_tokens.py` | diff --git a/docs/papers/citations.bib b/docs/papers/citations.bib index 3ceed59..f6449ee 100644 --- a/docs/papers/citations.bib +++ b/docs/papers/citations.bib @@ -31,6 +31,41 @@ @misc{oidccore2014 url = {https://openid.net/specs/openid-connect-core-1_0.html} } +@techreport{nist8001622014, + author = {Hu, Vincent C. and Ferraiolo, David and Kuhn, Rick and + Schnitzer, Adam and Sandlin, Kenneth and Miller, Robert and + Scarfone, Karen}, + title = {Guide to Attribute Based Access Control ({ABAC}) Definition + and Considerations}, + institution = {National Institute of Standards and Technology}, + type = {NIST Special Publication}, + number = {800-162}, + year = {2014}, + doi = {10.6028/NIST.SP.800-162}, + url = {https://doi.org/10.6028/NIST.SP.800-162} +} + +@techreport{nist80063b2017, + author = {Grassi, Paul A. and Garcia, Michael E. and Fenton, James L.}, + title = {Digital Identity Guidelines: Authentication and Lifecycle + Management}, + institution = {National Institute of Standards and Technology}, + type = {NIST Special Publication}, + number = {800-63B}, + year = {2017}, + doi = {10.6028/NIST.SP.800-63b}, + url = {https://doi.org/10.6028/NIST.SP.800-63b} +} + +@misc{rfc6750, + author = {Jones, Michael B. and Hardt, Dick}, + title = {The {OAuth} 2.0 Authorization Framework: Bearer Token Usage}, + howpublished = {RFC 6750, Internet Engineering Task Force (IETF)}, + year = {2012}, + doi = {10.17487/RFC6750}, + url = {https://www.rfc-editor.org/rfc/rfc6750} +} + @misc{samlcore2005, author = {{OASIS Security Services Technical Committee}}, title = {Assertions and Protocols for the OASIS Security Assertion diff --git a/docs/rp-onboarding.md b/docs/rp-onboarding.md index 854f3ed..906e011 100644 --- a/docs/rp-onboarding.md +++ b/docs/rp-onboarding.md @@ -210,3 +210,5 @@ evidence required by the deployment record. - [ ] exact redirect/origin/logout values independently reviewed - [ ] expected mapper audience and claim profile re-observed without drift - [ ] controlled login, downstream JWT acceptance/rejection, refresh, logout, and rollback evidence recorded +- [ ] software-unit grant and start-login helper documented for the RP +- [ ] PAT minted only when the RP needs machine API access, stored in the app secret manager, never used as a password diff --git a/services/account_unification/README.md b/services/account_unification/README.md index 5a52be3..647c714 100644 --- a/services/account_unification/README.md +++ b/services/account_unification/README.md @@ -10,7 +10,11 @@ nor an external ADFS offers natively: with a survivor-wins conflict policy, a tombstoned duplicate, and a full audit trail, and - a minimal **SCIM 2.0** inbound provisioning shim (`/scim/v2/Users`) that - provisions into Keycloak via its Admin REST API. + provisions into Keycloak via its Admin REST API, +- a hierarchical **authorization plane** for software-unit ACL, menu + ABAC/RBAC, SSO combinations, and org-path inheritance, +- an app **start-login** helper for brokered IdP discovery, and +- hashed **programmable application tokens** scoped to one software unit. See [`../../docs/merge-unification-flow.md`](../../docs/merge-unification-flow.md) for the algorithm and matching rules. @@ -28,6 +32,10 @@ for the algorithm and matching rules. | `app/scim.py` | Inbound SCIM 2.0 provisioning shim → Keycloak Admin API | | `app/audit.py` | Append-only audit (`account_merge_audit`); in-memory + SQLite sinks | | `app/api.py` / `app/main.py` | HTTP routes + `/healthz` | +| `app/org_authorization.py` | Hierarchical org-path, inheritance, menu, and SSO decisions | +| `app/authorization_plane.py` | Durable grants and PDP HTTP surface | +| `app/start_login.py` | App start-login / IdP discovery helper | +| `app/application_tokens.py` | Hashed programmable application tokens | ## Run the tests diff --git a/services/account_unification/app/application_tokens.py b/services/account_unification/app/application_tokens.py new file mode 100644 index 0000000..34d2692 --- /dev/null +++ b/services/account_unification/app/application_tokens.py @@ -0,0 +1,592 @@ +"""Programmable application tokens scoped to one software unit and API. + +Tokens are hashed at rest, purpose-bound, rotatable, and auditable. They are +never a password or WebAuthn substitute and never inherit down the org tree. +The plaintext secret is returned only at issue time. +""" +from __future__ import annotations + +import hashlib +import hmac +import secrets +import threading +import time +import uuid +from collections.abc import Callable +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from .audit import AuditLogger +from .errors import AuthorizationPolicyError +from .kv_store import KvStore +from .org_authorization import validate_capability_codes, validate_slug + +APPLICATION_TOKEN_NAMESPACE = "application_access_tokens" +TOKEN_SCHEME = "kvt" +CLOSED_PURPOSE_CODES: frozenset[str] = frozenset( + {"machine_api", "integration_sync", "operator_export"} +) +FORBIDDEN_PURPOSE_CODES: frozenset[str] = frozenset( + {"password", "webauthn", "browser_login", "login", "authenticator"} +) +ACTIVE_LIFECYCLE = "active" +REVOKED_LIFECYCLE = "revoked" +ROTATED_LIFECYCLE = "rotated" +MIN_LIFETIME_SECONDS = 60 +MAX_LIFETIME_SECONDS = 90 * 24 * 60 * 60 + +application_token_router = APIRouter( + prefix="/application-tokens", tags=["application-tokens"] +) + + +class ApplicationTokenIssueRequest(BaseModel): + """Mint one software-unit-scoped programmable application token.""" + + model_config = ConfigDict(extra="forbid") + + software_unit_id: str + purpose_code: str + capability_codes: list[str] + lifetime_seconds: int = Field(default=3600, ge=1) + actor_identity_id: str = Field(min_length=1, max_length=128) + tenant_deployment_id: str = "default-deployment" + + +class ApplicationTokenRecord(BaseModel): + """Durable hashed token record. The plaintext secret is never stored.""" + + model_config = ConfigDict(extra="forbid") + + application_token_id: str + tenant_deployment_id: str + software_unit_id: str + token_prefix: str + token_hash: str + purpose_code: str + capability_codes: list[str] + lifecycle_status_code: str + expires_at: float + created_at: float + revoked_at: float | None = None + actor_identity_id: str + replaced_token_id: str | None = None + + +class ApplicationTokenIssueResponse(BaseModel): + """One-time issue envelope containing the plaintext token.""" + + model_config = ConfigDict(extra="forbid") + + application_token_id: str + software_unit_id: str + token_prefix: str + purpose_code: str + capability_codes: list[str] + expires_at: str + plaintext_token: str + token_substitute_for_password: bool = False + inherits_org_grants: bool = False + application_next_action: str = ( + "Store the plaintext token in the relying application's secret " + "manager, then discard the response. Present the token only to " + "POST /application-tokens:verify." + ) + + +class ApplicationTokenView(BaseModel): + """Operator view of a token with secret material omitted.""" + + model_config = ConfigDict(extra="forbid") + + application_token_id: str + software_unit_id: str + token_prefix: str + purpose_code: str + capability_codes: list[str] + lifecycle_status_code: str + expires_at: str + created_at: str + revoked_at: str | None = None + actor_identity_id: str + replaced_token_id: str | None = None + token_substitute_for_password: bool = False + inherits_org_grants: bool = False + + +class ApplicationTokenVerifyRequest(BaseModel): + """Ask whether a presented token is active for a software unit and APIs.""" + + model_config = ConfigDict(extra="forbid") + + presented_token: str = Field(min_length=8, max_length=256) + software_unit_id: str + requested_capability_codes: list[str] = Field(default_factory=list) + + +class ApplicationTokenVerifyResponse(BaseModel): + """Secret-free verification result for one programmable token.""" + + model_config = ConfigDict(extra="forbid") + + active: bool + effect: str + denial_code: str | None = None + application_token_id: str | None = None + software_unit_id: str | None = None + capability_codes: list[str] = Field(default_factory=list) + purpose_code: str | None = None + token_substitute_for_password: bool = False + inherits_org_grants: bool = False + + +class ApplicationTokenService: + """Issue, verify, revoke, and rotate hashed programmable application tokens.""" + + def __init__( + self, + store: KvStore, + audit: AuditLogger, + *, + clock: Callable[[], float] | None = None, + ) -> None: + """Create one service around KV storage, audit, and an optional clock.""" + self._store = store + self._audit = audit + self._clock = clock or time.time + self._state_lock = threading.RLock() + + def issue( + self, request: ApplicationTokenIssueRequest + ) -> ApplicationTokenIssueResponse: + """Mint one token, persist only the hash, and audit the issue.""" + record, plaintext = self._mint(request, replaced_token_id=None) + self._write_record(record) + self._audit_event( + "application_token_issued", + request.actor_identity_id, + record, + ) + return self._issue_response(record, plaintext) + + def list_tokens(self) -> list[ApplicationTokenView]: + """Return secret-free views of every stored token.""" + return [ + self._view(record) + for record in sorted(self._records(), key=lambda item: item.application_token_id) + ] + + def get_token(self, application_token_id: str) -> ApplicationTokenView: + """Return one secret-free token view.""" + return self._view(self._require_record(application_token_id)) + + def revoke( + self, + application_token_id: str, + *, + actor_identity_id: str, + lifecycle_status_code: str = REVOKED_LIFECYCLE, + ) -> ApplicationTokenView: + """Revoke one token. Hashes remain stored for audit, never returned.""" + _validate_token_id(application_token_id) + with self._state_lock: + record = self._require_record(application_token_id) + if record.lifecycle_status_code != ACTIVE_LIFECYCLE: + raise AuthorizationPolicyError( + "application token is not active", + status_code=409, + ) + updated = record.model_copy( + update={ + "lifecycle_status_code": lifecycle_status_code, + "revoked_at": self._clock(), + } + ) + self._write_record(updated) + self._audit_event( + "application_token_revoked", + actor_identity_id, + updated, + ) + return self._view(updated) + + def rotate( + self, + application_token_id: str, + request: ApplicationTokenIssueRequest, + ) -> ApplicationTokenIssueResponse: + """Revoke one active token and issue a replacement in one actor action.""" + _validate_token_id(application_token_id) + existing = self._require_record(application_token_id) + if existing.software_unit_id != request.software_unit_id: + raise AuthorizationPolicyError( + "rotated token must stay bound to the same software unit" + ) + self.revoke( + application_token_id, + actor_identity_id=request.actor_identity_id, + lifecycle_status_code=ROTATED_LIFECYCLE, + ) + record, plaintext = self._mint( + request, replaced_token_id=application_token_id + ) + self._write_record(record) + self._audit_event( + "application_token_rotated", + request.actor_identity_id, + record, + ) + return self._issue_response(record, plaintext) + + def verify( + self, request: ApplicationTokenVerifyRequest + ) -> ApplicationTokenVerifyResponse: + """Verify a presented token without consulting org-tree grants.""" + validate_slug(request.software_unit_id, field_name="software_unit_id") + requested = validate_capability_codes(request.requested_capability_codes) + parsed = _parse_presented_token(request.presented_token) + if parsed is None: + return _inactive("malformed_token") + token_prefix, _secret = parsed + presented_hash = _hash_token(request.presented_token) + now = self._clock() + with self._state_lock: + matches = [ + record + for record in self._records() + if record.token_prefix == token_prefix + and _hash_matches(record.token_hash, presented_hash) + ] + if not matches: + return _inactive("unknown_token") + record = matches[0] + if record.lifecycle_status_code != ACTIVE_LIFECYCLE: + return _inactive("revoked_token", record) + if record.expires_at <= now: + return _inactive("expired_token", record) + if record.software_unit_id != request.software_unit_id: + return _inactive("software_unit_mismatch", record) + if any(code not in record.capability_codes for code in requested): + return _inactive("capability_denied", record) + return ApplicationTokenVerifyResponse( + active=True, + effect="allow", + application_token_id=record.application_token_id, + software_unit_id=record.software_unit_id, + capability_codes=list(record.capability_codes), + purpose_code=record.purpose_code, + ) + + def _mint( + self, + request: ApplicationTokenIssueRequest, + *, + replaced_token_id: str | None, + ) -> tuple[ApplicationTokenRecord, str]: + """Create one hashed record and the corresponding plaintext token.""" + software_unit_id = validate_slug( + request.software_unit_id, field_name="software_unit_id" + ) + validate_slug( + request.tenant_deployment_id, field_name="tenant_deployment_id" + ) + purpose_code = _validate_purpose(request.purpose_code) + capability_codes = validate_capability_codes(request.capability_codes) + if not capability_codes: + raise AuthorizationPolicyError( + "application tokens require at least one API capability" + ) + if ( + request.lifetime_seconds < MIN_LIFETIME_SECONDS + or request.lifetime_seconds > MAX_LIFETIME_SECONDS + ): + raise AuthorizationPolicyError( + "lifetime_seconds must be between 60 seconds and 90 days" + ) + application_token_id = f"tok-{uuid.uuid4().hex[:16]}" + token_prefix = secrets.token_hex(6) + secret_material = secrets.token_urlsafe(32) + plaintext = f"{TOKEN_SCHEME}_{token_prefix}_{secret_material}" + now = self._clock() + record = ApplicationTokenRecord( + application_token_id=application_token_id, + tenant_deployment_id=request.tenant_deployment_id, + software_unit_id=software_unit_id, + token_prefix=token_prefix, + token_hash=_hash_token(plaintext), + purpose_code=purpose_code, + capability_codes=capability_codes, + lifecycle_status_code=ACTIVE_LIFECYCLE, + expires_at=now + request.lifetime_seconds, + created_at=now, + actor_identity_id=request.actor_identity_id, + replaced_token_id=replaced_token_id, + ) + return record, plaintext + + def _write_record(self, record: ApplicationTokenRecord) -> None: + """Persist one hashed token record.""" + with self._state_lock: + self._store.put( + APPLICATION_TOKEN_NAMESPACE, + record.application_token_id, + record.model_dump_json(), + ) + + def _records(self) -> list[ApplicationTokenRecord]: + """Load every hashed token record, fail-closed on corruption.""" + with self._state_lock: + raw_values = list(self._store.get_all(APPLICATION_TOKEN_NAMESPACE).values()) + records: list[ApplicationTokenRecord] = [] + for raw_value in raw_values: + try: + records.append(ApplicationTokenRecord.model_validate_json(raw_value)) + except ValidationError as exc: + raise AuthorizationPolicyError( + "application token store is corrupt", + status_code=500, + ) from exc + return records + + def _require_record(self, application_token_id: str) -> ApplicationTokenRecord: + """Return one stored record or raise 404.""" + _validate_token_id(application_token_id) + with self._state_lock: + raw_value = self._store.get( + APPLICATION_TOKEN_NAMESPACE, application_token_id + ) + if raw_value is None: + raise AuthorizationPolicyError( + "application token is not registered", + status_code=404, + ) + try: + return ApplicationTokenRecord.model_validate_json(raw_value) + except ValidationError as exc: + raise AuthorizationPolicyError( + "application token store is corrupt", + status_code=500, + ) from exc + + def _issue_response( + self, record: ApplicationTokenRecord, plaintext: str + ) -> ApplicationTokenIssueResponse: + """Build the one-time plaintext issue envelope.""" + return ApplicationTokenIssueResponse( + application_token_id=record.application_token_id, + software_unit_id=record.software_unit_id, + token_prefix=record.token_prefix, + purpose_code=record.purpose_code, + capability_codes=list(record.capability_codes), + expires_at=_iso(record.expires_at), + plaintext_token=plaintext, + ) + + def _view(self, record: ApplicationTokenRecord) -> ApplicationTokenView: + """Build a secret-free operator view.""" + return ApplicationTokenView( + application_token_id=record.application_token_id, + software_unit_id=record.software_unit_id, + token_prefix=record.token_prefix, + purpose_code=record.purpose_code, + capability_codes=list(record.capability_codes), + lifecycle_status_code=record.lifecycle_status_code, + expires_at=_iso(record.expires_at), + created_at=_iso(record.created_at), + revoked_at=None if record.revoked_at is None else _iso(record.revoked_at), + actor_identity_id=record.actor_identity_id, + replaced_token_id=record.replaced_token_id, + ) + + def _audit_event( + self, + event_type: str, + actor_identity_id: str, + record: ApplicationTokenRecord, + ) -> None: + """Record one hashed-token lifecycle event without secret material.""" + self._audit.emit( + audit_id=record.application_token_id, + event_type=event_type, + actor=actor_identity_id, + payload={ + "application_token_id": record.application_token_id, + "software_unit_id": record.software_unit_id, + "token_prefix": record.token_prefix, + "purpose_code": record.purpose_code, + "lifecycle_status_code": record.lifecycle_status_code, + }, + ) + + +def _validate_purpose(purpose_code: str) -> str: + """Accept only closed machine-purpose codes.""" + if purpose_code in FORBIDDEN_PURPOSE_CODES: + raise AuthorizationPolicyError( + "application tokens cannot substitute for a password or authenticator" + ) + if purpose_code not in CLOSED_PURPOSE_CODES: + raise AuthorizationPolicyError("purpose_code is not in the closed set") + return purpose_code + + +def _validate_token_id(application_token_id: str) -> str: + """Validate the tok- identifier issued by this service.""" + if ( + not application_token_id.startswith("tok-") + or len(application_token_id) != 20 + or any( + character not in "0123456789abcdef" + for character in application_token_id[4:] + ) + ): + raise AuthorizationPolicyError("application_token_id is malformed") + return application_token_id + + +def _hash_token(plaintext: str) -> str: + """Return the hex SHA-256 digest of one token.""" + return hashlib.sha256(plaintext.encode("utf-8")).hexdigest() + + +def _hash_matches(stored_hash: str, presented_hash: str) -> bool: + """Compare token hashes without raising on length mismatch.""" + if len(stored_hash) != len(presented_hash): + return False + return hmac.compare_digest(stored_hash, presented_hash) + + +def _parse_presented_token(presented_token: str) -> tuple[str, str] | None: + """Split ``kvt__`` or return None for malformed input.""" + parts = presented_token.split("_", 2) + if len(parts) != 3 or parts[0] != TOKEN_SCHEME or len(parts[1]) != 12: + return None + if any(ord(character) < 0x20 for character in presented_token): + return None + return parts[1], parts[2] + + +def _inactive( + denial_code: str, record: ApplicationTokenRecord | None = None +) -> ApplicationTokenVerifyResponse: + """Return a secret-free deny without echoing the presented token.""" + return ApplicationTokenVerifyResponse( + active=False, + effect="deny", + denial_code=denial_code, + application_token_id=None if record is None else record.application_token_id, + software_unit_id=None if record is None else record.software_unit_id, + purpose_code=None if record is None else record.purpose_code, + ) + + +def _iso(timestamp: float) -> str: + """Format a unix timestamp as UTC ISO-8601.""" + return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat() + + +def get_application_token_service(request: Request) -> ApplicationTokenService: + """Return the wired token service from application state.""" + service = getattr(request.app.state, "application_token_service", None) + if service is None: + raise HTTPException( + status_code=503, detail="application token service not ready" + ) + return service + + +class ApplicationTokenRevokeRequest(BaseModel): + """Identify the operator revoking one programmable token.""" + + model_config = ConfigDict(extra="forbid") + + actor_identity_id: str = Field(min_length=1, max_length=128) + + +@application_token_router.post("", response_model=ApplicationTokenIssueResponse) +def issue_application_token( + body: ApplicationTokenIssueRequest, + service: ApplicationTokenService = Depends(get_application_token_service), +) -> ApplicationTokenIssueResponse: + """Issue one hashed-at-rest programmable application token.""" + try: + return service.issue(body) + except AuthorizationPolicyError as exc: + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc + + +@application_token_router.get("", response_model=list[ApplicationTokenView]) +def list_application_tokens( + service: ApplicationTokenService = Depends(get_application_token_service), +) -> list[ApplicationTokenView]: + """List secret-free programmable application tokens.""" + try: + return service.list_tokens() + except AuthorizationPolicyError as exc: + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc + + +@application_token_router.get( + "/{application_token_id}", + response_model=ApplicationTokenView, +) +def get_application_token( + application_token_id: str, + service: ApplicationTokenService = Depends(get_application_token_service), +) -> ApplicationTokenView: + """Return one secret-free programmable application token.""" + try: + return service.get_token(application_token_id) + except AuthorizationPolicyError as exc: + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc + + +@application_token_router.post( + "/{application_token_id}:revoke", + response_model=ApplicationTokenView, +) +def revoke_application_token( + application_token_id: str, + body: ApplicationTokenRevokeRequest, + service: ApplicationTokenService = Depends(get_application_token_service), +) -> ApplicationTokenView: + """Revoke one programmable application token.""" + try: + return service.revoke( + application_token_id, actor_identity_id=body.actor_identity_id + ) + except AuthorizationPolicyError as exc: + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc + + +@application_token_router.post( + "/{application_token_id}:rotate", + response_model=ApplicationTokenIssueResponse, +) +def rotate_application_token( + application_token_id: str, + body: ApplicationTokenIssueRequest, + service: ApplicationTokenService = Depends(get_application_token_service), +) -> ApplicationTokenIssueResponse: + """Rotate one programmable application token.""" + try: + return service.rotate(application_token_id, body) + except AuthorizationPolicyError as exc: + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc + + +@application_token_router.post( + ":verify", + response_model=ApplicationTokenVerifyResponse, +) +def verify_application_token( + body: ApplicationTokenVerifyRequest, + service: ApplicationTokenService = Depends(get_application_token_service), +) -> ApplicationTokenVerifyResponse: + """Verify one presented programmable application token.""" + try: + return service.verify(body) + except AuthorizationPolicyError as exc: + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc diff --git a/services/account_unification/app/authorization_plane.py b/services/account_unification/app/authorization_plane.py new file mode 100644 index 0000000..5189846 --- /dev/null +++ b/services/account_unification/app/authorization_plane.py @@ -0,0 +1,509 @@ +"""Durable authorization grants, SSO combinations, and PDP HTTP surface. + +Grants persist in the KV/DB store under descriptive two-word namespaces. The +decision endpoints evaluate inheritance locally and never contact Orgmetra or +Keycloak. Every decision reminds the caller that the relying party remains the +PEP (ADR-0008). +""" +from __future__ import annotations + +import threading + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, ConfigDict, ValidationError + +from .errors import AuthorizationPolicyError +from .kv_store import KvStore +from .org_authorization import ( + AuthorizationDecision, + AuthorizationGrant, + AssignmentSnapshot, + SsoCombinationDecision, + SsoCombinationScope, + decide_menu, + decide_software_unit, + decide_sso_combination, + validate_combination, + validate_grant, + validate_slug, + validate_snapshot, +) + +SOFTWARE_UNIT_GRANT_NAMESPACE = "authorization_software_unit_grants" +MENU_GRANT_NAMESPACE = "authorization_menu_grants" +SSO_COMBINATION_NAMESPACE = "authorization_sso_combination_scopes" + +authorization_router = APIRouter(prefix="/authorization", tags=["authorization"]) + + +class SoftwareUnitDecisionRequest(BaseModel): + """Ask whether one subject may use one software unit.""" + + model_config = ConfigDict(extra="forbid") + + snapshot: AssignmentSnapshot + software_unit_id: str + + +class MenuDecisionRequest(BaseModel): + """Ask whether one subject may use one software menu path.""" + + model_config = ConfigDict(extra="forbid") + + snapshot: AssignmentSnapshot + software_unit_id: str + menu_path: str + + +class SsoCombinationDecisionRequest(BaseModel): + """Ask whether one Keyverse session may cover a named RP combination.""" + + model_config = ConfigDict(extra="forbid") + + snapshot: AssignmentSnapshot + combination_name: str + + +class AuthorizationPlaneService: + """Persist closed grants and evaluate issuer-side authorization decisions.""" + + def __init__(self, store: KvStore) -> None: + """Create one service around the configured KV/DB backend.""" + self._store = store + self._state_lock = threading.RLock() + + def put_software_unit_grant(self, grant_key: str, grant: AuthorizationGrant) -> AuthorizationGrant: + """Validate and store one software-unit grant.""" + return self._put_grant( + grant_key, + grant, + expected_scope="software_unit", + namespace=SOFTWARE_UNIT_GRANT_NAMESPACE, + ) + + def put_menu_grant(self, grant_key: str, grant: AuthorizationGrant) -> AuthorizationGrant: + """Validate and store one menu grant.""" + return self._put_grant( + grant_key, + grant, + expected_scope="menu", + namespace=MENU_GRANT_NAMESPACE, + ) + + def get_software_unit_grant(self, grant_key: str) -> AuthorizationGrant: + """Return one stored software-unit grant.""" + return self._get_grant(SOFTWARE_UNIT_GRANT_NAMESPACE, grant_key) + + def get_menu_grant(self, grant_key: str) -> AuthorizationGrant: + """Return one stored menu grant.""" + return self._get_grant(MENU_GRANT_NAMESPACE, grant_key) + + def list_software_unit_grants(self) -> list[AuthorizationGrant]: + """Return every stored software-unit grant.""" + return self._list_grants(SOFTWARE_UNIT_GRANT_NAMESPACE) + + def list_menu_grants(self) -> list[AuthorizationGrant]: + """Return every stored menu grant.""" + return self._list_grants(MENU_GRANT_NAMESPACE) + + def delete_software_unit_grant(self, grant_key: str) -> None: + """Remove one software-unit grant.""" + self._delete_grant(SOFTWARE_UNIT_GRANT_NAMESPACE, grant_key) + + def delete_menu_grant(self, grant_key: str) -> None: + """Remove one menu grant.""" + self._delete_grant(MENU_GRANT_NAMESPACE, grant_key) + + def put_combination( + self, combination_name: str, combination: SsoCombinationScope + ) -> SsoCombinationScope: + """Validate and store one SSO combination scope.""" + validate_slug(combination_name, field_name="combination_name") + if combination.combination_name != combination_name: + raise AuthorizationPolicyError( + "path combination_name and body combination_name must match" + ) + validated = validate_combination(combination) + with self._state_lock: + self._store.put( + SSO_COMBINATION_NAMESPACE, + combination_name, + validated.model_dump_json(), + ) + return validated + + def get_combination(self, combination_name: str) -> SsoCombinationScope: + """Return one stored SSO combination.""" + validate_slug(combination_name, field_name="combination_name") + with self._state_lock: + raw_value = self._store.get(SSO_COMBINATION_NAMESPACE, combination_name) + if raw_value is None: + raise AuthorizationPolicyError( + "sso combination is not registered", + status_code=404, + ) + return self._parse_combination(raw_value) + + def list_combinations(self) -> list[SsoCombinationScope]: + """Return every stored SSO combination.""" + with self._state_lock: + raw_values = list(self._store.get_all(SSO_COMBINATION_NAMESPACE).values()) + combinations = [self._parse_combination(raw_value) for raw_value in raw_values] + return sorted(combinations, key=lambda item: item.combination_name) + + def delete_combination(self, combination_name: str) -> None: + """Remove one SSO combination.""" + validate_slug(combination_name, field_name="combination_name") + with self._state_lock: + if self._store.get(SSO_COMBINATION_NAMESPACE, combination_name) is None: + raise AuthorizationPolicyError( + "sso combination is not registered", + status_code=404, + ) + self._store.delete(SSO_COMBINATION_NAMESPACE, combination_name) + + def decide_software_unit( + self, request: SoftwareUnitDecisionRequest + ) -> AuthorizationDecision: + """Evaluate software-unit access from stored grants and a snapshot.""" + snapshot = validate_snapshot(request.snapshot) + return decide_software_unit( + self.list_software_unit_grants(), + snapshot, + request.software_unit_id, + ) + + def decide_menu(self, request: MenuDecisionRequest) -> AuthorizationDecision: + """Evaluate menu access from stored software-unit and menu grants.""" + snapshot = validate_snapshot(request.snapshot) + return decide_menu( + self.list_software_unit_grants() + self.list_menu_grants(), + snapshot, + request.software_unit_id, + request.menu_path, + ) + + def decide_combination( + self, request: SsoCombinationDecisionRequest + ) -> SsoCombinationDecision: + """Evaluate whether every member of a stored combination is allowed.""" + combination = self.get_combination(request.combination_name) + snapshot = validate_snapshot(request.snapshot) + return decide_sso_combination( + self.list_software_unit_grants(), + snapshot, + combination, + ) + + def _put_grant( + self, + grant_key: str, + grant: AuthorizationGrant, + *, + expected_scope: str, + namespace: str, + ) -> AuthorizationGrant: + """Validate uniqueness and persist one grant.""" + validate_slug(grant_key, field_name="grant_key") + if grant.grant_key != grant_key: + raise AuthorizationPolicyError("path grant_key and body grant_key must match") + if grant.grant_scope_code != expected_scope: + raise AuthorizationPolicyError( + f"this collection accepts only {expected_scope} grants" + ) + validated = validate_grant(grant) + identity = ( + validated.grant_scope_code, + validated.org_path, + validated.software_unit_id, + validated.menu_path or "", + ) + with self._state_lock: + for existing in self._list_grants(namespace): + existing_identity = ( + existing.grant_scope_code, + existing.org_path, + existing.software_unit_id, + existing.menu_path or "", + ) + if existing.grant_key != grant_key and existing_identity == identity: + raise AuthorizationPolicyError( + "an equivalent authorization grant already exists", + status_code=409, + ) + self._store.put(namespace, grant_key, validated.model_dump_json()) + return validated + + def _get_grant(self, namespace: str, grant_key: str) -> AuthorizationGrant: + """Return one stored grant or raise a 404 policy error.""" + validate_slug(grant_key, field_name="grant_key") + with self._state_lock: + raw_value = self._store.get(namespace, grant_key) + if raw_value is None: + raise AuthorizationPolicyError( + "authorization grant is not registered", + status_code=404, + ) + return self._parse_grant(raw_value) + + def _list_grants(self, namespace: str) -> list[AuthorizationGrant]: + """Return every grant in one namespace, fail-closed on corrupt rows.""" + with self._state_lock: + raw_values = list(self._store.get_all(namespace).values()) + grants = [self._parse_grant(raw_value) for raw_value in raw_values] + return sorted(grants, key=lambda item: item.grant_key) + + def _delete_grant(self, namespace: str, grant_key: str) -> None: + """Delete one grant after proving it exists.""" + validate_slug(grant_key, field_name="grant_key") + with self._state_lock: + if self._store.get(namespace, grant_key) is None: + raise AuthorizationPolicyError( + "authorization grant is not registered", + status_code=404, + ) + self._store.delete(namespace, grant_key) + + def _parse_grant(self, raw_value: str) -> AuthorizationGrant: + """Parse one stored grant or fail closed.""" + try: + grant = AuthorizationGrant.model_validate_json(raw_value) + except ValidationError as exc: + raise AuthorizationPolicyError( + "authorization grant store is corrupt", + status_code=500, + ) from exc + return validate_grant(grant) + + def _parse_combination(self, raw_value: str) -> SsoCombinationScope: + """Parse one stored combination or fail closed.""" + try: + combination = SsoCombinationScope.model_validate_json(raw_value) + except ValidationError as exc: + raise AuthorizationPolicyError( + "sso combination store is corrupt", + status_code=500, + ) from exc + return validate_combination(combination) + + +def get_authorization_service(request: Request) -> AuthorizationPlaneService: + """Return the wired authorization-plane service from application state.""" + service = getattr(request.app.state, "authorization_service", None) + if service is None: + raise HTTPException( + status_code=503, detail="authorization service not ready" + ) + return service + + +def _raise_policy_error(exc: AuthorizationPolicyError) -> None: + """Translate a closed policy failure into an HTTP error.""" + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc + + +@authorization_router.put( + "/software-unit-grants/{grant_key}", + response_model=AuthorizationGrant, +) +def put_software_unit_grant( + grant_key: str, + grant: AuthorizationGrant, + service: AuthorizationPlaneService = Depends(get_authorization_service), +) -> AuthorizationGrant: + """Create or replace one software-unit grant.""" + try: + return service.put_software_unit_grant(grant_key, grant) + except AuthorizationPolicyError as exc: + _raise_policy_error(exc) + + +@authorization_router.get( + "/software-unit-grants", + response_model=list[AuthorizationGrant], +) +def list_software_unit_grants( + service: AuthorizationPlaneService = Depends(get_authorization_service), +) -> list[AuthorizationGrant]: + """List stored software-unit grants.""" + try: + return service.list_software_unit_grants() + except AuthorizationPolicyError as exc: + _raise_policy_error(exc) + + +@authorization_router.get( + "/software-unit-grants/{grant_key}", + response_model=AuthorizationGrant, +) +def get_software_unit_grant( + grant_key: str, + service: AuthorizationPlaneService = Depends(get_authorization_service), +) -> AuthorizationGrant: + """Return one stored software-unit grant.""" + try: + return service.get_software_unit_grant(grant_key) + except AuthorizationPolicyError as exc: + _raise_policy_error(exc) + + +@authorization_router.delete("/software-unit-grants/{grant_key}", status_code=204) +def delete_software_unit_grant( + grant_key: str, + service: AuthorizationPlaneService = Depends(get_authorization_service), +) -> None: + """Delete one software-unit grant.""" + try: + service.delete_software_unit_grant(grant_key) + except AuthorizationPolicyError as exc: + _raise_policy_error(exc) + + +@authorization_router.post( + "/software-units:decide", + response_model=AuthorizationDecision, +) +def decide_software_unit_endpoint( + body: SoftwareUnitDecisionRequest, + service: AuthorizationPlaneService = Depends(get_authorization_service), +) -> AuthorizationDecision: + """Decide software-unit access from stored grants and an assignment snapshot.""" + try: + return service.decide_software_unit(body) + except AuthorizationPolicyError as exc: + _raise_policy_error(exc) + + +@authorization_router.put("/menu-grants/{grant_key}", response_model=AuthorizationGrant) +def put_menu_grant( + grant_key: str, + grant: AuthorizationGrant, + service: AuthorizationPlaneService = Depends(get_authorization_service), +) -> AuthorizationGrant: + """Create or replace one menu grant.""" + try: + return service.put_menu_grant(grant_key, grant) + except AuthorizationPolicyError as exc: + _raise_policy_error(exc) + + +@authorization_router.get("/menu-grants", response_model=list[AuthorizationGrant]) +def list_menu_grants( + service: AuthorizationPlaneService = Depends(get_authorization_service), +) -> list[AuthorizationGrant]: + """List stored menu grants.""" + try: + return service.list_menu_grants() + except AuthorizationPolicyError as exc: + _raise_policy_error(exc) + + +@authorization_router.get("/menu-grants/{grant_key}", response_model=AuthorizationGrant) +def get_menu_grant( + grant_key: str, + service: AuthorizationPlaneService = Depends(get_authorization_service), +) -> AuthorizationGrant: + """Return one stored menu grant.""" + try: + return service.get_menu_grant(grant_key) + except AuthorizationPolicyError as exc: + _raise_policy_error(exc) + + +@authorization_router.delete("/menu-grants/{grant_key}", status_code=204) +def delete_menu_grant( + grant_key: str, + service: AuthorizationPlaneService = Depends(get_authorization_service), +) -> None: + """Delete one menu grant.""" + try: + service.delete_menu_grant(grant_key) + except AuthorizationPolicyError as exc: + _raise_policy_error(exc) + + +@authorization_router.post("/menus:decide", response_model=AuthorizationDecision) +def decide_menu_endpoint( + body: MenuDecisionRequest, + service: AuthorizationPlaneService = Depends(get_authorization_service), +) -> AuthorizationDecision: + """Decide menu access from stored grants and an assignment snapshot.""" + try: + return service.decide_menu(body) + except AuthorizationPolicyError as exc: + _raise_policy_error(exc) + + +@authorization_router.put( + "/sso-combination-scopes/{combination_name}", + response_model=SsoCombinationScope, +) +def put_sso_combination( + combination_name: str, + combination: SsoCombinationScope, + service: AuthorizationPlaneService = Depends(get_authorization_service), +) -> SsoCombinationScope: + """Create or replace one SSO combination of software units.""" + try: + return service.put_combination(combination_name, combination) + except AuthorizationPolicyError as exc: + _raise_policy_error(exc) + + +@authorization_router.get( + "/sso-combination-scopes", + response_model=list[SsoCombinationScope], +) +def list_sso_combinations( + service: AuthorizationPlaneService = Depends(get_authorization_service), +) -> list[SsoCombinationScope]: + """List stored SSO combinations.""" + try: + return service.list_combinations() + except AuthorizationPolicyError as exc: + _raise_policy_error(exc) + + +@authorization_router.get( + "/sso-combination-scopes/{combination_name}", + response_model=SsoCombinationScope, +) +def get_sso_combination( + combination_name: str, + service: AuthorizationPlaneService = Depends(get_authorization_service), +) -> SsoCombinationScope: + """Return one stored SSO combination.""" + try: + return service.get_combination(combination_name) + except AuthorizationPolicyError as exc: + _raise_policy_error(exc) + + +@authorization_router.delete( + "/sso-combination-scopes/{combination_name}", + status_code=204, +) +def delete_sso_combination( + combination_name: str, + service: AuthorizationPlaneService = Depends(get_authorization_service), +) -> None: + """Delete one SSO combination.""" + try: + service.delete_combination(combination_name) + except AuthorizationPolicyError as exc: + _raise_policy_error(exc) + + +@authorization_router.post( + "/sso-combinations:decide", + response_model=SsoCombinationDecision, +) +def decide_sso_combination_endpoint( + body: SsoCombinationDecisionRequest, + service: AuthorizationPlaneService = Depends(get_authorization_service), +) -> SsoCombinationDecision: + """Decide whether one Keyverse session may cover a stored RP combination.""" + try: + return service.decide_combination(body) + except AuthorizationPolicyError as exc: + _raise_policy_error(exc) diff --git a/services/account_unification/app/errors.py b/services/account_unification/app/errors.py index 31ac759..078920c 100644 --- a/services/account_unification/app/errors.py +++ b/services/account_unification/app/errors.py @@ -24,3 +24,12 @@ class NoMatchError(UnificationError): class InactiveAccountError(UnificationError): """Refused: an account is not active (already merged/deactivated).""" + + +class AuthorizationPolicyError(UnificationError): + """Closed authorization-plane input or policy failure.""" + + def __init__(self, message: str, *, status_code: int = 400) -> None: + """Record one operator-safe policy failure and its HTTP status.""" + super().__init__(message) + self.status_code = status_code diff --git a/services/account_unification/app/main.py b/services/account_unification/app/main.py index 35a3833..2a5300d 100644 --- a/services/account_unification/app/main.py +++ b/services/account_unification/app/main.py @@ -15,8 +15,10 @@ from . import __version__ from .api import router +from .application_tokens import ApplicationTokenService, application_token_router from .audit import AuditLogger, SqliteAuditSink from .auth import operator_auth_dependency +from .authorization_plane import AuthorizationPlaneService, authorization_router from .bootstrap import load_bootstrap_descriptor, open_config_store from .config import load_service_config from .directory_federation import directory_federation_router @@ -33,6 +35,7 @@ from .relying_party_state import RelyingPartyService, relying_party_state_router from .scim import scim_router from .service import UnificationService +from .start_login import StartLoginService, start_login_router from .user_locks import SqliteUserOperationLocks # Preserve the established wiring seam used by lifecycle tests and embedders @@ -95,6 +98,9 @@ def build_service(app: FastAPI) -> None: app.state.temporary_user_operation_lock_database = temporary_lock_database app.state.federation_service = FederationService(store, api) app.state.relying_party_service = RelyingPartyService(store, api) + app.state.authorization_service = AuthorizationPlaneService(store) + app.state.start_login_service = StartLoginService(store, config) + app.state.application_token_service = ApplicationTokenService(store, audit) app.state.operator_api_token = config.operator_api_token app.state.registration_api_token = config.registration_api_token app.state.registration_client_id = config.registration_client_id @@ -207,6 +213,27 @@ def healthz() -> dict: admin_path_security_dependency, ], ) + app.include_router( + authorization_router, + dependencies=[ + operator_auth_dependency, + admin_path_security_dependency, + ], + ) + app.include_router( + start_login_router, + dependencies=[ + operator_auth_dependency, + admin_path_security_dependency, + ], + ) + app.include_router( + application_token_router, + dependencies=[ + operator_auth_dependency, + admin_path_security_dependency, + ], + ) app.include_router( registration_router, dependencies=[registration_auth_dependency], diff --git a/services/account_unification/app/org_authorization.py b/services/account_unification/app/org_authorization.py new file mode 100644 index 0000000..c0beb0c --- /dev/null +++ b/services/account_unification/app/org_authorization.py @@ -0,0 +1,613 @@ +"""Deterministic hierarchical authorization attributes and decisions. + +Keyverse is the issuer/PDP of authorization attributes and decisions. Each +relying party remains the PEP and must validate a Keyverse token before +enforcing a local decision (ADR-0008). Employment and org-tree truth stay in +Orgmetra; this module consumes a caller-supplied assignment snapshot and never +treats the snapshot as a source of record. + +Hierarchical attribute names are ``group_company``, ``legal_entity``, +``business_unit``, ``team``, ``person``, and structured ``org_path``. They do +not reuse the unmerged LineageWeave ``role``, ``org``, or ``workspace`` claim +names from open PR #100. +""" +from __future__ import annotations + +import re +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field + +from .errors import AuthorizationPolicyError + +ORG_PATH_LEVELS: tuple[str, ...] = ( + "group_company", + "legal_entity", + "business_unit", + "team", + "person", +) +LINEAGEWEAVE_RESERVED_CLAIM_NAMES: frozenset[str] = frozenset( + {"role", "org", "workspace"} +) +CLOSED_ATTRIBUTE_CONSTRAINT_KEYS: frozenset[str] = frozenset( + {"purpose", "sensitivity", "clearance", "residency"} +) +SOFTWARE_UNIT_GRANT_SCOPE = "software_unit" +MENU_GRANT_SCOPE = "menu" +GRANT_SCOPES: frozenset[str] = frozenset( + {SOFTWARE_UNIT_GRANT_SCOPE, MENU_GRANT_SCOPE} +) +ALLOW_EFFECT = "allow" +DENY_EFFECT = "deny" +GRANT_EFFECTS: frozenset[str] = frozenset({ALLOW_EFFECT, DENY_EFFECT}) +_SLUG = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") +_CAPABILITY = re.compile(r"^[a-z][a-z0-9]*(?:[._][a-z0-9]+){0,6}$") +_MAX_CAPABILITY_CODES = 16 +_MAX_MENU_SEGMENTS = 8 +_MAX_ORG_PATH_LENGTH = 512 +_MAX_MENU_PATH_LENGTH = 256 + + +class AuthorizationEffect(StrEnum): + """Closed PDP effect returned to a relying-party PEP.""" + + ALLOW = ALLOW_EFFECT + DENY = DENY_EFFECT + + +class AuthorizationDecisionCode(StrEnum): + """Why a software-unit, menu, or SSO decision resolved as it did.""" + + INHERITED_ALLOW = "inherited_allow" + SPECIFIC_ALLOW = "specific_allow" + INHERITED_DENY = "inherited_deny" + SPECIFIC_DENY = "specific_deny" + DEFAULT_DENY = "default_deny" + ATTRIBUTE_MISMATCH = "attribute_mismatch" + SOFTWARE_UNIT_DENIED = "software_unit_denied" + COMBINATION_DENIED = "combination_denied" + COMBINATION_ALLOW = "combination_allow" + + +class OrganizationPath(BaseModel): + """A contiguous Macro-to-Micro org path consumed from Orgmetra evidence.""" + + model_config = ConfigDict(extra="forbid") + + segments: tuple[tuple[str, str], ...] + + @property + def serialized(self) -> str: + """Return the canonical slash-delimited org path.""" + parts: list[str] = [] + for level_name, unit_identifier in self.segments: + parts.extend((level_name, unit_identifier)) + return "/" + "/".join(parts) + + @property + def depth(self) -> int: + """Return how many organization levels are present.""" + return len(self.segments) + + def attribute_map(self) -> dict[str, str]: + """Return hierarchical attributes without LineageWeave claim names.""" + values = {level_name: unit_identifier for level_name, unit_identifier in self.segments} + values["org_path"] = self.serialized + return values + + def ancestor_paths(self) -> list[str]: + """Return serialized paths from most specific to group company.""" + paths: list[str] = [] + for depth in range(self.depth, 0, -1): + paths.append(OrganizationPath(segments=self.segments[:depth]).serialized) + return paths + + +class AssignmentSnapshot(BaseModel): + """Caller-supplied Orgmetra assignment evidence bound to a Keyverse subject. + + Keyverse stores the snapshot only as decision input. It does not persist or + own Orgmetra ``organization_unit`` / ``assignment_record`` trees. + """ + + model_config = ConfigDict(extra="forbid") + + keyverse_subject: str = Field(min_length=1, max_length=128) + org_path: str = Field(min_length=1, max_length=_MAX_ORG_PATH_LENGTH) + assignment_record_id: str | None = Field(default=None, max_length=128) + request_attributes: dict[str, str] = Field(default_factory=dict) + + +class AuthorizationGrant(BaseModel): + """One software-unit or menu grant attached to an org-path node.""" + + model_config = ConfigDict(extra="forbid") + + grant_key: str + tenant_deployment_id: str + grant_scope_code: str + org_path: str + software_unit_id: str + menu_path: str | None = None + effect_code: str + capability_codes: list[str] = Field(default_factory=list) + attribute_constraints: dict[str, str] = Field(default_factory=dict) + actor_identity_id: str = Field(min_length=1, max_length=128) + + +class AuthorizationDecision(BaseModel): + """Issuer-side PDP result. The relying party remains the PEP.""" + + model_config = ConfigDict(extra="forbid") + + effect: AuthorizationEffect + decision_code: AuthorizationDecisionCode + keyverse_subject: str + software_unit_id: str + org_path: str + winning_org_path: str | None = None + winning_menu_path: str | None = None + inherited: bool = False + menu_path: str | None = None + capability_codes: list[str] = Field(default_factory=list) + authorization_attributes: dict[str, str] = Field(default_factory=dict) + pep_enforcement_required: bool = True + lineageweave_claim_names: list[str] = Field( + default_factory=lambda: sorted(LINEAGEWEAVE_RESERVED_CLAIM_NAMES) + ) + + +class SsoCombinationScope(BaseModel): + """Named set of software units that may share one Keyverse SSO session.""" + + model_config = ConfigDict(extra="forbid") + + combination_name: str + tenant_deployment_id: str + software_unit_ids: list[str] + actor_identity_id: str = Field(min_length=1, max_length=128) + + +class SsoCombinationDecision(BaseModel): + """Whether one Keyverse session may cover every member of a combination.""" + + model_config = ConfigDict(extra="forbid") + + effect: AuthorizationEffect + decision_code: AuthorizationDecisionCode + combination_name: str + keyverse_subject: str + org_path: str + member_decisions: list[AuthorizationDecision] = Field(default_factory=list) + pep_enforcement_required: bool = True + + +def validate_slug(value: str, *, field_name: str) -> str: + """Return one lowercase URL-safe slug or raise a policy error.""" + if not isinstance(value, str) or _SLUG.fullmatch(value) is None: + raise AuthorizationPolicyError( + f"{field_name} must be a lowercase URL-safe slug" + ) + return value + + +def validate_capability_codes(capability_codes: list[str]) -> list[str]: + """Return a de-duplicated closed capability list or raise a policy error.""" + if len(capability_codes) > _MAX_CAPABILITY_CODES: + raise AuthorizationPolicyError("capability_codes exceeds the closed bound") + normalized: list[str] = [] + seen: set[str] = set() + for capability_code in capability_codes: + if _CAPABILITY.fullmatch(capability_code) is None: + raise AuthorizationPolicyError( + "capability_codes must use closed dotted or underscored tokens" + ) + if capability_code in seen: + raise AuthorizationPolicyError("capability_codes must be unique") + seen.add(capability_code) + normalized.append(capability_code) + return normalized + + +def parse_org_path(raw_org_path: str) -> OrganizationPath: + """Parse a contiguous Macro-to-Micro org path and reject reserved names.""" + if not isinstance(raw_org_path, str) or not raw_org_path: + raise AuthorizationPolicyError("org_path is required") + if len(raw_org_path) > _MAX_ORG_PATH_LENGTH: + raise AuthorizationPolicyError("org_path exceeds the closed bound") + if not raw_org_path.startswith("/") or raw_org_path.endswith("/"): + raise AuthorizationPolicyError( + "org_path must be an absolute path without a trailing slash" + ) + body = raw_org_path.split("/")[1:] + if len(body) < 2 or len(body) % 2 != 0: + raise AuthorizationPolicyError( + "org_path must alternate level names and unit identifiers" + ) + segments: list[tuple[str, str]] = [] + expected_levels = ORG_PATH_LEVELS + for index in range(0, len(body), 2): + level_name = body[index] + unit_identifier = body[index + 1] + expected_index = index // 2 + if expected_index >= len(expected_levels): + raise AuthorizationPolicyError("org_path is deeper than the closed tree") + if level_name in LINEAGEWEAVE_RESERVED_CLAIM_NAMES: + raise AuthorizationPolicyError( + "org_path must not use LineageWeave reserved claim names" + ) + if level_name != expected_levels[expected_index]: + raise AuthorizationPolicyError( + "org_path levels must be contiguous from group_company" + ) + validate_slug(unit_identifier, field_name="org_path unit identifier") + segments.append((level_name, unit_identifier)) + return OrganizationPath(segments=tuple(segments)) + + +def parse_menu_path(raw_menu_path: str) -> str: + """Return a canonical menu path with optional descendant prefix matching.""" + if not isinstance(raw_menu_path, str) or not raw_menu_path: + raise AuthorizationPolicyError("menu_path is required") + if len(raw_menu_path) > _MAX_MENU_PATH_LENGTH: + raise AuthorizationPolicyError("menu_path exceeds the closed bound") + if not raw_menu_path.startswith("/") or raw_menu_path.endswith("/"): + raise AuthorizationPolicyError( + "menu_path must be an absolute path without a trailing slash" + ) + segments = raw_menu_path.split("/")[1:] + if not segments or len(segments) > _MAX_MENU_SEGMENTS: + raise AuthorizationPolicyError("menu_path has an invalid segment count") + for segment in segments: + validate_slug(segment, field_name="menu_path segment") + return "/" + "/".join(segments) + + +def menu_ancestor_paths(menu_path: str) -> list[str]: + """Return menu paths from most specific to the first segment.""" + canonical = parse_menu_path(menu_path) + segments = canonical.split("/")[1:] + return ["/" + "/".join(segments[:depth]) for depth in range(len(segments), 0, -1)] + + +def validate_attribute_constraints(attribute_constraints: dict[str, str]) -> dict[str, str]: + """Reject reserved LineageWeave keys and unknown ABAC constraint names.""" + validated: dict[str, str] = {} + for attribute_key, attribute_value in attribute_constraints.items(): + if attribute_key in LINEAGEWEAVE_RESERVED_CLAIM_NAMES: + raise AuthorizationPolicyError( + "attribute_constraints must not redefine LineageWeave claim names" + ) + if attribute_key not in CLOSED_ATTRIBUTE_CONSTRAINT_KEYS: + raise AuthorizationPolicyError( + "attribute_constraints keys must be purpose, sensitivity, " + "clearance, or residency" + ) + if not isinstance(attribute_value, str) or not attribute_value.strip(): + raise AuthorizationPolicyError( + "attribute_constraints values must be non-empty strings" + ) + if len(attribute_value) > 64: + raise AuthorizationPolicyError( + "attribute_constraints values exceed the closed bound" + ) + validated[attribute_key] = attribute_value + return validated + + +def validate_request_attributes(request_attributes: dict[str, str]) -> dict[str, str]: + """Validate optional ABAC attributes supplied with a decision snapshot.""" + return validate_attribute_constraints(request_attributes) + + +def validate_grant(grant: AuthorizationGrant) -> AuthorizationGrant: + """Normalize and close one authorization grant.""" + validate_slug(grant.grant_key, field_name="grant_key") + validate_slug(grant.tenant_deployment_id, field_name="tenant_deployment_id") + validate_slug(grant.software_unit_id, field_name="software_unit_id") + if grant.grant_scope_code not in GRANT_SCOPES: + raise AuthorizationPolicyError("grant_scope_code must be software_unit or menu") + if grant.effect_code not in GRANT_EFFECTS: + raise AuthorizationPolicyError("effect_code must be allow or deny") + parsed_org = parse_org_path(grant.org_path) + capability_codes = validate_capability_codes(grant.capability_codes) + constraints = validate_attribute_constraints(grant.attribute_constraints) + menu_path: str | None = None + if grant.grant_scope_code == SOFTWARE_UNIT_GRANT_SCOPE: + if grant.menu_path is not None: + raise AuthorizationPolicyError( + "software_unit grants must not carry a menu_path" + ) + if constraints and grant.effect_code == DENY_EFFECT: + raise AuthorizationPolicyError( + "deny grants cannot carry attribute_constraints" + ) + if capability_codes and grant.effect_code == DENY_EFFECT: + raise AuthorizationPolicyError("deny grants cannot carry capability_codes") + else: + if grant.menu_path is None: + raise AuthorizationPolicyError("menu grants require menu_path") + menu_path = parse_menu_path(grant.menu_path) + if grant.effect_code == DENY_EFFECT and (capability_codes or constraints): + raise AuthorizationPolicyError( + "deny grants cannot carry capability_codes or attribute_constraints" + ) + return grant.model_copy( + update={ + "org_path": parsed_org.serialized, + "menu_path": menu_path, + "capability_codes": capability_codes, + "attribute_constraints": constraints, + } + ) + + +def validate_combination(combination: SsoCombinationScope) -> SsoCombinationScope: + """Normalize one SSO combination of software units.""" + validate_slug(combination.combination_name, field_name="combination_name") + validate_slug( + combination.tenant_deployment_id, field_name="tenant_deployment_id" + ) + if not 2 <= len(combination.software_unit_ids) <= 16: + raise AuthorizationPolicyError( + "sso combination must name between 2 and 16 software units" + ) + seen: set[str] = set() + software_unit_ids: list[str] = [] + for software_unit_id in combination.software_unit_ids: + validate_slug(software_unit_id, field_name="software_unit_id") + if software_unit_id in seen: + raise AuthorizationPolicyError("sso combination software units must be unique") + seen.add(software_unit_id) + software_unit_ids.append(software_unit_id) + return combination.model_copy(update={"software_unit_ids": software_unit_ids}) + + +def validate_snapshot(snapshot: AssignmentSnapshot) -> AssignmentSnapshot: + """Validate one assignment snapshot without contacting Orgmetra.""" + if any(character.isspace() or ord(character) < 0x20 for character in snapshot.keyverse_subject): + raise AuthorizationPolicyError("keyverse_subject must be an opaque bounded token") + parsed_org = parse_org_path(snapshot.org_path) + if snapshot.assignment_record_id is not None: + validate_slug( + snapshot.assignment_record_id, field_name="assignment_record_id" + ) + request_attributes = validate_request_attributes(snapshot.request_attributes) + return snapshot.model_copy( + update={ + "org_path": parsed_org.serialized, + "request_attributes": request_attributes, + } + ) + + +def _constraints_match( + constraints: dict[str, str], request_attributes: dict[str, str] +) -> bool: + """Return whether every grant constraint is present and equal.""" + for attribute_key, expected_value in constraints.items(): + if request_attributes.get(attribute_key) != expected_value: + return False + return True + + +def _decision_code( + *, + effect: str, + inherited: bool, + attribute_mismatch: bool = False, +) -> AuthorizationDecisionCode: + """Map winning-grant geometry onto a closed decision code.""" + if attribute_mismatch: + return AuthorizationDecisionCode.ATTRIBUTE_MISMATCH + if effect == ALLOW_EFFECT and inherited: + return AuthorizationDecisionCode.INHERITED_ALLOW + if effect == ALLOW_EFFECT: + return AuthorizationDecisionCode.SPECIFIC_ALLOW + if inherited: + return AuthorizationDecisionCode.INHERITED_DENY + return AuthorizationDecisionCode.SPECIFIC_DENY + + +def _build_decision( + *, + snapshot: AssignmentSnapshot, + software_unit_id: str, + parsed_org: OrganizationPath, + winning: AuthorizationGrant | None, + inherited: bool, + menu_path: str | None, + attribute_mismatch: bool = False, + software_unit_denied: bool = False, +) -> AuthorizationDecision: + """Assemble one issuer-side decision envelope.""" + attributes = parsed_org.attribute_map() + attributes["software_unit"] = software_unit_id + if winning is None and software_unit_denied: + return AuthorizationDecision( + effect=AuthorizationEffect.DENY, + decision_code=AuthorizationDecisionCode.SOFTWARE_UNIT_DENIED, + keyverse_subject=snapshot.keyverse_subject, + software_unit_id=software_unit_id, + org_path=parsed_org.serialized, + inherited=False, + menu_path=menu_path, + authorization_attributes=attributes, + ) + if winning is None: + return AuthorizationDecision( + effect=AuthorizationEffect.DENY, + decision_code=AuthorizationDecisionCode.DEFAULT_DENY, + keyverse_subject=snapshot.keyverse_subject, + software_unit_id=software_unit_id, + org_path=parsed_org.serialized, + inherited=False, + menu_path=menu_path, + authorization_attributes=attributes, + ) + effect = ( + AuthorizationEffect.DENY + if winning.effect_code == DENY_EFFECT or attribute_mismatch + else AuthorizationEffect.ALLOW + ) + return AuthorizationDecision( + effect=effect, + decision_code=_decision_code( + effect=winning.effect_code, + inherited=inherited, + attribute_mismatch=attribute_mismatch, + ), + keyverse_subject=snapshot.keyverse_subject, + software_unit_id=software_unit_id, + org_path=parsed_org.serialized, + winning_org_path=winning.org_path, + winning_menu_path=winning.menu_path, + inherited=inherited, + menu_path=menu_path, + capability_codes=list(winning.capability_codes) if effect is AuthorizationEffect.ALLOW else [], + authorization_attributes=attributes, + ) + + +def _select_winning_grant( + grants: list[AuthorizationGrant], + *, + snapshot_path: OrganizationPath, + software_unit_id: str, + grant_scope_code: str, + requested_menu_path: str | None, +) -> tuple[AuthorizationGrant | None, bool]: + """Return the most specific matching grant and whether it was inherited.""" + candidates: list[tuple[int, int, AuthorizationGrant]] = [] + org_rank = {path: index for index, path in enumerate(snapshot_path.ancestor_paths())} + menu_rank: dict[str, int] = {} + if requested_menu_path is not None: + menu_rank = { + path: index for index, path in enumerate(menu_ancestor_paths(requested_menu_path)) + } + for grant in grants: + if grant.grant_scope_code != grant_scope_code: + continue + if grant.software_unit_id != software_unit_id: + continue + if grant.org_path not in org_rank: + continue + if grant_scope_code == MENU_GRANT_SCOPE: + if grant.menu_path is None or grant.menu_path not in menu_rank: + continue + menu_specificity = menu_rank[grant.menu_path] + else: + menu_specificity = 0 + candidates.append((menu_specificity, org_rank[grant.org_path], grant)) + if not candidates: + return None, False + candidates.sort(key=lambda item: (item[0], item[1])) + winning = candidates[0][2] + inherited = winning.org_path != snapshot_path.serialized + return winning, inherited + + +def decide_software_unit( + grants: list[AuthorizationGrant], + snapshot: AssignmentSnapshot, + software_unit_id: str, +) -> AuthorizationDecision: + """Decide whether a subject may use one software unit / relying party.""" + validated_snapshot = validate_snapshot(snapshot) + validate_slug(software_unit_id, field_name="software_unit_id") + parsed_org = parse_org_path(validated_snapshot.org_path) + validated_grants = [validate_grant(grant) for grant in grants] + winning, inherited = _select_winning_grant( + validated_grants, + snapshot_path=parsed_org, + software_unit_id=software_unit_id, + grant_scope_code=SOFTWARE_UNIT_GRANT_SCOPE, + requested_menu_path=None, + ) + return _build_decision( + snapshot=validated_snapshot, + software_unit_id=software_unit_id, + parsed_org=parsed_org, + winning=winning, + inherited=inherited, + menu_path=None, + ) + + +def decide_menu( + grants: list[AuthorizationGrant], + snapshot: AssignmentSnapshot, + software_unit_id: str, + menu_path: str, +) -> AuthorizationDecision: + """Decide menu access after software-unit allow, applying ABAC then RBAC.""" + software_decision = decide_software_unit(grants, snapshot, software_unit_id) + validated_snapshot = validate_snapshot(snapshot) + parsed_org = parse_org_path(validated_snapshot.org_path) + canonical_menu = parse_menu_path(menu_path) + if software_decision.effect is AuthorizationEffect.DENY: + return _build_decision( + snapshot=validated_snapshot, + software_unit_id=software_unit_id, + parsed_org=parsed_org, + winning=None, + inherited=False, + menu_path=canonical_menu, + software_unit_denied=True, + ) + validated_grants = [validate_grant(grant) for grant in grants] + winning, inherited = _select_winning_grant( + validated_grants, + snapshot_path=parsed_org, + software_unit_id=software_unit_id, + grant_scope_code=MENU_GRANT_SCOPE, + requested_menu_path=canonical_menu, + ) + attribute_mismatch = False + if ( + winning is not None + and winning.effect_code == ALLOW_EFFECT + and not _constraints_match( + winning.attribute_constraints, validated_snapshot.request_attributes + ) + ): + attribute_mismatch = True + return _build_decision( + snapshot=validated_snapshot, + software_unit_id=software_unit_id, + parsed_org=parsed_org, + winning=winning, + inherited=inherited, + menu_path=canonical_menu, + attribute_mismatch=attribute_mismatch, + ) + + +def decide_sso_combination( + grants: list[AuthorizationGrant], + snapshot: AssignmentSnapshot, + combination: SsoCombinationScope, +) -> SsoCombinationDecision: + """Allow a combination only when every member software unit is allowed.""" + validated_combination = validate_combination(combination) + validated_snapshot = validate_snapshot(snapshot) + member_decisions = [ + decide_software_unit(grants, validated_snapshot, software_unit_id) + for software_unit_id in validated_combination.software_unit_ids + ] + allowed = all( + decision.effect is AuthorizationEffect.ALLOW for decision in member_decisions + ) + return SsoCombinationDecision( + effect=AuthorizationEffect.ALLOW if allowed else AuthorizationEffect.DENY, + decision_code=( + AuthorizationDecisionCode.COMBINATION_ALLOW + if allowed + else AuthorizationDecisionCode.COMBINATION_DENIED + ), + combination_name=validated_combination.combination_name, + keyverse_subject=validated_snapshot.keyverse_subject, + org_path=validated_snapshot.org_path, + member_decisions=member_decisions, + ) diff --git a/services/account_unification/app/start_login.py b/services/account_unification/app/start_login.py new file mode 100644 index 0000000..9dcc724 --- /dev/null +++ b/services/account_unification/app/start_login.py @@ -0,0 +1,268 @@ +"""App-side start-login helper for brokered Keyverse federation. + +Relying parties call this helper to discover enabled identity providers and +receive a Keycloak authorization URL with ``kc_idp_hint``. The helper never +fetches OIDC discovery or SAML metadata, never becomes an IdP, and never +moves federation ownership into the application. +""" +from __future__ import annotations + +from urllib.parse import urlencode, urlsplit + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from .config import ServiceConfig +from .errors import AuthorizationPolicyError +from .federation import ( + FEDERATION_PROVIDER_NAMESPACE, + IdentityProviderRegistration, + IdentityProviderView, +) +from .kv_store import KvStore +from .org_authorization import validate_slug + +start_login_router = APIRouter(prefix="/federation", tags=["federation"]) +_HTTPS_SCHEME = "https" +_HTTP_SCHEME = "http" +_MAX_REDIRECT_URI_LENGTH = 2_048 +_FORBIDDEN_HINTS = frozenset({"fromUrl", "discoveryEndpoint", "metadataUrl"}) + + +class StartLoginRequest(BaseModel): + """Ask Keyverse how one relying party should start brokered login.""" + + model_config = ConfigDict(extra="forbid") + + software_unit_id: str + client_id: str + redirect_uri: str + provider_alias_hint: str | None = None + public_issuer_url: str | None = Field( + default=None, + description="Optional public realm issuer; never a discovery document URL.", + ) + + +class DiscoveredIdentityProvider(BaseModel): + """Redacted enabled identity provider an RP may hint.""" + + model_config = ConfigDict(extra="forbid") + + provider_alias: str + display_name: str + provider_id: str + enabled: bool + + +class StartLoginResponse(BaseModel): + """Discovery and start-login instruction owned by Keyverse, not the app.""" + + model_config = ConfigDict(extra="forbid") + + software_unit_id: str + client_id: str + identity_providers: list[DiscoveredIdentityProvider] + selected_provider_alias: str | None = None + kc_idp_hint_parameter: str = "kc_idp_hint" + authorization_endpoint: str + start_login_url: str | None = None + metadata_fetch_performed: bool = False + federation_ownership: str = "keyverse" + application_next_action: str = ( + "Add PKCE S256, state, and nonce locally, then redirect the browser " + "to start_login_url. Do not fetch IdP metadata from the application." + ) + + +class StartLoginService: + """Build start-login instructions from the local federation registry.""" + + def __init__(self, store: KvStore, config: ServiceConfig) -> None: + """Create one helper around the KV registry and local issuer config.""" + self._store = store + self._config = config + + def start_login(self, request: StartLoginRequest) -> StartLoginResponse: + """Return redacted discovery and an optional start URL without network I/O.""" + software_unit_id = validate_slug( + request.software_unit_id, field_name="software_unit_id" + ) + client_id = validate_slug(request.client_id, field_name="client_id") + if request.client_id != request.software_unit_id: + raise AuthorizationPolicyError( + "client_id and software_unit_id must match in this slice" + ) + _reject_discovery_request(request) + redirect_uri = _validated_redirect_uri(request.redirect_uri) + authorization_endpoint = _authorization_endpoint( + request.public_issuer_url, + self._config, + ) + providers = self.discover_enabled_providers() + selected = _select_provider(providers, request.provider_alias_hint) + start_login_url = None + if selected is not None: + start_login_url = _build_start_login_url( + authorization_endpoint, + client_id=client_id, + redirect_uri=redirect_uri, + provider_alias=selected, + ) + return StartLoginResponse( + software_unit_id=software_unit_id, + client_id=client_id, + identity_providers=providers, + selected_provider_alias=selected, + authorization_endpoint=authorization_endpoint, + start_login_url=start_login_url, + ) + + def discover_enabled_providers(self) -> list[DiscoveredIdentityProvider]: + """Return enabled providers from KV without calling Keycloak.""" + discovered: list[DiscoveredIdentityProvider] = [] + for raw_value in self._store.get_all(FEDERATION_PROVIDER_NAMESPACE).values(): + try: + registration = IdentityProviderRegistration.model_validate_json( + raw_value + ) + except ValidationError as exc: + raise AuthorizationPolicyError( + "federation provider store is corrupt", + status_code=500, + ) from exc + if not registration.enabled: + continue + view = IdentityProviderView.from_registration(registration) + discovered.append( + DiscoveredIdentityProvider( + provider_alias=view.provider_alias, + display_name=view.display_name, + provider_id=view.provider_id, + enabled=view.enabled, + ) + ) + return sorted(discovered, key=lambda item: item.provider_alias) + + +def _reject_discovery_request(request: StartLoginRequest) -> None: + """Refuse fields that would imply a metadata or discovery fetch.""" + public_issuer_url = request.public_issuer_url or "" + lowered = public_issuer_url.lower() + if any(marker.lower() in lowered for marker in _FORBIDDEN_HINTS): + raise AuthorizationPolicyError( + "start-login must not receive discovery or metadata URLs" + ) + if ".well-known" in lowered: + raise AuthorizationPolicyError( + "start-login must not receive discovery or metadata URLs" + ) + + +def _validated_redirect_uri(redirect_uri: str) -> str: + """Return one absolute HTTPS application redirect URI.""" + if len(redirect_uri) > _MAX_REDIRECT_URI_LENGTH: + raise AuthorizationPolicyError("redirect_uri exceeds the closed bound") + parsed = urlsplit(redirect_uri) + if ( + parsed.scheme != _HTTPS_SCHEME + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.fragment + ): + raise AuthorizationPolicyError( + "redirect_uri must be an absolute HTTPS URI without credentials " + "or fragments" + ) + return redirect_uri + + +def _authorization_endpoint( + public_issuer_url: str | None, config: ServiceConfig +) -> str: + """Build the local Keycloak authorization endpoint without discovery.""" + if public_issuer_url: + parsed = urlsplit(public_issuer_url) + if ( + parsed.scheme not in {_HTTP_SCHEME, _HTTPS_SCHEME} + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.fragment + or parsed.query + ): + raise AuthorizationPolicyError( + "public_issuer_url must be an absolute issuer URL without " + "credentials, query, or fragment" + ) + issuer = public_issuer_url.rstrip("/") + else: + issuer = ( + f"{config.keycloak_server_url.rstrip('/')}/realms/{config.keycloak_realm}" + ) + if issuer.endswith("/protocol/openid-connect/auth"): + return issuer + return f"{issuer}/protocol/openid-connect/auth" + + +def _select_provider( + providers: list[DiscoveredIdentityProvider], + provider_alias_hint: str | None, +) -> str | None: + """Select one enabled provider or require an explicit hint.""" + aliases = {provider.provider_alias for provider in providers} + if provider_alias_hint is not None: + validate_slug(provider_alias_hint, field_name="provider_alias_hint") + if provider_alias_hint not in aliases: + raise AuthorizationPolicyError( + "provider_alias_hint does not match an enabled identity provider", + status_code=404, + ) + return provider_alias_hint + if len(providers) == 1: + return providers[0].provider_alias + return None + + +def _build_start_login_url( + authorization_endpoint: str, + *, + client_id: str, + redirect_uri: str, + provider_alias: str, +) -> str: + """Compose a Keycloak authorization URL with kc_idp_hint.""" + query = urlencode( + { + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": "openid", + "kc_idp_hint": provider_alias, + } + ) + return f"{authorization_endpoint}?{query}" + + +def get_start_login_service(request: Request) -> StartLoginService: + """Return the wired start-login helper from application state.""" + service = getattr(request.app.state, "start_login_service", None) + if service is None: + raise HTTPException(status_code=503, detail="start-login service not ready") + return service + + +@start_login_router.post( + "/identity-providers:start-login", + response_model=StartLoginResponse, +) +def start_login_endpoint( + body: StartLoginRequest, + service: StartLoginService = Depends(get_start_login_service), +) -> StartLoginResponse: + """Discover enabled IdPs and return a Keyverse-owned start-login URL.""" + try: + return service.start_login(body) + except AuthorizationPolicyError as exc: + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc diff --git a/services/account_unification/tests/test_application_tokens.py b/services/account_unification/tests/test_application_tokens.py new file mode 100644 index 0000000..065ad0c --- /dev/null +++ b/services/account_unification/tests/test_application_tokens.py @@ -0,0 +1,314 @@ +"""Programmable application token issue, verify, revoke, and rotate contracts.""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app.application_tokens import ( + APPLICATION_TOKEN_NAMESPACE, + ApplicationTokenIssueRequest, + ApplicationTokenRecord, + ApplicationTokenService, + ApplicationTokenVerifyRequest, + get_application_token_service, +) +from app.audit import AuditLogger, InMemoryAuditSink +from app.kv_store import InMemoryKvStore +from app.main import create_app + + +class _Clock: + """Deterministic clock for expiry tests.""" + + def __init__(self, now: float = 1_700_000_000.0) -> None: + """Start the clock at a fixed unix timestamp.""" + self.now = now + + def __call__(self) -> float: + """Return the current test timestamp.""" + return self.now + + +@pytest.fixture +def store() -> InMemoryKvStore: + """Return an empty token store.""" + return InMemoryKvStore() + + +@pytest.fixture +def audit() -> AuditLogger: + """Return an in-memory audit logger.""" + return AuditLogger(InMemoryAuditSink()) + + +@pytest.fixture +def clock() -> _Clock: + """Return a controllable clock.""" + return _Clock() + + +@pytest.fixture +def token_service(store, audit, clock) -> ApplicationTokenService: + """Return a token service with a frozen clock.""" + return ApplicationTokenService(store, audit, clock=clock) + + +@pytest.fixture +def client(token_service, auth_header): + """Return an authenticated app with the token service wired.""" + app = create_app(wire=False) + app.state.application_token_service = token_service + app.state.operator_api_token = "test-operator-token" + with TestClient(app, headers=auth_header) as test_client: + yield test_client + + +ISSUE_BODY = { + "software_unit_id": "naruon-web", + "purpose_code": "machine_api", + "capability_codes": ["api.invoices.read", "api.invoices.write"], + "lifetime_seconds": 3600, + "actor_identity_id": "operator-ida", +} + + +def test_issue_verify_revoke_and_secret_omission(client, audit) -> None: + """Plaintext is returned once; verify works; revoke and list stay secret-free.""" + issued = client.post("/application-tokens", json=ISSUE_BODY) + assert issued.status_code == 200 + body = issued.json() + plaintext = body["plaintext_token"] + token_id = body["application_token_id"] + assert plaintext.startswith("kvt_") + assert body["token_substitute_for_password"] is False + assert body["inherits_org_grants"] is False + assert "Store the plaintext token" in body["application_next_action"] + + listed = client.get("/application-tokens") + fetched = client.get(f"/application-tokens/{token_id}") + assert "plaintext_token" not in listed.json()[0] + assert "token_hash" not in listed.json()[0] + assert listed.json()[0]["token_prefix"] == body["token_prefix"] + assert fetched.json()["application_token_id"] == token_id + assert plaintext not in listed.text + assert "token_hash" not in fetched.text + + verified = client.post( + "/application-tokens:verify", + json={ + "presented_token": plaintext, + "software_unit_id": "naruon-web", + "requested_capability_codes": ["api.invoices.read"], + }, + ) + assert verified.json()["active"] is True + assert verified.json()["effect"] == "allow" + assert plaintext not in verified.text + + revoked = client.post( + f"/application-tokens/{token_id}:revoke", + json={"actor_identity_id": "operator-ida"}, + ) + after = client.post( + "/application-tokens:verify", + json={ + "presented_token": plaintext, + "software_unit_id": "naruon-web", + "requested_capability_codes": ["api.invoices.read"], + }, + ) + assert revoked.json()["lifecycle_status_code"] == "revoked" + assert after.json()["active"] is False + assert after.json()["denial_code"] == "revoked_token" + events = audit.events_for(token_id) + assert {event.event_type for event in events} >= { + "application_token_issued", + "application_token_revoked", + } + + +def test_verify_denies_malformed_unknown_expired_and_capability( + client, clock: _Clock +) -> None: + """Verification is fail-closed and never inherits org-tree grants.""" + issued = client.post("/application-tokens", json=ISSUE_BODY).json() + plaintext = issued["plaintext_token"] + prefix = issued["token_prefix"] + malformed = client.post( + "/application-tokens:verify", + json={ + "presented_token": "not-a-token", + "software_unit_id": "naruon-web", + }, + ) + unknown = client.post( + "/application-tokens:verify", + json={ + "presented_token": f"kvt_{prefix}_wrong-secret-material-value", + "software_unit_id": "naruon-web", + }, + ) + wrong_unit = client.post( + "/application-tokens:verify", + json={ + "presented_token": plaintext, + "software_unit_id": "clearfolio-web", + }, + ) + capability = client.post( + "/application-tokens:verify", + json={ + "presented_token": plaintext, + "software_unit_id": "naruon-web", + "requested_capability_codes": ["api.payroll.admin"], + }, + ) + clock.now += 3601 + expired = client.post( + "/application-tokens:verify", + json={ + "presented_token": plaintext, + "software_unit_id": "naruon-web", + }, + ) + assert malformed.json()["denial_code"] == "malformed_token" + assert unknown.json()["denial_code"] == "unknown_token" + assert wrong_unit.json()["denial_code"] == "software_unit_mismatch" + assert capability.json()["denial_code"] == "capability_denied" + assert expired.json()["denial_code"] == "expired_token" + assert all(item.json()["inherits_org_grants"] is False for item in ( + malformed, unknown, wrong_unit, capability, expired + )) + + +def test_rotate_replaces_token_and_rejects_software_unit_change(client) -> None: + """Rotation revokes the old secret and issues a same-unit replacement.""" + issued = client.post("/application-tokens", json=ISSUE_BODY).json() + rotated = client.post( + f"/application-tokens/{issued['application_token_id']}:rotate", + json=ISSUE_BODY, + ) + assert rotated.status_code == 200 + assert rotated.json()["plaintext_token"] != issued["plaintext_token"] + old = client.post( + "/application-tokens:verify", + json={ + "presented_token": issued["plaintext_token"], + "software_unit_id": "naruon-web", + }, + ) + new = client.post( + "/application-tokens:verify", + json={ + "presented_token": rotated.json()["plaintext_token"], + "software_unit_id": "naruon-web", + }, + ) + assert old.json()["denial_code"] == "revoked_token" + assert new.json()["active"] is True + mismatch = client.post( + f"/application-tokens/{rotated.json()['application_token_id']}:rotate", + json={**ISSUE_BODY, "software_unit_id": "clearfolio-web"}, + ) + assert mismatch.status_code == 400 + + +def test_issue_rejects_password_purposes_and_bounds(client) -> None: + """PATs cannot be password substitutes and stay purpose-bounded.""" + password = client.post( + "/application-tokens", + json={**ISSUE_BODY, "purpose_code": "password"}, + ) + unknown_purpose = client.post( + "/application-tokens", + json={**ISSUE_BODY, "purpose_code": "custom"}, + ) + empty_caps = client.post( + "/application-tokens", + json={**ISSUE_BODY, "capability_codes": []}, + ) + short_life = client.post( + "/application-tokens", + json={**ISSUE_BODY, "lifetime_seconds": 30}, + ) + long_life = client.post( + "/application-tokens", + json={**ISSUE_BODY, "lifetime_seconds": 91 * 24 * 60 * 60}, + ) + assert password.status_code == 400 + assert "password" in password.json()["detail"] + assert unknown_purpose.status_code == 400 + assert empty_caps.status_code == 400 + assert short_life.status_code == 400 + assert long_life.status_code == 400 + + +def test_missing_and_inactive_token_paths(client, store: InMemoryKvStore) -> None: + """Unknown, malformed, and already-revoked token ids fail closed.""" + missing = client.get("/application-tokens/tok-0123456789abcdef") + malformed = client.get("/application-tokens/not-a-token-id") + issued = client.post("/application-tokens", json=ISSUE_BODY).json() + client.post( + f"/application-tokens/{issued['application_token_id']}:revoke", + json={"actor_identity_id": "operator-ida"}, + ) + again = client.post( + f"/application-tokens/{issued['application_token_id']}:revoke", + json={"actor_identity_id": "operator-ida"}, + ) + store.put(APPLICATION_TOKEN_NAMESPACE, "broken", "{") + corrupt_list = client.get("/application-tokens") + assert missing.status_code == 404 + assert malformed.status_code == 400 + assert again.status_code == 409 + assert corrupt_list.status_code == 500 + + +def test_corrupt_single_record_and_control_characters( + token_service: ApplicationTokenService, store: InMemoryKvStore +) -> None: + """Single-record corruption and control characters do not verify.""" + issued = token_service.issue(ApplicationTokenIssueRequest.model_validate(ISSUE_BODY)) + store.put(APPLICATION_TOKEN_NAMESPACE, issued.application_token_id, "{") + with pytest.raises(Exception, match="corrupt"): + token_service.get_token(issued.application_token_id) + denied = token_service.verify( + ApplicationTokenVerifyRequest( + presented_token="kvt_deadbeefcafe_\x00secret", + software_unit_id="naruon-web", + ) + ) + assert denied.denial_code == "malformed_token" + + +def test_stored_hash_length_mismatch_is_unknown( + token_service: ApplicationTokenService, store: InMemoryKvStore +) -> None: + """A stored hash of the wrong length cannot verify as a match.""" + issued = token_service.issue(ApplicationTokenIssueRequest.model_validate(ISSUE_BODY)) + record = ApplicationTokenRecord.model_validate_json( + store.get(APPLICATION_TOKEN_NAMESPACE, issued.application_token_id) + ) + store.put( + APPLICATION_TOKEN_NAMESPACE, + issued.application_token_id, + record.model_copy(update={"token_hash": "short"}).model_dump_json(), + ) + denied = token_service.verify( + ApplicationTokenVerifyRequest( + presented_token=issued.plaintext_token, + software_unit_id="naruon-web", + ) + ) + assert denied.denial_code == "unknown_token" + + +def test_missing_token_service_is_unavailable() -> None: + """Unwired token routes fail closed with HTTP 503.""" + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace())) + with pytest.raises(HTTPException) as captured: + get_application_token_service(request) + assert captured.value.status_code == 503 diff --git a/services/account_unification/tests/test_authorization_plane.py b/services/account_unification/tests/test_authorization_plane.py new file mode 100644 index 0000000..dd0994c --- /dev/null +++ b/services/account_unification/tests/test_authorization_plane.py @@ -0,0 +1,271 @@ +"""HTTP and persistence contracts for the authorization-plane PDP.""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app.authorization_plane import ( + MENU_GRANT_NAMESPACE, + SOFTWARE_UNIT_GRANT_NAMESPACE, + SSO_COMBINATION_NAMESPACE, + AuthorizationPlaneService, + get_authorization_service, +) +from app.kv_store import InMemoryKvStore +from app.main import create_app +from app.org_authorization import AuthorizationGrant, SsoCombinationScope + + +PERSON_PATH = ( + "/group_company/acme/legal_entity/holdco/business_unit/sales/" + "team/alpha/person/jdoe" +) +SNAPSHOT = { + "keyverse_subject": "sub-jdoe-opaque", + "org_path": PERSON_PATH, + "assignment_record_id": "assignment-record-77", + "request_attributes": {"purpose": "hr-review"}, +} +SOFTWARE_GRANT = { + "grant_key": "acme-naruon", + "tenant_deployment_id": "default-deployment", + "grant_scope_code": "software_unit", + "org_path": "/group_company/acme", + "software_unit_id": "naruon-web", + "effect_code": "allow", + "actor_identity_id": "operator-ida", +} +MENU_GRANT = { + "grant_key": "acme-naruon-invoices", + "tenant_deployment_id": "default-deployment", + "grant_scope_code": "menu", + "org_path": "/group_company/acme", + "software_unit_id": "naruon-web", + "menu_path": "/invoices", + "effect_code": "allow", + "capability_codes": ["menu.read", "menu.approve"], + "attribute_constraints": {"purpose": "hr-review"}, + "actor_identity_id": "operator-ida", +} +COMBINATION = { + "combination_name": "finance-suite", + "tenant_deployment_id": "default-deployment", + "software_unit_ids": ["naruon-web", "clearfolio-web"], + "actor_identity_id": "operator-ida", +} + + +@pytest.fixture +def store() -> InMemoryKvStore: + """Return an empty KV store for authorization grants.""" + return InMemoryKvStore() + + +@pytest.fixture +def client(store: InMemoryKvStore, auth_header): + """Return an authenticated app with the authorization plane wired.""" + app = create_app(wire=False) + app.state.authorization_service = AuthorizationPlaneService(store) + app.state.operator_api_token = "test-operator-token" + with TestClient(app, headers=auth_header) as test_client: + yield test_client + + +def test_software_unit_grant_round_trip_and_inherited_decision(client) -> None: + """Operators persist a grant and descendants inherit the allow.""" + created = client.put("/authorization/software-unit-grants/acme-naruon", json=SOFTWARE_GRANT) + assert created.status_code == 200 + listed = client.get("/authorization/software-unit-grants") + fetched = client.get("/authorization/software-unit-grants/acme-naruon") + decision = client.post( + "/authorization/software-units:decide", + json={"snapshot": SNAPSHOT, "software_unit_id": "naruon-web"}, + ) + assert listed.json()[0]["grant_key"] == "acme-naruon" + assert fetched.json()["software_unit_id"] == "naruon-web" + body = decision.json() + assert body["effect"] == "allow" + assert body["decision_code"] == "inherited_allow" + assert body["pep_enforcement_required"] is True + assert "org" not in body["authorization_attributes"] + assert body["authorization_attributes"]["group_company"] == "acme" + + +def test_menu_and_sso_combination_http_surface(client) -> None: + """Menu ABAC/RBAC and SSO combination decisions use stored grants.""" + client.put("/authorization/software-unit-grants/acme-naruon", json=SOFTWARE_GRANT) + client.put( + "/authorization/software-unit-grants/acme-clearfolio", + json={ + **SOFTWARE_GRANT, + "grant_key": "acme-clearfolio", + "software_unit_id": "clearfolio-web", + }, + ) + menu = client.put("/authorization/menu-grants/acme-naruon-invoices", json=MENU_GRANT) + combo = client.put( + "/authorization/sso-combination-scopes/finance-suite", + json=COMBINATION, + ) + allowed_menu = client.post( + "/authorization/menus:decide", + json={ + "snapshot": SNAPSHOT, + "software_unit_id": "naruon-web", + "menu_path": "/invoices/approve", + }, + ) + denied_menu = client.post( + "/authorization/menus:decide", + json={ + "snapshot": {**SNAPSHOT, "request_attributes": {}}, + "software_unit_id": "naruon-web", + "menu_path": "/invoices/approve", + }, + ) + combo_decision = client.post( + "/authorization/sso-combinations:decide", + json={"snapshot": SNAPSHOT, "combination_name": "finance-suite"}, + ) + assert menu.status_code == 200 + assert combo.status_code == 200 + assert client.get("/authorization/menu-grants/acme-naruon-invoices").status_code == 200 + assert client.get("/authorization/sso-combination-scopes/finance-suite").status_code == 200 + assert client.get("/authorization/menu-grants").json()[0]["menu_path"] == "/invoices" + assert client.get("/authorization/sso-combination-scopes").json()[0]["combination_name"] == ( + "finance-suite" + ) + assert allowed_menu.json()["effect"] == "allow" + assert allowed_menu.json()["capability_codes"] == ["menu.read", "menu.approve"] + assert denied_menu.json()["decision_code"] == "attribute_mismatch" + assert combo_decision.json()["effect"] == "allow" + + +def test_authorization_plane_rejects_mismatches_duplicates_and_unknowns(client) -> None: + """Path mismatches, duplicate identities, and missing keys fail closed.""" + mismatch = client.put( + "/authorization/software-unit-grants/other-key", + json=SOFTWARE_GRANT, + ) + wrong_scope = client.put( + "/authorization/software-unit-grants/acme-naruon-invoices", + json=MENU_GRANT, + ) + client.put("/authorization/software-unit-grants/acme-naruon", json=SOFTWARE_GRANT) + duplicate = client.put( + "/authorization/software-unit-grants/acme-naruon-dup", + json={**SOFTWARE_GRANT, "grant_key": "acme-naruon-dup"}, + ) + missing = client.get("/authorization/software-unit-grants/missing-grant") + missing_delete = client.delete("/authorization/software-unit-grants/missing-grant") + combo_mismatch = client.put( + "/authorization/sso-combination-scopes/other-name", + json=COMBINATION, + ) + missing_combo = client.get("/authorization/sso-combination-scopes/missing-combo") + missing_combo_delete = client.delete( + "/authorization/sso-combination-scopes/missing-combo" + ) + missing_menu = client.get("/authorization/menu-grants/missing-menu") + missing_menu_delete = client.delete("/authorization/menu-grants/missing-menu") + assert mismatch.status_code == 400 + assert wrong_scope.status_code == 400 + assert duplicate.status_code == 409 + assert missing.status_code == 404 + assert missing_delete.status_code == 404 + assert combo_mismatch.status_code == 400 + assert missing_combo.status_code == 404 + assert missing_combo_delete.status_code == 404 + assert missing_menu.status_code == 404 + assert missing_menu_delete.status_code == 404 + + +def test_authorization_plane_delete_and_replace(client) -> None: + """Deletes remove grants and combinations; replace keeps one identity.""" + client.put("/authorization/software-unit-grants/acme-naruon", json=SOFTWARE_GRANT) + client.put("/authorization/menu-grants/acme-naruon-invoices", json=MENU_GRANT) + client.put("/authorization/sso-combination-scopes/finance-suite", json=COMBINATION) + replaced = client.put( + "/authorization/software-unit-grants/acme-naruon", + json={**SOFTWARE_GRANT, "effect_code": "deny"}, + ) + assert replaced.json()["effect_code"] == "deny" + assert client.delete("/authorization/software-unit-grants/acme-naruon").status_code == 204 + assert client.delete("/authorization/menu-grants/acme-naruon-invoices").status_code == 204 + assert client.delete("/authorization/sso-combination-scopes/finance-suite").status_code == 204 + assert client.get("/authorization/software-unit-grants").json() == [] + + +def test_corrupt_store_fails_closed_over_http(client, store: InMemoryKvStore) -> None: + """Corrupt grant or combination rows never silently authorize over HTTP.""" + store.put(SOFTWARE_UNIT_GRANT_NAMESPACE, "broken", "{") + store.put(MENU_GRANT_NAMESPACE, "broken-menu", "{") + store.put(SSO_COMBINATION_NAMESPACE, "broken-combo", "{") + assert client.get("/authorization/software-unit-grants").status_code == 500 + assert client.get("/authorization/menu-grants").status_code == 500 + assert client.get("/authorization/sso-combination-scopes").status_code == 500 + + +def test_corrupt_store_fails_closed(store: InMemoryKvStore) -> None: + """Corrupt grant or combination rows never silently authorize.""" + service = AuthorizationPlaneService(store) + store.put(SOFTWARE_UNIT_GRANT_NAMESPACE, "broken", "{") + store.put(MENU_GRANT_NAMESPACE, "broken-menu", "{") + store.put(SSO_COMBINATION_NAMESPACE, "broken-combo", "{") + with pytest.raises(Exception, match="corrupt"): + service.list_software_unit_grants() + with pytest.raises(Exception, match="corrupt"): + service.list_menu_grants() + with pytest.raises(Exception, match="corrupt"): + service.list_combinations() + + +def test_authorization_service_missing_is_unavailable() -> None: + """Unwired authorization routes fail closed with HTTP 503.""" + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace())) + with pytest.raises(HTTPException) as captured: + get_authorization_service(request) + assert captured.value.status_code == 503 + + +def test_decision_endpoints_reject_invalid_snapshots(client) -> None: + """Decision routes validate snapshots before consulting grants.""" + response = client.post( + "/authorization/software-units:decide", + json={ + "snapshot": {**SNAPSHOT, "org_path": "/org/acme"}, + "software_unit_id": "naruon-web", + }, + ) + combo = client.post( + "/authorization/sso-combinations:decide", + json={"snapshot": SNAPSHOT, "combination_name": "missing-combo"}, + ) + menu = client.post( + "/authorization/menus:decide", + json={ + "snapshot": SNAPSHOT, + "software_unit_id": "naruon-web", + "menu_path": "invoices", + }, + ) + assert response.status_code == 400 + assert combo.status_code == 404 + assert menu.status_code == 400 + + +def test_direct_service_helpers_cover_getters(store: InMemoryKvStore) -> None: + """Service getters and combination helpers are reachable without HTTP.""" + service = AuthorizationPlaneService(store) + grant = AuthorizationGrant.model_validate(SOFTWARE_GRANT) + menu = AuthorizationGrant.model_validate(MENU_GRANT) + combination = SsoCombinationScope.model_validate(COMBINATION) + service.put_software_unit_grant("acme-naruon", grant) + service.put_menu_grant("acme-naruon-invoices", menu) + service.put_combination("finance-suite", combination) + assert service.get_software_unit_grant("acme-naruon").grant_key == "acme-naruon" + assert service.get_menu_grant("acme-naruon-invoices").menu_path == "/invoices" + assert service.get_combination("finance-suite").combination_name == "finance-suite" diff --git a/services/account_unification/tests/test_org_authorization.py b/services/account_unification/tests/test_org_authorization.py new file mode 100644 index 0000000..a1e11f9 --- /dev/null +++ b/services/account_unification/tests/test_org_authorization.py @@ -0,0 +1,414 @@ +"""RED/GREEN contracts for hierarchical authorization attributes and inheritance.""" +from __future__ import annotations + +import pytest + +from app.errors import AuthorizationPolicyError +from app.org_authorization import ( + LINEAGEWEAVE_RESERVED_CLAIM_NAMES, + ORG_PATH_LEVELS, + AuthorizationDecisionCode, + AuthorizationEffect, + AuthorizationGrant, + AssignmentSnapshot, + SsoCombinationScope, + decide_menu, + decide_software_unit, + decide_sso_combination, + parse_org_path, + validate_grant, +) + + +PERSON_PATH = ( + "/group_company/acme/legal_entity/holdco/business_unit/sales/" + "team/alpha/person/jdoe" +) +SUBSIDIARY_PERSON_PATH = ( + "/group_company/acme/legal_entity/subsidiary/business_unit/ops/" + "team/beta/person/jane" +) + + +def _snapshot(org_path: str = PERSON_PATH, **updates) -> AssignmentSnapshot: + """Return one Orgmetra assignment snapshot bound to a Keyverse subject.""" + values = { + "keyverse_subject": "sub-jdoe-opaque", + "org_path": org_path, + "assignment_record_id": "assignment-record-77", + "request_attributes": {"purpose": "hr-review"}, + } + values.update(updates) + return AssignmentSnapshot.model_validate(values) + + +def _software_grant( + org_path: str, + *, + grant_key: str = "acme-naruon", + software_unit_id: str = "naruon-web", + effect_code: str = "allow", +) -> AuthorizationGrant: + """Return one software-unit grant at an org node.""" + return AuthorizationGrant( + grant_key=grant_key, + tenant_deployment_id="default-deployment", + grant_scope_code="software_unit", + org_path=org_path, + software_unit_id=software_unit_id, + effect_code=effect_code, + actor_identity_id="operator-ida", + ) + + +def _menu_grant( + org_path: str, + *, + grant_key: str = "acme-naruon-invoices", + menu_path: str = "/invoices", + effect_code: str = "allow", + capability_codes: list[str] | None = None, + attribute_constraints: dict[str, str] | None = None, +) -> AuthorizationGrant: + """Return one menu grant with optional ABAC constraints.""" + return AuthorizationGrant( + grant_key=grant_key, + tenant_deployment_id="default-deployment", + grant_scope_code="menu", + org_path=org_path, + software_unit_id="naruon-web", + menu_path=menu_path, + effect_code=effect_code, + capability_codes=capability_codes or (["menu.read"] if effect_code == "allow" else []), + attribute_constraints=attribute_constraints or {}, + actor_identity_id="operator-ida", + ) + + +def test_org_path_levels_are_hierarchical_and_not_lineageweave_names() -> None: + """Hierarchical attributes stay distinct from PR #100 claim names.""" + parsed = parse_org_path(PERSON_PATH) + assert [level for level, _identifier in parsed.segments] == list(ORG_PATH_LEVELS) + attributes = parsed.attribute_map() + assert set(LINEAGEWEAVE_RESERVED_CLAIM_NAMES).isdisjoint(attributes) + assert attributes["group_company"] == "acme" + assert attributes["org_path"] == PERSON_PATH + + +def test_ancestor_allow_inherits_to_person_unless_restricted() -> None: + """A group-company allow applies to descendant persons.""" + grants = [_software_grant("/group_company/acme")] + decision = decide_software_unit(grants, _snapshot(), "naruon-web") + assert decision.effect is AuthorizationEffect.ALLOW + assert decision.decision_code is AuthorizationDecisionCode.INHERITED_ALLOW + assert decision.inherited is True + assert decision.winning_org_path == "/group_company/acme" + assert decision.pep_enforcement_required is True + + +def test_more_specific_deny_restricts_inherited_allow() -> None: + """A legal-entity deny overrides an ancestor allow for that subtree only.""" + grants = [ + _software_grant("/group_company/acme", grant_key="acme-allow"), + _software_grant( + "/group_company/acme/legal_entity/subsidiary", + grant_key="subsidiary-deny", + effect_code="deny", + ), + ] + holdco = decide_software_unit(grants, _snapshot(PERSON_PATH), "naruon-web") + subsidiary = decide_software_unit( + grants, _snapshot(SUBSIDIARY_PERSON_PATH, keyverse_subject="sub-jane"), "naruon-web" + ) + assert holdco.effect is AuthorizationEffect.ALLOW + assert subsidiary.effect is AuthorizationEffect.DENY + assert subsidiary.decision_code is AuthorizationDecisionCode.INHERITED_DENY + + +def test_absent_grant_is_default_deny() -> None: + """No matching software-unit grant fails closed.""" + decision = decide_software_unit([], _snapshot(), "naruon-web") + assert decision.effect is AuthorizationEffect.DENY + assert decision.decision_code is AuthorizationDecisionCode.DEFAULT_DENY + + +def test_exact_path_allow_is_specific_not_inherited() -> None: + """A grant at the person's node is a specific allow.""" + grants = [_software_grant(PERSON_PATH, grant_key="person-allow")] + decision = decide_software_unit(grants, _snapshot(), "naruon-web") + assert decision.decision_code is AuthorizationDecisionCode.SPECIFIC_ALLOW + assert decision.inherited is False + + +def test_unrelated_software_unit_does_not_authorize() -> None: + """Software-unit ACL is exact per relying party.""" + grants = [_software_grant("/group_company/acme", software_unit_id="clearfolio-web")] + decision = decide_software_unit(grants, _snapshot(), "naruon-web") + assert decision.effect is AuthorizationEffect.DENY + + +def test_menu_requires_software_unit_allow() -> None: + """Menu ABAC/RBAC cannot bypass a software-unit deny.""" + grants = [_menu_grant("/group_company/acme")] + decision = decide_menu(grants, _snapshot(), "naruon-web", "/invoices") + assert decision.effect is AuthorizationEffect.DENY + assert decision.decision_code is AuthorizationDecisionCode.SOFTWARE_UNIT_DENIED + + +def test_menu_inherit_and_more_specific_menu_deny() -> None: + """Menu grants inherit down the tree and more-specific menu paths restrict.""" + grants = [ + _software_grant("/group_company/acme"), + _menu_grant( + "/group_company/acme", + capability_codes=["menu.read", "menu.approve"], + ), + _menu_grant( + "/group_company/acme/legal_entity/subsidiary", + grant_key="payroll-deny", + menu_path="/invoices/payroll", + effect_code="deny", + ), + ] + invoices = decide_menu(grants, _snapshot(), "naruon-web", "/invoices/approve") + payroll = decide_menu( + grants, + _snapshot(SUBSIDIARY_PERSON_PATH, keyverse_subject="sub-jane"), + "naruon-web", + "/invoices/payroll", + ) + assert invoices.effect is AuthorizationEffect.ALLOW + assert invoices.capability_codes == ["menu.read", "menu.approve"] + assert invoices.inherited is True + assert payroll.effect is AuthorizationEffect.DENY + assert payroll.capability_codes == [] + + +def test_menu_abac_constraint_mismatch_denies() -> None: + """ABAC constraints are evaluated before remaining menu capabilities.""" + grants = [ + _software_grant("/group_company/acme"), + _menu_grant( + "/group_company/acme", + attribute_constraints={"purpose": "hr-review", "sensitivity": "internal"}, + ), + ] + allowed = decide_menu( + grants, + _snapshot(request_attributes={"purpose": "hr-review", "sensitivity": "internal"}), + "naruon-web", + "/invoices", + ) + denied = decide_menu( + grants, + _snapshot(request_attributes={"purpose": "hr-review"}), + "naruon-web", + "/invoices", + ) + assert allowed.effect is AuthorizationEffect.ALLOW + assert denied.effect is AuthorizationEffect.DENY + assert denied.decision_code is AuthorizationDecisionCode.ATTRIBUTE_MISMATCH + + +def test_sso_combination_requires_every_member_allowed() -> None: + """One Keyverse session may cover a combination only when every RP is allowed.""" + grants = [ + _software_grant("/group_company/acme", grant_key="naruon-allow"), + _software_grant( + "/group_company/acme", + grant_key="clearfolio-allow", + software_unit_id="clearfolio-web", + ), + ] + combination = SsoCombinationScope( + combination_name="finance-suite", + tenant_deployment_id="default-deployment", + software_unit_ids=["naruon-web", "clearfolio-web"], + actor_identity_id="operator-ida", + ) + allowed = decide_sso_combination(grants, _snapshot(), combination) + denied = decide_sso_combination( + grants, + _snapshot(), + combination.model_copy(update={"software_unit_ids": ["naruon-web", "sdp-web"]}), + ) + assert allowed.effect is AuthorizationEffect.ALLOW + assert allowed.decision_code is AuthorizationDecisionCode.COMBINATION_ALLOW + assert denied.effect is AuthorizationEffect.DENY + assert denied.decision_code is AuthorizationDecisionCode.COMBINATION_DENIED + + +def test_reserved_lineageweave_names_are_rejected_on_org_and_attributes() -> None: + """role/org/workspace cannot be smuggled in as hierarchical names.""" + with pytest.raises(AuthorizationPolicyError, match="reserved"): + parse_org_path("/org/acme") + with pytest.raises(AuthorizationPolicyError, match="LineageWeave"): + validate_grant( + _menu_grant( + "/group_company/acme", + attribute_constraints={"role": "member"}, + ) + ) + + +@pytest.mark.parametrize( + "raw_path", + [ + "", + "group_company/acme", + "/group_company/acme/", + "/group_company", + "/legal_entity/holdco", + "/group_company/acme/team/alpha", + "/group_company/acme/legal_entity/holdco/business_unit/sales/" + "team/alpha/person/jdoe/extra/layer", + "/group_company/ACME", + ], +) +def test_invalid_org_paths_fail_closed(raw_path: str) -> None: + """Malformed or skipped org levels never authorize.""" + with pytest.raises(AuthorizationPolicyError): + parse_org_path(raw_path) + + +def test_invalid_grant_shapes_fail_closed() -> None: + """Closed grant policy rejects scope, effect, and deny-payload mistakes.""" + with pytest.raises(AuthorizationPolicyError, match="grant_scope"): + validate_grant(_software_grant("/group_company/acme").model_copy( + update={"grant_scope_code": "wildcard"} + )) + with pytest.raises(AuthorizationPolicyError, match="effect_code"): + validate_grant(_software_grant("/group_company/acme").model_copy( + update={"effect_code": "maybe"} + )) + with pytest.raises(AuthorizationPolicyError, match="menu_path"): + validate_grant(_software_grant("/group_company/acme").model_copy( + update={"menu_path": "/invoices"} + )) + with pytest.raises(AuthorizationPolicyError, match="attribute_constraints"): + validate_grant(_software_grant("/group_company/acme", effect_code="deny").model_copy( + update={"attribute_constraints": {"purpose": "hr-review"}} + )) + with pytest.raises(AuthorizationPolicyError, match="capability_codes"): + validate_grant(_software_grant("/group_company/acme", effect_code="deny").model_copy( + update={"capability_codes": ["menu.read"]} + )) + with pytest.raises(AuthorizationPolicyError, match="menu grants require"): + validate_grant(_menu_grant("/group_company/acme").model_copy(update={"menu_path": None})) + with pytest.raises(AuthorizationPolicyError, match="deny grants cannot"): + validate_grant( + _menu_grant( + "/group_company/acme", + effect_code="deny", + capability_codes=["menu.read"], + ) + ) + + +def test_specific_deny_and_menu_default_deny() -> None: + """Exact-node deny and missing menu grants remain fail-closed.""" + grants = [ + _software_grant(PERSON_PATH, grant_key="person-deny", effect_code="deny"), + ] + software = decide_software_unit(grants, _snapshot(), "naruon-web") + assert software.decision_code is AuthorizationDecisionCode.SPECIFIC_DENY + menu_grants = [_software_grant("/group_company/acme")] + menu = decide_menu(menu_grants, _snapshot(), "naruon-web", "/settings") + assert menu.decision_code is AuthorizationDecisionCode.DEFAULT_DENY + + +def test_closed_slug_capability_and_attribute_bounds() -> None: + """Hostile slugs, capabilities, and ABAC values are rejected.""" + from app.org_authorization import ( + parse_menu_path, + validate_capability_codes, + validate_combination, + validate_snapshot, + validate_slug, + ) + + with pytest.raises(AuthorizationPolicyError): + validate_slug("Not a slug", field_name="grant_key") + with pytest.raises(AuthorizationPolicyError): + validate_capability_codes(["menu.read"] * 17) + with pytest.raises(AuthorizationPolicyError): + validate_capability_codes(["BAD"]) + with pytest.raises(AuthorizationPolicyError): + validate_capability_codes(["menu.read", "menu.read"]) + with pytest.raises(AuthorizationPolicyError): + parse_menu_path("") + with pytest.raises(AuthorizationPolicyError): + parse_menu_path("invoices") + with pytest.raises(AuthorizationPolicyError): + parse_menu_path("/invoices/") + with pytest.raises(AuthorizationPolicyError): + parse_menu_path("/" + "/".join(f"seg{index}" for index in range(9))) + with pytest.raises(AuthorizationPolicyError): + parse_menu_path("x" * 257) + with pytest.raises(AuthorizationPolicyError): + parse_org_path("x" * 513) + with pytest.raises(AuthorizationPolicyError): + parse_org_path(None) # type: ignore[arg-type] + with pytest.raises(AuthorizationPolicyError, match="purpose"): + validate_grant( + _menu_grant("/group_company/acme", attribute_constraints={"department": "sales"}) + ) + with pytest.raises(AuthorizationPolicyError, match="non-empty"): + validate_grant( + _menu_grant("/group_company/acme", attribute_constraints={"purpose": " "}) + ) + with pytest.raises(AuthorizationPolicyError, match="closed bound"): + validate_grant( + _menu_grant( + "/group_company/acme", + attribute_constraints={"purpose": "p" * 65}, + ) + ) + with pytest.raises(AuthorizationPolicyError, match="between 2 and 16"): + validate_combination( + SsoCombinationScope( + combination_name="solo", + tenant_deployment_id="default-deployment", + software_unit_ids=["naruon-web"], + actor_identity_id="operator-ida", + ) + ) + with pytest.raises(AuthorizationPolicyError, match="between 2 and 16"): + validate_combination( + SsoCombinationScope( + combination_name="too-many", + tenant_deployment_id="default-deployment", + software_unit_ids=[f"app-{index:02d}" for index in range(17)], + actor_identity_id="operator-ida", + ) + ) + with pytest.raises(AuthorizationPolicyError, match="deny grants cannot"): + validate_grant( + _menu_grant( + "/group_company/acme", + effect_code="deny", + attribute_constraints={"purpose": "hr-review"}, + ) + ) + with pytest.raises(AuthorizationPolicyError, match="unique"): + validate_combination( + SsoCombinationScope( + combination_name="dupes", + tenant_deployment_id="default-deployment", + software_unit_ids=["naruon-web", "naruon-web"], + actor_identity_id="operator-ida", + ) + ) + with pytest.raises(AuthorizationPolicyError, match="opaque"): + validate_snapshot(_snapshot(keyverse_subject="has space")) + assert parse_menu_path("/invoices/approve") == "/invoices/approve" + + +def test_menu_grant_without_matching_prefix_is_ignored() -> None: + """A grant for a different menu tree does not authorize the requested menu.""" + grants = [ + _software_grant("/group_company/acme"), + _menu_grant("/group_company/acme", menu_path="/settings"), + ] + decision = decide_menu(grants, _snapshot(), "naruon-web", "/invoices") + assert decision.decision_code is AuthorizationDecisionCode.DEFAULT_DENY diff --git a/services/account_unification/tests/test_start_login.py b/services/account_unification/tests/test_start_login.py new file mode 100644 index 0000000..f1f93bb --- /dev/null +++ b/services/account_unification/tests/test_start_login.py @@ -0,0 +1,302 @@ +"""Start-login helper contracts: local discovery, no metadata fetch.""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app.config import ServiceConfig +from app.federation import FEDERATION_PROVIDER_NAMESPACE, IdentityProviderRegistration +from app.kv_store import InMemoryKvStore +from app.main import create_app +from app.start_login import StartLoginService, get_start_login_service + + +def _oidc_provider_json(*, enabled: bool = True, alias: str = "employer-adfs") -> str: + """Return one stored OIDC provider registration.""" + return IdentityProviderRegistration.model_validate( + { + "provider_alias": alias, + "display_name": "Employer ADFS", + "provider_id": "oidc", + "enabled": enabled, + "trust_email": False, + "provider_config": { + "issuer": "https://login.employer.example/tenant", + "authorizationUrl": "https://login.employer.example/oauth2/authorize", + "tokenUrl": "https://login.employer.example/oauth2/token", + "jwksUrl": "https://login.employer.example/oidc/jwks", + "clientId": "keyverse", + "clientSecret": "secret", + "clientAuthMethod": "client_secret_basic", + "validateSignature": "true", + "useJwksUrl": "true", + "pkceEnabled": "true", + "pkceMethod": "S256", + "defaultScope": "openid profile email", + }, + } + ).model_dump_json() + + +@pytest.fixture +def store() -> InMemoryKvStore: + """Return a federation registry with one enabled employer IdP.""" + backend = InMemoryKvStore() + backend.put( + FEDERATION_PROVIDER_NAMESPACE, + "employer-adfs", + _oidc_provider_json(), + ) + return backend + + +@pytest.fixture +def config() -> ServiceConfig: + """Return local Keycloak issuer configuration.""" + return ServiceConfig( + keycloak_server_url="http://keycloak.test", + keycloak_realm="cwl", + keycloak_client_id="account-unification-svc", + keycloak_client_secret="test-secret", + operator_api_token="test-operator-token", + ) + + +@pytest.fixture +def client(store: InMemoryKvStore, config: ServiceConfig, auth_header): + """Return an authenticated app with the start-login helper wired.""" + app = create_app(wire=False) + app.state.start_login_service = StartLoginService(store, config) + app.state.operator_api_token = config.operator_api_token + with TestClient(app, headers=auth_header) as test_client: + yield test_client + + +def test_start_login_selects_single_enabled_provider_without_keycloak(client) -> None: + """One enabled IdP becomes kc_idp_hint; no metadata fetch occurs.""" + response = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback", + }, + ) + assert response.status_code == 200 + body = response.json() + assert body["metadata_fetch_performed"] is False + assert body["federation_ownership"] == "keyverse" + assert body["selected_provider_alias"] == "employer-adfs" + assert "kc_idp_hint=employer-adfs" in body["start_login_url"] + assert body["authorization_endpoint"].endswith("/realms/cwl/protocol/openid-connect/auth") + assert "clientSecret" not in response.text + assert "Add PKCE S256" in body["application_next_action"] + + +def test_start_login_requires_hint_when_multiple_providers( + store: InMemoryKvStore, client +) -> None: + """Multiple enabled IdPs return discovery until the RP supplies a hint.""" + store.put( + FEDERATION_PROVIDER_NAMESPACE, + "partner-oidc", + _oidc_provider_json(alias="partner-oidc"), + ) + discovered = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback", + }, + ) + hinted = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback", + "provider_alias_hint": "partner-oidc", + "public_issuer_url": "https://idp.example/realms/cwl", + }, + ) + unknown = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback", + "provider_alias_hint": "missing-idp", + }, + ) + assert discovered.status_code == 200 + assert discovered.json()["selected_provider_alias"] is None + assert discovered.json()["start_login_url"] is None + assert {item["provider_alias"] for item in discovered.json()["identity_providers"]} == { + "employer-adfs", + "partner-oidc", + } + assert hinted.json()["selected_provider_alias"] == "partner-oidc" + assert hinted.json()["authorization_endpoint"].startswith("https://idp.example/") + assert unknown.status_code == 404 + + +def test_start_login_rejects_discovery_urls_and_unsafe_redirects(client, store) -> None: + """The helper refuses discovery documents, HTTP redirects, and disabled IdPs.""" + store.put( + FEDERATION_PROVIDER_NAMESPACE, + "disabled-idp", + _oidc_provider_json(alias="disabled-idp", enabled=False), + ) + discovery = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback", + "public_issuer_url": "https://idp.example/.well-known/openid-configuration", + }, + ) + metadata = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback", + "public_issuer_url": "https://idp.example/metadataUrl", + }, + ) + http_redirect = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "http://naruon.example/callback", + }, + ) + mismatched = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "other-web", + "redirect_uri": "https://naruon.example/callback", + }, + ) + aliases = { + item["provider_alias"] + for item in client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback", + }, + ).json()["identity_providers"] + } + assert discovery.status_code == 400 + assert metadata.status_code == 400 + assert http_redirect.status_code == 400 + assert mismatched.status_code == 400 + assert "disabled-idp" not in aliases + + +def test_start_login_public_issuer_and_redirect_bounds(client) -> None: + """Issuer and redirect inputs stay closed and local.""" + credentials = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback", + "public_issuer_url": "https://user:pass@idp.example/realms/cwl", + }, + ) + query = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback", + "public_issuer_url": "https://idp.example/realms/cwl?x=1", + }, + ) + fragment_redirect = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback#frag", + }, + ) + oversized = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/" + ("a" * 2048), + }, + ) + auth_endpoint = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback", + "public_issuer_url": ( + "https://idp.example/realms/cwl/protocol/openid-connect/auth" + ), + }, + ) + assert credentials.status_code == 400 + assert query.status_code == 400 + assert fragment_redirect.status_code == 400 + assert oversized.status_code == 400 + assert auth_endpoint.json()["authorization_endpoint"].endswith( + "/protocol/openid-connect/auth" + ) + ftp = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback", + "public_issuer_url": "ftp://idp.example/realms/cwl", + }, + ) + assert ftp.status_code == 400 + + +def test_empty_registry_returns_discovery_without_start_url( + config: ServiceConfig, auth_header +) -> None: + """An empty local registry does not invent an identity provider.""" + app = create_app(wire=False) + app.state.start_login_service = StartLoginService(InMemoryKvStore(), config) + app.state.operator_api_token = config.operator_api_token + with TestClient(app, headers=auth_header) as client: + response = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback", + }, + ) + assert response.status_code == 200 + assert response.json()["identity_providers"] == [] + assert response.json()["start_login_url"] is None + + +def test_corrupt_provider_store_and_missing_service(store: InMemoryKvStore, config) -> None: + """Corrupt registry rows and missing wiring fail closed.""" + store.put(FEDERATION_PROVIDER_NAMESPACE, "broken", "{") + service = StartLoginService(store, config) + with pytest.raises(Exception, match="corrupt"): + service.discover_enabled_providers() + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace())) + with pytest.raises(HTTPException) as captured: + get_start_login_service(request) + assert captured.value.status_code == 503 From 363b26c08129f970163512a88ea16c6a7a5948b7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 02:26:09 +0000 Subject: [PATCH 02/14] test(authorization): close remaining PDP and PAT coverage branches Cover omitted assignment snapshots, failed menu grant PUTs, and policy errors on token verify so statement and branch coverage stay at 100% on the authorization slice. Co-authored-by: Seongho Bae --- .../account_unification/tests/test_application_tokens.py | 8 ++++++++ .../account_unification/tests/test_authorization_plane.py | 5 +++++ .../account_unification/tests/test_org_authorization.py | 7 +++++++ 3 files changed, 20 insertions(+) diff --git a/services/account_unification/tests/test_application_tokens.py b/services/account_unification/tests/test_application_tokens.py index 065ad0c..36b52db 100644 --- a/services/account_unification/tests/test_application_tokens.py +++ b/services/account_unification/tests/test_application_tokens.py @@ -174,6 +174,14 @@ def test_verify_denies_malformed_unknown_expired_and_capability( "software_unit_id": "naruon-web", }, ) + invalid_unit = client.post( + "/application-tokens:verify", + json={ + "presented_token": plaintext, + "software_unit_id": "Not a slug", + }, + ) + assert invalid_unit.status_code == 400 assert malformed.json()["denial_code"] == "malformed_token" assert unknown.json()["denial_code"] == "unknown_token" assert wrong_unit.json()["denial_code"] == "software_unit_mismatch" diff --git a/services/account_unification/tests/test_authorization_plane.py b/services/account_unification/tests/test_authorization_plane.py index dd0994c..964ba6e 100644 --- a/services/account_unification/tests/test_authorization_plane.py +++ b/services/account_unification/tests/test_authorization_plane.py @@ -169,6 +169,10 @@ def test_authorization_plane_rejects_mismatches_duplicates_and_unknowns(client) missing_combo_delete = client.delete( "/authorization/sso-combination-scopes/missing-combo" ) + menu_mismatch = client.put( + "/authorization/menu-grants/other-menu", + json=MENU_GRANT, + ) missing_menu = client.get("/authorization/menu-grants/missing-menu") missing_menu_delete = client.delete("/authorization/menu-grants/missing-menu") assert mismatch.status_code == 400 @@ -179,6 +183,7 @@ def test_authorization_plane_rejects_mismatches_duplicates_and_unknowns(client) assert combo_mismatch.status_code == 400 assert missing_combo.status_code == 404 assert missing_combo_delete.status_code == 404 + assert menu_mismatch.status_code == 400 assert missing_menu.status_code == 404 assert missing_menu_delete.status_code == 404 diff --git a/services/account_unification/tests/test_org_authorization.py b/services/account_unification/tests/test_org_authorization.py index a1e11f9..9b30af5 100644 --- a/services/account_unification/tests/test_org_authorization.py +++ b/services/account_unification/tests/test_org_authorization.py @@ -401,6 +401,13 @@ def test_closed_slug_capability_and_attribute_bounds() -> None: ) with pytest.raises(AuthorizationPolicyError, match="opaque"): validate_snapshot(_snapshot(keyverse_subject="has space")) + omitted = validate_snapshot( + AssignmentSnapshot( + keyverse_subject="sub-no-assignment", + org_path="/group_company/acme", + ) + ) + assert omitted.assignment_record_id is None assert parse_menu_path("/invoices/approve") == "/invoices/approve" From 44fb43428eab0075b9e5ee114a5ade56bb18eec2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 02:29:27 +0000 Subject: [PATCH 03/14] build(deps): resync uv.lock to pyproject coverage and setuptools pins Hosted account-unification-tests failed at uv sync --locked because uv.lock still recorded coverage 7.15.2 and setuptools 83.0.0 after pyproject.toml moved to 7.15.4 and 84.0.0. Refresh the lock metadata only so CI can install the already-reviewed pins. Co-authored-by: Seongho Bae --- services/account_unification/uv.lock | 200 +++++++++++++++------------ 1 file changed, 115 insertions(+), 85 deletions(-) diff --git a/services/account_unification/uv.lock b/services/account_unification/uv.lock index 1ed80ca..bf57988 100644 --- a/services/account_unification/uv.lock +++ b/services/account_unification/uv.lock @@ -172,86 +172,116 @@ wheels = [ [[package]] name = "coverage" -version = "7.15.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, - { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, - { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, - { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, - { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, - { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, - { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, - { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, - { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, - { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, - { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, - { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, - { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, - { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, - { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, - { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, - { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, - { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, - { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, - { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, - { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, - { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, - { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, - { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, - { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, - { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, - { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, - { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, - { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, - { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, - { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, - { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, - { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, - { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, - { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, - { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, - { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, - { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, - { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, - { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, - { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, - { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, - { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, - { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, - { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, - { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, - { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, - { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, - { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, - { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, - { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, - { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, - { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, - { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, - { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, - { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, - { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, - { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, - { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, - { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, - { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, - { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, - { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, - { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/66/edcec7d7a0b524aa8923e22925fde6fe50ce005a113dca13ae1581455c4c/coverage-7.15.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490", size = 222367, upload-time = "2026-08-06T13:47:15.578Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c6/ab8de429e2e8548faf58ec7e1674a4ce00414b4113942d3fe87109cf0f68/coverage-7.15.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e", size = 222874, upload-time = "2026-08-06T13:47:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/3b7b49587e8a6b9af79b3eb468d443d6042b6d65b47aa26586846a0d6566/coverage-7.15.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7", size = 253287, upload-time = "2026-08-06T13:47:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/fb/65/ec03b743a2a229c72cc1eff3e57be9d3564e9c6b4d5aba2d70744a3fc0d8/coverage-7.15.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6", size = 255199, upload-time = "2026-08-06T13:47:19.765Z" }, + { url = "https://files.pythonhosted.org/packages/41/4b/5163729e4b6582d61975cfd3ccab45b4ec53e21cf156d9941cb025188468/coverage-7.15.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d", size = 257308, upload-time = "2026-08-06T13:47:21.206Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/2167a0f08fb87d702fa423a48578a32865464b7c9e1db3911ad7812ab414/coverage-7.15.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce", size = 259268, upload-time = "2026-08-06T13:47:22.503Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e5/68eebae3053dbd48508edea559c21b23fbdf3460784f91370c83a86a6acd/coverage-7.15.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7", size = 253392, upload-time = "2026-08-06T13:47:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/1a/46/fd4ced40a2b691c774e515c9b69500bfa64c7960b67fcee4b2f6fad97fc3/coverage-7.15.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b", size = 255001, upload-time = "2026-08-06T13:47:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/53/25/ae2e5fa710bb6957a9aadeb9e3598d3b3e4af6587ce857ad42e8639a3f30/coverage-7.15.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc", size = 253061, upload-time = "2026-08-06T13:47:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/d7/31/67ddc0365db2c6e93ac8580bc4bbc50f65273262f973f63ebcdbc15c0495/coverage-7.15.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571", size = 256831, upload-time = "2026-08-06T13:47:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/82b8fd18f57fb13f12d98fe874995bb2c4f9f17be8aff762c426323fdb96/coverage-7.15.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719", size = 252781, upload-time = "2026-08-06T13:47:29.712Z" }, + { url = "https://files.pythonhosted.org/packages/0a/eb/6c74ef4dd12b252e573c49bdef9e2ac265bf3dbb79b8d7feb3266e084e9e/coverage-7.15.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7", size = 253692, upload-time = "2026-08-06T13:47:31.192Z" }, + { url = "https://files.pythonhosted.org/packages/5a/66/eb9aed1c3fd2d36ee00eb173f434b14fa607fc056739c9a89ff4244010ea/coverage-7.15.4-cp311-cp311-win32.whl", hash = "sha256:69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e", size = 224461, upload-time = "2026-08-06T13:47:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6d/81fa4161dfb3ed9d74e40d58647eff83a56b7612e78352581280fce2f477/coverage-7.15.4-cp311-cp311-win_amd64.whl", hash = "sha256:63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc", size = 224937, upload-time = "2026-08-06T13:47:34.205Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c1/d8dacf683c6cad3cf85ce68fd3774a6774ec402128822fdfaed920f11e6a/coverage-7.15.4-cp311-cp311-win_arm64.whl", hash = "sha256:ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890", size = 224479, upload-time = "2026-08-06T13:47:36.118Z" }, + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, ] [[package]] @@ -335,7 +365,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "coverage", marker = "extra == 'dev'", specifier = "==7.15.2" }, + { name = "coverage", marker = "extra == 'dev'", specifier = "==7.15.4" }, { name = "cryptography", specifier = "==50.0.0" }, { name = "fastapi", specifier = "==0.141.1" }, { name = "httpx", specifier = "==0.28.1" }, @@ -345,7 +375,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = "==9.1.1" }, { name = "pyyaml", specifier = "==6.0.3" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.1" }, - { name = "setuptools", marker = "extra == 'dev'", specifier = "==83.0.0" }, + { name = "setuptools", marker = "extra == 'dev'", specifier = "==84.0.0" }, { name = "uvicorn", specifier = "==0.52.1" }, ] provides-extras = ["dev"] @@ -726,11 +756,11 @@ wheels = [ [[package]] name = "setuptools" -version = "83.0.0" +version = "84.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, ] [[package]] From 157b76893b32cda66fc586aa67ae72a30ac6b0d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:32:37 +0900 Subject: [PATCH 04/14] fix(authorization): bind operator gate to embedded router --- ARCHITECTURE.md | 7 +++++++ CHANGELOG.md | 4 ++++ .../hierarchical-authorization-plane.md | 17 ++++++++++++++++- docs/operations/authorization-plane.md | 6 ++++++ .../app/authorization_plane.py | 8 +++++++- services/account_unification/app/main.py | 4 ---- .../tests/test_authorization_plane.py | 19 ++++++++++++++++++- 7 files changed, 58 insertions(+), 7 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4fbf449..983c547 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -70,6 +70,13 @@ application relying-party registration. - app start-login / IdP discovery helper for relying parties; - hashed programmable application tokens scoped to one software unit and API. +The hierarchical authorization router carries the existing operator bearer and +privileged-path dependencies itself, so an embedding application cannot make +grant administration public by mounting the module without the application +factory's outer dependency list. The operator credential is a coarse +operator-admin boundary; ``actor_identity_id`` on a grant is policy metadata, +not an end-user principal extracted from that bearer request. + The core merge and SCIM layer depends on the narrow `AdminApi` protocol. Product extensions are isolated behind `ProductAdminApi`; relying-party client CRUD is further narrowed behind `RelyingPartyAdminApi`. Deterministic preflight diff --git a/CHANGELOG.md b/CHANGELOG.md index b854dc0..2242091 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,10 @@ Keep a Changelog, and releases use semantic versioning. ### Changed +- The hierarchical authorization router now carries its operator-authentication + and privileged-path dependencies at the module boundary, so direct CWL/Naruon + embedding cannot accidentally mount grant administration without the existing + operator gate. - Relying-party deployment controllers now send validated, secret-free metadata to Keyverse desired-state PUT instead of applying client representations directly to Keycloak; confidential credential placement remains a separate diff --git a/docs/doctoring/hierarchical-authorization-plane.md b/docs/doctoring/hierarchical-authorization-plane.md index 5a62153..f97baf7 100644 --- a/docs/doctoring/hierarchical-authorization-plane.md +++ b/docs/doctoring/hierarchical-authorization-plane.md @@ -43,7 +43,22 @@ clients in the portable realm. `services/account_unification/tests/test_org_authorization.py` and `tests/test_authorization_plane.py` cover inheritance, restriction, menu ABAC/RBAC, SSO combinations, reserved-name rejection, and fail-closed -storage. +storage. The HTTP regression suite also verifies that the authorization router +rejects an unauthenticated direct embedding and accepts only the configured +operator bearer. The router now owns both the operator-authentication and +privileged-path dependencies rather than relying only on the application +factory's include-site wiring. + +The operator bearer is intentionally coarse operator-admin authority. The +`actor_identity_id` field is grant and audit metadata selected by that operator; +it is not an end-user principal asserted by the bearer. The current service +does not claim per-operator actor ownership. Any future multi-principal admin +model must add an explicit authenticated-principal contract and negative +cross-principal tests before changing this boundary. This distinction explains +why a scanner proof of two end users presenting different body identities is +not, by itself, a measured exploit of the operator-only route; the hosted Strix +finding remains a required current-head security review until independently +revalidated. ## Assumptions and limitations diff --git a/docs/operations/authorization-plane.md b/docs/operations/authorization-plane.md index 0d53e4a..8344d25 100644 --- a/docs/operations/authorization-plane.md +++ b/docs/operations/authorization-plane.md @@ -7,6 +7,12 @@ Keyverse operator token is in the deployment secret store. It covers grant changes, start-login troubleshooting, and PAT rotation. It does not replace federation or RP desired-state apply. +The authorization router enforces the operator bearer and privileged path +checks at its own module boundary. A parent application may embed the router, +but must still provide the configured operator token; `actor_identity_id` in a +grant is administrative policy metadata, not a caller identity derived from a +shared operator bearer. + ## Persist grants 1. Confirm the org path is contiguous from `group_company`. diff --git a/services/account_unification/app/authorization_plane.py b/services/account_unification/app/authorization_plane.py index 5189846..4b609c4 100644 --- a/services/account_unification/app/authorization_plane.py +++ b/services/account_unification/app/authorization_plane.py @@ -12,6 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, ConfigDict, ValidationError +from .auth import operator_auth_dependency from .errors import AuthorizationPolicyError from .kv_store import KvStore from .org_authorization import ( @@ -28,12 +29,17 @@ validate_slug, validate_snapshot, ) +from .path_security import admin_path_security_dependency SOFTWARE_UNIT_GRANT_NAMESPACE = "authorization_software_unit_grants" MENU_GRANT_NAMESPACE = "authorization_menu_grants" SSO_COMBINATION_NAMESPACE = "authorization_sso_combination_scopes" -authorization_router = APIRouter(prefix="/authorization", tags=["authorization"]) +authorization_router = APIRouter( + prefix="/authorization", + tags=["authorization"], + dependencies=[operator_auth_dependency, admin_path_security_dependency], +) class SoftwareUnitDecisionRequest(BaseModel): diff --git a/services/account_unification/app/main.py b/services/account_unification/app/main.py index 2a5300d..30869e1 100644 --- a/services/account_unification/app/main.py +++ b/services/account_unification/app/main.py @@ -215,10 +215,6 @@ def healthz() -> dict: ) app.include_router( authorization_router, - dependencies=[ - operator_auth_dependency, - admin_path_security_dependency, - ], ) app.include_router( start_login_router, diff --git a/services/account_unification/tests/test_authorization_plane.py b/services/account_unification/tests/test_authorization_plane.py index 964ba6e..db1797c 100644 --- a/services/account_unification/tests/test_authorization_plane.py +++ b/services/account_unification/tests/test_authorization_plane.py @@ -4,7 +4,7 @@ from types import SimpleNamespace import pytest -from fastapi import HTTPException +from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient from app.authorization_plane import ( @@ -12,6 +12,7 @@ SOFTWARE_UNIT_GRANT_NAMESPACE, SSO_COMBINATION_NAMESPACE, AuthorizationPlaneService, + authorization_router, get_authorization_service, ) from app.kv_store import InMemoryKvStore @@ -94,6 +95,22 @@ def test_software_unit_grant_round_trip_and_inherited_decision(client) -> None: assert body["authorization_attributes"]["group_company"] == "acme" +def test_embedded_authorization_router_requires_operator_authentication() -> None: + """A directly embedded authorization router cannot be mounted open.""" + app = FastAPI() + app.state.authorization_service = AuthorizationPlaneService(InMemoryKvStore()) + app.state.operator_api_token = "test-operator-token" + app.include_router(authorization_router) + with TestClient(app) as embedded_client: + denied = embedded_client.get("/authorization/software-unit-grants") + allowed = embedded_client.get( + "/authorization/software-unit-grants", + headers={"Authorization": "Bearer test-operator-token"}, + ) + assert denied.status_code == 401 + assert allowed.status_code == 200 + + def test_menu_and_sso_combination_http_surface(client) -> None: """Menu ABAC/RBAC and SSO combination decisions use stored grants.""" client.put("/authorization/software-unit-grants/acme-naruon", json=SOFTWARE_GRANT) From d540515be66a6ca0aa538c61f977a57bf7a69fb8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 08:58:42 +0900 Subject: [PATCH 05/14] fix(start-login): normalize encoded discovery markers --- CHANGELOG.md | 3 ++- docs/doctoring/start-login-helper.md | 7 ++++- .../account_unification/app/start_login.py | 4 +-- .../tests/test_start_login.py | 26 +++++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2242091..bf6e7e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ Keep a Changelog, and releases use semantic versioning. profile reserved as ADR-0009 on PR #100. - App start-login helper (ADR-0011) that discovers enabled brokered IdPs from the local registry and returns a Keycloak `kc_idp_hint` authorization URL - without metadata or discovery fetch. + without metadata or discovery fetch; encoded discovery markers are rejected + after URL normalization at the same boundary. - Programmable application tokens (ADR-0012) hashed at rest, purpose-bound, software-unit and API scoped, rotatable, auditable, and never a password substitute or inherited secret. diff --git a/docs/doctoring/start-login-helper.md b/docs/doctoring/start-login-helper.md index 764eef2..a098052 100644 --- a/docs/doctoring/start-login-helper.md +++ b/docs/doctoring/start-login-helper.md @@ -28,7 +28,12 @@ and does not become a new IdP. `services/account_unification/tests/test_start_login.py` proves single-IdP auto-selection, multi-IdP hinting, disabled-provider omission, discovery-URL rejection, HTTPS redirect policy, empty-registry behavior, and the -`metadata_fetch_performed=false` contract. +`metadata_fetch_performed=false` contract. The same tests prove that +percent-encoded `.well-known`, `metadataUrl`, and `discoveryEndpoint` markers +are normalized before the no-fetch policy check. The service only constructs a +response URL; it does not dereference the supplied issuer, so a security scan's +SSRF label is recorded here as a URL-normalization policy defect rather than +live server-side network evidence. ## Assumptions and limitations diff --git a/services/account_unification/app/start_login.py b/services/account_unification/app/start_login.py index 9dcc724..9008559 100644 --- a/services/account_unification/app/start_login.py +++ b/services/account_unification/app/start_login.py @@ -7,7 +7,7 @@ """ from __future__ import annotations -from urllib.parse import urlencode, urlsplit +from urllib.parse import unquote, urlencode, urlsplit from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, ConfigDict, Field, ValidationError @@ -148,7 +148,7 @@ def discover_enabled_providers(self) -> list[DiscoveredIdentityProvider]: def _reject_discovery_request(request: StartLoginRequest) -> None: """Refuse fields that would imply a metadata or discovery fetch.""" public_issuer_url = request.public_issuer_url or "" - lowered = public_issuer_url.lower() + lowered = unquote(public_issuer_url).lower() if any(marker.lower() in lowered for marker in _FORBIDDEN_HINTS): raise AuthorizationPolicyError( "start-login must not receive discovery or metadata URLs" diff --git a/services/account_unification/tests/test_start_login.py b/services/account_unification/tests/test_start_login.py index f1f93bb..4a53c50 100644 --- a/services/account_unification/tests/test_start_login.py +++ b/services/account_unification/tests/test_start_login.py @@ -203,6 +203,32 @@ def test_start_login_rejects_discovery_urls_and_unsafe_redirects(client, store) assert "disabled-idp" not in aliases +@pytest.mark.parametrize( + "encoded_discovery_path", + [ + "%2Ewell-known/openid-configuration", + "metadata%55rl", + "discovery%45ndpoint", + ], +) +def test_start_login_rejects_percent_encoded_discovery_urls( + client, encoded_discovery_path: str +) -> None: + """Encoded discovery markers cannot bypass the no-fetch boundary.""" + response = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback", + "public_issuer_url": f"https://idp.example/{encoded_discovery_path}", + }, + ) + + assert response.status_code == 400 + assert "discovery or metadata URLs" in response.json()["detail"] + + def test_start_login_public_issuer_and_redirect_bounds(client) -> None: """Issuer and redirect inputs stay closed and local.""" credentials = client.post( From ec8a1a5210c84c854eff365934ac7c057cf477ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 09:08:28 +0900 Subject: [PATCH 06/14] fix(tokens): validate rotation before revocation --- CHANGELOG.md | 3 ++ .../0012-programmable-application-tokens.md | 4 ++- .../programmable-application-tokens.md | 6 ++-- docs/operations/authorization-plane.md | 10 ++++-- .../app/application_tokens.py | 6 ++-- .../tests/test_application_tokens.py | 31 +++++++++++++++++++ 6 files changed, 51 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf6e7e7..7d4127c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,6 +136,9 @@ Keep a Changelog, and releases use semantic versioning. state storage lock is held. - Prevented unknown federation configuration keys, credentials, and private values from being echoed through list, get, or update responses. +- Application-token rotation now validates the replacement purpose, + capabilities, lifetime, and software-unit binding before revoking the + active token, so invalid rotation requests preserve the working credential. - Rejected Unicode-confusable federation aliases outside the explicit ASCII slug alphabet. - Rejected raw C0 controls, DEL, invalid ports, insecure HTTP SSO or metadata diff --git a/docs/adr/0012-programmable-application-tokens.md b/docs/adr/0012-programmable-application-tokens.md index 42f55b1..1e6ac46 100644 --- a/docs/adr/0012-programmable-application-tokens.md +++ b/docs/adr/0012-programmable-application-tokens.md @@ -19,7 +19,9 @@ inherit down the org tree (ADR-0010). `operator_export`. Password, WebAuthn, browser-login, and authenticator purposes are rejected. 3. Tokens are software-unit and API-capability scoped, time-bounded (60 - seconds to 90 days), rotatable, revocable, and auditable. + seconds to 90 days), rotatable, revocable, and auditable. Rotation validates + the replacement before revoking the active token, so invalid replacement + settings do not destroy a working credential. 4. The plaintext secret is returned only at issue or rotate time. List, get, verify, and revoke responses never include the secret or hash. 5. Verification does not consult org-tree grants. Tokens never inherit. diff --git a/docs/doctoring/programmable-application-tokens.md b/docs/doctoring/programmable-application-tokens.md index 75b4927..fc8947a 100644 --- a/docs/doctoring/programmable-application-tokens.md +++ b/docs/doctoring/programmable-application-tokens.md @@ -28,13 +28,15 @@ both plaintext and hash. 2. Lifetime bounded to 60 seconds–90 days. 3. At least one API capability is required. 4. Verification ignores org-tree grants; tokens never inherit. -5. Rotation revokes the previous hash and issues a replacement. +5. Rotation validates the replacement settings before revoking the previous + hash, then issues a replacement bound to the same software unit. ## Measured repository evidence `services/account_unification/tests/test_application_tokens.py` covers issue, verify, revoke, rotate, expiry, capability denial, software-unit mismatch, -password-purpose rejection, and secret omission. +password-purpose rejection, secret omission, and preservation of the active +token after invalid rotation settings. ## Assumptions and limitations diff --git a/docs/operations/authorization-plane.md b/docs/operations/authorization-plane.md index 8344d25..85b0222 100644 --- a/docs/operations/authorization-plane.md +++ b/docs/operations/authorization-plane.md @@ -33,9 +33,13 @@ shared operator bearer. ## PAT rotation 1. POST `/application-tokens/{id}:rotate` with the same software unit. -2. Place the new plaintext in the application secret manager. -3. Confirm the old token verifies as `revoked_token`. -4. Revoke unused tokens instead of extending them as login credentials. +2. If validation returns HTTP 400, correct the replacement settings; the old + token remains active and must not be discarded. +3. Place a successful response's new plaintext in the application secret + manager before retiring the old credential. +4. Confirm the old token verifies as `revoked_token` and the new token verifies + as active. +5. Revoke unused tokens instead of extending them as login credentials. ## Recovery diff --git a/services/account_unification/app/application_tokens.py b/services/account_unification/app/application_tokens.py index 34d2692..30df1bf 100644 --- a/services/account_unification/app/application_tokens.py +++ b/services/account_unification/app/application_tokens.py @@ -224,14 +224,14 @@ def rotate( raise AuthorizationPolicyError( "rotated token must stay bound to the same software unit" ) + record, plaintext = self._mint( + request, replaced_token_id=application_token_id + ) self.revoke( application_token_id, actor_identity_id=request.actor_identity_id, lifecycle_status_code=ROTATED_LIFECYCLE, ) - record, plaintext = self._mint( - request, replaced_token_id=application_token_id - ) self._write_record(record) self._audit_event( "application_token_rotated", diff --git a/services/account_unification/tests/test_application_tokens.py b/services/account_unification/tests/test_application_tokens.py index 36b52db..aa159e8 100644 --- a/services/account_unification/tests/test_application_tokens.py +++ b/services/account_unification/tests/test_application_tokens.py @@ -224,6 +224,37 @@ def test_rotate_replaces_token_and_rejects_software_unit_change(client) -> None: assert mismatch.status_code == 400 +@pytest.mark.parametrize( + "invalid_update", + [ + {"purpose_code": "password"}, + {"capability_codes": []}, + {"lifetime_seconds": 30}, + {"lifetime_seconds": 91 * 24 * 60 * 60}, + ], +) +def test_invalid_rotation_preserves_the_active_token( + client, invalid_update: dict[str, object] +) -> None: + """Invalid replacement settings cannot destroy the active credential.""" + issued = client.post("/application-tokens", json=ISSUE_BODY).json() + + response = client.post( + f"/application-tokens/{issued['application_token_id']}:rotate", + json={**ISSUE_BODY, **invalid_update}, + ) + still_active = client.post( + "/application-tokens:verify", + json={ + "presented_token": issued["plaintext_token"], + "software_unit_id": "naruon-web", + }, + ) + + assert response.status_code == 400 + assert still_active.json()["active"] is True + + def test_issue_rejects_password_purposes_and_bounds(client) -> None: """PATs cannot be password substitutes and stay purpose-bounded.""" password = client.post( From 1f8c8ac29e6860d289d4b54e9bd6e418bf731638 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 09:29:55 +0900 Subject: [PATCH 07/14] fix(authorization): isolate runtime and tenant boundaries --- CHANGELOG.md | 8 +- README.md | 8 +- deploy/bootstrap/bootstrap.example.yaml | 4 +- deploy/keycloak/README.md | 7 + docs/ERD.md | 24 ++- docs/OPERABILITY.md | 19 +- docs/TRD.md | 8 +- .../0010-hierarchical-authorization-plane.md | 10 +- .../0012-programmable-application-tokens.md | 14 +- docs/authorization-onboarding.md | 15 +- .../hierarchical-authorization-plane.md | 12 +- .../programmable-application-tokens.md | 18 +- docs/doctoring/start-login-helper.md | 16 +- docs/federation-onboarding.md | 12 ++ docs/papers/README.md | 13 +- docs/papers/citations.bib | 26 ++- .../app/application_tokens.py | 93 ++++++---- services/account_unification/app/auth.py | 24 +++ .../app/authorization_plane.py | 87 ++++++--- services/account_unification/app/config.py | 21 +++ services/account_unification/app/main.py | 12 +- .../app/org_authorization.py | 19 ++ .../account_unification/app/start_login.py | 25 ++- .../tests/test_application_tokens.py | 171 +++++++++++++++++- .../account_unification/tests/test_auth.py | 29 +++ .../tests/test_authorization_plane.py | 66 +++++++ .../account_unification/tests/test_config.py | 23 +++ .../tests/test_org_authorization.py | 49 +++++ .../tests/test_start_login.py | 28 ++- 29 files changed, 736 insertions(+), 125 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d4127c..3130177 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,13 @@ Keep a Changelog, and releases use semantic versioning. after URL normalization at the same boundary. - Programmable application tokens (ADR-0012) hashed at rest, purpose-bound, software-unit and API scoped, rotatable, auditable, and never a password - substitute or inherited secret. + substitute or inherited secret; failed issue/rotation audit and storage + writes are compensated, and runtime verification uses a separate service + credential. +- Authorization decisions now require a tenant-qualified assignment snapshot; + grant matching, ABAC constraints, duplicate identity, and KV keys preserve + tenant boundaries. +- Start-login issuer input is bound to configured Keyverse public issuer state. - 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. diff --git a/README.md b/README.md index d7099e5..2c91eb0 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,11 @@ See [`docs/federation-onboarding.md`](docs/federation-onboarding.md), [`deploy/keycloak/README.md`](deploy/keycloak/README.md), and [`deploy/templates/README.md`](deploy/templates/README.md). -An application starts brokered login through -`POST /federation/identity-providers:start-login` and then adds PKCE locally. -See [`docs/authorization-onboarding.md`](docs/authorization-onboarding.md). +An application backend starts brokered login through +`POST /federation/identity-providers:start-login` with the separately +provisioned `X-Keyverse-Runtime-Token`; it then adds PKCE locally. The operator +Bearer token is reserved for grant and token-management endpoints. See +[`docs/authorization-onboarding.md`](docs/authorization-onboarding.md). ### Onboard a relying party diff --git a/deploy/bootstrap/bootstrap.example.yaml b/deploy/bootstrap/bootstrap.example.yaml index a35fc89..f1bf90b 100644 --- a/deploy/bootstrap/bootstrap.example.yaml +++ b/deploy/bootstrap/bootstrap.example.yaml @@ -8,7 +8,9 @@ # Copy to bootstrap.yaml and mount read-only at /bootstrap/bootstrap.yaml. # The store is expected to hold, under the namespace below, the Keycloak wiring # keys: keycloak_server_url, keycloak_realm, keycloak_client_id, -# keycloak_client_secret (+ merge_conflict_policy, allow_unverified_email_link). +# keycloak_client_secret, operator_api_token. Optional keys include +# public_issuer_url and a distinct runtime_api_token for app-facing start-login +# and token verification. config_store: # Backends: "sqlite" (dev/standalone), "postgres" (prod), "env-kv" (12-factor diff --git a/deploy/keycloak/README.md b/deploy/keycloak/README.md index feaf209..67ba8d8 100644 --- a/deploy/keycloak/README.md +++ b/deploy/keycloak/README.md @@ -31,6 +31,13 @@ following account-unification KV entries: - `registration_redirect_uri` - `registration_action_lifespan_seconds` +Application backends use a separate optional `runtime_api_token` for +start-login and programmable-token verification. It must differ from both +`operator_api_token` and `registration_api_token`; do not put it in browser +code or source-controlled templates. Set `public_issuer_url` when the public +Keycloak issuer differs from the service URL so start-login cannot redirect to +an untrusted host. + Without the registration token the endpoint is unavailable rather than open. See [`../../docs/passwordless-policy.md`](../../docs/passwordless-policy.md). diff --git a/docs/ERD.md b/docs/ERD.md index 9d3155d..7653100 100644 --- a/docs/ERD.md +++ b/docs/ERD.md @@ -143,16 +143,18 @@ erDiagram AUTHORIZATION_SOFTWARE_UNIT_GRANT { text grant_key PK - uuid tenant_deployment_id FK + text tenant_deployment_id FK text org_path text software_unit_id text effect_code + jsonb capability_codes + jsonb attribute_constraints text actor_identity_id } AUTHORIZATION_MENU_GRANT { text grant_key PK - uuid tenant_deployment_id FK + text tenant_deployment_id FK text org_path text software_unit_id text menu_path @@ -163,15 +165,15 @@ erDiagram } SSO_COMBINATION_SCOPE { - text combination_name PK - uuid tenant_deployment_id FK + text combination_name + text tenant_deployment_id FK jsonb software_unit_ids text actor_identity_id } APPLICATION_ACCESS_TOKEN { - text application_token_id PK - uuid tenant_deployment_id FK + text application_token_id + text tenant_deployment_id FK text software_unit_id text token_prefix text token_hash @@ -182,12 +184,16 @@ erDiagram timestamptz created_at timestamptz revoked_at text actor_identity_id + text replaced_token_id } ``` ## Logical uniqueness constraints -UUID primary identifiers are globally unique. Human/provider identifiers are scoped to the owning tenant or federation source and MUST NOT be interpreted as global keys. +UUID primary identifiers are globally unique. The authorization-plane identifiers +are tenant-qualified text keys; `tenant_deployment_id` is a validated lowercase +slug, not a UUID. Human/provider identifiers are scoped to the owning tenant or +federation source and MUST NOT be interpreted as global keys. | Entity | Required logical uniqueness | |---|---| @@ -229,7 +235,9 @@ or documentation labels are bypassed. - Exact external identity key is `(identity_provider, subject)`; verified email may support matching under policy but unverified email never authorizes linking. - `tenant_deployment_id` is explicit in Keyverse-owned records; deployment/customer separation must not be inferred from realm/resource names. - Secrets are referenced through protected values/handles where possible; secret-free desired-state tables must never gain client/bind credentials accidentally. -- Application access tokens store only `token_hash` and `token_prefix`. Plaintext tokens and org-tree secrets never appear on grant or combination rows. +- Application access tokens store the hashed secret and lifecycle metadata only; + plaintext tokens and org-tree secrets never appear on grant or combination + rows. KV storage keys for grants and combinations are tenant-qualified. - Hierarchical grant paths use `group_company` / `legal_entity` / `business_unit` / `team` / `person`. They do not persist Orgmetra trees and do not reuse LineageWeave `role` / `org` / `workspace` claim names. ## Desired-state and receipt invariant diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index 28d129d..f15b537 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -68,16 +68,21 @@ Mapper unit tests alone do not prove Naruon product authorization readiness. ## Authorization-plane and token runbook -1. Obtain an Orgmetra assignment snapshot for the subject; do not copy the - Orgmetra tree into Keyverse. +1. Obtain an Orgmetra assignment snapshot for the subject, including its + validated `tenant_deployment_id`; do not copy the Orgmetra tree into + Keyverse. 2. PUT software-unit and menu grants at the intended org-path node. 3. PUT an SSO combination when several RPs should share one session. 4. Call the matching `:decide` endpoint and keep the RP as PEP. -5. For app login, call `POST /federation/identity-providers:start-login`, add - PKCE locally, and redirect. Do not fetch IdP metadata from the app. -6. Mint a PAT with `POST /application-tokens`, store the plaintext in the - application secret manager, and verify through Keyverse. Rotate or revoke - instead of sharing a password. +5. For app login, the application backend calls + `POST /federation/identity-providers:start-login` with the separately + provisioned `X-Keyverse-Runtime-Token`, then adds PKCE locally and + redirects. Do not fetch IdP metadata from the app. +6. An operator calls `POST /application-tokens` with the operator bearer, + stores the one-time plaintext response in the relying application's secret + manager, and discards the response. The application presents that PAT to + `POST /application-tokens:verify` with the runtime service token. Rotate or + revoke instead of sharing a password. See `docs/authorization-onboarding.md` and `docs/operations/authorization-plane.md`. diff --git a/docs/TRD.md b/docs/TRD.md index 8536e22..55f7b0f 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -24,7 +24,9 @@ Keyverse separates portable Keycloak realm policy, Keyverse-owned identity contr - RP desired state remains separate from confidential client material. - Deployment controller, not public API, owns private bind/client and certificate material. - Each non-fork RP is a separate authorization boundary: verified Keyverse token validation, tenant/resource ABAC, and role/scope RBAC must be proven in the RP repository before production routing. The Keyverse authorization-plane PDP issues attributes and decisions; it does not replace ADR-0008 PEP enforcement. -- Orgmetra remains the employment and org-tree system of record. Keyverse consumes assignment snapshots and persists only grants, combinations, and hashed application tokens. +- Orgmetra remains the employment and org-tree system of record. Keyverse + consumes tenant-qualified assignment snapshots and persists only + tenant-qualified grants, combinations, and hashed application tokens. ## 4. Identity evidence @@ -60,6 +62,10 @@ authorization readiness. Authenticated operator APIs accept closed versioned schemas. Errors must not echo private values, raw provider responses, or arbitrary Keycloak Location/header content. Remote resource IDs parsed from Keycloak are validated before use in privileged paths. +The start-login and token-verification runtime routes use a distinct +`X-Keyverse-Runtime-Token`; grant and token-management writes retain the +operator bearer boundary. + ## 8. Persistence/data model Current architecture owns PostgreSQL/KV state for configuration, desired-state sources, apply receipts, merge audit, operation locks, authorization grants, SSO combination scopes, and hashed application access tokens. Database objects use descriptive two-word-or-longer `snake_case` names. `docs/ERD.md` defines tenant-scoped uniqueness, receipt identity/version binding, relationships, and lifecycle; migrations must preserve tenant/identity/audit integrity. diff --git a/docs/adr/0010-hierarchical-authorization-plane.md b/docs/adr/0010-hierarchical-authorization-plane.md index 514ad20..0d69197 100644 --- a/docs/adr/0010-hierarchical-authorization-plane.md +++ b/docs/adr/0010-hierarchical-authorization-plane.md @@ -33,9 +33,10 @@ enforce ABAC/RBAC at its own boundary. receipt is issuer-side evidence, not a substitute for issuer, audience, signature, expiry, or subject validation at the RP. 2. Orgmetra remains employment SoR. Decision and grant APIs accept a caller- - supplied assignment snapshot (`keyverse_subject`, `org_path`, optional - `assignment_record_id`). Keyverse does not persist or synchronize the - Orgmetra tree. + supplied assignment snapshot (`keyverse_subject`, required validated + `tenant_deployment_id`, `org_path`, optional `assignment_record_id`). + Keyverse does not persist or synchronize the Orgmetra tree. Grant selection, + combination lookup, and duplicate identity are tenant-qualified. 3. Hierarchical attributes use distinct names: `group_company`, `legal_entity`, `business_unit`, `team`, `person`, and structured `org_path`. `role`, `org`, and `workspace` stay reserved for the @@ -60,6 +61,9 @@ enforce ABAC/RBAC at its own boundary. - Operators persist grants and combinations through authenticated Keyverse admin APIs and evaluate decisions without contacting Orgmetra or Keycloak. +- Runtime RPs use the separately provisioned runtime service token for + start-login and token verification; operator bearer credentials stay on + management endpoints. - Downstream RPs must still prove ADR-0008 token validation. This slice does not claim production login or federation acceptance. - ADR-0009 remains reserved for the unmerged LineageWeave claim profile. diff --git a/docs/adr/0012-programmable-application-tokens.md b/docs/adr/0012-programmable-application-tokens.md index 1e6ac46..0543570 100644 --- a/docs/adr/0012-programmable-application-tokens.md +++ b/docs/adr/0012-programmable-application-tokens.md @@ -13,15 +13,18 @@ inherit down the org tree (ADR-0010). ## Decision 1. Keyverse issues programmable application tokens (`kvt__`). - Only the SHA-256 hash, prefix, purpose, software unit, and capability codes - are stored. + The durable record stores the SHA-256 hash, prefix, purpose, software unit, + capability codes, `application_token_id`, `tenant_deployment_id`, lifecycle + state, creation/expiry/revocation timestamps, `actor_identity_id`, and + `replaced_token_id`. The plaintext secret is never stored. 2. Closed purposes are `machine_api`, `integration_sync`, and `operator_export`. Password, WebAuthn, browser-login, and authenticator purposes are rejected. 3. Tokens are software-unit and API-capability scoped, time-bounded (60 seconds to 90 days), rotatable, revocable, and auditable. Rotation validates - the replacement before revoking the active token, so invalid replacement - settings do not destroy a working credential. + the replacement before revoking the active token and compensates storage or + audit failures, so invalid or incomplete replacement actions do not destroy + a working credential. 4. The plaintext secret is returned only at issue or rotate time. List, get, verify, and revoke responses never include the secret or hash. 5. Verification does not consult org-tree grants. Tokens never inherit. @@ -31,6 +34,7 @@ inherit down the org tree (ADR-0010). ## Consequences - Relying applications store the plaintext token in their own secret manager - and present it only to `POST /application-tokens:verify`. + and present it only to `POST /application-tokens:verify`, authenticated with + the separately provisioned runtime service token. - Keycloak client secrets and operator bearers remain separate credentials. - This slice does not replace confidential RP client-secret placement. diff --git a/docs/authorization-onboarding.md b/docs/authorization-onboarding.md index fdbce67..f1f3fd0 100644 --- a/docs/authorization-onboarding.md +++ b/docs/authorization-onboarding.md @@ -16,6 +16,7 @@ Ask Orgmetra for the current `assignment_record` and send Keyverse a snapshot: ```json { "keyverse_subject": "opaque-keyverse-subject", + "tenant_deployment_id": "buyer-deployment", "org_path": "/group_company/acme/legal_entity/holdco/business_unit/sales/team/alpha/person/jdoe", "assignment_record_id": "assignment-record-77", "request_attributes": {"purpose": "hr-review"} @@ -61,13 +62,15 @@ session stays in Keycloak; this only authorizes the selected RP set. 1. Register the employer IdP through the existing federation desired-state APIs (`docs/federation-onboarding.md`). -2. From the application (or its deployment helper) call: +2. From the application (or its deployment helper), call the runtime endpoint + with the separately provisioned `X-Keyverse-Runtime-Token` header. This is + not the operator bearer used for grant administration: ```bash curl --config "$AUTH_CONFIG" --request POST \ --header "Content-Type: application/json" \ --data '{"software_unit_id":"naruon-web","client_id":"naruon-web","redirect_uri":"https://naruon.example/callback","provider_alias_hint":"employer-adfs"}' \ - "$KEYVERSE_ADMIN/federation/identity-providers:start-login" + "$KEYVERSE_RUNTIME/federation/identity-providers:start-login" ``` 3. Add PKCE `S256`, `state`, and `nonce` in the application. @@ -84,10 +87,10 @@ curl --config "$AUTH_CONFIG" --request POST \ ``` Store `plaintext_token` in the application's secret manager and discard the -response. Present the token only to `POST /application-tokens:verify` with -the same software unit and requested API capabilities. Rotate or revoke -instead of treating the token as a password. Tokens never inherit org-tree -grants. +response. Present the token only to `POST /application-tokens:verify` with the +same software unit and requested API capabilities, plus the runtime service +header. Rotate or revoke instead of treating the token as a password. Tokens +never inherit org-tree grants. Keep bearer tokens out of `curl` process arguments; use a private `--config` file as in `docs/rp-onboarding.md`. diff --git a/docs/doctoring/hierarchical-authorization-plane.md b/docs/doctoring/hierarchical-authorization-plane.md index f97baf7..e96ab8b 100644 --- a/docs/doctoring/hierarchical-authorization-plane.md +++ b/docs/doctoring/hierarchical-authorization-plane.md @@ -16,6 +16,12 @@ opaque Keyverse subject plus org-path attributes, the resource is the software unit and menu path, and environment attributes are the closed `purpose` / `sensitivity` / `clearance` / `residency` set. +The tenant deployment is an additional closed scope attribute. A decision +snapshot must carry a validated `tenant_deployment_id`, and grants or SSO +combinations from another deployment are not candidates. This is a Keyverse +policy choice that operationalizes the tenant-qualified uniqueness described in +the ERD; it is not a claim that NIST SP 800-162 prescribes this storage key. + NIST SP 800-63C requires federation to keep identity proofing and authentication distinct from relying-party authorization (Grassi et al., 2017). Orgmetra therefore remains employment truth; Keyverse issues @@ -41,9 +47,9 @@ clients in the portable realm. ## Measured repository evidence `services/account_unification/tests/test_org_authorization.py` and -`tests/test_authorization_plane.py` cover inheritance, restriction, menu -ABAC/RBAC, SSO combinations, reserved-name rejection, and fail-closed -storage. The HTTP regression suite also verifies that the authorization router +`tests/test_authorization_plane.py` cover inheritance, restriction, software- +unit and menu ABAC/RBAC, tenant-isolated grants and combinations, reserved-name +rejection, and fail-closed storage. The HTTP regression suite also verifies that the authorization router rejects an unauthenticated direct embedding and accepts only the configured operator bearer. The router now owns both the operator-authentication and privileged-path dependencies rather than relying only on the application diff --git a/docs/doctoring/programmable-application-tokens.md b/docs/doctoring/programmable-application-tokens.md index fc8947a..e27c30b 100644 --- a/docs/doctoring/programmable-application-tokens.md +++ b/docs/doctoring/programmable-application-tokens.md @@ -12,8 +12,8 @@ RFC 6750 describes bearer credentials presented to a resource server (Jones & Hardt, 2012). Keyverse stores only a SHA-256 hash and verifies equality with a compare-digest so the secret is not reconstructed from storage. -NIST SP 800-63B distinguishes authenticators used to prove a subscriber -account from other secrets (Grassi et al., 2017). Password and WebAuthn +NIST SP 800-63B-4 distinguishes authenticators used to prove a subscriber +account from other secrets (Temoshok et al., 2025). Password and WebAuthn purposes are therefore forbidden. A PAT is a machine credential for a software unit and API capability set, not a browser authenticator (ADR-0002). @@ -35,8 +35,9 @@ both plaintext and hash. `services/account_unification/tests/test_application_tokens.py` covers issue, verify, revoke, rotate, expiry, capability denial, software-unit mismatch, -password-purpose rejection, secret omission, and preservation of the active -token after invalid rotation settings. +password-purpose rejection, secret omission, preservation of the active token +after invalid rotation settings, and compensation after injected KV or audit +failure. Management and runtime router authentication are tested separately. ## Assumptions and limitations @@ -46,10 +47,11 @@ evidence boundary. ## References -Grassi, P. A., Garcia, M. E., & Fenton, J. L. (2017). *Digital identity -guidelines: Authentication and lifecycle management* (NIST Special -Publication 800-63B). National Institute of Standards and Technology. -https://doi.org/10.6028/NIST.SP.800-63b +Temoshok, D., Fenton, J., Choong, Y.-Y., Lefkovitz, N., Regenscheid, A., +Galluzzo, R., & Richer, J. (2025). *Digital identity guidelines: +Authentication and authenticator management* (NIST Special Publication +800-63B-4). National Institute of Standards and Technology. +https://doi.org/10.6028/NIST.SP.800-63b-4 Jones, M. B., & Hardt, D. (2012). *The OAuth 2.0 authorization framework: Bearer token usage* (RFC 6750). RFC Editor. diff --git a/docs/doctoring/start-login-helper.md b/docs/doctoring/start-login-helper.md index a098052..1c2e7b4 100644 --- a/docs/doctoring/start-login-helper.md +++ b/docs/doctoring/start-login-helper.md @@ -8,9 +8,10 @@ OpenID Connect or Keycloak brokering conformance. ## Normative and authoritative evidence -OpenID Connect Core defines the authorization endpoint and requires the RP -to perform the authorization-code flow, including PKCE when public (OpenID -Foundation, 2023). The helper only composes that endpoint with `client_id`, +OpenID Connect Core defines the authorization endpoint and authorization-code +flow. RFC 9700 requires public clients to use PKCE to prevent authorization +code injection (Lodderstedt et al., 2025). The helper only composes that +endpoint with `client_id`, `redirect_uri`, `response_type=code`, `scope=openid`, and Keycloak's `kc_idp_hint` parameter (Keycloak Project, 2026). The RP must still add PKCE, `state`, and `nonce`. @@ -19,6 +20,11 @@ SAML and OIDC preflight in this repository already forbid metadata and discovery fetches. The helper preserves that boundary: it reads the local desired-state registry and rejects `.well-known` or metadata URLs. +The authorization endpoint is also bound to the configured public Keyverse +issuer (or the configured Keycloak realm URL when no public override exists). +The request cannot redirect an RP to an arbitrary host. This is a Keyverse +trust-boundary policy, not a network-fetch claim. + NIST SP 800-63C treats the federation authority as distinct from the application (Grassi et al., 2017). The helper therefore stays Keyverse-owned and does not become a new IdP. @@ -55,3 +61,7 @@ https://www.keycloak.org/docs/latest/server_admin/#_identity_broker OpenID Foundation. (2023). *OpenID Connect Core 1.0 incorporating errata set 2*. https://openid.net/specs/openid-connect-core-1_0.html + +Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current +practice for OAuth 2.0 security* (RFC 9700; BCP 240). RFC Editor. +https://www.rfc-editor.org/rfc/rfc9700 diff --git a/docs/federation-onboarding.md b/docs/federation-onboarding.md index 3fdeb3b..ff7c894 100644 --- a/docs/federation-onboarding.md +++ b/docs/federation-onboarding.md @@ -200,6 +200,18 @@ certificate separately does not preserve active trust. After the identity provider is registered, a relying application starts brokered login through Keyverse rather than fetching metadata itself: +Set `AUTH_CONFIG` to an existing private curl config file containing the +runtime service-token header; this guard keeps the example from passing an +undefined or empty path to `curl --config`: + +```bash +: "${AUTH_CONFIG:?Set AUTH_CONFIG to a private curl config file}" +test -r "$AUTH_CONFIG" || { + echo "AUTH_CONFIG is not readable" >&2 + exit 1 +} +``` + ```bash curl --config "$AUTH_CONFIG" \ --fail-with-body \ diff --git a/docs/papers/README.md b/docs/papers/README.md index c070ece..94e17f6 100644 --- a/docs/papers/README.md +++ b/docs/papers/README.md @@ -39,9 +39,13 @@ Full BibTeX in [`citations.bib`](./citations.bib). & Scarfone, K. (2014). *Guide to Attribute Based Access Control (ABAC) Definition and Considerations.* NIST Special Publication 800-162. https://doi.org/10.6028/NIST.SP.800-162 -- Grassi, P. A., Garcia, M. E., & Fenton, J. L. (2017). *Digital Identity - Guidelines: Authentication and Lifecycle Management.* NIST Special - Publication 800-63B. https://doi.org/10.6028/NIST.SP.800-63b +- Temoshok, D., Fenton, J., Choong, Y.-Y., Lefkovitz, N., Regenscheid, A., + Galluzzo, R., & Richer, J. (2025). *Digital Identity Guidelines: + Authentication and Authenticator Management.* NIST Special Publication + 800-63B-4. https://doi.org/10.6028/NIST.SP.800-63b-4 +- Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best Current + Practice for OAuth 2.0 Security.* RFC 9700 (BCP 240), IETF. + https://www.rfc-editor.org/rfc/rfc9700 - Jones, M. B., & Hardt, D. (2012). *The OAuth 2.0 Authorization Framework: Bearer Token Usage.* RFC 6750, IETF. https://doi.org/10.17487/RFC6750 @@ -54,4 +58,5 @@ Full BibTeX in [`citations.bib`](./citations.bib). | OIDC Core | `deploy/templates/oidc-rp-client.json`, `docs/rp-onboarding.md`, start-login helper | | SAML V2.0 | `deploy/templates/saml-idp-employer-adfs.json`, `docs/topology.md` | | NIST SP 800-162 | hierarchical menu ABAC in `app/org_authorization.py` | -| NIST SP 800-63B / RFC 6750 | programmable application tokens in `app/application_tokens.py` | +| NIST SP 800-63B-4 / RFC 6750 | programmable application tokens in `app/application_tokens.py` | +| RFC 9700 | public-client PKCE instruction in the start-login helper | diff --git a/docs/papers/citations.bib b/docs/papers/citations.bib index f6449ee..f169fac 100644 --- a/docs/papers/citations.bib +++ b/docs/papers/citations.bib @@ -45,16 +45,28 @@ @techreport{nist8001622014 url = {https://doi.org/10.6028/NIST.SP.800-162} } -@techreport{nist80063b2017, - author = {Grassi, Paul A. and Garcia, Michael E. and Fenton, James L.}, - title = {Digital Identity Guidelines: Authentication and Lifecycle +@techreport{nist80063b42025, + author = {Temoshok, David and Fenton, James and Choong, Yee-Yin and + Lefkovitz, Naomi and Regenscheid, Andrew and Galluzzo, Ryan + and Richer, Justin}, + title = {Digital Identity Guidelines: Authentication and Authenticator Management}, institution = {National Institute of Standards and Technology}, type = {NIST Special Publication}, - number = {800-63B}, - year = {2017}, - doi = {10.6028/NIST.SP.800-63b}, - url = {https://doi.org/10.6028/NIST.SP.800-63b} + number = {800-63B-4}, + year = {2025}, + doi = {10.6028/NIST.SP.800-63b-4}, + url = {https://doi.org/10.6028/NIST.SP.800-63b-4} +} + +@misc{rfc9700, + author = {Lodderstedt, Tobias and Bradley, John and Labunets, Andrii + and Fett, Daniel}, + title = {Best Current Practice for OAuth 2.0 Security}, + howpublished = {RFC 9700 (BCP 240)}, + year = {2025}, + doi = {10.17487/RFC9700}, + url = {https://www.rfc-editor.org/rfc/rfc9700} } @misc{rfc6750, diff --git a/services/account_unification/app/application_tokens.py b/services/account_unification/app/application_tokens.py index 30df1bf..eee3cd6 100644 --- a/services/account_unification/app/application_tokens.py +++ b/services/account_unification/app/application_tokens.py @@ -19,9 +19,11 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError from .audit import AuditLogger +from .auth import operator_auth_dependency, runtime_auth_dependency from .errors import AuthorizationPolicyError from .kv_store import KvStore from .org_authorization import validate_capability_codes, validate_slug +from .path_security import admin_path_security_dependency APPLICATION_TOKEN_NAMESPACE = "application_access_tokens" TOKEN_SCHEME = "kvt" @@ -38,7 +40,14 @@ MAX_LIFETIME_SECONDS = 90 * 24 * 60 * 60 application_token_router = APIRouter( - prefix="/application-tokens", tags=["application-tokens"] + prefix="/application-tokens", + tags=["application-tokens"], + dependencies=[operator_auth_dependency, admin_path_security_dependency], +) +application_token_runtime_router = APIRouter( + prefix="/application-tokens", + tags=["application-tokens"], + dependencies=[runtime_auth_dependency], ) @@ -163,12 +172,17 @@ def issue( ) -> ApplicationTokenIssueResponse: """Mint one token, persist only the hash, and audit the issue.""" record, plaintext = self._mint(request, replaced_token_id=None) - self._write_record(record) - self._audit_event( - "application_token_issued", - request.actor_identity_id, - record, - ) + with self._state_lock: + self._write_record(record) + try: + self._audit_event( + "application_token_issued", + request.actor_identity_id, + record, + ) + except Exception: + self._delete_record(record) + raise return self._issue_response(record, plaintext) def list_tokens(self) -> list[ApplicationTokenView]: @@ -205,11 +219,15 @@ def revoke( } ) self._write_record(updated) - self._audit_event( - "application_token_revoked", - actor_identity_id, - updated, - ) + try: + self._audit_event( + "application_token_revoked", + actor_identity_id, + updated, + ) + except Exception: + self._write_record(record) + raise return self._view(updated) def rotate( @@ -219,25 +237,33 @@ def rotate( ) -> ApplicationTokenIssueResponse: """Revoke one active token and issue a replacement in one actor action.""" _validate_token_id(application_token_id) - existing = self._require_record(application_token_id) - if existing.software_unit_id != request.software_unit_id: - raise AuthorizationPolicyError( - "rotated token must stay bound to the same software unit" + with self._state_lock: + existing = self._require_record(application_token_id) + if existing.software_unit_id != request.software_unit_id: + raise AuthorizationPolicyError( + "rotated token must stay bound to the same software unit" + ) + record, plaintext = self._mint( + request, replaced_token_id=application_token_id ) - record, plaintext = self._mint( - request, replaced_token_id=application_token_id - ) - self.revoke( - application_token_id, - actor_identity_id=request.actor_identity_id, - lifecycle_status_code=ROTATED_LIFECYCLE, - ) - self._write_record(record) - self._audit_event( - "application_token_rotated", - request.actor_identity_id, - record, - ) + updated = existing.model_copy( + update={ + "lifecycle_status_code": ROTATED_LIFECYCLE, + "revoked_at": self._clock(), + } + ) + try: + self._write_record(record) + self._write_record(updated) + self._audit_event( + "application_token_rotated", + request.actor_identity_id, + record, + ) + except Exception: + self._write_record(existing) + self._delete_record(record) + raise return self._issue_response(record, plaintext) def verify( @@ -335,6 +361,11 @@ def _write_record(self, record: ApplicationTokenRecord) -> None: record.model_dump_json(), ) + def _delete_record(self, record: ApplicationTokenRecord) -> None: + """Compensate one lifecycle write when its audit event fails.""" + with self._state_lock: + self._store.delete(APPLICATION_TOKEN_NAMESPACE, record.application_token_id) + def _records(self) -> list[ApplicationTokenRecord]: """Load every hashed token record, fail-closed on corruption.""" with self._state_lock: @@ -577,7 +608,7 @@ def rotate_application_token( raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc -@application_token_router.post( +@application_token_runtime_router.post( ":verify", response_model=ApplicationTokenVerifyResponse, ) diff --git a/services/account_unification/app/auth.py b/services/account_unification/app/auth.py index 8ff2a8f..147c6ea 100644 --- a/services/account_unification/app/auth.py +++ b/services/account_unification/app/auth.py @@ -49,3 +49,27 @@ def require_operator_token( operator_auth_dependency = Depends(require_operator_token) + + +def require_runtime_token( + request: Request, + runtime_token: str | None = Header( + default=None, + alias="X-Keyverse-Runtime-Token", + ), +) -> None: + """Authenticate the least-privilege runtime service token.""" + expected = getattr(request.app.state, "runtime_api_token", None) + if not expected: + raise HTTPException(status_code=503, detail="runtime authentication unavailable") + if not runtime_token: + raise HTTPException( + status_code=401, + detail="runtime service token required", + headers={"WWW-Authenticate": "Bearer"}, + ) + if not hmac.compare_digest(runtime_token, expected): + raise HTTPException(status_code=403, detail="invalid runtime service token") + + +runtime_auth_dependency = Depends(require_runtime_token) diff --git a/services/account_unification/app/authorization_plane.py b/services/account_unification/app/authorization_plane.py index 4b609c4..35604aa 100644 --- a/services/account_unification/app/authorization_plane.py +++ b/services/account_unification/app/authorization_plane.py @@ -133,22 +133,42 @@ def put_combination( with self._state_lock: self._store.put( SSO_COMBINATION_NAMESPACE, - combination_name, + self._scoped_key( + validated.tenant_deployment_id, + combination_name, + ), validated.model_dump_json(), ) return validated - def get_combination(self, combination_name: str) -> SsoCombinationScope: + def get_combination( + self, + combination_name: str, + *, + tenant_deployment_id: str | None = None, + ) -> SsoCombinationScope: """Return one stored SSO combination.""" validate_slug(combination_name, field_name="combination_name") - with self._state_lock: - raw_value = self._store.get(SSO_COMBINATION_NAMESPACE, combination_name) - if raw_value is None: + combinations = [ + combination + for combination in self.list_combinations() + if combination.combination_name == combination_name + and ( + tenant_deployment_id is None + or combination.tenant_deployment_id == tenant_deployment_id + ) + ] + if not combinations: raise AuthorizationPolicyError( "sso combination is not registered", status_code=404, ) - return self._parse_combination(raw_value) + if len(combinations) > 1: + raise AuthorizationPolicyError( + "tenant_deployment_id is required for an ambiguous sso combination", + status_code=409, + ) + return combinations[0] def list_combinations(self) -> list[SsoCombinationScope]: """Return every stored SSO combination.""" @@ -160,13 +180,15 @@ def list_combinations(self) -> list[SsoCombinationScope]: def delete_combination(self, combination_name: str) -> None: """Remove one SSO combination.""" validate_slug(combination_name, field_name="combination_name") + combination = self.get_combination(combination_name) with self._state_lock: - if self._store.get(SSO_COMBINATION_NAMESPACE, combination_name) is None: - raise AuthorizationPolicyError( - "sso combination is not registered", - status_code=404, - ) - self._store.delete(SSO_COMBINATION_NAMESPACE, combination_name) + self._store.delete( + SSO_COMBINATION_NAMESPACE, + self._scoped_key( + combination.tenant_deployment_id, + combination_name, + ), + ) def decide_software_unit( self, request: SoftwareUnitDecisionRequest @@ -193,8 +215,11 @@ def decide_combination( self, request: SsoCombinationDecisionRequest ) -> SsoCombinationDecision: """Evaluate whether every member of a stored combination is allowed.""" - combination = self.get_combination(request.combination_name) snapshot = validate_snapshot(request.snapshot) + combination = self.get_combination( + request.combination_name, + tenant_deployment_id=snapshot.tenant_deployment_id, + ) return decide_sso_combination( self.list_software_unit_grants(), snapshot, @@ -219,6 +244,7 @@ def _put_grant( ) validated = validate_grant(grant) identity = ( + validated.tenant_deployment_id, validated.grant_scope_code, validated.org_path, validated.software_unit_id, @@ -227,6 +253,7 @@ def _put_grant( with self._state_lock: for existing in self._list_grants(namespace): existing_identity = ( + existing.tenant_deployment_id, existing.grant_scope_code, existing.org_path, existing.software_unit_id, @@ -237,20 +264,28 @@ def _put_grant( "an equivalent authorization grant already exists", status_code=409, ) - self._store.put(namespace, grant_key, validated.model_dump_json()) + self._store.put( + namespace, + self._scoped_key(validated.tenant_deployment_id, grant_key), + validated.model_dump_json(), + ) return validated def _get_grant(self, namespace: str, grant_key: str) -> AuthorizationGrant: """Return one stored grant or raise a 404 policy error.""" validate_slug(grant_key, field_name="grant_key") - with self._state_lock: - raw_value = self._store.get(namespace, grant_key) - if raw_value is None: + grants = [grant for grant in self._list_grants(namespace) if grant.grant_key == grant_key] + if not grants: raise AuthorizationPolicyError( "authorization grant is not registered", status_code=404, ) - return self._parse_grant(raw_value) + if len(grants) > 1: + raise AuthorizationPolicyError( + "tenant_deployment_id is required for an ambiguous authorization grant", + status_code=409, + ) + return grants[0] def _list_grants(self, namespace: str) -> list[AuthorizationGrant]: """Return every grant in one namespace, fail-closed on corrupt rows.""" @@ -262,13 +297,17 @@ def _list_grants(self, namespace: str) -> list[AuthorizationGrant]: def _delete_grant(self, namespace: str, grant_key: str) -> None: """Delete one grant after proving it exists.""" validate_slug(grant_key, field_name="grant_key") + grant = self._get_grant(namespace, grant_key) with self._state_lock: - if self._store.get(namespace, grant_key) is None: - raise AuthorizationPolicyError( - "authorization grant is not registered", - status_code=404, - ) - self._store.delete(namespace, grant_key) + self._store.delete( + namespace, + self._scoped_key(grant.tenant_deployment_id, grant_key), + ) + + @staticmethod + def _scoped_key(tenant_deployment_id: str, identifier: str) -> str: + """Return one collision-free KV key within a tenant namespace.""" + return f"{tenant_deployment_id}::{identifier}" def _parse_grant(self, raw_value: str) -> AuthorizationGrant: """Parse one stored grant or fail closed.""" diff --git a/services/account_unification/app/config.py b/services/account_unification/app/config.py index ecf9fe6..cfb304f 100644 --- a/services/account_unification/app/config.py +++ b/services/account_unification/app/config.py @@ -18,10 +18,12 @@ KEY_KEYCLOAK_REALM = "keycloak_realm" KEY_KEYCLOAK_CLIENT_ID = "keycloak_client_id" KEY_KEYCLOAK_CLIENT_SECRET = "keycloak_client_secret" +KEY_PUBLIC_ISSUER_URL = "public_issuer_url" KEY_MERGE_CONFLICT_POLICY = "merge_conflict_policy" KEY_ALLOW_UNVERIFIED_LINK = "allow_unverified_email_link" KEY_REQUEST_TIMEOUT_SECONDS = "request_timeout_seconds" KEY_OPERATOR_API_TOKEN = "operator_api_token" +KEY_RUNTIME_API_TOKEN = "runtime_api_token" KEY_REGISTRATION_API_TOKEN = "registration_api_token" KEY_REGISTRATION_CLIENT_ID = "registration_client_id" KEY_REGISTRATION_REDIRECT_URI = "registration_redirect_uri" @@ -46,6 +48,8 @@ class ServiceConfig: # Privileged and product registration surfaces deliberately use different # bearer credentials so relying products never acquire operator authority. operator_api_token: str + public_issuer_url: str | None = None + runtime_api_token: str | None = None registration_api_token: str | None = None registration_client_id: str | None = None registration_redirect_uri: str | None = None @@ -189,6 +193,14 @@ def load_service_config(store: KvStore, namespace: str) -> ServiceConfig: "config 'registration_api_token' must differ from " "'operator_api_token'" ) + runtime_api_token = store.get(namespace, KEY_RUNTIME_API_TOKEN) or None + if runtime_api_token is not None and runtime_api_token in { + operator_api_token, + registration_api_token, + }: + raise RuntimeError( + "config 'runtime_api_token' must differ from operator and registration tokens" + ) ( registration_client_id, registration_redirect_uri, @@ -224,7 +236,16 @@ def load_service_config(store: KvStore, namespace: str) -> ServiceConfig: keycloak_client_secret=_require( store, namespace, KEY_KEYCLOAK_CLIENT_SECRET ), + public_issuer_url=( + _validated_https_uri( + public_issuer_url, + entry_key=KEY_PUBLIC_ISSUER_URL, + ) + if (public_issuer_url := store.get(namespace, KEY_PUBLIC_ISSUER_URL)) + else None + ), operator_api_token=operator_api_token, + runtime_api_token=runtime_api_token, registration_api_token=registration_api_token, registration_client_id=registration_client_id, registration_redirect_uri=registration_redirect_uri, diff --git a/services/account_unification/app/main.py b/services/account_unification/app/main.py index 30869e1..6cdfb26 100644 --- a/services/account_unification/app/main.py +++ b/services/account_unification/app/main.py @@ -15,9 +15,13 @@ from . import __version__ from .api import router -from .application_tokens import ApplicationTokenService, application_token_router +from .application_tokens import ( + ApplicationTokenService, + application_token_router, + application_token_runtime_router, +) from .audit import AuditLogger, SqliteAuditSink -from .auth import operator_auth_dependency +from .auth import operator_auth_dependency, runtime_auth_dependency from .authorization_plane import AuthorizationPlaneService, authorization_router from .bootstrap import load_bootstrap_descriptor, open_config_store from .config import load_service_config @@ -102,6 +106,7 @@ def build_service(app: FastAPI) -> None: app.state.start_login_service = StartLoginService(store, config) app.state.application_token_service = ApplicationTokenService(store, audit) app.state.operator_api_token = config.operator_api_token + app.state.runtime_api_token = getattr(config, "runtime_api_token", None) app.state.registration_api_token = config.registration_api_token app.state.registration_client_id = config.registration_client_id app.state.registration_redirect_uri = config.registration_redirect_uri @@ -219,7 +224,7 @@ def healthz() -> dict: app.include_router( start_login_router, dependencies=[ - operator_auth_dependency, + runtime_auth_dependency, admin_path_security_dependency, ], ) @@ -230,6 +235,7 @@ def healthz() -> dict: admin_path_security_dependency, ], ) + app.include_router(application_token_runtime_router) app.include_router( registration_router, dependencies=[registration_auth_dependency], diff --git a/services/account_unification/app/org_authorization.py b/services/account_unification/app/org_authorization.py index c0beb0c..d218697 100644 --- a/services/account_unification/app/org_authorization.py +++ b/services/account_unification/app/org_authorization.py @@ -114,6 +114,7 @@ class AssignmentSnapshot(BaseModel): model_config = ConfigDict(extra="forbid") keyverse_subject: str = Field(min_length=1, max_length=128) + tenant_deployment_id: str org_path: str = Field(min_length=1, max_length=_MAX_ORG_PATH_LENGTH) assignment_record_id: str | None = Field(default=None, max_length=128) request_attributes: dict[str, str] = Field(default_factory=dict) @@ -368,6 +369,7 @@ def validate_snapshot(snapshot: AssignmentSnapshot) -> AssignmentSnapshot: """Validate one assignment snapshot without contacting Orgmetra.""" if any(character.isspace() or ord(character) < 0x20 for character in snapshot.keyverse_subject): raise AuthorizationPolicyError("keyverse_subject must be an opaque bounded token") + validate_slug(snapshot.tenant_deployment_id, field_name="tenant_deployment_id") parsed_org = parse_org_path(snapshot.org_path) if snapshot.assignment_record_id is not None: validate_slug( @@ -474,6 +476,7 @@ def _select_winning_grant( grants: list[AuthorizationGrant], *, snapshot_path: OrganizationPath, + tenant_deployment_id: str, software_unit_id: str, grant_scope_code: str, requested_menu_path: str | None, @@ -487,6 +490,8 @@ def _select_winning_grant( path: index for index, path in enumerate(menu_ancestor_paths(requested_menu_path)) } for grant in grants: + if grant.tenant_deployment_id != tenant_deployment_id: + continue if grant.grant_scope_code != grant_scope_code: continue if grant.software_unit_id != software_unit_id: @@ -521,6 +526,7 @@ def decide_software_unit( winning, inherited = _select_winning_grant( validated_grants, snapshot_path=parsed_org, + tenant_deployment_id=validated_snapshot.tenant_deployment_id, software_unit_id=software_unit_id, grant_scope_code=SOFTWARE_UNIT_GRANT_SCOPE, requested_menu_path=None, @@ -532,6 +538,14 @@ def decide_software_unit( winning=winning, inherited=inherited, menu_path=None, + attribute_mismatch=( + winning is not None + and winning.effect_code == ALLOW_EFFECT + and not _constraints_match( + winning.attribute_constraints, + validated_snapshot.request_attributes, + ) + ), ) @@ -560,6 +574,7 @@ def decide_menu( winning, inherited = _select_winning_grant( validated_grants, snapshot_path=parsed_org, + tenant_deployment_id=validated_snapshot.tenant_deployment_id, software_unit_id=software_unit_id, grant_scope_code=MENU_GRANT_SCOPE, requested_menu_path=canonical_menu, @@ -592,6 +607,10 @@ def decide_sso_combination( """Allow a combination only when every member software unit is allowed.""" validated_combination = validate_combination(combination) validated_snapshot = validate_snapshot(snapshot) + if validated_combination.tenant_deployment_id != validated_snapshot.tenant_deployment_id: + raise AuthorizationPolicyError( + "sso combination and assignment snapshot must use the same tenant" + ) member_decisions = [ decide_software_unit(grants, validated_snapshot, software_unit_id) for software_unit_id in validated_combination.software_unit_ids diff --git a/services/account_unification/app/start_login.py b/services/account_unification/app/start_login.py index 9008559..8d23701 100644 --- a/services/account_unification/app/start_login.py +++ b/services/account_unification/app/start_login.py @@ -13,6 +13,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError from .config import ServiceConfig +from .auth import runtime_auth_dependency from .errors import AuthorizationPolicyError from .federation import ( FEDERATION_PROVIDER_NAMESPACE, @@ -22,7 +23,11 @@ from .kv_store import KvStore from .org_authorization import validate_slug -start_login_router = APIRouter(prefix="/federation", tags=["federation"]) +start_login_router = APIRouter( + prefix="/federation", + tags=["federation"], + dependencies=[runtime_auth_dependency], +) _HTTPS_SCHEME = "https" _HTTP_SCHEME = "http" _MAX_REDIRECT_URI_LENGTH = 2_048 @@ -182,6 +187,10 @@ def _authorization_endpoint( public_issuer_url: str | None, config: ServiceConfig ) -> str: """Build the local Keycloak authorization endpoint without discovery.""" + configured_issuer = ( + config.public_issuer_url + or f"{config.keycloak_server_url.rstrip('/')}/realms/{config.keycloak_realm}" + ).rstrip("/") if public_issuer_url: parsed = urlsplit(public_issuer_url) if ( @@ -197,10 +206,18 @@ def _authorization_endpoint( "credentials, query, or fragment" ) issuer = public_issuer_url.rstrip("/") - else: - issuer = ( - f"{config.keycloak_server_url.rstrip('/')}/realms/{config.keycloak_realm}" + auth_suffix = "/protocol/openid-connect/auth" + candidate_issuer = ( + issuer[: -len(auth_suffix)] + if issuer.endswith(auth_suffix) + else issuer ) + if candidate_issuer != configured_issuer: + raise AuthorizationPolicyError( + "public_issuer_url must match the configured Keyverse issuer" + ) + else: + issuer = configured_issuer if issuer.endswith("/protocol/openid-connect/auth"): return issuer return f"{issuer}/protocol/openid-connect/auth" diff --git a/services/account_unification/tests/test_application_tokens.py b/services/account_unification/tests/test_application_tokens.py index aa159e8..9c42a8d 100644 --- a/services/account_unification/tests/test_application_tokens.py +++ b/services/account_unification/tests/test_application_tokens.py @@ -4,6 +4,7 @@ from types import SimpleNamespace import pytest +from fastapi import FastAPI from fastapi import HTTPException from fastapi.testclient import TestClient @@ -13,6 +14,8 @@ ApplicationTokenRecord, ApplicationTokenService, ApplicationTokenVerifyRequest, + application_token_router, + application_token_runtime_router, get_application_token_service, ) from app.audit import AuditLogger, InMemoryAuditSink @@ -20,6 +23,31 @@ from app.main import create_app +class _FailingStore(InMemoryKvStore): + """Inject one storage failure at a chosen write.""" + + def __init__(self, *, fail_on_put: int) -> None: + """Create a store that fails on the requested one-based write.""" + super().__init__() + self._fail_on_put = fail_on_put + self._put_count = 0 + + def put(self, namespace: str, entry_key: str, entry_value: str) -> None: + """Raise once at the configured write and otherwise persist normally.""" + self._put_count += 1 + if self._put_count == self._fail_on_put: + raise RuntimeError("injected token storage failure") + super().put(namespace, entry_key, entry_value) + + +class _FailingAuditSink(InMemoryAuditSink): + """Inject an audit persistence failure.""" + + def record(self, event) -> None: + """Reject every event to exercise lifecycle compensation.""" + raise RuntimeError("injected audit storage failure") + + class _Clock: """Deterministic clock for expiry tests.""" @@ -62,7 +90,11 @@ def client(token_service, auth_header): app = create_app(wire=False) app.state.application_token_service = token_service app.state.operator_api_token = "test-operator-token" - with TestClient(app, headers=auth_header) as test_client: + app.state.runtime_api_token = "test-runtime-token" + with TestClient( + app, + headers={**auth_header, "X-Keyverse-Runtime-Token": "test-runtime-token"}, + ) as test_client: yield test_client @@ -130,6 +162,48 @@ def test_issue_verify_revoke_and_secret_omission(client, audit) -> None: } +def test_embedded_application_token_router_requires_operator_authentication( + token_service: ApplicationTokenService, +) -> None: + """A directly embedded management router cannot be mounted open.""" + app = FastAPI() + app.state.application_token_service = token_service + app.state.operator_api_token = "test-operator-token" + app.include_router(application_token_router) + with TestClient(app) as embedded_client: + denied = embedded_client.get("/application-tokens") + allowed = embedded_client.get( + "/application-tokens", + headers={"Authorization": "Bearer test-operator-token"}, + ) + assert denied.status_code == 401 + assert allowed.status_code == 200 + + +def test_runtime_verify_does_not_require_operator_bearer( + token_service: ApplicationTokenService, +) -> None: + """Runtime verification accepts its own service credential only.""" + issued = token_service.issue(ApplicationTokenIssueRequest.model_validate(ISSUE_BODY)) + app = FastAPI() + app.state.application_token_service = token_service + app.state.runtime_api_token = "test-runtime-token" + app.include_router(application_token_runtime_router) + with TestClient( + app, + headers={"X-Keyverse-Runtime-Token": "test-runtime-token"}, + ) as runtime_client: + response = runtime_client.post( + "/application-tokens:verify", + json={ + "presented_token": issued.plaintext_token, + "software_unit_id": "naruon-web", + }, + ) + assert response.status_code == 200 + assert response.json()["active"] is True + + def test_verify_denies_malformed_unknown_expired_and_capability( client, clock: _Clock ) -> None: @@ -255,6 +329,101 @@ def test_invalid_rotation_preserves_the_active_token( assert still_active.json()["active"] is True +def test_issue_audit_failure_does_not_leave_an_active_token() -> None: + """An audit failure compensates the newly persisted issue record.""" + store = InMemoryKvStore() + service = ApplicationTokenService( + store, + AuditLogger(_FailingAuditSink()), + clock=_Clock(), + ) + with pytest.raises(RuntimeError, match="audit storage"): + service.issue(ApplicationTokenIssueRequest.model_validate(ISSUE_BODY)) + assert store.get_all(APPLICATION_TOKEN_NAMESPACE) == {} + + +def test_issue_storage_failure_does_not_persist_a_token() -> None: + """A failed initial storage write leaves no token record behind.""" + store = _FailingStore(fail_on_put=1) + service = ApplicationTokenService( + store, + AuditLogger(InMemoryAuditSink()), + clock=_Clock(), + ) + with pytest.raises(RuntimeError, match="storage"): + service.issue(ApplicationTokenIssueRequest.model_validate(ISSUE_BODY)) + assert store.get_all(APPLICATION_TOKEN_NAMESPACE) == {} + + +def test_revoke_audit_failure_restores_the_active_token() -> None: + """A revoke audit failure compensates the lifecycle update.""" + store = InMemoryKvStore() + service = ApplicationTokenService( + store, + AuditLogger(InMemoryAuditSink()), + clock=_Clock(), + ) + issued = service.issue(ApplicationTokenIssueRequest.model_validate(ISSUE_BODY)) + service._audit = AuditLogger(_FailingAuditSink()) + with pytest.raises(RuntimeError, match="audit storage"): + service.revoke(issued.application_token_id, actor_identity_id="operator-ida") + verified = service.verify( + ApplicationTokenVerifyRequest( + presented_token=issued.plaintext_token, + software_unit_id="naruon-web", + ) + ) + assert verified.active is True + + +def test_rotate_storage_failure_preserves_the_active_predecessor() -> None: + """A replacement write failure cannot rotate away the predecessor.""" + store = _FailingStore(fail_on_put=2) + service = ApplicationTokenService( + store, + AuditLogger(InMemoryAuditSink()), + clock=_Clock(), + ) + issued = service.issue(ApplicationTokenIssueRequest.model_validate(ISSUE_BODY)) + with pytest.raises(RuntimeError, match="storage"): + service.rotate( + issued.application_token_id, + ApplicationTokenIssueRequest.model_validate(ISSUE_BODY), + ) + verified = service.verify( + ApplicationTokenVerifyRequest( + presented_token=issued.plaintext_token, + software_unit_id="naruon-web", + ) + ) + assert verified.active is True + + +def test_rotate_audit_failure_restores_the_active_predecessor() -> None: + """An audit failure rolls back both replacement and predecessor state.""" + store = InMemoryKvStore() + service = ApplicationTokenService( + store, + AuditLogger(InMemoryAuditSink()), + clock=_Clock(), + ) + issued = service.issue(ApplicationTokenIssueRequest.model_validate(ISSUE_BODY)) + service._audit = AuditLogger(_FailingAuditSink()) + with pytest.raises(RuntimeError, match="audit storage"): + service.rotate( + issued.application_token_id, + ApplicationTokenIssueRequest.model_validate(ISSUE_BODY), + ) + verified = service.verify( + ApplicationTokenVerifyRequest( + presented_token=issued.plaintext_token, + software_unit_id="naruon-web", + ) + ) + assert verified.active is True + assert len(store.get_all(APPLICATION_TOKEN_NAMESPACE)) == 1 + + def test_issue_rejects_password_purposes_and_bounds(client) -> None: """PATs cannot be password substitutes and stay purpose-bounded.""" password = client.post( diff --git a/services/account_unification/tests/test_auth.py b/services/account_unification/tests/test_auth.py index d60a93a..fe7aaf1 100644 --- a/services/account_unification/tests/test_auth.py +++ b/services/account_unification/tests/test_auth.py @@ -137,3 +137,32 @@ def test_service_without_configured_token_fails_closed( }, ) assert client.get("/users/u1").status_code == 503 + + +def test_runtime_routes_use_a_distinct_service_token() -> None: + """Start-login accepts runtime auth without requiring operator auth.""" + app = create_app(wire=False) + app.state.runtime_api_token = "runtime-token" + payload = { + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback", + } + with TestClient(app) as client: + assert client.post( + "/federation/identity-providers:start-login", json=payload + ).status_code == 401 + assert client.post( + "/federation/identity-providers:start-login", + json=payload, + headers={"X-Keyverse-Runtime-Token": "wrong"}, + ).status_code == 403 + assert client.post( + "/federation/identity-providers:start-login", + json=payload, + headers={"X-Keyverse-Runtime-Token": "runtime-token"}, + ).status_code == 503 + with TestClient(create_app(wire=False)) as unconfigured_client: + assert unconfigured_client.post( + "/federation/identity-providers:start-login", json=payload + ).status_code == 503 diff --git a/services/account_unification/tests/test_authorization_plane.py b/services/account_unification/tests/test_authorization_plane.py index db1797c..56f5396 100644 --- a/services/account_unification/tests/test_authorization_plane.py +++ b/services/account_unification/tests/test_authorization_plane.py @@ -26,6 +26,7 @@ ) SNAPSHOT = { "keyverse_subject": "sub-jdoe-opaque", + "tenant_deployment_id": "default-deployment", "org_path": PERSON_PATH, "assignment_record_id": "assignment-record-77", "request_attributes": {"purpose": "hr-review"}, @@ -205,6 +206,71 @@ def test_authorization_plane_rejects_mismatches_duplicates_and_unknowns(client) assert missing_menu_delete.status_code == 404 +def test_authorization_grants_with_same_identity_are_isolated_by_tenant(client) -> None: + """Equivalent grant geometry is valid once per tenant deployment.""" + first = client.put( + "/authorization/software-unit-grants/shared-grant", + json={**SOFTWARE_GRANT, "grant_key": "shared-grant"}, + ) + second = client.put( + "/authorization/software-unit-grants/shared-grant-other", + json={ + **SOFTWARE_GRANT, + "grant_key": "shared-grant-other", + "tenant_deployment_id": "other-deployment", + }, + ) + other_snapshot = {**SNAPSHOT, "tenant_deployment_id": "other-deployment"} + decision = client.post( + "/authorization/software-units:decide", + json={"snapshot": other_snapshot, "software_unit_id": "naruon-web"}, + ) + assert first.status_code == 200 + assert second.status_code == 200 + assert decision.json()["effect"] == "allow" + + +def test_ambiguous_tenant_identifiers_require_scoped_lookup( + store: InMemoryKvStore, +) -> None: + """Legacy identifier-only reads fail closed when tenants share a name.""" + service = AuthorizationPlaneService(store) + service.put_software_unit_grant( + "shared-grant", + AuthorizationGrant.model_validate({**SOFTWARE_GRANT, "grant_key": "shared-grant"}), + ) + service.put_software_unit_grant( + "shared-grant", + AuthorizationGrant.model_validate( + { + **SOFTWARE_GRANT, + "grant_key": "shared-grant", + "tenant_deployment_id": "other-deployment", + } + ), + ) + service.put_combination( + "shared-suite", + SsoCombinationScope.model_validate( + {**COMBINATION, "combination_name": "shared-suite"} + ), + ) + service.put_combination( + "shared-suite", + SsoCombinationScope.model_validate( + { + **COMBINATION, + "combination_name": "shared-suite", + "tenant_deployment_id": "other-deployment", + } + ), + ) + with pytest.raises(Exception, match="tenant_deployment_id"): + service.get_software_unit_grant("shared-grant") + with pytest.raises(Exception, match="tenant_deployment_id"): + service.get_combination("shared-suite") + + def test_authorization_plane_delete_and_replace(client) -> None: """Deletes remove grants and combinations; replace keeps one identity.""" client.put("/authorization/software-unit-grants/acme-naruon", json=SOFTWARE_GRANT) diff --git a/services/account_unification/tests/test_config.py b/services/account_unification/tests/test_config.py index 0f71c21..4dac5c9 100644 --- a/services/account_unification/tests/test_config.py +++ b/services/account_unification/tests/test_config.py @@ -53,6 +53,29 @@ def test_config_loads_from_kv() -> None: assert config.allow_unverified_email_link is False assert config.merge_conflict_policy == "survivor_wins" assert config.registration_api_token is None + assert config.runtime_api_token is None + + +def test_runtime_and_public_issuer_settings_are_loaded_and_separated() -> None: + """Runtime callers use a distinct token and an explicit HTTPS issuer.""" + config = load_service_config( + _config_store( + runtime_api_token="runtime-token", + public_issuer_url="https://login.example/realms/cwl", + ), + "account_unification", + ) + assert config.runtime_api_token == "runtime-token" + assert config.public_issuer_url == "https://login.example/realms/cwl" + + +def test_runtime_token_must_not_equal_operator_token() -> None: + """Runtime service credentials cannot silently gain operator authority.""" + with pytest.raises(RuntimeError, match="runtime_api_token"): + load_service_config( + _config_store(runtime_api_token="operator-token"), + "account_unification", + ) def test_missing_required_config_fails_loudly() -> None: diff --git a/services/account_unification/tests/test_org_authorization.py b/services/account_unification/tests/test_org_authorization.py index 9b30af5..22e710f 100644 --- a/services/account_unification/tests/test_org_authorization.py +++ b/services/account_unification/tests/test_org_authorization.py @@ -34,6 +34,7 @@ def _snapshot(org_path: str = PERSON_PATH, **updates) -> AssignmentSnapshot: """Return one Orgmetra assignment snapshot bound to a Keyverse subject.""" values = { "keyverse_subject": "sub-jdoe-opaque", + "tenant_deployment_id": "default-deployment", "org_path": org_path, "assignment_record_id": "assignment-record-77", "request_attributes": {"purpose": "hr-review"}, @@ -48,6 +49,7 @@ def _software_grant( grant_key: str = "acme-naruon", software_unit_id: str = "naruon-web", effect_code: str = "allow", + attribute_constraints: dict[str, str] | None = None, ) -> AuthorizationGrant: """Return one software-unit grant at an org node.""" return AuthorizationGrant( @@ -57,6 +59,7 @@ def _software_grant( org_path=org_path, software_unit_id=software_unit_id, effect_code=effect_code, + attribute_constraints=attribute_constraints or {}, actor_identity_id="operator-ida", ) @@ -210,6 +213,51 @@ def test_menu_abac_constraint_mismatch_denies() -> None: assert denied.decision_code is AuthorizationDecisionCode.ATTRIBUTE_MISMATCH +def test_software_unit_abac_constraints_are_enforced() -> None: + """Software-unit grants apply the same closed ABAC contract as menus.""" + grants = [ + _software_grant( + "/group_company/acme", + attribute_constraints={"purpose": "hr-review"}, + ) + ] + allowed = decide_software_unit( + grants, + _snapshot(request_attributes={"purpose": "hr-review"}), + "naruon-web", + ) + denied = decide_software_unit( + grants, + _snapshot(request_attributes={"purpose": "payroll"}), + "naruon-web", + ) + assert allowed.effect is AuthorizationEffect.ALLOW + assert denied.effect is AuthorizationEffect.DENY + assert denied.decision_code is AuthorizationDecisionCode.ATTRIBUTE_MISMATCH + + +def test_grants_are_selected_only_for_the_snapshot_tenant() -> None: + """A grant from another tenant cannot authorize this assignment snapshot.""" + decision = decide_software_unit( + [_software_grant("/group_company/acme")], + _snapshot(tenant_deployment_id="other-deployment"), + "naruon-web", + ) + assert decision.effect is AuthorizationEffect.DENY + + +def test_sso_combination_must_match_snapshot_tenant() -> None: + """A combination cannot cross the assignment snapshot tenant boundary.""" + combination = SsoCombinationScope( + combination_name="finance-suite", + tenant_deployment_id="other-deployment", + software_unit_ids=["naruon-web", "clearfolio-web"], + actor_identity_id="operator-ida", + ) + with pytest.raises(AuthorizationPolicyError, match="tenant"): + decide_sso_combination([], _snapshot(), combination) + + def test_sso_combination_requires_every_member_allowed() -> None: """One Keyverse session may cover a combination only when every RP is allowed.""" grants = [ @@ -404,6 +452,7 @@ def test_closed_slug_capability_and_attribute_bounds() -> None: omitted = validate_snapshot( AssignmentSnapshot( keyverse_subject="sub-no-assignment", + tenant_deployment_id="default-deployment", org_path="/group_company/acme", ) ) diff --git a/services/account_unification/tests/test_start_login.py b/services/account_unification/tests/test_start_login.py index 4a53c50..3d95a5e 100644 --- a/services/account_unification/tests/test_start_login.py +++ b/services/account_unification/tests/test_start_login.py @@ -62,6 +62,7 @@ def config() -> ServiceConfig: keycloak_client_id="account-unification-svc", keycloak_client_secret="test-secret", operator_api_token="test-operator-token", + public_issuer_url="https://idp.example/realms/cwl", ) @@ -71,7 +72,11 @@ def client(store: InMemoryKvStore, config: ServiceConfig, auth_header): app = create_app(wire=False) app.state.start_login_service = StartLoginService(store, config) app.state.operator_api_token = config.operator_api_token - with TestClient(app, headers=auth_header) as test_client: + app.state.runtime_api_token = "test-runtime-token" + with TestClient( + app, + headers={**auth_header, "X-Keyverse-Runtime-Token": "test-runtime-token"}, + ) as test_client: yield test_client @@ -295,6 +300,21 @@ def test_start_login_public_issuer_and_redirect_bounds(client) -> None: assert ftp.status_code == 400 +def test_start_login_rejects_untrusted_public_issuer(client) -> None: + """The browser endpoint stays bound to configured Keyverse issuer state.""" + response = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback", + "public_issuer_url": "https://attacker.example/realms/cwl", + }, + ) + assert response.status_code == 400 + assert "configured Keyverse issuer" in response.json()["detail"] + + def test_empty_registry_returns_discovery_without_start_url( config: ServiceConfig, auth_header ) -> None: @@ -302,7 +322,11 @@ def test_empty_registry_returns_discovery_without_start_url( app = create_app(wire=False) app.state.start_login_service = StartLoginService(InMemoryKvStore(), config) app.state.operator_api_token = config.operator_api_token - with TestClient(app, headers=auth_header) as client: + app.state.runtime_api_token = "test-runtime-token" + with TestClient( + app, + headers={**auth_header, "X-Keyverse-Runtime-Token": "test-runtime-token"}, + ) as client: response = client.post( "/federation/identity-providers:start-login", json={ From 5514a24c1a3c56cc0a2a5c458ec4f8cb900c571a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 09:40:57 +0900 Subject: [PATCH 08/14] fix(tokens): reject rotation of inactive credentials --- CHANGELOG.md | 3 ++ .../app/application_tokens.py | 8 +++++ .../tests/test_application_tokens.py | 30 +++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3130177..d1af527 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,9 @@ Keep a Changelog, and releases use semantic versioning. ### Fixed +- Application-token rotation now rejects revoked, already-rotated, and expired + predecessors instead of reviving retired credentials. + - 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/services/account_unification/app/application_tokens.py b/services/account_unification/app/application_tokens.py index eee3cd6..b57d0ab 100644 --- a/services/account_unification/app/application_tokens.py +++ b/services/account_unification/app/application_tokens.py @@ -239,6 +239,14 @@ def rotate( _validate_token_id(application_token_id) with self._state_lock: existing = self._require_record(application_token_id) + if ( + existing.lifecycle_status_code != ACTIVE_LIFECYCLE + or existing.expires_at <= self._clock() + ): + raise AuthorizationPolicyError( + "application token is not active", + status_code=409, + ) if existing.software_unit_id != request.software_unit_id: raise AuthorizationPolicyError( "rotated token must stay bound to the same software unit" diff --git a/services/account_unification/tests/test_application_tokens.py b/services/account_unification/tests/test_application_tokens.py index 9c42a8d..69f9926 100644 --- a/services/account_unification/tests/test_application_tokens.py +++ b/services/account_unification/tests/test_application_tokens.py @@ -19,6 +19,7 @@ get_application_token_service, ) from app.audit import AuditLogger, InMemoryAuditSink +from app.errors import AuthorizationPolicyError from app.kv_store import InMemoryKvStore from app.main import create_app @@ -298,6 +299,35 @@ def test_rotate_replaces_token_and_rejects_software_unit_change(client) -> None: assert mismatch.status_code == 400 +@pytest.mark.parametrize("retirement", ["revoke", "rotate", "expire"]) +def test_rotate_rejects_a_retired_or_expired_predecessor( + token_service: ApplicationTokenService, + clock: _Clock, + retirement: str, +) -> None: + """Rotation cannot revive a revoked, rotated, or expired credential.""" + request = ApplicationTokenIssueRequest.model_validate(ISSUE_BODY) + issued = token_service.issue(request) + + if retirement == "revoke": + token_service.revoke(issued.application_token_id, actor_identity_id="operator-ida") + elif retirement == "rotate": + token_service.rotate(issued.application_token_id, request) + else: + clock.now += request.lifetime_seconds + + with pytest.raises(AuthorizationPolicyError, match="not active") as error: + token_service.rotate(issued.application_token_id, request) + + assert error.value.status_code == 409 + predecessor = token_service.get_token(issued.application_token_id) + assert predecessor.lifecycle_status_code == { + "revoke": "revoked", + "rotate": "rotated", + "expire": "active", + }[retirement] + + @pytest.mark.parametrize( "invalid_update", [ From 9274d3184443fba6c6294e08dff20734f3ae6fb4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 09:47:05 +0900 Subject: [PATCH 09/14] docs(tokens): document inactive rotation policy --- ARCHITECTURE.md | 3 ++- docs/OPERABILITY.md | 5 +++-- docs/adr/0012-programmable-application-tokens.md | 9 +++++---- docs/doctoring/programmable-application-tokens.md | 6 ++++-- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 983c547..dbdd740 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -204,7 +204,8 @@ Keyverse binds an opaque subject and does not copy the Orgmetra tree. Relying applications start brokered login through the Keyverse start-login helper (ADR-0011) and may present software-unit-scoped programmable tokens -(ADR-0012) that are hashed at rest and never inherit org-tree grants. +(ADR-0012) that are hashed at rest, never inherit org-tree grants, and cannot be +rotated after revocation, prior rotation, or expiry. ## Account and provisioning invariants diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index f15b537..1cdd429 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -81,8 +81,9 @@ Mapper unit tests alone do not prove Naruon product authorization readiness. 6. An operator calls `POST /application-tokens` with the operator bearer, stores the one-time plaintext response in the relying application's secret manager, and discards the response. The application presents that PAT to - `POST /application-tokens:verify` with the runtime service token. Rotate or - revoke instead of sharing a password. + `POST /application-tokens:verify` with the runtime service token. Rotate an + active, unexpired token or revoke it; retired and expired tokens cannot be + revived. Never share a password. See `docs/authorization-onboarding.md` and `docs/operations/authorization-plane.md`. diff --git a/docs/adr/0012-programmable-application-tokens.md b/docs/adr/0012-programmable-application-tokens.md index 0543570..bc18f08 100644 --- a/docs/adr/0012-programmable-application-tokens.md +++ b/docs/adr/0012-programmable-application-tokens.md @@ -21,10 +21,11 @@ inherit down the org tree (ADR-0010). `operator_export`. Password, WebAuthn, browser-login, and authenticator purposes are rejected. 3. Tokens are software-unit and API-capability scoped, time-bounded (60 - seconds to 90 days), rotatable, revocable, and auditable. Rotation validates - the replacement before revoking the active token and compensates storage or - audit failures, so invalid or incomplete replacement actions do not destroy - a working credential. + seconds to 90 days), rotatable, revocable, and auditable. Rotation accepts + only an active, unexpired predecessor, validates the replacement before + revoking it, and compensates storage or audit failures, so invalid, + incomplete, or retired-token replacement actions do not destroy or revive a + credential. 4. The plaintext secret is returned only at issue or rotate time. List, get, verify, and revoke responses never include the secret or hash. 5. Verification does not consult org-tree grants. Tokens never inherit. diff --git a/docs/doctoring/programmable-application-tokens.md b/docs/doctoring/programmable-application-tokens.md index e27c30b..320491f 100644 --- a/docs/doctoring/programmable-application-tokens.md +++ b/docs/doctoring/programmable-application-tokens.md @@ -28,8 +28,10 @@ both plaintext and hash. 2. Lifetime bounded to 60 seconds–90 days. 3. At least one API capability is required. 4. Verification ignores org-tree grants; tokens never inherit. -5. Rotation validates the replacement settings before revoking the previous - hash, then issues a replacement bound to the same software unit. +5. Rotation accepts only an active, unexpired predecessor, validates the + replacement settings before revoking its hash, and issues a replacement + bound to the same software unit. Revoked, rotated, and expired predecessors + fail closed with a conflict response. ## Measured repository evidence From e765f4860177af47b80b05ee3a918a4dc2cb4450 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 15:14:13 +0900 Subject: [PATCH 10/14] fix(authorization): close tenant and runtime auth gaps --- CHANGELOG.md | 10 +++ docs/ERD.md | 4 ++ docs/TEST_STRATEGY.md | 8 ++- .../0010-hierarchical-authorization-plane.md | 9 ++- docs/adr/0011-app-start-login-helper.md | 5 ++ .../0012-programmable-application-tokens.md | 7 +- docs/authorization-onboarding.md | 9 ++- .../hierarchical-authorization-plane.md | 8 ++- .../programmable-application-tokens.md | 2 + docs/doctoring/start-login-helper.md | 5 +- docs/operations/authorization-plane.md | 13 +++- .../app/application_tokens.py | 57 ++++++++++++--- .../app/authorization_plane.py | 63 +++++++++++++---- services/account_unification/app/main.py | 4 -- .../app/org_authorization.py | 20 ++++-- .../tests/test_application_tokens.py | 43 ++++++++++-- .../tests/test_authorization_plane.py | 54 +++++++++++++++ .../tests/test_org_authorization.py | 69 +++++++++++++------ .../tests/test_start_login.py | 29 ++++++++ 19 files changed, 345 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1af527..1c7befc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,13 @@ Keep a Changelog, and releases use semantic versioning. ### Changed +- Authorization decisions now require explicit tenant-bound assignment + snapshots, grants, SSO combinations, and application-token verification; + software-unit grants reject menu-only ABAC constraints. +- Start-login now uses a configured Keycloak public issuer and is exposed as a + front-channel runtime helper, while PAT management remains operator-gated + and PAT verification remains token-gated. + - The hierarchical authorization router now carries its operator-authentication and privileged-path dependencies at the module boundary, so direct CWL/Naruon embedding cannot accidentally mount grant administration without the existing @@ -115,6 +122,9 @@ Keep a Changelog, and releases use semantic versioning. - Application-token rotation now rejects revoked, already-rotated, and expired predecessors instead of reviving retired credentials. +- Prevented cross-tenant authorization selection, SSO-name collisions, PAT + tenant confusion, expired-token revival, and audit-failure state leakage; + start-login can no longer reflect an attacker-selected issuer. - Prevented relying-party inventory from silently accepting a KV key/body identity mismatch, rejected unsafe live or `Location`-derived client UUIDs, diff --git a/docs/ERD.md b/docs/ERD.md index 7653100..698133b 100644 --- a/docs/ERD.md +++ b/docs/ERD.md @@ -234,6 +234,10 @@ or documentation labels are bypassed. - Keycloak UUIDs, federation aliases, RP client IDs, email values, and external subjects are data identifiers, not authorization by themselves. - Exact external identity key is `(identity_provider, subject)`; verified email may support matching under policy but unverified email never authorizes linking. - `tenant_deployment_id` is explicit in Keyverse-owned records; deployment/customer separation must not be inferred from realm/resource names. +- The non-persisted `AssignmentSnapshot` decision input also requires an + explicit `tenant_deployment_id`. Software-unit `attribute_constraints` are + intentionally absent: ABAC belongs to `AUTHORIZATION_MENU_GRANT`; a + software-unit payload carrying those constraints is rejected. - Secrets are referenced through protected values/handles where possible; secret-free desired-state tables must never gain client/bind credentials accidentally. - Application access tokens store the hashed secret and lifecycle metadata only; plaintext tokens and org-tree secrets never appear on grant or combination diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index 77ed60c..d79f5bf 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -84,10 +84,14 @@ repository supplies its own exact token-validation and ABAC/RBAC evidence. - software-unit ACL isolation per relying party; - menu decisions require software-unit allow, then ABAC, then RBAC capabilities; - SSO combinations allow only when every member software unit is allowed; -- start-login uses the local registry, rejects discovery/metadata URLs, and - performs no Keycloak or network I/O; +- start-login uses the local registry, rejects discovery/metadata URLs and + untrusted issuer overrides, and performs no Keycloak or network I/O; - PAT issue returns plaintext once; verify/revoke/rotate never echo secrets; - PAT purposes cannot be password or authenticator substitutes; +- authorization snapshots, grants, SSO combinations, and PAT verification + require explicit matching tenants; +- PAT mutation state rolls back when audit persistence fails, and expired or + retired predecessors cannot rotate; - PATs do not inherit org-tree grants; - Orgmetra trees are not persisted as source of record. diff --git a/docs/adr/0010-hierarchical-authorization-plane.md b/docs/adr/0010-hierarchical-authorization-plane.md index 0d69197..933d4c1 100644 --- a/docs/adr/0010-hierarchical-authorization-plane.md +++ b/docs/adr/0010-hierarchical-authorization-plane.md @@ -37,6 +37,8 @@ enforce ABAC/RBAC at its own boundary. `tenant_deployment_id`, `org_path`, optional `assignment_record_id`). Keyverse does not persist or synchronize the Orgmetra tree. Grant selection, combination lookup, and duplicate identity are tenant-qualified. + Keyverse never infers tenant from a client, realm, UUID, email, or + federation source name. 3. Hierarchical attributes use distinct names: `group_company`, `legal_entity`, `business_unit`, `team`, `person`, and structured `org_path`. `role`, `org`, and `workspace` stay reserved for the @@ -50,12 +52,15 @@ enforce ABAC/RBAC at its own boundary. subtree. Default is deny. Secrets and programmable application tokens never inherit. 5. SSO combination scopes are named sets of software units. A combination is - allowed only when every member software unit is allowed for that snapshot. + tenant-scoped and is allowed only when its tenant matches the snapshot and + every member software unit is allowed for that snapshot. Ambiguous + same-name administration reads and deletes require an explicit tenant. The Keycloak session remains Keycloak-owned; this plane only authorizes which RP set may share it. 6. Menu decisions apply software-unit ACL first, then ABAC constraints (`purpose`, `sensitivity`, `clearance`, `residency`), then remaining RBAC - capability codes. + capability codes. Software-unit grants carry capabilities only; their + attribute constraints are rejected because ABAC is a menu-level contract. ## Consequences diff --git a/docs/adr/0011-app-start-login-helper.md b/docs/adr/0011-app-start-login-helper.md index fef30a9..ee919b7 100644 --- a/docs/adr/0011-app-start-login-helper.md +++ b/docs/adr/0011-app-start-login-helper.md @@ -20,6 +20,11 @@ SAML/OIDC preflight already forbids metadata and discovery fetches. 4. The RP must add PKCE `S256`, `state`, and `nonce` locally, then redirect the browser. The helper does not mint secrets or replace the OIDC client. 5. A discovery-document or metadata URL in the request is rejected. +6. The authorization issuer is the configured Keycloak public issuer (or the + configured server/realm fallback). A request may not redirect the helper + to an attacker-selected issuer. +7. Start-login is a front-channel runtime helper; it does not require the + operator bearer used by federation desired-state administration. ## Consequences diff --git a/docs/adr/0012-programmable-application-tokens.md b/docs/adr/0012-programmable-application-tokens.md index bc18f08..eae8b04 100644 --- a/docs/adr/0012-programmable-application-tokens.md +++ b/docs/adr/0012-programmable-application-tokens.md @@ -29,7 +29,12 @@ inherit down the org tree (ADR-0010). 4. The plaintext secret is returned only at issue or rotate time. List, get, verify, and revoke responses never include the secret or hash. 5. Verification does not consult org-tree grants. Tokens never inherit. -6. A token is not an authenticator. Browser passwordless policy (ADR-0002) +6. Tenant is explicit at issue, verify, and rotate time; a token is accepted + only for its stored tenant. Management routes require the operator bearer, + while `:verify` is a runtime route authenticated by the presented token. +7. Issue, revoke, and rotate mutations roll back token state when audit + persistence fails. Expired or retired predecessors cannot be rotated. +8. A token is not an authenticator. Browser passwordless policy (ADR-0002) remains unchanged. ## Consequences diff --git a/docs/authorization-onboarding.md b/docs/authorization-onboarding.md index f1f3fd0..fcf181b 100644 --- a/docs/authorization-onboarding.md +++ b/docs/authorization-onboarding.md @@ -73,7 +73,9 @@ curl --config "$AUTH_CONFIG" --request POST \ "$KEYVERSE_RUNTIME/federation/identity-providers:start-login" ``` -3. Add PKCE `S256`, `state`, and `nonce` in the application. +3. Add PKCE `S256`, `state`, and `nonce` in the application. The helper uses + the configured Keycloak public issuer and does not accept an arbitrary + issuer in the request. 4. Redirect the browser to `start_login_url`. Do not fetch IdP metadata from the app. Federation ownership stays in Keyverse. @@ -82,7 +84,7 @@ curl --config "$AUTH_CONFIG" --request POST \ ```bash curl --config "$AUTH_CONFIG" --request POST \ --header "Content-Type: application/json" \ - --data '{"software_unit_id":"naruon-web","purpose_code":"machine_api","capability_codes":["api.invoices.read"],"lifetime_seconds":3600,"actor_identity_id":"operator-ida"}' \ + --data '{"tenant_deployment_id":"buyer-deployment","software_unit_id":"naruon-web","purpose_code":"machine_api","capability_codes":["api.invoices.read"],"lifetime_seconds":3600,"actor_identity_id":"operator-ida"}' \ "$KEYVERSE_ADMIN/application-tokens" ``` @@ -91,6 +93,9 @@ response. Present the token only to `POST /application-tokens:verify` with the same software unit and requested API capabilities, plus the runtime service header. Rotate or revoke instead of treating the token as a password. Tokens never inherit org-tree grants. +The request must also carry the same explicit tenant; use the runtime service +header and never treat the token as a password. Tokens never inherit org-tree +grants. Keep bearer tokens out of `curl` process arguments; use a private `--config` file as in `docs/rp-onboarding.md`. diff --git a/docs/doctoring/hierarchical-authorization-plane.md b/docs/doctoring/hierarchical-authorization-plane.md index e96ab8b..9ec9415 100644 --- a/docs/doctoring/hierarchical-authorization-plane.md +++ b/docs/doctoring/hierarchical-authorization-plane.md @@ -68,9 +68,11 @@ revalidated. ## Assumptions and limitations -Callers supply a current Orgmetra snapshot. This slice does not subscribe to -Orgmetra change feeds. Production login acceptance remains a separate -runtime evidence boundary. +Callers supply a current Orgmetra snapshot with an explicit tenant deployment. +Grant and SSO evaluation filters that tenant before applying inheritance; a +software-unit grant cannot carry menu ABAC constraints. This slice does not +subscribe to Orgmetra change feeds. Production login acceptance remains a +separate runtime evidence boundary. ## References diff --git a/docs/doctoring/programmable-application-tokens.md b/docs/doctoring/programmable-application-tokens.md index 320491f..64563b3 100644 --- a/docs/doctoring/programmable-application-tokens.md +++ b/docs/doctoring/programmable-application-tokens.md @@ -40,6 +40,8 @@ verify, revoke, rotate, expiry, capability denial, software-unit mismatch, password-purpose rejection, secret omission, preservation of the active token after invalid rotation settings, and compensation after injected KV or audit failure. Management and runtime router authentication are tested separately. +Tenant mismatch, direct router embedding, and retired-predecessor rejection are +also covered. ## Assumptions and limitations diff --git a/docs/doctoring/start-login-helper.md b/docs/doctoring/start-login-helper.md index 1c2e7b4..5eddd90 100644 --- a/docs/doctoring/start-login-helper.md +++ b/docs/doctoring/start-login-helper.md @@ -18,7 +18,9 @@ PKCE, `state`, and `nonce`. SAML and OIDC preflight in this repository already forbid metadata and discovery fetches. The helper preserves that boundary: it reads the local -desired-state registry and rejects `.well-known` or metadata URLs. +desired-state registry, rejects `.well-known` or metadata URLs, and accepts +only the configured Keycloak public issuer. Start-login is a runtime +front-channel helper rather than an operator-admin route. The authorization endpoint is also bound to the configured public Keyverse issuer (or the configured Keycloak realm URL when no public override exists). @@ -40,6 +42,7 @@ are normalized before the no-fetch policy check. The service only constructs a response URL; it does not dereference the supplied issuer, so a security scan's SSRF label is recorded here as a URL-normalization policy defect rather than live server-side network evidence. +They also prove untrusted issuer rejection and authenticated runtime embedding. ## Assumptions and limitations diff --git a/docs/operations/authorization-plane.md b/docs/operations/authorization-plane.md index 85b0222..ca1e5b9 100644 --- a/docs/operations/authorization-plane.md +++ b/docs/operations/authorization-plane.md @@ -16,10 +16,15 @@ shared operator bearer. ## Persist grants 1. Confirm the org path is contiguous from `group_company`. -2. PUT the software-unit grant, then any menu grants. -3. Decide with a current Orgmetra snapshot. If the effect is unexpected, +2. Put the explicit `tenant_deployment_id` on the snapshot and every grant. +3. PUT the software-unit grant, then any menu grants. +4. Decide with a current Orgmetra snapshot. If the effect is unexpected, inspect winning_org_path and whether a more-specific deny exists. -4. Do not persist Orgmetra organization units into Keyverse. +5. Do not persist Orgmetra organization units into Keyverse. + +When the same combination name exists in more than one tenant, include +`tenant_deployment_id` as the GET/DELETE query parameter; an ambiguous +administration operation fails closed. ## Start-login failures @@ -40,6 +45,8 @@ shared operator bearer. 4. Confirm the old token verifies as `revoked_token` and the new token verifies as active. 5. Revoke unused tokens instead of extending them as login credentials. + Include the same explicit tenant in rotate and verify requests; an expired + predecessor is not rotatable. ## Recovery diff --git a/services/account_unification/app/application_tokens.py b/services/account_unification/app/application_tokens.py index b57d0ab..0761b98 100644 --- a/services/account_unification/app/application_tokens.py +++ b/services/account_unification/app/application_tokens.py @@ -49,6 +49,7 @@ tags=["application-tokens"], dependencies=[runtime_auth_dependency], ) +_MANAGEMENT_DEPENDENCIES = [operator_auth_dependency, admin_path_security_dependency] class ApplicationTokenIssueRequest(BaseModel): @@ -61,7 +62,7 @@ class ApplicationTokenIssueRequest(BaseModel): capability_codes: list[str] lifetime_seconds: int = Field(default=3600, ge=1) actor_identity_id: str = Field(min_length=1, max_length=128) - tenant_deployment_id: str = "default-deployment" + tenant_deployment_id: str class ApplicationTokenRecord(BaseModel): @@ -90,6 +91,7 @@ class ApplicationTokenIssueResponse(BaseModel): model_config = ConfigDict(extra="forbid") application_token_id: str + tenant_deployment_id: str software_unit_id: str token_prefix: str purpose_code: str @@ -111,6 +113,7 @@ class ApplicationTokenView(BaseModel): model_config = ConfigDict(extra="forbid") application_token_id: str + tenant_deployment_id: str software_unit_id: str token_prefix: str purpose_code: str @@ -131,6 +134,7 @@ class ApplicationTokenVerifyRequest(BaseModel): model_config = ConfigDict(extra="forbid") presented_token: str = Field(min_length=8, max_length=256) + tenant_deployment_id: str software_unit_id: str requested_capability_codes: list[str] = Field(default_factory=list) @@ -144,6 +148,7 @@ class ApplicationTokenVerifyResponse(BaseModel): effect: str denial_code: str | None = None application_token_id: str | None = None + tenant_deployment_id: str | None = None software_unit_id: str | None = None capability_codes: list[str] = Field(default_factory=list) purpose_code: str | None = None @@ -237,8 +242,20 @@ def rotate( ) -> ApplicationTokenIssueResponse: """Revoke one active token and issue a replacement in one actor action.""" _validate_token_id(application_token_id) + validate_slug( + request.tenant_deployment_id, + field_name="tenant_deployment_id", + ) with self._state_lock: existing = self._require_record(application_token_id) + if existing.software_unit_id != request.software_unit_id: + raise AuthorizationPolicyError( + "rotated token must stay bound to the same software unit" + ) + if existing.tenant_deployment_id != request.tenant_deployment_id: + raise AuthorizationPolicyError( + "rotated token must stay bound to the same tenant" + ) if ( existing.lifecycle_status_code != ACTIVE_LIFECYCLE or existing.expires_at <= self._clock() @@ -247,19 +264,15 @@ def rotate( "application token is not active", status_code=409, ) - if existing.software_unit_id != request.software_unit_id: - raise AuthorizationPolicyError( - "rotated token must stay bound to the same software unit" - ) - record, plaintext = self._mint( - request, replaced_token_id=application_token_id - ) updated = existing.model_copy( update={ "lifecycle_status_code": ROTATED_LIFECYCLE, "revoked_at": self._clock(), } ) + record, plaintext = self._mint( + request, replaced_token_id=application_token_id + ) try: self._write_record(record) self._write_record(updated) @@ -279,6 +292,10 @@ def verify( ) -> ApplicationTokenVerifyResponse: """Verify a presented token without consulting org-tree grants.""" validate_slug(request.software_unit_id, field_name="software_unit_id") + validate_slug( + request.tenant_deployment_id, + field_name="tenant_deployment_id", + ) requested = validate_capability_codes(request.requested_capability_codes) parsed = _parse_presented_token(request.presented_token) if parsed is None: @@ -296,6 +313,8 @@ def verify( if not matches: return _inactive("unknown_token") record = matches[0] + if record.tenant_deployment_id != request.tenant_deployment_id: + return _inactive("tenant_mismatch") if record.lifecycle_status_code != ACTIVE_LIFECYCLE: return _inactive("revoked_token", record) if record.expires_at <= now: @@ -308,6 +327,7 @@ def verify( active=True, effect="allow", application_token_id=record.application_token_id, + tenant_deployment_id=record.tenant_deployment_id, software_unit_id=record.software_unit_id, capability_codes=list(record.capability_codes), purpose_code=record.purpose_code, @@ -415,6 +435,7 @@ def _issue_response( """Build the one-time plaintext issue envelope.""" return ApplicationTokenIssueResponse( application_token_id=record.application_token_id, + tenant_deployment_id=record.tenant_deployment_id, software_unit_id=record.software_unit_id, token_prefix=record.token_prefix, purpose_code=record.purpose_code, @@ -427,6 +448,7 @@ def _view(self, record: ApplicationTokenRecord) -> ApplicationTokenView: """Build a secret-free operator view.""" return ApplicationTokenView( application_token_id=record.application_token_id, + tenant_deployment_id=record.tenant_deployment_id, software_unit_id=record.software_unit_id, token_prefix=record.token_prefix, purpose_code=record.purpose_code, @@ -452,6 +474,7 @@ def _audit_event( actor=actor_identity_id, payload={ "application_token_id": record.application_token_id, + "tenant_deployment_id": record.tenant_deployment_id, "software_unit_id": record.software_unit_id, "token_prefix": record.token_prefix, "purpose_code": record.purpose_code, @@ -516,6 +539,9 @@ def _inactive( effect="deny", denial_code=denial_code, application_token_id=None if record is None else record.application_token_id, + tenant_deployment_id=( + None if record is None else record.tenant_deployment_id + ), software_unit_id=None if record is None else record.software_unit_id, purpose_code=None if record is None else record.purpose_code, ) @@ -544,7 +570,11 @@ class ApplicationTokenRevokeRequest(BaseModel): actor_identity_id: str = Field(min_length=1, max_length=128) -@application_token_router.post("", response_model=ApplicationTokenIssueResponse) +@application_token_router.post( + "", + response_model=ApplicationTokenIssueResponse, + dependencies=_MANAGEMENT_DEPENDENCIES, +) def issue_application_token( body: ApplicationTokenIssueRequest, service: ApplicationTokenService = Depends(get_application_token_service), @@ -556,7 +586,11 @@ def issue_application_token( raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc -@application_token_router.get("", response_model=list[ApplicationTokenView]) +@application_token_router.get( + "", + response_model=list[ApplicationTokenView], + dependencies=_MANAGEMENT_DEPENDENCIES, +) def list_application_tokens( service: ApplicationTokenService = Depends(get_application_token_service), ) -> list[ApplicationTokenView]: @@ -570,6 +604,7 @@ def list_application_tokens( @application_token_router.get( "/{application_token_id}", response_model=ApplicationTokenView, + dependencies=_MANAGEMENT_DEPENDENCIES, ) def get_application_token( application_token_id: str, @@ -585,6 +620,7 @@ def get_application_token( @application_token_router.post( "/{application_token_id}:revoke", response_model=ApplicationTokenView, + dependencies=_MANAGEMENT_DEPENDENCIES, ) def revoke_application_token( application_token_id: str, @@ -603,6 +639,7 @@ def revoke_application_token( @application_token_router.post( "/{application_token_id}:rotate", response_model=ApplicationTokenIssueResponse, + dependencies=_MANAGEMENT_DEPENDENCIES, ) def rotate_application_token( application_token_id: str, diff --git a/services/account_unification/app/authorization_plane.py b/services/account_unification/app/authorization_plane.py index 35604aa..7ca54f6 100644 --- a/services/account_unification/app/authorization_plane.py +++ b/services/account_unification/app/authorization_plane.py @@ -9,7 +9,7 @@ import threading -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, ConfigDict, ValidationError from .auth import operator_auth_dependency @@ -149,6 +149,11 @@ def get_combination( ) -> SsoCombinationScope: """Return one stored SSO combination.""" validate_slug(combination_name, field_name="combination_name") + if tenant_deployment_id is not None: + validate_slug( + tenant_deployment_id, + field_name="tenant_deployment_id", + ) combinations = [ combination for combination in self.list_combinations() @@ -177,18 +182,44 @@ def list_combinations(self) -> list[SsoCombinationScope]: combinations = [self._parse_combination(raw_value) for raw_value in raw_values] return sorted(combinations, key=lambda item: item.combination_name) - def delete_combination(self, combination_name: str) -> None: + def delete_combination( + self, + combination_name: str, + *, + tenant_deployment_id: str | None = None, + ) -> None: """Remove one SSO combination.""" validate_slug(combination_name, field_name="combination_name") - combination = self.get_combination(combination_name) - with self._state_lock: - self._store.delete( - SSO_COMBINATION_NAMESPACE, - self._scoped_key( - combination.tenant_deployment_id, - combination_name, - ), + if tenant_deployment_id is not None: + validate_slug( + tenant_deployment_id, + field_name="tenant_deployment_id", ) + with self._state_lock: + matches = [] + for entry_key, raw_value in self._store.get_all( + SSO_COMBINATION_NAMESPACE + ).items(): + combination = self._parse_combination(raw_value) + if ( + combination.combination_name == combination_name + and ( + tenant_deployment_id is None + or combination.tenant_deployment_id == tenant_deployment_id + ) + ): + matches.append((entry_key, combination)) + if not matches: + raise AuthorizationPolicyError( + "sso combination is not registered", + status_code=404, + ) + if len(matches) > 1: + raise AuthorizationPolicyError( + "tenant_deployment_id is required for an ambiguous sso combination", + status_code=409, + ) + self._store.delete(SSO_COMBINATION_NAMESPACE, matches[0][0]) def decide_software_unit( self, request: SoftwareUnitDecisionRequest @@ -515,11 +546,15 @@ def list_sso_combinations( ) def get_sso_combination( combination_name: str, + tenant_deployment_id: str | None = Query(default=None), service: AuthorizationPlaneService = Depends(get_authorization_service), ) -> SsoCombinationScope: """Return one stored SSO combination.""" try: - return service.get_combination(combination_name) + return service.get_combination( + combination_name, + tenant_deployment_id=tenant_deployment_id, + ) except AuthorizationPolicyError as exc: _raise_policy_error(exc) @@ -530,11 +565,15 @@ def get_sso_combination( ) def delete_sso_combination( combination_name: str, + tenant_deployment_id: str | None = Query(default=None), service: AuthorizationPlaneService = Depends(get_authorization_service), ) -> None: """Delete one SSO combination.""" try: - service.delete_combination(combination_name) + service.delete_combination( + combination_name, + tenant_deployment_id=tenant_deployment_id, + ) except AuthorizationPolicyError as exc: _raise_policy_error(exc) diff --git a/services/account_unification/app/main.py b/services/account_unification/app/main.py index 6cdfb26..061c421 100644 --- a/services/account_unification/app/main.py +++ b/services/account_unification/app/main.py @@ -230,10 +230,6 @@ def healthz() -> dict: ) app.include_router( application_token_router, - dependencies=[ - operator_auth_dependency, - admin_path_security_dependency, - ], ) app.include_router(application_token_runtime_router) app.include_router( diff --git a/services/account_unification/app/org_authorization.py b/services/account_unification/app/org_authorization.py index d218697..e2ca435 100644 --- a/services/account_unification/app/org_authorization.py +++ b/services/account_unification/app/org_authorization.py @@ -114,7 +114,7 @@ class AssignmentSnapshot(BaseModel): model_config = ConfigDict(extra="forbid") keyverse_subject: str = Field(min_length=1, max_length=128) - tenant_deployment_id: str + tenant_deployment_id: str = Field(min_length=1, max_length=128) org_path: str = Field(min_length=1, max_length=_MAX_ORG_PATH_LENGTH) assignment_record_id: str | None = Field(default=None, max_length=128) request_attributes: dict[str, str] = Field(default_factory=dict) @@ -320,9 +320,9 @@ def validate_grant(grant: AuthorizationGrant) -> AuthorizationGrant: raise AuthorizationPolicyError( "software_unit grants must not carry a menu_path" ) - if constraints and grant.effect_code == DENY_EFFECT: + if constraints: raise AuthorizationPolicyError( - "deny grants cannot carry attribute_constraints" + "software_unit grants must not carry attribute_constraints" ) if capability_codes and grant.effect_code == DENY_EFFECT: raise AuthorizationPolicyError("deny grants cannot carry capability_codes") @@ -369,7 +369,10 @@ def validate_snapshot(snapshot: AssignmentSnapshot) -> AssignmentSnapshot: """Validate one assignment snapshot without contacting Orgmetra.""" if any(character.isspace() or ord(character) < 0x20 for character in snapshot.keyverse_subject): raise AuthorizationPolicyError("keyverse_subject must be an opaque bounded token") - validate_slug(snapshot.tenant_deployment_id, field_name="tenant_deployment_id") + validate_slug( + snapshot.tenant_deployment_id, + field_name="tenant_deployment_id", + ) parsed_org = parse_org_path(snapshot.org_path) if snapshot.assignment_record_id is not None: validate_slug( @@ -488,7 +491,7 @@ def _select_winning_grant( if requested_menu_path is not None: menu_rank = { path: index for index, path in enumerate(menu_ancestor_paths(requested_menu_path)) - } + } for grant in grants: if grant.tenant_deployment_id != tenant_deployment_id: continue @@ -607,9 +610,12 @@ def decide_sso_combination( """Allow a combination only when every member software unit is allowed.""" validated_combination = validate_combination(combination) validated_snapshot = validate_snapshot(snapshot) - if validated_combination.tenant_deployment_id != validated_snapshot.tenant_deployment_id: + if ( + validated_combination.tenant_deployment_id + != validated_snapshot.tenant_deployment_id + ): raise AuthorizationPolicyError( - "sso combination and assignment snapshot must use the same tenant" + "snapshot and combination tenant_deployment_id must match" ) member_decisions = [ decide_software_unit(grants, validated_snapshot, software_unit_id) diff --git a/services/account_unification/tests/test_application_tokens.py b/services/account_unification/tests/test_application_tokens.py index 69f9926..4a5f217 100644 --- a/services/account_unification/tests/test_application_tokens.py +++ b/services/account_unification/tests/test_application_tokens.py @@ -4,8 +4,7 @@ from types import SimpleNamespace import pytest -from fastapi import FastAPI -from fastapi import HTTPException +from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient from app.application_tokens import ( @@ -46,7 +45,7 @@ class _FailingAuditSink(InMemoryAuditSink): def record(self, event) -> None: """Reject every event to exercise lifecycle compensation.""" - raise RuntimeError("injected audit storage failure") + raise RuntimeError("audit unavailable") class _Clock: @@ -105,6 +104,7 @@ def client(token_service, auth_header): "capability_codes": ["api.invoices.read", "api.invoices.write"], "lifetime_seconds": 3600, "actor_identity_id": "operator-ida", + "tenant_deployment_id": "default-deployment", } @@ -133,6 +133,7 @@ def test_issue_verify_revoke_and_secret_omission(client, audit) -> None: "/application-tokens:verify", json={ "presented_token": plaintext, + "tenant_deployment_id": "default-deployment", "software_unit_id": "naruon-web", "requested_capability_codes": ["api.invoices.read"], }, @@ -149,6 +150,7 @@ def test_issue_verify_revoke_and_secret_omission(client, audit) -> None: "/application-tokens:verify", json={ "presented_token": plaintext, + "tenant_deployment_id": "default-deployment", "software_unit_id": "naruon-web", "requested_capability_codes": ["api.invoices.read"], }, @@ -198,6 +200,7 @@ def test_runtime_verify_does_not_require_operator_bearer( "/application-tokens:verify", json={ "presented_token": issued.plaintext_token, + "tenant_deployment_id": "default-deployment", "software_unit_id": "naruon-web", }, ) @@ -216,6 +219,7 @@ def test_verify_denies_malformed_unknown_expired_and_capability( "/application-tokens:verify", json={ "presented_token": "not-a-token", + "tenant_deployment_id": "default-deployment", "software_unit_id": "naruon-web", }, ) @@ -223,6 +227,7 @@ def test_verify_denies_malformed_unknown_expired_and_capability( "/application-tokens:verify", json={ "presented_token": f"kvt_{prefix}_wrong-secret-material-value", + "tenant_deployment_id": "default-deployment", "software_unit_id": "naruon-web", }, ) @@ -230,6 +235,7 @@ def test_verify_denies_malformed_unknown_expired_and_capability( "/application-tokens:verify", json={ "presented_token": plaintext, + "tenant_deployment_id": "default-deployment", "software_unit_id": "clearfolio-web", }, ) @@ -237,15 +243,25 @@ def test_verify_denies_malformed_unknown_expired_and_capability( "/application-tokens:verify", json={ "presented_token": plaintext, + "tenant_deployment_id": "default-deployment", "software_unit_id": "naruon-web", "requested_capability_codes": ["api.payroll.admin"], }, ) + wrong_tenant = client.post( + "/application-tokens:verify", + json={ + "presented_token": plaintext, + "tenant_deployment_id": "other-deployment", + "software_unit_id": "naruon-web", + }, + ) clock.now += 3601 expired = client.post( "/application-tokens:verify", json={ "presented_token": plaintext, + "tenant_deployment_id": "default-deployment", "software_unit_id": "naruon-web", }, ) @@ -253,6 +269,7 @@ def test_verify_denies_malformed_unknown_expired_and_capability( "/application-tokens:verify", json={ "presented_token": plaintext, + "tenant_deployment_id": "default-deployment", "software_unit_id": "Not a slug", }, ) @@ -261,6 +278,7 @@ def test_verify_denies_malformed_unknown_expired_and_capability( assert unknown.json()["denial_code"] == "unknown_token" assert wrong_unit.json()["denial_code"] == "software_unit_mismatch" assert capability.json()["denial_code"] == "capability_denied" + assert wrong_tenant.json()["denial_code"] == "tenant_mismatch" assert expired.json()["denial_code"] == "expired_token" assert all(item.json()["inherits_org_grants"] is False for item in ( malformed, unknown, wrong_unit, capability, expired @@ -270,6 +288,10 @@ def test_verify_denies_malformed_unknown_expired_and_capability( def test_rotate_replaces_token_and_rejects_software_unit_change(client) -> None: """Rotation revokes the old secret and issues a same-unit replacement.""" issued = client.post("/application-tokens", json=ISSUE_BODY).json() + tenant_mismatch = client.post( + f"/application-tokens/{issued['application_token_id']}:rotate", + json={**ISSUE_BODY, "tenant_deployment_id": "other-deployment"}, + ) rotated = client.post( f"/application-tokens/{issued['application_token_id']}:rotate", json=ISSUE_BODY, @@ -280,6 +302,7 @@ def test_rotate_replaces_token_and_rejects_software_unit_change(client) -> None: "/application-tokens:verify", json={ "presented_token": issued["plaintext_token"], + "tenant_deployment_id": "default-deployment", "software_unit_id": "naruon-web", }, ) @@ -287,11 +310,13 @@ def test_rotate_replaces_token_and_rejects_software_unit_change(client) -> None: "/application-tokens:verify", json={ "presented_token": rotated.json()["plaintext_token"], + "tenant_deployment_id": "default-deployment", "software_unit_id": "naruon-web", }, ) assert old.json()["denial_code"] == "revoked_token" assert new.json()["active"] is True + assert tenant_mismatch.status_code == 400 mismatch = client.post( f"/application-tokens/{rotated.json()['application_token_id']}:rotate", json={**ISSUE_BODY, "software_unit_id": "clearfolio-web"}, @@ -351,6 +376,7 @@ def test_invalid_rotation_preserves_the_active_token( "/application-tokens:verify", json={ "presented_token": issued["plaintext_token"], + "tenant_deployment_id": "default-deployment", "software_unit_id": "naruon-web", }, ) @@ -367,7 +393,7 @@ def test_issue_audit_failure_does_not_leave_an_active_token() -> None: AuditLogger(_FailingAuditSink()), clock=_Clock(), ) - with pytest.raises(RuntimeError, match="audit storage"): + with pytest.raises(RuntimeError, match="audit unavailable"): service.issue(ApplicationTokenIssueRequest.model_validate(ISSUE_BODY)) assert store.get_all(APPLICATION_TOKEN_NAMESPACE) == {} @@ -395,11 +421,12 @@ def test_revoke_audit_failure_restores_the_active_token() -> None: ) issued = service.issue(ApplicationTokenIssueRequest.model_validate(ISSUE_BODY)) service._audit = AuditLogger(_FailingAuditSink()) - with pytest.raises(RuntimeError, match="audit storage"): + with pytest.raises(RuntimeError, match="audit unavailable"): service.revoke(issued.application_token_id, actor_identity_id="operator-ida") verified = service.verify( ApplicationTokenVerifyRequest( presented_token=issued.plaintext_token, + tenant_deployment_id="default-deployment", software_unit_id="naruon-web", ) ) @@ -423,6 +450,7 @@ def test_rotate_storage_failure_preserves_the_active_predecessor() -> None: verified = service.verify( ApplicationTokenVerifyRequest( presented_token=issued.plaintext_token, + tenant_deployment_id="default-deployment", software_unit_id="naruon-web", ) ) @@ -439,7 +467,7 @@ def test_rotate_audit_failure_restores_the_active_predecessor() -> None: ) issued = service.issue(ApplicationTokenIssueRequest.model_validate(ISSUE_BODY)) service._audit = AuditLogger(_FailingAuditSink()) - with pytest.raises(RuntimeError, match="audit storage"): + with pytest.raises(RuntimeError, match="audit unavailable"): service.rotate( issued.application_token_id, ApplicationTokenIssueRequest.model_validate(ISSUE_BODY), @@ -447,6 +475,7 @@ def test_rotate_audit_failure_restores_the_active_predecessor() -> None: verified = service.verify( ApplicationTokenVerifyRequest( presented_token=issued.plaintext_token, + tenant_deployment_id="default-deployment", software_unit_id="naruon-web", ) ) @@ -516,6 +545,7 @@ def test_corrupt_single_record_and_control_characters( denied = token_service.verify( ApplicationTokenVerifyRequest( presented_token="kvt_deadbeefcafe_\x00secret", + tenant_deployment_id="default-deployment", software_unit_id="naruon-web", ) ) @@ -538,6 +568,7 @@ def test_stored_hash_length_mismatch_is_unknown( denied = token_service.verify( ApplicationTokenVerifyRequest( presented_token=issued.plaintext_token, + tenant_deployment_id="default-deployment", software_unit_id="naruon-web", ) ) diff --git a/services/account_unification/tests/test_authorization_plane.py b/services/account_unification/tests/test_authorization_plane.py index 56f5396..c0e8a0d 100644 --- a/services/account_unification/tests/test_authorization_plane.py +++ b/services/account_unification/tests/test_authorization_plane.py @@ -162,6 +162,55 @@ def test_menu_and_sso_combination_http_surface(client) -> None: assert combo_decision.json()["effect"] == "allow" +def test_authorization_decisions_are_isolated_by_tenant(client) -> None: + """Same paths and names remain separate across tenant deployments.""" + for tenant, effect in (("default-deployment", "allow"), ("other-deployment", "deny")): + for software_unit_id in ("naruon-web", "clearfolio-web"): + grant_key = f"{tenant.split('-')[0]}-{software_unit_id.split('-')[0]}" + response = client.put( + f"/authorization/software-unit-grants/{grant_key}", + json={ + **SOFTWARE_GRANT, + "grant_key": grant_key, + "tenant_deployment_id": tenant, + "software_unit_id": software_unit_id, + "effect_code": effect, + }, + ) + assert response.status_code == 200 + response = client.put( + "/authorization/sso-combination-scopes/finance-suite", + json={**COMBINATION, "tenant_deployment_id": tenant}, + ) + assert response.status_code == 200 + + listed = client.get("/authorization/sso-combination-scopes") + default_decision = client.post( + "/authorization/sso-combinations:decide", + json={"snapshot": SNAPSHOT, "combination_name": "finance-suite"}, + ) + other_decision = client.post( + "/authorization/sso-combinations:decide", + json={ + "snapshot": {**SNAPSHOT, "tenant_deployment_id": "other-deployment"}, + "combination_name": "finance-suite", + }, + ) + assert len(listed.json()) == 2 + assert client.get("/authorization/sso-combination-scopes/finance-suite").status_code == 409 + assert client.delete("/authorization/sso-combination-scopes/finance-suite").status_code == 409 + assert client.get( + "/authorization/sso-combination-scopes/finance-suite" + "?tenant_deployment_id=other-deployment" + ).status_code == 200 + assert client.delete( + "/authorization/sso-combination-scopes/finance-suite" + "?tenant_deployment_id=other-deployment" + ).status_code == 204 + assert default_decision.json()["effect"] == "allow" + assert other_decision.json()["effect"] == "deny" + + def test_authorization_plane_rejects_mismatches_duplicates_and_unknowns(client) -> None: """Path mismatches, duplicate identities, and missing keys fail closed.""" mismatch = client.put( @@ -354,6 +403,11 @@ def test_direct_service_helpers_cover_getters(store: InMemoryKvStore) -> None: service.put_software_unit_grant("acme-naruon", grant) service.put_menu_grant("acme-naruon-invoices", menu) service.put_combination("finance-suite", combination) + service.put_combination( + "other-suite", + combination.model_copy(update={"combination_name": "other-suite"}), + ) assert service.get_software_unit_grant("acme-naruon").grant_key == "acme-naruon" assert service.get_menu_grant("acme-naruon-invoices").menu_path == "/invoices" assert service.get_combination("finance-suite").combination_name == "finance-suite" + service.delete_combination("finance-suite") diff --git a/services/account_unification/tests/test_org_authorization.py b/services/account_unification/tests/test_org_authorization.py index 22e710f..5ff2ef9 100644 --- a/services/account_unification/tests/test_org_authorization.py +++ b/services/account_unification/tests/test_org_authorization.py @@ -50,11 +50,12 @@ def _software_grant( software_unit_id: str = "naruon-web", effect_code: str = "allow", attribute_constraints: dict[str, str] | None = None, + tenant_deployment_id: str = "default-deployment", ) -> AuthorizationGrant: """Return one software-unit grant at an org node.""" return AuthorizationGrant( grant_key=grant_key, - tenant_deployment_id="default-deployment", + tenant_deployment_id=tenant_deployment_id, grant_scope_code="software_unit", org_path=org_path, software_unit_id=software_unit_id, @@ -109,6 +110,27 @@ def test_ancestor_allow_inherits_to_person_unless_restricted() -> None: assert decision.pep_enforcement_required is True +def test_authorization_grants_cannot_cross_tenant_boundaries() -> None: + """A grant only participates in decisions for its explicit tenant.""" + grants = [ + _software_grant("/group_company/acme", grant_key="tenant-a-allow"), + _software_grant( + "/group_company/acme", + grant_key="tenant-b-deny", + tenant_deployment_id="other-deployment", + effect_code="deny", + ), + ] + default_decision = decide_software_unit(grants, _snapshot(), "naruon-web") + other_decision = decide_software_unit( + grants, + _snapshot(tenant_deployment_id="other-deployment"), + "naruon-web", + ) + assert default_decision.effect is AuthorizationEffect.ALLOW + assert other_decision.effect is AuthorizationEffect.DENY + + def test_more_specific_deny_restricts_inherited_allow() -> None: """A legal-entity deny overrides an ancestor allow for that subtree only.""" grants = [ @@ -213,27 +235,14 @@ def test_menu_abac_constraint_mismatch_denies() -> None: assert denied.decision_code is AuthorizationDecisionCode.ATTRIBUTE_MISMATCH -def test_software_unit_abac_constraints_are_enforced() -> None: - """Software-unit grants apply the same closed ABAC contract as menus.""" - grants = [ - _software_grant( - "/group_company/acme", - attribute_constraints={"purpose": "hr-review"}, - ) - ] - allowed = decide_software_unit( - grants, - _snapshot(request_attributes={"purpose": "hr-review"}), - "naruon-web", +def test_software_unit_abac_constraints_are_rejected() -> None: + """Software-unit grants stay RBAC-only; ABAC belongs to menu grants.""" + grant = _software_grant( + "/group_company/acme", + attribute_constraints={"purpose": "hr-review"}, ) - denied = decide_software_unit( - grants, - _snapshot(request_attributes={"purpose": "payroll"}), - "naruon-web", - ) - assert allowed.effect is AuthorizationEffect.ALLOW - assert denied.effect is AuthorizationEffect.DENY - assert denied.decision_code is AuthorizationDecisionCode.ATTRIBUTE_MISMATCH + with pytest.raises(AuthorizationPolicyError, match="software_unit grants"): + validate_grant(grant) def test_grants_are_selected_only_for_the_snapshot_tenant() -> None: @@ -286,6 +295,18 @@ def test_sso_combination_requires_every_member_allowed() -> None: assert denied.decision_code is AuthorizationDecisionCode.COMBINATION_DENIED +def test_sso_combination_requires_matching_snapshot_tenant() -> None: + """An SSO scope from another tenant cannot authorize this snapshot.""" + combination = SsoCombinationScope( + combination_name="finance-suite", + tenant_deployment_id="other-deployment", + software_unit_ids=["naruon-web", "clearfolio-web"], + actor_identity_id="operator-ida", + ) + with pytest.raises(AuthorizationPolicyError, match="tenant"): + decide_sso_combination([], _snapshot(), combination) + + def test_reserved_lineageweave_names_are_rejected_on_org_and_attributes() -> None: """role/org/workspace cannot be smuggled in as hierarchical names.""" with pytest.raises(AuthorizationPolicyError, match="reserved"): @@ -337,6 +358,12 @@ def test_invalid_grant_shapes_fail_closed() -> None: validate_grant(_software_grant("/group_company/acme", effect_code="deny").model_copy( update={"attribute_constraints": {"purpose": "hr-review"}} )) + with pytest.raises(AuthorizationPolicyError, match="software_unit grants"): + validate_grant( + _software_grant("/group_company/acme").model_copy( + update={"attribute_constraints": {"purpose": "hr-review"}} + ) + ) with pytest.raises(AuthorizationPolicyError, match="capability_codes"): validate_grant(_software_grant("/group_company/acme", effect_code="deny").model_copy( update={"capability_codes": ["menu.read"]} diff --git a/services/account_unification/tests/test_start_login.py b/services/account_unification/tests/test_start_login.py index 3d95a5e..d9a7deb 100644 --- a/services/account_unification/tests/test_start_login.py +++ b/services/account_unification/tests/test_start_login.py @@ -245,6 +245,15 @@ def test_start_login_public_issuer_and_redirect_bounds(client) -> None: "public_issuer_url": "https://user:pass@idp.example/realms/cwl", }, ) + untrusted_issuer = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback", + "public_issuer_url": "https://attacker.example/realms/cwl", + }, + ) query = client.post( "/federation/identity-providers:start-login", json={ @@ -282,6 +291,7 @@ def test_start_login_public_issuer_and_redirect_bounds(client) -> None: }, ) assert credentials.status_code == 400 + assert untrusted_issuer.status_code == 400 assert query.status_code == 400 assert fragment_redirect.status_code == 400 assert oversized.status_code == 400 @@ -340,6 +350,25 @@ def test_empty_registry_returns_discovery_without_start_url( assert response.json()["start_login_url"] is None +def test_start_login_runtime_surface_does_not_need_operator_bearer( + store: InMemoryKvStore, config: ServiceConfig +) -> None: + """The front-channel helper remains callable by an RP runtime.""" + app = create_app(wire=False) + app.state.start_login_service = StartLoginService(store, config) + app.state.runtime_api_token = "test-runtime-token" + with TestClient(app, headers={"X-Keyverse-Runtime-Token": "test-runtime-token"}) as client: + response = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback", + }, + ) + assert response.status_code == 200 + + def test_corrupt_provider_store_and_missing_service(store: InMemoryKvStore, config) -> None: """Corrupt registry rows and missing wiring fail closed.""" store.put(FEDERATION_PROVIDER_NAMESPACE, "broken", "{") From c2af5c56b3c92aa18374a9c296cc044c8194775a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:18:48 +0900 Subject: [PATCH 11/14] fix(authorization): report menu inheritance accurately --- CHANGELOG.md | 2 ++ .../0010-hierarchical-authorization-plane.md | 11 ++++++++--- .../hierarchical-authorization-plane.md | 11 ++++++++++- docs/operations/authorization-plane.md | 5 ++++- .../app/org_authorization.py | 10 ++++++++-- .../tests/test_org_authorization.py | 18 ++++++++++++++++++ 6 files changed, 50 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c7befc..d7a7cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ Keep a Changelog, and releases use semantic versioning. - Authorization decisions now require a tenant-qualified assignment snapshot; grant matching, ABAC constraints, duplicate identity, and KV keys preserve tenant boundaries. +- Authorization decision metadata now marks strict menu-prefix inheritance + correctly when the org path is an exact match. - Start-login issuer input is bound to configured Keyverse public issuer state. - ADR-0008 and the non-fork RP authorization matrix, requiring explicit Keyverse token validation, tenant/resource ABAC, bounded RBAC, and diff --git a/docs/adr/0010-hierarchical-authorization-plane.md b/docs/adr/0010-hierarchical-authorization-plane.md index 933d4c1..97a2df8 100644 --- a/docs/adr/0010-hierarchical-authorization-plane.md +++ b/docs/adr/0010-hierarchical-authorization-plane.md @@ -47,10 +47,15 @@ enforce ABAC/RBAC at its own boundary. context; `org_path` attributes describe Macro-to-Micro assignment evidence. 4. Inheritance: the most specific grant whose org path (and, for menus, menu - path) is an ancestor of the snapshot wins. An ancestor allow applies to + path) is an ancestor of the snapshot wins. For menu grants, menu-path + specificity is the primary tie-break and org-path specificity is the + secondary tie-break; this makes a more-specific menu restriction win over + a narrower org grant when both apply. An ancestor allow applies to descendants; a more-specific deny or replacement grant restricts that - subtree. Default is deny. Secrets and programmable application tokens - never inherit. + subtree. Decision responses set `inherited=true` when either the winning + org path or the winning menu path is a strict ancestor of the requested + path; exact org and menu matches are specific. Default is deny. Secrets and + programmable application tokens never inherit. 5. SSO combination scopes are named sets of software units. A combination is tenant-scoped and is allowed only when its tenant matches the snapshot and every member software unit is allowed for that snapshot. Ambiguous diff --git a/docs/doctoring/hierarchical-authorization-plane.md b/docs/doctoring/hierarchical-authorization-plane.md index 9ec9415..7f913dd 100644 --- a/docs/doctoring/hierarchical-authorization-plane.md +++ b/docs/doctoring/hierarchical-authorization-plane.md @@ -43,13 +43,22 @@ clients in the portable realm. 2. Inheritance is most-specific-wins with default deny. 3. Secrets and PATs never inherit. 4. Decision evaluation performs no Orgmetra, DNS, or Keycloak I/O. +5. Decision metadata reports inheritance for a strict org-path or menu-path + ancestor, so a menu prefix grant at the exact org node is not mislabeled as + a specific decision. +6. Menu-path specificity is evaluated before org-path specificity. This is a + measured policy choice: a narrower menu restriction wins over a narrower + org grant when both are candidates. ## Measured repository evidence `services/account_unification/tests/test_org_authorization.py` and `tests/test_authorization_plane.py` cover inheritance, restriction, software- unit and menu ABAC/RBAC, tenant-isolated grants and combinations, reserved-name -rejection, and fail-closed storage. The HTTP regression suite also verifies that the authorization router +rejection, and fail-closed storage. The focused +`test_menu_path_inheritance_is_reported_when_org_path_is_exact` regression +proves that a strict menu-prefix match sets `inherited=true` even when the org +path is exact. The HTTP regression suite also verifies that the authorization router rejects an unauthenticated direct embedding and accepts only the configured operator bearer. The router now owns both the operator-authentication and privileged-path dependencies rather than relying only on the application diff --git a/docs/operations/authorization-plane.md b/docs/operations/authorization-plane.md index ca1e5b9..8d36aae 100644 --- a/docs/operations/authorization-plane.md +++ b/docs/operations/authorization-plane.md @@ -19,7 +19,10 @@ shared operator bearer. 2. Put the explicit `tenant_deployment_id` on the snapshot and every grant. 3. PUT the software-unit grant, then any menu grants. 4. Decide with a current Orgmetra snapshot. If the effect is unexpected, - inspect winning_org_path and whether a more-specific deny exists. + inspect winning_org_path, winning_menu_path, `inherited`, and whether a + more-specific deny exists. `inherited=true` means the winning org path or + menu path is a strict ancestor of the requested path. For menu decisions, + menu-path specificity is evaluated before org-path specificity. 5. Do not persist Orgmetra organization units into Keyverse. When the same combination name exists in more than one tenant, include diff --git a/services/account_unification/app/org_authorization.py b/services/account_unification/app/org_authorization.py index e2ca435..414f9e1 100644 --- a/services/account_unification/app/org_authorization.py +++ b/services/account_unification/app/org_authorization.py @@ -484,7 +484,7 @@ def _select_winning_grant( grant_scope_code: str, requested_menu_path: str | None, ) -> tuple[AuthorizationGrant | None, bool]: - """Return the most specific matching grant and whether it was inherited.""" + """Return the most specific grant and report org or menu inheritance.""" candidates: list[tuple[int, int, AuthorizationGrant]] = [] org_rank = {path: index for index, path in enumerate(snapshot_path.ancestor_paths())} menu_rank: dict[str, int] = {} @@ -512,7 +512,13 @@ def _select_winning_grant( return None, False candidates.sort(key=lambda item: (item[0], item[1])) winning = candidates[0][2] - inherited = winning.org_path != snapshot_path.serialized + menu_inherited = ( + grant_scope_code == MENU_GRANT_SCOPE + and winning.menu_path is not None + and requested_menu_path is not None + and winning.menu_path != requested_menu_path + ) + inherited = winning.org_path != snapshot_path.serialized or menu_inherited return winning, inherited diff --git a/services/account_unification/tests/test_org_authorization.py b/services/account_unification/tests/test_org_authorization.py index 5ff2ef9..51c65d6 100644 --- a/services/account_unification/tests/test_org_authorization.py +++ b/services/account_unification/tests/test_org_authorization.py @@ -209,6 +209,24 @@ def test_menu_inherit_and_more_specific_menu_deny() -> None: assert payroll.capability_codes == [] +def test_menu_path_inheritance_is_reported_when_org_path_is_exact() -> None: + """Menu-prefix inheritance is visible even at the exact org node.""" + grants = [ + _software_grant(PERSON_PATH), + _menu_grant(PERSON_PATH, menu_path="/invoices"), + ] + decision = decide_menu( + grants, + _snapshot(), + "naruon-web", + "/invoices/approve", + ) + assert decision.effect is AuthorizationEffect.ALLOW + assert decision.winning_menu_path == "/invoices" + assert decision.inherited is True + assert decision.decision_code is AuthorizationDecisionCode.INHERITED_ALLOW + + def test_menu_abac_constraint_mismatch_denies() -> None: """ABAC constraints are evaluated before remaining menu capabilities.""" grants = [ From 86e87b2db0fc5aadfea6b141a523ee7cc59e3fcb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:21:53 +0900 Subject: [PATCH 12/14] fix(authorization): expose tenant-scoped grant management --- CHANGELOG.md | 3 + .../0010-hierarchical-authorization-plane.md | 3 +- .../hierarchical-authorization-plane.md | 6 + docs/operations/authorization-plane.md | 7 +- .../app/authorization_plane.py | 113 +++++++++++++++--- .../tests/test_authorization_plane.py | 34 ++++++ 6 files changed, 146 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7a7cd0..d1122c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ Keep a Changelog, and releases use semantic versioning. tenant boundaries. - Authorization decision metadata now marks strict menu-prefix inheritance correctly when the org path is an exact match. +- Ambiguous same-named authorization grants can now be read or deleted through + an explicit tenant-scoped GET/DELETE query without weakening fail-closed + behavior. - Start-login issuer input is bound to configured Keyverse public issuer state. - ADR-0008 and the non-fork RP authorization matrix, requiring explicit Keyverse token validation, tenant/resource ABAC, bounded RBAC, and diff --git a/docs/adr/0010-hierarchical-authorization-plane.md b/docs/adr/0010-hierarchical-authorization-plane.md index 97a2df8..58ea12c 100644 --- a/docs/adr/0010-hierarchical-authorization-plane.md +++ b/docs/adr/0010-hierarchical-authorization-plane.md @@ -59,7 +59,8 @@ enforce ABAC/RBAC at its own boundary. 5. SSO combination scopes are named sets of software units. A combination is tenant-scoped and is allowed only when its tenant matches the snapshot and every member software unit is allowed for that snapshot. Ambiguous - same-name administration reads and deletes require an explicit tenant. + same-name administration reads and deletes require an explicit tenant; + grant and combination HTTP paths expose that tenant scope explicitly. The Keycloak session remains Keycloak-owned; this plane only authorizes which RP set may share it. 6. Menu decisions apply software-unit ACL first, then ABAC constraints diff --git a/docs/doctoring/hierarchical-authorization-plane.md b/docs/doctoring/hierarchical-authorization-plane.md index 7f913dd..5219cae 100644 --- a/docs/doctoring/hierarchical-authorization-plane.md +++ b/docs/doctoring/hierarchical-authorization-plane.md @@ -49,6 +49,9 @@ clients in the portable realm. 6. Menu-path specificity is evaluated before org-path specificity. This is a measured policy choice: a narrower menu restriction wins over a narrower org grant when both are candidates. +7. Ambiguous same-name grant and combination administration is fail-closed + without a tenant, while GET/DELETE APIs accept an explicit validated + `tenant_deployment_id` for the intended record. ## Measured repository evidence @@ -63,6 +66,9 @@ rejects an unauthenticated direct embedding and accepts only the configured operator bearer. The router now owns both the operator-authentication and privileged-path dependencies rather than relying only on the application factory's include-site wiring. +The `test_ambiguous_grant_reads_and_deletes_accept_explicit_tenant` regression +proves that same-named grants remain ambiguous without scope but can be read +and deleted through the explicit tenant query parameter. The operator bearer is intentionally coarse operator-admin authority. The `actor_identity_id` field is grant and audit metadata selected by that operator; diff --git a/docs/operations/authorization-plane.md b/docs/operations/authorization-plane.md index 8d36aae..2968a0b 100644 --- a/docs/operations/authorization-plane.md +++ b/docs/operations/authorization-plane.md @@ -25,9 +25,10 @@ shared operator bearer. menu-path specificity is evaluated before org-path specificity. 5. Do not persist Orgmetra organization units into Keyverse. -When the same combination name exists in more than one tenant, include -`tenant_deployment_id` as the GET/DELETE query parameter; an ambiguous -administration operation fails closed. +When the same grant key or combination name exists in more than one tenant, +include `tenant_deployment_id` as the GET/DELETE query parameter; an ambiguous +administration operation fails closed. The query value is validated before the +tenant-qualified KV record is selected. ## Start-login failures diff --git a/services/account_unification/app/authorization_plane.py b/services/account_unification/app/authorization_plane.py index 7ca54f6..1734dbd 100644 --- a/services/account_unification/app/authorization_plane.py +++ b/services/account_unification/app/authorization_plane.py @@ -96,13 +96,31 @@ def put_menu_grant(self, grant_key: str, grant: AuthorizationGrant) -> Authoriza namespace=MENU_GRANT_NAMESPACE, ) - def get_software_unit_grant(self, grant_key: str) -> AuthorizationGrant: + def get_software_unit_grant( + self, + grant_key: str, + *, + tenant_deployment_id: str | None = None, + ) -> AuthorizationGrant: """Return one stored software-unit grant.""" - return self._get_grant(SOFTWARE_UNIT_GRANT_NAMESPACE, grant_key) + return self._get_grant( + SOFTWARE_UNIT_GRANT_NAMESPACE, + grant_key, + tenant_deployment_id=tenant_deployment_id, + ) - def get_menu_grant(self, grant_key: str) -> AuthorizationGrant: + def get_menu_grant( + self, + grant_key: str, + *, + tenant_deployment_id: str | None = None, + ) -> AuthorizationGrant: """Return one stored menu grant.""" - return self._get_grant(MENU_GRANT_NAMESPACE, grant_key) + return self._get_grant( + MENU_GRANT_NAMESPACE, + grant_key, + tenant_deployment_id=tenant_deployment_id, + ) def list_software_unit_grants(self) -> list[AuthorizationGrant]: """Return every stored software-unit grant.""" @@ -112,13 +130,31 @@ def list_menu_grants(self) -> list[AuthorizationGrant]: """Return every stored menu grant.""" return self._list_grants(MENU_GRANT_NAMESPACE) - def delete_software_unit_grant(self, grant_key: str) -> None: + def delete_software_unit_grant( + self, + grant_key: str, + *, + tenant_deployment_id: str | None = None, + ) -> None: """Remove one software-unit grant.""" - self._delete_grant(SOFTWARE_UNIT_GRANT_NAMESPACE, grant_key) + self._delete_grant( + SOFTWARE_UNIT_GRANT_NAMESPACE, + grant_key, + tenant_deployment_id=tenant_deployment_id, + ) - def delete_menu_grant(self, grant_key: str) -> None: + def delete_menu_grant( + self, + grant_key: str, + *, + tenant_deployment_id: str | None = None, + ) -> None: """Remove one menu grant.""" - self._delete_grant(MENU_GRANT_NAMESPACE, grant_key) + self._delete_grant( + MENU_GRANT_NAMESPACE, + grant_key, + tenant_deployment_id=tenant_deployment_id, + ) def put_combination( self, combination_name: str, combination: SsoCombinationScope @@ -302,10 +338,29 @@ def _put_grant( ) return validated - def _get_grant(self, namespace: str, grant_key: str) -> AuthorizationGrant: + def _get_grant( + self, + namespace: str, + grant_key: str, + *, + tenant_deployment_id: str | None = None, + ) -> AuthorizationGrant: """Return one stored grant or raise a 404 policy error.""" validate_slug(grant_key, field_name="grant_key") - grants = [grant for grant in self._list_grants(namespace) if grant.grant_key == grant_key] + if tenant_deployment_id is not None: + validate_slug( + tenant_deployment_id, + field_name="tenant_deployment_id", + ) + grants = [ + grant + for grant in self._list_grants(namespace) + if grant.grant_key == grant_key + and ( + tenant_deployment_id is None + or grant.tenant_deployment_id == tenant_deployment_id + ) + ] if not grants: raise AuthorizationPolicyError( "authorization grant is not registered", @@ -325,10 +380,20 @@ def _list_grants(self, namespace: str) -> list[AuthorizationGrant]: grants = [self._parse_grant(raw_value) for raw_value in raw_values] return sorted(grants, key=lambda item: item.grant_key) - def _delete_grant(self, namespace: str, grant_key: str) -> None: + def _delete_grant( + self, + namespace: str, + grant_key: str, + *, + tenant_deployment_id: str | None = None, + ) -> None: """Delete one grant after proving it exists.""" validate_slug(grant_key, field_name="grant_key") - grant = self._get_grant(namespace, grant_key) + grant = self._get_grant( + namespace, + grant_key, + tenant_deployment_id=tenant_deployment_id, + ) with self._state_lock: self._store.delete( namespace, @@ -414,11 +479,15 @@ def list_software_unit_grants( ) def get_software_unit_grant( grant_key: str, + tenant_deployment_id: str | None = Query(default=None), service: AuthorizationPlaneService = Depends(get_authorization_service), ) -> AuthorizationGrant: """Return one stored software-unit grant.""" try: - return service.get_software_unit_grant(grant_key) + return service.get_software_unit_grant( + grant_key, + tenant_deployment_id=tenant_deployment_id, + ) except AuthorizationPolicyError as exc: _raise_policy_error(exc) @@ -426,11 +495,15 @@ def get_software_unit_grant( @authorization_router.delete("/software-unit-grants/{grant_key}", status_code=204) def delete_software_unit_grant( grant_key: str, + tenant_deployment_id: str | None = Query(default=None), service: AuthorizationPlaneService = Depends(get_authorization_service), ) -> None: """Delete one software-unit grant.""" try: - service.delete_software_unit_grant(grant_key) + service.delete_software_unit_grant( + grant_key, + tenant_deployment_id=tenant_deployment_id, + ) except AuthorizationPolicyError as exc: _raise_policy_error(exc) @@ -477,11 +550,15 @@ def list_menu_grants( @authorization_router.get("/menu-grants/{grant_key}", response_model=AuthorizationGrant) def get_menu_grant( grant_key: str, + tenant_deployment_id: str | None = Query(default=None), service: AuthorizationPlaneService = Depends(get_authorization_service), ) -> AuthorizationGrant: """Return one stored menu grant.""" try: - return service.get_menu_grant(grant_key) + return service.get_menu_grant( + grant_key, + tenant_deployment_id=tenant_deployment_id, + ) except AuthorizationPolicyError as exc: _raise_policy_error(exc) @@ -489,11 +566,15 @@ def get_menu_grant( @authorization_router.delete("/menu-grants/{grant_key}", status_code=204) def delete_menu_grant( grant_key: str, + tenant_deployment_id: str | None = Query(default=None), service: AuthorizationPlaneService = Depends(get_authorization_service), ) -> None: """Delete one menu grant.""" try: - service.delete_menu_grant(grant_key) + service.delete_menu_grant( + grant_key, + tenant_deployment_id=tenant_deployment_id, + ) except AuthorizationPolicyError as exc: _raise_policy_error(exc) diff --git a/services/account_unification/tests/test_authorization_plane.py b/services/account_unification/tests/test_authorization_plane.py index c0e8a0d..aca7a44 100644 --- a/services/account_unification/tests/test_authorization_plane.py +++ b/services/account_unification/tests/test_authorization_plane.py @@ -211,6 +211,40 @@ def test_authorization_decisions_are_isolated_by_tenant(client) -> None: assert other_decision.json()["effect"] == "deny" +def test_ambiguous_grant_reads_and_deletes_accept_explicit_tenant(client) -> None: + """Operators can manage same-named grants through an explicit tenant scope.""" + for tenant, effect in (("default-deployment", "allow"), ("other-deployment", "deny")): + response = client.put( + "/authorization/software-unit-grants/shared-grant", + json={ + **SOFTWARE_GRANT, + "grant_key": "shared-grant", + "tenant_deployment_id": tenant, + "effect_code": effect, + }, + ) + assert response.status_code == 200 + + assert client.get("/authorization/software-unit-grants/shared-grant").status_code == 409 + scoped = client.get( + "/authorization/software-unit-grants/shared-grant" + "?tenant_deployment_id=other-deployment" + ) + assert scoped.status_code == 200 + assert scoped.json()["tenant_deployment_id"] == "other-deployment" + + assert client.delete( + "/authorization/software-unit-grants/shared-grant" + ).status_code == 409 + assert client.delete( + "/authorization/software-unit-grants/shared-grant" + "?tenant_deployment_id=other-deployment" + ).status_code == 204 + remaining = client.get("/authorization/software-unit-grants/shared-grant") + assert remaining.status_code == 200 + assert remaining.json()["tenant_deployment_id"] == "default-deployment" + + def test_authorization_plane_rejects_mismatches_duplicates_and_unknowns(client) -> None: """Path mismatches, duplicate identities, and missing keys fail closed.""" mismatch = client.put( From 1af77b0fa0f0e7d52dd0992cb4b15ea011336023 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:42:43 +0900 Subject: [PATCH 13/14] fix: make token rotation storage atomic --- ARCHITECTURE.md | 4 +- CHANGELOG.md | 3 ++ .../programmable-application-tokens.md | 9 +++-- docs/operations/authorization-plane.md | 4 +- .../app/application_tokens.py | 9 ++++- services/account_unification/app/kv_store.py | 22 +++++++++++ .../tests/test_application_tokens.py | 39 +++++++++++++++++++ .../tests/test_storage_concurrency.py | 6 +++ 8 files changed, 89 insertions(+), 7 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dbdd740..a52faff 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -205,7 +205,9 @@ Keyverse binds an opaque subject and does not copy the Orgmetra tree. Relying applications start brokered login through the Keyverse start-login helper (ADR-0011) and may present software-unit-scoped programmable tokens (ADR-0012) that are hashed at rest, never inherit org-tree grants, and cannot be -rotated after revocation, prior rotation, or expiry. +rotated after revocation, prior rotation, or expiry. Rotation writes the +replacement and predecessor through one KV-store transaction, then compensates +the batch if audit persistence fails. ## Account and provisioning invariants diff --git a/CHANGELOG.md b/CHANGELOG.md index d1122c0..0834c33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,9 @@ Keep a Changelog, and releases use semantic versioning. - Start-login now uses a configured Keycloak public issuer and is exposed as a front-channel runtime helper, while PAT management remains operator-gated and PAT verification remains token-gated. +- Application-token rotation now persists the replacement and rotated + predecessor through one atomic KV-store batch before recording the audit + event, preserving a consistent pair across SQLite transaction boundaries. - The hierarchical authorization router now carries its operator-authentication and privileged-path dependencies at the module boundary, so direct CWL/Naruon diff --git a/docs/doctoring/programmable-application-tokens.md b/docs/doctoring/programmable-application-tokens.md index 64563b3..81f4bed 100644 --- a/docs/doctoring/programmable-application-tokens.md +++ b/docs/doctoring/programmable-application-tokens.md @@ -30,8 +30,10 @@ both plaintext and hash. 4. Verification ignores org-tree grants; tokens never inherit. 5. Rotation accepts only an active, unexpired predecessor, validates the replacement settings before revoking its hash, and issues a replacement - bound to the same software unit. Revoked, rotated, and expired predecessors - fail closed with a conflict response. + bound to the same software unit. The replacement and rotated predecessor + are persisted through one atomic KV-store batch; an audit failure still + compensates the batch. Revoked, rotated, and expired predecessors fail + closed with a conflict response. ## Measured repository evidence @@ -39,7 +41,8 @@ both plaintext and hash. verify, revoke, rotate, expiry, capability denial, software-unit mismatch, password-purpose rejection, secret omission, preservation of the active token after invalid rotation settings, and compensation after injected KV or audit -failure. Management and runtime router authentication are tested separately. +failure, including one atomic store write for rotation. Management and runtime +router authentication are tested separately. Tenant mismatch, direct router embedding, and retired-predecessor rejection are also covered. diff --git a/docs/operations/authorization-plane.md b/docs/operations/authorization-plane.md index 2968a0b..6e05bf2 100644 --- a/docs/operations/authorization-plane.md +++ b/docs/operations/authorization-plane.md @@ -47,7 +47,9 @@ tenant-qualified KV record is selected. 3. Place a successful response's new plaintext in the application secret manager before retiring the old credential. 4. Confirm the old token verifies as `revoked_token` and the new token verifies - as active. + as active. Rotation persists both records through one KV-store transaction; + if audit persistence fails, the service compensates both records and the old + token remains active. 5. Revoke unused tokens instead of extending them as login credentials. Include the same explicit tenant in rotate and verify requests; an expired predecessor is not rotatable. diff --git a/services/account_unification/app/application_tokens.py b/services/account_unification/app/application_tokens.py index 0761b98..345cffa 100644 --- a/services/account_unification/app/application_tokens.py +++ b/services/account_unification/app/application_tokens.py @@ -274,8 +274,13 @@ def rotate( request, replaced_token_id=application_token_id ) try: - self._write_record(record) - self._write_record(updated) + self._store.put_many( + APPLICATION_TOKEN_NAMESPACE, + { + record.application_token_id: record.model_dump_json(), + updated.application_token_id: updated.model_dump_json(), + }, + ) self._audit_event( "application_token_rotated", request.actor_identity_id, diff --git a/services/account_unification/app/kv_store.py b/services/account_unification/app/kv_store.py index 341e506..8baca56 100644 --- a/services/account_unification/app/kv_store.py +++ b/services/account_unification/app/kv_store.py @@ -8,6 +8,7 @@ import sqlite3 import threading +from collections.abc import Mapping from typing import Protocol @@ -18,6 +19,10 @@ def put(self, namespace: str, entry_key: str, entry_value: str) -> None: """Store one value in one namespace.""" ... + def put_many(self, namespace: str, entries: Mapping[str, str]) -> None: + """Store multiple values in one namespace atomically.""" + ... + def get(self, namespace: str, entry_key: str) -> str | None: """Return a value or ``None`` when it is absent.""" ... @@ -51,6 +56,11 @@ def put(self, namespace: str, entry_key: str, entry_value: str) -> None: with self._lock: self._data.setdefault(namespace, {})[entry_key] = entry_value + def put_many(self, namespace: str, entries: Mapping[str, str]) -> None: + """Store multiple values in one namespace under one lock.""" + with self._lock: + self._data.setdefault(namespace, {}).update(entries) + def get(self, namespace: str, entry_key: str) -> str | None: """Return one value from one namespace, if present.""" with self._lock: @@ -109,6 +119,18 @@ def put(self, namespace: str, entry_key: str, entry_value: str) -> None: (namespace, entry_key, entry_value), ) + def put_many(self, namespace: str, entries: Mapping[str, str]) -> None: + """Upsert multiple config values in one SQLite transaction.""" + with self._lock, self._connection: + self._connection.executemany( + "INSERT INTO idp_config_entries " + "(config_namespace, entry_key, entry_value) " + "VALUES (?, ?, ?) " + "ON CONFLICT(config_namespace, entry_key) " + "DO UPDATE SET entry_value = excluded.entry_value", + ((namespace, entry_key, entry_value) for entry_key, entry_value in entries.items()), + ) + def get(self, namespace: str, entry_key: str) -> str | None: """Return one config value, if present.""" with self._lock: diff --git a/services/account_unification/tests/test_application_tokens.py b/services/account_unification/tests/test_application_tokens.py index 4a5f217..496765c 100644 --- a/services/account_unification/tests/test_application_tokens.py +++ b/services/account_unification/tests/test_application_tokens.py @@ -39,6 +39,27 @@ def put(self, namespace: str, entry_key: str, entry_value: str) -> None: raise RuntimeError("injected token storage failure") super().put(namespace, entry_key, entry_value) + def put_many(self, namespace: str, entries: dict[str, str]) -> None: + """Raise once at the configured atomic write and otherwise persist normally.""" + self._put_count += 1 + if self._put_count == self._fail_on_put: + raise RuntimeError("injected token storage failure") + super().put_many(namespace, entries) + + +class _AtomicTrackingStore(InMemoryKvStore): + """Record whether a multi-record write uses one store operation.""" + + def __init__(self) -> None: + """Create an empty store with an atomic-write counter.""" + super().__init__() + self.put_many_calls = 0 + + def put_many(self, namespace: str, entries: dict[str, str]) -> None: + """Count and perform one atomic multi-record write.""" + self.put_many_calls += 1 + super().put_many(namespace, entries) + class _FailingAuditSink(InMemoryAuditSink): """Inject an audit persistence failure.""" @@ -457,6 +478,24 @@ def test_rotate_storage_failure_preserves_the_active_predecessor() -> None: assert verified.active is True +def test_rotate_uses_one_atomic_store_write_for_both_records() -> None: + """Rotation persists the replacement and predecessor in one store operation.""" + store = _AtomicTrackingStore() + service = ApplicationTokenService( + store, + AuditLogger(InMemoryAuditSink()), + clock=_Clock(), + ) + issued = service.issue(ApplicationTokenIssueRequest.model_validate(ISSUE_BODY)) + + service.rotate( + issued.application_token_id, + ApplicationTokenIssueRequest.model_validate(ISSUE_BODY), + ) + + assert store.put_many_calls == 1 + + def test_rotate_audit_failure_restores_the_active_predecessor() -> None: """An audit failure rolls back both replacement and predecessor state.""" store = InMemoryKvStore() diff --git a/services/account_unification/tests/test_storage_concurrency.py b/services/account_unification/tests/test_storage_concurrency.py index bd2f86b..63dd557 100644 --- a/services/account_unification/tests/test_storage_concurrency.py +++ b/services/account_unification/tests/test_storage_concurrency.py @@ -32,6 +32,12 @@ def write_entry(index: int) -> str | None: f"entry_value_{index}" for index in range(100) ] assert len(store.get_all("runtime_configuration")) == 100 + store.put_many( + "runtime_configuration", + {"batch_entry_a": "batch_value_a", "batch_entry_b": "batch_value_b"}, + ) + assert store.get("runtime_configuration", "batch_entry_a") == "batch_value_a" + assert store.get("runtime_configuration", "batch_entry_b") == "batch_value_b" def test_sqlite_audit_sink_handles_concurrent_events(tmp_path) -> None: From 77b8f4ea9995329f1c55b916d110b460b4bc7649 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 19:00:37 +0900 Subject: [PATCH 14/14] fix: make token rotation compensation atomic --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 3 +- .../0012-programmable-application-tokens.md | 10 ++-- .../programmable-application-tokens.md | 10 ++-- docs/operations/authorization-plane.md | 4 +- .../app/application_tokens.py | 7 ++- services/account_unification/app/kv_store.py | 47 ++++++++++++++++++- .../tests/test_application_tokens.py | 47 +++++++++++++++++++ .../tests/test_storage_concurrency.py | 19 ++++++++ 9 files changed, 134 insertions(+), 15 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a52faff..fe75099 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -207,7 +207,7 @@ helper (ADR-0011) and may present software-unit-scoped programmable tokens (ADR-0012) that are hashed at rest, never inherit org-tree grants, and cannot be rotated after revocation, prior rotation, or expiry. Rotation writes the replacement and predecessor through one KV-store transaction, then compensates -the batch if audit persistence fails. +the pair through one atomic upsert/delete operation if audit persistence fails. ## Account and provisioning invariants diff --git a/CHANGELOG.md b/CHANGELOG.md index 0834c33..80854c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,7 +88,8 @@ Keep a Changelog, and releases use semantic versioning. and PAT verification remains token-gated. - Application-token rotation now persists the replacement and rotated predecessor through one atomic KV-store batch before recording the audit - event, preserving a consistent pair across SQLite transaction boundaries. + event, and restores that pair with one atomic upsert/delete compensation if + audit persistence fails. - The hierarchical authorization router now carries its operator-authentication and privileged-path dependencies at the module boundary, so direct CWL/Naruon diff --git a/docs/adr/0012-programmable-application-tokens.md b/docs/adr/0012-programmable-application-tokens.md index eae8b04..8820836 100644 --- a/docs/adr/0012-programmable-application-tokens.md +++ b/docs/adr/0012-programmable-application-tokens.md @@ -23,9 +23,9 @@ inherit down the org tree (ADR-0010). 3. Tokens are software-unit and API-capability scoped, time-bounded (60 seconds to 90 days), rotatable, revocable, and auditable. Rotation accepts only an active, unexpired predecessor, validates the replacement before - revoking it, and compensates storage or audit failures, so invalid, - incomplete, or retired-token replacement actions do not destroy or revive a - credential. + revoking it, and uses atomic storage compensation for storage or audit + failures, so invalid, incomplete, or retired-token replacement actions do + not destroy or revive a credential. 4. The plaintext secret is returned only at issue or rotate time. List, get, verify, and revoke responses never include the secret or hash. 5. Verification does not consult org-tree grants. Tokens never inherit. @@ -33,7 +33,9 @@ inherit down the org tree (ADR-0010). only for its stored tenant. Management routes require the operator bearer, while `:verify` is a runtime route authenticated by the presented token. 7. Issue, revoke, and rotate mutations roll back token state when audit - persistence fails. Expired or retired predecessors cannot be rotated. + persistence fails; rotation restores the predecessor and deletes its + replacement in one KV-store operation. Expired or retired predecessors + cannot be rotated. 8. A token is not an authenticator. Browser passwordless policy (ADR-0002) remains unchanged. diff --git a/docs/doctoring/programmable-application-tokens.md b/docs/doctoring/programmable-application-tokens.md index 81f4bed..36aa292 100644 --- a/docs/doctoring/programmable-application-tokens.md +++ b/docs/doctoring/programmable-application-tokens.md @@ -31,9 +31,10 @@ both plaintext and hash. 5. Rotation accepts only an active, unexpired predecessor, validates the replacement settings before revoking its hash, and issues a replacement bound to the same software unit. The replacement and rotated predecessor - are persisted through one atomic KV-store batch; an audit failure still - compensates the batch. Revoked, rotated, and expired predecessors fail - closed with a conflict response. + are persisted through one atomic KV-store batch; an audit failure restores + the predecessor and removes the replacement through one atomic upsert/delete + operation. Revoked, rotated, and expired predecessors fail closed with a + conflict response. ## Measured repository evidence @@ -42,7 +43,8 @@ verify, revoke, rotate, expiry, capability denial, software-unit mismatch, password-purpose rejection, secret omission, preservation of the active token after invalid rotation settings, and compensation after injected KV or audit failure, including one atomic store write for rotation. Management and runtime -router authentication are tested separately. +router authentication are tested separately. The SQLite adapter also covers +atomic replacement of one record and deletion of another. Tenant mismatch, direct router embedding, and retired-predecessor rejection are also covered. diff --git a/docs/operations/authorization-plane.md b/docs/operations/authorization-plane.md index 6e05bf2..5b06928 100644 --- a/docs/operations/authorization-plane.md +++ b/docs/operations/authorization-plane.md @@ -48,8 +48,8 @@ tenant-qualified KV record is selected. manager before retiring the old credential. 4. Confirm the old token verifies as `revoked_token` and the new token verifies as active. Rotation persists both records through one KV-store transaction; - if audit persistence fails, the service compensates both records and the old - token remains active. + if audit persistence fails, one atomic upsert/delete operation restores the + old record and removes the replacement. 5. Revoke unused tokens instead of extending them as login credentials. Include the same explicit tenant in rotate and verify requests; an expired predecessor is not rotatable. diff --git a/services/account_unification/app/application_tokens.py b/services/account_unification/app/application_tokens.py index 345cffa..2506fcc 100644 --- a/services/account_unification/app/application_tokens.py +++ b/services/account_unification/app/application_tokens.py @@ -287,8 +287,11 @@ def rotate( record, ) except Exception: - self._write_record(existing) - self._delete_record(record) + self._store.replace_many( + APPLICATION_TOKEN_NAMESPACE, + {existing.application_token_id: existing.model_dump_json()}, + {record.application_token_id}, + ) raise return self._issue_response(record, plaintext) diff --git a/services/account_unification/app/kv_store.py b/services/account_unification/app/kv_store.py index 8baca56..3a59304 100644 --- a/services/account_unification/app/kv_store.py +++ b/services/account_unification/app/kv_store.py @@ -8,7 +8,7 @@ import sqlite3 import threading -from collections.abc import Mapping +from collections.abc import Collection, Mapping from typing import Protocol @@ -23,6 +23,15 @@ def put_many(self, namespace: str, entries: Mapping[str, str]) -> None: """Store multiple values in one namespace atomically.""" ... + def replace_many( + self, + namespace: str, + entries: Mapping[str, str], + delete_keys: Collection[str], + ) -> None: + """Upsert values and delete keys in one atomic operation.""" + ... + def get(self, namespace: str, entry_key: str) -> str | None: """Return a value or ``None`` when it is absent.""" ... @@ -61,6 +70,19 @@ def put_many(self, namespace: str, entries: Mapping[str, str]) -> None: with self._lock: self._data.setdefault(namespace, {}).update(entries) + def replace_many( + self, + namespace: str, + entries: Mapping[str, str], + delete_keys: Collection[str], + ) -> None: + """Upsert and delete values under one lock.""" + with self._lock: + values = self._data.setdefault(namespace, {}) + values.update(entries) + for entry_key in delete_keys: + values.pop(entry_key, None) + def get(self, namespace: str, entry_key: str) -> str | None: """Return one value from one namespace, if present.""" with self._lock: @@ -131,6 +153,29 @@ def put_many(self, namespace: str, entries: Mapping[str, str]) -> None: ((namespace, entry_key, entry_value) for entry_key, entry_value in entries.items()), ) + def replace_many( + self, + namespace: str, + entries: Mapping[str, str], + delete_keys: Collection[str], + ) -> None: + """Upsert and delete config values in one SQLite transaction.""" + with self._lock, self._connection: + self._connection.executemany( + "INSERT INTO idp_config_entries " + "(config_namespace, entry_key, entry_value) " + "VALUES (?, ?, ?) " + "ON CONFLICT(config_namespace, entry_key) " + "DO UPDATE SET entry_value = excluded.entry_value", + ((namespace, entry_key, entry_value) for entry_key, entry_value in entries.items()), + ) + for entry_key in delete_keys: + self._connection.execute( + "DELETE FROM idp_config_entries " + "WHERE config_namespace = ? AND entry_key = ?", + (namespace, entry_key), + ) + def get(self, namespace: str, entry_key: str) -> str | None: """Return one config value, if present.""" with self._lock: diff --git a/services/account_unification/tests/test_application_tokens.py b/services/account_unification/tests/test_application_tokens.py index 496765c..b65e515 100644 --- a/services/account_unification/tests/test_application_tokens.py +++ b/services/account_unification/tests/test_application_tokens.py @@ -53,13 +53,36 @@ class _AtomicTrackingStore(InMemoryKvStore): def __init__(self) -> None: """Create an empty store with an atomic-write counter.""" super().__init__() + self.put_calls = 0 self.put_many_calls = 0 + self.replace_many_calls = 0 + self.delete_calls = 0 + + def put(self, namespace: str, entry_key: str, entry_value: str) -> None: + """Count single-record writes before delegating to the store.""" + self.put_calls += 1 + super().put(namespace, entry_key, entry_value) def put_many(self, namespace: str, entries: dict[str, str]) -> None: """Count and perform one atomic multi-record write.""" self.put_many_calls += 1 super().put_many(namespace, entries) + def replace_many( + self, + namespace: str, + entries: dict[str, str], + delete_keys: set[str], + ) -> None: + """Count atomic compensation operations before delegating.""" + self.replace_many_calls += 1 + super().replace_many(namespace, entries, delete_keys) + + def delete(self, namespace: str, entry_key: str) -> None: + """Count single-record deletes before delegating to the store.""" + self.delete_calls += 1 + super().delete(namespace, entry_key) + class _FailingAuditSink(InMemoryAuditSink): """Inject an audit persistence failure.""" @@ -522,6 +545,30 @@ def test_rotate_audit_failure_restores_the_active_predecessor() -> None: assert len(store.get_all(APPLICATION_TOKEN_NAMESPACE)) == 1 +def test_rotate_audit_failure_uses_one_atomic_compensation() -> None: + """Audit failure restores and removes rotation records in one operation.""" + store = _AtomicTrackingStore() + service = ApplicationTokenService( + store, + AuditLogger(InMemoryAuditSink()), + clock=_Clock(), + ) + issued = service.issue(ApplicationTokenIssueRequest.model_validate(ISSUE_BODY)) + initial_put_calls = store.put_calls + initial_delete_calls = store.delete_calls + service._audit = AuditLogger(_FailingAuditSink()) + + with pytest.raises(RuntimeError, match="audit unavailable"): + service.rotate( + issued.application_token_id, + ApplicationTokenIssueRequest.model_validate(ISSUE_BODY), + ) + + assert store.replace_many_calls == 1 + assert store.put_calls == initial_put_calls + assert store.delete_calls == initial_delete_calls + + def test_issue_rejects_password_purposes_and_bounds(client) -> None: """PATs cannot be password substitutes and stay purpose-bounded.""" password = client.post( diff --git a/services/account_unification/tests/test_storage_concurrency.py b/services/account_unification/tests/test_storage_concurrency.py index 63dd557..e0ac7cf 100644 --- a/services/account_unification/tests/test_storage_concurrency.py +++ b/services/account_unification/tests/test_storage_concurrency.py @@ -40,6 +40,25 @@ def write_entry(index: int) -> str | None: assert store.get("runtime_configuration", "batch_entry_b") == "batch_value_b" +def test_sqlite_kv_store_replaces_entries_in_one_operation(tmp_path) -> None: + """Durable compensation upserts and removes entries transactionally.""" + with closing(SqliteKvStore(str(tmp_path / "replacement.sqlite3"))) as store: + store.put_many( + "runtime_configuration", + {"keep_entry": "old_value", "remove_entry": "stale_value"}, + ) + store.replace_many( + "runtime_configuration", + {"keep_entry": "new_value", "added_entry": "new_value"}, + {"remove_entry"}, + ) + + assert store.get_all("runtime_configuration") == { + "keep_entry": "new_value", + "added_entry": "new_value", + } + + def test_sqlite_audit_sink_handles_concurrent_events(tmp_path) -> None: """Concurrent audit events remain complete and retrievable in DB order.""" with closing(