Internal Actor Impersonation: Server-Side Orchestration via Self-Minted JWTs
Table of Contents
Summary
A server-side orchestrator that needs to simulate several distinct real
users' actions in one request — e.g. "provision this entire multi-party
tenant, importing LEIs, publishing each party's data, activating and
onboarding each one" — has a third option alongside client credentials and
on-behalf-of delegation: mint its own short-lived JWT as if it were a
specific end-user/party, and drive the real NATS request/handler pipeline
through it, exactly as a real client would. This is internal actor
impersonation — ores.iam.core's internal_impersonation_service mints
the token, internal_request_client issues requests and polls workflows
with it. The token is deliberately short-lived (60s default), which is
fine for a single call but not for the multi-minute polling loops this
pattern is built to drive — so internal_request_client transparently
re-mints and retries once on expiry, given a refresh callback; without
one, expiry is a fast, clearly-labelled failure rather than a silent
retry-until-timeout. tenant_handler::provision_acme (see
Commission Acme Corporation) is the worked example and the pattern's origin.
Detail
Why a third pattern, not one of the existing two
Service-to-Service Auth: Client Credentials vs. On-Behalf-Of Delegation covers the two standard flows, and internal impersonation is neither:
- Client credentials gives the orchestrator its own service identity — no tenant, no party, no end-user permissions. It cannot drive handlers that are written to act as a specific party (activate this party, set this account's default party, complete this party's onboarding) — those handlers resolve "which party" from the caller's JWT claims, and a service identity has none.
- On-behalf-of delegation forwards an inbound caller's token. There is no inbound caller to forward for most of an orchestrator's own steps — by the time it needs to act as "Acme Corporation UK plc", nothing has ever authenticated as that party; the party didn't exist as an authenticate-able identity until this same request created it.
Internal impersonation originates a new identity, chosen by the orchestrator itself from data it already trusts (the tenant/account it is provisioning, a party it just looked up) — not by forwarding or reusing anyone else's credential. It is a purpose-built escape hatch for orchestration, not a general substitute for either flow: reach for it only when a server-side process must act as several different identities in turn to drive real, permission-checked handlers, and hand-writing SQL that duplicates those handlers' logic is the only alternative.
The flow
- Mint:
internal_impersonation_service::mint_token(ctx, tenant_id, account_id, party_id, username, ttl = 60s)builds a full JWT — tenant, party, and the account's actual permission set (looked up fromaccount_id, not inherited from whatever context is minting it) — and signs it with the same signer every other JWT in the system uses. Downstream handlers cannot tell this token apart from one a real login produced; they enforcehas_permissionand tenant/party scoping exactly as normal. - Request:
internal_request_client(nats, token, refresh_token)wraps one impersonated identity.client.request(req)attaches the token as a plainAuthorizationheader (notX-Delegated-Authorization— this is an originated identity, not a forwarded one), serialises/ deserialises via the same typed request/response structs any other caller of that subject uses, and throws on transport or parse failure. - Poll:
client.wait_for_workflow_instance(instance_id, timeout, expected_steps, on_progress)drives adq.v1.bundles.publish-style workflow to completion, callingon_progresson every step-status change so the orchestrator can stream human-readable progress back to its own caller.
Token expiry: fail fast, then self-heal
The token's short TTL (documented at the mint site as "keep short — this is a use-once, discard-immediately token, not a session credential") is correct for a single request but routinely too short for the polling loops built on top of it — a GLEIF counterparty import alone can run past 600s. Two things make that survivable rather than either an infinite retry storm or a spurious failure:
- The server tells the client why it was rejected. A JWT-validation
failure replies with an empty body and an
X-Errorheader (e.g.token_expired; seeores::service::messaging::error_reply/error_code) rather than a payload.internal_request_client::request()checks for that header before attempting to parse a body, and raises it as a distinct, typedservice_error— never a generic parse-failurestd::runtime_errorthat reads as "the server sent garbage" when it actually said something specific. service_error("token_expired")is retried once, transparently, if a refresh callback was supplied —request()re-mints viarefresh_token(), updates its own token, and retries the same call once. No caller-visible error, no interruption to a poll loop, for the routine case of a token expiring mid-wait. Without a refresh callback (or if the rejection isn'ttoken_expired, or the retried call is rejected again), theservice_errorpropagates. Inwait_for_workflow_instance, that is caught separately from generic transport failures and treated as terminal, not transient — it logs a clear "aborting wait: token rejected, cannot succeed on retry" message and returnsfalseimmediately, rather than retrying every 500ms until the wait's own deadline expires with no useful signal about why. Get this distinction right: an expired token will never become valid by asking again with the same token, so treating it like a busy-server hiccup (worth retrying) rather than a stale credential (worth refreshing, or else giving up loudly) is the concrete bug this pattern's first iteration shipped with, went undetected through a full ~10-minute happy-path run, and only surfaced under an end-to-end test against live services – see Commission Acme Corporation's task journal for how it was caught, diagnosed via theores.iam.service=/=ores.workflow.servicelogs, and fixed.
Wiring the refresh callback: one identity per client
A single orchestrator run typically impersonates several identities in
turn (the caller's own party for tenant-wide steps, then each
newly-activated party for its own steps) — refreshing must re-mint for
the same party each client was built for, not whichever party happens
to be in scope when expiry is detected. The idiom, from
tenant_handler::provision_acme:
auto mint = [&](const boost::uuids::uuid& party_id) { return impersonation_.mint_token(*ctx_expected, tenant_id_str, account_id, party_id, username); }; auto make_client = [&](const boost::uuids::uuid& party_id) { return internal_request_client(nats_, mint(party_id), [&mint, party_id] { return mint(party_id); }); }; internal_request_client client = make_client(party->id);
make_client closes over mint and the specific party_id by value, so
the refresh callback always re-mints for that same party regardless of
what the surrounding loop has since moved on to. Every
internal_request_client built in provision_acme goes through this
helper — there is no direct internal_request_client(nats_, mint(...))
construction left in that handler.
What this pattern is not for
- Not a way to skip permission checks — the minted token carries the impersonated account's real permission set; a handler that would reject that account acting as that party rejects this token too.
- Not a substitute for OBO when there genuinely is an inbound caller to forward — if the identity to act as is the request's own caller, delegate their token; only mint a new one when no such caller token exists for the identity you need.
- Not a general request-retry helper — the refresh-on-expiry behaviour
in
internal_request_clientis specific to thetoken_expiredX-Errorcase; other rejections (forbidden,bad_request) still fail immediately, by design — they will not resolve by re-minting.
See also
- Service-to-Service Auth: Client Credentials vs. On-Behalf-Of Delegation — the other two service-to-service auth flows this pattern sits alongside.
- Commission Acme Corporation: a realistic holding-group test entity — the
story that introduced this pattern;
tenant_handler::provision_acmeis the worked example. - Anatomy of a Service — general service call mechanics (mint or
delegate) this pattern's
mintstep builds on. - Service Bootstrap Phases — ordering constraints for minting tokens against a service's own just-started subjects, relevant background for why tokens are minted per-call rather than once at startup here.