---
order: 10
audience: partners
summary: 'End-to-end procedure for onboarding a customer: ScaiKey tenant and Tenant
  Admin, ScaiGrid tenant, model entitlement and a scoped API key.'
title: "Provisioning a New Tenant (ScaiKey \u2192 ScaiGrid)"
path: partner-tenant-provisioning
status: published
---

# Partner Runbook — Provisioning a New Tenant (ScaiKey → ScaiGrid)

**Audience:** ScaiLabs partners (resellers, MSPs, ISPs) onboarding a new end customer.
**Outcome:** A new tenant in ScaiKey with a first Tenant Admin, the matching tenant in ScaiGrid with a Tenant Admin, and an API key that can reach only an approved set of LLM models.

---

## 1. Conventions

| Variable | Meaning | Example |
| --- | --- | --- |
| `$SCAIKEY` | ScaiKey base URL | `https://scaikey.scailabs.ai` |
| `$SCAIGRID` | ScaiGrid base URL | `https://scaigrid.scailabs.ai` |
| `$PARTNER_ID` | Your partner ID (ScaiKey prefix `prt_`) | `prt_...` |
| `$SK_TOKEN` | ScaiKey admin bearer JWT | see §2 |
| `$SG_TOKEN` | ScaiGrid partner-admin bearer JWT | see §2 |
| `$TENANT_NAME` / `$TENANT_SLUG` | Customer display name / slug | `Acme Corp` / `acme-corp` |
| `$ADMIN_EMAIL` | First Tenant Admin's email | `alice@acme.example` |

ID prefixes differ per system — do not mix them up:

- ScaiKey: partners `prt_`, tenants `tnt_`, users `usr_`, groups `grp_`, apps `app_`
- ScaiGrid: tenants are referenced in the docs as `tenant_acme`, users as `user_abc`, keys as `apikey_xyz`, API key secrets as `sgk_...`

---

## 2. Prerequisites

Before you start, confirm you hold all of the following.

### 2.1 ScaiKey credentials

ScaiKey's admin API lives at `/api/v1/admin/...` and takes a Bearer JWT that either carries an admin role (`super_admin`, `partner_admin`, `tenant_admin`) **or** is a `client_credentials` platform token from a `GLOBAL` `SERVICE` application whose `allowed_scopes` include `admin:read` and `admin:write`.

For automated provisioning, use the service-application route:

1. Have ScaiLabs register a `GLOBAL` / `SERVICE` application for your partner organisation.
2. Ensure its `allowed_scopes` contains `admin:read` **and** `admin:write`. Scopes are constrained by the application's registered `allowed_scopes` — asking for a scope the app isn't registered for silently narrows the token; it does not error.
3. Obtain a token via `client_credentials`. The resulting JWT carries `platform_token: true` and `app_scope: GLOBAL`, which is what unlocks cross-tenant admin access.

If you receive `403 Platform token requires admin:read or admin:write scope`, fix the application's `allowed_scopes` — not the token request.

### 2.2 ScaiGrid credentials

**Recommended: a service principal via `client_credentials`.** ScaiGrid accepts OAuth 2.0 `client_credentials` access tokens issued by ScaiKey — no interactive PKCE flow and no API key needed. Present the JWT as `Authorization: Bearer <jwt>`, exactly like a user token.

- The token's `sub` is the `client_id`, and it carries `token_type=client_credentials`.
- ScaiGrid resolves it to a service identity and honours the roles and module permissions assigned to that `client_id`. **With nothing assigned it falls back to `tenant_user`** — so assign `partner_admin` explicitly, or your provisioning calls will fail with `403` in ways that look like a token problem.
- Registering the client and running the grant are ScaiKey operations; ScaiGrid only consumes the token.

This is the supported path for headless partner automation, and it means the whole runbook can run unattended.

Alternatively, a human partner admin can obtain a JWT through the ScaiKey OAuth 2.0 + PKCE flow (`/v1/auth/identify` → `/v1/auth/authorize` → SSO at ScaiKey → `/v1/auth/token`).

`partner_admin` in ScaiGrid grants: `models:list`, `accounting:view_own`, `accounting:view_tenant`, `accounting:view_partner`, `accounting:manage_budgets`, `users:manage`, `admin:access`.

### 2.3 Capacity check

Your partner record carries `max_tenants`, `max_users_per_tenant` and `max_applications_per_tenant` as top-level integer fields. Check headroom before provisioning:

```bash
curl -s "$SCAIKEY/api/v1/admin/partners/$PARTNER_ID" \
  -H "Authorization: Bearer $SK_TOKEN"
```

