picatz / picatz/flowstate

Non-human access: workload federation is mostly built, and here are the three holes between it and a CI job, a Terraform run, or a script

Open
#559 3 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

Non-human access is the part of flowstate's identity story that is mostly finished, and saying so precisely is more useful than proposing it again. A CI job, a Terraform run and a service account are all workloads presenting an assertion, and the machinery that verifies one, decides what it may do, and exchanges it for a credential elsewhere is on main and is good. What is missing is narrow, and it is not in the verification path — it is in acquisition, in claim mapping, and in the one deployment that has no IdP at all.

What is true on main, verified

Read from the tree at 048645c, because the whole value of this issue is that the inventory is honest.

Inbound is direct token presentation, and there is no exchange step. service WorkflowService in proto/flowstate/v1/flowstate.proto:5115 has seventeen RPCs and none of them is about tokens: there is no /token endpoint, no session, no flowstate-issued credential for a caller. auth.Authenticator (auth/connect.go:31) runs as connectrpc.com/authn middleware ahead of body decode and verifies the bearer token against the trust policy directly. So a workload presents its own IdP's token — the GitHub Actions OIDC token, the GitLab CI token, the Kubernetes projected service account token — and flowstate accepts or refuses it. That is the whole ceremony, and it means for these sources the inbound half is done rather than planned.

The trust policy is already the right shape for this. auth.Policy and auth.TrustedIssuer (auth/policy.go:24, :65) name an issuer with exact-string iss matching and no normalization (policy.go:77), require at least one audience so a token minted for another service cannot be replayed (policy.go:397), refuse none and every HMAC algorithm whatever the policy says (policy.go:202, :408), take Role and Namespace from the policy and never from the token (policy.go:109), and support MaxTokenAge so an operator can insist on the short lifetime the platform already gives (policy.go:156). ClaimRule matches by exact string equality and cannot express a wildcard (policy.go:160) — that is a deliberate refusal, and it is the right one. Several entries may name one issuer, tried in order, which is how one platform grants different roles to different repositories.

Outbound federation is complete and has no field that could hold a secret. FederationPolicy (auth/federation.go:37) describes four target kinds — RFC 8693 token exchange, AWS AssumeRoleWithWebIdentity, GCP Workload Identity Federation with optional service-account impersonation, and OAuth client credentials (federation.go:75:135, built at :291). Broker (auth/broker.go:33) gates with compiled CEL, mints a per-target assertion, exchanges it, and caches until shortly before expiry; Credential drops its secret material through any serializer, so a credential wrongly returned to workflow code arrives unusable rather than leaked (auth/exchange.go:65). examples/http-federated/ exercises this end to end, and its auth-policy.yaml already names https://token.actions.githubusercontent.com as an inbound issuer.

The CI path is tested against the real provider. auth/ci_federation_test.go covers the GitHub Actions claim shape offline; auth/realtoken_test.go:63 fetches a genuine runner token and verifies it through live discovery, and refuses to skip in the job that exists to run it (FLOWSTATE_REQUIRE_REAL_TOKEN). Very little in this repository is verified that hard.

The CLI does the one rotation-critical thing correctly. cmd/flow/credentials.go:90 re-reads the token file on every request rather than caching it at startup, precisely because Kubernetes rewrites a projected token in place. There is deliberately no --token flag, because a credential in argv is a credential in ps (credentials.go:31), and the file read is bounded at 64 KiB (credentials.go:48).

Per-source inventory

