picatz / picatz/flowstate

Tokens: what a claim may say, what a grant may mean, and delegation that is checkable rather than intended

Open
#560 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

auth
Dominant language
Go
Stars
9
Forks
0
Avg merge
3h 3m
Merged PRs (30d)
509

Description

Everything above this reads a token. #557 decides who a principal is, #548 decides the vocabulary a policy is written in, #549 decides how a token reaches us over a socket that can protect it. None of them decide what the token says, and that is the part with no room to be wrong twice: a claim set is a wire format, it is signed, it is cached by relying parties, and a mistake in it is a breaking change to every verifier in the world rather than a refactor.

This issue is only that: the claims, the grant model, the delegation encoding, and how each of them gets declared.

What is on main, verified

Checked against origin/main at write time, because the minting half of this is further along than it looks and the enforcing half is further behind.

  • The claim set an assertion carries is already fixed and documented: namespace, deployment, workflow, run, step, on_behalf_of, on_behalf_of_issuer, run_mode, beside the registered JWT claims (auth/issuer.go:69-106). They are deliberately flat strings, because "an AWS trust policy condition and a Google Cloud attribute mapping both address a top-level claim directly".
  • Delegation is already spelled, and is purely descriptive. ClaimOnBehalfOf is populated from identity.Subject on every mint (issuer.go:635). Its own doc says what it is for: "the subject says which workload is calling, and this says who caused it to run". Nothing in the tree checks that the workload's authority is a subset of that caller's, because there is no authority to compare — see the next point.
  • There are no grants, scopes, or capabilities anywhere. Principal.Role is set from the trust policy (auth/verifier.go:374) and is read by exactly two things: Principal.String and Principal.LogValue. grep over pkg/ and cmd/ finds no other reader. It is an audit label, not an authorization input.
  • RPC authorization today is tenancy and nothing else. authorizeRun (server/lifecycle.go:45) resolves the Temporal client from the caller's namespace, describes the run, and compares the recorded tenant; every failure answers no such run. There is no per-action check. A caller who can reach a namespace can Run, Get, List, Cancel, Terminate, Signal, and every schedule verb in it. The one exception is ManualTrigger.allowed_principals, which is a per-workflow subject allowlist declared in the Flowfile.
  • Flowstate is an RFC 8693 client and never a server. exchange_oauth.go:148 sends grant_type=token-exchange with subject_token, audience, resource, scope. grep finds no actor_token, no act, no may_act, and no token endpoint of our own.
  • Audience is already mandatory and singular. Issuer.Mint refuses an empty audience outright — "an assertion with no audience is one any relying party would accept" (issuer.go:577) — and takes one audience string, not a set.
  • Lifetimes are already short and already bounded: DefaultAssertionLifetime 5m, MaxAssertionLifetime 1h, DefaultKeyRetention 24h, enforced in NewIssuer.
  • Claims are treated as too sensitive to log and are not bounded. Principal.LogValue deliberately omits Claims "which may carry personal data that does not belong in logs" (auth/principal.go:159), while verifiedClaims (verifier.go:525) copies the entire verified claim set into the Principal with no cap on count or size, and WorkloadIdentity.claims in flowstate.proto:4532 is a bare map<string, string> with no protovalidate rules at all — in a file where a dozen other maps carry max_pairs. Both sit behind signature verification, so this is a trusted-issuer resource question rather than an unauthenticated one, and it is still the one bound this area is missing.

The shape of the gap: we can say things in a token precisely, and we cannot yet say anything about what the holder may do, so nothing downstream can check an authority it was never given.

The rule for what goes in a token

A token is a bearer credential that travels to parties we do not control, is cached, and is written down. So the rule is not "what would be convenient to have here", it is:

A claim earns its place only if a relying party must have it at the instant of verification and cannot derive it from something else already in the token. Everything else is looked up at decision time, keyed on the subject.

