From aabe3dc05fb29c7820b36edc4d3af7385a6f1157 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 02:24:07 +0000 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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(