Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: publish

# Publishes cbox-id-client to PyPI when a GitHub release is published.
#
# Trusted Publishing (OIDC): no API token is stored anywhere. PyPI trusts this exact
# repository + workflow + environment, configured once on pypi.org under
# "Publishing" (owner cboxdk, repository id-python, workflow publish.yml, environment pypi).
on:
release:
types: [published]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
# The tag is the version: a release tagged v0.10.0 must build 0.10.0, or nothing ships.
- name: Tag matches the package version
run: |
version=$(python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
test "v${version}" = "${GITHUB_REF_NAME}" || { echo "tag ${GITHUB_REF_NAME} != pyproject version ${version}"; exit 1; }
- run: pip install -e ".[dev]" build
- run: ruff check .
- run: mypy
- run: pytest -q
- run: python -m build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

publish:
needs: build
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/cbox-id-client
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,57 @@
All notable changes to `cbox-id-client` are recorded here. Earlier releases are described
by their tags and commit history.

## [Unreleased]

## [0.10.0] - 2026-10-08

### Added

- `cbox_id.management`: typed clients for Cbox ID's management planes, generated from the
OpenAPI documents the server publishes (vendored from Cbox ID's wave-7 specs). `EnvironmentClient` (an environment's own host,
`cbid_env_…` key or delegated token), `WorkspaceClient` (`cbid_ws_…` key),
`PlatformClient` (operator token) and `AccountClient` (a person's own token). Methods are
named after the server's actions (`env.apps.secrets.rotate(id, body)`,
`workspace.environments.create(body)`), with `TypedDict` bodies, queries and responses
that pass `mypy --strict`. Synchronous, on `httpx`, like the rest of the package.
- Every write sends an `Idempotency-Key` (a fresh UUID unless you pass one) and is retried
with the same key on network failures, `5xx`, `429` and `409 idempotency_in_progress`,
respecting `Retry-After`. `ApiResponse.replayed` reports `Idempotent-Replayed`.
- Approvals: a `202 approval_required` calls `on_approval_required` (show the binding code),
polls the approval on the plane's own host only, and repeats the request with
`Cbox-Approval` and the same key. `ApprovalDeniedError` and `ApprovalExpiredError` when it
is not approved; `approval="return"` hands back a `PendingApprovalResult` with `resume()`.
- `CboxIdApiError` (`status`, `error`, `message`, `errors`, `request_id` from the envelope's
`request_id` or `X-Request-Id`, `retry_after`) and `ManagementNetworkError` (with the
`idempotency_key` to repeat safely).
- `environment=` on `EnvironmentClient`: with a person's root access token and the platform
root as `base_url`, it sends `Cbox-Environment` so one token can drive any environment of
the workspace.
- Audit Logs helpers: `AuditLogger` (buffers events and sends batches of up to 100, each
with its own Idempotency-Key, on size, interval and `flush()`), `export_audit_logs()`
(creates an export and polls it until ready), and `verify_audit_chain()` /
`verify_audit_log_chain()` with `canonical_json()` and `audit_event_hash()`, which
recompute an organization's hash chain byte for byte as the server does — checked against
a vector the server's own code computed.
- `…_all` iterators on every paged list (cursor and page-number paging).
- DPoP-bound access tokens: `ES256DPoPSigner` and `generate_dpop_key()`, with
`DPoP-Nonce` challenge handling.
- Operation tables (`ENVIRONMENT_OPERATIONS`, …) with each action's method, path, scope,
danger and whether it can be held for approval.
- `verify_standard_webhook()`: verifies deliveries from an endpoint on the
`standard_webhooks` signature scheme (`webhook-id` / `webhook-timestamp` /
`webhook-signature`), with a `whsec_…` secret or the endpoint's hex Cbox secret. Tested
against the Standard Webhooks specification's vector.
- `python -m scripts.generate_management` regenerates the clients from the vendored specs in
`openapi/`; `--fetch <plane>=<host>` refreshes a spec from a running server first, and
`--check` fails when the generated code is stale. So does the test suite.

### Changed

- README: the install line pinned v0.8.0; it now names v0.9.0.
- New runtime dependency: `typing-extensions>=4.7` (`NotRequired` and `TypedDict` on
Python 3.10). The `dev` extra adds `pyyaml` and `types-PyYAML` for the generator.

## [0.9.0] - 2026-09-24

Organization selection, support sessions, and staff roles. Needs a Cbox ID instance that
Expand Down
250 changes: 242 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,14 @@ request abstraction invented to avoid them.
> Register an application in your environment console — see
> [Integrate your app](https://github.com/cboxdk/cbox-id/blob/main/docs/getting-started/integrate-your-app.md).

> **Not on PyPI, and not planned.** `pip install cbox-id-client` installs nothing — this
> package has never been published and there is no release pipeline for it. Everything
> below works; you install it from source.

```bash
pip install git+https://github.com/cboxdk/id-python@v0.8.0
pip install cbox-id-client
```

Published to PyPI from 0.10.0, by the release workflow (Trusted Publishing — no stored
token). Earlier versions were never on PyPI; install those from the tag instead:
`pip install git+https://github.com/cboxdk/id-python@v0.9.0`.

## Log in users

```python
Expand Down Expand Up @@ -345,6 +345,240 @@ ok = verify_webhook(
)
```

An endpoint switched to the `standard_webhooks` signature scheme sends `webhook-id`,
`webhook-timestamp` and `webhook-signature` instead, verifiable with any
[Standard Webhooks](https://www.standardwebhooks.com) library or with:

```python
from cbox_id import verify_standard_webhook

ok = verify_standard_webhook(
raw_body, # the exact bytes received
webhook_id=request.headers.get("webhook-id"),
webhook_timestamp=request.headers.get("webhook-timestamp"),
webhook_signature=request.headers.get("webhook-signature"),
secret=os.environ["CBOX_ID_WEBHOOK_SECRET"], # whsec_…, or the endpoint's hex secret
)
```

Switching scheme keeps the secret: a hex Cbox secret is used as `whsec_` + base64 of itself,
and both forms verify here. Update the receiver before you switch the endpoint
(`env.webhooks.signature_scheme.change(id, {"signature_scheme": "standard_webhooks"})`).

## Management API

`cbox_id.management` is a typed client for Cbox ID's management planes. It is generated from
the OpenAPI documents the server publishes, so every method, path, scope and type matches the
server it was generated from. Use it from server code only: every client holds a management
credential.

| Client | Plane | Credential | `base_url` |
| --- | --- | --- | --- |
| `EnvironmentClient` | One environment's tenancy: organizations, users, apps, roles, SSO, audit logs… | `cbid_env_…` key, or a delegated access token | The environment's own host, or the platform root with `environment` |
| `WorkspaceClient` | The workspace above its environments: projects, environments, team, keys | `cbid_ws_…` key, or a person's root token | `https://api.cboxid.com` (default) |
| `PlatformClient` | The deployment itself, for operators | Delegated operator token only | `https://api.cboxid.com` (default) |
| `AccountClient` | A person's own account | Delegated token only | The environment's own host |

Method names are the server's action names: `apps.secrets.rotate` is
`env.apps.secrets.rotate(...)`, and `sso.saml_metadata.import` is
`env.sso.saml_metadata.import_(...)`. Path parameters come first, in path order, then the body
(or the query, for a read) as a dict, then keyword-only options. Every call returns an
`ApiResponse` with `data`, `meta`, `body`, `status`, `replayed`, `idempotency_key`,
`request_id` and `headers`. Bodies, queries and `data` are `TypedDict`s, so mypy and your
editor check the keys.

The client is synchronous, on `httpx`, like the rest of this package.

### Create an app and rotate its secret

```python
import os

from cbox_id.management import ApprovalDeniedError, CboxIdApiError, EnvironmentClient

env = EnvironmentClient(
base_url="https://acme.cboxid.com",
api_key=os.environ["CBOX_ID_ENV_KEY"], # cbid_env_…
on_approval_required=lambda approval, ctx: print(
f"Approve {ctx.action} on your device. Code: {approval.binding_code}"
),
)

app = env.apps.create(
{"name": "Billing", "type": "web", "redirect_uris": ["https://billing.acme.com/callback"]}
).data
# app["client_secret"] is in this response and in no other. Store it now.

try:
secret = env.apps.secrets.rotate(app["id"], {"grace_seconds": 3600}).data
# secret["client_secret"]: the new secret, shown once.
except ApprovalDeniedError:
print("Rotation was declined.")
except CboxIdApiError as exc:
if not exc.is_validation_error:
raise
print(exc.errors)
```

When a key's policy holds an action for a person's approval, the server answers
`202 approval_required`. By default the client calls `on_approval_required` (show the
`binding_code` so the person can match it on their device), polls the approval, and repeats
the request with `Cbox-Approval: <id>` and the same `Idempotency-Key` once it is approved.
`ApprovalDeniedError` and `ApprovalExpiredError` are raised when it is denied or expires. The
poll goes only to the plane's own host: a `poll_url` on another origin is refused rather than
handed the credential. If you do not want to wait in the same call, pass `approval="return"`:

```python
from cbox_id.management import PendingApprovalResult

outcome = env.apps.secrets.rotate(app["id"], {"grace_seconds": 0}, approval="return")
if isinstance(outcome, PendingApprovalResult):
print(f"Code: {outcome.approval.binding_code}")
secret = outcome.resume().data # polls, then repeats the request
```

### Bootstrap an environment from a workspace key

```python
from cbox_id.management import EnvironmentClient, WorkspaceClient

workspace = WorkspaceClient(api_key=os.environ["CBOX_ID_WS_KEY"]) # cbid_ws_…

created = workspace.environments.create(
{
"name": "Staging",
"type": "sandbox",
"initial_key": {"name": "bootstrap", "scopes": ["apps:write", "organizations:write"]},
}
).data

# The first management key, returned once. On an idempotent replay its token is None.
initial_key = created["initial_key"]
assert initial_key is not None and initial_key["token"] is not None
env = EnvironmentClient(base_url=created["issuer"], api_key=initial_key["token"])
env.organizations.create({"name": "Acme", "slug": "acme"})
```

### Idempotency, retries and errors

- Every `POST`, `PUT`, `PATCH` and `DELETE` sends an `Idempotency-Key`, a fresh UUID unless
you pass `idempotency_key=`. Network failures, `5xx`, `429` and
`409 idempotency_in_progress` are retried with the **same** key
(`retry=RetryOptions(max_retries=3, base_delay=0.5, max_delay=30.0)`, in seconds).
`Retry-After` is respected; one longer than `max_delay` is raised instead of waited out.
`replayed` is `True` when the server returned the first request's stored answer
(`Idempotent-Replayed`). A secret in a replayed answer is `None`.
- A failed call raises `CboxIdApiError` with `status`, `error` (the stable code), `message`,
`errors` (field-keyed, on `validation_failed`), `request_id` (the envelope's `request_id`,
else the `X-Request-Id` header; quote it when reporting a problem) and `retry_after`. A call
that never got an answer raises `ManagementNetworkError`, which carries the
`idempotency_key` so you can repeat the request safely. Both are `CboxIdError`s.
- The client never logs. Secrets in responses are returned as they arrive, and request
bodies are never put in an error.

### One token for every environment

A person's access token issued at the platform root reaches the workspace, account and
operator planes there, and any environment of their workspace when the request names it with
`Cbox-Environment`. Pass `environment` (an id or slug) and the root host as `base_url`:

```python
staging = EnvironmentClient(
base_url="https://api.cboxid.com",
access_token=tokens.current, # a str, or a callable called before every request
environment="acme-staging", # sent as Cbox-Environment on every request
)
staging.organizations.portal_links.create(org_id, {"intents": ["sso", "dsync"]})
```

What the token may do there is bounded by the person's role and the token's scopes. A
`cbid_env_…` key is bound to its own environment's host, so `environment` is refused with one.

### Lists

Every paged list also has a `…_all` variant that iterates every item, fetching pages as you
reach them. It follows `meta.next_cursor` on the environment plane and `meta.next_page` on the
workspace plane:

```python
for org in env.organizations.list_all({"limit": 100}):
print(org["id"], org["name"])
```

### Audit Logs

Your app records what its users did, per organization (your customer), and Cbox ID keeps
each organization's events in a tamper-evident hash chain. `AuditLogger` buffers events and
sends them in batches of up to 100, each batch under its own `Idempotency-Key`. It sends a
batch when it is full, every `flush_interval` seconds (default 5, from a daemon thread), and on
`flush()` / `close()`. A batch that fails stays queued with the same key, so sending it again
never records an event twice:

```python
from collections.abc import Sequence

from cbox_id.management import AuditLogEventInput, AuditLogger


def report(exc: Exception, batch: Sequence[AuditLogEventInput]) -> None:
log.warning("audit flush failed (%d events kept for the next one): %s", len(batch), exc)


with AuditLogger(env, on_error=report) as audit:
audit.record(
{
"organization_id": org["id"],
"action": "invoice.voided",
"actor": {"id": user.id, "type": "user", "name": user.name},
"targets": [{"id": invoice.id, "type": "invoice"}],
"context": {"location": request.remote_addr, "user_agent": request.user_agent.string},
"metadata": {"reason": "duplicate"},
}
) # occurred_at defaults to now
# Leaving the block (or audit.close()) stops the thread and sends what is left.
```

Read events with `env.audit_logs.events.list_all({"organization_id": …, "actions": […]})`.
To get a CSV, `export_audit_logs(env, filters)` starts an export and polls it until it is
ready. Its `url` is signed and short-lived. To check the chain yourself instead of trusting
the server's `env.audit_logs.verify()`, call `verify_audit_log_chain(env, organization_id)` or
`verify_audit_chain(events)`. They recompute `sha256(prev_hash + canonical JSON)` exactly as
the server does — keys sorted by UTF-8 bytes at every depth, PHP's list and empty-object
rules, PHP's float format, slashes and Unicode unescaped — and report
`AuditChainVerification(valid, reason, broken_at_sequence, …)`.

### Delegated tokens and DPoP

Pass `access_token` (a string, or a callable called before every request so it can refresh)
instead of `api_key`. For a DPoP-bound token, pass a signer built from the P-256 key the token
was bound to:

```python
from cbox_id.management import AccountClient, ES256DPoPSigner

me = AccountClient(
base_url="https://acme.cboxid.com",
access_token=tokens.current,
dpop=ES256DPoPSigner(private_key), # an EllipticCurvePrivateKey (cryptography)
)
me.sessions.revoke_others()
```

### Types, metadata and regenerating

Schema and operation types live in each plane's module (`cbox_id.management.environment_api.App`,
`cbox_id.management.workspace_api.EnvironmentsCreateBody`). The operation tables
(`ENVIRONMENT_OPERATIONS`, …) list each action's method, path, scope, danger and whether it can
be held for approval, which is useful for showing a confirmation before a `critical` action.
`env.request(method, path, query=…, body=…)` calls a route that is not generated.

The specs are vendored in `openapi/`. `python -m scripts.generate_management` (with the `dev`
extra installed) regenerates `src/cbox_id/management/generated/` from them, and
`python -m scripts.generate_management --fetch environment=https://acme.cboxid.com` (or
`workspace=`, `platform=`, `account=`, `all=`) refreshes a vendored spec from a running server
first. `--check` exits non-zero when the generated code is stale, and the test suite fails
when the generated code and the vendored specs disagree.

## Security & scope

Login is hardened by default — PKCE, `state`, nonce, and full `id_token` verification
Expand All @@ -353,9 +587,9 @@ so `alg:none` and algorithm confusion are both refused. Keep the
client secret and webhook secrets server-side.

This is a **client**. It authenticates users — into a chosen organization, when you ask —
and calls a Cbox ID instance's standard endpoints; it does not configure SSO, run SCIM, or
administer organizations and their members — those are platform capabilities of
[`cboxdk/laravel-id`](https://github.com/cboxdk/laravel-id).
and calls a Cbox ID instance's endpoints. The management client drives the server's own
management API with a credential you hold; SSO, SCIM and the rules behind every action stay
platform capabilities of [`cboxdk/laravel-id`](https://github.com/cboxdk/laravel-id).

Report vulnerabilities via this repo's GitHub **Private Vulnerability Reporting**.

Expand Down
Loading
Loading