Three consequences, in order of how often they will be argued about:

  1. Identity and bounds travel; attributes do not. Issuer, subject, kind, namespace, audience, lifetime, the actor chain, and the ceiling on authority are all facts that must be true at signing and cannot be re-derived afterwards. Group membership, team rosters, role definitions, evidence detail beyond a summary, and anything a tenant configures are lookups. A token that carries an operator's group list is a cache with no invalidation, and it changes size with the customer.
  2. The set is closed and declared, and the mint refuses what is not in it. Today's reservedClaims check is a denylist: mintFor copies every carried claim and refuses only the ones that collide with a reserved name (issuer.go:648). That protects us from shadowing and does nothing about growth. Flipping it to an allowlist is what turns "the claim set is fixed" from a convention into a property.
  3. The claim set is bounded like every other input in this repo, on both count and total encoded bytes, refused at mint rather than truncated. A truncated claim set is a token that says something other than what was authorized.

The on_behalf_of claim is the interesting borderline case and it belongs in the token under this rule: a relying party verifying a delegated assertion has no way to look the delegator up, and that is exactly the fact it needs.

Grants: coarse actions, not fine-grained capabilities

Recommendation: a grant is a closed set of (action, namespace, workflow selector) tuples, where action is one of the verbs WorkflowService already has. Not a capability URI scheme, not per-field permissions, not object-capability tokens.

The argument is #548's, applied one layer down. The service's verbs are already named — Run, Get, List, Cancel, Terminate, Signal, SignalWithStart, Validate, Compile, and the seven schedule verbs — and #548 is unifying every policy surface onto one vocabulary in which the subject is spelled identity. A grant model with its own nouns would be the fifth spelling that issue is trying to prevent, and it would be the worst one, because it is the spelling that gets signed and cannot be renamed afterwards.

The division of labour that falls out is the important part, and it is what keeps the token small:

  • The grant is a ceiling, stamped at mint time. It can only ever narrow what the subject could otherwise do.
  • The policy is the decision, evaluated at request time in #548's vocabulary, reading identity as it does today.
  • A request is permitted only if both allow it. Neither can widen the other. A grant that names an action the policy denies is denied; a policy that allows an action the grant omits is denied.

What this costs, stated plainly: a grant cannot express "may cancel only runs it started", "may signal only the approve signal", or "may read the outputs of steps 1-3". Every one of those is a relation between the caller and the object, and a relation is not a set membership — it belongs in a CEL rule that can see both sides, which is precisely what #548 is building. So the honest framing is that the token answers "which verbs, where" and the policy answers "under what conditions", and anyone who wants conditions in the token is asking for a second policy engine that runs at mint time against facts that will have changed by the time the token is used.

The second cost is enumeration. Seven-plus verbs times a namespace times a selector is a set that a human will get wrong, so the grant needs a small number of named bundles an operator actually writes (read, operate, admin) that expand to tuples at load — expanded before signing, so the token carries tuples and a verifier never has to know what a bundle meant.

Delegation that is checkable rather than intended

Use RFC 8693's encoding literally: sub stays the delegator, act is a nested claim naming the current actor, may_act on the delegator's token names who is permitted to act for them. This is not a preference; it is the only encoding that a relying party we do not control already understands, and #557's on_behalf_of is the same fact — the mapping is direct, with Principal.on_behalf_of being the decoded form of act read in reverse.

