Recipe: Approvals on a multi-developer project with shared API keys
Time: ~15 minutes Prereqs: Teams tier; 2+ teammates; shared project API key already issued
A common pattern: a single project API key (cz_live_proj_foo_dev) gets distributed to alice, bob, carol. They all run agents against the same project. Without per-user identity, every request looks like it came from the key creator. This recipe shows how to set up identity correctly so the approval workflow works.
Step 1. Each developer installs with their own email
Each teammate runs:
controlzero install --api-key cz_live_proj_foo_dev --email alice@acme.com
controlzero install --api-key cz_live_proj_foo_dev --email bob@acme.com
controlzero install --api-key cz_live_proj_foo_dev --email carol@acme.com
Same API key; different [identity].email per machine. Each writes its own ~/.controlzero/config.yaml.
Step 2. Verify identity resolution
Each teammate runs:
controlzero doctor
Expected output for alice's machine:
IDENTITY: alice@acme.com -- resolved as user_id=<uuid> in org acme OK
If the email is not in the org (e.g., alice@othercompany.com), controlzero doctor reports E1306 HITLIdentityNotInOrg. Re-install with the right email.
Step 3. CI / non-TTY environments
Non-TTY shells (CI, Docker entrypoints, Lambda) can't run the interactive install prompt. Two options:
# Option A: explicit --email flag
controlzero install --api-key cz_live_ci --email ci-bot@acme.com
# Option B: env var
export CONTROLZERO_REQUESTOR_EMAIL=ci-bot@acme.com
For CI bots, use a real user account (e.g., the ops lead). Approval flows require a human accountability anchor.
Step 4. Trigger an approval request as alice
Alice runs:
from controlzero import Client
client = Client()
decision = client.guard("Bash:sudo apt-get install python3-foo")
if decision.denied and decision.hitl_eligible:
request = client.request_approval(decision, message="alice needs python3-foo")
final = request.wait()
In /approvals, the request shows up as:
Action: Bash:sudo apt-get install python3-foo
Requestor: alice@acme.com (NOT bob, NOT the key owner)
Scope: project foo
The approver sees alice as the requestor, not the key creator.
Step 5. Pick the right "Who can use this approval?" scope
The approver opens the drawer and sees the picker:
- Just alice@acme.com (default; recommended for security)
- Anyone using project foo (team-wide; one approval unblocks all three devs)
- Anyone on machine laptop-alice (machine-bound)
- Anyone using key cz_live_proj_foo_dev (key-bound; "via shared key 3 claimants this week" badge shows here)
- Custom: edit the rule before applying
For routine team needs, "Anyone using project foo" is the right choice. Bob and carol will hit the same deny otherwise and need their own approvals.
For sensitive ops (production secrets, destructive commands), "Just alice@acme.com" is the right choice.
Step 6. Bob runs the same call (cross-user replay)
If the approver chose "Just alice@acme.com":
# Bob's machine
decision = client.guard("Bash:sudo apt-get install python3-foo")
# decision.denied == True
# decision.hitl_eligible == True
# Bob's grant lookup does NOT match alice's principal_user_id
# Bob files his own request
If the approver chose "Anyone using project foo":
# Bob's machine
decision = client.guard("Bash:sudo apt-get install python3-foo")
# decision.allowed == True (the grant applies to bob too)
This is the whole point of per-user identity: the approver can pick the right blast radius.
Step 7. Verify audit lineage attribution
Open /audit. Filter by requestor = alice@acme.com. You see alice's row. Filter by requestor = bob@acme.com. You see bob's row. Filter by api_key_id = cz_live_proj_foo_dev. You see all three.
Each row shows:
api_key_id: the shared key (same across all three rows)claimed_email: alice / bob / carol per rowclaimed_user_id: resolved per-row from email
A forensic review can answer "who actually ran this?", not "who created the key?"
Threat model note
If bob sets [identity].email = alice@acme.com in his config, the audit log records api_key_id = cz_live_proj_foo_dev + claimed_email = alice@acme.com. Bob can impersonate alice in the audit log. This is the same threat surface as git config user.email.
Mitigation: the approver UI flags shared keys with via shared key (N distinct claimants this week). An admin reviewing alice's recent approvals sees the badge and asks whether the activity is genuinely hers.
For higher-stakes orgs, issue per-user API keys instead of sharing one project key. The identity claim then matches the key creator and spoofing is bounded by key issuance.
What you learned
- One API key + multiple
[identity].emailvalues gives per-user attribution on approvals. controlzero doctorverifies the identity resolution.- The approver picker scopes grants to user / project / machine / key.
- Audit log records both
api_key_idandclaimed_emailfor forensic clarity. - Spoofing is bounded by org membership; the audit trail surfaces it.
See also
- Concept: Multi-user keys. Threat model + identity model
- Recipe: First approval flow. Single-developer walkthrough
- E1306, E1307, E1308. Identity errors