feat(delegation): RFC 8693 actor tokens + gateway/workload subjects. - #131
feat(delegation): RFC 8693 actor tokens + gateway/workload subjects.#131terylt wants to merge 5 commits into
Conversation
Signed-off-by: Teryl Taylor <terylt@ibm.com>
Signed-off-by: Teryl Taylor <terylt@ibm.com>
…workload, and added SVID scenario. Signed-off-by: Teryl Taylor <terylt@ibm.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
There was a problem hiding this comment.
Nice work! I focused on the policy-definition mechanics and a few consistency gaps a reader would hit. For example, the route policy uses the apl: wrapper, which is the orchestrator-specific spelling. Our canonical keys are authentication: and authorization:.
1. Drop apl:, use the canonical authentication: / authorization: pair
In apl-cpex/src/visitor.rs, apl_subblock() honors an explicit apl: wrapper first, then falls back to FLAT_APL_KEYS written directly on the route. That list is ["pre_invocation", "post_invocation", "authorization", "args", "result", "pdp", "session_store"]. So authorization: { pre_invocation: [...] } on a route compiles to the same IR as apl: { authorization: { ... } }. configuration.md already uses this form.
apl names one policy orchestrator. The visitor's name() returns "apl", and its trait comment lists future "rego" and "cedar-direct" visitors. Writing user-facing policy as apl: couples our public config to that implementation. authentication: and authorization: stay stable across orchestrators. The DSL strings inside the steps (require(...), delegate(...), !delegation.granted: deny) do not change. Only the wrapper key moves.
Before (Recipe 1, as written):
routes:
- tool: get_compensation
apl:
pre_invocation:
- "require(role.hr)"
- "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])"After (canonical):
routes:
- tool: get_compensation
authorization:
pre_invocation:
- "require(role.hr)"
- "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])"Apply the same swap to Recipes 2, 3, 5 and the scoping prose. Two table cells also name the key:
- Scoping table, Route row: "route
apl:steps /plugins:" becomes "routeauthorization:steps /plugins:". - Check the surrounding examples so none reintroduce
apl:.
2. Promote group bundles to a top-level groups: key (schema change)
Today these live at global.policies.<name> (a HashMap<String, PolicyGroup> in config.rs). I suggest we move them to a top-level groups: key beside global: and routes:. Two reasons this reads better:
- "policies" is overloaded. The
pre_invocationandpost_invocationsteps are the authorization policy. Calling the tag bundles "policies" collides with that.groups:names them for what they do: reusable bundles a route joins. - Three top-level concerns line up.
global:for always-on defaults,groups:for opt-in bundles,routes:for per-entity policy.
Map-keyed vs list
I think I prefer map-keyed attributes over a list.
# Recommended: map keyed by group name
groups:
hr-tools:
authentication: [jwt-manager]
authorization:
pre_invocation:
- "require(role.hr)"vs.
# Alternative: list of {group: name, ...}
groups:
- group: hr-tools
authentication: [jwt-manager]
authorization: { pre_invocation: [...] }The list form only buys richer per-entry structure, which this doesn't need.
Consideration to resolve: the reserved all group
all applies to every request. Sitting it in a groups: map next to taggable bundles reads oddly, since you never tag a route into all. It behaves as a global default. Two options:
- (a) Keep
allas a reserved key insidegroups:and document it:groups.allapplies to every request, every other key is opt-in via a route'sgroup. - (b), cleaner Retire the magic name. Move the always-on policy to
global.authorization, beside the existingglobal.authentication. Thengroups:holds only opt-in bundles with no reserved names. I lean toward this option.
3. Make group a first-class route attribute (schema change)
Today: meta: { tags: [hr-tools] }. RouteMeta.tags drives group inheritance. The resolver merges meta.tags with host-injected request_tags, then pulls any group whose name matches a tag.
Pulling group membership out of meta would improve ergonomics. meta is a grab-bag of scope, properties, and tags. Bundle membership drives routing, so I think it makes sense to promote it as a first-class field.
Two things to settle:
Singular vs list. A route can join several bundles today (several tags). Keep that:
routes:
- tool: get_compensation
group: hr-tools # singular convenience
- tool: get_compensation
groups: [hr-tools, pii] # or list, for multi-bundleUse one field that accepts string-or-list. config.rs already has StringOrList for entity matchers, so reuse it. Name it groups: to match the top-level section, and accept a bare string as sugar.
Keep tags working. meta.tags covers more than group membership. The host injects runtime tags too, and any tag that matches a group name pulls in that group. Frame groups: as the first-class way to name bundle membership, with meta.tags as the lower-level runtime mechanism (or an accepted alias). Say so in the doc so no one reads it as tags going away.
4. Consolidated target example (all three together)
Add this as the canonical example, replacing the current "Identity example" block:
global:
authentication: [jwt-user] # every request gets user identity
# (optionally) authorization: { pre_invocation: [...] } # always-on policy
groups:
hr-tools:
authentication: [jwt-manager] # + manager identity on this bundle
authorization:
pre_invocation:
- "require(role.hr)"
routes:
- tool: get_compensation
group: hr-tools # joins the bundle: jwt-user + jwt-manager, require(role.hr)
authorization:
pre_invocation:
- "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])"Then update the scoping table rows:
- Global row: "the
allpolicy group'splugins" becomes "global.authorization" (option b) or "theallgroup'sauthorization" (option a). - Tag bundle row:
global.policies.<tag>.authenticationbecomesgroups.<name>.authentication;…pluginsbecomesgroups.<name>.authorization. - Rename that row's label from "Tag bundle" to "Group" for consistency.
5. Other review notes (doc-only, smaller)
- Stale stub, recipe-number collision. The
What to add nextcomment lists "Recipe 5 — user + actor … RFC 8693 actor_token," but Recipe 5 exists as Scope a token the agent already holds. The branch isfeat/actor_token, and theactor: caller_workloadrow sits in the "I want to…" table with no recipe behind it. Add the actor-token recipe or renumber the stub. The two disagree as written. - Terminology. The page calls the same thing "tag bundle," "policy group," and "policies." If we adopt
groups:, standardize on "group," with one parenthetical gloss on first use. - Deprecated-alias callouts. The
gateway→this_workloadnote handles this well. Whenapl:→authorization:andmeta.tags→group:land, add the same one-line "(old form still accepted)" note so operators with existing configs know their config still loads.
Summary
Foundational slice for multi-principal delegation. A
delegate(...)step cannow name whose identity the minted credential speaks for and who is
acting, covering the on-behalf-of, autonomous-agent, and gateway-as-itself
cases. More to come as we finish the remaining use cases.
What's here
subject: user | client | caller_workload | gatewayon a delegation step. The minted token's attribution (
DelegationMode) isderived from the subject, never declared separately — so a route can't claim
on-behalf-of-user while handing over a workload SVID.
actor: caller_workloadattaches the caller's SVIDas the
actor_token, so the minted token recordsactalongsidesub(validated and signed by the IdP).
subject: gatewayswitches to aclient_credentialsgrant (the gateway calls as itself); no inbound credential required.
role: caller_workloadresolver validatesJWT-SVIDs into
caller_workload.*, stashed asTokenKind::SpiffeJwt;non-SPIFFE tokens are rejected.
DelegationKeynow includes the calling workload, sotokens minted for one agent can't be served to another once cross-request
caching lands.
TokenRole::Workload→CallerWorkload(serdealias = "workload"keeps existing configs working), disambiguating the caller fromthe gateway.
subject:/actor:keysand the three attributions.
Not in this PR (follow-ups)
subject_token(subject: gateway+actor: caller_workload, so the backend sees both) — needs a gateway-credentialsource.
Verification
884 tests passing;
fmtandclippy -D warningsclean. New behavior is coveredby e2e tests, each mutation-checked (broke the code, confirmed the test fails).