You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
delegator-oauth covers delegation using RFC 8693 token exchange. For example, a legacy service sits behind one static API key. Or a third-party service like GitHub expects a personal scoped credential that the user creates themselves. Neither has an exchange endpoint to call.
The request is to add a TokenDelegate handler that resolves a downstream credential from Vault at delegation time. For example, Johnny creates a scoped GitHub token, stores it in Vault under his own identity, and his agent's calls are attached to it only while Johnny's inbound token is what authorized the call. Another case would be an agent with an SVID-JWT token, getting exchanged for a downstream legacy api key.
The load-bearing decision is how PPE authenticates to Vault. Where the credential belongs to a caller, PPE logs in to Vault with the caller's own token (Vault JWT auth, policy templated by the authenticated entity) so Vault enforces the per-user isolation. The alternative, PPE authenticating as itself and then choosing the caller's path, leaves PPE holding a credential that can read every user's secret and reduces the isolation to a PPE-side conditional. Only the shared-credential case authenticates as PPE.
Vault auth method follows DelegationSubject:
subject
Vault login
reads
user
JWT auth with the inbound user token
that user's path
caller_workload
JWT auth with the caller's JWT-SVID (actor_token)
that workload's path
client
JWT auth with the client credential
that client's path
this_workload
PPE's own AppRole, Kubernetes, or X.509-SVID cert auth
a shared path
Scope for this slice is KV v2 reads only. Dynamic secrets, where Vault mints a leased credential per call, are the natural follow-up and are what AttenuationConfig would actually be able to honor.
This is independent of #66. That issue resolves secrets into PPE's own configuration at startup; this one resolves a different credential per request per caller. The two share only a Vault HTTP client, which #66 R19 already keeps private so this can depend on it or fork it.
Acceptance criteria
A delegator-vault builtin registers a TokenDelegate handler and is available behind its own facade feature, absent from the default build.
All Vault calls go through the host-supplied HttpTransport. No Vault SDK, no second HTTP stack. A KV read is idempotent for retry purposes; a login is not.
Config selects the Vault auth method per subject, explicitly, with no default.
For user, client, and caller_workload, PPE logs in to Vault with that principal's token and never holds a Vault token that can read another principal's path.
The secret path is templated from a configured claim, and the claim used is operator-configurable rather than assumed to be sub.
A resolved credential populates RawDelegatedToken with the configured outbound_header, so a service expecting X-API-Key works as well as one expecting Authorization.
expires_at is set from the handler's cache TTL, since a KV secret carries no expiry of its own. The configured TTL is the documented upper bound on how long a revoked credential stays usable.
The credential cache is keyed by the resolved principal identity. A test asserts one caller cannot be served another caller's credential from cache.
A caller with no secret at their path is distinguishable from a Vault failure, and produces a deny that names the missing enrollment rather than a generic error.
Vault tokens, inbound tokens, and resolved credentials never appear in errors, logs, serialized state, or Debug output.
Delegation runs only after the policy decision allows the call.
Tests cover each subject mapping, path templating, a missing secret, a Vault outage, cache isolation between callers, and rotation of the stored secret.
Open questions
Enrollment. How the credential gets into Vault is out of scope, but the path convention is a contract between whatever Johnny uses to store it and this handler's config. Needs to be written down even though no code implements it.
Missing secret should probably elicit, not deny. PPE has an elicitation seam. "Your agent needs GitHub access, enroll a token" is a better answer than a 403, and it is the interaction that makes this feature feel finished. Out of scope here; worth designing the deny path so it can become an elicitation later.
scopes on RawDelegatedToken. A stored PAT has no discoverable scopes, and the framework documents monotonic narrowing as an invariant that TokenDelegate enforces. Needs a decision on what an empty scope list means before this ships.
AttenuationConfig cannot be honored for a static stored credential. Decide whether a route that requests attenuation against this handler fails config validation or is ignored with a warning.
Scheme prefixes.RawDelegatedToken carries one header name and one opaque value. Whether the handler or the consuming plugin owns a Bearer or token prefix is unspecified.
Description
delegator-oauthcovers delegation using RFC 8693 token exchange. For example, a legacy service sits behind one static API key. Or a third-party service like GitHub expects a personal scoped credential that the user creates themselves. Neither has an exchange endpoint to call.The request is to add a
TokenDelegatehandler that resolves a downstream credential from Vault at delegation time. For example, Johnny creates a scoped GitHub token, stores it in Vault under his own identity, and his agent's calls are attached to it only while Johnny's inbound token is what authorized the call. Another case would be an agent with an SVID-JWT token, getting exchanged for a downstream legacy api key.The load-bearing decision is how PPE authenticates to Vault. Where the credential belongs to a caller, PPE logs in to Vault with the caller's own token (Vault JWT auth, policy templated by the authenticated entity) so Vault enforces the per-user isolation. The alternative, PPE authenticating as itself and then choosing the caller's path, leaves PPE holding a credential that can read every user's secret and reduces the isolation to a PPE-side conditional. Only the shared-credential case authenticates as PPE.
Vault auth method follows
DelegationSubject:usercaller_workloadactor_token)clientthis_workloadScope for this slice is KV v2 reads only. Dynamic secrets, where Vault mints a leased credential per call, are the natural follow-up and are what
AttenuationConfigwould actually be able to honor.This is independent of #66. That issue resolves secrets into PPE's own configuration at startup; this one resolves a different credential per request per caller. The two share only a Vault HTTP client, which #66 R19 already keeps private so this can depend on it or fork it.
Acceptance criteria
delegator-vaultbuiltin registers aTokenDelegatehandler and is available behind its own facade feature, absent from the default build.HttpTransport. No Vault SDK, no second HTTP stack. A KV read is idempotent for retry purposes; a login is not.user,client, andcaller_workload, PPE logs in to Vault with that principal's token and never holds a Vault token that can read another principal's path.sub.RawDelegatedTokenwith the configuredoutbound_header, so a service expectingX-API-Keyworks as well as one expectingAuthorization.expires_atis set from the handler's cache TTL, since a KV secret carries no expiry of its own. The configured TTL is the documented upper bound on how long a revoked credential stays usable.Debugoutput.Open questions
scopesonRawDelegatedToken. A stored PAT has no discoverable scopes, and the framework documents monotonic narrowing as an invariant thatTokenDelegateenforces. Needs a decision on what an empty scope list means before this ships.AttenuationConfigcannot be honored for a static stored credential. Decide whether a route that requests attenuation against this handler fails config validation or is ignored with a warning.vaultcollides with feat(secrets-vault): resolve API keys from Vault KV v2 #66'ssecrets-vaultin the facade's short-name convention.RawDelegatedTokencarries one header name and one opaque value. Whether the handler or the consuming plugin owns aBearerortokenprefix is unspecified.