Two auth models, one issuer, zero client-asserted tenancy.
Humans authenticate with OIDC/PKCE against Zitadel
(https://zitadel.cloud.ig1.com) — browser flow on a
workstation, device flow on a headless host. Machines and agents use
phase-26 scoped API keys — a client_id/client_secret pair that
mints short-lived Bearer tokens with client_credentials. Either way, every
call ends up as Authorization: Bearer and the API resolves the
project and permission tier server-side — no header a client sends can
widen its scope.
0 · TLS trust — normally nothing to do
Since 2026-08-25 the public endpoints (*.cloud.ig1.com) serve publicly-trusted Let's Encrypt certificates — no CA to install, nothing to configure. This step matters only if you use the in-lab endpoints (*.10.57.8.64.nip.io), which serve the IG1 internal CA. For those, pull the CA certificate once per machine:
kubectl -n cert-manager get secret ig1-internal-ca-secret \
-o jsonpath='{.data.ca\.crt}' | base64 -d > ig1-internal-ca.pem
Then point every tool at it:
- curl —
--cacert ig1-internal-ca.pemon every call. Examples on this hub target the public cloud.ig1.com endpoints and therefore carry no--cacert; add it only when you are dialling an in-lab name. - ig1 CLI —
--cacert, theIG1_CACERTenv, or pin it to the context:ig1 config set-context lab --cacert-file ig1-internal-ca.pem. - Terraform provider —
ca_bundle = "ig1-internal-ca.pem"(theinsecureflag exists as a lab-only escape hatch — do not use it). - SDKs — python
Configuration.ssl_ca_cert, go a customhttp.Client, typescript a fetch agent.
-k, no --insecure. The CA certificate is public
material — distributing it weakens nothing. Skipping verification does.
1 · OIDC/PKCE — the human flow
Browser flow (workstation)
ig1 login starts a loopback listener on 127.0.0.1:8000 (fallback
18000), opens the system browser at the issuer's authorize endpoint, and
exchanges the returned code with a PKCE S256 verifier. The client is a
public native client — no secret, PKCE only. The requested scope carries the
project-roles claim and the mandatory project audience (without the audience segment the
API's introspection declares tokens inactive):
openid profile email offline_access \
urn:zitadel:iam:org:project:roles \
urn:zitadel:iam:org:project:id:385253743135817916:aud
The resulting tokens land in ~/.ig1/config.json (0600) under the current
context. When the access token has under 30 s left, the CLI transparently runs the
refresh_token grant — offline_access is what issues the refresh
token — and persists the rotated pair (Zitadel rotates refresh tokens; the CLI keeps the
newest). A login is verified immediately with a GET /v1/whoami before it is
stored.
Device flow (headless host, RFC 8628)
ig1 login --device
# ig1: visit https://zitadel.cloud.ig1.com/device and enter code XXXX-XXXX
The CLI POSTs /oauth/v2/device_authorization, prints the user code and
verification URI, then polls the token endpoint with the
urn:ietf:params:oauth:grant-type:device_code grant — honouring
authorization_pending, backing off on slow_down, and failing
cleanly on expired_token / access_denied. The tokens it wins are
stored exactly like the browser flow's.
2 · Scoped API keys — the machine flow (phase 26)
A scoped credential is a Zitadel machine user plus an entry in the API's
dynamic credential store: a kind (api-key for scripts/CI/Terraform,
mcp for agents), a tier, a label, the binding to
your Keystone project, and an optional expiry (1–365 days).
Create
Three equivalent paths — portal (Security → API keys / Agent / MCP credentials → Create), REST, or CLI:
curl -s -X POST \
https://api.cloud.ig1.com/v1/credentials \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"kind":"api-key","tier":1,"label":"ci-runner","expires_in_days":90}'
ig1 credential create --kind api-key --tier 1 --label ci-runner --expires-in 90
client_secret and the pat are in the create response ONLY. They are never
stored by IG1, never in the audit event, never retrievable afterwards — rotation means
revoke + re-issue. (ig1 credential list shows the non-secret
client_id only for credentials created by that CLI on that machine.)
Use
Paste-ready path (2026-08-12): the create response's pat is a
Zitadel personal access token — a Bearer that works as-is until the credential is revoked
or expires (no minting, no 12 h re-mint). It resolves project + tier through the same
introspection path as a minted token.
Headless path: the pair mints a short-lived Bearer at the issuer's token endpoint — Basic-auth with
client_id:client_secret, grant client_credentials, and the
machine-user scope:
curl -s \
-u "$IG1_CLIENT_ID:$IG1_CLIENT_SECRET" \
-d grant_type=client_credentials \
-d "scope=openid urn:zitadel:iam:org:project:roles urn:zitadel:iam:org:project:id:385253743135817916:aud" \
https://zitadel.cloud.ig1.com/oauth/v2/token
The CLI does this for you: IG1_API_KEY=<id>:<secret> (env, never
argv) or ig1 login --api-key, with a 5-minute per-credential token cache
under ~/.ig1/token-cache/ (0600).
How scoping actually works (server-side, always)
- Every call is introspected against Zitadel (RFC 7662, 60 s cache per token hash).
- The introspected user id resolves through the credential store first — the credential's own project and tier — then the static tenant map, then admin roles.
- The OpenStack proxy stamps
X-Project-Idtoward Nova/Neutron/Cinder/Glance itself (admin callers deliberately carry none — an unscoped all-projects view). - Client-supplied tenancy is dead:
X-Tenant-Idhas been ignored since phase 16. There is no header you can send to see another project.
3 · Tiers — the blast-radius contract
| Tier | Name | What it allows | Wire policy (API proxy) |
|---|---|---|---|
| 0 | read-only | list/get everything in the project | GET, HEAD, OPTIONS |
| 1 | operate | tier 0 + create/update/scale/actions | + POST, PUT, PATCH |
| 2 | destructive | tier 1 + deletes | + DELETE |
Static-map and admin-role callers resolve tier 2 (backward compatible). A credential can never outrank its creator: minting a tier-n credential requires holding tier n yourself. Start every machine credential at tier 0 and raise it only for the credentials that must act.
The one documented exception: detaching a volume
(DELETE …/os-volume_attachments/{id}) is tier 1, not tier 2.
Detach destroys nothing — the volume keeps its data, re-attaching restores the state, and
Nova refuses to detach a root volume — so automation that attaches at tier 1 can undo its
own work without holding a fully destructive credential. The exception table is enumerated
and test-asserted (tests/test_tier_overrides.py); notably not in it:
releasing a floating IP (the address returns to a shared pool) and deleting a security
group (it can strand a workload) — both stay tier 2.
Agent-minted credentials are tighter still. The MCP server's
create_credential tool refuses tier 2 outright and makes expiry mandatory,
capped at 7 days (default: tier 0, 7 days) — destructive authority stays a
human decision, and a forgotten agent key dies before anyone would have noticed it to
revoke it. The API itself accepts 1–365 days; the cap is the agent surface's own guardrail.
4 · Rotation and revocation
ig1 credential revoke cred-6f2a1c9e4b7d48f0 --yes
# or: curl -X DELETE \
# https://api.cloud.ig1.com/v1/credentials/cred-6f2a1c9e4b7d48f0 \
# -H "Authorization: Bearer $TOKEN"
Revoke deactivates the Zitadel machine user first (in-flight tokens die at
the next introspection), then marks the store entry revoked. A cross-tenant credential id
answers 404 — existence is never leaked to other tenants. Per-credential
last-used and call counts are at GET /v1/credentials/usage
(ig1 credential list merges the same audit stream).
5 · The error contract (verbatim)
These strings are the contract — agents and scripts may match on them:
| Status | detail (verbatim) | Meaning |
|---|---|---|
| 401 | Missing or invalid Authorization header | no Bearer credentials on the call |
| 401 | Token is not active / Token introspection failed | dead or unverifiable token — re-mint / re-login |
| 401 | Credential has been revoked / Credential has expired | the API key is dead — re-issue |
| 403 | Token carries no project roles; tenant access denied | the user holds no role in the IG1 project |
| 403 | No tenant assigned for user '…': no TENANT_MAP entry and no admin role. Ask an operator to map your Zitadel user to a Keystone project. | no project binding — never a silent fallback |
| 403 | Insufficient credential tier: POST requires tier 1, the caller's credential is tier 0. Tiers: 0 read-only, 1 operate (writes), 2 destructive (deletes). | tier too low for the method (proxied OpenStack/k8s calls) |
| 403 | Insufficient credential tier: this action requires tier 1, the caller's credential is tier 0. Tiers: 0 read-only, 1 operate (writes), 2 destructive (deletes). Create a higher-tier credential under POST /v1/credentials. | tier too low for a first-class route (credentials, alarms, edge, …) |
| 404 | Credential '…' not found | unknown OR cross-tenant id (existence never leaked) |
Debug any auth state with GET /v1/whoami (ig1 whoami -o json) —
it echoes the resolved user, roles, tenant (id, name, resolution source),
all_projects, tier, and originating credential id.