Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

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
1
2
3
4
5
6
7
8
9
{
  "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#

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:

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

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

verilog
1
2
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.

Updated 2026-05-18 01:48:39 View source (.md) rev 2