A `partner_admin` can read its own partner record, including the `max_tenants` quota and settings. It cannot read other partners, and cannot create or delete partners.

### 2.4 Confirm tenant self-management is enabled

**Do this before anything else — it determines whether Phase 1 is something you can run at all.**

By default, creating, updating and deleting tenants is a super-admin action. A super-admin can delegate it per partner with the **tenant self-management** flag. With it on, your `partner_admin`s may create tenants under your own partner, up to `max_tenants`. With it off, tenant writes return `403 "Tenant self-management is not enabled for this partner"` and provisioning becomes a ticket to ScaiLabs.

Check `settings.allow_tenant_self_management` on the partner record you fetched in §2.3. If it's absent or false, request it from ScaiLabs — only a super-admin can set it:

```
PATCH /api/v1/admin/partners/{id}
{"settings": {"allow_tenant_self_management": true}}
```

A partner admin cannot enable this for their own partner, so don't try.

### 2.5 Information to collect from the customer

- Legal / display name and preferred slug
- Contact email for the tenant
- First Tenant Admin: email, first name, last name
- MFA requirement, allowed MFA methods, password policy, session timeout
- The exact list of model slugs the customer is entitled to (e.g. `scailabs/poolnoodle-omni`, `mistral/large`)

---

## 3. Phase 1 — Create the tenant in ScaiKey

### Step 1.1 — Create the tenant

```bash
curl -X POST "$SCAIKEY/api/v1/admin/tenants/" \
  -H "Authorization: Bearer $SK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Corp",
    "slug": "acme-corp",
    "status": "active",
    "contact_email": "it@acme.example",
    "partner_id": "'"$PARTNER_ID"'"
  }'
```

Expected: `201 Created` with the new tenant object. **Record the `tnt_...` ID as `$SK_TENANT_ID`.**

**Fields.** Required: `name`, `slug` — plus `partner_id` for super-admin callers. Optional: `contact_email`, `status`, `settings`, `branding`. `partner_id` is accepted on create, so there's no separate step to attach the tenant to your partner. Full field table on the [Tenants API reference](https://www.scailabs.ai/docs/scaikey/reference/api/tenants).

**Who may create a tenant.** A `partner_admin` can, provided tenant self-management is enabled for the partner (§2.4). For a partner admin, `partner_id` is ignored and pinned server-side to your own partner — you cannot create a tenant under another one even by passing a different value, so the field in the payload above is belt-and-braces. Creation is capped at the partner's `max_tenants`.

### Step 1.2 — Apply tenant security settings

```bash
curl -X PATCH "$SCAIKEY/api/v1/admin/tenants/$SK_TENANT_ID" \
  -H "Authorization: Bearer $SK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "settings": {
      "mfa_required": true,
      "mfa_methods": ["totp", "webauthn"],
      "session_timeout": 480
    }
  }'
```

Documented `settings` keys: `mfa_required` (bool), `mfa_methods` (list from `totp`, `sms`, `email`, `webauthn`), `password_policy` (dict), `session_timeout` (minutes).

The internal structure of `password_policy` is not currently specified; confirm the shape you need with ScaiLabs support before relying on it.

### Step 1.3 — Verify

```bash
curl -s "$SCAIKEY/api/v1/admin/tenants/$SK_TENANT_ID" \
  -H "Authorization: Bearer $SK_TOKEN"
```

Check `status`, `slug`, and `partner_id`.

---

## 4. Phase 2 — Create the first Tenant Admin in ScaiKey

This is two operations: create the user in the tenant directory, then grant the admin role.

### Step 2.1 — Create the user

Use the tenant-scoped endpoint — it has a fully documented `UserCreate` schema, unlike the `/admin/users` variant:

```bash
curl -X POST "$SCAIKEY/api/v1/tenants/$SK_TENANT_ID/users" \
  -H "Authorization: Bearer $SK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "alice@acme.example",
    "first_name": "Alice",
    "last_name": "Example",
    "display_name": "Alice Example",
    "locale": "en",
    "timezone": "Europe/Amsterdam",
    "require_password_change": true,
    "send_welcome_email": true
  }'
```

`UserCreate` fields: `email` (required), `username`, `first_name`, `last_name`, `display_name`, `phone`, `locale` (default `en`), `timezone` (default `UTC`), `password`, `ou_id`, `group_ids`, `custom_attributes`, `require_password_change` (default `false`), `send_welcome_email` (default `true`).

Expected: `201 Created`. **Record the user ID as `$SK_USER_ID`.**

Notes:

