Skip to content

feat(delegation): RFC 8693 actor tokens + gateway/workload subjects. - #131

Open
terylt wants to merge 7 commits into
devfrom
feat/actor_token
Open

feat(delegation): RFC 8693 actor tokens + gateway/workload subjects.#131
terylt wants to merge 7 commits into
devfrom
feat/actor_token

Conversation

@terylt

@terylt terylt commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Foundational slice for multi-principal delegation. A delegate(...) step can
now 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 selectionsubject: user | client | caller_workload | gateway
    on a delegation step. The minted token's attribution (DelegationMode) is
    derived from the subject, never declared separately — so a route can't claim
    on-behalf-of-user while handing over a workload SVID.
  • RFC 8693 actor tokensactor: caller_workload attaches the caller's SVID
    as the actor_token, so the minted token records act alongside sub
    (validated and signed by the IdP).
  • Gateway-as-subjectsubject: gateway switches to a client_credentials
    grant (the gateway calls as itself); no inbound credential required.
  • SPIFFE workload ingress — a role: caller_workload resolver validates
    JWT-SVIDs into caller_workload.*, stashed as TokenKind::SpiffeJwt;
    non-SPIFFE tokens are rejected.
  • Cache-collision fixDelegationKey now includes the calling workload, so
    tokens minted for one agent can't be served to another once cross-request
    caching lands.
  • RenameTokenRole::WorkloadCallerWorkload (serde alias = "workload" keeps existing configs working), disambiguating the caller from
    the gateway.
  • Docs — identity/delegation pages document the subject: / actor: keys
    and the three attributions.

Not in this PR (follow-ups)

  • Gateway's own SVID as subject_token (subject: gateway +
    actor: caller_workload, so the backend sees both) — needs a gateway-credential
    source.
  • Vault-backed delegation for downstreams that don't speak OAuth.

Verification

884 tests passing; fmt and clippy -D warnings clean. New behavior is covered
by e2e tests, each mutation-checked (broke the code, confirmed the test fails).

Signed-off-by: Teryl Taylor <terylt@ibm.com>
@terylt
terylt force-pushed the feat/actor_token branch from ffa751e to bddd0d3 Compare July 23, 2026 23:24
terylt and others added 4 commits July 23, 2026 17:53
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>
@araujof
araujof marked this pull request as ready for review July 28, 2026 19:39
@araujof
araujof requested review from araujof and jonpspri as code owners July 28, 2026 19:39

@araujof araujof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 "route authorization: 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:

  1. "policies" is overloaded. The pre_invocation and post_invocation steps are the authorization policy. Calling the tag bundles "policies" collides with that. groups: names them for what they do: reusable bundles a route joins.
  2. 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 all as a reserved key inside groups: and document it: groups.all applies to every request, every other key is opt-in via a route's group.
  • (b), cleaner Retire the magic name. Move the always-on policy to global.authorization, beside the existing global.authentication. Then groups: 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-bundle

Use 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 all policy group's plugins" becomes "global.authorization" (option b) or "the all group's authorization" (option a).
  • Tag bundle row: global.policies.<tag>.authentication becomes groups.<name>.authentication; …plugins becomes groups.<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 next comment lists "Recipe 5 — user + actor … RFC 8693 actor_token," but Recipe 5 exists as Scope a token the agent already holds. The branch is feat/actor_token, and the actor: caller_workload row 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 gatewaythis_workload note handles this well. When apl:authorization: and meta.tagsgroup: land, add the same one-line "(old form still accepted)" note so operators with existing configs know their config still loads.

@araujof araujof self-assigned this Jul 28, 2026
@araujof araujof added documentation Improvements or additions to documentation enhancement New feature or request framework Rust labels Jul 28, 2026
@araujof araujof added this to CPEX Jul 28, 2026
@github-project-automation github-project-automation Bot moved this to Backlog in CPEX Jul 28, 2026
@araujof araujof moved this from Backlog to In progress in CPEX Jul 28, 2026
@araujof araujof added this to the 0.2.3 milestone Jul 28, 2026
@terylt terylt mentioned this pull request Jul 29, 2026
10 tasks
Signed-off-by: Teryl Taylor <terylt@ibm.com>
@terylt

terylt commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — all addressed on the branch (6e6e3db).

Config keys & docs

  • apl: → canonical authorization: across every recipe + the scoping table (1)
  • Consolidated "stack in one config" example replacing the identity-example block (5)
  • Terminology standardized on "group" — dropped "tag bundle" / "policy group" (6)
  • Recipe-stub collision: resolved by adding the actor-token recipe (Recipe 6) and renumbering the stub (5)

Schema — groups: (2, 4)

  • Top-level groups: section for bundles + a route groups: field to join them (string-or-list, reusing StringOrList).
  • One deliberate divergence worth flagging: groups: is implemented as sugar over tags — it folds into the route's tag set at resolution rather than being a second, parallel membership mechanism. Rationale: tags stay the single substrate (they're host-injectable at runtime and carry metadata beyond membership), the blast radius is ~zero (every meta.tags config keeps working unchanged), and the surface you asked for is unchanged — groups: hr-tools and meta: { tags: [hr-tools] } resolve identically.
  • global.policies: still loads as a back-compat alias; both merge at parse.

allglobal.authorization (3)

  • Split out as [CHORE]: Retire "all" APL keyword. #143. It's cross-crate (the cpex-core resolver and the apl-cpex always-on layer), and retiring a reserved name deserves its own focused change. Per your steer I've stopped surfacing all / global.policies in the docs — they now teach only groups: and describe the always-on layer generically.

Ready for another look

@araujof araujof linked an issue Aug 3, 2026 that may be closed by this pull request
@araujof
araujof self-requested a review August 4, 2026 00:14

@araujof araujof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::Workload was removed as a Rust symbol; its serde alias helps serialized config, not downstream Rust code.
  • DelegationMode::AsGateway became AsThisWorkload without a serde alias, so persisted as_gateway values no longer deserialize.
  • DelegationKey gained 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_workload and caller_workload subjects (delegator.rs:405). In particular, the comment at delegation_invoker.rs:169 says this_workload pairs with an actor, while the delegator deliberately omits it. Reject or warn on unsupported combinations and align the comment.
  • The caller_workload flow 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-act warning decodes every applicable JWT and logs on every request. It is unthrottled, and "act": null is treated as present. Gate/throttle the check and treat null as absent.
  • identity-delegation.md:428 says subject: this_workload carries the caller workload as a claim. It does not: the client_credentials request contains no caller identity. docs/content/docs/apl/delegation.md describes the behavior correctly.
  • Clean up the remaining Gateway / AsGateway comments and test names in payload.rs. The rustdoc for resolve_plugins_for_entity is also attached to route_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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request framework Rust

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

[FEATURE]: identity-spiffe plugin for SPIFFE workload identity

2 participants