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 prompt=select_account.

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 microsoft provider’s callback, because Keycloak redirects only to registered callback URLs.

/login/{idpRef}

A deep link for a company: straight to the Keycloak identity provider with that alias — the ref a project lists under Identity providers — without showing the login page.

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, authjs.session-token, valid for seven days. It carries the Keycloak access token, its expiry and the refresh token.

Refresh

Each time the session is read, an access token within 30 seconds of expiry is refreshed with the refresh token (lib/refresh-access-token.ts). When Keycloak rejects the refresh token, the session is stripped of its tokens and marked refreshAccessTokenError; pages then redirect to /auth-expired. That page first tries a silent sign-in (prompt=none on the Keycloak session, with the hint of the provider the session used) and offers the login page when that does not succeed. When Keycloak is merely unreachable, the refresh token is kept for the next attempt.

Calling the backend

Client components read session.accessToken with useSession() and send it as a bearer token (lib/api-client.ts). A 401 triggers one session refresh and a retry; if there is still no token the browser goes to /login. Server components and route handlers use requireSession(), requireAccessToken() and getAccessToken() from lib/authorization/server-auth.ts.

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 @PreAuthorize on 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 403 to an authenticated caller and 401 to 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

Owner

Everything, including members, invitations, external access grants, identity providers, account settings and billing changes.

Editor

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.

Viewer

Reads the assets, analytics and conversations in its scope. Member lists and invitations are not readable.

Consumer

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 Viewer only, and the Studio maps Consumer to no capabilities.

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

hasInTenant(id, role)

The caller has an account-level row for the account with at least role.

hasInTenantOrAnyProject(id, role)

The caller has any row — account or project — in the account with at least role, or an external grant reaching one of its projects. Used for the account context, so project-only members can see the account they belong to.

hasInProject(id, role)

A project row with at least role; otherwise an account-level row of the project’s account; otherwise, for a project with identity providers, an external grant matching the caller’s token (below).

hasInAgent(id, role)

hasInProject on the agent’s project.

emailOfDomain('promethist.ai')

The caller’s username is an email address in that domain — the staff check, see Staff-Only Features.

isAuthenticated()

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

identityProviders

The providers (ref, display name, logo) users may sign in with. A new ref is verified against the Keycloak realm first (KeycloakIdentityProviderProbe, which needs no admin credentials: an unknown alias answers 404 at the realm’s broker endpoint).

isIdentityRequired

Sign-in is mandatory for the project’s agents.

isUsingExternalRoles

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 /api/v1/admin/... endpoints and the Studio’s Admin section (templates, skills, realtime configurations) are staff-only.

Vault

GET /api/vault/{name} returns a value of the configured vault; staff only.

Account settings

The account-level privacy settings (AccountSettingsService) do not restrict staff: they see full conversation data, get evaluation results in exports and see single-user evaluation results whatever the account chose.

MCP server

Template authoring, skill administration and the test-harness tools refuse non-staff callers; guides marked audience: staff are served to staff only.

AI Assistant

Staff get the test-agent tools everywhere and may send RESET to clear their assistant memory.

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

displayFullConversationData

on

When off, transcripts and the identities of the people in them are hidden everywhere — the session endpoints answer 403, the conversation-data tools of the MCP server drop the account from what they can read.

includeEvaluationResultsInExports

on

When off, analytics exports leave the evaluation results out.

hideSingleUserEvaluationResults

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/callback verifies the Stripe-Signature header with stripe.webhookSecret and ignores events of subscriptions created in another region.

  • LiveAvatar API key — stored apart from the project document (secret collection), 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: true on destructive tools.