- Omit `password` and leave `send_welcome_email: true` so the customer sets their own credential. If you must set one, use `require_password_change: true`.
- The user's `status` will be one of `ACTIVE`, `PENDING_EMAIL_VERIFICATION`, `PENDING_APPROVAL`, `DISABLED`, `LOCKED`. A new user with a welcome email typically lands in `PENDING_EMAIL_VERIFICATION`.
- `identity_source` will be `LOCAL` here. It becomes `OIDC`/`SAML`/`SCIM`/`LDAP` only if the user is federated. If you later attach a customer IdP, run `GET /api/v1/admin/users/identity-source/mismatches?fix=true` to reconcile.

### Step 2.2 — Grant the TENANT_ADMIN role

```bash
curl -X POST "$SCAIKEY/api/v1/admin/admin-roles/" \
  -H "Authorization: Bearer $SK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "'"$SK_USER_ID"'",
    "role": "TENANT_ADMIN",
    "tenant_id": "'"$SK_TENANT_ID"'"
  }'
```

Documented fields: `user_id`, `role` (`SUPER_ADMIN` | `PARTNER_ADMIN` | `TENANT_ADMIN`), plus `tenant_id` (required for `TENANT_ADMIN`), `partner_id` (required for `PARTNER_ADMIN`), and optional `expires_at` (ISO 8601).

Expected: `201 Created`. **Record the `role_id`** — you need it to revoke later.

**A `partner_admin` can do this.** Assigning and revoking `TENANT_ADMIN` for tenants under its own partner is exactly how a partner seats the first Tenant Admin of a tenant it provisioned. A partner admin cannot assign `SUPER_ADMIN` or `PARTNER_ADMIN`, cannot touch another partner, and cannot edit an existing assignment — `PATCH /{id}` is super-admin-only.

Because `PATCH` is closed to you, **to change an assignment, `DELETE` it and `POST` a new one.** Mind the `cleanup_orphaned_user` default on delete (see §8) — it can soft-delete the user out from under you if they hold no other admin role and have never logged in. Pass `?cleanup_orphaned_user=false` when you intend to re-grant.

Note this step does not depend on the tenant self-management flag. That flag gates tenant writes only; seating a Tenant Admin is always available to a partner admin within its own partner.

Reads are scoped too: `GET` on the list, on a user, or on a single `role_id` returns only assignments within your own partner. **An out-of-scope `role_id` returns `404`, not `403`** — so a role that exists but belongs to another partner is indistinguishable from one that doesn't exist. Don't treat a `404` here as proof the assignment was deleted.

### Step 2.3 — Assign the applications (this is what triggers provisioning downstream)

**This step does more than grant access — it's the provisioning trigger for every ScaiLabs app.** Assigning at least one user in the tenant to an application causes that application to provision the tenant on its own side. This holds across the whole ScaiLabs portfolio, not just ScaiGrid.

The mechanism, precisely: **ScaiKey notifies the target app; the app provisions the tenant itself.** ScaiKey never reaches into the application's data. Notification goes one of two ways:

- **Push** — ScaiKey posts the user and `tenant_id` to the application's registered `sync_webhook_url`.
- **Pull** — the application calls `GET /api/v1/admin/applications/{id}/effective-users`, which returns the users along with full tenant slug, name and partner.

Two things follow from this that matter operationally. Provisioning depends on the downstream app being reachable and correctly configured, so a missing `sync_webhook_url` or a failing endpoint on the app's side looks identical to a missing assignment from ScaiKey's perspective. And because the app does the work, the tenant arrives with whatever defaults that app applies — ScaiKey doesn't dictate its shape.

So assigning your new Tenant Admin to the ScaiGrid application is what brings the tenant into existence in ScaiGrid. Do it here, in Phase 2, and Phase 3 becomes verification rather than creation.

See ScaiKey → Concepts → Applications for the full description of the sync contract.

**Find the application first.** The authoritative list for a tenant:

```bash
curl -s "$SCAIKEY/api/v1/admin/applications?tenant_id=$SK_TENANT_ID&search=scaigrid" \
  -H "Authorization: Bearer $SK_TOKEN"
```

Takes an admin token (super, partner or tenant admin), with results scoped to what the caller can see. Filters: `tenant_id`, `partner_id`, `scope` (`GLOBAL` / `PARTNER` / `TENANT`), `application_type`, `search`.

**A tenant's usable set is its `TENANT` apps plus all `GLOBAL` apps.** `GLOBAL` apps are cross-tenant, so filtering by `tenant_id` alone can look emptier than reality — check both scopes before concluding an app is missing.

Then assign the user, or a group they belong to, to each application the customer is entitled to. Group-based assignment is the better default: it keeps ScaiKey group membership as the single source of truth and pairs naturally with the ScaiGrid role mapping in Step 3.3.

