Security Architecture
- Scope
- How a request is authenticated, authorized, tenant-scoped and constrained in core; how untrusted code and anonymous callers are contained
- Verified against
- Source files read on 2026-09-26 (listed under Evidence). "Verified" here means the code does this; it does not mean a given prod deploy is running that code.
- Related
- Identity & Access, ADR-0005, ADR-0002, Secrets, Threat Model
1. Layers at a glance
flowchart LR U[Browser / API client] --> CF[Optional CDN / WAF] CF --> ING[Caddy or Kubernetes Ingress] ING --> F1[ActuatorEdgeGuardFilter] F1 --> F2[Bearer JWT validation: issuer set + azp] F2 --> F3[TenantContextFilter: tenant_id claim to TenantContext] F3 --> F4[Route matchers: PERM_* authorities] F4 --> SVC[Services: guardrails, approvals, tenant schema] SVC --> DB[(Postgres: schema per tenant)] ANON[Anonymous: demo, share links, webhooks, device tokens] --> ING SVC --> SBX[Sandboxes: tool-sandbox, mcp-stdio-runner]
Any CDN or WAF in front of the deployment is outside this repository; the Compose Caddyfile (deploy/caddy/Caddyfile) sets HSTS, nosniff, a referrer policy and a 32 MB body limit, and 404s the Keycloak admin console and master realm on auth.. What core itself enforces is described below. Edge configuration status is tracked as an open item on the Threat Model.
2. Authentication
- OAuth2 resource server, fail-closed default.
SecurityConfigis registered for@Profile("!insecure"). Without thesecurityprofile the issuer/JWKS placeholders fail to resolve and startup aborts rather than serving an open API; the no-auth chain (NoSecurityConfig) exists only under an explicitinsecureprofile, documented as dev/CI only. - Issuer set, not a single issuer. The JWT decoder is built from a JWKS URI, with accepted issuers = primary
issuer-uriplus the comma-separatedKEYCLOAK_ADDITIONAL_ISSUERS. Full default validation (timestamps) is kept. See ADR-0005. - Authorized-party (
azp) allowlist. Tokens must be issued to a first-party client (agentic-ai-web,agentic-ai-platform-admin,agentic-ai-mobile,agentic-ai-app,agentic-ai-ide,agentic-ai-core); a client that serves open self-registration for another product should be left out. Configurable viaAPP_KEYCLOAK_ALLOWED_AZP; setting it empty disables the check (documented emergency lever). A token with noazpis passed to the other validators. - Stateless. Session creation policy is STATELESS; CSRF is disabled because auth is bearer-token only. CORS is delegated to one shared
CorsConfigurationSource. - Response headers. Core sets CSP
default-src 'none'; frame-ancestors 'none',Referrer-PolicyandPermissions-Policy; Spring's defaults add X-Content-Type-Options, X-Frame-Options and HSTS on HTTPS. These apply to the core API only (see Threat Model for the front-end origins).
3. Tenant isolation
- Schema-per-tenant.
TenantContextFilterruns after bearer validation and copies thetenant_idclaim intoTenantContextfor the request; Hibernate resolves the Postgres schema from it (TenantSchemaResolver). Rationale: ADR-0002; data layout: Data Architecture. - Claim-less tokens are rejected with a 403 (after a 2026-07-15 incident that produced raw 500s);
platform-admintokens are tenant-less by design and exempt; social-login users without a tenant may reach onboarding only. - Tenant status gate.
TenantStatusGuardrefuses tokens for tenants that are not usable (e.g. a schema dropped by a failed signup). - Schema-name sanitization.
SchemaNameSanitizeris the single place a raw string becomes a Postgres identifier (lowercase letters, digits, underscore; no leading digit). An internal SAST review found no bypass where checked. - Missing-context fallback. If no
TenantContextis set, the resolver falls back to thepublicschema unlessapp.tenancy.fail-on-missing-context=true. Default isfalse; the code comment says it stays false until theauthenticated_no_contextcounter proves empty. This is an open item on the Threat Model. - PgBouncer must remain session-pooling for the search_path mechanism to be safe (unverified here; recorded in project notes — see Threat Model).
4. Authorization
Route-level authorization is a long ordered authorizeHttpRequests block in SecurityConfig. Roles from Keycloak are converted to authorities (KeycloakRoleConverter) and granular permissions to PERM_* authorities (GranularPermissionConverter, PermissionAuthoritySource); anything not matched falls to anyRequest().authenticated().
| Surface | Required authority (as coded) |
|---|---|
User admin, /api/admin/** default | PERM_MANAGE_USERS (templates: PERM_MANAGE_TEMPLATES; audit/issues: PERM_VIEW_AUDIT_LOG; usage: PERM_VIEW_USAGE) |
| Guardrail rules, procedures | PERM_MANAGE_GUARDRAILS |
| Approval policy writes | PERM_MANAGE_APPROVALS |
| MCP registry, provider keys, webhooks, credentials for SDLC/Sarvam/JEV/capabilities, knowledge connectors | PERM_MANAGE_INTEGRATIONS |
| Channel credentials, pairing, routing | PERM_MANAGE_CHANNELS |
| Code tools / OpenAPI tools | PERM_MANAGE_TOOLS (generate/validate: tenant-admin or agent-builder) |
| Code-intel, sandbox run-code | ROLE_tenant-admin or ROLE_agent-builder |
| Billing, tenant settings, SSO | PERM_MANAGE_BILLING, PERM_MANAGE_TENANT_SETTINGS |
| Platform-wide manifests | ROLE_platform-admin |
Denied requests by an authenticated caller produce the standard 403 and an authz.denied audit entry through AuditingAccessDeniedHandler and the AccessDeniedEventListener SPI.
4.1 Conversation ownership (session memory)
A chat session id is chosen by the client and is not a secret: it appears in tool-call logs, browser history and support tickets. Session memory (SessionMemoryService, Redis) therefore never stores a conversation under that id. Every read and write goes through a caller-scoped id:
c1:sha256( tenantId , agentId , subject , clientSessionId )
where subject is the authenticated user's JWT sub (for a delegation token, the delegating user; for a share link, the visitor token; for a channel, the external id) and agentId is the agent the caller addressed. Two callers share a history only when all four parts match, so the same client id used by another user, another tenant or against another agent lands in a disjoint list. The first append also records an owner record (tenant + subject, SETNX, same TTL as the history) under that scoped id.
Endpoints that read a conversation back by the id the client knows must derive the scoped id for the current caller (CallerSessionIds.scopedForCurrentCaller, AgentChatSessionController#scopedMemorySessionId) and then check SessionMemoryService.isOwnedBy on that id, in addition to the agent-level RUNNER permission:
| Endpoint | Check (as coded) |
|---|---|
GET /api/agents/{agentId}/sessions/{sessionId}/transcript | RUNNER on the agent + owner of the scoped session; otherwise 404 (same as a session that does not exist) |
GET /api/agents/{agentId}/sessions/{sessionId}/export | As above, plus CHAT_EXPORT_ENABLED; one audit entry per export, counts only |
POST /api/agents/{agentId}/chat/sessions/{sessionId}/rewind | RUNNER; operates on the caller's own scoped list, so it can only truncate the caller's conversation |
GET .../sessions/{sessionId}/messages/{messageId}/siblings, PUT .../messages/active-leaf, POST .../messages/{messageId}/edit|regenerate (chat branching) | RUNNER + APP_CHAT_BRANCHING_ENABLED; the live transcript read/written is the caller's own scoped list. The branch tree (Postgres, keyed by the raw client id) is bound to the first subject whose own turns it records or who changes its active branch; any other caller gets 404 |
GET /api/agents/{agentId}/chat-branching/capabilities | RUNNER + flag; reports whether branching is on, no session data |
A session with no owner record (expired, started before owner records existed, Redis unavailable) is not readable by anyone: the check fails closed. History and owner record expire together (app.chat.session-ttl-hours, 24h by default). Looking a session up by the raw client id is a bug, not a fallback; it finds nothing (regression fixed 2026-10-10, see CHANGELOG).
5. Deliberately unauthenticated surfaces
These are permitAll() at the Spring layer because the caller cannot hold a Keycloak token; each is expected to authenticate its own credential inside the controller. Anything new under these prefixes is anonymous by default.
| Surface | Own credential / control |
|---|---|
/webhook/**, /api/billing/webhook/** | Provider signature (Meta, Slack verified constant-time in an internal SAST review; HMAC for the optional Razorpay gateway); Slack adds a 5-minute replay window |
/webhook/alerts/**, /mcp/**, /api/dev/** | Dedicated filter chains without bearer processing: shared secret (alerts) or vmcp_ grant keys (McpApiKeyAuthFilter) |
/api/demo/** | Opaque 256-bit X-Demo-Token, hashed lookup in control_plane.demo_session; per-IP creation limit (DemoSessionRateLimiter), per-IP live-sandbox cap, per-sandbox message cap, FREE-tier limits |
/api/share/agents/**, /api/share/workflows/** | Unguessable share id; rate-limited, revocable, metered to the owning tenant |
/api/signup | SignupRateLimiter; email verification required by Keycloak |
/api/devices/claim|poll|screen-state|notifications|app-usage|sms | Pairing code (claim; per-IP and per-code limiter added after an internal SAST finding) or X-Device-Token |
/oauth/{google,x,atlassian}/callback | Signed state carries tenant identity |
/api/public/*, template gallery, plugin catalog, commerce feed, A2A agent card, /api/edge/* reads | Read-mostly public catalog data; commerce checkout is server-priced and capped per tenant per day |
/scim/v2/**, /api/tenant/comp-grant | Marked permitAll() in the matcher list; I did not trace the controller-level check for these two (see limits below) |
/actuator/health/**, /actuator/info, /actuator/prometheus | ActuatorEdgeGuardFilter (below) |
Actuator edge guard. Requests to non-public actuator paths that carry a proxy forwarding header (which any request via a CDN, Ingress or Caddy has, and an in-network scrape lacks) get a 404 unless they present an optional scrape token. It exists so that keeping metrics private does not depend on an edge rule.
6. Agent action controls
- Tool-output guardrails.
GuardrailServiceis the seam every tool output passes through before re-entering the model (GuardedToolCallback). Default implementation is semantic (app.guardrails.implementation); a regexpatternvariant is the opt-out. The source itself calls the pattern version "deliberately modest" and disclaims solving prompt injection or DLP. - Content guardrails. PII, jailbreak, policy, profanity, toxicity and tenant custom-rule guardrails (
core/guardrails/.../impl), managed through/api/guardrails/rulesbehindPERM_MANAGE_GUARDRAILS. - Approval gating.
ApprovalGateMode=POLICY_DEFAULT | ALWAYS_GATE | NEVER_GATE, mapped from an agent's autonomy tier. See Governance & Guardrails.
7. Untrusted-code and outbound-call containment
| Control | What it does | Status recorded in repo |
|---|---|---|
McpStdioCommandGuard | Launcher allowlist; rejects inline-eval flags (-e, --eval, -c, ...) on allowed interpreters | Pentest C1 fix shipped 2026-07-25; running arbitrary published packages via npx/uvx remains by design |
McpRemoteUrlGuard | SSRF filter: IP/denylist, IPv6-mapped decode, trailing-dot normalization, host.docker.internal; checkResolved re-checks resolved addresses at connect | Class documents a DNS-rebinding check (checkResolved); an internal pentest listed DNS rebinding as open at its time. Pin/egress-policy completeness not re-verified |
mcp-stdio-runner | Separate container: isolated network, no secrets, non-root, cap_drop: ALL, custom seccomp profile, resource limits, shared-secret fail-closed relay | Design doc §9 verified 2026-09-23; off by default — enable with the Compose tools/sandbox profiles or Helm global.components.mcpStdioRunner |
| Registration review gate | New MCP registrations pass a readiness/acknowledge gate; registration endpoints require PERM_MANAGE_INTEGRATIONS | Pentest M8 fixed |
SafePublicUrlFetcher, OpenApiUrlGuard, ReservedAddressRanges | Outbound-fetch guards for demo/OpenAPI/web-page tools | Present in code; not audited line-by-line here |
Design rationale for sandboxing: see ADR-0009 and docs/architecture/MCP-STDIO-SANDBOXING-DESIGN.md.
8. Operator and automation identity
A deployment can be taken down by an operator, a CI job or an automated agent that holds a human admin identity and issues a power-cycle or delete. Recommended mitigations: a procedural rule that no one resets, stops or deletes a production host or database without an explicit human decision, and a separate identity for automation with no destructive permissions. The repository does not enforce either; they are deployment decisions.
/scim/v2/** and /api/tenant/comp-grant; a live Keycloak realm (which can drift from the checked-in realm JSON); CDN/WAF settings; and whether each control above is in a given running image.
Evidence & references
core/security/src/main/java/com/agenticai/core/security/SecurityConfig.java— decoder, issuers,azp, header policy, dedicated chains, matcher list, demo/share/device comments.core/security/.../TenantContextFilter.java,TenantStatusGuard.java,ActuatorEdgeGuardFilter.java,AuditingAccessDeniedHandler.java,NoSecurityConfig.java.core/tenancy/.../TenantSchemaResolver.java(fail-on-missing-context),SchemaNameSanitizer.java,demo/DemoSessionRateLimiter.java.core/agents/.../memory/SessionMemoryService.java(scopedSessionId,bindOwner,isOwnedBy),memory/CallerSessionIds.java,chatport/ChatTranscriptService.java,chatport/ChatExportService.java,branching/ChatBranchService.java; testsChatTranscriptServiceTest,ChatExportServiceTest,ChatBranchServiceTestrun the real key derivation.core/guardrails/.../GuardrailService.java,ApprovalGateMode.java,impl/*Guardrail.java.core/mcp/.../McpStdioCommandGuard.java,McpRemoteUrlGuard.java;docs/architecture/MCP-STDIO-SANDBOXING-DESIGN.md(§8, §9).- Internal security reviews (MCP pentest, core SAST, live DAST of JWT forgery rejection and CORS; July 2026, not published).