Docs/Security & Compliance/Security Architecture

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
Read this with the Threat Model
This page describes controls that exist in code. Gaps and open items are deliberately kept on Threat Model & Known Exposures, and the certification stance is on Compliance Posture.

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

3. Tenant isolation

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().

SurfaceRequired authority (as coded)
User admin, /api/admin/** defaultPERM_MANAGE_USERS (templates: PERM_MANAGE_TEMPLATES; audit/issues: PERM_VIEW_AUDIT_LOG; usage: PERM_VIEW_USAGE)
Guardrail rules, proceduresPERM_MANAGE_GUARDRAILS
Approval policy writesPERM_MANAGE_APPROVALS
MCP registry, provider keys, webhooks, credentials for SDLC/Sarvam/JEV/capabilities, knowledge connectorsPERM_MANAGE_INTEGRATIONS
Channel credentials, pairing, routingPERM_MANAGE_CHANNELS
Code tools / OpenAPI toolsPERM_MANAGE_TOOLS (generate/validate: tenant-admin or agent-builder)
Code-intel, sandbox run-codeROLE_tenant-admin or ROLE_agent-builder
Billing, tenant settings, SSOPERM_MANAGE_BILLING, PERM_MANAGE_TENANT_SETTINGS
Platform-wide manifestsROLE_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:

EndpointCheck (as coded)
GET /api/agents/{agentId}/sessions/{sessionId}/transcriptRUNNER on the agent + owner of the scoped session; otherwise 404 (same as a session that does not exist)
GET /api/agents/{agentId}/sessions/{sessionId}/exportAs above, plus CHAT_EXPORT_ENABLED; one audit entry per export, counts only
POST /api/agents/{agentId}/chat/sessions/{sessionId}/rewindRUNNER; 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/capabilitiesRUNNER + 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.

SurfaceOwn 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/signupSignupRateLimiter; email verification required by Keycloak
/api/devices/claim|poll|screen-state|notifications|app-usage|smsPairing code (claim; per-IP and per-code limiter added after an internal SAST finding) or X-Device-Token
/oauth/{google,x,atlassian}/callbackSigned state carries tenant identity
/api/public/*, template gallery, plugin catalog, commerce feed, A2A agent card, /api/edge/* readsRead-mostly public catalog data; commerce checkout is server-priced and capped per tenant per day
/scim/v2/**, /api/tenant/comp-grantMarked permitAll() in the matcher list; I did not trace the controller-level check for these two (see limits below)
/actuator/health/**, /actuator/info, /actuator/prometheusActuatorEdgeGuardFilter (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

7. Untrusted-code and outbound-call containment

ControlWhat it doesStatus recorded in repo
McpStdioCommandGuardLauncher allowlist; rejects inline-eval flags (-e, --eval, -c, ...) on allowed interpretersPentest C1 fix shipped 2026-07-25; running arbitrary published packages via npx/uvx remains by design
McpRemoteUrlGuardSSRF filter: IP/denylist, IPv6-mapped decode, trailing-dot normalization, host.docker.internal; checkResolved re-checks resolved addresses at connectClass 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-runnerSeparate container: isolated network, no secrets, non-root, cap_drop: ALL, custom seccomp profile, resource limits, shared-secret fail-closed relayDesign doc §9 verified 2026-09-23; off by default — enable with the Compose tools/sandbox profiles or Helm global.components.mcpStdioRunner
Registration review gateNew MCP registrations pass a readiness/acknowledge gate; registration endpoints require PERM_MANAGE_INTEGRATIONSPentest M8 fixed
SafePublicUrlFetcher, OpenApiUrlGuard, ReservedAddressRangesOutbound-fetch guards for demo/OpenAPI/web-page toolsPresent 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.

Limits of this review
Not traced: the controller-level checks behind /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