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 in PlatformClaimGroupController, 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:

  1. 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.

  2. Identity is bound to authz. Because TrustMint reads realm_access.roles, platform admins must live in a realm that issues the platform-admin role. 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 entire SecurityHelper class are deleted.

  • Zero references to realm_access.roles in TrustMint source.

  • The Keycloak master.platform-admin realm role and the unused flexgalaxy realm roles are deleted.

  • Platform admins are migrated to the flexgalaxy realm and assigned the TrustmintPlatformAdministrator permission set in DotID IDC.

  • A trustmint.authz.use-policy-engine feature 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_sets and 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 isPlatformAdmin from 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.