Security architecture¶
This page is the contract. The threat model, the SPI signatures, the trust boundaries, and the failure modes Regulus commits to. For the operational hardening checklist (TLS, secrets, RBAC at the cloud layer), see Production hardening. For the regtech framing of why identity matters at all, see Concepts → Security model.
Threat model¶
Regulus defends against:
- Forged purpose codes. A caller cannot claim a purpose they don't
hold in
Claims.purposeCodes. TheIdentityAdapteris the single point of entry; downstream guards trust only the minted Identity. - Unauthenticated plugin invocation. Plugins run inside the ADK request lifecycle, behind an inbound filter that mints the Identity before any callback fires.
- Unsigned cross-org A2A calls. When an
A2ARequestSigneris configured, outbound envelopes are signed with RFC 9421; inbound envelopes are verified before policy guards see them. - Audit-log tampering by an in-process actor. Opt-in audit integrity
wraps every event in a
SealedAuditEventcarrying the previous event's hash. Mutation breaks verification. - Kill-switch bypass. Activation and dual-control approvals are
gated by
Identityroles, with approver-distinctness onPrincipal.id. - Identity-expiry replay. A dedicated guard runs first in the BeforeModel chain and rejects calls bound to expired tokens.
Regulus does not defend against:
- Cloud IAM misconfiguration (a service account with overbroad permissions is still a problem Regulus can't see).
- Host compromise. If an attacker runs code in the JVM, they're inside the trust boundary.
- Supply-chain attacks on transitive dependencies.
- Insiders with legitimate prod database access.
- OS / kernel CVEs, physical access, side-channel timing attacks on the JVM.
These are not gaps Regulus is going to close — they're the boundary that the cloud provider, the platform team, and the security org own.
The identity contract¶
public record Principal(String id, String displayName, PrincipalType type) {
public enum PrincipalType { HUMAN, SERVICE, AGENT }
}
public record Claims(
String tenantId,
Jurisdiction jurisdiction,
Set<String> purposeCodes,
Set<String> roles,
Set<String> lawfulBases,
Map<String, String> extensions) { }
public record Identity(Principal principal, Claims claims, Provenance provenance) {
public record Provenance(
String adapterId,
Instant mintedAt,
Instant tokenExpiry,
String tokenIssuer) {}
}
Field-by-field:
| Field | Type | Regulatory anchor |
|---|---|---|
Principal.id |
String, unique within tenant | GDPR Art. 30 (records of processing); FCA SYSC 9 (record-keeping) |
Principal.type |
enum HUMAN / SERVICE / AGENT | EU AI Act Art. 14 (human oversight) — agents acting for humans must be distinguishable |
Claims.tenantId |
String | Residency + retention scoping; DORA tenant-aware reporting |
Claims.jurisdiction |
enum EU / UK / EU_UK | GDPR vs UK GDPR + DPA 2018 — selects the active compliance profile |
Claims.purposeCodes |
Set\<String> | GDPR Art. 5(1)(b) purpose limitation |
Claims.lawfulBases |
Set\<String> | GDPR Art. 6 (and Art. 9 for special categories) |
Claims.roles |
Set\<String> | EU AI Act Art. 14, FCA SYSC 6 — RBAC for human oversight actions |
Provenance.adapterId |
String — "oidc", "saml", "mtls", ... |
Auditor question: "how did you know this was them?" |
Provenance.tokenExpiry |
Instant | Enforced by RegulusIdentityExpiryGuard |
Provenance.tokenIssuer |
String | Cross-org audit linking |
extensions is Map<String,String> deliberately. Custom claims must be
flattened at the trust boundary — if you need structured values, encode
JSON in the string. The discipline matters: it forces every adapter to
make explicit choices about what crosses the boundary, instead of
leaking opaque Objects into policy code.
IdentityAdapter SPI¶
public interface IdentityAdapter {
String adapterId();
Identity authenticate(RequestContext context) throws AuthenticationException;
default int order() { return 100; }
}
Contract:
- Idempotent. Two calls with the same
RequestContextmust return equal Identities. - Fail-closed. Any verification failure throws
AuthenticationException. Never return a half-populated Identity. - Jurisdiction-populating.
Claims.jurisdictionmust be set — every downstream guard depends on it. - Ordered. When multiple adapters are registered (e.g. mTLS at 50,
OIDC at 100), the lowest
order()wins.
A custom SAML adapter is about 30 lines:
public final class SamlIdentityAdapter implements IdentityAdapter {
public String adapterId() { return "saml"; }
public Identity authenticate(RequestContext ctx) throws AuthenticationException {
Saml2AuthenticatedPrincipal saml =
(Saml2AuthenticatedPrincipal) ctx.raw().get("springAuth");
if (saml == null) throw new AuthenticationException("missing SAML principal");
String sub = saml.getName();
String tenant = saml.getFirstAttribute("regulus.tenant");
String jur = saml.getFirstAttribute("regulus.jurisdiction");
return new Identity(
new Principal(sub, saml.getFirstAttribute("displayName"), Principal.PrincipalType.HUMAN),
new Claims(
tenant,
Jurisdiction.valueOf(jur),
Set.copyOf(saml.getAttribute("regulus.purpose")),
Set.copyOf(saml.getAttribute("roles")),
Set.copyOf(saml.getAttribute("regulus.lawful_basis")),
Map.of()),
new Identity.Provenance("saml", Instant.now(), saml.getNotOnOrAfter(), saml.getRelyingPartyRegistrationId())
);
}
}
Register it as a Spring bean — the OidcIdentityAdapter is itself
@ConditionalOnMissingBean, so your SAML adapter steps ahead of the
reference.
Reference OIDC adapter¶
The reference implementation ships in
regulus-ai-identity-oidc-spring-boot-starter. It maps Spring
Security's JwtAuthenticationToken to Identity:
| JWT claim | Regulus field |
|---|---|
sub |
Principal.id |
preferred_username (fallback name) |
Principal.displayName |
regulus.tenant (fallback tid) |
Claims.tenantId |
regulus.jurisdiction |
Claims.jurisdiction (EU, UK, EU_UK) |
regulus.purpose (array) |
Claims.purposeCodes |
regulus.lawful_basis (array) |
Claims.lawfulBases |
scope ∪ roles ∪ realm_access.roles |
Claims.roles |
| everything else | Claims.extensions |
Minimal application.yaml:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://idp.example/realms/regulated-prod
regulus:
identity:
oidc:
enabled: true # default; turn off to disable the autoconfig
Spring Security is compileOnly in the starter — non-OIDC tenants who
add regulus-ai-identity-oidc-spring-boot-starter and leave Spring
Security off the classpath get a dormant autoconfig (gated by
@ConditionalOnClass(JwtAuthenticationToken.class)).
The OidcSecurityContextFilter runs once per request: reads the
SecurityContextHolder, hands the JWT to the adapter, places the
resulting Identity in IdentityHolder, and clears the holder in a
finally block when the chain unwinds.
Trust boundaries¶
flowchart LR
Caller([External caller])
IdP([External IdP])
Boot[Spring Boot edge]
ADK[ADK runtime]
Plugins[Regulus plugins]
GRC([GRC sink])
Remote([Remote A2A peer])
Caller -- "[1] JWT signed by IdP" --> Boot
Caller -. "validated via JWKS" .-> IdP
Boot -- "[2] Identity in IdentityHolder (in-proc)" --> ADK
ADK -- "[3] in-proc; plugin code trusted" --> Plugins
Plugins -- "[4] signed audit events (opt-in)" --> GRC
Plugins -- "[5] RFC 9421 signed envelope" --> Remote
Remote -- "[5] signature + nonce verified" --> Plugins
Each boundary in detail:
- External caller → Spring Boot HTTP. The IdP-issued JWT is the
only trusted artefact. Spring Security validates signature, expiry,
audience, and issuer; the
IdentityAdapterthen mints the canonical Identity. If any of these fail, the filter returns 401 before any policy code runs. - Spring Boot → ADK runtime. In-process, single JVM. The Identity
is propagated via
IdentityHolder(aThreadLocal). No additional verification — the boundary is the process boundary. - ADK runtime → Regulus plugins. Plugin code is part of the
trusted base. PolicyContext is derived from Identity via the
PolicyContextBridge; plugins readClaimsdirectly. - Plugins → external GRC sinks. Network egress. When audit
integrity is enabled, each event is sealed with a hash chain plus
(optionally) a detached signature from the
KeyProvider. Sinks that want to surface chain metadata overrideAuditSink.writeSealed. - Local agent → remote A2A agent. The signer commits to method,
target URI, body digest, tenant id, and correlation id (RFC 9421
Signature-Inputselector). The verifier reconstructs the caller's Identity from the verified envelope before the inbound filter places it inIdentityHolderfor the downstream pipeline.
A2A request signing¶
Recommended scheme: RFC 9421 HTTP Message Signatures with Ed25519 keys. RFC 9421 binds the method, target URI, body digest, and a selected set of headers into a single signature base — gateway proxies stripping or rewriting a header invalidate the signature. This is materially safer than a detached JWS over the body alone, which is silent about HTTP routing context.
public interface A2ARequestSigner {
SignedEnvelope sign(A2AEnvelope envelope, Identity caller);
VerifiedCaller verify(SignedEnvelope envelope) throws SignatureException;
}
The signature base includes, in order:
"@method"— request method (e.g.POST)"@target-uri"— full URI"content-digest"—sha-256=:...:of the body"regulus-tenant"— caller tenant id"regulus-correlation-id"— correlation id, surfaced into the audit trail on the receiving side"regulus-identity-adapter"— which adapter minted the caller
Replay protection: every signature carries a unique nonce parameter
and a created timestamp. Verifiers reject nonces seen within the
configured replay window (default 5 minutes) and timestamps drifting by
more than the window.
Key material flows through KeyProvider:
public interface KeyProvider {
SigningKey signingKey(String keyId);
VerificationKey verificationKey(String keyId);
String defaultSigningKeyId();
}
The same KeyProvider is consumed by audit integrity — a tenant
configures key management once. Concrete providers (in-process JCA, GCP
KMS, HashiCorp Vault) live in separate starters.
Audit integrity¶
Opt-in via regulus.ai.observability.audit.integrity.enabled=true. When
on, every AuditEvent is appended to an AuditChain and the resulting
SealedAuditEvent is what each sink sees.
public record SealedAuditEvent(
AuditEvent event,
long chainIndex,
String previousEventHash,
String eventHash,
Optional<String> signature,
String keyId) {}
The default HashChainAuditChain computes
SHA-256(prev_hash | canonical_json(event)). Canonical JSON sorts map
keys, so equal events produce equal hashes regardless of ordering.
chainIndex is contiguous and detects gaps. The optional signature
slot is populated when a KeyProvider is configured — the SPI shape is
stable today even though signing implementation lands in the next
milestone.
Offline verification:
Returns 0 if the chain verifies, 1 if any event is mutated / reordered / missing, 2 on I/O or parse errors. Auditors run this against a copy of the production log to prove tamper-evidence — no Regulus runtime required.
Kill-switch authorization¶
public interface KillSwitchAuthorizer {
boolean canRequest(Identity identity, KillSwitchState.Scope scope);
boolean canApprove(Identity identity, KillSwitchState.Scope scope);
boolean canEmergencyBypass(Identity identity);
}
The default RoleBasedKillSwitchAuthorizer reads three roles, defaults
configurable via regulus.kill-switch.roles.{requester,approver,emergency}:
regulus.killswitch.requester— can request activation / deactivationregulus.killswitch.approver— can approve a pending requestregulus.killswitch.emergency— can invoke the emergency bypass
Why approver-distinctness is enforced on Principal.id and not on
arbitrary strings: the same human can hold multiple display names, the
same string can be spelled two ways, and a determined defeat of dual
control is one shared mailbox away if the only check is "two different
typed names." A Principal.id collision means the same authenticated
subject — that's the right granularity.
See Dual control / 4-eyes for the regulatory framing.
Failure modes¶
| Failure | Behaviour | Audit consequence |
|---|---|---|
IdentityAdapter throws AuthenticationException |
401 response; no Identity bound | No downstream audit event for the call; filter logs the rejection reason |
| A2A signature verification fails | SignatureException short-circuits inbound dispatch |
Audit event A2A_CALL with Outcome.BLOCKED and rejection reason |
KeyProvider unavailable while integrity enabled |
AuditChain falls back to unsigned chain (hash-only) |
Audit log carries signature: Optional.empty(); alarms fire from monitoring |
| Audit chain hash mismatch detected at append time | Sink continues writing — the inbound stream cannot stall the request — but verification will fail offline | Out-of-band: regulus audit verify returns 1; SOC investigates |
KillSwitchAuthorizer.canApprove returns false |
Approval rejected, request remains pending | Audit event UNAUTHORIZED_APPROVAL |
Token expired (caught by RegulusIdentityExpiryGuard) |
PolicyDecision.Block with code regulus.identity.token_expired |
Audit event POLICY_VIOLATION with that code and the original expiry timestamp |
Out of scope¶
Regulus does not pretend to defend against:
- Insider with prod credentials. A compromised SRE can do almost anything; that's why dual-control deactivation, signed audit chains, and per-event KeyProvider-backed signatures exist — to make it visible, not to make it impossible.
- OS / kernel CVEs. Patching is the platform team's job.
- Supply-chain compromise of dependencies. Reproducible builds, SBOM scanning, dependency pinning live at the build-system layer.
- Physical access to running JVMs. If an attacker can read process memory, they have the in-process trust.
- Side-channel timing attacks on Ed25519 verification. The JCA / KMS provider's mitigations are what they are; Regulus doesn't re-implement primitives.
- DDoS / availability. That's a cloud edge concern.
Where next¶
- Production hardening — the operational checklist (TLS, secrets, RBAC, network).
- Custom policy guards — how to add a guard
that reads
Claimsdirectly. - EU AI Act → Art. 15 (cybersecurity) — the regulatory anchor for this page.