The distinction that matters here is between an assertion that describes delegation and one that constrains it. Today's flat on_behalf_of describes: it tells a relying party who caused the run, and no check anywhere compares the two identities' authority. Four things turn that into something checkable:

  1. The issued grant is the intersection, computed at mint. An agent asks for a grant; the issuer intersects it with the delegator's grant and signs the result. An agent that asked for terminate when the human holds only run and get gets a token that does not contain terminate — not a token containing it that something later declines to honour.
  2. The result is stamped in the token. This is the whole difference between checkable and intended. A verifier compares the requested action against one claim it can read; it never walks a delegation graph, never fetches the delegator's token, and never needs an identity service to be up. An intersection that has to be recomputed at request time is a promise; an intersection that is signed is a fact.
  3. Delegation is permitted before it is attenuated. Intersection alone would let any actor act for anyone at reduced authority, which is still impersonation. The exchange refuses unless the delegator's token carries may_act matching the actor, or the deployment's delegation policy permits that pair — the fail-closed default being that neither is present and delegation is refused.
  4. The chain is bounded. act nests, so a chain is an attacker-influenced structure and gets a depth bound like every other one in this repo. Recommended maximum of two: a human, and one actor acting for them. An agent that wants to spawn an agent has to justify the third level, and until it does, the bound refuses it.
flowchart LR
    H["human principal<br/>sub=alice@example.com<br/>grant: run, get, list @ acme"] -->|may_act names the agent| X["token exchange<br/>RFC 8693<br/>subject_token + actor_token"]
    A["agent principal<br/>sub=flowstate:acme/prod/agent"] --> X
    X -->|"refuse unless may_act or delegation policy permits"| I["issuer.MintDelegated"]
    I -->|"grant = delegator ∩ requested<br/>namespace copied from delegator"| T["delegated assertion<br/>sub=alice, act={agent}<br/>grant: run, get @ acme"]
    T --> V["verifier: one claim compared,<br/>no graph walk"]

Audience, lifetime, revocation

Audience is close to solved and should be finished rather than redesigned. Mint already refuses an empty audience and already takes exactly one, which is RFC 8707's resource-indicator discipline arriving by a different road: an assertion is minted for one relying party and is useless at another. The gap is that resource — a URI naming the specific protected resource, which the exchanger already sends outbound (exchange_oauth.go:59) — has no inbound counterpart, so a token we issue cannot say "for https://api.example.com/v1/deployments" as distinct from "for example-api". Recommend carrying it as its own claim rather than overloading aud, because a verifier that matches aud by exact string must keep matching it by exact string.

Lifetime needs one new thing and one refusal. The new thing: human and agent session tokens from #557 want hours where a workload assertion wants five minutes, and MaxAssertionLifetime is one hour for a well-argued reason ("an assertion is a credential; one that lives for hours is a standing grant"). The refusal: do not raise it. Issue a short access token within the existing bound and put the long-lived half in a looked-up refresh credential, which is where revocation becomes possible at all.

Revocation, concretely, because this is where hand-waving usually happens and we have enough machinery on main to be specific:

  • For minted workload assertions, expiry is the revocation mechanism and that is a defensible answer. A five-minute credential minted inside the activity that presents it has a revocation window smaller than the propagation delay of any revocation list we could build. No introspection endpoint. Building one would add a synchronous dependency to every verification for a window that is already shorter than the outage it would cause.
  • Key rotation is not revocation, and the code says so. Issuer.Rotate drops the retired key's private half and keeps its public half published for DefaultKeyRetention (24h), precisely so assertions already in flight keep verifying (issuer.go:508-539). Rotating therefore invalidates nothing already signed. The missing verb is an explicit one — drop a retired key immediately, accepting that every assertion signed with it stops verifying — and it should be named for what it is rather than discovered by an operator who thought rotation was enough.
  • For anything longer-lived, revocation is a lookup at refresh, not a check at verification. Delete the session record, and the next refresh fails. Revocation latency then equals the access token lifetime, which is a number an operator configures and can be told.
  • A jti denylist is the one middle option, and it is bounded by construction. Every assertion carries a random jti already (issuer.go:685), and no assertion can outlive MaxAssertionLifetime, so a denylist never needs to hold an entry for more than an hour. That is a bounded amount of state with a self-evident eviction rule, which is the only reason to consider it at all.

Declaring a claim in the schema

The governing rule is #537's: a declaration that nothing is forced to consult buys nothing. It is stated there as "prove the property, not the plumbing" — a new attribute must not be recordable without being declared. The identical property here is that an undeclared claim cannot be minted and an undeclared capability cannot be granted, and today neither holds: mintFor copies any carried claim that does not collide with a reserved name.