Order matters. Assign the ScaiGrid application **after** the tenant settings in Step 1.2 and the admin role in Step 2.2, so the tenant materialises downstream with its final configuration and an admin already attached.

**Timing.** The sync starts as soon as the app has its first user — whether you assign that user directly or assign a group that already has members. It usually completes within seconds; **allow up to 30 seconds** before treating it as failed. Scripted provisioning should wait and poll Step 3.1 rather than proceeding straight to Phase 3, or the tenant lookup will race the sync and appear to fail.

**Group assignment works — the trigger follows the membership.** What starts the sync is a user reaching the app, not a direct assignment specifically, so assigning a populated group is equivalent. An empty group simply does nothing yet: assign the app first and the sync fires the moment you add the first user to that group.

So the order of "assign the app" and "add the user to the group" doesn't matter. What matters is where you put the wait — poll Step 3.1 after whichever of the two comes second, since that's the step that actually triggers the sync.

**Verify the assignment took.** To see what a specific user can actually use — direct assignments and group-derived ones, including `GLOBAL` apps:

```bash
curl -s "$SCAIKEY/api/v1/apps/users/$SK_USER_ID/applications?tenant_id=$SK_TENANT_ID" \
  -H "Authorization: Bearer $SK_TOKEN"
```

This is the check to run before polling for the tenant in Step 3.1 — if ScaiGrid isn't in this list, the sync has nothing to trigger on.

It's admin-gated and keyed by `user_id`, so it isn't a self-service call. Don't hand it to the customer's Tenant Admin as a "check your own access" endpoint; it requires assignment-admin rights.

> **Don't confuse it with `GET /api/v1/me/authorized-apps`.** That's a user-token call returning apps the current user has already *connected to* — an OAuth consent list, not a catalogue of what's available to them. It will not tell you whether an assignment succeeded, and an app can be assigned without appearing there until the user first authorises it.

**Nothing is auto-assigned.** No application is attached to a tenant or user automatically at creation — every one is an explicit assignment. Two consequences worth internalising:

- **This step is never optional.** A ScaiKey tenant with no app assignments exists in ScaiKey and nowhere else. Skip it and Phase 3 has nothing to find, no matter how long you wait.
- **The app list is a complete inventory of a customer's reach.** Since nothing arrives implicitly, what you see in `GET /api/v1/apps/users/{user_id}/applications` is the whole picture — useful for entitlement reviews and offboarding, where an implicit default would otherwise be easy to miss.

### Step 2.4 — Verify

```bash
curl -s "$SCAIKEY/api/v1/admin/admin-roles/user/$SK_USER_ID" \
  -H "Authorization: Bearer $SK_TOKEN"
```

Confirm one active `TENANT_ADMIN` entry scoped to `$SK_TENANT_ID`.

---

## 5. Phase 3 — Confirm the tenant and seat the Tenant Admin in ScaiGrid

ScaiGrid maintains its own tenant hierarchy (partner → tenant → user), but you don't populate it directly. Tenants arrive from ScaiKey via app assignment (Step 2.3); users originate in ScaiKey and are seated here by role mapping. Humans sign in via SSO, services authenticate with API keys or a `client_credentials` service principal.

### Step 3.1 — Confirm the tenant arrived (do not create it)

The tenant was provisioned in ScaiGrid by ScaiGrid itself, in response to the app assignment in Step 2.3. It carries the **same `tnt_` ID** as its ScaiKey counterpart, and the flow is **one-way, ScaiKey → ScaiGrid**. Your job here is to confirm it landed, not to create it:

```bash
curl -s "$SCAIGRID/v1/tenants" -H "Authorization: Bearer $SG_TOKEN"
```

Find `$SK_TENANT_ID` in the list and carry it forward as `$SG_TENANT_ID` — they're the same value.

**Do not call `POST /v1/tenants` in this flow.** Creating a tenant that already synced produces a duplicate, and you'll have two records competing for the same customer. That endpoint exists for ScaiGrid-only tenants with no ScaiKey counterpart, which is not this runbook's scenario.

If the tenant isn't there, don't reach for `POST` — go back and check that Step 2.3 actually assigned the ScaiGrid application to a user in the tenant. A missing tenant downstream almost always means a missing app assignment upstream.

**Give it 30 seconds.** The sync starts when the app first has a user behind it — assigned directly, or arriving through a group you've mapped (Step 2.3) — and usually finishes within seconds. If the tenant isn't in the list on your first call, wait and retry — a simple poll:

```bash
for i in $(seq 1 6); do
  curl -s "$SCAIGRID/v1/tenants" -H "Authorization: Bearer $SG_TOKEN" \
    | grep -q "$SK_TENANT_ID" && { echo "tenant synced"; break; }
  sleep 5
done
```

Only after 30 seconds with no result should you treat it as a failure — and then check Step 2.3, not `POST /v1/tenants`.

### Step 3.2 — Apply a tenant template (optional but recommended)

If you maintain a standard customer profile, apply it now — it enables modules, creates custom roles and sets budget defaults in one call:

```bash
curl -X POST "$SCAIGRID/v1/tenant-templates/$TEMPLATE_ID/apply/$SG_TENANT_ID" \
  -H "Authorization: Bearer $SG_TOKEN"
```

Templates are additive — they never remove anything the tenant already has.

### Step 3.3 — Get the admin user into ScaiGrid

Users are either provisioned manually by a tenant admin, or appear automatically the first time they authenticate via ScaiKey SSO — depending on your group-mapping configuration.

**Recommended: group mapping (works before first login).** Create the ScaiKey group in Phase 2 (e.g. `acme-admins`), add the admin to it, then map it:

```bash
curl -X POST "$SCAIGRID/v1/role-mappings" \
  -H "Authorization: Bearer $SG_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "scaikey_group": "acme-admins",
    "scaigrid_role": "tenant_admin",
    "tenant_id": "'"$SG_TENANT_ID"'",
    "auto_revoke": true
  }'
```

`auto_revoke: true` means removing the user from the ScaiKey group strips the role on their next authentication. This is the cleanest lifecycle: ScaiKey group membership becomes the single source of truth.

**Alternative: direct role assignment (after first login).** Have the admin sign in once so the record exists, find them, then set roles:

```bash
curl -s "$SCAIGRID/v1/users?limit=50" -H "Authorization: Bearer $SG_TOKEN"

curl -X PUT "$SCAIGRID/v1/users/$SG_USER_ID/roles" \
  -H "Authorization: Bearer $SG_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"roles": ["tenant_admin"], "custom_role_ids": []}'
```

Both require `users:manage`, which `partner_admin` has.

**Identity-only provisioning is by design.** ScaiGrid has no `POST /v1/users` and is not meant to. Users originate in ScaiKey and reach ScaiGrid through sync; you seat them with role mappings or `PUT /v1/users/{id}/roles`. The group-mapping route above is therefore the intended path, not a workaround, and it's what lets provisioning finish without the customer logging in first.

`tenant_admin` in ScaiGrid grants `models:list`, `models:use`, `api_keys:manage`, `modules:use`, `routing:view`, `accounting:view_tenant`, `accounting:manage_budgets`, `users:manage`, `webhooks:manage`, `modules:manage`, `admin:access`.

Note: tenant admins and partner admins receive **every module permission by default** through a short-circuit in `has_module_permission`. Platform-scope module routes are gated separately on role, but be aware the customer's admin is broadly privileged across enabled modules.

### Step 3.4 — Verify

Have the admin sign in to the ScaiGrid admin UI and call:

```bash
curl -s "$SCAIGRID/v1/me" -H "Authorization: Bearer $ADMIN_JWT"
```

Confirm `tenant_id` matches `$SG_TENANT_ID` and `roles` contains `tenant_admin`.

---

## 6. Phase 4 — Restrict models and issue the API key

**Read this section fully before executing.** ScaiGrid does not bind model permissions to an API key. The key inherits the permissions of the user it belongs to, and model entitlement is enforced by tenant-scoped access policies. The recipe below composes those two mechanisms; §6.5 explains what it does and does not guarantee.

### Step 4.1 — Confirm the model slugs

```bash
curl -s "$SCAIGRID/v1/models" -H "Authorization: Bearer $SG_TOKEN"
```

Slug conventions: `openai/gpt-4o` (platform, provider-supplied), `scailabs/poolnoodle-omni` (platform, ScaiLabs), `partner/{partner_slug}/...`, `tenant/{tenant_slug}/...`. Slugs containing `/` need `%2F` encoding in path parameters.

Without an explicit access entry, **models are implicitly allowed**. Entitlement is therefore a deny-by-exception system, not an allow-list — which matters for the ordering below.

### Step 4.2 — Group the approved models

```bash
curl -X POST "$SCAIGRID/v1/model-groups" \
  -H "Authorization: Bearer $SG_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme entitled models",
    "description": "Models Acme Corp is contracted for",
    "members": ["scailabs/poolnoodle-omni", "mistral/large"]
  }'
```

**Record the group ID as `$MODEL_GROUP_ID`.**

### Step 4.3 — Grant the group, deny everything else

Grant the entitled group:

```bash
curl -X POST "$SCAIGRID/v1/model-access" \
  -H "Authorization: Bearer $SG_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "scope_type": "tenant",
    "scope_id": "'"$SG_TENANT_ID"'",
    "model_group_id": "'"$MODEL_GROUP_ID"'",
    "enabled": true,
    "rate_limit": 100
  }'
```

Then, because unlisted models are implicitly allowed, explicitly disable every model the customer is **not** entitled to:

```bash
curl -X POST "$SCAIGRID/v1/model-access" \
  -H "Authorization: Bearer $SG_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "scope_type": "tenant",
    "scope_id": "'"$SG_TENANT_ID"'",
    "model_slug": "openai/gpt-4o",
    "enabled": false
  }'
```

Repeat per excluded model, or maintain a second "denied" model group and disable it as a unit.

**Entitlement resolution — now specified.** Three rules govern this:

- **Implicit allow.** A model with no matching policy is allowed. Entitlement is deny-by-exception.
- **`scope_type` is `tenant` or `partner`** — those two values only. A partner-scope policy applies across all that partner's tenants, which is the efficient way to deny a model fleet-wide.
- **Most-restrictive-wins across partner + tenant.** A model is usable only if it isn't disabled at *either* scope. Any matching `enabled: false` denies. A model-slug deny overrides a model-group allow, at the same scope or across scopes.

So the ordering below is safe: the deny entries win regardless of what the group allow says.

> **Closed enrolment.** Because a newly published platform model is visible until explicitly denied, for partners who need closed enrolment, run a deny-by-default reconciliation: deny new slugs, then allow the intended set per tenant or partner. Consider running this at partner scope so it covers every tenant you onboard, rather than repeating it per customer.

### Step 4.4 — Create the API key

The key belongs to one user in one tenant and inherits that user's permissions. Decide who owns it:

- **Preferred:** a dedicated service identity with the narrowest workable role (`tenant_user` — which carries `models:use`, `api_keys:manage`, `modules:use`, `models:list`, `accounting:view_own`), so the key cannot administer the tenant. Because ScaiGrid has no direct user-creation endpoint (§5.3), the service identity needs a ScaiKey user plus a group mapped to `tenant_user`.
- **Avoid:** issuing the integration key from the Tenant Admin's own account. That key would carry `users:manage`, `modules:manage` and `admin:access`, plus every module permission — far more than an inference integration needs.