Source Inbound today What it needs
GitHub Actions Works. Verified live in CI. Nothing in the verifier. Acquisition (hole 1); claim mapping if more than a handful of tenants (hole 2).
GitLab CI Works as an ordinary OIDC issuer — no GitLab-specific code exists and none is needed. Same two. Its tenant-shaped claim project_path is group/project, so hole 2 bites immediately.
Kubernetes projected SA tokens Works, and is the case the CLI's per-request file read was written for. Nothing in flowstate. The operator-side catch is real but external: the cluster's issuer must be reachable and its discovery document readable by the control plane, which for https://kubernetes.default.svc.cluster.local it usually is not — the answer is a configured external service-account issuer, not a code change here.
AWS / GCP / Azure, inbound Works. Any of them is an OIDC issuer. Nothing.
AWS / GCP, outbound Works. Dedicated exchangers. Nothing.
Azure, outbound Does not exist. There is no Azure target kind; grep -i azure over pkg/ and cmd/ finds one comment about a metadata IP. Either an azure: target or a documented recipe using the generic token_exchange: target against Entra. Proposal, not a defect.
Terraform Cloud / HCP Untested, but structurally identical to GitLab. See below.

Hole 1: nothing in the shipped tree acquires a token

The only code that turns an ambient workload identity into a token is requestCIToken in auth/realtoken_test.go — a test helper. Every real deployment therefore writes the same twelve lines of curl against ACTIONS_ID_TOKEN_REQUEST_URL, pipes the .value through jq into a file, and hopes nobody logs it. That is the gap between "flowstate supports workload identity federation" and "a job can talk to flowstate", and it is the largest one in this issue.

It also produces the mid-script failure. The CLI's per-request file read handles the rotating file case perfectly, and handles the fetched once case not at all: a GitHub Actions OIDC token lives minutes, a flow watch on a real deployment lives longer, and worthAskingAgain (cmd/flow/watch.go:160) deliberately does not retry CodeUnauthenticated — correctly, since re-presenting the same dead token a second later changes nothing. The refusal is right and the outcome is a broken script.

The fix is a credential source: a named, pluggable thing the CLI (and any Go client) asks for a fresh assertion, which knows how to reach the ambient identity and re-mints when the current one is close to expiry. Broker already has the refresh-margin logic on the outbound side; this is the same idea pointed inward. Sources: github-actions, gitlab, terraform-cloud, file (today's behaviour, unchanged and still the default), env. Fail-closed: an unknown source name is a startup error, and a source that cannot mint is a refusal rather than a fall-through to anonymous.

Hole 2: namespace_claim cannot map the claims real platforms mint

TrustedIssuer.NamespaceClaim passes a claim value straight into ValidateNamespace, and TestCITenantFromClaim (auth/ci_federation_test.go:228) records the consequence in four subtests: GitHub's repository claim can never map, because it always contains a /; repository_owner maps only when the org login happens to be lowercase letters and dashes already. GitLab's project_path has the same shape. The refusal is correct — a caller whose tenant cannot be determined must not land in a shared one — and the documented workaround at policy.go:133:141 is one issuer entry per tenant with a fixed namespace: and a pinning require: rule, ordered before any namespace_claim entry.

That workaround is fine at three tenants and is exactly #548's finding 2 at forty. What is missing is a mapping, and it must not be a looser grammar: no regex, no prefix rules, no separator substitution. An explicit table, unlisted values refused, is the spelling that matches ClaimRule's existing refusal to express a wildcard.

Hole 3: the deployment with no IdP

Some deployments have none. Today their only options are --insecure-no-auth (cmd/flow/main.go:1556), which is anonymous, or flow jwt sign, which is capped at one hour and whose own help says it is "for debugging identity, not for minting production workload assertions" (cmd/flow/jwt.go:22, :48). Neither is a service account.

The minimum honest story is a local issuer: the deployment holds a signing key (flow keys generate exists), auth.Issuer already mints, rotates with retention and publishes a key set (auth/issuer.go), and the trust policy names that issuer like any other. No new verification path, no account store, no new vocabulary.

Its risks, stated plainly because this is the static-credential path everything above exists to avoid. The signing key is a static credential, and holding it is equivalent to being every workload in the deployment. There is no revocation — an assertion is good until it expires, so a leaked token cannot be withdrawn and a leaked key is total compromise until rotation completes. Rotation is manual and nothing forces it. And it will be reached for by deployments that do have an IdP, because it is easier. The mitigations are real but partial: cap the assertion lifetime hard, require max_token_age on the trust entry, pin the audience to this deployment, and refuse to load a key that is not a file with restrictive permissions. My recommendation is to build it last, document it as a fallback in the same sentence as its risks, and never make it the default that a first run silently gets.

Note also that the key set the outbound issuer computes is not served anywhere — grep finds no JWKS route under cmd/ — which is #549's finding, not this issue's, but it is the reason a local issuer's assertions cannot yet be verified by anyone else.

Terraform, concretely

A picatz/flowstate provider authenticates the same way flow does, because there is nothing else to do: obtain a workload token, put it in Authorization: Bearer, done. Two ordinary cases.

In HCP Terraform, setting a workload-identity audience variable causes the run to be given an OIDC token in the environment; its issuer is HCP's, and its claims name the organization, project, workspace and — usefully — the run phase, so plan and apply are distinguishable. (That claim shape is from HashiCorp's documentation, not verified in this tree; it should be pinned by a test of the ci_federation_test.go shape before anything depends on it.) In Terraform run from CI, the provider reads the same CI-issued token the CLI would.

