Signup queue → admission check → one atomic onboard.
A customer reaches IG1 two ways: the public signup queue (a request an
operator approves) or a direct admin onboard. Either way the provisioning
call is the same — POST /v1/tenants builds a real Keystone project with the
tier's quota set, the network substrate, a Zitadel machine user and its first credential,
atomically, with rollback. There is no step two.
1 · The signup queue (public)
POST /v1/signup is the only unauthenticated write on the
API. It creates one row in a bounded queue and nothing else — no Zitadel user, no
Keystone project, no quota — so an abusive signup costs the platform nothing. The public
response is identical whether or not the address is already queued: a 409 would turn
signup into an account-existence oracle.
curl -s -X POST \
https://api.cloud.ig1.com/v1/signup \
-H "Content-Type: application/json" \
-d '{"email": "jane.doe@ig-1.net", "org_name": "Acme Corp"}' # → 202
Operators review the queue (admin-only):
GET /v1/signups?state=pending # the queue
GET /v1/signups/capacity # the sellable budget, before and after each tier
POST /v1/signups/{signup_id}/approve # body: {"tier": "discovery"} — runs the onboard
POST /v1/signups/{signup_id}/reject
2 · The onboard call (admin)
curl -s \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-X POST https://api.cloud.ig1.com/v1/tenants \
-d '{"name": "Acme Corp", "tier": "discovery"}' # tier omitted = discovery
The response carries the tenant record — project, tier, quotas, the network substrate it built — and the reveal-once client pair for the tenant's first machine user. Hand it to the customer through your secret channel; it is never stored and never retrievable again:
{
"tenant": {
"slug": "acme-corp",
"project_id": "d7e2…",
"project_name": "ig1-customer-acme-corp",
"tier": "discovery",
"quotas": { "nova": {…}, "cinder": {…}, "neutron": {…} }, // the tier's set
"network": { "network_id": "…", "subnet_id": "…", "router_id": "…", "external_network_id": "…" }
},
"credential": {
"id": "cred-…", "tier": 2,
"client_id": "ig1-tenant-acme-corp",
"client_secret": "…" // this response only
}
}
3 · The quota tiers
Tiers live in config/quota-tiers.yml, and every number there is justified
against measured platform capacity — never a public cloud's defaults.
The rule that matters is not the per-tier table but the aggregate invariant: the sum of
granted resources across live tenants must stay inside the sellable budget, checked at
approval time and asserted in CI, so an edit that oversells the fleet fails the build
instead of production.
| Tier | Compute | Block storage | Network | Object | KaaS | Edge |
|---|---|---|---|---|---|---|
| discovery (default) | 8 cores · 16 GiB · 5 instances | 8 volumes · 80 GiB | 3 networks · 2 routers · 2 FIPs | 5 buckets · 20 GiB | 1 cluster · 3 workers | 1 exposure |
| standard | 32 cores · 64 GiB · 20 instances | 32 volumes · 300 GiB | 12 networks · 4 routers · 8 FIPs | 25 buckets · 100 GiB | 3 clusters · 10 workers | 4 exposures |
| extension | 128 cores · 256 GiB · 60 instances | 100 volumes · 1 200 GiB | 24 networks · 8 routers · 16 FIPs | 100 buckets · 500 GiB | 8 clusters · 30 workers | 10 exposures |
Two more states complete the machine: pending (a signed-up account
nobody has approved — no Keystone project at all, so there is nothing to apply numbers
to) and suspended — an all-zero quota PUT over a live project, the
object-storage quota on the tenant's S3 user included, so direct S3 writes stop with
the same call. Running workloads keep running while nothing new can be created:
reversible and non-destructive, the correct first response to abuse, ahead of deleting
anything. One limit is real: Cinder refuses a block-storage quota below current usage
and has no override, so when a tenant's volumes exceed the target tier the
block keys are held at current usage (nothing new can be created,
nothing is destroyed), everything else moves, and the response lists each held key
under applied.cinder_held_at_usage — a re-run re-clamps to the then-current
usage (Nova and Neutron are forced through and keep running workloads running).
Moving a tenant between tiers is an admin call, admission-checked exactly like approval:
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-X POST https://api.cloud.ig1.com/v1/tenants/d7e2.../tier \
-d '{"tier": "standard"}'
# or: ig1 tenant set-tier d7e2... --tier standard
The call is idempotent and ordered: Nova, Cinder and Neutron quota sets first, then the
tenant's S3 user quota on the object gateway, and the ig1:tier= project tag
last — a project never advertises a tier its limits do not match. If the object
gateway refuses the quota write the call answers 502 with the OpenStack
quotas already applied and the tag unwritten (the response says which, and whether the
failure is transient — re-run — or structural — the gateway release or the service
account's caps refuse the call as shaped, so a re-run cannot help). Re-running the same
call converges a half-moved tenant; nothing is skipped silently. (api 0.11.1–0.11.4: the
object-gateway quota write on this call had been refused by the gateway for every tenant
— a request-shape bug on our side, fixed and pinned by test.)
4 · What the customer sees — GET /v1/quotas
Tier 0, self-service: the caller's own limits and usage, with the tier that set them. The
project is derived from the credential — ?project= is an admin-only override,
refused with a constant-shape 403 for everyone else regardless of whether the project
exists.
curl -s -H "Authorization: Bearer $TOKEN" \
https://api.cloud.ig1.com/v1/quotas
# or: ig1 quota show
The platform.object_storage rows carry the tier's limit and
the object gateway's live ceiling — enforced and used per row, plus
an enforcement verdict (source: rgw with matches_tier;
source: tier before the tenant's first S3 key exists; unavailable if
the gateway is not answering). Object-storage limits live only on the gateway — no OpenStack
command lists them — so this is the one place a customer or operator can see the number that
actually binds S3 writes, and whether it agrees with the tier.
5 · What the customer gets
- Isolation — their tokens resolve to exactly their Keystone project
(
tenant_source: credential-store); every list/create is scoped server-side. No shared pilot project. - The network substrate — a tenant network, subnet and router
uplinked to the external network (SNAT on; floating IPs attach through
it — since 2026-08-15, gotcha 151) built at onboard (and repairable later:
POST /v1/tenants/{project_id}/networkis idempotent, admin-only, and also uplinks a router provisioned gateway-less before that date). - Every surface — the same client pair drives the REST API, the CLI, Terraform, and the MCP agent surface.
Customer first steps
# mint
TOKEN=$(curl -s \
-u "ig1-tenant-acme-corp:$CLIENT_SECRET" \
-d grant_type=client_credentials \
--data-urlencode "scope=openid urn:zitadel:iam:org:project:roles urn:zitadel:iam:org:project:id:385253743135817916:aud" \
https://zitadel.cloud.ig1.com/oauth/v2/token | jq -r .access_token)
# resolve — expect tenant_id = their project, all_projects = false
curl -s -H "Authorization: Bearer $TOKEN" \
https://api.cloud.ig1.com/v1/whoami
Operations notes
- Admin gate — a customer credential (even tier 2) cannot onboard
tenants or approve signups: both handlers require a resolved admin caller
(
all_projects). - Rollback — if any leg fails mid-onboard, everything already built (Keystone project, network substrate, Zitadel users) is deleted; no half-built tenants. An unknown tier is refused before anything is touched.
- Listing —
GET /v1/tenantslists theig1-customer-*projects. - Rotation — additional credentials for the same tenant come from the
standard
POST /v1/credentialswith the tenant's own token (the phase-26 flow), never by re-onboarding.