Two mechanisms, because there are two populations:

  • Core claims are declared in the schema, as a registry message with a custom option marking which are reserved and which may be carried. buf breaking then covers the claim set, which is correct — it is a public contract that plugins compile against.
  • Extension claims are declared in configuration a deployment or a plugin manifest supplies, loaded and compiled at startup, because a tenant that needs a claim cannot edit our .proto. This is the same seam the plugin boundary already uses to get a plugin's tasks validated and documented like built-in ones.

Both feed one registry, and the verifier and the issuer read it. The property to test is the negative one: mint a claim absent from the registry and get an error, not a token.

Sketches

Illustrative, not the landed shape.

The grant, and the fields it adds to the message that already carries identity (1-5 are taken on WorkloadIdentity):

// Capability is one verb, in one namespace, optionally narrowed to workflows.
// The verb names are the service's own, so a grant and a policy rule (#548)
// are written in one vocabulary rather than two.
message Capability {
  Action action = 1 [(buf.validate.field).enum = {defined_only: true, not_in: [0]}];

  // Namespace this capability applies in. Copied from the verified principal at
  // mint time and never read from the request: a caller that could name its own
  // namespace could name someone else's.
  string namespace = 2 [(buf.validate.field).string = {
    max_len: 63,
    pattern: "^[a-z0-9][a-z0-9-]*$"   // auth.ValidateNamespace's grammar, one copy
  }];

  // Workflows narrows the capability. Empty means every workflow in the
  // namespace; an entry is an exact name or one trailing "*".
  repeated string workflows = 3 [(buf.validate.field).repeated = {
    max_items: 32,
    items: {string: {min_len: 1, max_len: 256}}
  }];
}

enum Action {
  ACTION_UNSPECIFIED = 0;
  ACTION_RUN = 1;
  ACTION_GET = 2;
  ACTION_LIST = 3;
  ACTION_CANCEL = 4;
  ACTION_TERMINATE = 5;
  ACTION_SIGNAL = 6;
  ACTION_SCHEDULE = 7;
}

message WorkloadIdentity {
  // ... subject = 1, issuer = 2, claims = 3, namespace = 4, deployment = 5

  // Claims gains the bound it does not have today. Every other map of this
  // shape in this schema carries one.
  // map<string, string> claims = 3 [(buf.validate.field).map = {
  //   max_pairs: 32,
  //   keys: {string: {min_len: 1, max_len: 64, pattern: "^[a-z][a-z0-9_]*$"}},
  //   values: {string: {max_len: 1024}}
  // }];

  // Grant is the ceiling on what a token minted for this identity may do. It
  // never widens what policy allows; policy never widens it.
  repeated Capability grant = 6 [(buf.validate.field).repeated.max_items = 32];

  // Actor is the RFC 8693 "act" chain, innermost first: who is acting for this
  // subject. Bounded, because a chain is an attacker-influenced structure.
  repeated Actor actor = 7 [(buf.validate.field).repeated.max_items = 2];

  PrincipalKind kind = 8 [(buf.validate.field).enum.defined_only = true];  // #557
}

the declaration, which is what makes the mint able to refuse:

// ClaimDefinition declares a claim that may appear in an assertion. Core claims
// are declared in this schema; a plugin or a deployment supplies its own
// registry for extensions. Declaration alone buys nothing — Issuer.Mint refuses
// a claim absent from the compiled registry (#537).
message ClaimDefinition {
  string name = 1 [(buf.validate.field).string = {min_len: 1, max_len: 64, pattern: "^[a-z][a-z0-9_]*$"}];
  ClaimType type = 2 [(buf.validate.field).enum = {defined_only: true, not_in: [0]}];
  uint32 max_len = 3 [(buf.validate.field).uint32 = {gt: 0, lte: 1024}];
  bool carried = 4;   // may be copied from a submitting caller's token
}

