Plattform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Modelle Tools & Services
Lösungen
Organisationen Entwickler Internet Service Provider Managed Service Provider AI-in-a-Box
Ressourcen
Support Documentation Blog Downloads
Unternehmen
Über uns Forschung Karriere Investieren Kontakt
Anmelden

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
1
2
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_admins 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:

scdoc
1
2
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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.

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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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
1
2
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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
1
2
3
4
5
6
7
8
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
1
2
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
1
2
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
1
2
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
1
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
1
2
3
4
5
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.

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

bash
1
2
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
1
2
3
4
5
6
7
8
9
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
1
2
3
4
5
6
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
1
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
1
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
1
2
3
4
5
6
7
8
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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
1
2
3
4
5
6
7
8
9
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
1
2
3
4
5
6
7
8
9
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# 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 404s, 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 keysDELETE $SCAIGRID/v1/api-keys/{key_id}. Immediate.
  2. Suspend the ScaiGrid tenantPUT $SCAIGRID/v1/tenants/{id} with {"status": "suspended"}. Users get 403 TENANT_SUSPENDED. Reversible with "active".
  3. Revoke the ScaiKey admin roleDELETE $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 userPUT $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 tenantDELETE $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
Updated 2026-09-10 01:14:20 View source (.md) rev 1