So what flowstate must expose is not a new protocol surface. It is: the credential source from hole 1, published as a Go package a provider can import rather than a CLI-only feature; a documented audience convention so an operator knows what to set the audience variable to; and the claim-mapping from hole 2, since organization:acme:project:infra:workspace:prod:run_phase:apply is not a namespace either. The genuinely nice consequence is that plan gets a read-only role and apply a writing one from a claim rule and a role: — no new mechanism, and it lands in #548's vocabulary as an ordinary rule.

Sketches

Illustrative, not the landed shape. The trust policy — the first two entries are today's spelling and work now; namespace_map is the proposal from hole 2, added beside namespace/namespace_claim rather than changing either, so no existing policy breaks:

issuers:
  # Works today, exactly as written.
  - name: github-actions-infra
    issuer: https://token.actions.githubusercontent.com
    audiences: [https://flowstate.example.com]
    algorithms: [RS256]
    require:
      - claim: repository
        any_of: [acme/infra]
      - claim: runner_environment
        any_of: [github-hosted]
    role: deployer
    namespace: infra
    max_token_age: 10m

  # Terraform, apply phase only, writing role. Also works today.
  - name: hcp-terraform-apply
    issuer: https://app.terraform.io
    audiences: [https://flowstate.example.com]
    require:
      - claim: terraform_workspace_name
        any_of: [prod]
      - claim: terraform_run_phase
        any_of: [apply]
    role: deployer
    namespace: infra
    max_token_age: 10m

  # PROPOSED: the mapping that removes one entry per tenant. Exact table only —
  # no patterns, no separator rewriting. A value not listed is refused with
  # ErrNoNamespace, the same as a missing claim: never a shared tenant.
  - name: gitlab
    issuer: https://gitlab.com
    audiences: [https://flowstate.example.com]
    namespace_map:
      claim: project_path
      values:
        acme/infra: infra
        acme/platform: platform

The workflow snippet, which is what a user copies. id-token: write is the only unusual line, and the audience must match the policy above or the token is refused — deliberately, per TestCIIssuedTokenVerifies:

jobs:
  deploy:
    permissions:
      contents: read
      id-token: write          # without this there is no OIDC token to get
    steps:
      - uses: picatz/setup-flow@v1
      - run: flow run deploy.yaml --server https://flowstate.example.com
        env:
          # PROPOSED. Today this line does not exist and the job writes ~12 lines
          # of curl against $ACTIONS_ID_TOKEN_REQUEST_URL to produce a token file.
          FLOWSTATE_CREDENTIAL_SOURCE: github-actions
          FLOWSTATE_AUDIENCE: https://flowstate.example.com

The provider block, with no token argument at all — there is deliberately no way to write a static credential into HCL, for the same reason flow has no --token flag:

provider "flowstate" {
  server = "https://flowstate.example.com"

  # PROPOSED. Omitted, the provider resolves in the CLI's order: an explicit
  # source, then FLOWSTATE_TOKEN_FILE, then FLOWSTATE_TOKEN, then anonymous.
  credential_source = "terraform-cloud"
  audience          = "https://flowstate.example.com"
}

And the session, which is the part that fails today. The first block is real behaviour; the second is the proposal:

$ flow watch deploy-01K7 --server https://flowstate.example.com
... 6m of output ...
refused while watching "deploy-01K7": unauthenticated

$ FLOWSTATE_CREDENTIAL_SOURCE=github-actions flow watch deploy-01K7 \
    --server https://flowstate.example.com
identity  repo:acme/infra:ref:refs/heads/main  (github-actions → namespace infra, role deployer)
          credential expires in 9m41s, source can re-mint
... 22m of output, one silent re-mint at the 8m mark ...
deploy-01K7  completed

How this composes with #557 and #548

A CI principal and a human principal differ in kind and in nothing else. #557's PrincipalKind gives this slice PRINCIPAL_KIND_WORKLOAD for free and, more importantly, gives it a way to say it: a rule that today cannot distinguish a script from a person becomes identity.kind == "workload". The federation machinery inventoried above already produces exactly the principal #557 proposes putting a kind on; nothing here needs a second type, and adding one would fork every policy surface.

#548 is a prerequisite for the same reason it is one for #557, and this slice supplies the sharpest evidence for it: the subject an assumption rule gates on is spelled workload (auth/assume.go:177:179) while an egress rule spells the same thing identity, and the credential-source work above will add a fourth surface if the vocabulary is not settled first. The rule that must hold when this lands is that a CI caller and a person are gated by the same rules, reading different fieldsidentity.namespace == "infra" means one thing, and only a rule that genuinely cares reaches for identity.kind or identity.claims.terraform_run_phase.

The ordering that falls out: #548's vocabulary, then the credential source and claim mapping here (which are independent of #557 and of interactive login), then #557's human work on top. Only the local issuer in hole 3 depends on #549, and only because its key set needs somewhere to be served.

Questions

  1. Is the credential source the right first slice? Recommended yes — it is the only hole a user hits on day one, it is independent of #548 and #557, and it is what turns a verified capability into a reachable one. Per this repository's own rule, workload federation is not done until a job can use it without hand-written curl.
  2. Credential source in the CLI, or in a Go package the CLI and a Terraform provider both import? Recommended the package, from the start. A CLI-only implementation means the provider reimplements it, and two implementations of a credential path is how they disagree.
  3. namespace_map as an exact table beside the existing fields, or a transform on namespace_claim? Recommended the table. A transform is one small step from a regex, and ClaimRule's existing refusal to express a wildcard is the precedent to follow rather than erode.
  4. Does the local issuer (hole 3) land at all in this arc, or does it wait for a concrete deployment that needs it? Recommended wait. It is the static-credential path, it is the one most likely to be misused by deployments that have a better option, and nothing else here is blocked on it.
  5. Azure outbound: a dedicated target kind, or a documented token_exchange: recipe? Recommended the recipe first. AWS and GCP earned dedicated exchangers by having non-standard protocols; if Entra's is standard enough, adding a fourth provider path costs more than it buys.
  6. Should the CLI print the identity it authenticated as? Recommended yes, on the first authenticated call of a non-interactive command. In CI the current failure mode is "refused" with no way to see which issuer entry was tried, and that is a diagnostics gap of the kind this repository treats as a feature.

Related: #557 (PrincipalKind — the kind this slice's principals carry), #549 (the serving surface; TLS is why the CLI refuses to send a credential off loopback today, and the unserved JWKS route blocks hole 3's local issuer), #548 (one policy vocabulary — prerequisite, and the workload/identity split is visible in auth/assume.go).

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 with auth/realtoken_test.go, auth/ci_federation_test.go, auth/policy.go, and cmd/flow/credentials.go to understand token acquisition, claim validation, and file rotation. Define and test the credential sources, explicit claim mapping, and local issuer proposal described here; done means the three missing deployment paths are implemented without changing the existing verification behavior and their risks are documented.

Written by the indexing model from the issue text.

Assessment

Tech stack
github-actions, go, terraform
Domain
authentication, backend-api-design, cli, security
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.