Skip to main content

Multi-user keys

Supported modes: Hosted Hybrid Available in: Free Solo Teams SDK: 1.6.0+

Real teams share API keys. A project key gets dropped in everyone's ~/.controlzero/config.yaml; a CI key gets used by every job; a team-shared agent key powers every developer's coding assistant. The API key is a machine credential, not a per-user identity.

Without per-user identity, approvals are broken: every request is attributed to the key creator, the requestor's /my-requests page is empty, the audit log lies, and the self-approval guard mis-fires.

This page explains how identity works on Control Zero so approvals (and audit) attribute requests to the actual human.

The contract

When a top-level email is configured, the SDK sends an identity claim on every backend call:

  • Header: X-CZ-Requestor-Email: alice@acme.com
  • Source: the top-level email key in ~/.controlzero/config.yaml, written by controlzero install --email. That file is the only place the SDK reads the operator email from — there is no CONTROLZERO_REQUESTOR_EMAIL environment variable and no user_email= constructor argument.
The key is top-level, and a wrong key fails silently

If email is missing, nested under another key, or the file is malformed, the SDK sends no identity header and the backend records identity_provenance: unknown. Nothing errors and nothing warns. Check the file itself after install rather than assuming it took.

Backend resolves the claim against users.email + org_membership:

  • Known user in this orgrequestor_user_id = users.id. Normal flow.
  • Email in users but not in this org results in E1706 HITLIdentityNotInOrg.
  • Email unknown results in E1707 HITLIdentityRequired.

What the SDK does on install

controlzero install --email alice@acme.com writes:

# ~/.controlzero/config.yaml
api_key: cz_live_...
email: alice@acme.com

email is a top-level key. The SDK does not read a nested identity.email.

If --email is omitted and stdin is a TTY, the CLI prompts. Non-TTY environments (CI, Docker entrypoints, Lambda) must either pass --email explicitly at install time or write the top-level email key into ~/.controlzero/config.yaml directly. There is no environment-variable equivalent.

What the SDK does at runtime

On every Client.guard() / Client.get_secret() / Client.request_approval() call, the SDK attaches the X-CZ-Requestor-Email header. The backend resolves it. Audit rows record both the api_key_id AND the claimed_email -- so a forensic review sees the full attribution chain.

Threat model: spoofable claim

A user with the API key can claim ANY email in the org via env var or config edit. This is intentional: the API key already grants full access to the policy bundle and the audit log; the claim adds no new powers it just attributes them.

Forensic trail: the audit log records both api_key_id AND claimed_email. If alice's key was used to claim bob@acme.com, the row says so. The dashboard shows "via shared key (3 distinct claimants this week)" as a badge in the approver UI when the same key has had more than 3 claimants in 7 days.

This is the same threat surface as git config user.email. No one verifies it; commits get blamed on whoever the config says. Same here.

Pure-local mode is exempt

If you call Client(policy={...}) with no api_key (pure local mode), no identity check fires. The 11-line Hello World still works unchanged. controlzero install --email is only required when an api_key is configured.

Existing customers (upgrading to 1.6.0)

If no top-level email is configured, nothing fails at construction time and nothing warns. Audit and bundle traffic continue; the identity header is simply omitted. The first approval request your code raises is where it surfaces, as a hard E1707 error. Run controlzero install --email <your-email> to set it.

When a user leaves the org

A user removed from the org mid-flight gets E1708 HITLIdentityClaimRejected on the next poll. The pending approval request synthetically-denies; the SDK raises HITLIdentityClaimRejected. The audit row records the rejection.

CI keys without a human

CI keys are by definition not user-scoped. For approval purposes, install with controlzero install <agent> --email ci@acme.com (a real user in the org, typically the ops lead) so escalations have a human accountability anchor.

For pure-machine audit (no approvals), the CI key works fine without identity. Audit rows show claimed_email: null and identity_provenance: 'legacy'.

See also