Studio Backend
Authentication and Authorization
Studio users are authenticated by Keycloak; the backend trusts Keycloak’s access tokens and decides what a user may do from access control lists stored in MongoDB. Claude, connecting through the hosted MCP server, uses tokens issued by the Studio itself — that flow is described in OAuth Authorization Server.
Studio Sign-In
The Studio uses NextAuth (Auth.js v5, frontend/auth.ts) with two Keycloak providers, google and
microsoft. Both are the same realm and client and differ only in their callback URL. Where Keycloak
sends the user is decided per sign-in by the kc_idp_hint the login passes
(lib/authorization/login-methods.ts, lib/authorization/sign-in.ts):
| Way in | Behavior |
|---|---|
Google, Microsoft |
The hint names the social provider, with |
Work email |
No hint: Keycloak asks for the email address and picks the company’s identity provider from its
domain (home realm discovery). Company sign-ins finish on the |
|
A deep link for a company: straight to the Keycloak identity provider with that alias — the |
After a company sign-in the Studio remembers the provider for a year in the last_idp cookie, with
its display name from GET /api/v1/user/identity-provider in last_idp_name. Both cookies are scoped
to /login, where that provider is then offered first.
| Aspect | Behavior |
|---|---|
Session |
NextAuth’s JWT strategy: the session is an encrypted cookie, |
Refresh |
Each time the session is read, an access token within 30 seconds of expiry is refreshed with the
refresh token ( |
Calling the backend |
Client components read |
Sign-out |
Also ends the Keycloak session, through the realm’s logout endpoint with the refresh token. |
The browser talks to the backend directly with the bearer token; the Next.js server is not a proxy
for the REST API. The voice-preview route (/api/realtime-configurations/preview) is the exception:
audio elements fetch it by URL and cannot attach a header, so the route adds the session’s token on
the server.
Backend Authentication
SecurityConfiguration defines the application-wide filter chain: every exchange is permitted, CSRF
protection is off, CORS allows any origin, and the OAuth2 resource server validates Keycloak JWTs
against spring.security.oauth2.resourceserver.jwt.issuer-uri. The consequences:
-
A request with a valid bearer token is authenticated; one with an invalid or expired token is rejected with
401; one without a token proceeds as anonymous. -
Every authorization decision is made per method, with
@PreAuthorizeon controller and service methods (reactive method security). An endpoint without an annotation is reachable anonymously — which some are on purpose: account listing (GET /api/v1/tenants, empty for anonymous callers), the published template catalog, the Stripe webhook (signature-checked) and the export and upload links (token-checked, see Capability URLs). -
A denied method answers
403to an authenticated caller and401to an anonymous one.
The MCP endpoint /api/v1/mcp has its own, stricter chain (ordered first): it requires
authentication and accepts only the Studio-issued MCP tokens, see
Endpoint and Security Chain.
Who the Caller Is
ai.promethist.platform.service.UserService resolves the platform user from the token: the email
claim, lowercased (or sub when there is no email), is the username. A user seen for the first time
is created on the spot, with a display name from the given_name/family_name or name claims.
Everything else — roles, memberships — hangs off that user id.
The AI Assistant Connection
The assistant’s Socket.IO handshake carries the access token in its auth payload. The backend
decodes it with the same Keycloak decoder at connect time and runs every event of the connection
with that identity in the reactive security context, so the assistant’s tools hit the same
@PreAuthorize checks as the REST API. A missing or invalid token closes the connection; the Studio
reconnects with the new token whenever its access token changes.
Roles and Access Control Lists
Access is granted by AccessControlList documents (accessControlList collection, model in
promethist-common). A row binds a role to a user — or to an external identity, see
Identity Providers and External Roles — either for a whole account (projectId empty) or for one
project.
| Role | Meaning |
|---|---|
|
Everything, including members, invitations, external access grants, identity providers, account settings and billing changes. |
|
Creates and changes every asset: agents and their lifecycle, knowledge, evaluations, interactive content, integrations, content navigation, test suites, project settings. On an account it may also create projects. |
|
Reads the assets, analytics and conversations in its scope. Member lists and invitations are not readable. |
|
Execution-only access for end users of client applications, used with identity-provider grants. It
gives no Studio access: account listings consider roles up to |
The declaration order of Role is load-bearing: every check compares ordinals, and a lower ordinal is
a stronger role. AclService.hasPermission(entityId, role) passes when the caller has a row for that
entity whose role is at least as strong as the one required.
Permission Expressions
The permission bean (component/PermissionEvaluator.kt) is what @PreAuthorize expressions call,
with the required role taken from the role bean — for example
@PreAuthorize("@permission.hasInProject(#projectId, @role.editor)").
| Expression | Passes when |
|---|---|
|
The caller has an account-level row for the account with at least |
|
The caller has any row — account or project — in the account with at least |
|
A project row with at least |
|
|
|
The caller’s username is an email address in that domain — the staff check, see Staff-Only Features. |
|
Any signed-in caller. Used for resources that are not tied to one account, such as the voice catalog and invitations addressed to the caller. |
Some reads are checked in the service instead, for example listing an account’s projects
(ProjectService.findAllWithTenantId keeps the projects the caller can see through an account row, a
project row or an external grant).
effectiveRoleInProject(project, userId) computes the strongest role the caller holds on a project
from all three sources. The project and agent context endpoints return it, and the Studio derives its
capabilities from it, so what the UI enables matches what the backend enforces.
What the Studio Shows
The Studio mirrors these rules in lib/authorization/access-control.ts. Its effective project role is
the stronger of the project and the account role; Editor or higher enables asset changes, Owner
enables members, billing and account management. Server-side scope guards
(lib/routing/scope-guards.ts) answer "not found" for pages the caller may not open — members and
billing pages require Owner — and an Axios interceptor turns an empty 403 into a readable
"You don’t have permission to do this." message.
Identity Providers and External Roles
A project can admit the end users of its client applications through external identity providers
(Keycloak identity-provider aliases). Three fields of the project control it, edited by Owners on the
Identity providers page (PUT /api/v1/project/{id}/identity-providers):
| Field | Meaning |
|---|---|
|
The providers ( |
|
Sign-in is mandatory for the project’s agents. |
|
A signed-in identity may run the project’s agents only with a matching external access grant. |
External access grants (/api/v1/project/{id}/external-access, Owner only) are ACL rows without a
user: they name an idpRef and optionally an external role, and grant a platform role — Consumer by
default — to every token whose idpRef claim (and, when set, extRoles claim) matches. A grant on
the account reaches each of its projects that has identity providers. hasInProject honors them, so an
external grant of Viewer or higher also opens the Studio to those users. Because such rows carry no
user id, the account and project listings look them up separately
(ProjectService.findAllExternallyGranted), so the reached accounts and projects are listed too. A
provider cannot be removed from a project while grants still reference it.
The engine enforces the same fields for conversations; its side is described in the Consumer Security chapter of the Platform Core documentation.
Staff-Only Features
Promethist staff are recognized by the email domain of their account (promethist.ai). The backend
uses @permission.emailOfDomain('promethist.ai'), the Studio its isAdminEmail helper.
| Area | Staff behavior |
|---|---|
Templates, skills, voices |
The |
Vault |
|
Account settings |
The account-level privacy settings ( |
MCP server |
Template authoring, skill administration and the test-harness tools refuse non-staff callers; guides
marked |
AI Assistant |
Staff get the test-agent tools everywhere and may send |
Account Privacy Settings
Owners control three account-wide settings (accountSettings collection,
PUT /api/v1/tenant/{id}/settings/...); every change is recorded in accountSettingsAuditLog.
| Setting | Default | Effect |
|---|---|---|
|
on |
When off, transcripts and the identities of the people in them are hidden everywhere — the session
endpoints answer |
|
on |
When off, analytics exports leave the evaluation results out. |
|
off |
When on, the analytics leave out every evaluation result that fewer than two users stand behind. |
Capability URLs
Two endpoints are authorized by a single-purpose token in the URL instead of a bearer token, because the party that opens them holds neither a Keycloak nor an MCP token:
-
GET /api/v1/exports/{id}?token=...downloads a conversation-data or analytics export file created through the MCP server. Links expire after 24 hours. -
PUT /api/v1/uploads/{id}?token=...receives a file for a knowledge source or an interactive-content element; the tool call that created the link checked the caller’s role. Links are single-use and expire after one hour.
Tokens are 32 random bytes, compared in constant time, and every failure — unknown id, wrong token,
expiry — answers the same empty 404. Expired records are swept every 15 minutes.
Other Measures
-
Stripe webhook —
POST /api/stripe/callbackverifies theStripe-Signatureheader withstripe.webhookSecretand ignores events of subscriptions created in another region. -
LiveAvatar API key — stored apart from the project document (
secretcollection), on the account or the project. Editors can read and change it; Viewers can only ask whether one is configured. -
Confirmation of destructive actions — the AI Assistant asks the user before deleting, reverting, cloning or promoting (Confirmations); the MCP server requires
confirm: trueon destructive tools.