the two Go calls that carry the whole delegation argument:

// Attenuate returns the grant a delegated token may carry: the intersection of
// what the delegator holds with what the actor asked for. It can only ever
// return a subset of delegator, which is what makes "an agent may not exceed the
// human it acts for" a property of the token rather than an intention of the
// caller. The result is signed, so a verifier compares one claim instead of
// walking identities it cannot see.
func Attenuate(delegator, requested []Capability) ([]Capability, error)

// MintDelegated mints an assertion whose "sub" remains the delegator and whose
// "act" names the actor, per RFC 8693. It refuses unless the delegator permits
// this actor — a "may_act" claim on their token, or the deployment's delegation
// policy — because attenuation without permission is still impersonation, only
// quieter. The namespace is taken from delegator's verified principal; a request
// naming a different one is an error, not an override.
func (i *Issuer) MintDelegated(ctx context.Context, delegator, actor Principal, req DelegationRequest) (Assertion, error)

what an operator writes, in #548's vocabulary rather than a new one:

policy:
  # Grants are a ceiling. Bundles expand to (action, namespace, selector) tuples
  # at load, so a signed token carries tuples and no verifier has to know what
  # "operate" meant.
  grants:
    - subject: "alice@example.com"
      issuer: https://acme.okta.com
      capabilities: [read, operate]        # expands to get, list / run, signal, cancel
      workflows: ["deploy-*"]

  delegation:
    # Fail closed: with no rule and no may_act claim, nothing may act for anyone.
    allow:
      - identity.kind == "agent" && identity.act.namespace == identity.namespace
    deny:
      - size(identity.act) > 1

and the token that comes out, decoded:

{
  "iss": "https://flowstate.example.com",
  "sub": "alice@example.com",
  "aud": "flowstate",
  "iat": 1786500000,
  "nbf": 1786500000,
  "exp": 1786500300,
  "jti": "9f2c1d0a4b6e8f31a5c7d9e0b2f4a681",
  "namespace": "acme",
  "deployment": "prod",
  "run_mode": "server",
  "act": {
    "iss": "https://flowstate.example.com",
    "sub": "flowstate:acme/prod/release-agent"
  },
  "grant": [
    {"action": "run", "namespace": "acme", "workflows": ["deploy-*"]},
    {"action": "get", "namespace": "acme"}
  ]
}

Note what is absent: no group list, no role definitions, no evidence detail beyond what #557's summary needs, no tenant configuration. Alice holds cancel in acme; the agent's token does not, because the agent did not ask for it, and if it had asked for terminate the intersection would still not contain it.

The namespace, through delegation and exchange

The rule already exists and is already load-bearing: Principal.Namespace is "determined by the trust policy from the verified token: either fixed for the issuer entry that admitted it, or taken from a claim", and IdentityFromPrincipal gives the principal's namespace precedence over the argument for exactly this reason (auth/identity.go:132). Three ways that rule can be lost, and what holds it:

  • An external IdP's claims. The tenant comes from TrustedIssuer.NamespaceClaim, which the operator names, not the token. The token supplies the value; the policy decides which claim is even consulted. Preserved unchanged; the only new work is that a human principal is bound by it identically.
  • Delegation. The delegated token's namespace is copied from the delegator's verified principal. An actor in a different namespace than its delegator is refused rather than reconciled — there is no correct merge of two tenants.
  • Token exchange. We are the client today and would become a server here. The namespace of an issued token is never read from the request parameters or from a peer's response; it is derived from the verified subject token. The resource and audience parameters a client sends are about the target, and must never be a path by which a caller selects a tenant.

The test for all three is the negative direction the house rule demands: not that a tenant reaches its own namespace, but that a delegated token, an exchanged token, and an IdP claim each fail to reach another's.

