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:
1 2 3 4 5 6 7 8 9 | |
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#
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:
1 2 | |
Handler uses TenantDbDep, which auto-filters on tenant_id=tnt_X. No additional scope check needed.
An operator viewing all invoices:
1 2 | |
Handler uses DbDep, no tenant filter; gates on require_permission("admin:billing").
A service heartbeating:
1 2 | |
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.