feat(delegation): RFC 8693 actor tokens + gateway/workload subjects. - #131
feat(delegation): RFC 8693 actor tokens + gateway/workload subjects.#131terylt wants to merge 7 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.
Signed-off-by: Teryl Taylor <terylt@ibm.com>
|
Thanks for the thorough review — all addressed on the branch ( Config keys & docs
Schema —
Ready for another look |
Signed-off-by: Teryl Taylor <terylt@ibm.com>
There was a problem hiding this comment.
Excellent work!
I found a few issues to address before merging.
Top-level groups: is not wired into the runtime load path
merge_groups_into_policies, the stale-identity: guard, and validate_config run only through parse_config. PluginManager::load_config_yaml deserializes CpexConfig directly, so a running APL-enabled host never folds top-level groups: into global.policies (crates/cpex-core/src/manager.rs:540). Routes that reference those groups therefore lose the group's plugins and authentication: steps.
The visitor walk has the same gap: it visits only global.policies, so authorization: / apl: inside a top-level group is never compiled (manager.rs:584). All new groups tests call parse_config, which hides both failures. Please route load_config_yaml through the same normalization/validation path and add a full-load regression test.
subject: client is attributed and keyed as a user
mode_for_subject has no Client arm, so client-subject exchanges become OnBehalfOfUser (builtins/plugins/delegator-oauth/src/delegator.rs:591). apply_to_extensions then keys the token with security.subject.id, even though the exchanged credential belongs to security.client (crates/cpex-core/src/delegation/payload.rs:575). DelegationKey also has no client principal, so different OAuth clients can collide when the remaining fields match.
Please add explicit client attribution and include client_id in the key. The subject-to-mode mapping should live in cpex-core (for example, DelegationSubject::default_mode) rather than being partly handler-specific with a separate user fallback in apply_to_extensions.
Invalid delegation principals fail open
An unknown subject: passes through from_config_str(...).unwrap_or_default() and silently becomes user (crates/apl-cpex/src/delegation_invoker.rs:141). An unknown actor: silently becomes no actor (delegation_invoker.rs:300). Both can exchange a different credential shape than the policy author requested.
Please distinguish an absent key, where the documented default is valid, from a present but invalid value, which should deny configuration or invocation. Add production-path tests for both typos and an e2e case for subject: client; the current subject-selection tests exercise role_from_cfg, but production subjects use DelegationSubject::from_config_str.
The SPIFFE fallback accepts non-SPIFFE identities
The sub branch checks for spiffe://, but the fallback spiffe_id claim does not (builtins/plugins/identity-jwt/src/claim_map.rs:187). A JWT with a non-SPIFFE sub and arbitrary spiffe_id is accepted into caller_workload and labeled TokenKind::SpiffeJwt. Apply the same prefix check to the fallback and cover that branch in the rejection test.
Public API breaks need migration support and a CHANGELOG entry
TokenRole::Workloadwas removed as a Rust symbol; its serde alias helps serialized config, not downstream Rust code.DelegationMode::AsGatewaybecameAsThisWorkloadwithout a serde alias, so persistedas_gatewayvalues no longer deserialize.DelegationKeygained a required public field without a constructor or#[non_exhaustive], breaking downstream struct literals.
The PR template requires a CHANGELOG update for user-facing changes, and 0.2.1 documents similar renames as breaking. Please document these changes and preserve serialized compatibility where possible.
Smaller issues
- An unknown route group is silently ignored. If that group supplied
authentication:, a typo can leave the route unauthenticated; validate group references at load time. - A configured actor is silently dropped for both
this_workloadandcaller_workloadsubjects (delegator.rs:405). In particular, the comment atdelegation_invoker.rs:169saysthis_workloadpairs with an actor, while the delegator deliberately omits it. Reject or warn on unsupported combinations and align the comment. - The
caller_workloadflow makes two IdP calls per request and does not cache the leg-1 base token. The base token is independent of the downstream audience/scope and can be reused until expiry. - Leg-1 rejection copies the IdP's error description or raw body into the caller-visible violation (
delegator.rs:250). Bound and sanitize it; an IdP may echo submitted credential material. - The missing-
actwarning decodes every applicable JWT and logs on every request. It is unthrottled, and"act": nullis treated as present. Gate/throttle the check and treat null as absent. identity-delegation.md:428sayssubject: this_workloadcarries the caller workload as a claim. It does not: theclient_credentialsrequest contains no caller identity.docs/content/docs/apl/delegation.mddescribes the behavior correctly.- Clean up the remaining
Gateway/AsGatewaycomments and test names inpayload.rs. The rustdoc forresolve_plugins_for_entityis also attached toroute_static_tags(config.rs:813).
Finally, the workload cache test validates key construction and insertion, but production does not yet read from delegated_tokens. It should not be described as proving that a token cannot be served cross-request until the lookup path exists and is tested end to end.
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.
Closes: #109
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).