Authorization model¶
How a request to core is allowed or refused, from the token to the row. The handbook page Security Architecture describes the same mechanisms with code evidence; this page is the model.
Identity¶
Every console or API call carries a Keycloak access token (JWT). Core validates
the signature, the issuer set (primary issuer plus KEYCLOAK_ADDITIONAL_ISSUERS)
and the authorized party (azp allow-list), then copies the token's tenant_id
claim into the request's TenantContext. Every database query of the request
runs in that tenant's Postgres schema. A token without a tenant is refused,
except for platform-admin and users still in onboarding.
That is app.auth.mode=keycloak, the default. A personal install runs with
app.auth.mode=local (APP_AUTH_MODE, set by the personal profile): core
then issues and validates its own tokens for a single owner, with no Keycloak
(core/security/.../localauth/LocalAuth.java, sign-in under
/api/auth/local/*). Any other value stops the boot.
Non-human callers have their own credentials: MCP grant keys (/mcp/**,
/api/dev/**), device tokens, share-link visitor tokens, channel webhook
signatures and delegation tokens (which act as the delegating user).
Roles¶
Keycloak realm roles become ROLE_* authorities:
| Role | Who | Effect |
|---|---|---|
platform-admin |
the operator | cross-tenant administration (the /platform-admin console section); tenant-less tokens allowed |
tenant-admin |
a tenant's administrators | seeds every permission below; owner-or-admin everywhere |
agent-builder |
people who build agents | seeds MANAGE_WORKFLOWS_ADMIN, MANAGE_INTEGRATIONS, MANAGE_AGENTS, MANAGE_TOOLS |
delegated-user, end-customer |
members, external customers | no permissions by default; end-customer is also blocked from every write by EndCustomerWriteGuardFilter |
Permissions (PERM_*)¶
Fine-grained permissions are stored per user (UserPermissionService), seeded
from the role bundles above on first use, and turned into PERM_<NAME>
authorities on every request (GranularPermissionConverter). The catalogue is
core/admin/.../permission/Permission.java; each constant's javadoc lists the
routes it gates. In short:
| Permission | Gates |
|---|---|
MANAGE_USERS |
user administration, /api/admin/**, tenant REST API keys (/api/tenant/api-keys/**) |
MANAGE_BILLING |
plan, invoices, billing profile, subscription, commerce catalog writes, payment checkout |
MANAGE_INTEGRATIONS |
MCP registry and grants, provider keys, SDLC credentials, knowledge connectors, OCR credential |
MANAGE_TENANT_SETTINGS |
tenant settings, branding, model preferences, tenant SSO and SCIM tokens (/api/tenant/sso/**, reads too) |
MANAGE_WORKFLOWS_ADMIN |
workflow webhooks, schedules, trigger channels |
VIEW_AUDIT_LOG |
the audit trail |
MANAGE_APPROVALS |
approval policy, tool-call approvals |
MANAGE_DATA_GOVERNANCE |
data export, deletion, usage, and the DPDP-oriented controls (/api/tenant/privacy/**) |
MANAGE_CHANNELS |
every channel credential and routing write |
MANAGE_AGENTS |
agent schedules and triggers, agent-learning policies, knowledge-base authoring (ingest, upload, re-embed, delete, collections, RAG eval, face enrollments) |
MANAGE_GUARDRAILS |
tenant guardrail rules |
MANAGE_TOOLS |
custom code tools and OpenAPI tool sources |
MANAGE_TEMPLATES |
the tenant's own community templates |
VIEW_USAGE |
LLM usage and spend reports |
MANAGE_LEADS |
the Leads CRM (reads too: leads are PII) |
Rule of thumb: writes are gated, reads are open to members unless the data is personal (leads, audit log, usage) or belongs to one user.
Route rules¶
SecurityConfig holds an ordered authorizeHttpRequests list split across
PublicSurfaceRules, AdminIntegrationRules, ToolChannelWorkflowRules and
TenantFeatureRules. Anything not matched falls to
anyRequest().authenticated(), so a new mutating endpoint is open to every
member of the tenant until it gets a rule. Three tests keep that honest:
AuthorizationCoverageTestscans every@Controller(including those behind@Profileor a property flag) and fails for anyPOST/PUT/PATCH/DELETEendpoint without a rule, without@PreAuthorize, and without a reviewed line incore/app/src/test/resources/security/authorization-coverage-allowlist.txt(HANDLER: the handler checks access;MEMBER: meant for any member;FIX: a known gap, and the count may only go down).SecurityRuleSnapshotTestpins the exact rule list; a change must be regenerated with-Dauthz.snapshot.write=trueand reviewed in the diff.scripts/ci/entity-name-lint.shrefuses two controllers on one base path unless allow-listed, so a rule cannot silently cover the wrong handler.
Resource grants (PermissionLevel)¶
Agents and workflows are owned by whoever created them and can be shared with
other members of the tenant (/api/{agents|workflows}/{id}/grants). A grant
carries one level, in increasing order:
| Level | Allows |
|---|---|
RUNNER |
execute / chat, no view of the configuration (prompt, tools, model, graph) |
VIEWER |
RUNNER plus read the full configuration |
EDITOR |
VIEWER plus change it |
The enum's declaration order is load-bearing (ResourceAccessService compares
with compareTo). The owner and a tenant-admin/platform-admin always have
EDITOR or more. Handlers call agentService.requireAccess(id, level) /
workflowService.requireAccess(id, level); a miss is a 404 (never a 403), so a
caller cannot enumerate ids they are not allowed to see.
Conversation ownership¶
A chat session id is chosen by the client and is not a secret. Session memory is
keyed by a server-derived id, c1:sha256(tenant, agent, subject, clientSessionId),
and the first turn records an owner (tenant + subject). Transcript reload,
export, rewind, branching and the LLM-request inspector resolve the caller's own
scoped id and check that owner record; another user with the same client id
reads nothing and gets 404. Details:
Security Architecture §4.1.
Adding an endpoint¶
- Prefer a rule in the matching
*Rulesclass (or@PreAuthorize) over an allowlist line. - If the handler itself decides (owner, grant level, caller's own data), add a
HANDLERorMEMBERline to the allowlist with the class and check that enforces it. - Run
mvn -pl app -am test -Dtest=AuthorizationCoverageTest,SecurityRuleSnapshotTestand regenerate the snapshot if you added a rule.
In the console¶
The console hides or disables controls the caller's permissions do not cover (the navigation
blueprint in web/app/components/nav/navSections.ts carries a perm per row, and pages such as
/admin/data-privacy guard on the same scope the backend requires). That is a convenience, not a
control: the rules above are enforced on the server. See the console guide.