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
The SDK sends an identity claim on every backend call:
- Header:
X-CZ-Requestor-Email: alice@acme.com - Source (in order): explicit
Client(user_email="...")arg →CONTROLZERO_REQUESTOR_EMAILenv var →[identity].emailin~/.controlzero/config.yaml(set bycontrolzero install --email)
Backend resolves the claim against users.email + org_membership:
- Known user in this org →
requestor_user_id = users.id. Normal flow. - Email in
usersbut not in this org results inE1306 HITLIdentityNotInOrg. - Email unknown results in
E1307 HITLIdentityRequired.
What the SDK does on install
controlzero install --email alice@acme.com writes:
# ~/.controlzero/config.yaml
api_key: cz_live_...
identity:
email: alice@acme.com
If --email is omitted and stdin is a TTY, the CLI prompts. Non-TTY environments (CI, Docker entrypoints, Lambda) MUST pass --email explicitly OR set CONTROLZERO_REQUESTOR_EMAIL.
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)
On first Client(...) construction after upgrade, the SDK detects missing [identity].email and prints a one-time stderr nag:
controlzero: no [identity].email configured for this client.
Approval traffic will fail with E1307. Non-approval audit traffic continues
unchanged. Run `controlzero install --email <your-email>` to set it.
(This message will not repeat.)
The nag is non-blocking: Client construction succeeds, audit + bundle calls continue. Only the first approval-eligible deny trips the hard E1307 error.
When a user leaves the org
A user removed from the org mid-flight gets E1308 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, set CONTROLZERO_REQUESTOR_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
- Approval Workflow. The parent concept
- E1306
- E1307
- E1308