---
title: Multi-tenancy and auth
path: concepts/multi-tenancy-and-auth
status: published
---

# Multi-tenancy and auth

ScaiFlow is multi-tenant from the ground up. Every flow, every catalog package, every credential, every event is scoped to a tenant. Auth flows through [ScaiKey](/docs/scaikey).

## Auth flows

### Canvas → backend (OIDC + PKCE)

```mermaid
sequenceDiagram
  participant User
  participant Canvas
  participant ScaiKey
  participant API as ScaiFlow API
  User->>Canvas: clicks Sign in
  Canvas->>Canvas: generates PKCE verifier + challenge
  Canvas->>ScaiKey: redirect to /authorize?challenge=...
  ScaiKey->>User: authenticate
  ScaiKey-->>Canvas: redirect back with code
  Canvas->>API: POST /v1/auth/exchange { code, verifier }
  API->>ScaiKey: POST /oauth/token { code, verifier, client_secret }
  ScaiKey-->>API: { access_token, refresh_token }
  API-->>Canvas: { access_token, refresh_token }
```

The single ScaiKey **WEB** client is shared by canvas (PKCE, browser-side) and backend (BFF, server-side). The client_secret never reaches the browser. Audience for issued tokens is `client_id` (ScaiKey's default), so verification doesn't need any special audience config.

When ScaiKey isn't configured, the canvas falls back to a dev-token shared-secret bearer that only enables `POST /v1/dev/deploy` (deprecated legacy path).

### Backend → ScaiGrid (token forwarding, with API-key fallback)

When the user calls `POST /v1/flows/{id}/deploy`, the backend forwards their ScaiKey access token to ScaiGrid directly — ScaiGrid accepts the audience-`client_id` JWT natively.

For non-user contexts (the background ScaiKey sync loop, scheduled jobs, dev environments without an authenticated user), the backend falls back to a **per-tenant API key** stored in [ScaiVault](/docs/scaivault) under `tenants/{tenant_id}/scaigrid_api_key`. Tenant admins set this via `POST /v1/tenants/me/scaigrid_credentials`.

You can pin the choice via `SCAIFLOW_DEPLOY_AUTH=user_jwt|api_key` for debugging.

### ScaiKey → backend (webhooks)

When users join/leave ScaiKey groups, ScaiKey fires signed webhooks at `POST /v1/webhooks/scaikey`. The backend verifies the HMAC-SHA256 signature against `SCAIFLOW_SCAIKEY_WEBHOOK_SECRET`, then updates its local user/group mirror.

## The user/group mirror

The backend maintains a local copy of every user and group in the tenant. This is what powers per-flow ACLs, super_admin role checks, and the tenant admin's "all users / all flows" views.

Sync runs on:

- **Webhook delivery** — incremental, near-real-time on join/leave events.
- **Hourly fallback** — `SCAIFLOW_SYNC_INTERVAL_MINUTES` (default 60) runs a full sweep.
- **Manual trigger** — super admins can hit `POST /v1/admin/sync`.
- **CLI bootstrap** — `scaiflow scaikey-register` provisions the ScaiKey application + key, then triggers an initial sync.

The sync prefers `applications.get_effective_users` (platform-tier, returns every user with access regardless of group); falls back to a member-walk over `groups.list_members` for tenant-tier credentials that can't reach the platform API. The fallback is slower (O(N_groups × N_members) vs O(N_users)) but doesn't require platform-tier scope.

## Permissions and roles

ScaiFlow uses two canonical ScaiKey permissions:

- **`scaicore:view`** — read-only access. Required for: listing flows, reading a flow, live preview, validation, browsing catalog, listing ScaiQueue scopes/queues/ScaiBunker bunkers, watching ScaiCore events.
- **`scaicore:manage`** — write access. Required for: create/update/delete flows, deploy, run tests, publish to catalog, resolve checkpoints, invoke a deployed Core.

There are no `scaiflow:*` permission variants — ScaiFlow piggy-backs on the ScaiCore permission pair so a single ScaiKey role config covers both.

### Admin roles (ScaiFlow-local)

Two roles maintained in ScaiFlow's DB (not ScaiKey):

- **`tenant_admin`** — can see every flow in their tenant, manage tenant ScaiGrid credentials, grant `tenant_admin` to others in their tenant.
- **`super_admin`** — platform-wide; can grant either role to anyone, sees all tenants.

Super_admin can be granted in three ways:

1. **Manual** — `POST /v1/admin/users/{user_id}/role` with `role=super_admin, source=manual`. Survives reconciliation.
2. **Group-derived** — set `SCAIFLOW_SUPER_ADMIN_GROUPS=group_id_1,group_name_2`. Members of any listed group are auto-elevated on sync (`source=group`). Removed from the group → auto-demoted.
3. **First-user bootstrap** — initial CLI sync; the first user with admin scopes can self-promote.

## Per-flow ACLs

Each flow has an ACL list:

```jsonc
[
  { "principal_type": "user", "principal_id": "usr_alice", "level": "edit" },
  { "principal_type": "group", "principal_id": "grp_devs", "level": "view" }
]
```

Levels are a strict hierarchy: `view` < `edit` < `deploy` < `admin`. Higher levels imply lower ones.

- **Owner**: implicit `admin` — can't be removed.
- **Super admin**: implicit `admin` on every flow.
- **Tenant admin**: implicit `admin` on every flow in their tenant.
- **Catalog `publish` action**: requires `deploy` ACL or higher.

Edit ACLs via `POST /v1/flows/{id}/acls` and `DELETE /v1/flows/{id}/acls/{acl_id}`.

## Catalog visibility

`CatalogPackage.visibility` is one of:

- **`tenant`** — visible to anyone in the tenant.
- **`groups`** — restricted to specific group IDs (`visibility_group_ids`).

The author of a package implicitly sees it regardless of visibility. Super admins see all packages across all tenants.

## Tenant ScaiGrid credentials

A tenant admin can configure their ScaiGrid API key via:

```bash
curl -X POST "https://scaiflow.example/api/v1/tenants/me/scaigrid_credentials" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "api_key": "sgk_...",
    "base_url": "https://scaigrid.scailabs.ai"
  }'
```

The key + base URL are written to ScaiVault at `tenants/{tenant_id}/scaigrid_api_key` and `.../scaigrid_base_url`. Background jobs and dev-token deploys read from there.

Status endpoint: `GET /v1/tenants/me/scaigrid_credentials` returns `{configured, base_url, masked_key}` (the key is masked as `sgk_…last4`).
