---
title: Authentication & RBAC
path: concepts/authentication-and-rbac
status: published
---

# Authentication & RBAC

ScaiControl trusts ScaiKey for identity. Every request to a protected endpoint carries a JWT issued by ScaiKey; ScaiControl validates the signature, extracts claims, merges in local roles, and dispatches the request with a `CurrentUser` in scope.

## The JWT

Issued by ScaiKey at `<SCAIKEY_ISSUER>/oauth/authorize` + `/oauth/token`. Standard OIDC ID token + access token. The access token's claims carry the bits ScaiControl needs:

```json
{
  "sub": "usr_…",
  "iss": "https://scaikey.scailabs.ai",
  "exp": 1715432400,
  "tenant_id": "tnt_…",
  "partner_id": "prt_…",
  "roles": ["tenant_admin"],
  "permissions": ["billing:manage", "admin:billing", ...]
}
```

ScaiControl validates against ScaiKey's JWKS endpoint (`<issuer>/api/v1/platform/.well-known/jwks.json`) with `cache_ttl=3600`. The keys rotate periodically; the cache picks up new ones on next refresh.

## How it reaches the handler

```mermaid
flowchart TB
    R["HTTP request"]
    GCU["<b>get_current_user()</b><br/>dependencies.py<br/>• extract Bearer token<br/>• validate JWT signature + iss/exp<br/>• parse claims → CurrentUser"]
    RP["<b>require_permission(\"scope:action\")</b><br/>where applicable<br/>• check scope in user.permissions<br/>• super_admin bypasses ALL scope checks"]
    H["handler(user: CurrentUserDep, db: DbDep)"]
    R --> GCU --> RP --> H
```

`DbDep` is just the async session. `TenantDbDep` is the same session with `tenant_id` pushed into `session.info` so tenant-scoped queries get auto-filtered.

## Scopes

The convention is `<area>:<action>`. Active scopes:

| Scope | Grants |
|---|---|
| `admin:billing` | Invoice + template + pack + webhook admin |
| `admin:tenants` | Tenant listing + billing-profile management |
| `admin:partners` | Partner config edits |
| `admin:subscriptions` | Subscription lifecycle |
| `admin:users` | User management |
| `admin:groups` | Group + role management |
| `admin:provisioning` | Provisioning workflow control |
| `admin:registry` | Service registry edits |
| `admin:accounting` | Accounting integration management |
| `admin:platform` | Platform-wide settings |
| `admin:usage` | Usage / metering read |
| `billing:manage` | Tenant-self billing-profile edits |
| `billing:read` | Tenant-self invoice listing |
| `subscriptions:read` | Tenant-self subscription listing |
| `services:read` | Catalog browsing |
| `registry:manage` | Service-registry write access (separate from `admin:registry`; for service-to-platform calls) |

Scope strings are stable. New scopes can be added freely; renaming is a breaking change requiring sync with ScaiKey.

## Roles

Roles bundle scopes server-side. The defaults:

| Role | Scopes |
|---|---|
| `super_admin` | Bypasses every `require_permission()` check — full access |
| `partner_admin` | Most `admin:*` scopes, but scoped to the partner's own tenants |
| `tenant_admin` | `billing:manage`, `billing:read`, `subscriptions:read`, `services:read` |
| (no role) | Tenant user — catalog + own services + own subscriptions |

ScaiKey owns the role assignment. ScaiControl reflects role membership locally via `services/identity_sync.py` so it can merge "user has role X" with "X holds scopes Y, Z" to produce the actual permission set per request.

## Merged permissions

The JWT carries a snapshot of permissions at issue time. ScaiKey's permissions may have changed since (e.g. operator added a role to the user). ScaiControl re-fetches the canonical permission list from `/auth/me` on each token refresh, so a user's effective access stays current without re-logging-in. This is the fix for the "super-admin downgrade after refresh" issue.

## Tenant filtering

Every model that inherits from `TenantScopedModel` automatically gets a `WHERE tenant_id = <session.info["tenant_id"]>` predicate added to its SELECTs by the `do_orm_execute` event hook (`db/tenant_filter.py`). To bypass for admin operations, use `DbDep` (which doesn't push tenant_id into session.info) instead of `TenantDbDep`.

The bypass is intentional: admins legitimately need to see other tenants. The risk is reduced by gating those endpoints on `require_permission("admin:*")`.

## Service-to-service (no human)

Services that integrate with ScaiControl — registering themselves, reporting metering, etc. — authenticate with a **ScaiKey app token**, which is a JWT with `token_type="service"` and an `app_id` claim instead of `tenant_id`. These hit a different set of endpoints (`/api/v1/registry/*`, `/api/v1/metering/*`) gated by `require_permission("registry:manage")` etc.

The CRM-driven plan-change flow (post-MVP) will use an app token with a `scaicontrol:admin` scope — not yet implemented; on the v1.x roadmap.

## API keys (for docs and integrations)

Separate from JWTs, the ScaiCMS docs subsystem and a handful of operator integrations issue long-lived API keys (`scai_live_…`). Those are bearer tokens like JWTs but with a different validation path (they're checked against an `api_keys` table, not signed). ScaiControl uses one of these against the docs subsystem; outside of that, it doesn't issue or accept API keys today.

## Common patterns

**A tenant admin viewing their own billing**:

```
GET /api/v1/billing/invoices
Authorization: Bearer <jwt with tenant_admin role, tenant_id=tnt_X>
```

Handler uses `TenantDbDep`, which auto-filters on `tenant_id=tnt_X`. No additional scope check needed.

**An operator viewing all invoices**:

```
GET /api/v1/admin/billing/invoices
Authorization: Bearer <jwt with super_admin or admin:billing scope>
```

Handler uses `DbDep`, no tenant filter; gates on `require_permission("admin:billing")`.

**A service heartbeating**:

```
POST /api/v1/registry/heartbeat
Authorization: Bearer <app token with registry:manage scope>
```

Handler checks `CurrentUser.token_type == "service"` and `app_id` against the registered service row.

## Bypass and audit

`super_admin` is the only operational bypass. Every admin action that mutates state writes to `audit_log` (`resource_type`, `resource_id`, `action`, `user_id`, `details` JSON) — this is what the per-row "History" feature in the admin UI reads. The audit log is partitioned by month; partitions can be archived without dropping the table.
