diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ac8706..6bfbd0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,8 +36,10 @@ jobs: run: uv run ruff check app tests tools - name: Docstrings run: uv run interrogate . - - name: Test - run: uv run pytest -q + - name: Test and enforce production coverage + run: | + uv run coverage run --branch --source=app -m pytest -q + uv run coverage report --show-missing --fail-under=100 realm-config-validates: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 774d92a..4caaf58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ Keep a Changelog, and releases use semantic versioning. ### Added +- Side-effect-free federation preflight validation with redacted operator + results, explicit SAML issuer pinning, mandatory signature validation, and + metadata-backed or cryptographically parsed manual X.509 certificate trust. +- An operational external-federation onboarding and recovery guide for + standalone, CWL platform, and Naruon-integrated deployments. - Password-free headless registration that sends one bounded Keycloak action email for address verification and passkey enrollment, with failure-atomic account rollback. @@ -43,6 +48,12 @@ Keep a Changelog, and releases use semantic versioning. ### Fixed +- Upgraded `cryptography` to 50.0.0 to remediate CVE-2026-69247 while + retaining the supported DER X.509 certificate parsing API. +- Converted the employer ADFS template from an incompatible raw Keycloak + representation to the closed Keyverse desired-state API contract. +- Corrected root and template documentation that still claimed employer + federation was embedded in the portable realm. - Prevented registration races from surfacing raw Keycloak duplicate-user errors by mapping exact HTTP 409 responses to a stable product conflict. - Prevented unusable registration orphans by deleting accounts when Keycloak @@ -53,6 +64,12 @@ Keep a Changelog, and releases use semantic versioning. values from being echoed through list, get, or update responses. - Rejected Unicode-confusable federation aliases outside the explicit ASCII slug alphabet. +- Rejected raw C0 controls, DEL, invalid ports, insecure HTTP SSO or metadata + endpoints, malformed Base64, non-X.509 DER, PEM-wrapped manual certificates, + and empty rollover certificate entries before federation desired state can + be persisted. +- Hardened federation operator examples against shell xtrace leakage, HTTP + redirects, ambiguous preflight responses, and non-standalone recovery steps. - Raised non-success health responses correctly in the restricted stdlib HTTP opener. - Replaced a potentially expensive registration email regular expression with diff --git a/CLAUDE.md b/CLAUDE.md index beaa97c..eeae08b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,82 +1,135 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to Claude Code (claude.ai/code) when working with +code in this repository. ## What this repo is -**keyverse** hosts **cwl-idp**, the ContextualWisdom ecosystem's central Identity Provider built on **Keycloak** (Apache-2.0). It issues OIDC/OAuth 2.1 to ecosystem relying parties (`naruon`, `pg-erd-cloud`, `semantic-data-portal`, `clearfolio`, `contextual-orchestrator`, `newsdom-api`), federates external IdPs in (employer ADFS via SAML/WS-Fed, corporate LDAP/AD, optional personal OIDC), runs an inbound SCIM v2 provisioning shim, and is **passwordless-first** (FIDO2/passkeys; the password authenticator is removed from the login flow). cwl-idp is the hub; the employer ADFS is an external compatibility target, never the hub. +**keyverse** hosts **cwl-idp**, the ContextualWisdom ecosystem's central Identity +Provider built on **Keycloak** (Apache-2.0). It issues OIDC/OAuth 2.1 to ecosystem +relying parties (`naruon`, `pg-erd-cloud`, `semantic-data-portal`, `clearfolio`, +`contextual-orchestrator`, `newsdom-api`), federates external IdPs in (employer +ADFS via SAML, corporate LDAP/AD, optional personal OIDC), runs an inbound SCIM +v2 provisioning shim, and is **passwordless-first** (FIDO2/passkeys; the password +authenticator is removed from the login flow). cwl-idp is the hub; employer ADFS +is external deployment data and a compatibility target, never the hub. ## Common commands -Make targets (work with docker or podman: `COMPOSE="podman compose" make up`): +Make targets work with Docker or Podman (`COMPOSE="podman compose" make up`): ```bash -make up # bring up Keycloak + Postgres + admin service (docker compose up -d) -make down # tear down (keeps volumes) +make up # bring up Keycloak + Postgres + admin service +make down # tear down while retaining volumes make logs # follow logs make ready # poll readiness (deploy/scripts/healthz.sh) -make install # pip install -e '.[dev]' for the admin service -make test # run account-unification unit tests (pytest -q) -make lint # ruff check + interrogate (docstring coverage) -make validate-realm # python scripts/validate_realm.py deploy/keycloak/realm-cwl.json -make seed-bootstrap # create a local sqlite KV bootstrap store for dev +make install # install the admin service development environment +make test # run account-unification unit tests +make lint # run Ruff + interrogate docstring coverage +make validate-realm # validate deploy/keycloak/realm-cwl.json +make seed-bootstrap # create a local SQLite KV bootstrap store ``` -Compose bring-up needs `.env` (from `.env.example`) and `deploy/bootstrap/bootstrap.yaml` (from `bootstrap.example.yaml`). Keycloak console: `http://localhost:8080`; admin service: `http://localhost:8099/healthz`. +Compose bring-up needs `.env` (from `.env.example`) and +`deploy/bootstrap/bootstrap.yaml` (from `bootstrap.example.yaml`). Keycloak +console: `http://localhost:8080`; admin service: +`http://localhost:8099/healthz`. -Per-service, matching what CI runs (from `services/account_unification/`): +Per-service commands matching CI, from `services/account_unification/`: ```bash -uv sync --locked --extra dev # install locked deps +uv sync --locked --extra dev # install locked dependencies uv run ruff check app tests tools # lint uv run interrogate . # docstring coverage gate (fail-under 100) uv run pytest -q # all tests -uv run pytest tests/test_merge.py -q # one file -uv run pytest tests/test_merge.py::test_name -q # one test +uv run pytest tests/test_merge.py -q +uv run pytest tests/test_merge.py::test_name -q ``` -Run the admin service standalone (from `services/account_unification/`): +Run the admin service standalone from `services/account_unification/`: ```bash python tools/seed_config_store.py --db /tmp/idp_config_store.db -export CWL_IDP_BOOTSTRAP=/path/to/bootstrap.yaml # points at the KV store +export CWL_IDP_BOOTSTRAP=/path/to/bootstrap.yaml uvicorn app.main:app --port 8099 ``` -## CI gates (.github/workflows/ci.yml) +## CI gates (`.github/workflows/ci.yml`) -1. **account-unification-tests** — `uv sync --locked --extra dev`, then `ruff check app tests tools`, `interrogate .`, `pytest -q` (Python 3.12, working dir `services/account_unification`). -2. **realm-config-validates** — `python scripts/validate_realm.py deploy/keycloak/realm-cwl.json`. Fails if the realm export breaks policy invariants: passwordless browser flow bound with the WebAuthn passwordless authenticator and **no** password authenticator, registration/reset-password off, ADFS SAML IdP + LDAP source present, no real committed client secret (placeholders must be `__set_from_kv__`). -3. **compose-config-validates** — `docker compose -f docker-compose.yml config` with placeholder passwords. +1. **account-unification-tests** — `uv sync --locked --extra dev`, then + `ruff check app tests tools`, `interrogate .`, and `pytest -q` on Python 3.12. +2. **realm-config-validates** — validates the portable realm export. The bound + browser flow must contain WebAuthn passwordless and no password + authenticator; registration and reset-password remain off; no external IdP or + user-storage federation may be committed; public RP access-token lifetime is + bounded; real client secrets are forbidden. +3. **compose-config-validates** — validates `docker-compose.yml` with placeholder + bootstrap passwords. -CodeQL (python) also runs on push/PR. `.clusterfuzzlite/` is a discovery marker; the fuzz entrypoint is `services/account_unification/fuzz/fuzz_matching.py` (Atheris). +CodeQL also runs on push and pull requests. `.clusterfuzzlite/` is a discovery +marker; the fuzz entrypoint is +`services/account_unification/fuzz/fuzz_matching.py` (Atheris). ## Architecture -Three containers on two networks (`docker-compose.yml`; same shape in the Helm chart): +Three containers run on two networks (`docker-compose.yml`; the Helm chart has +the same shape): -- **idp_database** — Postgres 17, Keycloak's system of record. Internal network only. -- **idp_engine** — Keycloak 26.3.2, `start --import-realm`; imports the passwordless-first `cwl` realm as-code from `deploy/keycloak/realm-cwl.json` on first boot. Health on management port 9000. TLS terminates at the WAF edge, so HTTP is enabled internally. -- **account_unification_service** — the only service under `services/`: a FastAPI admin service (Python ≥3.11) on port 8099. It talks to Keycloak exclusively through the **Admin REST API** (`app/keycloak_client.py`), authenticating with a confidential service-account client (client credentials, `realm-management` view-users/manage-users). It provides account **inspect/link/merge** plus the inbound **SCIM 2.0 shim** (`/scim/v2/Users` → Keycloak Admin API). Module responsibilities are tabled in `services/account_unification/README.md`; the merge algorithm and HTTP surface are in `docs/merge-unification-flow.md`. +- **idp_database** — Postgres 17, Keycloak's system of record. Internal network + only. +- **idp_engine** — Keycloak 26, `start --import-realm`; imports the portable, + passwordless-first `cwl` realm from `deploy/keycloak/realm-cwl.json`. Health is + exposed on management port 9000. TLS terminates at the WAF edge, so HTTP is + enabled internally. +- **account_unification_service** — the FastAPI admin service (Python ≥3.11) on + port 8099. It talks to Keycloak only through the Admin REST API using a + confidential service-account client. It provides account inspect/link/merge, + inbound SCIM 2.0, passwordless registration, and external-IdP desired-state + validation/reconciliation. -Networks: `idp_internal_network` (DB + engine + admin service, never public) and `idp_edge_network` (only Keycloak OIDC endpoints and the admin/SCIM API, behind the WAF edge). Every component exposes a `/healthz`-style probe; `deploy/scripts/healthz.sh` gates on all of them (public-port signal is the realm's OIDC discovery document). +Networks: `idp_internal_network` (database, engine, and admin service; never +public) and `idp_edge_network` (Keycloak OIDC endpoints and the admin/SCIM API +behind the WAF edge). Every component exposes a `/healthz`-style probe; +`deploy/scripts/healthz.sh` gates on all of them. -Tests run entirely against an in-memory Keycloak fake (`tests/mock_keycloak.py`) — no live IdP needed. +Tests run against an in-memory Keycloak fake; no live IdP is required. ### Deployment layout -- `deploy/keycloak/` — realm config-as-code (`realm-cwl.json`) + `kcadm-bootstrap.sh`, which patches secrets/URLs from KV after import. Committed: non-secret structure. Patched from KV, never committed: ADFS metadata URL, LDAP bind credential, client secrets. -- `deploy/templates/` — Admin-API request-body templates for registering **additional** RPs/IdPs against a running realm (SAML IdP, LDAP source, OIDC RP client); `{{placeholders}}` resolved from KV. -- `deploy/bootstrap/` — the bootstrap pointer file (`bootstrap.yaml`) locating the KV/DB config store. -- `helm/cwl-idp/` — chart templating the same three components; Keycloak and Postgres are individually toggleable (`enabled: false` to use externally-managed ones). Secrets come from pre-created Kubernetes secrets populated from KV. -- The repo is **standalone AND submodule-embeddable**: a parent compose can `include:` this `docker-compose.yml`, or depend on the Helm chart. +- `deploy/keycloak/` — portable realm config-as-code and + `kcadm-bootstrap.sh`. The realm contains no employer-specific federation. +- `deploy/templates/` — explicit deployment contracts. The employer SAML + template uses the Keyverse desired-state API; LDAP and RP-client templates + document their Keycloak Admin REST endpoints. All `{{placeholders}}` are + resolved from KV before use. +- `deploy/bootstrap/` — the bootstrap pointer locating the KV/DB config store. +- `helm/cwl-idp/` — the same three components; Keycloak and Postgres may be + disabled in favor of externally managed services. Secrets come from + pre-created Kubernetes secrets populated from KV. +- The repository is **standalone AND submodule-embeddable**: a parent compose can + `include:` `docker-compose.yml`, or depend on the Helm chart. ## Key conventions -- **Config/secrets come from the KV/DB store, never runtime `os.getenv`.** Environment variables are bootstrap transport only — the admin service reads exactly one env var, `CWL_IDP_BOOTSTRAP`, pointing at the bootstrap file (`app/bootstrap.py` → `app/kv_store.py` → typed `ServiceConfig` in `app/config.py`, which fails loudly on missing keys). RP client registrations and secrets live in the IdP DB/KV, never in an RP's environment. -- **Never link or merge accounts on an unverified email.** Matching precedence is exact `(identity_provider, subject)` → verified email → explicit operator link; `allow_unverified_email_link` hard-defaults to `false`. Merges are survivor-wins, tombstone the duplicate (disable + `merged_into_user_id` attribute, never delete), and audit every step under one `audit_id`. -- **Passwordless invariant.** Never add a password authenticator to the bound browser flow in `realm-cwl.json`; `scripts/validate_realm.py` fails CI if one appears. Realm secrets in committed JSON must stay `__set_from_kv__`. -- **Permissive OSS only** — no GPL/AGPL dependencies (this is why the SCIM shim is in-repo rather than ZITADEL or the commercial scim-for-keycloak plugin). -- Container images are pinned by tag **and** digest (compose and Helm values). -- Database objects use two-word snake_case names (`idp_config_entries`, `account_merge_audit`). -- Python: ruff (line-length 100, target py311), pytest, and interrogate docstring coverage 100% (docstrings are required on modules/functions). Dependencies are locked with `uv` (`uv.lock`); CI installs with `uv sync --locked`, so update the lockfile when changing `pyproject.toml`. +- **Config and secrets come from the KV/DB store, never runtime `os.getenv`.** + Environment variables are bootstrap transport only. The admin service reads + `CWL_IDP_BOOTSTRAP`, which points at the bootstrap file and then the typed KV + configuration. +- **External federation is desired state.** Validate registrations through + `POST /federation/identity-providers:validate`, then persist with `PUT` and + converge through the federation service. Preflight must not write, call + Keycloak, resolve DNS, or fetch metadata. Unknown and secret-bearing config is + always redacted from responses. +- **Never link or merge accounts on an unverified email.** Matching precedence + is exact `(identity_provider, subject)` → verified email → explicit operator + link. Merges are survivor-wins, tombstone the duplicate, and audit every step + under one `audit_id`. +- **Passwordless invariant.** Never add a password authenticator to the bound + browser flow. The realm validator fails if one appears. +- **Permissive OSS only** — no GPL/AGPL dependencies. +- Container images are pinned by tag **and** digest. +- Database objects use two-word snake_case names (`idp_config_entries`, + `account_merge_audit`). +- Python uses Ruff (target py311), pytest, and 100% interrogate docstring + coverage. Dependencies are locked with `uv`; update `uv.lock` whenever + `pyproject.toml` changes. diff --git a/README.md b/README.md index 4765b53..844b145 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,30 @@ # cwl-idp — ecosystem central IdP -The **ContextualWisdom ecosystem's central Identity Provider**, a new standalone +The **ContextualWisdom ecosystem's central Identity Provider**, a standalone component built on [**Keycloak**](https://www.keycloak.org) (Apache-2.0). It: - issues **OIDC / OAuth 2.1** to ecosystem relying parties (`naruon`, `pg-erd-cloud`, `semantic-data-portal`, `clearfolio`, `contextual-orchestrator`, - `newsdom-api` via the WAF edge); + and `newsdom-api` through the WAF edge); - is **passwordless-first**: FIDO2 / passkeys are the default and the **password authenticator is removed** from the login flow for ecosystem-local accounts; - runs a **SCIM v2 server shim** for inbound provisioning into Keycloak; -- **federates external IdPs in** — the employer **ADFS** (`sts.hssmartdev.com`) - via SAML/WS-Fed, corporate **LDAP/AD**, and optional personal OIDC — with JIT - provisioning and auto-link-by-**verified**-email; and +- **federates external IdPs in** — employer ADFS via SAML, corporate LDAP/AD, + and optional personal OIDC — with JIT provisioning and auto-link-by- + **verified**-email; and - adds an **account-unification** admin service to link one human's many external identities and to **merge** two pre-existing accounts into one. -> The employer ADFS is an **external, proprietary** IdP and a **compatibility -> target — not the hub**. cwl-idp is the hub. +> Employer ADFS is an **external, proprietary** compatibility target — not the +> hub. cwl-idp is the hub, and employer-specific federation remains deployment +> data rather than portable realm code. RP client registrations and secrets live in the **IdP DB / KV**, never in an RP's environment. ## Architecture -``` +```text external IdPs ──► cwl-idp (Keycloak) ──► OIDC to ecosystem RPs ADFS (SAML) passwordless OIDC/OAuth LDAP/AD FIDO2 passkeys @@ -38,14 +39,14 @@ Full diagram and trust directions: [`docs/topology.md`](docs/topology.md). | Path | What | | --- | --- | | `docker-compose.yml` | Standalone bring-up: Keycloak + Postgres + admin service (pinned by digest) | -| `deploy/keycloak/` | Keycloak config-as-code: realm export (passwordless flow, OIDC RP template, ADFS SAML IdP, LDAP source, service-account client) + kcadm bootstrap | -| `deploy/templates/` | Admin-API templates for registering more RPs/IdPs: ADFS (SAML), LDAP source, OIDC RP client | +| `deploy/keycloak/` | Portable Keycloak realm config-as-code, passwordless flows, shared scopes, concrete Naruon RP, and service-account bootstrap | +| `deploy/templates/` | Deployment templates split between the Keyverse desired-state API and explicit Keycloak Admin REST contracts | | `deploy/bootstrap/` | Bootstrap pointer to the KV/DB config store | | `deploy/scripts/healthz.sh` | Cross-component readiness probe | | `scripts/validate_realm.py` | Realm config-as-code validator (CI gate) | -| `services/account_unification/` | FastAPI admin service (link + merge + SCIM shim) with unit tests | +| `services/account_unification/` | FastAPI admin service (link + merge + SCIM + federation desired state) with unit tests | | `helm/cwl-idp/` | Helm chart (templated Keycloak + Postgres + admin service) | -| `docs/` | Topology, passwordless policy, merge flow, RP onboarding, papers | +| `docs/` | Topology, passwordless policy, federation, merge flow, RP onboarding, and papers | ## Quick start (standalone) @@ -67,13 +68,15 @@ The stack imports the **passwordless-first** realm at first start WebAuthn passwordless authenticator and **no password authenticator**, plus `registrationAllowed:false` / `resetPasswordAllowed:false`. -### Register the employer ADFS + LDAP +### Register external federation -The realm ships the employer ADFS SAML IdP and the LDAP/AD source as-code; run -`deploy/keycloak/kcadm-bootstrap.sh` to patch their secrets/URLs from KV. To -register additional RPs/IdPs against a running realm, apply the templates in -`deploy/templates/`. See [`deploy/keycloak/README.md`](deploy/keycloak/README.md) -and [`deploy/templates/README.md`](deploy/templates/README.md). +The portable realm contains no employer ADFS, LDAP/AD source, or other +customer-specific federation. Render deployment values from KV, validate SAML +desired state through the side-effect-free preflight endpoint, and converge it +through `/federation/identity-providers`. 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). ### Onboard a relying party diff --git a/deploy/templates/README.md b/deploy/templates/README.md index 253e643..efa2497 100644 --- a/deploy/templates/README.md +++ b/deploy/templates/README.md @@ -1,48 +1,113 @@ -# Federation & client registration templates +# Federation and client registration templates -These are **request-body templates** for the Keycloak Admin REST API. They are -applied as-code (idempotently) by your provisioning tooling, which resolves -`{{placeholders}}` from the ecosystem KV config store and posts them with an -admin bearer token (obtained from a service-account client whose secret is in -KV). Nothing here contains a secret. +These files are deployment inputs. They contain no reusable credentials, and +all `{{placeholders}}` must be resolved from the platform KV before use. -| Template | Direction | Keycloak endpoint | -| --- | --- | --- | -| `saml-idp-employer-adfs.json` | inbound (external IdP → cwl-idp) | `POST /admin/realms/{realm}/identity-provider/instances` | -| `ldap-source.json` | inbound (external directory → cwl-idp) | `POST /admin/realms/{realm}/components` | -| `oidc-rp-client.json` | outbound (cwl-idp → RP) | `POST /admin/realms/{realm}/clients` | +| Template | Owner | Direction | Apply endpoint | +| --- | --- | --- | --- | +| `saml-idp-employer-adfs.json` | Keyverse desired-state API | external IdP → Keyverse | `PUT /federation/identity-providers/employer-adfs` | +| `ldap-source.json` | Keycloak Admin REST | external directory → Keycloak | `POST /admin/realms/{realm}/components` | +| `oidc-rp-client.json` | Keycloak Admin REST | Keyverse → RP | `POST /admin/realms/{realm}/clients` | -The realm itself (flows, base client template, the two federation sources) is -imported as-code from [`../keycloak/realm-cwl.json`](../keycloak/realm-cwl.json); -these templates are for registering **additional** RPs / IdPs against a running -realm. +The portable realm contains no employer-specific federation. External providers +are customer or deployment data stored in the Keyverse KV/DB desired-state +registry and reconciled into Keycloak. -## Apply pattern +## Employer ADFS apply pattern + +Render the ADFS template into a private temporary file, validate it without side +effects, and apply it only after preflight returns HTTP 200. Keep the bearer +token out of the `curl` argument vector by placing the header in a private curl +configuration file: ```bash -# 1. Get an admin token from the service-account client (secret from KV). -REALM=cwl -BASE="https://idp.example" -TOKEN=$(curl -sS -X POST \ - "$BASE/realms/$REALM/protocol/openid-connect/token" \ - -d grant_type=client_credentials \ - -d client_id=account-unification-svc \ - -d client_secret="$(kv get secret/idp/account-unification-client-secret)" \ - | jq -r .access_token) - -# 2. Render placeholders from KV, then POST. -render deploy/templates/oidc-rp-client.json \ - | curl -sS -X POST "$BASE/admin/realms/$REALM/clients" \ - -H "Authorization: Bearer ${TOKEN}" \ - -H "Content-Type: application/json" \ - --data @- +set -euo pipefail +BASE="https://keyverse-admin.example" +ALIAS="employer-adfs" +PAYLOAD="$(mktemp)" +PREFLIGHT_RESPONSE="$(mktemp)" +AUTH_CONFIG="" +cleanup() { + rm -f "$PAYLOAD" "$PREFLIGHT_RESPONSE" + if [ -n "${AUTH_CONFIG:-}" ]; then + rm -f "$AUTH_CONFIG" + fi +} +trap cleanup EXIT +chmod 0600 "$PAYLOAD" "$PREFLIGHT_RESPONSE" + +XTRACE_WAS_ON=0 +case $- in + *x*) + XTRACE_WAS_ON=1 + set +x + ;; +esac +TOKEN="$(kv get secret/keyverse/operator-api-token)" +AUTH_CONFIG="$(mktemp)" +chmod 0600 "$AUTH_CONFIG" +printf 'header = "Authorization: Bearer %s"\n' "$TOKEN" >"$AUTH_CONFIG" +unset TOKEN +if [ "$XTRACE_WAS_ON" -eq 1 ]; then + set -x +fi + +render deploy/templates/saml-idp-employer-adfs.json >"$PAYLOAD" + +PREFLIGHT_STATUS="$( + curl --config "$AUTH_CONFIG" \ + --silent \ + --show-error \ + --max-redirs 0 \ + --output "$PREFLIGHT_RESPONSE" \ + --write-out '%{http_code}' \ + --header "Content-Type: application/json" \ + --data-binary @"$PAYLOAD" \ + "$BASE/federation/identity-providers:validate" +)" +if [ "$PREFLIGHT_STATUS" != "200" ]; then + printf 'preflight returned HTTP %s\n' "$PREFLIGHT_STATUS" >&2 + cat "$PREFLIGHT_RESPONSE" >&2 + exit 1 +fi +if ! python3 - "$PREFLIGHT_RESPONSE" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as response_file: + response = json.load(response_file) +raise SystemExit(0 if response.get("ready_to_apply") is True else 1) +PY +then + echo 'preflight response did not confirm ready_to_apply=true' >&2 + exit 1 +fi + +curl --config "$AUTH_CONFIG" \ + --fail-with-body \ + --silent \ + --show-error \ + --max-redirs 0 \ + --request PUT \ + --header "Content-Type: application/json" \ + --data-binary @"$PAYLOAD" \ + "$BASE/federation/identity-providers/$ALIAS" ``` -## Auto-linking policy (important) +Preflight performs no KV write, no Keycloak Admin REST request, and no metadata +fetch. Unresolved placeholders, unpinned SAML issuers, disabled signature +validation, non-HTTPS network endpoints, unsafe URI text, or a missing +certificate source return HTTP 400. Because preflight never dereferences remote +metadata, the Keycloak egress layer must also reject redirect downgrade. +Operator responses redact unknown and credential-bearing configuration values. + +See [`../../docs/federation-onboarding.md`](../../docs/federation-onboarding.md) +for the complete operational and recovery flow. + +## Auto-linking policy -Both federation templates set `trustEmail: true`, which makes an incoming -**verified** email an eligible anchor for Keycloak's first-broker-login flow to -auto-link the external identity to a single existing account (and JIT-provision -otherwise). The account-unification service (this repo) enforces the stricter -rule end-to-end: **never link or merge on an unverified email** — see -`docs/merge-unification-flow.md`. +`trust_email: true` makes an email assertion eligible for account linking only +when the upstream provider's assertion is trusted as verified. The +account-unification service retains the stricter invariant: it never links or +merges accounts when the only common signal is an unverified email. See +[`../../docs/merge-unification-flow.md`](../../docs/merge-unification-flow.md). diff --git a/deploy/templates/saml-idp-employer-adfs.json b/deploy/templates/saml-idp-employer-adfs.json index 767cd61..4de723a 100644 --- a/deploy/templates/saml-idp-employer-adfs.json +++ b/deploy/templates/saml-idp-employer-adfs.json @@ -1,40 +1,13 @@ { - "$comment": [ - "SAML IdP registration template — EMPLOYER ADFS (sts.hssmartdev.com).", - "Registers the employer's proprietary ADFS as an INBOUND federation source", - "on cwl-idp. cwl-idp is the SP; ADFS is the external IdP. Feed this body to", - "the Keycloak Admin REST API:", - " POST /admin/realms/{realm}/identity-provider/instances", - "with an Authorization: Bearer header.", - "The employer ADFS is a COMPATIBILITY TARGET, never the hub.", - "", - "trustEmail=true makes the ADFS-asserted email an eligible link anchor so", - "the first-broker-login flow AUTO-LINKS by verified email and JIT-provisions.", - "Placeholders {{like_this}} are resolved by tooling from the KV config store", - "at apply time — do not hardcode metadata URLs or secrets here." - ], - - "alias": "employer-adfs", - "displayName": "Employer ADFS (hssmartdev)", - "providerId": "saml", + "provider_alias": "employer-adfs", + "display_name": "Employer ADFS", + "provider_id": "saml", "enabled": true, - "trustEmail": true, - "storeToken": false, - "linkOnly": false, - "firstBrokerLoginFlowAlias": "first broker login", - - "$mapping_notes": [ - "ADFS emits WS-* / SAML claim URIs. nameID is EmailAddress format (stable", - "subject); the email claim drives AUTO-LINK-BY-VERIFIED-EMAIL on first login.", - "Attribute-to-user mappers (email, upn, given/family name) are created as", - "separate identity-provider/{alias}/mappers POSTs after this instance exists." - ], - - "config": { - "entityId": "https://idp.example/realms/cwl", - "idpEntityId": "http://sts.hssmartdev.com/adfs/services/trust", + "trust_email": true, + "provider_config": { + "entityId": "{{keyverse_saml_entity_id}}", + "idpEntityId": "{{employer_adfs_entity_id}}", "metadataDescriptorUrl": "{{employer_adfs_metadata_url}}", - "$metadata_example": "https://sts.hssmartdev.com/FederationMetadata/2007-06/FederationMetadata.xml", "useMetadataDescriptorUrl": "true", "singleSignOnServiceUrl": "{{employer_adfs_sso_url}}", "nameIDPolicyFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", diff --git a/docs/federation-onboarding.md b/docs/federation-onboarding.md new file mode 100644 index 0000000..489ed91 --- /dev/null +++ b/docs/federation-onboarding.md @@ -0,0 +1,193 @@ +# External federation onboarding + +Keyverse treats external identity providers as deployment desired state rather +than portable realm code. This keeps the standalone component reusable across +organizations and allows a parent CWL or Naruon deployment to manage federation +through one stable API. + +## Trust boundary + +The operator API is privileged. Store its bearer token in the platform secret +manager and expose it only to the deployment controller. At the WAF edge, +expose only the Keyverse admin and SCIM APIs plus explicitly permitted Keycloak +OIDC endpoints. Keep the account-unification/federation service network and the +Keycloak Admin REST API private and unreachable from the public internet. The +preflight endpoint accepts the same closed request schema as `PUT`, but +deliberately performs no storage write, Keycloak call, DNS lookup, or metadata +download. + +For SAML providers, Keyverse requires: + +- explicit service-provider and identity-provider entity identifiers; +- an HTTP(S) SSO endpoint; +- signature validation enabled; +- an explicit certificate-source mode; +- either an HTTP(S) metadata descriptor URL or a manually supplied signing + certificate; +- fully rendered values without `{{...}}` markers. + +SAML entity identifiers are bounded absolute URIs and may use an interoperable +`urn:` form. Network endpoints are restricted to HTTPS and reject credentials, +fragments, whitespace, backslashes, and raw or percent-encoded control +characters. Preflight deliberately does not dereference metadata or follow +redirects, so it cannot observe a redirect target. Restrict Keycloak egress or +its outbound proxy to approved HTTPS metadata and SSO hosts, and reject every +HTTPS-to-HTTP redirect before the response reaches Keycloak. + +## Render, validate, apply + +The following example keeps both the rendered payload and bearer header in +private temporary files. The token is never passed in the `curl` process +arguments. + +```bash +set -euo pipefail +BASE="https://keyverse-admin.example" +ALIAS="employer-adfs" +PAYLOAD="$(mktemp)" +PREFLIGHT_RESPONSE="$(mktemp)" +AUTH_CONFIG="" +cleanup() { + rm -f "$PAYLOAD" "$PREFLIGHT_RESPONSE" + if [ -n "$AUTH_CONFIG" ]; then + rm -f "$AUTH_CONFIG" + fi +} +trap cleanup EXIT +chmod 0600 "$PAYLOAD" "$PREFLIGHT_RESPONSE" + +xtrace_was_on=0 +case $- in + *x*) xtrace_was_on=1; set +x ;; +esac +TOKEN="$(kv get secret/keyverse/operator-api-token)" +AUTH_CONFIG="$(mktemp)" +chmod 0600 "$AUTH_CONFIG" +printf 'header = "Authorization: Bearer %s"\n' "$TOKEN" >"$AUTH_CONFIG" +unset TOKEN +if [ "$xtrace_was_on" -eq 1 ]; then + set -x +fi + +render deploy/templates/saml-idp-employer-adfs.json >"$PAYLOAD" + +preflight_code="$( + curl --config "$AUTH_CONFIG" \ + --max-redirs 0 \ + --silent \ + --show-error \ + --output "$PREFLIGHT_RESPONSE" \ + --write-out '%{http_code}' \ + --header "Content-Type: application/json" \ + --data-binary @"$PAYLOAD" \ + "$BASE/federation/identity-providers:validate" +)" + +if [ "$preflight_code" != "200" ]; then + cat "$PREFLIGHT_RESPONSE" >&2 + exit 1 +fi + +python3 - "$PREFLIGHT_RESPONSE" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as response_file: + response = json.load(response_file) +raise SystemExit(0 if response.get("ready_to_apply") is True else 1) +PY + +curl --config "$AUTH_CONFIG" \ + --fail-with-body \ + --max-redirs 0 \ + --silent \ + --show-error \ + --request PUT \ + --header "Content-Type: application/json" \ + --data-binary @"$PAYLOAD" \ + "$BASE/federation/identity-providers/$ALIAS" +``` + +A successful `PUT` persists desired state even when Keycloak is temporarily +unavailable and returns `applied_to_keycloak: false`. This makes the outage +visible without losing the intended configuration. + +## Convergence and recovery + +The recovery block below is independently executable in a new shell. It creates +and removes its own private bearer configuration before listing and applying +stored provider desired state: + +```bash +set -euo pipefail +BASE="https://keyverse-admin.example" +AUTH_CONFIG="" +cleanup() { + if [ -n "$AUTH_CONFIG" ]; then + rm -f "$AUTH_CONFIG" + fi +} +trap cleanup EXIT + +xtrace_was_on=0 +case $- in + *x*) xtrace_was_on=1; set +x ;; +esac +TOKEN="$(kv get secret/keyverse/operator-api-token)" +AUTH_CONFIG="$(mktemp)" +chmod 0600 "$AUTH_CONFIG" +printf 'header = "Authorization: Bearer %s"\n' "$TOKEN" >"$AUTH_CONFIG" +unset TOKEN +if [ "$xtrace_was_on" -eq 1 ]; then + set -x +fi + +curl --config "$AUTH_CONFIG" \ + --fail-with-body \ + --max-redirs 0 \ + --silent \ + --show-error \ + "$BASE/federation/identity-providers" + +curl --config "$AUTH_CONFIG" \ + --fail-with-body \ + --max-redirs 0 \ + --silent \ + --show-error \ + --request POST \ + "$BASE/federation/identity-providers:apply" +``` + +Delete removes the provider from Keycloak first and then removes desired state. +If Keycloak deletion fails, the desired-state record remains so the operator can +retry without silently orphaning an applied provider. + +## Secret and certificate handling + +Unknown provider configuration values are accepted for convergence but are +redacted from every operator response. Payload files must be private and +short-lived. Do not pass client secrets, bearer tokens, or signing material in +process arguments, workflow logs, issue comments, or source-controlled +templates. + +When metadata refresh is enabled, pin the identity-provider entity identifier, +restrict network egress to the approved HTTPS metadata host, and reject +redirect downgrade. A supplied optional `signingCertificate` is still parsed +and rejected if malformed, but metadata remains the selected certificate source. +When metadata refresh is disabled, each `signingCertificate` entry must be a +Base64 DER X.509 +certificate body without PEM headers or footers. For a manual rollover, set +`signingCertificate` in the `PUT` payload to +`previous_certificate_body,next_certificate_body` so both certificates remain +active trusted certificates throughout the upstream rollover window. After the +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. + +## Standards basis + +- OASIS Security Services Technical Committee. (2019). *SAML V2.0 Metadata + Interoperability Profile Version 1.0*. + https://docs.oasis-open.org/security/saml/Post2.0/sstc-metadata-iop-os.html +- Keycloak. (2026). *Server Administration Guide: SAML v2.0 identity providers*. + https://www.keycloak.org/docs/latest/server_admin/#saml-v2-0-identity-providers diff --git a/docs/superpowers/plans/2026-08-04-keyverse-federation-preflight.md b/docs/superpowers/plans/2026-08-04-keyverse-federation-preflight.md new file mode 100644 index 0000000..cb1a6cd --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-keyverse-federation-preflight.md @@ -0,0 +1,111 @@ +# Federation Preflight Validation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> `superpowers:subagent-driven-development` or `superpowers:executing-plans` to +> implement this plan task by task. + +**Goal:** Add an authenticated, side-effect-free federation preflight endpoint +and fail-closed ADFS/SAML runtime validation before desired state is persisted. + +**Architecture:** Extend the existing federation service boundary so preflight +and `PUT` share pure validation. Return only the redacted operator view, keep KV +and Keycloak calls out of preflight, and make the ADFS template use the Keyverse +desired-state contract. + +**Tech stack:** Python 3.11+, FastAPI, Pydantic v2, pytest, Keycloak 26 Admin +REST representations, and GitHub Actions. + +## Global constraints + +- Preserve the existing authenticated federation router and routes. +- Perform no remote metadata fetch in preflight. +- Never echo provider secrets or unknown configuration values. +- Reject unresolved `{{...}}` markers before persistence. +- Accept standards-valid absolute URI entity identifiers, including `urn:`. +- Require explicit SP and IdP identifiers, SAML signature validation, and a + metadata-backed or manual certificate trust source. +- Keep production docstrings and statement/branch coverage at 100%. +- Keep database object naming unchanged and two-word-or-longer snake_case. +- Update `CHANGELOG.md`; do not version-bump until broader 0.2.0 release criteria + are met. + +--- + +### Task 1: Specify preflight behavior with failing tests + +**Files:** + +- Create: `services/account_unification/tests/test_federation_preflight.py` +- Create: `services/account_unification/tests/test_federation_url_hardening.py` +- Modify: `services/account_unification/tests/test_federation.py` + +- [x] Add a no-side-effect HTTP preflight test with redacted output. +- [x] Add unresolved-template rejection with zero side effects. +- [x] Cover missing and malformed entity identifiers, SSO endpoints, boolean + security fields, metadata URLs, and certificate-source modes. +- [x] Preserve standards-valid `urn:` entity identifiers. +- [x] Add raw whitespace, backslash, malformed authority, fragment, + percent-encoded control, and ordinary percent-encoding regressions. +- [x] Verify RED. Tests-only head + `99b88fe74376ff42718660bd7dec6df38906cb11` failed because the route returned + HTTP 404; URL-hardening head + `3dd95ba154823c1c9d1d344e3005d86b2c169378` failed on the three ambiguous URL + classes before the production fix. + +### Task 2: Implement the shared validation boundary + +**Files:** + +- Modify: `services/account_unification/app/federation.py` + +- [x] Add `IdentityProviderValidationResult` with a redacted registration view. +- [x] Reject unresolved template markers generically. +- [x] Add required strict `true` / `false` parsing. +- [x] Add bounded absolute-URI validation for `entityId` and `idpEntityId`. +- [x] Add bounded HTTPS-only validation for SSO and metadata locations while + preserving HTTP(S) and `urn:` entity identifiers. +- [x] Reject whitespace, backslashes, raw/encoded controls, userinfo, fragments, + malformed authorities, and non-HTTP network schemes. +- [x] Require `validateSignature=true` and an explicit certificate-source mode. +- [x] Add `FederationService.validate_registration` and authenticated + `POST /federation/identity-providers:validate` without store or network calls. +- [x] Share the stronger validation with `PUT` before persistence. +- [x] Verify GREEN. Bootstrap workflow run `30875502879` completed the locked + dependency install, Ruff, interrogate, complete pytest suite, realm validator, + Compose validation, JSON validation, and `git diff --check`, then committed + implementation head `39223fad8329a2d5ed7cc133f219d18825eb9b83`. + +### Task 3: Correct operator artifacts and documentation + +**Files:** + +- Modify: `deploy/templates/saml-idp-employer-adfs.json` +- Modify: `deploy/templates/README.md` +- Create: `docs/federation-onboarding.md` +- Modify: `README.md` +- Modify: `CLAUDE.md` +- Modify: `CHANGELOG.md` + +- [x] Convert the ADFS template to `IdentityProviderRegistration` and remove raw + Keycloak-only top-level fields. +- [x] Parameterize SP entity ID, IdP entity ID, metadata URL, and SSO URL. +- [x] Document render → preflight → `PUT` with private payload and curl-config + files so bearer credentials do not enter process arguments. +- [x] Document convergence, outage recovery, certificate rotation, HTTPS-only + egress and redirect-downgrade restriction, redaction, and deletion semantics. +- [x] Correct root and agent guidance that still claimed external federation was + embedded in portable realm code or that every template used the same control + plane. +- [x] Record the feature and documentation corrections under `[Unreleased]`. + +### Task 4: Exact-head review, coverage, and merge readiness + +- [ ] Verify the final human-authored head after all documentation and template + commits: locked CI, realm validation, Compose validation, Semgrep, CodeQL, + security scan, 100% docstrings, and central statement/branch coverage. +- [ ] Inspect every current review submission and unresolved thread; resolve only + feedback addressed on the unchanged head. +- [ ] Mark the PR ready and enable exact-head auto-merge only after required + checks and independent approval satisfy repository policy. +- [ ] After merge, close issue #3 with the merge SHA and confirm the open PR queue + before choosing the next buyer-visible slice. diff --git a/docs/superpowers/specs/2026-08-04-keyverse-federation-preflight-design.md b/docs/superpowers/specs/2026-08-04-keyverse-federation-preflight-design.md new file mode 100644 index 0000000..f6e9278 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-keyverse-federation-preflight-design.md @@ -0,0 +1,221 @@ +# Federation Preflight Validation Design + +## Problem + +Keyverse stores external identity-provider desired state and converges it into +Keycloak, but the current operator workflow accepts only generic size and alias +checks. A rendered ADFS/SAML payload can therefore contain unresolved template +markers, missing issuer identifiers or SSO endpoints, disabled signature +validation, or no trusted certificate source. `PUT` persists that invalid +desired state before Keycloak reports convergence failure, and the committed +ADFS template still uses the raw Keycloak Admin REST shape instead of the +Keyverse desired-state API shape. + +For an enterprise identity control plane, configuration errors must fail before +persistence and before a network call. Operators also need a safe dry-run +endpoint that never writes desired state, never calls Keycloak, and never echoes +credentials. + +## Scope + +This slice adds a provider-neutral preflight endpoint and secure SAML-specific +validation for the employer ADFS compatibility path. + +In scope: + +- `POST /federation/identity-providers:validate` using the same + `IdentityProviderRegistration` contract as `PUT`; +- redacted success responses with an explicit `ready_to_apply` signal; +- no desired-state mutation and no Keycloak Admin REST call during preflight; +- unresolved `{{...}}` marker rejection for all provider configuration values; +- SAML validation for service-provider and identity-provider entity identifiers, + SSO URL, signature validation, and a certificate source; +- either a metadata descriptor URL or one or more manually supplied Base64 + DER X.509 signing certificates; +- SAML entity identifiers as bounded absolute URIs, preserving standards-valid + `urn:` identifiers as well as HTTPS identifiers; +- network-reachable SSO and metadata locations as absolute HTTPS URLs without + userinfo, fragments, whitespace, backslashes, raw controls, encoded controls, + or invalid ports; +- conversion of the employer ADFS template to the Keyverse runtime API shape; +- operator documentation, root README correction, and changelog update. + +Out of scope: + +- fetching or parsing remote SAML metadata inside the account-unification + service; +- OIDC discovery endpoint probing; +- LDAP desired-state management; +- an administrative web UI; +- secret rotation or drift fingerprinting. + +## API Contract + +### Request + +`POST /federation/identity-providers:validate` + +The request body is `IdentityProviderRegistration`: + +```json +{ + "provider_alias": "employer-adfs", + "display_name": "Employer ADFS", + "provider_id": "saml", + "enabled": true, + "trust_email": true, + "provider_config": { + "entityId": "https://idp.example/realms/cwl", + "idpEntityId": "http://sts.example/adfs/services/trust", + "singleSignOnServiceUrl": "https://sts.example/adfs/ls/", + "validateSignature": "true", + "useMetadataDescriptorUrl": "true", + "metadataDescriptorUrl": "https://sts.example/FederationMetadata/2007-06/FederationMetadata.xml" + } +} +``` + +### Success + +HTTP 200: + +```json +{ + "registration": { + "provider_alias": "employer-adfs", + "display_name": "Employer ADFS", + "provider_id": "saml", + "enabled": true, + "trust_email": true, + "provider_config": { + "entityId": "https://idp.example/realms/cwl", + "singleSignOnServiceUrl": "https://sts.example/adfs/ls/", + "validateSignature": "true", + "clientSecret": "" + } + }, + "ready_to_apply": true +} +``` + +The response uses the existing safe allowlist and redacts every unknown or +credential-like configuration value. + +### Failure + +Invalid input returns HTTP 400 with one bounded, non-secret explanation. No +provider configuration value is copied into the error detail. + +## Validation Rules + +All providers retain the existing alias, provider ID, entry-count, key-length, +and value-length bounds. Any provider configuration value containing `{{` or +`}}` is rejected as an unresolved template. + +For `provider_id == "saml"`: + +1. `entityId` is required and must be a non-empty absolute URI of at most 1,024 + characters. HTTP(S) and `urn:` forms are accepted. +2. `idpEntityId` is required and must be a non-empty absolute URI of at most + 1,024 characters. Requiring it prevents Keycloak's documented fallback in + which issuer validation is skipped when the field is empty. +3. `singleSignOnServiceUrl` is required and must be an absolute HTTPS URL. +4. `validateSignature` is required and must be the exact boolean string `true` + after trimming and ASCII case normalization. +5. `useMetadataDescriptorUrl` is required and must be `true` or `false`. +6. When metadata use is enabled, `metadataDescriptorUrl` is required and must + be an absolute HTTPS URL. An optional supplied `signingCertificate` is + validated when present but is not required because metadata is the selected + trust source. +7. When metadata use is disabled, `signingCertificate` is required. It must + contain one or more comma-separated Base64 DER X.509 certificate bodies. + Empty list entries, invalid Base64, non-X.509 DER, and PEM headers or footers + are rejected. + +URI validation rejects surrounding or internal whitespace, every C0 control +character, DEL, backslashes, credentials in hierarchical authority components, +and invalid or out-of-range ports. Network URL validation also rejects URI +fragments and every scheme other than HTTPS. Query strings remain allowed +because some enterprise metadata services use bounded query parameters. The +side-effect-free preflight never follows redirects; deployments must enforce an +approved-host, HTTPS-only redirect policy at Keycloak egress or its outbound +proxy. + +## Architecture and Data Flow + +The pure validation helpers remain inside `app/federation.py`, next to the +existing desired-state model and validation boundary. This avoids a circular +model dependency and keeps all HTTP 400 semantics consistent. Manual +certificates are decoded with strict Base64 validation and parsed through the +project-pinned `cryptography` X.509 loader without network or filesystem I/O. + +Preflight flow: + +1. FastAPI authenticates the operator bearer token. +2. Pydantic validates the closed request schema. +3. `FederationService.validate_registration` runs generic and SAML policy + validation. +4. The service returns `IdentityProviderValidationResult`, built from the + redacted `IdentityProviderView`. +5. No store or Keycloak method is invoked. + +Apply flow remains unchanged except that the same stronger validation runs +before the desired-state write. + +## Security and Privacy + +- Preflight is operator-authenticated through the existing router dependency. +- The endpoint performs no external fetch, preventing a new SSRF surface and + deliberately leaving redirect-target enforcement to the Keycloak egress + boundary. +- Unresolved placeholders fail before persistence. +- Secret-bearing configuration is accepted for validation but never returned. +- Error text names only configuration fields and policy requirements, never + supplied values. +- The external IdP issuer is pinned explicitly. +- SAML signature validation cannot be disabled through the supported runtime + contract. +- A certificate source is mandatory whether metadata refresh is enabled or not. +- Manual trust material must parse as X.509 before `ready_to_apply` can be true. + +## Testing + +The test-first sequence proves: + +- the new route is absent before implementation; +- a valid ADFS registration returns a redacted 200 response; +- preflight leaves both the KV store and Keycloak mock untouched; +- unresolved placeholders fail closed without side effects; +- every SAML required-field, URI, URL, trust-mode, and boolean branch fails with + HTTP 400 when invalid, including direct HTTP SSO and metadata endpoints; +- standards-valid `urn:` entity identifiers remain accepted; +- valid single and comma-separated rollover certificates are accepted when + metadata retrieval is disabled, and every certificate body is individually + absent from the redacted response; +- malformed optional manual trust material also fails when metadata mode is + selected; +- malformed Base64, non-X.509 DER, PEM-wrapped values, and empty certificate + list entries fail closed; +- raw NUL characters and out-of-range URL ports fail closed; +- the existing `PUT`, list, get, apply, outage, lock, and redaction regressions + remain green; +- production docstring and statement/branch coverage remain 100%. + +## Compatibility and Release + +The existing `PUT`, `GET`, `DELETE`, and `:apply` routes are not renamed. The +new endpoint is additive. The employer ADFS template changes from a raw +Keycloak representation to the already-public Keyverse registration contract; +operator documentation explicitly distinguishes it from the remaining raw LDAP +and RP-client templates. + +This is an unreleased feature. It updates `CHANGELOG.md` but does not bump the +package or Helm version until the broader 0.2.0 release criteria are satisfied. + +## Authoritative References + +- OASIS Security Services Technical Committee. (2019). *SAML V2.0 Metadata + Interoperability Profile Version 1.0*. + https://docs.oasis-open.org/security/saml/Post2.0/sstc-metadata-iop-os.html +- Keycloak. (2026). *Server Administration Guide: SAML v2.0 identity providers*. + https://www.keycloak.org/docs/latest/server_admin/#saml-v2-0-identity-providers diff --git a/services/account_unification/app/federation.py b/services/account_unification/app/federation.py index b108890..c7862d5 100644 --- a/services/account_unification/app/federation.py +++ b/services/account_unification/app/federation.py @@ -7,9 +7,16 @@ """ from __future__ import annotations +import base64 +import binascii import logging +import re import threading +from typing import NoReturn, cast +from urllib.parse import SplitResult, urlsplit +from cryptography import x509 +from cryptography.exceptions import UnsupportedAlgorithm from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, ConfigDict, Field @@ -27,6 +34,13 @@ _REDACTED_VALUE = "" _ALIAS_ALPHABET = frozenset("abcdefghijklmnopqrstuvwxyz0123456789-") _ALIAS_EDGE_ALPHABET = frozenset("abcdefghijklmnopqrstuvwxyz0123456789") +_HTTP_SCHEMES = frozenset({"http", "https"}) +_HTTPS_SCHEME = "https" +_PERCENT_ENCODED_CONTROL = re.compile( + r"%(?:0[0-9A-Fa-f]|1[0-9A-Fa-f]|7[Ff])" +) +_SAML_ENTITY_ID_MAX_LENGTH = 1_024 +_UNRESOLVED_TEMPLATE_MARKERS = ("{{", "}}") # Unknown fields are redacted. This allowlist contains only values that are # useful for operator diagnosis and are not credential material. _EXPOSED_PROVIDER_CONFIG_KEYS = frozenset( @@ -38,6 +52,7 @@ "entityId", "guiOrder", "hideOnLoginPage", + "idpEntityId", "issuer", "logoutUrl", "metadataDescriptorUrl", @@ -104,6 +119,13 @@ def from_registration( ) +class IdentityProviderValidationResult(BaseModel): + """Redacted result proving a registration is ready for persistence.""" + + registration: IdentityProviderView + ready_to_apply: bool = True + + class IdentityProviderStatus(BaseModel): """Redacted stored registration plus its Keycloak convergence status.""" @@ -121,6 +143,15 @@ def __init__(self, store: KvStore, api: ProductAdminApi) -> None: self._state_lock = threading.RLock() self._convergence_lock = threading.RLock() + def validate_registration( + self, registration: IdentityProviderRegistration + ) -> IdentityProviderValidationResult: + """Validate desired state without storage or Keycloak side effects.""" + _validate_registration(registration) + return IdentityProviderValidationResult( + registration=IdentityProviderView.from_registration(registration) + ) + def list_registrations(self) -> list[IdentityProviderStatus]: """Return all stored registrations with live convergence status.""" with self._state_lock: @@ -309,6 +340,167 @@ def _validate_registration( status_code=400, detail="provider_config key or value exceeds allowed bounds", ) + if any( + marker in config_value + for marker in _UNRESOLVED_TEMPLATE_MARKERS + ): + raise HTTPException( + status_code=400, + detail=( + "provider_config contains unresolved template placeholders" + ), + ) + if registration.provider_id == "saml": + _validate_saml_registration(registration.provider_config) + + +def _provider_config_error( + field_name: str, requirement: str +) -> NoReturn: + """Raise one bounded non-secret provider configuration error.""" + raise HTTPException( + status_code=400, + detail=f"{field_name} {requirement}", + ) + + +def _validate_provider_boolean( + provider_config: dict[str, str], field_name: str +) -> bool: + """Parse one required Keycloak configuration boolean strictly.""" + raw_value = provider_config.get(field_name) + if raw_value is None: + _provider_config_error(field_name, "is required and must be true or false") + normalized = raw_value.strip().lower() + if normalized not in {"true", "false"}: + _provider_config_error(field_name, "must be true or false") + return normalized == "true" + + +def _validate_absolute_uri( + provider_config: dict[str, str], + field_name: str, + *, + maximum_length: int, +) -> SplitResult: + """Validate one bounded absolute URI without dereferencing it.""" + value = provider_config.get(field_name, "") + invalid_text = ( + not value + or len(value) > maximum_length + or value != value.strip() + or any( + character.isspace() + or ord(character) < 0x20 + or ord(character) == 0x7F + for character in value + ) + or "\\" in value + or _PERCENT_ENCODED_CONTROL.search(value) is not None + ) + try: + parsed = urlsplit(value) + _ = parsed.port + except ValueError: + parsed = None + invalid_uri = ( + parsed is None + or not parsed.scheme + or parsed.username is not None + or parsed.password is not None + or ( + parsed.scheme.lower() in _HTTP_SCHEMES + and parsed.hostname is None + ) + ) + if invalid_text or invalid_uri: + _provider_config_error(field_name, "must be a bounded absolute URI") + return cast(SplitResult, parsed) + + +def _validate_https_url( + provider_config: dict[str, str], field_name: str +) -> None: + """Validate one HTTPS network location without dereferencing it.""" + parsed = _validate_absolute_uri( + provider_config, + field_name, + maximum_length=_MAX_PROVIDER_CONFIG_VALUE_LENGTH, + ) + if ( + parsed.scheme.lower() != _HTTPS_SCHEME + or parsed.hostname is None + or bool(parsed.fragment) + ): + _provider_config_error( + field_name, + "must be an absolute HTTPS URL without a fragment", + ) + + +def _validate_signing_certificates( + provider_config: dict[str, str], field_name: str +) -> None: + """Require comma-separated Base64 DER X.509 certificate bodies.""" + raw_value = provider_config.get(field_name, "") + certificate_bodies = [ + certificate_body.strip() + for certificate_body in raw_value.split(",") + ] + if not raw_value.strip() or any(not body for body in certificate_bodies): + _provider_config_error( + field_name, + "must contain one or more Base64 DER X.509 certificates", + ) + for certificate_body in certificate_bodies: + if "-----BEGIN CERTIFICATE-----" in certificate_body or ( + "-----END CERTIFICATE-----" in certificate_body + ): + _provider_config_error( + field_name, + "must omit PEM certificate headers and footers", + ) + try: + certificate_der = base64.b64decode( + certificate_body, validate=True + ) + x509.load_der_x509_certificate(certificate_der) + except (binascii.Error, UnsupportedAlgorithm, ValueError): + _provider_config_error( + field_name, + "must contain valid Base64 DER X.509 certificates", + ) + + +def _validate_saml_registration(provider_config: dict[str, str]) -> None: + """Enforce issuer, endpoint, signature, and certificate-source policy.""" + _validate_absolute_uri( + provider_config, + "entityId", + maximum_length=_SAML_ENTITY_ID_MAX_LENGTH, + ) + _validate_absolute_uri( + provider_config, + "idpEntityId", + maximum_length=_SAML_ENTITY_ID_MAX_LENGTH, + ) + _validate_https_url(provider_config, "singleSignOnServiceUrl") + if not _validate_provider_boolean(provider_config, "validateSignature"): + _provider_config_error( + "validateSignature", + "must be true for SAML identity providers", + ) + use_metadata = _validate_provider_boolean( + provider_config, "useMetadataDescriptorUrl" + ) + if use_metadata: + _validate_https_url(provider_config, "metadataDescriptorUrl") + if provider_config.get("signingCertificate", "").strip(): + _validate_signing_certificates( + provider_config, "signingCertificate" + ) + return + _validate_signing_certificates(provider_config, "signingCertificate") def _redacted_provider_config( @@ -356,6 +548,18 @@ def get_federation_service(request: Request) -> FederationService: return service +@federation_router.post( + "/identity-providers:validate", + response_model=IdentityProviderValidationResult, +) +def validate_identity_provider( + registration: IdentityProviderRegistration, + service: FederationService = Depends(get_federation_service), +) -> IdentityProviderValidationResult: + """Validate provider desired state without writing or converging it.""" + return service.validate_registration(registration) + + @federation_router.get( "/identity-providers", response_model=list[IdentityProviderStatus] ) diff --git a/services/account_unification/pyproject.toml b/services/account_unification/pyproject.toml index 031a988..b1c8773 100644 --- a/services/account_unification/pyproject.toml +++ b/services/account_unification/pyproject.toml @@ -10,6 +10,7 @@ readme = "README.md" requires-python = ">=3.11" license = { text = "Apache-2.0" } dependencies = [ + "cryptography==50.0.0", "fastapi==0.140.13", "pydantic==2.13.4", "httpx==0.28.1", @@ -19,6 +20,7 @@ dependencies = [ [project.optional-dependencies] dev = [ + "coverage==7.14.3", "httpx2>=2.5.0", "interrogate==1.7.0", "pytest==9.1.1", diff --git a/services/account_unification/requirements-dev.txt b/services/account_unification/requirements-dev.txt index daa94d8..77fde00 100644 --- a/services/account_unification/requirements-dev.txt +++ b/services/account_unification/requirements-dev.txt @@ -1,29 +1,120 @@ # This file was autogenerated by uv via the following command: -# uv export --format requirements-txt --extra dev --hashes --no-emit-project --output-file requirements-dev.txt -annotated-doc==0.0.4 \ - --hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \ - --hash=sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4 +# uv export --format requirements.txt --extra dev --no-emit-project --output-file requirements-dev.txt +annotated-doc==0.0.5 \ + --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \ + --hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb # via fastapi -annotated-types==0.7.0 \ - --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ - --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 +annotated-types==0.8.0 \ + --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 # via pydantic -anyio==4.14.2 \ - --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ - --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f +anyio==4.14.1 \ + --hash=sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72 \ + --hash=sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e # via # httpx + # httpx2 # starlette attrs==26.1.0 \ --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 # via interrogate -certifi==2026.7.22 \ - --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ - --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 +certifi==2026.6.17 \ + --hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \ + --hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db # via # httpcore # httpx +cffi==2.1.1 ; platform_python_implementation != 'PyPy' \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + # via cryptography click==8.4.2 \ --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \ --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 @@ -37,30 +128,167 @@ colorama==0.4.6 \ # click # interrogate # pytest -fastapi==0.139.0 \ - --hash=sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145 \ - --hash=sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189 +coverage==7.14.3 \ + --hash=sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26 \ + --hash=sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965 \ + --hash=sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a \ + --hash=sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd \ + --hash=sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd \ + --hash=sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f \ + --hash=sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5 \ + --hash=sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c \ + --hash=sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e \ + --hash=sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a \ + --hash=sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b \ + --hash=sha256:2e41fd3aab806770008279a93879b0924b16247e09ab537c043d08bbca53b4ab \ + --hash=sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37 \ + --hash=sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda \ + --hash=sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845 \ + --hash=sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24 \ + --hash=sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027 \ + --hash=sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92 \ + --hash=sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c \ + --hash=sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977 \ + --hash=sha256:4d310baf69a4fbe8a098ce727e4808a34866ac718a6f759ae659cbd3221358bc \ + --hash=sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889 \ + --hash=sha256:5952f8c1bda2a5347154450379316e6dfa4d934d62ca35f6784451e6f55074fb \ + --hash=sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf \ + --hash=sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc \ + --hash=sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5 \ + --hash=sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727 \ + --hash=sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f \ + --hash=sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f \ + --hash=sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9 \ + --hash=sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0 \ + --hash=sha256:74fdd718d88fe144f4579b8747873a07ec3f04cb837d5faec5a25d9e22fa31a8 \ + --hash=sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc \ + --hash=sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d \ + --hash=sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949 \ + --hash=sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed \ + --hash=sha256:8ac012839ff7e396030f1e94e10553a431d14e4de2ab65cb3acb72bbd5628ca2 \ + --hash=sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336 \ + --hash=sha256:8cf0f2509acb4619e2471a1951089054dd58ebea7a912066d2ea56dd4c24ca4a \ + --hash=sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205 \ + --hash=sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3 \ + --hash=sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665 \ + --hash=sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73 \ + --hash=sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501 \ + --hash=sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e \ + --hash=sha256:a090cbf9521e78ffdb2fcf448b72902afe9f5923ff6a12d5c0d0120200348af9 \ + --hash=sha256:a2335ea5fed26af2e831094964fa3f8fae60b45f7e37fcc2d3b615b2add3ad87 \ + --hash=sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7 \ + --hash=sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5 \ + --hash=sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3 \ + --hash=sha256:ac082660de8f429ba0ea363595abb838998570b9a7546777c60f413ab902bbde \ + --hash=sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994 \ + --hash=sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784 \ + --hash=sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498 \ + --hash=sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7 \ + --hash=sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de \ + --hash=sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388 \ + --hash=sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce \ + --hash=sha256:c66f9f9d4f1e9712eb9b1de5310f881d4e2188cfcba5065e1a8490f38687f2c4 \ + --hash=sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c \ + --hash=sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef \ + --hash=sha256:cc96aa922e21d4bc5d5ed3c915cef27dfcbc13686f47d5e378d647fbfba655a2 \ + --hash=sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f \ + --hash=sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891 \ + --hash=sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635 \ + --hash=sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb \ + --hash=sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5 \ + --hash=sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d \ + --hash=sha256:e574801e1d643561594aa021206c46d80b257e9853087090ba97bed8b0a509d3 \ + --hash=sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150 \ + --hash=sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35 \ + --hash=sha256:f0a47095963cfe054e0df178daca95aec21e680d6076da807c3add28dfe920f7 \ + --hash=sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a \ + --hash=sha256:f82b6bb7d75a2613e85d07cefa3a8c973d0544a8993337f6e2728e4a1e94c305 \ + --hash=sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027 \ + --hash=sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8 \ + --hash=sha256:fbb8c3a98e779013786ae01d229662aeacbc77100efbd3f2f245219ace5af700 + # via cwl-idp-account-unification +cryptography==50.0.0 \ + --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \ + --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \ + --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \ + --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \ + --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \ + --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \ + --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \ + --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \ + --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \ + --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \ + --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \ + --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \ + --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \ + --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \ + --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \ + --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \ + --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \ + --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \ + --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \ + --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \ + --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \ + --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \ + --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \ + --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \ + --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \ + --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \ + --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \ + --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \ + --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \ + --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \ + --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \ + --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \ + --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \ + --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \ + --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \ + --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \ + --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \ + --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \ + --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \ + --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \ + --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \ + --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \ + --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \ + --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \ + --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ + --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 + # via cwl-idp-account-unification +fastapi==0.140.13 \ + --hash=sha256:500172a08cf1459901f90b05c37d93060dada3b573fec8f0862445db52ba6b4b \ + --hash=sha256:8b017110e1e9f30a95e8bdb8f71fbe2f0fe3af5717109e5b14f9e069df54f6d4 # via cwl-idp-account-unification h11==0.16.0 \ --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 # via # httpcore + # httpcore2 # uvicorn httpcore==1.0.9 \ --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 # via httpx +httpcore2==2.5.0 \ + --hash=sha256:5ce35188de461d31e8d000bfb8ef8bf22c6c16587a211e5571deaa5e9bdf842a \ + --hash=sha256:88aa170137c17328d5ac44234f9fd10706466d5fb347f3edac4d39b91137b09d + # via httpx2 httpx==0.28.1 \ --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad # via cwl-idp-account-unification +httpx2==2.5.0 \ + --hash=sha256:3d2d4d9cf4b61f1a1f46a95947cfdb47e80cb56a2f91c6256ac8f58e4891df41 \ + --hash=sha256:e2df9cb4611021527ff8a675b1c320b610a2ec397acc8d6fe6e91df2d9b33c29 + # via cwl-idp-account-unification idna==3.18 \ --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 # via # anyio # httpx + # httpx2 iniconfig==2.3.0 \ --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 @@ -81,6 +309,10 @@ py==1.11.0 \ --hash=sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719 \ --hash=sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378 # via interrogate +pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi pydantic==2.13.4 \ --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \ --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6 @@ -239,25 +471,25 @@ pyyaml==6.0.3 \ --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 # via cwl-idp-account-unification -ruff==0.15.21 \ - --hash=sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075 \ - --hash=sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be \ - --hash=sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd \ - --hash=sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd \ - --hash=sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86 \ - --hash=sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341 \ - --hash=sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd \ - --hash=sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67 \ - --hash=sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41 \ - --hash=sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d \ - --hash=sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d \ - --hash=sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8 \ - --hash=sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233 \ - --hash=sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8 \ - --hash=sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500 \ - --hash=sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3 \ - --hash=sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646 \ - --hash=sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f +ruff==0.16.0 \ + --hash=sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17 \ + --hash=sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d \ + --hash=sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af \ + --hash=sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09 \ + --hash=sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522 \ + --hash=sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472 \ + --hash=sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0 \ + --hash=sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b \ + --hash=sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9 \ + --hash=sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213 \ + --hash=sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb \ + --hash=sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed \ + --hash=sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717 \ + --hash=sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a \ + --hash=sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81 \ + --hash=sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982 \ + --hash=sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e \ + --hash=sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4 # via cwl-idp-account-unification starlette==1.3.1 \ --hash=sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0 \ @@ -267,12 +499,19 @@ tabulate==0.10.0 \ --hash=sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d \ --hash=sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3 # via interrogate +truststore==0.10.4 \ + --hash=sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301 \ + --hash=sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981 + # via + # httpcore2 + # httpx2 typing-extensions==4.16.0 \ --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 # via # anyio # fastapi + # httpx2 # pydantic # pydantic-core # starlette @@ -283,7 +522,7 @@ typing-inspection==0.4.2 \ # via # fastapi # pydantic -uvicorn==0.51.0 \ - --hash=sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b \ - --hash=sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0 +uvicorn==0.52.0 \ + --hash=sha256:3d887809810b89ed33501bcf0a9aba469b06ecd608158efce04bd6b48d8c9b08 \ + --hash=sha256:ca8876ad6c1983f394157c168b39d52f6dd56dabf5602fa0982751cffc2293ae # via cwl-idp-account-unification diff --git a/services/account_unification/requirements.lock b/services/account_unification/requirements.lock index 3f54454..5e96e2f 100644 --- a/services/account_unification/requirements.lock +++ b/services/account_unification/requirements.lock @@ -1,23 +1,5 @@ -# Hash-pinned runtime dependency lock for the account-unification service. -# -# Purpose: the ContextualWisdomLab central OpenCode review runs this service's -# pytest suite inside a networkless coverage sandbox that installs project -# dependencies ONLY from a base-branch `requirements.lock` / `requirements-hashes.txt` -# (see ContextualWisdomLab/.github scripts/ci/materialize_base_python_requirements.py). -# Without this file the sandbox cannot import pydantic/fastapi and coverage-evidence -# fails, so the file must live on the default branch. It is separate from uv.lock, -# which remains the source of truth for `uv sync --locked` in local/CI development. -# -# Contents: the runtime imports needed to collect and run tests/ (fastapi, pydantic, -# httpx, pyyaml) plus their fully-resolved transitive closure. Kept in sync with the -# runtime `dependencies` in pyproject.toml. The pinned test toolchain (pytest, -# coverage, interrogate) is supplied by the sandbox image and is intentionally absent. -# -# Regenerate (from services/account_unification/) after changing runtime pins in -# pyproject.toml — target the sandbox interpreter (python:3.14-slim) and wheels only: -# printf 'fastapi==0.140.13\npydantic==2.13.4\nhttpx==0.28.1\npyyaml==6.0.3\n' \ -# | uv pip compile --generate-hashes --only-binary :all: \ -# --python-version 3.14 --python-platform x86_64-manylinux_2_28 - -o requirements.lock +# This file was autogenerated by uv via the following command: +# uv export --format requirements.txt --no-dev --no-emit-project --output-file requirements.lock annotated-doc==0.0.5 \ --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \ --hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb @@ -26,26 +8,174 @@ annotated-types==0.8.0 \ --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 # via pydantic -anyio==4.14.2 \ - --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ - --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f +anyio==4.14.1 \ + --hash=sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72 \ + --hash=sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e # via # httpx # starlette -certifi==2026.7.22 \ - --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ - --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 +certifi==2026.6.17 \ + --hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \ + --hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db # via # httpcore # httpx +cffi==2.1.1 ; platform_python_implementation != 'PyPy' \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + # via cryptography +click==8.4.2 \ + --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \ + --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 + # via uvicorn +colorama==0.4.6 ; sys_platform == 'win32' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via click +cryptography==50.0.0 \ + --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \ + --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \ + --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \ + --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \ + --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \ + --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \ + --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \ + --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \ + --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \ + --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \ + --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \ + --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \ + --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \ + --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \ + --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \ + --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \ + --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \ + --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \ + --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \ + --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \ + --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \ + --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \ + --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \ + --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \ + --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \ + --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \ + --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \ + --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \ + --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \ + --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \ + --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \ + --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \ + --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \ + --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \ + --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \ + --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \ + --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \ + --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \ + --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \ + --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \ + --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \ + --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \ + --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \ + --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \ + --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ + --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 + # via cwl-idp-account-unification fastapi==0.140.13 \ --hash=sha256:500172a08cf1459901f90b05c37d93060dada3b573fec8f0862445db52ba6b4b \ --hash=sha256:8b017110e1e9f30a95e8bdb8f71fbe2f0fe3af5717109e5b14f9e069df54f6d4 - # via -r requirements.lock (top-level runtime pin) + # via cwl-idp-account-unification h11==0.16.0 \ --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 - # via httpcore + # via + # httpcore + # uvicorn httpcore==1.0.9 \ --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 @@ -53,18 +183,22 @@ httpcore==1.0.9 \ httpx==0.28.1 \ --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad - # via -r requirements.lock (top-level runtime pin) + # via cwl-idp-account-unification idna==3.18 \ --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 # via # anyio # httpx +pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi pydantic==2.13.4 \ --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \ --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6 # via - # -r requirements.lock (top-level runtime pin) + # cwl-idp-account-unification # fastapi pydantic-core==2.46.4 \ --hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \ @@ -74,35 +208,26 @@ pydantic-core==2.46.4 \ --hash=sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e \ --hash=sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b \ --hash=sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594 \ - --hash=sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29 \ --hash=sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2 \ --hash=sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c \ --hash=sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d \ --hash=sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398 \ --hash=sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d \ --hash=sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3 \ - --hash=sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f \ --hash=sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb \ --hash=sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7 \ --hash=sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5 \ - --hash=sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9 \ --hash=sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462 \ --hash=sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4 \ --hash=sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b \ - --hash=sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d \ --hash=sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df \ --hash=sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2 \ - --hash=sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0 \ --hash=sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519 \ - --hash=sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd \ --hash=sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7 \ - --hash=sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac \ --hash=sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6 \ --hash=sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565 \ --hash=sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898 \ --hash=sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb \ - --hash=sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928 \ - --hash=sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6 \ --hash=sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3 \ --hash=sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a \ --hash=sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596 \ @@ -124,17 +249,13 @@ pydantic-core==2.46.4 \ --hash=sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac \ --hash=sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7 \ --hash=sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d \ - --hash=sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf \ --hash=sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4 \ --hash=sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc \ - --hash=sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15 \ --hash=sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3 \ --hash=sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b \ --hash=sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914 \ --hash=sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04 \ - --hash=sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c \ --hash=sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b \ - --hash=sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9 \ --hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce \ --hash=sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4 \ --hash=sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a \ @@ -143,19 +264,14 @@ pydantic-core==2.46.4 \ --hash=sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894 \ --hash=sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9 \ --hash=sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76 \ - --hash=sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201 \ --hash=sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb \ --hash=sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109 \ - --hash=sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4 \ --hash=sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848 \ --hash=sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526 \ --hash=sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0 \ - --hash=sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01 \ --hash=sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458 \ --hash=sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e \ - --hash=sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba \ --hash=sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a \ - --hash=sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39 \ --hash=sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c \ --hash=sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000 \ --hash=sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b \ @@ -166,65 +282,39 @@ pydantic-core==2.46.4 \ --hash=sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9 \ --hash=sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30 \ --hash=sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983 \ - --hash=sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1 \ - --hash=sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76 \ - --hash=sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5 \ - --hash=sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4 \ --hash=sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7 \ --hash=sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c \ - --hash=sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066 \ --hash=sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3 \ --hash=sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02 \ --hash=sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89 \ - --hash=sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50 \ --hash=sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76 \ - --hash=sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49 \ - --hash=sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b \ - --hash=sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d \ --hash=sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7 \ --hash=sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4 \ --hash=sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c \ --hash=sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e \ - --hash=sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff \ - --hash=sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae + --hash=sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff # via pydantic pyyaml==6.0.3 \ --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ - --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ - --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ - --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ - --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ - --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ - --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ - --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ - --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ - --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ - --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ - --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ - --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ - --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ - --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ - --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ - --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ @@ -238,31 +328,22 @@ pyyaml==6.0.3 \ --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ - --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ - --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ - --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ - --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ - --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ - --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ - --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ - --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ - --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 - # via -r requirements.lock (top-level runtime pin) + # via cwl-idp-account-unification starlette==1.3.1 \ --hash=sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0 \ --hash=sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6 @@ -271,9 +352,11 @@ typing-extensions==4.16.0 \ --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 # via + # anyio # fastapi # pydantic # pydantic-core + # starlette # typing-inspection typing-inspection==0.4.2 \ --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ @@ -281,3 +364,7 @@ typing-inspection==0.4.2 \ # via # fastapi # pydantic +uvicorn==0.52.0 \ + --hash=sha256:3d887809810b89ed33501bcf0a9aba469b06ecd608158efce04bd6b48d8c9b08 \ + --hash=sha256:ca8876ad6c1983f394157c168b39d52f6dd56dabf5602fa0982751cffc2293ae + # via cwl-idp-account-unification diff --git a/services/account_unification/tests/test_federation.py b/services/account_unification/tests/test_federation.py index aa36c14..d97b85e 100644 --- a/services/account_unification/tests/test_federation.py +++ b/services/account_unification/tests/test_federation.py @@ -28,7 +28,13 @@ def _employer_adfs_registration() -> IdentityProviderRegistration: trust_email=True, provider_config={ "entityId": "https://idp.example/realms/cwl", + "idpEntityId": "http://sts.example/adfs/services/trust", "singleSignOnServiceUrl": "https://sts.example/adfs/ls/", + "metadataDescriptorUrl": ( + "https://sts.example/FederationMetadata/2007-06/" + "FederationMetadata.xml" + ), + "useMetadataDescriptorUrl": "true", "clientSecret": "federation-secret", "unclassifiedValue": "must-not-leak", "validateSignature": "true", diff --git a/services/account_unification/tests/test_federation_preflight.py b/services/account_unification/tests/test_federation_preflight.py new file mode 100644 index 0000000..ac23d2f --- /dev/null +++ b/services/account_unification/tests/test_federation_preflight.py @@ -0,0 +1,430 @@ +"""Federation preflight validation, SAML policy, and side-effect tests.""" +from __future__ import annotations + +from copy import deepcopy + +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app.federation import ( + FEDERATION_PROVIDER_NAMESPACE, + FederationService, + IdentityProviderRegistration, +) +from app.kv_store import InMemoryKvStore +from app.main import create_app + +_VALID_SIGNING_CERTIFICATE = ( + "MIH2MIGpoAMCAQICAQEwBQYDK2VwMBwxGjAYBgNVBAMMEUtleXZlcnNlIFRlc3Qg" + "SWRQMB4XDTI2MDEwMTAwMDAwMFoXDTM2MDEwMTAwMDAwMFowHDEaMBgGA1UE" + "AwwRS2V5dmVyc2UgVGVzdCBJZFAwKjAFBgMrZXADIQB5tVYuj+ZU+UB4sRLo" + "qYunkB+FOuaVvtfg45ELrQSWZKMQMA4wDAYDVR0TAQH/BAIwADAFBgMrZXAD" + "QQBETl77qTx6FIw1ZEqHCxT1BpLpPf/dJwxF1+vXFGiHUC6HEWWqPhXcWEj9" + "nlg8E6KnnpjzSmaVOL2dtTZMoTkG" +) +_NEXT_SIGNING_CERTIFICATE = ( + "MIIBADCBs6ADAgECAgECMAUGAytlcDAhMR8wHQYDVQQDDBZLZXl2ZXJzZSBOZXh0" + "IFRlc3QgSWRQMB4XDTI2MDEwMTAwMDAwMFoXDTM2MDEwMTAwMDAwMFowITEf" + "MB0GA1UEAwwWS2V5dmVyc2UgTmV4dCBUZXN0IElkUDAqMAUGAytlcAMhAOfx" + "YqEL7FWa/qGV5NzoS2lWjV0ssJY+tEbAaF4rF/LwoxAwDjAMBgNVHRMBAf8E" + "AjAAMAUGAytlcANBAPBvYJMwDJ1k5Jb+BWzYUVirHSILZOjNzvFyOcR4PfMj" + "DJfk2ivJ/fat8qsQNXspyeplpqOinqXxB/mCruM2Rw0=" +) + + +def _adfs_body() -> dict: + """Return a valid employer ADFS desired-state request body.""" + return { + "provider_alias": "employer-adfs", + "display_name": "Employer ADFS", + "provider_id": "saml", + "enabled": True, + "trust_email": True, + "provider_config": { + "entityId": "https://idp.example/realms/cwl", + "idpEntityId": "http://sts.example/adfs/services/trust", + "singleSignOnServiceUrl": "https://sts.example/adfs/ls/", + "metadataDescriptorUrl": ( + "https://sts.example/FederationMetadata/2007-06/" + "FederationMetadata.xml" + ), + "useMetadataDescriptorUrl": "true", + "validateSignature": "true", + "clientSecret": "federation-secret", + "unclassifiedValue": "must-not-leak", + }, + } + + +def _build_app(store, api, operator_token: str): + """Return a test app with explicit federation dependencies.""" + app = create_app(wire=False) + app.state.federation_service = FederationService(store, api) + app.state.operator_api_token = operator_token + return app + + +def _post_preflight(body: dict, store, api, auth_header, operator_token): + """Post one preflight body through the authenticated HTTP boundary.""" + app = _build_app(store, api, operator_token) + with TestClient(app, headers=auth_header) as client: + return client.post( + "/federation/identity-providers:validate", + json=body, + ) + + +def _assert_no_side_effects(store, api) -> None: + """Assert that preflight did not persist or call the Keycloak mock.""" + assert store.get_all(FEDERATION_PROVIDER_NAMESPACE) == {} + assert api.calls == [] + + +def test_preflight_validates_without_side_effects_and_redacts( + api, auth_header, operator_token +) -> None: + """A valid preflight is redacted and never touches storage or Keycloak.""" + store = InMemoryKvStore() + + response = _post_preflight( + _adfs_body(), store, api, auth_header, operator_token + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["ready_to_apply"] is True + config = payload["registration"]["provider_config"] + assert config["clientSecret"] == "" + assert config["unclassifiedValue"] == "" + assert config["idpEntityId"] == "http://sts.example/adfs/services/trust" + assert config["singleSignOnServiceUrl"] == "https://sts.example/adfs/ls/" + assert "federation-secret" not in response.text + assert "must-not-leak" not in response.text + _assert_no_side_effects(store, api) + + +def test_preflight_rejects_unresolved_templates_without_side_effects( + api, auth_header, operator_token +) -> None: + """Unrendered deployment templates fail before storage or network calls.""" + store = InMemoryKvStore() + body = _adfs_body() + body["provider_config"]["metadataDescriptorUrl"] = ( + "{{employer_adfs_metadata_url}}" + ) + + response = _post_preflight(body, store, api, auth_header, operator_token) + + assert response.status_code == 400 + assert response.json()["detail"] == ( + "provider_config contains unresolved template placeholders" + ) + assert "employer_adfs_metadata_url" not in response.text + _assert_no_side_effects(store, api) + + +@pytest.mark.parametrize( + "removed_key", + ["entityId", "idpEntityId", "singleSignOnServiceUrl"], +) +def test_saml_preflight_requires_core_identifiers_and_endpoint( + removed_key: str, api, auth_header, operator_token +) -> None: + """SAML preflight requires both entity identifiers and the SSO endpoint.""" + store = InMemoryKvStore() + body = _adfs_body() + body["provider_config"].pop(removed_key) + + response = _post_preflight(body, store, api, auth_header, operator_token) + + assert response.status_code == 400 + assert removed_key in response.json()["detail"] + _assert_no_side_effects(store, api) + + +@pytest.mark.parametrize( + ("field_name", "unsafe_value"), + [ + ("entityId", "relative-realm-id"), + ("idpEntityId", "https://operator:secret@sts.example/adfs/trust"), + ("singleSignOnServiceUrl", "ftp://sts.example/adfs/ls/"), + ("singleSignOnServiceUrl", "http://sts.example/adfs/ls/"), + ("singleSignOnServiceUrl", " https://sts.example/adfs/ls/"), + ( + "metadataDescriptorUrl", + "http://sts.example/FederationMetadata.xml", + ), + ( + "metadataDescriptorUrl", + "https://operator:secret@sts.example/FederationMetadata.xml", + ), + ( + "metadataDescriptorUrl", + "https://sts.example/FederationMetadata.xml#certificate", + ), + ( + "metadataDescriptorUrl", + "https://sts.example/FederationMetadata.xml\nInjected: value", + ), + ("metadataDescriptorUrl", "https://[broken"), + ], +) +def test_saml_preflight_rejects_unsafe_uris_and_urls( + field_name: str, + unsafe_value: str, + api, + auth_header, + operator_token, +) -> None: + """SAML identifiers and URLs reject ambiguous or unsafe material.""" + store = InMemoryKvStore() + body = _adfs_body() + body["provider_config"][field_name] = unsafe_value + + response = _post_preflight(body, store, api, auth_header, operator_token) + + assert response.status_code == 400 + assert field_name in response.json()["detail"] + assert unsafe_value not in response.text + _assert_no_side_effects(store, api) + + +def test_saml_preflight_rejects_oversized_entity_identifier( + api, auth_header, operator_token +) -> None: + """SAML entity identifiers retain the standard 1,024-character ceiling.""" + store = InMemoryKvStore() + body = _adfs_body() + body["provider_config"]["entityId"] = "urn:keyverse:" + ("a" * 1_013) + + response = _post_preflight(body, store, api, auth_header, operator_token) + + assert response.status_code == 400 + assert "entityId" in response.json()["detail"] + _assert_no_side_effects(store, api) + + +def test_saml_preflight_accepts_urn_entity_identifiers( + api, auth_header, operator_token +) -> None: + """SAML entity identifiers remain interoperable with absolute URN forms.""" + store = InMemoryKvStore() + body = _adfs_body() + body["provider_config"]["entityId"] = "urn:keyverse:service-provider:cwl" + body["provider_config"]["idpEntityId"] = "urn:partner:identity-provider" + + response = _post_preflight(body, store, api, auth_header, operator_token) + + assert response.status_code == 200 + config = response.json()["registration"]["provider_config"] + assert config["entityId"] == "urn:keyverse:service-provider:cwl" + assert config["idpEntityId"] == "urn:partner:identity-provider" + _assert_no_side_effects(store, api) + + +@pytest.mark.parametrize( + ("field_name", "field_value"), + [ + ("validateSignature", "false"), + ("validateSignature", "yes"), + ("useMetadataDescriptorUrl", "sometimes"), + ], +) +def test_saml_preflight_rejects_insecure_or_malformed_booleans( + field_name: str, + field_value: str, + api, + auth_header, + operator_token, +) -> None: + """SAML security booleans accept only true/false and require validation.""" + store = InMemoryKvStore() + body = _adfs_body() + body["provider_config"][field_name] = field_value + + response = _post_preflight(body, store, api, auth_header, operator_token) + + assert response.status_code == 400 + assert field_name in response.json()["detail"] + assert field_value not in response.text + _assert_no_side_effects(store, api) + + +@pytest.mark.parametrize( + "removed_key", + ["validateSignature", "useMetadataDescriptorUrl"], +) +def test_saml_preflight_requires_explicit_security_mode( + removed_key: str, api, auth_header, operator_token +) -> None: + """SAML signature validation and certificate-source mode are explicit.""" + store = InMemoryKvStore() + body = _adfs_body() + body["provider_config"].pop(removed_key) + + response = _post_preflight(body, store, api, auth_header, operator_token) + + assert response.status_code == 400 + assert removed_key in response.json()["detail"] + _assert_no_side_effects(store, api) + + +def test_saml_preflight_requires_metadata_url_when_enabled( + api, auth_header, operator_token +) -> None: + """Metadata-backed SAML validation requires a usable descriptor URL.""" + store = InMemoryKvStore() + body = _adfs_body() + body["provider_config"].pop("metadataDescriptorUrl") + + response = _post_preflight(body, store, api, auth_header, operator_token) + + assert response.status_code == 400 + assert "metadataDescriptorUrl" in response.json()["detail"] + _assert_no_side_effects(store, api) + + +def test_saml_preflight_validates_optional_manual_certificate_in_metadata_mode( + api, auth_header, operator_token +) -> None: + """Metadata mode still rejects malformed optional manual trust material.""" + store = InMemoryKvStore() + body = _adfs_body() + body["provider_config"]["signingCertificate"] = "MIIC-test-certificate" + + response = _post_preflight(body, store, api, auth_header, operator_token) + + assert response.status_code == 400 + assert response.json()["detail"].startswith("signingCertificate ") + assert "MIIC-test-certificate" not in response.text + _assert_no_side_effects(store, api) + + +def test_saml_preflight_requires_manual_certificate_when_metadata_is_disabled( + api, auth_header, operator_token +) -> None: + """Manual SAML trust requires an explicit signing certificate.""" + store = InMemoryKvStore() + body = _adfs_body() + body["provider_config"]["useMetadataDescriptorUrl"] = "false" + body["provider_config"].pop("metadataDescriptorUrl") + + response = _post_preflight(body, store, api, auth_header, operator_token) + + assert response.status_code == 400 + assert "signingCertificate" in response.json()["detail"] + _assert_no_side_effects(store, api) + + +@pytest.mark.parametrize( + "signing_certificates", + [ + _VALID_SIGNING_CERTIFICATE, + f"{_VALID_SIGNING_CERTIFICATE},{_NEXT_SIGNING_CERTIFICATE}", + ], +) +def test_saml_preflight_accepts_valid_manual_signing_certificates( + signing_certificates: str, + api, + auth_header, + operator_token, +) -> None: + """Manual trust accepts one certificate or an active rollover pair.""" + store = InMemoryKvStore() + body = _adfs_body() + body["provider_config"]["useMetadataDescriptorUrl"] = "false" + body["provider_config"].pop("metadataDescriptorUrl") + body["provider_config"]["signingCertificate"] = signing_certificates + + response = _post_preflight(body, store, api, auth_header, operator_token) + + assert response.status_code == 200 + config = response.json()["registration"]["provider_config"] + assert config["signingCertificate"] == "" + for certificate_body in signing_certificates.split(","): + certificate_body = certificate_body.strip() + if certificate_body: + assert certificate_body not in response.text + _assert_no_side_effects(store, api) + + +@pytest.mark.parametrize( + "signing_certificates", + [ + "MIIC-test-certificate", + "bm90LWFuLXg1MDktY2VydGlmaWNhdGU=", + f"-----BEGIN CERTIFICATE-----{_VALID_SIGNING_CERTIFICATE}" + "-----END CERTIFICATE-----", + f"{_VALID_SIGNING_CERTIFICATE},", + ], +) +def test_saml_preflight_rejects_invalid_manual_signing_certificates( + signing_certificates: str, + api, + auth_header, + operator_token, +) -> None: + """Malformed Base64, non-X.509, PEM, and empty list entries fail closed.""" + store = InMemoryKvStore() + body = _adfs_body() + body["provider_config"]["useMetadataDescriptorUrl"] = "false" + body["provider_config"].pop("metadataDescriptorUrl") + body["provider_config"]["signingCertificate"] = signing_certificates + + response = _post_preflight(body, store, api, auth_header, operator_token) + + assert response.status_code == 400 + assert response.json()["detail"].startswith("signingCertificate ") + assert signing_certificates not in response.text + _assert_no_side_effects(store, api) + + +def test_put_rejects_invalid_saml_before_persisting_or_calling_keycloak( + api, +) -> None: + """The existing PUT boundary shares preflight validation before mutation.""" + store = InMemoryKvStore() + federation = FederationService(store, api) + body = _adfs_body() + body["provider_config"]["validateSignature"] = "false" + registration = IdentityProviderRegistration.model_validate(body) + + with pytest.raises(HTTPException) as error: + federation.put_registration("employer-adfs", registration) + + assert error.value.status_code == 400 + _assert_no_side_effects(store, api) + + +def test_non_saml_preflight_remains_provider_neutral( + api, auth_header, operator_token +) -> None: + """OIDC registrations retain generic validation in this focused slice.""" + store = InMemoryKvStore() + body = deepcopy(_adfs_body()) + body.update( + { + "provider_alias": "partner-oidc", + "display_name": "Partner OIDC", + "provider_id": "oidc", + "trust_email": False, + "provider_config": { + "issuer": "https://login.partner.example", + "clientId": "keyverse", + "clientSecret": "oidc-secret", + }, + } + ) + + response = _post_preflight(body, store, api, auth_header, operator_token) + + assert response.status_code == 200 + assert response.json()["registration"]["provider_alias"] == "partner-oidc" + assert response.json()["registration"]["provider_config"]["issuer"] == ( + "https://login.partner.example" + ) + assert response.json()["registration"]["provider_config"]["clientSecret"] == ( + "" + ) + _assert_no_side_effects(store, api) diff --git a/services/account_unification/tests/test_federation_url_hardening.py b/services/account_unification/tests/test_federation_url_hardening.py new file mode 100644 index 0000000..2ce22dd --- /dev/null +++ b/services/account_unification/tests/test_federation_url_hardening.py @@ -0,0 +1,87 @@ +"""Additional SAML URL hardening tests for encoded and ambiguous input.""" +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from app.federation import FEDERATION_PROVIDER_NAMESPACE, FederationService +from app.kv_store import InMemoryKvStore +from app.main import create_app + + +def _registration_body(metadata_url: str) -> dict: + """Return one otherwise-valid metadata-backed SAML registration.""" + return { + "provider_alias": "employer-adfs", + "display_name": "Employer ADFS", + "provider_id": "saml", + "enabled": True, + "trust_email": True, + "provider_config": { + "entityId": "urn:keyverse:service-provider:cwl", + "idpEntityId": "urn:partner:identity-provider", + "singleSignOnServiceUrl": "https://sts.example/adfs/ls/", + "metadataDescriptorUrl": metadata_url, + "useMetadataDescriptorUrl": "true", + "validateSignature": "true", + }, + } + + +def _preflight(metadata_url, api, auth_header, operator_token): + """Post one metadata URL and return the response plus its fresh store.""" + store = InMemoryKvStore() + app = create_app(wire=False) + app.state.federation_service = FederationService(store, api) + app.state.operator_api_token = operator_token + with TestClient(app, headers=auth_header) as client: + response = client.post( + "/federation/identity-providers:validate", + json=_registration_body(metadata_url), + ) + return response, store + + +@pytest.mark.parametrize( + "metadata_url", + [ + "https://sts.example/Federation Metadata.xml", + "https://sts.example\\metadata.example/FederationMetadata.xml", + "https://sts.example/FederationMetadata.xml%0d%0aInjected", + "https://sts.example/FederationMetadata.xml\u00a0", + "https://sts.example/FederationMetadata.xml\x00tail", + "https://sts.example:65536/FederationMetadata.xml", + ], +) +def test_preflight_rejects_whitespace_backslash_and_encoded_controls( + metadata_url: str, + api, + auth_header, + operator_token, +) -> None: + """Ambiguous URL material fails closed without persistence or network I/O.""" + response, store = _preflight( + metadata_url, api, auth_header, operator_token + ) + + assert response.status_code == 400 + assert response.json()["detail"].startswith("metadataDescriptorUrl ") + assert metadata_url not in response.text + assert store.get_all(FEDERATION_PROVIDER_NAMESPACE) == {} + assert api.calls == [] + + +def test_preflight_allows_non_control_percent_encoding( + api, auth_header, operator_token +) -> None: + """Ordinary percent encoding remains interoperable for metadata paths.""" + response, store = _preflight( + "https://sts.example/Federation%20Metadata.xml", + api, + auth_header, + operator_token, + ) + + assert response.status_code == 200 + assert store.get_all(FEDERATION_PROVIDER_NAMESPACE) == {} + assert api.calls == [] diff --git a/services/account_unification/tests/test_full_coverage_core.py b/services/account_unification/tests/test_full_coverage_core.py new file mode 100644 index 0000000..6e42b06 --- /dev/null +++ b/services/account_unification/tests/test_full_coverage_core.py @@ -0,0 +1,510 @@ +"""Coverage regressions for core API, storage, config, and lifecycle branches.""" +from __future__ import annotations + +import asyncio +import runpy +import sqlite3 +import urllib.request +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from app import api as api_routes +from app import healthcheck, main +from app.audit import AuditEvent, AuditLogger +from app.bootstrap import ( + BOOTSTRAP_ENV_VAR, + BootstrapDescriptor, + UnsupportedConfigBackendError, + load_bootstrap_descriptor, + open_config_store, +) +from app.config import ( + KEY_REGISTRATION_CLIENT_ID, + _as_bool, + _registration_settings, +) +from app.errors import ( + InactiveAccountError, + NoMatchError, + SameUserError, + UnverifiedEmailMergeError, + UserNotFoundError, +) +from app.identifiers import InvalidIdentifierError, validate_path_segment +from app.kv_store import InMemoryKvStore, SqliteKvStore +from app.models import MergeRequest +from app.user_locks import ( + SqliteUserOperationLocks, + UserOperationLockTimeout, + _normalise_user_ids, +) + + +class _RaisingLookupService: + """Raise a configured lookup error from both account read methods.""" + + def get_account(self, user_id: str): + """Raise the configured missing-user error.""" + raise UserNotFoundError(user_id) + + def list_identities(self, user_id: str): + """Raise the configured missing-user error.""" + raise UserNotFoundError(user_id) + + +class _RaisingMergeService: + """Raise one configured merge-policy exception.""" + + def __init__(self, error: Exception) -> None: + """Store the exception raised by ``merge_accounts``.""" + self.error = error + + def merge_accounts(self, body: MergeRequest): + """Raise the configured exception without mutating state.""" + raise self.error + + +class _EmptyAudit: + """Return no events for every requested audit identifier.""" + + def events_for(self, audit_id: str) -> list[AuditEvent]: + """Return an empty audit trail.""" + return [] + + +class _CloseTrackingSink: + """Minimal sink proving that ``AuditLogger.close`` delegates.""" + + def __init__(self) -> None: + """Create an open sink.""" + self.closed = False + + def record(self, event: AuditEvent) -> None: + """Accept an event without persistence.""" + + def events_for(self, audit_id: str) -> list[AuditEvent]: + """Return no events.""" + return [] + + def close(self) -> None: + """Record resource closure.""" + self.closed = True + + +class _StaticResponse: + """Context-managed stdlib response for module-entrypoint coverage.""" + + def __enter__(self): + """Return this response to the healthcheck context manager.""" + return self + + def __exit__(self, exc_type, exc, traceback) -> bool: + """Propagate any exception from the context body.""" + return False + + def read(self) -> bytes: + """Return a healthy JSON document.""" + return b'{"status":"ok"}' + + +class _StaticOpener: + """Return one deterministic response from ``open``.""" + + def __init__(self) -> None: + """Create an opener with no prior call.""" + self.call: tuple[str, int] | None = None + + def open(self, url: str, timeout: int): + """Record the URL and return a healthy response.""" + self.call = (url, timeout) + return _StaticResponse() + + +class _Closeable: + """Track whether application lifecycle cleanup called ``close``.""" + + def __init__(self) -> None: + """Create an open resource.""" + self.closed = False + + def close(self) -> None: + """Record closure.""" + self.closed = True + + +class _NonLockedConnection: + """Raise a non-contention SQLite error from ``BEGIN IMMEDIATE``.""" + + in_transaction = False + + def execute(self, statement: str, parameters=()): + """Raise an operational error that must not be translated.""" + raise sqlite3.OperationalError("disk I/O error") + + def close(self) -> None: + """Release no-op fake resources.""" + + +def test_read_routes_translate_missing_users() -> None: + """Both account read routes translate a missing user to HTTP 404.""" + service = _RaisingLookupService() + + with pytest.raises(HTTPException) as account_error: + api_routes.get_user("missing", service=service) + with pytest.raises(HTTPException) as identity_error: + api_routes.list_identities("missing", service=service) + + assert account_error.value.status_code == 404 + assert identity_error.value.status_code == 404 + + +@pytest.mark.parametrize( + ("error", "status_code"), + [ + (UserNotFoundError("missing"), 404), + (SameUserError("same"), 400), + (UnverifiedEmailMergeError("unverified"), 422), + (NoMatchError("no match"), 409), + (InactiveAccountError("inactive"), 409), + (UserOperationLockTimeout("busy"), 503), + ], +) +def test_merge_route_translates_every_domain_error( + error: Exception, status_code: int +) -> None: + """The merge route preserves its documented HTTP error mapping.""" + body = MergeRequest( + survivor_user_id="survivor", + duplicate_user_id="duplicate", + actor="coverage-test", + ) + + with pytest.raises(HTTPException) as translated: + api_routes.merge_accounts( + body, + service=_RaisingMergeService(error), + ) + + assert translated.value.status_code == status_code + + +def test_empty_merge_audit_is_not_found() -> None: + """An unknown audit correlation identifier returns HTTP 404.""" + with pytest.raises(HTTPException) as error: + api_routes.get_merge_audit("unknown", audit=_EmptyAudit()) + + assert error.value.status_code == 404 + + +def test_audit_logger_closes_its_sink() -> None: + """Logger lifecycle cleanup delegates to the configured sink.""" + sink = _CloseTrackingSink() + + AuditLogger(sink).close() + + assert sink.closed is True + + +def test_bootstrap_requires_a_pointer(monkeypatch) -> None: + """Startup fails closed when no bootstrap path is configured.""" + monkeypatch.delenv(BOOTSTRAP_ENV_VAR, raising=False) + + with pytest.raises(RuntimeError, match=BOOTSTRAP_ENV_VAR): + load_bootstrap_descriptor() + + +def test_sqlite_bootstrap_requires_database_path() -> None: + """The SQLite backend refuses an omitted database path.""" + descriptor = BootstrapDescriptor( + backend="sqlite", + namespace="account_unification", + ) + + with pytest.raises(RuntimeError, match="sqlite backend requires"): + open_config_store(descriptor) + + +def test_unknown_bootstrap_backend_is_explicitly_unsupported() -> None: + """Standalone images reject a backend with no packaged adapter.""" + descriptor = BootstrapDescriptor( + backend="postgres", + namespace="account_unification", + postgres_dsn_secret_ref="secret/postgres", + ) + + with pytest.raises(UnsupportedConfigBackendError, match="postgres"): + open_config_store(descriptor) + + +def test_config_boolean_rejects_ambiguous_text() -> None: + """Boolean configuration never guesses at ambiguous input.""" + with pytest.raises(RuntimeError, match="must be a boolean"): + _as_bool("perhaps", False, entry_key="feature_toggle") + + +def test_registration_settings_reject_ambiguous_client_id() -> None: + """Registration client identifiers cannot contain whitespace.""" + store = InMemoryKvStore( + { + "runtime": { + KEY_REGISTRATION_CLIENT_ID: "client with spaces", + } + } + ) + + with pytest.raises(RuntimeError, match="bounded client ID"): + _registration_settings(store, "runtime", "registration-token") + + +def test_health_opener_helper_uses_bounded_timeout(monkeypatch) -> None: + """The helper delegates through the restricted opener with five seconds.""" + opener = _StaticOpener() + monkeypatch.setattr(healthcheck, "_build_http_only_opener", lambda: opener) + + response = healthcheck._open_health_url("https://health.example/ready") + + assert isinstance(response, _StaticResponse) + assert opener.call == ("https://health.example/ready", 5) + + +def test_healthcheck_module_entrypoint_exits_zero(monkeypatch) -> None: + """The executable module exits successfully for a healthy endpoint.""" + monkeypatch.setattr( + urllib.request.OpenerDirector, + "open", + lambda self, url, timeout: _StaticResponse(), + ) + + with pytest.raises(SystemExit) as exit_error: + runpy.run_path(str(Path(healthcheck.__file__)), run_name="__main__") + + assert exit_error.value.code == 0 + + +def test_identifier_rejects_oversized_segments() -> None: + """Opaque Admin API path segments retain the 255-character ceiling.""" + with pytest.raises(InvalidIdentifierError, match="too long"): + validate_path_segment("a" * 256, field_name="user_id") + + +def test_sqlite_store_close_releases_connection(tmp_path) -> None: + """Closing the durable KV store makes its SQLite connection unusable.""" + store = SqliteKvStore(str(tmp_path / "config.sqlite3")) + + store.close() + + with pytest.raises(sqlite3.ProgrammingError): + store.get("runtime", "missing") + + +def test_main_helpers_cover_memory_and_persistent_paths(tmp_path) -> None: + """Filesystem helpers cover in-memory and persistent database modes.""" + main._ensure_parent_directory(":memory:") + target = tmp_path / "nested" / "audit.sqlite3" + main._ensure_parent_directory(str(target)) + assert target.parent.is_dir() + + persistent, persistent_is_temporary = main._user_operation_lock_path( + str(target) + ) + temporary, temporary_is_temporary = main._user_operation_lock_path( + ":memory:" + ) + try: + assert persistent == f"{target}.user-operation-locks.sqlite3" + assert persistent_is_temporary is False + assert Path(temporary).is_file() + assert temporary_is_temporary is True + finally: + Path(temporary).unlink(missing_ok=True) + + +def test_build_service_wires_all_state(monkeypatch) -> None: + """Live dependency wiring publishes every service and product setting.""" + app = FastAPI() + store = _Closeable() + api = _Closeable() + audit = _Closeable() + locks = object() + unification = object() + federation = object() + wired_locks: object | None = None + + def build_unification( + _api: object, + _audit: object, + _config: object, + user_operation_locks: object, + ) -> object: + """Capture the lock manager assigned to the merge service.""" + nonlocal wired_locks + wired_locks = user_operation_locks + return unification + + descriptor = SimpleNamespace(namespace="runtime") + config = SimpleNamespace( + keycloak_server_url="https://keycloak.example", + keycloak_realm="cwl", + keycloak_client_id="service-client", + keycloak_client_secret="secret", + request_timeout_seconds=3.0, + audit_database_path=":memory:", + operator_api_token="operator", + registration_api_token="registration", + registration_client_id="naruon-web", + registration_redirect_uri="https://naruon.example/auth/callback", + registration_action_lifespan_seconds=900, + ) + lock_path = str(Path.cwd() / "coverage-lock.sqlite3") + + monkeypatch.setattr(main, "load_bootstrap_descriptor", lambda: descriptor) + monkeypatch.setattr(main, "open_config_store", lambda current: store) + monkeypatch.setattr( + main, + "load_service_config", + lambda current_store, namespace: config, + ) + monkeypatch.setattr(main, "_ensure_parent_directory", lambda path: None) + monkeypatch.setattr(main, "ProductHttpAdminApi", lambda **kwargs: api) + monkeypatch.setattr(main, "SqliteAuditSink", lambda path: object()) + monkeypatch.setattr(main, "AuditLogger", lambda sink: audit) + monkeypatch.setattr( + main, + "_user_operation_lock_path", + lambda path: (lock_path, True), + ) + monkeypatch.setattr(main, "SqliteUserOperationLocks", lambda path: locks) + monkeypatch.setattr( + main, + "UnificationService", + build_unification, + ) + monkeypatch.setattr( + main, + "FederationService", + lambda *args: federation, + ) + + main.build_service(app) + + assert app.state.config_store is store + assert app.state.unification_service is unification + assert app.state.audit_logger is audit + assert app.state.keycloak_api is api + assert app.state.user_operation_locks is locks + assert wired_locks is locks + assert app.state.federation_service is federation + assert app.state.operator_api_token == "operator" + assert app.state.registration_api_token == "registration" + assert app.state.ready is True + assert app.state.temporary_user_operation_lock_database is True + + +def test_close_and_temporary_database_helpers(tmp_path) -> None: + """Cleanup helpers handle callable, absent, persistent, and temporary state.""" + closeable = _Closeable() + main._close_resource(closeable) + main._close_resource(object()) + assert closeable.closed is True + + app = FastAPI() + app.state.temporary_user_operation_lock_database = False + main._remove_temporary_lock_database(app) + + lock_path = tmp_path / "temporary-lock.sqlite3" + lock_path.write_text("lock", encoding="utf-8") + app.state.temporary_user_operation_lock_database = True + app.state.user_operation_lock_database_path = str(lock_path) + main._remove_temporary_lock_database(app) + assert lock_path.exists() is False + + +def test_lifespan_cleans_resources_after_context_error(monkeypatch, tmp_path) -> None: + """Lifecycle cleanup runs even when request-serving context exits by error.""" + app = FastAPI() + api = _Closeable() + audit = _Closeable() + store = _Closeable() + lock_path = tmp_path / "temporary-lock.sqlite3" + lock_path.write_text("lock", encoding="utf-8") + + def wire(current_app: FastAPI) -> None: + """Publish deterministic resources for the lifecycle test.""" + current_app.state.keycloak_api = api + current_app.state.audit_logger = audit + current_app.state.config_store = store + current_app.state.user_operation_lock_database_path = str(lock_path) + current_app.state.temporary_user_operation_lock_database = True + current_app.state.ready = True + + monkeypatch.setattr(main, "build_service", wire) + + async def exercise() -> None: + """Enter the lifespan and fail inside its yielded context.""" + with pytest.raises(RuntimeError, match="request failure"): + async with main.lifespan(app): + assert app.state.ready is True + raise RuntimeError("request failure") + + asyncio.run(exercise()) + + assert app.state.ready is False + assert api.closed is True + assert audit.closed is True + assert store.closed is True + assert lock_path.exists() is False + + +def test_healthz_reports_starting_state() -> None: + """The readiness endpoint exposes a starting state before live wiring.""" + app = main.create_app(wire=False) + app.state.ready = False + + with TestClient(app) as client: + response = client.get("/healthz") + + assert response.status_code == 200 + assert response.json()["status"] == "starting" + + +@pytest.mark.parametrize( + ("database_path", "timeout_seconds", "message"), + [ + ("", 1.0, "database_path is required"), + (":memory:", 0.0, "timeout_seconds must be positive"), + ], +) +def test_sqlite_user_locks_validate_constructor( + database_path: str, + timeout_seconds: float, + message: str, +) -> None: + """The cross-process lock manager refuses unusable constructor values.""" + with pytest.raises(ValueError, match=message): + SqliteUserOperationLocks( + database_path, + timeout_seconds=timeout_seconds, + ) + + +def test_user_id_normalisation_rejects_empty_members() -> None: + """User-operation locking requires only non-empty identifiers.""" + with pytest.raises(ValueError, match="non-empty"): + _normalise_user_ids(("valid", "")) + + +def test_sqlite_user_locks_preserve_non_contention_operational_errors( + monkeypatch, +) -> None: + """Only SQLite lock contention is translated to the retryable timeout.""" + manager = object.__new__(SqliteUserOperationLocks) + connection = _NonLockedConnection() + monkeypatch.setattr(manager, "_connect", lambda: connection) + + with pytest.raises(sqlite3.OperationalError, match="disk I/O"): + with manager.hold("user-1"): + pytest.fail("the lock body must not run") diff --git a/services/account_unification/tests/test_full_coverage_federation.py b/services/account_unification/tests/test_full_coverage_federation.py new file mode 100644 index 0000000..b55a86f --- /dev/null +++ b/services/account_unification/tests/test_full_coverage_federation.py @@ -0,0 +1,155 @@ +"""Coverage regressions for federation storage, validation, and route edges.""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app.federation import ( + FEDERATION_PROVIDER_NAMESPACE, + FederationService, + IdentityProviderRegistration, + _validate_provider_alias, + get_federation_service, +) +from app.kv_store import InMemoryKvStore +from app.main import create_app + + +def _oidc_registration(**updates) -> IdentityProviderRegistration: + """Return one valid provider-neutral OIDC desired-state record.""" + values = { + "provider_alias": "partner-oidc", + "display_name": "Partner OIDC", + "provider_id": "oidc", + "enabled": True, + "trust_email": False, + "provider_config": { + "issuer": "https://login.partner.example", + "clientId": "keyverse", + "clientSecret": "secret", + }, + } + values.update(updates) + return IdentityProviderRegistration.model_validate(values) + + +def test_delete_missing_and_unapplied_registration_paths(api) -> None: + """Delete reports missing state and skips Keycloak deletion when absent.""" + store = InMemoryKvStore() + federation = FederationService(store, api) + + with pytest.raises(HTTPException) as missing_error: + federation.delete_registration("partner-oidc") + + registration = _oidc_registration() + store.put( + FEDERATION_PROVIDER_NAMESPACE, + registration.provider_alias, + registration.model_dump_json(), + ) + federation.delete_registration(registration.provider_alias) + + assert missing_error.value.status_code == 404 + assert store.get( + FEDERATION_PROVIDER_NAMESPACE, + registration.provider_alias, + ) is None + assert not any( + call.startswith("delete_identity_provider:") for call in api.calls + ) + + +@pytest.mark.parametrize( + "provider_alias", + ["", "-leading", "trailing-", "a" * 64, "contains_underscore"], +) +def test_provider_alias_rejects_every_slug_boundary(provider_alias: str) -> None: + """Alias validation covers empty, edge, length, and alphabet failures.""" + with pytest.raises(HTTPException) as error: + _validate_provider_alias(provider_alias) + + assert error.value.status_code == 400 + + +@pytest.mark.parametrize( + "provider_config", + [ + {"": "value"}, + {"k" * 129: "value"}, + {"key": "v" * 16_385}, + ], +) +def test_provider_config_key_and_value_bounds_fail_before_persistence( + provider_config: dict[str, str], api +) -> None: + """Invalid configuration entries cannot enter the desired-state store.""" + store = InMemoryKvStore() + federation = FederationService(store, api) + registration = _oidc_registration(provider_config=provider_config) + + with pytest.raises(HTTPException) as error: + federation.put_registration("partner-oidc", registration) + + assert error.value.status_code == 400 + assert store.get_all(FEDERATION_PROVIDER_NAMESPACE) == {} + assert api.calls == [] + + +def test_http_url_requires_hostname(api) -> None: + """An HTTP entity or endpoint without an authority fails closed.""" + registration = IdentityProviderRegistration( + provider_alias="partner-saml", + display_name="Partner SAML", + provider_id="saml", + provider_config={ + "entityId": "urn:keyverse:sp", + "idpEntityId": "urn:partner:idp", + "singleSignOnServiceUrl": "https:/missing-authority", + "validateSignature": "true", + "useMetadataDescriptorUrl": "false", + "signingCertificate": "certificate", + }, + ) + federation = FederationService(InMemoryKvStore(), api) + + with pytest.raises(HTTPException) as error: + federation.validate_registration(registration) + + assert error.value.status_code == 400 + assert "singleSignOnServiceUrl" in error.value.detail + + +def test_federation_dependency_fails_closed_when_unwired() -> None: + """Federation routes return 503 instead of using an absent service.""" + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace())) + + with pytest.raises(HTTPException) as error: + get_federation_service(request) + + assert error.value.status_code == 503 + + +def test_http_apply_route_reconciles_stored_desired_state( + api, auth_header, operator_token +) -> None: + """The public operator route exposes the stored-state recovery action.""" + store = InMemoryKvStore() + registration = _oidc_registration() + store.put( + FEDERATION_PROVIDER_NAMESPACE, + registration.provider_alias, + registration.model_dump_json(), + ) + app = create_app(wire=False) + app.state.federation_service = FederationService(store, api) + app.state.operator_api_token = operator_token + + with TestClient(app, headers=auth_header) as client: + response = client.post("/federation/identity-providers:apply") + + assert response.status_code == 200 + assert response.json()[0]["applied_to_keycloak"] is True + assert registration.provider_alias in api.identity_providers diff --git a/services/account_unification/tests/test_full_coverage_http_clients.py b/services/account_unification/tests/test_full_coverage_http_clients.py new file mode 100644 index 0000000..5bc32f3 --- /dev/null +++ b/services/account_unification/tests/test_full_coverage_http_clients.py @@ -0,0 +1,327 @@ +"""Coverage regressions for core and product Keycloak HTTP adapters.""" +from __future__ import annotations + +from types import MethodType + +import httpx +import pytest + +from app.identifiers import InvalidIdentifierError +from app.keycloak_client import HttpAdminApi, _parse_user +from app.models import UserAccount +from app.product_keycloak_client import ProductHttpAdminApi + + +def _token_response(request: httpx.Request) -> httpx.Response | None: + """Return a deterministic bearer token for token endpoint requests.""" + if request.url.path.endswith("/protocol/openid-connect/token"): + return httpx.Response(200, json={"access_token": "coverage-token"}) + return None + + +def test_core_username_lookup_returns_none_without_exact_match() -> None: + """Username lookup ignores inexact Keycloak search results.""" + + def handler(request: httpx.Request) -> httpx.Response: + """Return one nonmatching user after authenticating.""" + token = _token_response(request) + if token is not None: + return token + return httpx.Response( + 200, + json=[{"id": "u1", "username": "different-user"}], + ) + + api = HttpAdminApi( + "https://keycloak.example", + "cwl", + "service-client", + "secret", + transport=httpx.MockTransport(handler), + ) + + assert api.find_user_by_username("missing-user") is None + api.close() + + +def test_core_create_user_falls_back_to_username_lookup() -> None: + """A create response without Location falls back to exact username lookup.""" + + def handler(request: httpx.Request) -> httpx.Response: + """Create without Location and return the user on lookup.""" + token = _token_response(request) + if token is not None: + return token + if request.method == "POST": + return httpx.Response(201) + return httpx.Response( + 200, + json=[{"id": "created-id", "username": "new-user"}], + ) + + api = HttpAdminApi( + "https://keycloak.example", + "cwl", + "service-client", + "secret", + transport=httpx.MockTransport(handler), + ) + + assert api.create_user( + UserAccount(user_id="", user_name="new-user") + ) == "created-id" + api.close() + + +def test_core_create_user_returns_empty_when_fallback_lookup_misses() -> None: + """A create response without Location or exact lookup returns an empty ID.""" + + def handler(request: httpx.Request) -> httpx.Response: + """Create without Location and return no lookup result.""" + token = _token_response(request) + if token is not None: + return token + if request.method == "POST": + return httpx.Response(201) + return httpx.Response(200, json=[]) + + api = HttpAdminApi( + "https://keycloak.example", + "cwl", + "service-client", + "secret", + transport=httpx.MockTransport(handler), + ) + + assert api.create_user( + UserAccount(user_id="", user_name="missing-user") + ) == "" + api.close() + + +def test_core_create_user_returns_location_identifier() -> None: + """A standard Keycloak Location header yields the created user ID.""" + + def handler(request: httpx.Request) -> httpx.Response: + """Authenticate and return a Location-bearing create response.""" + token = _token_response(request) + if token is not None: + return token + return httpx.Response( + 201, + headers={ + "Location": ( + "https://keycloak.example/admin/realms/cwl/users/location-id" + ) + }, + ) + + api = HttpAdminApi( + "https://keycloak.example", + "cwl", + "service-client", + "secret", + transport=httpx.MockTransport(handler), + ) + + assert api.create_user( + UserAccount(user_id="", user_name="new-user") + ) == "location-id" + api.close() + + +@pytest.mark.parametrize( + ("attribute_value", "expected"), + [ + ([], None), + ("survivor", "survivor"), + ({"unexpected": "shape"}, None), + ], +) +def test_core_user_attribute_handles_all_keycloak_shapes( + attribute_value, + expected: str | None, +) -> None: + """Attribute reads handle empty lists, bare strings, and invalid shapes.""" + api = object.__new__(HttpAdminApi) + api._realm = "cwl" + + def fake_get(self, path: str, params=None): + """Return the configured attribute representation.""" + return {"attributes": {"merged_into_user_id": attribute_value}} + + api._get = MethodType(fake_get, api) + + assert api.get_user_attribute( + "user-1", "merged_into_user_id" + ) == expected + + +def test_core_transport_helpers_cover_json_empty_and_mutating_responses() -> None: + """The base adapter's authenticated HTTP helpers cover every response path.""" + calls: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + """Return deterministic responses for every base transport helper.""" + calls.append(request) + token = _token_response(request) + if token is not None: + return token + if request.method == "GET": + return httpx.Response(200, json={"ok": True}) + if request.method == "POST" and request.url.path.endswith("/json"): + return httpx.Response(200, json={"created": True}) + return httpx.Response(204) + + api = HttpAdminApi( + "https://keycloak.example", + "cwl", + "service-client", + "secret", + transport=httpx.MockTransport(handler), + ) + + assert api._get("/admin/realms/cwl/users") == {"ok": True} + assert api._post("/admin/realms/cwl/json", {"value": 1}) == { + "created": True + } + assert api._post("/admin/realms/cwl/empty", {}) == {} + api._put("/admin/realms/cwl/users/u1", {"enabled": False}) + api._delete("/admin/realms/cwl/users/u1", body={"reason": "test"}) + api.close() + + assert {request.method for request in calls} >= { + "GET", + "POST", + "PUT", + "DELETE", + } + + +def test_parse_user_handles_empty_external_identifier_list() -> None: + """An empty Keycloak external-ID list becomes no domain external ID.""" + user = _parse_user( + { + "id": "u1", + "enabled": False, + "attributes": {"scim_external_id": []}, + } + ) + + assert user.state == "disabled" + assert user.external_id is None + + +def test_product_constructor_rejects_unsafe_token_realm() -> None: + """A dedicated token realm remains one safe opaque path segment.""" + with pytest.raises(InvalidIdentifierError): + ProductHttpAdminApi( + "https://keycloak.example", + "cwl", + "service-client", + "secret", + token_realm="../master", + ) + + +@pytest.mark.parametrize( + "path", + [ + "admin/realms/cwl/users", + "/admin/realms/cwl/users%2Fu1", + "/admin/realms/cwl//users", + "/admin/realms/cwl/users/line\nbreak", + "/admin/realms/other/users", + "/admin/realms/cwl/server-info", + ], +) +def test_product_guard_rejects_every_unsafe_path_class(path: str) -> None: + """The product transport accepts only reviewed Admin REST route shapes.""" + api = ProductHttpAdminApi( + "https://keycloak.example", + "cwl", + "service-client", + "secret", + transport=httpx.MockTransport( + lambda request: httpx.Response(500, request=request) + ), + ) + + with pytest.raises(InvalidIdentifierError): + api._guard_path(path) + + api.close() + + +def test_product_create_user_fallback_returns_found_and_missing_ids() -> None: + """The hardened create fallback validates found IDs and permits no result.""" + + def handler(request: httpx.Request) -> httpx.Response: + """Create without a Location header and return no transport lookup data.""" + token = _token_response(request) + if token is not None: + return token + if request.method == "POST": + return httpx.Response(201) + return httpx.Response(200, json=[]) + + api = ProductHttpAdminApi( + "https://keycloak.example", + "cwl", + "service-client", + "secret", + transport=httpx.MockTransport(handler), + ) + found = UserAccount(user_id="found-id", user_name="new-user") + + api.find_user_by_username = MethodType( + lambda self, username: found, + api, + ) + assert api.create_user( + UserAccount(user_id="", user_name="new-user") + ) == "found-id" + + api.find_user_by_username = MethodType( + lambda self, username: None, + api, + ) + assert api.create_user( + UserAccount(user_id="", user_name="missing-user") + ) == "" + api.close() + + +def test_product_identity_provider_handles_dict_non_dict_and_non_404() -> None: + """Identity-provider reads preserve dicts, ignore other JSON, and rethrow errors.""" + responses = iter( + [ + httpx.Response(200, json={"alias": "provider"}), + httpx.Response(200, json=[{"alias": "provider"}]), + httpx.Response(503), + ] + ) + + def handler(request: httpx.Request) -> httpx.Response: + """Authenticate once and then return the configured sequence.""" + token = _token_response(request) + if token is not None: + return token + response = next(responses) + response.request = request + return response + + api = ProductHttpAdminApi( + "https://keycloak.example", + "cwl", + "service-client", + "secret", + transport=httpx.MockTransport(handler), + ) + + assert api.get_identity_provider("provider") == {"alias": "provider"} + assert api.get_identity_provider("provider") is None + with pytest.raises(httpx.HTTPStatusError) as error: + api.get_identity_provider("provider") + assert error.value.response.status_code == 503 + api.close() diff --git a/services/account_unification/tests/test_full_coverage_registration_scim.py b/services/account_unification/tests/test_full_coverage_registration_scim.py new file mode 100644 index 0000000..3dfe4b3 --- /dev/null +++ b/services/account_unification/tests/test_full_coverage_registration_scim.py @@ -0,0 +1,332 @@ +"""Coverage regressions for passwordless registration and SCIM edge paths.""" +from __future__ import annotations + +from contextlib import contextmanager +from types import SimpleNamespace + +import httpx +import pytest +from fastapi import HTTPException + +from app import registration, scim +from app.models import UserAccount +from app.user_locks import ( + InMemoryUserOperationLocks, + UserOperationLockTimeout, +) + + +class _UnavailableApi: + """Expose only methods needed by direct registration error tests.""" + + def find_users_by_email(self, email: str) -> list[UserAccount]: + """Return no pre-existing accounts.""" + return [] + + def create_user(self, user: UserAccount) -> str: + """Return an empty identifier to model an unusable upstream response.""" + return "" + + +class _NonConflictCreateApi(_UnavailableApi): + """Raise a non-conflict Keycloak HTTP error during account creation.""" + + def create_user(self, user: UserAccount) -> str: + """Raise a service-unavailable response that must be preserved.""" + request = httpx.Request("POST", "https://keycloak.example/users") + response = httpx.Response(503, request=request) + raise httpx.HTTPStatusError( + "upstream unavailable", + request=request, + response=response, + ) + + +class _TimeoutLocks: + """Raise the shared user-mutation timeout on acquisition.""" + + @contextmanager + def hold(self, *user_ids: str): + """Fail before entering a mutation critical section.""" + raise UserOperationLockTimeout("busy") + yield + + +class _MinimalProvisioner: + """Provide deterministic users and record SCIM mutations.""" + + def __init__(self) -> None: + """Create one enabled user and an empty call log.""" + self.user = UserAccount( + user_id="user-1", + user_name="jane", + email="jane@example.com", + is_email_verified=True, + ) + self.calls: list[str] = [] + + def find_user_by_username(self, username: str) -> UserAccount | None: + """Return the known user only for its exact username.""" + return self.user if username == self.user.user_name else None + + def get_user(self, user_id: str) -> UserAccount: + """Return the known user or raise a KeyError.""" + if user_id != self.user.user_id: + raise KeyError(user_id) + return self.user + + def create_user(self, user: UserAccount) -> str: + """Record creation and return the stable user identifier.""" + self.calls.append("create") + self.user = user.model_copy(update={"user_id": "user-1"}) + return "user-1" + + def replace_user(self, user_id: str, user: UserAccount) -> None: + """Replace the stable user representation.""" + self.calls.append("replace") + self.user = user + + def deactivate_user(self, user_id: str) -> None: + """Disable the stable user representation.""" + self.calls.append("deactivate") + self.user = self.user.model_copy(update={"state": "disabled"}) + + def get_user_attribute(self, user_id: str, key: str) -> str | None: + """Return no merge tombstone.""" + return None + + +def _registration_request() -> SimpleNamespace: + """Return one valid direct-call registration request body.""" + return SimpleNamespace( + email_address="new.user@example.com", + first_name=None, + last_name=None, + ) + + +def _registration_http_request() -> SimpleNamespace: + """Return one request carrying complete enrollment state.""" + return SimpleNamespace( + app=SimpleNamespace( + state=SimpleNamespace( + registration_client_id="naruon-web", + registration_redirect_uri="https://naruon.example/callback", + registration_action_lifespan_seconds=900, + ) + ), + client=None, + ) + + +def test_registration_requires_a_bearer_header() -> None: + """A configured registration surface still rejects an absent header.""" + request = SimpleNamespace( + app=SimpleNamespace( + state=SimpleNamespace(registration_api_token="expected-token") + ) + ) + + with pytest.raises(HTTPException) as error: + registration.require_registration_token(request, authorization=None) + + assert error.value.status_code == 401 + assert error.value.headers == {"WWW-Authenticate": "Bearer"} + + +def test_registration_admin_api_dependency_fails_closed() -> None: + """Registration cannot proceed without a wired product Keycloak client.""" + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace())) + + with pytest.raises(HTTPException) as error: + registration.get_admin_api(request) + + assert error.value.status_code == 503 + + +def test_registration_client_key_handles_missing_peer() -> None: + """A request without peer metadata uses the bounded unknown-client bucket.""" + request = SimpleNamespace(client=None) + + assert registration._registration_client_key(request) == "unknown-client" + + +def test_registration_window_resets_after_expiry(monkeypatch) -> None: + """A fixed-window caller budget resets after the configured duration.""" + registration.reset_rate_limit_state() + times = iter([0.0, registration.REGISTRATION_RATE_LIMIT_WINDOW_SECONDS + 1.0]) + monkeypatch.setattr(registration.time, "monotonic", lambda: next(times)) + monkeypatch.setattr(registration, "REGISTRATION_RATE_LIMIT_MAX_ATTEMPTS", 1) + + registration._record_registration_attempt("caller") + registration._record_registration_attempt("caller") + + +def test_registration_name_normalization_covers_optional_and_invalid_paths() -> None: + """Display-name normalization handles absent, blank, and control input.""" + assert registration._validated_name(None) is None + assert registration._validated_name(" ") is None + assert registration._validated_name(" Jane ") == "Jane" + with pytest.raises(HTTPException) as error: + registration._validated_name("Jane\x00Doe") + assert error.value.status_code == 422 + + +def test_registration_reports_empty_upstream_identifier() -> None: + """An upstream create response without an ID becomes a bounded 502.""" + registration.reset_rate_limit_state() + + with pytest.raises(HTTPException) as error: + registration.register_account( + _registration_request(), + _registration_http_request(), + api=_UnavailableApi(), + ) + + assert error.value.status_code == 502 + assert error.value.detail == "account_creation_failed" + + +def test_registration_preserves_non_conflict_keycloak_errors() -> None: + """Only a Keycloak 409 is translated to an email conflict.""" + registration.reset_rate_limit_state() + + with pytest.raises(httpx.HTTPStatusError) as error: + registration.register_account( + _registration_request(), + _registration_http_request(), + api=_NonConflictCreateApi(), + ) + + assert error.value.response.status_code == 503 + + +def test_scim_dependencies_fail_closed_when_unwired() -> None: + """SCIM provisioner and shared-lock dependencies reject missing wiring.""" + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace())) + + with pytest.raises(HTTPException) as api_error: + scim.get_provisioner(request) + with pytest.raises(HTTPException) as lock_error: + scim.get_user_operation_locks(request) + + assert api_error.value.status_code == 503 + assert lock_error.value.status_code == 503 + + +def test_scim_primary_email_handles_absent_and_fallback_entries() -> None: + """Email extraction returns none or the first non-primary address.""" + assert scim._primary_email({}) is None + assert scim._primary_email( + { + "emails": [ + {"value": "first@example.com"}, + {"value": "second@example.com"}, + ] + } + ) == "first@example.com" + + +def test_scim_model_translation_covers_disabled_minimal_resource() -> None: + """Minimal disabled resources omit optional SCIM fields on serialization.""" + account = scim._to_user_account( + {"userName": "disabled", "active": False}, + user_id="disabled-id", + ) + + assert account.state == "disabled" + assert account.email is None + resource = scim._to_scim_resource(account) + assert resource["active"] is False + assert "externalId" not in resource + assert "emails" not in resource + + +def test_scim_create_requires_username() -> None: + """SCIM create rejects a resource without the required userName.""" + with pytest.raises(HTTPException) as error: + scim.create_user({}, provisioner=_MinimalProvisioner()) + + assert error.value.status_code == 400 + + +def test_scim_search_covers_empty_invalid_and_missing_results() -> None: + """Search handles no filter, malformed syntax, and an empty exact match.""" + provisioner = _MinimalProvisioner() + empty_request = SimpleNamespace(query_params={}) + missing_request = SimpleNamespace( + query_params={"filter": 'userName eq "missing"'} + ) + invalid_request = SimpleNamespace( + query_params={"filter": 'email eq "jane@example.com"'} + ) + + empty = scim.search_users(empty_request, provisioner=provisioner) + missing = scim.search_users(missing_request, provisioner=provisioner) + with pytest.raises(HTTPException) as invalid_error: + scim.search_users(invalid_request, provisioner=provisioner) + + assert b'"totalResults": 0' in empty.body + assert b'"totalResults": 0' in missing.body + assert invalid_error.value.status_code == 400 + + +def test_scim_replace_translates_unknown_user_and_lock_timeout() -> None: + """SCIM PUT distinguishes a missing user from lock contention.""" + provisioner = _MinimalProvisioner() + resource = {"userName": "missing"} + + with pytest.raises(HTTPException) as missing_error: + scim.replace_user( + "missing", + resource, + provisioner=provisioner, + user_operation_locks=InMemoryUserOperationLocks(), + ) + + with pytest.raises(HTTPException) as timeout_error: + scim.replace_user( + "user-1", + resource, + provisioner=provisioner, + user_operation_locks=_TimeoutLocks(), + ) + + assert missing_error.value.status_code == 404 + assert timeout_error.value.status_code == 503 + + +def test_scim_patch_handles_unknown_and_ignored_operations() -> None: + """SCIM PATCH rejects unknown users and ignores unsupported operations.""" + provisioner = _MinimalProvisioner() + + with pytest.raises(HTTPException) as missing_error: + scim.patch_user( + "missing", + {"Operations": []}, + provisioner=provisioner, + ) + + response = scim.patch_user( + "user-1", + { + "Operations": [ + {"op": "remove", "path": "active", "value": False}, + {"op": "add", "path": "displayName", "value": "Jane"}, + {"op": "replace", "path": "active", "value": "false"}, + ] + }, + provisioner=provisioner, + ) + + assert missing_error.value.status_code == 404 + assert response.status_code == 200 + assert "deactivate" in provisioner.calls + + +def test_scim_delete_rejects_unknown_user() -> None: + """SCIM DELETE reports a missing resource without mutation.""" + with pytest.raises(HTTPException) as error: + scim.delete_user("missing", provisioner=_MinimalProvisioner()) + + assert error.value.status_code == 404 diff --git a/services/account_unification/tests/test_full_coverage_remaining.py b/services/account_unification/tests/test_full_coverage_remaining.py new file mode 100644 index 0000000..364845b --- /dev/null +++ b/services/account_unification/tests/test_full_coverage_remaining.py @@ -0,0 +1,73 @@ +"""Final production statement and branch coverage regressions.""" +from __future__ import annotations + +from fastapi import FastAPI + +from app import main, scim +from app.config import _as_bool +from app.kv_store import SqliteKvStore +from app.models import UserAccount + + +class _ActiveProvisioner: + """Provide one active user without deactivating it on truthy PATCH input.""" + + def __init__(self) -> None: + """Create one active user and an empty deactivation log.""" + self.user = UserAccount( + user_id="user-1", + user_name="active-user", + state="active", + ) + self.deactivated = False + + def get_user(self, user_id: str) -> UserAccount: + """Return the one active user.""" + return self.user + + def deactivate_user(self, user_id: str) -> None: + """Record an unexpected deactivation.""" + self.deactivated = True + + +def test_config_boolean_parses_explicit_false_text() -> None: + """Explicit false spellings take the false branch, not the default.""" + assert _as_bool("false", True, entry_key="feature_toggle") is False + + +def test_sqlite_store_delete_is_idempotent(tmp_path) -> None: + """Durable KV deletion handles present and already-absent entries.""" + store = SqliteKvStore(str(tmp_path / "config.sqlite3")) + try: + store.put("runtime", "entry_key", "entry_value") + store.delete("runtime", "entry_key") + store.delete("runtime", "entry_key") + assert store.get("runtime", "entry_key") is None + finally: + store.close() + + +def test_temporary_lock_cleanup_handles_missing_path() -> None: + """Temporary-lock cleanup tolerates state without a published path.""" + app = FastAPI() + app.state.temporary_user_operation_lock_database = True + + main._remove_temporary_lock_database(app) + + +def test_scim_patch_keeps_user_active_for_truthy_value() -> None: + """A truthy active PATCH value covers the non-deprovision branch.""" + provisioner = _ActiveProvisioner() + + response = scim.patch_user( + "user-1", + { + "Operations": [ + {"op": "replace", "path": "active", "value": True} + ] + }, + provisioner=provisioner, + ) + + assert response.status_code == 200 + assert provisioner.deactivated is False diff --git a/services/account_unification/uv.lock b/services/account_unification/uv.lock index a5b4979..60acb31 100644 --- a/services/account_unification/uv.lock +++ b/services/account_unification/uv.lock @@ -51,6 +51,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "click" version = "8.4.2" @@ -72,11 +170,152 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/24/efb17eb94018dd3415d0e8a76a4786a866e8964aa9c50f033399d23939c2/coverage-7.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e574801e1d643561594aa021206c46d80b257e9853087090ba97bed8b0a509d3", size = 220501, upload-time = "2026-06-22T23:08:02.182Z" }, + { url = "https://files.pythonhosted.org/packages/76/93/32f1bfca6cdd34259c8af42820a034b7a28dfb44969a13ed38c17e0ba5b0/coverage-7.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f82b6bb7d75a2613e85d07cefa3a8c973d0544a8993337f6e2728e4a1e94c305", size = 221008, upload-time = "2026-06-22T23:08:03.701Z" }, + { url = "https://files.pythonhosted.org/packages/eb/88/0d0f974855ff905d15a64f7873d00bdc4182e2736267486c6634f4af293c/coverage-7.14.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2335ea5fed26af2e831094964fa3f8fae60b45f7e37fcc2d3b615b2add3ad87", size = 251420, upload-time = "2026-06-22T23:08:05.211Z" }, + { url = "https://files.pythonhosted.org/packages/39/7f/117dd2ec65e4140576f8ef991d88220f9b806769f7a8c20e0550c0f924e2/coverage-7.14.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbb8c3a98e779013786ae01d229662aeacbc77100efbd3f2f245219ace5af700", size = 253331, upload-time = "2026-06-22T23:08:06.672Z" }, + { url = "https://files.pythonhosted.org/packages/87/55/f0bd6d6538e3f16829fb8a44b6c0d2fe9da638bbfdd6a20f8b5da8f4fa81/coverage-7.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac082660de8f429ba0ea363595abb838998570b9a7546777c60f413ab902bbde", size = 255441, upload-time = "2026-06-22T23:08:08.208Z" }, + { url = "https://files.pythonhosted.org/packages/1e/98/aa71f7879019c846a8a9662579ea4484b0202cf1e252ffeed647075e7eca/coverage-7.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac012839ff7e396030f1e94e10553a431d14e4de2ab65cb3acb72bbd5628ca2", size = 257398, upload-time = "2026-06-22T23:08:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/f3/4f/5fd367e59844190f5965015d7bee899e67a89d13eb2760118479bf836f2f/coverage-7.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5952f8c1bda2a5347154450379316e6dfa4d934d62ca35f6784451e6f55074fb", size = 251558, upload-time = "2026-06-22T23:08:11.37Z" }, + { url = "https://files.pythonhosted.org/packages/8f/de/5383a6ee5a6376701fe07d980fa8e4a66c0c377fead16712720340d701a3/coverage-7.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8cf0f2509acb4619e2471a1951089054dd58ebea7a912066d2ea56dd4c24ca4a", size = 253134, upload-time = "2026-06-22T23:08:13.04Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/09542b1a99f788e3daec7f0fadc288821e71aca9ea298d51bfa1ba79fed5/coverage-7.14.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2e41fd3aab806770008279a93879b0924b16247e09ab537c043d08bbca53b4ab", size = 251195, upload-time = "2026-06-22T23:08:14.606Z" }, + { url = "https://files.pythonhosted.org/packages/02/9d/722fe8c13f0fbb064491b9e8656e56a606286792e5068c47ca1042e773e8/coverage-7.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f0a47095963cfe054e0df178daca95aec21e680d6076da807c3add28dfe920f7", size = 254959, upload-time = "2026-06-22T23:08:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/fb/58/943627179ff1d82da9e54d0a5b0bb907bb19cf19515599ccd921de50b469/coverage-7.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a090cbf9521e78ffdb2fcf448b72902afe9f5923ff6a12d5c0d0120200348af9", size = 250914, upload-time = "2026-06-22T23:08:18.03Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d4/803efcbf9ae5567454a0c71e983589529448e2704ee0da2dc0163d482f18/coverage-7.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d310baf69a4fbe8a098ce727e4808a34866ac718a6f759ae659cbd3221358bc", size = 251824, upload-time = "2026-06-22T23:08:19.704Z" }, + { url = "https://files.pythonhosted.org/packages/32/79/3f78ea9563132746eed5cecb75d2e576f9d8fec45a47242b5ae0950b82a3/coverage-7.14.3-cp311-cp311-win32.whl", hash = "sha256:74fdd718d88fe144f4579b8747873a07ec3f04cb837d5faec5a25d9e22fa31a8", size = 222594, upload-time = "2026-06-22T23:08:21.311Z" }, + { url = "https://files.pythonhosted.org/packages/85/22/9ebbc5a2ab42ac5d0eea1f48648629e1de9bbe41ec243ed6b93d55a5a53f/coverage-7.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:cc96aa922e21d4bc5d5ed3c915cef27dfcbc13686f47d5e378d647fbfba655a2", size = 223073, upload-time = "2026-06-22T23:08:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/69d5fcc16cb555153f99cec5467922f226be0369f7335a9506856d2a7bd0/coverage-7.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:c66f9f9d4f1e9712eb9b1de5310f881d4e2188cfcba5065e1a8490f38687f2c4", size = 222617, upload-time = "2026-06-22T23:08:25.054Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b0/8a911f6ffe6974dac4df95b468ab9a2899d0e59f0f99a489afeec39f00bc/coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24", size = 220672, upload-time = "2026-06-22T23:08:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665", size = 221035, upload-time = "2026-06-22T23:08:28.372Z" }, + { url = "https://files.pythonhosted.org/packages/77/e2/421ccfbb48335ac49e93301478cf5d623b0c2bf1c0cadd8e2b2fc6c0c710/coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a", size = 252540, upload-time = "2026-06-22T23:08:30.226Z" }, + { url = "https://files.pythonhosted.org/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727", size = 255274, upload-time = "2026-06-22T23:08:31.876Z" }, + { url = "https://files.pythonhosted.org/packages/dc/be/b6d9efe447f8ba3c3c854195f326bd64c54b907d936cd2fdebf8767ec72e/coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977", size = 256389, upload-time = "2026-06-22T23:08:33.843Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3c/f26e50acc429e608bc534ac06f0a3c169019c798178ec5e9de3dbc0df9c9/coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c", size = 258648, upload-time = "2026-06-22T23:08:35.481Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a2/01c1fabf816c8e1dae197e258edf878a3d3ddc86fbda34b76e5794277d8f/coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf", size = 252949, upload-time = "2026-06-22T23:08:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/941166dd79c31fd44a13063780ae8d552eee0089a0a0930b9bdb7df554ed/coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f", size = 254310, upload-time = "2026-06-22T23:08:39.174Z" }, + { url = "https://files.pythonhosted.org/packages/10/31/80b1fd028201a961033ce95be3cd1e39e521b3762e6b4a1ac1616cb291e7/coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205", size = 252453, upload-time = "2026-06-22T23:08:40.84Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/c3d9addd94c4b524f3f4af0232075f5fe7170ce99a1386edff803e5934db/coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c", size = 256522, upload-time = "2026-06-22T23:08:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/14/e5a0575f73795af3a7a9ae13dadf812e17d32422896839987dc3f86947e1/coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef", size = 252023, upload-time = "2026-06-22T23:08:44.243Z" }, + { url = "https://files.pythonhosted.org/packages/38/9b/9652ee531937ce3b8a63a8896885b2b4a2d56adc30e53c9540c666286d88/coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd", size = 253893, upload-time = "2026-06-22T23:08:46.113Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/42678841c8c38e4b08bdfc48269f5a16dfbf5806000fe6a89b4cece3c691/coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35", size = 222734, upload-time = "2026-06-22T23:08:47.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d", size = 223266, upload-time = "2026-06-22T23:08:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/aa/34/2b8b66a989282ea7b370beb49f50bab29470dc30bb0b03935b6b802782f7/coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336", size = 222655, upload-time = "2026-06-22T23:08:51.766Z" }, + { url = "https://files.pythonhosted.org/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" }, + { url = "https://files.pythonhosted.org/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" }, + { url = "https://files.pythonhosted.org/packages/82/50/dfce42eff2cecabcd5a9bbad5489449c87db3415f408d23ffee417ce01f6/coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73", size = 254657, upload-time = "2026-06-22T23:08:59.453Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d2/639ceb1bc8038fd0d66768278d5dc22df3391918b8278c2a21aa2602a531/coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9", size = 255892, upload-time = "2026-06-22T23:09:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/8b/96/002094a10e113512500dc1e10430a449417e17b0f90f7d496bcb820208b7/coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de", size = 258026, upload-time = "2026-06-22T23:09:03.017Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ec/286a5d2fad9c4bee59bd724feeb7d5bf8303c6c9200b51d1dd945a9c72b0/coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd", size = 252285, upload-time = "2026-06-22T23:09:04.773Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7d/a17753a0b12dd48d0d50f5fab079ad99d3be1eac790494d89f3a417ca0b9/coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5", size = 254023, upload-time = "2026-06-22T23:09:06.513Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/a76c6ceba6a2c313f905310abf2701d534cada22d372db11731831e9e209/coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb", size = 251989, upload-time = "2026-06-22T23:09:08.382Z" }, + { url = "https://files.pythonhosted.org/packages/d9/39/353013a75fec0fb49f7553519f9d52b4441e902e5178c93f38eb6c07cedb/coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f", size = 256144, upload-time = "2026-06-22T23:09:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/29/0e/613878555d734def11c5b20a2701a15cb3781b9e9ea749da27c5f436e928/coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498", size = 251808, upload-time = "2026-06-22T23:09:12.057Z" }, + { url = "https://files.pythonhosted.org/packages/af/76/359c058c9cfdcf1e8b107663881225b03b364a320017eda24a2a66e55102/coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0", size = 253579, upload-time = "2026-06-22T23:09:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d9/4ba2f060933a30ebe363cef9f67a365b0a317e580c0d5d9169d56a73ef1c/coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37", size = 222741, upload-time = "2026-06-22T23:09:15.636Z" }, + { url = "https://files.pythonhosted.org/packages/76/e8/196ebc25d8f34c06d43a6e9c8513c9266ef8dbf3b5672beb1a00cf5e29fa/coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994", size = 223283, upload-time = "2026-06-22T23:09:17.478Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/51d2aac6417523a286f10fb25f09eb9518a84df9f1151e93ff6871f34849/coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150", size = 222678, upload-time = "2026-06-22T23:09:19.7Z" }, + { url = "https://files.pythonhosted.org/packages/61/56/14e3b97facbfa1304dd19e676e26599ad359f04714bed32f7f1c5a88efdc/coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc", size = 220741, upload-time = "2026-06-22T23:09:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7", size = 221068, upload-time = "2026-06-22T23:09:23.452Z" }, + { url = "https://files.pythonhosted.org/packages/47/f0/3f8421b20d9c4fcd39be9a8ca3c3fda8bc204b44efbd09fede153afd3e2f/coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce", size = 252117, upload-time = "2026-06-22T23:09:25.458Z" }, + { url = "https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5", size = 254622, upload-time = "2026-06-22T23:09:27.523Z" }, + { url = "https://files.pythonhosted.org/packages/c8/25/ec6de51ae7493b92a1cf74d1b763121c29636759167e2a593ba4db5881e4/coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a", size = 255968, upload-time = "2026-06-22T23:09:29.43Z" }, + { url = "https://files.pythonhosted.org/packages/5d/05/c8bfc77823f42b4664fb25842f13b567022f6f84a4c83c8ecbb16734b7cb/coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501", size = 258284, upload-time = "2026-06-22T23:09:31.397Z" }, + { url = "https://files.pythonhosted.org/packages/f6/15/1d1b242027124a32b26ef01f82018b8c4ef34ef174aa6aeba7b1eeef48e8/coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e", size = 252143, upload-time = "2026-06-22T23:09:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/74/b6/d2a9842fd2a5d7d27f1ac851c043a734a494ad75402c5331db3da79ed691/coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3", size = 253976, upload-time = "2026-06-22T23:09:35.351Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/e1600ddf7e226db5558bb5323d2186fff00f505c4b764643ec89ce5d8175/coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5", size = 251942, upload-time = "2026-06-22T23:09:37.313Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2c/9159de64f9dd648e324328d588a44cfab1e331eb5259ce1141afe2a92dfb/coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845", size = 256220, upload-time = "2026-06-22T23:09:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/91/67/b7f536cc2c124f48e91b22fbb741d2261f4e3d310faf6f76007f47566e5d/coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027", size = 251756, upload-time = "2026-06-22T23:09:41.056Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/f3718038e2d4860c715a55428377ca7f6c75872caf98cabd982e1d76967d/coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b", size = 253413, upload-time = "2026-06-22T23:09:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a5/91f11efeef89b3cc9b30461128db15b0511ef813ab889a7b7ab636b3a497/coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965", size = 222946, upload-time = "2026-06-22T23:09:45.261Z" }, + { url = "https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3", size = 223436, upload-time = "2026-06-22T23:09:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a0/7cd612d650a772a0ae80144443406bf61981c896c3d57c9e6e79fb2cdbd1/coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92", size = 222861, upload-time = "2026-06-22T23:09:49.384Z" }, + { url = "https://files.pythonhosted.org/packages/55/57/017353fab573779c0d00448e47d102edd36c792f7b6f233a4d89a7a08384/coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949", size = 221474, upload-time = "2026-06-22T23:09:51.417Z" }, + { url = "https://files.pythonhosted.org/packages/69/92/90cf1f1a5c468a9c1b7ba2716e0e205293ad9b02f5f573a6de4318b15ba1/coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891", size = 221738, upload-time = "2026-06-22T23:09:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c0/4df964fa539f8399fd7679c09c472d73744de334686fd3f01e3a2465ce4e/coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388", size = 263101, upload-time = "2026-06-22T23:09:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/06/76/e5d33b2576ae3bf2be2058cd1cae57774b61e400f2c3c58f3783dc2ffb4a/coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784", size = 265225, upload-time = "2026-06-22T23:09:57.904Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/e52419afe391a39ba27fdefaf0737d8e34bf03faef6ab3b3006545bbd0d0/coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed", size = 267643, upload-time = "2026-06-22T23:09:59.938Z" }, + { url = "https://files.pythonhosted.org/packages/58/7a/f2625d8d5006b6b20fba5afaef00b24a763fe96476ea798a3076cbc1f84e/coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5", size = 268762, upload-time = "2026-06-22T23:10:01.943Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bf/908024006bba57127354d74e938954b9c3cd765cc2e0412dc9c37b415cda/coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26", size = 262208, upload-time = "2026-06-22T23:10:03.954Z" }, + { url = "https://files.pythonhosted.org/packages/34/a0/d4f9296441b909817442fdb26bd77a698f08272ec683a7394b00eb2e47a0/coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889", size = 265096, upload-time = "2026-06-22T23:10:05.936Z" }, + { url = "https://files.pythonhosted.org/packages/e8/da/4ae4f3f4e477b56a4ce1e5c48a35eff38a94b50130ce5bdc897024741cfc/coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d", size = 262699, upload-time = "2026-06-22T23:10:07.973Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/6927148073ff32856d78baa77b4ddc07a9be7e90020f9db0661c4ca523a1/coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e", size = 266433, upload-time = "2026-06-22T23:10:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a7/774f658dbe9c4c3f5daa86a87e0459ac3832e4e3cc67affe078547f727b9/coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7", size = 261547, upload-time = "2026-06-22T23:10:12.191Z" }, + { url = "https://files.pythonhosted.org/packages/3d/14/a0c18c0376c43cbf973f43ef6ca20019c950597180e6396232f7b6a27102/coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635", size = 263859, upload-time = "2026-06-22T23:10:14.492Z" }, + { url = "https://files.pythonhosted.org/packages/10/ac/43a3d0f460af524b131a6191805bc5d18b806ab4e828fbf82e8c8c3af446/coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc", size = 223250, upload-time = "2026-06-22T23:10:16.758Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5f/d5e5c56b0712e96ce8f69fe7dbf229ff938b437bc50862743c8a0d2cea84/coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda", size = 224082, upload-time = "2026-06-22T23:10:19.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/35/947cbd5be1d3bcbbdc43d6791de8a56c6501903311d42915ae06a82815f0/coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f", size = 223400, upload-time = "2026-06-22T23:10:21.24Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, +] + [[package]] name = "cwl-idp-account-unification" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "cryptography" }, { name = "fastapi" }, { name = "httpx" }, { name = "pydantic" }, @@ -86,6 +325,7 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "coverage" }, { name = "httpx2" }, { name = "interrogate" }, { name = "pytest" }, @@ -94,6 +334,8 @@ dev = [ [package.metadata] requires-dist = [ + { name = "coverage", marker = "extra == 'dev'", specifier = "==7.14.3" }, + { name = "cryptography", specifier = "==50.0.0" }, { name = "fastapi", specifier = "==0.140.13" }, { name = "httpx", specifier = "==0.28.1" }, { name = "httpx2", marker = "extra == 'dev'", specifier = ">=2.5.0" }, @@ -249,6 +491,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378", size = 98708, upload-time = "2021-11-04T17:17:00.152Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.4"