The constraint system stops a bad call before it leaves the JVM. The Safe Codebase Audit Pipeline stops bad code before a client ever loads it. What remains is identity: who is calling, and can you verify it?
Most frameworks answer with a token check at the door.
A filter validates a bearer token, sets a thread-local variable, and hopes that nothing downstream forgets to look at it.
DirtyChai answers differently.
Identity lives inside the JVM’s own security machinery: in Subject, in ProtectionDomain, in every permission check.
This part covers the two kinds of identity a call carries, how SPIFFE manages process credentials without keystores, and how three identity layers coexist on a single dispatch thread.
Two Subjects, Two Lifetimes
DirtyChai introduces a sealed Subject hierarchy with two distinct identity types, each with its own carrier, lifetime, and routing rule.
WorkerSubject-
The JVM process itself. Sealed, permits
SpiffeSubjectandRemoteSubjectonly. It carries the process workload identity, a SPIFFE SVID, and is baked into everyProtectionDomainat class load time. It is ambient: present at everycheckPermissionregardless ofdoPrivilegednesting, so the server never needs to reinstall it per request. Passing it tocallAs()ordoAs()is illegal. UserSubject-
A human user. Final. User identity is established via JWT/OIDC using
JwtLoginModulefrom thejgdms-security-jwtmodule. The resultingUserSubjectcarriesJwtPrincipalinstances, e.g.,"sub:alice@example.org"and"group:admins", travels in aScopedValue<Subject[]>, and is installed per-request viaSubject.callAs(). Kerberos is supported for legacy deployments.
This separation is also why DirtyChai is retiring Subject.doAs() and doAsPrivileged() in favour of Subject.callAs() plus an explicit doPrivileged where a privilege boundary is actually wanted.
Two things classic JAAS fused are orthogonal: who you are and what privileges the code runs with.
callAs binds identity on a ScopedValue, and that identity survives doPrivileged.
doPrivileged truncates the stack and thereby sheds the remote caller’s stack-borne domains.
doAs couples them: used merely to run privileged, it also drops the user, because it rebinds the subject and suppresses the enclosing one.
Dropping the user should be deliberate, not a side effect of a code boundary.
doAs and doAsPrivileged survive only for legacy JAAS and Kerberos GSS interop.
Identity Without Keystores
Part 1 declared ClientAuthentication.YES as a constraint.
TLSv1.3 with X.509 certificates is what satisfies it, and in a fleet of services, long-lived keystores are a management and security liability.
JGDMS and DirtyChai integrate SPIFFE workload identity via SPIRE.
Each host process and client JVM receives a short-lived X.509 SVID from a local SPIRE agent; the default lifetime is about one hour.
The SpiffeCredentialManager component:
- Opens the SPIRE Workload API socket on startup
- Populates an in-memory
Subjectwith the current X.509 certificate and private key; no filesystem keystore - Rotates credentials automatically when the SVID nears expiry
- Triggers policy refresh on each rotation
No keytool, no PKCS#12 files, no manual certificate renewal.
Each service’s identity is managed by the SPIRE control plane; revocation and rotation happen without JVM restarts.
The managed SpiffeSubject contains a SpiffePrincipal derived from the SVID URI SAN, e.g., spiffe://jgdms.example.org/host/bae/engine-2, an X500Principal derived from the certificate Subject DN, and the short-lived credential itself, never written to disk.
Every server-side dispatch thread automatically carries the SPIFFE worker Subject of the calling client for the lifetime of the remote method invocation.
Service implementations write no authentication boilerplate.
Three Layers on One Dispatch Thread
At any checkPermission during a dispatched call, up to three identity layers are in play.
- Layer 1, process worker
-
The local
WorkerSubject. JGDMS’sRFC3986URLClassLoaderandPreferredClassLoadercarry the server’sPrincipal[]as afinalfield and inject it into eachProtectionDomainatdefineClasstime. DirtyChai’sSecureClassLoadersimultaneously injects the client’s processPrincipal[]and theDigestCodeSource, the codebase’s SHA-256 hash. A proxy’sProtectionDomaintherefore carries both sides of the call plus the content hash: a policy grant can scope on the server’s SPIFFE workload, the client’s SPIFFE workload, and the exact JAR content. No single axis alone is sufficient. - Layer 2, remote process
-
The remote client’s
WorkerSubjectprincipals travel inside a serializedAccessControlContextover the JERI wire.AccessControlContextSerializerencodes verifiable domains, those with anhttpmd:URL or aDigestCodeSource, by identity; the receiving JVM checks the SHA-256 digest before accepting each one. Unverifiable domains are counted asanonCount, and the receiver reconstructs them as anonymous placeholderProtectionDomains: their permission ceilings are preserved without asserting a specific identity. All remote domains are shed atdoPrivilegedboundaries.Earlier versions stripped unverifiable domains before transport. Version 23 of the serializer removed the practice: unverifiable domains act as permission ceilings, and removing them is an implicit privilege escalation.
- Layer 3, user
-
The per-request
UserSubject. The JERI dispatcher binds it viaSubject.callAs(userSubject, () → invoke(…)). Unlike layer 2, it survivesdoPrivileged.
Service code retrieves the received user identities from the server context:
ClientUserSubject cus = (ClientUserSubject)
ServerContext.getServerContextElement(ClientUserSubject.class); (1)
Subject[] users = cus.getUserSubjects(); (2)
Subject primary = cus.getUserSubject(); (3)
| 1 | Inside a dispatched service method, the server context element holds the wire-transferred user Subjects |
| 2 | All user Subjects, outermost first |
| 3 | Convenience accessor for single-user callers, equivalent to users[0] |
A single RPC can carry both an end-user’s JWT identity and a delegation-chain Subject without any out-of-band negotiation.
The wire protocol supports up to 16 user Subjects per call; I’ll detail the format in the next part.
Before the Registry Is Online
Part 2’s Verdict Registry gates codebase loading, but the registry is itself a service.
During the boot window, before it is reachable, BootstrapPermission in net.jini.loader.pref, target "loadCodebase", takes its place.
Only callers whose ProtectionDomain holds this permission, that is, code already trusted by the bootstrap policy, can trigger a SPIFFE-principal-based codebase load before the registry is online.
The second bootstrap piece is LocalPrincipalProvider, an SPI in org.apache.river.api.security that bridges jgdms-platform and jgdms-jeri.
SpiffeCredentialManager.start() registers the managed Subject via Security.registerLocalPrincipalProvider().
When Security.currentPrincipals() is called outside a callAs scope, during bootstrapping or on a plain thread, it falls back to the registered provider rather than returning an empty set.
Conclusion
Identity in this platform is not a filter at the door.
The WorkerSubject is in every ProtectionDomain before the first request arrives, the UserSubject is bound per call, and the remote process context travels with the call and is shed at every privilege boundary.
No session state, no thread-local leakage between calls, no boilerplate in service code.
A single call can carry up to 16 user Subjects. I’ll cover in the series' next part how they travel on the JERI wire, and what service code does with them on arrival.