Skip to content

The security flow, step by step

What happens between a request arriving and a handler running, and where each decision is recorded. The paths below are the real ones; every step is covered by a test named at the end.

A request over HTTP

1. TenantMiddleware                RequestInfo(headers, path, host, query)
2. PrincipalProvider / authenticator   credential → SecurityContext
      · API key: parse_api_key → hash_api_key_secret → compare with the stored hash
      · OIDC:    verify the token against the JWKS (cached)
      · failure → 401 in the canonical envelope; recorded as a security event
3. ResolverChain                   which tenant: JwtClaimResolver → Subdomain → Path → Header → ApiKey
4. ContextFactory                  TenantContext + the factory's checks
      · the service account is active, the tenant is not suspended
      · failure → 403/404 per the mapping, recorded
5. context_scope(...)              the tenant context is bound for this request only
6. require("resource:write")       AuthorizationQuery(principal, tenant, target, action, roles)
7. AuthorizationPEP.require        the engine answers; a denial or a *degraded* answer raises
      · denial → PermissionDenied → 403 PERMISSION_DENIED, the handler never runs
      · degraded → AuthorizationUnavailable → 503
8. the handler                     the service, the repository, the database -- bound to the tenant
9. the envelope / the response     exactly one error contract for every failure

The same chain over MCP

1. McpSecurityBoundary             the request's credential → SecurityContext  (the same authenticator)
2. ResolverChain + ContextFactory  the same resolution, the same checks
3. the invocation binds both scopes: security_context_scope(...) and context_scope(context.require_tenant())
4. the declared capability is put to the same engine, against the caller's *tenant scope*
      · the tool has no resource id to be asked about before it runs -- the question is about the tenant
5. the handler                     the same services, the same repository, the same database
6. audit                           three events on a refusal: mcp.request, the engine's decision, the refusal

Where the answers are recorded

Event Where it lands Who can read it
authentication failure security event → the platform trail an operator with audit:read
authorization allow/deny security.AUTHZ_*.{SUCCESS,DENIED} in the trail same
privileged access the trail, with the operator's identity same, and it is the point
every request a span with allow-listed attributes the tracer's collector
failures the canonical envelope, with request and correlation ids the caller

What the tests assert

  • a denied call executes nothing — measured at the row, read as the owner (tests/security/test_denial_non_execution.py);
  • another tenant's row is unreachable by any path — over both surfaces (tests/security/test_tenant_isolation.py);
  • no failure leaks an internal — a deny-list over every probe (tests/security/test_error_surface.py);
  • the whole chain over a real socket, in a real process (tests/e2e/test_http_chain.py);
  • the MCP chain's own three events, and a forged key refused (tests/integration/test_mcp_live.py).