diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8a54a23..dbdd740 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,7 +64,18 @@ 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 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 @@ -93,7 +104,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 +193,19 @@ 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, never inherit org-tree grants, and cannot be +rotated after revocation, prior rotation, or expiry. ## Account and provisioning invariants @@ -246,6 +271,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..1c7befc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ 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; 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; failed issue/rotation audit and storage + writes are compensated, and runtime verification uses a separate service + credential. +- Authorization decisions now require a tenant-qualified assignment snapshot; + grant matching, ABAC constraints, duplicate identity, and KV keys preserve + tenant boundaries. +- Start-login issuer input is bound to configured Keyverse public issuer state. - ADR-0008 and the non-fork RP authorization matrix, requiring explicit Keyverse token validation, tenant/resource ABAC, bounded RBAC, and cross-tenant acceptance evidence per application. @@ -55,6 +75,17 @@ Keep a Changelog, and releases use semantic versioning. ### Changed +- Authorization decisions now require explicit tenant-bound assignment + snapshots, grants, SSO combinations, and application-token verification; + software-unit grants reject menu-only ABAC constraints. +- Start-login now uses a configured Keycloak public issuer and is exposed as a + front-channel runtime helper, while PAT management remains operator-gated + and PAT verification remains token-gated. + +- The hierarchical authorization router now carries its operator-authentication + and privileged-path dependencies at the module boundary, so direct CWL/Naruon + embedding cannot accidentally mount grant administration without the existing + 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 @@ -89,6 +120,12 @@ Keep a Changelog, and releases use semantic versioning. ### Fixed +- Application-token rotation now rejects revoked, already-rotated, and expired + predecessors instead of reviving retired credentials. +- Prevented cross-tenant authorization selection, SSO-name collisions, PAT + tenant confusion, expired-token revival, and audit-failure state leakage; + start-login can no longer reflect an attacker-selected issuer. + - Prevented relying-party inventory from silently accepting a KV key/body identity mismatch, rejected unsafe live or `Location`-derived client UUIDs, and aligned exact client discovery with Keycloak's documented @@ -118,6 +155,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/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..2c91eb0 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,17 @@ 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 backend starts brokered login through +`POST /federation/identity-providers:start-login` with the separately +provisioned `X-Keyverse-Runtime-Token`; it then adds PKCE locally. The operator +Bearer token is reserved for grant and token-management endpoints. See +[`docs/authorization-onboarding.md`](docs/authorization-onboarding.md). + ### Onboard a relying party -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/deploy/bootstrap/bootstrap.example.yaml b/deploy/bootstrap/bootstrap.example.yaml index a35fc89..f1bf90b 100644 --- a/deploy/bootstrap/bootstrap.example.yaml +++ b/deploy/bootstrap/bootstrap.example.yaml @@ -8,7 +8,9 @@ # Copy to bootstrap.yaml and mount read-only at /bootstrap/bootstrap.yaml. # The store is expected to hold, under the namespace below, the Keycloak wiring # keys: keycloak_server_url, keycloak_realm, keycloak_client_id, -# keycloak_client_secret (+ merge_conflict_policy, allow_unverified_email_link). +# keycloak_client_secret, operator_api_token. Optional keys include +# public_issuer_url and a distinct runtime_api_token for app-facing start-login +# and token verification. config_store: # Backends: "sqlite" (dev/standalone), "postgres" (prod), "env-kv" (12-factor diff --git a/deploy/keycloak/README.md b/deploy/keycloak/README.md index feaf209..67ba8d8 100644 --- a/deploy/keycloak/README.md +++ b/deploy/keycloak/README.md @@ -31,6 +31,13 @@ following account-unification KV entries: - `registration_redirect_uri` - `registration_action_lifespan_seconds` +Application backends use a separate optional `runtime_api_token` for +start-login and programmable-token verification. It must differ from both +`operator_api_token` and `registration_api_token`; do not put it in browser +code or source-controlled templates. Set `public_issuer_url` when the public +Keycloak issuer differs from the service URL so start-login cannot redirect to +an untrusted host. + Without the registration token the endpoint is unavailable rather than open. See [`../../docs/passwordless-policy.md`](../../docs/passwordless-policy.md). diff --git a/docs/ERD.md b/docs/ERD.md index e5e5d93..698133b 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,11 +140,60 @@ erDiagram timestamptz acquired_at timestamptz lease_expires_at } + + AUTHORIZATION_SOFTWARE_UNIT_GRANT { + text grant_key PK + text tenant_deployment_id FK + text org_path + text software_unit_id + text effect_code + jsonb capability_codes + jsonb attribute_constraints + text actor_identity_id + } + + AUTHORIZATION_MENU_GRANT { + text grant_key PK + text 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 + text tenant_deployment_id FK + jsonb software_unit_ids + text actor_identity_id + } + + APPLICATION_ACCESS_TOKEN { + text application_token_id + text 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 + text replaced_token_id + } ``` ## Logical uniqueness constraints -UUID primary identifiers are globally unique. Human/provider identifiers are scoped to the owning tenant or federation source and MUST NOT be interpreted as global keys. +UUID primary identifiers are globally unique. The authorization-plane identifiers +are tenant-qualified text keys; `tenant_deployment_id` is a validated lowercase +slug, not a UUID. Human/provider identifiers are scoped to the owning tenant or +federation source and MUST NOT be interpreted as global keys. | Entity | Required logical uniqueness | |---|---| @@ -148,6 +201,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)` | @@ -177,7 +234,15 @@ or documentation labels are bypassed. - Keycloak UUIDs, federation aliases, RP client IDs, email values, and external subjects are data identifiers, not authorization by themselves. - Exact external identity key is `(identity_provider, subject)`; verified email may support matching under policy but unverified email never authorizes linking. - `tenant_deployment_id` is explicit in Keyverse-owned records; deployment/customer separation must not be inferred from realm/resource names. +- The non-persisted `AssignmentSnapshot` decision input also requires an + explicit `tenant_deployment_id`. Software-unit `attribute_constraints` are + intentionally absent: ABAC belongs to `AUTHORIZATION_MENU_GRANT`; a + software-unit payload carrying those constraints is rejected. - Secrets are referenced through protected values/handles where possible; secret-free desired-state tables must never gain client/bind credentials accidentally. +- Application access tokens store the hashed secret and lifecycle metadata only; + plaintext tokens and org-tree secrets never appear on grant or combination + rows. KV storage keys for grants and combinations are tenant-qualified. +- Hierarchical grant paths use `group_company` / `legal_entity` / `business_unit` / `team` / `person`. They do not persist Orgmetra trees and do not reuse LineageWeave `role` / `org` / `workspace` claim names. ## Desired-state and receipt invariant diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index f215903..1cdd429 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,28 @@ 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, including its + validated `tenant_deployment_id`; do not copy the Orgmetra tree into + Keyverse. +2. PUT software-unit and menu grants at the intended org-path node. +3. PUT an SSO combination when several RPs should share one session. +4. Call the matching `:decide` endpoint and keep the RP as PEP. +5. For app login, the application backend calls + `POST /federation/identity-providers:start-login` with the separately + provisioned `X-Keyverse-Runtime-Token`, then adds PKCE locally and + redirects. Do not fetch IdP metadata from the app. +6. An operator calls `POST /application-tokens` with the operator bearer, + stores the one-time plaintext response in the relying application's secret + manager, and discards the response. The application presents that PAT to + `POST /application-tokens:verify` with the runtime service token. Rotate an + active, unexpired token or revoke it; retired and expired tokens cannot be + revived. Never share a password. + +See `docs/authorization-onboarding.md` and +`docs/operations/authorization-plane.md`. + ## 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..d79f5bf 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,25 @@ 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 + untrusted issuer overrides, and performs no Keycloak or network I/O; +- PAT issue returns plaintext once; verify/revoke/rotate never echo secrets; +- PAT purposes cannot be password or authenticator substitutes; +- authorization snapshots, grants, SSO combinations, and PAT verification + require explicit matching tenants; +- PAT mutation state rolls back when audit persistence fails, and expired or + retired predecessors cannot rotate; +- PATs do not inherit org-tree grants; +- Orgmetra trees are not persisted as source of record. + ## Deployment and persistence tests - PostgreSQL/KV migrations and rollback for Keyverse-owned records; @@ -93,7 +112,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..55f7b0f 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,10 @@ 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 tenant-qualified assignment snapshots and persists only + tenant-qualified grants, combinations, and hashed application tokens. ## 4. Identity evidence @@ -59,9 +62,13 @@ authorization readiness. Authenticated operator APIs accept closed versioned schemas. Errors must not echo private values, raw provider responses, or arbitrary Keycloak Location/header content. Remote resource IDs parsed from Keycloak are validated before use in privileged paths. +The start-login and token-verification runtime routes use a distinct +`X-Keyverse-Runtime-Token`; grant and token-management writes retain the +operator bearer boundary. + ## 8. Persistence/data model -Current architecture owns PostgreSQL/KV state for configuration, desired-state sources, apply receipts, merge audit, 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..933d4c1 --- /dev/null +++ b/docs/adr/0010-hierarchical-authorization-plane.md @@ -0,0 +1,74 @@ +# 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`, required validated + `tenant_deployment_id`, `org_path`, optional `assignment_record_id`). + Keyverse does not persist or synchronize the Orgmetra tree. Grant selection, + combination lookup, and duplicate identity are tenant-qualified. + Keyverse never infers tenant from a client, realm, UUID, email, or + federation source name. +3. Hierarchical attributes use distinct names: `group_company`, + `legal_entity`, `business_unit`, `team`, `person`, and structured + `org_path`. `role`, `org`, and `workspace` stay reserved for the + 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 + tenant-scoped and is allowed only when its tenant matches the snapshot and + every member software unit is allowed for that snapshot. Ambiguous + same-name administration reads and deletes require an explicit tenant. + The Keycloak session remains Keycloak-owned; this plane only authorizes + which RP set may share it. +6. Menu decisions apply software-unit ACL first, then ABAC constraints + (`purpose`, `sensitivity`, `clearance`, `residency`), then remaining RBAC + capability codes. Software-unit grants carry capabilities only; their + attribute constraints are rejected because ABAC is a menu-level contract. + +## Consequences + +- Operators persist grants and combinations through authenticated Keyverse + admin APIs and evaluate decisions without contacting Orgmetra or Keycloak. +- Runtime RPs use the separately provisioned runtime service token for + start-login and token verification; operator bearer credentials stay on + management endpoints. +- Downstream RPs must still prove ADR-0008 token validation. This slice does + not claim production login or federation acceptance. +- ADR-0009 remains reserved for the unmerged LineageWeave claim profile. diff --git a/docs/adr/0011-app-start-login-helper.md b/docs/adr/0011-app-start-login-helper.md new file mode 100644 index 0000000..ee919b7 --- /dev/null +++ b/docs/adr/0011-app-start-login-helper.md @@ -0,0 +1,35 @@ +# 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. +6. The authorization issuer is the configured Keycloak public issuer (or the + configured server/realm fallback). A request may not redirect the helper + to an attacker-selected issuer. +7. Start-login is a front-channel runtime helper; it does not require the + operator bearer used by federation desired-state administration. + +## Consequences + +- 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..eae8b04 --- /dev/null +++ b/docs/adr/0012-programmable-application-tokens.md @@ -0,0 +1,46 @@ +# 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__`). + The durable record stores the SHA-256 hash, prefix, purpose, software unit, + capability codes, `application_token_id`, `tenant_deployment_id`, lifecycle + state, creation/expiry/revocation timestamps, `actor_identity_id`, and + `replaced_token_id`. The plaintext secret is never stored. +2. Closed purposes are `machine_api`, `integration_sync`, and + `operator_export`. Password, WebAuthn, browser-login, and authenticator + purposes are rejected. +3. Tokens are software-unit and API-capability scoped, time-bounded (60 + seconds to 90 days), rotatable, revocable, and auditable. Rotation accepts + only an active, unexpired predecessor, validates the replacement before + revoking it, and compensates storage or audit failures, so invalid, + incomplete, or retired-token replacement actions do not destroy or revive a + credential. +4. The plaintext secret is returned only at issue or rotate time. List, get, + verify, and revoke responses never include the secret or hash. +5. Verification does not consult org-tree grants. Tokens never inherit. +6. Tenant is explicit at issue, verify, and rotate time; a token is accepted + only for its stored tenant. Management routes require the operator bearer, + while `:verify` is a runtime route authenticated by the presented token. +7. Issue, revoke, and rotate mutations roll back token state when audit + persistence fails. Expired or retired predecessors cannot be rotated. +8. A token is not an authenticator. Browser passwordless policy (ADR-0002) + remains unchanged. + +## Consequences + +- Relying applications store the plaintext token in their own secret manager + and present it only to `POST /application-tokens:verify`, authenticated with + the separately provisioned runtime service token. +- Keycloak client secrets and operator bearers remain separate credentials. +- This slice does not replace confidential RP client-secret placement. diff --git a/docs/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..fcf181b --- /dev/null +++ b/docs/authorization-onboarding.md @@ -0,0 +1,101 @@ +# 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", + "tenant_deployment_id": "buyer-deployment", + "org_path": "/group_company/acme/legal_entity/holdco/business_unit/sales/team/alpha/person/jdoe", + "assignment_record_id": "assignment-record-77", + "request_attributes": {"purpose": "hr-review"} +} +``` + +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 the runtime endpoint + with the separately provisioned `X-Keyverse-Runtime-Token` header. This is + not the operator bearer used for grant administration: + +```bash +curl --config "$AUTH_CONFIG" --request POST \ + --header "Content-Type: application/json" \ + --data '{"software_unit_id":"naruon-web","client_id":"naruon-web","redirect_uri":"https://naruon.example/callback","provider_alias_hint":"employer-adfs"}' \ + "$KEYVERSE_RUNTIME/federation/identity-providers:start-login" +``` + +3. Add PKCE `S256`, `state`, and `nonce` in the application. The helper uses + the configured Keycloak public issuer and does not accept an arbitrary + issuer in the request. +4. Redirect the browser to `start_login_url`. Do not fetch IdP metadata from + the app. Federation ownership stays in Keyverse. + +## 6. How a PAT is minted and scoped + +```bash +curl --config "$AUTH_CONFIG" --request POST \ + --header "Content-Type: application/json" \ + --data '{"tenant_deployment_id":"buyer-deployment","software_unit_id":"naruon-web","purpose_code":"machine_api","capability_codes":["api.invoices.read"],"lifetime_seconds":3600,"actor_identity_id":"operator-ida"}' \ + "$KEYVERSE_ADMIN/application-tokens" +``` + +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, plus the runtime service +header. Rotate or revoke instead of treating the token as a password. Tokens +never inherit org-tree grants. +The request must also carry the same explicit tenant; use the runtime service +header and never treat the token as a password. Tokens never inherit org-tree +grants. + +Keep bearer tokens out of `curl` process arguments; use a private `--config` +file as in `docs/rp-onboarding.md`. diff --git a/docs/doctoring/hierarchical-authorization-plane.md b/docs/doctoring/hierarchical-authorization-plane.md new file mode 100644 index 0000000..9ec9415 --- /dev/null +++ b/docs/doctoring/hierarchical-authorization-plane.md @@ -0,0 +1,93 @@ +# 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. + +The tenant deployment is an additional closed scope attribute. A decision +snapshot must carry a validated `tenant_deployment_id`, and grants or SSO +combinations from another deployment are not candidates. This is a Keyverse +policy choice that operationalizes the tenant-qualified uniqueness described in +the ERD; it is not a claim that NIST SP 800-162 prescribes this storage key. + +NIST SP 800-63C requires federation to keep identity proofing and +authentication distinct from relying-party authorization (Grassi et al., +2017). Orgmetra therefore remains employment truth; Keyverse issues +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, software- +unit and menu ABAC/RBAC, tenant-isolated grants and combinations, reserved-name +rejection, and fail-closed storage. The HTTP regression suite also verifies that the authorization router +rejects an unauthenticated direct embedding and accepts only the configured +operator bearer. The router now owns both the operator-authentication and +privileged-path dependencies rather than relying only on the application +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 + +Callers supply a current Orgmetra snapshot with an explicit tenant deployment. +Grant and SSO evaluation filters that tenant before applying inheritance; a +software-unit grant cannot carry menu ABAC constraints. This slice does not +subscribe to Orgmetra change feeds. Production login acceptance remains a +separate runtime evidence boundary. + +## References + +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..64563b3 --- /dev/null +++ b/docs/doctoring/programmable-application-tokens.md @@ -0,0 +1,66 @@ +# 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-4 distinguishes authenticators used to prove a subscriber +account from other secrets (Temoshok et al., 2025). Password and WebAuthn +purposes are therefore forbidden. A PAT is a machine credential for a +software unit and API capability set, not a browser authenticator +(ADR-0002). + +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 accepts only an active, unexpired predecessor, validates the + replacement settings before revoking its hash, and issues a replacement + bound to the same software unit. Revoked, rotated, and expired predecessors + fail closed with a conflict response. + +## Measured repository evidence + +`services/account_unification/tests/test_application_tokens.py` covers issue, +verify, revoke, rotate, expiry, capability denial, software-unit mismatch, +password-purpose rejection, secret omission, preservation of the active token +after invalid rotation settings, and compensation after injected KV or audit +failure. Management and runtime router authentication are tested separately. +Tenant mismatch, direct router embedding, and retired-predecessor rejection are +also covered. + +## Assumptions and limitations + +This slice does not replace confidential OIDC client-secret placement +(ADR-0005). Production API acceptance at each RP remains a separate +evidence boundary. + +## References + +Temoshok, D., Fenton, J., Choong, Y.-Y., Lefkovitz, N., Regenscheid, A., +Galluzzo, R., & Richer, J. (2025). *Digital identity guidelines: +Authentication and authenticator management* (NIST Special Publication +800-63B-4). National Institute of Standards and Technology. +https://doi.org/10.6028/NIST.SP.800-63b-4 + +Jones, M. B., & Hardt, D. (2012). *The OAuth 2.0 authorization framework: +Bearer token usage* (RFC 6750). RFC Editor. +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..5eddd90 --- /dev/null +++ b/docs/doctoring/start-login-helper.md @@ -0,0 +1,70 @@ +# 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 authorization-code +flow. RFC 9700 requires public clients to use PKCE to prevent authorization +code injection (Lodderstedt et al., 2025). The helper only composes that +endpoint with `client_id`, +`redirect_uri`, `response_type=code`, `scope=openid`, and Keycloak's +`kc_idp_hint` parameter (Keycloak Project, 2026). The RP must still add +PKCE, `state`, and `nonce`. + +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, rejects `.well-known` or metadata URLs, and accepts +only the configured Keycloak public issuer. Start-login is a runtime +front-channel helper rather than an operator-admin route. + +The authorization endpoint is also bound to the configured public Keyverse +issuer (or the configured Keycloak realm URL when no public override exists). +The request cannot redirect an RP to an arbitrary host. This is a Keyverse +trust-boundary policy, not a network-fetch claim. + +NIST SP 800-63C treats the federation authority as distinct from the +application (Grassi et al., 2017). The helper therefore stays Keyverse-owned +and does not become a new IdP. + +## 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. 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. +They also prove untrusted issuer rejection and authenticated runtime embedding. + +## 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 + +Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current +practice for OAuth 2.0 security* (RFC 9700; BCP 240). RFC Editor. +https://www.rfc-editor.org/rfc/rfc9700 diff --git a/docs/federation-onboarding.md b/docs/federation-onboarding.md index 28324ae..ff7c894 100644 --- a/docs/federation-onboarding.md +++ b/docs/federation-onboarding.md @@ -195,6 +195,37 @@ 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: + +Set `AUTH_CONFIG` to an existing private curl config file containing the +runtime service-token header; this guard keeps the example from passing an +undefined or empty path to `curl --config`: + +```bash +: "${AUTH_CONFIG:?Set AUTH_CONFIG to a private curl config file}" +test -r "$AUTH_CONFIG" || { + echo "AUTH_CONFIG is not readable" >&2 + exit 1 +} +``` + +```bash +curl --config "$AUTH_CONFIG" \ + --fail-with-body \ + --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..ca1e5b9 --- /dev/null +++ b/docs/operations/authorization-plane.md @@ -0,0 +1,55 @@ +# 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. + +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`. +2. Put the explicit `tenant_deployment_id` on the snapshot and every grant. +3. PUT the software-unit grant, then any menu grants. +4. Decide with a current Orgmetra snapshot. If the effect is unexpected, + inspect winning_org_path and whether a more-specific deny exists. +5. Do not persist Orgmetra organization units into Keyverse. + +When the same combination name exists in more than one tenant, include +`tenant_deployment_id` as the GET/DELETE query parameter; an ambiguous +administration operation fails closed. + +## Start-login failures + +- 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. 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. + Include the same explicit tenant in rotate and verify requests; an expired + predecessor is not rotatable. + +## 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..94e17f6 100644 --- a/docs/papers/README.md +++ b/docs/papers/README.md @@ -35,6 +35,19 @@ 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 +- Temoshok, D., Fenton, J., Choong, Y.-Y., Lefkovitz, N., Regenscheid, A., + Galluzzo, R., & Richer, J. (2025). *Digital Identity Guidelines: + Authentication and Authenticator Management.* NIST Special Publication + 800-63B-4. https://doi.org/10.6028/NIST.SP.800-63b-4 +- Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best Current + Practice for OAuth 2.0 Security.* RFC 9700 (BCP 240), IETF. + https://www.rfc-editor.org/rfc/rfc9700 +- Jones, M. B., & Hardt, D. (2012). *The OAuth 2.0 Authorization Framework: + Bearer Token Usage.* RFC 6750, IETF. https://doi.org/10.17487/RFC6750 ## How these map to the build @@ -42,5 +55,8 @@ 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-4 / RFC 6750 | programmable application tokens in `app/application_tokens.py` | +| RFC 9700 | public-client PKCE instruction in the start-login helper | diff --git a/docs/papers/citations.bib b/docs/papers/citations.bib index 3ceed59..f169fac 100644 --- a/docs/papers/citations.bib +++ b/docs/papers/citations.bib @@ -31,6 +31,53 @@ @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{nist80063b42025, + author = {Temoshok, David and Fenton, James and Choong, Yee-Yin and + Lefkovitz, Naomi and Regenscheid, Andrew and Galluzzo, Ryan + and Richer, Justin}, + title = {Digital Identity Guidelines: Authentication and Authenticator + Management}, + institution = {National Institute of Standards and Technology}, + type = {NIST Special Publication}, + number = {800-63B-4}, + year = {2025}, + doi = {10.6028/NIST.SP.800-63b-4}, + url = {https://doi.org/10.6028/NIST.SP.800-63b-4} +} + +@misc{rfc9700, + author = {Lodderstedt, Tobias and Bradley, John and Labunets, Andrii + and Fett, Daniel}, + title = {Best Current Practice for OAuth 2.0 Security}, + howpublished = {RFC 9700 (BCP 240)}, + year = {2025}, + doi = {10.17487/RFC9700}, + url = {https://www.rfc-editor.org/rfc/rfc9700} +} + +@misc{rfc6750, + 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..0761b98 --- /dev/null +++ b/services/account_unification/app/application_tokens.py @@ -0,0 +1,668 @@ +"""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 .auth import operator_auth_dependency, runtime_auth_dependency +from .errors import AuthorizationPolicyError +from .kv_store import KvStore +from .org_authorization import validate_capability_codes, validate_slug +from .path_security import admin_path_security_dependency + +APPLICATION_TOKEN_NAMESPACE = "application_access_tokens" +TOKEN_SCHEME = "kvt" +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"], + dependencies=[operator_auth_dependency, admin_path_security_dependency], +) +application_token_runtime_router = APIRouter( + prefix="/application-tokens", + tags=["application-tokens"], + dependencies=[runtime_auth_dependency], +) +_MANAGEMENT_DEPENDENCIES = [operator_auth_dependency, admin_path_security_dependency] + + +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 + + +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 + tenant_deployment_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 + tenant_deployment_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) + tenant_deployment_id: str + 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 + tenant_deployment_id: str | None = None + software_unit_id: str | None = None + capability_codes: list[str] = Field(default_factory=list) + purpose_code: str | None = None + 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) + with self._state_lock: + self._write_record(record) + try: + self._audit_event( + "application_token_issued", + request.actor_identity_id, + record, + ) + except Exception: + self._delete_record(record) + raise + return self._issue_response(record, plaintext) + + def list_tokens(self) -> list[ApplicationTokenView]: + """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) + try: + self._audit_event( + "application_token_revoked", + actor_identity_id, + updated, + ) + except Exception: + self._write_record(record) + raise + 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) + validate_slug( + request.tenant_deployment_id, + field_name="tenant_deployment_id", + ) + with self._state_lock: + existing = self._require_record(application_token_id) + if existing.software_unit_id != request.software_unit_id: + raise AuthorizationPolicyError( + "rotated token must stay bound to the same software unit" + ) + if existing.tenant_deployment_id != request.tenant_deployment_id: + raise AuthorizationPolicyError( + "rotated token must stay bound to the same tenant" + ) + if ( + existing.lifecycle_status_code != ACTIVE_LIFECYCLE + or existing.expires_at <= self._clock() + ): + raise AuthorizationPolicyError( + "application token is not active", + status_code=409, + ) + updated = existing.model_copy( + update={ + "lifecycle_status_code": ROTATED_LIFECYCLE, + "revoked_at": self._clock(), + } + ) + record, plaintext = self._mint( + request, replaced_token_id=application_token_id + ) + try: + self._write_record(record) + self._write_record(updated) + self._audit_event( + "application_token_rotated", + request.actor_identity_id, + record, + ) + except Exception: + self._write_record(existing) + self._delete_record(record) + raise + return self._issue_response(record, plaintext) + + def verify( + self, request: ApplicationTokenVerifyRequest + ) -> ApplicationTokenVerifyResponse: + """Verify a presented token without consulting org-tree grants.""" + validate_slug(request.software_unit_id, field_name="software_unit_id") + validate_slug( + request.tenant_deployment_id, + field_name="tenant_deployment_id", + ) + requested = validate_capability_codes(request.requested_capability_codes) + parsed = _parse_presented_token(request.presented_token) + if parsed is None: + 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.tenant_deployment_id != request.tenant_deployment_id: + return _inactive("tenant_mismatch") + if record.lifecycle_status_code != ACTIVE_LIFECYCLE: + return _inactive("revoked_token", record) + if record.expires_at <= now: + 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, + tenant_deployment_id=record.tenant_deployment_id, + software_unit_id=record.software_unit_id, + capability_codes=list(record.capability_codes), + purpose_code=record.purpose_code, + ) + + 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 _delete_record(self, record: ApplicationTokenRecord) -> None: + """Compensate one lifecycle write when its audit event fails.""" + with self._state_lock: + self._store.delete(APPLICATION_TOKEN_NAMESPACE, record.application_token_id) + + def _records(self) -> list[ApplicationTokenRecord]: + """Load every hashed token record, fail-closed on corruption.""" + with self._state_lock: + 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, + tenant_deployment_id=record.tenant_deployment_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, + tenant_deployment_id=record.tenant_deployment_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, + "tenant_deployment_id": record.tenant_deployment_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, + tenant_deployment_id=( + None if record is None else record.tenant_deployment_id + ), + software_unit_id=None if record is None else record.software_unit_id, + purpose_code=None if record is None else record.purpose_code, + ) + + +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, + dependencies=_MANAGEMENT_DEPENDENCIES, +) +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], + dependencies=_MANAGEMENT_DEPENDENCIES, +) +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, + dependencies=_MANAGEMENT_DEPENDENCIES, +) +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, + dependencies=_MANAGEMENT_DEPENDENCIES, +) +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, + dependencies=_MANAGEMENT_DEPENDENCIES, +) +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_runtime_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/auth.py b/services/account_unification/app/auth.py index 8ff2a8f..147c6ea 100644 --- a/services/account_unification/app/auth.py +++ b/services/account_unification/app/auth.py @@ -49,3 +49,27 @@ def require_operator_token( operator_auth_dependency = Depends(require_operator_token) + + +def require_runtime_token( + request: Request, + runtime_token: str | None = Header( + default=None, + alias="X-Keyverse-Runtime-Token", + ), +) -> None: + """Authenticate the least-privilege runtime service token.""" + expected = getattr(request.app.state, "runtime_api_token", None) + if not expected: + raise HTTPException(status_code=503, detail="runtime authentication unavailable") + if not runtime_token: + raise HTTPException( + status_code=401, + detail="runtime service token required", + headers={"WWW-Authenticate": "Bearer"}, + ) + if not hmac.compare_digest(runtime_token, expected): + raise HTTPException(status_code=403, detail="invalid runtime service token") + + +runtime_auth_dependency = Depends(require_runtime_token) diff --git a/services/account_unification/app/authorization_plane.py b/services/account_unification/app/authorization_plane.py new file mode 100644 index 0000000..7ca54f6 --- /dev/null +++ b/services/account_unification/app/authorization_plane.py @@ -0,0 +1,593 @@ +"""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, Query, 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 ( + AuthorizationDecision, + AuthorizationGrant, + AssignmentSnapshot, + SsoCombinationDecision, + SsoCombinationScope, + decide_menu, + decide_software_unit, + decide_sso_combination, + validate_combination, + validate_grant, + 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"], + dependencies=[operator_auth_dependency, admin_path_security_dependency], +) + + +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, + self._scoped_key( + validated.tenant_deployment_id, + combination_name, + ), + validated.model_dump_json(), + ) + return validated + + def get_combination( + self, + combination_name: str, + *, + tenant_deployment_id: str | None = None, + ) -> SsoCombinationScope: + """Return one stored SSO combination.""" + validate_slug(combination_name, field_name="combination_name") + if tenant_deployment_id is not None: + validate_slug( + tenant_deployment_id, + field_name="tenant_deployment_id", + ) + combinations = [ + combination + for combination in self.list_combinations() + if combination.combination_name == combination_name + and ( + tenant_deployment_id is None + or combination.tenant_deployment_id == tenant_deployment_id + ) + ] + if not combinations: + raise AuthorizationPolicyError( + "sso combination is not registered", + status_code=404, + ) + if len(combinations) > 1: + raise AuthorizationPolicyError( + "tenant_deployment_id is required for an ambiguous sso combination", + status_code=409, + ) + return combinations[0] + + def list_combinations(self) -> list[SsoCombinationScope]: + """Return every stored SSO combination.""" + 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, + *, + tenant_deployment_id: str | None = None, + ) -> None: + """Remove one SSO combination.""" + validate_slug(combination_name, field_name="combination_name") + if tenant_deployment_id is not None: + validate_slug( + tenant_deployment_id, + field_name="tenant_deployment_id", + ) + with self._state_lock: + matches = [] + for entry_key, raw_value in self._store.get_all( + SSO_COMBINATION_NAMESPACE + ).items(): + combination = self._parse_combination(raw_value) + if ( + combination.combination_name == combination_name + and ( + tenant_deployment_id is None + or combination.tenant_deployment_id == tenant_deployment_id + ) + ): + matches.append((entry_key, combination)) + if not matches: + raise AuthorizationPolicyError( + "sso combination is not registered", + status_code=404, + ) + if len(matches) > 1: + raise AuthorizationPolicyError( + "tenant_deployment_id is required for an ambiguous sso combination", + status_code=409, + ) + self._store.delete(SSO_COMBINATION_NAMESPACE, matches[0][0]) + + def decide_software_unit( + self, request: SoftwareUnitDecisionRequest + ) -> 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.""" + snapshot = validate_snapshot(request.snapshot) + combination = self.get_combination( + request.combination_name, + tenant_deployment_id=snapshot.tenant_deployment_id, + ) + return decide_sso_combination( + self.list_software_unit_grants(), + snapshot, + 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.tenant_deployment_id, + 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.tenant_deployment_id, + 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, + self._scoped_key(validated.tenant_deployment_id, grant_key), + validated.model_dump_json(), + ) + return validated + + def _get_grant(self, namespace: str, grant_key: str) -> AuthorizationGrant: + """Return one stored grant or raise a 404 policy error.""" + validate_slug(grant_key, field_name="grant_key") + grants = [grant for grant in self._list_grants(namespace) if grant.grant_key == grant_key] + if not grants: + raise AuthorizationPolicyError( + "authorization grant is not registered", + status_code=404, + ) + if len(grants) > 1: + raise AuthorizationPolicyError( + "tenant_deployment_id is required for an ambiguous authorization grant", + status_code=409, + ) + return grants[0] + + def _list_grants(self, namespace: str) -> list[AuthorizationGrant]: + """Return every grant in one namespace, fail-closed on corrupt rows.""" + 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") + grant = self._get_grant(namespace, grant_key) + with self._state_lock: + self._store.delete( + namespace, + self._scoped_key(grant.tenant_deployment_id, grant_key), + ) + + @staticmethod + def _scoped_key(tenant_deployment_id: str, identifier: str) -> str: + """Return one collision-free KV key within a tenant namespace.""" + return f"{tenant_deployment_id}::{identifier}" + + def _parse_grant(self, raw_value: str) -> AuthorizationGrant: + """Parse one stored grant or fail closed.""" + 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, + tenant_deployment_id: str | None = Query(default=None), + service: AuthorizationPlaneService = Depends(get_authorization_service), +) -> SsoCombinationScope: + """Return one stored SSO combination.""" + try: + return service.get_combination( + combination_name, + tenant_deployment_id=tenant_deployment_id, + ) + 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, + tenant_deployment_id: str | None = Query(default=None), + service: AuthorizationPlaneService = Depends(get_authorization_service), +) -> None: + """Delete one SSO combination.""" + try: + service.delete_combination( + combination_name, + tenant_deployment_id=tenant_deployment_id, + ) + 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/config.py b/services/account_unification/app/config.py index ecf9fe6..cfb304f 100644 --- a/services/account_unification/app/config.py +++ b/services/account_unification/app/config.py @@ -18,10 +18,12 @@ KEY_KEYCLOAK_REALM = "keycloak_realm" KEY_KEYCLOAK_CLIENT_ID = "keycloak_client_id" KEY_KEYCLOAK_CLIENT_SECRET = "keycloak_client_secret" +KEY_PUBLIC_ISSUER_URL = "public_issuer_url" KEY_MERGE_CONFLICT_POLICY = "merge_conflict_policy" KEY_ALLOW_UNVERIFIED_LINK = "allow_unverified_email_link" KEY_REQUEST_TIMEOUT_SECONDS = "request_timeout_seconds" KEY_OPERATOR_API_TOKEN = "operator_api_token" +KEY_RUNTIME_API_TOKEN = "runtime_api_token" KEY_REGISTRATION_API_TOKEN = "registration_api_token" KEY_REGISTRATION_CLIENT_ID = "registration_client_id" KEY_REGISTRATION_REDIRECT_URI = "registration_redirect_uri" @@ -46,6 +48,8 @@ class ServiceConfig: # Privileged and product registration surfaces deliberately use different # bearer credentials so relying products never acquire operator authority. operator_api_token: str + public_issuer_url: str | None = None + runtime_api_token: str | None = None registration_api_token: str | None = None registration_client_id: str | None = None registration_redirect_uri: str | None = None @@ -189,6 +193,14 @@ def load_service_config(store: KvStore, namespace: str) -> ServiceConfig: "config 'registration_api_token' must differ from " "'operator_api_token'" ) + runtime_api_token = store.get(namespace, KEY_RUNTIME_API_TOKEN) or None + if runtime_api_token is not None and runtime_api_token in { + operator_api_token, + registration_api_token, + }: + raise RuntimeError( + "config 'runtime_api_token' must differ from operator and registration tokens" + ) ( registration_client_id, registration_redirect_uri, @@ -224,7 +236,16 @@ def load_service_config(store: KvStore, namespace: str) -> ServiceConfig: keycloak_client_secret=_require( store, namespace, KEY_KEYCLOAK_CLIENT_SECRET ), + public_issuer_url=( + _validated_https_uri( + public_issuer_url, + entry_key=KEY_PUBLIC_ISSUER_URL, + ) + if (public_issuer_url := store.get(namespace, KEY_PUBLIC_ISSUER_URL)) + else None + ), operator_api_token=operator_api_token, + runtime_api_token=runtime_api_token, registration_api_token=registration_api_token, registration_client_id=registration_client_id, registration_redirect_uri=registration_redirect_uri, diff --git a/services/account_unification/app/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..061c421 100644 --- a/services/account_unification/app/main.py +++ b/services/account_unification/app/main.py @@ -15,8 +15,14 @@ from . import __version__ from .api import router +from .application_tokens import ( + ApplicationTokenService, + application_token_router, + application_token_runtime_router, +) from .audit import AuditLogger, SqliteAuditSink -from .auth import operator_auth_dependency +from .auth import operator_auth_dependency, runtime_auth_dependency +from .authorization_plane import AuthorizationPlaneService, authorization_router from .bootstrap import load_bootstrap_descriptor, open_config_store from .config import load_service_config from .directory_federation import directory_federation_router @@ -33,6 +39,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,7 +102,11 @@ 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.runtime_api_token = getattr(config, "runtime_api_token", None) app.state.registration_api_token = config.registration_api_token app.state.registration_client_id = config.registration_client_id app.state.registration_redirect_uri = config.registration_redirect_uri @@ -207,6 +218,20 @@ def healthz() -> dict: admin_path_security_dependency, ], ) + app.include_router( + authorization_router, + ) + app.include_router( + start_login_router, + dependencies=[ + runtime_auth_dependency, + admin_path_security_dependency, + ], + ) + app.include_router( + application_token_router, + ) + app.include_router(application_token_runtime_router) app.include_router( registration_router, dependencies=[registration_auth_dependency], diff --git a/services/account_unification/app/org_authorization.py b/services/account_unification/app/org_authorization.py new file mode 100644 index 0000000..e2ca435 --- /dev/null +++ b/services/account_unification/app/org_authorization.py @@ -0,0 +1,638 @@ +"""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) + tenant_deployment_id: str = Field(min_length=1, max_length=128) + org_path: str = Field(min_length=1, max_length=_MAX_ORG_PATH_LENGTH) + assignment_record_id: str | None = Field(default=None, max_length=128) + request_attributes: dict[str, str] = Field(default_factory=dict) + + +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: + raise AuthorizationPolicyError( + "software_unit grants must not carry attribute_constraints" + ) + if capability_codes and grant.effect_code == DENY_EFFECT: + raise AuthorizationPolicyError("deny grants cannot carry capability_codes") + 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") + validate_slug( + snapshot.tenant_deployment_id, + field_name="tenant_deployment_id", + ) + parsed_org = parse_org_path(snapshot.org_path) + if snapshot.assignment_record_id is not None: + validate_slug( + 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, + tenant_deployment_id: str, + 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.tenant_deployment_id != tenant_deployment_id: + continue + 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, + tenant_deployment_id=validated_snapshot.tenant_deployment_id, + software_unit_id=software_unit_id, + grant_scope_code=SOFTWARE_UNIT_GRANT_SCOPE, + requested_menu_path=None, + ) + return _build_decision( + snapshot=validated_snapshot, + software_unit_id=software_unit_id, + parsed_org=parsed_org, + winning=winning, + inherited=inherited, + menu_path=None, + attribute_mismatch=( + winning is not None + and winning.effect_code == ALLOW_EFFECT + and not _constraints_match( + winning.attribute_constraints, + validated_snapshot.request_attributes, + ) + ), + ) + + +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, + tenant_deployment_id=validated_snapshot.tenant_deployment_id, + software_unit_id=software_unit_id, + grant_scope_code=MENU_GRANT_SCOPE, + requested_menu_path=canonical_menu, + ) + 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) + if ( + validated_combination.tenant_deployment_id + != validated_snapshot.tenant_deployment_id + ): + raise AuthorizationPolicyError( + "snapshot and combination tenant_deployment_id must match" + ) + 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..8d23701 --- /dev/null +++ b/services/account_unification/app/start_login.py @@ -0,0 +1,285 @@ +"""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 unquote, urlencode, urlsplit + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from .config import ServiceConfig +from .auth import runtime_auth_dependency +from .errors import AuthorizationPolicyError +from .federation import ( + FEDERATION_PROVIDER_NAMESPACE, + IdentityProviderRegistration, + IdentityProviderView, +) +from .kv_store import KvStore +from .org_authorization import validate_slug + +start_login_router = APIRouter( + prefix="/federation", + tags=["federation"], + dependencies=[runtime_auth_dependency], +) +_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 = 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" + ) + 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.""" + configured_issuer = ( + config.public_issuer_url + or f"{config.keycloak_server_url.rstrip('/')}/realms/{config.keycloak_realm}" + ).rstrip("/") + if public_issuer_url: + parsed = urlsplit(public_issuer_url) + if ( + 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("/") + auth_suffix = "/protocol/openid-connect/auth" + candidate_issuer = ( + issuer[: -len(auth_suffix)] + if issuer.endswith(auth_suffix) + else issuer + ) + if candidate_issuer != configured_issuer: + raise AuthorizationPolicyError( + "public_issuer_url must match the configured Keyverse issuer" + ) + else: + issuer = configured_issuer + if issuer.endswith("/protocol/openid-connect/auth"): + return issuer + return f"{issuer}/protocol/openid-connect/auth" + + +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..4a5f217 --- /dev/null +++ b/services/account_unification/tests/test_application_tokens.py @@ -0,0 +1,583 @@ +"""Programmable application token issue, verify, revoke, and rotate contracts.""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from app.application_tokens import ( + APPLICATION_TOKEN_NAMESPACE, + ApplicationTokenIssueRequest, + ApplicationTokenRecord, + ApplicationTokenService, + ApplicationTokenVerifyRequest, + application_token_router, + application_token_runtime_router, + get_application_token_service, +) +from app.audit import AuditLogger, InMemoryAuditSink +from app.errors import AuthorizationPolicyError +from app.kv_store import InMemoryKvStore +from app.main import create_app + + +class _FailingStore(InMemoryKvStore): + """Inject one storage failure at a chosen write.""" + + def __init__(self, *, fail_on_put: int) -> None: + """Create a store that fails on the requested one-based write.""" + super().__init__() + self._fail_on_put = fail_on_put + self._put_count = 0 + + def put(self, namespace: str, entry_key: str, entry_value: str) -> None: + """Raise once at the configured write and otherwise persist normally.""" + self._put_count += 1 + if self._put_count == self._fail_on_put: + raise RuntimeError("injected token storage failure") + super().put(namespace, entry_key, entry_value) + + +class _FailingAuditSink(InMemoryAuditSink): + """Inject an audit persistence failure.""" + + def record(self, event) -> None: + """Reject every event to exercise lifecycle compensation.""" + raise RuntimeError("audit unavailable") + + +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" + app.state.runtime_api_token = "test-runtime-token" + with TestClient( + app, + headers={**auth_header, "X-Keyverse-Runtime-Token": "test-runtime-token"}, + ) as test_client: + yield test_client + + +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", + "tenant_deployment_id": "default-deployment", +} + + +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, + "tenant_deployment_id": "default-deployment", + "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, + "tenant_deployment_id": "default-deployment", + "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_embedded_application_token_router_requires_operator_authentication( + token_service: ApplicationTokenService, +) -> None: + """A directly embedded management router cannot be mounted open.""" + app = FastAPI() + app.state.application_token_service = token_service + app.state.operator_api_token = "test-operator-token" + app.include_router(application_token_router) + with TestClient(app) as embedded_client: + denied = embedded_client.get("/application-tokens") + allowed = embedded_client.get( + "/application-tokens", + headers={"Authorization": "Bearer test-operator-token"}, + ) + assert denied.status_code == 401 + assert allowed.status_code == 200 + + +def test_runtime_verify_does_not_require_operator_bearer( + token_service: ApplicationTokenService, +) -> None: + """Runtime verification accepts its own service credential only.""" + issued = token_service.issue(ApplicationTokenIssueRequest.model_validate(ISSUE_BODY)) + app = FastAPI() + app.state.application_token_service = token_service + app.state.runtime_api_token = "test-runtime-token" + app.include_router(application_token_runtime_router) + with TestClient( + app, + headers={"X-Keyverse-Runtime-Token": "test-runtime-token"}, + ) as runtime_client: + response = runtime_client.post( + "/application-tokens:verify", + json={ + "presented_token": issued.plaintext_token, + "tenant_deployment_id": "default-deployment", + "software_unit_id": "naruon-web", + }, + ) + assert response.status_code == 200 + assert response.json()["active"] is True + + +def test_verify_denies_malformed_unknown_expired_and_capability( + client, clock: _Clock +) -> None: + """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", + "tenant_deployment_id": "default-deployment", + "software_unit_id": "naruon-web", + }, + ) + unknown = client.post( + "/application-tokens:verify", + json={ + "presented_token": f"kvt_{prefix}_wrong-secret-material-value", + "tenant_deployment_id": "default-deployment", + "software_unit_id": "naruon-web", + }, + ) + wrong_unit = client.post( + "/application-tokens:verify", + json={ + "presented_token": plaintext, + "tenant_deployment_id": "default-deployment", + "software_unit_id": "clearfolio-web", + }, + ) + capability = client.post( + "/application-tokens:verify", + json={ + "presented_token": plaintext, + "tenant_deployment_id": "default-deployment", + "software_unit_id": "naruon-web", + "requested_capability_codes": ["api.payroll.admin"], + }, + ) + wrong_tenant = client.post( + "/application-tokens:verify", + json={ + "presented_token": plaintext, + "tenant_deployment_id": "other-deployment", + "software_unit_id": "naruon-web", + }, + ) + clock.now += 3601 + expired = client.post( + "/application-tokens:verify", + json={ + "presented_token": plaintext, + "tenant_deployment_id": "default-deployment", + "software_unit_id": "naruon-web", + }, + ) + invalid_unit = client.post( + "/application-tokens:verify", + json={ + "presented_token": plaintext, + "tenant_deployment_id": "default-deployment", + "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" + assert capability.json()["denial_code"] == "capability_denied" + assert wrong_tenant.json()["denial_code"] == "tenant_mismatch" + assert expired.json()["denial_code"] == "expired_token" + assert all(item.json()["inherits_org_grants"] is False for item in ( + malformed, unknown, wrong_unit, capability, expired + )) + + +def test_rotate_replaces_token_and_rejects_software_unit_change(client) -> None: + """Rotation revokes the old secret and issues a same-unit replacement.""" + issued = client.post("/application-tokens", json=ISSUE_BODY).json() + tenant_mismatch = client.post( + f"/application-tokens/{issued['application_token_id']}:rotate", + json={**ISSUE_BODY, "tenant_deployment_id": "other-deployment"}, + ) + rotated = client.post( + f"/application-tokens/{issued['application_token_id']}:rotate", + json=ISSUE_BODY, + ) + 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"], + "tenant_deployment_id": "default-deployment", + "software_unit_id": "naruon-web", + }, + ) + new = client.post( + "/application-tokens:verify", + json={ + "presented_token": rotated.json()["plaintext_token"], + "tenant_deployment_id": "default-deployment", + "software_unit_id": "naruon-web", + }, + ) + assert old.json()["denial_code"] == "revoked_token" + assert new.json()["active"] is True + assert tenant_mismatch.status_code == 400 + mismatch = client.post( + f"/application-tokens/{rotated.json()['application_token_id']}:rotate", + json={**ISSUE_BODY, "software_unit_id": "clearfolio-web"}, + ) + assert mismatch.status_code == 400 + + +@pytest.mark.parametrize("retirement", ["revoke", "rotate", "expire"]) +def test_rotate_rejects_a_retired_or_expired_predecessor( + token_service: ApplicationTokenService, + clock: _Clock, + retirement: str, +) -> None: + """Rotation cannot revive a revoked, rotated, or expired credential.""" + request = ApplicationTokenIssueRequest.model_validate(ISSUE_BODY) + issued = token_service.issue(request) + + if retirement == "revoke": + token_service.revoke(issued.application_token_id, actor_identity_id="operator-ida") + elif retirement == "rotate": + token_service.rotate(issued.application_token_id, request) + else: + clock.now += request.lifetime_seconds + + with pytest.raises(AuthorizationPolicyError, match="not active") as error: + token_service.rotate(issued.application_token_id, request) + + assert error.value.status_code == 409 + predecessor = token_service.get_token(issued.application_token_id) + assert predecessor.lifecycle_status_code == { + "revoke": "revoked", + "rotate": "rotated", + "expire": "active", + }[retirement] + + +@pytest.mark.parametrize( + "invalid_update", + [ + {"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"], + "tenant_deployment_id": "default-deployment", + "software_unit_id": "naruon-web", + }, + ) + + assert response.status_code == 400 + assert still_active.json()["active"] is True + + +def test_issue_audit_failure_does_not_leave_an_active_token() -> None: + """An audit failure compensates the newly persisted issue record.""" + store = InMemoryKvStore() + service = ApplicationTokenService( + store, + AuditLogger(_FailingAuditSink()), + clock=_Clock(), + ) + with pytest.raises(RuntimeError, match="audit unavailable"): + service.issue(ApplicationTokenIssueRequest.model_validate(ISSUE_BODY)) + assert store.get_all(APPLICATION_TOKEN_NAMESPACE) == {} + + +def test_issue_storage_failure_does_not_persist_a_token() -> None: + """A failed initial storage write leaves no token record behind.""" + store = _FailingStore(fail_on_put=1) + service = ApplicationTokenService( + store, + AuditLogger(InMemoryAuditSink()), + clock=_Clock(), + ) + with pytest.raises(RuntimeError, match="storage"): + service.issue(ApplicationTokenIssueRequest.model_validate(ISSUE_BODY)) + assert store.get_all(APPLICATION_TOKEN_NAMESPACE) == {} + + +def test_revoke_audit_failure_restores_the_active_token() -> None: + """A revoke audit failure compensates the lifecycle update.""" + store = InMemoryKvStore() + service = ApplicationTokenService( + store, + AuditLogger(InMemoryAuditSink()), + clock=_Clock(), + ) + issued = service.issue(ApplicationTokenIssueRequest.model_validate(ISSUE_BODY)) + service._audit = AuditLogger(_FailingAuditSink()) + with pytest.raises(RuntimeError, match="audit unavailable"): + service.revoke(issued.application_token_id, actor_identity_id="operator-ida") + verified = service.verify( + ApplicationTokenVerifyRequest( + presented_token=issued.plaintext_token, + tenant_deployment_id="default-deployment", + software_unit_id="naruon-web", + ) + ) + assert verified.active is True + + +def test_rotate_storage_failure_preserves_the_active_predecessor() -> None: + """A replacement write failure cannot rotate away the predecessor.""" + store = _FailingStore(fail_on_put=2) + service = ApplicationTokenService( + store, + AuditLogger(InMemoryAuditSink()), + clock=_Clock(), + ) + issued = service.issue(ApplicationTokenIssueRequest.model_validate(ISSUE_BODY)) + with pytest.raises(RuntimeError, match="storage"): + service.rotate( + issued.application_token_id, + ApplicationTokenIssueRequest.model_validate(ISSUE_BODY), + ) + verified = service.verify( + ApplicationTokenVerifyRequest( + presented_token=issued.plaintext_token, + tenant_deployment_id="default-deployment", + software_unit_id="naruon-web", + ) + ) + assert verified.active is True + + +def test_rotate_audit_failure_restores_the_active_predecessor() -> None: + """An audit failure rolls back both replacement and predecessor state.""" + store = InMemoryKvStore() + service = ApplicationTokenService( + store, + AuditLogger(InMemoryAuditSink()), + clock=_Clock(), + ) + issued = service.issue(ApplicationTokenIssueRequest.model_validate(ISSUE_BODY)) + service._audit = AuditLogger(_FailingAuditSink()) + with pytest.raises(RuntimeError, match="audit unavailable"): + service.rotate( + issued.application_token_id, + ApplicationTokenIssueRequest.model_validate(ISSUE_BODY), + ) + verified = service.verify( + ApplicationTokenVerifyRequest( + presented_token=issued.plaintext_token, + tenant_deployment_id="default-deployment", + software_unit_id="naruon-web", + ) + ) + assert verified.active is True + assert len(store.get_all(APPLICATION_TOKEN_NAMESPACE)) == 1 + + +def test_issue_rejects_password_purposes_and_bounds(client) -> None: + """PATs cannot be password substitutes and stay purpose-bounded.""" + password = client.post( + "/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", + tenant_deployment_id="default-deployment", + 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, + tenant_deployment_id="default-deployment", + 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_auth.py b/services/account_unification/tests/test_auth.py index d60a93a..fe7aaf1 100644 --- a/services/account_unification/tests/test_auth.py +++ b/services/account_unification/tests/test_auth.py @@ -137,3 +137,32 @@ def test_service_without_configured_token_fails_closed( }, ) assert client.get("/users/u1").status_code == 503 + + +def test_runtime_routes_use_a_distinct_service_token() -> None: + """Start-login accepts runtime auth without requiring operator auth.""" + app = create_app(wire=False) + app.state.runtime_api_token = "runtime-token" + payload = { + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback", + } + with TestClient(app) as client: + assert client.post( + "/federation/identity-providers:start-login", json=payload + ).status_code == 401 + assert client.post( + "/federation/identity-providers:start-login", + json=payload, + headers={"X-Keyverse-Runtime-Token": "wrong"}, + ).status_code == 403 + assert client.post( + "/federation/identity-providers:start-login", + json=payload, + headers={"X-Keyverse-Runtime-Token": "runtime-token"}, + ).status_code == 503 + with TestClient(create_app(wire=False)) as unconfigured_client: + assert unconfigured_client.post( + "/federation/identity-providers:start-login", json=payload + ).status_code == 503 diff --git a/services/account_unification/tests/test_authorization_plane.py b/services/account_unification/tests/test_authorization_plane.py new file mode 100644 index 0000000..c0e8a0d --- /dev/null +++ b/services/account_unification/tests/test_authorization_plane.py @@ -0,0 +1,413 @@ +"""HTTP and persistence contracts for the authorization-plane PDP.""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from app.authorization_plane import ( + MENU_GRANT_NAMESPACE, + SOFTWARE_UNIT_GRANT_NAMESPACE, + SSO_COMBINATION_NAMESPACE, + AuthorizationPlaneService, + authorization_router, + 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", + "tenant_deployment_id": "default-deployment", + "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_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) + 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_decisions_are_isolated_by_tenant(client) -> None: + """Same paths and names remain separate across tenant deployments.""" + for tenant, effect in (("default-deployment", "allow"), ("other-deployment", "deny")): + for software_unit_id in ("naruon-web", "clearfolio-web"): + grant_key = f"{tenant.split('-')[0]}-{software_unit_id.split('-')[0]}" + response = client.put( + f"/authorization/software-unit-grants/{grant_key}", + json={ + **SOFTWARE_GRANT, + "grant_key": grant_key, + "tenant_deployment_id": tenant, + "software_unit_id": software_unit_id, + "effect_code": effect, + }, + ) + assert response.status_code == 200 + response = client.put( + "/authorization/sso-combination-scopes/finance-suite", + json={**COMBINATION, "tenant_deployment_id": tenant}, + ) + assert response.status_code == 200 + + listed = client.get("/authorization/sso-combination-scopes") + default_decision = client.post( + "/authorization/sso-combinations:decide", + json={"snapshot": SNAPSHOT, "combination_name": "finance-suite"}, + ) + other_decision = client.post( + "/authorization/sso-combinations:decide", + json={ + "snapshot": {**SNAPSHOT, "tenant_deployment_id": "other-deployment"}, + "combination_name": "finance-suite", + }, + ) + assert len(listed.json()) == 2 + assert client.get("/authorization/sso-combination-scopes/finance-suite").status_code == 409 + assert client.delete("/authorization/sso-combination-scopes/finance-suite").status_code == 409 + assert client.get( + "/authorization/sso-combination-scopes/finance-suite" + "?tenant_deployment_id=other-deployment" + ).status_code == 200 + assert client.delete( + "/authorization/sso-combination-scopes/finance-suite" + "?tenant_deployment_id=other-deployment" + ).status_code == 204 + assert default_decision.json()["effect"] == "allow" + assert other_decision.json()["effect"] == "deny" + + +def test_authorization_plane_rejects_mismatches_duplicates_and_unknowns(client) -> None: + """Path mismatches, duplicate identities, and missing keys fail closed.""" + mismatch = client.put( + "/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" + ) + 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 + 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 menu_mismatch.status_code == 400 + assert missing_menu.status_code == 404 + assert missing_menu_delete.status_code == 404 + + +def test_authorization_grants_with_same_identity_are_isolated_by_tenant(client) -> None: + """Equivalent grant geometry is valid once per tenant deployment.""" + first = client.put( + "/authorization/software-unit-grants/shared-grant", + json={**SOFTWARE_GRANT, "grant_key": "shared-grant"}, + ) + second = client.put( + "/authorization/software-unit-grants/shared-grant-other", + json={ + **SOFTWARE_GRANT, + "grant_key": "shared-grant-other", + "tenant_deployment_id": "other-deployment", + }, + ) + other_snapshot = {**SNAPSHOT, "tenant_deployment_id": "other-deployment"} + decision = client.post( + "/authorization/software-units:decide", + json={"snapshot": other_snapshot, "software_unit_id": "naruon-web"}, + ) + assert first.status_code == 200 + assert second.status_code == 200 + assert decision.json()["effect"] == "allow" + + +def test_ambiguous_tenant_identifiers_require_scoped_lookup( + store: InMemoryKvStore, +) -> None: + """Legacy identifier-only reads fail closed when tenants share a name.""" + service = AuthorizationPlaneService(store) + service.put_software_unit_grant( + "shared-grant", + AuthorizationGrant.model_validate({**SOFTWARE_GRANT, "grant_key": "shared-grant"}), + ) + service.put_software_unit_grant( + "shared-grant", + AuthorizationGrant.model_validate( + { + **SOFTWARE_GRANT, + "grant_key": "shared-grant", + "tenant_deployment_id": "other-deployment", + } + ), + ) + service.put_combination( + "shared-suite", + SsoCombinationScope.model_validate( + {**COMBINATION, "combination_name": "shared-suite"} + ), + ) + service.put_combination( + "shared-suite", + SsoCombinationScope.model_validate( + { + **COMBINATION, + "combination_name": "shared-suite", + "tenant_deployment_id": "other-deployment", + } + ), + ) + with pytest.raises(Exception, match="tenant_deployment_id"): + service.get_software_unit_grant("shared-grant") + with pytest.raises(Exception, match="tenant_deployment_id"): + service.get_combination("shared-suite") + + +def test_authorization_plane_delete_and_replace(client) -> None: + """Deletes remove grants and combinations; replace keeps one identity.""" + client.put("/authorization/software-unit-grants/acme-naruon", json=SOFTWARE_GRANT) + 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) + service.put_combination( + "other-suite", + combination.model_copy(update={"combination_name": "other-suite"}), + ) + assert service.get_software_unit_grant("acme-naruon").grant_key == "acme-naruon" + assert service.get_menu_grant("acme-naruon-invoices").menu_path == "/invoices" + assert service.get_combination("finance-suite").combination_name == "finance-suite" + service.delete_combination("finance-suite") diff --git a/services/account_unification/tests/test_config.py b/services/account_unification/tests/test_config.py index 0f71c21..4dac5c9 100644 --- a/services/account_unification/tests/test_config.py +++ b/services/account_unification/tests/test_config.py @@ -53,6 +53,29 @@ def test_config_loads_from_kv() -> None: assert config.allow_unverified_email_link is False assert config.merge_conflict_policy == "survivor_wins" assert config.registration_api_token is None + assert config.runtime_api_token is None + + +def test_runtime_and_public_issuer_settings_are_loaded_and_separated() -> None: + """Runtime callers use a distinct token and an explicit HTTPS issuer.""" + config = load_service_config( + _config_store( + runtime_api_token="runtime-token", + public_issuer_url="https://login.example/realms/cwl", + ), + "account_unification", + ) + assert config.runtime_api_token == "runtime-token" + assert config.public_issuer_url == "https://login.example/realms/cwl" + + +def test_runtime_token_must_not_equal_operator_token() -> None: + """Runtime service credentials cannot silently gain operator authority.""" + with pytest.raises(RuntimeError, match="runtime_api_token"): + load_service_config( + _config_store(runtime_api_token="operator-token"), + "account_unification", + ) def test_missing_required_config_fails_loudly() -> None: diff --git a/services/account_unification/tests/test_org_authorization.py b/services/account_unification/tests/test_org_authorization.py new file mode 100644 index 0000000..5ff2ef9 --- /dev/null +++ b/services/account_unification/tests/test_org_authorization.py @@ -0,0 +1,497 @@ +"""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", + "tenant_deployment_id": "default-deployment", + "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", + attribute_constraints: dict[str, str] | None = None, + tenant_deployment_id: str = "default-deployment", +) -> AuthorizationGrant: + """Return one software-unit grant at an org node.""" + return AuthorizationGrant( + grant_key=grant_key, + tenant_deployment_id=tenant_deployment_id, + grant_scope_code="software_unit", + org_path=org_path, + software_unit_id=software_unit_id, + effect_code=effect_code, + attribute_constraints=attribute_constraints or {}, + 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_authorization_grants_cannot_cross_tenant_boundaries() -> None: + """A grant only participates in decisions for its explicit tenant.""" + grants = [ + _software_grant("/group_company/acme", grant_key="tenant-a-allow"), + _software_grant( + "/group_company/acme", + grant_key="tenant-b-deny", + tenant_deployment_id="other-deployment", + effect_code="deny", + ), + ] + default_decision = decide_software_unit(grants, _snapshot(), "naruon-web") + other_decision = decide_software_unit( + grants, + _snapshot(tenant_deployment_id="other-deployment"), + "naruon-web", + ) + assert default_decision.effect is AuthorizationEffect.ALLOW + assert other_decision.effect is AuthorizationEffect.DENY + + +def test_more_specific_deny_restricts_inherited_allow() -> None: + """A legal-entity deny overrides an ancestor allow for that subtree only.""" + grants = [ + _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_software_unit_abac_constraints_are_rejected() -> None: + """Software-unit grants stay RBAC-only; ABAC belongs to menu grants.""" + grant = _software_grant( + "/group_company/acme", + attribute_constraints={"purpose": "hr-review"}, + ) + with pytest.raises(AuthorizationPolicyError, match="software_unit grants"): + validate_grant(grant) + + +def test_grants_are_selected_only_for_the_snapshot_tenant() -> None: + """A grant from another tenant cannot authorize this assignment snapshot.""" + decision = decide_software_unit( + [_software_grant("/group_company/acme")], + _snapshot(tenant_deployment_id="other-deployment"), + "naruon-web", + ) + assert decision.effect is AuthorizationEffect.DENY + + +def test_sso_combination_must_match_snapshot_tenant() -> None: + """A combination cannot cross the assignment snapshot tenant boundary.""" + combination = SsoCombinationScope( + combination_name="finance-suite", + tenant_deployment_id="other-deployment", + software_unit_ids=["naruon-web", "clearfolio-web"], + actor_identity_id="operator-ida", + ) + with pytest.raises(AuthorizationPolicyError, match="tenant"): + decide_sso_combination([], _snapshot(), combination) + + +def test_sso_combination_requires_every_member_allowed() -> None: + """One Keyverse session may cover a combination only when every RP is allowed.""" + grants = [ + _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_sso_combination_requires_matching_snapshot_tenant() -> None: + """An SSO scope from another tenant cannot authorize this snapshot.""" + combination = SsoCombinationScope( + combination_name="finance-suite", + tenant_deployment_id="other-deployment", + software_unit_ids=["naruon-web", "clearfolio-web"], + actor_identity_id="operator-ida", + ) + with pytest.raises(AuthorizationPolicyError, match="tenant"): + decide_sso_combination([], _snapshot(), combination) + + +def test_reserved_lineageweave_names_are_rejected_on_org_and_attributes() -> None: + """role/org/workspace cannot be smuggled in as hierarchical names.""" + with pytest.raises(AuthorizationPolicyError, match="reserved"): + 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="software_unit grants"): + validate_grant( + _software_grant("/group_company/acme").model_copy( + update={"attribute_constraints": {"purpose": "hr-review"}} + ) + ) + with pytest.raises(AuthorizationPolicyError, match="capability_codes"): + validate_grant(_software_grant("/group_company/acme", effect_code="deny").model_copy( + update={"capability_codes": ["menu.read"]} + )) + 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")) + omitted = validate_snapshot( + AssignmentSnapshot( + keyverse_subject="sub-no-assignment", + tenant_deployment_id="default-deployment", + org_path="/group_company/acme", + ) + ) + assert omitted.assignment_record_id is None + 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..d9a7deb --- /dev/null +++ b/services/account_unification/tests/test_start_login.py @@ -0,0 +1,381 @@ +"""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", + public_issuer_url="https://idp.example/realms/cwl", + ) + + +@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 + app.state.runtime_api_token = "test-runtime-token" + with TestClient( + app, + headers={**auth_header, "X-Keyverse-Runtime-Token": "test-runtime-token"}, + ) as test_client: + yield test_client + + +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 + + +@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( + "/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", + }, + ) + untrusted_issuer = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback", + "public_issuer_url": "https://attacker.example/realms/cwl", + }, + ) + query = client.post( + "/federation/identity-providers:start-login", + json={ + "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 untrusted_issuer.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_start_login_rejects_untrusted_public_issuer(client) -> None: + """The browser endpoint stays bound to configured Keyverse issuer state.""" + response = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback", + "public_issuer_url": "https://attacker.example/realms/cwl", + }, + ) + assert response.status_code == 400 + assert "configured Keyverse issuer" in response.json()["detail"] + + +def test_empty_registry_returns_discovery_without_start_url( + config: ServiceConfig, auth_header +) -> None: + """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 + app.state.runtime_api_token = "test-runtime-token" + with TestClient( + app, + headers={**auth_header, "X-Keyverse-Runtime-Token": "test-runtime-token"}, + ) as client: + response = client.post( + "/federation/identity-providers:start-login", + json={ + "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_start_login_runtime_surface_does_not_need_operator_bearer( + store: InMemoryKvStore, config: ServiceConfig +) -> None: + """The front-channel helper remains callable by an RP runtime.""" + app = create_app(wire=False) + app.state.start_login_service = StartLoginService(store, config) + app.state.runtime_api_token = "test-runtime-token" + with TestClient(app, headers={"X-Keyverse-Runtime-Token": "test-runtime-token"}) as client: + response = client.post( + "/federation/identity-providers:start-login", + json={ + "software_unit_id": "naruon-web", + "client_id": "naruon-web", + "redirect_uri": "https://naruon.example/callback", + }, + ) + assert response.status_code == 200 + + +def test_corrupt_provider_store_and_missing_service(store: InMemoryKvStore, config) -> None: + """Corrupt registry rows and missing wiring fail closed.""" + store.put(FEDERATION_PROVIDER_NAMESPACE, "broken", "{") + 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 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]]