Constraints

  • Fail closed everywhere new. No grant claim means no capability, not every capability. No delegation permission means refused. An unparseable act chain denies rather than degrading to no actor.
  • Nothing here is required for a first run. A deployment with no grants configured keeps today's behaviour, which is namespace-scoped and verb-agnostic. The grant claim is a ceiling that is absent until someone sets one — the migration hazard is the opposite one, where absent starts meaning empty.
  • Bound the new inputs on the axis the peer controls: claim count, total encoded claim bytes, capability count, selector count, actor chain depth. All refused at mint, none truncated.
  • Breaking-change discipline. Claim names and the Action enum are public schema covered by buf breaking. A grant claim that ships and is later renamed breaks every token in flight, which is worse than the usual breakage because the tokens are already signed.
  • Tokens stay out of history and logs, with the containment shapes already tested for Assertion and SigningKey. The grant claim is not secret; the token carrying it is.
  • Not done until reachable. A grant model that only a Go test can construct is scaffolding. It lands when an operator can write it in policy, flow can show what a token permits, and an example exercises the refusal.

Questions

  1. Grant unit: coarse actions, or fine-grained capabilities? Recommended coarse — (action, namespace, workflow selector) over the verbs WorkflowService already has, with named bundles expanded before signing. It costs every relational grant ("only runs it started", "only the approve signal"), which moves to #548's CEL where both sides of the relation are visible. The alternative buys expressiveness in the one place expressiveness cannot be revised, because the tokens are signed.
  2. Grant as ceiling plus policy as decision, or grant as the whole decision? Recommended ceiling-plus-policy, with permission requiring both. A token that is the whole decision cannot be narrowed after issuance and cannot see request context; a policy that is the whole decision cannot attenuate a delegated agent at all.
  3. Does the grant claim land before there is any per-action enforcement? Recommended yes, mint-and-ignore, for the reason #557 gives for landing on_behalf_of early: retrofitting a claim that policies already read is a breaking change to every rule and every token. Enforcement follows once #548's vocabulary exists to express it.
  4. act/may_act as RFC 8693 spells them, or #557's on_behalf_of shape on the wire? Recommended RFC 8693 on the wire and #557's on_behalf_of as the decoded Go/proto form. The existing flat on_behalf_of and on_behalf_of_issuer claims stay as they are — they are load-bearing for cloud relying parties that read flat claims — and are documented as descriptive, with act being the constraining one.
  5. Maximum actor chain depth: 2, or configurable? Recommended a fixed 2 to start. An agent spawning an agent is a real request and should have to arrive with a use case rather than a config knob.
  6. Explicit key revocation, or expiry only? Recommended adding one verb that drops a retired public key immediately, since Rotate today deliberately keeps it published for 24h and an operator reasonably expects rotation to revoke. The jti denylist is the deferred option: bounded by construction because nothing outlives MaxAssertionLifetime, and still not worth the state until something asks for it.
  7. Where does the extension claim registry live? Recommended: core claims in the schema under a custom option, extensions in a deployment or plugin-supplied registry compiled at startup, both feeding one registry the issuer and verifier consult — with the property tested in the negative, that an undeclared claim fails to mint.
  8. Should MaxAssertionLifetime move for human sessions? Recommended no. Short access token within the existing bound, long-lived half as a looked-up refresh credential, which is also the only construction in which revocation means anything.

Related: #557 (PrincipalKind, Evidence, on_behalf_of — the principal this token describes), #548 (one policy vocabulary — the grant model is written in it or it is a fifth spelling), #549 (the serving surface an issued token would be obtained over), #537 (declaration is not verification).

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reading auth/issuer.go, auth/verifier.go, auth/principal.go, exchange_oauth.go, server/lifecycle.go, and the WorkloadIdentity definition in flowstate.proto. Trace the existing claim minting, verification, token exchange, and RPC authorization paths before determining how the proposed claims, grants, delegation checks, and bounds fit together. Done means the design is resolved and the resulting authorization behavior is covered by tests.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
api, authentication, authorization, backend, security
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.