Create it (called with the owning user's token):

```bash
curl -X POST "$SCAIGRID/v1/api-keys" \
  -H "Authorization: Bearer $SERVICE_USER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "acme-prod-integration",
    "expires_at": "2027-09-09T00:00:00Z",
    "allowed_model_slugs": ["scailabs/poolnoodle-omni", "mistral/large"],
    "assigned_user_id": null
  }'
```

`allowed_model_slugs` is the per-key allowlist. Its semantics:

- A non-empty list **narrows** the key to exactly those slugs.
- It narrows **only within** what the tenant is already entitled to — **it never widens access**. Listing a model the tenant is denied does not grant it.
- Empty or absent means the key can use whatever the tenant can.

This is what lets two keys in the same tenant carry different model entitlements. It's a second, independent layer on top of the tenant/partner policies from §6.3 — so keep §6.3 in place as the contractual boundary and treat the allowlist as per-integration least privilege. Don't rely on the allowlist alone: it's per-key, so a new key created by the tenant admin defaults to the full tenant entitlement.

`assigned_user_id` is the key's **permission source** when set — the user whose permissions the key borrows. But the key's **identity** (its `user_id`, what `/v1/me` reports, and what audit and ownership checks key on) is always the creator/owner, never the assigned user. When `assigned_user_id` is `null` (as here), the key both *is* and draws its permissions from the owning service user — so make the creating token that dedicated service identity, not the tenant admin.

**This owner-vs-assignee split is a footgun.** A key scoped by `assigned_user_id` still acts as the owner for anything that checks the acting user, so a key minted by a tenant admin and pointed at a low-privilege user is not thereby a low-privilege key everywhere. Scope by *who creates it*, and treat `assigned_user_id` as a refinement rather than a containment boundary.

`scopes` in the response is **informational**, a readout rather than an input; don't try to set it.

> **Field behaviour.** `allowed_model_slugs`, `assigned_user_id`, `name` and `expires_at` are the fields on the `POST /v1/api-keys` create body (`APIKeyCreate`). A non-empty `allowed_model_slugs` is deduplicated and empty-to-`null` normalised server-side. `assigned_user_id` and `assigned_group_id` are mutually exclusive. `scopes` is response-only.

Response — **the full key is shown exactly once**; the database stores only a SHA-256 hash:

```json
{
  "data": {
    "id": "apikey_xyz",
    "name": "acme-prod-integration",
    "key": "sgk_full_key_here_shown_once",
    "prefix": "sgk_",
    "scopes": [],
    "expires_at": "2027-09-09T00:00:00Z",
    "created_at": "2026-09-09T..."
  }
}
```

Deliver the key to the customer through an out-of-band secure channel and record only `apikey_xyz` and its `prefix` in your own systems. Keys never expire unless you set `expires_at` — always set it.

### Step 4.5 — What this actually enforces

Be precise with the customer about the guarantee:

| Claim | True? |
| --- | --- |
| The key can only call models the **tenant** is entitled to | Yes — enforced at tenant and partner scope, most-restrictive-wins |
| The key can only call a subset of what **other keys in the same tenant** can call | Yes — via `allowed_model_slugs` on that key |
| The allowlist can grant a model the tenant is denied | **No** — it narrows only, never widens |
| Other keys in the tenant are also narrowed | **No** — the allowlist is per-key; a new key defaults to the full tenant entitlement |
| The key's blast radius is limited to one user's permissions | Yes — the owner's, or the `assigned_user_id`'s when one is set; note the key still acts as the **owner** for identity and audit |
| The key can be revoked independently and immediately | Yes — `DELETE /v1/api-keys/{key_id}`, effective immediately including in-flight requests |

Two keys in one tenant with different model entitlements is now a supported configuration. The earlier workaround of splitting into two tenants is no longer needed.

The layering is worth stating plainly to customers: the tenant/partner policies are the contractual boundary, and the per-key allowlist is least privilege for one integration. A tenant admin holding `api_keys:manage` can mint a key with no allowlist and reach everything the tenant is entitled to — so the allowlist constrains the integration, not the customer.

### Step 4.6 — Verify the key

```bash
# Should list only the entitled models
curl -s "$SCAIGRID/v1/models" -H "Authorization: Bearer sgk_..."

# Should identify the owning service user with the expected roles
curl -s "$SCAIGRID/v1/me" -H "Authorization: Bearer sgk_..."

# Positive test — should succeed
curl -X POST "$SCAIGRID/oai/v1/chat/completions" \
  -H "Authorization: Bearer sgk_..." \
  -H "Content-Type: application/json" \
  -d '{"model": "scailabs/poolnoodle-omni", "messages": [{"role": "user", "content": "ping"}]}'

# Negative test — should be refused
curl -X POST "$SCAIGRID/oai/v1/chat/completions" \
  -H "Authorization: Bearer sgk_..." \
  -H "Content-Type: application/json" \
  -d '{"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "ping"}]}'
```

**Do not skip the negative test.** Given the implicit-allow default, it is the only thing that proves the entitlement actually bit.

> **Endpoint.** The body above is OpenAI-shaped (`{model, messages}`), so it goes to the OpenAI-compatible surface: `POST $SCAIGRID/oai/v1/chat/completions`, mounted at the `/oai/v1` prefix. **There is no `/v1/chat/completions`** — that path `404`s, and a `404` here is easy to mistake for the entitlement biting, which would make the negative test above pass for the wrong reason. ScaiGrid's native inference surface is `POST $SCAIGRID/v1/inference/chat`, but it takes a different (non-`messages`) request shape; use the OpenAI path for the body shown.

---

## 7. Post-provisioning

### 7.1 Budgets

Set a spend cap so a runaway integration cannot generate an unbounded bill. Budgets enforce at partner, tenant and user level; a tenant over its cap blocks its users. See the Accounting and Budgets documentation.

### 7.2 Rate limits

API keys, users, tenants and partners each carry independent limits. Exceeding one returns `429 RATE_LIMITED` with `Retry-After`. The `rate_limit` field on a model-access policy (used in §6.3) applies at that policy's scope.

### 7.3 Handover pack for the customer

- Tenant slug and ScaiGrid base URL
- Tenant Admin sign-in instructions (SSO via ScaiKey)
- API key, out of band, with its `expires_at` and rotation date
- The entitled model slugs
- Reminder: never embed `sgk_` keys in client-side code; one key per service, per environment; rotate at minimum on personnel changes

### 7.4 Record in your own systems

`$SK_TENANT_ID`, `$SK_USER_ID`, ScaiKey `role_id`, `$SG_TENANT_ID`, `$MODEL_GROUP_ID`, each model-access policy ID, `apikey_xyz`, and key expiry. You need all of them for §8.

---

## 8. Rollback and deprovisioning

Run in this order — it revokes access before removing the records that let you find things.

1. **Revoke API keys** — `DELETE $SCAIGRID/v1/api-keys/{key_id}`. Immediate.
2. **Suspend the ScaiGrid tenant** — `PUT $SCAIGRID/v1/tenants/{id}` with `{"status": "suspended"}`. Users get `403 TENANT_SUSPENDED`. Reversible with `"active"`.
3. **Revoke the ScaiKey admin role** — `DELETE $SCAIKEY/api/v1/admin/admin-roles/{role_id}`. Takes effect **immediately** for ScaiKey's own admin API (it does a live lookup). Note `cleanup_orphaned_user` defaults to `true` and will soft-delete the user if they have no remaining admin roles, exist in multiple tenants with the same email, and have never logged in. Pass `?cleanup_orphaned_user=false` if you want the user preserved.
4. **Disable or delete the ScaiKey user** — `PUT $SCAIKEY/api/v1/tenants/{tenant_id}/users/{user_id}/status` with `{"status": "DISABLED", "terminate_sessions": true}`, or `DELETE` (soft delete; requires `confirm_email` as a query parameter).
5. **Soft-delete the ScaiKey tenant** — `DELETE $SCAIKEY/api/v1/admin/tenants/{tenant_id}`.

### Revocation timing — read before promising an SLA

ScaiKey access tokens are stateless JWTs verified locally against the published JWKS. There is no per-token revocation list, so most "revoke" actions do not invalidate in-flight access tokens:

| Action | Effective |
| --- | --- |
| Suspending or deleting a user | Next refresh, or token `exp` — whichever comes first |
| Removing a user from a group | Next refresh (the `groups` claim is re-evaluated then) |
| Revoking an admin role | **Immediately** for ScaiKey's admin API; at refresh for other resource servers |
| Revoking a refresh token | Refresh chain dies immediately; already-issued access tokens live until `exp` |
| Disabling an application | New token requests fail; in-flight access tokens live until `exp` |

Worst-case staleness equals the audience application's `token_lifetime` — 1 hour by default, configurable down to 5 minutes. For an urgent revocation, **revoke the ScaiGrid API key** (immediate) rather than relying on identity-side changes.

---

## Appendix A — Quick reference

### ScaiKey

| Purpose | Call |
| --- | --- |
| Create tenant | `POST /api/v1/admin/tenants/` |
| Update tenant settings | `PATCH /api/v1/admin/tenants/{tenant_id}` |
| Create user in tenant | `POST /api/v1/tenants/{tenant_id}/users` |
| Grant admin role | `POST /api/v1/admin/admin-roles/` |
| List a user's admin roles | `GET /api/v1/admin/admin-roles/user/{user_id}` |
| Revoke admin role | `DELETE /api/v1/admin/admin-roles/{role_id}` |
| Set user status | `PUT /api/v1/tenants/{tenant_id}/users/{user_id}/status` |
| Reset password | `POST /api/v1/tenants/{tenant_id}/users/{user_id}/password/reset` |
| List applications in a tenant | `GET /api/v1/admin/applications?tenant_id={id}` |
| Apps a user can use (direct + group) | `GET /api/v1/apps/users/{user_id}/applications?tenant_id={id}` |
| Audit trail | `GET /api/v1/admin/audit` |

### ScaiGrid

| Purpose | Call |
| --- | --- |
| Confirm tenant synced from ScaiKey | `GET /v1/tenants` (do not `POST` in this flow) |
| Apply template | `POST /v1/tenant-templates/{template_id}/apply/{tenant_id}` |
| Map ScaiKey group → role | `POST /v1/role-mappings` |
| Set user roles | `PUT /v1/users/{user_id}/roles` |
| Create model group | `POST /v1/model-groups` |
| Set model access | `POST /v1/model-access` |
| Create API key | `POST /v1/api-keys` |
| Revoke API key | `DELETE /v1/api-keys/{key_id}` |
| Current identity | `GET /v1/me` |
| Suspend tenant | `PUT /v1/tenants/{tenant_id}` with `status: suspended` |

### Error codes worth recognising

| Code | Meaning |
| --- | --- |
| `403 AUTHZ_PERMISSION_DENIED` | Caller lacks a permission. Deliberately does not say which one — inspect the user's permissions directly. |
| `403 TENANT_SUSPENDED` | Tenant (or its partner) is suspended |
| `401 AUTH_TOKEN_INVALID` | Signature invalid or expired |
| `403 Platform token requires admin:read or admin:write scope` | ScaiKey service app's `allowed_scopes` is missing them |
| `429 RATE_LIMITED` | Rate limit hit; honour `Retry-After` |
