ADR-0004: TrustMint Authz Is Delegated to DotID’s Policy Decision Service¶
Status: Accepted
Date: 2026-05-18
Deciders: Chao Jiang
Related: ADR-0001, DotID ADR-0005 (PDP authoritative for tenant authz)
Motivating phase: Phase 24 —
trustmint-authz-policy-engine
Context¶
TrustMint backend historically made authorization decisions by parsing Keycloak claims directly. SecurityHelper.isPlatformAdmin() reads realm_access.roles from the JWT and returns true if platform-admin appears. There are 13 call sites of this pattern across the codebase:
Pattern A — boolean gate (reject if
!admin): 8 sites inPlatformClaimGroupController,RegistrarAccessRequestService,RegistrarService,ThingService.Pattern B — scope widening (admin sees all, user sees own): 5 sites in
ProvisioningRunController(list, get),RegistrarController.list,ThingMakeAuditController.list,ThingService(read).
This pattern predates DotID’s resource-based authorization stack (policy-engine, permission_sets, the IDC model in iam-svc). It is freestyle code written before the authz architecture existed — once DotID landed its PDP, trustmint never migrated, leaving an architectural inconsistency: tenant authz on the DotID side is resource-action-permission-set, while platform authz on the TrustMint side is JWT-realm-role.
The inconsistency has two concrete costs:
No single source of truth for “what can a user do.” Platform admin is defined twice (Keycloak realm-role list + DotID permission-set assignment) and the two definitions can drift silently.
Identity is bound to authz. Because TrustMint reads
realm_access.roles, platform admins must live in a realm that issues theplatform-adminrole. Identity placement is forced by the authz mechanism rather than being an independent decision.
Decision¶
TrustMint backend MUST delegate every authorization decision to DotID’s policy-engine via a canPerform(action, resource) contract. TrustMint code MUST NOT inspect realm_access.roles, resource_access, or any other Keycloak claim to make an authorization decision. The JWT remains the authentication credential; it does not carry authorization data that TrustMint reads.
The contract¶
boolean canPerform(String action, String resource); // resource may be null
action is a namespaced verb the operation requires (e.g. registrar:create, provisioning_run:list_all). resource is an opaque FRN or null for global operations. The implementation queries policy-engine; policy-engine evaluates the caller’s permission-set assignments against the requested action and returns allow/deny. Callers see only a boolean.
Migration shape (the only two allowed)¶
Pattern A — boolean gate. Mechanical 1:1 replacement.
// Before
if (!securityHelper.isPlatformAdmin()) { throw new ForbiddenException(); }
// After
if (!securityHelper.canPerform("registrar:create", null)) { throw new ForbiddenException(); }
Pattern B — scope widening. Keep the branching, replace only the boolean.
// Before
return securityHelper.isPlatformAdmin()
? repo.findAll()
: repo.findByOwner(callerId);
// After
return securityHelper.canPerform("provisioning_run:list_all", null)
? repo.findAll()
: repo.findByOwner(callerId);
A scope-filter abstraction (policyEngine.evaluateScope(...) returning a server-side filter object) is rejected for this milestone. It is a bigger refactor and the branching shape works. Revisit if Pattern B grows beyond a handful of sites.
End state after Phase 24¶
SecurityHelper.isPlatformAdmin(),SecurityHelper.isMasterRealm(), and the entireSecurityHelperclass are deleted.Zero references to
realm_access.rolesin TrustMint source.The Keycloak
master.platform-adminrealm role and the unusedflexgalaxyrealm roles are deleted.Platform admins are migrated to the
flexgalaxyrealm and assigned theTrustmintPlatformAdministratorpermission set in DotID IDC.A
trustmint.authz.use-policy-enginefeature flag exists during migration. It is removed at the end of Phase 24; post-Phase-24 rollback requires a code revert.
Consequences¶
Positive¶
Single source of truth for authorization. “Who can do what” is defined exclusively in DotID permission sets. Adding or revoking a platform admin is a permission-set assignment change, never a Keycloak realm-role change AND a permission-set change.
Identity placement becomes an independent decision. Platform admins can live in any realm DotID’s IDC links from; the realm choice no longer forces the authz mechanism.
Aligns with DotID’s existing tenant authz model. One mental model, one contract, one client SDK, one audit trail.
New TrustMint endpoints that need authz have one and only one shape to follow: call
canPerform. No JWT introspection, no role-list parsing.
Negative / Trade-offs¶
Every TrustMint authz check now incurs a network call to policy-engine. The policy-engine client SDK must implement appropriate caching, timeouts, and retry to keep the hot path responsive. The Phase 24 plan specifies these knobs.
A new failure mode: policy-engine unreachable. The SDK must fail closed (deny) — a fail-open default would silently disable authz when the PDP is down. This is an acceptable trade-off: a brief 503 on writes is preferable to silent privilege escalation.
One-time migration cost: 13 call sites, 1 identity migration, Keycloak realm-role deletes. Phase 24 sequences this with a feature flag so each call site can be migrated and validated independently.
Anti-patterns¶
Reading
realm_access.roles“just for this one place” — there should be zero such reads after Phase 24.Building a TrustMint-local cache of
permission_setsand evaluating actions locally. This re-introduces the two-sources-of-truth problem on a different axis. Use the policy-engine SDK’s cache; do not roll your own.Inventing a
canPerformAny(actions...)variant before there is a real call site that needs it. Add when justified.Deriving
isPlatformAdminfrom the JWT as a “compatibility shim.” Phase 24 explicitly forbids this — the JWT is auth, not authz.
References¶
Phase 24 context — full migration design, call-site inventory, feature-flag plan.
DotID ADR-0005 — companion ADR establishing PDP as the sole authority for tenant authz.
PolicyEngineClient— client SDK (Phase 24 Plan 01).SecurityHelper— class to be deleted at end of Phase 24.