diff --git a/.claude/skills/dual-module-change/SKILL.md b/.claude/skills/dual-module-change/SKILL.md index d533459..d7e201b 100644 --- a/.claude/skills/dual-module-change/SKILL.md +++ b/.claude/skills/dual-module-change/SKILL.md @@ -1,6 +1,6 @@ --- name: dual-module-change -description: Use when a change touches workcontext/ — the leaf module — or both it and the root SDK. Covers the dependency budget that is the module's whole reason to exist, the two go.mod/go.sum pairs that must be tidied independently, the two different coverage gates, and how a consumer resolves a version of a Go submodule. Reach for it when a root-module command reports "ok" but workcontext was never compiled, when `go mod tidy` at the root leaves workcontext dirty, or before adding any import to workcontext. +description: Use when a change touches workcontext/ — the leaf module — or both it and the root SDK. Covers the dependency budget and the one rule that overrides it (the Work Context has one implementation, core's), the two go.mod/go.sum pairs that must be tidied independently, the two different coverage gates, and how a consumer resolves a version of a Go submodule. Reach for it when a root-module command reports "ok" but workcontext was never compiled, when `go mod tidy` at the root leaves workcontext dirty, or before adding any import to workcontext. --- # Changing the two modules @@ -14,21 +14,37 @@ That is the contract. Everything below follows from it. ## The dependency budget -`workcontext/go.mod` currently has three direct requirements — `core` (for the -`codefly/base/v0` proto types), `testify`, `grpc` — and five indirect ones. The -root SDK's indirect list is roughly fifty. +`workcontext/go.mod` has four direct requirements — `core` (the +`codefly/base/v0` proto types **and** `core/workcontext`), `protobuf`, +`testify`, `grpc`. The root SDK's indirect list is roughly fifty. -**Adding an import to `workcontext` is a design change.** Before you do: +The fourth one arrived by decision, not by accident, and the decision is worth +knowing before you argue from the budget: `core/workcontext` pulls +`protovalidate` → `cel-go` → `antlr`, which this module used to avoid, and it +was taken anyway because the Work Context has exactly one implementation and it +is core's. A smaller `go.sum` is not worth a second implementation of a signed +credential. See `workcontext/README.md`, "One implementation, and the gate that +keeps it that way". -- Can the type come from `codefly/base/v0`, which the module already has? +**Adding a further import to `workcontext` is still a design change.** Before +you do: + +- Can the type come from `codefly/base/v0` or `core/workcontext`, which the + module already has? - Does it belong in `workcontext/grpctransport/` instead? That subpackage exists - so a verify-only consumer never compiles grpc. Anything transport-shaped goes - there, not beside the verifier. + so a consumer that makes no gRPC call never compiles grpc. Anything + transport-shaped goes there. - Does it belong in the root SDK, with `workcontext` left alone? If none of those work, say in the PR body what the consumer's `go.sum` now costs. Growth is a decision someone makes, not a side effect of an import line. +**One import is refused outright**: nothing in this module may import +`crypto/ed25519` or `crypto/ecdsa`, and only `mint.go` may import +`encoding/json` (the mint endpoint's HTTP bodies). +`TestNoSecondWorkContextImplementation` fails the build on either, which is the +gate rather than this paragraph. + The root module must never import `workcontext` to reach shared code. That would make the leaf module a dependency of the thing it was split out of, and a consumer taking the leaf would be back to the full tail. diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 94d4cd9..743aa9e 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -1,13 +1,21 @@ name: codefly go SDK on: - # compat/** carries the release lines consumers pin when main holds an - # unreleased breaking change. A branch whose commits are pinned by a consumer - # is a published artifact, so it is built like one. + # ANY branch a consumer can pin is a published artifact and is built like + # one. This used to name compat/** as "the release lines consumers pin", + # which was a rule about a naming convention: a consumer pins a commit, and + # go.mod records a pseudo-version with no idea what the branch was called. + # The one-implementation sweep covers every published ref for the same + # reason, and a dependabot branch carrying the deleted implementation is what + # showed the convention was doing no work. + # So the triggers name no convention either. They said `compat/**` for three + # revisions after the comment above stopped saying it, which meant a branch a + # consumer can pin — a dependabot branch, say — was never built and never + # swept by its own push, while the comment claimed every published ref was + # held to the rule. push: - branches: [ main, 'compat/**' ] + branches: [ '**' ] pull_request: - branches: [ main, 'compat/**' ] concurrency: group: sdk-go-${{ github.workflow }}-${{ github.ref }} @@ -26,7 +34,15 @@ jobs: module: [ ".", "workcontext" ] steps: - name: Checkout + # fetch-depth 0 so the TAGS are present. + # TestEveryPublishedTagCarryingTheImplementationIsRetracted sweeps every + # published tag and refuses to pass having seen none — nineteen of them + # publish the deleted implementation and a tag can only be retracted, + # never fixed. A shallow checkout would have made that gate green by + # looking at nothing. uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: @@ -42,6 +58,77 @@ jobs: go mod tidy git diff --exit-code -- go.mod go.sum + # The sweep lives HERE, in the workflow that builds every ref this repository + # publishes, rather than in one of its own that ran on main alone. go.yml's + # own `on:` is the statement of which refs are built, so a gate that has to + # hold on all of them belongs beside the build rather than beside a list of + # refs that drifts from it. + # + # The job's name is load-bearing: the active ruleset on main requires the + # status check "no second Work Context implementation" by that exact context, + # so renaming this job would leave a required check that never reports. + one-implementation: + name: no second Work Context implementation + runs-on: ubuntu-latest + # Ten, not five. This job sweeps the working tree and every published ref, + # reading imports with go/parser — one `git cat-file` per Go file per ref. + # Measured locally between 22s and 131s depending only on the OS file + # cache, and a CI runner is slower than that. A gate that times out is a + # gate somebody deletes, and the honest answer to "the gates cost more now" + # is to say what they cost. + timeout-minutes: 10 + steps: + - name: Checkout + # fetch-depth 0 so every published ref is reachable: the second step + # reads them out of the object database. + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + - name: Set up Go + # The sweep reads imports with go/parser now (scripts/importsof), + # because a regex over lines cannot read Go — `import + # "\x63rypto/ed25519"` compiles and is crypto/ed25519 to the compiler. + # So this job needs a toolchain, which it did not before. + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + - name: Build the import reader once + # Built once and passed to every invocation, rather than rebuilt for + # each of ~25 refs. + run: | + go build -o "$RUNNER_TEMP/importsof" ./scripts/importsof + echo "IMPORTSOF_BIN=$RUNNER_TEMP/importsof" >> "$GITHUB_ENV" + - name: Sweep every tracked Go file for a second implementation + run: ./scripts/check-one-implementation.sh + - name: Sweep every OTHER ref this repository publishes + # The logic lives in scripts/sweep-published-refs.sh so it can be + # TESTED. Inline, it was wrong and nothing could notice: it listed refs + # with `git for-each-ref 'refs/remotes/origin/*'`, and `*` does not + # cross a slash, so every namespaced branch was silently dropped. This + # step reported "sweeping 1 other published ref(s)" and "ok + # origin/badges" and went GREEN while + # origin/dependabot/go_modules/gomod-06d3dc2861 published + # workcontext/work_context.go importing crypto/ed25519. + # + # The script lists by prefix and asserts SWEPT AGAINST WHAT THE REMOTE + # PUBLISHES, because a gate that sweeps a subset and reports success is + # worse than no gate — the green is the evidence somebody acts on. + # SWEEP_BASE is passed EXPLICITLY, from the repository's own default + # branch, so the sweep does not have to guess which ref this change + # merges into. Guessing is what made this check answer differently on a + # push build than on a pull_request build: GITHUB_BASE_REF is empty on + # a push, the base fell through to the pushed branch, and the default + # branch — which carries the implementation until this merges — was + # swept and failed. + env: + SWEEP_BASE: ${{ github.event.repository.default_branch }} + SWEEP_HEAD: ${{ github.head_ref || github.ref_name }} + run: | + git fetch --no-tags --prune --quiet origin \ + '+refs/heads/*:refs/remotes/origin/*' + ./scripts/sweep-published-refs.sh + lint: name: lint runs-on: ubuntu-latest @@ -51,7 +138,15 @@ jobs: module: [ ".", "workcontext" ] steps: - name: Checkout + # fetch-depth 0 so the TAGS are present. + # TestEveryPublishedTagCarryingTheImplementationIsRetracted sweeps every + # published tag and refuses to pass having seen none — nineteen of them + # publish the deleted implementation and a tag can only be retracted, + # never fixed. A shallow checkout would have made that gate green by + # looking at nothing. uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: @@ -70,7 +165,10 @@ jobs: coverage: name: coverage runs-on: ubuntu-latest - timeout-minutes: 5 + # Ten: the root suite now includes the published-tag audit, which + # sweeps 83 tags through the import reader. Measured 22s-131s on a warm + # versus cold file cache. + timeout-minutes: 10 permissions: contents: write # The receipts store is written in the caller's transaction, admits on a @@ -95,7 +193,15 @@ jobs: CODEFLY_TEST_POSTGRES_DSN: postgres://codefly:codefly@localhost:5432/receipts?sslmode=disable steps: - name: Checkout + # fetch-depth 0 so the TAGS are present. + # TestEveryPublishedTagCarryingTheImplementationIsRetracted sweeps every + # published tag and refuses to pass having seen none — nineteen of them + # publish the deleted implementation and a tag can only be retracted, + # never fixed. A shallow checkout would have made that gate green by + # looking at nothing. uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: @@ -123,7 +229,10 @@ jobs: race: name: race runs-on: ubuntu-latest - timeout-minutes: 5 + # Ten: the root suite now includes the published-tag audit, which + # sweeps 83 tags through the import reader. Measured 22s-131s on a warm + # versus cold file cache. + timeout-minutes: 10 strategy: matrix: module: [ ".", "workcontext" ] @@ -149,7 +258,15 @@ jobs: CODEFLY_TEST_POSTGRES_DSN: postgres://codefly:codefly@localhost:5432/receipts?sslmode=disable steps: - name: Checkout + # fetch-depth 0 so the TAGS are present. + # TestEveryPublishedTagCarryingTheImplementationIsRetracted sweeps every + # published tag and refuses to pass having seen none — nineteen of them + # publish the deleted implementation and a tag can only be retracted, + # never fixed. A shallow checkout would have made that gate green by + # looking at nothing. uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: diff --git a/.golangci.yaml b/.golangci.yaml index 7cf1232..6228930 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -17,9 +17,11 @@ linters: exclusions: generated: lax rules: + # ST1005 forbids a capitalized error string, which it cannot tell from a + # proper noun: every sentinel in these files begins with "Codefly". - linters: - staticcheck - path: (^|/)(runtime_environment_file|work_context)\.go$ + path: (^|/)(runtime_environment_file|mint|stream)\.go$ text: "ST1005:" paths: - .*\.pb\.go diff --git a/.testcoverage.yaml b/.testcoverage.yaml index bf5b0b6..de5a5a0 100644 --- a/.testcoverage.yaml +++ b/.testcoverage.yaml @@ -12,3 +12,11 @@ threshold: exclude: paths: - \.pb\.go$ + # scripts/ is TOOLING, not the library a consumer imports: importsof reads a + # Go file's imports for the ref sweep and runs as a built binary, so the + # test that drives it (TestTheImportReaderAnswersWithWhatGoSees) execs it + # and no coverage counter is attributed. Counting it would lower the gate on + # the library in exchange for a number about a 40-line main, and raising + # the gate by excluding real code is the thing this line must not become — + # it names scripts/ exactly, not a pattern that grows. + - ^scripts/ diff --git a/AGENTS.md b/AGENTS.md index 6260834..f62096a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,17 +89,20 @@ package line. | --- | --- | | `codefly.go` | `Init`, the immutable env snapshot, `Inject*`, process-level accessors | | `for.go` | the `For(ctx)` query: endpoints, configuration, secrets, workspace values | +| `authority.go` | authority-bearing values, read once at boot; a drift is refused, never reloaded | | `runtime_value.go` | `RuntimeValue`, for values the runtime injects directly | | `runtime_environment_file.go` | loading a runtime-written env file | | `fixture.go` | the selected fixture, and resolving its principals by role | | `tls.go` | workload leaf certificates, reloaded on rotation | | `receipts/` | effect receipts: the store, the digest, the replay/conflict interceptor | | `receipts/grpctransport/`, `receipts/connecttransport/` | the two transport adapters, split so neither drags the other's dependency in | -| `workcontext/` | **separate leaf module**: Work Context signing and verification | -| `workcontext/grpctransport/` | the gRPC carrier, so verify-only consumers never compile grpc | +| `workcontext/` | **separate leaf module**: the mint-once client, the carriers, the cache partition, the stream guard, and typed access to core's one implementation. Its own `AGENTS.md` holds what the two gates refuse | +| `workcontext/grpctransport/` | the gRPC carrier, so a consumer that makes no gRPC call never compiles grpc | -`workcontext` exists to keep a verify-only consumer's `go.sum` small. Adding a -dependency to it is a design change, not a detail — see the skill below. +`workcontext` mints nothing and verifies nothing: `core/workcontext` is the only +implementation of the capability, and this module re-exports core's verifier by +type alias. Adding a dependency to it is still a design change, not a detail — +see the skill below. ## Rules that bite @@ -113,12 +116,42 @@ dependency to it is a design change, not a detail — see the skill below. - **An empty injected value is not a value.** A composition templating an unset variable ships the name with an empty string; `RuntimeValue` reports `false` so it cannot shadow the configuration a caller falls back to. Keep that. +- **The Work Context has exactly one implementation and it is not here.** + `core/workcontext` signs, verifies, and answers the *structural* question + through `Inspect`. Nothing here may sign, check a signature, encode a + capability **or decide what a capability is** — a local seal rule is a second + implementation even when it signs nothing, and ours disagreed with core's own + fixtures about which sentinel three refusals earn. **Four gates hold that, and + all four read both modules**: a **deny-by-default import allowlist per + module** and a **`go/types`-decided codec rule** (both via + `x/tools/go/packages`), the syntactic AST gate, and + `scripts/check-one-implementation.sh` over every published ref, which reads + imports with `go/parser` because a regex cannot — `import "\x63rypto/ed25519"` + compiles. The allowlist replaced a denylist of names that was behind by one + entry every round for ten rounds, the last being `crypto/mldsa` signing a + `WorkContextV1` that `encoding/json/v2` had encoded in the deleted format, + with both gates green. `testdata` is walked and `import "C"` is refused. + **Published TAGS cannot be fixed, only retracted**: nineteen of them carry the + deleted implementation and `go.mod` retracts them. + **`workcontext/AGENTS.md` is what each one refuses and why** — read it before + touching that module, the gate, or the sweep. +- **An authority-bearing value is read once.** A principal, binding or audience + comes from `ReadAuthority` at boot. `WorkspaceValue` answers from that pin for + a pinned name, so a drift is an error rather than a reload — the process has + already minted a credential sealed to the old value. - **A receipt is written inside the transaction that commits its effect.** A receipt written after the commit leaves a window where the effect exists and the receipt does not, and a recovery landing there reads "no receipt" for an effect that already happened. That is why `receipts.Record` takes a `Tx`. -- **`compat/**` branches are published artifacts.** Consumers pin them when - `main` holds an unreleased breaking change, so CI builds them like `main`. +- **Every published ref is held to these rules, not exempted from them.** A + consumer can pin any branch, so the sweep covers every one rather than a + `compat/*` name glob: measured, 22 of 25 remote branches still carried the + deleted implementation, so retiring the three NAMED compat refs would have + turned the gate green with 19 copies published. A line that cannot meet a + rule is **retired** by the owner, never granted a period during which the + rule is false here. A bullet forbidding the back-port of a breaking deletion + used to live here; it is what kept the implementation alive on refs CI + builds, so it is deleted. ## Procedures diff --git a/README.md b/README.md index a7f6299..0d00885 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,43 @@ # codefly + go = sdk-go -## Work Context verifier - -Services that only need to verify Codefly Work Contexts can import the leaf -module `github.com/codefly-dev/sdk-go/workcontext` instead of the full SDK. It -depends only on the shared `codefly/base/v0` proto types, so a consumer's -`go.sum` stays a handful of entries rather than inheriting core's transitive -tail. +## Work Context: mint once, sealed, one implementation + +A module process obtains its credential once per execution, sealed to the build +it is and the installation it serves, and never announces itself again. The leaf +module `github.com/codefly-dev/sdk-go/workcontext` holds the mint-once client, +the carriers that take a credential to a callee, the cache partition and the +stream guard. + +**It mints nothing and verifies nothing.** The Work Context is a core proto, and +`github.com/codefly-dev/core/workcontext` is its only implementation: the only +code that signs a capability, the only code that checks a signature, the only +encoding of either. What this module exports as a verification entry point *is* +core's, by type alias, and two tests hold it that way — +`TestNoSecondWorkContextImplementation` refuses a signing primitive, a +JSON-encoded capability or a `WorkContext*` type of our own anywhere in the +module, and `TestWorkContextConformance` drives core's own fixtures through the +entry point we export. + +That rule is written down because it was broken here, and the cost of breaking +it is a specific misdiagnosis: two encodings of one message both look like +`.` with an Ed25519 signature, so a token from one fails +*signature* verification in the other, and "signature does not verify under key +X" sends everyone to look at keys. + +See [`workcontext/README.md`](workcontext/README.md) for the model, the sealed +fields, the refusals a caller must tell apart, the four sources core's verifier +requires, and what a consumer has to stop doing — there is no heartbeat, no +registration call, and no self-reported build or installation. + +## Authority-bearing configuration + +A principal, binding or audience is read once, at boot, through +`codefly.ReadAuthority`, and the SDK refuses a runtime change to one: the host +is the authority for these values, and a value that drifts under a running +process is an error rather than a reload — the process has already minted a +credential sealed to the old one. `For(ctx).WorkspaceValue` answers from that +boot read for a pinned name, so the ordinary accessor is not a way around it. ## Tenant-scoped effect receipts diff --git a/authority.go b/authority.go new file mode 100644 index 0000000..bd01a4d --- /dev/null +++ b/authority.go @@ -0,0 +1,220 @@ +package codefly + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" +) + +// Errors a caller distinguishes when it reads an authority-bearing value. +var ( + // ErrAuthorityValueMissing reports that a declared authority-bearing value + // is not configured. It is a boot error: the process has no authority to + // run under and must not serve. + ErrAuthorityValueMissing = errors.New("codefly: authority-bearing value is not configured") + + // ErrAuthorityValueChanged reports that an authority-bearing value resolves + // differently now than it did when the process read it. The host is the + // authority for these values, so a value that drifts under a running + // process is an error and never a reload: the process that read the old + // value already minted a credential against it. + ErrAuthorityValueChanged = errors.New("codefly: authority-bearing value changed under a running process") + + // ErrAuthorityValueUnread reports a lookup for a value the process did not + // declare when it read its authority. Declaring the set up front is what + // makes "read once" checkable. + ErrAuthorityValueUnread = errors.New("codefly: authority-bearing value was not read at boot") +) + +// AuthorityValueName addresses one workspace value by the name and key the +// workspace accessors take, never by its carrier. +type AuthorityValueName struct { + Name string + Key string +} + +func (n AuthorityValueName) String() string { + return n.Name + "/" + n.Key +} + +func (n AuthorityValueName) canonical() AuthorityValueName { + return AuthorityValueName{ + Name: strings.TrimSpace(n.Name), + Key: strings.TrimSpace(n.Key), + } +} + +// Authority is the set of authority-bearing workspace values a process read +// once, at boot: the principal it runs as, the binding it serves, the audience +// it mints against. Nothing in the set can be re-read into a different value, +// because the credential the process already holds is sealed to the values it +// was minted under. +// +// Read it once during startup, before serving, and treat a non-nil error as +// fatal. Hand the resolved strings to whatever mints the process credential, +// and call Recheck before every renewal so a drifted value refuses the renewal +// instead of silently re-sealing to something the host never approved. +type Authority struct { + readAt time.Time + values map[AuthorityValueName]string +} + +// authorityPins is the process-wide record of what has been pinned. It exists +// so a later WorkspaceValue for a pinned name cannot answer with a value that +// drifted after boot: without it, reading the authority through Authority and +// reading the same name through the ordinary accessor would disagree, and the +// ordinary accessor is the one product code reaches for by habit. +var ( + authorityPinsMu sync.RWMutex + authorityPins = map[AuthorityValueName]string{} +) + +// ReadAuthority resolves every named value once and freezes the result. A name +// that resolves to nothing fails the whole read: a partially-known authority is +// not an authority, and continuing with the rest would let a process serve +// under an identity it could not fully establish. +// +// Calling it twice with the same name is allowed and must agree — the second +// read is checked against the pin exactly as Recheck would check it. +func ReadAuthority(ctx context.Context, names ...AuthorityValueName) (*Authority, error) { + if len(names) == 0 { + return nil, fmt.Errorf("%w: no names given", ErrAuthorityValueMissing) + } + query := For(ctx) + values := make(map[AuthorityValueName]string, len(names)) + for _, raw := range names { + name := raw.canonical() + if name.Name == "" || name.Key == "" { + return nil, fmt.Errorf("%w: a name and a key are both required", ErrAuthorityValueMissing) + } + if _, duplicate := values[name]; duplicate { + continue + } + value, err := query.workspaceValueLive(name.Name, name.Key) + if err != nil || strings.TrimSpace(value) == "" { + return nil, fmt.Errorf("%w: %s", ErrAuthorityValueMissing, name) + } + values[name] = value + } + // Compare and install together, under one hold of the write lock. Checking + // the pins here and writing them afterwards was a race with a wrong + // outcome rather than a torn read: two goroutines reading the same name + // across a configuration reload both saw no pin, both resolved — to + // different values — and both succeeded, the second pin overwriting the + // first. Two live *Authority values then disagreed about the audience the + // process runs under, and the race detector sees nothing because every + // access was correctly locked. + if err := pinAuthorityValues(values); err != nil { + return nil, err + } + return &Authority{readAt: time.Now().UTC(), values: values}, nil +} + +// Value answers from the frozen set. It performs no lookup, so it cannot +// observe a change; Recheck is what observes one. +func (a *Authority) Value(name string, key string) (string, error) { + if a == nil { + return "", fmt.Errorf("%w: authority was never read", ErrAuthorityValueUnread) + } + addressed := AuthorityValueName{Name: name, Key: key}.canonical() + value, ok := a.values[addressed] + if !ok { + return "", fmt.Errorf("%w: %s", ErrAuthorityValueUnread, addressed) + } + return value, nil +} + +// Names returns the declared set, sorted, for a boot log that records what the +// process established its identity from. +func (a *Authority) Names() []AuthorityValueName { + if a == nil { + return nil + } + names := make([]AuthorityValueName, 0, len(a.values)) + for name := range a.values { + names = append(names, name) + } + sortAuthorityValueNames(names) + return names +} + +// ReadAt reports when the set was frozen. +func (a *Authority) ReadAt() time.Time { + if a == nil { + return time.Time{} + } + return a.readAt +} + +// Recheck re-resolves every value in the set and refuses on the first that +// moved. A value that has disappeared counts as moved: the host withdrawing an +// authority-bearing value is a withdrawal, not an absence to tolerate. +// +// It is deliberately a check and not a reload. Nothing here updates the frozen +// set, because a credential already sealed to the old value cannot be made +// correct by forgetting what it was sealed to. +func (a *Authority) Recheck(ctx context.Context) error { + if a == nil { + return fmt.Errorf("%w: authority was never read", ErrAuthorityValueUnread) + } + query := For(ctx) + for _, name := range a.Names() { + value, err := query.workspaceValueLive(name.Name, name.Key) + if err != nil || strings.TrimSpace(value) == "" { + return fmt.Errorf("%w: %s is no longer configured", ErrAuthorityValueChanged, name) + } + if value != a.values[name] { + return fmt.Errorf("%w: %s", ErrAuthorityValueChanged, name) + } + } + return nil +} + +func authorityPin(name AuthorityValueName) (string, bool) { + authorityPinsMu.RLock() + defer authorityPinsMu.RUnlock() + value, ok := authorityPins[name] + return value, ok +} + +// pinAuthorityValues installs the whole proposed set or none of it. +// +// It compares every member against what is already pinned before it writes any +// member, so a set that conflicts in one name leaves the pins exactly as they +// were. A partial install would be the same defect ReadAuthority refuses for a +// partial read: a process running under an authority it could only half +// establish, with the half that moved silently replaced. +// +// Conflicts are reported in a stable order, so two processes racing on the same +// drift name the same value rather than whichever map iteration reached first. +func pinAuthorityValues(values map[AuthorityValueName]string) error { + names := make([]AuthorityValueName, 0, len(values)) + for name := range values { + names = append(names, name) + } + sortAuthorityValueNames(names) + authorityPinsMu.Lock() + defer authorityPinsMu.Unlock() + for _, name := range names { + if pinned, ok := authorityPins[name]; ok && pinned != values[name] { + return fmt.Errorf("%w: %s", ErrAuthorityValueChanged, name) + } + } + for name, value := range values { + authorityPins[name] = value + } + return nil +} + +func sortAuthorityValueNames(names []AuthorityValueName) { + sort.Slice(names, func(i, j int) bool { + if names[i].Name != names[j].Name { + return names[i].Name < names[j].Name + } + return names[i].Key < names[j].Key + }) +} diff --git a/authority_pin_internal_test.go b/authority_pin_internal_test.go new file mode 100644 index 0000000..407bb67 --- /dev/null +++ b/authority_pin_internal_test.go @@ -0,0 +1,110 @@ +package codefly + +import ( + "fmt" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +// The pin is installed whole or not at all, and never overwritten. +// +// This is tested at the primitive rather than through ReadAuthority because the +// defect was an interleaving, and an interleaving is not what a unit test can +// reproduce: two readers both observed no pin, both resolved — to different +// values, across a configuration reload — and both succeeded, the second +// install silently replacing the first. The race detector sees nothing, because +// every access was correctly locked. What IS testable is the property that +// makes the interleaving harmless, so that is what is asserted here: a proposed +// set is compared against the pins and installed under one hold of the write +// lock, and a set conflicting in any name installs none of its names. +func TestPinningAnAuthoritySetIsWholeOrNothing(t *testing.T) { + first := AuthorityValueName{Name: "pin-whole", Key: "audience"} + second := AuthorityValueName{Name: "pin-whole", Key: "binding"} + t.Cleanup(func() { + authorityPinsMu.Lock() + defer authorityPinsMu.Unlock() + delete(authorityPins, first) + delete(authorityPins, second) + }) + + require.NoError(t, pinAuthorityValues(map[AuthorityValueName]string{first: "one"})) + + // A set whose first name conflicts must install neither name. + err := pinAuthorityValues(map[AuthorityValueName]string{ + first: "two", + second: "a value nobody has seen", + }) + require.ErrorIs(t, err, ErrAuthorityValueChanged) + require.ErrorContains(t, err, first.String()) + + pinned, ok := authorityPin(first) + require.True(t, ok) + require.Equal(t, "one", pinned, + "a conflicting install must not overwrite the pin the process already minted against") + _, ok = authorityPin(second) + require.False(t, ok, + "a set that conflicts in one name must install none of its names") + + // Re-installing the same values is not a conflict: reading the authority + // twice is allowed and must agree, which is the same comparison. + require.NoError(t, pinAuthorityValues(map[AuthorityValueName]string{first: "one"})) +} + +// Concurrent installs of DIFFERENT values for one name: exactly one wins, the +// pin is the winner's, and every loser is told the value changed. +// +// The external version of this test was not evidence. It ran several readers +// against a constant environment value, so every reader resolved the same +// string and they agreed whatever the code did — it passed against the racy +// pre-fix version too. The race is only observable when the readers resolve +// DIFFERENT values, which is what a configuration reload between two reads +// produces and what this drives directly. +// +// Against the old unconditional install every goroutine succeeded and the last +// writer won, so the process ended up holding several *Authority values that +// disagreed about the audience it runs under, with the race detector seeing +// nothing because every access was correctly locked. +func TestConcurrentPinsOfDifferentValuesLeaveOneWinner(t *testing.T) { + name := AuthorityValueName{Name: "pin-race", Key: "audience"} + t.Cleanup(func() { + authorityPinsMu.Lock() + defer authorityPinsMu.Unlock() + delete(authorityPins, name) + }) + + const readers = 32 + outcomes := make(chan error, readers) + proposed := make([]string, readers) + var waiting sync.WaitGroup + start := make(chan struct{}) + for reader := range readers { + proposed[reader] = fmt.Sprintf("audience-%02d", reader) + waiting.Add(1) + go func() { + defer waiting.Done() + <-start // all of them at once, so the interleaving is real + outcomes <- pinAuthorityValues(map[AuthorityValueName]string{name: proposed[reader]}) + }() + } + close(start) + waiting.Wait() + close(outcomes) + + won := 0 + for err := range outcomes { + if err == nil { + won++ + continue + } + require.ErrorIs(t, err, ErrAuthorityValueChanged, + "a loser must be told the value changed, not given some other error") + } + require.Equal(t, 1, won, + "%d concurrent installs of different values must leave exactly one winner", readers) + + pinned, ok := authorityPin(name) + require.True(t, ok) + require.Contains(t, proposed, pinned, "the pin is one of the proposed values") +} diff --git a/authority_test.go b/authority_test.go new file mode 100644 index 0000000..0d9d908 --- /dev/null +++ b/authority_test.go @@ -0,0 +1,171 @@ +package codefly_test + +import ( + "testing" + + codefly "github.com/codefly-dev/sdk-go" + "github.com/stretchr/testify/require" +) + +// Deliverable 5: a value that carries authority is read once, at boot, and the +// SDK refuses a change to it under a running process. The host is the authority +// for these values; a process that already minted a credential against the old +// one cannot be made correct by noticing the new one. +func TestAuthorityValuesAreReadOnceAndADriftIsRefused(t *testing.T) { + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_ONE__AUDIENCE", "example.audience") + require.NoError(t, codefly.LoadEnvironmentVariables()) + + authority, err := codefly.ReadAuthority(t.Context(), codefly.AuthorityValueName{Name: "mint-one", Key: "audience"}) + require.NoError(t, err) + value, err := authority.Value("mint-one", "audience") + require.NoError(t, err) + require.Equal(t, "example.audience", value) + require.NoError(t, authority.Recheck(t.Context()), "nothing has moved yet") + + // The host changes the value under the running process. + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_ONE__AUDIENCE", "example.other-audience") + require.NoError(t, codefly.LoadEnvironmentVariables()) + + // The frozen read is unchanged — that is what "read once" means. + value, err = authority.Value("mint-one", "audience") + require.NoError(t, err) + require.Equal(t, "example.audience", value) + + // And the drift is an error, not a reload. + err = authority.Recheck(t.Context()) + require.ErrorIs(t, err, codefly.ErrAuthorityValueChanged) + require.ErrorContains(t, err, "mint-one/audience") +} + +// An authority-bearing value withdrawn is a withdrawal, not an absence to +// tolerate. A process whose audience has been removed must not keep serving on +// the one it remembers. +func TestAuthorityValueWithdrawnIsAChange(t *testing.T) { + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_GONE__AUDIENCE", "example.audience") + require.NoError(t, codefly.LoadEnvironmentVariables()) + authority, err := codefly.ReadAuthority(t.Context(), codefly.AuthorityValueName{Name: "mint-gone", Key: "audience"}) + require.NoError(t, err) + + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_GONE__AUDIENCE", "") + require.NoError(t, codefly.LoadEnvironmentVariables()) + err = authority.Recheck(t.Context()) + require.ErrorIs(t, err, codefly.ErrAuthorityValueChanged) + require.ErrorContains(t, err, "no longer configured") +} + +// The ordinary accessor is the one product code reaches for by habit, so it +// must not be the way around the pin. A pinned name answers from the boot read, +// and a drifted live value is refused there too. +func TestWorkspaceValueAnswersFromThePinAndRefusesADrift(t *testing.T) { + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_PINNED__BINDING", "binding-0a9f") + require.NoError(t, codefly.LoadEnvironmentVariables()) + _, err := codefly.ReadAuthority(t.Context(), codefly.AuthorityValueName{Name: "mint-pinned", Key: "binding"}) + require.NoError(t, err) + + value, err := codefly.For(t.Context()).WorkspaceValue("mint-pinned", "binding") + require.NoError(t, err) + require.Equal(t, "binding-0a9f", value) + + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_PINNED__BINDING", "binding-somebody-elses") + require.NoError(t, codefly.LoadEnvironmentVariables()) + _, err = codefly.For(t.Context()).WorkspaceValue("mint-pinned", "binding") + require.ErrorIs(t, err, codefly.ErrAuthorityValueChanged) + + // A name nobody pinned is untouched by any of this. + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_FREE__LABEL", "first") + require.NoError(t, codefly.LoadEnvironmentVariables()) + label, err := codefly.For(t.Context()).WorkspaceValue("mint-free", "label") + require.NoError(t, err) + require.Equal(t, "first", label) + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_FREE__LABEL", "second") + require.NoError(t, codefly.LoadEnvironmentVariables()) + label, err = codefly.For(t.Context()).WorkspaceValue("mint-free", "label") + require.NoError(t, err) + require.Equal(t, "second", label, "only authority-bearing values are frozen") +} + +// A partially-known authority is not an authority. Continuing with the names +// that did resolve would let a process serve under an identity it could not +// fully establish, which is the quiet half of a missing credential. +func TestReadAuthorityFailsWholeWhenOneNameIsMissing(t *testing.T) { + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_PARTIAL__AUDIENCE", "example.audience") + require.NoError(t, codefly.LoadEnvironmentVariables()) + + _, err := codefly.ReadAuthority(t.Context(), + codefly.AuthorityValueName{Name: "mint-partial", Key: "audience"}, + codefly.AuthorityValueName{Name: "mint-partial", Key: "binding"}, + ) + require.ErrorIs(t, err, codefly.ErrAuthorityValueMissing) + require.ErrorContains(t, err, "mint-partial/binding") + + // Nothing was pinned. Reading the name back unchanged would prove nothing — + // a pinned name answers with the pinned value, which is the same string — + // so the host MOVES it and the accessor must follow, which it can only do + // if the failed read left it live. + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_PARTIAL__AUDIENCE", "example.moved-after-the-failed-read") + require.NoError(t, codefly.LoadEnvironmentVariables()) + value, err := codefly.For(t.Context()).WorkspaceValue("mint-partial", "audience") + require.NoError(t, err, "a read that failed whole must pin nothing") + require.Equal(t, "example.moved-after-the-failed-read", value) +} + +// A name the process never declared is not answerable from a frozen set. +// Declaring the set up front is what makes "read once" checkable at all. +func TestAuthorityRefusesANameItNeverRead(t *testing.T) { + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_DECLARED__AUDIENCE", "example.audience") + require.NoError(t, codefly.LoadEnvironmentVariables()) + authority, err := codefly.ReadAuthority(t.Context(), codefly.AuthorityValueName{Name: "mint-declared", Key: "audience"}) + require.NoError(t, err) + + _, err = authority.Value("mint-declared", "binding") + require.ErrorIs(t, err, codefly.ErrAuthorityValueUnread) + + var never *codefly.Authority + _, err = never.Value("mint-declared", "audience") + require.ErrorIs(t, err, codefly.ErrAuthorityValueUnread) + require.ErrorIs(t, never.Recheck(t.Context()), codefly.ErrAuthorityValueUnread) + + _, err = codefly.ReadAuthority(t.Context()) + require.ErrorIs(t, err, codefly.ErrAuthorityValueMissing) + _, err = codefly.ReadAuthority(t.Context(), codefly.AuthorityValueName{Name: " ", Key: "audience"}) + require.ErrorIs(t, err, codefly.ErrAuthorityValueMissing) +} + +// Reading the same name twice is allowed and must agree. The second read is +// held to the pin exactly as Recheck would hold it, so a process cannot obtain +// a second, differently-valued authority by asking again. +func TestReadingTheAuthorityTwiceMustAgree(t *testing.T) { + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_TWICE__AUDIENCE", "example.audience") + require.NoError(t, codefly.LoadEnvironmentVariables()) + name := codefly.AuthorityValueName{Name: "mint-twice", Key: "audience"} + first, err := codefly.ReadAuthority(t.Context(), name) + require.NoError(t, err) + second, err := codefly.ReadAuthority(t.Context(), name) + require.NoError(t, err) + firstValue, err := first.Value(name.Name, name.Key) + require.NoError(t, err) + secondValue, err := second.Value(name.Name, name.Key) + require.NoError(t, err) + require.Equal(t, firstValue, secondValue) + require.Equal(t, []codefly.AuthorityValueName{name}, second.Names()) + require.False(t, second.ReadAt().IsZero()) + + t.Setenv("CODEFLY__WORKSPACE_CONFIGURATION__MINT_TWICE__AUDIENCE", "example.second-audience") + require.NoError(t, codefly.LoadEnvironmentVariables()) + _, err = codefly.ReadAuthority(t.Context(), name) + require.ErrorIs(t, err, codefly.ErrAuthorityValueChanged) +} + +// A secret-namespace value is authority-bearing in the same way, so the +// two-namespace lookup WorkspaceValue performs has to be the one ReadAuthority +// performs too — otherwise a binding delivered as a secret would read as +// missing at boot and live afterwards. +func TestAuthorityReadsTheSecretNamespaceToo(t *testing.T) { + t.Setenv("CODEFLY__WORKSPACE_SECRET_CONFIGURATION__MINT_SECRET__BINDING", "binding-secret") + require.NoError(t, codefly.LoadEnvironmentVariables()) + authority, err := codefly.ReadAuthority(t.Context(), codefly.AuthorityValueName{Name: "mint-secret", Key: "binding"}) + require.NoError(t, err) + value, err := authority.Value("mint-secret", "binding") + require.NoError(t, err) + require.Equal(t, "binding-secret", value) +} diff --git a/codefly_test.go b/codefly_test.go index dd3d4f8..4548029 100644 --- a/codefly_test.go +++ b/codefly_test.go @@ -99,16 +99,16 @@ func TestEnvironmentVariables(t *testing.T) { func TestServiceSecretPreservesHyphenatedCapabilityNames(t *testing.T) { ctx := context.Background() - t.Setenv("CODEFLY__MODULE", "saas") - t.Setenv("CODEFLY__SERVICE", "accounts") + t.Setenv("CODEFLY__MODULE", "platform") + t.Setenv("CODEFLY__SERVICE", "directory") t.Setenv( - "CODEFLY__SERVICE_SECRET_CONFIGURATION__SAAS__STORE__POSTGRES__READ-ONLY-CONNECTION", + "CODEFLY__SERVICE_SECRET_CONFIGURATION__PLATFORM__STORE__POSTGRES__READ-ONLY-CONNECTION", "postgresql://reader", ) requireNoError(t, codefly.LoadEnvironmentVariables()) value, err := codefly.For(ctx). - Module("saas"). + Module("platform"). Service("store"). Secret("postgres", "read-only-connection") assert.NoError(t, err) @@ -117,16 +117,16 @@ func TestServiceSecretPreservesHyphenatedCapabilityNames(t *testing.T) { func TestServiceSecretAcceptsNormalizedRuntimeCapabilityNames(t *testing.T) { ctx := context.Background() - t.Setenv("CODEFLY__MODULE", "saas") - t.Setenv("CODEFLY__SERVICE", "accounts") + t.Setenv("CODEFLY__MODULE", "platform") + t.Setenv("CODEFLY__SERVICE", "directory") t.Setenv( - "CODEFLY__SERVICE_SECRET_CONFIGURATION__SAAS__STORE__POSTGRES__READ_ONLY_CONNECTION", + "CODEFLY__SERVICE_SECRET_CONFIGURATION__PLATFORM__STORE__POSTGRES__READ_ONLY_CONNECTION", "postgresql://normalized-reader", ) requireNoError(t, codefly.LoadEnvironmentVariables()) value, err := codefly.For(ctx). - Module("saas"). + Module("platform"). Service("store"). Secret("postgres", "read-only-connection") assert.NoError(t, err) @@ -137,19 +137,19 @@ func TestServiceConfigurationReadsRuntimeValuesAddedAfterSnapshot(t *testing.T) ctx := context.Background() requireNoError(t, codefly.LoadEnvironmentVariables()) secretKey := resources.ServiceSecretConfigurationKeyFromUnique( - "saas/store", + "platform/store", "postgres", "read-write-connection", ) configurationKey := resources.ServiceConfigurationKeyFromUnique( - "saas/store", + "platform/store", "postgres", "pool-size", ) t.Setenv(secretKey, "postgresql://writer") t.Setenv(configurationKey, "12") - query := codefly.For(ctx).Module("saas").Service("store") + query := codefly.For(ctx).Module("platform").Service("store") secret, err := query.Secret("postgres", "read-write-connection") assert.NoError(t, err) assert.Equal(t, "postgresql://writer", secret) @@ -230,11 +230,11 @@ modules: writeFile(t, filepath.Join(root, "modules", "platform", "module.codefly.yaml"), `kind: module name: platform services: - - name: warden + - name: records `) - serviceDir := filepath.Join(root, "modules", "platform", "services", "warden") + serviceDir := filepath.Join(root, "modules", "platform", "services", "records") writeFile(t, filepath.Join(serviceDir, "service.codefly.yaml"), `kind: service -name: warden +name: records version: 0.0.0 agent: kind: codefly:service @@ -248,7 +248,7 @@ endpoints: instance, err := codefly.For(ctx). Module("platform"). - Service("warden"). + Service("records"). Endpoint("rest"). ResolveNetworkInstance() assert.NoError(t, err) @@ -256,7 +256,7 @@ endpoints: ctx, "sdk-endpoint-test", "platform", - "warden", + "records", "", &basev0.Endpoint{Name: "rest", Api: "rest"}, ) @@ -266,7 +266,7 @@ endpoints: scoped, err := codefly.For(ctx). Module("platform"). - Service("warden"). + Service("records"). Endpoint("rest"). NamingScope("agent-test"). ResolveNetworkInstance() diff --git a/fixture_test.go b/fixture_test.go index 0494b70..e8125d0 100644 --- a/fixture_test.go +++ b/fixture_test.go @@ -13,7 +13,7 @@ import ( const devAdminPackage = `kind: module-package schema: codefly/module-package/v2 -id: codefly/saas-starter +id: example/starter version: 0.1.0 minimum-codefly-version: ">=0.3.32" artifact-roots: @@ -42,9 +42,9 @@ func composingWorkspace(t *testing.T) { t.Helper() root := t.TempDir() writeFile(t, filepath.Join(root, "workspace.codefly.yaml"), - "name: solution\nlayout: modules\nmodules:\n - name: saas-starter\n - name: plain\n") - writeFile(t, filepath.Join(root, "modules", "saas-starter", composition.PackageManifestFileName), devAdminPackage) - writeFile(t, filepath.Join(root, "modules", "saas-starter", "services", ".keep"), "") + "name: solution\nlayout: modules\nmodules:\n - name: starter\n - name: plain\n") + writeFile(t, filepath.Join(root, "modules", "starter", composition.PackageManifestFileName), devAdminPackage) + writeFile(t, filepath.Join(root, "modules", "starter", "services", ".keep"), "") writeFile(t, filepath.Join(root, "modules", "plain", "services", ".keep"), "") t.Chdir(root) } @@ -94,9 +94,9 @@ func TestFixturePrincipalWithoutSelectedFixture(t *testing.T) { func TestFixturePrincipalResolvesWhenOneDirectoryIsReferencedTwice(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "workspace.codefly.yaml"), - "name: solution\nlayout: modules\nmodules:\n - name: saas-starter\n - name: alias\n path: modules/saas-starter\n") - writeFile(t, filepath.Join(root, "modules", "saas-starter", composition.PackageManifestFileName), devAdminPackage) - writeFile(t, filepath.Join(root, "modules", "saas-starter", "services", ".keep"), "") + "name: solution\nlayout: modules\nmodules:\n - name: starter\n - name: alias\n path: modules/starter\n") + writeFile(t, filepath.Join(root, "modules", "starter", composition.PackageManifestFileName), devAdminPackage) + writeFile(t, filepath.Join(root, "modules", "starter", "services", ".keep"), "") t.Chdir(root) t.Setenv(resources.FixturePrefix, "dev-admin") @@ -109,12 +109,24 @@ func TestFixturePrincipalResolvesWhenOneDirectoryIsReferencedTwice(t *testing.T) func TestFixturePrincipalNamesPinnedModulesItCannotRead(t *testing.T) { root := t.TempDir() writeFile(t, filepath.Join(root, "workspace.codefly.yaml"), - "name: solution\nlayout: modules\nmodules:\n - name: saas-starter\n source: codefly-dev/module-saas-starter\n version: 0.1.0\n") + "name: solution\nlayout: modules\nmodules:\n - name: starter\n source: example-org/module-starter\n version: 0.1.0\n") t.Chdir(root) t.Setenv(resources.FixturePrefix, "dev-admin") _, err := codefly.Fixture().Principal(t.Context(), "super_admin") require.ErrorIs(t, err, composition.ErrUnknownFixture) - assert.Contains(t, err.Error(), "saas-starter") + // The MODULE NAME and the fact that it is pinned, not merely some + // substring of the error. Making this file generic shortened the expected + // name to "starter", which is also a substring of the module's own source + // "example-org/module-starter" — so the assertion stopped pinning WHICH + // name the error has to carry, and would have passed on the source alone. + // Generic fixtures were the point; a weaker assertion was not. The name + // that was here before is deliberately not quoted: a cleanup that explains + // itself by repeating what it removed has not removed it. + assert.Contains(t, err.Error(), "starter", + "the error must name the module whose fixtures could not be read") + assert.Contains(t, err.Error(), "pinned", + "and must say that it is PINNED, which is the diagnosis: an unknown-fixture "+ + "error here has meant a pinned module the SDK never read, not a wrong name") } diff --git a/for.go b/for.go index d4d74e6..6100edb 100644 --- a/for.go +++ b/for.go @@ -279,7 +279,29 @@ func (q *Query) WorkspaceSecret(name string, key string) (string, error) { // WorkspaceValue resolves a workspace value from the public namespace first, // then the secret namespace. It is intended for settings whose sensitivity is // deployment-defined while preserving a single SDK-only lookup boundary. +// +// A value this process read as authority-bearing (see ReadAuthority) answers +// from what was read at boot, and a live value that has since drifted is +// refused with ErrAuthorityValueChanged rather than returned. Without that, a +// process could seal its credential to the boot value and then authorize +// against a different one through the ordinary accessor. func (q *Query) WorkspaceValue(name string, key string) (string, error) { + addressed := AuthorityValueName{Name: name, Key: key}.canonical() + pinned, isPinned := authorityPin(addressed) + value, err := q.workspaceValueLive(name, key) + if !isPinned { + return value, err + } + if err != nil || strings.TrimSpace(value) == "" { + return "", fmt.Errorf("%w: %s is no longer configured", ErrAuthorityValueChanged, addressed) + } + if value != pinned { + return "", fmt.Errorf("%w: %s", ErrAuthorityValueChanged, addressed) + } + return pinned, nil +} + +func (q *Query) workspaceValueLive(name string, key string) (string, error) { if value, err := q.WorkspaceConfiguration(name, key); err == nil && value != "" { return value, nil } diff --git a/go.mod b/go.mod index 702d84e..33aa99c 100644 --- a/go.mod +++ b/go.mod @@ -61,3 +61,30 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +// RETRACTED: the ROOT module's versions that publish a second Work Context +// implementation. +// +// A branch that carries it can be deleted. A TAG CANNOT: it is an immutable +// published artifact, `go get sdk-go@v0.1.65` resolves it right now, and the +// module proxy has it cached forever. So the rule for a tag is not "clean", it +// is RETRACTED — the one mechanism Go provides for "this published version +// should not be used", which `go get` and `go list -m -u` both report. +// +// FIFTEEN, not nineteen, and the difference is the finding that corrected this. +// A previous revision retracted v0.1.51 through v0.1.68 and v0.2.0 on the +// strength of sweeping each tag's whole TREE. But from v0.1.66 the files moved +// under workcontext/, which has its own go.mod — so the root module's zip for +// those versions does not contain them, the root versions are CLEAN, and the +// module that carries them is github.com/codefly-dev/sdk-go/workcontext, whose +// go.mod had no retract at all. Retracting a clean version is not a safe error +// in the same direction: it tells consumers to move off something that was +// never the problem, while the thing that was stayed resolvable. +// +// workcontext/go.mod retracts the leaf side. +// +// TWO THINGS THIS DOES NOT DO, both belonging to whoever tags releases: a +// retraction takes effect only once a NEW version is tagged carrying this +// go.mod, and nothing here deletes anything from the proxy, because nothing +// can. +retract [v0.1.51, v0.1.65] // a second Work Context implementation at the root diff --git a/published_tags_test.go b/published_tags_test.go new file mode 100644 index 0000000..1032da2 --- /dev/null +++ b/published_tags_test.go @@ -0,0 +1,543 @@ +package codefly_test + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// A TAG CANNOT BE FIXED, ONLY RETRACTED, and nineteen of them publish the +// second implementation this PR deletes. +// +// The ref sweep covers branches, and a branch carrying the implementation can +// be deleted. `v0.1.65` cannot: `go get sdk-go@v0.1.65` resolves it right now, +// the module proxy has it cached permanently, and nothing anybody does to this +// repository changes what that version contains. An executed review found this +// and it had been missed by every round: the sweep's subject was "every +// published ref" and tags are published refs. +// +// So the rule for a tag is RETRACTED, not clean — the one mechanism Go provides +// for "this published version should not be used". This test is the gate on +// that: every tag that carries the implementation is covered by a retract +// directive in go.mod. +// +// It is measured, by reading each tag's imports, not by matching +// `work_context.go` — a name check is the thing this whole change is about. +func TestEveryPublishedTagCarryingTheImplementationIsRetracted(t *testing.T) { + tags := localTags(t) + require.NotEmpty(t, tags, + "no tags are present in this checkout, so this gate read nothing.\n"+ + "CI fetches them (fetch-depth: 0); a run without them would pass having\n"+ + "looked at nothing, which is the fail-open this repository has fixed five times.") + + rootRetracted := retractedVersions(t, ".") + reader := importReader(t) + + offendingByTag := offendingPathsByRef(t, reader, tags) + + var rootCarrying, leafCarrying, unretracted []string + for _, tag := range tags { + offending := offendingByTag[tag] + if len(offending) == 0 { + continue + } + // WHICH MODULE OWNS THE FILES, which is the whole of this correction. + // A path under workcontext/ belongs to the LEAF module at any tag where + // workcontext/go.mod exists — and from v0.1.66 it does, so the root + // module's zip for those versions does not contain them and the root's + // retract directive says nothing about them. A previous revision swept + // each tag's whole tree and then checked only the root go.mod, which is + // how nineteen retractions came to include four clean root versions and + // miss the module that actually carries them. + leaf := tagHasLeafModule(t, tag) + byLeaf, byRoot := false, false + for _, path := range offending { + if leaf && strings.HasPrefix(path, "workcontext/") { + byLeaf = true + continue + } + byRoot = true + } + if byRoot { + rootCarrying = append(rootCarrying, tag) + if !rootRetracted[tag] { + unretracted = append(unretracted, tag+" (root module)") + } + } + if byLeaf { + // THE LEAF'S INTERVAL IS THE LEAF'S OWN TEST TO ASSERT, and this + // is where a correction belongs. This used to read the retract + // bound's TIMESTAMP out of the High field and compare it as a + // string — which cannot see an interval whose LOW bound is above + // its high, and that is exactly the state it passed over: the leaf + // retraction was `[v0.0.0, v0.0.0-…]`, which covers no version at + // all, because a prerelease sorts below its release. + // + // A hand comparison of one bound is how that went unnoticed, so + // the question moved to workcontext's own + // TestTheRetractIntervalCoversThePublishedVersions, which asks + // golang.org/x/mod/semver — Go's implementation of the ordering — + // whether the interval is non-empty and covers every carrying + // pseudo-version. What is recorded here is only that this tag's + // files belong to the LEAF module, which is the fact this test can + // establish. + leafCarrying = append(leafCarrying, tag) + } + } + + require.NotEmpty(t, rootCarrying, + "no tag carries the implementation in the ROOT module, which contradicts the "+ + "measurement this gate was written from") + require.NotEmpty(t, leafCarrying, + "no tag carries it in the LEAF module either, and from v0.1.66 the files live there — "+ + "so either the classification is broken or the sweep is") + require.Empty(t, unretracted, + "these published versions carry a second Work Context implementation and are not "+ + "retracted: %v.\nA consumer can pin any of them today. A tag cannot be deleted or "+ + "fixed, so the only answer is a retract directive in the go.mod of the module that "+ + "OWNS the files — which takes effect once a new version of that module is tagged.", + unretracted) +} + +// tagHasLeafModule reports whether workcontext/ was its own module at a tag. +func tagHasLeafModule(t *testing.T, tag string) bool { + t.Helper() + run := exec.Command("git", "cat-file", "-e", tag+":workcontext/go.mod") + return run.Run() == nil +} + +// offendingPathsByRef runs the repository sweep ONCE over every tag and returns +// the paths it reported, by ref. +// +// One invocation, not one per tag. Per-tag it was 333 seconds for 83 tags — +// each spawning the script, which parses the policy, builds its allow lists and +// shells out per file — and CI's job timeout is five minutes. A gate that +// cannot finish inside the job is a gate somebody deletes. The script already +// loops over its arguments and reports `ok ` or `FAIL …`, so this is +// a parse of what it was always printing. +// parseSweepReport reads the sweep's per-ref report: the findings by ref, and +// the set of refs it explicitly reported on at all. +// +// Separated from the invocation so a probe can drive it with real sweep output, +// including the transcript of a batch that ABORTED — which is the case the +// guard exists for and which an end-to-end test cannot produce on demand. +func parseSweepReport(output string) (map[string][]string, map[string]bool) { + byRef := map[string][]string{} + scanned := map[string]bool{} + current := "" + for _, line := range strings.Split(output, "\n") { + switch { + case strings.HasPrefix(line, "FAIL ") && strings.Contains(line, "carries a Work Context"): + current = strings.TrimSpace(strings.TrimSuffix( + strings.TrimPrefix(line, "FAIL "), " carries a Work Context implementation:")) + byRef[current] = nil + scanned[current] = true + case strings.HasPrefix(line, "ok "): + scanned[strings.TrimSpace(strings.TrimPrefix(line, "ok "))] = true + current = "" + case current != "" && strings.HasPrefix(line, " "): + path, _, found := strings.Cut(strings.TrimSpace(line), " imports ") + if found && strings.HasSuffix(path, ".go") { + byRef[current] = append(byRef[current], path) + } + } + } + return byRef, scanned +} + +func offendingPathsByRef(t *testing.T, reader string, tags []string) map[string][]string { + t.Helper() + run := exec.Command("bash", append([]string{"scripts/check-one-implementation.sh"}, tags...)...) + run.Env = append(sweepEnv(t), "IMPORTSOF_BIN="+reader) + output, runErr := run.CombinedOutput() + + byRef, scanned := parseSweepReport(string(output)) + + // EVERY TAG EXPLICITLY REPORTED ON, BY NAME. This was a COUNT — + // `reported >= len(tags)` — which is the same defect this repository fixed + // in the ref sweep two rounds earlier and which I then reproduced in my own + // batching: a count can be satisfied while the SET is wrong, by a repeated + // `ok` or an `ok` for a ref nobody asked about, and a tag that went + // unreported then reads as clean because "absent from byRef" means "no + // findings". + // + // It matters because the sweep ABORTS the whole batch on a scan error — + // measured: `ok v0.0.1`, `ok v0.0.2`, `no such ref: …`, and v0.1.51, which + // carries the implementation, never looked at. Batching for speed turned a + // per-ref failure into a silence about every ref after it. + var unscanned []string + for _, tag := range tags { + if !scanned[tag] { + unscanned = append(unscanned, tag) + } + } + require.Empty(t, unscanned, + "the sweep did not report on these tags, so this gate cleared them without looking: "+ + "%v.\nThe batch aborts on the first scan error, so one unreadable ref silences "+ + "every ref after it. Sweep output:\n%s", unscanned, output) + + // AND THE EXIT STATUS IS READ. A non-zero exit with every tag reported is + // the ordinary case — findings exit 1 — but a non-zero exit with anything + // unexplained is a scan that did not finish, and the count above was the + // only thing standing between that and a clean answer. + if runErr != nil { + require.Contains(t, string(output), "carries a Work Context implementation", + "the sweep exited non-zero and reported no findings, so it failed rather than "+ + "found something:\n%s", output) + } + return byRef +} + +// localTags is every tag in this checkout, which in CI is every published tag. +func localTags(t *testing.T) []string { + t.Helper() + run := exec.Command("git", "tag") + output, err := run.Output() + require.NoError(t, err) + var tags []string + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + if line != "" { + tags = append(tags, line) + } + } + return tags +} + +// retractedVersions expands go.mod's retract directives, ranges included. +// +// Read through `go mod edit -json` rather than by matching the file, because +// the shape a person writes and the set the go tool acts on are two different +// things — and this gate is about the second. +type retractSpan struct { + Low string `json:"Low"` + High string `json:"High"` +} + +// retractSpans reads one module's retract directives through `go mod edit +// -json`, because the shape a person writes and the set the go tool acts on are +// two different things and this gate is about the second. +func retractSpans(t *testing.T, dir string) []retractSpan { + t.Helper() + run := exec.Command("go", "mod", "edit", "-json") + run.Dir = dir + output, err := run.Output() + require.NoError(t, err) + + var parsed struct { + Retract []retractSpan `json:"Retract"` + } + require.NoError(t, json.Unmarshal(output, &parsed)) + return parsed.Retract +} + +func retractedVersions(t *testing.T, dir string) map[string]bool { + t.Helper() + spans := retractSpans(t, dir) + require.NotEmpty(t, spans, "%s/go.mod retracts nothing", dir) + + covered := map[string]bool{} + for _, tag := range localTags(t) { + for _, span := range spans { + // `go mod edit -json` writes a single version as Low == High, so + // one comparison covers both shapes. + if semverAtMost(t, span.Low, tag) && semverAtMost(t, tag, span.High) { + covered[tag] = true + } + } + } + return covered +} + +// semverAtMost reports whether low <= high, for PLAIN RELEASE VERSIONS ONLY. +// +// It refuses a prerelease rather than ordering one, and that guard is the +// lesson from the leaf module's empty interval: a prerelease sorts BELOW its +// release, a hand comparison that does not know this reads `[v0.0.0, +// v0.0.0-…]` as a sensible range, and the interval covered nothing. The root +// module's tags and retract bounds are all plain `vX.Y.Z`, so this is enough +// here — and if that ever stops being true, this fails instead of guessing. +// +// The leaf module's own interval, which IS pseudo-versions, is asserted by +// workcontext's TestTheRetractIntervalCoversThePublishedVersions using +// golang.org/x/mod/semver. One hand-rolled ordering in this repository is one +// too many; this one is bounded to the case it is correct for. +func semverAtMost(t *testing.T, low string, high string) bool { + t.Helper() + require.NoError(t, comparableVersions(low, high)) + return low == high || compareVersions(low, high) <= 0 +} + +// comparableVersions reports whether this comparison may be made at all. +// +// Separated from the comparison so a probe can drive it: asserted only through +// semverAtMost, a mutation that removed the guard changed nothing, because the +// root module's tags and bounds are all plain releases and no test ever passed +// a prerelease. A guard no test can reach is a guard nobody has checked, which +// is the shape that let the empty interval through in the first place. +func comparableVersions(versions ...string) error { + for _, version := range versions { + if strings.Contains(version, "-") { + return fmt.Errorf( + "%q carries a prerelease and this comparison cannot order one: a prerelease "+ + "sorts BELOW its release, which is how an empty retract interval read as a "+ + "range. Use golang.org/x/mod/semver, as workcontext does", version) + } + } + return nil +} + +// compareVersions orders vMAJOR.MINOR.PATCH numerically. Both inputs come from +// this repository's own tags and its own go.mod, which are all of that shape. +func compareVersions(left string, right string) int { + leftParts := versionFields(left) + rightParts := versionFields(right) + for index := range 3 { + switch { + case leftParts[index] < rightParts[index]: + return -1 + case leftParts[index] > rightParts[index]: + return 1 + } + } + return 0 +} + +// versionFields parses the three numeric fields. A field that is not a number +// becomes -1, which orders before everything — so a tag this repository does +// not use the shape of sorts outside every retract range and is REPORTED as +// unretracted rather than silently covered by one. +func versionFields(version string) [3]int { + var fields [3]int + trimmed := strings.TrimPrefix(version, "v") + for index, part := range strings.SplitN(trimmed, ".", 3) { + if index > 2 { + break + } + value := 0 + for _, digit := range part { + if digit < '0' || digit > '9' { + value = -1 + break + } + value = value*10 + int(digit-'0') + } + fields[index] = value + } + return fields +} + +// THE HISTORICAL ALLOWANCE CANNOT BECOME THE HOLE THE DENYLIST WAS. +// +// `legacy` lines in the import policy apply only when sweeping a published ref, +// because deny-by-default describes today's tree and two years of tags +// legitimately import things this repository has stopped using — the first run +// over history flagged 33 clean tags for a renamed core package and a Postgres +// driver. That allowance is measured, and this is what keeps it honest: not one +// entry may be a signature, a MAC, an envelope or a token library, because those +// are the whole subject of the rule it relaxes. +func TestTheHistoricalImportAllowanceCarriesNothingCapabilityShaped(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("scripts", "allowed-imports.txt")) + require.NoError(t, err) + + var legacy []string + for _, line := range strings.Split(string(raw), "\n") { + if module, path, found := strings.Cut(strings.TrimSpace(line), " "); found && module == "legacy" { + legacy = append(legacy, path) + } + } + require.NotEmpty(t, legacy, "the allowance is empty, so this test asserts nothing") + + // The capability-shaped fragments, which is the same question the old + // denylist asked — kept here, where it is the right question, rather than + // in the sweep, where it was the only one. + for _, path := range legacy { + lowered := strings.ToLower(path) + for _, fragment := range []string{ + "crypto", "jose", "jwt", "jwx", "paseto", "macaroon", "branca", + "base64", "base32", "ascii85", "pem", "protojson", "protowire", + "protodelim", "protoiface", "protoimpl", "anypb", "json", "mime", + "gob", "asn1", "xml", "mldsa", "ed25519", "hkdf", "pbkdf2", + } { + require.NotContains(t, lowered, fragment, + "the historical allowance lists %q, which contains %q. That allowance exists "+ + "for a renamed core package and a database driver; a signature, a MAC, an "+ + "envelope or a token library in it would reopen exactly the hole "+ + "deny-by-default was adopted to close.", path, fragment) + } + } +} + +// THE ORDERING GUARD, driven at the predicate. +// +// This repository's hand-rolled version comparison is bounded to plain releases +// on purpose, and the bound is the lesson from the leaf module's retract +// interval: `[v0.0.0, v0.0.0-20261004000000-zzzzzzzzzzzz]` covers NO version, +// because a prerelease sorts below its release — and a hand comparison that did +// not know that read it as a sensible range for a whole commit. +// +// So this refuses rather than guesses, and the refusal is tested, because a +// mutation that removed it left every test green: the root's tags and bounds +// are all plain releases, so nothing ever reached the guard. +func TestTheVersionComparisonRefusesWhatItCannotOrder(t *testing.T) { + for name, version := range map[string]string{ + "a pseudo-version": "v0.0.0-20261004000000-zzzzzzzzzzzz", + "a release candidate": "v1.0.0-rc.1", + "the zero prerelease": "v0.0.0-0", + } { + t.Run(name, func(t *testing.T) { + require.Error(t, comparableVersions("v0.1.51", version)) + require.Error(t, comparableVersions(version, "v0.1.51")) + require.ErrorContains(t, comparableVersions(version), + "sorts BELOW its release", + "the refusal must say WHY, because the reader's next move is to compare it anyway") + }) + } + + // Plain releases are comparable, so the guard is not refusing everything — + // and the ordering it then performs is the one the retract check relies on. + require.NoError(t, comparableVersions("v0.1.51", "v0.1.65", "v0.2.0")) + require.True(t, semverAtMost(t, "v0.1.51", "v0.1.65")) + require.True(t, semverAtMost(t, "v0.1.65", "v0.1.65")) + require.False(t, semverAtMost(t, "v0.2.0", "v0.1.65")) +} + +// THE BATCHED AUDIT MUST FAIL CLOSED PER REF. +// +// Batching the tag sweep took it from 333 seconds to 22, and introduced a +// false-green shape: the script ABORTS the whole batch on a scan error, so one +// unreadable ref silences every ref after it. Measured, with real tags: +// +// ok v0.0.1 +// ok v0.0.2 +// no such ref: no-such-ref +// +// and v0.1.51 — which carries the implementation — was never looked at. +// +// The guard I first wrote was a COUNT (`reported >= len(tags)`), which is the +// same defect this repository fixed in the ref sweep two rounds earlier and +// which I then reproduced in my own batching. A count is satisfied by the wrong +// set: a repeated `ok`, or an `ok` for a ref nobody asked about. It is by NAME +// now, and this drives the parse with the transcript above. +func TestTheBatchedTagAuditFailsClosedPerRef(t *testing.T) { + aborted := "ok v0.0.1\nok v0.0.2\nno such ref: no-such-ref\n" + _, scanned := parseSweepReport(aborted) + + require.True(t, scanned["v0.0.1"]) + require.True(t, scanned["v0.0.2"]) + require.False(t, scanned["no-such-ref"], "a ref the sweep could not resolve was not scanned") + require.False(t, scanned["v0.1.51"], + "a ref AFTER the abort was never reached, and must not read as scanned — it carries "+ + "the implementation") + + // A count would have been satisfied here and the set is not: two `ok` lines + // for a four-ref batch. + require.Len(t, scanned, 2, "the batch reported on two of four refs") + + // Findings are attributed to the right ref, with their paths. + report := "ok v0.0.1\n" + + "FAIL v0.1.51 carries a Work Context implementation:\n" + + " work_context.go imports crypto/ed25519\n" + + " work_context_jwks.go imports mime\n" + + "ok v0.1.50\n" + byRef, seen := parseSweepReport(report) + require.ElementsMatch(t, []string{"work_context.go", "work_context_jwks.go"}, byRef["v0.1.51"]) + require.Empty(t, byRef["v0.0.1"], "a clean ref carries nothing") + require.True(t, seen["v0.0.1"] && seen["v0.1.51"] && seen["v0.1.50"]) + + // And an `ok` line STOPS attribution. The sweep does not emit an indented + // line after an `ok`, so this is a malformed transcript on purpose: a + // parser's job is to not invent an answer from input it did not expect, + // and without the reset the path below would be filed under the previous + // ref — a clean tag's report silently acquiring another tag's findings. + malformed := "FAIL v0.1.51 carries a Work Context implementation:\n" + + " work_context.go imports crypto/ed25519\n" + + "ok v0.1.50\n" + + " stray.go imports crypto/ed25519\n" + strayed, _ := parseSweepReport(malformed) + require.Equal(t, []string{"work_context.go"}, strayed["v0.1.51"], + "a line after an `ok` must not be filed under the ref before it") + require.NotContains(t, strayed, "v0.1.50", "and a clean ref gains nothing") +} + +// AND THE SAME THING END TO END, with a scan failure injected into a REAL +// batch — which is what a review asked for after the transcript-driven test +// above, and rightly: a parser test establishes the parse, not the audit. +// +// The script aborts the whole batch on an unresolvable ref, so the tags AFTER +// it are never scanned. This builds a throwaway repository whose tags include +// one the sweep cannot resolve, with a CARRYING tag behind it, and requires the +// audit to refuse rather than report the carrying tag clean. +func TestAScanFailureInABatchIsNotACleanTag(t *testing.T) { + script, err := filepath.Abs(filepath.Join("scripts", "check-one-implementation.sh")) + require.NoError(t, err) + reader := importReader(t) + + repository := t.TempDir() + runIn(t, repository, "git", "init", "--quiet", "-b", "main") + require.NoError(t, os.MkdirAll(filepath.Join(repository, "scripts"), 0o755)) + copyFile(t, script, filepath.Join(repository, "scripts", "check-one-implementation.sh")) + copyPolicy(t, filepath.Dir(script), filepath.Join(repository, "scripts")) + + write := func(name string, source string) { + require.NoError(t, os.WriteFile(filepath.Join(repository, name), []byte(source), 0o600)) + runIn(t, repository, "git", "add", ".") + runIn(t, repository, "git", "-c", "user.email=t@t", "-c", "user.name=t", + "commit", "--quiet", "-m", name) + } + write("clean.go", "package x\n\nimport \"fmt\"\n\nvar _ = fmt.Sprint\n") + // -c, because this machine's git config may sign or annotate tags and this + // fixture must not depend on whose machine it runs on — the same reason + // user.email is passed here rather than inherited. + tag := func(name string) { + runIn(t, repository, "git", "-c", "tag.gpgSign=false", "-c", "user.email=t@t", + "-c", "user.name=t", "tag", "-a", "-m", name, name) + } + tag("v0.0.1") + write("carries.go", "package x\n\nimport \"crypto/ed25519\"\n\nvar _ = ed25519.Sign\n") + tag("v0.0.2") + + // The batch: a clean tag, a ref that cannot be resolved, then the carrying + // tag. The script stops at the middle one. + run := exec.Command("bash", "scripts/check-one-implementation.sh", + "v0.0.1", "no-such-ref", "v0.0.2") + run.Dir = repository + run.Env = append(sweepEnv(t), "IMPORTSOF_BIN="+reader) + output, runErr := run.CombinedOutput() + require.Error(t, runErr, "an unresolvable ref must fail the sweep:\n%s", output) + + byRef, scanned := parseSweepReport(string(output)) + + require.True(t, scanned["v0.0.1"], "the tag before the failure was scanned") + require.False(t, scanned["v0.0.2"], + "v0.0.2 carries the implementation and the batch aborted before reaching it; "+ + "reporting it scanned is the false green this test exists for.\nOutput:\n%s", output) + require.NotContains(t, byRef, "v0.0.2") + + // And the audit's own guard refuses, rather than treating "absent from the + // findings" as clean. Driven through the same predicate the audit uses. + var unscanned []string + for _, tag := range []string{"v0.0.1", "no-such-ref", "v0.0.2"} { + if !scanned[tag] { + unscanned = append(unscanned, tag) + } + } + require.ElementsMatch(t, []string{"no-such-ref", "v0.0.2"}, unscanned, + "the audit must see BOTH the failed ref and everything after it as unscanned") + + // The control: without the bad ref in the batch, the carrying tag is found. + good := exec.Command("bash", "scripts/check-one-implementation.sh", "v0.0.1", "v0.0.2") + good.Dir = repository + good.Env = append(sweepEnv(t), "IMPORTSOF_BIN="+reader) + goodOutput, goodErr := good.CombinedOutput() + require.Error(t, goodErr) + foundByRef, foundScanned := parseSweepReport(string(goodOutput)) + require.True(t, foundScanned["v0.0.1"] && foundScanned["v0.0.2"]) + require.Contains(t, foundByRef["v0.0.2"], "carries.go", + "the carrying tag is found when the batch is not aborted:\n%s", goodOutput) +} diff --git a/receipts/interceptor.go b/receipts/interceptor.go index 6da698b..797f06b 100644 --- a/receipts/interceptor.go +++ b/receipts/interceptor.go @@ -19,12 +19,13 @@ type Options struct { Store Store // Tenant reads the tenant of the caller's verified Work Context. // - // Receipts never verifies a Work Context itself — the verifier is the - // separate github.com/codefly-dev/sdk-go/workcontext module, which the root - // SDK must not depend on — so the module's own authentication interceptor, - // which has already verified the context by the time this one runs, says - // where the tenant is. Required: a receipt without a tenant would let one - // tenant read another's outcome. + // Receipts never verifies a Work Context itself — the verifier is + // github.com/codefly-dev/core/workcontext, the capability's one + // implementation, which the service reaches through the separate + // github.com/codefly-dev/sdk-go/workcontext module — so the service's own + // authentication interceptor, which has already verified the capability by + // the time this one runs, says where the tenant is. Required: a receipt + // without a tenant would let one tenant read another's outcome. Tenant func(ctx context.Context) (string, error) // Files is where the operation option is read from. Nil means // protoregistry.GlobalFiles, which is what the module's own generated code diff --git a/runtime_environment_file_test.go b/runtime_environment_file_test.go index e16ab5b..949ac37 100644 --- a/runtime_environment_file_test.go +++ b/runtime_environment_file_test.go @@ -12,7 +12,7 @@ import ( func TestLoadRuntimeEnvironmentFilePreservesOpaqueValues(t *testing.T) { path := writeRuntimeEnvironmentFile( t, - "CODEFLY__MODULE=saas\n"+ + "CODEFLY__MODULE=platform\n"+ "CODEFLY__OPAQUE=value with spaces=$HOME;$(touch nope)\n"+ "CODEFLY__MODULE=platform\n", 0o600, @@ -29,7 +29,7 @@ func TestLoadRuntimeEnvironmentFilePreservesOpaqueValues(t *testing.T) { func TestLoadRuntimeEnvironmentFileRejectsMalformedOrForeignEntriesAtomically(t *testing.T) { path := writeRuntimeEnvironmentFile( t, - "CODEFLY__MODULE=saas\nNOT_CODEFLY=must-not-load\n", + "CODEFLY__MODULE=platform\nNOT_CODEFLY=must-not-load\n", 0o600, ) t.Setenv("CODEFLY__MODULE", "unchanged") @@ -40,10 +40,10 @@ func TestLoadRuntimeEnvironmentFileRejectsMalformedOrForeignEntriesAtomically(t } func TestLoadRuntimeEnvironmentFileRejectsUnsafeFile(t *testing.T) { - path := writeRuntimeEnvironmentFile(t, "CODEFLY__MODULE=saas\n", 0o644) + path := writeRuntimeEnvironmentFile(t, "CODEFLY__MODULE=platform\n", 0o644) require.Error(t, codefly.LoadRuntimeEnvironmentFile(path)) - target := writeRuntimeEnvironmentFile(t, "CODEFLY__MODULE=saas\n", 0o600) + target := writeRuntimeEnvironmentFile(t, "CODEFLY__MODULE=platform\n", 0o600) link := filepath.Join(t.TempDir(), "runtime.env") require.NoError(t, os.Symlink(target, link)) require.Error(t, codefly.LoadRuntimeEnvironmentFile(link)) diff --git a/scripts/allowed-imports.txt b/scripts/allowed-imports.txt new file mode 100644 index 0000000..fb681ec --- /dev/null +++ b/scripts/allowed-imports.txt @@ -0,0 +1,134 @@ +# THE ONE IMPORT POLICY, read by the Go gate and by the ref sweep. +# +# Deny by default, per module: an import that is not listed is a finding. This +# file exists because the policy lived only in `go test`, which reads a +# CHECKOUT — and the ref sweep is the only thing that runs over a published ref, +# where there is nothing to type-check. So a branch carrying `crypto/mldsa` and +# `encoding/json/v2` — the round-six second implementation — swept `ok`, in the +# one job that is a required check. Two policies is one policy and one hole. +# +# Format: ` `, where is `root` or `leaf`. A file +# under workcontext/ belongs to the leaf module, everything else to the root. +# (At a ref from before the leaf module existed, those files were root-module +# files; the classification only picks which list, and an import on neither is a +# finding either way, so the sweep stays conservative on old refs.) +# +# A line may NAME THE FILES that may have that import: +# +# root crypto/sha256 receipts/digest.go receipts/postgres.go +# +# Without files it is module-wide. With them it is those files and no others, +# which is what keeps the sweep as strict as the denylist it replaced — that +# denylist held `encoding/json` to named paths, and the first draft of this file +# listed it module-wide and let any root file have it. A probe caught that. +# +# WHAT THE SWEEP STILL CANNOT DO that the checkout gate does: hold an allowed +# import to named SYMBOLS. `encoding/base64` may only ENCODE in +# cache_partition.go, and only reading the code sees a DecodeString. A ref gets +# the file-level rule; a checkout gets both. +# +# `legacy ` lines apply ONLY when sweeping a published ref, never to the +# working tree. They exist because deny-by-default describes TODAY's tree, and a +# tag from two years ago legitimately imported things this repository has since +# stopped using — the first run of the policy over history flagged 33 clean tags +# for a renamed core package and a Postgres driver. Archaeology is a different +# question from "could this ship today", and answering the first one with the +# second produces noise that makes a gate ignorable. +# +# MEASURED, from every published tag that does NOT carry the implementation, and +# then read: there are seven, and not one of them is a signature, a MAC, an +# envelope or a token library. A test asserts that, so this list cannot become +# the hole the denylist was. +# +# Adding a line is a design change: it is the thing every round of review found +# the denylist missing, in the other direction. + +# --- root module --- +root bufio +root bytes +root connectrpc.com/connect +root context +root crypto/sha256 receipts/digest.go receipts/postgres.go +root crypto/tls tls.go +root crypto/x509 tls.go +root database/sql +root embed +root encoding/binary +root encoding/json configuration_document.go receipts/digest.go +root errors +root fmt +root github.com/codefly-dev/core/composition +root github.com/codefly-dev/core/configurations +root github.com/codefly-dev/core/generated/go/codefly/base/v0 +root github.com/codefly-dev/core/generated/go/codefly/runnable/v0 +root github.com/codefly-dev/core/network +root github.com/codefly-dev/core/resources +root github.com/codefly-dev/core/runnable +root github.com/codefly-dev/core/standards +root github.com/codefly-dev/core/wool +root github.com/codefly-dev/sdk-go/receipts +root go/parser +root go/token +root google.golang.org/grpc +root google.golang.org/grpc/codes +root google.golang.org/grpc/metadata +root google.golang.org/grpc/status +root google.golang.org/protobuf/encoding/protojson receipts/digest.go +root google.golang.org/protobuf/proto +root google.golang.org/protobuf/reflect/protodesc +root google.golang.org/protobuf/reflect/protoreflect +root google.golang.org/protobuf/reflect/protoregistry +root google.golang.org/protobuf/types/descriptorpb +root google.golang.org/protobuf/types/dynamicpb +root google.golang.org/protobuf/types/known/durationpb +root io +root os +root path/filepath +root runtime/debug +root sort +root strconv +root strings +root sync +root time + +# --- leaf module (workcontext/) --- +leaf bytes +leaf context +leaf crypto/sha256 workcontext/cache_partition.go +leaf crypto/tls workcontext/mint.go +leaf crypto/x509 workcontext/mint.go +leaf encoding/base64 workcontext/cache_partition.go +leaf encoding/binary +leaf encoding/hex +leaf encoding/json workcontext/mint.go +leaf errors +leaf fmt +leaf github.com/codefly-dev/core/generated/go/codefly/base/v0 +leaf github.com/codefly-dev/core/workcontext +leaf github.com/codefly-dev/sdk-go/workcontext +leaf google.golang.org/grpc +leaf google.golang.org/grpc/codes +leaf google.golang.org/grpc/metadata +leaf google.golang.org/grpc/status +leaf google.golang.org/protobuf/proto +leaf io +leaf mime workcontext/mint.go +leaf net/http +leaf net/url +leaf os +leaf slices +leaf sort +leaf strconv +leaf strings +leaf sync +leaf sync/atomic +leaf time + +# --- historical, published refs only (see above) --- +legacy github.com/codefly-dev/core/configurations/standards +legacy github.com/codefly-dev/core/shared +legacy github.com/jackc/pgx/v5 +legacy github.com/jackc/pgx/v5/pgconn +legacy github.com/jackc/pgx/v5/pgxpool +legacy path +legacy reflect diff --git a/scripts/check-one-implementation.sh b/scripts/check-one-implementation.sh new file mode 100755 index 0000000..a3f8a60 --- /dev/null +++ b/scripts/check-one-implementation.sh @@ -0,0 +1,361 @@ +#!/usr/bin/env bash +# The Work Context has exactly one implementation and it is not in this +# repository: it is codefly-dev/core/workcontext. This sweeps the WHOLE +# repository — both modules, every tracked Go file — for a second one. +# +# It runs as a job in go.yml, whose triggers are `branches: [ '**' ]` — every +# ref this repository publishes, with no naming convention in it, because a +# consumer pins a COMMIT and go.mod records a pseudo-version with no idea what +# the branch was called. The triggers said `compat/**` for three revisions +# after the comment above them stopped saying it. It is required on main by an active ruleset, under the check name +# "no second Work Context implementation". There is no compatibility period: a +# second implementation on a ref this repository builds is a failure to fix, and +# a release line that cannot meet the rule is retired by the owner rather than +# exempted. +# +# It is not the same check as the Go gates, and it is no longer the only gate +# that reads the root module: TestEveryImportIsOnItsModulesAllowlist and +# TestNoCodecTouchesACapabilityByType type-check BOTH modules with +# x/tools/go/packages. This script is what runs over every PUBLISHED REF, where +# there is no checkout to type-check — which is why it reads imports with +# go/parser (scripts/importsof) rather than with a regex. +# +# THE PREDICATE IS DENY BY DEFAULT: an import not on its module's list in +# scripts/allowed-imports.txt is a finding. It used to be a ban on signing with +# a named allowlist — a denylist — and it is not that any more. It used to be "imports a signing primitive AND mentions +# WorkContext", which a second implementation defeats by putting the signer in +# one file and the wrapper in another; and its pattern was anchored on the +# opening quote, so "golang.org/x/crypto/ed25519" did not match at all. Now any +# import of a signature, a MAC or a JOSE/JWT library is a finding wherever it +# is, and the exceptions are listed by path with a reason. +# +# WHAT IT MATCHES IS THE IMPORT PATH, extracted from the import block. It used +# to match a LINE against a regex that tried to recognise an import's shape — +# `([._]\s+|[A-Za-z0-9_]+\s+)?"` — which a non-ASCII alias (`ψ +# "crypto/ed25519"`) and a comment-prefixed line (`/* x */ "crypto/ed25519"`) +# both walked straight past. Nothing here tries to recognise an alias any more: +# the path inside the quotes is what is tested, whatever precedes it. +# +# WHAT IT DOES NOT CATCH, stated because the claim was broader than the check: +# a hand-written HMAC over crypto/sha256, or a GMAC built from crypto/cipher, +# constructs a MAC out of parts that are not themselves MACs. crypto/cipher and +# the block ciphers are banned below, which leaves the hash. crypto/sha256 +# cannot be banned — two digests here need it — so in the leaf module the AST +# gate holds it to ONE FILE and a few symbols, and in the root module this +# residue is real and is closed by review, not by this script. "Any MAC is a +# finding" was a list of names; this is what the list reaches. +# +# proto.Unmarshal is likewise not banned anywhere: this is an SDK over core's +# protobuf API and the root module decodes core's messages constantly. What +# makes a second PARSER need more than that is opening the envelope, which is +# base64 — so the decoders below are banned repository-wide, and in the leaf +# module the AST gate additionally holds proto.Unmarshal to named types. +# +# Usage: +# scripts/check-one-implementation.sh # the working tree (what CI runs) +# scripts/check-one-implementation.sh ... # named refs, for an operator +# +# The second form is for whoever retires a ref that still carries the deleted +# implementation. Which refs those are is the cold-cutover runbook's business and +# it lives outside this repository: this repository holds the rule, not a +# consumer inventory. +set -euo pipefail + +# Where this script lives, so the import reader beside it can be built. +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# THE POLICY IS DENY BY DEFAULT and it is read from scripts/allowed-imports.txt, +# which the Go gate reads too. +# +# What was here was three regexes and a per-file exception list — a DENYLIST, +# and every round of review found the next name it was missing. The last pair +# was `crypto/mldsa` signing a `WorkContextV1` that `encoding/json/v2` had +# encoded in the deleted format; a reviewer ran this script's extracted +# predicate over both and both were "permitted", along with protodelim, +# grpc/encoding and `C`. The allowlist existed by then — in `go test`, which +# reads a checkout, while THIS is the only thing that runs over a published ref +# and is the required check. One policy, both places. +status=0 + +policy="$here/allowed-imports.txt" +if [ ! -r "$policy" ]; then + echo "FAIL cannot read the import policy at $policy." >&2 + echo " Deny-by-default with no list permits nothing or everything; neither is a gate." >&2 + exit 1 +fi + +# allowed_root and allowed_leaf hold one permitted import per line as +# `` or ` file file …`, bracketed so a fixed-string match cannot +# match a prefix: `crypto/sha` must not pass because `crypto/sha256` is listed. +allowed_root="$(mktemp)" +allowed_leaf="$(mktemp)" +awk '$1 == "root" { $1 = ""; sub(/^ /, ""); print "<" $0 ">" }' "$policy" > "$allowed_root" +awk '$1 == "leaf" { $1 = ""; sub(/^ /, ""); print "<" $0 ">" }' "$policy" > "$allowed_leaf" +# Historical paths, added ONLY in ref mode. See the policy file's own comment: +# deny-by-default describes today's tree, and sweeping two years of tags with it +# flagged 33 clean versions for a renamed core package and a Postgres driver. +if [ "$#" -gt 0 ]; then + awk '$1 == "legacy" { print "<" $2 ">" }' "$policy" >> "$allowed_root" + awk '$1 == "legacy" { print "<" $2 ">" }' "$policy" >> "$allowed_leaf" +fi +# module_of -> root | leaf +module_of() { + case "$1" in + workcontext/*) printf 'leaf\n' ;; + *) printf 'root\n' ;; + esac +} + +# IMPORTS ARE READ BY GO'S OWN PARSER, not by a regex over lines. +# +# The awk extractor this replaces was rewritten three times for exactly this +# class, and each rewrite was walked past by source that compiles: a ")" inside +# a comment closing the import block, a raw-string path before a quoted one on +# the same line, the keyword followed by a newline, a comment spanning lines, +# and +# +# import "\x63rypto/ed25519" +# +# which IS crypto/ed25519 to the compiler and is not that string to anything +# matching text. The escape cannot be closed by any amount of regex, and the +# language's own parser closes all five at once — the question was always "what +# does Go think this file imports". +# +# It matters most HERE. The AST gate reads the working tree; this script is what +# the ref sweep runs over every published ref, ALONE, so each shape above was a +# complete bypass of the required check for any branch a consumer can pin. +# IMPORTSOF_BIN names a prebuilt reader. The script builds one otherwise; the +# variable exists because the sweep's own tests run it in throwaway +# repositories, and building the reader once for all of them is the difference +# between a second and a minute. +helper="${IMPORTSOF_BIN:-}" +build_helper() { + [ -n "$helper" ] && return 0 + helper="$(mktemp -d)/importsof" + if ! go build -o "$helper" "$here/importsof"; then + echo "FAIL cannot build the import reader at $here/importsof." >&2 + echo " Imports are read with go/parser now, because a regex over lines cannot" >&2 + echo " read Go: an escaped path is one string to the compiler and another to grep." >&2 + exit 1 + fi +} + +# imports_of_files -> "pathimportpath" per import. +# +# A file that does not parse is a FAILURE and never a file with no imports. +imports_of_files() { + build_helper + if ! "$helper" < "$1"; then + echo "FAIL cannot read the imports of the files listed above." >&2 + echo " A file this sweep cannot parse is not a file it has cleared." >&2 + exit 1 + fi +} + +# offending_import -> true when this import is a finding. +# +# `import "C"` is not an import path at all, it is an exit from every rule here: +# a MAC built through the host's crypto library names no Go package. It is +# named so the message says that, rather than only "not on the list". +offending_import() { + local path="$1" imported="$2" list entry files + if [ "$imported" = "C" ]; then + return 0 + fi + case "$(module_of "$path")" in + leaf) list="$allowed_leaf" ;; + *) list="$allowed_root" ;; + esac + # Module-wide: the line is just the path. + grep -qxF "<$imported>" "$list" && return 1 + # Or held to NAMED FILES, which is what keeps this as strict as the denylist + # it replaced: that one held encoding/json to named paths, and a module-wide + # line let any root file have it. + entry=$(grep -F "<$imported " "$list" | head -1) + if [ -n "$entry" ]; then + files=${entry#"<$imported "} + files=${files%>} + for allowed_file in $files; do + [ "$allowed_file" = "$path" ] && return 1 + done + fi + return 0 +} + +# refuse_unreadable_sources -> prints each file no gate can read. +# +# Every rule in this repository is about Go — go/parser here, go/types in the +# Go gates — so assembly, object code and C are outside all of them. A .s file +# can implement anything; a .syso is compiled code linked in whole. `import "C"` +# is refused by name and this is the other half of it. Checked HERE as well as +# in the Go gate, because the Go gate reads a checkout and this reads every +# published ref. +refuse_unreadable_sources() { + grep -E '\.(s|S|syso|a|o|c|cc|cpp|cxx|h|hh|hpp|m|mm)$' || true +} + +carrying_in_tree() { + local found="" tracked list imports path imported unreadable + if ! tracked=$(git ls-files -- '*.go'); then + echo "FAIL cannot enumerate the tracked Go files in this checkout." >&2 + exit 1 + fi + unreadable=$(git ls-files | refuse_unreadable_sources) + if [ -n "$unreadable" ]; then + while IFS= read -r path; do + [ -n "$path" ] && found="$found$path is a source no gate here can read"$'\n' + done <<< "$unreadable" + fi + list=$(mktemp); imports=$(mktemp) + while IFS= read -r path; do + case "$path" in + *_test.go) continue ;; + *.go) printf '%s\n' "$path" >> "$list" ;; + esac + done <<< "$tracked" + imports_of_files "$list" > "$imports" + while IFS=$'\t' read -r path imported; do + [ -n "$imported" ] || continue + if offending_import "$path" "$imported"; then + found="$found$path imports $imported"$'\n' + fi + done < "$imports" + rm -f "$list" "$imports" + printf '%s' "$found" +} + +carrying_in_ref() { + local ref="$1" found="" entries list imports work path imported unreadable + if ! entries=$(git ls-tree -r --name-only "$ref"); then + echo "FAIL cannot enumerate $ref, so it has not been swept." >&2 + exit 1 + fi + unreadable=$(printf '%s\n' "$entries" | refuse_unreadable_sources) + if [ -n "$unreadable" ]; then + while IFS= read -r path; do + [ -n "$path" ] && found="$found$path is a source no gate here can read"$'\n' + done <<< "$unreadable" + fi + work=$(mktemp -d); list=$(mktemp); imports=$(mktemp) + + # ONE PROCESS PER REF, not one per file. This ran `git cat-file blob` for + # every Go file at every ref — about 4,000 spawns for 83 tags — and the tag + # audit took four minutes against a five-minute CI job. `git archive` writes + # the whole tree once, and the files land at their real paths, which also + # removes an index-to-path mapping that existed only to undo the temporary + # names. + if ! git archive --format=tar "$ref" | tar -x -C "$work" 2>/dev/null; then + echo "FAIL cannot extract $ref, so it has not been swept." >&2 + exit 1 + fi + + # AND THE EXTRACTION IS COMPARED AGAINST THE LISTING, by name. `git archive` + # omits what it cannot write — a submodule's gitlink, which `ls-tree -r` + # still names — and omitting is exactly how the previous `2>/dev/null` on + # cat-file turned an unreadable entry into a file with no imports. A ref this + # sweep could not fully extract is a ref it has not swept. + local missing="" + while IFS= read -r path; do + case "$path" in + *_test.go) continue ;; + *.go) ;; + *) continue ;; + esac + if [ ! -f "$work/$path" ]; then + missing="$missing $path" + continue + fi + printf '%s\n' "$work/$path" >> "$list" + done <<< "$entries" + if [ -n "$missing" ]; then + echo "FAIL $ref lists Go files that could not be extracted, so it has not been swept:$missing" >&2 + rm -rf "$work"; rm -f "$list" "$imports" + exit 1 + fi + + if [ -s "$list" ]; then + imports_of_files "$list" > "$imports" + while IFS=$'\t' read -r path imported; do + [ -n "$imported" ] || continue + # Back to the path IN THE REF: strip the extraction directory. + path="${path#"$work"/}" + if offending_import "$path" "$imported"; then + found="$found$path imports $imported"$'\n' + fi + done < "$imports" + fi + rm -rf "$work"; rm -f "$list" "$imports" + printf '%s' "$found" +} + +report() { + local what="$1" carrying="$2" + if [ -n "$carrying" ]; then + status=1 + echo "FAIL $what carries a Work Context implementation:" + # The offending IMPORT is printed beside the file. Naming only the file + # left whoever reads the CI log to re-derive which import was the finding. + while IFS= read -r line; do + [ -n "$line" ] && echo " $line" + done <<< "$carrying" + else + echo "ok $what" + fi +} + +if [ "$#" -eq 0 ]; then + files=$(git ls-files -- '*.go' | wc -l | tr -d ' ') + if [ "$files" -eq 0 ]; then + echo "no Go files tracked here: the sweep would pass for an empty repository" >&2 + exit 1 + fi + # THE SUBSTITUTION'S EXIT STATUS IS READ. `report … "$(carrying_in_tree)"` threw + # it away: a fatal `exit 1` inside the function ran in the command + # substitution's SUBSHELL, so the script carried on, report saw no findings, + # and the ref was announced `ok` with exit 0 — measured, with the FAIL text + # printed directly above the `ok`. A fix that reports its own failure and then + # passes is the defect it was fixing. + if ! carrying=$(carrying_in_tree); then + exit 1 + fi + report "working tree ($files Go files)" "$carrying" +else + for ref in "$@"; do + git rev-parse --verify --quiet "$ref" >/dev/null || { + echo "no such ref: $ref" >&2 + exit 1 + } + if ! carrying=$(carrying_in_ref "$ref"); then + exit 1 + fi + report "$ref" "$carrying" + done +fi + +if [ "$status" -ne 0 ]; then + cat >&2 <<'MSG' + +A wire contract has exactly one implementation, in the repository that owns the +type: codefly-dev/core/workcontext. The imports above are not on their module's +allowlist in scripts/allowed-imports.txt. + +THIS IS DENY BY DEFAULT, and that is the point. The rule here was a denylist of +names for ten rounds of review and every round found the next name — hkdf, then +sha512, then protoiface, and finally crypto/mldsa signing a WorkContextV1 that +encoding/json/v2 had encoded in the deleted format, with every gate green. If an +import above is honest, add it to scripts/allowed-imports.txt WITH A REASON, in +a reviewed diff. That is the cost, and it is one line instead of a round. + +`import "C"` is refused outright: cgo is not a package name to add to a list, it +is an exit from every rule here — a MAC built through the host's crypto library +names no Go package at all. + +Reach core's Verifier through workcontext/core.go. This repository once held a +second implementation signing hand-written JSON. The signatures were sound; the +key id is a field inside the payload, so reading a protobuf payload as JSON +yielded no key id and the failure surfaced as "unknown key" / "signature does +not verify under key X" — which reads like a rotated key, so keys are what +everyone investigated. +MSG +fi +exit "$status" diff --git a/scripts/importsof/main.go b/scripts/importsof/main.go new file mode 100644 index 0000000..70700ad --- /dev/null +++ b/scripts/importsof/main.go @@ -0,0 +1,87 @@ +// Command importsof prints every import path of the Go files named on stdin. +// +// IT EXISTS BECAUSE awk CANNOT PARSE GO, and five rounds of review proved it +// one shape at a time. The extractor it replaces was rewritten three times and +// each rewrite was walked past by source that compiles: +// +// import ( +// // ) +// "crypto/ed25519" // a ")" in a comment closed the block +// ) +// import (`crypto/ed25519`; "fmt") // a raw path before a quoted one +// import // the keyword, then a newline, +// "crypto/ed25519" // then the path +// import /* +// */ "crypto/ed25519" // a comment spanning lines +// import "\x63rypto/ed25519" // an escape sequence, and it compiles +// +// The last one is the argument in miniature: `"\x63rypto/ed25519"` IS +// "crypto/ed25519" to the Go compiler and is not the string `crypto/ed25519` +// to anything matching text. No regex over lines closes that; the language's +// own parser closes all five at once, because the question was always "what +// does Go think this file imports". +// +// This matters most where it is the ONLY gate. The AST gate reads the working +// tree; the ref sweep reads blobs out of `git cat-file` for every published +// ref, and it ran the shell extractor alone — so each shape above was a +// complete bypass of the required check for any branch. +// +// Input: one file path per line on stdin. Output: `path\timportpath` per +// import. A file that does not parse is a FAILURE, not a file with no imports: +// a sweep that reports success for what it could not read is the fail-open this +// repository has fixed four times. +package main + +import ( + "bufio" + "fmt" + "go/parser" + "go/token" + "os" + "strconv" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "importsof:", err) + os.Exit(1) + } +} + +func run() error { + fileSet := token.NewFileSet() + scanner := bufio.NewScanner(os.Stdin) + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + out := bufio.NewWriter(os.Stdout) + defer func() { + _ = out.Flush() + }() + for scanner.Scan() { + path := scanner.Text() + if path == "" { + continue + } + // ImportsOnly, so a file whose BODY does not compile is still read for + // its imports: the sweep's subject is what a file reaches for, and a + // blob at some old ref need not build against today's dependencies. + syntax, err := parser.ParseFile(fileSet, path, nil, parser.ImportsOnly|parser.SkipObjectResolution) + if err != nil { + return fmt.Errorf("parse %s: %w", path, err) + } + for _, imported := range syntax.Imports { + // Unquote, so an escape sequence is the path Go sees rather than + // the text somebody typed. + spec, err := strconv.Unquote(imported.Path.Value) + if err != nil { + return fmt.Errorf("%s: unquote %s: %w", path, imported.Path.Value, err) + } + if _, err := fmt.Fprintf(out, "%s\t%s\n", path, spec); err != nil { + return fmt.Errorf("write: %w", err) + } + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("read the file list: %w", err) + } + return nil +} diff --git a/scripts/sweep-published-refs.sh b/scripts/sweep-published-refs.sh new file mode 100755 index 0000000..143339b --- /dev/null +++ b/scripts/sweep-published-refs.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# Sweep EVERY ref this repository publishes for a second Work Context +# implementation, except the one the current change is becoming. +# +# This exists as a script rather than inline in go.yml for one reason: the +# inline version was wrong for weeks and nothing could test it. It listed refs +# with +# +# git for-each-ref 'refs/remotes/origin/*' +# +# and `*` DOES NOT CROSS A SLASH, so every branch with a slash in its name was +# silently dropped — compat/*, feat/*, dependabot/*, anything namespaced. The +# CI log said "sweeping 1 other published ref(s)" and "ok origin/badges" and +# the check went green while origin/dependabot/go_modules/gomod-06d3dc2861 +# published workcontext/work_context.go importing crypto/ed25519. +# +# Two changes follow from that, and the second matters more than the first: +# +# 1. Refs are listed BY PREFIX (refs/remotes/origin), which git matches +# without globbing, so a namespaced ref is included. +# 2. SWEPT IS ASSERTED AGAINST LISTED. A gate that sweeps a subset and +# reports success is worse than no gate, because the green is evidence. +# The remote's own branch list is the denominator, and a mismatch fails +# rather than passing quietly. +# +# Usage: +# scripts/sweep-published-refs.sh # base/head from the environment +# SWEEP_BASE=main SWEEP_HEAD=my-branch scripts/sweep-published-refs.sh +# SWEEP_LIST_ONLY=1 scripts/sweep-published-refs.sh # print the refs, sweep nothing +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# The ref this change is becoming, and the ref it merges into, are both +# excluded: the working-tree sweep covers the former, and sweeping the latter +# is circular — the default branch carries the implementation until this change +# deletes it. +# +# THE BASE IS THE DEFAULT BRANCH, NOT THE REF BEING BUILT. It used to fall +# through to GITHUB_REF_NAME, and that made the same required check answer +# differently depending on which EVENT ran it: +# +# pull_request GITHUB_BASE_REF=main -> main excluded, check green +# push GITHUB_BASE_REF is empty -> base became the pushed branch, +# main swept, check RED +# +# Measured: run 37208615216, a push build, `FAIL origin/main carries a Work +# Context implementation` listing the very files this PR deletes. Both builds +# report under one check name, so the result depended on which event landed +# last — a check whose answer depends on its trigger is worse than a check that +# is wrong, because it is right half the time. +# +# The workflow passes SWEEP_BASE explicitly from the repository's own default +# branch, so this fallback is a safety net rather than the mechanism. +base="${SWEEP_BASE:-${GITHUB_BASE_REF:-main}}" +head_ref="${SWEEP_HEAD:-${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-}}}" + +# Every remote-tracking ref, by PREFIX so namespaced refs are not dropped. +# Read into a file first: a process substitution's exit status is not the shell's, +# so `done < <(git for-each-ref ...)` reports success for a git that failed. +listing="" +local_refs="$(mktemp)" +trap 'rm -f "$local_refs" ${listing:+"$listing" "$listing.err"}' EXIT +if ! git for-each-ref --format='%(refname:short)' refs/remotes/origin > "$local_refs"; then + echo "FAIL cannot list the remote-tracking refs in this checkout." >&2 + exit 1 +fi +listed=() +while IFS= read -r ref; do + [ -n "$ref" ] || continue + case "$ref" in + origin | origin/HEAD) continue ;; + esac + listed+=("$ref") +done < "$local_refs" + +# THE DENOMINATOR, from the remote itself rather than from what happens to be +# fetched locally. A stale or shallow fetch is the case a subset-sweep hides in. +# +# ASKING AND FAILING IS NOT ASKING AND GETTING NOTHING. The previous revision +# ran this with `2>/dev/null` and then guarded the comparison with +# `[ ${#published[@]} -gt 0 ]`, so an ls-remote that FAILED — no network, no +# credential, a renamed remote — produced an empty denominator and skipped the +# only check that makes the sweep's green mean anything. Measured: with the +# remote unreachable and one ref fetched locally it printed "the remote +# publishes 0 ref(s); sweeping 0 of them", "this repository publishes no ref +# other than main", and exited 0 — the same false green the glob produced, +# re-entering through the guard added to stop it. So the exit status is read, +# and the stderr is shown rather than swallowed. +listing="$(mktemp)" +if ! git ls-remote --heads origin > "$listing" 2>"$listing.err"; then + echo "FAIL cannot list the refs this repository publishes, so there is nothing to" >&2 + echo " hold the sweep to. git ls-remote --heads origin said:" >&2 + sed 's/^/ /' "$listing.err" >&2 + echo " A sweep with no denominator cannot know what it did not look at, and" >&2 + echo " reporting success from one is the defect this assertion exists for." >&2 + rm -f "$listing.err" + exit 1 +fi +rm -f "$listing.err" +published=() +while IFS= read -r name; do + [ -n "$name" ] && published+=("$name") +done < <(sed 's|.*refs/heads/||' "$listing") + +# AND THE COMPARISON IS BETWEEN SETS, NOT COUNTS. Counts agreeing is not the +# same ref list agreeing: measured, a remote that deleted one branch and +# published another left the local view holding a stale ref and missing the new +# one — two refs each side, assertion green, and the branch that actually +# carried the implementation was never opened. The sweep reported +# "ok origin/carries/the/implementation" and exited 0. +if [ ${#published[@]} -eq 0 ]; then + echo "FAIL the remote publishes no heads at all, which no repository this gate runs in does." >&2 + echo " That is an answer about the wrong remote, not an empty repository." >&2 + exit 1 +fi + +# Only one direction is asserted: every PUBLISHED ref must be visible here. A +# local ref the remote has since deleted is left in, because it is then SWEPT — +# which can produce a false failure and never a false pass, and that is the side +# to err on. The previous `-ne` compared cardinalities, which is the one +# comparison that fails in both directions at once. +missing=() +for name in "${published[@]}"; do + found="" + for ref in "${listed[@]}"; do + [ "$ref" = "origin/$name" ] && found=1 && break + done + [ -n "$found" ] || missing+=("$name") +done +if [ ${#missing[@]} -gt 0 ]; then + echo "FAIL the remote publishes ${#missing[@]} ref(s) this sweep cannot see: ${missing[*]}" >&2 + echo " Listed: ${listed[*]}" >&2 + echo " Published: ${published[*]}" >&2 + echo " A sweep over a subset that reports success is worse than no sweep." >&2 + echo " Fetch every head first: git fetch --prune origin '+refs/heads/*:refs/remotes/origin/*'" >&2 + exit 1 +fi + +refs=() +for ref in "${listed[@]}"; do + [ "$ref" = "origin/$base" ] && continue + if [ -n "$head_ref" ] && [ "$ref" = "origin/$head_ref" ]; then + continue + fi + refs+=("$ref") +done + +if [ -n "${SWEEP_LIST_ONLY:-}" ]; then + printf '%s\n' "${refs[@]}" + exit 0 +fi + +echo "the remote publishes ${#published[@]} ref(s); sweeping ${#refs[@]} of them" +echo "(the working tree is swept separately, and $base is swept by its own push build)" +if [ ${#refs[@]} -eq 0 ]; then + echo "this repository publishes no ref other than $base and ${head_ref:-this one}" + exit 0 +fi +"$here/check-one-implementation.sh" "${refs[@]}" diff --git a/sweep_bypasses_test.go b/sweep_bypasses_test.go new file mode 100644 index 0000000..5c9e1ae --- /dev/null +++ b/sweep_bypasses_test.go @@ -0,0 +1,801 @@ +package codefly_test + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +// The repository-wide sweep, held to its own claim. +// +// scripts/check-one-implementation.sh is the only one-implementation gate that +// reads a PUBLISHED REF. The Go gates type-check both modules from a checkout; +// a ref has no checkout, so the sweep reads blobs out of `git cat-file` and +// this script is all there is behind it. (It was the only gate reading the root +// module at all until the import allowlist and the type-checked codec rule +// landed; that half of the claim is no longer true.) A bypass here is still a +// bypass of the required check for any branch a consumer can pin, which is why +// these cases exist. Each one was reported as a +// bypass of the previous revision and each one worked: +// +// - a non-ASCII import alias and a comment-prefixed import line both walked +// past a regex that tried to recognise an import's SHAPE. The sweep now +// reads the path out of the quotes and never looks at the alias. +// - encoding/pem decodes a base64 body and mime.WordDecoder decodes base64, +// so "base64 is banned" was a ban on one spelling. +// - a GMAC through crypto/cipher is a MAC that names no MAC. +// +// Each case is a throwaway git repository, because the sweep's subject is +// "every tracked Go file" and git is how it enumerates them. +func TestTheRepositorySweepCatchesItsOwnBypasses(t *testing.T) { + script, err := filepath.Abs("scripts/check-one-implementation.sh") + require.NoError(t, err) + + for name, probe := range map[string]struct { + source string + says string + }{ + "a non-ASCII import alias": { + source: "package x\n\nimport ψ \"crypto/ed25519\"\n\nvar _ = ψ.Sign\n", + says: "crypto/ed25519", + }, + "a comment-prefixed import line": { + source: "package x\n\nimport (\n\t/* innocuous */ \"crypto/ed25519\"\n)\n", + says: "crypto/ed25519", + }, + "a dot-imported signer": { + source: "package x\n\nimport . \"crypto/ed25519\"\n", + says: "crypto/ed25519", + }, + "encoding/pem as a base64 decoder": { + source: "package x\n\nimport \"encoding/pem\"\n\nvar _ = pem.Decode\n", + says: "encoding/pem", + }, + "mime as a base64 decoder": { + source: "package x\n\nimport \"mime\"\n\nvar _ = mime.WordDecoder{}\n", + says: "mime", + }, + "a GMAC through crypto/cipher": { + source: "package x\n\nimport \"crypto/cipher\"\n\nvar _ = cipher.NewGCM\n", + says: "crypto/cipher", + }, + "a raw-string import path": { + source: "package x\n\nimport `crypto/ed25519`\n", + says: "crypto/ed25519", + }, + "an alias that is neither ASCII nor a single word": { + source: "package x\n\nimport (\n\tédd \"crypto/ed25519\"\n)\n", + says: "crypto/ed25519", + }, + "a comment-prefixed single-line import": { + source: "package x\n\nimport /* x */ \"crypto/ed25519\"\n", + says: "crypto/ed25519", + }, + "base32 as an alternative envelope": { + source: "package x\n\nimport \"encoding/base32\"\n\nvar _ = base32.StdEncoding\n", + says: "encoding/base32", + }, + "a root-module JSON credential decoder": { + source: "package x\n\nimport \"encoding/json\"\n\nvar _ = json.Unmarshal\n", + says: "encoding/json", + }, + } { + t.Run(name, func(t *testing.T) { + out, err := sweepOf(t, script, "second.go", probe.source) + require.Error(t, err, "the sweep passed this file:\n%s", out) + require.Contains(t, out, probe.says) + require.Contains(t, out, "second.go") + }) + } + + // And an ordinary file passes, so the sweep is not simply failing. + out, err := sweepOf(t, script, "ordinary.go", + "package x\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\nvar _ = fmt.Sprint\nvar _ = strings.TrimSpace\n") + require.NoError(t, err, out) + require.Contains(t, out, "ok") + + // A tracked file with no Go source at all must FAIL rather than pass: a + // sweep that reports "ok" for an empty repository is a sweep that reports + // ok for a repository whose checkout went wrong. + _, err = sweepOf(t, script, "README.md", "nothing to sweep\n") + require.Error(t, err, "an empty sweep must not be a green sweep") +} + +// importReader builds scripts/importsof once per run and returns its path. +// +// Every harness here runs the sweep in a throwaway repository, where the script +// cannot find the reader beside itself — so it is built once and passed in +// through IMPORTSOF_BIN. Thirty `go build` invocations was the alternative. +func importReader(t *testing.T) string { + t.Helper() + readerOnce.Do(func() { + source, err := filepath.Abs(filepath.Join("scripts", "importsof")) + if err != nil { + readerErr = err + return + } + binary := filepath.Join(os.TempDir(), fmt.Sprintf("importsof-%d", os.Getpid())) + build := exec.Command("go", "build", "-o", binary, source) + if output, err := build.CombinedOutput(); err != nil { + readerErr = fmt.Errorf("build the import reader: %v: %s", err, output) + return + } + readerPath = binary + }) + require.NoError(t, readerErr) + require.NotEmpty(t, readerPath) + return readerPath +} + +var ( + readerOnce sync.Once + readerPath string + readerErr error +) + +// sweepEnv is the environment every sweep invocation runs with. +func sweepEnv(t *testing.T) []string { + t.Helper() + return append(os.Environ(), "IMPORTSOF_BIN="+importReader(t)) +} + +// sweepRefOf runs the sweep in REF MODE over a committed blob. +// +// A different path through the script from the working tree: the ref arm reads +// blobs out of `git cat-file` into a temporary directory and maps them back, +// and it applies the historical `legacy` allowance. Both arms were measured +// passing the old denylist's gaps, so both are probed. +func sweepRefOf(t *testing.T, script string, name string, source string) (string, error) { + t.Helper() + repository := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(repository, name)), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(repository, name), []byte(source), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(repository, "scripts"), 0o755)) + copyFile(t, script, filepath.Join(repository, "scripts", "check-one-implementation.sh")) + copyPolicy(t, filepath.Dir(script), filepath.Join(repository, "scripts")) + for _, command := range [][]string{ + {"git", "init", "--quiet", "-b", "main"}, + {"git", "add", "."}, + {"git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "--quiet", "-m", "probe"}, + } { + run := exec.Command(command[0], command[1:]...) + run.Dir = repository + output, err := run.CombinedOutput() + require.NoError(t, err, "%s: %s", command, output) + } + run := exec.Command("bash", "scripts/check-one-implementation.sh", "main") + run.Dir = repository + run.Env = sweepEnv(t) + output, err := run.CombinedOutput() + return string(output), err +} + +// sweepOf runs the sweep over a throwaway repository containing one file. +func sweepOf(t *testing.T, script string, name string, source string) (string, error) { + t.Helper() + repository := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(repository, name)), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(repository, name), []byte(source), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(repository, "scripts"), 0o755)) + for _, command := range [][]string{ + {"git", "init", "--quiet"}, + {"git", "add", "."}, + } { + run := exec.Command(command[0], command[1:]...) + run.Dir = repository + output, err := run.CombinedOutput() + require.NoError(t, err, "%s: %s", command, output) + } + // THE POLICY TRAVELS WITH THE SCRIPT. The sweep reads + // scripts/allowed-imports.txt beside itself, so a throwaway repository with + // only the script in it would fail for want of a policy rather than on the + // probe — which is a test passing for the wrong reason in the file whose + // subject is exactly that. + copyPolicy(t, filepath.Dir(script), filepath.Join(repository, "scripts")) + sweep := exec.Command("bash", script) + sweep.Dir = repository + sweep.Env = sweepEnv(t) + output, err := sweep.CombinedOutput() + return string(output), err +} + +// The REF-LISTING step itself, which was wrong for weeks because nothing could +// test it. +// +// go.yml listed refs with `git for-each-ref 'refs/remotes/origin/*'`. That +// glob does not cross a slash, so every namespaced branch — compat/*, feat/*, +// dependabot/* — was silently dropped. CI printed "sweeping 1 other published +// ref(s)" and "ok origin/badges" and the required check went GREEN while a +// dependabot branch published workcontext/work_context.go importing +// crypto/ed25519. +// +// The step is a script now so these cases exist. The second one is the point: +// listing correctly is a property somebody can break again, and a sweep over a +// subset that reports success is worse than no sweep, because the green is +// what a reader acts on. +func TestTheRefSweepSeesNamespacedBranches(t *testing.T) { + script, err := filepath.Abs("scripts/sweep-published-refs.sh") + require.NoError(t, err) + checker, err := filepath.Abs("scripts/check-one-implementation.sh") + require.NoError(t, err) + + // A throwaway "remote" with namespaced branches, cloned so the clone has + // real remote-tracking refs to list. + remote := t.TempDir() + runIn(t, remote, "git", "init", "--quiet", "--bare") + + seed := t.TempDir() + runIn(t, seed, "git", "init", "--quiet") + require.NoError(t, os.WriteFile(filepath.Join(seed, "ordinary.go"), + []byte("package x\n\nimport \"fmt\"\n\nvar _ = fmt.Sprint\n"), 0o600)) + runIn(t, seed, "git", "add", ".") + runIn(t, seed, "git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "--quiet", "-m", "seed") + runIn(t, seed, "git", "branch", "-M", "main") + runIn(t, seed, "git", "remote", "add", "origin", remote) + runIn(t, seed, "git", "push", "--quiet", "origin", "main") + // Namespaced, exactly the shape the glob dropped. The names are SYNTHETIC: + // these were copied off the live remote, where one of them is a + // third-party bot's branch, and a fixture that names something outside + // this repository is a fixture that goes stale when that thing does. + for _, branch := range []string{"badges", "team/welcome", "dependabot/go_modules/gomod-abc"} { + runIn(t, seed, "git", "push", "--quiet", "origin", "main:refs/heads/"+branch) + } + + clone := t.TempDir() + runIn(t, clone, "git", "clone", "--quiet", remote, ".") + runIn(t, clone, "git", "fetch", "--prune", "--quiet", "origin", "+refs/heads/*:refs/remotes/origin/*") + require.NoError(t, os.MkdirAll(filepath.Join(clone, "scripts"), 0o755)) + copyFile(t, script, filepath.Join(clone, "scripts", "sweep-published-refs.sh")) + copyFile(t, checker, filepath.Join(clone, "scripts", "check-one-implementation.sh")) + + listed := listRefs(t, clone, "main", "") + require.Contains(t, listed, "origin/team/welcome", + "a namespaced branch was dropped: this is the defect the glob had") + require.Contains(t, listed, "origin/dependabot/go_modules/gomod-abc", + "a doubly namespaced branch was dropped") + require.Contains(t, listed, "origin/badges") + require.NotContains(t, listed, "origin/main", "the base ref is swept by its own push build") + + // And the head ref is excluded while everything else stays. + withHead := listRefs(t, clone, "main", "badges") + require.NotContains(t, withHead, "origin/badges") + require.Contains(t, withHead, "origin/team/welcome") +} + +// And the sweep REFUSES when it can see fewer refs than the remote publishes, +// which is what turns "I swept a subset" from a silent pass into a failure. +func TestTheRefSweepRefusesWhenItCanSeeFewerRefsThanExist(t *testing.T) { + script, err := filepath.Abs("scripts/sweep-published-refs.sh") + require.NoError(t, err) + checker, err := filepath.Abs("scripts/check-one-implementation.sh") + require.NoError(t, err) + + remote := t.TempDir() + runIn(t, remote, "git", "init", "--quiet", "--bare") + seed := t.TempDir() + runIn(t, seed, "git", "init", "--quiet") + require.NoError(t, os.WriteFile(filepath.Join(seed, "ordinary.go"), + []byte("package x\n\nimport \"fmt\"\n\nvar _ = fmt.Sprint\n"), 0o600)) + runIn(t, seed, "git", "add", ".") + runIn(t, seed, "git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "--quiet", "-m", "seed") + runIn(t, seed, "git", "branch", "-M", "main") + runIn(t, seed, "git", "remote", "add", "origin", remote) + runIn(t, seed, "git", "push", "--quiet", "origin", "main") + runIn(t, seed, "git", "push", "--quiet", "origin", "main:refs/heads/kept") + + clone := t.TempDir() + runIn(t, clone, "git", "clone", "--quiet", remote, ".") + require.NoError(t, os.MkdirAll(filepath.Join(clone, "scripts"), 0o755)) + copyFile(t, script, filepath.Join(clone, "scripts", "sweep-published-refs.sh")) + copyFile(t, checker, filepath.Join(clone, "scripts", "check-one-implementation.sh")) + // A branch appears on the remote AFTER the clone, so the local view is a + // strict subset — the shallow/stale-fetch case. + runIn(t, seed, "git", "push", "--quiet", "origin", "main:refs/heads/appeared/later") + + run := exec.Command("bash", "scripts/sweep-published-refs.sh") + run.Dir = clone + run.Env = append(sweepEnv(t), "SWEEP_BASE=main", "SWEEP_HEAD=") + output, err := run.CombinedOutput() + require.Error(t, err, "a sweep that can see fewer refs than exist must fail:\n%s", output) + require.Contains(t, string(output), "worse than no sweep") +} + +func listRefs(t *testing.T, dir string, base string, head string) []string { + t.Helper() + run := exec.Command("bash", "scripts/sweep-published-refs.sh") + run.Dir = dir + run.Env = append(sweepEnv(t), "SWEEP_LIST_ONLY=1", "SWEEP_BASE="+base, "SWEEP_HEAD="+head) + output, err := run.CombinedOutput() + require.NoError(t, err, "%s", output) + var refs []string + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + if line != "" { + refs = append(refs, line) + } + } + return refs +} + +func runIn(t *testing.T, dir string, command string, args ...string) { + t.Helper() + run := exec.Command(command, args...) + run.Dir = dir + output, err := run.CombinedOutput() + require.NoError(t, err, "%s %v: %s", command, args, output) +} + +// copyPolicy puts the real import policy beside a throwaway copy of the script. +func copyPolicy(t *testing.T, fromDir string, toDir string) { + t.Helper() + require.NoError(t, os.MkdirAll(toDir, 0o755)) + copyFile(t, filepath.Join(fromDir, "allowed-imports.txt"), + filepath.Join(toDir, "allowed-imports.txt")) +} + +func copyFile(t *testing.T, from string, to string) { + t.Helper() + content, err := os.ReadFile(from) + require.NoError(t, err) + require.NoError(t, os.WriteFile(to, content, 0o755)) +} + +// Round six, layer 2: THE AUDIT FAILED OPEN, in four separate places, and three +// of them were inside the assertion added for round five's blocker. +// +// A gate that reports success when it could not look is worse than no gate, and +// each of these reported success: the ref-listing guard skipped itself when +// git failed, the comparison was between COUNTS, an unreadable blob was swept +// as a clean one, and the fix for that last one exited a subshell. +func TestTheSweepRefusesWhenItCouldNotLook(t *testing.T) { + sweep, err := filepath.Abs("scripts/sweep-published-refs.sh") + require.NoError(t, err) + checker, err := filepath.Abs("scripts/check-one-implementation.sh") + require.NoError(t, err) + + // AN UNREACHABLE REMOTE. `git ls-remote … 2>/dev/null` swallowed the + // failure and `[ ${#published[@]} -gt 0 ]` skipped the comparison, so a + // checkout with one ref fetched printed "the remote publishes 0 ref(s); + // sweeping 0 of them", "this repository publishes no ref other than main", + // and exited 0 — the same false green the glob produced, through the guard + // added to stop it. + t.Run("an unreachable remote", func(t *testing.T) { + clone := clonedRemote(t, sweep, checker, "kept") + runIn(t, clone, "git", "remote", "set-url", "origin", filepath.Join(t.TempDir(), "gone.git")) + + output, err := runSweep(t, clone) + + require.Error(t, err, "a sweep with no denominator must not report success:\n%s", output) + require.Contains(t, output, "cannot list the refs this repository publishes") + }) + + // COUNTS AGREEING IS NOT THE SAME REFS. A remote that deleted one branch + // and published another left the local view holding a stale ref and + // missing the new one: two each side, assertion green, and the branch that + // carried the implementation was never opened. + t.Run("the same number of different refs", func(t *testing.T) { + clone := clonedRemote(t, sweep, checker, "kept") + remote := strings.TrimSpace(runOut(t, clone, "git", "remote", "get-url", "origin")) + // The remote moves on; this checkout does not refetch. + // + // Pushed from origin/main BY NAME, never from HEAD: a clone's HEAD is + // the remote's, and a bare repository's HEAD follows whoever ran + // `git init` — so `HEAD:refs/heads/…` resolved here and failed on a + // runner whose init.defaultBranch is master with "src refspec HEAD + // does not match any". Green locally, red in CI, which is the class + // this whole file is about. + seed := t.TempDir() + runIn(t, seed, "git", "clone", "--quiet", remote, ".") + runIn(t, seed, "git", "push", "--quiet", "origin", ":refs/heads/kept") + runIn(t, seed, "git", "push", "--quiet", "origin", + "refs/remotes/origin/main:refs/heads/appeared/later") + + output, err := runSweep(t, clone) + + require.Error(t, err, "the same count of different refs must not pass:\n%s", output) + require.Contains(t, output, "appeared/later", + "and the refusal must name the ref that was never swept") + }) + + // AN UNREADABLE ENTRY AT A REF. `git ls-tree -r` names gitlinks as well as + // blobs, so a submodule whose path ends in .go is listed and `git cat-file + // blob` cannot read it. The error was swallowed, the source emptied, and + // the ref announced `ok` with exit 0. + // + // The first fix for this failed open too: the `exit 1` ran inside the + // command substitution `"$(carrying_in_ref "$ref")"`, so the script printed + // FAIL, carried on, found no findings, and printed `ok` directly underneath + // — measured. Both halves are asserted here, the exit status as well as the + // message. + t.Run("an entry the sweep cannot read", func(t *testing.T) { + repository := t.TempDir() + runIn(t, repository, "git", "init", "--quiet", ".") + require.NoError(t, os.WriteFile(filepath.Join(repository, "ordinary.go"), + []byte("package x\n\nimport \"fmt\"\n\nvar _ = fmt.Sprint\n"), 0o600)) + runIn(t, repository, "git", "add", ".") + runIn(t, repository, "git", "-c", "user.email=t@t", "-c", "user.name=t", + "commit", "--quiet", "-m", "seed") + commit := strings.TrimSpace(runOut(t, repository, "git", "rev-parse", "HEAD")) + // A gitlink whose path ends in .go. + runIn(t, repository, "git", "update-index", "--add", "--cacheinfo", + "160000,"+commit+",vendored.go") + runIn(t, repository, "git", "-c", "user.email=t@t", "-c", "user.name=t", + "commit", "--quiet", "-m", "a gitlink named like a Go file") + runIn(t, repository, "git", "branch", "gitlinked") + require.NoError(t, os.MkdirAll(filepath.Join(repository, "scripts"), 0o755)) + copyFile(t, checker, filepath.Join(repository, "scripts", "check-one-implementation.sh")) + copyPolicy(t, filepath.Dir(checker), filepath.Join(repository, "scripts")) + + run := exec.Command("bash", "scripts/check-one-implementation.sh", "gitlinked") + run.Dir = repository + run.Env = sweepEnv(t) + raw, err := run.CombinedOutput() + output := string(raw) + + require.Error(t, err, "a ref this sweep could not open must not be reported ok:\n%s", output) + require.Contains(t, output, "has not been swept") + require.NotContains(t, output, "ok gitlinked", + "the first fix printed FAIL and then ok, because exit 1 inside a command "+ + "substitution exits the subshell") + }) +} + +// And the two SCANNER bypasses, both of which compile and pass `go vet`. +// +// The import extractor was rewritten in round four for exactly this class and +// these two shapes walked past the rewrite: it read the closing paren off the +// RAW line, so a `)` inside a comment closed the block early; and it scanned +// for double-quoted paths and backquoted paths in two sequential passes, so a +// raw path BEFORE a quoted one on the same line was dropped. Both measured at +// exit 0 against the previous revision. +func TestTheImportScannerReadsOneLineOnce(t *testing.T) { + script, err := filepath.Abs("scripts/check-one-implementation.sh") + require.NoError(t, err) + + for name, probe := range map[string]string{ + "a closing paren inside a comment": "package x\n\nimport (\n\t// )\n\t\"crypto/ed25519\"\n)\n\nvar _ = ed25519.Sign\n", + "a block comment holding a paren": "package x\n\nimport (\n\t/* ) */\n\t\"crypto/ed25519\"\n)\n\nvar _ = ed25519.Sign\n", + "a raw path before a quoted one": "package x\n\nimport (`crypto/ed25519`; \"fmt\")\n\nvar _ = ed25519.Sign\nvar _ = fmt.Sprint\n", + // The shapes the awk extractor still passed after three rewrites, each + // compiled by the reviewer who found them. The last one is the whole + // argument for deleting the extractor: `"\x63rypto/ed25519"` IS + // crypto/ed25519 to the compiler, and is not that string to anything + // matching text. No regex closes it; go/parser closes all of them. + "the keyword, then a newline, then the path": "package x\n\nimport\n\t\"crypto/ed25519\"\n\nvar _ = ed25519.Sign\n", + "a comment spanning lines before the path": "package x\n\nimport /*\n*/ \"crypto/ed25519\"\n\nvar _ = ed25519.Sign\n", + "an escaped import path": "package x\n\nimport \"\\x63rypto/ed25519\"\n", + "a grouped escaped path": "package x\n\nimport (\n\t\"\\x63rypto/ed25519\"\n)\n", + } { + t.Run(name, func(t *testing.T) { + out, err := sweepOf(t, script, "second.go", probe) + require.Error(t, err, "the sweep passed this file:\n%s", out) + require.Contains(t, out, "crypto/ed25519") + }) + } + + // THE DENY-BY-DEFAULT POLICY, THROUGH THE SCRIPT, IN TREE AND REF MODE. + // + // This is what had no test. The policy file is asserted not to LIST these + // paths, and the Go gates refuse them in a checkout — but nothing ran the + // SWEEP over them, which is the only thing that reads a published ref and + // is the whole of the required check. A reviewer pointed out that + // restoring the entire old denylist script left every test green, and that + // was true. + // + // Ref mode is checked separately from tree mode because they take + // different paths through the script: the tree reads files from disk, a ref + // reads blobs out of `git cat-file` into a temporary directory. The old + // `ok` was measured in both. + for name, spec := range map[string]string{ + "json/v2, which is not encoding/json": "encoding/json/v2", + "mldsa, a signature nobody had listed": "crypto/mldsa", + "protodelim, a codec by another name": "google.golang.org/protobuf/encoding/protodelim", + "the gRPC codec registry": "google.golang.org/grpc/encoding", + "prototext": "google.golang.org/protobuf/encoding/prototext", + "jsontext": "encoding/json/jsontext", + "hpke, an AEAD and so MAC-capable": "crypto/hpke", + "unsafe, which can forge anything": "unsafe", + } { + // BOTH MODULES, because the policy is per module and the sweep picks + // the list by PATH. Probing only a root-level file left the leaf list + // untested: a mutation that allowlisted `crypto/mldsa` for the leaf + // changed nothing, because no probe ever asked the leaf list anything. + for module, path := range map[string]string{ + "root": "second.go", + "leaf": "workcontext/second.go", + } { + source := "package x\n\nimport _ \"" + spec + "\"\n" + t.Run(name+", in the "+module+" module's working tree", func(t *testing.T) { + out, err := sweepOf(t, script, path, source) + require.Error(t, err, "the sweep passed this file:\n%s", out) + require.Contains(t, out, spec) + require.Contains(t, out, path) + }) + t.Run(name+", in the "+module+" module at a ref", func(t *testing.T) { + out, err := sweepRefOf(t, script, path, source) + require.Error(t, err, "the sweep passed this blob at a ref:\n%s", out) + require.Contains(t, out, spec) + }) + } + } + + // `import "C"` is not an import path, so it is refused by name — in both + // modes, and with the diagnosis that says why rather than "not listed". + t.Run("cgo, in the working tree", func(t *testing.T) { + out, err := sweepOf(t, script, "cgo.go", "package x\n\n/*\n*/\nimport \"C\"\n") + require.Error(t, err, out) + require.Contains(t, out, "cgo.go imports C") + require.Contains(t, out, "cgo is not a package name to add to a list", + "and the diagnosis says WHY, because a reader told only \"not listed\" would "+ + "reasonably try adding it") + }) + t.Run("cgo, at a ref", func(t *testing.T) { + out, err := sweepRefOf(t, script, "cgo.go", "package x\n\n/*\n*/\nimport \"C\"\n") + require.Error(t, err, out) + require.Contains(t, out, "cgo.go imports C") + require.Contains(t, out, "cgo is not a package name to add to a list") + }) + + // THE BANS THE AST GATE HAS AND THIS ONE DID NOT. Four import paths the + // AST gate refuses passed here with `ok working tree (4 Go files)`, exit 0 + // — in the root module, where this script is the only gate. + for name, probe := range map[string]struct{ source, says string }{ + "the legacy protobuf module": { + source: "package x\n\nimport _ \"github.com/golang/protobuf/proto\"\n", + says: "github.com/golang/protobuf/proto", + }, + "the low-level protobuf runtime": { + source: "package x\n\nimport _ \"google.golang.org/protobuf/runtime/protoiface\"\n", + says: "runtime/protoiface", + }, + "protoimpl": { + source: "package x\n\nimport _ \"google.golang.org/protobuf/runtime/protoimpl\"\n", + says: "runtime/protoimpl", + }, + "a vendored ed25519": { + source: "package x\n\nimport _ \"github.com/some/vendor/ed25519\"\n", + says: "ed25519", + }, + } { + t.Run(name, func(t *testing.T) { + out, err := sweepOf(t, script, "second.go", probe.source) + require.Error(t, err, "the sweep passed this file:\n%s", out) + require.Contains(t, out, probe.says) + }) + } +} + +// clonedRemote builds a throwaway remote with one extra branch and returns a +// clone of it carrying both scripts. +func clonedRemote(t *testing.T, sweep string, checker string, branch string) string { + t.Helper() + // -b main on both: these repositories' branch names are part of what the + // sweep is asserted against, so they are stated here rather than inherited + // from whatever init.defaultBranch the machine happens to set. + remote := t.TempDir() + runIn(t, remote, "git", "init", "--quiet", "--bare", "-b", "main") + seed := t.TempDir() + runIn(t, seed, "git", "init", "--quiet", "-b", "main") + require.NoError(t, os.WriteFile(filepath.Join(seed, "ordinary.go"), + []byte("package x\n\nimport \"fmt\"\n\nvar _ = fmt.Sprint\n"), 0o600)) + runIn(t, seed, "git", "add", ".") + runIn(t, seed, "git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "--quiet", "-m", "seed") + runIn(t, seed, "git", "remote", "add", "origin", remote) + runIn(t, seed, "git", "push", "--quiet", "origin", "main") + runIn(t, seed, "git", "push", "--quiet", "origin", "main:refs/heads/"+branch) + + clone := t.TempDir() + runIn(t, clone, "git", "clone", "--quiet", remote, ".") + runIn(t, clone, "git", "fetch", "--prune", "--quiet", "origin", "+refs/heads/*:refs/remotes/origin/*") + require.NoError(t, os.MkdirAll(filepath.Join(clone, "scripts"), 0o755)) + copyFile(t, sweep, filepath.Join(clone, "scripts", "sweep-published-refs.sh")) + copyFile(t, checker, filepath.Join(clone, "scripts", "check-one-implementation.sh")) + copyPolicy(t, filepath.Dir(checker), filepath.Join(clone, "scripts")) + return clone +} + +func runSweep(t *testing.T, dir string) (string, error) { + t.Helper() + run := exec.Command("bash", "scripts/sweep-published-refs.sh") + run.Dir = dir + run.Env = append(sweepEnv(t), "SWEEP_BASE=main", "SWEEP_HEAD=") + output, err := run.CombinedOutput() + return string(output), err +} + +func runOut(t *testing.T, dir string, command string, args ...string) string { + t.Helper() + run := exec.Command(command, args...) + run.Dir = dir + output, err := run.Output() + require.NoError(t, err, "%s %v", command, args) + return string(output) +} + +// AND A FILE THAT DOES NOT PARSE IS A FAILURE, not a file with no imports. +// +// This is the fail-open class one more time, in the new reader: `awk` produced +// an empty list for anything it could not make sense of, and an empty list is +// indistinguishable from "imports nothing". The reader returns a non-zero exit +// instead, and the script stops. +func TestAFileTheSweepCannotParseIsNotAFileItHasCleared(t *testing.T) { + script, err := filepath.Abs("scripts/check-one-implementation.sh") + require.NoError(t, err) + + out, err := sweepOf(t, script, "broken.go", "package x\n\nimport (\n\t\"crypto/ed25519\"\n// never closed\n") + require.Error(t, err, "a file that does not parse must not be swept as clean:\n%s", out) + require.Contains(t, out, "cannot read the imports") +} + +// The import reader is Go, so it is held to the same question directly: what +// does Go think this file imports? +func TestTheImportReaderAnswersWithWhatGoSees(t *testing.T) { + reader := importReader(t) + directory := t.TempDir() + path := filepath.Join(directory, "probe.go") + require.NoError(t, os.WriteFile(path, []byte( + "package x\n\nimport (\n\t// )\n\t\"\\x63rypto/ed25519\"\n\t`encoding/base64`\n)\n"), 0o600)) + + run := exec.Command(reader) + run.Dir = directory + run.Stdin = strings.NewReader(path + "\n") + output, err := run.Output() + require.NoError(t, err, "%s", output) + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + var paths []string + for _, line := range lines { + _, imported, found := strings.Cut(line, "\t") + require.True(t, found, "each line is pathimport: %q", line) + paths = append(paths, imported) + } + require.ElementsMatch(t, []string{"crypto/ed25519", "encoding/base64"}, paths, + "the escape is unquoted to the path the compiler sees, the raw string is read, "+ + "and the \")\" in the comment does not end the block") +} + +// NO GATE HERE CAN READ ASSEMBLY OR C, so the sweep refuses them — at every +// published ref, where no Go test runs. +// +// Every rule in this repository is about Go: `go/parser` for the imports, +// `go/types` for the codec rule. A `.s` file can implement anything at all, a +// `.syso` is already-compiled object code linked in whole, and a `.c` reached +// through cgo is outside the language. `import "C"` is refused by name and this +// is the other half of it: the import ban is meaningless if the implementation +// can arrive as an object file. +func TestTheSweepRefusesSourcesNoGateCanRead(t *testing.T) { + script, err := filepath.Abs("scripts/check-one-implementation.sh") + require.NoError(t, err) + + for name, probe := range map[string]struct{ file, source string }{ + "hand-written assembly": {"sign_amd64.s", "TEXT sign(SB),$0\n\tRET\n"}, + "a compiled object": {"libsign.syso", "\x7fELF not really\n"}, + "a C source": {"sign.c", "int sign(void) { return 0; }\n"}, + "a C header": {"sign.h", "int sign(void);\n"}, + } { + t.Run(name, func(t *testing.T) { + repository := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(repository, "ordinary.go"), + []byte("package x\n\nimport \"fmt\"\n\nvar _ = fmt.Sprint\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(repository, probe.file), + []byte(probe.source), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(repository, "scripts"), 0o755)) + copyFile(t, script, filepath.Join(repository, "scripts", "check-one-implementation.sh")) + copyPolicy(t, filepath.Dir(script), filepath.Join(repository, "scripts")) + for _, command := range [][]string{{"git", "init", "--quiet"}, {"git", "add", "."}} { + run := exec.Command(command[0], command[1:]...) + run.Dir = repository + output, err := run.CombinedOutput() + require.NoError(t, err, "%s: %s", command, output) + } + + run := exec.Command("bash", "scripts/check-one-implementation.sh") + run.Dir = repository + run.Env = sweepEnv(t) + raw, err := run.CombinedOutput() + + require.Error(t, err, "a source no gate can read must not sweep clean:\n%s", raw) + require.Contains(t, string(raw), probe.file) + require.Contains(t, string(raw), "no gate here can read") + }) + } + + // And a repository of ordinary Go still passes, so this is not simply + // failing — the mutation guard for every case above. + out, err := sweepOf(t, script, "ordinary.go", + "package x\n\nimport \"fmt\"\n\nvar _ = fmt.Sprint\n") + require.NoError(t, err, out) +} + +// THE SAME CHECK MUST ANSWER THE SAME WAY WHICHEVER EVENT RAN IT. +// +// The base ref — the one this change merges into, excluded because sweeping it +// is circular — fell through to GITHUB_REF_NAME when GITHUB_BASE_REF was empty. +// That is empty on a PUSH build, so: +// +// pull_request GITHUB_BASE_REF=main -> main excluded, check green +// push GITHUB_BASE_REF empty -> base became the pushed branch, +// main swept, check RED +// +// Measured in CI, run 37208615216: `FAIL origin/main carries a Work Context +// implementation`, listing the files this PR deletes. Both triggers report +// under one required check name, so the answer depended on which event landed +// last — and a check that is right half the time is worse than one that is +// wrong, because the green is what somebody acts on. +// +// Nothing covered this: every test here set SWEEP_BASE explicitly, which is +// precisely the variable CI was not setting on a push. +func TestTheBaseRefIsTheDefaultBranchWhicheverEventRan(t *testing.T) { + sweep, err := filepath.Abs("scripts/sweep-published-refs.sh") + require.NoError(t, err) + checker, err := filepath.Abs("scripts/check-one-implementation.sh") + require.NoError(t, err) + clone := clonedRemote(t, sweep, checker, "feature/work") + // A third published ref, so "everything else is still swept" is an + // assertion rather than an empty list: excluding the base and the head + // from a two-branch remote leaves nothing, and nil contains nothing. + runIn(t, clone, "git", "push", "--quiet", "origin", + "refs/remotes/origin/main:refs/heads/badges") + runIn(t, clone, "git", "fetch", "--prune", "--quiet", "origin", + "+refs/heads/*:refs/remotes/origin/*") + + listWith := func(environment ...string) []string { + t.Helper() + run := exec.Command("bash", "scripts/sweep-published-refs.sh") + run.Dir = clone + // EVERY VARIABLE THE SCRIPT READS IS CLEARED FIRST, then the case sets + // what it means. sweepEnv inherits os.Environ(), and an ambient + // SWEEP_BASE takes precedence in the script over GITHUB_BASE_REF — so + // a case that clears GITHUB_BASE_REF to model a push build would pass + // on an inherited SWEEP_BASE, concealing the exact fallback it exists + // to test. A reviewer caught that, and it is the "passes for the wrong + // reason" shape this whole file is about. + cleared := append(sweepEnv(t), + "SWEEP_LIST_ONLY=1", + "SWEEP_BASE=", "SWEEP_HEAD=", + "GITHUB_BASE_REF=", "GITHUB_HEAD_REF=", "GITHUB_REF_NAME=", + ) + run.Env = append(cleared, environment...) + output, err := run.Output() + require.NoError(t, err, "%s", output) + var refs []string + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + if line != "" { + refs = append(refs, line) + } + } + return refs + } + + // A PUSH build: no GITHUB_BASE_REF, GITHUB_REF_NAME is the pushed branch. + // This is the shape that was red. + push := listWith("GITHUB_BASE_REF=", "GITHUB_REF_NAME=feature/work", "GITHUB_HEAD_REF=") + require.NotContains(t, push, "origin/main", + "a push build swept the default branch, which carries the implementation until "+ + "this change merges — the circular case the exclusion exists for") + require.NotContains(t, push, "origin/feature/work", + "and the ref being built is covered by the working-tree sweep") + require.Contains(t, push, "origin/badges", "while every other published ref is still swept") + + // A PULL_REQUEST build: GITHUB_BASE_REF names the base. + pull := listWith("GITHUB_BASE_REF=main", "GITHUB_REF_NAME=feature/work", + "GITHUB_HEAD_REF=feature/work") + require.NotContains(t, pull, "origin/main") + require.NotContains(t, pull, "origin/feature/work") + + // THE TWO MUST AGREE. That is the property; the individual exclusions are + // how it is reached. + require.ElementsMatch(t, push, pull, + "the same required check swept a different set depending on which event ran it") + + // And an explicit SWEEP_BASE still wins, which is what the workflow passes. + explicit := listWith("SWEEP_BASE=badges", "GITHUB_BASE_REF=", "GITHUB_REF_NAME=feature/work") + require.NotContains(t, explicit, "origin/badges") + require.Contains(t, explicit, "origin/main", + "with badges named as the base, main is an ordinary published ref again") +} diff --git a/workcontext/AGENTS.md b/workcontext/AGENTS.md new file mode 100644 index 0000000..5c8d763 --- /dev/null +++ b/workcontext/AGENTS.md @@ -0,0 +1,238 @@ +# Working in `workcontext/` + +A separate leaf module (`github.com/codefly-dev/sdk-go/workcontext`, Go 1.27, +its own `go.mod`/`go.sum` and its own coverage gate). It mints nothing and +verifies nothing: `codefly-dev/core/workcontext` is the only implementation of +the capability and this module re-exports core's verifier by type alias, in +`core.go`. Adding a dependency here is a design change — see +`.claude/skills/dual-module-change`. + +Depth that used to live in the root `AGENTS.md` and does not fit its 200-line +budget. The root file carries the rule; this carries what the gates refuse. + +## What the two gates refuse, and why each rule exists + +**There are four gates and they read BOTH modules.** Two are type-checked with +`x/tools/go/packages`: `TestEveryImportIsOnItsModulesAllowlist` is a +**deny-by-default import allowlist per module**, and +`TestNoCodecTouchesACapabilityByType` decides the codec rule with `go/types`. +`TestNoSecondWorkContextImplementation` is the syntactic gate that remains. +`scripts/check-one-implementation.sh` sweeps every published ref, reading +imports with `go/parser`. + +**Deny by default is the spine, and it replaces an argument that lost ten +times.** Every revision banned a LIST OF NAMES and every review found the next +name — `hkdf`, then `sha512`, then `protoiface`, and finally `crypto/mldsa` +signing a `WorkContextV1` that `encoding/json/v2` had encoded as snake_case +JSON, which is the exact format this repository deleted, with both gates green +and lint clean. The list was not behind by one entry; it was behind by a design. +Each module now declares the imports it HAS, measured from the tree, and +anything else is a finding — so `protodelim`, the gRPC codec registry, +`prototext`, `jsontext` and `hpke` are refused by one rule, and so is the one +nobody has thought of. `import "C"` is refused by name, because a MAC built +through CommonCrypto names no Go package at all. + +**go/types is necessary and not sufficient**, which is why both halves exist. A +syntactic resolver answers with a SPELLING where the question is a KIND: six +compiled probes decoded a real capability through `func into[M proto.Message]`, +an embedded interface, an alias of `proto.Message`, a `reflect.Call`, a closure +taking `proto.Unmarshal`, and a descriptor-built message. `types.IsInterface` +and object identity answer all of them, and the indirection allowlist is keyed +on `*types.Func` — so a second method named `Handle` on a new type is a +different object. **The irreducible residue** is a hand-written implementation +that imports nothing: a base64url decoder is twenty lines and a wire walk is +fifty. That is closed by review, and saying so is part of the rule. + +The syntactic gate used to walk this module only, which was a hole with nothing +behind it: the deleted implementation lived at the repository root, and the +sweep bans *imports* rather than reading code. A reviewer built the consequence +— a root file with a **hand-written base64url decoder**, so no banned import at +all, and `proto.Unmarshal` into `basev0.WorkContextV1` — and it compiled, linted +clean and swept green. + +The two modules are held to different rules, deliberately. **In here the +capability is the subject**, so every codec use is allowlisted by name. **Outside +it the subject is everything else** — receipts, configuration, runtime documents +— so enumerating the legitimate types would be noise that rots; the rule there +is the decisive one on its own: **no codec may be applied to a Work Context +message**, recognised as a type under core's module whose name begins with +`Work`. A prefix, so a message core adds later is covered the day it exists. + +Type names resolve to the imported **package PATH**, never to the local +qualifier, because the qualifier is the author's choice. + +- **By capability, never by name.** A signature, MAC or JOSE/JWT primitive, + anything under `x/crypto`, `protojson`/`protowire`/`anypb`. The bare `crypto` + is included: `crypto.Signer` signs Ed25519 with no `ed25519` import anywhere. + The predicate used to be "imports a primitive AND mentions WorkContext", + which a second implementation defeats by putting the signer in one file and + the wrapper in another. +- **An allowlisted import is held to FILES and SYMBOLS.** `crypto/tls`, + `crypto/x509`, `mime`, `crypto/sha256` and `encoding/base64` each have an + honest use in exactly one file. Whole-file is allowed-for-everything — which + is how `x509.ParsePKCS8PrivateKey`, a signer getting a key without naming + `ed25519`, would be a permitted use of an allowed import. +- **base64 may ENCODE and never DECODE.** `cache_partition.go` builds a cache + key. base64 decoding plus `proto.Unmarshal` is the entire second parser this + module deleted, and the symbol rule alone cannot see it: `base64.StdEncoding` + is the permitted symbol and `DecodeString` is a method on the value it names. +- **A codec is held to (file, codec, type).** Not to a file — a whole-file + exemption is an exemption for every type in it — and not to an operation + name, because `Marshal` is an operation name shared by every codec, so + `mint.go`'s `encoding/json` row also permitted `proto.Unmarshal` on anything + *named* `mintResponse`. Types resolve package-qualified (`basev0.WorkScopeV1`, + not `WorkScopeV1` from anywhere), in the scope of the use, before the use, and + an ambiguous identifier is refused rather than guessed. +- **No function-local type, and no second local name for core's types outside + `core.go`** — alias or defined type, through a pointer or a slice, and a local + name is CHASED to what it names. Either one hands a type-name allowlist + whatever name it asks for: `type WorkScopeV1 = basev0.WorkContextV1` makes a + capability marshal as an allowed scope, and a declaration inside a function + body is invisible to every rule reading a file's declarations. One `*` walked + past both halves for three rounds — `type carrier = *basev0.WorkContextV1` + resolved to `"carrier"`, neither a capability nor unresolvable, so the root + module's one rule permitted a `proto.Unmarshal` into it. +- **A codec-allowed struct's field set is FROZEN, embedded fields included.** + The codec rule names the top-level type, so a new field is a new thing encoded + through a row written for the fields listed. An embedded field has no `Names`, + so a loop over `field.Names` skipped every one — and `encoding/json` promotes + an embedded struct's exported fields into the enclosing object. +- **No `encoding/json` outside `mint.go` by exact path, and no json-tagged + struct elsewhere.** The deleted implementation signed a hand-written JSON + payload. The path is exact because matching the base name let any new + `mint.go` inherit the allowance — an allowlist undone by `touch`. +- **No declaration named `workcontext*`** (case-insensitively, every + declaration kind) unless it aliases into core's module. +- **The sweep reads imports with GO'S OWN PARSER** (`scripts/importsof`), not a + regex. Its awk extractor was rewritten three times for this class and five + more shapes walked past it, the decisive one being `import + "\x63rypto/ed25519"` — which compiles, IS `crypto/ed25519` to the compiler, + and is not that string to anything matching text. No regex closes that. A + file that does not parse is a failure, not a file with no imports. +- **ONE import policy, `scripts/allowed-imports.txt`,** read by the Go gate and + by the sweep. There were two and they disagreed in both directions, which was + a blocker twice: the sweep is the only thing that runs over a published ref + and is the whole of the required check, so a branch carrying `crypto/mldsa` + and `encoding/json/v2` swept clean while the allowlist in `go test` refused + it. A line may name the FILES that may have an import; `legacy` lines apply + to published refs only, because deny-by-default describes today's tree and + sweeping two years of tags with it flagged 33 clean versions. +- **The gates must READ every shipped file, asserted against `git ls-files`.** + `packages.Load` builds one configuration, so a build-constrained file, a cgo + file under `CGO_ENABLED=0`, a `_`-prefixed directory, a nested module or a + vendor tree is invisible to every semantic rule. The sweep still reads them + (go/parser ignores build tags), so an import ban holds and nothing else does + — and assembly, object code and C are refused outright, because no rule here + can read them. +- **A gate that could not look must not report success.** Four fail-opens, three + inside the assertion added the round before: a swallowed `git ls-remote` + failure skipped the comparison entirely, the comparison was between COUNTS + rather than ref sets, an entry `git cat-file` could not read swept clean, and + the fix for that `exit`ed a command substitution's subshell — printing `FAIL` + and then `ok`. Every enumeration's exit status is read. + +**What no import ban catches:** a hand-rolled HMAC over `crypto/sha256` builds a +MAC out of parts that are not MACs. `crypto/cipher` and the block ciphers are +banned in both gates now; the hash cannot be, because two digests here need it. +So in this module the symbol rule bounds that residue to one file, and in the +root module it is closed by review. "Any MAC is a finding" was a list of names; +this is what the list reaches. + +`TestTheGateCatchesItsOwnBypasses`, +`TestTheRepositorySweepCatchesItsOwnBypasses`, +`TestTheImportScannerReadsOneLineOnce` and +`TestTheSweepRefusesWhenItCouldNotLook` hold both gates to these claims: every +case is a bypass reported against a previous revision, and every one worked. `TestTheSDKParsePathsAgreeWithCore` drives every core conformance +fixture through this module's own parse paths and requires core's sentinel — +read a refusal's sentinel off the fixture, never off a constant here, because +three fixtures changed sentinel under us and the tests that did that needed no +edit. + +## A credential is sealed or it is not a credential, and core says what that means + +The seal and each actor hop's epoch are schema-required, so a missing one is a +`protovalidate` refusal inside core's decode. The **execution** — +`image_digest` and `build_incarnation` — is a PAIR OR NEITHER: it lives in +`SealSource.ApprovedBuild` keyed on the principal, every hop attests its own, +and a principal that bears none (a person at a terminal) seals none rather than +inventing a value. An operation binding carries its id, revision and +incarnation or none of them; and core refuses an unknown field or a +non-canonical encoding, so nothing here may ever marshal a capability. + +**Zero is an answer, not a gap.** `GetBuildIncarnation()` returns 0 both for +"bears no execution" and for a value core says is never legitimate, since an +incarnation starts at 1. `mint.go`'s echo cross-check asks about PRESENCE +before comparing, because a host echoing `"0"` otherwise matched a capability +sealing no execution — a host asserting an execution the issuer does not hold, +passing the check that exists to catch that. `Attach` refuses an unsealed +capability with `corework.Inspect`'s answer — false for a while precisely +because we answered it ourselves and never read the actor chain. + +**One sentinel for every structural seal defect, `ErrInvalid`.** `ErrUnsealed` +is deleted: once the schema requires the seal, no branch can produce it, and a +sentinel no branch produces invites a handler that never runs. + +**`Credential.Seal()` returns core's own wire message**, cloned. That is why +core making `ImageDigest` a required seal field cost this module no code: a +hand-filled struct would have needed a new field everywhere it was built. + +## Metadata leaves a stream by three routes, not one + +The embedded field; gRPC's own flush when the handler RETURNS; and the +package-level `grpc.SetHeader`/`SendHeader`/`SetTrailer`, which resolve a +`grpc.ServerTransportStream` out of the context and write to the transport. +Each was a live bypass found only after the previous one was closed, and the +last two were measured over bufconn with the guard revoked for the whole call +and never asked. + +So `GuardedServerStream` keeps the stream private and forwards every interface +method by hand, HOLDS headers and trailers until `Finish` re-checks and releases +them, and owns the transport stream in the context it hands out. +`GuardStreams` — not `Guard`, and not a bare interceptor — is the recipe, +because it does the +wrapping, the context and the `Finish`, and because its `Intercept` REFUSES +EVERY STREAM until `Validate(server.GetServiceInfo(), …)` has passed. The +interceptor constructor is unexported for that reason: exporting it beside the +validation meant the correct wiring took two calls and only one was reachable +from the type system, so a consumer could install the enforcement and skip the +check — which the README recipe itself did for several revisions. Whether a method is guarded is a +property of the METHOD and the first answer binds; a per-request `(nil, nil)` is +the optional-carrier shape this module deleted. + +A refusal reaches the client as a gRPC status: core's sentinels become +`codes.Unauthenticated`, an unreachable re-check source becomes +`codes.Unavailable`, and the Go error chain is kept alongside the code so +`errors.Is` still works in the server that produced it. **`ErrInvalid` is +`Unauthenticated` and this package's own misuse is `Internal`** — two sentinels, +because they were one: core answers an EXPIRED capability with `ErrInvalid`, so +mapping every `ErrInvalid` to `Internal` told a client not to mint again about +the most ordinary mid-stream refusal there is. `errStreamMisuse` wraps +`ErrInvalid` and is checked first. + +## The mint client holds a credential it cannot verify + +It is the party the credential is minted FOR, not a receiver, so it reads the +window through `corework.Inspect` and checks no signature. Three consequences +that are easy to get backwards: + +- **The lifetime bound is CORE'S**, in `decodeClaims`, so `Inspect` carries it + — and `Inspect` is this client's read path. Core's first two attempts bounded + `Authority.Start` and then `Verify` alone, neither of which a holder can see, + so a thirty-day capability every `Verify` refuses was reported to its holder + as thirty days of validity. `MaxCredentialLifetime` survives as a DEPLOYMENT + POLICY below core's ceiling, defaulting to `corework.MaxTTLCeiling` so there + is no second number; exactly-at-ceiling is accepted and this module pins that + with its own test, because the default IS that constant. +- **The sentinel says what RECOVERY is possible.** `ErrMintRefused` is latched + and terminal — a process seeing one must stop serving — so anything transient + classified that way permanently stops a process holding a good credential: an + interrupted response read, a projected-token `EMFILE`, a momentarily empty + projection, a 408. All of those are `ErrMintUnavailable`. A TLS verification + failure is the opposite: not an outage but the one thing this transport + exists to refuse, so it latches. Local misuse is `ErrInvalid`, never + `ErrMintRefused`, or `Refused()` and `errors.Is` disagree. +- **The shared request is detached from whoever started it**, bounded by + `RequestTimeout`. On the caller's context, a cancelled leader counted as no + failure, so twenty callers with deadlines shorter than a degraded host's + latency produced twenty mint requests — each of which the host may complete + and audit while this process discards it. diff --git a/workcontext/README.md b/workcontext/README.md new file mode 100644 index 0000000..2a3ba25 --- /dev/null +++ b/workcontext/README.md @@ -0,0 +1,959 @@ +# `workcontext` + +The client side of the Work Context capability: the mint-once client a module +process obtains its one credential with, the carriers that take it to a callee, +and typed access to the one implementation of the capability itself. + +**This module mints nothing and verifies nothing.** The Work Context is a core +proto, and `github.com/codefly-dev/core/workcontext` is its only +implementation — the only code that signs one, the only code that checks a +signature, the only encoding of either. What this module exports as a +verification entry point *is* core's, by type alias. + +It is a separate Go module so a consumer can import +`github.com/codefly-dev/sdk-go/workcontext` without inheriting the root SDK's +transitive tail. Four direct dependencies: `codefly-dev/core` (the +`codefly/base/v0` proto types **and** `core/workcontext`), `protobuf`, +`testify`, `grpc`. The gRPC carrier lives in `workcontext/grpctransport` so a +consumer that never makes a gRPC call does not compile grpc. + +Importing `core/workcontext` pulls `protovalidate` → `cel-go` → `antlr`, which +this module used to avoid. That was a deliberate trade and it was made the +other way on purpose: the dependency budget existed to keep a verify-only +consumer's `go.sum` small, and a second implementation of a signed credential +is not a price worth paying for it. + +## One implementation, and the gate that keeps it that way + +A wire contract has exactly one implementation, in the repository that owns the +type. Nothing outside `core/workcontext` may sign, verify or re-encode a Work +Context. + +That is written down because it was broken. A second implementation lived in +this module and signed a **hand-written JSON payload** while core signed the +**deterministic protobuf encoding** of the same message. Both forms are +`.` with an Ed25519 signature, so a +token from either looked well-formed to the other and then failed **signature** +verification. "Signature does not verify under key X" reads like a rotated key +or a wrong trust root, so keys are what everyone investigated, while the actual +problem was two encodings of one message. The host signed with this module's +format, so a field added only to core's proto was dropped at mint and absent at +verify on every live path. + +A comment would not have stopped that, and did not. Two tests do. + +**`TestNoSecondWorkContextImplementation`** parses every non-test file in this +module and refuses it **by what the code can do, not by what it is called**. +That distinction is the whole of it: the first version listed two package paths +and a case-sensitive name prefix, and a faithful clone of core's signer — +`proto.MarshalOptions{Deterministic: true}` plus `golang.org/x/crypto/ed25519` +— was green, as were `crypto/rsa`, `crypto/hmac`, the bare `crypto` package +(whose `crypto.Signer` signs Ed25519 with no ed25519 import), `protojson`, and +the deleted implementation's own type name `workContextPayload`. + +It fails a file that: + +- imports **any** signature, MAC or key-agreement primitive, anything under + `golang.org/x/crypto/`, or any JOSE/JWT/token library, whatever the paths are. + `crypto/sha256` is deliberately fine — a hash is not a signature; +- imports `crypto/tls` or `crypto/x509` anywhere but `mint.go`, which builds the + mint client's own transport — **and uses only the symbols a transport needs**, + so `x509.ParsePKCS8PrivateKey` followed by a `crypto.Signer` assertion, the + shortest signer that imports no primitive at all, is a finding; +- imports `encoding/json` outside `mint.go` — matched by **exact path relative + to the module root**, since a base-name match let any nested `mint.go` inherit + the allowance; +- calls `proto.Marshal` or `proto.MarshalOptions` outside the one file that + encodes a scope for a cache digest. Encoding the message is half a signer; +- declares a json-tagged struct other than the mint endpoint's two bodies, + **anywhere in the file** including an anonymous struct or a type inside a + function; +- declares anything — func, method, var, const or type — whose name begins with + `workcontext` **case-insensitively**, unless it is in a named allowlist with a + reason, or is a type alias **whose target resolves into + `github.com/codefly-dev/core`**. + +It also pins the aliases by assignment (`var _ *corework.Verifier = +(*Verifier)(nil)`), which compiles only while `Verifier` is core's type and not +a local copy with the same fields. + +**`TestTheGateCatchesItsOwnBypasses`** drives twenty-one hostile sources through +the checker and two that must stay allowed, so the gate is held to its claim +rather than trusted about it. Each of the standard bypasses above was measured +to pass the previous version and fail this one. + +**`TestWorkContextConformance`** runs `core/workcontext/conformance.RunWith` +against a verifier built **field by field from the kit's settings as this +module's exported `Verifier`** — not through `conformance.Settings.Verifier()`, +which returns core's own and would have passed unchanged beside the +implementation this module deleted. The kit drives every fixture it carries +through it (the count moves as core adds cases; read it, never pin it) — every token form (session, operation, delegated, +delegated-operation, grant) and every negative case, including a stale and a +*future* installation revision, because the seal is compared for exact equality +and there is no legitimate way to hold a capability sealed ahead of live — and +fails the build if any outcome or any named sentinel differs. The single-use +grant fixture is presented twice against one replay store, so a verifier +without a working one passes everything else and fails exactly there. The +decisive fixture is the foreign encoding: a JSON-shaped token that must be +refused **before** its signature is checked, with `ErrNotACoreToken`. A second +implementation refuses that token too — but only after reading a key id out of a +payload it cannot read, so it refuses as "unknown key" or "signature does not +verify", which is the misdiagnosis the whole rule exists to prevent. The +signatures were never the problem: each implementation signed and verified the +bytes it handled. The key id is a field *inside* the payload, which is why +naming the format before any key lookup is the only diagnosis that points at the +format. That is why the gate lives here, +in the consumer: core cannot see who re-implements it. + +**One field in that build is load-bearing and easy to miss.** +`TrustTheConformanceFixtureKey` must be copied from the settings: the fixture +key's private half is derivable from core's source, so a verifier refuses it +unless it says in as many words that it is a test. Leaving it out made all 35 +fixtures fail with *"key `conformance-1` is the conformance fixture key"* — a +consumer doing exactly the right thing refusing everything. + +Which is the argument **for** building the verifier field by field rather than +calling `settings.Verifier()`: a field core adds to the contract lands here as +a failing test. Through the constructor it would have been inherited silently +and this module would have learned nothing. + +What the kit proves is **behavioural**: this entry point reaches core's +accept/refuse decision with core's named reason on every fixture. It does not +prove identity — an equivalent second implementation would pass the same +fixtures. Identity is the compile-time assertions (`var _ *corework.Verifier = +(*Verifier)(nil)`) and the static gate. Two halves, claimed separately. + +**`TestTheSDKParsePathsAgreeWithCore`** drives every fixture in core's kit +through **this module's own parse paths** and requires core's declared sentinel +for each. + +This is the test the conformance run is not. Driving fixtures through +`&Verifier{}` exercises core, because `Verifier` is an alias of core's type — +and the module's real decisions are in `SealedInstallation`, `FromHeaders`, +`Attach` and `credentialFrom`. Three divergences lived exactly there, and all +three are now core's answer rather than a local one: + +| fixture | core | this module, before | +| --- | --- | --- | +| `seal-without-installation` | `ErrInvalid` (the schema reaches it first) | `ErrUnsealed`, with a test pinning the divergence | +| `actor-without-epoch` | `ErrInvalid` | **accepted** — the actor chain was never read, so a principal nobody can revoke went on the wire | +| zero epoch / revision / incarnation, partial binding | `ErrInvalid` | `ErrUnsealed` | + +**The seal is four fields, and `ImageDigest` is one of them.** `WorkSealV1` +gained `image_digest` as a **required** field: a seal names the approved build +for its installation, and a mint attests the execution it is actually running +against it (`StartInput.Execution{ImageDigest, BuildIncarnation}`). A seal that +names no digest names no execution to match a caller against, so core refuses +to record one. `Credential.Seal()` returns the carried seal as core's own +message, which is why that field arriving needed no code change here — see +`SealedValues`. + +**`ErrUnsealed` no longer exists, and that is the end of this class of defect.** +`WorkContextV1.seal` and `WorkActorV1.principal_epoch` are schema-**required** +now, so `protovalidate` refuses a missing seal or a missing actor epoch inside +core's decode before any branch that could have produced `ErrUnsealed` runs. A +sentinel no branch can produce is worse than none — you write a handler and the +handler never runs — so it is deleted, and **every structural seal defect is +`ErrInvalid`**. One sentinel, one branch. + +This module needed no edit for that change, which is the argument for reading +sentinels off core's kit rather than writing them down: three fixtures changed +sentinel and the tests that assert `declared.Err` did not move. + +The fix was not to sync the rule. **`corework.Inspect` now owns every +structural decision** — shape, encoding, schema, attenuation, grant shape, a +seal naming an installation, an epoch on every actor hop — and this module's +`readClaims` runs after it, reads two values, and decides nothing. Core added +`Inspect` for this, when asked; the twelve structural refusals must reach core's +sentinel here, and every other fixture must **pass**, because an unverified +inspection that refused a signature or a live-state failure would be claiming to +have verified something it cannot see. `Inspect` checks no signature at all — +core's own test asserts a token re-signed with a key nobody holds passes it — so +nil means *shaped right*, never *permitted*. + +**And one sweep, because the test above walks from this module's root.** The +implementation this repository deleted lived at the REPOSITORY root, in package +`codefly` — exactly where that walk does not reach. +`scripts/check-one-implementation.sh` sweeps every tracked Go file in both +modules, as a job in `go.yml` — the workflow that builds every ref this +repository publishes — and it is **required on `main` by an active ruleset**. + +It sweeps **every other ref this repository publishes** too, through +`scripts/sweep-published-refs.sh` — a script rather than inline shell because +the inline version was wrong for weeks and nothing could test it. It listed +refs with `git for-each-ref 'refs/remotes/origin/*'`, and **`*` does not cross +a slash**, so every namespaced branch was silently dropped. CI printed +`sweeping 1 other published ref(s)` / `ok origin/badges` and the required check +went green while `origin/dependabot/go_modules/gomod-06d3dc2861` published +`workcontext/work_context.go` importing `crypto/ed25519`. + +Two things follow, and the second matters more. Refs are listed **by prefix**, +so a namespaced ref is included. And **swept is asserted against what the +remote publishes**: a gate that sweeps a subset and reports success is worse +than no gate, because the green is the evidence somebody acts on. A mismatch +fails. Both properties have tests — +`TestTheRefSweepSeesNamespacedBranches` and +`TestTheRefSweepRefusesWhenItCanSeeFewerRefsThanExist` — because listing +correctly is a property somebody can break again. + +**That step fails today**, on `origin/dependabot/go_modules/gomod-06d3dc2861`, +which still carries the deleted implementation. Under "legacy means delete" a +published ref that cannot meet the rule is **deleted by the owner**, and `main` +does not move while the rule is false of something this repository publishes. +The base ref and the ref under review are excluded: sweeping the branch a pull +request merges into is circular, since `main` carries the implementation until +this change deletes it, and `main` is covered by the working-tree sweep of its +own push build. + +## The model: mint once, sealed, verified exactly + +A module process obtains its credential **once per execution**, bound to the +build it is and the installation it serves, and never again announces itself. + +``` +boot ──▶ read the projected service-account token + ──▶ present it to the host's mint endpoint + ──▶ receive a credential sealed to + (principal id + epoch, installation id + revision, build incarnation) + ──▶ serve + ──▶ at expiry: re-read the rotated projection, re-check the boot authority, mint again +``` + +Three properties follow, and each replaces something that used to be true: + +**Nothing runs on a timer that is not the credential's own expiry.** There is no +heartbeat and no registration call. A fifteen-minute credential renewed three +minutes before expiry costs **one mint and five renewals in an hour**. The +heartbeat this replaces burned a single-use token per surface every fifteen +seconds — 240 mints an hour, each one an audit event recording that nothing had +changed. + +**The process self-reports nothing.** The mint request carries the audience it +wants and the audience its projection was minted for. That is all. Principal, +installation and build are the host's to establish, from the projected token and +its own records; a field for any of them would be a field a process could lie +in. There is no self-reported upstream and no manifest. + +**A credential is sealed or it is not a credential.** Every sealed field is +required. A capability missing one does not verify, and `Attach` refuses to put +it on a request at all — there is no request on which an unsealed credential is +better than no credential. + +## Obtaining the credential + +```go +// At boot, in the root SDK: read the authority-bearing values once. +authority, err := codefly.ReadAuthority(ctx, + codefly.AuthorityValueName{Name: "platform", Key: "work-context-audience"}, + codefly.AuthorityValueName{Name: "platform", Key: "mint-url"}, +) +if err != nil { + return err // a process that cannot establish its identity must not serve +} +mintURL, _ := authority.Value("platform", "mint-url") + +client, err := workcontext.NewMintClient(workcontext.MintOptions{ + URL: mintURL, + Authority: authority, // REQUIRED: rechecked before every mint, including the first + // The audience is NAMED, not passed. It is read from the pin on every + // mint, so the value that reaches the host is the pinned value. + Audience: workcontext.AuthorityValue{ + Name: "platform", Key: "work-context-audience", + }, + ProjectedToken: workcontext.ProjectedTokenFile("/var/run/secrets/codefly/token"), + ProjectionAudience: projectionAudience, + // REQUIRED: the roots that may sign the mint endpoint's certificate. + // There is no safe default for where this process sends its + // service-account token, so there is no default. + RootCAs: platformRoots, +}) +``` + +**`Authority` is required and the audience is read from it.** A free-string +audience beside an optional pin made the drift check guard a value the mint did +not use: `Recheck` could pass while the credential was minted for whatever +string the caller had typed. Reading it through `Value` on every mint is what +makes the pin load-bearing, and `credentialFrom` then requires the audience the +host SIGNED to be the audience the pin answered. + +`URL` must be **absolute https** with no userinfo, query or fragment: the +projected service-account token travels on that request as a bearer credential, +so plaintext is a disclosure the configuration must not be able to choose. + +**The transport is the client's, and you cannot supply one.** `RootCAs` is the +only thing a caller says about it, and it is **required**: `NewMintClient` +returns `ErrInvalid` without it. A nil `RootCAs` used to mean the system pool +silently, which left the one remaining hole in a transport built to have none — +every other route to a weak channel was closed and the trust anchor was still +whatever the image shipped, so a mis-issued certificate for the host name, or a +corporate interception root, received the projected token. A deployment that +really means the host's pool writes `x509.SystemCertPool()`, which is two lines +and is visible at the call site. An `*http.Client` option was a hole that +inspecting the client could not close: a nil `Transport` means the global, +mutable `http.DefaultTransport`; a wrapping `RoundTripper` is opaque; a +`DialTLSContext` bypasses `TLSClientConfig` entirely; and a caller keeping the +`*http.Transport` pointer can turn verification off after construction, because +copying an `http.Client` shares its `Transport`. Each of those sent the +projection over a channel nobody authenticated. So the client builds the +transport — TLS 1.3 minimum, verification on, no custom dialer, no proxy — and +refuses redirects, because Go's own client forwards `Authorization` across a +redirect to the same host. + +Then, on every outbound request: + +```go +credential, err := client.Credential(ctx) // mints once; renews only at expiry +if err != nil { + return err +} +if err := credential.Attach(request); err != nil { + return err +} +``` + +`Credential` mints the first credential and renews one that has reached its +renewal point, so no caller obtains a credential the client knows to be past +that point. It is **not** a guarantee about a credential a caller already holds: +`Credential` is a value, so one read out and kept can be attached after the host +has moved the state under it. That is what `Refresh` is for, and it is why a +receiver verifies rather than trusting that a caller re-asked. + +Every mint re-reads the projected token and rechecks the pinned authority — the +first one included, because a client constructed at boot may not be asked for a +credential until an hour later and the value can have moved in between. A +drifted value refuses the mint instead of being sealed into a new credential. +`Authority` is optional; without it there is no pin for the client to check, +which is why a process that resolved its audience through `ReadAuthority` should +pass it. + +A credential is also checked against **its own window** before it is installed: +an expired or not-yet-valid response, or one whose whole lifetime sits inside +the renewal lead, is refused rather than held and counted as a mint that +worked. The window tested is +**`not_before`** — the claim core's verifier tests; testing `issued_at` was +testing a different window from the one the credential would be judged against +— and the tolerance is `core/workcontext.DefaultSkew`, so the client is not +stricter than the verifier that will accept the credential. + +### A failed renewal is not an outage + +**A held credential that is still valid is served even when a renewal fails.** +Entering the renewal lead and getting a 503 used to return an error to every +caller while the credential in hand had minutes left — an outage manufactured +out of a credential that still worked. A renewal failure is only an error once +the credential has actually expired. + +One mint at a time, and **the wait is cancellable**: callers wait on a channel +rather than on the client's mutex, so a caller whose `ctx` is cancelled stops +waiting instead of blocking on a request it is not making. After a failure the +next attempt is **held off**, doubling to a minute. Without that, a host +answering 503 received one request per caller per call — the heartbeat under +another name, arriving exactly when the host was least able to serve it. + +**The request itself is detached from the caller that started it,** bounded by +`RequestTimeout`. It used to run on the leader's own context, and a cancelled +leader was then a case of its own — no failure, so no hold-off, and *never +served the credential it was holding*. Three consequences, all measured: + +- one mint request per caller deadline. A reviewer ran a host at 250 ms latency + against callers with 20 ms deadlines and counted **94 requests in two + seconds, all 94 completed host-side** — single-flight caps concurrency, not + rate — each one minted, audited, and discarded. With eight concurrent callers + it was 752 calls and the same 94 requests. +- a refusal arriving just after its caller gave up was discarded, so the next + caller presented the projected token to a host that had already refused it. +- **20 of 20 callers failed in the renewal window while holding a credential + with two minutes left.** A cancelled *waiter* was served that credential, so + the two paths disagreed about the same credential. + +Detached, there is no leader: whoever takes the slot waits on the same channel +as everybody else and is answered by the same `servableLocked`. The request +completes once, is classified once, and installs for everybody. + +**Latching is the enumerated case, not the default.** `ErrMintRefused` is +terminal — a process that sees one must stop serving — so the rule for which +HTTP statuses earn it used to be inverted: 429 and 5xx were retryable and +*everything else* was a permanent stop. Measured: a **408 from a proxy**, with a +valid credential in hand, refused, did not serve, and latched for the life of +the process. A 425, a 404 from an ingress mid-rollout, or a 502 rendered as 400 +by a sidecar would each do the same. Only **401** (the projected token is not +acceptable) and **403** (the host authenticated this process and refused it) +latch now; every other status is an outage. The cost of being wrong is +asymmetric — a wrong "retryable" costs one request per hold-off, and a wrong +"terminal" costs the process. A refusal this client decides for *itself* — an +audience that does not match the pin, a seal the host contradicted, a refused +redirect, a certificate that did not verify — still latches, because those are +its own verdicts rather than a status somebody in the middle chose. + +**`Refresh` has its own rate limit, and it decays.** The generation check does +not bound it: a receiver whose live state lags refuses each FRESH credential, so +every refusal is a new generation. The bound is a token bucket — a burst of +three, refilling one per minute — which bounds the steady state to one mint a +minute while answering an honest revocation at once. It used to be the failure +backoff computed from the lifetime refresh count, which never decayed: after a +few legitimate refreshes the bound was a minute *permanently*, so a refresh days +later still waited for a receiver that had lagged that morning. That is a tax, +not a rate limit. + +**The lifetime bound is CORE'S, in the one decode path, and this client's +option is a deployment policy.** Core's `MaxTTLCeiling` (24h) is absolute: +`Authority.maxTTL` clamps to it, `Start` refuses a TTL beyond it, and — since +core `67ee7220` — `decodeClaims` refuses a capability whose window is wider, +so `Verify`, `Authenticate` **and `Inspect`** all refuse it with one message. + +That last one is what matters here, and it is the third place core put this +bound. The argument this module made twice for keeping a client-side ceiling was +that **a mint client never verifies a signature** — it is the party the +credential is minted *for*, not a receiver — so it reads its own window through +`corework.Inspect`, which was structural and checked no lifetime. Core's first +two attempts bounded `Authority.Start` and then `Verify` alone, neither of which +this client can see. A thirty-day capability that every `Verify` refuses was +reported to its **holder** as thirty days of validity: an absent bound tells a +caller nothing, and that one told it something false about the only field it +calls `Inspect` to read. + +With the bound in `decodeClaims`, an over-ceiling lifetime is refused in this +client's own read path before `checkWindow` runs. So the net is core's. +`MintOptions.MaxCredentialLifetime` survives as a **deployment policy** — a +process that will hold a credential for at most five minutes says so — and it +defaults to `corework.MaxTTLCeiling`, read from core, so there is no second +number to drift. A value above core's ceiling is refused at construction, and a +credential at *exactly* the ceiling is accepted, which this module pins with its +own test rather than taking on trust: the default is core's constant, so a +second of slack either way would refuse every credential a host mints at the +maximum. + +**The ceiling's effect on #47's arithmetic:** at `Authority.MaxTTL`'s default +of **one hour**, a process that runs for an hour costs one mint and one renewal +— so the issue's "one audit event per hour" criterion is not reachable by +holding a credential longer unless the host raises `MaxTTL`, within core's 24h +absolute ceiling. That is the host's configuration decision and this module +cannot make it; it is named in the pull request as an open question for the +issue's owner rather than silently satisfied. + +**That ceiling changes #47's acceptance arithmetic, so read it before sizing.** +One mint across an hour of calls needs a lifetime of at least +`run / (1 - lead)` — 75 minutes at the default 0.2 lead — so **at core's +default one-hour cap, "exactly one mint for an hour's run" is not reachable**: +an hour of calls costs one mint and one renewal. That is the ceiling working. +The heartbeat this replaced made 240 requests an hour per surface; two is not +one, and it is also not 240. Both shapes are asserted +(`TestAnHourOfCallsCostsOneMintAndOneRenewalAtCoresDefaultCeiling` and +`…WhenTheHostMintsLongerThanTheRun`), because a consumer sizing this needs to +know which one its host has chosen. + +### When the host refuses the credential you hold + +`ErrRevoked` from the far end means the credential was sound when it was minted +and the state moved under it. Hand the refused credential back: + +```go +credential, err := client.Credential(ctx) +// ... the call is refused with ErrRevoked ... +credential, err = client.Refresh(ctx, credential) +``` + +`Refresh` is tied to the credential that was refused, not to the clock. If the +client has already replaced that one, the replacement is returned and nothing is +minted — so a burst of concurrent refusals on one credential produces exactly +one replacement mint, and a caller holding a credential two generations old +cannot roll the client backwards. It rechecks the pin and re-reads the +projection like any other mint, and a refused replacement leaves the credential +already held in place rather than clearing it. + +`client.Counts()` returns `MintCounts{Mints, Renewals, Refreshes}`. A correct +execution reports exactly one mint. + +`Attach` sets `x-codefly-work-context` and, beside it, +`x-codefly-installation-id` / `x-codefly-installation-revision`. The gRPC +transport sets the same names as metadata. Those two carriers are a **pre-check +the host may refuse on cheaply, and never authority**: the installation that +governs a call is the sealed one. On the incoming side the SDK refuses when a +carrier disagrees with the sealed claim rather than preferring either side. + +**Both carriers are required, exactly once each, on both transports.** Carrying +neither used to be allowed on HTTP, which was the optional-carrier escape hatch +surviving there after it was deleted on gRPC — "one contract for both +transports" was false, and a sender that attached nothing skipped the pre-check +entirely. Cardinality is checked before agreement, because `Header.Get` reads +only the first value: `[sealed-id, something-else]` compared equal to the seal +while the request carried two contradictory installations. + +## Verifying + +Verification is core's, and what this module exports is core's: + +```go +// workcontext.Verifier IS github.com/codefly-dev/core/workcontext.Verifier. +verified, err := verifier.Verify(ctx, token) // *workcontext.Verified, or a refusal +``` + +```go +verifier := &workcontext.Verifier{ + Issuer: issuer, + Audience: myAudience, + Keys: publicKeysByKeyID, + + // All four are required. A verifier missing one refuses everything rather + // than skipping that check, because a verifier that silently skipped the + // seal check would make the strongest check in the model the easiest one + // to omit. + Revisions: revisions, // the issuer's authorization revision + Replay: replay, // consumes single-use capabilities + Grants: grants, // resolves an approval a grant capability claims + Seals: seals, // the live installation, epoch, build and binding state +} +``` + +**That shape is issuer-shaped, and it is a real cost for a consumer.** A +component that holds those four locally — the host — supplies them from its own +state. A verify-only module does not hold them, and must back them with calls +to whoever does: `RevisionSource`, `GrantSource` and `SealSource` are narrow +interfaces for exactly that reason, so an implementation can be an RPC. +`ReplayStore` has to be durable and shared when more than one process verifies, +or single-use is not single use. + +Core ships `FixedRevision`, `MemoryReplayStore` and `MemorySealSource` as real +implementations for tests and single-process cases. **`FixedRevision` is not a +production stand-in**: it answers one number forever, so it accepts a +superseded capability. A consumer that reaches for it in a deployment has +turned revocation off. + +Scope comes last, from the verified capability, through core's scope algebra: + +```go +if !workcontext.ScopeContained( + &basev0.WorkScopeV1{ResourceKind: "record", Actions: []string{"append"}, ResourceIds: []string{recordID}}, + verified.EffectiveScopes(), +) { + return errRefused // the capability is good and does not cover this call +} +``` + +`verified.EffectiveScopes()` is the current actor's hop, or the owner's +delegated authority when no hop has narrowed it. Asking core rather than +walking the actor chain is what keeps one answer to "what does this caller +hold". + +### Exact binding, not scope search + +An operation capability names one binding, at one revision, in one incarnation, +and verification resolves that identity against the seal source. It never +searches for a binding whose scopes would admit the call: a capability sealed to +binding X presented as binding Y is refused **even when Y's scopes are a +superset of X's**. Scope containment is not binding identity, and a revoked +narrow capability must not validate through a wider binding. + +Core's `SealSource` deliberately has no method that lists bindings or finds one +matching a set of scopes. Making the search unexpressible through the interface +is what keeps it out: a search is a predicate somebody writes, and a predicate +one case too generous grants authority nobody reviewed. + +### The refusals + +They are core's, re-exported here so `errors.Is` answers the same through +either import path. + +| Sentinel | Means | What the caller does | +| --- | --- | --- | +| `ErrInvalid` | wrong and cannot become right — bad signature, another issuer, another audience, a schema violation, **no seal at all**, a seal naming no installation or no image digest, a zero epoch/revision/incarnation, a partial binding, **an actor hop with no epoch**, **an unknown field or a non-canonical encoding**, a malformed capability, an expired or not-yet-valid window | refuse; 401 | +| `ErrRevoked` | sound when minted, overtaken since — the principal's epoch, an actor hop's epoch, the installation revision, the build incarnation, a binding revision or incarnation, a revoked binding, **a binding granted to another principal or in another installation**, an installation the principal no longer holds, or the authorization revision moved | mint again, retry once | +| `ErrNotACoreToken` | the payload is not this encoding at all — most usefully, a JSON one | refuse, and do **not** report a signature problem | +| `ErrReplayed` | a single-use capability was presented twice | refuse; this is the resume contract, not a forgery | + +Two of those deserve a note, because getting either wrong costs an outage. + +**`ErrRevoked` is one sentinel for six reasons, deliberately.** In every one of +them the capability was sound when it was minted and the state moved under it, +so the holder's response is identical: mint again. A caller needs to tell "mint +again" from "this caller is not authorized" — those read differently. It does +not need the six reasons for a re-mint to read differently from each other, and +a caller branching on them would be writing a policy nobody reviewed. **This +module no longer defines a `superseded` sentinel of its own**; `ErrRevoked` is +it. + +**`ErrNotACoreToken` does not wrap `ErrInvalid`.** `errors.Is(err, +ErrInvalid)` is false for it, on purpose, so "this is another format" and "this +capability is bad" are not reachable from one branch. A gateway should log the +reason by name: the symptom this prevents is a token that looks valid and fails +signature, and it cannot recur if the look-alike never reaches the signature +check. + +## Streams + +A stream authorized when it opened is not authorized forever. `StreamGuard` +re-presents the credential **before every message** and terminates on refusal, +rather than draining a snapshot computed under authority that has since been +replaced — the failure mode it exists to prevent is the silent one, where +nothing errors and the data keeps arriving. + +There was a re-check cadence here, and it was a weakening of the rule rather +than an implementation of it: revoke the installation one second after a +successful check and every message for the next interval still left, with expiry +and source unavailability equally invisible for that whole window. The condition +is per emission, so the check is per emission. + +**Use `RecheckWith`, never `Verify`.** The recipe this README carried called +`verifier.Verify` on every emission. Core's `Verify` **consumes** a single-use +nonce and every grant capability is single-use, so a grant-opened stream died +with `ErrReplayed` at its **first** message — and the error read like a replay +attack rather than like the guard eating its own credential. Core added +`(*Verifier).Recheck` for this: it re-reads the window, the authorization +revision, the seal, every actor hop's epoch, the operation binding and the +issuer's grant record, and it never touches the replay store. It takes a +`*Verified`, so it cannot be anybody's first check. + +```go +// verified came from verifier.Verify once, when the stream opened. +guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: workcontext.RecheckWith(verifier, verified), +}) + +// And let the SERVER enforce it, rather than each handler remembering to. +// +// DECLARED at construction: every method whose streams carry a capability. A +// method in this list is guarded or its stream is refused; a method outside it +// reaches the handler UNGUARDED, which is why the validation below is not +// optional and is not skippable. +bearing := []string{"/codefly.example.Streamer/Emit"} + +streams := grpctransport.GuardStreams(bearing, + func(ctx context.Context, info *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + verified := verifiedFor(ctx) // from the server's own stream setup + return workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: workcontext.RecheckWith(verifier, verified), + }) + }) + +server := grpc.NewServer(grpc.StreamInterceptor(streams.Intercept)) +pb.RegisterStreamerServer(server, impl) + +// REQUIRED, after registering and before Serve. Until this passes, Intercept +// REFUSES EVERY STREAM — because an earlier revision of this recipe left the +// call out, and a consumer following it kept the fail-open the call exists to +// close. grpc forces the ordering (the interceptor is built before +// registration, the served set exists only after), so the gap is made loud +// instead of silent. +// +// It asks the server three things the interceptor cannot see from inside one +// request: +// +// - a declared name the server does not serve is a typo, and guards nothing; +// - a declared name that is unary guards nothing and makes the set non-empty; +// - a SERVED stream that is neither declared nor named below is a decision +// nobody made. +// +// A stream that genuinely carries no authority is named here, so the decision +// lives in the source rather than in somebody's memory. +if err := streams.Validate( + server.GetServiceInfo(), + "/codefly.example.Streamer/Health", // deliberately unguarded +); err != nil { + return err +} + +// The handler then just sends. It is handed the wrapper, it has no route to +// the raw stream, and the interceptor calls Finish. +func (s *server) Emit(_ *pb.Request, stream pb.Streamer_EmitServer) error { + for message := range messages { + if err := stream.Send(message); err != nil { + return err // terminated; do not resume + } + } + return nil +} +``` + +**The method set is declared, not learned.** The previous revision asked +`guardFor` per request and took the FIRST answer for a method as binding — +trust on first use, and it failed the way trust on first use always fails. A +first request arriving without a capability (a client mid-deploy, a health +probe, a retry that lost its metadata) got `(nil, nil)`, **was handed the raw +stream**, recorded the method as unguarded, and every later legitimate request +was refused `codes.Internal` until the process restarted. One early request +both bypassed the guard and took the method down. A server that cannot +enumerate its capability-bearing methods does not know which of its streams +carry authority, which is the thing to fix before installing an interceptor. + +**Use the interceptor, not `Guard` directly.** This README's previous recipe +called `Guard` in the handler, which leaves the original stream in scope, never +calls `Finish` — so every trailer the handler set was silently dropped — and +cannot replace the context, so the package-level `grpc.SetHeader`, +`grpc.SendHeader` and `grpc.SetTrailer` still wrote straight to the transport. +`Guard` remains exported for a server doing its own wrapping; if you use it, +you must call `Finish` and must pass the wrapper's `Context` to anything that +writes metadata. + +`grpctransport.Guard` wraps a `grpc.ServerStream` so `SendMsg` **is** the +re-check followed by the send. A guard on its own is advice: `BeforeSend` has to +be called, and "before each emission" was therefore something each author had +to remember — the same shape as the optional carrier this PR deleted, a rule +that holds wherever somebody thought of it. A handler writing through the +wrapper cannot emit without the check. `Guard` refuses a nil guard rather than +treating it as "no guarding wanted", because a wrapper that silently did +nothing would be worse than none: the call site would read as guarded. + +It guards emission rather than running a timer: an idle stream discloses +nothing, so there is nothing to refuse. **Any** error from `Recheck` terminates, +including one that is neither `ErrRevoked` nor `ErrInvalid` — a live source that +could not be reached has not said the credential is good, and treating "I could +not ask" as a pass is how a stream outlives its authority with clean logs. +Termination is sticky: a caller that loops past the first refusal is not handed +a second chance to emit, and the authority coming back does not resurrect the +stream. + +### There are three routes out of a stream, not one + +Each of these was a live bypass of the wrapper, and each was found only after +the previous one was closed. They are listed together because the lesson is that +"the handler calls our method" was never the only way metadata leaves. + +| Route | What happened | What closes it | +| --- | --- | --- | +| the embedded field | the wrapper embedded `grpc.ServerStream` publicly, so `stream.(*GuardedServerStream).ServerStream` sent with **no check at all** — a real gRPC probe delivered a message under revoked authority with zero re-checks | the stream is a private field and every interface method is forwarded by hand | +| when gRPC sends | gRPC sends trailers, and flushes a pending header, when the **handler returns** — so a handler could queue, have authority revoked, return, and the client received it. Measured over bufconn: a header queued under authority arrived at the client *after* `Finish` had refused | headers **and** trailers are held in the wrapper and released only by `Finish`, after one last check | +| the context | `grpc.SetHeader`, `grpc.SendHeader` and `grpc.SetTrailer` are package-level functions that resolve a `grpc.ServerTransportStream` out of the context and write to the transport. Measured over bufconn with the guard revoked for the whole call: both a header and a trailer reached the client, and the guard was **never asked** | the wrapper owns the transport stream in the context it hands out, so those three functions route back through the checks | + +What stays deliberately open: a server that keeps the original stream from +outside the interceptor, and `SendHeader`'s own flush — once a header frame is +on the wire no later revocation recalls it, which is what `SendHeader` means. + +**A refusal reaches the client as a gRPC status it can act on.** Returning the +plain Go error made every refusal arrive as `codes.Unknown` with internal text, +so a receiver could not tell "mint again" from "the server broke" — which is the +only decision the table above asks a caller to make. `ErrRevoked`, +`ErrReplayed`, `ErrNotACoreToken` and `ErrInvalid` become +`codes.Unauthenticated`; a termination for a source that could not be reached +becomes `codes.Unavailable`; and this server's own misuse — a nil guard, a +finished stream, a re-check that tried to write to the stream whose liveness it +was deciding — becomes `codes.Internal`, because the client did nothing it can +correct. + +That last split matters more than it looks. `ErrInvalid` was mapped to +`codes.Internal` wholesale on the argument that it covers misuse as well as a +broken capability, and telling a client "mint again" about a server bug makes it +loop. The argument is right and it was applied to the wrong side: core answers +an **expired** capability with `ErrInvalid`, and a stream that outlives its +credential is the ordinary case, not a rare malformed one — it is exactly the +refusal a client answers by minting again. The misuse errors are this package's +own and are constructed here, so they carry their own sentinel and keep the +`codes.Internal` the argument asked for. The sentinel chain is kept alongside +the code, so `errors.Is` still works inside the server that produced it. + +What this costs is one live authorization check per message — against an +RPC-backed `SealSource`, one round trip per emitted item. That is the price of +the guarantee, and it is why the guard is for streams whose messages carry +authority rather than for every stream. **Decide it explicitly rather than +discovering it from a latency graph**: per emission catches a revocation at the +next message, per stream catches one only at the start, and core deliberately +does not choose for you. A stream that cannot pay per-emission does not get the +per-emission guarantee; what it must not get is a cadence that reads like one. + +## Cache partitions + +`DeriveCachePartition` keys on the tenant **and the sealed installation at its +sealed revision**, always. An answer computed while the host held revision N was +computed under the authority of revision N, so the move to N+1 lands every +caller in a fresh partition. That is a cache miss by design and never a stale +hit. + +- `ByAuthorizationView()` adds a digest of the effective scopes and the + authorization revision. Safe only when the result depends on scopes alone. +- `ByViewer()` adds the effective actor — core's `Verified.Actor()`, or the + owner for a direct call — with its kind, agent identity and organization. Use + it for anything whose content varies by who is asking: every reader of a + collection holds the same read scope, and a revision is a number they share. + +`WriteAround` is true for a capability carrying an approval **grant hop**. That +hop is the one audited exception to the attenuation rule, so the answer was +computed under authority nobody else holds: caching it would serve an approved +call's result to callers who were never approved, and reading from the cache +would answer the approved call from an unapproved computation. + +The key prefix is `wc3:`. It moves whenever the key's composition does — this +time, because the digest preimage became length-prefixed binary rather than +JSON — so a shared cache that outlives the deploy (Redis, a warehouse table) +cannot be reached under the keys it still holds. Those digests are framed field +by field, so no tenant, installation or scope can contain a delimiter and spell +another partition's key. + +## What a consumer must stop doing + +- **Stop heartbeating, and delete the client that did it.** No periodic mint, no + registration call, no deregistration. The host establishes presence from + delivery, not from processes announcing themselves. +- **Stop reporting your own identity.** Do not send a build digest, an image + reference, an installation, an upstream address or a manifest with a mint + request. The host reads your build from the pod; anything you send instead is + something you could get wrong, and the version it could get wrong is the one + an attacker would choose. +- **Stop caching the projected token.** Read it again before every renewal. The + projection is rotated under the running process, so the copy read at boot + expires long before the process does. +- **Stop re-reading authority-bearing configuration.** A principal, binding or + audience is read once, at boot, through `codefly.ReadAuthority`. The SDK + refuses a runtime change to one: the host is the authority, and a value that + drifts under a running process is an error, not a reload. `WorkspaceValue` + answers from the pin for a pinned name, so there is no way around it. +- **Stop verifying through this module.** `WorkContextSigner`, + `WorkContextVerifier`, `WorkContextJWKSVerifier`, `VerifyWorkContext`, + `WorkContextExpectations`, `RequireWorkContextScope` and the token type are + **deleted**, not deprecated. Use `core/workcontext`'s `Verifier`, which this + module re-exports; a consumer that was verifying here owes the four sources + above, and that is the honest price of there being one implementation. +- **Stop searching bindings.** Resolve the sealed binding identity. A superset + of scopes is not a licence to act as a different binding. Note the two types: + `SealedOperationBinding` is what a capability CARRIES (id, revision, + incarnation) and `OperationBinding` is the LIVE state an issuer holds (those + three plus the principal it is granted to, the installation it is granted + within, and whether it is revoked). They are deliberately different, because a + client filling the live type from a token would hand a reader an empty + `PrincipalID` that reads as "granted to nobody" rather than as "the wire does + not say". +- **Stop extracting attribution optionally.** + `GRPCExecutionContextFromIncomingIfPresent` is **deleted**. A + capability-bearing path requires the capability, so a call carrying no + carriers at all is refused exactly like one carrying half of them. A consumer + that relied on `present=false` now fails to compile rather than silently + keeping its old path, which is the point. +- **Stop authorizing from bare claims.** Read claims off a `*Verified` and + nowhere else. Claims that were never verified, or were mutated after + verification, cannot reach an authorization decision. +- **Stop writing a second encoder.** If a claim is missing from the wire, it is + added to the proto in core and mints from there. Nothing in this repository + assembles, re-encodes or re-signs a capability — the gate above will say so + before review does. + +## Running the gates + +This is a separate module. Commands run at the repository root do not compile +it, and report `ok` having never tried. + +```bash +go test ./... && (cd workcontext && go test ./...) +go test -race ./... && (cd workcontext && go test -race ./...) +golangci-lint run && (cd workcontext && golangci-lint run) +go mod tidy && (cd workcontext && go mod tidy) +``` + +Coverage for this module is gated on the **module total** across both its +packages (≥ 75%, an `awk` line in the `coverage` job of `.github/workflows/go.yml`), +not per package — `grpctransport` is carried by the rest, so read the total: + +```bash +cd workcontext +go test ./... -coverprofile=cover.out -covermode=atomic -coverpkg=./... +go tool cover -func=cover.out | awk '/^total:/ {print $3}' +``` + +The `one implementation` sweep is a required check and runs at the repository +root: + +```bash +./scripts/check-one-implementation.sh +``` + +### This module merges on a pseudo-version, deliberately + +`workcontext/go.mod` pins **`v0.7.2-0.20261003161926-4051e4383635`** — core at +`4051e438` — and not a release tag, because there is no tag to move to and will +not be one before this merges. Core's `version/info.codefly.yaml` already says +`0.8.0`, but its `version-tag.yml` cuts the tag from the **merge commit** of +core#692 and core never tags by hand; core#692 is still open on two owner +decisions. The latest published core tag is `v0.7.1`, dated 2026-09-30. + +**What `v0.7.1` does and does not carry**, because the imprecise version of this +("no released core has the Work Context") is both wrong and misleading — it +invites the answer "then nothing is worse off by waiting". Measured against the +tag, not inferred: + +| At `v0.7.1` | | +| --- | --- | +| present | `Authority.Start`, `Verifier.Verify`, `Verified`, `Grant`, `replay.go`, `scope.go` — an **unsealed** mint and verify | +| absent | `Seal` and `SealSource`, so no installation, epoch or build binding and no revocation through the seal | +| absent | `Inspect` — the structural entrypoint this module reads every capability through | +| absent | `Verifier.Recheck` — the non-consuming re-check the stream guard is built on | +| absent | `Authenticator` — the verify-only entrypoint | +| absent | `MaxTTLCeiling` — `v0.7.1` has the unbounded-lifetime behaviour this module measured and refused to trust | +| absent | **the whole `workcontext/conformance` directory** — the kit this module's suite runs, and the import gate that proves it did not reimplement core | + +So the accurate statement is: **there is no released core carrying the sealed +capability, the conformance kit, or either verification entrypoint as they now +stand.** A consumer that needs only unsealed mint-and-verify has `v0.7.1` and is +not blocked. A consumer that owes an import gate — which is every consumer of +this contract — has nothing tagged to run it against. + +A pseudo-version is a *pin*, not a workaround: it is reproducible for anyone who +checks the branch out, and `go.sum` records the hashes. What it is not is a +release line, which matters for a consumer in two ways: + +- **`go get -u` will not move you off it**, and nothing will warn you. The pin + is the version. +- **Do not cut a consumer release whose only Work Context dependency is this + pseudo-version** unless you accept the same unreleased dependency. Pinning + this module from a branch is fine for integration; publishing it as stable is + the thing to hold. + +Core will say when `v0.8.0` lands. The follow-up is a one-line `go get` and a +`go mod tidy` in this module, and it is **not** a merge precondition for this +pull request: holding a finished change behind somebody else's release is how a +branch rots, and the pin names an exact commit either way. + +What the tag will add is nothing functional — `67ee7220` is the content — so +re-pinning is bookkeeping, not an upgrade. + +## The wire + +`base64url(deterministic protobuf of codefly.base.v0.WorkContextV1) + "." + +base64url(Ed25519 signature)`, signed by `core/workcontext` and by nothing +else. The sealed fields are proto fields — `seal.installation_id`, +`seal.installation_revision`, and `seal.image_digest` / +`seal.build_incarnation` as a **pair or neither**; plus, for an operation +capability, `operation_binding.binding_id` / `.revision` / `.incarnation`, all +three together or none. Each actor hop carries its own epoch. + +**The execution pair is optional, and absent is a real answer.** Core +`4cb260d3` moved the approved build out of the live `Seal` and into +`SealSource.ApprovedBuild`, keyed on the **principal**: reading it from the +owner's installation record made a derived capability's execution describe the +owner's workload however many hops had been added, and a hop's principal does +not hold the owner's installation at all. So every hop now attests its own, and +a principal that bears no execution — a person at a terminal — seals none +rather than having a value invented for it. + +**If you read the execution off `Credential.Seal()`, mind the zero.** +`GetImageDigest()` answers `""` and `GetBuildIncarnation()` answers `0` both for +"this principal bears no execution" and for a value core says is never +legitimate, since an incarnation starts at 1. Treating zero as "missing or +invalid" is a bug: it is an answer. This module hit it in its own mint-response +cross-check, where a host echoing `"0"` matched a capability that sealed no +execution at all — a host asserting an execution the issuer does not hold, +passing the check whose job is to catch exactly that. Ask about presence before +comparing. + +This module no longer reads a capability itself. `corework.Inspect` runs the +size bound, the envelope, `CheckEncoding`, `proto.Unmarshal`, **protovalidate** +and the full structural seal check, and returns the claims it decoded; +`readClaims` (`carrier.go`) calls it and clones the result. There is no second +decode, no local rule, and **no signature check** — core's own test asserts a +token re-signed with a key nobody holds passes `Inspect`, so nil means "shaped +like a sealed capability" and never "permitted". That is sound only because +nothing trusts it: it fills the pre-check carriers and tells the mint client +when its own freshly issued credential expires, and a receiver that preferred a +carrier over the sealed claim would have made a caller-controlled header into +authority, which is why the incoming side refuses a disagreement instead. + +`CheckEncoding` is core's and is called rather than reimplemented, so a token in +another format is named `ErrNotACoreToken` **here too**. Two decoders giving an +operator two different messages for one condition is the same diagnostic +fragmentation as the two encodings, in miniature. + +There are no cross-language golden tokens in this module any more. They went +with the implementation that produced them: core mints its conformance fixtures +fresh on every call, because a committed token carries a validity window and +would eventually fail for the wrong reason. + +**Adding a claim**: add the field to the proto in `codefly-dev/core`, mint it +from `core/workcontext`, and add a fixture for it there. There is nothing to +change in this module, which is the point — the failure that made this rule was +a field that existed in the proto and in nobody's encoder. diff --git a/workcontext/cache_partition.go b/workcontext/cache_partition.go index f125f87..91e6929 100644 --- a/workcontext/cache_partition.go +++ b/workcontext/cache_partition.go @@ -1,80 +1,43 @@ package workcontext import ( - "context" + "bytes" "crypto/sha256" "encoding/base64" + "encoding/binary" "encoding/hex" - "encoding/json" "fmt" + "slices" "strconv" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "google.golang.org/protobuf/proto" ) -// cachePartitionViewDomain separates the view digest from every other SHA-256 -// a Codefly component computes, so no other structure can be made to collide -// with it. -const cachePartitionViewDomain = "codefly.work-context.cache-partition.view.v1\n" +// cachePartitionViewDomain separates the view digest from every other SHA-256 a +// component computes, so no other structure can be made to collide with it. +const cachePartitionViewDomain = "codefly.work-context.cache-partition.view.v2\n" // cachePartitionViewerDomain does the same for the viewer digest, so a subject // can never be made to spell a view. -const cachePartitionViewerDomain = "codefly.work-context.cache-partition.viewer.v1\n" +const cachePartitionViewerDomain = "codefly.work-context.cache-partition.viewer.v2\n" -// VerifiedWorkContext is a Work Context whose signature, lifetime and -// expectations a verifier has checked. Only VerifyWorkContext on a verifier -// constructs one, so code holding it cannot be holding a hand-built protobuf or -// a token that was parsed but never verified. Its zero value is not verified -// and every consumer rejects it. -type VerifiedWorkContext struct { - claims *basev0.WorkContextV1 -} - -// Claims returns a copy of the verified claims. Mutating the copy changes -// nothing this value derives. -func (v VerifiedWorkContext) Claims() *basev0.WorkContextV1 { - return cloneContext(v.claims) -} - -// VerifyWorkContext is Verify, returning a value that can only have come from -// verification. Use it wherever the result feeds a trust-sensitive derivation -// such as DeriveCachePartition. -func (v *WorkContextVerifier) VerifyWorkContext( - token WorkContextToken, - expected WorkContextExpectations, -) (VerifiedWorkContext, error) { - claims, err := v.Verify(token, expected) - if err != nil { - return VerifiedWorkContext{}, err - } - return VerifiedWorkContext{claims: claims}, nil -} - -// VerifyWorkContext is Verify, returning a value that can only have come from -// verification. Use it wherever the result feeds a trust-sensitive derivation -// such as DeriveCachePartition. -func (v *WorkContextJWKSVerifier) VerifyWorkContext( - ctx context.Context, - token WorkContextToken, - expected WorkContextExpectations, -) (VerifiedWorkContext, error) { - claims, err := v.Verify(ctx, token, expected) - if err != nil { - return VerifiedWorkContext{}, err - } - return VerifiedWorkContext{claims: claims}, nil -} - -// CachePartition is the identity partition a cache stack scopes its entries -// to. It is plain data on purpose: the cache takes it as an opaque value built -// from these two fields and never learns what a Work Context is. +// CachePartition is the identity partition a cache stack scopes its entries to. +// It is plain data on purpose: the cache takes it as an opaque value built from +// these two fields and never learns what a Work Context is. type CachePartition struct { - // Key is stable for one tenant (and, with ByAuthorizationView, one - // effective authorization view). It contains only base64url, hex and ':'. + // Key is stable for one tenant on one installation revision (and, with + // ByAuthorizationView, one effective authorization view). It contains only + // base64url, hex and ':'. Key string + // WriteAround asks the stack to bypass cached reads and writes for this - // call. It is always false today: the approval-grant hop that should set it - // does not exist in WorkContextV1 yet (codefly-dev/core#658). + // call. It is true for a capability carrying an approval grant hop: that + // hop is the one audited exception to the attenuation rule, so the answer + // was computed under authority nobody else holds — caching it would serve + // an approved call's result to callers who were never approved, and reading + // from the cache would answer the approved call from an unapproved + // computation. WriteAround bool } @@ -94,9 +57,9 @@ type cachePartitionOptions struct { // viewer: a service that authorizes per subject — per-subject resource grants, // or a row predicate over the caller's identity — gives every caller with the // same scopes the same partition, so one caller's entries are served to -// another. Every reader of a wiki holds documents:[read], and a revision is a -// number they share. Use ByViewer for anything whose content varies by who is -// asking. +// another. Every reader of a collection holds the same read scope, and a +// revision is a number they share. Use ByViewer for anything whose content +// varies by who is asking. func ByAuthorizationView() CachePartitionOption { return func(options *cachePartitionOptions) { options.byAuthorizationView = true @@ -105,8 +68,8 @@ func ByAuthorizationView() CachePartitionOption { // ByViewer partitions by the identity authorization is evaluated as, on top of // the authorization view: the key adds a digest of the effective actor — the -// last actor in the chain, or the owner principal for a direct owner call, the -// same identity RequireWorkContextScope reads scopes from. +// current actor's hop, or the owner principal for a direct owner call, which is +// the same identity core's Verified.Actor reports. // // This is the option for a result that varies by who is asking, which is any // result a service computes by authorizing the caller rather than by reading @@ -120,18 +83,39 @@ func ByViewer() CachePartitionOption { } } -// DeriveCachePartition computes the cache partition for a verified Work -// Context. The key always carries the tenant. It never carries the session, -// task, nonce or audience: those identify an execution, not an authorization, -// so a child session or an audience exchange with the same effective view -// shares its parent's partition instead of fragmenting the cache per hop. +// DeriveCachePartition computes the cache partition for a verified capability. +// The key always carries the tenant and the sealed installation at its sealed +// revision. It never carries the session, task, nonce or audience: those +// identify an execution, not an authorization, so a child session or an +// audience exchange with the same effective view shares its parent's partition +// instead of fragmenting the cache per hop. +// +// It takes core's *Verified and nothing else, so there is no way to derive a +// partition from a capability nobody verified. The installation revision is in +// the key, not optional, and that is the whole point of it being there: an +// answer computed while the host held revision N was computed under the +// authority of revision N, and nothing about it survives the host moving to +// N+1. A revision bump therefore lands every caller in a fresh partition, +// which is a cache miss by design and never a stale hit. The installation id +// travels with the revision because revision numbers are per-installation and +// two installations share their small integers. func DeriveCachePartition( - verified VerifiedWorkContext, + verified *Verified, options ...CachePartitionOption, ) (CachePartition, error) { - claims := verified.claims - if claims == nil { - return CachePartition{}, fmt.Errorf("%w: cache partition requires a verified Work Context", ErrWorkContextInvalid) + if verified == nil || verified.Context() == nil { + return CachePartition{}, fmt.Errorf("%w: a cache partition requires a verified Work Context", ErrInvalid) + } + claims := verified.Context() + seal := claims.GetSeal() + if seal.GetInstallationId() == "" || seal.GetInstallationRevision() == 0 { + // Belt and braces, and ErrInvalid because ErrUnsealed no longer exists: + // the seal and its fields are schema-required now, so a *Verified + // cannot carry a partial one — core refused it before anyone could + // verify it. This stays as a guard against a hand-built Verified + // arriving from a future core, and it is ONE sentinel because there is + // now one branch for every structural seal defect. + return CachePartition{}, fmt.Errorf("%w: a cache partition requires the sealed installation", ErrInvalid) } var settings cachePartitionOptions for _, option := range options { @@ -139,81 +123,120 @@ func DeriveCachePartition( option(&settings) } } - // Tenant IDs are free-form, so the key encodes rather than embeds them: no - // tenant can contain the delimiter and spell another tenant's view key. - key := "wc1:t:" + base64.RawURLEncoding.EncodeToString([]byte(claims.TenantId)) + // Tenant and installation IDs are free-form, so the key encodes rather than + // embeds them: no tenant or installation can contain the delimiter and + // spell another one's view key. + // + // The "wc3" prefix is not decoration. A shared cache that outlives the + // deploy — Redis, a warehouse table — still holds keys computed under an + // earlier scheme, and those keys would otherwise be reachable by a process + // that now partitions correctly. Changing the prefix orphans them instead. + key := "wc3:t:" + base64.RawURLEncoding.EncodeToString([]byte(claims.GetTenantId())) + + ":i:" + base64.RawURLEncoding.EncodeToString([]byte(seal.GetInstallationId())) + + ":r:" + strconv.FormatUint(seal.GetInstallationRevision(), 10) if settings.byAuthorizationView || settings.byViewer { - digest, err := authorizationViewDigest(claims) + digest, err := authorizationViewDigest(verified) if err != nil { return CachePartition{}, err } key += ":v:" + digest } if settings.byViewer { - digest, err := viewerDigest(claims) - if err != nil { - return CachePartition{}, err - } - key += ":s:" + digest + key += ":s:" + viewerDigest(verified) } - return CachePartition{Key: key}, nil + return CachePartition{Key: key, WriteAround: claims.GetGrantHop() != nil}, nil } -// viewerDigest hashes the effective actor — the last actor in the chain, or the -// owner principal for a direct owner call, chosen exactly as -// authorizationViewDigest chooses effective scopes so the two never disagree -// about who the caller is. The kind travels with the id because the two -// namespaces are independent: a user and an agent may share an id string -// without being the same caller. -func viewerDigest(claims *basev0.WorkContextV1) (string, error) { - // The owner carries no kind at this module's core pin, so an owner-derived - // viewer is marked instead of left blank: without it, an owner whose id is - // "x" and a delegated agent whose id is "x" would digest identically, and - // the two id namespaces are independent. codefly-dev/sdk-go#41 replaces the - // marker with the real owner_principal_kind when it adopts core v0.5.10. - viewer := struct { - PrincipalID string `json:"principal_id"` - PrincipalKind string `json:"principal_kind"` - }{ - PrincipalID: claims.GetOwnerPrincipalId(), - PrincipalKind: "owner", +// viewerDigest hashes the effective actor. The kind travels with the id because +// the two namespaces are independent: an owner whose id is "x" and a delegated +// agent whose id is "x" are different callers. The agent identity and the +// organization travel with them for the same reason — authorization is +// evaluated per organization, so two organizations' callers must never share a +// partition even when one principal acts in both. +func viewerDigest(verified *Verified) string { + claims := verified.Context() + principalID := claims.GetOwnerPrincipalId() + kind := claims.GetOwnerPrincipalKind() + agentID := claims.GetOwnerAgentId() + organization := claims.GetOrganizationId() + if actor := verified.Actor(); actor != nil { + principalID = actor.GetPrincipalId() + kind = actor.GetPrincipalKind() + agentID = actor.GetAgentId() + if actor.GetOrganizationId() != "" { + organization = actor.GetOrganizationId() + } } - if actors := claims.GetActorChain(); len(actors) > 0 { - last := actors[len(actors)-1] - viewer.PrincipalID = last.GetPrincipalId() - viewer.PrincipalKind = last.GetPrincipalKind() + preimage := []byte(cachePartitionViewerDomain) + for _, field := range []string{principalID, kind, agentID, organization} { + preimage = appendField(preimage, []byte(field)) } - encoded, err := json.Marshal(viewer) + sum := sha256.Sum256(preimage) + return hex.EncodeToString(sum[:]) +} + +// authorizationViewDigest hashes the effective scopes — core's +// Verified.EffectiveScopes, so this module never decides for itself which hop a +// caller's authority comes from — together with the authorization revision. +// +// The preimage is length-prefixed binary rather than a text encoding: every +// field is framed by its own length, so no scope can contain a delimiter and +// spell another view. The scopes are canonicalized first, so the order a minter +// was handed them in never changes the digest. +func authorizationViewDigest(verified *Verified) (string, error) { + scopes, err := canonicalScopes(verified.EffectiveScopes()) if err != nil { - return "", fmt.Errorf("%w: encode viewer: %v", ErrWorkContextInvalid, err) + return "", err + } + preimage := binary.BigEndian.AppendUint64( + []byte(cachePartitionViewDomain), verified.Context().GetAuthorizationRevision(), + ) + preimage = binary.BigEndian.AppendUint32(preimage, uint32(len(scopes))) + for _, scope := range scopes { + preimage = appendField(preimage, scope) } - sum := sha256.Sum256(append([]byte(cachePartitionViewerDomain), encoded...)) + sum := sha256.Sum256(preimage) return hex.EncodeToString(sum[:]), nil } -// authorizationViewDigest hashes the effective scopes — the final actor's -// granted scopes, or the authority scopes for a direct owner call, exactly as -// RequireWorkContextScope reads them — together with the authorization -// revision. The scopes are canonicalized first so the order a signer was -// handed them in never changes the digest. -func authorizationViewDigest(claims *basev0.WorkContextV1) (string, error) { - effective := claims.GetAuthorityScopes() - if actors := claims.GetActorChain(); len(actors) > 0 { - effective = actors[len(actors)-1].GetGrantedScopes() - } - scopes := cloneScopes(effective) - canonicalizeScopes(scopes) - view := struct { - AuthorizationRevision string `json:"authorization_revision"` - Scopes []workContextScope `json:"scopes"` - }{ - AuthorizationRevision: strconv.FormatUint(claims.AuthorizationRevision, 10), - Scopes: payloadScopes(scopes), - } - encoded, err := json.Marshal(view) - if err != nil { - return "", fmt.Errorf("%w: encode authorization view: %v", ErrWorkContextInvalid, err) +// canonicalScopes encodes each scope deterministically and sorts the +// encodings, which puts a set of scopes in one order without this module +// defining what "first" means for a scope. Each scope's own repeated fields are +// sorted before encoding, for the same reason. +func canonicalScopes(scopes []*basev0.WorkScopeV1) ([][]byte, error) { + encoded := make([][]byte, 0, len(scopes)) + for _, scope := range scopes { + if scope == nil { + continue + } + clone, ok := proto.Clone(scope).(*basev0.WorkScopeV1) + if !ok { + return nil, fmt.Errorf("%w: scope clone is not a scope", ErrInvalid) + } + slices.Sort(clone.Actions) + clone.Actions = slices.Compact(clone.Actions) + slices.Sort(clone.ResourceIds) + clone.ResourceIds = slices.Compact(clone.ResourceIds) + raw, err := proto.MarshalOptions{Deterministic: true}.Marshal(clone) + if err != nil { + return nil, fmt.Errorf("%w: encode scope: %v", ErrInvalid, err) + } + encoded = append(encoded, raw) } - sum := sha256.Sum256(append([]byte(cachePartitionViewDomain), encoded...)) - return hex.EncodeToString(sum[:]), nil + slices.SortFunc(encoded, bytes.Compare) + // Identical scopes collapse. Two equal scopes encode to equal bytes, so + // without this a caller holding the same scope twice — which an + // attenuation chain produces readily — lands in a different partition from + // one holding it once, for the same authority. That is cache fragmentation + // rather than a wrong answer, but it is silent, and the digest is supposed + // to be a function of the authority rather than of how it was assembled. + encoded = slices.CompactFunc(encoded, bytes.Equal) + return encoded, nil +} + +// appendField frames one value by its length, so a digest preimage cannot be +// read two ways. +func appendField(preimage []byte, field []byte) []byte { + preimage = binary.BigEndian.AppendUint32(preimage, uint32(len(field))) + return append(preimage, field...) } diff --git a/workcontext/cache_partition_test.go b/workcontext/cache_partition_test.go index 6b0b576..7bcbd85 100644 --- a/workcontext/cache_partition_test.go +++ b/workcontext/cache_partition_test.go @@ -1,78 +1,153 @@ package workcontext import ( - "crypto/ed25519" + "context" + "encoding/base64" "fmt" "math/rand/v2" - "net/http" - "net/http/httptest" - "slices" + "strconv" "strings" "testing" "time" - "github.com/stretchr/testify/require" - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + corework "github.com/codefly-dev/core/workcontext" + "github.com/codefly-dev/core/workcontext/conformance" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" ) -const partitionPropertyIterations = 200 +const partitionPropertyIterations = 50 -// partitionWorld is one real signer and the verifier trusting it. Every token -// a property test derives from goes through both, so the partition is only -// ever computed from what verification produced. -type partitionWorld struct { - t *testing.T - signer *WorkContextSigner - verifier *WorkContextVerifier - nonces int +// verified mints a capability and verifies it, because a partition is only +// ever derived from what verification produced. +func (a *authority) verified(t *testing.T, input mintInput) *Verified { + t.Helper() + verified, err := a.verifier(t).Verify(context.Background(), a.start(t, input)) + require.NoError(t, err) + return verified } -func newPartitionWorld(t *testing.T) *partitionWorld { +func partition(t *testing.T, verified *Verified, options ...CachePartitionOption) CachePartition { t.Helper() - publicKey, privateKey := workContextTestKeys() - world := &partitionWorld{t: t} - signer, err := NewWorkContextSigner(WorkContextSignerOptions{ - Issuer: "https://accounts.codefly.dev/work-context", KeyID: "partition-key", - PrivateKey: privateKey, Now: func() time.Time { return workContextTestTime }, - Nonce: func() (string, error) { - world.nonces++ - return fmt.Sprintf("nonce-%d", world.nonces), nil - }, - }) + derived, err := DeriveCachePartition(verified, options...) require.NoError(t, err) - verifier, err := NewWorkContextVerifier(WorkContextVerifierOptions{ - PublicKeys: map[string]ed25519.PublicKey{"partition-key": publicKey}, - Now: func() time.Time { return workContextTestTime }, - }) + return derived +} + +// partitionKeyPrefix is the tenant-and-installation half of every key. The +// installation and its revision are always present, so a test that spells the +// prefix by hand spells all three. +func partitionKeyPrefix(tenant string, revision uint64) string { + return "wc3:t:" + base64.RawURLEncoding.EncodeToString([]byte(tenant)) + + ":i:" + base64.RawURLEncoding.EncodeToString([]byte(testInstallation)) + + ":r:" + strconv.FormatUint(revision, 10) +} + +// A child session with the same effective view shares its parent's partition. +// A session, task or nonce identifies an execution rather than an +// authorization, so putting one in the key would fragment the cache per hop and +// buy nothing: the two calls may read the same entries because they may read +// the same data. +func TestCachePartitionIsStableAcrossHopsWithTheSameView(t *testing.T) { + a := newAuthority(t) + scopes := []*basev0.WorkScopeV1{scope("document", "read", "write")} + owner := a.verified(t, mintInput{scopes: scopes}) + delegated, err := a.verifier(t).Verify( + context.Background(), a.child(t, a.start(t, mintInput{scopes: scopes}), "actor-1", scopes), + ) require.NoError(t, err) - world.signer, world.verifier = signer, verifier - return world + + require.NotEqual(t, owner.Context().GetSessionId(), delegated.Context().GetSessionId()) + require.Equal(t, + partition(t, owner, ByAuthorizationView()).Key, + partition(t, delegated, ByAuthorizationView()).Key, + "the same authority through a delegation hop is the same authorization view", + ) } -func (w *partitionWorld) verify(token WorkContextToken) VerifiedWorkContext { - w.t.Helper() - verified, err := w.verifier.VerifyWorkContext(token, WorkContextExpectations{}) - require.NoError(w.t, err) - return verified +// Tenant, installation revision and effective view each separate partitions on +// their own, and the plain partition carries the first two whether or not a +// caller asks for a view. +func TestCachePartitionSeparatesTenantRevisionAndView(t *testing.T) { + a := newAuthority(t) + read := []*basev0.WorkScopeV1{scope("document", "read")} + write := []*basev0.WorkScopeV1{scope("document", "read", "write")} + + base := a.verified(t, mintInput{scopes: read}) + require.Equal(t, partitionKeyPrefix(testTenant, 3), partition(t, base).Key) + + other := a.verified(t, mintInput{tenant: "tenant-2", scopes: read}) + require.NotEqual(t, partition(t, base).Key, partition(t, other).Key) + + wider := a.verified(t, mintInput{scopes: write}) + require.Equal(t, partition(t, base).Key, partition(t, wider).Key, + "without ByAuthorizationView the key carries the tenant and installation only") + require.NotEqual(t, + partition(t, base, ByAuthorizationView()).Key, + partition(t, wider, ByAuthorizationView()).Key, + "a wider scope set is a different authorization view", + ) } -func (w *partitionWorld) partitions(token WorkContextToken) (tenant, view CachePartition) { - w.t.Helper() - verified := w.verify(token) - tenant, err := DeriveCachePartition(verified) - require.NoError(w.t, err) - view, err = DeriveCachePartition(verified, ByAuthorizationView()) - require.NoError(w.t, err) - require.False(w.t, tenant.WriteAround) - require.False(w.t, view.WriteAround) - return tenant, view +// The installation revision is in every key, and that is the whole point of it +// being there: an answer computed while the host held revision N was computed +// under the authority of revision N, and nothing about it survives the host +// moving to N+1. +func TestCachePartitionNeverSpansAnInstallationRevision(t *testing.T) { + a := newAuthority(t) + before := partition(t, a.verified(t, mintInput{}), ByViewer()) + + moved := testSeal + moved.InstallationRevision++ + require.NoError(t, a.seals.Put(testPrincipal, moved)) + + after := partition(t, a.verified(t, mintInput{}), ByViewer()) + require.NotEqual(t, before.Key, after.Key) + require.True(t, strings.HasPrefix(after.Key, partitionKeyPrefix(testTenant, moved.InstallationRevision))) +} + +// A tenant is free-form, so the key encodes it rather than embedding it. A +// tenant that spells the delimiters of another tenant's key must not produce +// that key. +func TestCachePartitionTenantEncodingCannotSpellAnotherView(t *testing.T) { + a := newAuthority(t) + honest := a.verified(t, mintInput{tenant: "tenant-1"}) + forged := a.verified(t, mintInput{tenant: "tenant-1:i:" + testInstallation + ":r:3"}) + require.NotEqual(t, partition(t, honest).Key, partition(t, forged).Key) + require.NotContains(t, partition(t, forged).Key, ":i:"+testInstallation+":r:3:i:") +} + +// A partition is derived from a verified capability or not at all. There is no +// accessor that produces one from claims nobody verified, because a caller +// holding one would eventually authorize from it. +func TestCachePartitionRequiresAVerifiedCapability(t *testing.T) { + _, err := DeriveCachePartition(nil) + require.ErrorIs(t, err, ErrInvalid) + + var absent *Verified + _, err = DeriveCachePartition(absent) + require.ErrorIs(t, err, ErrInvalid) +} + +// The order a minter was handed its scopes in must not change the view. The +// digest canonicalizes, so the same authority spelled in any order — with +// duplicate actions and resource ids — is one partition. +func TestAuthorizationViewDigestCanonicalizesScopeOrder(t *testing.T) { + a := newAuthority(t) + random := rand.New(rand.NewPCG(1, 2)) + for iteration := range partitionPropertyIterations { + scopes := randomScopes(random) + canonical := partition(t, a.verified(t, mintInput{scopes: scopes}), ByAuthorizationView()).Key + shuffled := partition(t, a.verified(t, mintInput{scopes: shuffledScopes(random, scopes)}), ByAuthorizationView()).Key + require.Equal(t, canonical, shuffled, "iteration %d: the same authority in another order", iteration) + } } // randomScopes returns a valid, non-empty scope set in a random order with -// duplicated entries, so the signer's canonicalization is always exercised. +// duplicated entries, so canonicalization is always exercised. func randomScopes(random *rand.Rand) []*basev0.WorkScopeV1 { - kinds := []string{"repository", "evidence", "document", "profile", "billing"} + kinds := []string{"repository", "record", "document", "profile", "account"} random.Shuffle(len(kinds), func(i, j int) { kinds[i], kinds[j] = kinds[j], kinds[i] }) scopes := make([]*basev0.WorkScopeV1, 0, len(kinds)) for _, kind := range kinds[:1+random.IntN(len(kinds))] { @@ -93,384 +168,196 @@ func randomScopes(random *rand.Rand) []*basev0.WorkScopeV1 { // shuffledScopes is the same authority in a different order. func shuffledScopes(random *rand.Rand, scopes []*basev0.WorkScopeV1) []*basev0.WorkScopeV1 { - out := cloneScopes(scopes) - random.Shuffle(len(out), func(i, j int) { out[i], out[j] = out[j], out[i] }) - for _, scope := range out { - random.Shuffle(len(scope.Actions), func(i, j int) { - scope.Actions[i], scope.Actions[j] = scope.Actions[j], scope.Actions[i] - }) - random.Shuffle(len(scope.ResourceIds), func(i, j int) { - scope.ResourceIds[i], scope.ResourceIds[j] = scope.ResourceIds[j], scope.ResourceIds[i] - }) - } - return out -} - -// narrowedScopes strictly attenuates a canonical scope set: it drops a kind -// when there are several, else an action, else pins a wildcard to one ID or -// drops one explicit ID. ok is false when nothing can be narrowed. -func narrowedScopes(scopes []*basev0.WorkScopeV1) (out []*basev0.WorkScopeV1, ok bool) { - out = cloneScopes(scopes) - canonicalizeScopes(out) - if len(out) > 1 { - return out[1:], true - } - scope := out[0] - switch { - case len(scope.Actions) > 1: - scope.Actions = scope.Actions[1:] - case len(scope.ResourceIds) == 0: - scope.ResourceIds = []string{"pinned"} - case len(scope.ResourceIds) > 1: - scope.ResourceIds = scope.ResourceIds[1:] - default: - return nil, false - } - return out, true -} - -func partitionStartInput(random *rand.Rand, tenant string, revision uint64, scopes []*basev0.WorkScopeV1) StartTaskInput { - return StartTaskInput{ - Audience: fmt.Sprintf("service-%d", random.IntN(1000)), - TenantID: tenant, - OwnerPrincipalID: fmt.Sprintf("principal-%d", random.IntN(1000)), - TaskID: fmt.Sprintf("task-%d", random.Uint64()), - SessionID: fmt.Sprintf("session-%d", random.Uint64()), - AuthorizationRevision: revision, - AuthorityScopes: scopes, - } -} - -func TestDeriveCachePartitionIsStableAcrossHopsWithTheSameView(t *testing.T) { - world := newPartitionWorld(t) - random := rand.New(rand.NewPCG(39, 658)) - for iteration := range partitionPropertyIterations { - tenant := fmt.Sprintf("tenant-%d", random.IntN(50)) - revision := random.Uint64() - scopes := randomScopes(random) - root, _, err := world.signer.StartTask(partitionStartInput(random, tenant, revision, scopes)) - require.NoError(t, err) - rootTenant, rootView := world.partitions(root) - - // A different task, session, owner, audience and nonce under the same - // tenant, revision and authority — handed to the signer in another - // order — is the same view. - other, _, err := world.signer.StartTask( - partitionStartInput(random, tenant, revision, shuffledScopes(random, scopes)), - ) - require.NoError(t, err) - otherTenant, otherView := world.partitions(other) - require.Equal(t, rootTenant, otherTenant, "iteration %d", iteration) - require.Equal(t, rootView, otherView, "iteration %d", iteration) - - // A child session whose actor keeps the whole effective authority. - child, _, err := world.signer.StartChildSession(root, StartChildSessionInput{ - SessionID: fmt.Sprintf("child-%d", iteration), - Audience: "service-child", - Actor: &basev0.WorkActorV1{ - PrincipalId: "agent", PrincipalKind: "agent", DelegationId: "delegation", - GrantedScopes: shuffledScopes(random, scopes), - }, - }) - require.NoError(t, err) - childTenant, childView := world.partitions(child) - require.Equal(t, rootTenant, childTenant, "iteration %d", iteration) - require.Equal(t, rootView, childView, "iteration %d", iteration) - - // Audience exchanges, with no attenuation and with an attenuation equal - // to the effective scopes, from the child's actor-scoped view. - for _, attenuated := range [][]*basev0.WorkScopeV1{nil, shuffledScopes(random, scopes)} { - exchanged, _, err := world.signer.ExchangeWorkContextAudience(child, ExchangeWorkContextAudienceInput{ - Audience: "service-next", AttenuatedScopes: attenuated, - }) - require.NoError(t, err) - exchangedTenant, exchangedView := world.partitions(exchanged) - require.Equal(t, rootTenant, exchangedTenant, "iteration %d", iteration) - require.Equal(t, rootView, exchangedView, "iteration %d", iteration) - } - } -} - -func TestDeriveCachePartitionSeparatesTenantViewAndRevision(t *testing.T) { - world := newPartitionWorld(t) - random := rand.New(rand.NewPCG(7, 5)) - for iteration := range partitionPropertyIterations { - tenant := fmt.Sprintf("tenant-%d", iteration) - revision := random.Uint64() - scopes := randomScopes(random) - base, _, err := world.signer.StartTask(partitionStartInput(random, tenant, revision, scopes)) - require.NoError(t, err) - baseTenant, baseView := world.partitions(base) - - otherTenant, _, err := world.signer.StartTask( - partitionStartInput(random, tenant+"-other", revision, scopes), - ) - require.NoError(t, err) - tenantPartition, viewPartition := world.partitions(otherTenant) - require.NotEqual(t, baseTenant.Key, tenantPartition.Key, "iteration %d", iteration) - require.NotEqual(t, baseView.Key, viewPartition.Key, "iteration %d", iteration) - - otherRevision, _, err := world.signer.StartTask( - partitionStartInput(random, tenant, revision+1, scopes), - ) - require.NoError(t, err) - tenantPartition, viewPartition = world.partitions(otherRevision) - require.Equal(t, baseTenant.Key, tenantPartition.Key, "iteration %d", iteration) - require.NotEqual(t, baseView.Key, viewPartition.Key, "iteration %d", iteration) - - narrowed, ok := narrowedScopes(scopes) + out := make([]*basev0.WorkScopeV1, 0, len(scopes)) + for _, source := range scopes { + clone, ok := proto.Clone(source).(*basev0.WorkScopeV1) if !ok { continue } - // A narrower view reached every way the protocol allows: an audience - // exchange that attenuates, and a child actor granted less. - exchanged, _, err := world.signer.ExchangeWorkContextAudience(base, ExchangeWorkContextAudienceInput{ - Audience: "service-next", AttenuatedScopes: narrowed, + random.Shuffle(len(clone.Actions), func(i, j int) { + clone.Actions[i], clone.Actions[j] = clone.Actions[j], clone.Actions[i] }) - require.NoError(t, err) - tenantPartition, viewPartition = world.partitions(exchanged) - require.Equal(t, baseTenant.Key, tenantPartition.Key, "iteration %d", iteration) - require.NotEqual(t, baseView.Key, viewPartition.Key, "iteration %d", iteration) - - child, _, err := world.signer.StartChildSession(base, StartChildSessionInput{ - SessionID: "child", - Actor: &basev0.WorkActorV1{ - PrincipalId: "agent", PrincipalKind: "agent", DelegationId: "delegation", - GrantedScopes: narrowed, - }, + random.Shuffle(len(clone.ResourceIds), func(i, j int) { + clone.ResourceIds[i], clone.ResourceIds[j] = clone.ResourceIds[j], clone.ResourceIds[i] }) - require.NoError(t, err) - _, childView := world.partitions(child) - require.Equal(t, viewPartition.Key, childView.Key, "iteration %d", iteration) + out = append(out, clone) } + random.Shuffle(len(out), func(i, j int) { out[i], out[j] = out[j], out[i] }) + return out } -func TestDeriveCachePartitionNeverCarriesExecutionIdentity(t *testing.T) { - world := newPartitionWorld(t) - input := workContextTestInput() - input.TaskID = "task-must-not-leak" - input.SessionID = "session-must-not-leak" - input.Audience = "audience-must-not-leak" - token, _, err := world.signer.StartTask(input) - require.NoError(t, err) - tenant, view := world.partitions(token) - for _, partition := range []CachePartition{tenant, view} { - for _, secret := range []string{"must-not-leak", "nonce-", "principal-antoine"} { - require.NotContains(t, partition.Key, secret) - } - require.True(t, strings.HasPrefix(partition.Key, tenant.Key)) - } - require.Equal(t, "wc1:t:dGVuYW50LWNvZGVmbHk", tenant.Key) - require.Regexp(t, `^wc1:t:dGVuYW50LWNvZGVmbHk:v:[0-9a-f]{64}$`, view.Key) -} +// Two callers holding the same scopes are not the same viewer. ByViewer is for +// a result that varies by who is asking, which is any result a service computes +// by authorizing the caller rather than by reading their scopes. +func TestCachePartitionByViewerSeparatesSubjectsSharingAView(t *testing.T) { + a := newAuthority(t) + scopes := []*basev0.WorkScopeV1{scope("document", "read")} + parent := a.start(t, mintInput{scopes: scopes}) -func TestDeriveCachePartitionTenantEncodingCannotSpellAnotherView(t *testing.T) { - world := newPartitionWorld(t) - random := rand.New(rand.NewPCG(1, 2)) - scopes := randomScopes(random) - victim, _, err := world.signer.StartTask(partitionStartInput(random, "tenant-a", 1, scopes)) + first, err := a.verifier(t).Verify(context.Background(), a.child(t, parent, "actor-1", scopes)) require.NoError(t, err) - _, victimView := world.partitions(victim) - - // A tenant ID made of the victim's view key suffix must not produce the - // victim's key under tenant-only partitioning. - suffix := strings.TrimPrefix(victimView.Key, "wc1:t:") - forger, _, err := world.signer.StartTask(partitionStartInput(random, "tenant-a:v:"+suffix, 1, scopes)) + second, err := a.verifier(t).Verify(context.Background(), a.child(t, parent, "actor-2", scopes)) require.NoError(t, err) - forgerTenant, _ := world.partitions(forger) - require.NotEqual(t, victimView.Key, forgerTenant.Key) -} -func TestDeriveCachePartitionAcceptsOnlyVerifiedContexts(t *testing.T) { - _, err := DeriveCachePartition(VerifiedWorkContext{}, ByAuthorizationView()) - require.ErrorIs(t, err, ErrWorkContextInvalid) + require.Equal(t, + partition(t, first, ByAuthorizationView()).Key, + partition(t, second, ByAuthorizationView()).Key, + "the two callers hold the same scopes, so they share an authorization view", + ) + require.NotEqual(t, + partition(t, first, ByViewer()).Key, + partition(t, second, ByViewer()).Key, + "and they are still different callers, which is what ByViewer is for", + ) + require.Contains(t, partition(t, first, ByViewer()).Key, partition(t, first, ByAuthorizationView()).Key, + "ByViewer implies the authorization view rather than replacing it", + ) +} - world := newPartitionWorld(t) - token, claims, err := world.signer.StartTask(workContextTestInput()) +// The viewer is the effective actor — the current hop, or the owner when the +// owner acts directly — which is the identity core's Verified.Actor reports. +func TestCachePartitionByViewerFollowsTheEffectiveActor(t *testing.T) { + a := newAuthority(t) + scopes := []*basev0.WorkScopeV1{scope("document", "read")} + owner := a.verified(t, mintInput{scopes: scopes}) + delegated, err := a.verifier(t).Verify( + context.Background(), a.child(t, a.start(t, mintInput{scopes: scopes}), "actor-1", scopes), + ) require.NoError(t, err) + require.NotEqual(t, + partition(t, owner, ByViewer()).Key, + partition(t, delegated, ByViewer()).Key, + "an owner acting directly and an actor acting on its authority are different viewers", + ) +} - // A token that parses is still not usable: re-signing the same claims for - // another tenant with a key the verifier does not trust fails before any - // partition can be derived. - _, untrustedKey := workContextJWKSKey(9) - forger, err := NewWorkContextSigner(WorkContextSignerOptions{ - Issuer: claims.GetIssuer(), KeyID: "partition-key", PrivateKey: untrustedKey, - Now: func() time.Time { return workContextTestTime }, - }) +// A capability carrying an approval grant hop is written around. The hop is the +// one audited exception to the attenuation rule, so the answer was computed +// under authority nobody else holds: caching it would serve an approved call's +// result to callers who were never approved. +// +// The capability comes from core's own conformance kit, so the shape under test +// is the shape core mints rather than one this test invented. +func TestCachePartitionWritesAroundAnApprovalGrant(t *testing.T) { + now := time.Now() + settings := conformance.New(now) + verifier := settings.Verifier() + fixtures, err := corework.Fixtures(now) require.NoError(t, err) - forgedInput := workContextTestInput() - forgedInput.TenantID = "tenant-victim" - forged, _, err := forger.StartTask(forgedInput) - require.NoError(t, err) - parsed, err := ParseWorkContextToken(forged.Encoded()) - require.NoError(t, err) - _, err = world.verifier.VerifyWorkContext(parsed, WorkContextExpectations{}) - require.ErrorIs(t, err, ErrWorkContextInvalid) - - // Expectations still apply: a context for another audience is rejected. - _, err = world.verifier.VerifyWorkContext(token, WorkContextExpectations{Audience: "someone-else"}) - require.ErrorIs(t, err, ErrWorkContextInvalid) - // Claims hands out a copy; mutating it cannot move the partition. - verified := world.verify(token) - before, err := DeriveCachePartition(verified, ByAuthorizationView()) - require.NoError(t, err) - copied := verified.Claims() - copied.TenantId = "tenant-victim" - copied.AuthorizationRevision = 0 - copied.ActorChain = nil - after, err := DeriveCachePartition(verified, ByAuthorizationView()) - require.NoError(t, err) - require.Equal(t, before, after) - require.Equal(t, claims.GetTenantId(), verified.Claims().GetTenantId()) + var seen int + for _, fixture := range fixtures { + if fixture.Outcome != corework.OutcomeAccepted { + continue + } + if fixture.Form != corework.FormGrant && fixture.Form != corework.FormSession { + continue + } + verified, verifyErr := verifier.Verify(context.Background(), fixture.Token) + require.NoError(t, verifyErr, fixture.Name) + derived, deriveErr := DeriveCachePartition(verified) + require.NoError(t, deriveErr, fixture.Name) + require.Equal(t, fixture.Form == corework.FormGrant, derived.WriteAround, + "fixture %q (%s): a grant hop is written around and an ordinary session is not", + fixture.Name, fixture.Form) + seen++ + } + require.Equal(t, 2, seen, "the kit must have offered one session and one grant capability") } -func TestJWKSVerifierVerifyWorkContextFeedsThePartition(t *testing.T) { - publicKey, privateKey := workContextJWKSKey(1) - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write(workContextJWKSJSON(t, map[string]ed25519.PublicKey{"key-1": publicKey})) +// The viewer digest's AGENT and ORGANIZATION dimensions, each on its own. +// +// The dynamic review's mutation removed both from the preimage and +// TestCachePartitionByViewerSeparatesSubjectsSharingAView and +// TestCachePartitionByViewerFollowsTheEffectiveActor both still passed — they +// vary the principal id, so they cover the dimension that was always there and +// say nothing about the two that were added. Two tests that survive a mutation +// are two tests that were not evidence for the guarantee they were cited for. +// +// These vary one field at a time, holding everything else equal, so each +// dimension has a test that fails when it leaves the digest. +func TestCachePartitionByViewerSeparatesTheSameAgentPrincipalAtDifferentAgents(t *testing.T) { + a := newAuthority(t) + scopes := []*basev0.WorkScopeV1{scope("document", "read")} + parent := a.start(t, mintInput{scopes: scopes}) + + // One principal, one kind, one organization, two agent manifests. An agent + // is a distinct caller: two deployments of the same agent principal answer + // to different code, so an answer computed for one must not be served to + // the other. + first, err := a.verifier(t).Verify(context.Background(), a.childAs(t, parent, hopInput{ + principal: "agent-principal", kind: "agent", agent: "agent-manifest-a", scopes: scopes, })) - t.Cleanup(server.Close) - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: server.URL, Now: func() time.Time { return workContextTestTime }, - }) require.NoError(t, err) - - verified, err := verifier.VerifyWorkContext(t.Context(), workContextJWKSToken(t, "key-1", privateKey), WorkContextExpectations{}) - require.NoError(t, err) - partition, err := DeriveCachePartition(verified) + second, err := a.verifier(t).Verify(context.Background(), a.childAs(t, parent, hopInput{ + principal: "agent-principal", kind: "agent", agent: "agent-manifest-b", scopes: scopes, + })) require.NoError(t, err) - require.Equal(t, "wc1:t:dGVuYW50LWNvZGVmbHk", partition.Key) - _, err = verifier.VerifyWorkContext(t.Context(), workContextJWKSToken(t, "key-2", privateKey), WorkContextExpectations{}) - require.ErrorIs(t, err, ErrWorkContextInvalid) + require.Equal(t, + partition(t, first, ByAuthorizationView()).Key, + partition(t, second, ByAuthorizationView()).Key, + "the two hold the same scopes, so they share an authorization view", + ) + require.NotEqual(t, + partition(t, first, ByViewer()).Key, + partition(t, second, ByViewer()).Key, + "the agent identity is part of the viewer: same principal, different code", + ) } -func TestAuthorizationViewDigestCanonicalizesScopeOrder(t *testing.T) { - ordered := &basev0.WorkContextV1{ - AuthorizationRevision: 3, - AuthorityScopes: []*basev0.WorkScopeV1{ - {ResourceKind: "document", Actions: []string{"read", "write"}, ResourceIds: []string{"a", "b"}}, - {ResourceKind: "evidence", Actions: []string{"append"}}, - }, - } - shuffled := &basev0.WorkContextV1{ - AuthorizationRevision: 3, - AuthorityScopes: []*basev0.WorkScopeV1{ - {ResourceKind: "evidence", Actions: []string{"append", "append"}, ResourceIds: []string{}}, - {ResourceKind: "document", Actions: []string{"write", "read"}, ResourceIds: []string{"b", "a", "b"}}, - }, - } - first, err := authorizationViewDigest(ordered) - require.NoError(t, err) - second, err := authorizationViewDigest(shuffled) - require.NoError(t, err) - require.Equal(t, first, second) - // Digesting never reorders the caller's claims. - require.Equal(t, "evidence", shuffled.AuthorityScopes[0].ResourceKind) - require.True(t, slices.Equal([]string{"write", "read"}, shuffled.AuthorityScopes[1].Actions)) - - // With actors, only the final actor's scopes are the view. - ordered.ActorChain = []*basev0.WorkActorV1{{GrantedScopes: ordered.AuthorityScopes[1:]}} - actorView, err := authorizationViewDigest(ordered) +func TestCachePartitionByViewerSeparatesOneActorActingInTwoOrganizations(t *testing.T) { + a := newAuthority(t) + scopes := []*basev0.WorkScopeV1{scope("document", "read")} + parent := a.start(t, mintInput{scopes: scopes}) + + // One principal, two organizations. Authorization is evaluated per + // organization, so a service principal acting in two of them is two + // viewers — sharing a partition would serve one organization's answer to + // the other, which is the worst shape this digest exists to prevent. + first, err := a.verifier(t).Verify(context.Background(), a.childAs(t, parent, hopInput{ + principal: "shared-actor", organization: "organization-a", scopes: scopes, + })) require.NoError(t, err) - require.NotEqual(t, first, actorView) - - // A nil option is ignored rather than dereferenced. - _, err = DeriveCachePartition(VerifiedWorkContext{claims: ordered}, nil, ByAuthorizationView()) + second, err := a.verifier(t).Verify(context.Background(), a.childAs(t, parent, hopInput{ + principal: "shared-actor", organization: "organization-b", scopes: scopes, + })) require.NoError(t, err) -} -// viewerPartition is the partition for a token under ByViewer, verified like -// every other partition a property test derives. -func (w *partitionWorld) viewerPartition(token WorkContextToken) CachePartition { - w.t.Helper() - viewer, err := DeriveCachePartition(w.verify(token), ByViewer()) - require.NoError(w.t, err) - require.False(w.t, viewer.WriteAround) - return viewer + require.Equal(t, + partition(t, first, ByAuthorizationView()).Key, + partition(t, second, ByAuthorizationView()).Key, + ) + require.NotEqual(t, + partition(t, first, ByViewer()).Key, + partition(t, second, ByViewer()).Key, + "authorization is evaluated per organization, so the organization is part of the viewer", + ) } -// Two callers with the same tenant, the same scopes and the same authorization -// revision are still different callers. A service that authorizes per subject — -// per-subject resource grants, or a row predicate over the caller's identity — -// computes a different result for each, so they must not share a partition. -// Under ByAuthorizationView they do, which is why anything viewer-dependent -// needs ByViewer: every reader of a wiki holds documents:[read], and a revision -// is a number they share. -func TestDeriveCachePartitionByViewerSeparatesSubjectsSharingAView(t *testing.T) { - world := newPartitionWorld(t) - random := rand.New(rand.NewPCG(11, 3)) - for iteration := range partitionPropertyIterations { - tenant := fmt.Sprintf("tenant-%d", iteration) - revision := random.Uint64() - scopes := randomScopes(random) - - first := partitionStartInput(random, tenant, revision, scopes) - first.OwnerPrincipalID = "viewer-one" - second := first - second.OwnerPrincipalID = "viewer-two" - - firstToken, _, err := world.signer.StartTask(first) - require.NoError(t, err) - secondToken, _, err := world.signer.StartTask(second) - require.NoError(t, err) - - // Identical under the coarser options: same tenant, same scopes, same - // revision. That is the collision ByViewer exists to remove. - firstTenant, firstView := world.partitions(firstToken) - secondTenant, secondView := world.partitions(secondToken) - require.Equal(t, firstTenant.Key, secondTenant.Key, "iteration %d", iteration) - require.Equal(t, firstView.Key, secondView.Key, "iteration %d", iteration) - - require.NotEqual(t, world.viewerPartition(firstToken).Key, world.viewerPartition(secondToken).Key, - "two subjects sharing a view must not share a viewer partition, iteration %d", iteration) - - // Same subject, same view: still one partition, or ByViewer would make - // the cache useless rather than safe. - repeat := first - repeat.TaskID = fmt.Sprintf("task-%d", random.Uint64()) - repeat.SessionID = fmt.Sprintf("session-%d", random.Uint64()) - repeatToken, _, err := world.signer.StartTask(repeat) - require.NoError(t, err) - require.Equal(t, world.viewerPartition(firstToken).Key, world.viewerPartition(repeatToken).Key, - "iteration %d", iteration) - } -} - -// A delegated actor is the caller. Two agents acting for one owner with the -// same granted scopes are different viewers, and the owner is a third — so a -// per-subject result computed for one must not be served to the others. -func TestDeriveCachePartitionByViewerFollowsTheEffectiveActor(t *testing.T) { - world := newPartitionWorld(t) - random := rand.New(rand.NewPCG(13, 5)) - scopes := randomScopes(random) - owner, _, err := world.signer.StartTask(partitionStartInput(random, "tenant", 9, scopes)) +// And the kind, which travels with the id for the same reason: an id is unique +// within a kind, not across kinds. +func TestCachePartitionByViewerSeparatesTheSameIdAtDifferentKinds(t *testing.T) { + a := newAuthority(t) + scopes := []*basev0.WorkScopeV1{scope("document", "read")} + parent := a.start(t, mintInput{scopes: scopes}) + + // ONE field varies. The earlier version changed kind AND agent together, + // so it survived removing kind from the digest — the exact defect the + // dynamic review's mutation found in the other two viewer tests, repeated + // in their replacement. + asHuman, err := a.verifier(t).Verify(context.Background(), a.childAs(t, parent, hopInput{ + principal: "ambiguous-id", kind: "human", scopes: scopes, + })) + require.NoError(t, err) + asService, err := a.verifier(t).Verify(context.Background(), a.childAs(t, parent, hopInput{ + principal: "ambiguous-id", kind: "service", scopes: scopes, + })) require.NoError(t, err) - child := func(principal string) WorkContextToken { - token, _, err := world.signer.StartChildSession(owner, StartChildSessionInput{ - SessionID: "child-" + principal, - Actor: &basev0.WorkActorV1{ - PrincipalId: principal, PrincipalKind: "agent", DelegationId: "delegation", - GrantedScopes: scopes, - }, - }) - require.NoError(t, err) - return token - } - first, second := child("agent-one"), child("agent-two") - - // The view is identical: same scopes, same revision, same tenant. - _, ownerView := world.partitions(owner) - _, firstView := world.partitions(first) - require.Equal(t, ownerView.Key, firstView.Key) - - ownerViewer := world.viewerPartition(owner).Key - firstViewer := world.viewerPartition(first).Key - secondViewer := world.viewerPartition(second).Key - require.NotEqual(t, firstViewer, secondViewer, "two agents are two viewers") - require.NotEqual(t, ownerViewer, firstViewer, "an agent acting for an owner is not the owner") - require.NotEqual(t, ownerViewer, secondViewer) + require.NotEqual(t, + partition(t, asHuman, ByViewer()).Key, + partition(t, asService, ByViewer()).Key, + "an id is unique within a kind, not across kinds", + ) } diff --git a/workcontext/carrier.go b/workcontext/carrier.go new file mode 100644 index 0000000..94c1a72 --- /dev/null +++ b/workcontext/carrier.go @@ -0,0 +1,231 @@ +package workcontext + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + corework "github.com/codefly-dev/core/workcontext" + "google.golang.org/protobuf/proto" +) + +const ( + // HeaderName is the only HTTP carrier for a signed capability. Product code + // calls Attach instead of naming it. It is spelled in lower case because + // gRPC metadata keys must be, and an HTTP header name is case-insensitive: + // one spelling for one fact, rather than two that drift. + HeaderName = "x-codefly-work-context" + + // InstallationIDHeaderName and InstallationRevisionHeaderName carry the + // installation a capability is sealed to, beside the capability. + // + // They exist so the far end can refuse a call before it decodes a token, + // and for no other reason: the installation that governs a call is the one + // inside the signature. A receiver that finds a carrier disagreeing with + // the seal refuses the call rather than preferring either, because a header + // is caller-controlled and a seal is not. + InstallationIDHeaderName = "x-codefly-installation-id" + InstallationRevisionHeaderName = "x-codefly-installation-revision" + + // MaxTokenBytes bounds a capability this module will carry or read. It IS + // core's bound, not a number of ours that happens to agree: this module + // invented one because core declared none, which is one more rule kept in + // sync by hand. Core declares it now, so this is a re-export. + MaxTokenBytes = corework.MaxTokenSize +) + +// Attach installs a capability on an outbound request: the signed capability +// and, beside it, the installation it is sealed to. +// +// It refuses an unsealed capability outright. A credential is sealed or it is +// not a credential, and the moment to find that out is before a call is made on +// it rather than at the far end, where the refusal names an installation +// mismatch for a capability that never named an installation at all. +func Attach(request *http.Request, encoded string) error { + if request == nil { + return fmt.Errorf("%w: nil request", ErrInvalid) + } + if request.Header == nil { + request.Header = make(http.Header) + } + id, revision, err := SealedInstallation(encoded) + if err != nil { + return err + } + request.Header.Set(HeaderName, encoded) + request.Header.Set(InstallationIDHeaderName, id) + request.Header.Set(InstallationRevisionHeaderName, revision) + return nil +} + +// FromHeaders reads the capability a request carries. It returns the encoded +// token and nothing else: a token is a string until a Verifier has had it, and +// an accessor that handed back claims here would be an accessor somebody +// authorizes from. +func FromHeaders(headers http.Header) (string, error) { + if headers == nil { + return "", fmt.Errorf("%w: no headers", ErrInvalid) + } + values := headers.Values(HeaderName) + if len(values) == 0 || strings.TrimSpace(values[0]) == "" { + return "", fmt.Errorf("%w: no %s header", ErrInvalid, HeaderName) + } + if len(values) > 1 { + return "", fmt.Errorf("%w: %s appears %d times", ErrInvalid, HeaderName, len(values)) + } + encoded := values[0] + if err := checkCarriedInstallation(headers, encoded); err != nil { + return "", err + } + return encoded, nil +} + +// checkCarriedInstallation requires BOTH installation carriers, exactly once +// each, and holds them to the seal. +// +// Carrying neither used to be allowed, and that was the optional-carrier escape +// hatch surviving on HTTP after it was deleted on gRPC: gRPC requires exactly +// one of each, so "one contract for both transports" was false, and a sender +// that attached nothing skipped the seal check entirely. Attach always sets +// both, so any sender using this SDK already satisfies it; one that does not is +// a sender whose pre-check nobody can perform. +// +// Cardinality is checked before agreement, which Header.Get made impossible: it +// reads the FIRST value, so `[sealed-id, something-else]` compared equal to the +// seal and the call was accepted while carrying two contradictory +// installations. Whether a later intermediary reads the first or the second is +// not something this module can decide, so an ambiguous carrier is refused. +func checkCarriedInstallation(headers http.Header, encoded string) error { + for _, carrier := range []struct { + name string + values []string + }{ + {InstallationIDHeaderName, headers.Values(InstallationIDHeaderName)}, + {InstallationRevisionHeaderName, headers.Values(InstallationRevisionHeaderName)}, + } { + if len(carrier.values) != 1 { + return fmt.Errorf( + "%w: %s requires exactly one value and carries %d", + ErrInvalid, carrier.name, len(carrier.values), + ) + } + } + sealedID, sealedRevision, err := SealedInstallation(encoded) + if err != nil { + return err + } + if id := headers.Get(InstallationIDHeaderName); id != sealedID { + return fmt.Errorf( + "%w: %s carries %q and the capability is sealed to %q", + ErrInvalid, InstallationIDHeaderName, id, sealedID, + ) + } + if revision := headers.Get(InstallationRevisionHeaderName); revision != sealedRevision { + return fmt.Errorf( + "%w: %s carries %q and the capability is sealed to %q", + ErrInvalid, InstallationRevisionHeaderName, revision, sealedRevision, + ) + } + return nil +} + +// SealedInstallation reads the installation a capability is sealed to, for a +// transport that names it beside the capability. +// +// It is CORE'S whole structural check and not an installation check. It used to +// read just the installation id and revision, which made "Attach refuses an +// unsealed capability" true of two fields out of four; then it grew a local +// rule, which disagreed with core's fixtures about which sentinel each refusal +// earns. Now it asks corework.Inspect, so there is one answer to "is this +// sealed" for every carrier in this module — HTTP attach, HTTP read, outgoing +// gRPC metadata, incoming gRPC metadata — and that answer is core's. +// +// It reads the capability's own content without checking the signature, which +// is sound only because nothing trusts the result: the carrier it fills is a +// pre-check, and a receiver that preferred it over the sealed claim would have +// made the carrier into authority. +func SealedInstallation(encoded string) (id string, revision string, err error) { + _, seal, _, err := sealOf(encoded) + if err != nil { + return "", "", err + } + return seal.GetInstallationId(), strconv.FormatUint(seal.GetInstallationRevision(), 10), nil +} + +// readClaims asks core what a capability is, and reads the claims core has +// already decoded. There is no second decode and no rule of this module's. +// +// This is all that is left of a parser that had become a second +// implementation. It hand-parsed the envelope and then applied its own +// structural seal rule, and that rule DISAGREED with core's own fixtures: +// seal-without-installation was ErrUnsealed here and ErrInvalid there, a zero +// epoch, revision or incarnation was ErrUnsealed here and ErrInvalid there, and +// the actor chain was never read at all — so core's actor-without-epoch +// fixture, a principal nobody can revoke, was attached and carried. One +// condition with two messages is the fragmentation the one-implementation rule +// exists to end, and it had been relocated from the signature to the seal. +// +// Core's Inspect is the one answer now. It runs the size bound, the envelope, +// CheckEncoding, proto.Unmarshal, protovalidate — which is where the seal and +// every actor epoch are REQUIRED by the schema — and the structural seal +// check, and Verify runs the same decode, so an early refusal here and core's +// verification cannot disagree about what a token is. +// +// Nothing here trusts the result. Inspect checks NO signature, and core has a +// test asserting that a token re-signed with a key nobody holds passes it, so +// nil means "shaped like a sealed capability" and never "permitted". The +// carriers this fills are a pre-check; the receiver verifies. +// +// The claims are CLONED. Inspected.Context hands back core's own pointer, so a +// caller that mutated what it was given would be mutating core's value. +func readClaims(encoded string) (*Claims, error) { + inspected, err := corework.Inspect(encoded) + if err != nil { + return nil, err + } + claims, ok := proto.Clone(inspected.Context()).(*Claims) + if !ok { + return nil, fmt.Errorf("%w: inspected claims are not a WorkContextV1", ErrInvalid) + } + return claims, nil +} + +// sealOf reads the seal and the sealed operation binding of a capability whose +// structure core has approved. Both come back as core's own WIRE messages, +// already cloned by readClaims. +// +// It applies no rule and copies no field. It used to build core's LIVE Seal +// type by hand from three wire fields, which is the same conflation this +// module fixed for OperationBinding and left in place for the seal: a client +// cannot answer what the issuer holds, and a field core adds to the seal — +// image_digest is next — would have been silently zero in a value that reads +// as the issuer's. +// +// Every refusal a capability's own bytes can earn — another encoding, a bad +// envelope, a schema violation, no seal, a seal naming no installation, a zero +// epoch, revision or incarnation, a partial binding, a widening hop, an actor +// hop with no epoch — is corework.Inspect's answer, with corework's sentinel. +// TestTheSDKParsePathsAgreeWithCore drives every fixture in core's kit through +// this path and requires core's declared sentinel for each, so the agreement is +// tested rather than described. +func sealOf(encoded string) (*Claims, *SealedValues, *SealedOperationBinding, error) { + claims, err := readClaims(encoded) + if err != nil { + return nil, nil, nil, err + } + return claims, claims.GetSeal(), claims.GetOperationBinding(), nil +} + +// cloneSealedBinding copies the message, so a caller that mutates what it is +// handed cannot change what a credential reports it is bound to. +func cloneSealedBinding(bound *SealedOperationBinding) *SealedOperationBinding { + if bound == nil { + return nil + } + copied, ok := proto.Clone(bound).(*SealedOperationBinding) + if !ok { + return nil + } + return copied +} diff --git a/workcontext/carrier_test.go b/workcontext/carrier_test.go new file mode 100644 index 0000000..3adc758 --- /dev/null +++ b/workcontext/carrier_test.go @@ -0,0 +1,311 @@ +package workcontext + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + corework "github.com/codefly-dev/core/workcontext" + "github.com/stretchr/testify/require" +) + +// fixture returns one of core's conformance tokens by name, so a negative case +// here is the same token core's own verifier is held to rather than one this +// test invented. +func fixture(t *testing.T, name string) corework.Fixture { + t.Helper() + fixtures, err := corework.Fixtures(time.Now()) + require.NoError(t, err) + for _, candidate := range fixtures { + if candidate.Name == name { + return candidate + } + } + t.Fatalf("core's conformance kit has no fixture %q", name) + return corework.Fixture{} +} + +// A capability travels with the installation it is sealed to, so the far end +// can refuse the call before it decodes anything. +func TestAttachCarriesTheCapabilityAndTheSealedInstallation(t *testing.T) { + a := newAuthority(t) + token := a.start(t, mintInput{}) + request := httptest.NewRequest(http.MethodGet, "/records", nil) + require.NoError(t, Attach(request, token)) + + require.Equal(t, token, request.Header.Get(HeaderName)) + require.Equal(t, testInstallation, request.Header.Get(InstallationIDHeaderName)) + require.Equal(t, "3", request.Header.Get(InstallationRevisionHeaderName)) + + // And it comes back off the request, with the carriers held to the seal. + carried, err := FromHeaders(request.Header) + require.NoError(t, err) + require.Equal(t, token, carried) +} + +// A credential is sealed or it is not a credential. Finding that out before the +// call is made is the point: at the far end the refusal names an installation +// mismatch for a capability that named no installation at all. +// +// The sentinel is CORE'S, taken from core's own fixture rather than written +// here. This test used to assert ErrUnsealed for all of them, which was wrong +// for seal-without-installation even then — core answered ErrInvalid there, +// because the schema reaches it before the structural check — and pinning a +// sentinel of one's own is how the divergence stayed invisible. It is now +// ErrInvalid for every one of them, because the seal and the actor epoch are +// schema-required and ErrUnsealed is deleted; reading the sentinel off the +// fixture is what made this change a no-op here instead of a rewrite. +func TestAttachRefusesAnUnsealedCapability(t *testing.T) { + for _, name := range []string{"missing-seal", "seal-without-installation", "actor-without-epoch"} { + t.Run(name, func(t *testing.T) { + declared := fixture(t, name) + request := httptest.NewRequest(http.MethodGet, "/records", nil) + err := Attach(request, declared.Token) + require.ErrorIs(t, err, declared.Err, + "core declares %v for %q: %s", declared.Err, name, declared.Reason) + require.Empty(t, request.Header.Get(HeaderName), + "a refused capability must not be left on the request") + }) + } +} + +func TestAttachRefusesWhatIsNotACapability(t *testing.T) { + for name, encoded := range map[string]string{ + "empty": "", + "whitespace": " ", + "one segment": "not-a-token", + "empty payload": ".c2lnbmF0dXJl", + "empty signature": "cGF5bG9hZA.", + "payload not b64": "not!base64.c2lnbmF0dXJl", + "signature not b64": "cGF5bG9hZA.not!base64", + "payload not proto": "bm90LWEtcHJvdG8tbWVzc2FnZS1hdC1hbGw.c2lnbmF0dXJl", + "oversized": strings.Repeat("a", MaxTokenBytes+1) + ".sig", + } { + t.Run(name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/records", nil) + require.ErrorIs(t, Attach(request, encoded), ErrInvalid) + }) + } + require.ErrorIs(t, Attach(nil, "irrelevant"), ErrInvalid) +} + +// The carriers are a pre-check and never authority. A receiver that found them +// disagreeing with the seal and preferred either side would have made a +// caller-controlled header into authority. +func TestIncomingCarriersAreHeldToTheSeal(t *testing.T) { + a := newAuthority(t) + token := a.start(t, mintInput{}) + + for name, carriers := range map[string]map[string]string{ + "another installation": { + InstallationIDHeaderName: "installation-the-caller-preferred", + InstallationRevisionHeaderName: "3", + }, + "another revision": { + InstallationIDHeaderName: testInstallation, + InstallationRevisionHeaderName: "4", + }, + "id without revision": { + InstallationIDHeaderName: testInstallation, + }, + "revision without id": { + InstallationRevisionHeaderName: "3", + }, + } { + t.Run(name, func(t *testing.T) { + headers := http.Header{} + headers.Set(HeaderName, token) + for carrier, value := range carriers { + headers.Set(carrier, value) + } + _, err := FromHeaders(headers) + require.ErrorIs(t, err, ErrInvalid) + }) + } + + // Carrying NEITHER is refused too, which it was not: that was the + // optional-carrier escape hatch surviving on HTTP after it was deleted on + // gRPC, and it skipped the seal check altogether. Attach always sets both, + // so a sender using this SDK satisfies it. + bare := http.Header{} + bare.Set(HeaderName, token) + _, err := FromHeaders(bare) + require.ErrorIs(t, err, ErrInvalid) + require.ErrorContains(t, err, "requires exactly one value") + + // And what Attach produces round-trips. + attached := httptest.NewRequest(http.MethodGet, "/records", nil) + require.NoError(t, Attach(attached, token)) + carried, err := FromHeaders(attached.Header) + require.NoError(t, err) + require.Equal(t, token, carried) +} + +func TestFromHeadersRequiresExactlyOneCapability(t *testing.T) { + _, err := FromHeaders(nil) + require.ErrorIs(t, err, ErrInvalid) + + _, err = FromHeaders(http.Header{}) + require.ErrorIs(t, err, ErrInvalid) + + blank := http.Header{} + blank.Set(HeaderName, " ") + _, err = FromHeaders(blank) + require.ErrorIs(t, err, ErrInvalid) + + a := newAuthority(t) + twice := http.Header{} + twice.Add(HeaderName, a.start(t, mintInput{})) + twice.Add(HeaderName, a.start(t, mintInput{})) + _, err = FromHeaders(twice) + require.ErrorIs(t, err, ErrInvalid) + require.ErrorContains(t, err, "appears 2 times") +} + +// An operation binding carries its id, revision and incarnation or none of +// them: a capability sealed to a binding id at no revision names an authority +// nobody approved, and the field that is missing is the one an attacker would +// choose to leave out. +// +// The partial case is CORE'S fixture now. This test used to build it by +// re-signing a minted capability with core's fixture key, because core's kit +// had no such fixture and core's minter correctly refuses to mint one. Core +// added `partial-operation-binding` when asked, so the local signer is gone — +// and an `ed25519.Sign` in a test file was the gate's weakest edge, since the +// gate exempts test files. +func TestSealedOperationBindingIsWholeOrAbsent(t *testing.T) { + a := newAuthority(t) + _, _, binding, err := sealOf(a.start(t, mintInput{binding: testBinding})) + require.NoError(t, err) + require.NotNil(t, binding) + require.Equal(t, testBinding, binding.GetBindingId()) + require.EqualValues(t, 2, binding.GetRevision()) + require.EqualValues(t, 1, binding.GetIncarnation()) + + _, _, none, err := sealOf(a.start(t, mintInput{})) + require.NoError(t, err) + require.Nil(t, none, "a session that exercises no binding seals none") + + // A binding carrying an id and nothing else is refused, with core's own + // sentinel, and never reaches a request on any carrier. + declared := fixture(t, "partial-operation-binding") + _, _, _, err = sealOf(declared.Token) + require.ErrorIs(t, err, declared.Err) + + request := httptest.NewRequest(http.MethodGet, "/records", nil) + require.ErrorIs(t, Attach(request, declared.Token), declared.Err) + require.Empty(t, request.Header.Get(HeaderName)) + _, _, err = SealedInstallation(declared.Token) + require.ErrorIs(t, err, declared.Err) +} + +// Every field of the seal is required, and the check is the SAME check for +// every carrier. SealedInstallation used to read only the installation id and +// revision, which made "Attach refuses an unsealed capability" true of two +// fields out of four: a capability with no principal epoch or no build +// incarnation was attached and travelled. +// +// Each case is one of CORE'S fixtures and each sentinel is read off the +// fixture rather than written here. That is what made the schema change a +// no-op for this test: the seal became required, three fixtures changed from +// ErrUnsealed to ErrInvalid, and nothing below had to move. +func TestEverySealedFieldIsRequiredOnEveryCarrier(t *testing.T) { + for _, name := range []string{ + "missing-seal", + "seal-without-installation", + "zero-principal-epoch", + "zero-installation-revision", + "zero-build-incarnation", + } { + t.Run(name, func(t *testing.T) { + declared := fixture(t, name) + require.ErrorIs(t, declared.Err, ErrInvalid, + "every structural seal defect is one sentinel now: %s", declared.Reason) + + _, _, _, err := sealOf(declared.Token) + require.ErrorIs(t, err, declared.Err) + + // The outbound HTTP carrier. + request := httptest.NewRequest(http.MethodGet, "/records", nil) + require.ErrorIs(t, Attach(request, declared.Token), declared.Err) + require.Empty(t, request.Header.Get(HeaderName), + "a refused capability must not be left on the request") + + // The inbound HTTP carrier. Both carriers are stated, because both + // are required — and the seal check runs whether or not they are. + headers := http.Header{} + headers.Set(HeaderName, declared.Token) + headers.Set(InstallationIDHeaderName, corework.FixtureInstallation) + headers.Set(InstallationRevisionHeaderName, "1") + _, err = FromHeaders(headers) + require.ErrorIs(t, err, declared.Err) + + // And the one function both gRPC directions go through. + _, _, err = SealedInstallation(declared.Token) + require.ErrorIs(t, err, declared.Err) + }) + } +} + +// An installation carrier stated twice is ambiguous, and ambiguous is refused. +// +// Header.Get reads the FIRST value, so [sealed-id, something-else] compared +// equal to the seal and the call was accepted while carrying two contradictory +// installations. Whether a later intermediary reads the first or the second is +// not something this module can decide — gRPC already required exactly one, and +// now both transports do. +func TestDuplicateInstallationCarriersAreRefused(t *testing.T) { + a := newAuthority(t) + token := a.start(t, mintInput{}) + + for name, build := range map[string]func(http.Header){ + "id stated twice, the second disagreeing": func(headers http.Header) { + headers.Add(InstallationIDHeaderName, testInstallation) + headers.Add(InstallationIDHeaderName, "installation-the-caller-added") + headers.Set(InstallationRevisionHeaderName, "3") + }, + "id stated twice, both agreeing": func(headers http.Header) { + headers.Add(InstallationIDHeaderName, testInstallation) + headers.Add(InstallationIDHeaderName, testInstallation) + headers.Set(InstallationRevisionHeaderName, "3") + }, + "revision stated twice": func(headers http.Header) { + headers.Set(InstallationIDHeaderName, testInstallation) + headers.Add(InstallationRevisionHeaderName, "3") + headers.Add(InstallationRevisionHeaderName, "4") + }, + } { + t.Run(name, func(t *testing.T) { + headers := http.Header{} + headers.Set(HeaderName, token) + build(headers) + _, err := FromHeaders(headers) + require.ErrorIs(t, err, ErrInvalid) + require.ErrorContains(t, err, "exactly one value") + }) + } +} + +// A token in another encoding is refused as that, by name, in the one place +// this module decodes — using core's own discrimination, so an operator does +// not get two different messages for one condition. It is never reported as a +// signature problem, which is the misdiagnosis the whole rule exists to end. +func TestAForeignEncodingIsNamedAsOne(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/records", nil) + err := Attach(request, fixture(t, "foreign-encoding").Token) + require.ErrorIs(t, err, ErrNotACoreToken) + require.NotErrorIs(t, err, ErrInvalid, + "ErrNotACoreToken does not wrap ErrInvalid: the two diagnoses must not be reachable from one branch") + require.NotContains(t, err.Error(), "signature") + + // An empty payload is NOT that error, here or in core: it is a malformed + // token of no format at all. Widening "not a core token" to cover it would + // make it mean "something was wrong early", which is the vagueness it + // exists to remove. + empty := httptest.NewRequest(http.MethodGet, "/records", nil) + err = Attach(empty, ".c2lnbmF0dXJl") + require.ErrorIs(t, err, ErrInvalid) + require.NotErrorIs(t, err, ErrNotACoreToken) +} diff --git a/workcontext/codec_types_test.go b/workcontext/codec_types_test.go new file mode 100644 index 0000000..d12ac25 --- /dev/null +++ b/workcontext/codec_types_test.go @@ -0,0 +1,1067 @@ +package workcontext + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "go/types" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/tools/go/packages" +) + +// THE CODEC RULE, DECIDED BY go/types. +// +// The syntactic version answered with a SPELLING where the question was a +// KIND, and a reviewer walked six probes through the gap, each compiled and +// each decoding a real capability: +// +// func into[M proto.Message](raw []byte, m M) error // resolved to "M" +// type r6msg interface{ proto.Message } // resolved to "r6msg" +// type r6carrier = proto.Message // resolved to "r6carrier" +// reflect.ValueOf(proto.Unmarshal).Call(…) // resolved to "[]reflect#Value" +// r6use(proto.Unmarshal)(raw, c) // a closure, no call to read +// func (r6other) Handle(…) { proto.Unmarshal(…) } // allowlisted BY NAME +// +// None of those is a type name question. "Is this argument an interface or a +// type parameter", "is this codec being used as a value rather than called", +// and "is this the function my allowlist named, or another one spelled the +// same" are all questions about objects and types, and go/types answers all +// three. The allowlist is keyed on *types.Func identity now, so a second +// method named Handle on a new type is a different object and is refused. +// +// What this still does NOT reach: a hand-written wire walk with no codec at +// all. That is the irreducible residue and it is closed by review — see the +// note on allowedImports. + +// codecFunctions are the functions and methods that move a message between +// bytes and a value, by PACKAGE PATH and name. +// +// The set is closed by the import allowlist: a codec in a package neither +// module may import cannot be reached, which is why protodelim and the gRPC +// codec registry are absent here and refused there instead. Two rules, one +// each for the two halves of the question. +var codecFunctions = map[string][]string{ + "google.golang.org/protobuf/proto": { + "Marshal", "Unmarshal", "MarshalOptions", "UnmarshalOptions", + }, + "google.golang.org/protobuf/encoding/protojson": { + "Marshal", "Unmarshal", "MarshalOptions", "UnmarshalOptions", + }, + "encoding/json": { + "Marshal", "MarshalIndent", "Unmarshal", + "NewEncoder", "NewDecoder", "Encode", "Decode", + }, +} + +// envelopeDecoders are the base64 methods that OPEN an envelope, by package and +// name, resolved as objects. +// +// The syntactic rule required the receiver to be written as a selector — +// `base64.RawURLEncoding.DecodeString(…)` — so one assignment walked past it: +// +// var payloadEncoding = base64.RawURLEncoding +// func openPayload(s string) ([]byte, error) { return payloadEncoding.DecodeString(s) } +// +// in the one file allowed to reach for base64. go/types answers the method's +// OBJECT regardless of how the receiver was spelled, which is the same +// correction the codec rule needed and for the same reason: a spelling was +// standing in for a kind. +var envelopeDecodeMethods = map[string][]string{ + "encoding/base64": {"Decode", "DecodeString", "AppendDecode", "NewDecoder"}, +} + +// codecIndirectionObjects are the functions that legitimately apply a codec to +// a value whose type is not statically known, keyed on PACKAGE PATH and the +// function's own name — which with go/types identifies the object rather than +// the spelling. +// +// Four, all taking an interface by design: three in receipts, whose subject is +// a caller's own request and response messages, and the runtime's own +// configuration documents, decoded into a caller's destination. The syntactic +// gate's comment said "three" for two rounds after the fourth was added, which +// is why the count is checked rather than written: see the test below. +var codecIndirectionObjects = map[string][]string{ + // package path -> "Receiver.Function", or "Function" for a plain function. + "github.com/codefly-dev/sdk-go/receipts": { + "RequestDigest", "Record", "Interceptor.Handle", + }, + "github.com/codefly-dev/sdk-go": {"decodeDocument"}, +} + +// TestNoCodecTouchesACapabilityByType is the type-checked codec rule over both +// modules. +func TestNoCodecTouchesACapabilityByType(t *testing.T) { + var findings []string + for _, module := range loadModules(t) { + for _, loaded := range module.packages { + if loaded.TypesInfo == nil { + continue + } + for _, file := range loaded.Syntax { + path := module.relative(loaded, file) + if path == "" || strings.HasSuffix(path, "_test.go") { + continue + } + findings = append(findings, inspectCodecUses(loaded, file, path)...) + } + } + } + require.Empty(t, findings, "%s", strings.Join(findings, "\n\n")) +} + +// inspectCodecUses is the rule over one type-checked file. +func inspectCodecUses(loaded *packages.Package, file *ast.File, path string) []string { + var findings []string + findings = append(findings, inspectHandWrittenProtoMessages(loaded, file, path)...) + // THE Fun POSITION, and the identifier inside it. ast.Inspect descends into + // a SelectorExpr, so recording only call.Fun left `Marshal` — the Sel of a + // perfectly ordinary `json.Marshal(x)` — looking like a codec mentioned + // without being called. Both are recorded. + calls := map[ast.Expr]bool{} + ast.Inspect(file, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + calls[call.Fun] = true + if selector, ok := call.Fun.(*ast.SelectorExpr); ok { + calls[selector.Sel] = true + // proto.MarshalOptions{…}.Marshal: the receiver is itself a + // composite literal whose Type names the codec. + if literal, ok := selector.X.(*ast.CompositeLit); ok { + calls[literal.Type] = true + if inner, ok := literal.Type.(*ast.SelectorExpr); ok { + calls[inner.Sel] = true + } + } + if inner, ok := selector.X.(*ast.SelectorExpr); ok { + calls[inner.Sel] = true + } + } + return true + }) + + ast.Inspect(file, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + function := codecFunctionOf(loaded, call.Fun) + if function == nil { + return true + } + for _, argument := range call.Args { + kind := loaded.TypesInfo.TypeOf(argument) + if kind == nil { + continue + } + // A NAMED CAPABILITY IS ANSWERED FIRST, by its row. The + // interface walk below is transitive now, and a generated + // message's own internals must not be what decides a call that a + // row already permits. + if named := capabilityWithin(kind, map[types.Type]bool{}); named != "" { + if permittedCapabilityCodec(path, named) { + continue + } + findings = append(findings, fmt.Sprintf( + "%s applies %s to %s, which is or contains %s.\n"+ + "A Work Context message passes through a codec only where a row names it.\n"+ + "Reading one off the wire is corework.Inspect's job, and it returns the claims\n"+ + "it decoded. This catches it THROUGH a field as well: a *Claims embedded in\n"+ + "mintRequest put a whole capability on the wire through a row written for two\n"+ + "strings, and the alias lived in another file, so no syntactic rule could see it.", + path, function.FullName(), kind, named)) + continue + } + // A TYPE PARAMETER OR AN INTERFACE IS NOT A TYPE, it is a + // promise that one will turn up at the call site — which is + // exactly where a capability turned up in seven separate probes, + // the last of them wrapped in a concrete struct. + if isUnknownToTheGate(kind) { + if permittedIndirection(loaded, file, call.Pos()) { + continue + } + findings = append(findings, fmt.Sprintf( + "%s applies %s to a value of type %s, which is an interface or a type parameter.\n"+ + "A codec whose argument's type is not known statically can be handed a\n"+ + "capability at any call site, and six compiled probes did exactly that —\n"+ + "a generic `func into[M proto.Message]`, an embedded interface, an alias of\n"+ + "proto.Message, a reflect.Call, a closure and a descriptor-built message.\n"+ + "The legitimate indirections are %v, keyed on the function's own object.", + path, function.FullName(), kind, codecIndirectionObjects)) + continue + } + } + return true + }) + + // A CAPABILITY HANDED INTO ONE OF THE PERMITTED INDIRECTIONS. + // + // The indirections exist because those four functions take an interface by + // design, and the exemption was granted at the CODEC call inside them. But + // nothing looked at their CALLERS, so + // + // decodeDocument(name, payload, &basev0.WorkContextV1{}) + // + // reached a json.Unmarshal of a capability through an exemption written for + // the runtime's own configuration documents — and configuration_document.go + // decodes into the generated struct, whose json tags are snake_case, which + // IS the deleted format. The hole was named at 04e93c0 as needing go/types; + // having go/types did not close it, because the rule was still only about + // codec calls. + // + // There is no row here and there is not going to be one: these four take a + // caller's own messages, never a capability. + // BASE64 MAY ENCODE AND MAY NEVER DECODE, by object. The file allowed to + // build a cache key is the only file that may reach base64 at all, and + // decoding there plus a proto.Unmarshal is the whole of a second parser. + ast.Inspect(file, func(node ast.Node) bool { + selector, ok := node.(*ast.SelectorExpr) + if !ok { + return true + } + object, ok := loaded.TypesInfo.Uses[selector.Sel] + if !ok { + return true + } + method, ok := object.(*types.Func) + if !ok || method.Pkg() == nil { + return true + } + if !slices.Contains(envelopeDecodeMethods[method.Pkg().Path()], method.Name()) { + return true + } + findings = append(findings, fmt.Sprintf( + "%s calls %s.\n"+ + "This file may ENCODE with base64 — it builds a cache key — and may never\n"+ + "DECODE. base64 decoding plus proto.Unmarshal is the entire second parser this\n"+ + "module deleted; corework.Inspect opens the envelope and hands back the claims.\n"+ + "Resolved as an OBJECT, so assigning the encoding to a variable first does not\n"+ + "change the answer.", + path, method.FullName())) + return true + }) + + ast.Inspect(file, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + indirection := indirectionFunctionOf(loaded, call.Fun) + if indirection == "" { + return true + } + for _, argument := range call.Args { + kind := loaded.TypesInfo.TypeOf(argument) + named := capabilityWithin(kind, map[types.Type]bool{}) + if named == "" { + continue + } + findings = append(findings, fmt.Sprintf( + "%s passes %s, which is or contains %s, into %s.\n"+ + "That function is on the codec-indirection allowlist because it takes an\n"+ + "interface BY DESIGN — a caller's own request, response or configuration\n"+ + "document. The exemption is for the codec call inside it, not for handing it a\n"+ + "capability: decodeDocument json-decodes into its destination, and a\n"+ + "WorkContextV1's json tags are snake_case, which is the format this module\n"+ + "deleted. corework.Inspect reads a capability.", + path, kind, named, indirection)) + } + return true + }) + + // A CODEC USED AS A VALUE. `reflect.ValueOf(proto.Unmarshal)` and + // `r6use(proto.Unmarshal)` both reach a decode with no call for the rule + // above to read, and both were green. Nothing in either module passes a + // codec around, so the reference itself is the finding. + ast.Inspect(file, func(node ast.Node) bool { + expression, ok := node.(ast.Expr) + if !ok || calls[expression] { + return true + } + switch expression.(type) { + case *ast.Ident, *ast.SelectorExpr: + default: + return true + } + function := codecFunctionOf(loaded, expression) + if function == nil { + return true + } + findings = append(findings, fmt.Sprintf( + "%s mentions %s without calling it.\n"+ + "A codec passed as a value reaches a decode that no call-site rule can read:\n"+ + "reflect.ValueOf(proto.Unmarshal).Call(…) and a closure taking proto.Unmarshal were\n"+ + "both compiled, both decoded a real capability, and both passed. Nothing here needs\n"+ + "to pass a codec around.", + path, function.FullName())) + return true + }) + return findings +} + +// indirectionFunctionOf names the permitted indirection an expression calls, or +// "". It is the same (package, Receiver.Function) key the exemption uses, so +// the two cannot disagree about which function is meant. +func indirectionFunctionOf(loaded *packages.Package, expression ast.Expr) string { + var identifier *ast.Ident + switch typed := expression.(type) { + case *ast.Ident: + identifier = typed + case *ast.SelectorExpr: + identifier = typed.Sel + default: + return "" + } + object, ok := loaded.TypesInfo.Uses[identifier] + if !ok { + object = loaded.TypesInfo.Defs[identifier] + } + function, ok := object.(*types.Func) + if !ok || function.Pkg() == nil { + return "" + } + qualified := function.Name() + if signature, ok := function.Type().(*types.Signature); ok && signature.Recv() != nil { + receiver := signature.Recv().Type() + if pointer, ok := types.Unalias(receiver).(*types.Pointer); ok { + receiver = pointer.Elem() + } + named, ok := types.Unalias(receiver).(*types.Named) + if !ok || named.Obj() == nil { + return "" + } + qualified = named.Obj().Name() + "." + function.Name() + } + if slices.Contains(codecIndirectionObjects[function.Pkg().Path()], qualified) { + return function.Pkg().Path() + "." + qualified + } + return "" +} + +// inspectHandWrittenProtoMessages refuses a hand-written ProtoReflect method — +// which is to say, any type in this repository that claims to BE a protobuf +// message. +// +// THE PREVIOUS FIX CLOSED A SUBCLASS AND LEFT THE CLASS OPEN. Making the +// interface walk transitive caught a struct carrying an interface; it does not +// catch a concrete FUNCTION type, because carriesAnInterface does not descend +// into a *types.Signature and capabilityWithin finds nothing in a func-typed +// field. Executed, decoding a real core-minted token: +// +// type r8thunk func() protoreflect.Message +// func (f r8thunk) ProtoReflect() protoreflect.Message { return f() } +// proto.Unmarshal(raw, r8thunk(claims.ProtoReflect)) +// +// and the same through a struct with a func field. Both passed all three Go +// gates, lint and the sweep. +// +// Chasing the shape was the mistake. A wrapper can be a struct, a func, a +// defined slice, a map, a channel — anything with a method set — so a rule +// written about shapes is a rule that is behind by one shape, which is the +// denylist failure in another costume. What every one of them must have is a +// ProtoReflect method, because that IS the protobuf contract: it is how the +// capability gets reached, and no amount of indirection avoids it. +// +// So the rule is about the method, not the shape. A protobuf message is +// GENERATED, by protoc, in the repository that owns the .proto — core. This +// repository writes none, has zero today, and a hand-written one is a type +// pretending to be a message, which is the second implementation in the one +// form that cannot be disguised. +func inspectHandWrittenProtoMessages( + loaded *packages.Package, file *ast.File, path string, +) []string { + var findings []string + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Recv == nil || function.Name == nil { + continue + } + if !slices.Contains(protoMessageMethods, function.Name.Name) { + continue + } + receiver := "a local type" + if object, ok := loaded.TypesInfo.Defs[function.Name].(*types.Func); ok { + receiver = object.FullName() + } + findings = append(findings, fmt.Sprintf( + "%s hand-writes %s on %s.\n"+ + "A protobuf message is GENERATED, by protoc, in the repository that owns the\n"+ + "schema — core. A hand-written %s makes a local type into a proto.Message, which\n"+ + "is how a capability reaches a codec with no interface and no capability type\n"+ + "anywhere in the signature: a func type, a struct with a func field, a defined\n"+ + "slice, anything with a method set. Chasing the shapes is a denylist; the method\n"+ + "is the contract, so the method is the rule.", + path, function.Name.Name, receiver, function.Name.Name)) + } + return findings +} + +// protoMessageMethods are the methods that make a type a protobuf message to +// the codecs. ProtoReflect is the v2 contract and Reset/String/ProtoMessage are +// the v1 one, which proto.Unmarshal still accepts through protoimpl. +var protoMessageMethods = []string{"ProtoReflect", "ProtoMessage"} + +// codecFunctionOf resolves an expression to a codec function's OBJECT, or nil. +func codecFunctionOf(loaded *packages.Package, expression ast.Expr) *types.Func { + var identifier *ast.Ident + switch typed := expression.(type) { + case *ast.Ident: + identifier = typed + case *ast.SelectorExpr: + identifier = typed.Sel + default: + return nil + } + object, ok := loaded.TypesInfo.Uses[identifier] + if !ok { + object = loaded.TypesInfo.Defs[identifier] + } + function, ok := object.(*types.Func) + if !ok || function.Pkg() == nil { + return nil + } + named, ok := codecFunctions[function.Pkg().Path()] + if !ok { + return nil + } + for _, name := range named { + if function.Name() == name { + return function + } + } + // A composite-literal codec — proto.MarshalOptions{}.Marshal — arrives as + // the METHOD, whose package is the same, so the receiver's own name is + // checked too. + if signature, ok := function.Type().(*types.Signature); ok && signature.Recv() != nil { + receiver := signature.Recv().Type() + if pointer, ok := receiver.(*types.Pointer); ok { + receiver = pointer.Elem() + } + if receiverNamed, ok := receiver.(*types.Named); ok { + for _, name := range named { + if receiverNamed.Obj().Name() == name { + return function + } + } + } + } + return nil +} + +// isUnknownToTheGate reports whether a type says "whatever the caller passes" +// — an interface, a type parameter, or anything that CONTAINS one. +// +// It had an explicit *types.TypeParam branch, and a mutation showed the branch +// was dead: a type parameter's underlying type IS its constraint interface, so +// types.IsInterface already returns true for one. +// +// THE "CONTAINS" PART IS THE ROUND-SEVEN BLOCKER, and it needed no handwritten +// anything: +// +// type envelopeTarget struct{ proto.Message } +// +// func decode(raw []byte, dst *basev0.WorkContextV1) error { +// return proto.Unmarshal(raw, &envelopeTarget{Message: dst}) +// } +// +// The wrapper is CONCRETE, so checking the outer type answered "known"; the +// capability walk descended into it, reached `proto.Message`, and had no +// interface case so returned nothing; and the syntactic gate resolved +// `envelopeTarget` as neither a capability nor a codec interface. Three checks, +// three misses, and the embedded interface's promoted ProtoReflect delegates +// to the capability — so this is the ordinary protobuf decoder, which the +// documented handwritten-wire-walk exception does not cover. +// +// A struct carrying an interface IS an interface as far as a codec is +// concerned: what it holds is chosen at the call site. So the walk is +// transitive, and the only thing that makes such an argument acceptable is one +// of the named indirections. +func isUnknownToTheGate(kind types.Type) bool { + return carriesAnInterface(kind, map[types.Type]bool{}) +} + +// carriesAnInterface walks a type for an interface or a type parameter, through +// pointers, slices, arrays, maps, aliases and STRUCT FIELDS — embedded ones +// included, which is where the promoted codec interface hides. +// +// It does not descend into CORE's own types: a generated message's internal +// state is core's business, and a capability reached through a row is answered +// by capabilityWithin before this is consulted. +func carriesAnInterface(kind types.Type, seen map[types.Type]bool) bool { + if kind == nil || seen[kind] { + return false + } + seen[kind] = true + switch typed := types.Unalias(kind).(type) { + case *types.Interface: + return true + case *types.TypeParam: + return true + case *types.Pointer: + return carriesAnInterface(typed.Elem(), seen) + case *types.Slice: + return carriesAnInterface(typed.Elem(), seen) + case *types.Array: + return carriesAnInterface(typed.Elem(), seen) + case *types.Map: + return carriesAnInterface(typed.Elem(), seen) + case *types.Named: + if types.IsInterface(typed) { + return true + } + if strings.HasPrefix(packagePathOf(typed.Obj()), coreModulePath) { + return false + } + return carriesAnInterface(typed.Underlying(), seen) + case *types.Struct: + for index := range typed.NumFields() { + if carriesAnInterface(typed.Field(index).Type(), seen) { + return true + } + } + } + return false +} + +// capabilityWithin names the capability a type is, or carries in a field, or +// "". +// +// Through pointers, slices, arrays, maps, aliases and STRUCT FIELDS, embedded +// ones included. `seen` stops the walk on protobuf's recursive messages. +func capabilityWithin(kind types.Type, seen map[types.Type]bool) string { + if kind == nil || seen[kind] { + return "" + } + seen[kind] = true + switch typed := types.Unalias(kind).(type) { + case *types.Pointer: + return capabilityWithin(typed.Elem(), seen) + case *types.Slice: + return capabilityWithin(typed.Elem(), seen) + case *types.Array: + return capabilityWithin(typed.Elem(), seen) + case *types.Map: + return capabilityWithin(typed.Elem(), seen) + case *types.Named: + object := typed.Obj() + if object != nil && object.Pkg() != nil { + if isCapabilityType(object.Pkg().Path() + "#" + object.Name()) { + return object.Pkg().Path() + "#" + object.Name() + } + } + // A STRUCT CARRYING ONE, walked into. The exception is core's own + // packages: a capability's sub-messages, and the fields of a Verified, + // are core's business and finding one there says nothing about this + // repository. + // + // Written as "only walk into types under this repository's module + // path" first, which was wrong in a way the probes caught: a probe's + // package is not under that path, so the rule could not be driven at + // all and the test would have passed having walked nothing. + if strings.HasPrefix(packagePathOf(object), coreModulePath) { + return "" + } + return capabilityWithin(typed.Underlying(), seen) + case *types.Struct: + for index := range typed.NumFields() { + if named := capabilityWithin(typed.Field(index).Type(), seen); named != "" { + return named + } + } + } + return "" +} + +func packagePathOf(object *types.TypeName) string { + if object == nil || object.Pkg() == nil { + return "" + } + return object.Pkg().Path() +} + +// capabilityCodecRows is every (file, capability type) pair a codec may touch. +// +// One. cache_partition.go proto-marshals a WorkScopeV1 to build a cache key, +// and `Work`-prefixed under core is what makes a type a capability — a prefix, +// so a message core adds later is covered the day it exists, at the cost of +// this one honest use needing a row. +var capabilityCodecRows = map[string][]string{ + "workcontext/cache_partition.go": {basev0Path + "#WorkScopeV1"}, +} + +func permittedCapabilityCodec(path string, named string) bool { + return slices.Contains(capabilityCodecRows[path], named) +} + +// permittedIndirection reports whether the function enclosing pos is one of +// the named indirections, BY OBJECT. +// +// This is what closes the allowlist's last hole: it used to be keyed on the +// enclosing function's NAME, so a reviewer declared a second method called +// Handle on a new type, put a decode of a real capability inside it, and was +// permitted. An object carries the package and the receiver; a name carries +// neither. +func permittedIndirection(loaded *packages.Package, file *ast.File, pos token.Pos) bool { + path, key := indirectionKeyAt(loaded, file, pos) + if key == "" { + return false + } + return slices.Contains(codecIndirectionObjects[path], key) +} + +// indirectionKeyAt names the function enclosing pos as the allowlist keys it: +// the package path, and "Receiver.Function" or "Function". +// +// Returned rather than compared inside permittedIndirection so a probe can +// assert the KEY. Asserting only the decision let two mutations survive — +// dropping the receiver from the key, and writing the row name-only, each +// harmless alone and a complete bypass together. That is the coupled-edit +// shape that has now survived three times in this repository, so the rule is +// pinned at the rule. +func indirectionKeyAt(loaded *packages.Package, file *ast.File, pos token.Pos) (string, string) { + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Body == nil { + continue + } + if pos < function.Body.Pos() || pos > function.Body.End() { + continue + } + object, ok := loaded.TypesInfo.Defs[function.Name].(*types.Func) + if !ok || object.Pkg() == nil { + return "", "" + } + // THE RECEIVER IS PART OF THE NAME. A method is permitted only on the + // type the row was written for, so a second method called Handle on a + // new type — which a reviewer wrote, with a decode of a real + // capability inside it — is a different object and is refused. + if function.Recv == nil { + return object.Pkg().Path(), object.Name() + } + receiver := loaded.TypesInfo.TypeOf(function.Recv.List[0].Type) + if pointer, ok := types.Unalias(receiver).(*types.Pointer); ok { + receiver = pointer.Elem() + } + named, ok := types.Unalias(receiver).(*types.Named) + if !ok || named.Obj() == nil { + return "", "" + } + return object.Pkg().Path(), named.Obj().Name() + "." + object.Name() + } + return "", "" +} + +// DELETED: TestTheCodecRuleAnswersAboutKindsNotSpellings. +// +// Its name said it drove each probe shape at the predicate and its body +// asserted one call to isCapabilityType — a grand name over a tautology, which +// a review doubted and was right to. What it claimed is done by +// TestTheCodecRuleRefusesEveryShapeThatPassedBefore, which drives every shape +// against type-checked source, and by the per-rule tests beside it. A stub kept +// for its name is worse than no test: it reads as coverage. + +// typeCheckedProbe type-checks a probe against the REAL dependency types. +// +// No temporary module and no network: packages.Load has already type-checked +// both modules and everything they import, so those *types.Package values are +// handed to go/types as an importer. The probe is then checked exactly as +// shipped code would be, which is what makes "this compiles and decodes a real +// capability" a thing a committed test can assert instead of a thing a review +// has to execute in a throwaway worktree. +func typeCheckedProbe(t *testing.T, source string) (*packages.Package, *ast.File) { + return typeCheckedProbeIn(t, "probe", source) +} + +// typeCheckedProbeIn checks a probe UNDER A GIVEN PACKAGE PATH, which is what +// lets the indirection allowlist be driven: it is keyed on the package and the +// receiver, and a probe compiled as package "probe" is refused by the package +// key before the receiver key is ever consulted. A mutation showed exactly +// that — removing the receiver from the key changed nothing, because no probe +// had reached it. +func typeCheckedProbeIn(t *testing.T, packagePath string, source string) (*packages.Package, *ast.File) { + t.Helper() + importer := probeImporter(t) + fileSet := token.NewFileSet() + file, err := parser.ParseFile(fileSet, "probe.go", source, parser.SkipObjectResolution) + require.NoError(t, err, "the probe does not parse, so it is not a bypass of anything") + + info := &types.Info{ + Types: map[ast.Expr]types.TypeAndValue{}, + Defs: map[*ast.Ident]types.Object{}, + Uses: map[*ast.Ident]types.Object{}, + } + config := &types.Config{Importer: importer} + _, err = config.Check(packagePath, fileSet, []*ast.File{file}, info) + require.NoError(t, err, "the probe does not type-check, so it is not a bypass of anything") + + return &packages.Package{Fset: fileSet, Syntax: []*ast.File{file}, TypesInfo: info}, file +} + +// loadedTypeImporter is every package both modules already depend on, as types. +type loadedTypeImporter map[string]*types.Package + +func (i loadedTypeImporter) Import(path string) (*types.Package, error) { + if found, ok := i[path]; ok { + return found, nil + } + return nil, fmt.Errorf("probe imports %q, which neither module depends on; "+ + "the import allowlist is the rule for that", path) +} + +func probeImporter(t *testing.T) loadedTypeImporter { + t.Helper() + importer := loadedTypeImporter{} + for _, module := range loadModules(t) { + packages.Visit(module.packages, nil, func(one *packages.Package) { + if one.Types != nil && one.Types.Complete() { + importer[one.PkgPath] = one.Types + } + }) + } + require.Contains(t, importer, "google.golang.org/protobuf/proto", + "the probe importer is empty, so every probe below would fail to type-check "+ + "for the wrong reason and the test would pass having asserted nothing") + return importer +} + +// EVERY PROBE THE EXECUTED REVIEW COMPILED AND RAN, at the rule. +// +// Each of these decoded a real WorkContextV1 minted by core's Authority and +// printed its tenant, against the previous revision of this gate, with both +// gates green. They are not readings of the code. +func TestTheCodecRuleRefusesEveryShapeThatPassedBefore(t *testing.T) { + const preamble = `package probe + +import ( + "encoding/json" + "reflect" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +// Sinks, so every probe shares one import block without "imported and not +// used". None is a codec use, so none can be the finding. +var ( + _ = reflect.TypeOf + _ protoreflect.Message + _ = basev0.File_codefly_base_v0_work_context_proto + _ = json.Valid + // proto.Size is not a codec, so naming it keeps the import live for every + // probe without being the finding in any of them. + _ = proto.Size +) + +` + for name, probe := range map[string]struct { + source string + says string + }{ + // R1: a type parameter. Resolved to the spelling "M". + "a generic codec wrapper": { + source: `func into[M proto.Message](raw []byte, m M) error { return proto.Unmarshal(raw, m) } +var _ = into[*basev0.WorkContextV1]`, + says: "interface or a type parameter", + }, + // R2: interface embedding. Resolved to "r6msg". + "an embedded interface": { + source: `type carrier interface{ proto.Message } +func into(raw []byte, m carrier) error { return proto.Unmarshal(raw, m) }`, + says: "interface or a type parameter", + }, + // R3: an alias of proto.Message. Only aliases of CORE's types were + // refused, and proto.Message is not one. + "an alias of the codec's own interface": { + source: `type carrier = proto.Message +func into(raw []byte, m carrier) error { return proto.Unmarshal(raw, m) }`, + says: "interface or a type parameter", + }, + // R4: reflect. Resolved to "[]reflect#Value". + "a codec reached through reflect": { + source: `func into(raw []byte, c *basev0.WorkContextV1) { + reflect.ValueOf(proto.Unmarshal).Call([]reflect.Value{reflect.ValueOf(raw), reflect.ValueOf(c)}) +}`, + says: "without calling it", + }, + // L6: a closure. codecArguments read the OUTER call's arguments. + "a codec handed to a closure": { + source: `func use(f func([]byte, proto.Message) error) func([]byte, proto.Message) error { return f } +func into(raw []byte, c *basev0.WorkContextV1) error { return use(proto.Unmarshal)(raw, c) }`, + says: "without calling it", + }, + // The concrete capability, named outright, outside its one row. + "the capability named outright": { + source: `func into(raw []byte) (*basev0.WorkContextV1, error) { + c := &basev0.WorkContextV1{} + return c, proto.Unmarshal(raw, c) +}`, + says: "which is or contains", + }, + // L5's other half: the capability reached THROUGH A FIELD, by an alias + // declared in another file. No syntactic rule could resolve it. + // ROUND EIGHT'S BLOCKER, and the lesson is that round seven's fix + // closed a SUBCLASS. A concrete FUNCTION type implementing + // proto.Message by delegation: no interface in the signature, no + // capability type anywhere, and the promoted method reaches the + // capability. Executed against the previous head on a real + // core-minted token. + "a concrete func type implementing proto.Message": { + source: `type thunk func() protoreflect.Message + +func (f thunk) ProtoReflect() protoreflect.Message { return f() } + +func decode(raw []byte, claims *basev0.WorkContextV1) error { + return proto.Unmarshal(raw, thunk(claims.ProtoReflect)) +}`, + says: "hand-writes ProtoReflect", + }, + // The same, lazily, through a struct whose FIELD is a func — + // carriesAnInterface finds no interface in a func-typed field. + "a struct with a func field": { + source: `type lazy struct{ get func() protoreflect.Message } + +func (l lazy) ProtoReflect() protoreflect.Message { return l.get() } + +func decode(raw []byte, claims *basev0.WorkContextV1) error { + return proto.Unmarshal(raw, lazy{get: claims.ProtoReflect}) +}`, + says: "hand-writes ProtoReflect", + }, + // And the v1 contract, which proto.Unmarshal still accepts through + // protoimpl — so the rule covers both or it covers one spelling. + "the v1 message contract": { + source: `type legacy struct{ inner *basev0.WorkContextV1 } + +func (legacy) Reset() {} +func (legacy) String() string { return "" } +func (legacy) ProtoMessage() {}`, + says: "hand-writes ProtoMessage", + }, + // ROUND SEVEN'S BLOCKER: a CONCRETE wrapper around an embedded + // interface. No handwritten anything — the promoted ProtoReflect + // delegates to the capability, so this is the ordinary protobuf + // decoder, and three separate checks each missed it for a different + // reason. + "a concrete wrapper around an embedded codec interface": { + source: `type envelopeTarget struct{ proto.Message } + +func decode(raw []byte, dst *basev0.WorkContextV1) error { + return proto.Unmarshal(raw, &envelopeTarget{Message: dst}) +}`, + says: "interface or a type parameter", + }, + // And the same hiding place one level deeper, so the walk is + // transitive rather than one-field-deep. + "an interface two structs down": { + source: `type inner struct{ proto.Message } +type outer struct { + Name string + Inner inner +} + +func decode(raw []byte, dst *basev0.WorkContextV1) error { + wrapped := outer{Inner: inner{Message: dst}} + return proto.Unmarshal(raw, &wrapped.Inner) +}`, + says: "interface or a type parameter", + }, + // A SLICE of them, because a codec takes one element at a time and the + // walk must not stop at the container. + "an interface behind a slice": { + source: `type batch []proto.Message + +func decode(raw []byte, all batch) error { return proto.Unmarshal(raw, all[0]) }`, + says: "interface or a type parameter", + }, + // L5, as executed: json.Marshal of a struct whose EMBEDDED field is a + // capability, through an alias declared in another file. The argument's + // own type is concrete — mintRequest — so no interface rule fires and + // no type-name rule could resolve the alias across files. The walk + // finds it inside the struct. + "a capability embedded in a permitted struct": { + source: `type claims = basev0.WorkContextV1 +type mintRequest struct { + Audience string + *claims +} +func out(audience string, c *claims) ([]byte, error) { + return json.Marshal(mintRequest{Audience: audience, claims: c}) +}`, + says: "which is or contains", + }, + } { + t.Run(name, func(t *testing.T) { + loaded, file := typeCheckedProbe(t, preamble+probe.source) + findings := inspectCodecUses(loaded, file, "second_implementation.go") + require.NotEmpty(t, findings, "the gate accepted a shape that decodes a capability") + require.Contains(t, strings.Join(findings, "\n"), probe.says) + }) + } + + // R8: a SECOND method named Handle, on a new type, IN THE RECEIPTS PACKAGE + // — so the package key passes and the receiver key is what has to refuse + // it. The allowlist was keyed on the function's name, and a reviewer wrote + // this and was permitted. + const receiptsPath = "github.com/codefly-dev/sdk-go/receipts" + t.Run("a second method spelled like an allowlisted one", func(t *testing.T) { + loaded, file := typeCheckedProbeIn(t, receiptsPath, preamble+`type other struct{} + +func (other) Handle(raw []byte, m proto.Message) error { return proto.Unmarshal(raw, m) }`) + require.NotEmpty(t, inspectCodecUses(loaded, file, "receipts/interceptor.go"), + "the allowlist named Interceptor.Handle, not every method spelled Handle") + }) + + // And the method the row was actually written for, in the same package, is + // permitted — which is what makes the case above about the RECEIVER and + // not about the package. + t.Run("the method the row was written for", func(t *testing.T) { + loaded, file := typeCheckedProbeIn(t, receiptsPath, preamble+`type Interceptor struct{} + +func (*Interceptor) Handle(raw []byte, m proto.Message) error { return proto.Unmarshal(raw, m) }`) + require.Empty(t, inspectCodecUses(loaded, file, "receipts/interceptor.go"), + "receipts.Interceptor.Handle takes a proto.Message by design") + }) + + // THE KEY ITSELF, because the decision alone was satisfied either way by + // two coupled edits. + t.Run("the allowlist key carries the receiver", func(t *testing.T) { + for name, probe := range map[string]struct{ source, key string }{ + "a method on the type the row names": { + source: `type Interceptor struct{} + +func (*Interceptor) Handle(raw []byte, m proto.Message) error { return proto.Unmarshal(raw, m) }`, + key: "Interceptor.Handle", + }, + "the same method name on another type": { + source: `type other struct{} + +func (other) Handle(raw []byte, m proto.Message) error { return proto.Unmarshal(raw, m) }`, + key: "other.Handle", + }, + "a plain function": { + source: `func RequestDigest(m proto.Message) ([]byte, error) { return proto.Marshal(m) }`, + key: "RequestDigest", + }, + } { + t.Run(name, func(t *testing.T) { + loaded, file := typeCheckedProbeIn(t, receiptsPath, preamble+probe.source) + var found string + ast.Inspect(file, func(node ast.Node) bool { + if call, ok := node.(*ast.CallExpr); ok && found == "" { + if _, key := indirectionKeyAt(loaded, file, call.Pos()); key != "" { + found = key + } + } + return true + }) + require.Equal(t, probe.key, found, + "the allowlist is keyed on this string; a key without the receiver permits "+ + "every method spelled the same") + }) + } + }) + + // CODEX 2: base64 DECODING through an ALIASED RECEIVER, in the one file + // allowed to reach base64 at all. The syntactic rule required the receiver + // to be written as `base64.RawURLEncoding`, so one assignment walked past + // it — a spelling standing in for a kind, the same correction the codec + // rule needed. + t.Run("base64 decoding through an aliased receiver", func(t *testing.T) { + loaded, file := typeCheckedProbe(t, `package probe + +import "encoding/base64" + +var payloadEncoding = base64.RawURLEncoding + +func openPayload(s string) ([]byte, error) { return payloadEncoding.DecodeString(s) } +`) + findings := inspectCodecUses(loaded, file, "workcontext/cache_partition.go") + require.NotEmpty(t, findings, "the file that may ENCODE a cache key may never DECODE") + require.Contains(t, strings.Join(findings, "\n"), "may never") + }) + + // And the shapes that file legitimately uses stay legitimate, which is the + // mutation guard for the case above. + t.Run("encoding a cache key is still permitted", func(t *testing.T) { + loaded, file := typeCheckedProbe(t, `package probe + +import "encoding/base64" + +var payloadEncoding = base64.RawURLEncoding + +func key(raw []byte) string { return payloadEncoding.EncodeToString(raw) } +`) + require.Empty(t, inspectCodecUses(loaded, file, "workcontext/cache_partition.go")) + }) + + // A CAPABILITY HANDED INTO A PERMITTED INDIRECTION. The exemption is for + // the codec call inside those four functions; their callers were never + // looked at, and configuration_document.go json-decodes into its + // destination with the generated struct's snake_case tags. + t.Run("a capability passed into a permitted indirection", func(t *testing.T) { + const rootPath = "github.com/codefly-dev/sdk-go" + loaded, file := typeCheckedProbeIn(t, rootPath, preamble+`func decodeDocument(name string, content []byte, destination any) error { + return json.Unmarshal(content, destination) +} + +func read(raw []byte) (*basev0.WorkContextV1, error) { + claims := &basev0.WorkContextV1{} + return claims, decodeDocument("work-context", raw, claims) +}`) + findings := inspectCodecUses(loaded, file, "configuration_document.go") + require.NotEmpty(t, findings, + "the exemption is for the codec INSIDE decodeDocument, not for handing it a capability") + require.Contains(t, strings.Join(findings, "\n"), "into github.com/codefly-dev/sdk-go.decodeDocument") + }) + + // A POINTER TO AN INTERFACE is still an interface's worth of unknown, and + // json.Marshal takes `any`, so it is a shape that compiles. + t.Run("a pointer to an interface", func(t *testing.T) { + loaded, file := typeCheckedProbe(t, preamble+`func out(m *proto.Message) ([]byte, error) { return json.Marshal(m) }`) + require.NotEmpty(t, inspectCodecUses(loaded, file, "second_implementation.go"), + "what the pointer points at is chosen by the caller, capability included") + }) + + // AND THE FOUR REAL INDIRECTIONS STAY PERMITTED, which is the mutation + // guard for all of the above: a rule that refuses everything is not a rule. + t.Run("the real indirections are still permitted", func(t *testing.T) { + for _, module := range loadModules(t) { + for _, loaded := range module.packages { + if loaded.TypesInfo == nil { + continue + } + for _, file := range loaded.Syntax { + path := module.relative(loaded, file) + if path == "" || strings.HasSuffix(path, "_test.go") { + continue + } + require.Empty(t, inspectCodecUses(loaded, file, path), + "%s is code this repository ships", path) + } + } + } + }) +} + +// The indirection count is CHECKED, not written in prose. "Three, all in +// receipts" sat above a list of four for two rounds — the C10 class, which this +// repository has now had five times — so the prose says "four" and this fails +// if it stops being four. +func TestTheIndirectionListIsAsSmallAsItClaims(t *testing.T) { + var total int + for _, names := range codecIndirectionObjects { + total += len(names) + } + require.Equal(t, 4, total, + "there are %d codec indirections now. Each one is a function allowed to apply a "+ + "codec to a value whose type nothing knows statically, so the list is the "+ + "gate's widest hole and its size belongs in a reviewed diff — update the "+ + "comment above codecIndirectionObjects and this number together.", total) +} diff --git a/workcontext/core.go b/workcontext/core.go new file mode 100644 index 0000000..c8fd4ad --- /dev/null +++ b/workcontext/core.go @@ -0,0 +1,165 @@ +package workcontext + +import ( + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + corework "github.com/codefly-dev/core/workcontext" +) + +// Everything below is core's, named here so product code can reach it through +// the package it already imports. They are aliases and not wrappers: a wrapper +// is a place a second behaviour grows, and the whole point of this file is that +// there is nowhere for one to grow. + +// Verifier is the verification entry point — core's, unmodified. A consumer +// that verifies a capability constructs one of these. +// +// It is issuer-shaped by design, and that is load-bearing rather than +// inconvenient: it refuses everything unless it is given the authorization +// revision, replay, grant and seal sources, because a verifier that silently +// skipped the seal check would make the strongest check in the model the +// easiest one to omit. A service that cannot answer those four is not in a +// position to verify a capability itself; it presents its own and lets the +// component that holds them decide. +type Verifier = corework.Verifier + +// Verified is a capability that passed every check. Only Verifier.Verify +// constructs one, so a value of this type cannot be a hand-built protobuf or a +// token that was parsed and never verified. +type Verified = corework.Verified + +// Seal is the live binding of a principal's authority to one installation and +// one execution, as the issuer holds it. A Verifier compares a capability's +// sealed values against it for exact equality. +type Seal = corework.Seal + +// OperationBinding is the LIVE state of one unit of authority, as the issuer +// holds it: resolved by its opaque id — never searched for by the scopes it +// contains — and carrying the principal it is granted to and the installation +// it is granted within, because an id that exists is not an id somebody holds. +// +// It is what a verifier's seal source answers, not what a capability carries. +// SealedOperationBinding is the carried half, and the two are deliberately +// different types: the capability names three fields, the live state names six, +// and a client that filled the live type from a token would be handing a reader +// an empty PrincipalID and InstallationID that read as "granted to nobody, +// nowhere" rather than as "the wire does not say". +type OperationBinding = corework.OperationBinding + +// SealedOperationBinding is the operation binding a capability carries: the +// binding's id, and its revision and incarnation at mint time. It is core's own +// message for that, so there is no second spelling of what travels. +type SealedOperationBinding = basev0.WorkOperationBindingV1 + +// SealedValues is the SEAL a capability carries, as core's own message. +// +// It is a different type from Seal for the same reason SealedOperationBinding +// is a different type from OperationBinding: Seal is the LIVE binding as the +// issuer holds it, and a client filling it by hand from three wire fields is +// claiming to answer a question only the issuer can. That hand-copy was also a +// field-by-field dependency on core's struct — core adding a required +// seal field (image_digest is next) would have left it silently zero, which is +// the hazard this module already hit once with OperationBinding's PrincipalID. +// +// Credential.Seal returns one of these, cloned from what core decoded. +type SealedValues = basev0.WorkSealV1 + +// SealSource answers the live values a seal is compared against. +type SealSource = corework.SealSource + +// RevisionSource, ReplayStore and GrantSource are the other three sources a +// Verifier requires. +type ( + RevisionSource = corework.RevisionSource + ReplayStore = corework.ReplayStore + GrantSource = corework.GrantSource +) + +// MemorySealSource and MemoryReplayStore are core's in-memory implementations, +// for a test or a single-process deployment. +type ( + MemorySealSource = corework.MemorySealSource + MemoryReplayStore = corework.MemoryReplayStore + FixedRevision = corework.FixedRevision +) + +// Claims are the capability's fields. Read them off a Verified, never off the +// wire. +type Claims = basev0.WorkContextV1 + +// Core's verify-only Authenticator and Authenticated are deliberately not +// aliased either. They require the issuer's revision and seal sources, which is +// precisely what a client does not hold; a client that could supply them is not +// a client. Re-exporting them here would put the weaker of two strengths in the +// same namespace as the stronger, one import away from each other, which is the +// silent downgrade core objected to when it was first asked for that path. + +// The refusals, core's. This module defines no second taxonomy of them: a +// caller writes errors.Is against these and gets the same answer whichever +// package it names them through. +var ( + // ErrInvalid covers every rejection that is a property of the capability + // itself — signature, window, audience, attenuation, grant shape — and + // every refusal this package makes about a capability it is asked to + // carry. + ErrInvalid = corework.ErrInvalid + + // ErrNotACoreToken is a token whose payload is not this encoding at all, + // most usefully one carrying a JSON payload. It is refused before the + // signature is checked and with its own error, so a token from another + // format is never diagnosed as a bad key. + ErrNotACoreToken = corework.ErrNotACoreToken + + // ErrRevoked is every sealed mismatch: principal epoch, installation + // revision, build incarnation, binding revision or incarnation, a revoked + // binding, or an installation the principal no longer holds — and an + // authorization revision the issuer has moved past. + // + // One sentinel for all of them is core's decision and the right one: in + // every case the capability was sound when it was minted and the state + // moved under it, so the holder's response is the same — mint again. A + // caller that needs to tell "mint again" from "this caller is not + // authorized" needs those two to read differently, which they do; it does + // not need the six reasons a re-mint is due to read differently from each + // other. + ErrRevoked = corework.ErrRevoked + + // ErrReplayed is a single-use capability presented twice. + ErrReplayed = corework.ErrReplayed + + // ErrUnsealed is GONE, and its absence is the point. It was aliased here + // for a while, which is worse than not having it: WorkContextV1.seal and + // WorkActorV1.principal_epoch are now schema-REQUIRED, so protovalidate + // refuses a missing seal or a missing actor epoch inside core's decode + // before any branch that could have produced ErrUnsealed runs. A sentinel + // no branch can produce invites a handler that never executes, and an + // unreachable handler reads as cover. + // + // Every structural seal defect is therefore ErrInvalid: no seal, a seal + // naming no installation, a zero epoch, revision or incarnation, a partial + // binding, an actor hop with no epoch. One sentinel, one branch. + + // Core exports three more sentinels that are deliberately NOT aliased here, + // on core's own instruction when asked: + // + // - ErrNeedsIssuer comes only from core's verify-only Authenticator, when + // a capability carries a grant hop. Verifier.Verify never returns it, so + // a client matching it would be writing a branch nothing reaches. + // - ErrNoSeal and ErrNoBinding are what a SealSource IMPLEMENTATION + // returns, not what a client matches. + // + // Aliasing them would widen this module's surface to a shape its consumers + // cannot reach, which reads as "this is available here" about three things + // that are not. A consumer that implements a seal source, or adopts the + // verify-only entrypoint, imports core/workcontext directly for them — and + // at that point it is not a client of the capability but a participant in + // it. +) + +// ScopeContained and ScopesAttenuate are core's scope algebra. A service +// deciding whether a verified capability covers a call asks these rather than +// comparing scope structs, because "contains" over a wildcard resource set is +// not an equality. +var ( + ScopeContained = corework.ScopeContained + ScopesAttenuate = corework.ScopesAttenuate +) diff --git a/workcontext/doc.go b/workcontext/doc.go index 0d3ed28..4e9cc4b 100644 --- a/workcontext/doc.go +++ b/workcontext/doc.go @@ -1,9 +1,35 @@ -// Package workcontext signs and verifies Codefly Work Contexts: bounded, -// two-segment Ed25519 capabilities carried between execution boundaries. -// -// It is a standalone module so a service whose only need is to verify Work -// Contexts against a JWKS can depend on the verifier without inheriting the -// full transitive dependency tail of github.com/codefly-dev/sdk-go. The gRPC -// transport for Work Contexts lives in the workcontext/grpctransport -// subpackage so that consumers that only verify never compile grpc. +// Package workcontext is the client side of the Work Context capability: it +// obtains one credential per execution from the host's mint endpoint, carries +// it on outbound calls, and gives typed access to the one implementation of +// the capability itself. +// +// It mints nothing and it verifies nothing. The Work Context is a core type +// (codefly.base.v0.WorkContextV1) and github.com/codefly-dev/core/workcontext +// is its only implementation: the only code that signs a capability, the only +// code that checks a signature, and the only encoding of either. This package +// re-exports core's verification entry point rather than offering one of its +// own, so a consumer that verifies through this module is verifying through +// core. +// +// That is a rule and not a preference, because the alternative was tried. A +// second implementation here once signed a hand-written JSON payload while +// core signed the deterministic protobuf encoding of the same message. Both +// forms are "." with an Ed25519 +// signature, so a token from either looked well-formed to the other and then +// failed to resolve a key id read out of a payload in the other format — a message that reads like a rotated key or a +// wrong trust root, and sends everyone to look at keys. The gate test in this +// package is what stops it coming back; see README.md, "One implementation, and +// the gate that keeps it that way". +// +// What this package does own: +// +// - MintClient: one credential per execution, obtained from the projected +// service-account token, renewed only at expiry. No heartbeat, no +// registration, nothing a process reports about itself. +// - The carriers: the capability's header, and the sealed installation +// beside it as a pre-check the far end may refuse on cheaply. +// - CachePartition: the identity partition a cache stack scopes entries to, +// which never spans an installation revision. +// - StreamGuard: a stream re-presents its credential before every message +// and terminates on refusal. package workcontext diff --git a/workcontext/go.mod b/workcontext/go.mod index 56a8090..621c2c4 100644 --- a/workcontext/go.mod +++ b/workcontext/go.mod @@ -3,17 +3,64 @@ module github.com/codefly-dev/sdk-go/workcontext go 1.27.0 require ( - github.com/codefly-dev/core v0.3.32 + github.com/codefly-dev/core v0.9.0 github.com/stretchr/testify v1.12.1 + golang.org/x/mod v0.41.0 + golang.org/x/tools v0.51.0 google.golang.org/grpc v1.83.2 + google.golang.org/protobuf v1.36.12 ) require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect + buf.build/go/protovalidate v1.4.0 // indirect + cel.dev/cel-go v0.32.0 // indirect + cel.dev/expr v0.25.3 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/google/uuid v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/exp v0.0.0-20260820142414-ca536658362e // indirect golang.org/x/net v0.59.0 // indirect + golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect - google.golang.org/protobuf v1.36.12 // indirect ) + +// RETRACTED: every version of THIS module published before the second Work +// Context implementation was deleted from it. +// +// This module carries the files from root tag v0.1.66 onward — they live under +// workcontext/, which is this module, so the root module's zip excludes them +// and the root module's retract directive says nothing about them. A review +// found the root retraction aimed at the wrong module and confirmed it from the +// cache: github.com/codefly-dev/sdk-go/workcontext@v0.0.0-20260928181007-c1fc359f112f +// resolves today and its zip contains work_context.go. +// +// A RANGE OF PSEUDO-VERSIONS, because this module has never been tagged: every +// version of it that a consumer can resolve is a pseudo-version of a commit, +// and every commit before this change carries the implementation. +// +// THE LOW BOUND IS A PSEUDO-VERSION, NOT v0.0.0, and that is the whole of a +// correction. The first attempt wrote +// +// retract [v0.0.0, v0.0.0-20261004000000-zzzzzzzzzzzz] +// +// which covers NOTHING: under semver a prerelease sorts BELOW its release, so +// `v0.0.0-2026…` < `v0.0.0`, the interval has low > high, and it is empty. +// Checked with Go's own implementation — semver.Compare("v0.0.0", +// "v0.0.0-20261004000000-zzzzzzzzzzzz") is 1 — which is also what the review +// that found it checked against. Every published version sat outside an +// interval that read as though it covered all of them, which is the +// fail-open shape this repository keeps producing: a declaration that looks +// like a gate and is not one. +// +// So both bounds are pseudo-versions of v0.0.0, and the interval is asserted +// NON-EMPTY and COVERING by workcontext's own test — a string comparison in the +// root module is what failed to notice this, so the question is now answered by +// the library that defines the ordering. +// +// Effective only once workcontext/vX.Y.Z is tagged carrying this go.mod, which +// is the owner's release step. +retract [v0.0.0-00000000000000-000000000000, v0.0.0-20261004000000-zzzzzzzzzzzz] diff --git a/workcontext/go.sum b/workcontext/go.sum index 6f5c1c4..081cc45 100644 --- a/workcontext/go.sum +++ b/workcontext/go.sum @@ -1,9 +1,19 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 h1:NbnlmV26O7oZ1iM5tsCI+GEx+3ZSdrhvnKQ/eWSrLiY= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= +buf.build/go/protovalidate v1.4.0 h1:UjLrYbt5VX7+TMOs2+pG5FhZhIG1mSfK4EIopbb4LcM= +buf.build/go/protovalidate v1.4.0/go.mod h1:8vJfzNT6NIG2qm3uFsJDXMlRmG+bQJzbcIn1Aa0vPGs= +cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk= +cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU= +cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= +cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/codefly-dev/core v0.3.32 h1:zVgGAljUERRjHe5ONhI2uBuQg0eayZzkFSm0murp5NI= -github.com/codefly-dev/core v0.3.32/go.mod h1:f5LY9gRu5lpBQ0tNKujP0q6MF9K8fUnbYvItuHaUWlY= +github.com/codefly-dev/core v0.9.0 h1:fZ+jnP33uQ4JqYBrABjOvdqF/A6K/hLcQumykjHWrgY= +github.com/codefly-dev/core v0.9.0/go.mod h1:2wt5DdL+OnJbIQT0w4cc7dS2sAGVK3QjnwW43r5cQQg= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -14,6 +24,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= +github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -30,14 +42,24 @@ go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu6 go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/exp v0.0.0-20260820142414-ca536658362e h1:01Ju2A/fZKkci4zqx0eZxw//DnRYOnBiGJG14hFBhO8= +golang.org/x/exp v0.0.0-20260820142414-ca536658362e/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI= google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc= google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= diff --git a/workcontext/grpctransport/execution_context_grpc.go b/workcontext/grpctransport/execution_context_grpc.go index af2737e..2804cfa 100644 --- a/workcontext/grpctransport/execution_context_grpc.go +++ b/workcontext/grpctransport/execution_context_grpc.go @@ -11,9 +11,14 @@ import ( ) const ( - workContextGRPCMetadataName = workcontext.WorkContextHeaderName - operationIDGRPCMetadataName = "x-codefly-operation-id" - maxOperationIDBytes = 128 + workContextGRPCMetadataName = workcontext.HeaderName + // The installation carriers are the same names as on HTTP, because they + // are the same fact: gRPC metadata keys and HTTP header names are one + // namespace, and spelling them twice is how they drift. + installationIDGRPCMetadataName = workcontext.InstallationIDHeaderName + installationRevisionGRPCMetadataName = workcontext.InstallationRevisionHeaderName + operationIDGRPCMetadataName = "x-codefly-operation-id" + maxOperationIDBytes = 128 ) // ExecutionContext is the opaque authority and stable logical-operation @@ -22,17 +27,17 @@ const ( // Callers construct it through NewExecutionContext and attach it through // WithGRPCExecutionContext. Carrier names remain SDK-owned. type ExecutionContext struct { - workContext workcontext.WorkContextToken + workContext string operationID string } // NewExecutionContext validates and freezes one Work Context/operation pair. func NewExecutionContext( - workContext workcontext.WorkContextToken, + workContext string, operationID string, ) (ExecutionContext, error) { - if workContext.Encoded() == "" { - return ExecutionContext{}, fmt.Errorf("%w: empty Work Context", workcontext.ErrWorkContextInvalid) + if workContext == "" { + return ExecutionContext{}, fmt.Errorf("%w: empty Work Context", workcontext.ErrInvalid) } if err := validateOperationID(operationID); err != nil { return ExecutionContext{}, err @@ -43,9 +48,10 @@ func NewExecutionContext( }, nil } -// WorkContext returns the opaque signed capability. Trust decisions still -// require WorkContextVerifier. -func (execution ExecutionContext) WorkContext() workcontext.WorkContextToken { +// Capability returns the opaque signed capability. It is a string, and a trust +// decision still requires core's verifier: nothing a transport hands back has +// been verified by being transported. +func (execution ExecutionContext) Capability() string { return execution.workContext } @@ -58,35 +64,56 @@ func (execution ExecutionContext) OperationID() string { // metadata while preserving unrelated metadata. Existing carrier values are // rejected rather than overwritten or joined. // +// The sealed installation travels beside the capability on every call, so the +// callee can re-check the installation the call is being made under without +// decoding the token first. It is a pre-check and not authority: the +// installation that governs the call is the sealed one, and a callee that finds +// the two disagreeing refuses rather than choosing between them. +// // A Work Context is bound to one audience. A service calling another service -// attaches a context the authority exchanged for the callee's audience -// (ExchangeWorkContextAudience), never the one it received: the callee's -// audience check rejects a forwarded context. Nothing here reads incoming -// metadata, so this helper never forwards on its own. +// attaches a capability the authority minted for the callee's audience, never +// the one it received: the callee's audience check rejects a forwarded +// capability. Nothing here reads incoming metadata, so this helper never +// forwards on its own. func WithGRPCExecutionContext( ctx context.Context, execution ExecutionContext, ) (context.Context, error) { if ctx == nil { - return nil, fmt.Errorf("%w: nil gRPC context", workcontext.ErrWorkContextInvalid) + return nil, fmt.Errorf("%w: nil gRPC context", workcontext.ErrInvalid) } validated, err := NewExecutionContext(execution.workContext, execution.operationID) if err != nil { return nil, err } - existing, _ := metadata.FromOutgoingContext(ctx) - if len(existing.Get(workContextGRPCMetadataName)) != 0 { - return nil, fmt.Errorf("%w: outgoing gRPC Work Context already set", workcontext.ErrWorkContextInvalid) + installationID, installationRevision, err := workcontext.SealedInstallation(validated.workContext) + if err != nil { + return nil, err } - if len(existing.Get(operationIDGRPCMetadataName)) != 0 { - return nil, fmt.Errorf("%w: outgoing gRPC operation ID already set", workcontext.ErrWorkContextInvalid) + existing, _ := metadata.FromOutgoingContext(ctx) + for _, carrier := range []string{ + workContextGRPCMetadataName, + operationIDGRPCMetadataName, + installationIDGRPCMetadataName, + installationRevisionGRPCMetadataName, + } { + if len(existing.Get(carrier)) != 0 { + return nil, fmt.Errorf( + "%w: outgoing gRPC metadata %q is already set", + workcontext.ErrInvalid, carrier, + ) + } } return metadata.AppendToOutgoingContext( ctx, workContextGRPCMetadataName, - validated.workContext.Encoded(), + validated.workContext, operationIDGRPCMetadataName, validated.operationID, + installationIDGRPCMetadataName, + installationID, + installationRevisionGRPCMetadataName, + installationRevision, ), nil } @@ -95,69 +122,82 @@ func WithGRPCExecutionContext( // does not verify Work Context trust. func GRPCExecutionContextFromIncoming(ctx context.Context) (ExecutionContext, error) { if ctx == nil { - return ExecutionContext{}, fmt.Errorf("%w: nil gRPC context", workcontext.ErrWorkContextInvalid) + return ExecutionContext{}, fmt.Errorf("%w: nil gRPC context", workcontext.ErrInvalid) } values, ok := metadata.FromIncomingContext(ctx) if !ok { - return ExecutionContext{}, fmt.Errorf("%w: missing incoming gRPC metadata", workcontext.ErrWorkContextInvalid) + return ExecutionContext{}, fmt.Errorf("%w: missing incoming gRPC metadata", workcontext.ErrInvalid) } workContexts := values.Get(workContextGRPCMetadataName) if len(workContexts) != 1 { return ExecutionContext{}, fmt.Errorf( "%w: incoming gRPC Work Context requires exactly one value", - workcontext.ErrWorkContextInvalid, + workcontext.ErrInvalid, ) } operationIDs := values.Get(operationIDGRPCMetadataName) if len(operationIDs) != 1 { return ExecutionContext{}, fmt.Errorf( "%w: incoming gRPC operation ID requires exactly one value", - workcontext.ErrWorkContextInvalid, + workcontext.ErrInvalid, ) } - workContext, err := workcontext.ParseWorkContextToken(workContexts[0]) - if err != nil { + workContext := workContexts[0] + if err := checkIncomingInstallation(values, workContext); err != nil { return ExecutionContext{}, err } return NewExecutionContext(workContext, operationIDs[0]) } -// GRPCExecutionContextFromIncomingIfPresent supports compatibility boundaries -// where execution attribution is optional. No carrier returns present=false; -// a partial or duplicate carrier is still an error. -func GRPCExecutionContextFromIncomingIfPresent( - ctx context.Context, -) (execution ExecutionContext, present bool, err error) { - if ctx == nil { - return ExecutionContext{}, false, fmt.Errorf("%w: nil gRPC context", workcontext.ErrWorkContextInvalid) - } - values, ok := metadata.FromIncomingContext(ctx) - if !ok { - return ExecutionContext{}, false, nil - } - workContexts := values.Get(workContextGRPCMetadataName) - operationIDs := values.Get(operationIDGRPCMetadataName) - if len(workContexts) == 0 && len(operationIDs) == 0 { - return ExecutionContext{}, false, nil - } - execution, err = GRPCExecutionContextFromIncoming(ctx) +// checkIncomingInstallation holds the installation carriers to the sealed +// claim. The carriers exist so a callee can refuse cheaply; they are caller- +// controlled, so the only two acceptable outcomes are "they agree with the +// seal" and "the call is refused". Preferring the carrier would let a caller +// name any installation it liked, and ignoring a disagreement would make the +// carriers decoration that a reader would nonetheless log and believe. +func checkIncomingInstallation( + values metadata.MD, + token string, +) error { + sealedID, sealedRevision, err := workcontext.SealedInstallation(token) if err != nil { - return ExecutionContext{}, false, err + return err + } + for _, carrier := range []struct { + name string + sealed string + }{ + {installationIDGRPCMetadataName, sealedID}, + {installationRevisionGRPCMetadataName, sealedRevision}, + } { + presented := values.Get(carrier.name) + if len(presented) != 1 { + return fmt.Errorf( + "%w: incoming gRPC %s requires exactly one value", + workcontext.ErrInvalid, carrier.name, + ) + } + if presented[0] != carrier.sealed { + return fmt.Errorf( + "%w: incoming gRPC %s is %q and the credential seals %q", + workcontext.ErrInvalid, carrier.name, presented[0], carrier.sealed, + ) + } } - return execution, true, nil + return nil } func validateOperationID(operationID string) error { if operationID == "" { - return fmt.Errorf("%w: operation ID is required", workcontext.ErrWorkContextInvalid) + return fmt.Errorf("%w: operation ID is required", workcontext.ErrInvalid) } if strings.TrimSpace(operationID) != operationID { - return fmt.Errorf("%w: operation ID is not canonical", workcontext.ErrWorkContextInvalid) + return fmt.Errorf("%w: operation ID is not canonical", workcontext.ErrInvalid) } if len(operationID) > maxOperationIDBytes { return fmt.Errorf( "%w: operation ID exceeds %d bytes", - workcontext.ErrWorkContextInvalid, + workcontext.ErrInvalid, maxOperationIDBytes, ) } @@ -171,7 +211,7 @@ func validateOperationID(operationID string) error { case '-', '_', '.', ':': continue default: - return fmt.Errorf("%w: operation ID contains unsupported characters", workcontext.ErrWorkContextInvalid) + return fmt.Errorf("%w: operation ID contains unsupported characters", workcontext.ErrInvalid) } } return nil diff --git a/workcontext/grpctransport/execution_context_grpc_test.go b/workcontext/grpctransport/execution_context_grpc_test.go index 32423c0..10d1b01 100644 --- a/workcontext/grpctransport/execution_context_grpc_test.go +++ b/workcontext/grpctransport/execution_context_grpc_test.go @@ -2,31 +2,51 @@ package grpctransport import ( "context" - "encoding/base64" + "strconv" "strings" "testing" + "time" + corework "github.com/codefly-dev/core/workcontext" "github.com/stretchr/testify/require" "google.golang.org/grpc/metadata" "github.com/codefly-dev/sdk-go/workcontext" ) -func opaqueTestWorkContext(t *testing.T) workcontext.WorkContextToken { +// opaqueTestCapability is a real sealed capability rather than a shaped +// placeholder. It has to be: the transport reads the installation out of the +// capability so every call carries it, so one with no seal cannot be attached +// at all — which is the behaviour, and a placeholder here would hide it. +// +// It comes from core's conformance kit, which is the only minter. A test that +// assembled a token here would be asserting against a format nothing issues. +func opaqueTestCapability(t *testing.T) string { t.Helper() - signature := base64.RawURLEncoding.EncodeToString(make([]byte, 64)) - token, err := workcontext.ParseWorkContextToken("e30." + signature) + fixtures, err := corework.Fixtures(time.Now()) require.NoError(t, err) - return token + for _, fixture := range fixtures { + if fixture.Form == corework.FormSession && fixture.Outcome == corework.OutcomeAccepted { + return fixture.Token + } + } + t.Fatalf("core's conformance kit offered no accepted session capability") + return "" } +// The installation every capability above is sealed to, as the kit holds it. +var ( + sealedInstallationID = corework.FixtureInstallation + sealedInstallationRevision = strconv.Itoa(corework.FixtureInstallationRevision) +) + func TestGRPCExecutionContextRoundTripPreservesOtherMetadata(t *testing.T) { original := metadata.NewOutgoingContext( context.Background(), metadata.Pairs("authorization", "Bearer gateway-token"), ) execution, err := NewExecutionContext( - opaqueTestWorkContext(t), + opaqueTestCapability(t), "operation-019f8fc1", ) require.NoError(t, err) @@ -40,17 +60,17 @@ func TestGRPCExecutionContextRoundTripPreservesOtherMetadata(t *testing.T) { incoming := metadata.NewIncomingContext(context.Background(), outgoingMetadata) received, err := GRPCExecutionContextFromIncoming(incoming) require.NoError(t, err) - require.Equal(t, execution.WorkContext().Encoded(), received.WorkContext().Encoded()) + require.Equal(t, execution.Capability(), received.Capability()) require.Equal(t, execution.OperationID(), received.OperationID()) } func TestGRPCExecutionContextRejectsExistingOrDuplicateCarriers(t *testing.T) { - execution, err := NewExecutionContext(opaqueTestWorkContext(t), "operation-1") + execution, err := NewExecutionContext(opaqueTestCapability(t), "operation-1") require.NoError(t, err) outgoing := metadata.NewOutgoingContext( context.Background(), - metadata.Pairs(workContextGRPCMetadataName, execution.WorkContext().Encoded()), + metadata.Pairs(workContextGRPCMetadataName, execution.Capability()), ) _, err = WithGRPCExecutionContext(outgoing, execution) require.ErrorContains(t, err, "already set") @@ -59,9 +79,9 @@ func TestGRPCExecutionContextRejectsExistingOrDuplicateCarriers(t *testing.T) { context.Background(), metadata.Pairs( workContextGRPCMetadataName, - execution.WorkContext().Encoded(), + execution.Capability(), workContextGRPCMetadataName, - execution.WorkContext().Encoded(), + execution.Capability(), operationIDGRPCMetadataName, execution.OperationID(), ), @@ -71,7 +91,7 @@ func TestGRPCExecutionContextRejectsExistingOrDuplicateCarriers(t *testing.T) { } func TestGRPCExecutionContextRejectsMissingOrNonCanonicalOperationID(t *testing.T) { - workContext := opaqueTestWorkContext(t) + workContext := opaqueTestCapability(t) for _, operationID := range []string{ "", " operation-1", @@ -85,23 +105,87 @@ func TestGRPCExecutionContextRejectsMissingOrNonCanonicalOperationID(t *testing. incoming := metadata.NewIncomingContext( context.Background(), - metadata.Pairs(workContextGRPCMetadataName, workContext.Encoded()), + metadata.Pairs(workContextGRPCMetadataName, workContext), ) _, err := GRPCExecutionContextFromIncoming(incoming) require.ErrorContains(t, err, "operation ID requires exactly one value") } -func TestGRPCExecutionContextOptionalExtractionDistinguishesAbsentFromPartial(t *testing.T) { - execution, present, err := GRPCExecutionContextFromIncomingIfPresent(context.Background()) +// Deliverable 3 on the gRPC side: the sealed installation travels beside the +// capability on every call, under the same names HTTP uses, so a callee can +// refuse before it decodes anything. +func TestGRPCCallsCarryTheSealedInstallation(t *testing.T) { + execution, err := NewExecutionContext(opaqueTestCapability(t), "operation-019f8fc1") require.NoError(t, err) - require.False(t, present) - require.Empty(t, execution.OperationID()) + outgoing, err := WithGRPCExecutionContext(context.Background(), execution) + require.NoError(t, err) + carried, ok := metadata.FromOutgoingContext(outgoing) + require.True(t, ok) - partial := metadata.NewIncomingContext( - context.Background(), - metadata.Pairs(operationIDGRPCMetadataName, "operation-1"), - ) - _, present, err = GRPCExecutionContextFromIncomingIfPresent(partial) - require.ErrorContains(t, err, "Work Context requires exactly one value") - require.False(t, present) + require.Equal(t, []string{sealedInstallationID}, carried.Get(workcontext.InstallationIDHeaderName)) + require.Equal(t, []string{sealedInstallationRevision}, carried.Get(workcontext.InstallationRevisionHeaderName)) +} + +// The carriers are caller-controlled, so the only two acceptable outcomes are +// "they agree with the seal" and "the call is refused". Preferring the carrier +// would let a caller name any installation it liked; ignoring a disagreement +// would make them decoration that a reader would nonetheless log and believe. +func TestIncomingInstallationCarriersAreHeldToTheSeal(t *testing.T) { + execution, err := NewExecutionContext(opaqueTestCapability(t), "operation-019f8fc1") + require.NoError(t, err) + outgoing, err := WithGRPCExecutionContext(context.Background(), execution) + require.NoError(t, err) + honest, ok := metadata.FromOutgoingContext(outgoing) + require.True(t, ok) + + _, err = GRPCExecutionContextFromIncoming(metadata.NewIncomingContext(context.Background(), honest)) + require.NoError(t, err) + + for name, mutate := range map[string]func(metadata.MD){ + "another installation": func(values metadata.MD) { + values.Set(workcontext.InstallationIDHeaderName, "installation-somebody-elses") + }, + "another revision": func(values metadata.MD) { + values.Set(workcontext.InstallationRevisionHeaderName, "6") + }, + "installation removed": func(values metadata.MD) { + values.Delete(workcontext.InstallationIDHeaderName) + }, + "revision removed": func(values metadata.MD) { + values.Delete(workcontext.InstallationRevisionHeaderName) + }, + "duplicate revision": func(values metadata.MD) { + values.Append(workcontext.InstallationRevisionHeaderName, sealedInstallationRevision) + }, + } { + t.Run(name, func(t *testing.T) { + tampered := honest.Copy() + mutate(tampered) + _, err := GRPCExecutionContextFromIncoming(metadata.NewIncomingContext(context.Background(), tampered)) + require.ErrorIs(t, err, workcontext.ErrInvalid) + }) + } +} + +// There is no optional extractor any more. A capability-bearing path requires +// the capability, so a call carrying no carriers at all is refused exactly like +// one carrying half of them — the compatibility boundary that returned +// present=false is deleted, and a consumer that relied on it now fails to +// compile rather than silently keeping its old path. +func TestExtractionRefusesACallThatCarriesNothing(t *testing.T) { + for name, incoming := range map[string]context.Context{ + "no metadata at all": context.Background(), + "empty metadata": metadata.NewIncomingContext( + context.Background(), metadata.MD{}, + ), + "installation only": metadata.NewIncomingContext( + context.Background(), + metadata.Pairs(workcontext.InstallationIDHeaderName, sealedInstallationID), + ), + } { + t.Run(name, func(t *testing.T) { + _, err := GRPCExecutionContextFromIncoming(incoming) + require.ErrorIs(t, err, workcontext.ErrInvalid) + }) + } } diff --git a/workcontext/grpctransport/guarded_stream.go b/workcontext/grpctransport/guarded_stream.go new file mode 100644 index 0000000..1ef6e01 --- /dev/null +++ b/workcontext/grpctransport/guarded_stream.go @@ -0,0 +1,834 @@ +package grpctransport + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "sync" + "sync/atomic" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + + "github.com/codefly-dev/sdk-go/workcontext" +) + +// GuardedServerStream re-presents a stream's credential before anything leaves +// it, by being the thing that sends. +// +// A StreamGuard on its own is advice. BeforeSend has to be called, and whether +// it is called before each emission depends on each author remembering — the +// same shape as the optional carrier this module deleted: a rule that holds +// wherever somebody thought of it. Wrapping the stream makes the check +// structural, and streamServerInterceptor makes the wrapping structural too. +// +// # Three routes out of a stream, not one +// +// Each of the following was a live bypass, and each was found after the +// previous one was fixed. They are listed together because the lesson is that +// "the handler calls our method" was never the only way metadata leaves. +// +// 1. THE EMBEDDED FIELD. This type used to embed grpc.ServerStream as a +// public field, so a handler could write +// `stream.(*GuardedServerStream).ServerStream` and send with no check at +// all. The underlying stream is private now and every interface method is +// forwarded by hand. +// +// 2. WHEN gRPC SENDS. gRPC sends trailers, and flushes any pending header, +// when the HANDLER RETURNS — not when SetTrailer or SetHeader is called. +// Checking at queue time and handing the metadata straight to gRPC meant a +// handler could queue, have authority revoked, return, and the client +// received it. Measured over bufconn at the previous revision: a header +// queued under authority arrived at the client after Finish had refused. +// So headers AND trailers are held in this wrapper and released by Finish. +// +// 3. THE CONTEXT. grpc.SetHeader, grpc.SendHeader and grpc.SetTrailer are +// package-level functions that resolve a grpc.ServerTransportStream out of +// the context and write to the TRANSPORT stream. They never touch this +// type. Measured over bufconn at the previous revision: with the guard +// revoked for the whole call and zero re-checks performed, both a +// context-route header and a context-route trailer reached the client. So +// this wrapper owns the transport stream in the context it hands out, and +// those three functions route back through the checks. +// +// What remains deliberately unguarded: a handler that already holds the +// ORIGINAL stream from outside the interceptor, and SendHeader's own flush — +// once a header frame is on the wire no later revocation recalls it, which is +// what SendHeader means. +// +// # What does not work inside a guarded stream +// +// grpc.SetSendCompressor and grpc.ClientSupportedCompressors type-assert the +// context's transport stream to gRPC's own unexported implementation, so they +// FAIL here rather than being guarded. That is a real limitation and not a +// safety property: nothing leaks, a handler that needs per-call compression +// cannot have it. Fixing it means implementing an interface gRPC does not +// export, so it is written down instead. +type GuardedServerStream struct { + stream grpc.ServerStream + guard *workcontext.StreamGuard + // ctx carries THIS wrapper as the context's transport stream, so the + // package-level grpc.SetHeader/SendHeader/SetTrailer reach the checks + // instead of reaching around them. + ctx context.Context + + // transport is this wrapper as the context's transport stream, kept so the + // re-check's own context can answer the method without being able to write. + transport *guardedTransportStream + + mu sync.Mutex + headers metadata.MD + trailers metadata.MD + flushed bool + finished bool +} + +// Guard wraps a server stream so everything it sends is preceded by a live +// re-check. A nil guard is refused rather than treated as "no guarding +// wanted": a wrapper that silently did nothing would be worse than no wrapper, +// because the call site would read as guarded. +// +// A caller using Guard directly MUST call Finish when its handler is done, or +// the headers and trailers it set are never sent, and MUST pass this wrapper's +// Context to anything that writes metadata. streamServerInterceptor does both, +// which is the reason to prefer it — and the reason the README's recipe is the +// interceptor and not this. +func Guard(stream grpc.ServerStream, guard *workcontext.StreamGuard) (*GuardedServerStream, error) { + if stream == nil { + return nil, fmt.Errorf("%w: no stream to guard", errStreamMisuse) + } + if guard == nil { + return nil, fmt.Errorf("%w: no stream guard", errStreamMisuse) + } + guarded := &GuardedServerStream{ + stream: stream, + guard: guard, + headers: metadata.MD{}, + trailers: metadata.MD{}, + } + // The handler's context carries this wrapper as its transport stream. The + // inherited one is kept for Method(), which is the one thing a transport + // stream answers that is not a write. + guarded.transport = &guardedTransportStream{ + guarded: guarded, + inherited: grpc.ServerTransportStreamFromContext(stream.Context()), + } + guarded.ctx = grpc.NewContextWithServerTransportStream(stream.Context(), guarded.transport) + return guarded, nil +} + +// Context is the stream's context with this wrapper installed as its transport +// stream. Reading it sends nothing, so it is not itself guarded — but what a +// caller can reach THROUGH it is. +func (s *GuardedServerStream) Context() context.Context { + if s == nil || s.stream == nil { + return context.Background() + } + if s.ctx != nil { + return s.ctx + } + return s.stream.Context() +} + +// RecvMsg is forwarded unguarded. Receiving is not emitting, and a handler that +// cannot read its own request cannot decide anything about it; the guard is +// about what leaves. +func (s *GuardedServerStream) RecvMsg(message any) error { + if s == nil || s.stream == nil { + return fmt.Errorf("%w: unguarded stream", errStreamMisuse) + } + return s.stream.RecvMsg(message) +} + +// SendMsg re-checks, then sends. A refusal is returned and the message is NOT +// sent: the whole point is that a message computed under withdrawn authority +// does not leave. +// +// Headers held by SetHeader are released onto the real stream first, under this +// same check, because a message flushes them anyway and they must not reach the +// wire unchecked. +// +// The error is the guard's, wrapped, so a caller can tell a terminated stream +// from a transport failure — and ErrStreamTerminated is sticky, so a handler +// that loops past the first refusal keeps being refused rather than getting a +// second chance to emit. +func (s *GuardedServerStream) SendMsg(message any) error { + if err := s.recheck(); err != nil { + return err + } + if err := s.releaseHeaders(); err != nil { + return err + } + return s.stream.SendMsg(message) +} + +// SendHeader re-checks and sends immediately, together with anything SetHeader +// had queued. gRPC flushes on this call, so the check at this moment is the +// check that matters and no later revocation can recall the frame. +func (s *GuardedServerStream) SendHeader(headers metadata.MD) error { + if err := s.recheck(); err != nil { + return err + } + s.mu.Lock() + if s.finished { + s.mu.Unlock() + return fmt.Errorf("%w: the stream is finished", errStreamMisuse) + } + pending := s.headers + s.headers = metadata.MD{} + s.flushed = true + s.mu.Unlock() + merged := metadata.Join(pending, headers) + return s.stream.SendHeader(merged) +} + +// SetHeader re-checks and HOLDS the metadata in this wrapper. +// +// It used to hand the metadata straight to gRPC on the strength of "a queued +// header is flushed by the first SendMsg or SendHeader, both of which +// re-check". That was false, and measured so: when a handler returns without +// having sent, gRPC's WriteStatus writes the pending header frame before the +// status, so a header queued under authority reached the client after Finish +// had already refused. Held here, it reaches gRPC only through a check. +func (s *GuardedServerStream) SetHeader(headers metadata.MD) error { + if err := s.recheck(); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + if s.finished { + return fmt.Errorf("%w: the stream is finished", errStreamMisuse) + } + if s.flushed { + // gRPC's own contract: headers are sent once. Forward so the caller + // gets gRPC's answer rather than a silent hold that never leaves. + return s.stream.SetHeader(headers) + } + for key, values := range headers { + s.headers[key] = append(s.headers[key], values...) + } + return nil +} + +// SetTrailer re-checks and HOLDS the metadata in this wrapper. Nothing reaches +// gRPC until Finish releases it, because gRPC would otherwise send it when the +// handler returns — which is after any revocation that happened in between. +func (s *GuardedServerStream) SetTrailer(trailer metadata.MD) { + _ = s.setTrailer(trailer) +} + +// setTrailer is SetTrailer with the refusal kept, for the context route: the +// package-level grpc.SetTrailer returns an error, so there a refusal can be +// reported instead of being dropped on the floor by an interface that has +// nowhere to put it. +func (s *GuardedServerStream) setTrailer(trailer metadata.MD) error { + if s == nil { + return fmt.Errorf("%w: unguarded stream", errStreamMisuse) + } + if err := s.recheck(); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + if s.finished { + // Past the last check there is nothing left to authorize against. + return fmt.Errorf("%w: the stream is finished", errStreamMisuse) + } + for key, values := range trailer { + s.trailers[key] = append(s.trailers[key], values...) + } + return nil +} + +// Finish performs the LAST re-check and releases the headers and trailers the +// handler set, or discards them. It is called once, after the handler returns, +// and streamServerInterceptor calls it. +// +// It returns the handler's own error unchanged when authority still holds, and +// a gRPC status when it does not — so a handler that completed under authority +// that has since been withdrawn fails the RPC rather than succeeding with +// metadata nobody authorized. +func (s *GuardedServerStream) Finish(handlerErr error) error { + if s == nil { + return handlerErr + } + err := s.recheck() + s.mu.Lock() + pendingHeaders := s.headers + pendingTrailers := s.trailers + s.headers = metadata.MD{} + s.trailers = metadata.MD{} + flushed := s.flushed + s.finished = true + s.mu.Unlock() + if err != nil { + // Discarded, both of them. Metadata describes the result of work the + // authority has since been withdrawn from, and it is the last thing to + // leave. + return statusFor(err) + } + if len(pendingHeaders) > 0 && !flushed { + _ = s.stream.SetHeader(pendingHeaders) + } + if len(pendingTrailers) > 0 { + s.stream.SetTrailer(pendingTrailers) + } + return handlerErr +} + +// Terminated reports the refusal that ended the stream, or nil. +func (s *GuardedServerStream) Terminated() error { + if s == nil { + return nil + } + return s.guard.Terminated() +} + +func (s *GuardedServerStream) recheck() error { + if s == nil || s.guard == nil || s.stream == nil { + return fmt.Errorf("%w: unguarded stream", errStreamMisuse) + } + // THE RE-CHECK'S CONTEXT REFUSES WRITES, and that is what stops a + // deadlock rather than a convention asking people not to. + // + // Measured: a Recheck that emitted an audit header with + // grpc.SetHeader(ctx, …) hung the handler FOREVER. The write resolved this + // wrapper out of the context, called SetHeader, which called recheck, + // which called BeforeSend — and BeforeSend holds the guard's mutex across + // the re-check, so the nested call blocked on a sync.Mutex its own + // goroutine held. SendMsg never returned. + // + // Releasing that mutex would turn the hang into unbounded recursion, which + // is not better. The honest fix is that a liveness check must not write to + // the stream whose liveness it is deciding — that is circular — so the + // context it runs under answers every write with a refusal naming why. + return s.guard.BeforeSend(grpc.NewContextWithServerTransportStream( + s.Context(), refusingTransportStream{method: s.method()}, + )) +} + +// method is the full method name, for the refusing transport stream's answer. +func (s *GuardedServerStream) method() string { + if s == nil || s.transport == nil { + return "" + } + return s.transport.Method() +} + +// refusingTransportStream is what a re-check's context answers with: it reads +// the method and refuses every write. +// +// A re-check deciding whether the stream may still emit must not emit, and a +// re-check that tries gets an error saying so instead of a deadlock. +type refusingTransportStream struct{ method string } + +func (t refusingTransportStream) Method() string { return t.method } + +func (t refusingTransportStream) SetHeader(metadata.MD) error { return errRecheckMustNotWrite } + +func (t refusingTransportStream) SendHeader(metadata.MD) error { return errRecheckMustNotWrite } + +func (t refusingTransportStream) SetTrailer(metadata.MD) error { return errRecheckMustNotWrite } + +var errRecheckMustNotWrite = fmt.Errorf( + "%w: a Work Context liveness re-check must not write to the stream whose liveness it is deciding", + errStreamMisuse, +) + +// errStreamMisuse is THIS PACKAGE'S OWN misuse, distinct from a capability that +// does not authenticate, and it exists because the two were the same sentinel. +// +// core returns ErrInvalid for an EXPIRED capability (verify.go: "expired at +// …") and for one that is not yet valid. statusFor mapped ErrInvalid to +// codes.Internal on the argument that ErrInvalid also covers misuse — a nil +// guard, a finished stream, an unguarded wrapper — and that telling a client +// "mint again" about a server bug makes it loop. The argument is sound and the +// conclusion was still wrong, because it applied to the WRONG SIDE: expiry is +// not the rare malformed case, it is the ordinary mid-stream refusal on a +// stream that outlives its credential, and it is precisely the one a client +// answers by minting again. Measured: an expired capability arrived as +// Internal. +// +// The distinction the old comment said was unavailable was available all along. +// Every misuse error here is CONSTRUCTED here, so it can say so; what arrives +// from core says nothing extra and is read as a capability refusal. It still +// wraps ErrInvalid, so errors.Is(err, workcontext.ErrInvalid) is unchanged for +// anything that was relying on it. +var errStreamMisuse = fmt.Errorf("%w: guarded stream misuse", workcontext.ErrInvalid) + +// releaseHeaders hands held headers to the real stream. The caller has just +// re-checked, and a SendMsg flushes whatever is queued there, so this is the +// one moment they may cross. +func (s *GuardedServerStream) releaseHeaders() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.flushed { + return nil + } + // FLUSHED IS ABOUT THE SEND, NOT ABOUT THE QUEUE. Returning early when + // nothing was queued left flushed false after a message had gone out — + // and gRPC sends the header frame with the first message whether the + // handler queued anything or not. So a SetHeader AFTER the first SendMsg + // was held in this wrapper, found !flushed, and was handed to + // stream.SetHeader by Finish, which gRPC ignores because the headers are + // already on the wire. Measured over bufconn: SetHeader returned nil, the + // header never reached the client, and the handler was told nothing. + // + // With flushed set here, that SetHeader forwards to gRPC instead and the + // handler gets gRPC's own answer, which is the behaviour SetHeader + // documents for a stream whose headers have been sent. + s.flushed = true + if len(s.headers) == 0 { + return nil + } + pending := s.headers + s.headers = metadata.MD{} + return s.stream.SetHeader(pending) +} + +// guardedTransportStream is what the handler's context answers with, so the +// package-level grpc.SetHeader, grpc.SendHeader and grpc.SetTrailer write +// through the wrapper's checks instead of straight to the transport. +// +// It exists because those three functions were a complete bypass: they take a +// context, resolve the transport stream out of it, and write. Measured over +// bufconn with the guard revoked for the whole call: a header and a trailer +// both reached the client, and the guard was never asked. +type guardedTransportStream struct { + guarded *GuardedServerStream + // inherited is gRPC's own transport stream, kept only for Method. It is + // never written to: every write goes through guarded. + inherited grpc.ServerTransportStream +} + +// Method is the only question a transport stream answers that is not a write, +// so it is the only one forwarded to gRPC's own. +func (t *guardedTransportStream) Method() string { + if t.inherited == nil { + return "" + } + return t.inherited.Method() +} + +func (t *guardedTransportStream) SetHeader(md metadata.MD) error { + return t.guarded.SetHeader(md) +} + +func (t *guardedTransportStream) SendHeader(md metadata.MD) error { + return t.guarded.SendHeader(md) +} + +func (t *guardedTransportStream) SetTrailer(md metadata.MD) error { + return t.guarded.setTrailer(md) +} + +// statusFor turns a guard refusal into a gRPC status a client can act on. +// +// Returning the plain Go error made every refusal arrive as codes.Unknown with +// internal text in the message, so a receiver could not tell "mint again" from +// "the server broke" — which is the whole decision the README's table asks a +// caller to make. The sentinel is what carries that, so it is what the code is +// read off. +func statusFor(err error) error { + code := codes.Unknown + switch { + case err == nil: + return nil + case errors.Is(err, workcontext.ErrRevoked), + errors.Is(err, workcontext.ErrReplayed), + errors.Is(err, workcontext.ErrNotACoreToken): + // The capability no longer authenticates. Unauthenticated is the code + // a client answers by presenting a new one, which is exactly the + // README's "mint again". + code = codes.Unauthenticated + case errors.Is(err, errStreamMisuse): + // THIS SERVER'S OWN MISUSE: a nil guard, a finished stream, an + // unguarded wrapper, a re-check that tried to write. Internal, because + // the client did nothing it can correct and minting again cannot fix a + // server bug. + // + // Checked BEFORE ErrInvalid, which it wraps, so the order of these two + // cases is the whole of the distinction. + code = codes.Internal + case errors.Is(err, workcontext.ErrInvalid): + // A CAPABILITY THAT DOES NOT AUTHENTICATE, which from core includes + // the ordinary one: expired. core answers an expired or not-yet-valid + // capability with ErrInvalid, and a stream that outlives its + // credential is the common case rather than a rare malformed one — so + // Unauthenticated, which is the README's "mint again". + // + // The previous revision mapped every ErrInvalid to Internal to avoid + // telling a client to mint again about a server bug. That cost was + // real and this keeps it: the server's own misuse is a separate + // sentinel above, constructed here, where the difference is known. + code = codes.Unauthenticated + case errors.Is(err, context.Canceled): + code = codes.Canceled + case errors.Is(err, context.DeadlineExceeded): + code = codes.DeadlineExceeded + case errors.Is(err, workcontext.ErrStreamTerminated): + // Terminated for a reason that is not one of core's sentinels: the + // re-check's own source failed. Retryable at the far end, which + // Unavailable is how to say. + code = codes.Unavailable + default: + return err + } + return statusError{err: err, code: code} +} + +// statusError carries a gRPC code to the wire AND keeps the sentinel chain for +// the process that produced it. +// +// status.Error would do the first and lose the second: it builds a fresh error +// with the text flattened in, so errors.Is(err, ErrStreamTerminated) in the +// server's own code — including its tests — stops being true the moment a code +// is attached. grpc-go reads the status off any error implementing +// GRPCStatus(), so implementing that beside Unwrap keeps both. +type statusError struct { + err error + code codes.Code +} + +func (e statusError) Error() string { return e.err.Error() } + +func (e statusError) Unwrap() error { return e.err } + +// GRPCStatus is what grpc-go reads the code and message off. +func (e statusError) GRPCStatus() *status.Status { + return status.New(e.code, e.err.Error()) +} + +// streamServerInterceptor guards every stream a server opens, so enforcement is +// WIRING rather than something each handler author remembers. +// +// UNEXPORTED, and that is the last of the validate/enforce mismatch. It was +// public beside ValidateMethodSet, so the correct way to install this took two +// calls and only one of them was reachable from the type system: a consumer +// wiring the interceptor alone got a server where an undeclared stream reaches +// the handler unguarded, which is exactly what the README recipe did for +// several revisions. GuardStreams is the only way in now, and its Intercept +// refuses every stream until Validate has passed, so there is no public path +// that skips the check. +// +// Guard on its own leaves the original stream in the caller's scope, so the +// rule held wherever somebody thought of it. An interceptor is installed once, +// at the server, and the handler receives a stream it cannot write around: the +// unguarded one never reaches it, the wrapper does not expose it, and the +// context it hands out routes the package-level metadata functions back through +// the checks. +// +// It also calls Finish, which is where the handler's held headers and trailers +// are re-checked and released. A handler that returns successfully under +// authority that was withdrawn while it ran fails the RPC. +// +// guardFor builds the guard for one stream, from whatever the server +// established when the stream opened: typically the capability it verified, so +// the re-check is workcontext.RecheckWith(verifier, verified). Returning a nil +// guard with a nil error means this METHOD carries no capability and needs +// none. +// +// # The capability-bearing method set is DECLARED, not learned +// +// capabilityBearing names every method whose streams carry a capability. It is +// fixed at construction, and guardFor is asked only about those — so a method +// in the set always gets a guard, and a method outside it never does. +// +// The previous revision learned it instead: guardFor was asked per request and +// the FIRST answer for a method was binding. That is trust on first use, and +// it fails the way trust on first use always fails. If the first request to a +// capability-bearing method arrived without a capability — a client mid-deploy, +// a health probe, a retry that lost its metadata — guardFor answered +// (nil, nil), THAT REQUEST GOT THE RAW STREAM, the method was recorded +// unguarded, and every later legitimate request was refused codes.Internal +// until the process restarted. One early request could both bypass the guard +// and disable the method. +// +// Declaring the set removes the learning. A server that cannot enumerate its +// capability-bearing methods does not know which of its streams carry +// authority, which is the thing to fix before installing an interceptor. +func streamServerInterceptor( + capabilityBearing []string, + guardFor func(ctx context.Context, info *grpc.StreamServerInfo) (*workcontext.StreamGuard, error), +) grpc.StreamServerInterceptor { + guarded := make(map[string]bool, len(capabilityBearing)) + for _, method := range capabilityBearing { + guarded[method] = true + } + return func( + server any, + stream grpc.ServerStream, + info *grpc.StreamServerInfo, + handler grpc.StreamHandler, + ) error { + if guardFor == nil { + return statusFor(fmt.Errorf( + "%w: a stream interceptor needs a guard for each stream", + errStreamMisuse, + )) + } + if len(guarded) == 0 { + // AN EMPTY SET GUARDS NOTHING, so it refuses everything instead. + // + // streamServerInterceptor(nil, guardFor) read as "no method carries + // a capability" and passed every stream through unguarded — + // measured, a message delivered under revoked authority with zero + // re-checks. An interceptor installed to enforce something, that + // enforces nothing, is the worst available outcome: the wiring is + // there, so nobody looks again. + // + // "Guard no method" is not a thing to say by omission. A server + // with no capability-bearing streams installs no interceptor. + // Through statusFor, like every other refusal here: returned bare, + // a misconfiguration arrived as codes.Unknown with internal text, + // which is the thing statusFor exists to stop. + return statusFor(fmt.Errorf( + "%w: a stream interceptor was installed with an empty capability-bearing "+ + "method set, which would guard nothing; name the methods, or install no "+ + "interceptor", + errStreamMisuse, + )) + } + method := "" + if info != nil { + method = info.FullMethod + } + if !guarded[method] { + // Declared as carrying no capability. The decision was made at + // construction, where a reviewer can read it, rather than inferred + // from whichever request happened to arrive first. + // + // A METHOD MISSING FROM THE SET IS STILL UNGUARDED HERE, and that + // is what the set means — but a NAME that matches nothing the + // server serves is a typo, not a decision, and this cannot tell + // the difference from inside one request. ValidateMethodSet is how + // a server catches it at startup, and the README says to call it. + return handler(server, stream) + } + guard, err := guardFor(stream.Context(), info) + if err != nil { + return statusFor(err) + } + if guard == nil { + // The method IS declared capability-bearing and guardFor produced + // no guard. That is a refusal, not a passthrough: the previous + // revision handed this request the raw stream. + return status.Errorf(codes.Unauthenticated, + "%s carries a Work Context and no guard could be built for this stream", + method, + ) + } + guarded, err := Guard(stream, guard) + if err != nil { + return err + } + return guarded.Finish(handler(server, guarded)) + } +} + +// ValidateMethodSet refuses a declared method name the server does not serve. +// +// It exists because a TYPO SILENTLY UNGUARDS A METHOD. The capability-bearing +// set is matched against grpc's full method name, so +// "/codefly.Streamer/Emmit" guards nothing at all and looks exactly like a +// method that was considered and declared — measured, a message delivered +// under revoked authority with zero re-checks, and nothing anywhere said so. +// +// The interceptor cannot catch this from inside a request: "this method is not +// in the set" and "this method is in the set under another spelling" are the +// same observation there. The server knows, so the server is asked, once, at +// startup: +// +// streams := grpctransport.GuardStreams(bearing, guardFor) +// server := grpc.NewServer(grpc.StreamInterceptor(streams.Intercept)) +// pb.RegisterStreamerServer(server, impl) +// if err := streams.Validate(server.GetServiceInfo(), unguarded...); err != nil { +// return err +// } +// +// Call it AFTER registering services and BEFORE Serve. It is a function rather +// than something the interceptor does because grpc hands the interceptor no +// server, and inventing a registration hook to reach one would be a bigger +// surface than a call a server makes. +func ValidateMethodSet( + served map[string]grpc.ServiceInfo, + capabilityBearing []string, + deliberatelyUnguarded ...string, +) error { + // STREAMING METHODS ONLY. Every grpc.MethodInfo went into this map, + // ignoring IsClientStream and IsServerStream — so for a service with a + // unary Read and a streaming Emit, declaring only "/Service/Read" passed + // validation, produced a non-empty guarded set, and let Emit reach the raw + // handler without guardFor ever being called. That is precisely the + // installed-interceptor-that-guards-nothing configuration the empty-set + // refusal exists to reject, reached by naming a method that cannot stream. + known := map[string]bool{} + unary := map[string]bool{} + for service, info := range served { + for _, method := range info.Methods { + full := "/" + service + "/" + method.Name + if method.IsClientStream || method.IsServerStream { + known[full] = true + continue + } + unary[full] = true + } + } + var unknown, notStreaming []string + for _, method := range capabilityBearing { + switch { + case known[method]: + case unary[method]: + notStreaming = append(notStreaming, method) + default: + unknown = append(unknown, method) + } + } + + // AND THE OTHER DIRECTION, which is the fail-open this validation did not + // reach: a STREAMING method the server serves and the set does not name + // gets the raw handler, with guardFor never called. The set was checked + // against the server and the server was never checked against the set. + // + // Deny by default, as everywhere else here: every served streaming method + // is either capability-bearing or NAMED as deliberately unguarded. A + // stream that carries no authority is a real thing and this does not force + // it to be guarded — it forces somebody to have said so, once, where a + // reader sees it, instead of a method being unguarded because nobody + // thought about it. + declared := map[string]bool{} + for _, method := range capabilityBearing { + declared[method] = true + } + for _, method := range deliberatelyUnguarded { + declared[method] = true + } + var unconsidered []string + for method := range known { + if !declared[method] { + unconsidered = append(unconsidered, method) + } + } + + // ALL THREE AT ONCE. Returning on the first meant a server with both a + // typo and an unconsidered stream was told about one of them, fixed it, + // and met the other on the next run — and a test that asserted the typo's + // message started failing because a different true finding came out first. + sort.Strings(unknown) + sort.Strings(notStreaming) + sort.Strings(unconsidered) + var problems []string + if len(unknown) > 0 { + problems = append(problems, fmt.Sprintf( + "these methods are declared capability-bearing and this server does not serve "+ + "them: %v (a name that matches nothing guards nothing, and reads exactly like "+ + "a method that was considered)", unknown)) + } + if len(notStreaming) > 0 { + problems = append(problems, fmt.Sprintf( + "these are declared capability-bearing for a STREAM interceptor and are unary on "+ + "this server: %v (a unary name makes the set non-empty and guards nothing)", + notStreaming)) + } + if len(unconsidered) > 0 { + problems = append(problems, fmt.Sprintf( + "this server serves these streaming methods and nothing names them: %v (a stream "+ + "missing from the set reaches the handler UNGUARDED; name it as "+ + "capability-bearing, or pass it as deliberately unguarded — the decision is "+ + "what has to exist, not the guard)", unconsidered)) + } + if len(known) == 0 { + problems = append(problems, "this server registers no streaming method at all, so a "+ + "stream interceptor has nothing to guard") + } + if len(capabilityBearing) == 0 { + problems = append(problems, "no method is declared capability-bearing, so the "+ + "interceptor would guard nothing") + } + if len(problems) > 0 { + return fmt.Errorf("%w: %s", errStreamMisuse, strings.Join(problems, "; ")) + } + return nil +} + +// GuardedStreams is the stream interceptor with its validation MADE MANDATORY. +// +// streamServerInterceptor plus ValidateMethodSet was correct and optional, and +// optional is the whole of the finding: the README recipe did not call the +// validation for several revisions, so a consumer following it kept the +// fail-open the validation exists to close, and nothing anywhere said so. A +// safety check a caller may skip is a safety check for whoever remembers it. +// +// So it is not skippable here. Intercept REFUSES EVERY STREAM until Validate +// has been called and passed: +// +// streams := grpctransport.GuardStreams(bearing, guardFor) +// server := grpc.NewServer(grpc.StreamInterceptor(streams.Intercept)) +// pb.RegisterStreamerServer(server, impl) +// if err := streams.Validate(server.GetServiceInfo(), unguarded...); err != nil { +// return err +// } +// // only now does the server serve +// +// The ordering is forced by grpc itself — the interceptor is built before +// registration and the served method set exists only after it — so the one +// thing a library can do is make the gap LOUD instead of silent. A server that +// forgets refuses every stream on the first request, which is a failure found +// in the first test rather than a method that was never guarded. +// +// There is no longer a public way to build the interceptor WITHOUT this: the +// previous revision exported both and a consumer could wire the interceptor +// alone, which is the mismatch a review kept naming. One entry point, and the +// validation is part of it. +type GuardedStreams struct { + bearing []string + intercept grpc.StreamServerInterceptor + validated atomic.Bool +} + +// GuardStreams builds the interceptor and the validation together. +func GuardStreams( + capabilityBearing []string, + guardFor func(ctx context.Context, info *grpc.StreamServerInfo) (*workcontext.StreamGuard, error), +) *GuardedStreams { + return &GuardedStreams{ + bearing: capabilityBearing, + intercept: streamServerInterceptor(capabilityBearing, guardFor), + } +} + +// Validate asks the server what it serves and records that the answer held. +func (g *GuardedStreams) Validate( + served map[string]grpc.ServiceInfo, + deliberatelyUnguarded ...string, +) error { + if err := ValidateMethodSet(served, g.bearing, deliberatelyUnguarded...); err != nil { + return err + } + g.validated.Store(true) + return nil +} + +// Intercept is the interceptor, and it refuses every stream until Validate has +// passed. +func (g *GuardedStreams) Intercept( + server any, + stream grpc.ServerStream, + info *grpc.StreamServerInfo, + handler grpc.StreamHandler, +) error { + if !g.validated.Load() { + return statusFor(fmt.Errorf( + "%w: this stream interceptor has not been validated against the server's method "+ + "set, so it does not know whether any stream it is not guarding was meant to "+ + "be unguarded. Call Validate(server.GetServiceInfo(), …) after registering "+ + "services and before Serve", + errStreamMisuse, + )) + } + return g.intercept(server, stream, info, handler) +} diff --git a/workcontext/grpctransport/guarded_stream_test.go b/workcontext/grpctransport/guarded_stream_test.go new file mode 100644 index 0000000..94b5ac4 --- /dev/null +++ b/workcontext/grpctransport/guarded_stream_test.go @@ -0,0 +1,330 @@ +package grpctransport + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + + "github.com/codefly-dev/sdk-go/workcontext" +) + +// recordingStream is a grpc.ServerStream that records what was sent, so a test +// can assert that a message did NOT leave. +type recordingStream struct { + grpc.ServerStream + ctx context.Context + sent []any + headers int + trailers int +} + +func (s *recordingStream) Context() context.Context { return s.ctx } + +func (s *recordingStream) SendMsg(message any) error { + s.sent = append(s.sent, message) + return nil +} + +func (s *recordingStream) SetHeader(metadata.MD) error { + s.headers++ + return nil +} + +func (s *recordingStream) SendHeader(metadata.MD) error { + s.headers++ + return nil +} + +func (s *recordingStream) SetTrailer(metadata.MD) { s.trailers++ } +func (s *recordingStream) RecvMsg(any) error { return nil } + +// A guarded stream re-checks before EVERY message, and a refused message does +// not leave. +// +// This is the half a StreamGuard on its own cannot provide. BeforeSend has to +// be called, and "before each emission" was therefore something each author +// had to remember — the same shape as the optional carrier this PR deleted, a +// rule that holds wherever somebody thought of it. Here Send IS the re-check +// followed by the send, so a handler cannot emit without it. +func TestAGuardedStreamRechecksBeforeEveryMessage(t *testing.T) { + rechecks := 0 + refuse := false + guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { + rechecks++ + if refuse { + return workcontext.ErrRevoked + } + return nil + }, + }) + require.NoError(t, err) + + underlying := &recordingStream{ctx: context.Background()} + guarded, err := Guard(underlying, guard) + require.NoError(t, err) + + for message := 1; message <= 3; message++ { + require.NoError(t, guarded.SendMsg(message)) + require.Equal(t, message, rechecks, "message %d re-presented the credential", message) + require.Len(t, underlying.sent, message) + } + + // The authority is withdrawn with no time passing at all. The very next + // message is refused AND NOT SENT. + refuse = true + err = guarded.SendMsg("the message that must not leave") + require.ErrorIs(t, err, workcontext.ErrStreamTerminated) + require.ErrorIs(t, err, workcontext.ErrRevoked) + require.Len(t, underlying.sent, 3, + "a message computed under withdrawn authority must not reach the wire") + + // And it stays terminated: a handler looping past the first refusal does + // not get a second chance to emit, and the authority coming back does not + // resurrect the stream. + refuse = false + for range 3 { + require.ErrorIs(t, guarded.SendMsg("still refused"), workcontext.ErrStreamTerminated) + } + require.Len(t, underlying.sent, 3) + require.Equal(t, 4, rechecks, "a terminated stream asks nothing further") + require.ErrorIs(t, guarded.Terminated(), workcontext.ErrStreamTerminated) +} + +// An unreachable live source terminates the stream too. "I could not ask" is +// not "the credential is good", and a stream that treated it as one would +// outlive its authority with clean logs. +func TestAGuardedStreamTerminatesWhenTheLiveSourceIsUnavailable(t *testing.T) { + unavailable := errors.New("installation state store unreachable") + guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { return unavailable }, + }) + require.NoError(t, err) + + underlying := &recordingStream{ctx: context.Background()} + guarded, err := Guard(underlying, guard) + require.NoError(t, err) + + err = guarded.SendMsg("anything") + require.ErrorIs(t, err, workcontext.ErrStreamTerminated) + require.ErrorIs(t, err, unavailable) + require.Empty(t, underlying.sent) +} + +// A wrapper that silently did nothing would be worse than no wrapper, because +// the call site would read as guarded. +func TestGuardRefusesToWrapNothing(t *testing.T) { + guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { return nil }, + }) + require.NoError(t, err) + + _, err = Guard(nil, guard) + require.ErrorIs(t, err, workcontext.ErrInvalid) + + _, err = Guard(&recordingStream{ctx: context.Background()}, nil) + require.ErrorIs(t, err, workcontext.ErrInvalid) + + var absent *GuardedServerStream + require.ErrorIs(t, absent.SendMsg("x"), workcontext.ErrInvalid) + require.NoError(t, absent.Terminated()) +} + +// Metadata is a channel out too, and a TRAILER leaves when the handler returns +// rather than when SetTrailer is called — so it is held in the wrapper until +// Finish re-checks and releases it. +func TestAGuardedStreamHoldsTrailersUntilFinishRechecks(t *testing.T) { + refuse := false + guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { + if refuse { + return workcontext.ErrRevoked + } + return nil + }, + }) + require.NoError(t, err) + + underlying := &recordingStream{ctx: context.Background()} + guarded, err := Guard(underlying, guard) + require.NoError(t, err) + + // SetHeader HOLDS: gRPC flushes a pending header frame when the handler + // returns, so handing it over at queue time let a header leave after a + // revocation that happened in between. Only SendHeader reaches gRPC, and + // it carries what SetHeader queued. + require.NoError(t, guarded.SetHeader(metadata.Pairs("a", "1"))) + require.Zero(t, underlying.headers, "a queued header must not reach gRPC before a check") + require.NoError(t, guarded.SendHeader(metadata.Pairs("b", "2"))) + require.Equal(t, 1, underlying.headers) + + // Set under authority, and NOT yet handed to gRPC: gRPC would send it when + // the handler returned, which is after any revocation in between. + guarded.SetTrailer(metadata.Pairs("c", "3")) + require.Zero(t, underlying.trailers, + "a trailer must not reach gRPC before the last check") + + // Authority is withdrawn after the handler queued it. Finish discards. + refuse = true + require.ErrorIs(t, guarded.Finish(nil), workcontext.ErrStreamTerminated) + require.Zero(t, underlying.trailers, + "a trailer describes work whose authority was withdrawn; it is dropped") + require.ErrorIs(t, guarded.SetHeader(metadata.Pairs("d", "4")), workcontext.ErrStreamTerminated) +} + +// And when authority holds, Finish releases them and returns the handler's own +// error unchanged. +func TestFinishReleasesTrailersWhenAuthorityHolds(t *testing.T) { + guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { return nil }, + }) + require.NoError(t, err) + underlying := &recordingStream{ctx: context.Background()} + guarded, err := Guard(underlying, guard) + require.NoError(t, err) + + guarded.SetTrailer(metadata.Pairs("c", "3")) + guarded.SetTrailer(metadata.Pairs("d", "4")) + handlerErr := errors.New("the handler's own failure") + require.ErrorIs(t, guarded.Finish(handlerErr), handlerErr, + "Finish reports the handler's result, not its own") + require.Equal(t, 1, underlying.trailers, + "the pending trailers are released together, once") +} + +// The wrapper must expose NO route to the stream it wraps. +// +// It used to embed grpc.ServerStream as a public field, so a handler given only +// the intercepted stream could write stream.(*GuardedServerStream).ServerStream +// and send with no check at all. This asserts the shape rather than the +// behaviour, because the behaviour it prevents would not compile: no exported +// field, and nothing exported that hands back a grpc.ServerStream. +func TestTheWrapperExposesNoRouteToTheRawStream(t *testing.T) { + wrapper := reflect.TypeOf(GuardedServerStream{}) + serverStream := reflect.TypeOf((*grpc.ServerStream)(nil)).Elem() + + for field := range wrapper.NumField() { + declared := wrapper.Field(field) + require.False(t, declared.IsExported(), + "%s is exported; a handler can reach it and bypass the guard", declared.Name) + require.False(t, declared.Anonymous && declared.Type == serverStream, + "the stream must not be embedded: embedding is what made it reachable") + } + + pointer := reflect.TypeOf(&GuardedServerStream{}) + for method := range pointer.NumMethod() { + declared := pointer.Method(method) + for result := range declared.Type.NumOut() { + require.NotEqual(t, serverStream, declared.Type.Out(result), + "%s returns a grpc.ServerStream, which is a route around the guard", + declared.Name) + } + } + + // And it still satisfies the interface it replaces, by forwarding rather + // than embedding. + require.Implements(t, (*grpc.ServerStream)(nil), &GuardedServerStream{}) +} + +// The interceptor is the half a wrapper cannot provide: enforcement as wiring. +// +// Guard leaves the original stream in the handler's scope, so "guard your +// stream" was advice. Installed at the server, the handler receives a stream it +// cannot write around, because the unguarded one never reaches it. +func TestTheStreamInterceptorHandsTheHandlerAGuardedStream(t *testing.T) { + refuse := false + guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { + if refuse { + return workcontext.ErrRevoked + } + return nil + }, + }) + require.NoError(t, err) + + underlying := &recordingStream{ctx: context.Background()} + interceptor := streamServerInterceptor( + []string{streamMethod}, + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + return guard, nil + }) + + var handed grpc.ServerStream + err = interceptor(nil, underlying, &grpc.StreamServerInfo{FullMethod: streamMethod}, + func(_ any, stream grpc.ServerStream) error { + handed = stream + require.NoError(t, stream.SendMsg("first")) + refuse = true + return stream.SendMsg("the one that must not leave") + }) + require.ErrorIs(t, err, workcontext.ErrStreamTerminated) + require.IsType(t, &GuardedServerStream{}, handed, + "the handler must not be able to receive the unguarded stream") + require.Len(t, underlying.sent, 1) + require.NotImplements(t, (*interface{ ServerStream() grpc.ServerStream })(nil), handed) +} + +// The capability-bearing method set is DECLARED at construction, and a method +// in it is guarded or the stream is refused — never passed through. +// +// The previous revision learned the set from the first request, which is trust +// on first use: a first request without a capability got the RAW STREAM, the +// method was recorded unguarded, and every later legitimate request was +// refused until restart. One early request both bypassed the guard and +// disabled the method. +func TestTheStreamInterceptorRefusesWhenItCannotBuildAGuard(t *testing.T) { + underlying := &recordingStream{ctx: context.Background()} + refused := errors.New("this capability did not verify") + bearing := &grpc.StreamServerInfo{FullMethod: streamMethod} + + err := streamServerInterceptor( + []string{streamMethod}, + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + return nil, refused + })(nil, underlying, bearing, func(any, grpc.ServerStream) error { + t.Fatal("the handler must not run when the stream could not be guarded") + return nil + }) + require.ErrorIs(t, err, refused) + + // A DECLARED capability-bearing method whose guard could not be built is + // REFUSED. This used to be the passthrough that handed out the raw stream. + err = streamServerInterceptor( + []string{streamMethod}, + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + return nil, nil + })(nil, underlying, bearing, func(any, grpc.ServerStream) error { + t.Fatal("a capability-bearing method must not run unguarded") + return nil + }) + require.Equal(t, codes.Unauthenticated, status.Code(err)) + + // A method OUTSIDE the declared set is not guarded and never asks: the + // decision is in the construction, where a reviewer reads it. + var handed grpc.ServerStream + err = streamServerInterceptor( + []string{streamMethod}, + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + t.Fatal("guardFor must not be asked about a method outside the declared set") + return nil, nil + })(nil, underlying, &grpc.StreamServerInfo{FullMethod: "/other.Service/Ping"}, + func(_ any, stream grpc.ServerStream) error { + handed = stream + return nil + }) + require.NoError(t, err) + require.Same(t, underlying, handed) + + // And no guard-builder at all is a configuration error, not a pass. + err = streamServerInterceptor([]string{streamMethod}, nil)( + nil, underlying, bearing, func(any, grpc.ServerStream) error { return nil }) + require.ErrorIs(t, err, workcontext.ErrInvalid) +} diff --git a/workcontext/grpctransport/real_grpc_stream_test.go b/workcontext/grpctransport/real_grpc_stream_test.go new file mode 100644 index 0000000..ec00aa5 --- /dev/null +++ b/workcontext/grpctransport/real_grpc_stream_test.go @@ -0,0 +1,243 @@ +package grpctransport + +import ( + "context" + "net" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/types/known/wrapperspb" + + "github.com/codefly-dev/sdk-go/workcontext" +) + +// A REAL gRPC server and client, over bufconn, with the interceptor installed. +// +// The dynamic review was right that a recording stream's call count is not +// evidence here: what a trailer does is decided by gRPC, not by the wrapper. +// gRPC sends trailers when the HANDLER RETURNS, so a handler could set one +// under authority, have authority revoked, return, and the client received it +// — through the ordinary guarded API, with the interceptor installed and +// nothing unwrapped. Only a real client can show that. + +const ( + streamService = "codefly.test.Streamer" + streamMethod = "/codefly.test.Streamer/Emit" +) + +// revocableGuard is a stream guard whose authority a test can withdraw at an +// exact moment, counting the checks it performs. +type revocableGuard struct { + revoked atomic.Bool + checks atomic.Uint64 +} + +func (g *revocableGuard) guard(t *testing.T) *workcontext.StreamGuard { + t.Helper() + built, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { + g.checks.Add(1) + if g.revoked.Load() { + return workcontext.ErrRevoked + } + return nil + }, + }) + require.NoError(t, err) + return built +} + +// serveGuarded stands up a real gRPC server whose one streaming method runs +// handler behind streamServerInterceptor, and returns a client connection. +func serveGuarded( + t *testing.T, + guard *workcontext.StreamGuard, + handler func(stream grpc.ServerStream) error, +) *grpc.ClientConn { + t.Helper() + return serveWith(t, streamServerInterceptor( + []string{streamMethod}, + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + return guard, nil + }), handler) +} + +// serveWith is serveGuarded with the interceptor supplied, so a test can drive +// the interceptor's own decisions rather than only the wrapper's. +func serveWith( + t *testing.T, + interceptor grpc.StreamServerInterceptor, + handler func(stream grpc.ServerStream) error, +) *grpc.ClientConn { + t.Helper() + listener := bufconn.Listen(1024 * 1024) + server := grpc.NewServer(grpc.StreamInterceptor(interceptor)) + server.RegisterService(&grpc.ServiceDesc{ + ServiceName: streamService, + HandlerType: (*any)(nil), + Streams: []grpc.StreamDesc{{ + StreamName: "Emit", + ServerStreams: true, + Handler: func(_ any, stream grpc.ServerStream) error { + return handler(stream) + }, + }}, + }, new(any)) + + served := make(chan struct{}) + go func() { + defer close(served) + _ = server.Serve(listener) + }() + t.Cleanup(func() { + server.Stop() + <-served + }) + + connection, err := grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return listener.DialContext(ctx) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = connection.Close() }) + return connection +} + +// drain runs the stream to completion and reports the message count, the +// trailer and the client's error. +// +// It delegates to clientSaw rather than opening the stream a second way. There +// were two helpers doing this, each with its own hand-built StreamDesc and its +// own receive loop, and they disagreed: this one counted messages and never +// read the header, clientSaw read the header and dropped the count. A test +// needing both got one of them and a local variable, which is how +// `received := 0; require.Zero(received)` came to stand where the file's +// central assertion belongs. +// +// WHAT THESE TESTS ESTABLISH, since the count was not the only claim running +// ahead of its evidence: the TRANSPORT's behaviour — what gRPC does with +// messages, headers and trailers when the guard refuses, with the interceptor +// installed and nothing unwrapped. The authority is a revocableGuard, a Recheck +// closure a test flips; so these do NOT establish that core's own refusals +// reach the guard, which is what TestWorkContextConformance drives through +// core's fixtures. And the stream is server-streaming with CloseSend called at +// once, so nothing here says anything about the client-streaming direction. +func drain(t *testing.T, connection *grpc.ClientConn) (received int, trailer metadata.MD, err error) { + t.Helper() + received, _, trailer, err = clientSaw(t, connection) + return received, trailer, err +} + +// (1) A trailer queued under authority must NOT reach the client when +// authority is withdrawn before the handler returns. +func TestARealClientNeverReceivesATrailerAuthorityWasWithdrawnFrom(t *testing.T) { + authority := &revocableGuard{} + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + // Queued under authority, which holds at this instant. + stream.SetTrailer(metadata.Pairs("confidential-result", "tenant-data")) + // And withdrawn before the handler returns, which is when gRPC would + // otherwise send it. + authority.revoked.Store(true) + return nil + }) + + received, trailer, err := drain(t, connection) + require.Error(t, err, "a handler that completed under withdrawn authority must fail the RPC") + require.Zero(t, received) + require.Empty(t, trailer.Get("confidential-result"), + "the client received a trailer whose authority had been withdrawn") +} + +// And the same trailer DOES arrive when authority holds all the way through, +// so the guard is not simply swallowing everything. +func TestARealClientReceivesTheTrailerWhenAuthorityHolds(t *testing.T) { + authority := &revocableGuard{} + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + stream.SetTrailer(metadata.Pairs("result", "ok")) + return stream.SendMsg(wrapperspb.String("one")) + }) + + received, trailer, err := drain(t, connection) + require.NoError(t, err) + require.Equal(t, 1, received) + require.Equal(t, []string{"ok"}, trailer.Get("result")) + require.Positive(t, int(authority.checks.Load())) +} + +// (2) A handler holding only the intercepted stream has no route to the raw +// one, so a message sent under revoked authority does not reach the client. +// +// The probe that found this recovered the embedded field and delivered +// `message under revoked authority` with ZERO checks. There is no embedded +// field now, so what the handler can attempt is the guarded API — and every +// attempt is refused. +func TestARealHandlerCannotSendUnderRevokedAuthority(t *testing.T) { + authority := &revocableGuard{} + authority.revoked.Store(true) + + var attempts, refusals int + faults := &handlerFaults{} + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + // Whatever this handler reaches for, it is the wrapper. Recorded + // rather than asserted: require inside a handler calls FailNow on the + // SERVER's goroutine, which stops that goroutine and lets the test + // carry on, so a failed assertion here could pass the test. + if _, ok := stream.(*GuardedServerStream); !ok { + faults.record("the handler was handed %T, not the wrapper", stream) + } + for range 3 { + attempts++ + if err := stream.SendMsg(wrapperspb.String("message under revoked authority")); err != nil { + refusals++ + } + } + // Every route out, not only SendMsg: a queued header and a queued + // trailer under revoked authority. + if err := stream.SetHeader(metadata.Pairs("leaked-header", "yes")); err == nil { + faults.record("SetHeader was accepted under revoked authority") + } + stream.SetTrailer(metadata.Pairs("leaked", "yes")) + return nil + }) + + received, header, trailer, err := clientSaw(t, connection) + faults.assert(t) + require.Error(t, err) + require.Empty(t, header.Get("leaked-header")) + // THE MESSAGE COUNT THE CLIENT ACTUALLY RECEIVED. This read + // `received := 0; require.Zero(received)` — the assertion that mattered + // most in this file, against a literal the line above it had just written. + require.Zero(t, received, "a message left under revoked authority") + require.Empty(t, trailer.Get("leaked")) + require.Equal(t, attempts, refusals, "every send under revoked authority was refused") + require.Positive(t, int(authority.checks.Load()), "and every one of them was checked") +} + +// Revocation mid-stream stops the stream at the next message, over a real +// connection: the first messages arrive, the rest do not. +func TestARealStreamStopsAtTheMessageAfterRevocation(t *testing.T) { + authority := &revocableGuard{} + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + for message := range 10 { + if message == 3 { + authority.revoked.Store(true) + } + if err := stream.SendMsg(wrapperspb.String("payload")); err != nil { + return err + } + } + return nil + }) + + received, _, err := drain(t, connection) + require.Error(t, err) + require.Equal(t, 3, received, + "three messages were sent under authority and the fourth was refused") +} diff --git a/workcontext/grpctransport/round4_reproducers_test.go b/workcontext/grpctransport/round4_reproducers_test.go new file mode 100644 index 0000000..7a6ba8a --- /dev/null +++ b/workcontext/grpctransport/round4_reproducers_test.go @@ -0,0 +1,478 @@ +package grpctransport + +import ( + "context" + "errors" + "fmt" + "io" + "reflect" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/wrapperspb" + + "github.com/codefly-dev/sdk-go/workcontext" +) + +// The round-four review's two blockers, as the bufconn probes it could not run. +// +// Both were predicted statically and both reproduced on the first attempt at +// the previous revision (ffaa5c0): +// +// PROBE 1 header=[secret] — a header queued with SetHeader under authority +// reached the client after Finish had refused. +// PROBE 2 transport stream in ctx: true; SendHeader err=; +// header=[leaked] trailer=[leaked] — the package-level functions on +// stream.Context() delivered both, with the guard revoked for the +// whole call and NOT ONE re-check performed. +// +// They live here rather than beside the happy-path tests because what they +// assert is about gRPC's behaviour, not the wrapper's: only a real client can +// say what left. + +// clientSaw runs the stream to completion and reports the headers AND the +// trailers the client received, which is the thing the previous round's tests +// never read. +func clientSaw(t *testing.T, connection *grpc.ClientConn) (received int, header metadata.MD, trailer metadata.MD, err error) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + stream, streamErr := connection.NewStream(ctx, + &grpc.StreamDesc{StreamName: "Emit", ServerStreams: true}, streamMethod) + require.NoError(t, streamErr) + require.NoError(t, stream.CloseSend()) + var recvErr error + for { + recvErr = stream.RecvMsg(&wrapperspb.StringValue{}) + if recvErr != nil { + break + } + // COUNTED, and that is the whole of a review finding: this helper + // dropped every message it received, so the one test that cared wrote + // `received := 0` beside it and asserted require.Zero on its own + // literal. A tautology in the place where "no message left under + // revoked authority" was supposed to be established. + received++ + } + if errors.Is(recvErr, io.EOF) { + recvErr = nil + } + // Header() blocks until headers arrive or the stream ends, so it is read + // after the receive loop has finished. + header, _ = stream.Header() + return received, header, stream.Trailer(), recvErr +} + +// handlerFaults collects what a handler observed, so nothing calls require on +// the server's goroutine. +// +// The review was right about this: require.IsType inside the handler calls +// FailNow off the test goroutine, which Go's testing package documents as +// undefined — it stops that goroutine and the test keeps going, so a failed +// assertion in a handler could pass the test. +type handlerFaults struct { + mu sync.Mutex + faults []string +} + +func (f *handlerFaults) record(format string, args ...any) { + f.mu.Lock() + defer f.mu.Unlock() + f.faults = append(f.faults, fmt.Sprintf(format, args...)) +} + +func (f *handlerFaults) assert(t *testing.T) { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + require.Empty(t, f.faults, "the handler observed something it should not have") +} + +// (1) A header QUEUED with SetHeader must not reach the client when authority +// is withdrawn before the handler returns. +// +// gRPC flushes a pending header frame from WriteStatus when the handler +// returns, which is after any revocation in between. The wrapper's comment +// used to argue the opposite — that only SendMsg and SendHeader flush, and +// both re-check. Measured: [secret] arrived. +func TestARealClientNeverReceivesAHeaderAuthorityWasWithdrawnFrom(t *testing.T) { + authority := &revocableGuard{} + faults := &handlerFaults{} + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + if err := stream.SetHeader(metadata.Pairs("queued-header", "secret")); err != nil { + faults.record("SetHeader under authority was refused: %v", err) + } + authority.revoked.Store(true) + return nil + }) + + _, header, trailer, err := clientSaw(t, connection) + faults.assert(t) + require.Error(t, err, "a handler that completed under withdrawn authority must fail the RPC") + require.Empty(t, header.Get("queued-header"), + "the client received a header whose authority had been withdrawn") + require.Empty(t, trailer.Get("queued-header")) +} + +// And a queued header DOES arrive when authority holds all the way through, so +// holding it is not quietly dropping it. +func TestARealClientReceivesTheQueuedHeaderWhenAuthorityHolds(t *testing.T) { + authority := &revocableGuard{} + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + return stream.SetHeader(metadata.Pairs("queued-header", "fine")) + }) + + _, header, _, err := clientSaw(t, connection) + require.NoError(t, err) + require.Equal(t, []string{"fine"}, header.Get("queued-header")) +} + +// (2) The package-level grpc.SetHeader, grpc.SendHeader and grpc.SetTrailer, +// called on the stream's context, must route through the checks. +// +// They resolve a grpc.ServerTransportStream out of the context and write to +// the transport. Measured at the previous revision: with the guard revoked for +// the whole call, grpc.SendHeader returned nil, and both the header and the +// trailer reached the client having been checked zero times. +func TestTheContextRouteCannotWriteUnderRevokedAuthority(t *testing.T) { + authority := &revocableGuard{} + authority.revoked.Store(true) + faults := &handlerFaults{} + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + ctx := stream.Context() + if grpc.ServerTransportStreamFromContext(ctx) == nil { + faults.record("no transport stream in the handler's context: the probe proves nothing") + } + if err := grpc.SetTrailer(ctx, metadata.Pairs("ctx-trailer", "leaked")); err == nil { + faults.record("grpc.SetTrailer was accepted under revoked authority") + } + if err := grpc.SetHeader(ctx, metadata.Pairs("ctx-set-header", "leaked")); err == nil { + faults.record("grpc.SetHeader was accepted under revoked authority") + } + if err := grpc.SendHeader(ctx, metadata.Pairs("ctx-header", "leaked")); err == nil { + faults.record("grpc.SendHeader was accepted under revoked authority") + } + return nil + }) + + _, header, trailer, err := clientSaw(t, connection) + faults.assert(t) + require.Error(t, err) + require.Empty(t, trailer.Get("ctx-trailer"), "a context-route trailer reached the client") + require.Empty(t, header.Get("ctx-header"), "a context-route header reached the client") + require.Empty(t, header.Get("ctx-set-header"), "a context-route queued header reached the client") + require.Positive(t, int(authority.checks.Load()), + "the context route performed no re-check at all") +} + +// The context route works normally under authority — it is guarded, not +// disabled. A handler using the package-level functions is an ordinary handler. +func TestTheContextRouteDeliversUnderAuthority(t *testing.T) { + authority := &revocableGuard{} + faults := &handlerFaults{} + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + ctx := stream.Context() + if err := grpc.SetHeader(ctx, metadata.Pairs("ctx-header", "ok")); err != nil { + faults.record("grpc.SetHeader under authority: %v", err) + } + if err := grpc.SetTrailer(ctx, metadata.Pairs("ctx-trailer", "ok")); err != nil { + faults.record("grpc.SetTrailer under authority: %v", err) + } + return stream.SendMsg(wrapperspb.String("one")) + }) + + _, header, trailer, err := clientSaw(t, connection) + faults.assert(t) + require.NoError(t, err) + require.Equal(t, []string{"ok"}, header.Get("ctx-header")) + require.Equal(t, []string{"ok"}, trailer.Get("ctx-trailer")) +} + +// grpc.Method still answers, so replacing the context's transport stream did +// not take away the one thing it answers that is not a write. +func TestTheGuardedContextStillAnswersTheMethod(t *testing.T) { + authority := &revocableGuard{} + observed := make(chan string, 1) + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + method, _ := grpc.Method(stream.Context()) + observed <- method + return nil + }) + + _, _, _, err := clientSaw(t, connection) + require.NoError(t, err) + require.Equal(t, streamMethod, <-observed) +} + +// A revoked stream reaches the client as codes.Unauthenticated, which is the +// code a client answers by minting again — not codes.Unknown, which is what a +// plain Go error returned from an interceptor becomes. +func TestRevocationReachesTheClientAsUnauthenticated(t *testing.T) { + authority := &revocableGuard{} + authority.revoked.Store(true) + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + return stream.SendMsg(wrapperspb.String("never")) + }) + + _, _, _, err := clientSaw(t, connection) + require.Error(t, err) + require.Equal(t, codes.Unauthenticated, status.Code(err), + "a client cannot tell 'mint again' from 'the server broke' out of codes.Unknown") +} + +// N-C: the capability-bearing method set is DECLARED, so a first request +// without a capability never gets the raw stream and never disables the method. +// +// The previous revision learned the set: guardFor was asked per request and the +// FIRST answer for a method was binding. That is trust on first use. If the +// first request to a capability-bearing method arrived without a capability — a +// client mid-deploy, a health probe, a retry that lost its metadata — +// guardFor answered (nil, nil), THAT request was handed the raw stream, the +// method was recorded unguarded, and every later legitimate request was +// refused codes.Internal until the process restarted. One early request both +// bypassed the guard and took the method down. +func TestAFirstRequestWithoutACapabilityNeitherBypassesNorDisablesTheMethod(t *testing.T) { + var asked int + var mu sync.Mutex + var handedRaw bool + interceptor := streamServerInterceptor( + // DECLARED: this method carries a capability, whatever any one request + // looks like. + []string{streamMethod}, + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + mu.Lock() + defer mu.Unlock() + asked++ + if asked == 1 { + // The first request has no capability. + return nil, nil + } + return (&revocableGuard{}).guard(t), nil + }) + + connection := serveWith(t, interceptor, func(stream grpc.ServerStream) error { + if _, ok := stream.(*GuardedServerStream); !ok { + mu.Lock() + handedRaw = true + mu.Unlock() + } + return nil + }) + + // The first request is REFUSED rather than handed the raw stream. + _, _, _, first := clientSaw(t, connection) + require.Error(t, first, "a capability-bearing method ran with no guard") + require.Equal(t, codes.Unauthenticated, status.Code(first)) + mu.Lock() + require.False(t, handedRaw, "the handler was handed the raw stream") + mu.Unlock() + + // And the method still WORKS afterwards: nothing was learned, so nothing + // is stuck. This is the half that used to be codes.Internal until restart. + _, _, _, second := clientSaw(t, connection) + require.NoError(t, second, "one early request without a capability disabled the method") +} + +// The three survivors the layer-4 mutation pass found in this package: no test +// sent a header through SendHeader under revoked authority, SetTrailer after +// Finish could still append, and the shape test survives an Unwrap accessor. + +// SendHeader is refused under revoked authority, and nothing reaches the +// client. The whole suite stayed green with SendHeader's re-check deleted, +// because every header test went through SetHeader. +func TestSendHeaderIsRefusedUnderRevokedAuthority(t *testing.T) { + authority := &revocableGuard{} + authority.revoked.Store(true) + faults := &handlerFaults{} + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + if err := stream.SendHeader(metadata.Pairs("sent-header", "leaked")); err == nil { + faults.record("SendHeader was accepted under revoked authority") + } + return nil + }) + + _, header, _, err := clientSaw(t, connection) + faults.assert(t) + require.Error(t, err) + require.Empty(t, header.Get("sent-header")) + require.Positive(t, int(authority.checks.Load()), "SendHeader performed no re-check") +} + +// A trailer set AFTER Finish is not appended, so it cannot ride out on a +// subsequent release. Past the last check there is nothing left to authorize +// against, and the wrapper says so rather than silently keeping it. +func TestATrailerSetAfterFinishIsRefused(t *testing.T) { + guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { return nil }, + }) + require.NoError(t, err) + underlying := &recordingStream{ctx: context.Background()} + guarded, err := Guard(underlying, guard) + require.NoError(t, err) + + require.NoError(t, guarded.Finish(nil)) + released := underlying.trailers + + for range 3 { + guarded.SetTrailer(metadata.Pairs("after-finish", "late")) + } + require.Equal(t, released, underlying.trailers, + "a trailer set after the last check reached gRPC") + + // And the context route reports it rather than dropping it, because + // grpc.SetTrailer has somewhere to put an error. + require.Error(t, grpc.SetTrailer(guarded.Context(), metadata.Pairs("after-finish", "late"))) +} + +// The wrapper exposes no METHOD that returns the underlying stream either. +// +// The shape test checked fields and the interface's own methods, so it survived +// an `Unwrap() any` accessor — the embedded field with one more step. This +// asserts the property the shape test was reaching for: nothing on this type +// hands back something satisfying grpc.ServerStream, and nothing returns a +// bare `any`, which would carry the raw stream past every shape check. +func TestNoMethodOfTheWrapperReturnsTheRawStream(t *testing.T) { + guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { return nil }, + }) + require.NoError(t, err) + underlying := &recordingStream{ctx: context.Background()} + guarded, err := Guard(underlying, guard) + require.NoError(t, err) + + streamInterface := reflect.TypeOf((*grpc.ServerStream)(nil)).Elem() + wrapper := reflect.TypeOf(guarded) + for index := range wrapper.NumMethod() { + method := wrapper.Method(index) + for result := range method.Type.NumOut() { + out := method.Type.Out(result) + if out == reflect.TypeOf((*error)(nil)).Elem() { + continue + } + require.False(t, out.Implements(streamInterface), + "%s returns %s, which satisfies grpc.ServerStream: that is the embedded field again", + method.Name, out) + if out.Kind() == reflect.Interface && out.NumMethod() == 0 { + require.Failf(t, "an empty interface escapes the wrapper", + "%s returns %s; an `any` result can carry the raw stream past every shape check", + method.Name, out) + } + } + } +} + +// N-P: a Recheck that writes to the stream must get an ERROR, not a deadlock. +// +// Measured before the fix: a Recheck emitting an audit header with +// grpc.SetHeader(ctx, …) hung the handler FOREVER. The write resolved the +// wrapper out of the context, called SetHeader, which called recheck, which +// called BeforeSend — and BeforeSend holds the guard's mutex across the +// re-check, so the nested call blocked on a sync.Mutex its own goroutine held. +// SendMsg never returned. +// +// Releasing that mutex would turn the hang into unbounded recursion. A liveness +// check deciding whether the stream may emit must not emit, so the context it +// runs under refuses every write and names why. +func TestARecheckThatWritesToTheStreamIsRefusedRatherThanDeadlocking(t *testing.T) { + var wrote error + guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(ctx context.Context) error { + wrote = grpc.SetHeader(ctx, metadata.Pairs("audited", "yes")) + return nil + }, + }) + require.NoError(t, err) + guarded, err := Guard(&recordingStream{ctx: context.Background()}, guard) + require.NoError(t, err) + + sent := make(chan error, 1) + go func() { sent <- guarded.SendMsg("x") }() + select { + case sendErr := <-sent: + require.NoError(t, sendErr, "the send itself is fine; the write inside the re-check is not") + case <-time.After(10 * time.Second): + t.Fatal("SendMsg never returned: the re-check re-entered BeforeSend under its own mutex") + } + + require.ErrorIs(t, wrote, workcontext.ErrInvalid) + require.ErrorContains(t, wrote, "must not write to the stream whose liveness it is deciding") + + // And the method is still readable from inside a re-check, because reading + // is not writing. + var seen string + readOnly, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(ctx context.Context) error { + seen, _ = grpc.Method(ctx) + return nil + }, + }) + require.NoError(t, err) + reader, err := Guard(&recordingStream{ctx: context.Background()}, readOnly) + require.NoError(t, err) + require.NoError(t, reader.SendMsg("x")) + require.Empty(t, seen, "a bare recordingStream carries no transport stream, so there is no method to read") +} + +// Round five's two survivors in this package. + +// The UNAVAILABLE mapping (guarded_stream.go). "Mutation-verified both ways" +// did not hold for this branch: a termination whose re-check SOURCE failed — +// neither revoked nor replayed nor malformed, just unreachable — must reach the +// client as codes.Unavailable, because that is the one case where retrying the +// same capability is the right answer. Mapped to Unknown, a client cannot tell +// it from a server bug. +func TestARecheckSourceFailureReachesTheClientAsUnavailable(t *testing.T) { + unreachable := errors.New("the seal source did not answer") + guard, err := workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { return unreachable }, + }) + require.NoError(t, err) + + connection := serveWith(t, streamServerInterceptor( + []string{streamMethod}, + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + return guard, nil + }), func(stream grpc.ServerStream) error { + return stream.SendMsg(wrapperspb.String("never")) + }) + + _, _, _, err = clientSaw(t, connection) + require.Error(t, err) + require.Equal(t, codes.Unavailable, status.Code(err), + "a re-check source that could not be reached is the one retryable termination") +} + +// SendHeader must CARRY what SetHeader queued, not drop it. The wrapper holds +// queued headers, so a SendHeader that sent only its own argument would +// silently discard everything a handler had set — and no test noticed. +func TestSendHeaderCarriesWhatSetHeaderQueued(t *testing.T) { + authority := &revocableGuard{} + faults := &handlerFaults{} + connection := serveGuarded(t, authority.guard(t), func(stream grpc.ServerStream) error { + if err := stream.SetHeader(metadata.Pairs("queued", "one")); err != nil { + faults.record("SetHeader: %v", err) + } + if err := stream.SetHeader(metadata.Pairs("queued-two", "two")); err != nil { + faults.record("SetHeader: %v", err) + } + // SendHeader flushes, and must include both of the above. + if err := stream.SendHeader(metadata.Pairs("sent", "three")); err != nil { + faults.record("SendHeader: %v", err) + } + return nil + }) + + _, header, _, err := clientSaw(t, connection) + faults.assert(t) + require.NoError(t, err) + require.Equal(t, []string{"one"}, header.Get("queued"), + "SendHeader dropped a header SetHeader had queued") + require.Equal(t, []string{"two"}, header.Get("queued-two")) + require.Equal(t, []string{"three"}, header.Get("sent")) +} diff --git a/workcontext/grpctransport/round6_reproducers_test.go b/workcontext/grpctransport/round6_reproducers_test.go new file mode 100644 index 0000000..aa2e122 --- /dev/null +++ b/workcontext/grpctransport/round6_reproducers_test.go @@ -0,0 +1,320 @@ +package grpctransport + +import ( + "context" + "fmt" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/wrapperspb" + + "github.com/codefly-dev/sdk-go/workcontext" +) + +// B7. EXPIRY IS THE ORDINARY REFUSAL AND IT ARRIVED AS Internal. +// +// core answers an expired capability with ErrInvalid — verify.go returns +// "%w: expired at …" — and statusFor mapped every ErrInvalid to codes.Internal. +// The argument for that was sound and applied to the wrong side: ErrInvalid +// also covers this package's own misuse, and telling a client "mint again" +// about a server bug makes it loop. But a stream outliving its credential is +// the COMMON case, not a rare malformed one, and minting again is exactly the +// right answer to it. Measured: Internal. +// +// The distinction the old comment called unavailable was available all along, +// because every misuse error here is constructed here and can say so. +func TestAnExpiredCapabilityTellsTheClientToMintAgain(t *testing.T) { + // The sentinel core actually returns for expiry, with core's own wording. + expired := fmt.Errorf("%w: expired at 2026-10-02T12:00:00Z", workcontext.ErrInvalid) + + require.Equal(t, codes.Unauthenticated, status.Code(statusFor(expired)), + "an expired capability is answered by presenting a new one, which is what "+ + "Unauthenticated means to a client") + + // And this server's own misuse is still Internal, which is the cost the + // previous mapping was protecting and this keeps. + for name, misuse := range map[string]error{ + "a re-check that tried to write": errRecheckMustNotWrite, + "a finished stream": fmt.Errorf("%w: the stream is finished", errStreamMisuse), + "an unguarded wrapper": (*GuardedServerStream)(nil).SetHeader(metadata.MD{}), + } { + t.Run(name, func(t *testing.T) { + require.Equal(t, codes.Internal, status.Code(statusFor(misuse)), + "the client did nothing it can correct, and minting again cannot fix a server bug") + require.ErrorIs(t, misuse, workcontext.ErrInvalid, + "misuse still wraps ErrInvalid, so anything relying on that is unchanged") + }) + } + + // The sentinels that were already Unauthenticated stay there, so this is + // not green by mapping everything one way. + require.Equal(t, codes.Unauthenticated, status.Code(statusFor(workcontext.ErrRevoked))) + require.Equal(t, codes.Unavailable, status.Code(statusFor(workcontext.ErrStreamTerminated))) +} + +// B8. THE FIRST SEND LEFT THE HEADER STATE WRONG WHEN NOTHING WAS QUEUED. +// +// releaseHeaders returned early on an empty queue without marking flushed, and +// gRPC sends the header frame with the first message whether the handler queued +// anything or not. So a SetHeader after the first SendMsg was held in the +// wrapper, found !flushed, and was handed to stream.SetHeader by Finish — which +// gRPC ignores, because the headers are already on the wire. +// +// Measured over bufconn: SetHeader returned nil, the header never reached the +// client, and the handler was told nothing. A handler cannot act on an error it +// is not given. +func TestAHeaderSetAfterTheFirstMessageIsNotSilentlyDropped(t *testing.T) { + faults := &handlerFaults{} + answered := make(chan error, 1) + connection := serveGuarded(t, (&revocableGuard{}).guard(t), func(stream grpc.ServerStream) error { + // NOTHING QUEUED before the first send: that is the whole case. With a + // header queued first, flushed was set and this all worked. + if err := stream.SendMsg(wrapperspb.String("first")); err != nil { + faults.record("the first send failed: %v", err) + } + answered <- stream.SetHeader(metadata.Pairs("late-header", "yes")) + return nil + }) + + received, header, _, err := clientSaw(t, connection) + faults.assert(t) + require.NoError(t, err) + require.Equal(t, 1, received, "the message itself must still arrive") + + setHeaderErr := <-answered + require.Error(t, setHeaderErr, + "the headers went out with the first message, so this SetHeader cannot be honoured — "+ + "and a handler that is told nothing cannot act on it") + require.Empty(t, header.Get("late-header"), + "and it must not arrive either: an error plus delivery would be worse than silence") +} + +// F-9. THE INTERCEPTOR FAILED OPEN, two ways, both executed. +// +// A method missing from the declared set, and an EMPTY set, each delivered a +// message under revoked authority with ZERO re-checks. The second is the worse +// one: streamServerInterceptor(nil, guardFor) read as "no method carries a +// capability", so the wiring was present and enforced nothing — and wiring +// that is present is wiring nobody looks at again. +func TestAnEmptyMethodSetGuardsNothingAndSoRefusesEverything(t *testing.T) { + authority := &revocableGuard{} + authority.revoked.Store(true) + + connection := serveWith(t, streamServerInterceptor(nil, + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + return authority.guard(t), nil + }), func(stream grpc.ServerStream) error { + return stream.SendMsg(wrapperspb.String("delivered under revoked authority")) + }) + + received, _, _, err := clientSaw(t, connection) + + require.Error(t, err, "an interceptor that guards nothing must refuse, not pass through") + require.Zero(t, received, "a message left under revoked authority with no guard installed") + require.Equal(t, codes.Internal, status.Code(err), + "guarding nothing is this server's own misconfiguration, which the client cannot correct") + require.Contains(t, status.Convert(err).Message(), "would guard nothing") +} + +// And the typo, which no interceptor can catch from inside one request: "not in +// the set" and "in the set under another spelling" are the same observation +// there. The server knows, so ValidateMethodSet asks it. +func TestADeclaredMethodTheServerDoesNotServeIsRefusedAtStartup(t *testing.T) { + served := map[string]grpc.ServiceInfo{ + streamService: {Methods: []grpc.MethodInfo{{Name: "Emit", IsServerStream: true}}}, + } + + require.NoError(t, ValidateMethodSet(served, []string{streamMethod}), + "the method this server really serves is declared correctly") + + err := ValidateMethodSet(served, []string{"/" + streamService + "/Emmit"}) + require.Error(t, err, "a name that matches nothing guards nothing") + require.ErrorIs(t, err, workcontext.ErrInvalid) + require.Contains(t, err.Error(), "Emmit", + "the refusal must name the method, because the whole defect is that it looks right") + + require.Error(t, ValidateMethodSet(served, nil), + "an empty set would guard nothing, and saying so by omission is not saying it") +} + +// F-2, AS EXECUTED: a real core Verifier, a moving clock, and a capability that +// expires mid-stream. The unit mapping is asserted in +// TestAnExpiredCapabilityTellsTheClientToMintAgain; this is the path the review +// drove over bufconn, where it observed codes.Internal. +func TestACapabilityThatExpiresMidStreamTellsTheClientToMintAgain(t *testing.T) { + expired := fmt.Errorf("stream terminated: %w: expired at 2026-10-02T12:15:00Z", + workcontext.ErrInvalid) + + connection := serveWith(t, streamServerInterceptor([]string{streamMethod}, + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + // A guard whose re-check answers exactly what core's Verifier + // answers for a capability whose window has passed: ErrInvalid, + // with core's own wording. + var checks atomic.Uint64 + return workcontext.NewStreamGuard(workcontext.StreamGuardOptions{ + Recheck: func(context.Context) error { + if checks.Add(1) > 1 { + return expired + } + return nil + }, + }) + }), func(stream grpc.ServerStream) error { + for range 3 { + if err := stream.SendMsg(wrapperspb.String("payload")); err != nil { + return err + } + } + return nil + }) + + received, _, _, err := clientSaw(t, connection) + + require.Error(t, err) + require.Equal(t, 1, received, "the first message goes out, the rest do not") + require.Equal(t, codes.Unauthenticated, status.Code(err), + "a stream that outlives its credential is answered by minting a new one; this "+ + "arrived as codes.Internal, which tells a client the server is broken") + require.Contains(t, status.Convert(err).Message(), "expired") +} + +// CODEX 7: a set of only UNARY methods passed validation, so a streaming method +// reached the raw handler with guardFor never called — the +// interceptor-that-guards-nothing configuration, reached by naming a method +// that cannot stream. +func TestAUnaryMethodNameGuardsNothingAndIsRefused(t *testing.T) { + served := map[string]grpc.ServiceInfo{ + streamService: {Methods: []grpc.MethodInfo{ + {Name: "Emit", IsServerStream: true}, + {Name: "Read"}, + }}, + } + + require.NoError(t, ValidateMethodSet(served, []string{streamMethod}), + "the streaming method this server serves is declared correctly") + + err := ValidateMethodSet(served, []string{"/" + streamService + "/Read"}) + require.Error(t, err, "a unary method's name makes the set non-empty and guards nothing") + require.ErrorIs(t, err, workcontext.ErrInvalid) + require.Contains(t, err.Error(), "are unary on this server") + require.Contains(t, err.Error(), "Read", + "the refusal must name the method, because the defect is that it looks declared") + + // A server with no streaming method at all has nothing for a stream + // interceptor to do, which is also not something to express by omission. + require.Error(t, ValidateMethodSet( + map[string]grpc.ServiceInfo{streamService: {Methods: []grpc.MethodInfo{{Name: "Read"}}}}, + []string{"/" + streamService + "/Read"})) +} + +// M-4: THE INTERCEPTOR FAILED OPEN FOR A SERVED METHOD MISSING FROM THE SET, +// and the validation that was supposed to catch misconfiguration only looked +// one way — the declared set was checked against the server, and the server was +// never checked against the set. +// +// Deny by default, as everywhere else here: every served streaming method is +// either capability-bearing or NAMED as deliberately unguarded. A stream that +// carries no authority is a real thing, so this does not force a guard on it — +// it forces somebody to have said so once, where a reader sees it, instead of a +// method going unguarded because nobody thought about it. +func TestEveryServedStreamIsEitherGuardedOrDeliberatelyNot(t *testing.T) { + served := map[string]grpc.ServiceInfo{ + streamService: {Methods: []grpc.MethodInfo{ + {Name: "Emit", IsServerStream: true}, + {Name: "Watch", IsServerStream: true}, + {Name: "Read"}, + }}, + } + watch := "/" + streamService + "/Watch" + + // Emit declared, Watch neither declared nor exempted: refused. + err := ValidateMethodSet(served, []string{streamMethod}) + require.Error(t, err, "Watch reaches the handler unguarded and nothing says that is intended") + require.ErrorIs(t, err, workcontext.ErrInvalid) + require.Contains(t, err.Error(), "Watch") + require.Contains(t, err.Error(), "reaches the handler UNGUARDED") + + // Watch named as deliberately unguarded: accepted. The decision exists. + require.NoError(t, ValidateMethodSet(served, []string{streamMethod}, watch)) + + // Or guarded instead: also accepted. + require.NoError(t, ValidateMethodSet(served, []string{streamMethod, watch})) + + // AND EVERY PROBLEM AT ONCE, because returning on the first told a server + // with two mistakes about one of them. + both := ValidateMethodSet(served, []string{"/" + streamService + "/Emmit", "/" + streamService + "/Read"}) + require.Error(t, both) + require.Contains(t, both.Error(), "Emmit", "the typo") + require.Contains(t, both.Error(), "unary on this server", "the unary name") + require.Contains(t, both.Error(), "UNGUARDED", "and the streams nothing names") +} + +// THE VALIDATION IS MANDATORY, not merely available. +// +// streamServerInterceptor plus ValidateMethodSet was correct and OPTIONAL, and +// optional is the finding: the README recipe did not call the validation for +// several revisions, so a consumer following it kept the fail-open the +// validation exists to close. A safety check a caller may skip is a safety +// check for whoever remembers it. +// +// grpc forces the ordering — the interceptor is built before registration and +// the served set exists only after — so the one thing a library can do is make +// the gap LOUD. An unvalidated interceptor refuses every stream, which is a +// failure found by the first test rather than a method that was never guarded. +func TestAnUnvalidatedInterceptorRefusesEveryStream(t *testing.T) { + authority := &revocableGuard{} + streams := GuardStreams([]string{streamMethod}, + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + return authority.guard(t), nil + }) + + // Not validated: every stream is refused, and the message says what to do. + connection := serveWith(t, streams.Intercept, func(stream grpc.ServerStream) error { + return stream.SendMsg(wrapperspb.String("served without validation")) + }) + received, _, _, err := clientSaw(t, connection) + require.Error(t, err) + require.Zero(t, received, "a stream was served by an interceptor that validated nothing") + require.Equal(t, codes.Internal, status.Code(err), + "an unvalidated interceptor is this server's own misconfiguration") + require.Contains(t, status.Convert(err).Message(), "has not been validated") + require.Contains(t, status.Convert(err).Message(), "GetServiceInfo", + "and names the call, because a refusal with no way forward gets worked around") + + // Validated against a server that really serves it: streams flow. + served := map[string]grpc.ServiceInfo{ + streamService: {Methods: []grpc.MethodInfo{{Name: "Emit", IsServerStream: true}}}, + } + require.NoError(t, streams.Validate(served)) + + ok := GuardStreams([]string{streamMethod}, + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + return authority.guard(t), nil + }) + require.NoError(t, ok.Validate(served)) + flowing := serveWith(t, ok.Intercept, func(stream grpc.ServerStream) error { + return stream.SendMsg(wrapperspb.String("payload")) + }) + count, _, _, err := clientSaw(t, flowing) + require.NoError(t, err) + require.Equal(t, 1, count, "a validated interceptor serves the stream it guards") + + // And a validation that FAILS does not arm it: the typo case, where the + // declared method matches nothing the server serves. + wrong := GuardStreams([]string{"/" + streamService + "/Emmit"}, + func(context.Context, *grpc.StreamServerInfo) (*workcontext.StreamGuard, error) { + return authority.guard(t), nil + }) + require.Error(t, wrong.Validate(served)) + refused := serveWith(t, wrong.Intercept, func(stream grpc.ServerStream) error { + return stream.SendMsg(wrapperspb.String("served after a failed validation")) + }) + after, _, _, err := clientSaw(t, refused) + require.Error(t, err, "a FAILED validation must not arm the interceptor") + require.Zero(t, after) +} diff --git a/workcontext/grpctransport/two_service_test.go b/workcontext/grpctransport/two_service_test.go index 27054fa..3be2817 100644 --- a/workcontext/grpctransport/two_service_test.go +++ b/workcontext/grpctransport/two_service_test.go @@ -16,18 +16,27 @@ import ( "google.golang.org/grpc/status" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + corework "github.com/codefly-dev/core/workcontext" "github.com/codefly-dev/sdk-go/workcontext" "github.com/codefly-dev/sdk-go/workcontext/grpctransport" ) -// Two real gRPC services on loopback listeners, a real Ed25519 authority, and -// a real verifier in each service. Service A is called by a client holding a -// Work Context for A's audience, and calls service B on the same logical -// operation. The request's Service field names how A reaches B. +// Two real gRPC services on loopback listeners, core's authority holding the +// one signing key, and core's verifier in each service. Service A is called by +// a client holding a capability for A's audience, and calls service B on the +// same logical operation. The request's Service field names how A reaches B. +// +// Everything that mints or verifies here is core's. The transport under test is +// this module's, and that is the whole shape of the module: it carries a +// capability it did not make to a verifier it does not own. const ( - hopIssuer = "https://accounts.codefly.dev/work-context" + hopIssuer = "https://authority.codefly.test/work-context" hopKeyID = "hop-key" + hopTenant = "tenant-hop" + hopPrincipal = "principal-owner" + hopActor = "principal-service-a" + hopRevision = 7 audienceA = "codefly.test.service-a" audienceB = "codefly.test.service-b" hopMethod = "/codefly.test.Hop/Call" @@ -41,7 +50,7 @@ var hopNow = time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC) var hopScopes = []*basev0.WorkScopeV1{ {ResourceKind: "document", Actions: []string{"read", "write"}}, - {ResourceKind: "evidence", Actions: []string{"append"}}, + {ResourceKind: "record", Actions: []string{"append"}}, } // hopService is the handler both services register under one ServiceDesc. It @@ -66,10 +75,12 @@ var hopServiceDesc = grpc.ServiceDesc{ }}, } -// hopAuthority stands in for the exchange endpoint a service calls: it holds -// the signer, which services never do. +// hopAuthority is the exchange endpoint a service calls: it holds the signing +// key, which a service never does. type hopAuthority struct { - signer *workcontext.WorkContextSigner + minter *corework.Authority + seals *corework.MemorySealSource + replay corework.ReplayStore publicKey ed25519.PublicKey } @@ -77,51 +88,81 @@ func newHopAuthority(t *testing.T) *hopAuthority { t.Helper() publicKey, privateKey, err := ed25519.GenerateKey(nil) require.NoError(t, err) - signer, err := workcontext.NewWorkContextSigner(workcontext.WorkContextSignerOptions{ - Issuer: hopIssuer, KeyID: hopKeyID, PrivateKey: privateKey, - Now: func() time.Time { return hopNow }, - }) - require.NoError(t, err) - return &hopAuthority{signer: signer, publicKey: publicKey} + seals := corework.NewMemorySealSource() + require.NoError(t, seals.Put(hopPrincipal, hopSeal())) + // Per PRINCIPAL, and for BOTH of them: the actor exercises the derived + // capability, so the actor's own approved build is what a hop attests + // against. Recording only the owner's is the defect this move closes. + require.NoError(t, seals.PutApprovedBuild(hopPrincipal, hopImageDigest, hopApprovedBuildIncarnation)) + require.NoError(t, seals.PutApprovedBuild(hopActor, hopImageDigest, hopApprovedBuildIncarnation)) + // The delegated actor needs a live epoch of its own. An actor's authority is + // narrowed independently of the owner's — the owner here is one service and + // the actor another — so core seals the actor's epoch per hop and refuses a + // hop that carries none. Recording only the owner's seal left the actor + // unrevocable, which is a capability nobody can withdraw. + require.NoError(t, seals.PutEpoch(hopActor, 1)) + // And the owner's, which the seal no longer carries: one source for every + // principal's epoch, so revocation cannot be undone by storing a seal. + require.NoError(t, seals.PutEpoch(hopPrincipal, 1)) + return &hopAuthority{ + minter: &corework.Authority{ + Issuer: hopIssuer, KeyID: hopKeyID, Key: privateKey, + Revisions: corework.FixedRevision(hopRevision), + Seals: seals, + Now: func() time.Time { return hopNow }, + }, + seals: seals, + replay: corework.NewMemoryReplayStore(), + publicKey: publicKey, + } } -func (a *hopAuthority) verifier(t *testing.T) *workcontext.WorkContextVerifier { - t.Helper() - verifier, err := workcontext.NewWorkContextVerifier(workcontext.WorkContextVerifierOptions{ - PublicKeys: map[string]ed25519.PublicKey{hopKeyID: a.publicKey}, - Now: func() time.Time { return hopNow }, - }) - require.NoError(t, err) - return verifier +// verifier is core's, configured for one service's audience. A verifier is +// given all four of core's sources because a verifier missing one refuses +// everything rather than skipping that check. +func (a *hopAuthority) verifier(audience string) *workcontext.Verifier { + return &workcontext.Verifier{ + Issuer: hopIssuer, + Audience: audience, + Keys: map[string]ed25519.PublicKey{hopKeyID: a.publicKey}, + Revisions: corework.FixedRevision(hopRevision), + Replay: a.replay, + Grants: hopNoGrants{}, + Seals: a.seals, + Now: func() time.Time { return hopNow }, + } +} + +type hopNoGrants struct{} + +func (hopNoGrants) Grant(context.Context, string) (*corework.Grant, error) { + return nil, corework.ErrInvalid } // verifyIncoming is the authentication a service runs on every call: the // carrier from gRPC metadata, verified for this service's own audience. func verifyIncoming( ctx context.Context, - verifier *workcontext.WorkContextVerifier, - audience string, -) (grpctransport.ExecutionContext, workcontext.VerifiedWorkContext, error) { + verifier *workcontext.Verifier, +) (grpctransport.ExecutionContext, *workcontext.Verified, error) { execution, err := grpctransport.GRPCExecutionContextFromIncoming(ctx) if err != nil { - return grpctransport.ExecutionContext{}, workcontext.VerifiedWorkContext{}, status.Error(codes.Unauthenticated, err.Error()) + return grpctransport.ExecutionContext{}, nil, status.Error(codes.Unauthenticated, err.Error()) } - verified, err := verifier.VerifyWorkContext(execution.WorkContext(), workcontext.WorkContextExpectations{ - Issuer: hopIssuer, Audience: audience, - }) + verified, err := verifier.Verify(ctx, execution.Capability()) if err != nil { - return grpctransport.ExecutionContext{}, workcontext.VerifiedWorkContext{}, status.Error(codes.Unauthenticated, err.Error()) + return grpctransport.ExecutionContext{}, nil, status.Error(codes.Unauthenticated, err.Error()) } return execution, verified, nil } type serviceB struct { - verifier *workcontext.WorkContextVerifier - accepted chan workcontext.VerifiedWorkContext + verifier *workcontext.Verifier + accepted chan *workcontext.Verified } func (s *serviceB) call(ctx context.Context, _ *healthv1.HealthCheckRequest) (*healthv1.HealthCheckResponse, error) { - _, verified, err := verifyIncoming(ctx, s.verifier, audienceB) + _, verified, err := verifyIncoming(ctx, s.verifier) if err != nil { return nil, err } @@ -130,14 +171,14 @@ func (s *serviceB) call(ctx context.Context, _ *healthv1.HealthCheckRequest) (*h } type serviceA struct { - verifier *workcontext.WorkContextVerifier + verifier *workcontext.Verifier authority *hopAuthority b grpc.ClientConnInterface - accepted chan workcontext.VerifiedWorkContext + accepted chan *workcontext.Verified } func (s *serviceA) call(ctx context.Context, request *healthv1.HealthCheckRequest) (*healthv1.HealthCheckResponse, error) { - incoming, verified, err := verifyIncoming(ctx, s.verifier, audienceA) + incoming, verified, err := verifyIncoming(ctx, s.verifier) if err != nil { return nil, err } @@ -145,16 +186,35 @@ func (s *serviceA) call(ctx context.Context, request *healthv1.HealthCheckReques outgoing := incoming if request.GetService() != hopForward { - input := workcontext.ExchangeWorkContextAudienceInput{Audience: audienceB} + // A hop across an audience boundary is a delegation minted by the + // authority for the callee's audience, never the capability A was + // handed: B's audience check refuses a forwarded one. + scopes := cloneHopScopes() switch request.GetService() { case hopAttenuate: - input.AttenuatedScopes = hopScopes[1:] + scopes = cloneHopScopes()[1:] case hopWiden: - input.AttenuatedScopes = append(cloneHopScopes(), &basev0.WorkScopeV1{ + scopes = append(cloneHopScopes(), &basev0.WorkScopeV1{ ResourceKind: "profile", Actions: []string{"read"}, }) } - exchanged, _, err := s.authority.signer.ExchangeWorkContextAudience(incoming.WorkContext(), input) + exchanged, _, err := s.authority.minter.Child(ctx, verified, corework.ChildInput{ + // THE HOP ATTESTS ITS OWN EXECUTION. The derived capability is + // exercised by hopActor, so what the issuer approves for hopActor + // is what this must match — previously the execution came off the + // OWNER's installation seal, which described the wrong workload + // the moment a delegation existed. + Execution: corework.Execution{ + ImageDigest: hopImageDigest, + BuildIncarnation: hopApprovedBuildIncarnation, + }, + PrincipalID: hopActor, + PrincipalKind: "service", + DelegationID: "delegation-hop", + GrantedScopes: scopes, + Audience: audienceB, + TTL: 5 * time.Minute, + }) if err != nil { return nil, status.Error(codes.PermissionDenied, err.Error()) } @@ -210,8 +270,8 @@ type hopTopology struct { authority *hopAuthority a *grpc.ClientConn b *grpc.ClientConn - atA chan workcontext.VerifiedWorkContext - atB chan workcontext.VerifiedWorkContext + atA chan *workcontext.Verified + atB chan *workcontext.Verified } func newHopTopology(t *testing.T) *hopTopology { @@ -219,29 +279,60 @@ func newHopTopology(t *testing.T) *hopTopology { authority := newHopAuthority(t) topology := &hopTopology{ authority: authority, - atA: make(chan workcontext.VerifiedWorkContext, 1), - atB: make(chan workcontext.VerifiedWorkContext, 1), + atA: make(chan *workcontext.Verified, 1), + atB: make(chan *workcontext.Verified, 1), } - topology.b = serveHop(t, &serviceB{verifier: authority.verifier(t), accepted: topology.atB}) + topology.b = serveHop(t, &serviceB{verifier: authority.verifier(audienceB), accepted: topology.atB}) topology.a = serveHop(t, &serviceA{ - verifier: authority.verifier(t), authority: authority, b: topology.b, accepted: topology.atA, + verifier: authority.verifier(audienceA), authority: authority, b: topology.b, accepted: topology.atA, }) return topology } -// startTask issues the context the external caller holds: for service A only. -func (h *hopTopology) startTask(t *testing.T) workcontext.WorkContextToken { +// hopSeal is the INSTALLATION both services in the hop hold authority through: +// one installation, one revision. A hop crosses an audience boundary inside +// one execution, so the seal is the same on both sides of it. +// +// The execution is no longer here. It is recorded per principal through +// PutApprovedBuild and attested per hop through ChildInput.Execution, which is +// the point of the move: a hop's principal does not hold the owner's +// installation, so an execution read from the owner's seal described the wrong +// workload the moment a delegation was added. +func hopSeal() workcontext.Seal { + return workcontext.Seal{ + InstallationID: "installation-hop", + InstallationRevision: 12, + } +} + +// hopApprovedBuild is the build the issuer approves for the hop's principals. +const hopApprovedBuildIncarnation = 9 + +const hopImageDigest = "sha256:2222222222222222222222222222222222222222222222222222222222222222" + +// startTask issues the capability the external caller holds: for service A +// only. +func (h *hopTopology) startTask(t *testing.T) string { t.Helper() - token, _, err := h.authority.signer.StartTask(workcontext.StartTaskInput{ - Audience: audienceA, TenantID: "tenant-codefly", OwnerPrincipalID: "principal-owner", - TaskID: "task-hop", SessionID: "session-hop", AuthorizationRevision: 7, + token, _, err := h.authority.minter.Start(context.Background(), corework.StartInput{ + // What this caller attests it is running, matched against the build the + // ISSUER approves for this principal — read through ApprovedBuild now, + // not off the installation seal. + Execution: corework.Execution{ + ImageDigest: hopImageDigest, + BuildIncarnation: hopApprovedBuildIncarnation, + }, + Audience: audienceA, TenantID: hopTenant, OwnerPrincipalID: hopPrincipal, + OwnerPrincipalKind: "service", TaskID: "task-hop", + InstallationID: hopSeal().InstallationID, AuthorityScopes: cloneHopScopes(), + TTL: 10 * time.Minute, }) require.NoError(t, err) return token } -func (h *hopTopology) call(t *testing.T, connection *grpc.ClientConn, token workcontext.WorkContextToken, mode string) error { +func (h *hopTopology) call(t *testing.T, connection *grpc.ClientConn, token string, mode string) error { t.Helper() execution, err := grpctransport.NewExecutionContext(token, "operation-hop") require.NoError(t, err) @@ -250,20 +341,13 @@ func (h *hopTopology) call(t *testing.T, connection *grpc.ClientConn, token work return connection.Invoke(ctx, hopMethod, &healthv1.HealthCheckRequest{Service: mode}, new(healthv1.HealthCheckResponse)) } -func effectiveScopes(claims *basev0.WorkContextV1) []*basev0.WorkScopeV1 { - if actors := claims.GetActorChain(); len(actors) > 0 { - return actors[len(actors)-1].GetGrantedScopes() - } - return claims.GetAuthorityScopes() -} - -func TestServiceHopRejectsAWorkContextForwardedToAnotherAudience(t *testing.T) { +func TestServiceHopRejectsACapabilityForwardedToAnotherAudience(t *testing.T) { topology := newHopTopology(t) token := topology.startTask(t) err := topology.call(t, topology.a, token, hopForward) require.Equal(t, codes.Unauthenticated, status.Code(err), err) - require.ErrorContains(t, err, "audience mismatch") + require.ErrorContains(t, err, "presented to") require.Len(t, topology.atA, 1, "A accepted its own context before forwarding it") require.Empty(t, topology.atB, "B must not accept a context minted for A") @@ -279,9 +363,10 @@ func TestServiceHopExchangeThenCallSucceedsWithoutWideningScopes(t *testing.T) { token := topology.startTask(t) require.NoError(t, topology.call(t, topology.a, token, hopExchange)) - atA := (<-topology.atA).Claims() + verifiedA := <-topology.atA + atA := verifiedA.Context() verifiedB := <-topology.atB - atB := verifiedB.Claims() + atB := verifiedB.Context() require.Equal(t, audienceA, atA.GetAudience()) require.Equal(t, audienceB, atB.GetAudience()) @@ -289,26 +374,36 @@ func TestServiceHopExchangeThenCallSucceedsWithoutWideningScopes(t *testing.T) { {atA.GetTenantId(), atB.GetTenantId()}, {atA.GetOwnerPrincipalId(), atB.GetOwnerPrincipalId()}, {atA.GetTaskId(), atB.GetTaskId()}, - {atA.GetSessionId(), atB.GetSessionId()}, } { require.Equal(t, pair[0], pair[1]) } + // A hop is a child session of the same task, not the same session: the + // task is what survives a delegation, and the parent is named rather than + // impersonated. + require.NotEqual(t, atA.GetSessionId(), atB.GetSessionId()) + require.Equal(t, atA.GetSessionId(), atB.GetParentSessionId()) require.Equal(t, atA.GetAuthorizationRevision(), atB.GetAuthorizationRevision()) - require.Equal(t, len(effectiveScopes(atA)), len(effectiveScopes(atB))) - for index, scope := range effectiveScopes(atB) { - want := effectiveScopes(atA)[index] + require.Equal(t, len(verifiedA.EffectiveScopes()), len(verifiedB.EffectiveScopes())) + for index, scope := range verifiedB.EffectiveScopes() { + want := verifiedA.EffectiveScopes()[index] require.Equal(t, want.GetResourceKind(), scope.GetResourceKind()) require.Equal(t, want.GetActions(), scope.GetActions()) require.Equal(t, want.GetResourceIds(), scope.GetResourceIds()) } - // B's exchanged context is itself bound to B: sent back to A it fails. - exchangedToA := topology.call(t, topology.a, mustReissueForB(t, topology.authority, token), hopExchange) + // The seal survives the hop unchanged: a hop crosses an audience boundary + // inside one execution, so both sides are sealed to one installation at one + // revision on one build. + require.Equal(t, atA.GetSeal().GetInstallationId(), atB.GetSeal().GetInstallationId()) + require.Equal(t, atA.GetSeal().GetInstallationRevision(), atB.GetSeal().GetInstallationRevision()) + require.Equal(t, atA.GetSeal().GetBuildIncarnation(), atB.GetSeal().GetBuildIncarnation()) + + // B's delegated capability is itself bound to B: sent back to A it fails. + exchangedToA := topology.call(t, topology.a, mustReissueForB(t, topology.authority, verifiedA), hopExchange) require.Equal(t, codes.Unauthenticated, status.Code(exchangedToA), exchangedToA) - // The hop keeps the cache partition: same tenant, same view. - verifiedA, err := topology.authority.verifier(t).VerifyWorkContext(token, workcontext.WorkContextExpectations{Audience: audienceA}) - require.NoError(t, err) + // The hop keeps the cache partition: same tenant, same installation, same + // view. for _, options := range [][]workcontext.CachePartitionOption{nil, {workcontext.ByAuthorizationView()}} { partitionA, err := workcontext.DeriveCachePartition(verifiedA, options...) require.NoError(t, err) @@ -323,26 +418,41 @@ func TestServiceHopExchangeMayAttenuateButNeverWiden(t *testing.T) { require.NoError(t, topology.call(t, topology.a, topology.startTask(t), hopAttenuate)) <-topology.atA - atB := (<-topology.atB).Claims() - require.NoError(t, workcontext.RequireWorkContextScope(atB, workcontext.WorkContextScopeRequirement{ - ResourceKind: "evidence", Action: "append", - })) - err := workcontext.RequireWorkContextScope(atB, workcontext.WorkContextScopeRequirement{ - ResourceKind: "document", Action: "read", - }) - require.ErrorIs(t, err, workcontext.ErrWorkContextDenied) - - err = topology.call(t, topology.a, topology.startTask(t), hopWiden) + atB := <-topology.atB + + // Whether the delegated capability covers a call is core's scope algebra, + // asked of the scopes core says are effective. This module answers no part + // of that question. + require.True(t, workcontext.ScopeContained( + &basev0.WorkScopeV1{ResourceKind: "record", Actions: []string{"append"}}, + atB.EffectiveScopes(), + )) + require.False(t, workcontext.ScopeContained( + &basev0.WorkScopeV1{ResourceKind: "document", Actions: []string{"read"}}, + atB.EffectiveScopes(), + ), "the hop attenuated the document scope away, so it is no longer covered") + + err := topology.call(t, topology.a, topology.startTask(t), hopWiden) require.Equal(t, codes.PermissionDenied, status.Code(err), err) require.ErrorContains(t, err, "widens authority") <-topology.atA require.Empty(t, topology.atB, "a widening exchange must never reach B") } -func mustReissueForB(t *testing.T, authority *hopAuthority, token workcontext.WorkContextToken) workcontext.WorkContextToken { +func mustReissueForB(t *testing.T, authority *hopAuthority, parent *workcontext.Verified) string { t.Helper() - exchanged, _, err := authority.signer.ExchangeWorkContextAudience(token, workcontext.ExchangeWorkContextAudienceInput{ - Audience: audienceB, + exchanged, _, err := authority.minter.Child(context.Background(), parent, corework.ChildInput{ + // The actor's own approved build, attested at the hop. + Execution: corework.Execution{ + ImageDigest: hopImageDigest, + BuildIncarnation: hopApprovedBuildIncarnation, + }, + PrincipalID: hopActor, + PrincipalKind: "service", + DelegationID: "delegation-hop", + GrantedScopes: cloneHopScopes(), + Audience: audienceB, + TTL: 5 * time.Minute, }) require.NoError(t, err) return exchanged diff --git a/workcontext/import_allowlist_test.go b/workcontext/import_allowlist_test.go new file mode 100644 index 0000000..2172560 --- /dev/null +++ b/workcontext/import_allowlist_test.go @@ -0,0 +1,668 @@ +package workcontext + +import ( + "fmt" + "go/ast" + "go/types" + "os" + "os/exec" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/tools/go/packages" +) + +// DENY BY DEFAULT. This is the gate's spine, and it replaces an argument that +// lost ten times running. +// +// Every previous revision banned a LIST OF NAMES and every review found the +// next name: `hkdf`, then `sha512`, then `protoiface`, then — in the round that +// prompted this file — `crypto/mldsa` signing a `WorkContextV1` that +// `encoding/json/v2` had encoded as snake_case JSON, which is the exact format +// this repository deleted. Both gates were green. `golangci-lint` reported 0 +// issues. The denylist was not behind by one entry; it was behind by a design. +// +// So the question is inverted. Each module declares the imports it HAS, and +// anything else is a finding until somebody adds it here with a reason. The +// next name costs a reviewed line in this file rather than a round of review: +// `protodelim`, `grpc/encoding`, `json/v2`, `mldsa`, `prototext`, `jsontext` +// and `hpke` are all refused by the same rule, and so is the one nobody has +// thought of. +// +// Three things this does NOT reach, stated rather than implied: +// +// - A hand-written implementation that imports nothing. A base64url decoder +// is twenty lines, a protobuf wire walk is fifty, and neither names a +// package. That is the irreducible residue, the same class as a hand-rolled +// HMAC over an allowed hash, and it is closed by review. +// - Test files, which are exempt: `minting_test.go` imports `crypto/ed25519` +// to hold the verifier's public-key map, and that is the right thing for a +// test of a verifier to do. A `_test.go` cannot be imported by shipped +// code, so a second implementation there ships to nobody. +// - Anything a dependency does. This is a rule about what THIS repository +// spells. + +// allowedImports is every import path a module's non-test code may use, READ +// FROM scripts/allowed-imports.txt. +// +// It was a map in this file, and that was the hole: the ref sweep is the only +// thing that runs over a published ref, and it had its own denylist — so a +// branch carrying `crypto/mldsa` and `encoding/json/v2`, which is the round-six +// second implementation, swept `ok` in the job that is a required check. Two +// policies is one policy and one hole. One file now, read by both. +// +// The test below fails in BOTH directions: an import not in the file is a +// finding, and a line nothing imports is deleted, because a permission nobody +// uses is precedent for the next reader. +func allowedImportsFor(t *testing.T, module string) []string { + t.Helper() + policy := loadImportPolicy(t) + require.Contains(t, policy, module, "the policy file lists no module %q", module) + return policy[module] +} + +// loadImportPolicy parses the shared policy file. +func loadImportPolicy(t *testing.T) map[string][]string { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "scripts", "allowed-imports.txt")) + require.NoError(t, err, "the import policy is the gate; a gate with no policy is not one") + + policy := map[string][]string{} + for index, line := range strings.Split(string(raw), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + module, rest, found := strings.Cut(trimmed, " ") + require.True(t, found, "line %d is not ` [file …]`: %q", index+1, line) + // A line may name the FILES that may have the import. This package + // checks module membership; the file list is what keeps the ref sweep + // as strict as the denylist it replaced, and the Go gate's own + // narrowedImports holds the same imports to files AND symbols. + path, _, _ := strings.Cut(rest, " ") + require.Contains(t, []string{"root", "leaf", "legacy"}, module, + "line %d names %q, which is not root, leaf or legacy", index+1, module) + // `legacy` is the historical allowance, applied by the sweep to + // published refs only — never to the working tree, which is what this + // package checks. It is kept out of the module lists deliberately: a + // path in it must not become permitted in a checkout, and + // TestTheHistoricalImportAllowanceCarriesNothingCapabilityShaped is + // what holds it to being benign. + policy[module] = append(policy[module], strings.TrimSpace(path)) + } + require.NotEmpty(t, policy["root"]) + require.NotEmpty(t, policy["leaf"]) + return policy +} + +// TestEveryImportIsOnItsModulesAllowlist is the deny-by-default rule. +func TestEveryImportIsOnItsModulesAllowlist(t *testing.T) { + for _, module := range loadModules(t) { + allowed := allowedImportsFor(t, module.name) + require.NotEmpty(t, allowed) + + for _, finding := range unallowedImports(module.name, allowed, importsOfModule(t, module)) { + t.Error(finding) + } + + } +} + +// An entry nothing imports is a permission in force for nobody, which the next +// reader takes as precedent. The allowlist is measured from the tree, so it +// stays measured. +func TestTheAllowlistHasNoUnusedPermission(t *testing.T) { + for _, module := range loadModules(t) { + used := importsOfModule(t, module) + for _, spec := range allowedImportsFor(t, module.name) { + require.Contains(t, used, spec, + "allowedImports[%q] permits %q and no file in that module imports it. "+ + "An unused permission is precedent; delete the line.", module.name, spec) + } + } +} + +// TESTDATA IS SHIPPED CODE. The go tool excludes a `testdata` directory from +// WILDCARD matching, not from importing — so a package there is compiled into +// anything that imports it by path, and no `./...` ever mentions it. A reviewer +// put a complete Ed25519 signer under receipts/testdata/ and imported it from a +// root file; the syntactic walk skipped the directory by name and both gates +// were green. +// +// This asserts the loader's patterns reach such a package, because the rule +// above is only as wide as what it was handed. +func TestTestdataIsNotAHidingPlace(t *testing.T) { + // IN A TEMPORARY DIRECTORY, NOT IN THE REPOSITORY. The first version of + // this test created receipts/testdata/gateprobe and its cleanup removed + // receipts/testdata — the PARENT. Nothing lives there today, so it did no + // harm here, but a test that deletes a directory it did not create will + // delete somebody's fixtures the day they add some, and running the suite + // is not supposed to be a destructive act. A reviewer was right to call + // that a major finding rather than a nit. + // + // What is actually being asserted is a property of testdataPatterns, which + // takes a directory — so it is handed one that belongs to this test. + root := t.TempDir() + probe := filepath.Join(root, "receipts", "testdata", "gateprobe") + require.NoError(t, os.MkdirAll(probe, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(probe, "signer.go"), + []byte("package gateprobe\n\nimport \"crypto/ed25519\"\n\n"+ + "func Sign(key ed25519.PrivateKey, payload []byte) []byte {\n"+ + "\treturn ed25519.Sign(key, payload)\n}\n"), 0o600)) + + require.Contains(t, testdataPatterns(t, root), "./receipts/testdata/...", + "the loader must be given the testdata packages explicitly; ./... does not match them, "+ + "and a reviewer imported a complete Ed25519 signer from one") + + // A directory with no testdata in it yields no patterns, so the walk is + // not simply returning everything. + require.Empty(t, testdataPatterns(t, t.TempDir())) + + // And the rule refuses what is in there, which is the point of reaching it. + require.NotEmpty(t, unallowedImports("root", allowedImportsFor(t, "root"), map[string][]string{ + "crypto/ed25519": {"receipts/testdata/gateprobe/signer.go"}, + }), "a signer under testdata is still a signer") +} + +// A package that does not type-check is a package this gate has not read, and +// passing what it could not read is the fail-open this repository has fixed +// four times in three rounds. The loader fails on it rather than skipping it. +func TestTheGateRefusesAPackageItCannotTypeCheck(t *testing.T) { + // Asserted on the loader's own contract rather than by breaking the tree: + // every package loaded here carries zero errors, so if the check below + // were removed nothing would notice until something was already broken. + for _, module := range loadModules(t) { + for _, loaded := range module.packages { + require.Empty(t, loaded.Errors, + "%s did not type-check; the type-based rules cannot run on it", loaded.PkgPath) + require.NotNil(t, loaded.TypesInfo, + "%s carries no type information, so every go/types rule read nothing there", + loaded.PkgPath) + } + } +} + +// importsOfModule is what a module's non-test files import, by path. +func importsOfModule(t *testing.T, module loadedModule) map[string][]string { + t.Helper() + used := map[string][]string{} + for _, loaded := range module.packages { + for _, file := range loaded.Syntax { + path := module.relative(loaded, file) + if path == "" || strings.HasSuffix(path, "_test.go") { + continue + } + for _, imported := range file.Imports { + spec, err := strconv.Unquote(imported.Path.Value) + require.NoError(t, err) + used[spec] = append(used[spec], path) + } + } + } + return used +} + +// unallowedImports is the rule itself, over a module name and what its files +// import, returning findings rather than calling require. +// +// Separated so a probe can drive it: the test above reads the real tree, so the +// only way to ask "would this refuse crypto/mldsa?" through it is to commit +// crypto/mldsa. A rule that cannot be probed is a rule nobody has tested, which +// is the finding that prompted this whole file. +func unallowedImports(module string, allowed []string, used map[string][]string) []string { + var findings []string + for _, spec := range sortedKeys(used) { + files := strings.Join(used[spec], ", ") + // `import "C"` IS NOT AN IMPORT PATH, it is an escape from Go + // entirely: a reviewer MACed a capability through CommonCrypto's + // CCHmac and checked it against RFC 4231, and no rule written about Go + // imports could see it. There is no honest use for cgo in an SDK whose + // job is resolving values. + if spec == "C" { + findings = append(findings, fmt.Sprintf("%s imports \"C\".\n"+ + "cgo is not an import path, it is an exit from every rule here: a MAC built with\n"+ + "the host's crypto library names no Go package at all. Nothing in this repository\n"+ + "needs it.", files)) + continue + } + if slices.Contains(allowed, spec) { + continue + } + findings = append(findings, fmt.Sprintf( + "%s (module %s) imports %q, which is not on that module's allowlist.\n"+ + "THIS LIST IS DENY-BY-DEFAULT, and that is the point. A denylist of names was\n"+ + "behind by one entry every round — hkdf, then sha512, then protoiface, then\n"+ + "crypto/mldsa signing a WorkContextV1 that encoding/json/v2 had encoded in the\n"+ + "deleted format, with both gates green. If this import is honest, add it to\n"+ + "allowedImports[%q] WITH A REASON, in a reviewed diff. That is the cost, and it is\n"+ + "one line instead of a round of review.", + files, module, spec, module)) + } + return findings +} + +func sortedKeys(from map[string][]string) []string { + keys := make([]string, 0, len(from)) + for key := range from { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +// THE PROBES, every one of them a shape a reviewer compiled and ran as a +// decoder, a signer or a MAC of a real WorkContextV1 minted by core's +// Authority, against the previous revision of this gate. Every one passed. +func TestTheImportAllowlistRefusesTheNextName(t *testing.T) { + for name, probe := range map[string]struct { + module string + spec string + }{ + // The two halves of the complete second implementation: json/v2 + // encoded a real capability as snake_case JSON — the deleted format — + // and mldsa signed it, 2420 bytes, verified. Both gates green. + "encoding/json/v2, which is not encoding/json": {"leaf", "encoding/json/v2"}, + "crypto/mldsa, a signature nobody had listed": {"leaf", "crypto/mldsa"}, + "protodelim, a codec with different method names": {"leaf", "google.golang.org/protobuf/encoding/protodelim"}, + "the gRPC codec registry": {"leaf", "google.golang.org/grpc/encoding"}, + // Named in the review as inferred, never compiled. They cost nothing + // here, which is the whole argument for inverting the question. + "prototext": {"leaf", "google.golang.org/protobuf/encoding/prototext"}, + "jsontext": {"leaf", "encoding/json/jsontext"}, + "hpke, an AEAD and so MAC-capable": {"leaf", "crypto/hpke"}, + // And in the root module, where the shell sweep is the only other gate. + "json/v2 in the root module": {"root", "encoding/json/v2"}, + "protodelim in the root module": {"root", "google.golang.org/protobuf/encoding/protodelim"}, + // cgo, which is not an import path at all. + "cgo": {"leaf", "C"}, + } { + t.Run(name, func(t *testing.T) { + findings := unallowedImports(probe.module, allowedImportsFor(t, probe.module), + map[string][]string{probe.spec: {"second_implementation.go"}}) + require.NotEmpty(t, findings, + "%q is not on the %s allowlist and must be a finding", probe.spec, probe.module) + if probe.spec == "C" { + // THE DIAGNOSIS, not only the refusal. `"C"` is refused by the + // allowlist anyway — a mutation of the cgo branch changed + // nothing — so what that branch is for is saying WHY: cgo is + // not a package whose name a reviewer can add to a list, it is + // an exit from every rule here, and a reader told "not on the + // allowlist" would reasonably try adding it. + require.Contains(t, strings.Join(findings, "\n"), "cgo is not an import path") + } + }) + } + + // And every import the tree really has stays allowed, so this is not green + // by refusing everything — which is also the mutation guard for the cases + // above. + for _, module := range []string{"root", "leaf"} { + allowed := allowedImportsFor(t, module) + used := map[string][]string{} + for _, spec := range allowed { + used[spec] = []string{"real.go"} + } + require.Empty(t, unallowedImports(module, allowed, used), + "module %s refuses an import it actually has", module) + } +} + +// loadedModule is one module's type-checked packages. +type loadedModule struct { + name string + dir string + packages []*packages.Package +} + +// relative is a file's path relative to the REPOSITORY root, so a finding reads +// the same way whichever module it is in. +func (m loadedModule) relative(loaded *packages.Package, file *ast.File) string { + position := loaded.Fset.Position(file.Pos()) + if position.Filename == "" { + return "" + } + repository, err := filepath.Abs("..") + if err != nil { + return "" + } + path, err := filepath.Rel(repository, position.Filename) + if err != nil || strings.HasPrefix(path, "..") { + return "" + } + return filepath.ToSlash(path) +} + +// loadModules type-checks both modules with x/tools/go/packages. +// +// TESTDATA IS INCLUDED, which a syntactic walk had skipped: a reviewer put a +// complete Ed25519 signer under receipts/testdata/ and imported it from a root +// file. The go tool excludes `testdata` from WILDCARD matching, not from +// importing, so a package there is shipped code that no `./...` ever mentions. +// It is loaded by explicit pattern for that reason. +// loadModules type-checks both modules ONCE per test binary. +// +// Cached because it is not cheap: the leaf suite went from 60s to 247s when the +// type-checked gates arrived, and every one of them was re-running the same +// load. The result is read-only — findings are computed from it, nothing +// mutates it — so one load serves every test, and the gates stay fast enough +// that nobody is tempted to skip them. +func loadModules(t *testing.T) []loadedModule { + t.Helper() + loadOnce.Do(func() { loadedModules, loadErr = loadBothModules() }) + require.NoError(t, loadErr) + require.NotEmpty(t, loadedModules) + return loadedModules +} + +var ( + loadOnce sync.Once + loadedModules []loadedModule + loadErr error +) + +func loadBothModules() ([]loadedModule, error) { + repository, err := filepath.Abs("..") + if err != nil { + return nil, fmt.Errorf("resolve the repository root: %w", err) + } + + modules := []loadedModule{ + {name: "root", dir: repository}, + {name: "leaf", dir: filepath.Join(repository, "workcontext")}, + } + for index := range modules { + found, err := testdataPackages(modules[index].dir) + if err != nil { + return nil, err + } + patterns := append([]string{"./..."}, found...) + loaded, err := packages.Load(&packages.Config{ + Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax | + packages.NeedTypes | packages.NeedTypesInfo | packages.NeedDeps | + packages.NeedImports, + Dir: modules[index].dir, + Tests: true, + }, patterns...) + if err != nil { + return nil, fmt.Errorf("loading module %s: %w", modules[index].name, err) + } + if len(loaded) == 0 { + return nil, fmt.Errorf("module %s loaded no packages", modules[index].name) + } + for _, one := range loaded { + // A package that does not type-check is a package this gate has not + // read, and a gate that passes what it could not read is the + // fail-open this repository has now fixed five times. + for _, problem := range one.Errors { + return nil, fmt.Errorf( + "module %s: %s did not type-check: %s. The type-based rules cannot run on a "+ + "package that does not compile, and skipping it would be a gate reporting "+ + "success for code it never read", + modules[index].name, one.PkgPath, problem) + } + } + modules[index].packages = loaded + } + return modules, nil +} + +// testdataPatterns names every package under a testdata directory, which +// `./...` deliberately does not match. +func testdataPatterns(t *testing.T, dir string) []string { + t.Helper() + patterns, err := testdataPackages(dir) + require.NoError(t, err) + return patterns +} + +func testdataPackages(dir string) ([]string, error) { + var patterns []string + walkErr := filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error { + if err != nil || !entry.IsDir() { + return err //nolint:wrapcheck // the walk's own error, returned as-is + } + switch entry.Name() { + case ".git", ".github", ".claude", "node_modules": + return filepath.SkipDir + } + if entry.Name() != "testdata" { + return nil + } + relative, relErr := filepath.Rel(dir, path) + if relErr != nil { + return relErr //nolint:wrapcheck // the walk's own error + } + patterns = append(patterns, "./"+filepath.ToSlash(relative)+"/...") + return filepath.SkipDir + }) + if walkErr != nil { + return nil, fmt.Errorf("walk %s for testdata: %w", dir, walkErr) + } + sort.Strings(patterns) + return patterns, nil +} + +// typesAreAvailable is a guard on the loader itself: if NeedTypes ever stops +// being requested, every type-based rule below silently answers "nothing to +// see" and the gate goes green by reading nothing. +func TestTheGateActuallyHasTypeInformation(t *testing.T) { + var checked int + for _, module := range loadModules(t) { + for _, loaded := range module.packages { + if loaded.TypesInfo == nil || loaded.Types == nil { + continue + } + for expression, kind := range loaded.TypesInfo.Types { + if kind.Type != nil && expression != nil { + checked++ + break + } + } + } + } + require.Positive(t, checked, + "no package carried type information, so every go/types rule in this gate "+ + "was deciding nothing. A gate with no inputs passes everything.") + var _ types.Type = types.Typ[types.Bool] // the import is load-bearing above +} + +// THE GATE READS EVERY SHIPPED FILE, asserted against git rather than assumed. +// +// This is the half of the one-implementation rule that the shared policy file +// did not close. `packages.Load` builds for ONE configuration, so a file the +// current build does not select is a file the type-checked gates never see: +// GOOS/GOARCH or custom build tags, cgo under CGO_ENABLED=0, a directory +// beginning with `_` or `.`, a nested module, a `vendor/` tree. The sweep reads +// every tracked file regardless of tags (go/parser ignores them), so a +// prohibited IMPORT there is still caught — what is not caught is every +// semantic rule: the codec rule, the capability walk, the envelope rule. +// +// "Every source file is checked regardless of build tags" was true of the +// syntactic scanners and stopped being true when the type-checked gates +// arrived. So the claim is now a test: what the loader read must equal what the +// repository ships. Today they are equal; the day somebody adds a +// build-constrained file this fails, and the answer is to load that +// configuration too or to refuse the file — not to let it through unread. +// +// It also subsumes the testdata claim. A reviewer struck +// TestTestdataIsNotAHidingPlace for checking the helper and never that the gate +// LOADS a testdata package: `git ls-files` lists one, so this is what would +// fail if the loader skipped it. +func TestTheGateReadsEveryShippedFile(t *testing.T) { + tracked := trackedGoFiles(t) + require.NotEmpty(t, tracked, "git listed no Go files, so this asserted nothing") + + read := map[string]bool{} + for _, module := range loadModules(t) { + for _, loaded := range module.packages { + for _, file := range loaded.Syntax { + path := module.relative(loaded, file) + if path != "" && !strings.HasSuffix(path, "_test.go") { + read[path] = true + } + } + } + } + + var unread []string + for _, path := range tracked { + if !read[path] { + unread = append(unread, path) + } + } + require.Empty(t, unread, + "these files are shipped and the type-checked gates never read them: %v.\n"+ + "packages.Load builds for ONE configuration, so a build-constrained file, a cgo\n"+ + "file under CGO_ENABLED=0, a `_`-prefixed directory, a nested module or a vendor\n"+ + "tree is invisible to every semantic rule here — the codec rule, the capability\n"+ + "walk, the envelope rule. The import sweep still reads them, so an import ban\n"+ + "holds; nothing else does. Load that configuration too, or refuse the file.", + unread) + + // And the loader must not be reading files git does not track, which would + // mean it is answering about something other than what ships. + trackedSet := map[string]bool{} + for _, path := range tracked { + trackedSet[path] = true + } + var untracked []string + for path := range read { + if !trackedSet[path] { + untracked = append(untracked, path) + } + } + require.Empty(t, untracked, + "the gates read files this repository does not track: %v", untracked) +} + +// NO GATE HERE READS ASSEMBLY OR C, so none may be shipped. +// +// Every rule in this repository is about Go: `go/parser` for the sweep, +// `go/types` for the codec rule. A `.s` file can implement anything at all, a +// `.syso` is already-compiled object code linked in whole, and a `.c` compiled +// through cgo is outside the language. There is no honest need for any of them +// in an SDK whose job is resolving values — and `import "C"` is already refused +// by name, so this is the other half of that. +func TestNoSourceNoGateCanReadIsShipped(t *testing.T) { + run := exec.Command("git", "ls-files", "--", + "*.s", "*.S", "*.syso", "*.a", "*.o", "*.c", "*.cc", "*.cpp", "*.cxx", + "*.h", "*.hh", "*.hpp", "*.m", "*.mm") + run.Dir = ".." + output, err := run.Output() + require.NoError(t, err) + + var shipped []string + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + if trimmed := strings.TrimSpace(line); trimmed != "" { + shipped = append(shipped, trimmed) + } + } + require.Empty(t, shipped, + "these files are shipped and no gate in this repository can read them: %v.\n"+ + "Every rule here is about Go — go/parser for the import sweep, go/types for the\n"+ + "codec rule — so assembly, object code and C are outside all of them. A .s file\n"+ + "can implement anything; a .syso is compiled code linked in whole. `import \"C\"`\n"+ + "is already refused by name and this is the other half of it.", + shipped) +} + +// buildConstraintsIn is the constraint lines in a file's header, or none. +// +// Only the header: the block ends at the first blank line before `package`, so +// a `//go:build` in a comment further down is not a constraint and is not +// reported as one. +func buildConstraintsIn(source string) []string { + var found []string + for _, line := range strings.Split(source, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + break + } + if strings.HasPrefix(trimmed, "//go:build") || strings.HasPrefix(trimmed, "// +build") { + found = append(found, trimmed) + } + } + return found +} + +// trackedGoFiles is every non-test Go file this repository ships, from git. +func trackedGoFiles(t *testing.T) []string { + t.Helper() + run := exec.Command("git", "ls-files", "--", "*.go") + run.Dir = ".." + output, err := run.Output() + require.NoError(t, err) + + var files []string + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + path := strings.TrimSpace(line) + if path == "" || strings.HasSuffix(path, "_test.go") { + continue + } + files = append(files, path) + } + return files +} + +// NO BUILD CONSTRAINT IN A HAND-WRITTEN NON-TEST FILE. +// +// A constrained file is not built in the current configuration, so +// `packages.Load` never type-checks it and every semantic rule here — the codec +// rule, the capability walk, the hand-written-message rule — reads nothing. +// Executed: Codex's `envelopeTarget` probe inside a `//go:build r8hidden` root +// file passed all three Go gates and the sweep. +// +// TestTheGateReadsEveryShippedFile detects this generically, because such a +// file is tracked and unread. This refuses it by name as well, for two reasons: +// the diagnosis is the actionable part ("unread" sends a reader looking for a +// loader bug), and it says what the answer is — this repository has no use for +// one. Zero in the tree today, and one would be a design change rather than a +// detail: the SDK resolves values and compiles the same everywhere. +// +// Test files are exempt. A `_test.go` cannot be imported by shipped code, and +// nothing in a test binary reaches a consumer. +func TestNoBuildConstraintHidesAFileFromTheGates(t *testing.T) { + // THE DETECTION FIRST, over source written here. A mutation that disabled + // the rule entirely left this test green, because the tree has no + // constrained file and an empty answer was right either way — the same + // assert-the-tree-is-clean shape a review struck elsewhere in this + // repository. The rule is driven over an input before it is run over the + // tree. + for name, source := range map[string]string{ + "the modern form": "//go:build r8hidden\n\npackage x\n", + "the legacy form": "// +build r8hidden\n\npackage x\n", + "a negated constraint": "//go:build !r8hidden\n\npackage x\n", + "one after a doc line": "// Package x does things.\n//go:build r8hidden\n\npackage x\n", + } { + t.Run(name, func(t *testing.T) { + require.NotEmpty(t, buildConstraintsIn(source), + "a constrained file is invisible to every semantic rule here") + }) + } + // And ordinary source is not flagged, so the rule is not simply refusing + // everything — the mutation guard for the cases above. + require.Empty(t, buildConstraintsIn("// Package x does things.\npackage x\n\nimport \"fmt\"\n")) + + constrained := map[string][]string{} + for _, path := range trackedGoFiles(t) { + content, err := os.ReadFile(filepath.Join("..", path)) + require.NoError(t, err) + if found := buildConstraintsIn(string(content)); len(found) > 0 { + constrained[path] = found + } + } + require.Empty(t, constrained, + "these shipped files carry a build constraint: %v.\n"+ + "A constrained file is not built in the current configuration, so packages.Load "+ + "never type-checks it and every semantic rule here reads nothing — a reviewer put "+ + "a working decoder behind `//go:build r8hidden` and it passed all three Go gates "+ + "and the sweep. The import sweep still reads it, so an import ban holds and "+ + "nothing else does. This repository compiles the same everywhere and has no use "+ + "for one; adding the first is a design change.", constrained) +} diff --git a/workcontext/layer4_reproducers_test.go b/workcontext/layer4_reproducers_test.go new file mode 100644 index 0000000..d3f4223 --- /dev/null +++ b/workcontext/layer4_reproducers_test.go @@ -0,0 +1,584 @@ +package workcontext + +import ( + "bufio" + "context" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/json" + "io" + "math/big" + "net" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + corework "github.com/codefly-dev/core/workcontext" + "github.com/stretchr/testify/require" +) + +// The reproducers from the dynamic review (layer 4), kept as tests rather than +// described in a reply. +// +// They ran in a sandbox with no OS sockets, which is why they reach for +// net.Pipe and real TLS over it instead of httptest: that exercises Go's actual +// TLS and HTTP machinery without a listener. They are kept in that form on +// purpose — a test that cannot run where the next reviewer runs it is a test +// that stops being evidence. + +// D1. The effective transport, not the inspectable one. +// +// At the reviewed head a nil Transport was approved as "a RoundTripper this +// package cannot inspect", and net/http then substitutes http.DefaultTransport +// — which is a package-level variable any code in the process can replace. With +// an InsecureSkipVerify transport installed there, a default MintClient sent +// the projected bearer to an untrusted TLS endpoint and reported success. The +// review observed `Bearer fixture-bearer` arrive. +// +// It cannot happen now because there is nothing to inspect and nothing to +// substitute: the client BINDS a transport it built. This test holds that, by +// making http.DefaultTransport hostile and asserting the bearer never arrives. +func TestTheDefaultTransportIsNeverTheOneThatCarriesTheBearer(t *testing.T) { + authority := newAuthority(t) + payload, err := json.Marshal(mintResponse{WorkContext: authority.start(t, mintInput{})}) + require.NoError(t, err) + + public, private := corework.FixtureKeyPair() + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + DNSNames: []string{"mint.example.test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, public, private) + require.NoError(t, err) + serverTLS := &tls.Config{ + Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: private}}, + MinVersion: tls.VersionTLS12, + } + + // An endpoint nothing configured, reachable only through the hostile + // default transport, recording every Authorization header it is shown. + received := make(chan string, 4) + hostile := &http.Transport{ + DisableKeepAlives: true, + //nolint:gosec // the hostile configuration IS the test + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + DialContext: func(context.Context, string, string) (net.Conn, error) { + caller, endpoint := net.Pipe() + go func() { + defer func() { + _ = endpoint.Close() + }() + conn := tls.Server(endpoint, serverTLS) + request, readErr := http.ReadRequest(bufio.NewReader(conn)) + if readErr != nil { + return + } + received <- request.Header.Get("Authorization") + _, _ = io.Copy(io.Discard, request.Body) + _ = request.Body.Close() + response := &http.Response{ + StatusCode: http.StatusOK, ProtoMajor: 1, ProtoMinor: 1, + Header: http.Header{"Content-Type": []string{"application/json"}}, + ContentLength: int64(len(payload)), + Body: io.NopCloser(strings.NewReader(string(payload))), + } + _ = response.Write(conn) + }() + return caller, nil + }, + } + previous := http.DefaultTransport + http.DefaultTransport = hostile + t.Cleanup(func() { + http.DefaultTransport = previous + hostile.CloseIdleConnections() + }) + + client, err := NewMintClient(MintOptions{ + URL: "https://mint.example.test/mint", + Authority: newTestPin(), + Audience: testAudienceName, + ProjectedToken: ProjectedTokenFile(projectedFile(t, "fixture-bearer")), + ProjectionAudience: "projection-audience", + // No socket is dialled in either of these, so the anchor is never + // exercised — but RootCAs is required, so it is named. + RootCAs: x509.NewCertPool(), + Now: func() time.Time { return testClock }, + }) + require.NoError(t, err) + + // The mint fails because mint.example.test resolves nowhere through the + // transport the CLIENT built — and it fails as an OUTAGE, which is the + // assertion that distinguishes "the client used its own transport and + // could not reach the host" from "the client used the hostile one and got + // an answer". Accepting any error would also have accepted success + // followed by some later failure. + _, err = client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintUnavailable) + require.Equal(t, MintCounts{}, client.Counts(), + "nothing was minted, so nothing was counted") + + select { + case bearer := <-received: + t.Fatalf("the hostile default transport carried the projection: the endpoint was shown %q", bearer) + default: + } + + // And structurally: the client's transport is its own, bound at + // construction, so no later substitution of http.DefaultTransport can + // reach it either. + require.NotSame(t, http.DefaultTransport, client.httpClient.Transport) + bound, ok := client.httpClient.Transport.(*http.Transport) + require.True(t, ok) + require.False(t, bound.TLSClientConfig.InsecureSkipVerify) + require.Nil(t, bound.DialContext) + require.Nil(t, bound.DialTLSContext) +} + +// D2. Omitting both installation carriers used to skip the seal check +// altogether, so FromHeaders reported success for a malformed token, one +// carrying no seal, and one whose actor hop carried no epoch. +// +// The review's probe is kept in its own right rather than folded into the +// carrier tests, because what it demonstrates is the SHAPE of the mistake: a +// pre-check whose strictness depended on what the caller had chosen to attach. +func TestTheSealCheckIsReachedEvenWhenTheCarriersAreWellFormed(t *testing.T) { + // The carriers are PRESENT AND WELL-FORMED, which is the whole point. + // + // This test used to send the capability with no carriers at all, so every + // case failed on carrier CARDINALITY and the structural seal check was + // never reached — it passed with the seal check deleted, which a reviewer + // measured. Supplying both carriers is what gets past cardinality and puts + // the seal check on the only remaining path. + for name, token := range map[string]string{ + "malformed": "not-a-token", + "missing seal": fixture(t, "missing-seal").Token, + "actor without epoch": fixture(t, "actor-without-epoch").Token, + "foreign encoding": fixture(t, "foreign-encoding").Token, + } { + t.Run(name, func(t *testing.T) { + carried := http.Header{} + carried.Set(HeaderName, token) + carried.Set(InstallationIDHeaderName, testInstallation) + carried.Set(InstallationRevisionHeaderName, "3") + + _, err := FromHeaders(carried) + require.Error(t, err, "a structurally broken capability is refused") + require.NotContains(t, err.Error(), "requires exactly one value", + "this case never reached the seal check: it failed on carrier cardinality, "+ + "which is what made this test pass with the seal check removed") + }) + } + + // And cardinality is still refused, as its own property rather than as the + // thing that hides the one above. + bare := http.Header{} + bare.Set(HeaderName, fixture(t, "missing-seal").Token) + _, err := FromHeaders(bare) + require.ErrorContains(t, err, "requires exactly one value") +} + +// D3. Refreshing an old generation returned its replacement even after that +// replacement had expired. +// +// The generation check answered "somebody already replaced that one" and handed +// the replacement over without looking at it. A caller refused on an old +// credential — which is exactly the caller most likely to have been holding one +// for a while — was given an expired one. +func TestRefreshingAnOldGenerationDoesNotHandBackAnExpiredReplacement(t *testing.T) { + clock := testClock + now := func() time.Time { return clock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + first, err := client.Credential(t.Context()) + require.NoError(t, err) + replaced, err := client.Refresh(t.Context(), first) + require.NoError(t, err) + require.EqualValues(t, 2, host.requests.Load()) + + // Time passes until the replacement has expired outright. + clock = testClock.Add(30 * time.Minute) + require.True(t, clock.After(replaced.ExpiresAt()), "the replacement has expired") + + // A caller that was refused on the FIRST credential comes back. The + // generation has moved on, so nothing needs replacing on that account — + // but what is held is expired, and an expired credential must not be + // handed to anybody. + current, err := client.Refresh(t.Context(), first) + require.NoError(t, err) + require.True(t, current.ExpiresAt().After(clock), + "Refresh handed back a credential that expired at %s, and it is %s", + current.ExpiresAt(), clock) + require.NotEqual(t, replaced.Token(), current.Token()) + require.EqualValues(t, 3, host.requests.Load(), "it minted rather than handing over a dead credential") + + // The same for Credential, which is the path that always checked. + clock = testClock.Add(90 * time.Minute) + fresh, err := client.Credential(t.Context()) + require.NoError(t, err) + require.True(t, fresh.ExpiresAt().After(clock)) +} + +// D4. A type named mintRequest or mintResponse outside the endpoint's own file +// inherited the JSON exemption, so `extra/payload.go` could hold a json-tagged +// struct enumerating a capability's fields by hand — the deleted +// implementation — with the gate green. +func TestTheGateDoesNotLetMintBodyNamesTravel(t *testing.T) { + source := "package extra\n" + + "type mintRequest struct { Principal string `json:\"principal\"` }\n" + + "type mintResponse struct { Epoch uint64 `json:\"epoch\"` }" + for _, path := range []string{"extra/payload.go", "grpctransport/mint.go", "mint_bodies.go"} { + t.Run(path, func(t *testing.T) { + findings := inspectForSecondImplementation(parseSource(t, path, source)) + require.NotEmpty(t, findings, + "a namesake mintRequest/mintResponse outside %s inherits the exemption", mintEndpointJSONFile) + require.Contains(t, strings.Join(findings, "\n"), "struct with json tags") + }) + } + + // And the real file keeps its exemption, so the gate is not green by + // refusing everything. + genuine := "package workcontext\n" + + "type mintRequest struct { Audience string `json:\"audience\"` }\n" + + "type mintResponse struct { WorkContext string `json:\"work_context\"` }" + require.Empty(t, inspectForSecondImplementation(parseSource(t, mintEndpointJSONFile, genuine))) +} + +// The review's own redirect containment probe, which it ran with an in-process +// RoundTripper because the sandbox denied listeners. Kept because it asserts +// containment without needing a socket, which the httptest version cannot. +func TestRedirectContainmentWithoutASocket(t *testing.T) { + var destination atomic.Uint64 + authority := newAuthority(t) + payload, err := json.Marshal(mintResponse{WorkContext: authority.start(t, mintInput{})}) + require.NoError(t, err) + + client, err := NewMintClient(MintOptions{ + URL: "https://mint.example.test/mint", + Authority: newTestPin(), + Audience: testAudienceName, + ProjectedToken: ProjectedTokenFile(projectedFile(t, "projected")), + ProjectionAudience: "projection-audience", + // No socket is dialled in either of these, so the anchor is never + // exercised — but RootCAs is required, so it is named. + RootCAs: x509.NewCertPool(), + Now: func() time.Time { return testClock }, + }) + require.NoError(t, err) + + // Replace the bound transport's round trip, which only a test in this + // package can do: a 307 to another host, then a 200 at the destination. + client.httpClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.URL.Host != "mint.example.test" { + destination.Add(1) + return &http.Response{ + StatusCode: http.StatusOK, Request: request, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(string(payload))), + }, nil + } + return &http.Response{ + StatusCode: http.StatusTemporaryRedirect, Request: request, + Header: http.Header{"Location": []string{"https://elsewhere.example.test/mint"}}, + Body: http.NoBody, + }, nil + }) + + _, err = client.Credential(t.Context()) + require.EqualValues(t, 0, destination.Load(), + "the redirect destination received a request carrying the projection") + // RETRYABLE: the destination receives nothing either way, so latching + // bought no confidentiality and stopped the process on an ordinary 307. + require.ErrorIs(t, err, ErrMintUnavailable) + require.ErrorContains(t, err, "redirected") + require.NoError(t, client.Refused(), "a redirect must not latch") +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } + +// The hour-long acceptance run the dynamic review supplied and this suite did +// not have. It passed there (1,952 calls, one mint request) and is kept so the +// arithmetic is a gate rather than a reviewer's one-off. +// +// It establishes ENDPOINT ISSUANCE COUNT and not durable host audit events, +// which is the review's own caveat and remains true. +// +// # The arithmetic moved, and #47's acceptance criterion moved with it +// +// Core now caps what an authority will mint: Authority.MaxTTL, defaulting to +// DefaultMaxTTL of one hour. One mint across an hour of calls needs a lifetime +// of at least run/(1-lead) — 75 minutes at the default 0.2 lead — so **at +// core's default cap, "exactly one mint for an hour's run" is NOT reachable**. +// An hour of calls costs one mint and one renewal. +// +// That is the host's ceiling working, not a defect, and the acceptance +// criterion has to be read against it: what the mint-once model guarantees is +// a credential's own clock, not a fixed number. The heartbeat this replaced +// made 240 requests an hour per surface. Two is not one, and it is also not +// 240. +// +// Both shapes are asserted, because a consumer sizing this needs to know which +// one its host has chosen. +func TestAnHourOfCallsCostsOneMintAndOneRenewalAtCoresDefaultCeiling(t *testing.T) { + // A credential as long as the host will mint by default. + requests, counts := hourOfCalls(t, time.Hour, 0) + require.EqualValues(t, 2, requests, + "at a one-hour ceiling and a 0.2 lead, an hour of calls is one mint and one renewal") + require.Equal(t, MintCounts{Mints: 1, Renewals: 1}, counts) +} + +// And the one-mint shape, when a host raises its own ceiling deliberately — +// which is where that decision belongs, and where a reviewer sees it. +func TestAnHourOfCallsCostsOneMintWhenTheHostMintsLongerThanTheRun(t *testing.T) { + requests, counts := hourOfCalls(t, 90*time.Minute, 2*time.Hour) + require.EqualValues(t, 1, requests, + "an hour of calls on a credential that outlives the run plus its lead is ONE endpoint request") + require.Equal(t, MintCounts{Mints: 1}, counts) +} + +// hourOfCalls runs 32 concurrent first calls and then 32 calls in each of 60 +// simulated minutes, and reports what the endpoint was asked for. +func hourOfCalls(t *testing.T, lifetime time.Duration, maxTTL time.Duration) (uint64, MintCounts) { + t.Helper() + clock := testClock + var clockMu sync.RWMutex + now := func() time.Time { + clockMu.RLock() + defer clockMu.RUnlock() + return clock + } + host := newMintHost(t, now) + host.lifetime = lifetime + host.authority.core.MaxTTL = maxTTL // zero takes core's DefaultMaxTTL + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + // Errors are collected and asserted on the TEST goroutine: require inside a + // goroutine calls FailNow off it, which Go's testing package does not + // support and which can leave the run reporting a pass. + var waiting sync.WaitGroup + failures := make(chan error, 32) + for range 32 { + waiting.Add(1) + go func() { + defer waiting.Done() + if _, err := client.Credential(context.Background()); err != nil { + failures <- err + } + }() + } + waiting.Wait() + close(failures) + for err := range failures { + require.NoError(t, err, "a concurrent first call must not fail") + } + + for minute := range 60 { + clockMu.Lock() + clock = testClock.Add(time.Duration(minute) * time.Minute) + clockMu.Unlock() + for range 32 { + _, err := client.Credential(t.Context()) + require.NoError(t, err) + } + } + return host.requests.Load(), client.Counts() +} + +// D3 (round three). A transient response-body read failure must not stop the +// client permanently. +// +// The host answers 200 and the connection goes away mid-body. That was wrapped +// in ErrMintRefused, which became TERMINAL once refusals started latching: one +// interrupted read permanently stopped a process holding a perfectly good +// credential, and it stayed stopped after the endpoint recovered. A body that +// stops mid-read is an outage. +func TestAnInterruptedResponseBodyIsAnOutageRatherThanARefusal(t *testing.T) { + clock := testClock + now := func() time.Time { return clock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + + // A 200 whose body fails partway. The transport is the client's, so the + // failure is injected through it rather than through a socket. + transport, ok := client.httpClient.Transport.(*http.Transport) + require.True(t, ok) + t.Cleanup(func() { client.httpClient.Transport = transport }) + client.httpClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, Request: request, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(interruptedBody{}), + }, nil + }) + + // Inside the renewal lead, so the credential in hand still has minutes. + clock = testClock.Add(13 * time.Minute) + served, err := client.Credential(t.Context()) + require.NoError(t, err, "an interrupted read is an outage, and the held credential survives it") + require.Equal(t, held.Token(), served.Token()) + require.NoError(t, client.Refused(), "and it must not be latched as terminal") + + // And the endpoint recovering recovers the client. + client.httpClient.Transport = transport + clock = testClock.Add(13*time.Minute + 2*minMintBackoff) + fresh, err := client.Credential(t.Context()) + require.NoError(t, err) + require.NotEqual(t, held.Token(), fresh.Token()) +} + +// interruptedBody returns a short read and then a transport failure, which is +// what a connection dropped after the headers looks like to io.ReadAll. +type interruptedBody struct{} + +func (interruptedBody) Read(p []byte) (int, error) { + copied := copy(p, `{"work_context":"partial`) + return copied, io.ErrUnexpectedEOF +} + +// An oversized or malformed COMPLETE response stays terminal, because the host +// answered and answered wrongly: retrying gets the same answer. +func TestACompleteButUnacceptableResponseIsRetryable(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + oversized := strings.Repeat("a", maxMintResponseBytes+64) + client.httpClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, Request: request, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"work_context":"` + oversized + `"}`)), + }, nil + }) + + _, err := client.Credential(t.Context()) + // RETRYABLE, not terminal. An oversized body on a 200 is something in the + // middle answering — an ingress default page, a captive error page, a + // misrouted rollout — rather than the host refusing this process. Latched, + // any one of those stopped it for good. + require.ErrorIs(t, err, ErrMintUnavailable) + require.ErrorContains(t, err, "exceeds") + require.NoError(t, client.Refused(), "nothing on the response path latches but an enumerated refusal") +} + +// D4 (round three). A cancelled waiter must not be handed an EXPIRED credential. +// +// The waiter snapshotted the credential and its servability BEFORE waiting, +// then answered from that snapshot on cancellation without re-reading the +// clock. A waiter that started a second before expiry and cancelled a second +// after it received an expired credential with a nil error — and two seconds +// fits easily inside the default five-second request timeout. +func TestACancelledWaiterIsNeverHandedAnExpiredCredential(t *testing.T) { + clock := testClock + var clockMu sync.RWMutex + now := func() time.Time { + clockMu.RLock() + defer clockMu.RUnlock() + return clock + } + host := newMintHost(t, now) + release := make(chan struct{}) + var releaseOnce sync.Once + letGo := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(letGo) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + expiry := held.ExpiresAt() + + // One second before expiry, with the host holding its answer: a renewal is + // in flight and a second caller becomes a waiter. + host.before = func() { + select { + case <-release: + case <-time.After(2 * time.Second): + } + } + clockMu.Lock() + clock = expiry.Add(-time.Second) + clockMu.Unlock() + + renewing := make(chan struct{}) + go func() { + defer close(renewing) + _, _ = client.Credential(context.Background()) + }() + require.Eventually(t, func() bool { return host.requests.Load() == 2 }, + 2*time.Second, time.Millisecond) + + // The waiter's own context is cancelled one second AFTER expiry. + clockMu.Lock() + clock = expiry.Add(time.Second) + clockMu.Unlock() + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + served, err := client.Credential(cancelled) + + require.Error(t, err, "there is nothing servable: the held credential has expired") + require.ErrorIs(t, err, ErrMintUnavailable) + require.Empty(t, served.Token(), + "a cancelled waiter was handed a credential that expired at %s", expiry) + + letGo() + <-renewing +} + +// D6 (round three). DisallowUnknownFields applies to the value just decoded and +// says nothing about what follows it, so a valid response with a second JSON +// value appended was accepted. The response is ONE object and nothing after it. +func TestAMintResponseCarryingTrailingJSONIsRefused(t *testing.T) { + now := func() time.Time { return testClock } + authority := newAuthority(t) + token := authority.start(t, mintInput{}) + + for name, payload := range map[string]string{ + "a second object": `{"work_context":"` + token + `"}{"unsupported_security_field":true}`, + "a trailing array": `{"work_context":"` + token + `"}[1,2,3]`, + "a trailing scalar": `{"work_context":"` + token + `"} 7`, + } { + t.Run(name, func(t *testing.T) { + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + client.httpClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, Request: request, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(payload)), + }, nil + }) + + _, err := client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintUnavailable) + require.ErrorContains(t, err, "more than one JSON value") + }) + } + + // And trailing WHITESPACE is fine: a response is allowed to end in a + // newline, which is what most encoders write. + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + client.httpClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, Request: request, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"work_context":"` + token + `"}` + "\n\n ")), + }, nil + }) + _, err := client.Credential(t.Context()) + require.NoError(t, err, "a trailing newline is not a second value") +} diff --git a/workcontext/mint.go b/workcontext/mint.go new file mode 100644 index 0000000..b968edd --- /dev/null +++ b/workcontext/mint.go @@ -0,0 +1,1723 @@ +package workcontext + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "os" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + corework "github.com/codefly-dev/core/workcontext" + "google.golang.org/protobuf/proto" +) + +// Bounds on everything the mint endpoint can hand back. A credential is the +// one thing a process will trust for the rest of its life, so nothing about +// the response is allowed to be unbounded. +const ( + defaultMintRequestTimeout = 5 * time.Second + maxMintRequestTimeout = 30 * time.Second + maxMintResponseBytes = 64 * 1024 + maxProjectedTokenBytes = 64 * 1024 + + // defaultRenewalLead is how far before expiry a renewal is attempted, as a + // fraction of the credential's whole lifetime. It is not a heartbeat: at + // 0.2 a fifteen-minute credential is renewed once, three minutes before it + // expires, and a process that runs for an hour renews four times rather + // than announcing itself two hundred and forty times. + defaultRenewalLead = 0.2 + + // minRenewalLeadTime floors the lead so a very short credential still + // leaves room for one request and one retry. + minRenewalLeadTime = 5 * time.Second + + // defaultMaxCredentialLifetime is the longest credential this client will + // hold, and it is CORE'S OWN CONSTANT so there is one number rather than + // two to keep in step. + // + // WHAT THIS IS NOW FOR, because it is not what it was for. The argument + // that kept this check was that a mint client NEVER VERIFIES A SIGNATURE — + // it is the party the credential is minted FOR, not a receiver — so it + // reads its own window through corework.Inspect, which was structural and + // bounded no lifetime. Core's first two attempts bounded Authority.Start + // and then Verify alone, neither of which a holder can reach: a thirty-day + // capability that every Verify refuses was reported to its HOLDER as + // thirty days of validity. + // + // Core 67ee7220 moved the bound into decodeClaims, which Inspect uses, so + // an over-ceiling lifetime is refused IN THIS CLIENT'S OWN READ PATH — + // inside sealOf, before checkWindow runs at all. + // + // So the safety net is core's and this is no longer one. What remains is a + // DEPLOYMENT POLICY: a process that will hold a credential for at most + // five minutes says so here, and the check fires below core's ceiling + // where core has nothing to say. That is the only case it can reach, and + // it is the case its test exercises — describing it as a safety net would + // be claiming a branch nothing can enter. + defaultMaxCredentialLifetime = corework.MaxTTLCeiling + + // Backoff after a mint that failed. A failed renewal used to return an + // error to every caller while the held credential still had minutes left, + // and each caller then retried serially under the client's lock: one mint + // request per caller, which is the heartbeat under another name. + minMintBackoff = time.Second + maxMintBackoff = time.Minute + + // The refresh bucket. A refresh is not a failure, so it is not held off by + // the failure backoff; it is rate-limited in its own right because a + // receiver whose live state lags refuses every FRESH credential too, and + // each refusal is a new generation that the generation check cannot stop. + // + // One per minute in the steady state, with a burst of three so the honest + // case — a revocation, then a second one shortly after — is not delayed. + refreshInterval = time.Minute + maxRefreshBurst = 3.0 +) + +// Errors a mint caller distinguishes. +var ( + // ErrMintRefused marks a refusal the host will give again: the projected + // token is not acceptable, the build does not match what the host approved, + // the installation is not one this process may serve. Minting again changes + // nothing, so a process that sees it must not serve. + ErrMintRefused = errors.New("Codefly credential mint refused") + + // ErrMintUnavailable marks a refusal caused by the endpoint being + // unreachable or answering 5xx/429. It is retryable, and it is the only + // mint outcome that is. + ErrMintUnavailable = errors.New("Codefly credential mint unavailable") +) + +// ProjectedTokenSource reads the service-account token the platform projects +// into the process. It is an interface with one implementation so a test can +// rotate the token without a filesystem, and so the only thing that ever holds +// a path to it is this package. +type ProjectedTokenSource interface { + // ProjectedToken returns the token as it is on disk right now. It is called + // again before every renewal rather than cached, because the projection is + // rotated under the running process and the copy read at boot is expired + // long before the process is. + ProjectedToken() (string, error) +} + +// ProjectedTokenFile reads the projection from its mounted path. +type ProjectedTokenFile string + +// ProjectedToken reads and trims the file. An empty or whitespace-only file is +// an error and never an empty token: a projection that has been unmounted or +// not yet written must not be presented as a credential request, because the +// host would refuse it and the refusal would read as "this build is not +// approved" rather than "the token is missing". +func (path ProjectedTokenFile) ProjectedToken() (string, error) { + if strings.TrimSpace(string(path)) == "" { + // Misconfiguration, not a host decision: no request was made and + // retrying changes nothing. ErrInvalid rather than ErrMintRefused, + // which is latched and means "the host refused you". + return "", fmt.Errorf("%w: no projected token path", ErrInvalid) + } + file, err := os.Open(string(path)) + if err != nil { + // A LOCAL TRANSIENT FAULT, so an outage and not a refusal. EMFILE + // under load, EIO on the mount, or the projection being replaced + // between unlink and create are all momentary — and classifying them + // ErrMintRefused made them TERMINAL once refusals latched: one + // exhausted file-descriptor table permanently stopped a process that + // was holding a perfectly good credential, and it stayed stopped. This + // is the same defect the response-body read had, on the local side. + return "", fmt.Errorf("%w: open projected token: %w", ErrMintUnavailable, err) + } + defer func() { + _ = file.Close() + }() + raw, err := io.ReadAll(io.LimitReader(file, maxProjectedTokenBytes+1)) + if err != nil { + return "", fmt.Errorf("%w: read projected token: %w", ErrMintUnavailable, err) + } + if len(raw) > maxProjectedTokenBytes { + // Retryable. "The file is there and is the wrong thing" was the reason + // for latching this, and it is not a reason: latching buys nothing + // here — the host would refuse an oversized bearer anyway — while a + // projection caught mid-write is exactly the shape that produces it. + return "", fmt.Errorf("%w: projected token exceeds %d bytes", ErrMintUnavailable, maxProjectedTokenBytes) + } + token := strings.TrimSpace(string(raw)) + if token == "" { + // The projection is rewritten under the running process, so an empty + // read is a moment in the middle of a rotation. Retryable. + return "", fmt.Errorf("%w: projected token is empty", ErrMintUnavailable) + } + return token, nil +} + +// AuthorityPin is the boot-read authority every mint reads its audience from +// and is checked against. The root SDK's *codefly.Authority satisfies it. +// +// It is REQUIRED, and it answers the audience rather than merely agreeing with +// one. A free-string Audience beside an optional pin was a drift check guarding +// a value the mint did not use: Recheck could pass while the credential was +// minted for whatever string the caller had typed. Reading the audience through +// Value on every mint is what makes the pin load-bearing — the value that +// reaches the host is the pinned value, or the mint fails. +// +// Value's contract is the pin's: it answers from what was frozen at boot, and a +// name the process did not declare is an error rather than a live lookup. +type AuthorityPin interface { + // Recheck re-resolves every pinned value and refuses on the first that + // moved. A value that drifts under a running process is an error, never a + // reload: the process has already minted a credential sealed to the old + // one. + Recheck(ctx context.Context) error + + // Value answers one pinned value, by the name and key the workspace + // accessors take. + Value(name string, key string) (string, error) +} + +// AuthorityValue addresses one pinned workspace value, by the name and key +// ReadAuthority declared it under. It is how MintOptions names the audience +// without the audience being a string a caller can type. +type AuthorityValue struct { + Name string + Key string +} + +func (v AuthorityValue) String() string { return v.Name + "/" + v.Key } + +// MintOptions configures the one credential a process obtains per execution. +type MintOptions struct { + // URL is the host's mint endpoint: absolute HTTPS, no query, no fragment, + // no userinfo. Plain HTTP is refused — the projected service-account token + // travels on this request as a bearer credential. + URL string + + // Authority is the process's boot-read authority. REQUIRED: it answers the + // audience on every mint and it is rechecked before every mint, including + // the first. + Authority AuthorityPin + + // Audience names the pinned value the Work Context audience is read from. + // REQUIRED. The audience is never a string passed in beside the pin — that + // made the drift check guard a value the mint did not use. + Audience AuthorityValue + + // ProjectedToken reads the platform's projection of this process's + // service-account token. Required. + ProjectedToken ProjectedTokenSource + + // ProjectionAudience is the audience the projected token itself was minted + // for, which the host states. Sent so the host can refuse a projection + // aimed at something else rather than reviewing whatever it is handed. + ProjectionAudience string + + // RootCAs is the only thing a caller may say about the transport: the roots + // that may sign the mint endpoint's certificate. + // + // REQUIRED. Nil used to mean the system pool SILENTLY, which left the one + // remaining hole in a transport built to have none: every other way of + // weakening it was closed — no caller-supplied client, no reachable + // Transport, a TLS 1.3 floor, no followed redirect — and the trust anchor + // was still whatever the image happened to ship. A mint endpoint is + // platform infrastructure, so a public CA has no business vouching for it; + // a mis-issued certificate for the host name, or a corporate interception + // root in the image, receives the projected service-account token. + // + // A revision in between accepted a TrustSystemRoots flag beside this, so + // the system pool was a sentence somebody wrote rather than a field + // somebody left alone. That is better than a default and it is not the + // posture this module is held to: the flag is the same decision with a + // shorter spelling, and a shorter spelling is what gets copied. A + // deployment that really means the host's pool says + // + // pool, err := x509.SystemCertPool() + // + // which is two lines, is a thing a reader of the call site can see, and + // cannot be arrived at by leaving a field alone. + // + // It replaces an *http.Client, and that is the point. A client is a hole: + // its Transport may be nil (so the global, mutable http.DefaultTransport), + // a wrapper this package cannot inspect, or one with a DialTLSContext that + // bypasses TLSClientConfig entirely — and a caller holding the same + // *http.Transport pointer can turn verification off after construction, + // because a copied http.Client shares it. Inspecting a supplied client + // could not close any of that. So the client builds and owns its transport. + // What that transport is, is stated once, at mintHTTPClient — not restated + // here, because a field comment and a constructor comment saying the same + // thing is how this one came to claim a TLS 1.2 floor for three revisions + // after the floor became 1.3. + RootCAs *x509.CertPool + + // RequestTimeout bounds one mint request. + RequestTimeout time.Duration + + // RenewalLead overrides the fraction of a credential's lifetime at which + // renewal is attempted. Zero takes the default. + RenewalLead float64 + + // MaxCredentialLifetime refuses a credential valid for longer than this + // process will hold one. Zero takes defaultMaxCredentialLifetime, which is + // core's own MaxTTLCeiling. + // + // It is a DEPLOYMENT POLICY below core's absolute ceiling, and what that + // means is argued once at defaultMaxCredentialLifetime rather than again + // here. This comment claimed to be the only thing between a misconfigured + // host and a month-long credential, which stopped being true when core put + // the bound in its one decode path — and it went on claiming it because + // the argument was written down twice. + MaxCredentialLifetime time.Duration + + // Now is the clock, for tests. + Now func() time.Time +} + +// Credential is the sealed capability a process holds for its execution. It is +// obtained once by Mint and replaced only by renewal at expiry or by Refresh +// after the host has refused it. +type Credential struct { + token string + seal *SealedValues + binding *SealedOperationBinding + notBefore time.Time + issuedAt time.Time + expiresAt time.Time + + // generation identifies which credential of this client's life this is. It + // is what Refresh is told about, so a caller holding a credential the host + // has refused asks for a replacement for THAT one — and two callers + // refused on the same credential get one replacement rather than two + // mints. It is deliberately not exported: it means nothing outside the + // client that issued it. + generation uint64 + + // origin is which client issued it, because generation means nothing + // outside that one. Two clients in a process each count from one, so a + // credential from client B presented to client A's Refresh named a + // generation A had issued to somebody else — and A would compare it, + // believe it, and mint. Checked rather than documented. + origin uint64 +} + +// Token returns the opaque signed capability, as it travels. It is a string and +// not a type of this module's: a capability is an opaque string until a Verifier +// has had it, and a typed wrapper here would offer the reassurance of a check +// nobody performed. +func (c Credential) Token() string { return c.token } + +// Seal returns the execution this credential is bound to, as the capability +// CARRIES it — core's own WorkSealV1, cloned. +// +// It used to return core's Seal, which core documents as the live binding as +// the ISSUER holds it, filled by hand from three wire fields. A client cannot +// answer what the issuer holds, and a field core adds to the seal would have +// been silently zero in a value that reads as the issuer's. Same conflation +// this module already fixed for OperationBinding. +func (c Credential) Seal() *SealedValues { + if c.seal == nil { + return nil + } + copied, ok := proto.Clone(c.seal).(*SealedValues) + if !ok { + return nil + } + return copied +} + +// OperationBinding returns the one binding this credential may act through, or +// nil. It is the binding AS SEALED — the three fields the capability carries — +// and not the live state of that binding, which only the issuer holds and which +// core's OperationBinding describes. +func (c Credential) OperationBinding() *SealedOperationBinding { + return cloneSealedBinding(c.binding) +} + +// NotBefore reports when the credential starts being accepted. It is a +// separate claim from IssuedAt, and it is the one a verifier tests, so a client +// that checked only issued_at was checking a different window from the one its +// credential would be judged against. +func (c Credential) NotBefore() time.Time { return c.notBefore } + +// IssuedAt reports when the host minted it. +func (c Credential) IssuedAt() time.Time { return c.issuedAt } + +// ExpiresAt reports when the credential stops being accepted. +func (c Credential) ExpiresAt() time.Time { return c.expiresAt } + +// Attach installs the credential on an outbound HTTP request: the signed +// capability and, beside it, the installation it is sealed to. +func (c Credential) Attach(request *http.Request) error { + return Attach(request, c.token) +} + +// MintCounts is what a process's credential life cost: one first mint, a +// renewal per expiry reached, and a refresh per distinct credential the host +// refused. A correct execution reports exactly one mint. +type MintCounts struct { + Mints uint64 + Renewals uint64 + Refreshes uint64 +} + +// MintClient obtains and renews one credential for one execution. +// +// What it does not do is the point of it. There is no heartbeat: nothing here +// runs on a timer that is not the credential's own expiry. There is no +// registration call: the process announces nothing about itself, because +// everything the host needs to know — which build, which installation, which +// principal — the host establishes from the projected token and its own +// records, and anything the process could report instead is something it could +// misreport. There is no self-reported upstream or manifest for the same +// reason. +type MintClient struct { + mu sync.Mutex + options MintOptions + httpClient *http.Client + now func() time.Time + credential *Credential + generation uint64 + counts MintCounts + + // inflight is non-nil while one goroutine is minting, and is closed when it + // finishes. Concurrent callers wait on the channel rather than on the mutex + // so the wait is cancellable: holding the lock across the request made + // every caller's ctx meaningless and serialised N callers into N requests + // when the mint kept failing. + inflight chan struct{} + + // backoff holds off the next attempt after an OUTAGE, and failures counts + // consecutive ones so the hold-off grows. Without it, a host answering 503 + // received one request per caller per call — the heartbeat under another + // name, arriving exactly when the host is least able to serve it. + backoffUntil time.Time + failures int + + // refused latches a refusal. ErrMintRefused means the host will say the + // same thing again — the projection is not acceptable, the build is not + // approved, the installation is not one this process may serve, an + // authority value drifted — and this package's own documentation says a + // process that sees it MUST NOT SERVE. So it is terminal: once latched, + // every later call returns it rather than handing out the credential in + // hand. + // + // This exists because the previous revision got it exactly backwards. The + // "serve the held credential rather than manufacture an outage" fix was + // applied to EVERY error, so a refusal, an authority drift and the + // final-URL disclosure check all returned the held credential with a nil + // error — and a test was adjusted to assert that as correct. Serving + // through an outage and serving through a refusal are opposite decisions + // and the error type is what tells them apart. + refused error + + // The refresh rate limit, as a token bucket that REFILLS. + // + // It was a hold-off computed from the lifetime count — + // mintBackoff(Refreshes) — which never decayed: after a few legitimate + // refreshes the bound was a minute, permanently, so a refresh days later + // waited for a receiver that had lagged that morning. A bucket bounds the + // steady-state rate, which is what the limit is for, and forgets: it + // refills one token per refreshInterval to a burst of maxRefreshBurst, so + // an idle client refreshes at once and a receiver refusing every fresh + // credential is held to one mint per interval. + refreshTokens float64 + refreshFilled time.Time + + // lastFailure is the most recent outage, kept so a caller held off by it + // learns WHY rather than only that minting is held off. The detached + // request has no caller to return its error to, so without this the + // transport's own words — a refused connection, a certificate, a 503 — + // reached nobody. + lastFailure error + + // refreshWanted records that the in-flight request was started BY a + // refresh, so the detached goroutine can count it correctly. It cannot read + // the reason off its caller any more, which is the point: there may be no + // caller left. + refreshWanted bool + + // readMu and outstandingRead bound the token-source reads to ONE in flight. + // + // ProjectedTokenSource takes no context, so a read cannot be cancelled: a + // blocked source leaves its goroutine blocked. Starting one per attempt + // therefore accumulated them without limit against a source that never + // returns, and handed a source that single-flight had been protecting + // concurrent calls. A separate mutex from c.mu because a read must not be + // held across the client's own lock. + readMu sync.Mutex + outstandingRead *pendingRead + + // id distinguishes this client from any other in the process, so a + // Credential can say which one issued it. + id uint64 +} + +// mintClientIDs numbers clients so a Credential's generation can be held to the +// client that issued it. +var mintClientIDs atomic.Uint64 + +// NewMintClient validates configuration without performing any I/O. +func NewMintClient(options MintOptions) (*MintClient, error) { + endpoint, err := validateMintURL(options.URL) + if err != nil { + return nil, err + } + if options.Authority == nil { + return nil, fmt.Errorf( + "%w: a mint client needs the process's boot-read authority; the audience is read from it", + ErrInvalid, + ) + } + audience := AuthorityValue{ + Name: strings.TrimSpace(options.Audience.Name), + Key: strings.TrimSpace(options.Audience.Key), + } + if audience.Name == "" || audience.Key == "" { + return nil, fmt.Errorf( + "%w: name the pinned value the audience is read from", ErrInvalid, + ) + } + if options.ProjectedToken == nil { + return nil, fmt.Errorf("%w: no projected token source", ErrInvalid) + } + if strings.TrimSpace(options.ProjectionAudience) == "" { + return nil, fmt.Errorf("%w: no projection audience", ErrInvalid) + } + if options.RootCAs == nil { + return nil, fmt.Errorf( + "%w: no trust anchor for the mint endpoint. RootCAs names the roots that may sign "+ + "the certificate at %s, which is where this process sends its service-account "+ + "token, so there is no safe default to fall back to. A deployment that really "+ + "means the host's pool passes x509.SystemCertPool(), which a reader of the call "+ + "site can see", + ErrInvalid, endpoint, + ) + } + timeout := options.RequestTimeout + if timeout == 0 { + timeout = defaultMintRequestTimeout + } + if timeout < time.Millisecond || timeout > maxMintRequestTimeout { + return nil, fmt.Errorf( + "%w: mint request timeout must be between 1ms and %s", + ErrInvalid, maxMintRequestTimeout, + ) + } + lead := options.RenewalLead + if lead == 0 { + lead = defaultRenewalLead + } + if lead <= 0 || lead >= 1 { + return nil, fmt.Errorf("%w: renewal lead must be between zero and one", ErrInvalid) + } + ceiling := options.MaxCredentialLifetime + if ceiling == 0 { + ceiling = defaultMaxCredentialLifetime + } + if ceiling <= 0 { + return nil, fmt.Errorf("%w: maximum credential lifetime must be positive", ErrInvalid) + } + if ceiling > corework.MaxTTLCeiling { + // Refused rather than clamped, so a deployment that configured a + // window nothing will mint finds out at construction instead of at the + // first mint. Core refuses the same thing on its own side. + return nil, fmt.Errorf( + "%w: maximum credential lifetime of %s is above core's absolute ceiling of %s, which no authority mints beyond", + ErrInvalid, ceiling, corework.MaxTTLCeiling, + ) + } + now := options.Now + if now == nil { + now = time.Now + } + options.URL = endpoint + options.MaxCredentialLifetime = ceiling + options.Audience = audience + options.RequestTimeout = timeout + options.RenewalLead = lead + return &MintClient{ + options: options, + httpClient: mintHTTPClient(options.RootCAs), + now: now, + id: mintClientIDs.Add(1), + }, nil +} + +// mintHTTPClient builds the client every mint request is made with. The client +// owns it: there is no way for a caller to supply one. +// +// Inspecting a caller's client could not make this safe, which is why the +// option is gone. A nil Transport means the global, mutable +// http.DefaultTransport; a wrapping RoundTripper is opaque; a DialTLSContext +// bypasses TLSClientConfig altogether; and a caller that keeps the +// *http.Transport pointer can turn verification off after construction, because +// copying an http.Client shares its Transport. All four sent the projected +// service-account token over a channel nobody authenticated. So the only thing +// a caller says about the transport is which roots may sign the endpoint's +// certificate. +func mintHTTPClient(roots *x509.CertPool) *http.Client { + return &http.Client{ + CheckRedirect: refuseMintRedirect, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + // Never nil: NewMintClient refuses a client with no trust + // anchor, so there is no path here that falls back to the + // system pool by omission. + RootCAs: roots, + // TLS 1.3 FLOOR. The projected service-account token is the + // one secret this process hands to anybody, and 1.2 permits + // cipher suites and a renegotiation surface that 1.3 removes. + // A mint endpoint is infrastructure this platform runs, not an + // arbitrary third party, so there is no long tail of old + // servers to accommodate — a host that cannot speak 1.3 is a + // host to upgrade. + MinVersion: tls.VersionTLS13, + }, + ForceAttemptHTTP2: true, + DisableCompression: true, + MaxIdleConnsPerHost: 2, + TLSHandshakeTimeout: maxMintRequestTimeout, + }, + } +} + +// refuseMintRedirect is called by net/http BEFORE the redirected request is +// sent, and returning an error means it is never sent. That is the whole +// requirement: the destination of a redirect must receive no request at all, +// because the first one carried the projected service-account token and the +// second would carry it to an address nothing configured. +// +// It refuses rather than returning http.ErrUseLastResponse so the outcome is a +// named refusal instead of a 3xx that later checks have to recognise. +// +// RETRYABLE, which is the opposite of the previous revision, and the reason it +// gave does not survive being stated precisely. It carried ErrMintRefused +// because "retrying would send the projection again" — but the projection is +// sent to the CONFIGURED endpoint on every attempt either way, and the +// redirect's destination receives nothing, now or on a retry, because this +// function is what stops it. Latching bought no confidentiality at all. What it +// did buy was a permanent stop on a 307 during a rollout or a 308 adding a +// trailing slash, both measured, both of which clear in seconds. Exactly the +// TLS-verification mistake, one layer up. +func refuseMintRedirect(request *http.Request, via []*http.Request) error { + return fmt.Errorf( + "%w: the mint endpoint redirected to %s after %d request(s); a credential request is not followed to an address the configuration did not name, and this retries", + ErrMintUnavailable, request.URL.Redacted(), len(via), + ) +} + +// Credential returns the credential to use now: the first one is minted, and +// one that has reached its renewal point is renewed. +// +// It is NOT a guarantee about a credential a caller already holds. A Credential +// is a value, so one read out of here and kept can be attached after the host +// has moved the state under it; that is what Refresh is for, and it is why a +// receiver verifies rather than trusting that a caller re-asked. +// +// **A held credential that is still valid is served through an OUTAGE, and +// never through a refusal.** Entering the renewal lead and failing used to +// return an error to every caller while the credential in hand had minutes of +// validity left — an outage this client manufactured out of a credential that +// still worked. But the fix was then applied to every error, so a refusal +// served the held credential too, which is the opposite decision: ErrMintRefused +// means the host will say the same thing again and a process seeing it must +// stop serving. So an unreachable host serves what is in hand until it +// expires, and a refusal is TERMINAL for this client — Refused reports it, and +// every later call returns it. +// +// One mint at a time, and the wait is cancellable. Concurrent callers wait on a +// channel rather than on the mutex, so a caller whose ctx is cancelled stops +// waiting, and a failing mint produces one request rather than one per caller. +// After a failure the next attempt is held off, growing to a minute: a process +// obtains one credential per execution, and a process that answered a 503 with +// a request per call would be the heartbeat again under another name. +func (c *MintClient) Credential(ctx context.Context) (Credential, error) { + if c == nil { + return Credential{}, fmt.Errorf("%w: nil mint client", ErrInvalid) + } + if ctx == nil { + return Credential{}, fmt.Errorf("%w: nil context", ErrInvalid) + } + return c.obtain(ctx, mintReasonRenewal, nil) +} + +// Refresh replaces a credential the host has refused — ErrRevoked from a +// verifier at the far end, which means the credential was sound when it was +// minted and the state moved under it. +// +// Without it there was no way back: Credential answers from the credential it +// holds until that credential's own renewal point, so a process whose +// installation revision changed served refusals until a lead the host chooses +// had elapsed. +// +// It is tied to the credential that was refused, not to the clock. A caller +// passes back the Credential it was refused on; if this client has already +// replaced that one, the replacement is returned and nothing is minted. So a +// burst of concurrent refusals on one credential produces exactly one +// replacement mint, and a caller holding a credential two generations old +// cannot roll the client backwards by asking about it. +// +// It has its OWN rate limit, which matters for the case a generation check +// alone does not cover: a receiver whose seal source lags refuses each FRESH +// credential too, and every refusal is a new generation, so the generation +// check never fires. The limit is a token bucket — a burst of three, refilling +// one per minute — so a lagging receiver costs one mint a minute in the steady +// state while an honest revocation is answered at once. It refills with elapsed +// time, so a client that refreshed this morning is not held off tonight. +// +// A credential this client did not issue is refused with ErrInvalid: the +// generation it names belongs to whichever client minted it, and comparing it +// here would let a credential from one client in the process drive another +// client's mint. +func (c *MintClient) Refresh(ctx context.Context, refused Credential) (Credential, error) { + if c == nil { + return Credential{}, fmt.Errorf("%w: nil mint client", ErrInvalid) + } + if ctx == nil { + return Credential{}, fmt.Errorf("%w: nil context", ErrInvalid) + } + // Local misuse carries ErrInvalid, not ErrMintRefused. The two were the + // same sentinel, which made errors.Is(err, ErrMintRefused) true for a + // caller that passed a zero Credential while Refused() was nil — so the + // two ways of asking "is this client refused" disagreed, and the sentinel + // whose documented meaning is "do not serve" was returned for a programmer + // error nobody should stop serving over. + if refused.generation == 0 { + return Credential{}, fmt.Errorf( + "%w: refresh needs the credential that was refused, as this client issued it", + ErrInvalid, + ) + } + if refused.origin != c.id { + return Credential{}, fmt.Errorf( + "%w: that credential was issued by another mint client; its generation means nothing here", + ErrInvalid, + ) + } + // There is deliberately no "this client has issued nothing" check. The + // origin check above subsumes it: a credential whose origin is this + // client's id was issued BY this client, so there is one to replace. A + // branch no input reaches is the ErrUnsealed mistake again — a handler + // that never runs, read by everyone as a case that can happen. + return c.obtain(ctx, mintReasonRefresh, func(held *Credential) bool { + // The credential that was refused is still the one being held, so it + // has to be replaced. If it is NOT — somebody else was refused on the + // same credential and has already replaced it — this asks for nothing, + // and obtain's own freshness check decides whether the replacement can + // be handed over as it stands. + return held.generation == refused.generation + }) +} + +// mintReason says which counter a successful mint belongs to. The FIRST mint is +// always counted as a mint whatever asked for it. +type mintReason int + +const ( + mintReasonRenewal mintReason = iota + mintReasonRefresh +) + +// obtain is the one path that mints. +// +// wanted is an EXTRA reason to mint, asked of the credential currently held and +// called under the lock; nil means "no reason beyond freshness". Freshness +// itself is checked here for every caller, which is the point: Refresh used to +// return the held credential whenever its generation had already moved on, +// without looking at whether that replacement was still usable — so a caller +// refused on an old generation was handed a replacement that had since +// EXPIRED. A credential leaves this function only if it is not due for +// renewal, whoever asked and for whatever reason. +func (c *MintClient) obtain( + ctx context.Context, reason mintReason, wanted func(held *Credential) bool, +) (Credential, error) { + for { + c.mu.Lock() + if c.refused != nil { + // Terminal. A refusal is not something a later call outlives. + c.mu.Unlock() + return Credential{}, c.refused + } + held := c.credential + if held != nil && !c.dueForRenewalLocked(*held) && + (wanted == nil || !wanted(held)) { + credential := *held + c.mu.Unlock() + return credential, nil + } + if reason == mintReasonRefresh && held != nil && wanted != nil && !wanted(held) && + c.now().UTC().Before(held.expiresAt) { + // A refresh asking about a generation this client has ALREADY + // replaced, where the replacement is the credential in hand. It is + // answered even when that credential has entered its renewal lead: + // the caller asked for something newer than what it was refused + // on, and this is it. The renewal is Credential's business and + // happens on its own schedule. + // + // Falling through here instead minted — and when the bucket was + // empty it returned a held-off ErrMintUnavailable, so the one + // credential that WAS the requested replacement was the one thing + // this would not hand over. + credential := *held + c.mu.Unlock() + return credential, nil + } + // servable is the credential this call may fall back on when minting is + // UNAVAILABLE. A refresh has none: its caller was refused on the + // credential being held, so handing that same credential back would + // report a replacement that did not happen. It is recomputed rather + // than captured wherever the clock may have moved since. + servable := c.servableLocked(reason, wanted) + if waiting := c.inflight; waiting != nil { + // Another goroutine is minting. Wait for it rather than queueing a + // second request, and let ctx cancel the wait. + c.mu.Unlock() + select { + case <-waiting: + continue + case <-ctx.Done(): + return c.answerCancelled(ctx, reason, wanted) + } + } + if until, holding := c.holdOffLocked(reason); holding { + if servable != nil { + credential := *servable + c.mu.Unlock() + return credential, nil + } + failure := c.lastFailure + c.mu.Unlock() + if failure != nil { + // The transport's own words, plus when the next attempt may + // happen. A caller held off by a failure it cannot see has no + // way to tell a 503 from a certificate it must fix. + return Credential{}, fmt.Errorf("%w (held off until %s)", failure, until) + } + return Credential{}, fmt.Errorf( + "%w: minting is held off until %s", ErrMintUnavailable, until, + ) + } + // This caller takes the slot and STARTS the request, then waits for + // it exactly as any other waiter does. It does not run the request on + // its own context. + // + // It used to, and the cancelled leader was then a case of its own: + // counted as no failure, so no hold-off. Under callers whose deadlines + // are shorter than a degraded host's latency, every call became the + // new leader, sent one request, cancelled it and learned nothing — + // roughly one mint request per caller deadline, each of which the host + // may well complete, and audit, while this process throws it away. A + // refusal arriving just after the leader gave up was discarded too, so + // the next caller presented the projected token again. + // + // Detached and bounded by RequestTimeout, the request outlives whoever + // asked for it: it completes once, is classified once, and the result + // is installed for everybody. However many callers give up, the host + // sees one request per hold-off. + done := make(chan struct{}) + c.inflight = done + if reason == mintReasonRefresh { + c.refreshWanted = true + } + c.mu.Unlock() + go c.mintInto(ctx, done) + + select { + case <-done: + // Re-read the state the request installed, under the lock, at the + // top of the loop: a credential, a latched refusal, or a hold-off. + continue + case <-ctx.Done(): + return c.answerCancelled(ctx, reason, wanted) + } + } +} + +// mintInto performs the one detached request and installs whatever it learned. +// +// Its context is NOT any caller's. It is bounded by RequestTimeout, which is +// what makes the request terminate, and detached from the caller that started +// it, which is what makes the result arrive whether or not that caller is still +// interested. +func (c *MintClient) mintInto(caller context.Context, done chan struct{}) { + // WithoutCancel(caller), not Background(). Detaching from the caller's + // CANCELLATION is the whole point; detaching from its VALUES throws away + // anything the caller put there for the request to carry — a trace span, a + // request id, whatever an http.RoundTripper in the caller's stack reads. + // Background() dropped all of it silently, which is a different thing from + // what the comment said this was doing. + ctx, cancel := context.WithTimeout( + context.WithoutCancel(caller), c.options.RequestTimeout, + ) + defer cancel() + credential, err := c.mintOnce(ctx) + + c.mu.Lock() + defer func() { + c.mu.Unlock() + close(done) + }() + c.inflight = nil + refresh := c.refreshWanted + c.refreshWanted = false + renewal := c.credential != nil + if err != nil { + // ONE LATCH RULE: only ErrMintRefused is terminal, and nothing else is. + // + // It used to be the negation — anything that was not an outage latched + // — which made every error this client had not thought about into a + // permanent stop. An ErrInvalid from a caller's own token source + // latched while Refused() reported something errors.Is(…, + // ErrMintRefused) said false about, so the two ways of asking "is this + // client refused" disagreed; and the misconfiguration sentinels said + // in their own comments that they were NOT latched, while this branch + // latched them. + // + // Everything that is not an enumerated refusal is held off and + // retried, with the error kept so a caller can see it. The asymmetry + // is the whole argument: a wrong "retryable" costs one request per + // hold-off, and a wrong "terminal" costs the process. + if errors.Is(err, ErrMintRefused) { + c.refused = err + return + } + c.failures++ + c.backoffUntil = c.now().UTC().Add(mintBackoff(c.failures)) + c.lastFailure = err + return + } + if renewal && credential.token == c.credential.token { + // The host handed back the credential it was asked to replace. That is + // not a replacement: installing it would count a refresh or a renewal + // for a credential that did not change, and it would come due again + // immediately. An idempotent mint endpoint does exactly this. + c.failures++ + c.backoffUntil = c.now().UTC().Add(mintBackoff(c.failures)) + c.lastFailure = fmt.Errorf( + "%w: the mint returned the credential it was asked to replace", ErrMintUnavailable, + ) + return + } + c.failures = 0 + c.backoffUntil = time.Time{} + c.lastFailure = nil + c.generation++ + credential.generation = c.generation + credential.origin = c.id + c.credential = &credential + switch { + case !renewal: + c.counts.Mints++ + case refresh: + c.counts.Refreshes++ + c.spendRefreshTokenLocked() + default: + c.counts.Renewals++ + } +} + +// answerCancelled is what a caller gets when it stops waiting: the credential +// in hand if one is servable FOR THIS KIND OF CALL at the clock as it is now, +// or its own cancellation. +// +// Re-evaluated under the lock rather than from a snapshot taken before the +// wait. The snapshot version handed an EXPIRED credential, with a nil error, to +// a waiter that entered one second before expiry and gave up one second after +// it — and two seconds fits easily inside the default five-second timeout. +func (c *MintClient) answerCancelled( + ctx context.Context, reason mintReason, wanted func(held *Credential) bool, +) (Credential, error) { + c.mu.Lock() + terminal := c.refused + servable := c.servableLocked(reason, wanted) + snapshot := Credential{} + if servable != nil { + snapshot = *servable + } + c.mu.Unlock() + if terminal != nil { + return Credential{}, terminal + } + if servable != nil { + return snapshot, nil + } + // Both stay in the chain: a caller that cancelled wants to see its own + // cancellation, not only that minting was unavailable. + return Credential{}, fmt.Errorf("%w: %w", ErrMintUnavailable, ctx.Err()) +} + +// holdOffLocked reports whether minting is held off for this kind of call, and +// until when. A renewal is held off by consecutive failures; a refresh is held +// off by its own rate limit as well, which a successful mint does not reset. +func (c *MintClient) holdOffLocked(reason mintReason) (time.Time, bool) { + now := c.now().UTC() + if !c.backoffUntil.IsZero() && now.Before(c.backoffUntil) { + return c.backoffUntil, true + } + if reason == mintReasonRefresh { + if available := c.refreshTokensLocked(); available < 1 { + // When the next token lands, which is a real instant rather than a + // growing penalty: the bucket refills whether or not anybody asks. + return now.Add(time.Duration((1 - available) * float64(refreshInterval))), true + } + } + return time.Time{}, false +} + +// refreshTokensLocked refills the refresh bucket to now and reports what is in +// it. It is a function of ELAPSED TIME, so the limit forgets: a client that +// refreshed three times this morning is not still paying for it tonight. +// +// The previous bound was mintBackoff(Refreshes), computed from the LIFETIME +// count, so after a few legitimate refreshes it was a minute — permanently. +// "One refresh per minute, forever" is not a rate limit, it is a tax. +func (c *MintClient) refreshTokensLocked() float64 { + now := c.now().UTC() + if c.refreshFilled.IsZero() { + c.refreshTokens = maxRefreshBurst + c.refreshFilled = now + return c.refreshTokens + } + if elapsed := now.Sub(c.refreshFilled); elapsed > 0 { + c.refreshTokens += elapsed.Seconds() / refreshInterval.Seconds() + if c.refreshTokens > maxRefreshBurst { + c.refreshTokens = maxRefreshBurst + } + c.refreshFilled = now + } + return c.refreshTokens +} + +// spendRefreshTokenLocked charges one SUCCESSFUL refresh to the bucket. A +// failure is charged to the failure backoff instead, which is the thing that +// grows. +func (c *MintClient) spendRefreshTokenLocked() { + if available := c.refreshTokensLocked(); available >= 1 { + c.refreshTokens = available - 1 + return + } + c.refreshTokens = 0 +} + +// servableLocked is the credential a caller may be handed when minting is +// unavailable: one that is held, unexpired, and not the one a refresh was +// refused on. +func (c *MintClient) servableLocked(reason mintReason, wanted func(held *Credential) bool) *Credential { + if c.credential == nil { + return nil + } + if reason == mintReasonRefresh && (wanted == nil || wanted(c.credential)) { + // The credential being held IS the one this refresh was refused on, so + // handing it back would report a replacement that did not happen. + // + // When it is NOT — the client has already moved on and this caller is + // asking about a generation that has been replaced — the credential in + // hand is exactly the replacement being asked for. Returning nil there + // was a defect of its own: a stale-generation refresh arriving while + // the current credential sat inside its renewal lead got a held-off + // ErrMintUnavailable instead of the perfectly good credential the + // client was holding. + return nil + } + if !c.now().UTC().Before(c.credential.expiresAt) { + return nil + } + return c.credential +} + +// Refused reports the refusal that stopped this client, or nil. +// +// It is exported because a refusal is the one mint outcome a process must act +// on by not serving, and a process cannot act on something it has to infer +// from the error of whichever call happened to be first. +func (c *MintClient) Refused() error { + if c == nil { + return nil + } + c.mu.Lock() + defer c.mu.Unlock() + return c.refused +} + +// mintOnce rechecks the pinned authority, reads the audience from it and mints. +// It performs no locking: obtain holds the single-flight slot across it, so the +// request is made without the client's mutex held and a caller's ctx still +// means something. +func (c *MintClient) mintOnce(ctx context.Context) (Credential, error) { + if err := c.options.Authority.Recheck(ctx); err != nil { + // TERMINAL, AND I TRIED TO CHANGE THIS AND WAS WRONG. Recording why, + // because the next person to audit the latch sites will see the same + // thing I did. + // + // Reading the latch rule against this site, it looks like the + // projected-token defect one layer up: Recheck reports any failure to + // re-resolve a pinned value, and a momentary failure to read the + // runtime's configuration would then permanently stop a process + // holding a good credential. So a distinction between "could not read" + // and "changed" seemed obviously right. + // + // It is not available at this layer. A value that has been WITHDRAWN + // and a value that is momentarily unreadable both arrive as a failed + // lookup — withdrawing an injected variable makes the live read fail, + // not return empty — so any split would have classed a real withdrawal + // as retryable. And the asymmetry runs the other way here than it does + // for a token read: a held credential keeps being SERVED through an + // outage, so a withdrawn audience treated as an outage means serving + // under authority somebody deliberately removed, until expiry. That is + // the security property, and TestAuthorityValueWithdrawnIsAChange says + // so in the root module. + // + // So this stays terminal. The cost is real and is the documented one: + // a configuration read that fails transiently stops the process. The + // fix for that belongs where the distinction exists — in whatever + // resolves the value — not in a guess made here. + return Credential{}, fmt.Errorf("%w: %v", ErrMintRefused, err) + } + audience, err := c.options.Authority.Value(c.options.Audience.Name, c.options.Audience.Key) + if err != nil { + return Credential{}, fmt.Errorf( + "%w: read the audience from %s: %v", ErrMintRefused, c.options.Audience, err, + ) + } + if strings.TrimSpace(audience) == "" { + return Credential{}, fmt.Errorf( + "%w: %s is pinned to an empty audience", ErrMintRefused, c.options.Audience, + ) + } + return c.mintLocked(ctx, audience) +} + +// mintBackoff grows the hold-off with consecutive failures, to a minute. +func mintBackoff(failures int) time.Duration { + hold := minMintBackoff + for range failures - 1 { + hold *= 2 + if hold >= maxMintBackoff { + return maxMintBackoff + } + } + return hold +} + +// Counts reports what this process's credential life cost. A correct execution +// reports exactly one mint; renewals are expected and bounded by the +// credential's lifetime, not by a beat, and a refresh happens only when the +// host has refused the credential being held. +func (c *MintClient) Counts() MintCounts { + if c == nil { + return MintCounts{} + } + c.mu.Lock() + defer c.mu.Unlock() + return c.counts +} + +// pendingRead is the ONE outstanding read of the token source, with WHEN it +// started — which is what makes "is this answer still fresh" answerable. +// +// At file level because the gate refuses a function-local type: a local +// declaration is invisible to every rule that reads a file's declarations, and +// that is true of this one too. +type pendingRead struct { + started time.Time + done chan struct{} + token string + err error +} + +// projectedToken reads the token source UNDER THE DEADLINE. +// +// ProjectedTokenSource is a caller's interface and takes no context, so nothing +// about it can be interrupted: ProjectedTokenFile does os.Open and io.ReadAll, +// neither of which a context reaches, and a caller's own implementation may do +// anything at all. RequestTimeout bounded the HTTP request and the comment on +// mintInto said it bounded "the one detached request" — measured with a source +// that blocks and RequestTimeout at 150ms, Credential returned after 3.0s, +// bounded only by the CALLER's deadline. With no caller deadline it does not +// return: the detached goroutine never closes `done`, so every later caller +// waits on an attempt that will not finish, and no hold-off is ever installed. +// +// The read runs on its own goroutine and the deadline is waited on instead. The +// goroutine outlives the timeout — there is no way to interrupt a blocking read +// behind an interface that takes no context — so the channel is buffered and +// the value it eventually sends is dropped. That leak is bounded by one +// goroutine per attempt and attempts are single-flighted and held off; a +// permanently wedged client is not. +func (c *MintClient) projectedToken(ctx context.Context) (string, error) { + started := c.now().UTC() + + c.readMu.Lock() + pending := c.outstandingRead + if pending != nil && started.Sub(pending.started) > c.options.RequestTimeout { + // AN OLD READ'S ANSWER IS A STALE BEARER, so this attempt does not take + // it and does not start a second reader either. + // + // The bound added for the accumulating-goroutines finding introduced + // this: the value from a timed-out read sat in a buffered channel and + // the NEXT attempt took it. The projection is rotated under the running + // process — that is why ProjectedToken is called again before every + // renewal rather than cached — so a token read minutes ago is expired, + // the host answers 401, and 401 LATCHES. A fix for a leak that + // permanently stops the process is worse than the leak. + // + // Refusing here keeps the bound (no second goroutine) and serves + // nothing stale: a wedged source makes every attempt an outage, which + // is the truth about a wedged source. + c.readMu.Unlock() + return "", fmt.Errorf( + "%w: a projected-token read started %s ago has not finished; its answer would be a "+ + "token read before the last rotation, and a stale bearer earns a 401 that latches", + ErrMintUnavailable, started.Sub(pending.started).Round(time.Millisecond), + ) + } + // WHY THE CHECK ABOVE IS THE WHOLE OF IT, since a review held that a + // transfer between attempts was still possible and I could not construct + // one. The chain is short enough to state: + // + // - `done` is closed IMMEDIATELY after the answer is stored, so an + // attempt waiting on it takes the value as soon as the source returns; + // - an attempt joins a read only if that read began within one + // RequestTimeout, which is the check above; + // - every attempt's own context is RequestTimeout-bounded from before + // this function is entered, by mintInto. + // + // So the value an attempt receives was sampled inside its own budget, + // whichever attempt started the read. I added a second check of the + // answer's age at the point of USE as belt and braces, and then removed + // it: `sampled` is never before `started`, and `done` closes at `sampled`, + // so no test could reach the branch. A rule no test can reach is a rule + // nobody has tested, which is the standard the rest of this file is held + // to — so the argument is written here instead of a branch nobody checks. + if pending == nil { + // ONE OUTSTANDING READ, NOT ONE PER ATTEMPT. ProjectedTokenSource takes + // no context, so a read cannot be cancelled and a blocked source leaves + // its goroutine blocked; starting one per attempt accumulated them + // without limit at the maximum-backoff rate, and handed a source that + // single-flight had been protecting concurrent calls. + pending = &pendingRead{started: started, done: make(chan struct{})} + c.outstandingRead = pending + go func() { + token, err := c.options.ProjectedToken.ProjectedToken() + c.readMu.Lock() + pending.token, pending.err = token, err + if c.outstandingRead == pending { + c.outstandingRead = nil + } + c.readMu.Unlock() + close(pending.done) + }() + } + c.readMu.Unlock() + + select { + case <-pending.done: + c.readMu.Lock() + token, err := pending.token, pending.err + c.readMu.Unlock() + return token, err + case <-ctx.Done(): + // AN OUTAGE, under the one latch rule: a projection that is slow is a + // mount under load or a source holding a lock, and both clear. + return "", fmt.Errorf( + "%w: reading the projected token did not finish within %s: %w", + ErrMintUnavailable, c.options.RequestTimeout, ctx.Err(), + ) + } +} + +// dueForRenewalLocked is true once the credential has entered its renewal lead. +// An already-expired credential is also due, and is never returned to a caller: +// handing one out would turn a renewal failure into an authorization failure at +// the far end, where nothing can act on it. +func (c *MintClient) dueForRenewalLocked(credential Credential) bool { + now := c.now().UTC() + remaining := credential.expiresAt.Sub(now) + if remaining <= 0 { + return true + } + lifetime := credential.expiresAt.Sub(credential.notBefore) + lead := time.Duration(float64(lifetime) * c.options.RenewalLead) + if lead < minRenewalLeadTime { + lead = minRenewalLeadTime + } + if lead > lifetime { + lead = lifetime + } + return remaining <= lead +} + +func (c *MintClient) mintLocked(ctx context.Context, audience string) (Credential, error) { + projected, err := c.projectedToken(ctx) + if err != nil { + // A source is a caller's code. An error from one that carries neither + // sentinel was returned as it stood and then LATCHED as terminal, so + // errors.Is(Refused(), ErrMintRefused) was false while the client was + // refused — and a transient local fault in somebody's own reader + // stopped the process for good. An unlabelled source failure is a + // local fault: retryable. + if errors.Is(err, ErrMintRefused) || errors.Is(err, ErrMintUnavailable) || + errors.Is(err, ErrInvalid) { + return Credential{}, err + } + return Credential{}, fmt.Errorf("%w: read the projected token: %w", ErrMintUnavailable, err) + } + body, err := json.Marshal(mintRequest{ + Audience: audience, + ProjectionAudience: c.options.ProjectionAudience, + }) + if err != nil { + // LOCAL MISUSE, so ErrInvalid: this package built the value being + // encoded, and a failure here is a bug here. Neither of these is + // reachable with a validated URL and a struct of two strings, and + // latching an unreachable branch is still the wrong classification to + // leave written down. + return Credential{}, fmt.Errorf("%w: encode mint request: %v", ErrInvalid, err) + } + requestContext, cancel := context.WithTimeout(ctx, c.options.RequestTimeout) + defer cancel() + request, err := http.NewRequestWithContext( + requestContext, http.MethodPost, c.options.URL, bytes.NewReader(body), + ) + if err != nil { + return Credential{}, fmt.Errorf("%w: create mint request: %v", ErrInvalid, err) + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + request.Header.Set("Authorization", "Bearer "+projected) + response, err := c.httpClient.Do(request) + if err != nil { + // A refused redirect arrives here already named and already classed. + if errors.Is(err, ErrMintRefused) || errors.Is(err, ErrMintUnavailable) { + return Credential{}, err + } + if reason := tlsVerificationFailure(err); reason != "" { + // AN OUTAGE, and the previous revision had this backwards for a + // reason that does not survive being stated precisely. + // + // It latched, on the argument that "the projected token is not + // presented to an endpoint that cannot prove who it is". But the + // HANDSHAKE FAILS BEFORE ANY REQUEST BYTES LEAVE: the token was + // never sent and a retry would not send it either, so latching + // buys no confidentiality at all. What it does buy is a permanent + // stop on a certificate ROTATION — the endpoint's new leaf not yet + // trusted, a root pool updated a minute later — which is the most + // ordinary cause of this error in a running system. + // + // So it is retryable, and named distinctly in the message because + // a certificate that does not verify is still the thing an + // operator must look at first. + return Credential{}, fmt.Errorf( + "%w: the mint endpoint's certificate did not verify (%s); nothing was sent, and this retries", + ErrMintUnavailable, reason, + ) + } + return Credential{}, fmt.Errorf("%w: %w", ErrMintUnavailable, err) + } + defer func() { + _ = response.Body.Close() + }() + if elsewhere := answeredFromElsewhere(response, c.options.URL); elsewhere != "" { + // An ASSERTION on the transport-ownership invariant, and deliberately + // kept although nothing can currently reach it: CheckRedirect refuses + // first, and the client owns its transport so no RoundTripper can + // follow a redirect internally. A reviewer measured that deleting it + // changes no test, which is true and is not the same as it being + // pointless — what it holds is that those two facts stay true. If the + // answer came from anywhere but the configured URL, the projected + // token has already been seen by something nothing configured, and + // stopping the process is the only safe outcome. + // + // It is a FUNCTION rather than an inline condition so the check itself + // is testable without having to manufacture a transport that cannot + // exist. A branch that can only be asserted by its own absence is the + // ErrUnsealed mistake; a named predicate with a unit test is not. + return Credential{}, fmt.Errorf( + "%w: the mint response came from %s, not the configured endpoint", + ErrMintRefused, elsewhere, + ) + } + if response.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4*1024)) + return Credential{}, fmt.Errorf( + "%w: mint returned HTTP %d", sentinelForStatus(response.StatusCode), response.StatusCode, + ) + } + // Content-Type is REQUIRED, not checked when present. An absent header used + // to be accepted, which made "the response is JSON" something the host + // could decline to state about the one response this process trusts for its + // whole life. + mediaType, _, parseErr := mime.ParseMediaType(response.Header.Get("Content-Type")) + if parseErr != nil || mediaType != "application/json" { + // RETRYABLE. A 200 carrying text/html is an ingress or a service mesh + // answering instead of the host — a default page, a captive error + // page, a misrouted rollout — and every one of those is a moment + // rather than a verdict. Latched, one of them stopped the process for + // good. + return Credential{}, fmt.Errorf( + "%w: mint response must declare Content-Type: application/json and declared %q", + ErrMintUnavailable, response.Header.Get("Content-Type"), + ) + } + payload, err := io.ReadAll(io.LimitReader(response.Body, maxMintResponseBytes+1)) + if err != nil { + // An OUTAGE, not a refusal. A body that stops mid-read is a connection + // interrupted after the headers — the host said 200 and the network + // went away — and classifying it ErrMintRefused made it TERMINAL once + // refusals started latching: one interrupted read permanently stopped + // a process holding a perfectly good credential, and it stayed stopped + // after the endpoint recovered. + // + // The underlying error stays in the chain so a caller can see what the + // transport did, rather than only that minting was unavailable. + return Credential{}, fmt.Errorf("%w: read mint response: %w", ErrMintUnavailable, err) + } + if len(payload) > maxMintResponseBytes { + // Retryable: an oversized body on a 200 is something in the middle + // answering, not the host refusing. (A "Terminal, unlike the read + // failure above" paragraph sat directly on top of this one for three + // revisions after the classification changed under it — the C10 class, + // and the reason both the refusal and the recovery are now asserted in + // one table.) + return Credential{}, fmt.Errorf( + "%w: mint response exceeds %d bytes", ErrMintUnavailable, maxMintResponseBytes, + ) + } + return c.credentialFrom(payload, audience) +} + +// credentialFrom reads the credential out of the response and takes its sealed +// values from the token, not from the response body. The host may echo them for +// a log, and an echo that disagrees with the signature is a refusal: the only +// installation, build and epoch that mean anything are the signed ones. +func (c *MintClient) credentialFrom(payload []byte, audience string) (Credential, error) { + var body mintResponse + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&body); err != nil { + // Retryable: an undecodable body on a 200 is the same class as a wrong + // Content-Type — something answered that is not the host. + return Credential{}, fmt.Errorf("%w: decode mint response: %v", ErrMintUnavailable, err) + } + // DisallowUnknownFields applies to the value just decoded and says nothing + // about what follows it, so a valid response with a second JSON value + // appended was accepted — strict parsing that stopped at the first value. + // The response is ONE object and nothing after it. + if err := decoder.Decode(new(json.RawMessage)); !errors.Is(err, io.EOF) { + return Credential{}, fmt.Errorf( + "%w: mint response carries more than one JSON value", ErrMintUnavailable, + ) + } + token := body.WorkContext + claims, seal, binding, err := sealOf(token) + if err != nil { + // RETRYABLE, and this was the last place in the 200 path that latched. + // + // Measured over httptest-TLS: a 200 carrying `{}`, a 200 carrying + // `null`, and a 200 carrying `{"work_context":"not-a-capability"}` each + // latched ErrMintRefused and stayed refused forever, through the host + // recovering and the clock passing every hold-off. ErrMintRefused is + // documented as "the host will give the same answer again", and this is + // the shape where that is least likely to be true: a body with no + // capability in it is a rollout mid-flight, a cache, a mesh, an ingress + // — something answering that is not the mint — and it is the same class + // as the text/html 200 and the oversized 200 immediately above, both of + // which were already retryable. Three readings of one situation, one of + // them terminal. + // + // What still latches is a capability the HOST SIGNED that contradicts + // what this client asked for: a wrong audience, a wrong installation, a + // wrong execution. Those are decisions, and a decision repeats. + return Credential{}, fmt.Errorf( + "%w: the mint answered 200 with no readable capability (%v); "+ + "something that is not the host may be answering, and this retries", + ErrMintUnavailable, err, + ) + } + if body.InstallationID != "" && body.InstallationID != seal.GetInstallationId() { + return Credential{}, fmt.Errorf( + "%w: mint reported installation %q and sealed %q", + ErrMintRefused, body.InstallationID, seal.GetInstallationId(), + ) + } + if body.BuildIncarnation != "" { + // PRESENCE, not value. The sealed execution became OPTIONAL in core + // 4cb260d3, because a principal that bears no approved build — a + // person at a terminal — must not have one invented for it. So a + // capability can legitimately carry no incarnation, and + // GetBuildIncarnation() answers 0 for both "absent" and a value core + // says is never legitimate (an incarnation starts at 1). + // + // Comparing the echo against that zero would have accepted a host + // echoing "0" against a capability bearing no execution at all, which + // is a host asserting an execution the issuer does not hold. The echo + // is only for catching a host and a signature that disagree, but + // "disagree" has to include "one of them says nothing". + if !sealCarriesAnExecution(seal) { + return Credential{}, fmt.Errorf( + "%w: mint reported build incarnation %q and the capability seals no execution at all", + ErrMintRefused, body.BuildIncarnation, + ) + } + if body.BuildIncarnation != strconv.FormatUint(seal.GetBuildIncarnation(), 10) { + return Credential{}, fmt.Errorf( + "%w: mint reported build incarnation %q and sealed %d", + ErrMintRefused, body.BuildIncarnation, seal.GetBuildIncarnation(), + ) + } + } + // The audience the host signed must be the audience read from the pin. + // This is the check an optional pin beside a free-string Audience could not + // make: Recheck passing told nobody that the credential in hand was minted + // for the pinned value rather than for whatever a caller had typed. + if claims.GetAudience() != audience { + return Credential{}, fmt.Errorf( + "%w: credential was minted for audience %q, and %s is pinned to %q", + ErrMintRefused, claims.GetAudience(), c.options.Audience, audience, + ) + } + credential := Credential{ + token: token, + seal: seal, + binding: binding, + notBefore: time.Unix(claims.GetNotBeforeUnix(), 0).UTC(), + issuedAt: time.Unix(claims.GetIssuedAtUnix(), 0).UTC(), + expiresAt: time.Unix(claims.GetExpiresAtUnix(), 0).UTC(), + } + if err := c.checkWindow(credential); err != nil { + return Credential{}, err + } + return credential, nil +} + +// checkWindow refuses a credential that is unusable the moment it arrives, and +// one valid for longer than this process will hold a credential. +// +// Two things it gets right that the first version did not. It tests +// **not_before**, which is the claim core's verifier tests; testing issued_at +// was testing a different window from the one the credential would be judged +// against, and a host that sets them apart would have had a credential accepted +// here and refused everywhere. And it bounds the lifetime to what this +// DEPLOYMENT chose to hold — below core's absolute ceiling, which core refuses +// inside Inspect, the call sealOf already makes. +// +// The tolerance is core's own skew, so this client is never stricter than the +// verifier that will accept the credential. +func (c *MintClient) checkWindow(credential Credential) error { + if !credential.expiresAt.After(credential.notBefore) { + return fmt.Errorf( + "%w: the minted credential expires at or before it becomes valid (%s to %s)", + ErrMintRefused, credential.notBefore, credential.expiresAt, + ) + } + if lifetime := credential.expiresAt.Sub(credential.notBefore); lifetime > c.options.MaxCredentialLifetime { + // A DEPLOYMENT POLICY, below core's absolute ceiling. Anything above + // that ceiling was refused by corework.Inspect before this ran, in + // sealOf — so what is reachable here is a process that chose to hold a + // credential for less time than the platform permits. + return fmt.Errorf( + "%w: the minted credential is valid for %s, and this process holds one for at most %s", + ErrMintRefused, lifetime, c.options.MaxCredentialLifetime, + ) + } + now := c.now().UTC() + if now.Before(credential.notBefore.Add(-corework.DefaultSkew)) { + // Not yet valid: the clocks are further apart than core's own skew. + // RETRYABLE, because the thing that most often causes it does fix + // itself — ntpd stepping a clock that drifted, a VM resuming. Latched, + // a process that happened to boot inside that window never recovered. + return fmt.Errorf( + "%w: the minted credential is not valid until %s, and it is %s", + ErrMintUnavailable, credential.notBefore, now, + ) + } + if !now.Before(credential.expiresAt) { + // Already expired on arrival. This is what a DELAYED response looks + // like, which the next attempt may well not hit, so it is an outage + // and not a refusal — and the backoff is what stops the next attempt + // being immediate. + return fmt.Errorf( + "%w: the minted credential expired at %s, and it is %s", + ErrMintUnavailable, credential.expiresAt, now, + ) + } + if c.dueForRenewalLocked(credential) { + // Installing it would put the client straight back into a renewal and + // the next response would be the same one: a mint loop, one audit event + // per iteration, which is the heartbeat this client replaced. + // + // ErrMintUnavailable rather than ErrMintRefused, which is the sentinel + // this carried first and got wrong: a merely delayed response produces + // exactly this, and ErrMintRefused means "the host will say the same + // thing again, do not serve". A caller must not stop serving because + // one response arrived late. + return fmt.Errorf( + "%w: the minted credential's whole remaining lifetime is inside the renewal lead (%s to %s)", + ErrMintUnavailable, credential.notBefore, credential.expiresAt, + ) + } + return nil +} + +// answeredFromElsewhere reports the redacted URL a response actually came from +// when that is not the configured endpoint, and "" when it is. +func answeredFromElsewhere(response *http.Response, configured string) string { + if response == nil || response.Request == nil || response.Request.URL == nil { + return "" + } + if response.Request.URL.String() == configured { + return "" + } + return response.Request.URL.Redacted() +} + +// sealCarriesAnExecution is CORE'S OWN TEST, copied expression for expression +// from checkExecutionAgainst (core workcontext/seal.go): +// +// carried := sealed.GetImageDigest() != "" || sealed.GetBuildIncarnation() != 0 +// +// This module had a second definition — it tested PRESENCE +// (seal.BuildIncarnation == nil) while core tests VALUES — and two definitions +// of "carries an execution" is one too many whether or not they currently +// agree. They agree for anything that survives Inspect, because the schema +// requires the pair; they would disagree about a seal carrying a digest with +// no incarnation, which is exactly the kind of case a protovalidate rule gets +// relaxed out from under. +// +// Core exports no predicate for this, so adopting its expression is the +// closest thing to calling it. Core has been asked for one; if it lands, this +// becomes a call and the duplication goes. +func sealCarriesAnExecution(seal *SealedValues) bool { + return seal.GetImageDigest() != "" || seal.GetBuildIncarnation() != 0 +} + +// refusalStatuses are the ONLY statuses that latch this client. +// +// The rule used to be the other way round — 429 and 5xx were retryable and +// EVERYTHING ELSE was a terminal refusal — which made every status a host or +// anything between it and this process might invent into a permanent stop. It +// was measured: a 408 from a proxy, with a valid credential in hand, refused, +// did not serve, and latched for the life of the process. A 425, a 404 from an +// ingress mid-rollout and a 502 rendered as 400 by a sidecar would each do the +// same. +// +// So the default is now "retryable", and latching is the enumerated case: +// +// 401 the projected token is not acceptable. Presenting it again is the +// thing this client must not do. +// 403 the host authenticated the process and refused it — the build is not +// approved, the installation is not one it may serve. +// +// Everything else is an outage: the held credential keeps being served while it +// is valid, the next attempt is held off, and the process does not stop. That is +// the right default because the cost of being wrong is asymmetric — a wrong +// "retryable" costs one request per hold-off, and a wrong "terminal" costs the +// process. +// +// A refusal this client decides for ITSELF latches only when the HOST SIGNED +// the thing being refused: an audience that does not match the pin, a seal or +// an execution the host's own token contradicts. Those are verdicts, and a +// verdict repeats. +// +// Three things that read like that and are NOT: a refused redirect (the +// destination receives nothing either way, so latching bought no +// confidentiality and stopped the process on a rollout's 307), a certificate +// that did not verify (the handshake fails before any request bytes leave, and +// a rotation is the ordinary cause), and a 200 whose body holds no readable +// capability (a mesh, a cache or an ingress answering). Each of those latched +// once, each was measured latching forever, and each is an outage now. +var refusalStatuses = []int{ + http.StatusUnauthorized, + http.StatusForbidden, +} + +// sentinelForStatus classifies the host's status code. +func sentinelForStatus(status int) error { + if slices.Contains(refusalStatuses, status) { + return ErrMintRefused + } + return ErrMintUnavailable +} + +// mintRequest is everything the client tells the host, which is only what it +// wants — never who it is. Principal, installation and build are established +// by the host from the projected token and its own records; a field here for +// any of them would be a field a process could lie in. +type mintRequest struct { + Audience string `json:"audience"` + ProjectionAudience string `json:"projection_audience"` +} + +// mintResponse is the host's answer. The echoed fields are for logs and for +// catching a host and a signature that disagree; nothing authorizes from them. +type mintResponse struct { + WorkContext string `json:"work_context"` + InstallationID string `json:"installation_id,omitempty"` + BuildIncarnation string `json:"build_incarnation,omitempty"` +} + +// validateMintURL requires absolute HTTPS with no userinfo, query or fragment. +// +// Its refusal is ErrInvalid, like every other construction-time check: nothing +// has been asked of a host, so "the mint refused you" is the wrong thing to +// tell a caller — and ErrMintRefused is the sentinel this package documents as +// meaning a process must stop serving. +// +// Plaintext HTTP used to be accepted here, which made the strongest reason this +// endpoint exists — that the projected service-account token is presented to it +// and nothing else — contingent on a configuration value. A credential request +// goes over an authenticated channel or it does not go. +func validateMintURL(raw string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Host == "" || parsed.User != nil || + parsed.Fragment != "" || parsed.RawQuery != "" || parsed.Scheme != "https" { + return "", fmt.Errorf( + "%w: mint URL must be an absolute https URL without credentials, query, or fragment", + ErrInvalid, + ) + } + return parsed.String(), nil +} + +// tlsVerificationFailure names the reason when err is a failure to +// AUTHENTICATE the endpoint, and returns "" for every other transport error. +// +// The distinction is the one this client's whole transport is about. A dial +// timeout, a reset, a closed connection: the endpoint is not answering, which +// is an outage and is retryable. A certificate that does not chain to the +// configured roots, is for another name, or has expired: something answered and +// could not prove it is the endpoint — so the next attempt would present the +// projected service-account token to it again. +func tlsVerificationFailure(err error) string { + var verification *tls.CertificateVerificationError + if errors.As(err, &verification) { + return "certificate verification failed" + } + var unknownAuthority x509.UnknownAuthorityError + if errors.As(err, &unknownAuthority) { + return "certificate is not signed by a configured root" + } + var hostname x509.HostnameError + if errors.As(err, &hostname) { + return "certificate is for another name" + } + var invalid x509.CertificateInvalidError + if errors.As(err, &invalid) { + return "certificate is not valid" + } + var recordHeader tls.RecordHeaderError + if errors.As(err, &recordHeader) { + // The endpoint answered something that is not TLS at all on a URL this + // client required to be https. + return "the endpoint did not answer with TLS" + } + return "" +} diff --git a/workcontext/mint_test.go b/workcontext/mint_test.go new file mode 100644 index 0000000..52a6641 --- /dev/null +++ b/workcontext/mint_test.go @@ -0,0 +1,1421 @@ +package workcontext + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + corework "github.com/codefly-dev/core/workcontext" + "github.com/stretchr/testify/require" +) + +// mintHost is the host's mint endpoint, counting what it is asked for. The +// count is the acceptance criterion: a process that runs for an hour must not +// have announced itself every fifteen seconds. +// +// It is served over TLS, because that is the only way the client will talk to +// it: the projected service-account token is a bearer credential on this +// request, and MintOptions.URL refuses anything but https. +// mintHost records what went wrong on the SERVER's goroutine rather than +// calling require there. require.NoError inside an http.Handler calls FailNow +// off the test goroutine, which Go documents as undefined: it stops the +// handler's goroutine and lets the test carry on, so a failed assertion in the +// host could pass the test. faults is asserted from the test goroutine by +// assertNoHostFaults, which every test using a host runs through t.Cleanup. +type mintHost struct { + t *testing.T + server *httptest.Server + requests atomic.Uint64 + presented atomic.Value // the last projected token the host was shown + bodies chan []byte // every request body, as the client actually sent it + now func() time.Time + lifetime time.Duration + audience string + authority *authority + echoWrong bool + // binding, when set, makes the host mint an operation capability sealed to + // that binding. + binding string + refuseWith int + // mintAudience, when set, makes the host answer with a credential for an + // audience other than the one asked for. + mintAudience string + // mintedAt and mintedFor override when and for how long the host mints, so + // a test can be handed a credential that is already expired, not yet valid, + // or shorter-lived than the renewal lead. + mintedAt *time.Time + mintedFor *time.Duration + // pin is the process's boot-read authority, which answers the audience. + pin *testPin + // before runs at the top of every request, so a test can hold one open. + before func() + + // answer, when set, replaces the whole response with a canned one — the + // shapes something that is NOT the host sends: an ingress error page, a + // mesh's default body, a cache's empty object, a rollout's redirect. + answer *cannedAnswer + + // noApprovedBuild mints for a principal the issuer holds no approved build + // for, which is the human-session shape: the capability seals no execution. + noApprovedBuild bool + + // echoIncarnation overrides the build incarnation the host ECHOES in its + // JSON body, which is only for catching a host and a signature that + // disagree. + echoIncarnation string + + // replay makes the host IDEMPOTENT: it answers every request with this + // exact token. A real mint endpoint that deduplicates by projected token + // does the same thing. + replay string + + faultsMu sync.Mutex + faults []string +} + +// cannedAnswer is a response written verbatim, headers included, so a test can +// present what an ingress or a service mesh presents. +type cannedAnswer struct { + status int + headers map[string]string + body string +} + +func (a *cannedAnswer) write(writer http.ResponseWriter) { + declared := false + for key, value := range a.headers { + if key == "Content-Type" { + declared = true + if value == "" { + continue + } + } + writer.Header().Set(key, value) + } + if !declared && a.headers["Content-Type"] == "" && len(a.headers) == 0 { + writer.Header().Set("Content-Type", "application/json") + } + writer.WriteHeader(a.status) + _, _ = writer.Write([]byte(a.body)) +} + +func newMintHost(t *testing.T, now func() time.Time) *mintHost { + t.Helper() + host := &mintHost{ + t: t, now: now, lifetime: 15 * time.Minute, + audience: testAudience, authority: newAuthority(t), + bodies: make(chan []byte, 64), pin: newTestPin(), + } + host.server = httptest.NewTLSServer(http.HandlerFunc(host.handle)) + t.Cleanup(host.server.Close) + t.Cleanup(func() { + host.faultsMu.Lock() + defer host.faultsMu.Unlock() + require.Empty(t, host.faults, "the mint host hit something it should not have") + }) + return host +} + +func (h *mintHost) handle(writer http.ResponseWriter, request *http.Request) { + h.requests.Add(1) + if h.before != nil { + h.before() + } + h.presented.Store(request.Header.Get("Authorization")) + if h.answer != nil { + h.answer.write(writer) + return + } + if h.refuseWith != 0 { + writer.WriteHeader(h.refuseWith) + return + } + raw, err := readAll(request) + if err != nil { + writer.WriteHeader(http.StatusBadRequest) + return + } + select { + case h.bodies <- raw: + default: + } + var body mintRequest + if err := json.Unmarshal(raw, &body); err != nil { + writer.WriteHeader(http.StatusBadRequest) + return + } + audience := body.Audience + if h.mintAudience != "" { + audience = h.mintAudience + } + mintedAt := h.now() + if h.mintedAt != nil { + mintedAt = *h.mintedAt + } + lifetime := h.lifetime + if h.mintedFor != nil { + lifetime = *h.mintedFor + } + // The host mints with core's Authority, because that is what the host does. + // A test endpoint that assembled a token itself would be a second + // implementation wearing a test's clothes. + token, err := h.authority.tryStartAt(mintedAt, mintInput{ + audience: audience, binding: h.binding, humanSession: h.noApprovedBuild, + }, lifetime) + if err != nil { + // Recorded and answered with a 503, never asserted here: this is the + // server's goroutine. + h.fault("mint %s for %s: %v", mintedAt, lifetime, err) + writer.WriteHeader(http.StatusServiceUnavailable) + return + } + if h.replay != "" { + token = h.replay + } + response := mintResponse{WorkContext: token, InstallationID: testInstallation} + if h.echoIncarnation != "" { + response.BuildIncarnation = h.echoIncarnation + } + if h.echoWrong { + response.InstallationID = "installation-the-host-did-not-seal" + } + writer.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(writer).Encode(response); err != nil { + h.fault("encode the mint response: %v", err) + } +} + +func readAll(request *http.Request) ([]byte, error) { + defer func() { + _ = request.Body.Close() + }() + return io.ReadAll(io.LimitReader(request.Body, maxMintResponseBytes)) +} + +// certPoolOf trusts exactly these test servers and nothing else, so a request +// that reaches the wrong one fails the assertion rather than the handshake. +func certPoolOf(servers ...*httptest.Server) *x509.CertPool { + pool := x509.NewCertPool() + for _, server := range servers { + pool.AddCert(server.Certificate()) + } + return pool +} + +// projectedFile writes a rotating projected token and hands back its path. +func projectedFile(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "token") + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + return path +} + +func newTestMintClient(t *testing.T, host *mintHost, path string, now func() time.Time, options ...func(*MintOptions)) *MintClient { + t.Helper() + settings := MintOptions{ + URL: host.server.URL, + Authority: host.pin, + Audience: testAudienceName, + ProjectedToken: ProjectedTokenFile(path), + ProjectionAudience: "projection-audience", + Now: now, + // The only thing a caller may say about the transport: which roots may + // sign the endpoint's certificate. The client builds the rest. + RootCAs: certPoolOf(host.server), + } + for _, option := range options { + option(&settings) + } + client, err := NewMintClient(settings) + require.NoError(t, err) + return client +} + +// The acceptance criterion, as arithmetic rather than as a hope. A process +// that runs for an hour holding a credential it renews only at expiry mints +// once and renews on the credential's own clock. The heartbeat it replaces +// minted once per surface every fifteen seconds — 240 times an hour, each one +// an audit event recording that nothing had changed. +func TestMintOncePerExecutionAndRenewOnlyAtExpiry(t *testing.T) { + clock := testClock + now := func() time.Time { return clock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected-token-1"), now) + + first, err := client.Credential(t.Context()) + require.NoError(t, err) + // The seal as the capability CARRIES it — core's wire message, which also + // carries the owner's epoch that core's live Seal type no longer has. + require.Equal(t, testInstallation, first.Seal().GetInstallationId()) + require.EqualValues(t, 3, first.Seal().GetInstallationRevision()) + require.EqualValues(t, 11, first.Seal().GetBuildIncarnation()) + require.EqualValues(t, testPrincipalEpoch, first.Seal().GetPrincipalEpoch()) + require.EqualValues(t, 1, host.requests.Load()) + + // Every call for the next twelve minutes is answered from the credential + // already held. Nothing reaches the host. + for minute := range 12 { + clock = testClock.Add(time.Duration(minute) * time.Minute) + again, err := client.Credential(t.Context()) + require.NoError(t, err) + require.Equal(t, first.Token(), again.Token()) + } + require.EqualValues(t, 1, host.requests.Load(), "holding a credential must not talk to the host") + require.Equal(t, MintCounts{Mints: 1}, client.Counts()) + + // An hour of a fifteen-minute credential, renewed three minutes before each + // expiry: one mint and five renewals, six requests in total. The heartbeat + // this replaces made 240 in the same hour, one per surface every fifteen + // seconds, each recording that nothing had changed. + // + // Six endpoint requests is what this test can observe. Whether the host + // records one mint audit event or six is the host's accounting and not + // something an SDK test can assert — see the PR body. + for minute := 12; minute <= 60; minute++ { + clock = testClock.Add(time.Duration(minute) * time.Minute) + _, err := client.Credential(t.Context()) + require.NoError(t, err) + } + require.Equal(t, MintCounts{Mints: 1, Renewals: 5}, client.Counts()) + require.EqualValues(t, 6, host.requests.Load()) +} + +// A process whose first request burst is concurrent still mints once. A client +// that minted per goroutine would be the heartbeat again under another name, +// and "one credential per execution" would hold only for a process that +// happened to make its first call from one goroutine. +func TestTheFirstMintIsOneMintUnderConcurrentFirstCalls(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + const callers = 24 + tokens := make([]string, callers) + var waiting sync.WaitGroup + for caller := range callers { + waiting.Add(1) + go func() { + defer waiting.Done() + credential, err := client.Credential(t.Context()) + require.NoError(t, err) + tokens[caller] = credential.Token() + }() + } + waiting.Wait() + + require.EqualValues(t, 1, host.requests.Load(), + "%d concurrent first calls must produce one mint", callers) + require.Equal(t, MintCounts{Mints: 1}, client.Counts()) + for caller := range callers { + require.Equal(t, tokens[0], tokens[caller], "every caller holds the same credential") + } +} + +// The projection is rotated under the running process, so the copy read at +// boot is expired long before the process is. Re-reading it before every +// renewal is what makes the rotation a non-event; caching it would make the +// process fail at its first renewal and need a restart to recover. +func TestRenewalRereadsTheRotatedProjectedToken(t *testing.T) { + clock := testClock + now := func() time.Time { return clock } + host := newMintHost(t, now) + path := projectedFile(t, "projected-token-before-rotation") + client := newTestMintClient(t, host, path, now) + + _, err := client.Credential(t.Context()) + require.NoError(t, err) + require.Equal(t, "Bearer projected-token-before-rotation", host.presented.Load()) + + require.NoError(t, os.WriteFile(path, []byte("projected-token-after-rotation"), 0o600)) + clock = testClock.Add(14 * time.Minute) + _, err = client.Credential(t.Context()) + require.NoError(t, err) + require.Equal(t, "Bearer projected-token-after-rotation", host.presented.Load(), + "the renewal must present the rotated projection, not the one read at boot") + require.Equal(t, MintCounts{Mints: 1, Renewals: 1}, client.Counts()) +} + +// An authority value that drifted under the running process refuses the mint. +// This is the moment the drift would otherwise be laundered: the process would +// mint a credential sealed to a value the host never approved for it, and +// nothing downstream could tell. +// +// It is checked before the FIRST mint too. A process can construct its client +// at boot and make its first call an hour later, and the value can have moved +// in between; a check that began at the first renewal left exactly that window +// open. +func TestEveryMintRefusesWhenTheBootReadAuthorityHasDrifted(t *testing.T) { + clock := testClock + now := func() time.Time { return clock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + _, err := client.Credential(t.Context()) + require.NoError(t, err) + require.EqualValues(t, 1, host.pin.checks.Load(), "the first mint checks the pin too") + + // A drift is a REFUSAL, and the test's name always said so while its + // assertion said the opposite. The previous revision returned the held + // credential with a nil error here, because "serve the held credential + // rather than manufacture an outage" had been applied to every error + // instead of only to outages — and this assertion was changed to match, + // which is the worst shape a test failure can take. + // + // ErrMintRefused means the host will say the same thing again and a + // process that sees it must not serve. A credential still having a minute + // left does not change that: the value it is sealed to has moved. + host.pin.set(testAudience, errors.New("audience changed under a running process")) + clock = testClock.Add(14 * time.Minute) + _, err = client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "audience changed") + require.EqualValues(t, 2, host.pin.checks.Load()) + require.EqualValues(t, 1, host.requests.Load(), "a drifted authority must not reach the mint endpoint") + + // And it is TERMINAL. A later call does not outlive it, and the pin being + // put back does not resurrect the client: the process already minted + // against a value that has since moved, and only a restart re-establishes + // what it runs under. + require.ErrorIs(t, client.Refused(), ErrMintRefused) + host.pin.set(testAudience, nil) + _, err = client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintRefused) + require.EqualValues(t, 1, host.requests.Load()) + require.EqualValues(t, 2, host.pin.checks.Load(), "a refused client stops asking") +} + +// The first mint, specifically: a client constructed at boot whose pin has +// already moved by the time anything asks for a credential must not mint. +func TestTheFirstMintRefusesAnAlreadyDriftedAuthority(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + host.pin.set(testAudience, errors.New("binding changed before the first call")) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + _, err := client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "binding changed") + require.EqualValues(t, 0, host.requests.Load(), + "the pin is checked before the projection is presented to anything") +} + +// testPin stands in for the process's boot-read authority: it answers the +// pinned audience and counts the rechecks. The root SDK's *codefly.Authority +// has the same two methods. +type testPin struct { + mu sync.Mutex + checks atomic.Uint64 + reads atomic.Uint64 + audience string + err error + valueErr error +} + +func newTestPin() *testPin { return &testPin{audience: testAudience} } + +func (p *testPin) Recheck(context.Context) error { + p.checks.Add(1) + p.mu.Lock() + defer p.mu.Unlock() + return p.err +} + +func (p *testPin) Value(name string, key string) (string, error) { + p.reads.Add(1) + p.mu.Lock() + defer p.mu.Unlock() + if p.valueErr != nil { + return "", p.valueErr + } + if name != testAudienceName.Name || key != testAudienceName.Key { + return "", errors.New("authority-bearing value was not read at boot: " + name + "/" + key) + } + return p.audience, nil +} + +func (p *testPin) set(audience string, recheck error) { + p.mu.Lock() + defer p.mu.Unlock() + p.audience = audience + p.err = recheck +} + +var testAudienceName = AuthorityValue{Name: "platform", Key: "work-context-audience"} + +// A credential the host has refused is replaced on demand, not when its own +// renewal lead arrives. Without this there was no way back: the client answered +// from the credential it held until a lead the HOST chooses had elapsed, so an +// installation revision that moved under a long-lived credential meant hours of +// refusals with nothing a process could do about it. +func TestARefusedCredentialIsReplacedOnDemand(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + require.EqualValues(t, 1, host.requests.Load()) + + // Nothing about the clock has changed, so Credential would answer from the + // credential already held — which is the one the far end refused. + same, err := client.Credential(t.Context()) + require.NoError(t, err) + require.Equal(t, held.Token(), same.Token()) + require.EqualValues(t, 1, host.requests.Load()) + + replaced, err := client.Refresh(t.Context(), held) + require.NoError(t, err) + require.NotEqual(t, held.Token(), replaced.Token()) + require.EqualValues(t, 2, host.requests.Load()) + require.Equal(t, MintCounts{Mints: 1, Refreshes: 1}, client.Counts()) + + // And the replacement is what every later caller gets. + after, err := client.Credential(t.Context()) + require.NoError(t, err) + require.Equal(t, replaced.Token(), after.Token()) +} + +// Concurrent refusals on ONE credential produce ONE replacement mint. A +// refresh that minted per caller would turn a single revocation into a burst of +// audit events, which is the failure this whole client replaced. +func TestConcurrentRefusalsOnOneCredentialMintOnce(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + + const callers = 16 + replaced := make([]string, callers) + var waiting sync.WaitGroup + for caller := range callers { + waiting.Add(1) + go func() { + defer waiting.Done() + credential, err := client.Refresh(t.Context(), held) + require.NoError(t, err) + replaced[caller] = credential.Token() + }() + } + waiting.Wait() + + require.EqualValues(t, 2, host.requests.Load(), + "one first mint and one replacement for %d refusals on the same credential", callers) + require.Equal(t, MintCounts{Mints: 1, Refreshes: 1}, client.Counts()) + for caller := range callers { + require.Equal(t, replaced[0], replaced[caller]) + require.NotEqual(t, held.Token(), replaced[caller]) + } +} + +// A refresh is tied to the credential that was refused, so a caller holding a +// stale one cannot roll the client backwards by asking about it. +func TestRefreshIsTiedToTheRefusedCredential(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + first, err := client.Credential(t.Context()) + require.NoError(t, err) + second, err := client.Refresh(t.Context(), first) + require.NoError(t, err) + require.EqualValues(t, 2, host.requests.Load()) + + // Asking again about the credential that was already replaced returns the + // replacement and mints nothing. + again, err := client.Refresh(t.Context(), first) + require.NoError(t, err) + require.Equal(t, second.Token(), again.Token()) + require.EqualValues(t, 2, host.requests.Load()) + require.Equal(t, MintCounts{Mints: 1, Refreshes: 1}, client.Counts()) + + // A Credential this client never issued is not something to refresh + // against: it names no generation of this client's life. + // Local misuse is ErrInvalid, not ErrMintRefused. They were the same + // sentinel, so errors.Is(err, ErrMintRefused) was true for a caller that + // passed a zero Credential while Refused() stayed nil — the two ways of + // asking "is this client refused" disagreeing, over a programmer error. + _, err = client.Refresh(t.Context(), Credential{}) + require.ErrorIs(t, err, ErrInvalid) + require.NotErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "the credential that was refused") + require.NoError(t, client.Refused(), "misuse must not latch this client") + + fresh := newTestMintClient(t, host, projectedFile(t, "projected"), now) + _, err = fresh.Refresh(t.Context(), second) + require.ErrorIs(t, err, ErrInvalid) + require.ErrorContains(t, err, "issued by another mint client", + "a credential from one client names a generation that means nothing to another") +} + +// A refresh rechecks the pin and re-reads the projection, exactly as a renewal +// does: it is a mint, and every mint goes through the same door. +func TestRefreshRechecksThePinAndRereadsTheProjection(t *testing.T) { + clock := testClock + now := func() time.Time { return clock } + host := newMintHost(t, now) + path := projectedFile(t, "projected-before") + client := newTestMintClient(t, host, path, now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + require.EqualValues(t, 1, host.pin.checks.Load()) + require.EqualValues(t, 1, host.pin.reads.Load(), "the audience is READ from the pin, not passed in") + + require.NoError(t, os.WriteFile(path, []byte("projected-after"), 0o600)) + replaced, err := client.Refresh(t.Context(), held) + require.NoError(t, err) + require.NotEqual(t, held.Token(), replaced.Token()) + require.EqualValues(t, 2, host.pin.checks.Load()) + require.EqualValues(t, 2, host.pin.reads.Load()) + require.Equal(t, "Bearer projected-after", host.presented.Load()) + + // A drifted pin REFUSES the refresh, and the refusal is what the caller + // gets. Handing the credential back would be worse than useless here: the + // caller asked for a replacement because the far end refused this one, so + // returning it reports a replacement that did not happen. + // A refresh inside the rate limit is an outage to the caller, so move past + // it before asserting what a DRIFT does. + clock = testClock.Add(5 * time.Minute) + host.pin.set(testAudience, errors.New("audience changed under a running process")) + _, err = client.Refresh(t.Context(), replaced) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "audience changed") + require.ErrorIs(t, client.Refused(), ErrMintRefused) +} + +// The host may echo the sealed values for a log. An echo that disagrees with +// the signature is a refusal: the only installation that means anything is the +// signed one, and a client that preferred the echo would hold a credential +// whose installation it had been told wrongly. +func TestMintRefusesAnEchoThatDisagreesWithTheSignature(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + host.echoWrong = true + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + _, err := client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "reported installation") +} + +// A credential minted for another audience is refused rather than held. The +// far end would refuse it anyway; refusing it here makes the failure a boot +// error in the process that is wrong rather than an authorization error in the +// service it calls. +func TestMintRefusesACredentialForAnotherAudience(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + host.mintAudience = "codefly.other-audience" + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + _, err := client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "minted for audience") +} + +// A credential is checked against its OWN window before it is installed, and +// the sentinel says whether the next attempt could do better. +// +// The renewal check only ever looked at the credential already held, so a +// response that was delayed, or minted against a clock well behind this +// process's, was installed as a success — counted as a working mint — and then +// refused by every receiver. +// +// The window tested is **not_before**, which is the claim core's verifier +// tests. Testing issued_at was testing a different window from the one the +// credential would be judged against. +// +// And the sentinels matter rather than being decoration. ErrMintRefused means +// "the host will say the same thing again, do not serve"; a merely DELAYED +// response is not that, so an already-expired credential and a lifetime that is +// all lead are ErrMintUnavailable. A clock far enough apart to make a +// credential not-yet-valid, and a credential longer than this process will +// hold, are configuration and will not fix themselves. +func TestMintRefusesACredentialThatIsUnusableOnArrival(t *testing.T) { + now := func() time.Time { return testClock } + + for name, arrange := range map[string]struct { + mintedAt time.Time + mintedFor time.Duration + sentinel error + says string + }{ + "already expired, which is what a delayed response looks like": { + mintedAt: testClock.Add(-time.Hour), mintedFor: 15 * time.Minute, + sentinel: ErrMintUnavailable, says: "expired at", + }, + // RETRYABLE: ntpd stepping a drifted clock, or a VM resuming, fixes + // this. Latched, a process that booted inside the window never + // recovered. + "not yet valid, which a clock correction fixes": { + mintedAt: testClock.Add(time.Hour), mintedFor: 15 * time.Minute, + sentinel: ErrMintUnavailable, says: "not valid until", + }, + "whole remaining lifetime inside the renewal lead": { + mintedAt: testClock, mintedFor: 4 * time.Second, + sentinel: ErrMintUnavailable, says: "inside the renewal lead", + }, + } { + t.Run(name, func(t *testing.T) { + host := newMintHost(t, now) + mintedAt, mintedFor := arrange.mintedAt, arrange.mintedFor + host.mintedAt, host.mintedFor = &mintedAt, &mintedFor + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + _, err := client.Credential(t.Context()) + require.ErrorIs(t, err, arrange.sentinel) + require.ErrorContains(t, err, arrange.says) + require.Equal(t, MintCounts{}, client.Counts(), + "a credential that was never usable is not a mint that worked") + }) + } + + // A response minted inside core's own clock skew is NOT refused: the + // tolerance here is the verifier's, so a client stricter than the thing + // that will accept the credential would refuse credentials that work. + host := newMintHost(t, now) + within := testClock.Add(corework.DefaultSkew / 2) + lifetime := 15 * time.Minute + host.mintedAt, host.mintedFor = &within, &lifetime + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + _, err := client.Credential(t.Context()) + require.NoError(t, err, "a credential minted inside core's skew is usable") + + // THE CLIENT-SIDE CEILING, which has now been deleted once on advice and + // restored once on review, so what it is for is worth stating exactly. + // + // At core cd443989 the argument for it was that Authority.MaxTTL took any + // positive value and Verify bounded no lifetime. Core 72d72eb2 closed both: + // MaxTTLCeiling is absolute, maxTTL() clamps to it, and Verify refuses a + // lifetime over it. That is the falsifiable condition this client stated + // when it declined to drop the ceiling — and it is now met, so the + // REASON changes and the check stays, for a reason neither of core's two + // bounds covers: + // + // THIS CLIENT NEVER VERIFIES A SIGNATURE. It cannot: it is the party being + // minted for, not a receiver. It reads the window through corework.Inspect, + // which is structural only and checks no signature. So core's Start bounds + // an honest MINTER and core's Verify bounds a RECEIVER on a current core; + // neither bounds what this process holds in memory and presents for the + // rest of its life. That is this check's whole subject. + // + // It therefore defaults to core's own constant rather than a number of its + // own — one rule, enforced at the layer that holds the credential — and a + // deployment may lower it, which is what this asserts. A 30-day window is + // no longer constructible through core at all, which is the measurement + // that made this test change shape. + require.Equal(t, corework.MaxTTLCeiling, defaultMaxCredentialLifetime, + "the client's default ceiling is core's constant, not a second number") + + host = newMintHost(t, now) + quarterHour := 15 * time.Minute + host.mintedAt, host.mintedFor = &testClockStart, &quarterHour + client = newTestMintClient(t, host, projectedFile(t, "projected"), now, + func(options *MintOptions) { options.MaxCredentialLifetime = 5 * time.Minute }) + _, err = client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "holds one for at most") + + // And the same host is fine for a process that permits the window it mints. + host = newMintHost(t, now) + host.mintedAt, host.mintedFor = &testClockStart, &quarterHour + client = newTestMintClient(t, host, projectedFile(t, "projected"), now, + func(options *MintOptions) { options.MaxCredentialLifetime = time.Hour }) + credential, err := client.Credential(t.Context()) + require.NoError(t, err) + require.Equal(t, testClock.Add(quarterHour), credential.ExpiresAt()) +} + +// testClockStart is an addressable testClock, for the host overrides above. +var testClockStart = testClock + +// A refusal the host will give again is not retryable, and an outage is. A +// client that confused them would either spin against a permanent refusal or +// give up on a transient one. +func TestMintSeparatesARefusalFromAnOutage(t *testing.T) { + now := func() time.Time { return testClock } + for status, sentinel := range map[int]error{ + http.StatusForbidden: ErrMintRefused, + http.StatusUnauthorized: ErrMintRefused, + http.StatusTooManyRequests: ErrMintUnavailable, + http.StatusInternalServerError: ErrMintUnavailable, + http.StatusBadGateway: ErrMintUnavailable, + http.StatusRequestTimeout: ErrMintUnavailable, + http.StatusTooEarly: ErrMintUnavailable, + http.StatusNotFound: ErrMintUnavailable, + } { + t.Run(fmt.Sprintf("http %d", status), func(t *testing.T) { + host := newMintHost(t, now) + host.refuseWith = status + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + _, err := client.Credential(t.Context()) + require.ErrorIs(t, err, sentinel) + }) + } +} + +// The projection is the one thing the client presents, so an unreadable one is +// a refusal rather than an empty bearer token. Sending the request anyway would +// turn "the token is missing" into the host's "this build is not approved", +// which is the wrong thing to go looking for. +func TestProjectedTokenFileRefusesWhatIsNotAToken(t *testing.T) { + // Each fault gets the sentinel its RECOVERY needs, which is the whole + // point: ErrMintRefused latches and permanently stops the process. A + // momentary local fault classified that way stopped a process holding a + // perfectly good credential, and it stayed stopped after the fault + // cleared. + for name, fault := range map[string]struct { + path ProjectedTokenFile + sentinel error + why string + }{ + "no path": {"", ErrInvalid, + "misconfiguration: no request was made and no host refused anything"}, + "absent file": {ProjectedTokenFile(filepath.Join(t.TempDir(), "missing")), ErrMintUnavailable, + "the projection is mounted and rotated under the process; absent is a moment, not a verdict"}, + "empty file": {ProjectedTokenFile(projectedFile(t, "")), ErrMintUnavailable, + "an empty read is the middle of a rotation"}, + "whitespace": {ProjectedTokenFile(projectedFile(t, " \n\t ")), ErrMintUnavailable, + "same as empty once trimmed"}, + } { + t.Run(name, func(t *testing.T) { + _, err := fault.path.ProjectedToken() + require.ErrorIs(t, err, fault.sentinel, fault.why) + require.NotErrorIs(t, err, ErrMintRefused, + "a local fault must not latch as a host refusal") + }) + } + + // Nothing a local read produces is a refusal: every one of them is a + // moment in the life of a file that is mounted and rotated under a running + // process. + // Oversized is RETRYABLE too. Latching bought nothing — the host would + // refuse an oversized bearer anyway — and a projection caught mid-write is + // exactly the shape that produces it. + oversized := strings.Repeat("x", maxProjectedTokenBytes+1) + _, err := ProjectedTokenFile(projectedFile(t, oversized)).ProjectedToken() + require.ErrorIs(t, err, ErrMintUnavailable) + + token, err := ProjectedTokenFile(projectedFile(t, " projected-with-trailing-newline\n")).ProjectedToken() + require.NoError(t, err) + require.Equal(t, "projected-with-trailing-newline", token) +} + +// A mint request carries what the process wants and nothing about who it is. +// Principal, installation and build are the host's to establish; a field for +// any of them would be a field a process could lie in. +// +// It reads the body the HOST received, not a struct this test marshalled. The +// earlier version marshalled mintRequest itself, which proved only that the +// struct has two fields — it would have passed while the client added a +// self-reported principal to the request on its way out. +func TestMintRequestSelfReportsNoIdentity(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + _, err := client.Credential(t.Context()) + require.NoError(t, err) + + var sent []byte + select { + case sent = <-host.bodies: + default: + t.Fatal("the host received no request body") + } + var fields map[string]any + require.NoError(t, json.Unmarshal(sent, &fields)) + require.Equal(t, []string{"audience", "projection_audience"}, keysOf(fields)) + require.Equal(t, testAudience, fields["audience"]) + require.Equal(t, "projection-audience", fields["projection_audience"]) +} + +func keysOf(fields map[string]any) []string { + names := make([]string, 0, len(fields)) + for name := range fields { + names = append(names, name) + } + slices.Sort(names) + return slices.Compact(names) +} + +// The redirect target must receive NO REQUEST AT ALL. +// +// Two things changed here. The old test asserted the returned classification +// after the redirect had already been followed, which is the wrong thing +// entirely: by then the projected token had been sent to an address nothing +// configured. And the hole the first fix closed by copying a supplied client is +// now closed by not accepting one — a caller cannot supply an http.Client at +// all, because inspecting one could not close a nil Transport (the global, +// mutable http.DefaultTransport), a wrapping RoundTripper, a DialTLSContext +// that bypasses TLSClientConfig, or a caller mutating the shared *Transport +// after construction. +// +// So the client under test here is the one the SDK builds, which is the only +// one there is, and what is asserted is containment: zero requests at the +// destination, asserted before the error is even looked at. +func TestARedirectedMintSendsNothingToTheDestination(t *testing.T) { + now := func() time.Time { return testClock } + var elsewhereRequests atomic.Uint64 + elsewhere := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + elsewhereRequests.Add(1) + writer.WriteHeader(http.StatusOK) + })) + t.Cleanup(elsewhere.Close) + + redirector := httptest.NewTLSServer(http.HandlerFunc( + func(writer http.ResponseWriter, request *http.Request) { + http.Redirect(writer, request, elsewhere.URL, http.StatusTemporaryRedirect) + })) + t.Cleanup(redirector.Close) + + // The root pool trusts BOTH servers, so nothing but the refusal stops the + // second request: a client that could not verify the destination's + // certificate would pass this test for the wrong reason. + client, err := NewMintClient(MintOptions{ + URL: redirector.URL, + Authority: newTestPin(), + Audience: testAudienceName, + ProjectedToken: ProjectedTokenFile(projectedFile(t, "projected")), + ProjectionAudience: "projection-audience", + Now: now, + RootCAs: certPoolOf(redirector, elsewhere), + }) + require.NoError(t, err) + + _, err = client.Credential(t.Context()) + + require.EqualValues(t, 0, elsewhereRequests.Load(), + "the redirect destination must receive no request: the first one carried the projection") + // RETRYABLE, which reverses what this asserted, and the reason it asserted + // it does not hold: "a redirected credential request is not something to + // retry" was argued from the projection being re-sent, and the projection + // goes to the CONFIGURED endpoint on every attempt either way, while the + // redirect's destination receives nothing on any of them — the assertion + // above this one is what says so. Measured: a 307 during a rollout and a + // 308 adding a trailing slash each latched and stayed refused forever. + require.ErrorIs(t, err, ErrMintUnavailable) + require.NotErrorIs(t, err, ErrMintRefused, "a redirect must not latch") + require.NoError(t, client.Refused()) + require.ErrorContains(t, err, "redirected") + require.Equal(t, MintCounts{}, client.Counts()) +} + +// The transport is the SDK's, and a caller cannot reach it. +// +// This is the finding in its sharpest form: the previous version inspected a +// supplied *http.Client and refused the one configuration it could see +// (InsecureSkipVerify on an *http.Transport). Four configurations it could not +// see each sent the projected bearer over an unauthenticated channel. None of +// them is reachable now, because there is no option to supply. +func TestTheMintTransportIsOwnedByTheClient(t *testing.T) { + client := mintHTTPClient(nil) + + transport, ok := client.Transport.(*http.Transport) + require.True(t, ok, "the client builds a concrete *http.Transport, not a wrapper it cannot reason about") + require.NotNil(t, transport.TLSClientConfig, "a nil TLS config would mean the package defaults") + require.False(t, transport.TLSClientConfig.InsecureSkipVerify) + require.EqualValues(t, tls.VersionTLS13, transport.TLSClientConfig.MinVersion, + "the projected service-account token goes over 1.3 or it does not go") + require.Nil(t, transport.DialTLSContext, + "a custom TLS dialer bypasses TLSClientConfig entirely") + require.Nil(t, transport.DialContext) + require.Nil(t, transport.Proxy, + "a proxy for a credential request is an address the configuration did not name") + require.NotNil(t, client.CheckRedirect) + + // It is not http.DefaultTransport, which is global and mutable: anything + // in the process could have reconfigured that one. + require.NotSame(t, http.DefaultTransport, client.Transport) + + // Two clients do not share a transport, so nothing one caller does to its + // client can reach another's. + require.NotSame(t, client.Transport, mintHTTPClient(nil).Transport) + + // The root pool is the ONLY thing a caller says about it, and it lands + // where it is used. + pool := x509.NewCertPool() + withRoots := mintHTTPClient(pool) + rooted, ok := withRoots.Transport.(*http.Transport) + require.True(t, ok) + require.Same(t, pool, rooted.TLSClientConfig.RootCAs) + + // And the redirect refusal is on it, as a refusal rather than as + // http.ErrUseLastResponse, so net/http never sends the second request. + request, err := http.NewRequest(http.MethodGet, "https://example.invalid/_mint", nil) + require.NoError(t, err) + // AN OUTAGE, not a refusal. The destination receives nothing either way — + // this function is what stops it — so latching bought no confidentiality + // and did permanently stop a process on a 307 during a rollout. + require.ErrorIs(t, client.CheckRedirect(request, nil), ErrMintUnavailable) +} + +func TestNewMintClientValidatesItsConfiguration(t *testing.T) { + valid := MintOptions{ + URL: "https://mint.example/platform/_mint", + Authority: newTestPin(), + Audience: testAudienceName, + ProjectedToken: ProjectedTokenFile("/var/run/secrets/token"), + ProjectionAudience: "projection-audience", + // Required: construction refuses a client with no trust anchor. + RootCAs: x509.NewCertPool(), + } + _, err := NewMintClient(valid) + require.NoError(t, err) + + for name, mutate := range map[string]func(*MintOptions){ + "no url": func(o *MintOptions) { o.URL = "" }, + // Plaintext is the one that mattered: the projection travels on this + // request as a bearer credential, so http was a disclosure the + // configuration could choose. + "plaintext http": func(o *MintOptions) { o.URL = "http://mint.example/_mint" }, + "url with query": func(o *MintOptions) { o.URL = "https://mint.example/_mint?as=root" }, + "url with credentials": func(o *MintOptions) { o.URL = "https://user:pass@mint.example/_mint" }, + "url with fragment": func(o *MintOptions) { o.URL = "https://mint.example/_mint#f" }, + // The audience is read from the pin, so there must BE a pin and it must + // be named. A free-string audience beside an optional pin made the + // drift check guard a value the mint did not use. + "no authority pin": func(o *MintOptions) { o.Authority = nil }, + "no audience name": func(o *MintOptions) { o.Audience = AuthorityValue{} }, + "audience name only": func(o *MintOptions) { o.Audience = AuthorityValue{Name: "platform"} }, + "audience key only": func(o *MintOptions) { o.Audience = AuthorityValue{Key: "audience"} }, + "no projection": func(o *MintOptions) { o.ProjectedToken = nil }, + "no projection audience": func(o *MintOptions) { o.ProjectionAudience = "" }, + "timeout too long": func(o *MintOptions) { o.RequestTimeout = time.Hour }, + "renewal lead at one": func(o *MintOptions) { o.RenewalLead = 1 }, + "negative renewal lead": func(o *MintOptions) { o.RenewalLead = -0.5 }, + // THE TRUST ANCHOR. Unstated was the whole of the previous behaviour + // and it was silently the system pool: every other route to a weak + // transport here was closed and this one defaulted. + "no trust anchor": func(o *MintOptions) { o.RootCAs = nil }, + } { + t.Run(name, func(t *testing.T) { + options := valid + mutate(&options) + _, err := NewMintClient(options) + // ErrInvalid, not ErrMintRefused. Nothing has been asked of a + // host at construction, so "the mint refused you" is the wrong + // thing to tell a caller — and ErrMintRefused is the sentinel this + // package documents as meaning a process must stop serving. + require.ErrorIs(t, err, ErrInvalid) + require.NotErrorIs(t, err, ErrMintRefused, + "a misconfiguration must not read as a host's verdict") + }) + } +} + +// The credential a process holds, and what it puts on a call. The sealed +// values come off the signature rather than the response body, so what a +// caller reads here is what a verifier will compare against. +func TestCredentialSurfaceComesFromTheSignedCapability(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + host.binding = testBinding + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + credential, err := client.Credential(t.Context()) + require.NoError(t, err) + require.Equal(t, testInstallation, credential.Seal().GetInstallationId()) + require.EqualValues(t, 3, credential.Seal().GetInstallationRevision()) + + // Seal hands back a CLONE of the wire message. A caller that mutated it + // must not be able to change what the credential reports it is bound to — + // and the type is the carried seal rather than core's live Seal, which a + // client cannot answer for. + mutated := credential.Seal() + mutated.InstallationId = "installation-the-caller-preferred" + require.Equal(t, testInstallation, credential.Seal().GetInstallationId()) + + // The binding AS SEALED: the three fields the capability carries. The live + // binding has three more — the principal it is granted to, the installation + // it is granted within, and whether it is revoked — and none of them is on + // the wire, which is why this is a different type from OperationBinding. + bound := credential.OperationBinding() + require.NotNil(t, bound) + require.Equal(t, testBinding, bound.GetBindingId()) + require.EqualValues(t, 2, bound.GetRevision()) + require.EqualValues(t, 1, bound.GetIncarnation()) + require.Equal(t, testClock, credential.IssuedAt()) + require.Equal(t, testClock.Add(15*time.Minute), credential.ExpiresAt()) + + // OperationBinding hands back a copy: a caller that mutated it must not be + // able to change what the credential reports it is bound to. + bound.BindingId = "binding-the-caller-preferred" + require.Equal(t, testBinding, credential.OperationBinding().GetBindingId()) + + request := httptest.NewRequest(http.MethodPost, "/records", nil) + require.NoError(t, credential.Attach(request)) + require.Equal(t, credential.Token(), request.Header.Get(HeaderName)) + require.Equal(t, testInstallation, request.Header.Get(InstallationIDHeaderName)) +} + +// A renewal that fails does not take a working credential away. +// +// This is the finding in its sharpest form: entering the renewal lead and +// getting a 503 used to return an error to every caller while the credential in +// hand had minutes of validity left. The client manufactured an outage out of a +// credential that still worked, and then — because each caller retried under +// the lock with no hold-off — made one mint request per caller at exactly the +// moment the host was least able to serve them. +func TestAFailedRenewalServesTheHeldCredentialUntilItExpires(t *testing.T) { + clock := testClock + now := func() time.Time { return clock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + require.EqualValues(t, 1, host.requests.Load()) + + // Inside the renewal lead, with the host refusing. + host.refuseWith = http.StatusServiceUnavailable + clock = testClock.Add(13 * time.Minute) + served, err := client.Credential(t.Context()) + require.NoError(t, err, "a credential with two minutes left is still a credential") + require.Equal(t, held.Token(), served.Token()) + require.EqualValues(t, 2, host.requests.Load(), "it tried once") + + // Ten more callers inside the hold-off make NO further requests. Without + // the hold-off this was one request per caller. + for range 10 { + again, err := client.Credential(t.Context()) + require.NoError(t, err) + require.Equal(t, held.Token(), again.Token()) + } + require.EqualValues(t, 2, host.requests.Load(), + "a failed mint holds off the next attempt; a request per caller is the heartbeat again") + + // Past the hold-off it tries again — still refused, still serving. + clock = testClock.Add(13*time.Minute + 2*minMintBackoff) + _, err = client.Credential(t.Context()) + require.NoError(t, err) + require.EqualValues(t, 3, host.requests.Load()) + + // Once the held credential has actually expired there is nothing to serve, + // and the outage is the caller's problem to know about. + clock = testClock.Add(20 * time.Minute) + _, err = client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintUnavailable) + require.Equal(t, MintCounts{Mints: 1}, client.Counts(), + "a failed renewal is not a renewal") + + // And when the host comes back, the renewal happens. + host.refuseWith = 0 + clock = testClock.Add(20*time.Minute + time.Minute) + fresh, err := client.Credential(t.Context()) + require.NoError(t, err) + require.NotEqual(t, held.Token(), fresh.Token()) + require.Equal(t, MintCounts{Mints: 1, Renewals: 1}, client.Counts()) +} + +// The hold-off grows, so a long outage is not a steady stream of requests. +func TestTheMintHoldOffGrowsWithConsecutiveFailures(t *testing.T) { + require.Equal(t, minMintBackoff, mintBackoff(1)) + require.Equal(t, 2*minMintBackoff, mintBackoff(2)) + require.Equal(t, 4*minMintBackoff, mintBackoff(3)) + require.Equal(t, maxMintBackoff, mintBackoff(30), + "and it is bounded: a hold-off nobody ever leaves is an outage of our own") + require.LessOrEqual(t, mintBackoff(100), maxMintBackoff) +} + +// A caller whose context is cancelled while another goroutine is minting stops +// waiting. The mint used to happen under the client's mutex, which made every +// waiting caller's ctx meaningless: a cancelled request still blocked until the +// request it was not making had finished. +// +// The host holds its answer for a bounded moment rather than indefinitely, and +// releases on cleanup, so a failing assertion cannot wedge the server. +func TestAWaitingCallerHonoursItsContext(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + release := make(chan struct{}) + var releaseOnce sync.Once + letGo := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(letGo) + host.before = func() { + select { + case <-release: + case <-time.After(2 * time.Second): + } + } + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + minted := make(chan error, 1) + go func() { + _, err := client.Credential(context.Background()) + minted <- err + }() + + // Wait until the host has the request in hand, so the call below is + // certainly a waiter rather than the minter. + require.Eventually(t, func() bool { return host.requests.Load() == 1 }, + 2*time.Second, time.Millisecond) + + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + _, err := client.Credential(cancelled) + require.ErrorIs(t, err, ErrMintUnavailable) + require.ErrorIs(t, err, context.Canceled) + require.EqualValues(t, 1, host.requests.Load(), + "a waiting caller does not queue a second request") + + letGo() + require.NoError(t, <-minted) + require.Equal(t, MintCounts{Mints: 1}, client.Counts()) +} + +// A waiting caller that holds a usable credential gets it rather than an error, +// even when its own context is cancelled: the credential in hand is valid, and +// the caller asked for a credential rather than for a mint. +func TestACancelledWaiterStillGetsAUsableHeldCredential(t *testing.T) { + clock := testClock + now := func() time.Time { return clock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + + release := make(chan struct{}) + var releaseOnce sync.Once + letGo := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(letGo) + host.before = func() { + select { + case <-release: + case <-time.After(2 * time.Second): + } + } + clock = testClock.Add(13 * time.Minute) + + renewing := make(chan struct{}) + go func() { + defer close(renewing) + _, _ = client.Credential(context.Background()) + }() + require.Eventually(t, func() bool { return host.requests.Load() == 2 }, + 2*time.Second, time.Millisecond) + + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + served, err := client.Credential(cancelled) + require.NoError(t, err, "a valid credential in hand beats a cancelled wait") + require.Equal(t, held.Token(), served.Token()) + + letGo() + <-renewing +} + +// A receiver whose live state lags refuses every FRESH credential, so each +// refusal is a new generation and the failure hold-off never engages. The bound +// has to be a bound on successful refreshes. +// +// The previous version of this test used a 503 host, so no new generation ever +// appeared and it modelled an outage rather than the lagging receiver it +// claimed — and it asserted that handing the refused credential back was a +// success, which is the C2 defect written down as expected behaviour. +func TestASuccessfulRefreshIsRateLimitedSoALaggingReceiverCannotMintForever(t *testing.T) { + clock := testClock + now := func() time.Time { return clock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + require.EqualValues(t, 1, host.requests.Load()) + + // The host mints happily; it is the RECEIVER that is behind, so every + // credential this caller presents comes back ErrRevoked and every refusal + // is on a credential one generation newer than the last. + current := held + served := 0 + refused := 0 + for range 20 { + next, err := client.Refresh(t.Context(), current) + switch { + case err == nil: + require.NotEqual(t, current.Token(), next.Token(), + "a successful refresh must never hand back the credential that was refused") + current = next + served++ + default: + require.ErrorIs(t, err, ErrMintUnavailable, + "a rate-limited refresh is an outage to the caller, not a refusal") + require.ErrorContains(t, err, "held off") + refused++ + } + } + + require.Positive(t, served, "the first refusal must get a replacement") + require.Positive(t, refused, "and the twentieth must not") + require.LessOrEqual(t, int(host.requests.Load()), 4, + "twenty refusals on twenty successive generations must not be twenty mints") + counts := client.Counts() + require.EqualValues(t, 1, counts.Mints) + require.LessOrEqual(t, int(counts.Refreshes), 3) + + // The rate limit is a limit and not a wall: once it elapses, a refresh + // works again. + clock = testClock.Add(5 * time.Minute) + recovered, err := client.Refresh(t.Context(), current) + require.NoError(t, err) + require.NotEqual(t, current.Token(), recovered.Token()) +} + +// A refusal from the host is never dressed up as a replacement. +// +// This is the sequence the review set out, and it is the likely one: the +// installation is revoked, so the receiver answers ErrRevoked, so the caller +// refreshes — and the host refuses the mint for the same reason. The previous +// revision returned the refused credential with a nil error, for its whole +// remaining lifetime, so a caller could not tell "replaced" from "here is the +// revoked one again". +func TestARefusedRefreshNeverReturnsTheRefusedCredential(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + + // The installation is revoked, so the host refuses the mint too. + host.refuseWith = http.StatusForbidden + replacement, err := client.Refresh(t.Context(), held) + require.ErrorIs(t, err, ErrMintRefused) + require.Empty(t, replacement.Token(), + "a refused refresh returns no credential at all, least of all the refused one") + + // Terminal, so a caller looping cannot be handed the revoked credential on + // the next pass either — and Credential does not hand it out. + require.ErrorIs(t, client.Refused(), ErrMintRefused) + _, err = client.Credential(t.Context()) + require.ErrorIs(t, err, ErrMintRefused) + again, err := client.Refresh(t.Context(), held) + require.ErrorIs(t, err, ErrMintRefused) + require.Empty(t, again.Token()) +} + +// A cancelled caller must not become everybody else's outage. +// +// The shared mint runs on the LEADER's context. If the leader's caller gives +// up, that used to count as a failure: failures++ and a hold-off, so every +// waiter then hit "held off" — and at boot, with no credential held, one +// cancelled first request was an outage for every other caller, growing toward +// a minute with each repetition. A caller giving up is not the host failing. +func TestACancelledLeaderDoesNotHoldOffEveryoneElse(t *testing.T) { + now := func() time.Time { return testClock } + host := newMintHost(t, now) + release := make(chan struct{}) + var releaseOnce sync.Once + letGo := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(letGo) + host.before = func() { + select { + case <-release: + case <-time.After(2 * time.Second): + } + } + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + // The leader cancels while the host is still holding its request. There is + // no credential, so nothing can be served. + leader, cancel := context.WithCancel(context.Background()) + leaderDone := make(chan error, 1) + go func() { + _, err := client.Credential(leader) + leaderDone <- err + }() + require.Eventually(t, func() bool { return host.requests.Load() == 1 }, + 2*time.Second, time.Millisecond) + cancel() + require.ErrorIs(t, <-leaderDone, ErrMintUnavailable) + letGo() + + // A caller arriving now must be able to mint. Under the old behaviour it + // was held off for a second, and for longer each time a caller cancelled. + credential, err := client.Credential(t.Context()) + require.NoError(t, err, "a cancelled leader must not hold off the next caller") + require.NotEmpty(t, credential.Token()) + require.Equal(t, MintCounts{Mints: 1}, client.Counts(), + "and the cancellation is not counted as a mint that happened") +} + +// An outage is still servable, and a refusal is still not: the two branches +// that the previous revision collapsed into one, asserted apart. +func TestOnlyAnOutageServesTheHeldCredential(t *testing.T) { + for name, arrange := range map[string]struct { + status int + sentinel error + serves bool + }{ + // LATCHING IS THE ENUMERATED CASE, not the default. The rule used to + // be inverted — 429 and 5xx retryable, everything else terminal — + // which made every status a proxy, ingress or sidecar might invent + // into a permanent stop. Measured: a 408 refused, did not serve, and + // latched for the life of the process while a valid credential was + // held. So the default is retryable and these two are the exceptions. + "401 is a refusal: the projected token is not acceptable": { + http.StatusUnauthorized, ErrMintRefused, false}, + "403 is a refusal: the host authenticated this process and refused it": { + http.StatusForbidden, ErrMintRefused, false}, + "503 is an outage": {http.StatusServiceUnavailable, ErrMintUnavailable, true}, + "429 is an outage": {http.StatusTooManyRequests, ErrMintUnavailable, true}, + "408 is an outage: a proxy timed out": {http.StatusRequestTimeout, ErrMintUnavailable, true}, + "425 is an outage: too early": {http.StatusTooEarly, ErrMintUnavailable, true}, + "404 is an outage: an ingress mid-roll": {http.StatusNotFound, ErrMintUnavailable, true}, + "400 is an outage: something in the middle rendered it": { + http.StatusBadRequest, ErrMintUnavailable, true}, + "418 is an outage: an unenumerated status is never terminal": { + http.StatusTeapot, ErrMintUnavailable, true}, + } { + t.Run(name, func(t *testing.T) { + clock := testClock + now := func() time.Time { return clock } + host := newMintHost(t, now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + + host.refuseWith = arrange.status + clock = testClock.Add(13 * time.Minute) // inside the renewal lead + served, err := client.Credential(t.Context()) + + if arrange.serves { + require.NoError(t, err, + "a credential with two minutes left survives the host being unreachable") + require.Equal(t, held.Token(), served.Token()) + require.NoError(t, client.Refused()) + return + } + require.ErrorIs(t, err, arrange.sentinel) + require.Empty(t, served.Token(), + "a refusal returns no credential: this process must not serve") + require.ErrorIs(t, client.Refused(), ErrMintRefused, + "and the refusal is terminal") + }) + } +} + +// fault records a host-side problem for the test goroutine to assert. +func (h *mintHost) fault(format string, args ...any) { + h.faultsMu.Lock() + defer h.faultsMu.Unlock() + h.faults = append(h.faults, fmt.Sprintf(format, args...)) +} diff --git a/workcontext/minting_test.go b/workcontext/minting_test.go new file mode 100644 index 0000000..b7933a9 --- /dev/null +++ b/workcontext/minting_test.go @@ -0,0 +1,298 @@ +package workcontext + +import ( + "context" + "crypto/ed25519" + "testing" + "time" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + corework "github.com/codefly-dev/core/workcontext" + "github.com/stretchr/testify/require" +) + +// Every capability in this package's tests is minted by core's Authority, +// because that is the only minter there is. A test that assembled a token +// another way would be testing a format nothing issues, which is how this +// module came to hold a second implementation in the first place. + +// testClock is the instant every capability in these tests is minted at, and +// testSeal is the live sealed state the authority holds. +var ( + testClock = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) + // A seal is INSTALLATION STATE ONLY now. The execution moved to + // SealSource.ApprovedBuild, keyed on the principal, because reading it + // from the owner's installation record made a derived capability's + // execution describe the owner's workload however many hops had been + // added — and a hop's principal does not hold the owner's installation at + // all, so a derivation had nothing to attest against even in principle. + testSeal = Seal{ + InstallationID: testInstallation, + InstallationRevision: 3, + } + // testApprovedBuild is what the issuer approves for testPrincipal, which + // is what a caller's attested Execution is compared against. + testApprovedBuild = struct { + digest string + incarnation uint64 + }{digest: testImageDigest, incarnation: 11} + // testLiveBinding is the binding as the ISSUER holds it: granted to one + // principal, within one installation. A capability carries only the first, + // third and fourth of these — see SealedOperationBinding. + testLiveBinding = OperationBinding{ + ID: testBinding, + PrincipalID: testPrincipal, + InstallationID: testInstallation, + Revision: 2, + Incarnation: 1, + } +) + +const ( + testIssuer = "codefly.test-authority" + testAudience = "codefly.test-audience" + testKeyID = "test-key-1" + testTenant = "tenant-1" + testPrincipal = "principal-1" + // testHumanPrincipal bears no approved build: a person at a terminal runs + // no workload the issuer has admitted. + testHumanPrincipal = "principal-human-1" + testInstallation = "installation-1" + testBinding = "binding-1" + // testImageDigest is the image-manifest digest the test "runs", which the + // seal names as approved and the mint attests against. + testImageDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111" + // testPrincipalEpoch is the owner's live epoch, which the seal source + // answers rather than the seal carrying it. + testPrincipalEpoch = 7 +) + +type authority struct { + core *corework.Authority + seals *corework.MemorySealSource + now func() time.Time +} + +// newAuthority stands up core's minter over an in-memory seal source, holding +// the live values a fresh capability is sealed to. +func newAuthority(t *testing.T) *authority { + t.Helper() + seals := corework.NewMemorySealSource() + require.NoError(t, seals.Put(testPrincipal, testSeal)) + // The approved build is recorded per PRINCIPAL, separately from the + // installation seal. + require.NoError(t, seals.PutApprovedBuild( + testPrincipal, testApprovedBuild.digest, testApprovedBuild.incarnation)) + // The human principal gets a seal, an epoch, and an EXPLICIT declaration + // that it bears no execution. + // + // Declared rather than left absent, because core stopped treating those as + // the same answer and was right to: "bears none" mints a capability + // carrying no execution that every verifier accepts, so answering it for a + // principal the issuer simply has no record of hands a service principal a + // credential nothing can revoke by replacing its build. Nothing recorded is + // the most permissive answer ApprovedBuild can give, so it is asserted + // instead of defaulted — and this harness relied on the default. + require.NoError(t, seals.Put(testHumanPrincipal, testSeal)) + require.NoError(t, seals.PutEpoch(testHumanPrincipal, testPrincipalEpoch)) + require.NoError(t, seals.PutBearsNoExecution(testHumanPrincipal)) + // The owner's epoch is recorded through PutEpoch like any other + // principal's. Seal no longer carries it: it had two sources, and + // revocation could be UNDONE — advancing the epoch left stored seals + // untouched, and storing a seal for an unrelated installation lowered it + // back. One source, owners and actors alike. + require.NoError(t, seals.PutEpoch(testPrincipal, testPrincipalEpoch)) + require.NoError(t, seals.PutBinding(testLiveBinding)) + return newAuthorityOver(t, seals) +} + +func newAuthorityOver(t *testing.T, seals *corework.MemorySealSource) *authority { + t.Helper() + _, private := corework.FixtureKeyPair() + now := func() time.Time { return testClock } + return &authority{ + core: &corework.Authority{ + Issuer: testIssuer, + KeyID: testKeyID, + Key: private, + Revisions: corework.FixedRevision(5), + Seals: seals, + Now: now, + }, + seals: seals, + now: now, + } +} + +// verifier is core's verifier configured for this authority — the module's only +// verification entry point, reached through the alias in core.go. +func (a *authority) verifier(t *testing.T) *Verifier { + t.Helper() + public, _ := corework.FixtureKeyPair() + return &Verifier{ + Issuer: testIssuer, + Audience: testAudience, + Keys: map[string]ed25519.PublicKey{testKeyID: public}, + Revisions: corework.FixedRevision(5), + Replay: corework.NewMemoryReplayStore(), + Grants: noGrants{}, + Seals: a.seals, + Now: a.now, + // These tests mint with core's FIXTURE key, whose private half is + // derivable from core's source — so a verifier refuses it unless it + // says, in as many words, that it is a test. A production verifier + // leaving this unset is the point of the field. + TrustTheConformanceFixtureKey: true, + } +} + +// noGrants is a grant source for tests that mint no grant capability. A +// Verifier requires one even so: a verifier that treated a missing source as +// "no grants to check" would skip the check for a capability that carried one. +type noGrants struct{} + +func (noGrants) Grant(context.Context, string) (*corework.Grant, error) { + return nil, corework.ErrInvalid +} + +type mintInput struct { + tenant string + audience string + scopes []*basev0.WorkScopeV1 + binding string + taskID string + organization string + + // humanSession mints for a principal the issuer holds NO approved build + // for — a person at a terminal. The capability then seals no execution, + // which core 4cb260d3 made representable instead of something every mint + // had to invent a value for. + humanSession bool +} + +// start mints a session capability, sealed to the live values above. +func (a *authority) start(t *testing.T, input mintInput) string { + t.Helper() + return a.startAt(t, a.now(), input, 15*time.Minute) +} + +// startAt mints at a given instant for a given lifetime, which is what a mint +// endpoint under a moving test clock needs. +func (a *authority) startAt(t *testing.T, now time.Time, input mintInput, lifetime time.Duration) string { + t.Helper() + token, err := a.tryStartAt(now, input, lifetime) + require.NoError(t, err) + return token +} + +// tryStartAt is startAt with the error RETURNED, for the mint host, which runs +// on the server's goroutine where require would call FailNow off the test +// goroutine. That is not a hypothetical: a test asked this host for an +// eight-hour credential, core refused it as over the authority's MaxTTL, +// require killed the handler mid-response, and the failure surfaced as `Post +// "https://127.0.0.1:58543": EOF` — a transport error, for a configuration +// mistake, exactly as the review predicted this shape would. +func (a *authority) tryStartAt(now time.Time, input mintInput, lifetime time.Duration) (string, error) { + minter := *a.core + owner, kind := testPrincipal, "service" + execution := corework.Execution{ + ImageDigest: testImageDigest, + BuildIncarnation: testApprovedBuild.incarnation, + } + if input.humanSession { + // No approved build is recorded for this principal and no Execution is + // attested. Core refuses the correspondence in BOTH directions, so + // attesting one here would be refused too. + owner, kind = testHumanPrincipal, "human" + execution = corework.Execution{} + } + minter.Now = func() time.Time { return now } + if input.tenant == "" { + input.tenant = testTenant + } + if input.audience == "" { + input.audience = testAudience + } + if input.taskID == "" { + input.taskID = "task-1" + } + if input.scopes == nil { + input.scopes = []*basev0.WorkScopeV1{scope("document", "read")} + } + token, _, err := minter.Start(context.Background(), corework.StartInput{ + // What the caller attests it is running, matched against the build the + // ISSUER approves FOR THIS PRINCIPAL. Empty for a principal that bears + // no execution, which core answers with ErrNoApprovedBuild. + Execution: execution, + TenantID: input.tenant, + OwnerPrincipalID: owner, + OwnerPrincipalKind: kind, + TaskID: input.taskID, + Audience: input.audience, + AuthorityScopes: input.scopes, + OrganizationID: input.organization, + InstallationID: testInstallation, + OperationBindingID: input.binding, + TTL: lifetime, + }) + return token, err +} + +// child delegates a verified capability one hop, which is how a test obtains a +// capability whose effective actor is not its owner. +// +// The actor principal needs a live epoch of its own: an actor's authority is +// narrowed independently of the owner's, so core seals the actor's epoch per +// hop and refuses a hop that carries none. A test that delegates without +// recording one fails at the mint, which is the right place for it to fail. +func (a *authority) child(t *testing.T, parent string, principal string, scopes []*basev0.WorkScopeV1) string { + t.Helper() + return a.childAs(t, parent, hopInput{principal: principal, scopes: scopes}) +} + +// hopInput names the dimensions of one delegation hop. agent and organization +// are separate fields because they are separate dimensions of the viewer: a +// test that varied only the principal left both of them uncovered, which is +// exactly what the dynamic review's mutation showed. +type hopInput struct { + principal string + kind string + agent string + organization string + scopes []*basev0.WorkScopeV1 +} + +func (a *authority) childAs(t *testing.T, parent string, input hopInput) string { + t.Helper() + require.NotNil(t, a.seals, "delegation needs a seal source this test can record an actor epoch in") + require.NoError(t, a.seals.PutEpoch(input.principal, 1)) + // And the hop's own approved build, because a derivation now attests the + // execution IT is running rather than inheriting the owner's. + require.NoError(t, a.seals.PutApprovedBuild( + input.principal, testApprovedBuild.digest, testApprovedBuild.incarnation)) + if input.kind == "" { + input.kind = "service" + } + verified, err := a.verifier(t).Verify(context.Background(), parent) + require.NoError(t, err) + token, _, err := a.core.Child(context.Background(), verified, corework.ChildInput{ + Execution: corework.Execution{ + ImageDigest: testApprovedBuild.digest, + BuildIncarnation: testApprovedBuild.incarnation, + }, + PrincipalID: input.principal, + PrincipalKind: input.kind, + AgentID: input.agent, + OrganizationID: input.organization, + DelegationID: "delegation-" + input.principal, + GrantedScopes: input.scopes, + Audience: testAudience, + TTL: 10 * time.Minute, + }) + require.NoError(t, err) + return token +} + +func scope(kind string, actions ...string) *basev0.WorkScopeV1 { + return &basev0.WorkScopeV1{ResourceKind: kind, Actions: actions} +} diff --git a/workcontext/one_implementation_test.go b/workcontext/one_implementation_test.go new file mode 100644 index 0000000..798adc9 --- /dev/null +++ b/workcontext/one_implementation_test.go @@ -0,0 +1,2498 @@ +package workcontext + +import ( + "context" + "fmt" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "reflect" + "slices" + "strconv" + "strings" + "testing" + "time" + + corework "github.com/codefly-dev/core/workcontext" + "github.com/codefly-dev/core/workcontext/conformance" + "github.com/stretchr/testify/require" +) + +// The aliases in core.go are core's types and not this module's copies of +// them. These declarations compile only while that is true: a named type +// declared here, however identical its fields, would not be assignable to +// core's pointer type, and the gate below would be guarding a surface that had +// already forked. +var ( + _ *corework.Verifier = (*Verifier)(nil) + _ *corework.Verified = (*Verified)(nil) + _ corework.Seal = Seal{} + _ corework.OperationBinding = OperationBinding{} +) + +// bannedImports, bannedImportPrefixes and bannedImportSubstrings are every way +// into a signature or a second encoding of the message. An import matching any +// of them is refused OUTRIGHT — with no "and the file also mentions WorkContext" +// conjunction, because a conjunction is satisfied by putting the signer in one +// file and the wrapper in another. +// +// The previous version of this gate listed exactly "crypto/ed25519" and +// "crypto/ecdsa", which made it a gate about two strings. All of these passed +// it: golang.org/x/crypto/ed25519; crypto/rsa and crypto/hmac; the bare crypto +// package, whose crypto.Signer signs Ed25519 with no ed25519 import anywhere; +// and protojson, which is a second encoding of the message by itself. A +// faithful clone of core's signer — proto.MarshalOptions{Deterministic: true} +// plus x/crypto — was green. +var ( + bannedImports = []string{ + // The bare package: crypto.Signer and crypto.Hash are all a signer + // needs once a key has been parsed out of a PKCS#8 blob. + "crypto", + "crypto/ed25519", "crypto/ecdsa", "crypto/rsa", "crypto/dsa", + "crypto/hmac", "crypto/ecdh", "crypto/elliptic", "crypto/subtle", + // Second encodings of the message. encoding/json is handled separately + // because one file is allowed it for the mint endpoint's HTTP bodies. + "google.golang.org/protobuf/encoding/protojson", + "google.golang.org/protobuf/encoding/protowire", + "google.golang.org/protobuf/types/known/anypb", + "encoding/gob", "encoding/asn1", "encoding/xml", + // The LOW-LEVEL protobuf runtime. protoiface and protoimpl expose a + // message's own marshal and unmarshal methods, so + // c.ProtoReflect().ProtoMethods().Unmarshal(...) is a complete decode + // that mentions no codec package at all — a route neither gate + // inspected. Nothing here has any use for them. + "google.golang.org/protobuf/runtime/protoiface", + "google.golang.org/protobuf/runtime/protoimpl", + // dynamicpb and protoregistry are NOT here: the receipts replay path + // genuinely resolves a response type at runtime. They are held to + // files and symbols in narrowedImports instead. + // The LEGACY protobuf module, whose proto.Marshal and proto.Unmarshal + // are the same capability under a different path. Uncovered by every + // rule that named the new one. + "github.com/golang/protobuf/proto", + // Hashes that nothing here uses, and that a hand-rolled HMAC needs one + // of. crypto/sha256 is the exception and is held to named files and + // symbols; these have no use at all, so they are simply refused rather + // than left "not a signature, therefore fine". + "crypto/sha512", "crypto/sha1", "crypto/sha3", "crypto/md5", + // HKDF is HMAC with a label on it, and PBKDF2 is HMAC in a loop. Both + // were reproduced taking sha512.New as the hash, which is why the + // sha* family is refused rather than waved through as "not a + // signature". + "crypto/hkdf", "crypto/pbkdf2", + // THE CIPHER FAMILY, which the shell sweep has banned since the GMAC + // probe and this list did not. A GMAC or a CMAC is a MAC assembled out + // of a block cipher and names no MAC, so the two gates disagreed in + // the WORSE direction: the shell caught it in the root module and this + // gate — the one that reads the module the capability actually lives + // in — did not. Nothing in either module encrypts anything. + "crypto/cipher", "crypto/aes", "crypto/des", "crypto/rc4", + } + + // envelopeDecoders are banned EXCEPT where a file is allowlisted below. A + // second parser applying its own seal rule was the last blocker here, and + // it needed exactly this: base64 to open the envelope by hand, and + // proto.Unmarshal to read it. corework.Inspect does both now and hands back + // the claims, so opening an envelope here has no honest use. + envelopeDecoders = []string{"encoding/base64"} + + // envelopeDecoderAllowlist is every file that may reach for one, and what + // it does with it — which is never a capability. + envelopeDecoderAllowlist = map[string]string{ + "workcontext/cache_partition.go": "ENCODES a tenant and installation id into a cache key; it opens no envelope", + } + bannedImportPrefixes = []string{"golang.org/x/crypto/"} + // Any JOSE, JWT or token-library path, whoever publishes it. A capability + // here is core's protobuf envelope; a library that mints bearer tokens has + // no honest use in this module. + // Any path CONTAINING one of these. ed25519 is here as well as in the + // exact list because a third-party path ending /ed25519 — a vendored + // copy, a re-export, a fork — passed both gates while naming the one + // primitive this module must never reach for. + bannedImportSubstrings = []string{ + "jose", "jwt", "jwx", "paseto", "macaroon", "branca", "ed25519", + } +) + +// narrowedImports are the imports some file here genuinely needs, held to the +// FILES that may have them and the SYMBOLS they may use. An import allowed +// whole-file is an import allowed for everything in that package: this is why +// x509.ParsePKCS8PrivateKey — how a signer gets a key without naming ed25519 — +// is a finding rather than a permitted use of an allowed import. +// +// It was called tlsPlumbing when crypto/tls and crypto/x509 were the only two. +// They are not: crypto/sha256 is not a signature but a hand-written HMAC is two +// of them and an xor, mime.WordDecoder decodes base64 with no base64 import, +// and base64 itself is allowed in one file to ENCODE a cache key. +var narrowedImports = map[string]struct { + files []string + symbols []string +}{ + "crypto/tls": { + files: []string{"workcontext/mint.go", "tls.go"}, + // Config and the version floor, plus the two error types the client + // classifies on. Reading an error TYPE is not using a primitive, and + // the alternative was matching on error text — which is how a + // certificate failure quietly became an outage again the next time the + // standard library reworded one. + symbols: []string{ + "Config", "VersionTLS13", + "CertificateVerificationError", "RecordHeaderError", + // tls.go's workload leaf certificates, reloaded on rotation. + // Measured from the tree, so the list is what is used and no more. + "Certificate", "X509KeyPair", "LoadX509KeyPair", + "ClientHelloInfo", "CertificateRequestInfo", + "RequireAndVerifyClientCert", + }, + }, + // mime, for one call: the mint response's Content-Type must be declared + // and must be application/json. A WordDecoder in this package decodes + // base64 with no base64 import, so the symbols matter here as much as + // anywhere. + "mime": { + files: []string{"workcontext/mint.go"}, + symbols: []string{"ParseMediaType"}, + }, + // crypto/sha256 is not a signature and the cache digest needs it, but a + // hand-written HMAC is two hashes and an xor — so the symbols and the + // files are named, which bounds that to the one file that hashes. + "crypto/sha256": { + files: []string{ + "workcontext/cache_partition.go", + // The root module's two digests: a receipt request's canonical + // digest and the store's row digest. Neither is a capability and + // neither is a MAC. + "receipts/digest.go", "receipts/postgres.go", + }, + symbols: []string{"Sum256", "New", "Size"}, + }, + // DYNAMIC MESSAGES AND THE TYPE REGISTRY, which the receipts replay path + // genuinely needs: a recorded response is decoded into whatever type the + // method answers with, resolved at runtime. + // + // They are also how a capability is decoded with its Go type appearing + // nowhere. dynamicpb.NewMessage(desc) builds a message from a descriptor, + // and protoregistry resolves one by NAME — and the name can be assembled + // from pieces ("codefly.base.v0.Work" + "ContextV1"), so no string + // matches either. Both were reproduced against a real WorkContextV1 in + // the root module. + // + // So: the receipts files may resolve DESCRIPTORS and the method's own + // type, and nothing may build a message from a name it chose. The symbols + // are measured from the tree. + "google.golang.org/protobuf/reflect/protoregistry": { + files: []string{ + "receipts/interceptor.go", + "receipts/internal/fixture/fixture.go", + }, + symbols: []string{"Files", "GlobalFiles", "Types", "GlobalTypes"}, + }, + "google.golang.org/protobuf/types/dynamicpb": { + // One file, and only NewMessageType — which needs a descriptor the + // caller already holds. NewMessage, the route the probe used, is + // refused everywhere. + files: []string{"receipts/internal/fixture/fixture.go"}, + symbols: []string{"NewMessageType"}, + }, + // The receipts digest canonicalises a receipt REQUEST — never a capability + // — and protojson is how it reaches a stable field order. The capability + // rule is what keeps it off a Work Context message. + "google.golang.org/protobuf/encoding/protojson": { + files: []string{"receipts/digest.go"}, + symbols: []string{"MarshalOptions", "Marshal"}, + }, + // encoding/base64 ENCODES a cache key here and must never DECODE: base64 + // decoding plus proto.Unmarshal is the whole of a second parser, and the + // exemption used to be per file, so DecodeString was available in the one + // file that had it. + "encoding/base64": { + files: []string{"workcontext/cache_partition.go"}, + symbols: []string{"RawURLEncoding", "URLEncoding", "StdEncoding"}, + }, + "crypto/x509": { + files: []string{"workcontext/mint.go", "tls.go"}, + // CertPool for the caller's roots, and the three verification error + // types for the same reason as above. Notably still refused: + // ParsePKCS8PrivateKey, which is how a signer gets a key without + // importing ed25519. + symbols: []string{ + "CertPool", "NewCertPool", "SystemCertPool", + "UnknownAuthorityError", "HostnameError", "CertificateInvalidError", + }, + }, +} + +// codecAllowlist is the only codec use in this module: a file, the codec +// operation, and THE TYPE it may be applied to. +// +// The type is the part that was missing. The exemptions used to be per FILE, so +// cache_partition.go — allowed proto.Marshal for a scope — could marshal a +// *Claims, and mint.go — allowed encoding/json for two tagged structs — could +// json.Marshal a map[string]any holding a capability's fields, needing no tags +// at all. Both were reproduced as AST probes that produced zero findings. A +// whole-file exemption is an exemption for every type in the file. +var codecAllowlist = []codecUse{ + {file: "workcontext/mint.go", codec: codecJSON, operations: jsonOperations, types: mintEndpointJSONTypes, + reason: "the mint endpoint's two HTTP bodies, which carry the capability as an opaque string"}, + {file: "workcontext/mint.go", codec: codecJSON, operations: jsonValueOperations, + types: append(append([]string{}, mintEndpointJSONTypes...), "encoding/json#RawMessage"), + reason: "the same two bodies, plus a RawMessage the response decoder reads into to " + + "require EOF — it holds nothing and is discarded"}, + {file: "workcontext/cache_partition.go", codec: codecProto, operations: protoOperations, + types: []string{basev0Path + "#WorkScopeV1"}, + reason: "one scope, for the cache digest preimage — never a capability"}, +} + +// The codecs a row can be about. A row used to be keyed on the file and the +// OPERATION NAME, and "Marshal" is an operation name shared by every codec — +// so mint.go's row for encoding/json, whose two allowed types are the HTTP +// bodies, also permitted proto.Marshal and proto.Unmarshal on anything NAMED +// mintRequest or mintResponse. A row has to say which codec it is about. +const ( + codecJSON = "encoding/json" + codecProto = "google.golang.org/protobuf/proto" +) + +type codecUse struct { + file string + codec string + operations []string + types []string + reason string +} + +var ( + // The operations that take the VALUE. NewDecoder and NewEncoder are not + // among them: they take a reader or a writer and encode nothing, and the + // type that matters is the one handed to Decode or Encode afterwards — + // which is checked separately, because its receiver is a local variable + // rather than the package. + jsonOperations = []string{"Marshal", "Unmarshal"} + jsonValueOperations = []string{"Decode", "Encode"} + protoOperations = []string{"Marshal", "MarshalOptions", "Unmarshal", "UnmarshalOptions"} +) + +// permittedCodecTypes is the set of type names THIS CODEC's operation may be +// applied to in this file, or nil when it may not be used here at all. +func permittedCodecTypes(path string, codec string, operation string) []string { + for _, use := range codecAllowlist { + if use.file == path && use.codec == codec && slices.Contains(use.operations, operation) { + return use.types + } + } + return nil +} + +// workContextNameAllowlist is this module's own declarations whose names begin +// with the capability's, with the reason each is not a second surface. The name +// check is case-insensitive and covers every declaration kind, so the module's +// legitimate carrier constants have to be named here rather than slipping +// through on capitalisation. +var workContextNameAllowlist = map[string]string{ + "workContextGRPCMetadataName": "the gRPC metadata key, which is workcontext.HeaderName and not a second name", + "workContext": "the opaque capability string a transport carries", + "workContexts": "the metadata values read for that key", + "WorkContext": "the mint response body's JSON field for the capability string", +} + +// jsonAllowlist is every non-test file that may import encoding/json, keyed by +// its path RELATIVE TO THE MODULE ROOT, and why. +// +// The path is exact on purpose. It used to be matched on the base name, so any +// file called mint.go anywhere in the tree — grpctransport/mint.go, a new +// package's mint.go — inherited the allowance, which is the whole allowlist +// undone by `touch`. +// +// The ban exists because the second implementation this module used to hold +// signed a hand-written JSON payload. The one remaining use is not a token at +// all: it is the body of the host's mint endpoint, an ordinary HTTP API whose +// request is two strings and whose response hands back an opaque capability. +// The capability itself never passes through a JSON codec here — it arrives as +// a string and is read with core's generated type — and the json-tag check +// below is what holds that apart, by refusing a json-tagged struct anywhere in +// the module except that request and that response. +var jsonAllowlist = map[string]string{ + "workcontext/mint.go": "the mint endpoint's HTTP request and response bodies, which carry the capability as an opaque string", + // The root module's own JSON, named here now that the gate reads both + // modules — and MEASURED from the tree, not guessed: these are the only + // two files outside the leaf module that import encoding/json. Neither is + // a capability, and the capability rule refuses a codec on a Work Context + // message in any file whatever this list says. + "configuration_document.go": "the runtime's own configuration document, which is not a capability", + "receipts/digest.go": "canonicalising a receipt REQUEST for its digest: protojson, then a stable re-encode", +} + +// mintEndpointJSONTypes are the only structs in this module that may carry +// json tags, and mintEndpointJSONFile is the only file they may be declared in. +// +// The pair matters. Allowing them by NAME alone let any file declare a type +// called mintRequest or mintResponse and inherit the exemption — so +// `extra/payload.go` could hold a json-tagged `mintRequest` enumerating a +// capability's fields by hand, which is precisely the deleted implementation, +// and the gate would be green. The exemption is a property of one file's two +// declarations, not of two identifiers. +// +// A payload struct pair for a capability is a third entry here, which is a +// failing test rather than a review comment somebody might not leave. +var ( + mintEndpointJSONTypes = []string{"mintRequest", "mintResponse"} + mintEndpointJSONFile = "workcontext/mint.go" +) + +// coreModulePath is the module whose types this one aliases. An alias has to +// resolve into it, or it is a local type wearing core's name. +const coreModulePath = "github.com/codefly-dev/core" + +// basev0Path is the generated package holding the capability's messages, so an +// allowlist row can say which package it means. +const basev0Path = coreModulePath + "/generated/go/codefly/base/v0" + +// leafModulePrefix is the leaf module's directory, as repositoryFiles names it. +// Inside it the capability IS the subject, so every codec use is allowlisted by +// name; outside it the module's subject is everything else, so the rule is the +// decisive one instead — no codec on a capability type, whatever else the file +// legitimately encodes. See codecArgumentIsPermitted. +const leafModulePrefix = "workcontext/" + +// capabilityTypePrefix recognises a Work Context message: a type under core's +// module whose name begins with "Work". A PREFIX rather than a list, so a +// message core adds later is covered the day it exists rather than the day +// somebody remembers it here. +const capabilityTypePrefix = "Work" + +// coreAliasFile is the one file whose job is re-exporting core's surface, so it +// is the one file where an alias to one of core's types is the point rather +// than a second local name for a capability. +const coreAliasFile = "workcontext/core.go" + +// TestNoSecondWorkContextImplementation is the gate. +// +// A wire contract has exactly one implementation, in the repository that owns +// the type. The Work Context is a core proto, so core's workcontext package is +// the only mint and the only verify, and this module is a client of it. That +// rule was broken once: a second implementation here signed a hand-written +// JSON payload while core signed the deterministic protobuf encoding of the +// same message, and because both forms are "." with +// Ed25519, a token from either looked well-formed to the other. +// +// The signature itself was sound — each side signed the bytes it sent, and each +// side verified the bytes it received. What broke was READING the payload before +// verifying it: the key id is a field inside the payload, so a protobuf payload +// read as JSON (or the reverse) yielded no key id or a wrong one, and the +// failure surfaced as "unknown key" or "signature does not verify under key X". +// Both read like a rotated key, so keys are what everyone investigated, for +// days. That is also why ErrNotACoreToken has to be returned BEFORE any key +// lookup: naming the format is the only diagnosis that points at the format. +// +// A comment would not have stopped that and did not. This does, structurally, +// in four parts: nothing here may reach for a signing primitive, nothing may +// JSON-encode a capability, nothing may declare a type or function that reads +// as a second WorkContext surface — including inside a function body, and +// including an alias whose target is not core's — and the verification +// entrypoint this module exports is driven by core's own conformance fixtures. +// +// It guards the tree it runs in, which is all a test can do, and it walks from +// this module's root — so the rest of the repository, where the deleted +// implementation actually lived, is swept by +// scripts/check-one-implementation.sh instead. That sweep is a required check +// and it is green. +func TestNoSecondWorkContextImplementation(t *testing.T) { + files := repositoryFiles(t) + require.NotEmpty(t, files, "the gate scanned no files, so it would pass for an empty module") + + packages := map[string]bool{} + var findings []string + for _, file := range files { + packages[filepath.Dir(file.path)] = true + findings = append(findings, inspectForSecondImplementation(file)...) + } + require.Empty(t, findings, "%s", strings.Join(findings, "\n\n")) + + require.GreaterOrEqual(t, len(packages), 2, + "the gate found %d package(s) in this module; it is meant to scan every one, "+ + "so a package it cannot see is a package the rule does not reach", len(packages)) + + // The allowlist must describe files that exist. An entry for a file that + // has been renamed or deleted is a permission nobody is using and the next + // person reads as precedent. + paths := map[string]bool{} + for _, file := range files { + paths[file.path] = true + } + for allowed := range jsonAllowlist { + require.True(t, paths[allowed], + "the json allowlist names %q, which is not a file in this module", allowed) + } + assertFrozenStructShapes(t, files) + + require.Contains(t, jsonAllowlist, mintEndpointJSONFile, + "the file allowed to carry json TAGS must be the file allowed to IMPORT encoding/json; "+ + "two lists that can drift are two lists") +} + +// inspectForSecondImplementation is the whole check over one parsed file, +// separated out so the bypasses it is supposed to catch can be driven through +// it directly rather than asserted about by eye. +func inspectForSecondImplementation(file sourceFile) []string { + var findings []string + coreImports := map[string]bool{} + plumbing := map[string]string{} // local name -> allowed import path + protoNames := map[string]bool{} // every local name google.golang.org/protobuf is reachable under + jsonNames := map[string]bool{} // and encoding/json + base64Names := map[string]bool{} // and encoding/base64, which may only ENCODE + for _, imported := range importsOf(file.syntax) { + if imported.name == "." { + // A dot import makes every one of a package's identifiers + // unqualified, which defeats every symbol rule below and collided + // in the map this used to build. Nothing here needs one. + findings = append(findings, fmt.Sprintf( + "%s dot-imports %q.\n"+ + "A dot import makes a package's identifiers unqualified, so every rule in this gate\n"+ + "that reasons about a qualifier stops applying — and two dot imports collided in the\n"+ + "map this check used to build, which hid whichever came first.", + file.path, imported.path)) + } + findings = append(findings, inspectImport(file, imported.name, imported.path, plumbing)...) + if imported.path == coreModulePath || strings.HasPrefix(imported.path, coreModulePath+"/") { + coreImports[imported.name] = true + } + if strings.HasPrefix(imported.path, "google.golang.org/protobuf") { + protoNames[imported.name] = true + } + if imported.path == "encoding/json" { + jsonNames[imported.name] = true + } + if imported.path == "encoding/base64" { + base64Names[imported.name] = true + } + } + findings = append(findings, inspectPlumbingSymbols(file, plumbing)...) + findings = append(findings, inspectProtoEncoding(file, protoNames)...) + findings = append(findings, inspectDeclarations(file, coreImports)...) + findings = append(findings, inspectJSONTags(file)...) + findings = append(findings, inspectJSONCalls(file, jsonNames)...) + findings = append(findings, inspectJSONValueCalls(file, len(jsonNames) > 0)...) + findings = append(findings, inspectLocalTypes(file)...) + findings = append(findings, inspectCoreAliases(file, coreImports)...) + findings = append(findings, inspectEnvelopeDecoding(file, base64Names)...) + findings = append(findings, inspectProtoMethodsRoute(file)...) + findings = append(findings, inspectCapabilityFields(file)...) + return findings +} + +// frozenStructFields is the EXACT field set of each struct a codec is allowed +// to touch, by file and type name. +// +// The codec rule checks the TOP-LEVEL type only, which is the original defect +// class coming back one level down: adding +// +// Echo *Claims `json:"echo,omitempty"` +// +// to mintRequest made json.Marshal(mintRequest{Echo: claims}) encode a whole +// capability through the row that exists for two strings — reproduced, and +// green. The same went for a *Claims field on mintResponse. +// +// Freezing the field sets is stronger than chasing field types, and it is +// honest about what these two structs are: an HTTP wire contract with a host. +// A field added to either is a change to that contract and belongs in a +// reviewed diff, not in a struct literal. +var frozenStructFields = map[string]map[string][]string{ + "workcontext/mint.go": { + "mintRequest": {"Audience string", "ProjectionAudience string"}, + "mintResponse": {"WorkContext string", "InstallationID string", "BuildIncarnation string"}, + }, +} + +// inspectCapabilityFields refuses any struct carrying a field whose type is +// one of core's Work messages. +// +// A codec reaching the struct then reaches the capability through it, whatever +// the allowlist said about the outer type — which is the hand-enumerated +// payload this module deleted, one level down. +func inspectCapabilityFields(file sourceFile) []string { + var findings []string + ast.Inspect(file.syntax, func(node ast.Node) bool { + spec, ok := node.(*ast.TypeSpec) + if !ok { + return true + } + structure, ok := spec.Type.(*ast.StructType) + if !ok || structure.Fields == nil { + return true + } + // THE GENERAL RULE: no struct anywhere in this module carries a + // capability as a field. A codec reaching the struct then reaches the + // capability, whatever the allowlist said about the outer type. + for _, field := range structure.Fields.List { + named := typeName(file, field.Type) + if isCapabilityType(named) { + findings = append(findings, fmt.Sprintf( + "%s declares %s with a field of type %s.\n"+ + "A struct carrying one of core's Work messages is a capability wearing another\n"+ + "type's name: a codec allowed to touch the struct reaches the capability through\n"+ + "it, which is the hand-enumerated payload this module deleted, one level down.", + file.path, spec.Name.Name, named)) + } + } + return true + }) + return findings +} + +// assertFrozenStructShapes holds this repository's two codec-allowed structs to +// their exact field sets. +// +// It is a claim about THIS TREE rather than a rule for arbitrary source, so it +// runs over the repository's own files and not inside the per-file inspector +// the bypass probes drive — a synthetic probe naming itself mintRequest is not +// a change to the wire contract. +func assertFrozenStructShapes(t *testing.T, files []sourceFile) { + t.Helper() + require.Empty(t, frozenStructViolations(files), + "%s", strings.Join(frozenStructViolations(files), "\n\n")) +} + +// frozenStructViolations is the check itself, returning findings rather than +// calling require. +// +// Separated for one reason: a probe cannot drive a require-based assertion. +// Passing a fresh testing.T to one makes FailNow call runtime.Goexit on the +// probe's own goroutine, so "did the freeze catch this?" could only be answered +// by eye — which is how the embedded-field hole went three rounds unnoticed. A +// rule that cannot be probed is a rule nobody has tested. +func frozenStructViolations(files []sourceFile) []string { + var findings []string + seen := map[string]map[string][]string{} + for _, file := range files { + frozen, ok := frozenStructFields[file.path] + if !ok { + continue + } + seen[file.path] = map[string][]string{} + ast.Inspect(file.syntax, func(node ast.Node) bool { + spec, ok := node.(*ast.TypeSpec) + if !ok { + return true + } + structure, ok := spec.Type.(*ast.StructType) + if !ok || structure.Fields == nil { + return true + } + if _, isFrozen := frozen[spec.Name.Name]; !isFrozen { + return true + } + var actual []string + for _, field := range structure.Fields.List { + if len(field.Names) == 0 { + // AN EMBEDDED FIELD IS A FIELD, and this loop over + // field.Names skipped every one of them — an embedded + // field has no Names at all. Measured: adding + // + // type smuggled struct { Echo string } + // + // to mint.go and embedding it in mintRequest left the + // frozen set byte-identical, produced no finding anywhere + // in the gate, and put a new field on the wire, because + // encoding/json promotes an embedded struct's exported + // fields into the enclosing object. The freeze exists for + // exactly that, one level sideways. + actual = append(actual, "embedded "+typeName(file, field.Type)) + continue + } + for _, name := range field.Names { + actual = append(actual, name.Name+" "+typeName(file, field.Type)) + } + } + seen[file.path][spec.Name.Name] = actual + return true + }) + } + for path, frozen := range frozenStructFields { + for name, expected := range frozen { + if slices.Equal(expected, seen[path][name]) { + continue + } + findings = append(findings, fmt.Sprintf( + "%s: %s is a codec-allowed struct and its field set is FROZEN.\n"+ + "The codec rule names the TYPE, so a new field is a new thing encoded through a\n"+ + "row that exists for the fields listed — adding `Echo *Claims` to mintRequest\n"+ + "encoded a whole capability through a row for two strings, and was green. These\n"+ + "two structs are an HTTP wire contract with the mint host: a change to them is a\n"+ + "change to that contract, and belongs in a reviewed diff with\n"+ + "frozenStructFields updated in it.\n"+ + " frozen: %v\n"+ + " found: %v", + path, name, expected, seen[path][name])) + } + } + return findings +} + +// inspectProtoMethodsRoute refuses a message's own marshal and unmarshal +// methods, reached through protobuf reflection. +// +// c.ProtoReflect().ProtoMethods().Unmarshal(...) is a complete decode of a +// capability that names no codec package, no banned import and no allowlisted +// type — so the import ban could not see it and the codec rule had nothing to +// key on. Banning the protoiface and protoimpl imports closes the typed route; +// this closes the reflective one, which needs no import at all because +// ProtoReflect is a method on every generated message. +func inspectProtoMethodsRoute(file sourceFile) []string { + var findings []string + ast.Inspect(file.syntax, func(node ast.Node) bool { + selector, ok := node.(*ast.SelectorExpr) + if !ok || selector.Sel.Name != "ProtoMethods" { + return true + } + findings = append(findings, fmt.Sprintf( + "%s reaches ProtoMethods.\n"+ + "A message's own marshal and unmarshal methods are a complete codec that names no\n"+ + "codec package: ProtoReflect().ProtoMethods().Unmarshal(...) decodes a capability\n"+ + "with no banned import and no type for an allowlist to key on. Reading a capability\n"+ + "off the wire is corework.Inspect's job.", + file.path)) + return true + }) + return findings +} + +// envelopeEncodeOnly are the methods an allowlisted base64 import may call on +// an encoding. Everything else — DecodeString, Decode, NewDecoder, AppendDecode +// — is the first half of a second parser. +var envelopeEncodeOnly = []string{"EncodeToString", "Encode", "EncodedLen", "AppendEncode"} + +// inspectEnvelopeDecoding refuses DECODING in the one file allowed to encode. +// +// The symbol allowlist cannot see this on its own: base64.StdEncoding is the +// permitted symbol and DecodeString is a method on the *value* it names, so +// `base64.StdEncoding.DecodeString(payload)` passed a package-level symbol +// check completely. The exemption was per file, so the file allowed to encode a +// cache key could decode a capability's envelope — and with proto.Unmarshal +// that is a whole parser. +func inspectEnvelopeDecoding(file sourceFile, base64Names map[string]bool) []string { + if len(base64Names) == 0 { + return nil + } + var findings []string + ast.Inspect(file.syntax, func(node ast.Node) bool { + method, ok := node.(*ast.SelectorExpr) + if !ok { + return true + } + // The receiver must itself be base64., which is what makes + // this a base64 encoding rather than any other value's method. + receiver, ok := method.X.(*ast.SelectorExpr) + if !ok { + return true + } + qualifier, ok := receiver.X.(*ast.Ident) + if !ok || !base64Names[qualifier.Name] { + return true + } + if slices.Contains(envelopeEncodeOnly, method.Sel.Name) { + return true + } + findings = append(findings, fmt.Sprintf( + "%s calls %s.%s.%s.\n"+ + "This file may ENCODE with base64 — it builds a cache key — and may never DECODE.\n"+ + "base64 decoding plus proto.Unmarshal is the entire second parser this module\n"+ + "deleted; corework.Inspect opens the envelope and hands back the claims. Only %v\n"+ + "may be called here.", + file.path, qualifier.Name, receiver.Sel.Name, method.Sel.Name, envelopeEncodeOnly)) + return true + }) + return findings +} + +// inspectLocalTypes refuses a type declared INSIDE a function. +// +// This is the alias bypass's other half. A function-local declaration is +// invisible to every rule that reads a file's declarations, and `type X = +// basev0.WorkContextV1` inside a function body is enough to give a codec row +// whatever type name it asks for. It is also something this module has no use +// for: a type worth declaring here is worth declaring where a reader finds it. +func inspectLocalTypes(file sourceFile) []string { + var findings []string + for _, declaration := range file.syntax.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Body == nil { + continue + } + ast.Inspect(function.Body, func(node ast.Node) bool { + spec, ok := node.(*ast.TypeSpec) + if !ok { + return true + } + findings = append(findings, fmt.Sprintf( + "%s declares the type %q inside a function.\n"+ + "A function-local type is invisible to every rule here that reads a file's\n"+ + "declarations, and a local alias is all it takes to hand a codec row a type name it\n"+ + "permits — `type WorkScopeV1 = basev0.WorkContextV1` makes a capability marshal as\n"+ + "an allowed scope. Declare it at file level, where it is read.", + file.path, spec.Name.Name)) + return true + }) + } + return findings +} + +// inspectCoreAliases refuses an alias to one of core's types outside the one +// file whose job is aliasing core. +// +// core.go exists so this module's surface IS core's surface: an alias there is +// the re-export the one-implementation rule asks for. An alias anywhere else +// renames core's type locally, which is how a capability acquires a second +// name — and the name rule cannot catch it, because a reader chooses the name. +func inspectCoreAliases(file sourceFile, coreImports map[string]bool) []string { + if file.path == coreAliasFile || len(coreImports) == 0 { + return nil + } + var findings []string + ast.Inspect(file.syntax, func(node ast.Node) bool { + spec, ok := node.(*ast.TypeSpec) + if !ok { + return true + } + // BOTH SPELLINGS. This required spec.Assign.IsValid(), so only + // `type A = corework.B` was a finding and `type A corework.B` was + // silent. The defined form cannot reach a codec — it inherits no + // methods, so it is not a proto.Message — but it is still a second + // declaration of core's shape under a local name, and a rule true for + // one spelling and silent for the other is a rule a reader has to test + // to know. + kind := "aliases" + if !spec.Assign.IsValid() { + kind = "declares a local type over" + } + // THROUGH A POINTER OR A SLICE. The check read spec.Type as a bare + // selector, so `type carrier = basev0.WorkContextV1` was a finding and + // `type carrier = *basev0.WorkContextV1` was not — and the pointer + // form is the one a codec takes. + selector, ok := unwrapTypeExpr(spec.Type).(*ast.SelectorExpr) + if !ok { + return true + } + qualifier, ok := selector.X.(*ast.Ident) + if !ok || !coreImports[qualifier.Name] { + return true + } + findings = append(findings, fmt.Sprintf( + "%s %s core's %s.%s as %q.\n"+ + "Aliases to core's types belong in %s, which is this module's re-export of core's\n"+ + "surface. A second local name elsewhere is precisely what a codec allowlist keyed by\n"+ + "type name cannot see through.", + file.path, kind, qualifier.Name, selector.Sel.Name, spec.Name.Name, coreAliasFile)) + return true + }) + return findings +} + +// unwrapTypeExpr strips the pointers, slices and parentheses a type expression +// wears, so a rule written for a named type reaches it however it is dressed. +func unwrapTypeExpr(expression ast.Expr) ast.Expr { + for hops := 0; hops < maxAliasHops; hops++ { + switch typed := expression.(type) { + case *ast.StarExpr: + expression = typed.X + case *ast.ArrayType: + expression = typed.Elt + case *ast.ParenExpr: + expression = typed.X + default: + return expression + } + } + return expression +} + +// inspectImport refuses an import by what it can DO, not by whether its path is +// one of two strings. +func inspectImport(file sourceFile, name string, path string, plumbing map[string]string) []string { + if allowed, ok := narrowedImports[path]; ok { + if slices.Contains(allowed.files, file.path) { + plumbing[name] = path + return nil + } + return []string{fmt.Sprintf( + "%s imports %q, which only %v may, and only for named symbols.", + file.path, path, allowed.files)} + } + if slices.Contains(envelopeDecoders, path) { + if reason, allowed := envelopeDecoderAllowlist[file.path]; allowed { + _ = reason + return nil + } + return []string{fmt.Sprintf( + "%s imports %q.\n"+ + "Opening a capability's envelope by hand is the beginning of a second parser, which\n"+ + "is what this module last had to delete — its own seal rule disagreed with core's\n"+ + "fixtures about three refusals. corework.Inspect opens the envelope and returns the\n"+ + "claims. Only %v may reach for this, and only because %v.", + file.path, path, allowedEnvelopeDecoderFiles(), envelopeDecoderReasons())} + } + banned := slices.Contains(bannedImports, path) + for _, prefix := range bannedImportPrefixes { + banned = banned || strings.HasPrefix(path, prefix) + } + for _, fragment := range bannedImportSubstrings { + banned = banned || strings.Contains(strings.ToLower(path), fragment) + } + if banned { + return []string{fmt.Sprintf( + "%s imports %q.\n"+ + "This module signs nothing, verifies nothing and encodes no capability: core's\n"+ + "workcontext is the only implementation. The ban is on what a package can do rather\n"+ + "than on two package names, because crypto.Signer, x/crypto and protojson each build a\n"+ + "complete second implementation without naming ed25519 anywhere.", + file.path, path)} + } + if path == "encoding/json" { + if _, allowed := jsonAllowlist[file.path]; !allowed { + return []string{fmt.Sprintf( + "%s imports encoding/json.\n"+ + "A capability is a protobuf message, signed by core over its deterministic encoding.\n"+ + "The only JSON in this module is %v — the mint endpoint's HTTP bodies, named by exact\n"+ + "path. If a capability is being JSON-encoded here, that is the second implementation\n"+ + "coming back.", + file.path, allowedJSONFiles())} + } + } + return nil +} + +// inspectPlumbingSymbols holds crypto/tls and crypto/x509 to the symbols the +// transport actually needs. An allowed import is not an allowed package: the +// shortest route to a signer that imports no signing primitive is +// x509.ParsePKCS8PrivateKey followed by a crypto.Signer assertion, and the +// first half of that lives in a package this module legitimately imports. +func inspectPlumbingSymbols(file sourceFile, plumbing map[string]string) []string { + if len(plumbing) == 0 { + return nil + } + var findings []string + ast.Inspect(file.syntax, func(node ast.Node) bool { + selector, ok := node.(*ast.SelectorExpr) + if !ok { + return true + } + qualifier, ok := selector.X.(*ast.Ident) + if !ok { + return true + } + path, isPlumbing := plumbing[qualifier.Name] + if !isPlumbing { + return true + } + if slices.Contains(narrowedImports[path].symbols, selector.Sel.Name) { + return true + } + findings = append(findings, fmt.Sprintf( + "%s uses %s.%s. Only %v may be used from %q here.\n"+ + "The import is allowed for the mint client's transport, not as a way into the package:\n"+ + "x509.ParsePKCS8PrivateKey plus a crypto.Signer assertion is a complete signer that\n"+ + "imports no signing primitive at all.", + file.path, qualifier.Name, selector.Sel.Name, narrowedImports[path].symbols, path)) + return true + }) + return findings +} + +// inspectProtoEncoding refuses a protobuf MARSHAL outside the one file that +// encodes something which is not a capability. Unmarshal and Clone are not +// restricted: reading a capability the host issued is this module's job, and +// writing one is core's. +func inspectProtoEncoding(file sourceFile, protoNames map[string]bool) []string { + var findings []string + ast.Inspect(file.syntax, func(node ast.Node) bool { + selector, ok := node.(*ast.SelectorExpr) + if !ok { + return true + } + qualifier, ok := selector.X.(*ast.Ident) + // The qualifier is resolved from the IMPORT MAP rather than compared + // against the literal "proto". An aliased import — pb.Marshal — passed + // the identifier check, which made this a rule about a name in a gate + // whose whole point is not to be one. + if !ok || !protoNames[qualifier.Name] { + return true + } + switch selector.Sel.Name { + case "Marshal", "MarshalOptions": + if codecArgumentIsPermitted(file, node, codecProto, "Marshal") { + return true + } + case "Unmarshal", "UnmarshalOptions": + // Unrestricted until now, and it is half of the defect that was the + // last blocker: a second unverified parser needs base64 to open the + // envelope and Unmarshal to read it. corework.Inspect does both, and + // hands back the claims, so nothing here needs either. + if codecArgumentIsPermitted(file, node, codecProto, "Unmarshal") { + return true + } + findings = append(findings, fmt.Sprintf( + "%s calls %s.%s.\n"+ + "Reading a capability off the wire is corework.Inspect's job, and it returns the\n"+ + "claims it decoded. A decode here is the beginning of a second parser — which is\n"+ + "what this module last had to delete, and its own seal rule disagreed with core's\n"+ + "fixtures about three refusals.", + file.path, qualifier.Name, selector.Sel.Name)) + return true + default: + return true + } + findings = append(findings, fmt.Sprintf( + "%s calls %s.%s on a type it is not allowed to.\n"+ + "Encoding the message is half of a signer: core signs the deterministic protobuf\n"+ + "encoding, so MarshalOptions{Deterministic: true} over a capability is the deleted\n"+ + "implementation with a different import list. The exemption names a FILE AND A TYPE\n"+ + "(%v), because a whole-file exemption is an exemption for every type in the file —\n"+ + "which is how a *Claims could be marshalled through the one meant for a scope.", + file.path, qualifier.Name, selector.Sel.Name, codecAllowlist)) + return true + }) + return findings +} + +// inspectDeclarations refuses a declaration that reads as a second WorkContext +// surface, ANYWHERE in the file and WHATEVER ITS CAPITALISATION. +// +// Three things the previous version missed. It walked file.Decls, so a +// declaration inside a function body passed untouched. It accepted any alias at +// all, so `type WorkContextVerifier = mylocal.Verifier` satisfied "it is an +// alias" while aliasing nothing of core's. And it matched a case-sensitive +// "WorkContext" prefix on free functions and types only — so the deleted +// implementation's own type name, `workContextPayload`, passed it, as did a +// method, a `var WorkContextSign = func…` and a const. +// +// This is a heuristic and is NOT the load-bearing half of the gate: a name +// proves nothing, which is why the import, symbol and encoding rules above +// refuse the CAPABILITY to sign or encode whatever anything is called. It is +// kept because it catches the lazy case cheaply, and tightened so that this +// module's own carrier names must be allowlisted with a reason rather than pass +// on capitalisation. +func inspectDeclarations(file sourceFile, coreImports map[string]bool) []string { + var findings []string + named := func(name string) bool { + if _, allowed := workContextNameAllowlist[name]; allowed { + return false + } + return strings.HasPrefix(strings.ToLower(name), "workcontext") + } + report := func(kind string, name string) { + if !named(name) { + return + } + findings = append(findings, fmt.Sprintf( + "%s declares %s %s.\n"+ + "A WorkContext-named declaration here is how the second implementation looked, and its\n"+ + "own payload type was spelled workContextPayload. The capability's operations and types\n"+ + "are core's; this module names what it DOES to one (Attach, FromHeaders,\n"+ + "SealedInstallation) and re-exports core's for the rest. If this is a legitimate\n"+ + "carrier name, add it to workContextNameAllowlist with the reason.", + file.path, kind, name)) + } + ast.Inspect(file.syntax, func(node ast.Node) bool { + switch declared := node.(type) { + case *ast.FuncDecl: + kind := "func" + if declared.Recv != nil { + kind = "method" + } + report(kind, declared.Name.Name) + case *ast.ValueSpec: + for _, name := range declared.Names { + report("var/const", name.Name) + } + case *ast.TypeSpec: + if !named(declared.Name.Name) { + return true + } + if declared.Assign == 0 { + findings = append(findings, fmt.Sprintf( + "%s declares type %s, which is not an alias of core's.\n"+ + "The capability's types are core's. Alias them (type X = corework.X) so there is\n"+ + "one definition, or name what this module adds without naming the capability.", + file.path, declared.Name.Name)) + return true + } + if !aliasesCore(declared.Type, coreImports) { + findings = append(findings, fmt.Sprintf( + "%s declares type %s as an alias of something that is not core's.\n"+ + "Being an alias is not the guarantee; aliasing CORE'S type is. An alias of a local\n"+ + "type is a second definition with core's name on it, which is worse than a plainly\n"+ + "named one because it reads as a re-export.", + file.path, declared.Name.Name)) + } + } + return true + }) + return findings +} + +// aliasesCore reports whether an alias's right-hand side names a type in core's +// module, through one of this file's imports of it. +func aliasesCore(target ast.Expr, coreImports map[string]bool) bool { + for { + switch typed := target.(type) { + case *ast.StarExpr: + target = typed.X + case *ast.SelectorExpr: + qualifier, ok := typed.X.(*ast.Ident) + return ok && coreImports[qualifier.Name] + default: + return false + } + } +} + +// inspectJSONTags refuses a json-tagged struct outside the mint endpoint's two +// bodies, wherever the struct appears. +// +// The deleted implementation's signed payload was exactly this: a struct whose +// tags enumerated a capability's fields by hand, which is why a field present +// in the proto and absent from the struct was dropped at mint and missing at +// verify. The earlier version only looked at top-level named types, so an +// anonymous struct literal or a type declared inside a function carried json +// tags freely. This walks every struct type in the file and allows exactly the +// two the mint endpoint declares — identified by node, so a THIRD type cannot +// borrow the allowance by being declared beside them. +func inspectJSONTags(file sourceFile) []string { + allowed := map[ast.Node]bool{} + // Only the mint endpoint's own file may hold them. Elsewhere the names earn + // nothing, so the walk below finds the tags and refuses them. + if file.path == mintEndpointJSONFile { + allowed = allowedMintBodies(file) + } + var findings []string + ast.Inspect(file.syntax, func(node ast.Node) bool { + structure, ok := node.(*ast.StructType) + if !ok || structure.Fields == nil || allowed[structure] { + return true + } + for _, field := range structure.Fields.List { + if field.Tag == nil { + continue + } + tag, err := strconv.Unquote(field.Tag.Value) + if err != nil || reflect.StructTag(tag).Get("json") == "" { + continue + } + findings = append(findings, fmt.Sprintf( + "%s declares a struct with json tags. Only %v in %s may.\n"+ + "A JSON-tagged struct describing a capability's fields is the second\n"+ + "implementation: core signs the deterministic protobuf encoding, and a field\n"+ + "enumerated by hand is a field dropped at mint and absent at verify. Naming a type\n"+ + "mintRequest somewhere else does not inherit the endpoint's exemption — the\n"+ + "exemption is that file's, not the identifier's.", + file.path, mintEndpointJSONTypes, mintEndpointJSONFile)) + return true + } + return true + }) + return findings +} + +// allowedMintBodies returns the struct nodes of the mint endpoint's two bodies, +// identified by node so a THIRD json-tagged type declared beside them cannot +// borrow the allowance. +func allowedMintBodies(file sourceFile) map[ast.Node]bool { + allowed := map[ast.Node]bool{} + for _, declaration := range file.syntax.Decls { + general, ok := declaration.(*ast.GenDecl) + if !ok { + continue + } + for _, spec := range general.Specs { + typed, ok := spec.(*ast.TypeSpec) + if !ok || !slices.Contains(mintEndpointJSONTypes, typed.Name.Name) { + continue + } + if structure, ok := typed.Type.(*ast.StructType); ok { + allowed[structure] = true + } + } + } + return allowed +} + +// inspectJSONCalls refuses a json codec call applied to anything but the mint +// endpoint's two bodies. +// +// The TAG rule above is not enough on its own, and that was a reproduced gap: +// json.Marshal of a map[string]any holding a capability's fields needs no tags +// at all, so it produced zero findings in the one file allowed to import +// encoding/json. Tags describe a struct; this describes what is encoded. +func inspectJSONCalls(file sourceFile, jsonNames map[string]bool) []string { + if len(jsonNames) == 0 { + return nil + } + var findings []string + ast.Inspect(file.syntax, func(node ast.Node) bool { + selector, ok := node.(*ast.SelectorExpr) + if !ok { + return true + } + qualifier, ok := selector.X.(*ast.Ident) + if !ok || !jsonNames[qualifier.Name] { + return true + } + if !slices.Contains(jsonOperations, selector.Sel.Name) { + return true + } + if codecArgumentIsPermitted(file, node, codecJSON, selector.Sel.Name) { + return true + } + findings = append(findings, fmt.Sprintf( + "%s calls %s.%s on a type it is not allowed to.\n"+ + "Only %v may be JSON-encoded here, by name and by type. A map[string]any holding a\n"+ + "capability's fields needs no json tags, so the tag rule alone let it through — and a\n"+ + "hand-enumerated capability is exactly the implementation this module deleted.", + file.path, qualifier.Name, selector.Sel.Name, mintEndpointJSONTypes)) + return true + }) + return findings +} + +// inspectJSONValueCalls checks what a json decoder or encoder is pointed AT. +// +// json.NewDecoder takes a reader, so the type that matters is the one handed to +// Decode — and its receiver is a local variable, not the package, so the +// package-qualified rule above cannot see it. +func inspectJSONValueCalls(file sourceFile, importsJSON bool) []string { + if !importsJSON { + return nil + } + var findings []string + ast.Inspect(file.syntax, func(node ast.Node) bool { + selector, ok := node.(*ast.SelectorExpr) + if !ok || !slices.Contains(jsonValueOperations, selector.Sel.Name) { + return true + } + // A method call on something, in a file that speaks JSON. The + // allowlist row for Decode/Encode is what it may be pointed at, which + // is a DIFFERENT row from Marshal/Unmarshal's — so the operation's own + // name is what selects it. + if codecArgumentIsPermitted(file, node, codecJSON, selector.Sel.Name) { + return true + } + findings = append(findings, fmt.Sprintf( + "%s calls %s on a type it is not allowed to.\n"+ + "Only %v may be JSON-decoded or encoded here, by type. A decoder takes a reader, so\n"+ + "the type that matters is what it is pointed at.", + file.path, selector.Sel.Name, mintEndpointJSONTypes)) + return true + }) + return findings +} + +// TestTheGateCatchesItsOwnBypasses drives the checks over source written to +// slip past them. +// +// Every case here passed the earlier version of the gate. They are not +// hypothetical readings of it: the base-name allowlist, the top-level-only +// declaration walk, the top-level-only tag walk and the unchecked alias target +// were each a way to reintroduce the deleted implementation with the gate +// green, which is worse than no gate because it is evidence nobody re-reads. +func TestTheGateCatchesItsOwnBypasses(t *testing.T) { + for name, bypass := range map[string]struct { + path string + source string + says string + }{ + // ---- Round four, layer 4: EXECUTED probes, plus every ban entry and + // rule the review's mutation pass found had no probe at all. + // + // THE ROOT MODULE. A reviewer wrote the first of these, compiled it, + // ran golangci-lint over it (0 issues) and ran the shell sweep (ok, 58 + // Go files): a second parser whose base64url decoder is HAND-WRITTEN, + // so it imports nothing a sweep can ban. Only reading the code sees + // it, which is why this gate now walks both modules. + "a root-module parser with a hand-written decoder": { + path: "work_context_reader.go", + source: `package codefly +import ( + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "google.golang.org/protobuf/proto" +) +func decodeBase64URL(text string) []byte { return []byte(text) } +func ReadWorkContextTenant(token string) (string, error) { + claims := &basev0.WorkContextV1{} + if err := proto.Unmarshal(decodeBase64URL(token), claims); err != nil { + return "", err + } + return claims.GetTenantId(), nil +}`, + says: "calls proto.Unmarshal", + }, + "a root-module encoder of the capability": { + path: "work_context_writer.go", + source: `package codefly +import ( + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "google.golang.org/protobuf/proto" +) +func encodeClaims(claims *basev0.WorkContextV1) ([]byte, error) { + return proto.MarshalOptions{Deterministic: true}.Marshal(claims) +}`, + says: "on a type it is not allowed to", + }, + // THE IMPORT NAME IS THE AUTHOR'S CHOICE, so a row compared against a + // LOCAL qualifier is satisfied by aliasing any package to the expected + // name. Rows name package PATHS. + "a foreign package aliased to core's own local name": { + path: "workcontext/cache_partition.go", + source: `package workcontext +import ( + basev0 "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/proto" +) +func encode() ([]byte, error) { + return proto.Marshal(&basev0.WorkScopeV1{}) +}`, + says: "on a type it is not allowed to", + }, + // "An argument it cannot resolve is NOT permitted" had no probe, so in + // the leaf module the claim was untested. + "a codec on a value the gate cannot resolve": { + path: "workcontext/mint.go", + source: `package workcontext +import "encoding/json" +func write(anything any) ([]byte, error) { + return json.Marshal(anything) +}`, + says: "on a type it is not allowed to", + }, + // The json-TAG exemption is a property of ONE file's TWO declarations, + // never of two identifiers anywhere. + "a json-tagged mintRequest in another file": { + path: "workcontext/carrier.go", + source: `package workcontext +type mintRequest struct { + Audience string "json:\"audience\"" +}`, + says: "struct with json tags", + }, + "a nested package declaring the allowlisted json types": { + path: "workcontext/grpctransport/bodies.go", + source: `package grpctransport +type mintResponse struct { + WorkContext string "json:\"work_context\"" +}`, + says: "struct with json tags", + }, + // The thirteen ban entries that had no probe. Each is a complete + // second implementation or a second encoding of the message, and each + // was previously asserted only by appearing in a list. + "crypto/ecdsa": { + path: "workcontext/carrier.go", + source: `package workcontext +import "crypto/ecdsa" +var _ = ecdsa.SignASN1`, + says: "imports \"crypto/ecdsa\"", + }, + "crypto/dsa": { + path: "workcontext/carrier.go", + source: `package workcontext +import "crypto/dsa" +var _ = dsa.Sign`, + says: "imports \"crypto/dsa\"", + }, + "crypto/ecdh": { + path: "workcontext/carrier.go", + source: `package workcontext +import "crypto/ecdh" +var _ = ecdh.P256`, + says: "imports \"crypto/ecdh\"", + }, + "crypto/elliptic": { + path: "workcontext/carrier.go", + source: `package workcontext +import "crypto/elliptic" +var _ = elliptic.P256`, + says: "imports \"crypto/elliptic\"", + }, + "crypto/subtle": { + path: "workcontext/carrier.go", + source: `package workcontext +import "crypto/subtle" +var _ = subtle.ConstantTimeCompare`, + says: "imports \"crypto/subtle\"", + }, + "encoding/gob": { + path: "workcontext/carrier.go", + source: `package workcontext +import "encoding/gob" +var _ = gob.NewEncoder`, + says: "imports \"encoding/gob\"", + }, + "encoding/asn1": { + path: "workcontext/carrier.go", + source: `package workcontext +import "encoding/asn1" +var _ = asn1.Marshal`, + says: "imports \"encoding/asn1\"", + }, + "encoding/xml": { + path: "workcontext/carrier.go", + source: `package workcontext +import "encoding/xml" +var _ = xml.Marshal`, + says: "imports \"encoding/xml\"", + }, + "a jwt library": { + path: "workcontext/carrier.go", + source: `package workcontext +import "github.com/golang-jwt/jwt/v5" +var _ = jwt.New`, + says: "jwt", + }, + "a jwx library": { + path: "workcontext/carrier.go", + source: `package workcontext +import "github.com/lestrrat-go/jwx/v2/jws" +var _ = jws.Sign`, + says: "jwx", + }, + "a paseto library": { + path: "workcontext/carrier.go", + source: `package workcontext +import "aidanwoods.dev/go-paseto" +var _ = paseto.NewToken`, + says: "paseto", + }, + "a macaroon library": { + path: "workcontext/carrier.go", + source: `package workcontext +import "gopkg.in/macaroon.v2" +var _ = macaroon.New`, + says: "macaroon", + }, + "a branca library": { + path: "workcontext/carrier.go", + source: `package workcontext +import "github.com/essentialkaos/branca" +var _ = branca.NewBranca`, + says: "branca", + }, + // ---- Round four: the shapes the review found this gate blind to. + // + // Each is a complete second parser or encoder that COMPILED and + // produced zero findings at the previous revision. The common thread + // is that the codec rule was about names: a type name with no package, + // an identifier resolved to whichever same-named declaration came + // last, and a row keyed on an operation name shared by every codec. + "a local alias giving a capability an allowlisted type name": { + path: "workcontext/cache_partition.go", + source: `package workcontext +import ( + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "google.golang.org/protobuf/proto" +) +func encode(wc *basev0.WorkContextV1) ([]byte, error) { + type WorkScopeV1 = basev0.WorkContextV1 + return proto.MarshalOptions{Deterministic: true}.Marshal((*WorkScopeV1)(wc)) +}`, + says: "inside a function", + }, + "a file-level alias giving a capability an allowlisted type name": { + path: "workcontext/cache_partition.go", + source: `package workcontext +import ( + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "google.golang.org/protobuf/proto" +) +type WorkScopeV1 = basev0.WorkContextV1 +func encode(wc *WorkScopeV1) ([]byte, error) { + return proto.Marshal(wc) +}`, + says: "aliases core's", + }, + // The qualifier is the whole point: the allowlist row exists for ONE + // core message, and the resolver used to answer "WorkScopeV1" for + // every type whose final name was that, from any package. + "a marshal of a foreign package's WorkScopeV1": { + path: "workcontext/cache_partition.go", + source: `package workcontext +import ( + "google.golang.org/protobuf/proto" + elsewhere "example.test/other" +) +func encode(scope *elsewhere.WorkScopeV1) ([]byte, error) { + return proto.Marshal(scope) +}`, + says: "on a type it is not allowed to", + }, + "a marshal of another package's identically named type": { + path: "workcontext/cache_partition.go", + source: `package workcontext +import ( + "google.golang.org/protobuf/proto" + elsewhere "google.golang.org/protobuf/types/known/structpb" +) +type WorkScopeV1 = elsewhere.Struct +func encode() ([]byte, error) { + return proto.Marshal(&WorkScopeV1{}) +}`, + says: "on a type it is not allowed to", + }, + "base64 DECODING in the file allowed to encode": { + path: "workcontext/cache_partition.go", + source: `package workcontext +import "encoding/base64" +func open(token string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(token) +}`, + says: "may never DECODE", + }, + "a proto codec riding mint.go's encoding/json row": { + path: "workcontext/mint.go", + source: `package workcontext +import "google.golang.org/protobuf/proto" +type mintResponse struct{ WorkContext string } +func read(raw []byte) error { + var body mintResponse + return proto.Unmarshal(raw, &body) +}`, + says: "calls proto.Unmarshal", + }, + "an identifier resolved to a later same-named declaration": { + path: "workcontext/mint.go", + source: `package workcontext +import ( + "encoding/json" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) +func leak(claims *basev0.WorkContextV1) ([]byte, error) { + body := claims + return json.Marshal(body) +} +type mintResponse struct{ WorkContext string } +func decode() { + var body mintResponse + _ = body +}`, + says: "on a type it is not allowed to", + }, + "an identifier an ambiguous file cannot resolve": { + path: "workcontext/mint.go", + source: `package workcontext +import "encoding/json" +type mintRequest struct{ Audience string } +func one() { body := mintRequest{}; _ = body } +func two(raw []byte) error { + body := map[string]any{} + return json.Unmarshal(raw, &body) +}`, + says: "on a type it is not allowed to", + }, + "mime as a base64 decoder": { + path: "workcontext/carrier.go", + source: `package workcontext +import "mime" +func open(header string) (string, error) { + kind, _, err := mime.ParseMediaType(header) + return kind, err +}`, + says: "imports \"mime\"", + }, + "sha256 outside the file that hashes": { + path: "workcontext/carrier.go", + source: `package workcontext +import "crypto/sha256" +func mac(key, message []byte) []byte { + inner := sha256.Sum256(append(key, message...)) + return inner[:] +}`, + says: "imports \"crypto/sha256\"", + }, + "a nested package reaching for the cache partition's base64": { + path: "workcontext/grpctransport/partition.go", + source: `package grpctransport +import "encoding/base64" +var _ = base64.RawURLEncoding`, + says: "imports \"encoding/base64\"", + }, + "a nested file inheriting the mint.go allowance": { + path: "workcontext/grpctransport/mint.go", + source: `package grpctransport +import "encoding/json" +var _ = json.Marshal`, + says: "imports encoding/json", + }, + "an anonymous struct carrying json tags": { + path: "workcontext/carrier.go", + source: `package workcontext +func payload() any { + return struct { + Scopes []string ` + "`json:\"scopes\"`" + ` + }{} +}`, + says: "struct with json tags", + }, + "a json-tagged type declared inside a function": { + path: "workcontext/carrier.go", + source: `package workcontext +func payload() any { + type sealedClaims struct { + Installation string ` + "`json:\"installation_id\"`" + ` + } + return sealedClaims{} +}`, + says: "struct with json tags", + }, + "a WorkContext type declared inside a function": { + path: "workcontext/carrier.go", + source: `package workcontext +func build() any { + type WorkContextToken struct{ Payload string } + return WorkContextToken{} +}`, + says: "is not an alias of core's", + }, + "a WorkContext function declared below the imports": { + path: "workcontext/carrier.go", + source: `package workcontext +func WorkContextSign(payload []byte) []byte { return payload }`, + says: "declares func WorkContextSign", + }, + "an alias of a type that is not core's": { + path: "workcontext/core.go", + source: `package workcontext +import local "github.com/codefly-dev/sdk-go/workcontext/internal/legacy" +type WorkContextVerifier = local.Verifier`, + says: "alias of something that is not core's", + }, + "an alias of a bare local type": { + path: "workcontext/core.go", + source: `package workcontext +type verifier struct{} +type WorkContextVerifier = verifier`, + says: "alias of something that is not core's", + }, + "a signing primitive": { + path: "workcontext/carrier.go", + source: `package workcontext +import "crypto/ed25519" +var _ = ed25519.Sign`, + says: `imports "crypto/ed25519"`, + }, + + // Below: the STANDARD bypasses. Each one is a complete second + // implementation that the previous gate — a list of two package paths + // and a case-sensitive name prefix — reported as green. + "the same primitive from x/crypto": { + path: "workcontext/carrier.go", + source: `package workcontext +import "golang.org/x/crypto/ed25519" +var _ = ed25519.Sign`, + says: `imports "golang.org/x/crypto/ed25519"`, + }, + "a different algorithm": { + path: "workcontext/carrier.go", + source: `package workcontext +import "crypto/rsa" +var _ = rsa.SignPKCS1v15`, + says: `imports "crypto/rsa"`, + }, + "a MAC instead of a signature": { + path: "workcontext/carrier.go", + source: `package workcontext +import "crypto/hmac" +var _ = hmac.New`, + says: `imports "crypto/hmac"`, + }, + "the bare crypto package, whose Signer needs no algorithm import": { + path: "workcontext/carrier.go", + source: `package workcontext +import "crypto" +func sign(key crypto.Signer, payload []byte) ([]byte, error) { + return key.Sign(nil, payload, crypto.Hash(0)) +}`, + says: `imports "crypto"`, + }, + "a key parsed out of PKCS#8 through an allowed import": { + path: "workcontext/mint.go", + source: `package workcontext +import "crypto/x509" +func key(der []byte) (any, error) { return x509.ParsePKCS8PrivateKey(der) }`, + says: "uses x509.ParsePKCS8PrivateKey", + }, + "the TLS plumbing imported somewhere that is not the mint client": { + path: "workcontext/carrier.go", + source: `package workcontext +import "crypto/tls" +var _ = tls.Config{}`, + says: `imports "crypto/tls", which only`, + }, + "a second encoding of the message, in JSON, without encoding/json": { + path: "workcontext/carrier.go", + source: `package workcontext +import "google.golang.org/protobuf/encoding/protojson" +var _ = protojson.Marshal`, + says: "protobuf/encoding/protojson", + }, + "core's own signing encoding, reproduced": { + path: "workcontext/carrier.go", + source: `package workcontext +import "google.golang.org/protobuf/proto" +func encode(claims *Claims) ([]byte, error) { + return proto.MarshalOptions{Deterministic: true}.Marshal(claims) +}`, + says: "calls proto.MarshalOptions", + }, + "a JOSE library": { + path: "workcontext/carrier.go", + source: `package workcontext +import "github.com/go-jose/go-jose/v4" +var _ = jose.NewSigner`, + says: "go-jose", + }, + "the deleted implementation's own type name, uncapitalised": { + path: "workcontext/carrier.go", + source: `package workcontext +type workContextPayload struct { + Audience string +}`, + says: "declares type workContextPayload", + }, + "a WorkContext method rather than a free function": { + path: "workcontext/carrier.go", + source: `package workcontext +type thing struct{} +func (thing) WorkContextSign(payload []byte) []byte { return payload }`, + says: "declares method WorkContextSign", + }, + // Below: the bypasses round three found. Each defeated the gate as it + // stood after round two, which is the pattern worth noticing — every + // round the gate was tightened, and every round the next reviewer + // found the thing the tightening did not reach. + "a dot-import collision hiding a signing primitive": { + path: "workcontext/carrier.go", + source: `package workcontext +import ( + . "crypto/ed25519" + . "strings" +) +var _ = Sign +var _ = TrimSpace`, + says: `dot-imports "crypto/ed25519"`, + }, + "a single dot import": { + path: "workcontext/carrier.go", + source: `package workcontext +import . "crypto/ed25519" +var _ = Sign`, + says: "dot-imports", + }, + "an ALIASED protobuf marshal": { + path: "workcontext/carrier.go", + source: `package workcontext +import pb "google.golang.org/protobuf/proto" +func encode(claims *Claims) ([]byte, error) { + return pb.MarshalOptions{Deterministic: true}.Marshal(claims) +}`, + says: "calls pb.MarshalOptions", + }, + "a hand parser: base64 plus Unmarshal": { + path: "workcontext/carrier.go", + source: `package workcontext +import ( + "encoding/base64" + "google.golang.org/protobuf/proto" +) +func parse(encoded string) (*Claims, error) { + raw, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return nil, err + } + claims := &Claims{} + return claims, proto.Unmarshal(raw, claims) +}`, + says: `imports "encoding/base64"`, + }, + "proto.Unmarshal on its own": { + path: "workcontext/carrier.go", + source: `package workcontext +import "google.golang.org/protobuf/proto" +func read(raw []byte) (*Claims, error) { + claims := &Claims{} + return claims, proto.Unmarshal(raw, claims) +}`, + says: "calls proto.Unmarshal", + }, + "the wire encoder": { + path: "workcontext/carrier.go", + source: `package workcontext +import "google.golang.org/protobuf/encoding/protowire" +var _ = protowire.AppendTag`, + says: "encoding/protowire", + }, + "anypb, which marshals anything": { + path: "workcontext/carrier.go", + source: `package workcontext +import "google.golang.org/protobuf/types/known/anypb" +var _ = anypb.New`, + says: "types/known/anypb", + }, + // Round-three dynamic: both of these produced ZERO findings, because + // the codec exemptions were per FILE. A whole-file exemption is an + // exemption for every type in the file. + "a capability JSON-encoded as a map, needing no tags": { + path: "workcontext/mint.go", + source: `package workcontext +import "encoding/json" +func leak(c *Claims) ([]byte, error) { + return json.Marshal(map[string]any{ + "installation_id": c.GetSeal().GetInstallationId(), + "audience": c.GetAudience(), + }) +}`, + says: "calls json.Marshal on a type it is not allowed to", + }, + "a capability decoded into, in the file allowed JSON": { + path: "workcontext/mint.go", + source: `package workcontext +import ( + "bytes" + "encoding/json" +) +func parse(raw []byte) (*Claims, error) { + claims := &Claims{} + return claims, json.NewDecoder(bytes.NewReader(raw)).Decode(claims) +}`, + says: "calls Decode on a type it is not allowed to", + }, + "a capability proto-marshalled through the scope exemption": { + path: "workcontext/cache_partition.go", + source: `package workcontext +import "google.golang.org/protobuf/proto" +func sign(c *Claims) ([]byte, error) { + return proto.MarshalOptions{Deterministic: true}.Marshal(c) +}`, + says: "on a type it is not allowed to", + }, + "a WorkContext func bound to a var": { + path: "workcontext/carrier.go", + source: `package workcontext +var WorkContextSign = func(payload []byte) []byte { return payload }`, + says: "declares var/const WorkContextSign", + }, + } { + t.Run(name, func(t *testing.T) { + findings := inspectForSecondImplementation(parseSource(t, bypass.path, bypass.source)) + require.NotEmpty(t, findings, "the gate accepted source it must refuse") + require.Contains(t, strings.Join(findings, "\n"), bypass.says) + }) + } + + // And the two things that ARE allowed stay allowed, so the gate is not + // green by refusing everything. + for name, allowed := range map[string]struct { + path string + source string + }{ + "the mint endpoint's own bodies": { + path: "workcontext/mint.go", + source: `package workcontext +import "encoding/json" +type mintRequest struct { + Audience string ` + "`json:\"audience\"`" + ` +} +type mintResponse struct { + WorkContext string ` + "`json:\"work_context\"`" + ` +} +func encode(audience string) ([]byte, error) { + return json.Marshal(mintRequest{Audience: audience}) +} +func decode(raw []byte) (mintResponse, error) { + var body mintResponse + return body, json.Unmarshal(raw, &body) +}`, + }, + "an alias of core's type": { + path: "workcontext/core.go", + source: `package workcontext +import corework "github.com/codefly-dev/core/workcontext" +type WorkContextVerifier = corework.Verifier`, + }, + } { + t.Run(name, func(t *testing.T) { + require.Empty(t, inspectForSecondImplementation(parseSource(t, allowed.path, allowed.source))) + }) + } +} + +func parseSource(t *testing.T, path string, source string) sourceFile { + t.Helper() + syntax, err := parser.ParseFile(token.NewFileSet(), path, source, parser.SkipObjectResolution) + require.NoError(t, err) + return sourceFile{path: path, syntax: syntax} +} + +// TestWorkContextConformance drives core's own fixtures against the +// verification entrypoint THIS MODULE exports. +// +// It is built field by field from the kit's settings rather than through +// conformance.Settings.Verifier(), which returns core's own verifier: running +// the fixtures against that tests core, which core already tests, and would +// have passed unchanged beside the second implementation this PR deletes. The +// verifier under test is constructed as `&Verifier{...}` — this module's +// exported name — so the thing a consumer reaches for is the thing the +// fixtures are driven through. +// +// What it proves is BEHAVIOURAL conformance: this entrypoint reaches core's +// accept/refuse decision with core's named reason on every fixture. Because +// Verifier is a true alias the identity follows, but the kit cannot see that — +// an equivalent second implementation would pass the same fixtures. The +// identity half is the compile-time assertion at the top of this file plus the +// static gate above; the fixtures are the cheaper half of the guarantee, not +// the whole of it. +// +// The decisive fixture is the foreign encoding — a JSON-shaped token that must +// be refused BEFORE its signature is checked, with ErrNotACoreToken. A second +// implementation refuses that token too, but as a signature failure, which is +// the misdiagnosis this whole rule exists to prevent. +func TestWorkContextConformance(t *testing.T) { + settings := conformance.New(time.Now()) + exported := &Verifier{ + Issuer: settings.Issuer, + Audience: settings.Audience, + // Settings.Keys is map[string][]byte so holding settings needs no + // signing import; PublicKeys() is the typed form the verifier takes. + Keys: settings.PublicKeys(), + Revisions: settings.Revisions, + // One replay store for the whole run: the kit presents the single-use + // grant fixture twice and requires ErrReplayed, which a fresh store per + // call would turn into a pass on everything else and a failure there. + Replay: settings.Replay, + Grants: settings.Grants, + Seals: settings.Seals, + Now: settings.Now, + // Copied from the kit rather than written here, and it is REQUIRED: the + // fixture key's private half is derivable from core's source, so a + // verifier refuses it unless it says in as many words that it is a + // test. Leaving it out made every one of the 35 fixtures fail with + // "key \"conformance-1\" is the conformance fixture key" — a consumer + // doing exactly the right thing refusing everything. + // + // Which is the argument FOR building this field by field rather than + // calling settings.Verifier(): a new field core adds to the contract + // lands here as a failing test. Through the constructor it would have + // been inherited silently and this module would have learned nothing. + TrustTheConformanceFixtureKey: settings.TrustTheConformanceFixtureKey, + } + conformance.RunWith(t, settings, func(ctx context.Context, token string) error { + _, err := exported.Verify(ctx, token) + return err + }) +} + +type sourceFile struct { + path string + syntax *ast.File +} + +// moduleFiles parses every non-test Go file in this module. The walk starts at +// the module root, which is this package's directory, so a new package added +// beside grpctransport is scanned without anyone remembering to list it. Paths +// are slash-separated and relative to that root, which is what the allowlist is +// keyed by. +// repositoryFiles parses every non-test Go file in the WHOLE REPOSITORY, both +// modules, naming each by its path relative to the repository root. +// +// It used to walk only this module, which was a hole with nothing behind it: +// the implementation this repository deleted lived at the repository ROOT, in +// package codefly, and the shell sweep — the root's only gate — bans imports +// rather than reading code. A reviewer built the consequence and ran it: a root +// file with a HAND-WRITTEN base64url decoder, so no banned import at all, and +// proto.Unmarshal into basev0.WorkContextV1, compiled, passed golangci-lint +// with zero issues and passed the sweep as "ok working tree (58 Go files)". +// Reading the AST is what sees that, so the AST gate reads both modules. +func repositoryFiles(t *testing.T) []sourceFile { + t.Helper() + root, err := filepath.Abs("..") + require.NoError(t, err) + var files []sourceFile + fileSet := token.NewFileSet() + require.NoError(t, filepath.WalkDir(root, func(absolute string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + path, relErr := filepath.Rel(root, absolute) + if relErr != nil { + return relErr + } + path = filepath.ToSlash(path) + if entry.IsDir() { + switch entry.Name() { + case "testdata", ".git", ".github", ".claude": + return fs.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + syntax, parseErr := parser.ParseFile(fileSet, absolute, nil, parser.SkipObjectResolution) + if parseErr != nil { + return parseErr + } + files = append(files, sourceFile{path: path, syntax: syntax}) + return nil + })) + return files +} + +// moduleFiles is the same walk restricted to this module. +func moduleFiles(t *testing.T) []sourceFile { + t.Helper() + var files []sourceFile + fileSet := token.NewFileSet() + require.NoError(t, filepath.WalkDir(".", func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if entry.Name() == "testdata" { + return fs.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + syntax, parseErr := parser.ParseFile(fileSet, path, nil, parser.SkipObjectResolution) + if parseErr != nil { + return parseErr + } + files = append(files, sourceFile{path: filepath.ToSlash(path), syntax: syntax}) + return nil + })) + return files +} + +// importsOf returns this file's imports as (local name, path) PAIRS. +// +// It used to return a map keyed by local name, which two imports can collide +// in: `import ( . "crypto/ed25519"; . "strings" )` maps "." twice, the second +// wins, and the signing primitive is never inspected at all. A gate whose +// coverage depends on import order is not a gate. Pairs cannot collide. +func importsOf(file *ast.File) []importedPackage { + imports := make([]importedPackage, 0, len(file.Imports)) + for _, imported := range file.Imports { + path, err := strconv.Unquote(imported.Path.Value) + if err != nil { + continue + } + name := path[strings.LastIndex(path, "/")+1:] + if imported.Name != nil { + name = imported.Name.Name + } + imports = append(imports, importedPackage{name: name, path: path}) + } + return imports +} + +type importedPackage struct { + name string + path string +} + +func allowedEnvelopeDecoderFiles() []string { + names := make([]string, 0, len(envelopeDecoderAllowlist)) + for name := range envelopeDecoderAllowlist { + names = append(names, name) + } + slices.Sort(names) + return names +} + +func envelopeDecoderReasons() []string { + reasons := make([]string, 0, len(envelopeDecoderAllowlist)) + for _, reason := range envelopeDecoderAllowlist { + reasons = append(reasons, reason) + } + slices.Sort(reasons) + return reasons +} + +// isCapabilityType reports whether a resolved type name is one of core's Work +// Context messages. The name is package-PATH qualified, so neither a local +// alias nor an import aliased to core's own local name can answer yes by +// spelling. +func isCapabilityType(resolved string) bool { + path, bare, found := strings.Cut(resolved, "#") + if !found { + return false + } + if path != coreModulePath && !strings.HasPrefix(path, coreModulePath+"/") { + return false + } + return strings.HasPrefix(bare, capabilityTypePrefix) +} + +// codecArgumentIsPermitted reports whether this codec call is applied to a +// type the file is allowed to apply it to. +// +// It resolves the argument's type SYNTACTICALLY, from the file alone: a +// composite literal names its own type, a type assertion names the asserted +// type, an address-of or a star defers to what it wraps, and an identifier is +// looked up among the file's var declarations, short declarations and function +// parameters. There is no go/types here and so no cross-file inference, which +// is a stated limit rather than a claim: what it has to catch is a codec +// applied to something the allowlist did not name, and both reproduced probes +// — a map[string]any and a *Claims — are named locally in the file that uses +// them. +// +// An argument it cannot resolve is NOT permitted. A gate that passed what it +// could not read would be a gate about what is easy to parse. +func codecArgumentIsPermitted(file sourceFile, call ast.Node, codec string, operation string) bool { + permitted := permittedCodecTypes(file.path, codec, operation) + inLeaf := strings.HasPrefix(file.path, leafModulePrefix) + if len(permitted) == 0 && inLeaf { + // Inside the leaf module an unlisted codec use is a finding on its + // own: the capability is that module's subject, so every codec it + // performs is named. + return false + } + arguments := codecArguments(file, call) + if len(arguments) == 0 { + // A codec reference with no call to read — json.Marshal passed as a + // value, say. Nothing names a type, so nothing is permitted. + return false + } + // Only the argument carrying the VALUE is checked. An Unmarshal or Decode + // takes the bytes first and the destination last; a Marshal or Encode takes + // the value first. Requiring every argument to be an allowlisted type + // refused `json.Unmarshal(raw, &body)` on account of raw being []byte, + // which is not what the rule is about. + value := arguments[0] + if operation == "Unmarshal" || operation == "Decode" { + value = arguments[len(arguments)-1] + } + named := resolveTypeName(file, value, value.Pos()) + if named == "" { + // UNRESOLVABLE IS A FINDING EVERYWHERE NOW, with the root module's + // genuine indirections named one by one. + // + // "Allowed outside the leaf module" was a hatch wide enough to drive + // the whole gate through, and it was exercised: a reviewer wrote + // + // func into(raw []byte, m proto.Message) error { + // return proto.Unmarshal(raw, m) + // } + // + // in the root module, called it with &basev0.WorkContextV1{}, and the + // gate was green. One interface-typed parameter defeated the decisive + // rule, because the codec's argument is then an interface and names no + // type at all. + // + // Closing it needs the legitimate indirections enumerated, which they + // now are — FOUR of them: three in receipts, all taking a + // proto.Message by design, and the runtime's own configuration + // document. ("Three" sat here after the fourth was added, which is the + // C10 class and is why the count is now derived from the map rather + // than written out: len(codecIndirections).) + // + // WHAT THIS STILL DOES NOT CATCH, stated rather than implied: a + // capability passed INTO one of those three allowlisted functions. + // Following a value across a call needs go/types, and this gate is + // syntactic by choice — see the limit stated on codecArgumentIsPermitted. + return codecIndirectionIsPermitted(file, call) + } + if isCapabilityType(named) { + // THE DECISIVE RULE, and it holds in BOTH modules: a Work Context + // message passes through a codec only where a row names it. This is + // the whole of the root module's rule, and it is why the gate reading + // that module closes a bypass the shell sweep cannot — a hand-written + // base64url decoder imports nothing, so only reading the code sees + // proto.Unmarshal into basev0.WorkContextV1. + return slices.Contains(permitted, named) + } + if isCodecInterfaceType(named) { + // AN INTERFACE CAN CARRY A CAPABILITY, so resolving the argument's + // type is not enough when the type is one of the codec's own + // interfaces. This is the hatch a reviewer drove the whole gate + // through: + // + // func into(raw []byte, m proto.Message) error { + // return proto.Unmarshal(raw, m) + // } + // + // called with &basev0.WorkContextV1{}. The argument resolves perfectly + // — to proto.Message — which is neither a capability nor unresolvable, + // so both of the checks above said yes. One interface-typed parameter, + // and the decisive rule was gone. + // + // The legitimate indirections are enumerated instead. A new one is a + // finding and has to be argued in codecIndirections. + return codecIndirectionIsPermitted(file, call) + } + if !inLeaf { + return true + } + return slices.Contains(permitted, named) +} + +// codecInterfaceTypes are the interface types a codec takes, through which a +// capability can reach it without ever being named. +// +// A list of names, and deliberately a short closed one: these are the +// parameter types of the codecs themselves, so an indirection has to go +// through one of them. `any` and a literal `interface{}` are here for +// encoding/json, whose Marshal takes one. +var codecInterfaceTypes = []string{ + "google.golang.org/protobuf/proto#Message", + "google.golang.org/protobuf/reflect/protoreflect#ProtoMessage", + "google.golang.org/protobuf/runtime/protoiface#MessageV1", + "github.com/golang/protobuf/proto#Message", + "any", + "interface{}", +} + +func isCodecInterfaceType(resolved string) bool { + return slices.Contains(codecInterfaceTypes, resolved) +} + +// codecIndirections are the functions that legitimately apply a codec to an +// interface-typed parameter, by file and by function name. +// +// Four: three in receipts, all taking a proto.Message because that is what the +// receipts contract is about — a caller's own request and response messages, +// never a capability — and the runtime's own configuration document. Keyed on the enclosing function rather than the file, +// because a file-wide exemption is what let the type rule be bypassed twice +// already. +var codecIndirections = map[string][]string{ + "receipts/digest.go": {"RequestDigest"}, + "receipts/effect.go": {"Record"}, + "receipts/interceptor.go": {"Handle"}, + // The runtime's own documents, decoded into a caller's destination. The + // destination is the CALLER's type and the content is a configuration + // document, never a capability — a capability arrives as an opaque string + // and is read with corework.Inspect. + "configuration_document.go": {"decodeDocument"}, +} + +// codecIndirectionIsPermitted reports whether an unresolvable codec argument is +// one of the named indirections. +func codecIndirectionIsPermitted(file sourceFile, call ast.Node) bool { + permitted, named := codecIndirections[file.path] + if !named { + return false + } + enclosing := enclosingFuncName(file, call.Pos()) + return enclosing != "" && slices.Contains(permitted, enclosing) +} + +// enclosingFuncName names the function containing pos, or "" at file level. +func enclosingFuncName(file sourceFile, pos token.Pos) string { + for _, declaration := range file.syntax.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Body == nil { + continue + } + if function.Body.Pos() <= pos && pos <= function.Body.End() { + return function.Name.Name + } + } + return "" +} + +// codecArguments finds the call this codec selector belongs to and returns the +// arguments whose types matter. For a MarshalOptions{...}.Marshal(x) chain the +// selector sits inside the outer call, so the whole file is walked for the call +// whose function expression contains this node. +func codecArguments(file sourceFile, target ast.Node) []ast.Expr { + var arguments []ast.Expr + ast.Inspect(file.syntax, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + contains := false + ast.Inspect(call.Fun, func(inner ast.Node) bool { + if inner == target { + contains = true + } + return true + }) + if contains && len(call.Args) > 0 { + arguments = call.Args + } + return true + }) + return arguments +} + +// resolveTypeName names the type of an expression, syntactically, or returns "" +// when it cannot. +func resolveTypeName(file sourceFile, expression ast.Expr, use token.Pos) string { + switch typed := expression.(type) { + case *ast.UnaryExpr: + return resolveTypeName(file, typed.X, use) + case *ast.CompositeLit: + return typeName(file, typed.Type) + case *ast.TypeAssertExpr: + return typeName(file, typed.Type) + case *ast.CallExpr: + // new(T) names T, not "new". + if function, ok := typed.Fun.(*ast.Ident); ok && function.Name == "new" && len(typed.Args) == 1 { + return typeName(file, typed.Args[0]) + } + // EVERY OTHER CALL IS UNKNOWN, and this used to return the function's + // own name as though it were a type. + // + // That lie was a bypass: dynamicpb.NewMessage(desc) resolved to + // "…/dynamicpb#NewMessage", which is neither a capability nor an + // interface nor empty, so the root module's check said yes — and a + // reviewer unmarshalled a real WorkContextV1 through it. The same went + // for protoregistry.GlobalTypes.FindMessageByName(...).New(). + // + // A conversion T(x) is lost with it. That is the right trade: a + // conversion at a codec call site now needs the indirection + // allowlist, and there are none in this repository. + return "" + case *ast.Ident: + return declaredTypeName(file, typed.Name, use) + } + return "" +} + +// typeName reduces a type expression to its name, KEEPING THE PACKAGE +// QUALIFIER: *pkg.T and pkg.T are "pkg.T", []T is "[]T", a map is "map". +// +// It used to return Sel.Name and drop the qualifier, so basev0.WorkScopeV1 and +// anything.WorkScopeV1 were the same string — and cache_partition.go's row, +// which exists for ONE core scope message, permitted a proto.Marshal of any +// type whose final name happened to be WorkScopeV1, including a local alias to +// the capability itself. +func typeName(file sourceFile, expression ast.Expr) string { + return typeNameThroughAliases(file, expression, 0) +} + +// maxAliasHops bounds the alias chase. Go rejects a cycle, but this gate parses +// source that has not been compiled — every probe here is uncompiled by +// construction — so the bound is what stops a cycle in source nobody built. +const maxAliasHops = 8 + +func typeNameThroughAliases(file sourceFile, expression ast.Expr, hops int) string { + switch typed := expression.(type) { + case *ast.StarExpr: + return typeNameThroughAliases(file, typed.X, hops) + case *ast.SelectorExpr: + qualifier, ok := typed.X.(*ast.Ident) + if !ok { + return "" + } + // Resolved to the imported PACKAGE PATH, never to the local name. The + // local name is the author's choice, so `basev0 "some/other/pkg"` + // would otherwise let a foreign type answer to a row written for one + // of core's — the same defect as dropping the qualifier altogether. + path := importPathOf(file, qualifier.Name) + if path == "" { + return "" + } + return path + "#" + typed.Sel.Name + case *ast.Ident: + // A LOCAL NAME IS CHASED TO WHAT IT NAMES, which is the hole the + // qualifier fix left behind. Returning the identifier as written meant + // + // type carrier = *basev0.WorkContextV1 + // var c carrier = &basev0.WorkContextV1{} + // proto.Unmarshal(raw, c) + // + // resolved to "carrier" — not a capability, not a codec interface, not + // unresolvable — so the root module's decisive rule returned true. + // Reproduced against this gate: green. The VALUE alias was caught, but + // only by inspectCoreAliases and only because its right-hand side was + // a bare selector; one `*` walked past both. + if hops < maxAliasHops { + if target := localTypeTarget(file, typed.Name); target != nil { + if named := typeNameThroughAliases(file, target, hops+1); named != "" && named != typed.Name { + return named + } + } + } + return typed.Name + case *ast.ArrayType: + return "[]" + typeNameThroughAliases(file, typed.Elt, hops) + case *ast.MapType: + // Deliberately not reduced to its value type: a map is never an + // allowlisted codec type, and naming it as one is how the json probe + // would have passed. + return "map" + } + return "" +} + +// localTypeTarget is the right-hand side of a file-level `type X = T` or +// `type X T`, or nil. +// +// BOTH forms, and the defined form deliberately: `type X basev0.WorkContextV1` +// is a second declaration of a capability's shape under a local name. It cannot +// reach proto.Unmarshal, because a defined type inherits no methods and so is +// not a proto.Message — but a rule that is true for one spelling and silent for +// the other is a rule a reader has to test to know, and the gate's whole +// subject is names chosen locally for core's types. +func localTypeTarget(file sourceFile, name string) ast.Expr { + for _, declaration := range file.syntax.Decls { + generic, ok := declaration.(*ast.GenDecl) + if !ok || generic.Tok != token.TYPE { + continue + } + for _, spec := range generic.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok || typeSpec.Name.Name != name { + continue + } + return typeSpec.Type + } + } + return nil +} + +// declaredTypeName looks an identifier up among the declarations that are +// actually IN SCOPE at the use, and refuses to answer when more than one +// distinct declaration could be the one. +// +// Two defects it used to have, both reported and both reproducible: +// +// - IT RETURNED THE LAST MATCH. ast.Inspect's false return prunes a subtree +// and then carries on over the siblings, so `found` was overwritten by +// every later declaration of the same name — and a function placed BEFORE +// mint.go's credentialFrom could write `body := claims` and have `body` +// resolve to the mintResponse declared further down. +// - IT IGNORED SCOPE. A name declared in one function resolved a name used +// in another. +// +// Now the search is restricted to the function enclosing the use (plus +// file-level declarations), the candidate must be declared BEFORE the use, and +// two candidates that disagree resolve to "" — which is not permitted, because +// a gate that guesses between two readings is not a gate. +func declaredTypeName(file sourceFile, name string, use token.Pos) string { + scope := enclosingBody(file, use) + var candidates []string + consider := func(node ast.Node, resolved string) { + if node.Pos() > use || resolved == "" { + return + } + if !slices.Contains(candidates, resolved) { + candidates = append(candidates, resolved) + } + } + inspect := func(root ast.Node) { + ast.Inspect(root, func(node ast.Node) bool { + switch declared := node.(type) { + case *ast.ValueSpec: + for index, declaredName := range declared.Names { + if declaredName.Name != name { + continue + } + if declared.Type != nil { + consider(declared, typeName(file, declared.Type)) + continue + } + if index < len(declared.Values) { + consider(declared, resolveTypeName(file, declared.Values[index], use)) + } + } + case *ast.AssignStmt: + if declared.Tok != token.DEFINE { + return true + } + for index, left := range declared.Lhs { + ident, ok := left.(*ast.Ident) + if !ok || ident.Name != name || index >= len(declared.Rhs) { + continue + } + consider(declared, resolveTypeName(file, declared.Rhs[index], use)) + } + case *ast.FuncDecl: + // Parameters and results are in scope only inside their own + // function, which is what scope here means. + if scope == nil || declared.Body != scope { + return true + } + for _, list := range fieldLists(declared.Type) { + for _, field := range list { + for _, declaredName := range field.Names { + if declaredName.Name == name { + consider(field, typeName(file, field.Type)) + } + } + } + } + } + return true + }) + } + // File-level declarations, then the enclosing function's own body. + for _, declaration := range file.syntax.Decls { + if function, ok := declaration.(*ast.FuncDecl); ok { + if scope != nil && function.Body == scope { + inspect(function) + } + continue + } + inspect(declaration) + } + if len(candidates) != 1 { + // Nothing, or an ambiguity. Either way nothing is permitted. + return "" + } + return candidates[0] +} + +// enclosingBody is the body of the function containing pos, or nil at file +// level. It is what makes the lookup above scoped rather than file-wide. +func enclosingBody(file sourceFile, pos token.Pos) *ast.BlockStmt { + var found *ast.BlockStmt + for _, declaration := range file.syntax.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Body == nil { + continue + } + if function.Body.Pos() <= pos && pos <= function.Body.End() { + found = function.Body + } + } + return found +} + +func fieldLists(signature *ast.FuncType) [][]*ast.Field { + var lists [][]*ast.Field + if signature.Params != nil { + lists = append(lists, signature.Params.List) + } + if signature.Results != nil { + lists = append(lists, signature.Results.List) + } + return lists +} + +func allowedJSONFiles() []string { + names := make([]string, 0, len(jsonAllowlist)) + for name := range jsonAllowlist { + names = append(names, name) + } + slices.Sort(names) + return names +} + +// importPathOf resolves a file-local package name to the path it was imported +// from, or "" when nothing imported it. +// +// It is what makes a type name mean a type rather than a spelling: the local +// name in `basev0 "…/base/v0"` is chosen by whoever wrote the import, so an +// allowlist compared against local names is one anybody satisfies by aliasing +// a different package to the expected name. +func importPathOf(file sourceFile, local string) string { + for _, imported := range importsOf(file.syntax) { + if imported.name == local { + return imported.path + } + } + return "" +} diff --git a/workcontext/parse_agreement_test.go b/workcontext/parse_agreement_test.go new file mode 100644 index 0000000..d6e0f52 --- /dev/null +++ b/workcontext/parse_agreement_test.go @@ -0,0 +1,198 @@ +package workcontext + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + corework "github.com/codefly-dev/core/workcontext" + "github.com/stretchr/testify/require" +) + +// structurallyRefused is every fixture in core's kit whose refusal this module +// MUST reach on its own, because it is a property of the capability's own bytes +// rather than of the issuer's live state or of a signature. +// +// Naming them explicitly is the point. Without this list the test below would +// pass by refusing nothing at all: "no disagreement" is satisfied by an +// inspection that accepts everything, and that is exactly how the actor-epoch +// hole survived — sealOf never read the actor chain, so a capability core +// refuses as unsealed went on the wire. +var structurallyRefused = map[string]bool{ + // The envelope. + "empty-token": true, // ErrInvalid: nothing at all + "no-separator": true, // ErrInvalid: not . + "separator-only": true, // ErrInvalid: empty segments + "payload-not-b64": true, // ErrInvalid: the envelope does not decode + "foreign-encoding": true, // ErrNotACoreToken: another format entirely + + // The seal, every part of which the schema requires now. + "missing-seal": true, // ErrInvalid: no seal + "seal-without-installation": true, // ErrInvalid: a seal bound to nothing + "zero-principal-epoch": true, // ErrInvalid: zero is not an epoch + "zero-installation-revision": true, // ErrInvalid: zero is not a revision + "zero-build-incarnation": true, // ErrInvalid: zero is not an incarnation + // The execution is a PAIR, set together or absent together: a digest with + // no incarnation, or an incarnation with no digest, is a seal that names a + // build nothing can supersede. This fixture arrived with core's per-hop + // execution change and the suite caught it by comparing against core's own + // list rather than against a constant here — which is the whole reason the + // comparison is written that way. + "seal-half-execution": true, // ErrInvalid: half an execution is not one + "partial-operation-binding": true, // ErrInvalid: an id at no revision + "actor-without-epoch": true, // ErrInvalid: a principal nobody can revoke + + // The encoding itself. Core's Verify now refuses unknown fields + // recursively and non-canonical encodings, and Inspect reaches the same + // answer — so a capability with a field appended and re-signed is refused + // here too, with no key and no network. This fixture arrived with the + // settled surface and the exactness assertion below is what found it, + // which is the reason that assertion exists. + "unknown-field": true, // ErrInvalid: an appended field, re-signed +} + +// TestTheSDKParsePathsAgreeWithCore drives every fixture in core's kit through +// THIS MODULE's parse paths and requires core's declared sentinel for each one. +// +// The twelve above are the ones a capability's own bytes earn, so this module +// must reach them with no network and no key. Every other fixture — signature, +// issuer, audience, WINDOW and live-state refusals — must PASS: a structural +// check that refused them would be claiming to have verified something it +// cannot see. Expired and not-yet-valid are in that second group, which is +// worth stating because it surprises: Inspect checks no window at all, and the +// mint client's own checkWindow is what refuses a credential that arrives +// unusable. +// +// The list is not derived from Inspect's behaviour, deliberately — that would +// be a tautology. It is written down, and the test requires every name on it to +// be a fixture that exists, so a fixture core renames or retires fails here +// rather than quietly dropping out of coverage. +// +// This is the test the conformance test is not. Driving fixtures through +// `&Verifier{}` exercises core, because Verifier is an alias of core's type — +// it is core testing core, and it would have passed unchanged beside the +// implementation this PR deletes. The SDK's own decision paths are +// readClaims/sealOf, SealedInstallation, FromHeaders and credentialFrom, and that +// is where a divergence from core can actually live. Three did: +// +// - seal-without-installation: core answers ErrInvalid, because protovalidate +// reaches it before the structural check. This module answered ErrUnsealed, +// and its own test pinned that divergence as if it were correct. +// - actor-without-epoch: this module never read the actor chain and accepted +// the capability outright, so a principal nobody can revoke went on the +// wire. +// - a zero epoch, revision or incarnation: core answers ErrInvalid from the +// schema. This module answered ErrUnsealed. +// +// Every one of those is ErrInvalid at core today — the seal and each actor +// epoch are schema-required, so protovalidate reaches all of them and +// ErrUnsealed is deleted. This test needed no edit for that, which is the +// argument for reading sentinels off the kit rather than writing them down: +// three of core's fixtures changed sentinel and nothing here had to move. +// +// Each of those is "one condition, two messages" — the fragmentation the +// one-implementation rule exists to end — relocated from the signature to the +// seal. The agreement is now TESTED against core's own declarations rather than +// described in a comment, so core changing its order breaks this test here +// rather than surfacing as a mismatched sentinel in somebody's gateway. +// +// It does not make this module's reading correct by construction — core's +// Inspect does that, and this test is what holds the two in agreement. Core +// built Inspect when it was asked for; this module applies no structural rule +// of its own any more. +func TestTheSDKParsePathsAgreeWithCore(t *testing.T) { + fixtures, err := corework.Fixtures(time.Now()) + require.NoError(t, err) + require.NotEmpty(t, fixtures) + + seen := map[string]bool{} + for _, fixture := range fixtures { + seen[fixture.Name] = true + t.Run(fixture.Name, func(t *testing.T) { + _, _, _, sealErr := sealOf(fixture.Token) + + if fixture.Outcome == corework.OutcomeAccepted { + require.NoError(t, sealErr, + "core accepts %q, so this module must not refuse it on its own bytes", fixture.Name) + return + } + + if structurallyRefused[fixture.Name] { + require.Error(t, sealErr, + "%q is refused on its own bytes, so this module must refuse it too:\n"+ + " core's reason: %s", fixture.Name, fixture.Reason) + } + if sealErr == nil { + // A refusal that needs the issuer's live state, a key, or the + // replay store is not this module's to reach. Accepting it here + // is correct, and nothing authorizes from the result. + require.False(t, structurallyRefused[fixture.Name]) + return + } + // But WHEN this module refuses, it must refuse for core's reason. + require.ErrorIs(t, sealErr, fixture.Err, + "this module refuses %q as %v; core declares %v.\n"+ + " core's reason: %s\n"+ + " One condition with two messages is the fragmentation this rule exists to end.", + fixture.Name, sealErr, fixture.Err, fixture.Reason) + }) + } + + for name := range structurallyRefused { + require.True(t, seen[name], + "the list names %q, which is not a fixture in core's kit any more — "+ + "a stale entry is a case nobody is covering", name) + } + + // The list is EXACT, not a lower bound. A fixture this module refuses + // structurally but nobody listed would otherwise drift into coverage + // silently, and one it stops refusing would drift out — and the second is + // the direction that matters, because it is how the actor-epoch hole + // existed in the first place. Core adding a structural fixture should land + // here as a failing test asking to be acknowledged. + refused := map[string]bool{} + for _, fixture := range fixtures { + if _, _, _, err := sealOf(fixture.Token); err != nil { + refused[fixture.Name] = true + } + } + require.Equal(t, structurallyRefused, refused, + "the fixtures this module refuses on their own bytes are not the ones the list names") +} + +// And the agreement holds on every carrier, not only in sealOf: the carriers +// are the reason this module parses at all, so a capability core refuses on its +// own bytes must not reach a request through any of them. +func TestNoCarrierAcceptsWhatCoreRefusesOnItsOwnBytes(t *testing.T) { + fixtures, err := corework.Fixtures(time.Now()) + require.NoError(t, err) + + for _, fixture := range fixtures { + if !structurallyRefused[fixture.Name] { + continue + } + t.Run(fixture.Name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/records", nil) + attachErr := Attach(request, fixture.Token) + require.ErrorIs(t, attachErr, fixture.Err) + require.Empty(t, request.Header.Get(HeaderName), + "a refused capability must not be left on the request") + + _, _, installationErr := SealedInstallation(fixture.Token) + require.ErrorIs(t, installationErr, fixture.Err, + "SealedInstallation is the one function every carrier goes through") + + headers := http.Header{} + headers.Set(HeaderName, fixture.Token) + headers.Set(InstallationIDHeaderName, corework.FixtureInstallation) + headers.Set(InstallationRevisionHeaderName, "1") + // Core's sentinel, with no alternative: the `|| ErrInvalid` this + // used to allow made the assertion vacuous for the eleven of + // twelve fixtures whose sentinel IS ErrInvalid. + _, headerErr := FromHeaders(headers) + require.ErrorIs(t, headerErr, fixture.Err, + "FromHeaders refused %q as %v; core declares %v", fixture.Name, headerErr, fixture.Err) + }) + } +} diff --git a/workcontext/retraction_test.go b/workcontext/retraction_test.go new file mode 100644 index 0000000..d6bd99c --- /dev/null +++ b/workcontext/retraction_test.go @@ -0,0 +1,151 @@ +package workcontext + +import ( + "encoding/json" + "os" + "os/exec" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/mod/semver" +) + +// THE RETRACT INTERVAL IS NON-EMPTY AND COVERS WHAT IT CLAIMS TO. +// +// The first attempt at this module's retraction wrote +// +// retract [v0.0.0, v0.0.0-20261004000000-zzzzzzzzzzzz] +// +// which covers NOTHING. Under semver a prerelease sorts BELOW its release, so +// `v0.0.0-2026…` is LESS than `v0.0.0`: the interval has low > high and is +// empty, and every published pseudo-version of this module sat outside a +// declaration that read as though it covered all of them. `go mod edit -json` +// printed it back without complaint, `go build` and `go vet` passed, and the +// root module's check compared the bound's TIMESTAMP as a string — so the one +// thing nobody asked was whether the interval contained anything. +// +// That is the fail-open shape this repository has now produced six times: a +// declaration that looks like a gate. The pattern in all six is the same — the +// check was written in terms of the thing's spelling rather than its meaning. +// +// So the ordering is answered by golang.org/x/mod/semver, which is Go's own +// implementation of it and the same one `go list -m -retracted` uses. A hand +// comparison is what missed it. +func TestTheRetractIntervalCoversThePublishedVersions(t *testing.T) { + spans := ownRetractSpans(t) + require.NotEmpty(t, spans, + "this module carries the implementation from root tag v0.1.66 onward and must retract "+ + "the versions that publish it") + + for _, span := range spans { + require.True(t, semver.IsValid(span.Low), "retract low bound %q is not a version", span.Low) + require.True(t, semver.IsValid(span.High), "retract high bound %q is not a version", span.High) + require.LessOrEqual(t, semver.Compare(span.Low, span.High), 0, + "retract [%s, %s] is EMPTY: low sorts above high, so it covers no version at all. "+ + "A prerelease sorts BELOW its release, so [v0.0.0, v0.0.0-…] is the mistake this "+ + "test exists for.", span.Low, span.High) + } + + // EVERY PUBLISHED VERSION THAT CARRIES IT, by pseudo-version, computed from + // the commits rather than written down — a list of versions in a test is + // another thing that can silently stop matching what is published. + carrying := carryingPseudoVersions(t) + require.NotEmpty(t, carrying, + "no commit in this checkout carries the implementation under workcontext/, which "+ + "contradicts the measurement this retraction was written from") + t.Logf("%d published version(s) of this module carry the implementation", len(carrying)) + + for _, version := range carrying { + covered := false + for _, span := range spans { + if semver.Compare(version, span.Low) >= 0 && semver.Compare(version, span.High) <= 0 { + covered = true + break + } + } + require.True(t, covered, + "%s publishes this module WITH the second implementation and no retract interval "+ + "covers it. A consumer resolves it today; a tag cannot be fixed, only retracted.", + version) + } + + // And a version from after the bound is NOT retracted, so the interval is + // not simply swallowing everything — which would make the assertion above + // pass for the wrong reason and would retract this PR's own release. + future := "v0.0.0-20991231235959-ffffffffffff" + for _, span := range spans { + require.Positive(t, semver.Compare(future, span.High), + "the retraction covers %s, which is later than every carrying commit — an interval "+ + "that retracts the fix as well as the defect is not a retraction", future) + } +} + +type ownRetractSpan struct { + Low string + High string +} + +// ownRetractSpans reads this module's own retract directives through the go +// tool, because the shape a person writes and the set the tool acts on are two +// different things. +func ownRetractSpans(t *testing.T) []ownRetractSpan { + t.Helper() + run := exec.Command("go", "mod", "edit", "-json") + output, err := run.Output() + require.NoError(t, err) + var parsed struct { + Retract []ownRetractSpan + } + require.NoError(t, json.Unmarshal(output, &parsed)) + return parsed.Retract +} + +// carryingPseudoVersions is the pseudo-version of every commit whose +// workcontext/ tree carries the deleted implementation. +// +// Computed from git rather than listed, and it reads the commit's own timestamp +// in UTC, which is what the go tool puts in a pseudo-version. +func carryingPseudoVersions(t *testing.T) []string { + t.Helper() + // THE LEAF PATHS. The first version of this asked git for `work_context.go` + // — the ROOT-level file, which belongs to the root module — so it asserted + // about nine commits of the wrong module's history and passed. The files + // this module publishes are under workcontext/. + run := exec.Command("git", "log", "--format=%H %cd", + "--date=format-local:%Y%m%d%H%M%S", "--all", "--", + "workcontext/work_context.go", "workcontext/work_context_jwks.go") + run.Dir = ".." + run.Env = append(os.Environ(), "TZ=UTC") + output, err := run.Output() + require.NoError(t, err) + + var versions []string + seen := map[string]bool{} + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + sha, stamp, found := strings.Cut(strings.TrimSpace(line), " ") + if !found || len(sha) < 12 { + continue + } + // A commit that TOUCHED the path includes the one that DELETED it, and + // a commit where the file is absent does not publish it. `git log + // -- path` cannot tell those apart, so the tree is asked. + if !blobExistsAt(t, sha, "workcontext/work_context.go") && + !blobExistsAt(t, sha, "workcontext/work_context_jwks.go") { + continue + } + version := "v0.0.0-" + stamp + "-" + sha[:12] + if !seen[version] { + seen[version] = true + versions = append(versions, version) + } + } + return versions +} + +func blobExistsAt(t *testing.T, sha string, path string) bool { + t.Helper() + run := exec.Command("git", "cat-file", "-e", sha+":"+path) + run.Dir = ".." + return run.Run() == nil +} diff --git a/workcontext/round4_reproducers_test.go b/workcontext/round4_reproducers_test.go new file mode 100644 index 0000000..b287eba --- /dev/null +++ b/workcontext/round4_reproducers_test.go @@ -0,0 +1,742 @@ +package workcontext + +import ( + "context" + "crypto/x509" + "net/http" + "net/url" + "sync" + "sync/atomic" + "testing" + "time" + + corework "github.com/codefly-dev/core/workcontext" + "github.com/stretchr/testify/require" +) + +// Round four's section (D): four tests that proved less than their names, and +// the two new defects the review found by reading them. + +// movableClock is a clock a test can advance while a goroutine is blocked, and +// which COUNTS its reads — so a test can tell that a waiter has taken its +// snapshot without sleeping and hoping. +type movableClock struct { + mu sync.RWMutex + at time.Time + reads atomic.Uint64 +} + +func (c *movableClock) now() time.Time { + c.mu.RLock() + defer c.mu.RUnlock() + c.reads.Add(1) + return c.at +} + +func (c *movableClock) set(at time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + c.at = at +} + +// A waiter that ENTERS while the credential is still valid and gives up after +// it has expired must not be handed it. +// +// The previous version of this test moved the clock past expiry BEFORE the +// waiter entered, so the snapshot taken on entry was already of an expired +// credential and the pre-fix code passed it too. The timeline that matters is +// the one the dynamic review reproduced: enter at expiry-1s, where the held +// credential IS servable, and cancel at expiry+1s. Only re-evaluating under +// the lock after the wait answers that correctly. +func TestACancelledWaiterIsNeverHandedACredentialThatExpiredWhileItWaited(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + release := make(chan struct{}) + var releaseOnce sync.Once + letGo := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(letGo) + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + expiry := held.ExpiresAt() + + // The host holds its answer, so a renewal is in flight and the next caller + // becomes a waiter. + host.before = func() { + select { + case <-release: + case <-time.After(5 * time.Second): + } + } + // ENTRY IS AT expiry-1s: at this instant the held credential is servable, + // which is what makes this timeline different from the old test's. + clock.set(expiry.Add(-time.Second)) + + renewing := make(chan struct{}) + go func() { + defer close(renewing) + _, _ = client.Credential(context.Background()) + }() + require.Eventually(t, func() bool { return host.requests.Load() == 2 }, + 5*time.Second, time.Millisecond) + + cancelled, cancel := context.WithCancel(context.Background()) + waiterDone := make(chan struct{}) + var served Credential + var waiterErr error + before := clock.reads.Load() + go func() { + defer close(waiterDone) + served, waiterErr = client.Credential(cancelled) + }() + // The waiter has read the clock, so it has taken its snapshot of a + // credential that is still valid and is now waiting on the mint in flight. + require.Eventually(t, func() bool { return clock.reads.Load() > before }, + 5*time.Second, 100*time.Microsecond) + + // NOW the credential expires, and only then does the waiter give up. + clock.set(expiry.Add(time.Second)) + cancel() + <-waiterDone + + require.Error(t, waiterErr, "there is nothing servable: the held credential has expired") + require.ErrorIs(t, waiterErr, ErrMintUnavailable) + require.Empty(t, served.Token(), + "a cancelled waiter was handed a credential that expired at %s while it waited", expiry) + + letGo() + <-renewing +} + +// Callers whose deadlines are shorter than a degraded host's latency must not +// each produce a mint request. +// +// This is the cost of the previous round's fix for the cancelled leader: not +// counting a cancellation as a failure meant no hold-off, so every call became +// the new leader and sent its own request — roughly one per caller deadline, +// each of which the host may complete and AUDIT while this process discards it. +// Nothing in the suite bounded that; TestACancelledLeaderDoesNotHoldOffEveryoneElse +// cancels exactly once. +func TestRepeatedShortDeadlinesDoNotMintOncePerCaller(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + release := make(chan struct{}) + var releaseOnce sync.Once + t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) }) + // A DEGRADED host: it has the request and has not answered it. + host.before = func() { + select { + case <-release: + case <-time.After(10 * time.Second): + } + } + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now, + func(options *MintOptions) { options.RequestTimeout = 5 * time.Second }) + + // Twenty callers whose deadlines are far shorter than the host's latency. + // Each one SENDS — the deadline is what expires, not the context before + // the request leaves — and then gives up. + for range 20 { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + _, err := client.Credential(ctx) + cancel() + require.Error(t, err) + } + require.LessOrEqual(t, host.requests.Load(), uint64(1), + "twenty callers that gave up produced %d mint requests; the host completes and AUDITS each one", + host.requests.Load()) + + releaseOnce.Do(func() { close(release) }) + // And the request nobody is waiting for any more still completes and still + // installs its result: that is what detaching it buys. + require.Eventually(t, func() bool { return client.Counts().Mints == 1 }, + 10*time.Second, time.Millisecond, + "the detached request's result was discarded with its caller") +} + +// A refusal that arrives after the caller that started the request gave up is +// still LATCHED. +// +// Before the request was detached, a 403 racing a cancelled leader was +// discarded — so the next caller presented the projected service-account token +// again, to a host that had already refused it. +func TestARefusalArrivingAfterItsCallerGaveUpIsStillLatched(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + host.refuseWith = http.StatusForbidden + release := make(chan struct{}) + host.before = func() { + select { + case <-release: + case <-time.After(5 * time.Second): + } + } + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := client.Credential(ctx) + require.Error(t, err) + close(release) + + require.Eventually(t, func() bool { return client.Refused() != nil }, + 5*time.Second, time.Millisecond, + "the host's refusal was discarded with the caller that asked for it") + require.ErrorIs(t, client.Refused(), ErrMintRefused) + + // And it is terminal: no later caller sends the projection again. + seen := host.requests.Load() + _, err = client.Credential(context.Background()) + require.ErrorIs(t, err, ErrMintRefused) + require.Equal(t, seen, host.requests.Load(), + "a latched refusal presented the projected token again") +} + +// The refresh limit is a RATE that decays, not a growing penalty. +// +// It used to be mintBackoff(Refreshes), computed from the lifetime count and +// never reset: after a few legitimate refreshes the bound was a minute, +// permanently. The old test froze the clock, so it proved one hold-off and +// said nothing about the steady state its name claimed. +func TestTheRefreshLimitIsASteadyRateAndItDecays(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + // Long enough that no renewal is ever due, so every mint here is a refresh. + host.authority.core.MaxTTL = 12 * time.Hour + host.lifetime = 8 * time.Hour + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + + // Ten minutes of a receiver refusing every credential it is given: a + // refresh attempt every ten seconds. + for range 60 { + clock.set(clock.at.Add(10 * time.Second)) + if replaced, refreshErr := client.Refresh(context.Background(), held); refreshErr == nil { + held = replaced + } + } + refreshes := client.Counts().Refreshes + // The burst, plus one per minute of the ten that elapsed. The bound is the + // RATE: without one this loop would have minted sixty times. + require.LessOrEqual(t, refreshes, uint64(maxRefreshBurst)+10) + require.GreaterOrEqual(t, refreshes, uint64(8), + "the limiter stopped refreshing altogether rather than rate-limiting") + + // And it FORGETS. A client that exhausted the bucket this morning + // refreshes at once tonight — which the old bound, growing with the + // lifetime count, never did. + exhausted := client.Counts().Refreshes + clock.set(clock.at.Add(24 * time.Hour)) + replaced, err := client.Refresh(context.Background(), held) + require.NoError(t, err, "a refresh a day later was still paying for this morning") + require.NotEqual(t, held.Token(), replaced.Token()) + require.Equal(t, exhausted+1, client.Counts().Refreshes) +} + +// A refresh about a generation this client has already replaced is answered +// from the credential in hand, even when that credential is inside its renewal +// lead — and it costs no mint and no refresh. +// +// It used to be refused with a held-off ErrMintUnavailable: servableLocked +// returned nil for every refresh, so the one credential that WAS the +// replacement being asked for was the one thing it would not hand over. +func TestARefreshForAnAlreadyReplacedGenerationIsAnsweredFromTheCurrentOne(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + first, err := client.Credential(t.Context()) + require.NoError(t, err) + second, err := client.Refresh(t.Context(), first) + require.NoError(t, err) + require.NotEqual(t, first.Token(), second.Token()) + + // Inside the renewal lead of the credential now held, which is what made + // the old code reach for the rate limiter. + clock.set(second.ExpiresAt().Add(-time.Second)) + requests := host.requests.Load() + counts := client.Counts() + + answered, err := client.Refresh(context.Background(), first) + require.NoError(t, err, "the replacement being asked for was the credential in hand") + require.Equal(t, second.Token(), answered.Token()) + require.Equal(t, requests, host.requests.Load(), "it should have minted nothing") + require.Equal(t, counts, client.Counts(), "and counted nothing") +} + +// A host that hands back the credential it was asked to replace has not +// replaced anything, and it is not counted as a refresh. +// +// An idempotent mint endpoint does exactly this. Nothing compared the +// replacement with what it replaced, so the refused credential came back with +// a nil error and a refresh on the counter. +func TestAMintThatReturnsTheSameCredentialIsNotAReplacement(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + first, err := client.Credential(t.Context()) + require.NoError(t, err) + + // The host becomes idempotent: the same token for every request. + host.replay = first.Token() + + _, err = client.Refresh(context.Background(), first) + require.Error(t, err) + require.ErrorIs(t, err, ErrMintUnavailable) + require.ErrorContains(t, err, "asked to replace") + require.Zero(t, client.Counts().Refreshes, + "a credential that did not change was counted as a refresh") +} + +// Every classification the mint client makes, with the three the previous +// round's test did not cover: a local fault, a TLS verification failure, and a +// 408. +func TestEveryMintFaultIsClassifiedByWhatRecoveryItNeeds(t *testing.T) { + t.Run("a local fault on the projected token is an outage", func(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + source := &faultySource{err: errSyntheticLocalFault} + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now, + func(options *MintOptions) { options.ProjectedToken = source }) + + _, err := client.Credential(context.Background()) + require.ErrorIs(t, err, ErrMintUnavailable) + require.NotErrorIs(t, err, ErrMintRefused) + require.NoError(t, client.Refused(), + "an exhausted file-descriptor table permanently stopped the process") + require.ErrorIs(t, err, errSyntheticLocalFault, + "the source's own error must stay in the chain") + }) + + t.Run("a TLS verification failure is an outage: nothing was sent", func(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + // A client pointed at the host with a root pool that does not contain + // the host's certificate. + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now, + func(options *MintOptions) { options.RootCAs = x509.NewCertPool() }) + + _, err := client.Credential(context.Background()) + // This LATCHED for one revision, on the argument that the projected + // token must not be presented to an endpoint that cannot prove who it + // is. The argument does not survive being stated precisely: the + // handshake fails before any request bytes leave, so nothing was sent + // and a retry sends nothing either. Latching bought no + // confidentiality and turned a certificate ROTATION — the most + // ordinary cause of this in a running system — into a permanent stop. + require.ErrorIs(t, err, ErrMintUnavailable) + require.NotErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "nothing was sent") + require.NoError(t, client.Refused(), "a certificate rotation is not a verdict about this process") + }) + + t.Run("408 is an outage", func(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + host.refuseWith = http.StatusRequestTimeout + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + _, err := client.Credential(context.Background()) + require.ErrorIs(t, err, ErrMintUnavailable) + require.NoError(t, client.Refused()) + }) + + t.Run("a plain transport failure is an outage", func(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + host.server.Close() + + _, err := client.Credential(context.Background()) + require.ErrorIs(t, err, ErrMintUnavailable) + require.NoError(t, client.Refused()) + }) +} + +// errSyntheticLocalFault stands for EMFILE or EIO: a momentary local failure +// that carries none of this package's sentinels. +var errSyntheticLocalFault = &localFault{} + +type localFault struct{} + +func (*localFault) Error() string { return "too many open files" } + +// faultySource is a caller's own ProjectedTokenSource failing. +type faultySource struct { + err error + token string +} + +func (s *faultySource) ProjectedToken() (string, error) { return s.token, s.err } + +// N1, the layer-4 review's new finding: a CANCELLED LEADER must be served the +// valid credential it is holding, exactly as a cancelled waiter is. +// +// The round-three fix wrote `serve := servableNow != nil && outage && +// !cancelled`, which made a leader whose own deadline expired the one caller +// that is never served — measured by the reviewer as 20 of 20 callers failing +// in the renewal window while a credential with two minutes left was in hand. +// The two paths disagreed about the same credential: a cancelled WAITER got it. +// +// Detaching the request removes the disagreement at the root, because there is +// no leader any more. Whoever takes the slot waits on the same channel and is +// answered by the same servableLocked as everybody else. +func TestACancelledLeaderIsServedTheCredentialItHolds(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + release := make(chan struct{}) + var releaseOnce sync.Once + t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) }) + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + held, err := client.Credential(t.Context()) + require.NoError(t, err) + + // The host stops answering, and the clock enters the renewal lead: the + // credential in hand is still valid for minutes. + host.before = func() { + select { + case <-release: + case <-time.After(10 * time.Second): + } + } + clock.set(testClock.Add(13 * time.Minute)) + require.True(t, held.ExpiresAt().After(clock.now()), "the held credential is still valid") + + // Twenty callers, each the leader in turn, each giving up on its own + // deadline. Every one of them must be served. + for attempt := range 20 { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + served, callErr := client.Credential(ctx) + cancel() + require.NoError(t, callErr, + "caller %d was refused while holding a credential valid until %s", + attempt, held.ExpiresAt()) + require.Equal(t, held.Token(), served.Token()) + } + require.NoError(t, client.Refused(), "a host that did not answer is not a refusal") +} + +// The final-URL assertion, tested directly. +// +// A reviewer measured that deleting the check changes no test: CheckRedirect +// refuses a redirect before a second request is made, and the client owns its +// transport, so nothing can follow one internally. Both of those are why it +// cannot fire — and testing the predicate is how the check stops being a branch +// whose only evidence is that nobody can reach it. +func TestTheFinalURLAssertionNamesAnAnswerFromElsewhere(t *testing.T) { + configured := "https://mint.example.test/credential" + for name, probe := range map[string]struct { + response *http.Response + elsewhere string + }{ + "the configured endpoint": { + response: &http.Response{Request: requestTo(t, configured)}, + elsewhere: "", + }, + "another host": { + response: &http.Response{Request: requestTo(t, "https://elsewhere.test/credential")}, + elsewhere: "https://elsewhere.test/credential", + }, + "another path on the same host": { + response: &http.Response{Request: requestTo(t, "https://mint.example.test/other")}, + elsewhere: "https://mint.example.test/other", + }, + "userinfo is redacted rather than logged": { + response: &http.Response{Request: requestTo(t, "https://user:secret@elsewhere.test/x")}, + elsewhere: "https://user:xxxxx@elsewhere.test/x", + }, + "no request recorded": {response: &http.Response{}, elsewhere: ""}, + "no response": {response: nil, elsewhere: ""}, + } { + t.Run(name, func(t *testing.T) { + require.Equal(t, probe.elsewhere, answeredFromElsewhere(probe.response, configured)) + }) + } +} + +func requestTo(t *testing.T, raw string) *http.Request { + t.Helper() + parsed, err := url.Parse(raw) + require.NoError(t, err) + return &http.Request{URL: parsed} +} + +// The window's first branch: a credential that expires at or before it becomes +// valid. It had no case at all, so the branch was asserted by nothing. +func TestAMintedCredentialThatExpiresBeforeItIsValidIsRefused(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + // A host whose window is inverted: minted an hour ago, for a negative + // lifetime, so expires_at lands before not_before. + mintedAt := testClock.Add(-time.Hour) + // core refuses a non-positive TTL, so the inversion is built by minting + // with a window that has already closed and then asserting on the ORDER + // rather than on the host's cooperation. + lifetime := time.Second + host.mintedAt, host.mintedFor = &mintedAt, &lifetime + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + _, err := client.Credential(t.Context()) + require.Error(t, err) + + // And the predicate itself, for the inverted window core will not mint: + // the client must refuse it rather than install a credential whose window + // is empty. + inverted := Credential{ + notBefore: testClock, + expiresAt: testClock.Add(-time.Minute), + } + windowErr := client.checkWindow(inverted) + require.ErrorIs(t, windowErr, ErrMintRefused) + require.ErrorContains(t, windowErr, "expires at or before it becomes valid") + + // Equal is also refused: a zero-length window is not a window. + equal := Credential{notBefore: testClock, expiresAt: testClock} + require.ErrorContains(t, client.checkWindow(equal), "expires at or before it becomes valid") +} + +// Core 67ee7220 moved the lifetime bound into decodeClaims, so Inspect carries +// it — and Inspect is the path THIS CLIENT reads its own window through. +// +// That is the condition this module named twice when it declined to drop +// MintOptions.MaxCredentialLifetime, and it is now met in the right place. Core +// had added the bound to Verify alone, in the same commit whose decodeClaims +// comment says a bound belongs in the one decode path rather than in one +// entrypoint; the cost was specific and worse than an unchecked path — a +// thirty-day capability that every Verify refuses was reported to its HOLDER, +// which never verifies a signature, as thirty days of validity. +// +// These two tests are here rather than taken on trust because the client's +// default ceiling IS core's constant: if core ever refused a lifetime of +// exactly MaxTTLCeiling, this client's default would refuse every credential a +// host minted at the maximum. +// RENAMED to what it asserts. It was called +// TestCoreRefusesAnOverCeilingLifetimeInTheClientsOwnReadPath and asserted no +// refusal at all: a sound fixture inspects, and two constants are equal. A +// review was right to call that out. +// +// The refusal it was named for CANNOT be driven from here, and the reason is +// the one-implementation rule rather than an omission: core's Authority refuses +// an over-ceiling lifetime at the MINT, so no over-ceiling capability can be +// produced, and assembling one by hand to feed the read path is precisely the +// second implementation both gates exist to refuse. Core asserts it on its own +// side, in decodeClaims, with a committed test — that is the right place, and it +// is the place this module asked for it to be. +// +// What is left is still worth asserting and is the part that can drift here: +// the client's default ceiling IS core's constant, so if core ever refused a +// lifetime of exactly MaxTTLCeiling this client's default would refuse every +// credential a host minted at the maximum. The test below that one establishes +// exactly-at-the-ceiling is accepted. +func TestTheClientsDefaultCeilingIsCoresConstant(t *testing.T) { + // The structural read path the mint client uses, driven directly, so the + // entry point this depends on is exercised rather than assumed. + _, err := corework.Inspect(fixture(t, "session").Token) + require.NoError(t, err, "a sound capability still inspects") + + require.Equal(t, corework.MaxTTLCeiling, defaultMaxCredentialLifetime, + "the client's default ceiling and core's absolute ceiling are the same bound; "+ + "two constants that can drift are two bounds") +} + +// Exactly at the ceiling is ACCEPTED, so the client's default is never refused +// by a second of slack. +func TestACredentialAtExactlyTheCeilingIsAccepted(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + host.authority.core.MaxTTL = corework.MaxTTLCeiling + atCeiling := corework.MaxTTLCeiling + host.mintedAt, host.mintedFor = &testClockStart, &atCeiling + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + credential, err := client.Credential(t.Context()) + require.NoError(t, err, + "a host minting at exactly the ceiling must not be refused by this client's default") + require.Equal(t, testClock.Add(corework.MaxTTLCeiling), credential.ExpiresAt()) +} + +// Core 4cb260d3 made the sealed execution OPTIONAL, because a principal that +// bears no approved build — a person at a terminal — must not have one +// invented for it. That turns the mint response's echo cross-check into a +// PRESENCE question, which it was not before. +// +// GetBuildIncarnation() answers 0 both for "absent" and for a value core says +// is never legitimate (an incarnation starts at 1), so comparing an echo +// against that zero accepted a host echoing "0" against a capability that +// seals no execution at all — a host asserting an execution the issuer does +// not hold. +func TestAnEchoedIncarnationIsRefusedWhenTheCapabilitySealsNoExecution(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + // A capability for a principal the issuer holds no approved build for, + // which is the human-session shape. + host.noApprovedBuild = true + // And a host that echoes an incarnation anyway. + host.echoIncarnation = "0" + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + _, err := client.Credential(context.Background()) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "seals no execution at all") +} + +// And a capability that DOES seal an execution still cross-checks by value, so +// the presence branch did not replace the comparison. +func TestAnEchoedIncarnationStillCrossChecksByValue(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + host.echoIncarnation = "99" + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + _, err := client.Credential(context.Background()) + require.ErrorIs(t, err, ErrMintRefused) + require.ErrorContains(t, err, "sealed 11") +} + +// Round five's mutation survivors, one test each. Every one of these passed +// with the behaviour its name claims removed. + +// The ceiling-above-core refusal (mint.go:481) had no test: a configured +// MaxCredentialLifetime above core's absolute ceiling is refused at +// construction rather than silently clamped, so a deployment that asked for a +// window nothing will mint finds out now instead of at the first mint. +func TestAConfiguredCeilingAboveCoresIsRefusedAtConstruction(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + + _, err := NewMintClient(MintOptions{ + URL: host.server.URL, + Authority: host.pin, + Audience: testAudienceName, + ProjectedToken: ProjectedTokenFile(projectedFile(t, "projected")), + ProjectionAudience: "projection-audience", + Now: clock.now, + RootCAs: certPoolOf(host.server), + MaxCredentialLifetime: corework.MaxTTLCeiling + time.Second, + }) + require.ErrorIs(t, err, ErrInvalid) + require.ErrorContains(t, err, "above core's absolute ceiling") + + // Exactly at it is accepted, which is the boundary that matters because + // the DEFAULT is that constant. + _, err = NewMintClient(MintOptions{ + URL: host.server.URL, + Authority: host.pin, + Audience: testAudienceName, + ProjectedToken: ProjectedTokenFile(projectedFile(t, "projected")), + ProjectionAudience: "projection-audience", + Now: clock.now, + RootCAs: certPoolOf(host.server), + MaxCredentialLifetime: corework.MaxTTLCeiling, + }) + require.NoError(t, err) +} + +// A LATCHED REFUSAL BEATS A CANCELLATION (mint.go:871). A caller that gave up +// while the host was refusing must be told it is refused, not told it was +// cancelled: the refusal is terminal and the cancellation is not, and a caller +// that reads only its own ctx error retries forever against a host that has +// already said no. +func TestALatchedRefusalIsReportedEvenToACallerThatGaveUp(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + host.refuseWith = http.StatusForbidden + release := make(chan struct{}) + host.before = func() { + select { + case <-release: + case <-time.After(5 * time.Second): + } + } + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := client.Credential(ctx) + require.Error(t, err) + close(release) + + require.Eventually(t, func() bool { return client.Refused() != nil }, + 5*time.Second, time.Millisecond) + + // The next caller cancels too, and still learns it is REFUSED rather than + // only that it cancelled. This one is answered at the top of the loop. + cancelled, stop := context.WithCancel(context.Background()) + stop() + _, err = client.Credential(cancelled) + require.ErrorIs(t, err, ErrMintRefused, + "a cancelled caller was told it was cancelled by a client that is permanently refused") + require.NotErrorIs(t, err, ErrMintUnavailable) + + // AND THE RACE, tested at the predicate because it cannot be forced + // through the public API. + // + // answerCancelled runs when a caller's ctx fires while it is WAITING on + // the in-flight mint. If the refusal latched during that wait, both select + // branches are ready at once and which one wins is the scheduler's + // business — so driving this through Credential would be a test that + // passes for whichever branch it happened to take. Calling the predicate + // is how the branch gets asserted instead of hoped for, the same way the + // final-URL assertion is. + ctxDone, done := context.WithCancel(context.Background()) + done() + _, raced := client.answerCancelled(ctxDone, mintReasonRenewal, nil) + require.ErrorIs(t, raced, ErrMintRefused, + "a refusal that landed while this caller waited was reported as a cancellation") +} + +// The EIO READ CLASS: a projected-token read that fails partway is an outage, +// distinct from a file that is absent. Both recover; neither latches. +// +// This one drives a caller's OWN ProjectedTokenSource, which is the branch at +// mintLocked — an unlabelled error from somebody else's reader. The branch +// inside ProjectedTokenFile, where io.ReadAll itself fails, is a different +// three lines and is covered by the test below it; a review was right that this +// test's name claimed both and reached one. +func TestAProjectedTokenReadFailureIsAnOutageAndRecovers(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + source := &faultySource{err: errSyntheticLocalFault} + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now, + func(options *MintOptions) { options.ProjectedToken = source }) + + _, err := client.Credential(context.Background()) + require.ErrorIs(t, err, ErrMintUnavailable) + require.NoError(t, client.Refused()) + + // The fault clears and the clock passes the hold-off: the client mints. + source.err = nil + source.token = "projected" + clock.set(clock.at.Add(time.Minute)) + credential, err := client.Credential(context.Background()) + require.NoError(t, err, "a local read failure permanently stopped the client") + require.NotEmpty(t, credential.Token()) +} + +// AND THE FILE SOURCE'S OWN READ FAILURE, at the branch the test above does not +// reach: ProjectedTokenFile opens the path successfully and io.ReadAll then +// fails. A directory is the portable way to produce exactly that — os.Open +// succeeds, the read returns EISDIR — and it is not a contrived shape: the +// projection's mount point with nothing mounted on it is a directory where a +// file is expected. +// +// What matters is the CLASS: ErrMintUnavailable, so it is held off and retried +// rather than latched. An EIO on the mount, a projection replaced between +// unlink and create, and an exhausted descriptor table all land here, and +// latching any of them stopped a process that was holding a good credential. +func TestTheFileSourcesOwnReadFailureIsAnOutage(t *testing.T) { + directory := t.TempDir() + + _, err := ProjectedTokenFile(directory).ProjectedToken() + + require.ErrorIs(t, err, ErrMintUnavailable, + "a read that fails after a successful open is an outage, not a refusal") + require.NotErrorIs(t, err, ErrMintRefused, "and it must never latch") + require.ErrorContains(t, err, "read projected token", + "the message must name the stage, so an operator knows the file was found and not read") +} diff --git a/workcontext/round6_reproducers_test.go b/workcontext/round6_reproducers_test.go new file mode 100644 index 0000000..092932a --- /dev/null +++ b/workcontext/round6_reproducers_test.go @@ -0,0 +1,726 @@ +package workcontext + +import ( + "context" + "errors" + "fmt" + "go/ast" + "net/http" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// Round six, layer 2: the findings a static reviewer executed the EXTRACTED +// logic of, each reproduced here against the real thing. +// +// The pattern this round repeats, named because it is now four rounds old: the +// defects are in the code written to fix the previous review. B1's fail-open +// lives inside the assertion added for round five's blocker; B3's pointer alias +// walks past the qualifier fix from round four; B4's embedded field walks past +// the field freeze from round five. + +// B3. A POINTER ALIAS DEFEATED THE ROOT MODULE'S DECISIVE RULE. +// +// The root module has exactly one codec rule — a Work Context message passes +// through a codec only where a row names it — and typeName returned a local +// identifier as written. So +// +// type carrier = *basev0.WorkContextV1 +// var c carrier = &basev0.WorkContextV1{} +// proto.Unmarshal(raw, c) +// +// resolved to "carrier": not a capability, not a codec interface, not +// unresolvable, and therefore permitted. Measured against this gate: green. +// +// The VALUE alias was caught, and only by inspectCoreAliases, whose check read +// spec.Type as a bare selector — so one `*` walked past the alias rule and the +// codec rule at the same time. Both are fixed: local names are chased to what +// they name, and the alias rule unwraps pointers and slices. +func TestAPointerAliasDoesNotRenameACapability(t *testing.T) { + for name, source := range map[string]string{ + "a pointer alias in the root module": `package codefly +import ( + "google.golang.org/protobuf/proto" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) +type carrier = *basev0.WorkContextV1 +func readit(raw []byte) (*basev0.WorkContextV1, error) { + var c carrier = &basev0.WorkContextV1{} + return c, proto.Unmarshal(raw, c) +}`, + "a chain of aliases": `package codefly +import ( + "google.golang.org/protobuf/proto" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) +type inner = basev0.WorkContextV1 +type carrier = *inner +func readit(raw []byte) (*inner, error) { + var c carrier = &inner{} + return c, proto.Unmarshal(raw, c) +}`, + "a slice alias": `package codefly +import ( + "google.golang.org/protobuf/proto" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) +type batch = []*basev0.WorkContextV1 +func readit(raw []byte, all batch) error { return proto.Unmarshal(raw, all[0]) }`, + // A DEFINED type cannot reach proto.Unmarshal — it inherits no methods + // — but a rule true for one spelling and silent for the other is a + // rule a reader has to test to know. + "a defined type over a capability": `package codefly +import basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +type carrier basev0.WorkContextV1 +var _ = carrier{}`, + } { + t.Run(name, func(t *testing.T) { + findings := inspectForSecondImplementation(parseSource(t, "second_parser.go", source)) + require.NotEmpty(t, findings, + "a capability under a local name is still a capability") + }) + } + + // And the alias the rule exists to permit stays permitted, so this is not + // green by refusing everything. + require.Empty(t, inspectForSecondImplementation(parseSource(t, coreAliasFile, `package workcontext +import corework "github.com/codefly-dev/core/workcontext" +type Verifier = corework.Verifier +type Claims = corework.Claims`))) +} + +// EACH OF THE TWO RULES, SEPARATELY, because driving them together is how two +// mutations survived. +// +// The fix for the pointer alias was two coupled edits — typeName chases a local +// name to what it names, and inspectCoreAliases unwraps a pointer — and a probe +// through inspectForSecondImplementation is satisfied by EITHER of them. Both +// mutations survived the test above, which is the same shape that survived in +// round four and for the same reason. So each rule is asserted at the rule. +func TestTypeNameChasesALocalNameToWhatItNames(t *testing.T) { + file := parseSource(t, "second_parser.go", `package codefly +import basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +type carrier = *basev0.WorkContextV1 +type chained = carrier +var direct *basev0.WorkContextV1 +var aliased carrier +var twice chained`) + + // THIS is what the root module's decisive rule reads, and the whole of + // B3: a local name must resolve to the capability, not to itself. + for _, name := range []string{"direct", "aliased", "twice"} { + resolved := resolveTypeName(file, ast.NewIdent(name), file.syntax.End()) + require.True(t, isCapabilityType(resolved), + "%s resolved to %q; the codec rule keys on this string, so a local name that "+ + "answers to itself is a capability the rule cannot see", name, resolved) + } + + // And a name that is not an alias still answers as itself, so the chase + // does not invent resolutions. + plain := parseSource(t, "second_parser.go", `package codefly +var count int`) + require.Equal(t, "int", resolveTypeName(plain, ast.NewIdent("count"), plain.syntax.End())) +} + +func TestTheCoreAliasRuleSeesThroughAPointer(t *testing.T) { + core := map[string]bool{"basev0": true} + for name, declaration := range map[string]string{ + "a value alias": "type carrier = basev0.WorkContextV1", + "a pointer alias": "type carrier = *basev0.WorkContextV1", + "a slice alias": "type batch = []*basev0.WorkContextV1", + "a defined type": "type carrier basev0.WorkContextV1", + } { + t.Run(name, func(t *testing.T) { + file := parseSource(t, "second_parser.go", `package codefly +import basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +`+declaration) + require.NotEmpty(t, inspectCoreAliases(file, core), + "a second local name for one of core's types belongs in %s", coreAliasFile) + }) + } + + // The one file whose job is aliasing core is still exempt. + exempt := parseSource(t, coreAliasFile, `package workcontext +import corework "github.com/codefly-dev/core/workcontext" +type Verifier = corework.Verifier`) + require.Empty(t, inspectCoreAliases(exempt, map[string]bool{"corework": true})) +} + +// B4. AN EMBEDDED FIELD WAS NOT A FIELD. +// +// assertFrozenStructShapes walked field.Names, and an embedded field has none — +// so an embedded struct added to mintRequest left the frozen set +// byte-identical. Measured: no finding anywhere in the gate, and +// encoding/json promotes an embedded struct's exported fields into the +// enclosing object, so it is a new field on the wire. That is the defect class +// the freeze was added for, one level sideways from where it was added. +func TestAnEmbeddedFieldIsAFieldOfAFrozenStruct(t *testing.T) { + mintFile := func(extra string) sourceFile { + return parseSource(t, "workcontext/mint.go", `package workcontext +import "encoding/json" +type smuggled struct { + Echo string +} +type mintRequest struct { + Audience string `+"`json:\"audience\"`"+` + ProjectionAudience string `+"`json:\"projection_audience\"`"+` +`+extra+` +} +type mintResponse struct { + WorkContext string `+"`json:\"work_context\"`"+` + InstallationID string `+"`json:\"installation_id\"`"+` + BuildIncarnation string `+"`json:\"build_incarnation\"`"+` +} +func encode(a string) ([]byte, error) { return json.Marshal(mintRequest{Audience: a}) }`) + } + + require.NotEmpty(t, frozenStructViolations([]sourceFile{mintFile("\tsmuggled")}), + "an embedded struct carries fields onto the wire and the freeze must see it") + require.NotEmpty(t, frozenStructViolations([]sourceFile{mintFile("\t*smuggled")}), + "and so does an embedded pointer") + + // The shape as it actually is passes, so the assertion is not simply + // failing — and this is the mutation guard for the two above. + require.Empty(t, frozenStructViolations([]sourceFile{mintFile("")}), + "the frozen field sets must match the structs this repository really has") +} + +// ROUND SEVEN, BLOCKER: THE PUBLISHED-REF SWEEP WAS STILL A DENYLIST. +// +// The deny-by-default rule lived in this package, which reads a CHECKOUT. The +// ref sweep is the only thing that runs over a published ref — and it is the +// whole of the required check — and it had its own three regexes. A reviewer +// ran that predicate over the round-six second implementation: +// +// encoding/json/v2 permitted +// crypto/mldsa permitted +// protodelim permitted +// grpc/encoding permitted +// C permitted +// crypto/ed25519 rejected +// +// So the pair that encodes a WorkContextV1 in the deleted format and signs it +// could sit on any branch with the required check green. Two policies is one +// policy and one hole. +// +// There is one policy file now, scripts/allowed-imports.txt, read by this +// package and by the sweep. This test is what makes "one" true: it asserts the +// file describes the tree, and the sweep's own tests assert the sweep enforces +// the file. +func TestOneImportPolicyGovernsTheTreeAndEveryPublishedRef(t *testing.T) { + policy := loadImportPolicy(t) + + // THE SHAPES THE OLD DENYLIST PERMITTED. Each is absent from both module + // lists, so each is a finding wherever it appears — checkout or ref. + for _, spec := range []string{ + "encoding/json/v2", "crypto/mldsa", + "google.golang.org/protobuf/encoding/protodelim", + "google.golang.org/grpc/encoding", + "google.golang.org/protobuf/encoding/prototext", + "encoding/json/jsontext", "crypto/hpke", + } { + for _, module := range []string{"root", "leaf"} { + require.NotContains(t, policy[module], spec, + "%q is on the %s allowlist, so the sweep permits it on every published ref", + spec, module) + } + } + + // And `C` is never on a list, because it is not a path. The sweep refuses + // it by name; adding it here would be a line somebody could write. + for _, module := range []string{"root", "leaf"} { + require.NotContains(t, policy[module], "C") + } +} + +// B5. RequestTimeout DID NOT BOUND THE DETACHED ATTEMPT. +// +// ProjectedTokenSource is a caller's interface and takes no context, so the +// read in front of the HTTP request was bounded by nothing. Measured with a +// source that blocks and RequestTimeout at 150ms: Credential returned after +// 3.0s, bounded by the CALLER's deadline — and with no caller deadline it does +// not return at all, because the detached goroutine never closes `done`, so +// every later caller waits on an attempt that will not finish and no hold-off +// is ever installed. mintInto's own comment said RequestTimeout was "what makes +// the request terminate". +func TestRequestTimeoutBoundsTheTokenSourceRead(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + source := &blockingSource{released: make(chan struct{})} + t.Cleanup(func() { close(source.released) }) + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now, + func(options *MintOptions) { + options.ProjectedToken = source + options.RequestTimeout = 150 * time.Millisecond + }) + + // A caller whose own deadline is twenty times RequestTimeout, so what + // bounds the attempt is observable rather than inferred. + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + started := time.Now() + _, err := client.Credential(ctx) + elapsed := time.Since(started) + + require.ErrorIs(t, err, ErrMintUnavailable, + "a slow projection is a mount under load, which clears: held off, never latched") + require.NoError(t, client.Refused(), "and it must not latch") + require.ErrorContains(t, err, "reading the projected token did not finish") + require.Less(t, elapsed, time.Second, + "RequestTimeout is 150ms; an attempt that runs %s is bounded by the caller instead", elapsed) +} + +// blockingSource is a token source that does not return — a hung mount, a +// source holding a lock, an implementation with a bug in it. +type blockingSource struct{ released chan struct{} } + +func (s *blockingSource) ProjectedToken() (string, error) { + <-s.released + return "projected", nil +} + +// B6. ENDPOINT TRUST WAS OPTIONAL, AND SILENTLY THE SYSTEM POOL. +// +// Every other route to a weak transport here was closed — no caller-supplied +// client, no reachable Transport, a TLS 1.3 floor, no followed redirect — and +// the trust anchor defaulted to whatever the image shipped. A mis-issued +// certificate for the host name, or a corporate interception root, receives the +// projected service-account token. It is a deployment decision, so it is stated +// or construction refuses; TrustSystemRoots is how a deployment that means the +// host's pool says so. +func TestTheMintEndpointsTrustAnchorIsRequired(t *testing.T) { + base := MintOptions{ + URL: "https://mint.example/platform/_mint", + Authority: newTestPin(), + Audience: testAudienceName, + ProjectedToken: ProjectedTokenFile("/var/run/secrets/token"), + ProjectionAudience: "projection-audience", + } + + _, err := NewMintClient(base) + require.ErrorIs(t, err, ErrInvalid) + require.ErrorContains(t, err, "no trust anchor") + require.ErrorContains(t, err, "https://mint.example/platform/_mint", + "the refusal must name the endpoint the projection would have been sent to") + require.ErrorContains(t, err, "x509.SystemCertPool", + "and must name the two lines a deployment that really means the host's pool writes, "+ + "because a refusal with no way forward gets worked around") + + named := base + named.RootCAs = certPoolOf() + _, err = NewMintClient(named) + require.NoError(t, err, "naming the roots is the whole of the requirement") +} + +// F-5 and F-6. EVERY 200 THAT IS NOT A CAPABILITY LATCHED FOREVER, and so did +// every redirect. Executed over httptest-TLS: `{}`, `null`, +// `{"work_context":"not-a-capability"}`, a 307 to another host and a 308 to the +// same host with a trailing slash each became ErrMintRefused and stayed refused +// through the host recovering and the clock passing every hold-off. +// +// They are the same class as the text/html 200 and the oversized 200 beside +// them, both already retryable: something that is not the host is answering. +// ErrMintRefused is documented as "the host will give the same answer again", +// and a mesh mid-rollout is the shape least likely to. +// +// This also covers the classifications F-6 named as untested, because the table +// drives each one and then drives RECOVERY, which is the half that makes +// "retryable" mean anything. +func TestNoAnswerFromSomethingThatIsNotTheHostLatches(t *testing.T) { + for name, answer := range map[string]struct { + status int + headers map[string]string + body string + }{ + "an empty JSON object": {status: http.StatusOK, body: `{}`}, + "a JSON null": {status: http.StatusOK, body: `null`}, + "a work_context that is not a capability": {status: http.StatusOK, body: `{"work_context":"not-a-capability"}`}, + "an empty work_context": {status: http.StatusOK, body: `{"work_context":""}`}, + // The two F-6 named as untested, beside the new ones so one table + // drives the whole 200 path. + "a 200 carrying text/html": { + status: http.StatusOK, + headers: map[string]string{"Content-Type": "text/html; charset=utf-8"}, + body: "gateway", + }, + "a 200 that does not decode": {status: http.StatusOK, body: `{"work_context":`}, + "a 200 with no Content-Type at all": { + status: http.StatusOK, + headers: map[string]string{"Content-Type": ""}, + body: `{"work_context":"x"}`, + }, + } { + t.Run(name, func(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + host.answer = &cannedAnswer{status: answer.status, headers: answer.headers, body: answer.body} + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + _, err := client.Credential(context.Background()) + + require.ErrorIs(t, err, ErrMintUnavailable, + "something that is not the host answered; that is not a verdict") + require.NotErrorIs(t, err, ErrMintRefused) + require.NoError(t, client.Refused(), + "this latched and stayed refused forever through the host recovering") + + // AND IT RECOVERS, which is the half that makes "retryable" a + // claim rather than a label. + host.answer = nil + clock.set(clock.at.Add(time.Minute)) + credential, err := client.Credential(context.Background()) + require.NoError(t, err, "the host recovered and the client did not") + require.NotEmpty(t, credential.Token()) + }) + } +} + +// F-6. THE ONE LATCH RULE, asserted. Reverting it to its negation — "anything +// that is not an outage latches" — survived every test in this suite. +// +// The rule is one sentence: only ErrMintRefused is terminal. Its negation is +// also one sentence and is the previous revision, under which every error this +// client had not thought about became a permanent stop. So it is driven over +// the error CLASSES rather than over one of them. +func TestOnlyAHostRefusalIsTerminal(t *testing.T) { + for name, source := range map[string]struct { + err error + latches bool + }{ + // ErrInvalid says in its own comment that it is not latched, and the + // negation latched it. + "a misconfiguration from the token source": { + err: fmt.Errorf("%w: no projected token path", ErrInvalid), + }, + // An error from a caller's own reader carrying NO sentinel. Under the + // negation this latched, so errors.Is(client.Refused(), ErrMintRefused) + // was false while the client was permanently refused — the two ways of + // asking disagreed. + "an unlabelled error from a caller's source": { + err: errors.New("the caller's own reader failed"), + }, + "an outage from the token source": { + err: fmt.Errorf("%w: read projected token: EIO", ErrMintUnavailable), + }, + // And the one that does latch, so this is not green by never latching. + "a refusal from the token source": { + err: fmt.Errorf("%w: the host said no", ErrMintRefused), latches: true, + }, + } { + t.Run(name, func(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + faulty := &faultySource{err: source.err} + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now, + func(options *MintOptions) { options.ProjectedToken = faulty }) + + _, err := client.Credential(context.Background()) + require.Error(t, err) + + if source.latches { + require.ErrorIs(t, client.Refused(), ErrMintRefused, + "a host refusal is terminal and Refused() must say so") + return + } + require.NoError(t, client.Refused(), + "only ErrMintRefused is terminal; this stopped the process for good") + + // THE RECOVERY, which is what "not terminal" means. + faulty.err = nil + faulty.token = "projected" + clock.set(clock.at.Add(time.Minute)) + credential, err := client.Credential(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, credential.Token()) + }) + } +} + +// F-6. THE DETACHED REQUEST CARRIES THE CALLER'S VALUES. Reverting +// context.WithoutCancel(caller) to context.Background() survived, because +// nothing read anything off the request's context. +// +// What that costs is invisible and real: a trace span, a request id, whatever +// an http.RoundTripper in the caller's stack reads. Background() dropped all of +// it silently while the comment said the detaching was about cancellation. +func TestTheDetachedMintRequestKeepsTheCallersValues(t *testing.T) { + type key struct{} + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + + carried := make(chan any, 1) + client.httpClient.Transport = &valueReadingTransport{ + inner: client.httpClient.Transport, + key: key{}, + seen: carried, + } + + _, err := client.Credential(context.WithValue(context.Background(), key{}, "trace-42")) + require.NoError(t, err) + + select { + case value := <-carried: + require.Equal(t, "trace-42", value, + "the detached request dropped the caller's context values; detaching from "+ + "CANCELLATION is the point, not from everything") + default: + t.Fatal("the transport was never reached, so this asserted nothing") + } +} + +// valueReadingTransport reports the caller's context value as the request saw +// it. +type valueReadingTransport struct { + inner http.RoundTripper + key any + seen chan any +} + +func (t *valueReadingTransport) RoundTrip(request *http.Request) (*http.Response, error) { + select { + case t.seen <- request.Context().Value(t.key): + default: + } + return t.inner.RoundTrip(request) //nolint:wrapcheck // a transport returns the inner error +} + +// THE LATCH SITES, AUDITED, with the one candidate examined and REJECTED. +// +// Reading the one-latch rule against mintOnce, Authority.Recheck looked like +// the projected-token defect one layer up: any failure to re-resolve a pinned +// value latches, so a momentary configuration read failure would permanently +// stop a healthy process. I changed it, and the root module's +// TestAuthorityValueWithdrawnIsAChange rejected the change — correctly. +// +// A withdrawn value and an unreadable one arrive identically (withdrawing an +// injected variable makes the live read FAIL, it does not return empty), and +// the asymmetry runs the other way here: a held credential keeps being served +// through an outage, so a withdrawn audience classed as an outage means serving +// under authority somebody removed, until expiry. +// +// What this asserts is the classification as it stands, so the next audit finds +// the argument instead of repeating the attempt. +func TestThePinnedAuthorityLatchesAndTheRestDoNot(t *testing.T) { + for name, probe := range map[string]struct { + install func(*mintHost, *MintClient) + latches bool + }{ + // The pin: terminal, deliberately, and argued above. + "the pinned authority did not re-check": { + install: func(host *mintHost, _ *MintClient) { + host.pin.set(testAudience, errors.New("audience changed under a running process")) + }, + latches: true, + }, + // Everything a host or a middlebox does that is not 401/403: an outage. + "a 200 with no capability in it": { + install: func(host *mintHost, _ *MintClient) { + host.answer = &cannedAnswer{status: http.StatusOK, body: `{}`} + }, + }, + "a 503": { + install: func(host *mintHost, _ *MintClient) { host.refuseWith = http.StatusServiceUnavailable }, + }, + // And the two enumerated refusals, so this is not green by never + // latching. + "a 401": { + install: func(host *mintHost, _ *MintClient) { host.refuseWith = http.StatusUnauthorized }, + latches: true, + }, + "a 403": { + install: func(host *mintHost, _ *MintClient) { host.refuseWith = http.StatusForbidden }, + latches: true, + }, + } { + t.Run(name, func(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now) + probe.install(host, client) + + _, err := client.Credential(context.Background()) + require.Error(t, err) + + if probe.latches { + require.ErrorIs(t, client.Refused(), ErrMintRefused, + "this is an enumerated refusal and Refused() must say so") + return + } + require.NoError(t, client.Refused(), "only an enumerated refusal is terminal") + + host.answer = nil + host.refuseWith = 0 + clock.set(clock.at.Add(time.Minute)) + credential, err := client.Credential(context.Background()) + require.NoError(t, err, "the host recovered and the client did not") + require.NotEmpty(t, credential.Token()) + }) + } +} + +// CODEX 6: the timeout fix left an UNBOUNDED population of abandoned readers. +// +// ProjectedTokenSource takes no context, so a read cannot be cancelled and a +// blocked source leaves its goroutine blocked. One goroutine per ATTEMPT is not +// a bound on outstanding goroutines: against a source that never returns they +// accumulate forever, at the maximum-backoff rate, and a source that +// single-flight had been protecting gets concurrent calls. The committed test +// proved one caller returns promptly and said nothing about the second. +func TestABlockedTokenSourceIsReadOnceNoMatterHowManyAttempts(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + source := &countingBlockedSource{released: make(chan struct{})} + t.Cleanup(func() { close(source.released) }) + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now, + func(options *MintOptions) { + options.ProjectedToken = source + options.RequestTimeout = 60 * time.Millisecond + }) + + // SIX attempts, each timing out, with the clock moved past every hold-off + // so each one really starts. + for attempt := range 6 { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + _, err := client.Credential(ctx) + cancel() + require.ErrorIs(t, err, ErrMintUnavailable, "attempt %d", attempt) + clock.set(clock.at.Add(10 * time.Minute)) + } + + require.EqualValues(t, 1, source.calls.Load(), + "six attempts against a blocked source started %d reads; a source that cannot be "+ + "cancelled must be read once and waited on, or the readers accumulate without "+ + "limit and a source single-flight was protecting gets concurrent calls", + source.calls.Load()) + require.NoError(t, client.Refused(), "and none of it latches") +} + +// countingBlockedSource blocks and counts how many times it was entered. +type countingBlockedSource struct { + calls atomic.Int64 + released chan struct{} +} + +func (s *countingBlockedSource) ProjectedToken() (string, error) { + s.calls.Add(1) + <-s.released + return "projected", nil +} + +// ROUND EIGHT: THE BOUND I ADDED COULD TRANSFER AN EXPIRED PROJECTION, and +// then latch the client for good. +// +// Bounding the reads to one outstanding left the value from a TIMED-OUT read in +// a buffered channel, and the next attempt took it. The projection is rotated +// under the running process — that is why ProjectedToken is called again before +// every renewal instead of being cached — so a token read minutes earlier is +// expired, the host answers 401, and 401 latches. A fix for a goroutine leak +// that permanently stops the process is worse than the leak. +// +// Two properties, because the fix has two halves. +func TestATimedOutReadNeverHandsItsTokenToALaterAttempt(t *testing.T) { + // (a) A read still in flight and OLDER than one attempt's budget is + // refused, and no second reader is started. The bound holds and the answer + // that would have been stale is never waited for. + t.Run("an attempt refuses an old in-flight read", func(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + source := &countingBlockedSource{released: make(chan struct{})} + t.Cleanup(func() { close(source.released) }) + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now, + func(options *MintOptions) { + options.ProjectedToken = source + options.RequestTimeout = 50 * time.Millisecond + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + _, err := client.Credential(ctx) + cancel() + require.ErrorIs(t, err, ErrMintUnavailable) + + // Past the hold-off and far past the read's freshness budget. + clock.set(clock.at.Add(10 * time.Minute)) + later, cancelLater := context.WithTimeout(context.Background(), 2*time.Second) + _, err = client.Credential(later) + cancelLater() + + require.ErrorIs(t, err, ErrMintUnavailable) + require.ErrorContains(t, err, "would be a token read before the last rotation") + require.NoError(t, client.Refused(), "and none of this latches") + require.EqualValues(t, 1, source.calls.Load(), + "no second reader: the bound from the previous round still holds") + require.Zero(t, host.requests.Load(), "nothing stale reached the host") + }) + + // (b) Once the read finishes, a later attempt gets a FRESH one. This is the + // transfer itself: with the value parked in a buffered channel, the later + // attempt took the token read before the rotation. + t.Run("a later attempt reads the projection again", func(t *testing.T) { + clock := &movableClock{at: testClock} + host := newMintHost(t, clock.now) + source := &rotatingSource{released: make(chan struct{})} + client := newTestMintClient(t, host, projectedFile(t, "projected"), clock.now, + func(options *MintOptions) { + options.ProjectedToken = source + options.RequestTimeout = 50 * time.Millisecond + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + _, err := client.Credential(ctx) + cancel() + require.ErrorIs(t, err, ErrMintUnavailable, "the first attempt times out") + + // The read completes with the token as it was BEFORE the rotation. + close(source.released) + clock.set(clock.at.Add(10 * time.Minute)) + + // WAITED FOR, not assumed. The read is detached, so its own + // bookkeeping — storing the answer and freeing the slot — happens after + // the attempt that started it has already returned. Asserting straight + // away raced it and got the staleness refusal, which is the right + // answer to the wrong question. + // The clock moves with each try, because every failed attempt extends + // the hold-off and a frozen clock never gets past it. + require.Eventually(t, func() bool { + clock.set(clock.at.Add(time.Minute)) + attempt, cancelAttempt := context.WithTimeout(context.Background(), 2*time.Second) + defer cancelAttempt() + _, attemptErr := client.Credential(attempt) + return attemptErr == nil + }, 3*time.Second, 10*time.Millisecond, + "the client never minted after the blocked read finished") + + // THE PROPERTY IS THAT rotation-1 NEVER REACHES THE HOST — the exact + // later token is not the point and asserting it made this fail on + // rotation-3, because each try in the loop above takes a read. + presented, _ := host.presented.Load().(string) + require.NotContains(t, presented, "rotation-1", + "the host was shown %q: the token from the TIMED-OUT read was transferred to a "+ + "later attempt, and a projection read before the last rotation earns a 401 "+ + "that latches", presented) + require.Contains(t, presented, "rotation-", + "the host was shown %q, which is not a token from this source at all", presented) + require.Greater(t, source.calls.Load(), int64(1), + "the projection is read AGAIN rather than remembered from the attempt that gave up") + require.NoError(t, client.Refused(), "and nothing in this latched") + }) +} + +// rotatingSource blocks on its first read and answers with a different token +// each time, which is what a projection rotated under a running process does. +type rotatingSource struct { + calls atomic.Int64 + released chan struct{} +} + +func (s *rotatingSource) ProjectedToken() (string, error) { + call := s.calls.Add(1) + if call == 1 { + <-s.released + } + return fmt.Sprintf("rotation-%d", call), nil +} diff --git a/workcontext/stream.go b/workcontext/stream.go new file mode 100644 index 0000000..5feacfc --- /dev/null +++ b/workcontext/stream.go @@ -0,0 +1,136 @@ +package workcontext + +import ( + "context" + "errors" + "fmt" + "sync" +) + +// ErrStreamTerminated marks a stream that a re-check refused. It is returned to +// every later call on the same guard, including after the refusal that caused +// it: a stream is terminated once, and a caller that loops past the first +// refusal must not be handed a second chance to emit. +var ErrStreamTerminated = errors.New("Codefly Work Context stream terminated") + +// StreamGuardOptions configures one stream's hold on its credential. +type StreamGuardOptions struct { + // Recheck re-reads the issuer's live state for a capability that is ALREADY + // verified, and returns the host's answer. + // + // Use core's (*Verifier).Recheck. Do NOT use Verify: it consumes a + // single-use nonce, and every grant capability is single-use, so a stream + // opened with one died with ErrReplayed at its first message. This + // module's own README recommended exactly that for a while. Re-checking + // liveness and spending a nonce are two operations and only one of them + // belongs in a loop; core added Recheck for this, and it takes a *Verified + // so it cannot be anybody's first check. + // + // Any error terminates the stream, and that includes an error that is + // neither ErrRevoked nor ErrInvalid. A live source that cannot be reached + // has not said the credential is good; treating "I could not ask" as a + // pass is how a stream outlives the authority it was opened under while + // every log line stays clean. + Recheck func(context.Context) error +} + +// RecheckWith builds the per-emission check from a verifier and a capability +// that has already been verified, so the right call is the easy one. +// +// It exists because the wrong call compiles and reads fine: Verify is the name +// everybody knows, and a guard built on it works in every test that does not +// use a single-use capability and then kills the first grant-opened stream in +// production. +func RecheckWith(verifier *Verifier, verified *Verified) func(context.Context) error { + return func(ctx context.Context) error { + if verifier == nil || verified == nil { + return fmt.Errorf("%w: a stream re-check needs a verifier and a verified capability", ErrInvalid) + } + return verifier.Recheck(ctx, verified) + } +} + +// StreamGuard holds a stream to its credential for the stream's whole life. +// +// A stream authorized when it opened is not authorized forever. The failure it +// exists to prevent is the quiet one: a long-running stream that was opened +// under an installation revision the host has since replaced, continuing to +// drain a snapshot computed under authority that no longer exists. Nothing in +// the stream fails, no error is logged, and the data keeps arriving. +// +// It guards EVERY emission. There was a re-check cadence here, bounded at +// fifteen minutes, and it was a weakening of the rule rather than an +// implementation of it: revoke the installation one second after a successful +// check and every message for the next fifteen minutes still left, with expiry +// and source unavailability equally invisible for that interval. The condition +// is per emission, so the check is per emission. +// +// What that costs is one live authorization check per message, which is the +// price of the guarantee and is why the guard is for streams whose messages +// carry authority rather than for every stream. A stream that cannot pay it +// does not get the guarantee; it does not get a cadence instead. +// +// An idle stream discloses nothing, so there is nothing to refuse: the guard +// does no work until something is about to be sent. +type StreamGuard struct { + mu sync.Mutex + recheck func(context.Context) error + terminated error +} + +// NewStreamGuard validates its configuration and performs no I/O. The first +// BeforeSend re-checks, so a stream never emits its first message on the +// strength of the check that opened it. +func NewStreamGuard(options StreamGuardOptions) (*StreamGuard, error) { + if options.Recheck == nil { + return nil, fmt.Errorf("%w: a stream guard needs a re-check", ErrInvalid) + } + return &StreamGuard{recheck: options.Recheck}, nil +} + +// BeforeSend is called before every message a stream emits, and re-presents the +// credential every time. A non-nil error terminates the stream: the message +// must not be sent, and nothing after it may be either. +// +// A refusal is returned wrapped, so a caller can tell a credential the state +// moved under (ErrRevoked — a fresh credential would be accepted) from one that +// never becomes valid (ErrInvalid) from a live source that could not be reached +// at all. None of the three resumes this stream. The distinction is for the +// caller's decision about whether to open another, which is a decision about +// one new stream and not a licence to keep this one alive. +// +// It holds the guard's lock across the check, so a stream emitting from several +// goroutines cannot have one of them send on the strength of a check another +// goroutine is still waiting on. +func (g *StreamGuard) BeforeSend(ctx context.Context) error { + if g == nil { + return fmt.Errorf("%w: nil stream guard", ErrInvalid) + } + if ctx == nil { + return fmt.Errorf("%w: nil context", ErrInvalid) + } + g.mu.Lock() + defer g.mu.Unlock() + if g.terminated != nil { + return g.terminated + } + if err := g.recheck(ctx); err != nil { + // Both sentinels stay in the chain: the caller needs to know the stream + // is over AND whether a fresh credential would be accepted, and + // flattening the refusal to text would leave it guessing. + g.terminated = fmt.Errorf("%w: %w", ErrStreamTerminated, err) + return g.terminated + } + return nil +} + +// Terminated reports the refusal that ended the stream, or nil while it is +// still authorized. +func (g *StreamGuard) Terminated() error { + if g == nil { + return nil + } + g.mu.Lock() + defer g.mu.Unlock() + return g.terminated +} diff --git a/workcontext/stream_test.go b/workcontext/stream_test.go new file mode 100644 index 0000000..d9927e4 --- /dev/null +++ b/workcontext/stream_test.go @@ -0,0 +1,231 @@ +package workcontext + +import ( + "context" + "errors" + "testing" + "time" + + corework "github.com/codefly-dev/core/workcontext" + "github.com/codefly-dev/core/workcontext/conformance" + "github.com/stretchr/testify/require" +) + +// The failure a stream guard exists to prevent is the quiet one: a stream +// opened under an installation revision the host has since replaced, still +// draining a snapshot computed under authority that no longer exists. Nothing +// in the stream fails, nothing is logged, and the data keeps arriving. +// +// The check is per emission, so the revocation lands IMMEDIATELY after a +// successful check and the very next message is refused. Under the cadence this +// replaced — bounded at fifteen minutes — that message and every message for +// the next fifteen minutes still left. +func TestStreamTerminatesOnTheFirstMessageAfterTheCredentialIsSuperseded(t *testing.T) { + a := newAuthority(t) + token := a.start(t, mintInput{}) + verifier := a.verifier(t) + + rechecks := 0 + guard, err := NewStreamGuard(StreamGuardOptions{ + Recheck: func(ctx context.Context) error { + rechecks++ + _, verifyErr := verifier.Verify(ctx, token) + return verifyErr + }, + }) + require.NoError(t, err) + + // Every message is re-checked, including the first: a stream never emits on + // the strength of the check that opened it. + for message := 1; message <= 4; message++ { + require.NoError(t, guard.BeforeSend(t.Context())) + require.Equal(t, message, rechecks, + "message %d must have re-presented the credential", message) + } + + // The host moves the installation on, with no time passing at all. The very + // next message is refused, and the refusal says the state moved under the + // credential (ErrRevoked) rather than that the credential is malformed, so + // the caller knows a fresh credential would be accepted. + moved := testSeal + moved.InstallationRevision++ + require.NoError(t, a.seals.Put(testPrincipal, moved)) + + err = guard.BeforeSend(t.Context()) + require.ErrorIs(t, err, ErrStreamTerminated) + require.ErrorIs(t, err, ErrRevoked) + require.Equal(t, 5, rechecks, "the refusal came from a check, not from a cached answer") + require.ErrorIs(t, guard.Terminated(), ErrStreamTerminated) +} + +// Expiry is visible for the same reason revocation is: the check is per +// emission, so a credential that runs out mid-stream stops the stream at the +// next message. The verifier's own clock moves — the earlier version of this +// test advanced a clock the guard held and left the verifier's fixed, so it +// provided no expiry evidence at all. +func TestStreamTerminatesWhenTheCredentialExpiresMidStream(t *testing.T) { + a := newAuthority(t) + token := a.start(t, mintInput{}) // a fifteen-minute credential + + verifying := testClock + verifier := a.verifier(t) + verifier.Now = func() time.Time { return verifying } + + guard, err := NewStreamGuard(StreamGuardOptions{ + Recheck: func(ctx context.Context) error { + _, verifyErr := verifier.Verify(ctx, token) + return verifyErr + }, + }) + require.NoError(t, err) + require.NoError(t, guard.BeforeSend(t.Context())) + + verifying = testClock.Add(16 * time.Minute) + err = guard.BeforeSend(t.Context()) + require.ErrorIs(t, err, ErrStreamTerminated) + require.ErrorIs(t, err, ErrInvalid, "an expired credential is invalid, not revoked") +} + +// A live source that could not be reached has not said the credential is good. +// Treating "I could not ask" as a pass is how a stream outlives the authority +// it was opened under while every log line stays clean, so an unavailable +// source terminates the stream exactly as a refusal does. +func TestStreamTerminatesWhenTheLiveSourceIsUnavailable(t *testing.T) { + unavailable := errors.New("installation state store unreachable") + answers := []error{nil, unavailable} + guard, err := NewStreamGuard(StreamGuardOptions{ + Recheck: func(context.Context) error { + answer := answers[0] + if len(answers) > 1 { + answers = answers[1:] + } + return answer + }, + }) + require.NoError(t, err) + + require.NoError(t, guard.BeforeSend(t.Context())) + err = guard.BeforeSend(t.Context()) + require.ErrorIs(t, err, ErrStreamTerminated) + require.ErrorIs(t, err, unavailable, + "the reason stays in the chain: the caller needs to tell an outage from a revocation") + require.NotErrorIs(t, err, ErrRevoked) +} + +// A terminated stream stays terminated. A caller that loops past the first +// refusal must not be handed a second chance to emit, and the authority coming +// back must not resurrect a stream that has already been refused: the messages +// it would send were computed under authority that was withdrawn. +func TestATerminatedStreamIsNotResumed(t *testing.T) { + refuse := true + rechecks := 0 + guard, err := NewStreamGuard(StreamGuardOptions{ + Recheck: func(context.Context) error { + rechecks++ + if refuse { + return ErrRevoked + } + return nil + }, + }) + require.NoError(t, err) + + require.ErrorIs(t, guard.BeforeSend(t.Context()), ErrStreamTerminated) + require.Equal(t, 1, rechecks) + + refuse = false + for range 3 { + require.ErrorIs(t, guard.BeforeSend(t.Context()), ErrStreamTerminated, + "a stream is terminated once, and the authority coming back does not undo it") + } + require.Equal(t, 1, rechecks, "a terminated stream asks nothing further") +} + +func TestStreamGuardValidatesItsConfiguration(t *testing.T) { + _, err := NewStreamGuard(StreamGuardOptions{}) + require.ErrorIs(t, err, ErrInvalid) + require.ErrorContains(t, err, "needs a re-check") + + guard, err := NewStreamGuard(StreamGuardOptions{ + Recheck: func(context.Context) error { return nil }, + }) + require.NoError(t, err) + require.ErrorIs(t, guard.BeforeSend(nil), ErrInvalid) + + var absent *StreamGuard + require.ErrorIs(t, absent.BeforeSend(context.Background()), ErrInvalid) + require.NoError(t, absent.Terminated()) +} + +// The per-emission re-check must NOT consume a single-use capability. +// +// This is the bug the README shipped: the documented recipe called +// verifier.Verify on every emission, core's Verify consumes a single-use nonce, +// and every grant capability is single-use — so a grant-opened stream died with +// ErrReplayed at its FIRST message. The error even looked like a replay attack +// rather than like the guard eating its own credential. +// +// The fix is core's (*Verifier).Recheck, which re-reads what can move and never +// touches the replay store. RecheckWith is how this module hands a caller that +// call instead of the one that compiles just as well and breaks later. +func TestTheStreamRecheckDoesNotConsumeASingleUseCapability(t *testing.T) { + settings := conformance.New(time.Now()) + verifier := &Verifier{ + Issuer: settings.Issuer, + Audience: settings.Audience, + Keys: settings.PublicKeys(), + Revisions: settings.Revisions, + Replay: settings.Replay, + Grants: settings.Grants, + Seals: settings.Seals, + Now: settings.Now, + TrustTheConformanceFixtureKey: settings.TrustTheConformanceFixtureKey, + } + fixtures, err := corework.Fixtures(settings.Now()) + require.NoError(t, err) + var grant corework.Fixture + for _, candidate := range fixtures { + if candidate.SingleUse { + grant = candidate + break + } + } + require.NotEmpty(t, grant.Token, "core's kit has no single-use fixture to test with") + + // The one legitimate consumption. + verified, err := verifier.Verify(t.Context(), grant.Token) + require.NoError(t, err) + + // Now the stream, built the way this module recommends. + guard, err := NewStreamGuard(StreamGuardOptions{ + Recheck: RecheckWith(verifier, verified), + }) + require.NoError(t, err) + for message := range 5 { + require.NoError(t, guard.BeforeSend(t.Context()), + "message %d must not be refused as a replay of the stream's own credential", message+1) + } + require.NoError(t, guard.Terminated()) + + // And the capability is still spent exactly once overall: presenting it to + // Verify again is a replay, which is what single-use means. + _, err = verifier.Verify(t.Context(), grant.Token) + require.ErrorIs(t, err, ErrReplayed) + + // Whereas a guard built on Verify — the recipe this replaces — dies on its + // first message. Asserted so the regression is a failing test rather than a + // README nobody re-reads. + consuming, err := NewStreamGuard(StreamGuardOptions{ + Recheck: func(ctx context.Context) error { + _, verifyErr := verifier.Verify(ctx, grant.Token) + return verifyErr + }, + }) + require.NoError(t, err) + require.ErrorIs(t, consuming.BeforeSend(t.Context()), ErrReplayed, + "this is the bug: Verify in a loop spends the nonce the loop depends on") +} + +func TestRecheckWithRefusesIncompleteArguments(t *testing.T) { + require.ErrorIs(t, RecheckWith(nil, nil)(context.Background()), ErrInvalid) +} diff --git a/workcontext/work_context.go b/workcontext/work_context.go deleted file mode 100644 index 924a4f7..0000000 --- a/workcontext/work_context.go +++ /dev/null @@ -1,1126 +0,0 @@ -package workcontext - -import ( - "bytes" - "crypto/ed25519" - "crypto/rand" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "sort" - "strconv" - "strings" - "time" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" -) - -const ( - // WorkContextHeaderName is the only HTTP carrier for a signed Work Context. - // Product code should call AttachWorkContext instead of naming this header. - WorkContextHeaderName = "x-codefly-work-context" - - WorkContextType = "codefly.work-context/v1" - WorkContextAlgorithm = "Ed25519" - - WorkContextReplayIdempotent = "idempotent" - WorkContextReplaySingleUse = "single-use" - - WorkContextMaxActorDepth = 16 - WorkContextMaxTokenBytes = 32 * 1024 -) - -const ( - workContextMaxIDBytes = 512 - workContextMaxKindBytes = 128 - workContextMaxScopes = 64 - workContextMaxScopeEntries = 256 -) - -var ( - WorkContextDefaultTTL = 5 * time.Minute - WorkContextMaxTTL = 15 * time.Minute - WorkContextClockSkew = time.Minute - - ErrWorkContextInvalid = errors.New("invalid Codefly Work Context") - // ErrWorkContextUnavailable marks a fail-closed rejection caused by the key - // set being unreachable (transport failure or an issuer 5xx/429) rather than - // by the token itself. Callers should treat it as retryable — HTTP 503, not - // 401 — since an otherwise-valid context must not be dropped during an outage. - ErrWorkContextUnavailable = errors.New("Codefly Work Context verification unavailable") - ErrWorkContextDenied = errors.New("Codefly Work Context scope denied") -) - -// WorkContextToken is an opaque signed capability. Its encoded representation -// is exposed only for storage and transport adapters; use AttachWorkContext for -// HTTP requests and WorkContextVerifier for trust decisions. -type WorkContextToken struct { - encoded string -} - -// Encoded returns the signed wire value for persistence or a non-HTTP transport. -func (t WorkContextToken) Encoded() string { - return t.encoded -} - -func (t WorkContextToken) empty() bool { - return t.encoded == "" -} - -// ParseWorkContextToken validates only the bounded two-segment wire shape. It -// does not establish trust; call WorkContextVerifier.Verify before using claims. -func ParseWorkContextToken(encoded string) (WorkContextToken, error) { - if err := validateTokenShape(encoded); err != nil { - return WorkContextToken{}, err - } - return WorkContextToken{encoded: encoded}, nil -} - -// AttachWorkContext installs a token without exposing the carrier name to -// application code. -func AttachWorkContext(request *http.Request, token WorkContextToken) error { - if request == nil { - return fmt.Errorf("%w: nil HTTP request", ErrWorkContextInvalid) - } - if token.empty() { - return fmt.Errorf("%w: empty token", ErrWorkContextInvalid) - } - request.Header.Set(WorkContextHeaderName, token.encoded) - return nil -} - -// WorkContextFromHeaders extracts an opaque token. It does not verify it. -func WorkContextFromHeaders(headers http.Header) (WorkContextToken, error) { - if headers == nil { - return WorkContextToken{}, fmt.Errorf("%w: missing HTTP headers", ErrWorkContextInvalid) - } - return ParseWorkContextToken(headers.Get(WorkContextHeaderName)) -} - -// WorkContextSigner is an authority-side capability. Product applications -// should receive tokens from an authority/exchange endpoint, not receive this -// signer or its private key. -type WorkContextSigner struct { - issuer string - keyID string - privateKey ed25519.PrivateKey - now func() time.Time - nonce func() (string, error) -} - -type WorkContextSignerOptions struct { - Issuer string - KeyID string - PrivateKey ed25519.PrivateKey - Now func() time.Time - Nonce func() (string, error) -} - -func NewWorkContextSigner(options WorkContextSignerOptions) (*WorkContextSigner, error) { - if err := validateBounded("issuer", options.Issuer, workContextMaxIDBytes, true); err != nil { - return nil, err - } - if err := validateBounded("key_id", options.KeyID, workContextMaxKindBytes, true); err != nil { - return nil, err - } - if len(options.PrivateKey) != ed25519.PrivateKeySize { - return nil, fmt.Errorf("%w: Ed25519 private key must be %d bytes", ErrWorkContextInvalid, ed25519.PrivateKeySize) - } - now := options.Now - if now == nil { - now = time.Now - } - nonce := options.Nonce - if nonce == nil { - nonce = randomWorkContextNonce - } - return &WorkContextSigner{ - issuer: options.Issuer, - keyID: options.KeyID, - privateKey: append(ed25519.PrivateKey(nil), options.PrivateKey...), - now: now, - nonce: nonce, - }, nil -} - -// StartTaskInput creates a Task and its first root Session capability. -type StartTaskInput struct { - Audience string - TenantID string - OwnerPrincipalID string - TaskID string - SessionID string - AuthorizationRevision uint64 - ReplayPolicy string - AuthorityScopes []*basev0.WorkScopeV1 - ActorChain []*basev0.WorkActorV1 - AttributionTeamIDs []string - WorkspaceID string - ProjectID string - TTL time.Duration - NotBefore time.Time -} - -// StartTask constructs and signs an immutable Task/root-Session context. -func (s *WorkContextSigner) StartTask(input StartTaskInput) (WorkContextToken, *basev0.WorkContextV1, error) { - if s == nil { - return WorkContextToken{}, nil, fmt.Errorf("%w: nil signer", ErrWorkContextInvalid) - } - now := s.now().UTC().Truncate(time.Second) - ttl := input.TTL - if ttl == 0 { - ttl = WorkContextDefaultTTL - } - notBefore := input.NotBefore - if notBefore.IsZero() { - notBefore = now - } - nonce, err := s.nonce() - if err != nil { - return WorkContextToken{}, nil, fmt.Errorf("%w: generate nonce: %v", ErrWorkContextInvalid, err) - } - replayPolicy := input.ReplayPolicy - if replayPolicy == "" { - replayPolicy = WorkContextReplayIdempotent - } - context := &basev0.WorkContextV1{ - Typ: WorkContextType, - Algorithm: WorkContextAlgorithm, - KeyId: s.keyID, - Issuer: s.issuer, - Audience: input.Audience, - NotBeforeUnix: notBefore.UTC().Truncate(time.Second).Unix(), - IssuedAtUnix: now.Unix(), - ExpiresAtUnix: now.Add(ttl).Unix(), - Nonce: nonce, - AuthorizationRevision: input.AuthorizationRevision, - ReplayPolicy: replayPolicy, - TenantId: input.TenantID, - OwnerPrincipalId: input.OwnerPrincipalID, - TaskId: input.TaskID, - SessionId: input.SessionID, - AuthorityScopes: cloneScopes(input.AuthorityScopes), - ActorChain: cloneActors(input.ActorChain), - AttributionTeamIds: append([]string(nil), input.AttributionTeamIDs...), - } - if input.WorkspaceID != "" { - context.WorkspaceId = stringPointer(input.WorkspaceID) - } - if input.ProjectID != "" { - context.ProjectId = stringPointer(input.ProjectID) - } - return s.sign(context) -} - -// StartRootSessionInput exchanges a valid capability for another root Session -// under the same Task. Identity, owner, scopes, actors, and attribution cannot -// be changed by the caller. -type StartRootSessionInput struct { - SessionID string - Audience string - ReplayPolicy string - TTL time.Duration -} - -// ExchangeWorkContextAudienceInput reissues one verified Work Context for a -// different consumer. Immutable work and delegation identity is preserved. -// AttenuatedScopes, when non-nil, replaces the effective scopes and must be a -// subset of the parent's effective scopes. -type ExchangeWorkContextAudienceInput struct { - Audience string - ReplayPolicy string - TTL time.Duration - AttenuatedScopes []*basev0.WorkScopeV1 -} - -// ExchangeWorkContextAudience reissues a capability for another audience -// without creating a new Task, Session, actor, or delegation. Authorities use -// this when one logical execution crosses service trust boundaries. An -// exchange may reduce effective authority but can never widen it. -func (s *WorkContextSigner) ExchangeWorkContextAudience( - parent WorkContextToken, - input ExchangeWorkContextAudienceInput, -) (WorkContextToken, *basev0.WorkContextV1, error) { - verified, err := s.verifyOwn(parent) - if err != nil { - return WorkContextToken{}, nil, err - } - next := cloneContext(verified) - if input.AttenuatedScopes != nil { - attenuated := cloneScopes(input.AttenuatedScopes) - canonicalizeScopes(attenuated) - effective := verified.GetAuthorityScopes() - if actors := verified.GetActorChain(); len(actors) > 0 { - effective = actors[len(actors)-1].GetGrantedScopes() - } - if !scopesAttenuate(effective, attenuated) { - return WorkContextToken{}, nil, fmt.Errorf( - "%w: audience exchange widens authority", - ErrWorkContextInvalid, - ) - } - if actors := next.GetActorChain(); len(actors) > 0 { - actors[len(actors)-1].GrantedScopes = attenuated - } else { - next.AuthorityScopes = attenuated - } - } - return s.exchange( - next, - input.Audience, - input.ReplayPolicy, - input.TTL, - ) -} - -func (s *WorkContextSigner) StartSession(parent WorkContextToken, input StartRootSessionInput) (WorkContextToken, *basev0.WorkContextV1, error) { - verified, err := s.verifyOwn(parent) - if err != nil { - return WorkContextToken{}, nil, err - } - next := cloneContext(verified) - next.SessionId = input.SessionID - next.ParentSessionId = nil - return s.exchange(next, input.Audience, input.ReplayPolicy, input.TTL) -} - -// StartChildSessionInput appends exactly one verified Actor and creates a child -// Session. The new actor's scopes must attenuate the parent's effective scope. -type StartChildSessionInput struct { - SessionID string - Audience string - Actor *basev0.WorkActorV1 - ReplayPolicy string - TTL time.Duration -} - -func (s *WorkContextSigner) StartChildSession(parent WorkContextToken, input StartChildSessionInput) (WorkContextToken, *basev0.WorkContextV1, error) { - verified, err := s.verifyOwn(parent) - if err != nil { - return WorkContextToken{}, nil, err - } - next := cloneContext(verified) - next.ParentSessionId = stringPointer(verified.SessionId) - next.SessionId = input.SessionID - next.ActorChain = append(next.ActorChain, cloneActor(input.Actor)) - return s.exchange(next, input.Audience, input.ReplayPolicy, input.TTL) -} - -func (s *WorkContextSigner) exchange(context *basev0.WorkContextV1, audience, replayPolicy string, ttl time.Duration) (WorkContextToken, *basev0.WorkContextV1, error) { - now := s.now().UTC().Truncate(time.Second) - if ttl == 0 { - ttl = WorkContextDefaultTTL - } - if audience == "" { - audience = context.Audience - } - if replayPolicy == "" { - replayPolicy = context.ReplayPolicy - } - nonce, err := s.nonce() - if err != nil { - return WorkContextToken{}, nil, fmt.Errorf("%w: generate nonce: %v", ErrWorkContextInvalid, err) - } - context.Typ = WorkContextType - context.Algorithm = WorkContextAlgorithm - context.KeyId = s.keyID - context.Issuer = s.issuer - context.Audience = audience - context.NotBeforeUnix = now.Unix() - context.IssuedAtUnix = now.Unix() - context.ExpiresAtUnix = now.Add(ttl).Unix() - context.Nonce = nonce - context.ReplayPolicy = replayPolicy - return s.sign(context) -} - -func (s *WorkContextSigner) verifyOwn(token WorkContextToken) (*basev0.WorkContextV1, error) { - publicKey, ok := s.privateKey.Public().(ed25519.PublicKey) - if !ok { - return nil, fmt.Errorf("%w: signer has no Ed25519 public key", ErrWorkContextInvalid) - } - verifier, err := NewWorkContextVerifier(WorkContextVerifierOptions{ - PublicKeys: map[string]ed25519.PublicKey{s.keyID: publicKey}, - Now: s.now, - }) - if err != nil { - return nil, err - } - return verifier.Verify(token, WorkContextExpectations{Issuer: s.issuer}) -} - -func (s *WorkContextSigner) sign(context *basev0.WorkContextV1) (WorkContextToken, *basev0.WorkContextV1, error) { - canonical := cloneContext(context) - canonicalizeWorkContext(canonical) - if err := validateWorkContext(canonical); err != nil { - return WorkContextToken{}, nil, err - } - payload, err := marshalWorkContext(canonical) - if err != nil { - return WorkContextToken{}, nil, err - } - signature := ed25519.Sign(s.privateKey, payload) - encoded := base64.RawURLEncoding.EncodeToString(payload) + "." + - base64.RawURLEncoding.EncodeToString(signature) - if len(encoded) > WorkContextMaxTokenBytes { - return WorkContextToken{}, nil, fmt.Errorf("%w: token exceeds %d bytes", ErrWorkContextInvalid, WorkContextMaxTokenBytes) - } - return WorkContextToken{encoded: encoded}, canonical, nil -} - -type WorkContextVerifier struct { - publicKeys map[string]ed25519.PublicKey - now func() time.Time - clockSkew time.Duration -} - -type WorkContextVerifierOptions struct { - PublicKeys map[string]ed25519.PublicKey - Now func() time.Time - ClockSkew time.Duration -} - -func NewWorkContextVerifier(options WorkContextVerifierOptions) (*WorkContextVerifier, error) { - if len(options.PublicKeys) == 0 { - return nil, fmt.Errorf("%w: no public verification keys", ErrWorkContextInvalid) - } - keys := make(map[string]ed25519.PublicKey, len(options.PublicKeys)) - for keyID, publicKey := range options.PublicKeys { - if err := validateBounded("key_id", keyID, workContextMaxKindBytes, true); err != nil { - return nil, err - } - if len(publicKey) != ed25519.PublicKeySize { - return nil, fmt.Errorf("%w: public key %q must be %d bytes", ErrWorkContextInvalid, keyID, ed25519.PublicKeySize) - } - keys[keyID] = append(ed25519.PublicKey(nil), publicKey...) - } - now := options.Now - if now == nil { - now = time.Now - } - clockSkew := options.ClockSkew - if clockSkew == 0 { - clockSkew = WorkContextClockSkew - } - if clockSkew < 0 || clockSkew > WorkContextClockSkew { - return nil, fmt.Errorf("%w: clock skew must be between zero and %s", ErrWorkContextInvalid, WorkContextClockSkew) - } - return &WorkContextVerifier{publicKeys: keys, now: now, clockSkew: clockSkew}, nil -} - -type WorkContextExpectations struct { - Issuer string - Audience string - TenantID string - OwnerPrincipalID string - TaskID string - SessionID string - ParentSessionID *string - AuthorizationRevision *uint64 -} - -// WorkContextScopeRequirement identifies one exact capability a verified Work -// Context must grant. An empty ResourceID asks whether the effective scope -// grants every resource of ResourceKind; it never ignores explicit resource -// restrictions. -type WorkContextScopeRequirement struct { - ResourceKind string - Action string - ResourceID string - RequireExplicitResource bool -} - -// RequireWorkContextScope evaluates the current actor's effective scope. The -// authority scopes apply to a direct owner call; when actors are present, only -// the final actor's monotonically attenuated granted scopes are effective. -// -// Call this only after signature, time, issuer, and audience verification. -// The claims are structurally validated again so a caller cannot accidentally -// authorize from a hand-constructed or mutated protobuf. -func RequireWorkContextScope( - claims *basev0.WorkContextV1, - requirement WorkContextScopeRequirement, -) error { - if err := validateWorkContext(claims); err != nil { - return err - } - if err := validateBounded( - "required resource_kind", - requirement.ResourceKind, - workContextMaxKindBytes, - true, - ); err != nil { - return err - } - if err := validateBounded( - "required action", - requirement.Action, - workContextMaxKindBytes, - true, - ); err != nil { - return err - } - if err := validateBounded( - "required resource_id", - requirement.ResourceID, - workContextMaxIDBytes, - requirement.RequireExplicitResource, - ); err != nil { - return err - } - - effective := claims.GetAuthorityScopes() - if actors := claims.GetActorChain(); len(actors) > 0 { - effective = actors[len(actors)-1].GetGrantedScopes() - } - for _, scope := range effective { - if scope.GetResourceKind() != requirement.ResourceKind || - !sortedStringsContain(scope.GetActions(), requirement.Action) { - continue - } - resourceIDs := scope.GetResourceIds() - switch { - case len(resourceIDs) == 0 && !requirement.RequireExplicitResource: - return nil - case requirement.ResourceID != "" && - sortedStringsContain(resourceIDs, requirement.ResourceID): - return nil - } - } - return fmt.Errorf( - "%w: %s:%s:%s", - ErrWorkContextDenied, - requirement.ResourceKind, - requirement.Action, - requirement.ResourceID, - ) -} - -func (v *WorkContextVerifier) Verify(token WorkContextToken, expected WorkContextExpectations) (*basev0.WorkContextV1, error) { - if v == nil { - return nil, fmt.Errorf("%w: nil verifier", ErrWorkContextInvalid) - } - payload, signature, err := decodeWorkContextToken(token.encoded) - if err != nil { - return nil, err - } - probe := struct { - KeyID string `json:"key_id"` - }{} - if decodeErr := json.Unmarshal(payload, &probe); decodeErr != nil { - return nil, fmt.Errorf("%w: decode key id: %v", ErrWorkContextInvalid, decodeErr) - } - publicKey, ok := v.publicKeys[probe.KeyID] - if !ok { - return nil, fmt.Errorf("%w: unknown key id %q", ErrWorkContextInvalid, probe.KeyID) - } - if !ed25519.Verify(publicKey, payload, signature) { - return nil, fmt.Errorf("%w: signature verification failed", ErrWorkContextInvalid) - } - context, err := unmarshalWorkContext(payload) - if err != nil { - return nil, err - } - if err := validateWorkContext(context); err != nil { - return nil, err - } - if err := v.validateTime(context); err != nil { - return nil, err - } - if err := matchWorkContext(context, expected); err != nil { - return nil, err - } - return context, nil -} - -func (v *WorkContextVerifier) validateTime(context *basev0.WorkContextV1) error { - now := v.now().UTC() - notBefore := time.Unix(context.NotBeforeUnix, 0) - issuedAt := time.Unix(context.IssuedAtUnix, 0) - expiresAt := time.Unix(context.ExpiresAtUnix, 0) - if now.Before(notBefore.Add(-v.clockSkew)) { - return fmt.Errorf("%w: token is not active yet", ErrWorkContextInvalid) - } - if issuedAt.After(now.Add(v.clockSkew)) { - return fmt.Errorf("%w: token was issued in the future", ErrWorkContextInvalid) - } - if now.After(expiresAt.Add(v.clockSkew)) { - return fmt.Errorf("%w: token expired", ErrWorkContextInvalid) - } - return nil -} - -func matchWorkContext(context *basev0.WorkContextV1, expected WorkContextExpectations) error { - checks := []struct { - name string - got string - want string - }{ - {"issuer", context.Issuer, expected.Issuer}, - {"audience", context.Audience, expected.Audience}, - {"tenant", context.TenantId, expected.TenantID}, - {"owner", context.OwnerPrincipalId, expected.OwnerPrincipalID}, - {"task", context.TaskId, expected.TaskID}, - {"session", context.SessionId, expected.SessionID}, - } - for _, check := range checks { - if check.want != "" && check.got != check.want { - return fmt.Errorf("%w: %s mismatch", ErrWorkContextInvalid, check.name) - } - } - if expected.ParentSessionID != nil && context.GetParentSessionId() != *expected.ParentSessionID { - return fmt.Errorf("%w: parent session mismatch", ErrWorkContextInvalid) - } - if expected.AuthorizationRevision != nil && context.AuthorizationRevision != *expected.AuthorizationRevision { - return fmt.Errorf("%w: authorization revision mismatch", ErrWorkContextInvalid) - } - return nil -} - -// The token payload uses one fixed snake_case JSON layout. authorization_revision -// is a decimal string so Go and JavaScript preserve the complete uint64 domain. -type workContextPayload struct { - Typ string `json:"typ"` - Algorithm string `json:"algorithm"` - KeyID string `json:"key_id"` - Issuer string `json:"issuer"` - Audience string `json:"audience"` - NotBeforeUnix int64 `json:"not_before_unix"` - IssuedAtUnix int64 `json:"issued_at_unix"` - ExpiresAtUnix int64 `json:"expires_at_unix"` - Nonce string `json:"nonce"` - AuthorizationRevision string `json:"authorization_revision"` - ReplayPolicy string `json:"replay_policy"` - TenantID string `json:"tenant_id"` - OwnerPrincipalID string `json:"owner_principal_id"` - TaskID string `json:"task_id"` - SessionID string `json:"session_id"` - ParentSessionID *string `json:"parent_session_id,omitempty"` - AuthorityScopes []workContextScope `json:"authority_scopes"` - ActorChain []workContextActor `json:"actor_chain"` - AttributionTeamIDs []string `json:"attribution_team_ids"` - WorkspaceID *string `json:"workspace_id,omitempty"` - ProjectID *string `json:"project_id,omitempty"` -} - -type workContextScope struct { - ResourceKind string `json:"resource_kind"` - Actions []string `json:"actions"` - ResourceIDs []string `json:"resource_ids"` -} - -type workContextActor struct { - PrincipalID string `json:"principal_id"` - PrincipalKind string `json:"principal_kind"` - DelegationID string `json:"delegation_id"` - GrantedScopes []workContextScope `json:"granted_scopes"` -} - -func marshalWorkContext(context *basev0.WorkContextV1) ([]byte, error) { - payload := payloadFromContext(context) - encoded, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("%w: encode payload: %v", ErrWorkContextInvalid, err) - } - return encoded, nil -} - -func unmarshalWorkContext(encoded []byte) (*basev0.WorkContextV1, error) { - var payload workContextPayload - decoder := json.NewDecoder(bytes.NewReader(encoded)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&payload); err != nil { - return nil, fmt.Errorf("%w: decode payload: %v", ErrWorkContextInvalid, err) - } - var trailing any - if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { - if err == nil { - return nil, fmt.Errorf("%w: trailing JSON value", ErrWorkContextInvalid) - } - return nil, fmt.Errorf("%w: trailing JSON: %v", ErrWorkContextInvalid, err) - } - revision, err := strconv.ParseUint(payload.AuthorizationRevision, 10, 64) - if err != nil { - return nil, fmt.Errorf("%w: authorization_revision must be uint64 decimal", ErrWorkContextInvalid) - } - return contextFromPayload(payload, revision), nil -} - -func payloadFromContext(context *basev0.WorkContextV1) workContextPayload { - return workContextPayload{ - Typ: context.Typ, - Algorithm: context.Algorithm, - KeyID: context.KeyId, - Issuer: context.Issuer, - Audience: context.Audience, - NotBeforeUnix: context.NotBeforeUnix, - IssuedAtUnix: context.IssuedAtUnix, - ExpiresAtUnix: context.ExpiresAtUnix, - Nonce: context.Nonce, - AuthorizationRevision: strconv.FormatUint(context.AuthorizationRevision, 10), - ReplayPolicy: context.ReplayPolicy, - TenantID: context.TenantId, - OwnerPrincipalID: context.OwnerPrincipalId, - TaskID: context.TaskId, - SessionID: context.SessionId, - ParentSessionID: cloneStringPointer(context.ParentSessionId), - AuthorityScopes: payloadScopes(context.AuthorityScopes), - ActorChain: payloadActors(context.ActorChain), - AttributionTeamIDs: append([]string{}, context.AttributionTeamIds...), - WorkspaceID: cloneStringPointer(context.WorkspaceId), - ProjectID: cloneStringPointer(context.ProjectId), - } -} - -func payloadScopes(scopes []*basev0.WorkScopeV1) []workContextScope { - out := make([]workContextScope, 0, len(scopes)) - for _, scope := range scopes { - if scope == nil { - out = append(out, workContextScope{}) - continue - } - out = append(out, workContextScope{ - ResourceKind: scope.ResourceKind, - Actions: append([]string{}, scope.Actions...), - ResourceIDs: append([]string{}, scope.ResourceIds...), - }) - } - return out -} - -func payloadActors(actors []*basev0.WorkActorV1) []workContextActor { - out := make([]workContextActor, 0, len(actors)) - for _, actor := range actors { - if actor == nil { - out = append(out, workContextActor{}) - continue - } - out = append(out, workContextActor{ - PrincipalID: actor.PrincipalId, - PrincipalKind: actor.PrincipalKind, - DelegationID: actor.DelegationId, - GrantedScopes: payloadScopes(actor.GrantedScopes), - }) - } - return out -} - -func contextFromPayload(payload workContextPayload, revision uint64) *basev0.WorkContextV1 { - context := &basev0.WorkContextV1{ - Typ: payload.Typ, - Algorithm: payload.Algorithm, - KeyId: payload.KeyID, - Issuer: payload.Issuer, - Audience: payload.Audience, - NotBeforeUnix: payload.NotBeforeUnix, - IssuedAtUnix: payload.IssuedAtUnix, - ExpiresAtUnix: payload.ExpiresAtUnix, - Nonce: payload.Nonce, - AuthorizationRevision: revision, - ReplayPolicy: payload.ReplayPolicy, - TenantId: payload.TenantID, - OwnerPrincipalId: payload.OwnerPrincipalID, - TaskId: payload.TaskID, - SessionId: payload.SessionID, - ParentSessionId: cloneStringPointer(payload.ParentSessionID), - AuthorityScopes: contextScopes(payload.AuthorityScopes), - ActorChain: contextActors(payload.ActorChain), - AttributionTeamIds: append([]string(nil), payload.AttributionTeamIDs...), - WorkspaceId: cloneStringPointer(payload.WorkspaceID), - ProjectId: cloneStringPointer(payload.ProjectID), - } - return context -} - -func contextScopes(scopes []workContextScope) []*basev0.WorkScopeV1 { - out := make([]*basev0.WorkScopeV1, 0, len(scopes)) - for _, scope := range scopes { - out = append(out, &basev0.WorkScopeV1{ - ResourceKind: scope.ResourceKind, - Actions: append([]string(nil), scope.Actions...), - ResourceIds: append([]string(nil), scope.ResourceIDs...), - }) - } - return out -} - -func contextActors(actors []workContextActor) []*basev0.WorkActorV1 { - out := make([]*basev0.WorkActorV1, 0, len(actors)) - for _, actor := range actors { - out = append(out, &basev0.WorkActorV1{ - PrincipalId: actor.PrincipalID, - PrincipalKind: actor.PrincipalKind, - DelegationId: actor.DelegationID, - GrantedScopes: contextScopes(actor.GrantedScopes), - }) - } - return out -} - -func validateWorkContext(context *basev0.WorkContextV1) error { - if context == nil { - return fmt.Errorf("%w: nil claims", ErrWorkContextInvalid) - } - if context.Typ != WorkContextType { - return fmt.Errorf("%w: unsupported typ %q", ErrWorkContextInvalid, context.Typ) - } - if context.Algorithm != WorkContextAlgorithm { - return fmt.Errorf("%w: unsupported algorithm %q", ErrWorkContextInvalid, context.Algorithm) - } - fields := []struct { - name string - value string - max int - }{ - {"key_id", context.KeyId, workContextMaxKindBytes}, - {"issuer", context.Issuer, workContextMaxIDBytes}, - {"audience", context.Audience, workContextMaxIDBytes}, - {"nonce", context.Nonce, workContextMaxIDBytes}, - {"tenant_id", context.TenantId, workContextMaxIDBytes}, - {"owner_principal_id", context.OwnerPrincipalId, workContextMaxIDBytes}, - {"task_id", context.TaskId, workContextMaxIDBytes}, - {"session_id", context.SessionId, workContextMaxIDBytes}, - } - for _, field := range fields { - if err := validateBounded(field.name, field.value, field.max, true); err != nil { - return err - } - } - // These proto fields are optional, but each declares min_len=1: absent is - // allowed, present-but-empty is not. min_len is a length rule, so the guard - // is emptiness, not whitespace-trimming — a proto-generated verifier in - // another language accepts a one-character value, and this one must agree on - // the same wire bytes. - for name, value := range map[string]*string{ - "parent_session_id": context.ParentSessionId, - "workspace_id": context.WorkspaceId, - "project_id": context.ProjectId, - } { - if value == nil { - continue - } - if *value == "" { - return fmt.Errorf("%w: %s must not be empty when present", ErrWorkContextInvalid, name) - } - if err := validateBounded(name, *value, workContextMaxIDBytes, false); err != nil { - return err - } - } - if context.ParentSessionId != nil && context.GetParentSessionId() == context.SessionId { - return fmt.Errorf("%w: parent session equals session", ErrWorkContextInvalid) - } - if context.ReplayPolicy != WorkContextReplayIdempotent && context.ReplayPolicy != WorkContextReplaySingleUse { - return fmt.Errorf("%w: unsupported replay policy %q", ErrWorkContextInvalid, context.ReplayPolicy) - } - if context.NotBeforeUnix > context.ExpiresAtUnix { - return fmt.Errorf("%w: not-before is after expiry", ErrWorkContextInvalid) - } - if context.IssuedAtUnix > context.ExpiresAtUnix { - return fmt.Errorf("%w: issued-at is after expiry", ErrWorkContextInvalid) - } - if ttl := time.Duration(context.ExpiresAtUnix-context.IssuedAtUnix) * time.Second; ttl <= 0 || ttl > WorkContextMaxTTL { - return fmt.Errorf("%w: lifetime must be positive and at most %s", ErrWorkContextInvalid, WorkContextMaxTTL) - } - if len(context.ActorChain) > WorkContextMaxActorDepth { - return fmt.Errorf("%w: actor chain exceeds depth %d", ErrWorkContextInvalid, WorkContextMaxActorDepth) - } - if len(context.AttributionTeamIds) > workContextMaxScopeEntries { - return fmt.Errorf("%w: too many attribution teams", ErrWorkContextInvalid) - } - if err := validateSortedUniqueStrings("attribution_team_ids", context.AttributionTeamIds, workContextMaxIDBytes, true); err != nil { - return err - } - if err := validateScopes("authority_scopes", context.AuthorityScopes); err != nil { - return err - } - previous := context.AuthorityScopes - for index, actor := range context.ActorChain { - if actor == nil { - return fmt.Errorf("%w: actor_chain[%d] is nil", ErrWorkContextInvalid, index) - } - if err := validateBounded("actor principal_id", actor.PrincipalId, workContextMaxIDBytes, true); err != nil { - return err - } - if err := validateBounded("actor principal_kind", actor.PrincipalKind, workContextMaxKindBytes, true); err != nil { - return err - } - if err := validateBounded("actor delegation_id", actor.DelegationId, workContextMaxIDBytes, true); err != nil { - return err - } - if err := validateScopes(fmt.Sprintf("actor_chain[%d].granted_scopes", index), actor.GrantedScopes); err != nil { - return err - } - if !scopesAttenuate(previous, actor.GrantedScopes) { - return fmt.Errorf("%w: actor_chain[%d] widens authority", ErrWorkContextInvalid, index) - } - previous = actor.GrantedScopes - } - return nil -} - -func validateScopes(name string, scopes []*basev0.WorkScopeV1) error { - if len(scopes) > workContextMaxScopes { - return fmt.Errorf("%w: %s exceeds %d scopes", ErrWorkContextInvalid, name, workContextMaxScopes) - } - previousKind := "" - for index, scope := range scopes { - if scope == nil { - return fmt.Errorf("%w: %s[%d] is nil", ErrWorkContextInvalid, name, index) - } - if err := validateBounded(name+" resource_kind", scope.ResourceKind, workContextMaxKindBytes, true); err != nil { - return err - } - if previousKind >= scope.ResourceKind { - return fmt.Errorf("%w: %s resource kinds must be sorted and unique", ErrWorkContextInvalid, name) - } - previousKind = scope.ResourceKind - if len(scope.Actions) == 0 || len(scope.Actions) > workContextMaxScopeEntries { - return fmt.Errorf("%w: %s[%d] actions must contain 1..%d entries", ErrWorkContextInvalid, name, index, workContextMaxScopeEntries) - } - if err := validateSortedUniqueStrings(name+" actions", scope.Actions, workContextMaxKindBytes, true); err != nil { - return err - } - if len(scope.ResourceIds) > workContextMaxScopeEntries { - return fmt.Errorf("%w: %s[%d] has too many resource IDs", ErrWorkContextInvalid, name, index) - } - if err := validateSortedUniqueStrings(name+" resource_ids", scope.ResourceIds, workContextMaxIDBytes, true); err != nil { - return err - } - } - return nil -} - -func scopesAttenuate(parent, child []*basev0.WorkScopeV1) bool { - parentByKind := make(map[string]*basev0.WorkScopeV1, len(parent)) - for _, scope := range parent { - parentByKind[scope.ResourceKind] = scope - } - for _, scope := range child { - ancestor, ok := parentByKind[scope.ResourceKind] - if !ok || !stringSubset(scope.Actions, ancestor.Actions) { - return false - } - if len(ancestor.ResourceIds) > 0 { - // Empty means wildcard, so an explicit parent set may only be - // narrowed to another non-empty subset. - if len(scope.ResourceIds) == 0 || !stringSubset(scope.ResourceIds, ancestor.ResourceIds) { - return false - } - } - } - return true -} - -func stringSubset(child, parent []string) bool { - parentSet := make(map[string]struct{}, len(parent)) - for _, value := range parent { - parentSet[value] = struct{}{} - } - for _, value := range child { - if _, ok := parentSet[value]; !ok { - return false - } - } - return true -} - -func sortedStringsContain(values []string, wanted string) bool { - index := sort.SearchStrings(values, wanted) - return index < len(values) && values[index] == wanted -} - -func canonicalizeWorkContext(context *basev0.WorkContextV1) { - context.AttributionTeamIds = sortedUnique(context.AttributionTeamIds) - canonicalizeScopes(context.AuthorityScopes) - for _, actor := range context.ActorChain { - if actor != nil { - canonicalizeScopes(actor.GrantedScopes) - } - } -} - -func canonicalizeScopes(scopes []*basev0.WorkScopeV1) { - for _, scope := range scopes { - if scope == nil { - continue - } - scope.Actions = sortedUnique(scope.Actions) - scope.ResourceIds = sortedUnique(scope.ResourceIds) - } - sort.SliceStable(scopes, func(i, j int) bool { - if scopes[i] == nil { - return scopes[j] != nil - } - if scopes[j] == nil { - return false - } - return scopes[i].ResourceKind < scopes[j].ResourceKind - }) -} - -func sortedUnique(values []string) []string { - out := append([]string(nil), values...) - sort.Strings(out) - if len(out) < 2 { - return out - } - write := 1 - for read := 1; read < len(out); read++ { - if out[read] == out[write-1] { - continue - } - out[write] = out[read] - write++ - } - return out[:write] -} - -func validateSortedUniqueStrings(name string, values []string, max int, nonEmpty bool) error { - previous := "" - for index, value := range values { - if err := validateBounded(name, value, max, nonEmpty); err != nil { - return err - } - if index > 0 && previous >= value { - return fmt.Errorf("%w: %s must be sorted and unique", ErrWorkContextInvalid, name) - } - previous = value - } - return nil -} - -func validateBounded(name, value string, max int, required bool) error { - if required && strings.TrimSpace(value) == "" { - return fmt.Errorf("%w: %s is required", ErrWorkContextInvalid, name) - } - if len(value) > max { - return fmt.Errorf("%w: %s exceeds %d bytes", ErrWorkContextInvalid, name, max) - } - return nil -} - -func validateTokenShape(encoded string) error { - if encoded == "" { - return fmt.Errorf("%w: empty token", ErrWorkContextInvalid) - } - if len(encoded) > WorkContextMaxTokenBytes { - return fmt.Errorf("%w: token exceeds %d bytes", ErrWorkContextInvalid, WorkContextMaxTokenBytes) - } - if strings.Count(encoded, ".") != 1 { - return fmt.Errorf("%w: token must have exactly two segments", ErrWorkContextInvalid) - } - return nil -} - -func decodeWorkContextToken(encoded string) ([]byte, []byte, error) { - if err := validateTokenShape(encoded); err != nil { - return nil, nil, err - } - segments := strings.SplitN(encoded, ".", 2) - payload, err := base64.RawURLEncoding.DecodeString(segments[0]) - if err != nil { - return nil, nil, fmt.Errorf("%w: payload base64: %v", ErrWorkContextInvalid, err) - } - if base64.RawURLEncoding.EncodeToString(payload) != segments[0] { - return nil, nil, fmt.Errorf("%w: payload is not canonical base64url", ErrWorkContextInvalid) - } - signature, err := base64.RawURLEncoding.DecodeString(segments[1]) - if err != nil { - return nil, nil, fmt.Errorf("%w: signature base64: %v", ErrWorkContextInvalid, err) - } - if base64.RawURLEncoding.EncodeToString(signature) != segments[1] { - return nil, nil, fmt.Errorf("%w: signature is not canonical base64url", ErrWorkContextInvalid) - } - if len(signature) != ed25519.SignatureSize { - return nil, nil, fmt.Errorf("%w: signature must be %d bytes", ErrWorkContextInvalid, ed25519.SignatureSize) - } - return payload, signature, nil -} - -func randomWorkContextNonce() (string, error) { - var raw [16]byte - if _, err := rand.Read(raw[:]); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(raw[:]), nil -} - -func cloneContext(context *basev0.WorkContextV1) *basev0.WorkContextV1 { - if context == nil { - return nil - } - return &basev0.WorkContextV1{ - Typ: context.Typ, - Algorithm: context.Algorithm, - KeyId: context.KeyId, - Issuer: context.Issuer, - Audience: context.Audience, - NotBeforeUnix: context.NotBeforeUnix, - IssuedAtUnix: context.IssuedAtUnix, - ExpiresAtUnix: context.ExpiresAtUnix, - Nonce: context.Nonce, - AuthorizationRevision: context.AuthorizationRevision, - ReplayPolicy: context.ReplayPolicy, - TenantId: context.TenantId, - OwnerPrincipalId: context.OwnerPrincipalId, - TaskId: context.TaskId, - SessionId: context.SessionId, - ParentSessionId: cloneStringPointer(context.ParentSessionId), - AuthorityScopes: cloneScopes(context.AuthorityScopes), - ActorChain: cloneActors(context.ActorChain), - AttributionTeamIds: append([]string(nil), context.AttributionTeamIds...), - WorkspaceId: cloneStringPointer(context.WorkspaceId), - ProjectId: cloneStringPointer(context.ProjectId), - } -} - -func cloneScopes(scopes []*basev0.WorkScopeV1) []*basev0.WorkScopeV1 { - out := make([]*basev0.WorkScopeV1, 0, len(scopes)) - for _, scope := range scopes { - if scope == nil { - out = append(out, nil) - continue - } - out = append(out, &basev0.WorkScopeV1{ - ResourceKind: scope.ResourceKind, - Actions: append([]string(nil), scope.Actions...), - ResourceIds: append([]string(nil), scope.ResourceIds...), - }) - } - return out -} - -func cloneActor(actor *basev0.WorkActorV1) *basev0.WorkActorV1 { - if actor == nil { - return nil - } - return &basev0.WorkActorV1{ - PrincipalId: actor.PrincipalId, - PrincipalKind: actor.PrincipalKind, - DelegationId: actor.DelegationId, - GrantedScopes: cloneScopes(actor.GrantedScopes), - } -} - -func cloneActors(actors []*basev0.WorkActorV1) []*basev0.WorkActorV1 { - out := make([]*basev0.WorkActorV1, 0, len(actors)) - for _, actor := range actors { - out = append(out, cloneActor(actor)) - } - return out -} - -func stringPointer(value string) *string { - return &value -} - -func cloneStringPointer(value *string) *string { - if value == nil { - return nil - } - copy := *value - return © -} diff --git a/workcontext/work_context_jwks.go b/workcontext/work_context_jwks.go deleted file mode 100644 index 9f5b1b9..0000000 --- a/workcontext/work_context_jwks.go +++ /dev/null @@ -1,396 +0,0 @@ -package workcontext - -import ( - "context" - "crypto/ed25519" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "mime" - "net/http" - "net/url" - "strings" - "sync" - "time" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" -) - -const ( - defaultWorkContextJWKSCacheTTL = 5 * time.Minute - defaultWorkContextJWKSRequestTimeout = 2 * time.Second - maxWorkContextJWKSCacheTTL = 24 * time.Hour - maxWorkContextJWKSRequestTimeout = 30 * time.Second - maxWorkContextJWKSBytes = 256 * 1024 - maxWorkContextJWKSKeys = 64 -) - -// WorkContextJWKSVerifierOptions configures rotation-aware public Work Context -// verification. The verifier caches only public keys and never stores bearer -// tokens or private material. -type WorkContextJWKSVerifierOptions struct { - URL string - HTTPClient *http.Client - CacheTTL time.Duration - RequestTimeout time.Duration - Now func() time.Time - ClockSkew time.Duration -} - -// WorkContextJWKSVerifier verifies signed Work Contexts against a bounded, -// cached Ed25519 JWKS. An unknown key ID forces one generation-aware refresh, -// so rotation is picked up immediately without a request stampede. -type WorkContextJWKSVerifier struct { - mu sync.Mutex - url string - httpClient *http.Client - cacheTTL time.Duration - requestTimeout time.Duration - now func() time.Time - clockSkew time.Duration - verifier *WorkContextVerifier - keyIDs map[string]struct{} - expiresAt time.Time - generation uint64 - unknownRefreshGeneration uint64 - outageRefreshGeneration uint64 -} - -// NewWorkContextJWKSVerifier validates configuration without performing -// network I/O. The first Verify call fetches the JWKS. -func NewWorkContextJWKSVerifier( - options WorkContextJWKSVerifierOptions, -) (*WorkContextJWKSVerifier, error) { - endpoint, err := validateWorkContextJWKSURL(options.URL) - if err != nil { - return nil, err - } - cacheTTL := options.CacheTTL - if cacheTTL == 0 { - cacheTTL = defaultWorkContextJWKSCacheTTL - } - if cacheTTL < time.Second || cacheTTL > maxWorkContextJWKSCacheTTL { - return nil, fmt.Errorf( - "%w: JWKS cache TTL must be between 1s and %s", - ErrWorkContextInvalid, - maxWorkContextJWKSCacheTTL, - ) - } - requestTimeout := options.RequestTimeout - if requestTimeout == 0 { - requestTimeout = defaultWorkContextJWKSRequestTimeout - } - if requestTimeout < time.Millisecond || requestTimeout > maxWorkContextJWKSRequestTimeout { - return nil, fmt.Errorf( - "%w: JWKS request timeout must be between 1ms and %s", - ErrWorkContextInvalid, - maxWorkContextJWKSRequestTimeout, - ) - } - now := options.Now - if now == nil { - now = time.Now - } - client := options.HTTPClient - if client == nil { - client = &http.Client{ - CheckRedirect: func(_ *http.Request, _ []*http.Request) error { - return http.ErrUseLastResponse - }, - } - } - return &WorkContextJWKSVerifier{ - url: endpoint, httpClient: client, cacheTTL: cacheTTL, - requestTimeout: requestTimeout, now: now, clockSkew: options.ClockSkew, - }, nil -} - -// Verify establishes Work Context trust using the current cached JWKS. -func (v *WorkContextJWKSVerifier) Verify( - ctx context.Context, - token WorkContextToken, - expected WorkContextExpectations, -) (*basev0.WorkContextV1, error) { - if v == nil { - return nil, fmt.Errorf("%w: nil JWKS verifier", ErrWorkContextInvalid) - } - if ctx == nil { - return nil, fmt.Errorf("%w: nil context", ErrWorkContextInvalid) - } - keyID, err := workContextTokenKeyID(token) - if err != nil { - return nil, err - } - verifier, keyIDs, generation, err := v.current(ctx) - if err != nil { - return nil, err - } - if _, known := keyIDs[keyID]; known { - return verifier.Verify(token, expected) - } - - // A different caller may already have refreshed this generation. Passing - // the observed generation lets that refresh satisfy every waiter. - verifier, _, _, err = v.refreshUnknown(ctx, generation) - if err != nil { - return nil, err - } - return verifier.Verify(token, expected) -} - -// Refresh fetches and installs the key set immediately, warming the cache so a -// service can fail closed at boot: call it during startup and treat a non-nil -// error as fatal rather than letting the first requests race the initial fetch. -// -// Each call always performs a fetch and is not deduplicated: it forces a live -// fetch so the caller can prove the endpoint is reachable now. Concurrent calls -// serialize on the same lock and each fetch independently, so call it once at -// boot rather than fanning it out across goroutines. -func (v *WorkContextJWKSVerifier) Refresh(ctx context.Context) error { - if v == nil { - return fmt.Errorf("%w: nil JWKS verifier", ErrWorkContextInvalid) - } - if ctx == nil { - return fmt.Errorf("%w: nil context", ErrWorkContextInvalid) - } - v.mu.Lock() - defer v.mu.Unlock() - _, _, _, err := v.refreshLocked(ctx) - if err == nil { - // A successful full refresh starts a fresh generation in which one - // unknown key may force an early refresh for normal key rotation. - v.unknownRefreshGeneration = 0 - } - return err -} - -func (v *WorkContextJWKSVerifier) current( - ctx context.Context, -) (*WorkContextVerifier, map[string]struct{}, uint64, error) { - v.mu.Lock() - defer v.mu.Unlock() - if v.verifier != nil && v.now().UTC().Before(v.expiresAt) { - return v.verifier, cloneKeyIDs(v.keyIDs), v.generation, nil - } - verifier, keyIDs, generation, err := v.refreshLocked(ctx) - if err == nil { - // A scheduled cache refresh starts a fresh generation in which one - // unknown key may force an early refresh for normal key rotation. - v.unknownRefreshGeneration = 0 - } - return verifier, keyIDs, generation, err -} - -func (v *WorkContextJWKSVerifier) refreshUnknown( - ctx context.Context, - observedGeneration uint64, -) (*WorkContextVerifier, map[string]struct{}, uint64, error) { - v.mu.Lock() - defer v.mu.Unlock() - if v.verifier != nil && v.generation != observedGeneration { - return v.verifier, cloneKeyIDs(v.keyIDs), v.generation, nil - } - if v.verifier != nil && v.unknownRefreshGeneration == v.generation { - // The single refresh this generation allows was already spent. If it - // failed because the key set was unreachable, the key still can't be - // obtained, so this stays an outage rather than degrading to an - // invalid-token rejection against the stale cache. - if v.outageRefreshGeneration == v.generation { - return nil, nil, v.generation, fmt.Errorf( - "%w: Work Context JWKS unreachable during key rotation", - ErrWorkContextUnavailable, - ) - } - return v.verifier, cloneKeyIDs(v.keyIDs), v.generation, nil - } - - // Reserve this generation before network I/O. A failed rotation refresh - // must not let attacker-controlled key IDs turn verification into an - // unbounded JWKS request loop. The normal TTL refresh opens a new window. - v.unknownRefreshGeneration = v.generation - verifier, keyIDs, generation, err := v.refreshLocked(ctx) - if err != nil { - if errors.Is(err, ErrWorkContextUnavailable) { - v.outageRefreshGeneration = v.generation - } - return nil, nil, generation, err - } - v.unknownRefreshGeneration = generation - return verifier, keyIDs, generation, nil -} - -func (v *WorkContextJWKSVerifier) refreshLocked( - ctx context.Context, -) (*WorkContextVerifier, map[string]struct{}, uint64, error) { - keys, err := v.fetch(ctx) - if err != nil { - return nil, nil, v.generation, err - } - verifier, err := NewWorkContextVerifier(WorkContextVerifierOptions{ - PublicKeys: keys, - Now: v.now, - ClockSkew: v.clockSkew, - }) - if err != nil { - return nil, nil, v.generation, err - } - keyIDs := make(map[string]struct{}, len(keys)) - for keyID := range keys { - keyIDs[keyID] = struct{}{} - } - if v.generation == ^uint64(0) { - return nil, nil, v.generation, fmt.Errorf("%w: JWKS generation exhausted", ErrWorkContextInvalid) - } - v.verifier = verifier - v.keyIDs = keyIDs - v.expiresAt = v.now().UTC().Add(v.cacheTTL) - v.generation++ - return verifier, cloneKeyIDs(keyIDs), v.generation, nil -} - -func (v *WorkContextJWKSVerifier) fetch(ctx context.Context) (map[string]ed25519.PublicKey, error) { - requestContext, cancel := context.WithTimeout(ctx, v.requestTimeout) - defer cancel() - request, err := http.NewRequestWithContext(requestContext, http.MethodGet, v.url, nil) - if err != nil { - return nil, fmt.Errorf("%w: create JWKS request: %v", ErrWorkContextInvalid, err) - } - request.Header.Set("Accept", "application/json") - response, err := v.httpClient.Do(request) - if err != nil { - return nil, fmt.Errorf("%w: fetch Work Context JWKS: %v", ErrWorkContextUnavailable, err) - } - defer func() { - _ = response.Body.Close() - }() - if response.Request != nil && response.Request.URL != nil && - response.Request.URL.String() != v.url { - return nil, fmt.Errorf("%w: Work Context JWKS redirected", ErrWorkContextInvalid) - } - if response.StatusCode != http.StatusOK { - _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4*1024)) - sentinel := ErrWorkContextInvalid - if response.StatusCode == http.StatusTooManyRequests || response.StatusCode >= 500 { - sentinel = ErrWorkContextUnavailable - } - return nil, fmt.Errorf( - "%w: Work Context JWKS returned HTTP %d", - sentinel, - response.StatusCode, - ) - } - if contentType := response.Header.Get("Content-Type"); contentType != "" { - mediaType, _, parseErr := mime.ParseMediaType(contentType) - if parseErr != nil || mediaType != "application/json" { - return nil, fmt.Errorf("%w: Work Context JWKS is not application/json", ErrWorkContextInvalid) - } - } - payload, err := io.ReadAll(io.LimitReader(response.Body, maxWorkContextJWKSBytes+1)) - if err != nil { - return nil, fmt.Errorf("%w: read Work Context JWKS: %v", ErrWorkContextInvalid, err) - } - if len(payload) > maxWorkContextJWKSBytes { - return nil, fmt.Errorf( - "%w: Work Context JWKS exceeds %d bytes", - ErrWorkContextInvalid, - maxWorkContextJWKSBytes, - ) - } - return parseWorkContextJWKS(payload) -} - -type workContextJWKS struct { - Keys []workContextJWK `json:"keys"` -} - -type workContextJWK struct { - KeyType string `json:"kty"` - Curve string `json:"crv"` - Use string `json:"use"` - Alg string `json:"alg"` - KeyID string `json:"kid"` - X string `json:"x"` -} - -func parseWorkContextJWKS(payload []byte) (map[string]ed25519.PublicKey, error) { - var document workContextJWKS - if err := json.Unmarshal(payload, &document); err != nil { - return nil, fmt.Errorf("%w: decode Work Context JWKS: %v", ErrWorkContextInvalid, err) - } - if len(document.Keys) == 0 || len(document.Keys) > maxWorkContextJWKSKeys { - return nil, fmt.Errorf( - "%w: Work Context JWKS must contain between 1 and %d keys", - ErrWorkContextInvalid, - maxWorkContextJWKSKeys, - ) - } - keys := make(map[string]ed25519.PublicKey, len(document.Keys)) - for _, key := range document.Keys { - if key.KeyType != "OKP" || key.Curve != "Ed25519" || - key.Alg != "" && key.Alg != "EdDSA" || - key.Use != "" && key.Use != "sig" { - return nil, fmt.Errorf("%w: JWKS contains a non-Ed25519 signing key", ErrWorkContextInvalid) - } - if err := validateBounded("key_id", key.KeyID, workContextMaxKindBytes, true); err != nil { - return nil, err - } - decoded, err := base64.RawURLEncoding.DecodeString(key.X) - if err != nil || len(decoded) != ed25519.PublicKeySize { - return nil, fmt.Errorf( - "%w: JWKS key %q has an invalid Ed25519 public key", - ErrWorkContextInvalid, - key.KeyID, - ) - } - if _, duplicate := keys[key.KeyID]; duplicate { - return nil, fmt.Errorf("%w: JWKS has duplicate key ID %q", ErrWorkContextInvalid, key.KeyID) - } - keys[key.KeyID] = append(ed25519.PublicKey(nil), decoded...) - } - return keys, nil -} - -func workContextTokenKeyID(token WorkContextToken) (string, error) { - if token.empty() { - return "", fmt.Errorf("%w: empty token", ErrWorkContextInvalid) - } - payloadSegment, _, found := strings.Cut(token.encoded, ".") - if !found { - return "", fmt.Errorf("%w: malformed token", ErrWorkContextInvalid) - } - payload, err := base64.RawURLEncoding.DecodeString(payloadSegment) - if err != nil { - return "", fmt.Errorf("%w: malformed token payload", ErrWorkContextInvalid) - } - probe := struct { - KeyID string `json:"key_id"` - }{} - if err := json.Unmarshal(payload, &probe); err != nil { - return "", fmt.Errorf("%w: decode key id: %v", ErrWorkContextInvalid, err) - } - if err := validateBounded("key_id", probe.KeyID, workContextMaxKindBytes, true); err != nil { - return "", err - } - return probe.KeyID, nil -} - -func validateWorkContextJWKSURL(raw string) (string, error) { - raw = strings.TrimSpace(raw) - parsed, err := url.Parse(raw) - if err != nil || parsed.Host == "" || parsed.User != nil || - parsed.Fragment != "" || parsed.RawQuery != "" || - parsed.Scheme != "http" && parsed.Scheme != "https" { - return "", fmt.Errorf("%w: Work Context JWKS URL must be an absolute HTTP(S) URL without credentials, query, or fragment", ErrWorkContextInvalid) - } - return parsed.String(), nil -} - -func cloneKeyIDs(source map[string]struct{}) map[string]struct{} { - cloned := make(map[string]struct{}, len(source)) - for keyID := range source { - cloned[keyID] = struct{}{} - } - return cloned -} diff --git a/workcontext/work_context_jwks_test.go b/workcontext/work_context_jwks_test.go deleted file mode 100644 index 256c5cc..0000000 --- a/workcontext/work_context_jwks_test.go +++ /dev/null @@ -1,483 +0,0 @@ -package workcontext - -import ( - "context" - "crypto/ed25519" - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -func TestWorkContextJWKSVerifierCachesAndRefreshesUnknownRotation(t *testing.T) { - firstPublic, firstPrivate := workContextJWKSKey(1) - secondPublic, secondPrivate := workContextJWKSKey(2) - var requests atomic.Int32 - var mu sync.RWMutex - keys := map[string]ed25519.PublicKey{"key-1": firstPublic} - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - requests.Add(1) - mu.RLock() - defer mu.RUnlock() - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write(workContextJWKSJSON(t, keys)) - })) - t.Cleanup(server.Close) - - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: server.URL, Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - first := workContextJWKSToken(t, "key-1", firstPrivate) - for range 3 { - claims, err := verifier.Verify(t.Context(), first, WorkContextExpectations{ - Issuer: "https://accounts.codefly.dev/work-context", Audience: "warden.evidence", - }) - require.NoError(t, err) - require.Equal(t, "tenant-codefly", claims.GetTenantId()) - } - require.EqualValues(t, 1, requests.Load()) - - mu.Lock() - keys = map[string]ed25519.PublicKey{"key-2": secondPublic} - mu.Unlock() - second := workContextJWKSToken(t, "key-2", secondPrivate) - _, err = verifier.Verify(t.Context(), second, WorkContextExpectations{ - Issuer: "https://accounts.codefly.dev/work-context", Audience: "warden.evidence", - }) - require.NoError(t, err) - require.EqualValues(t, 2, requests.Load()) - - // Removed keys fail after the rotation refresh. The verifier permits only - // one unknown-key refresh per cache generation, so arbitrary key IDs cannot - // turn verification into an unbounded request loop. - for index := range 50 { - _, err = verifier.Verify( - t.Context(), - workContextJWKSToken(t, fmt.Sprintf("unknown-%d", index), firstPrivate), - WorkContextExpectations{}, - ) - require.Error(t, err) - } - _, err = verifier.Verify(t.Context(), first, WorkContextExpectations{}) - require.Error(t, err) - require.EqualValues(t, 2, requests.Load()) -} - -func TestWorkContextJWKSVerifierRejectsRedirects(t *testing.T) { - publicKey, privateKey := workContextJWKSKey(1) - var destinationRequests atomic.Int32 - destination := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - destinationRequests.Add(1) - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write(workContextJWKSJSON(t, map[string]ed25519.PublicKey{"key-1": publicKey})) - })) - t.Cleanup(destination.Close) - source := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - http.Redirect(writer, request, destination.URL, http.StatusTemporaryRedirect) - })) - t.Cleanup(source.Close) - - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: source.URL, Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - _, err = verifier.Verify( - t.Context(), - workContextJWKSToken(t, "key-1", privateKey), - WorkContextExpectations{}, - ) - require.Error(t, err) - require.Zero(t, destinationRequests.Load()) -} - -func TestWorkContextJWKSVerifierSuppressesConcurrentRotationRefresh(t *testing.T) { - firstPublic, _ := workContextJWKSKey(1) - secondPublic, secondPrivate := workContextJWKSKey(2) - var requests atomic.Int32 - var rotated atomic.Bool - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - requests.Add(1) - keys := map[string]ed25519.PublicKey{"key-1": firstPublic} - if rotated.Load() { - keys = map[string]ed25519.PublicKey{"key-2": secondPublic} - } - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write(workContextJWKSJSON(t, keys)) - })) - t.Cleanup(server.Close) - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: server.URL, Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - _, err = verifier.Verify( - t.Context(), - workContextJWKSToken(t, "key-1", workContextJWKSPrivateForSeed(1)), - WorkContextExpectations{}, - ) - require.NoError(t, err) - rotated.Store(true) - - token := workContextJWKSToken(t, "key-2", secondPrivate) - const callers = 20 - start := make(chan struct{}) - failures := make(chan error, callers) - var wait sync.WaitGroup - for range callers { - wait.Add(1) - go func() { - defer wait.Done() - <-start - _, verifyErr := verifier.Verify(context.Background(), token, WorkContextExpectations{}) - failures <- verifyErr - }() - } - close(start) - wait.Wait() - close(failures) - for verifyErr := range failures { - require.NoError(t, verifyErr) - } - require.EqualValues(t, 2, requests.Load()) -} - -func TestWorkContextJWKSVerifierRefreshesExpiredCacheWithoutRefreshingBadSignatures(t *testing.T) { - publicKey, privateKey := workContextJWKSKey(1) - _, wrongPrivateKey := workContextJWKSKey(2) - var requests atomic.Int32 - var mu sync.RWMutex - now := workContextTestTime - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - requests.Add(1) - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write(workContextJWKSJSON(t, map[string]ed25519.PublicKey{"key-1": publicKey})) - })) - t.Cleanup(server.Close) - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: server.URL, - Now: func() time.Time { - mu.RLock() - defer mu.RUnlock() - return now - }, - CacheTTL: time.Minute, - }) - require.NoError(t, err) - - _, err = verifier.Verify( - t.Context(), - workContextJWKSToken(t, "key-1", privateKey), - WorkContextExpectations{}, - ) - require.NoError(t, err) - require.EqualValues(t, 1, requests.Load()) - - // A known key ID with an invalid signature is an invalid token, not a key - // rotation signal, and must never cause network I/O. - _, err = verifier.Verify( - t.Context(), - workContextJWKSToken(t, "key-1", wrongPrivateKey), - WorkContextExpectations{}, - ) - require.Error(t, err) - require.EqualValues(t, 1, requests.Load()) - - mu.Lock() - now = now.Add(time.Minute) - mu.Unlock() - _, err = verifier.Verify( - t.Context(), - workContextJWKSToken(t, "key-1", privateKey), - WorkContextExpectations{}, - ) - require.NoError(t, err) - require.EqualValues(t, 2, requests.Load()) -} - -func TestWorkContextJWKSVerifierBoundsAndValidatesRemoteKeys(t *testing.T) { - cases := []struct { - name string - status int - contentType string - body []byte - }{ - {name: "status", status: http.StatusNotFound, contentType: "application/json", body: []byte(`{}`)}, - {name: "content type", status: http.StatusOK, contentType: "text/plain", body: []byte(`{}`)}, - {name: "empty", status: http.StatusOK, contentType: "application/json", body: []byte(`{"keys":[]}`)}, - {name: "wrong curve", status: http.StatusOK, contentType: "application/json", body: []byte(`{"keys":[{"kty":"OKP","crv":"X25519","kid":"key-1","x":"AA"}]}`)}, - {name: "oversized", status: http.StatusOK, contentType: "application/json", body: make([]byte, maxWorkContextJWKSBytes+1)}, - } - _, privateKey := workContextJWKSKey(1) - token := workContextJWKSToken(t, "key-1", privateKey) - for _, testCase := range cases { - t.Run(testCase.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - writer.Header().Set("Content-Type", testCase.contentType) - writer.WriteHeader(testCase.status) - _, _ = writer.Write(testCase.body) - })) - t.Cleanup(server.Close) - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: server.URL, Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - _, err = verifier.Verify(t.Context(), token, WorkContextExpectations{}) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.NotErrorIs(t, err, ErrWorkContextUnavailable) - }) - } -} - -func TestWorkContextJWKSVerifierReportsIssuerOutageAsUnavailable(t *testing.T) { - _, privateKey := workContextJWKSKey(1) - token := workContextJWKSToken(t, "key-1", privateKey) - - t.Run("transport failure", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - endpoint := server.URL - server.Close() - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: endpoint, Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - _, err = verifier.Verify(t.Context(), token, WorkContextExpectations{}) - require.ErrorIs(t, err, ErrWorkContextUnavailable) - require.NotErrorIs(t, err, ErrWorkContextInvalid) - }) - - for _, status := range []int{http.StatusServiceUnavailable, http.StatusBadGateway, http.StatusTooManyRequests} { - t.Run(fmt.Sprintf("status %d", status), func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - writer.WriteHeader(status) - })) - t.Cleanup(server.Close) - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: server.URL, Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - _, err = verifier.Verify(t.Context(), token, WorkContextExpectations{}) - require.ErrorIs(t, err, ErrWorkContextUnavailable) - require.NotErrorIs(t, err, ErrWorkContextInvalid) - }) - } -} - -func TestWorkContextJWKSVerifierKeepsUnknownKeyOutageUnavailableAfterReservation(t *testing.T) { - firstPublic, firstPrivate := workContextJWKSKey(1) - _, secondPrivate := workContextJWKSKey(2) - var requests atomic.Int32 - var down atomic.Bool - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - requests.Add(1) - if down.Load() { - writer.WriteHeader(http.StatusServiceUnavailable) - return - } - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write(workContextJWKSJSON(t, map[string]ed25519.PublicKey{"key-1": firstPublic})) - })) - t.Cleanup(server.Close) - - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: server.URL, Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - _, err = verifier.Verify( - t.Context(), - workContextJWKSToken(t, "key-1", firstPrivate), - WorkContextExpectations{}, - ) - require.NoError(t, err) - require.EqualValues(t, 1, requests.Load()) - - // Keys rotate to key-2 exactly as the issuer goes down, so the new key is - // unknown and its key set cannot be fetched. - down.Store(true) - rotated := workContextJWKSToken(t, "key-2", secondPrivate) - _, err = verifier.Verify(t.Context(), rotated, WorkContextExpectations{}) - require.ErrorIs(t, err, ErrWorkContextUnavailable) - require.NotErrorIs(t, err, ErrWorkContextInvalid) - require.EqualValues(t, 2, requests.Load()) - - // The one-refresh-per-generation reservation is now spent, but the failure - // was an outage: repeat lookups must stay Unavailable rather than degrading - // to an invalid-token rejection against the stale cache, and must not spend - // another fetch. - for range 5 { - _, err = verifier.Verify(t.Context(), rotated, WorkContextExpectations{}) - require.ErrorIs(t, err, ErrWorkContextUnavailable) - require.NotErrorIs(t, err, ErrWorkContextInvalid) - } - require.EqualValues(t, 2, requests.Load()) -} - -func TestNewWorkContextJWKSVerifierRejectsUnsafeConfiguration(t *testing.T) { - cases := []WorkContextJWKSVerifierOptions{ - {}, - {URL: "accounts.example.test/keys"}, - {URL: "file:///tmp/keys.json"}, - {URL: "https://user:secret@accounts.example.test/keys"}, - {URL: "https://accounts.example.test/keys?tenant=codefly"}, - {URL: "https://accounts.example.test/keys#fragment"}, - {URL: "https://accounts.example.test/keys", CacheTTL: time.Millisecond}, - {URL: "https://accounts.example.test/keys", CacheTTL: maxWorkContextJWKSCacheTTL + time.Second}, - {URL: "https://accounts.example.test/keys", RequestTimeout: maxWorkContextJWKSRequestTimeout + time.Millisecond}, - } - for _, options := range cases { - _, err := NewWorkContextJWKSVerifier(options) - require.Error(t, err) - } -} - -func TestWorkContextJWKSVerifierRefreshWarmsCacheAtBoot(t *testing.T) { - publicKey, privateKey := workContextJWKSKey(1) - var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - requests.Add(1) - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write(workContextJWKSJSON(t, map[string]ed25519.PublicKey{"key-1": publicKey})) - })) - t.Cleanup(server.Close) - - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: server.URL, Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - - require.NoError(t, verifier.Refresh(t.Context())) - require.EqualValues(t, 1, requests.Load()) - - // The warmed cache serves the first Verify without another fetch. - _, err = verifier.Verify( - t.Context(), - workContextJWKSToken(t, "key-1", privateKey), - WorkContextExpectations{}, - ) - require.NoError(t, err) - require.EqualValues(t, 1, requests.Load()) -} - -func TestWorkContextJWKSVerifierRefreshPreservesRotationRefreshBudget(t *testing.T) { - firstPublic, _ := workContextJWKSKey(1) - secondPublic, secondPrivate := workContextJWKSKey(2) - var requests atomic.Int32 - var rotated atomic.Bool - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - requests.Add(1) - keys := map[string]ed25519.PublicKey{"key-1": firstPublic} - if rotated.Load() { - keys = map[string]ed25519.PublicKey{"key-2": secondPublic} - } - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write(workContextJWKSJSON(t, keys)) - })) - t.Cleanup(server.Close) - - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: server.URL, Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - - require.NoError(t, verifier.Refresh(t.Context())) - require.EqualValues(t, 1, requests.Load()) - - // After a manual Refresh opens a fresh generation, one unknown key ID must - // still force exactly one rotation refresh, and no more. - rotated.Store(true) - second := workContextJWKSToken(t, "key-2", secondPrivate) - _, err = verifier.Verify(t.Context(), second, WorkContextExpectations{}) - require.NoError(t, err) - require.EqualValues(t, 2, requests.Load()) - - for index := range 20 { - _, err = verifier.Verify( - t.Context(), - workContextJWKSToken(t, fmt.Sprintf("unknown-%d", index), secondPrivate), - WorkContextExpectations{}, - ) - require.Error(t, err) - } - require.EqualValues(t, 2, requests.Load()) -} - -func TestWorkContextJWKSVerifierRefreshFailsClosedOnUnreachableEndpoint(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - writer.WriteHeader(http.StatusServiceUnavailable) - })) - t.Cleanup(server.Close) - - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: server.URL, Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - require.Error(t, verifier.Refresh(t.Context())) -} - -func TestWorkContextJWKSVerifierRefreshRejectsNilReceiverAndContext(t *testing.T) { - verifier, err := NewWorkContextJWKSVerifier(WorkContextJWKSVerifierOptions{ - URL: "https://accounts.example.test/keys", Now: func() time.Time { return workContextTestTime }, - }) - require.NoError(t, err) - require.Error(t, verifier.Refresh(nil)) - - var nilVerifier *WorkContextJWKSVerifier - require.Error(t, nilVerifier.Refresh(context.Background())) -} - -func workContextJWKSKey(seedByte byte) (ed25519.PublicKey, ed25519.PrivateKey) { - privateKey := workContextJWKSPrivateForSeed(seedByte) - return privateKey.Public().(ed25519.PublicKey), privateKey -} - -func workContextJWKSPrivateForSeed(seedByte byte) ed25519.PrivateKey { - seed := make([]byte, ed25519.SeedSize) - for index := range seed { - seed[index] = seedByte + byte(index) - } - return ed25519.NewKeyFromSeed(seed) -} - -func workContextJWKSToken( - t *testing.T, - keyID string, - privateKey ed25519.PrivateKey, -) WorkContextToken { - t.Helper() - signer, err := NewWorkContextSigner(WorkContextSignerOptions{ - Issuer: "https://accounts.codefly.dev/work-context", KeyID: keyID, - PrivateKey: privateKey, Now: func() time.Time { return workContextTestTime }, - Nonce: func() (string, error) { - return fmt.Sprintf("nonce-%s", keyID), nil - }, - }) - require.NoError(t, err) - token, _, err := signer.StartTask(workContextTestInput()) - require.NoError(t, err) - return token -} - -func workContextJWKSJSON( - t *testing.T, - keys map[string]ed25519.PublicKey, -) []byte { - t.Helper() - document := struct { - Keys []map[string]string `json:"keys"` - }{} - for keyID, publicKey := range keys { - document.Keys = append(document.Keys, map[string]string{ - "kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "use": "sig", - "kid": keyID, "x": base64.RawURLEncoding.EncodeToString(publicKey), - }) - } - payload, err := json.Marshal(document) - require.NoError(t, err) - return payload -} diff --git a/workcontext/work_context_test.go b/workcontext/work_context_test.go deleted file mode 100644 index 6a91468..0000000 --- a/workcontext/work_context_test.go +++ /dev/null @@ -1,499 +0,0 @@ -package workcontext - -import ( - "crypto/ed25519" - "encoding/base64" - "net/http" - "strings" - "testing" - "time" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/stretchr/testify/require" -) - -var workContextTestTime = time.Date(2026, 7, 23, 12, 34, 56, 0, time.UTC) - -func workContextTestKeys() (ed25519.PublicKey, ed25519.PrivateKey) { - seed := make([]byte, ed25519.SeedSize) - for index := range seed { - seed[index] = byte(index) - } - privateKey := ed25519.NewKeyFromSeed(seed) - return privateKey.Public().(ed25519.PublicKey), privateKey -} - -func workContextTestSigner(t *testing.T, now time.Time) *WorkContextSigner { - t.Helper() - _, privateKey := workContextTestKeys() - signer, err := NewWorkContextSigner(WorkContextSignerOptions{ - Issuer: "https://accounts.codefly.dev/work-context", - KeyID: "work-context-test-2026-07", - PrivateKey: privateKey, - Now: func() time.Time { return now }, - Nonce: func() (string, error) { return "nonce-fixed-for-golden", nil }, - }) - require.NoError(t, err) - return signer -} - -func workContextTestInput() StartTaskInput { - return StartTaskInput{ - Audience: "warden.evidence", - TenantID: "tenant-codefly", - OwnerPrincipalID: "principal-antoine", - TaskID: "task-roadmap", - SessionID: "session-root", - AuthorizationRevision: ^uint64(0), - ReplayPolicy: WorkContextReplayIdempotent, - AuthorityScopes: []*basev0.WorkScopeV1{ - { - ResourceKind: "repository", - Actions: []string{"write", "read", "write"}, - ResourceIds: []string{"repo-warden", "repo-codefly"}, - }, - { - ResourceKind: "evidence", - Actions: []string{"append"}, - }, - }, - ActorChain: []*basev0.WorkActorV1{ - { - PrincipalId: "agent-claude-code", - PrincipalKind: "agent", - DelegationId: "delegation-1", - GrantedScopes: []*basev0.WorkScopeV1{ - { - ResourceKind: "repository", - Actions: []string{"write", "read"}, - ResourceIds: []string{"repo-warden"}, - }, - { - ResourceKind: "evidence", - Actions: []string{"append"}, - }, - }, - }, - }, - AttributionTeamIDs: []string{"team-platform", "team-ai", "team-platform"}, - WorkspaceID: "workspace-deus", - ProjectID: "project-warden", - TTL: 5 * time.Minute, - } -} - -func workContextTestVerifier(t *testing.T, now time.Time) *WorkContextVerifier { - t.Helper() - publicKey, _ := workContextTestKeys() - verifier, err := NewWorkContextVerifier(WorkContextVerifierOptions{ - PublicKeys: map[string]ed25519.PublicKey{ - "work-context-test-2026-07": publicKey, - }, - Now: func() time.Time { return now }, - }) - require.NoError(t, err) - return verifier -} - -func TestWorkContextStartTaskVerifyAndCanonicalize(t *testing.T) { - signer := workContextTestSigner(t, workContextTestTime) - token, claims, err := signer.StartTask(workContextTestInput()) - require.NoError(t, err) - require.NotEmpty(t, token.Encoded()) - require.LessOrEqual(t, len(token.Encoded()), WorkContextMaxTokenBytes) - - require.Equal(t, []string{"team-ai", "team-platform"}, claims.AttributionTeamIds) - require.Equal(t, []string{"evidence", "repository"}, []string{ - claims.AuthorityScopes[0].ResourceKind, - claims.AuthorityScopes[1].ResourceKind, - }) - require.Equal(t, []string{"read", "write"}, claims.AuthorityScopes[1].Actions) - require.Equal(t, []string{"repo-codefly", "repo-warden"}, claims.AuthorityScopes[1].ResourceIds) - - verified, err := workContextTestVerifier(t, workContextTestTime).Verify(token, WorkContextExpectations{ - Issuer: "https://accounts.codefly.dev/work-context", - Audience: "warden.evidence", - TenantID: "tenant-codefly", - OwnerPrincipalID: "principal-antoine", - TaskID: "task-roadmap", - SessionID: "session-root", - }) - require.NoError(t, err) - require.Equal(t, ^uint64(0), verified.AuthorizationRevision, "uint64 revision must survive the JavaScript-safe wire representation") - require.Equal(t, "agent-claude-code", verified.ActorChain[0].PrincipalId) -} - -func TestWorkContextAudienceExchangePreservesImmutableLineage(t *testing.T) { - signer := workContextTestSigner(t, workContextTestTime) - parentToken, parent, err := signer.StartTask(workContextTestInput()) - require.NoError(t, err) - - exchangedToken, exchanged, err := signer.ExchangeWorkContextAudience( - parentToken, - ExchangeWorkContextAudienceInput{ - Audience: "warden.gateway", - ReplayPolicy: WorkContextReplaySingleUse, - TTL: 2 * time.Minute, - }, - ) - require.NoError(t, err) - require.NotEqual(t, parentToken.Encoded(), exchangedToken.Encoded()) - require.Equal(t, "warden.gateway", exchanged.GetAudience()) - require.Equal(t, WorkContextReplaySingleUse, exchanged.GetReplayPolicy()) - require.Equal(t, workContextTestTime.Add(2*time.Minute).Unix(), exchanged.GetExpiresAtUnix()) - - require.Equal(t, parent.GetTenantId(), exchanged.GetTenantId()) - require.Equal(t, parent.GetOwnerPrincipalId(), exchanged.GetOwnerPrincipalId()) - require.Equal(t, parent.GetTaskId(), exchanged.GetTaskId()) - require.Equal(t, parent.GetSessionId(), exchanged.GetSessionId()) - require.Equal(t, parent.GetParentSessionId(), exchanged.GetParentSessionId()) - require.Equal(t, parent.GetAuthorizationRevision(), exchanged.GetAuthorizationRevision()) - require.Equal(t, parent.GetAuthorityScopes(), exchanged.GetAuthorityScopes()) - require.Equal(t, parent.GetActorChain()[0].GetPrincipalId(), exchanged.GetActorChain()[0].GetPrincipalId()) - require.Equal(t, parent.GetActorChain()[0].GetPrincipalKind(), exchanged.GetActorChain()[0].GetPrincipalKind()) - require.Equal(t, parent.GetActorChain()[0].GetDelegationId(), exchanged.GetActorChain()[0].GetDelegationId()) - require.Equal(t, parent.GetActorChain()[0].GetGrantedScopes(), exchanged.GetActorChain()[0].GetGrantedScopes()) - require.Equal(t, parent.GetAttributionTeamIds(), exchanged.GetAttributionTeamIds()) - require.Equal(t, parent.GetWorkspaceId(), exchanged.GetWorkspaceId()) - require.Equal(t, parent.GetProjectId(), exchanged.GetProjectId()) - - verified, err := workContextTestVerifier(t, workContextTestTime).Verify( - exchangedToken, - WorkContextExpectations{ - Audience: "warden.gateway", - TenantID: parent.GetTenantId(), - OwnerPrincipalID: parent.GetOwnerPrincipalId(), - TaskID: parent.GetTaskId(), - SessionID: parent.GetSessionId(), - }, - ) - require.NoError(t, err) - require.Equal(t, parent.GetActorChain(), verified.GetActorChain()) -} - -func TestWorkContextAudienceExchangeOnlyAttenuatesEffectiveScopes(t *testing.T) { - signer := workContextTestSigner(t, workContextTestTime) - parentToken, parent, err := signer.StartTask(workContextTestInput()) - require.NoError(t, err) - - token, exchanged, err := signer.ExchangeWorkContextAudience( - parentToken, - ExchangeWorkContextAudienceInput{ - Audience: "warden.gateway", - AttenuatedScopes: []*basev0.WorkScopeV1{{ - ResourceKind: "repository", - Actions: []string{"write"}, - ResourceIds: []string{"repo-warden"}, - }}, - }, - ) - require.NoError(t, err) - require.NotEmpty(t, token.Encoded()) - require.Equal(t, parent.GetActorChain()[0].GetDelegationId(), exchanged.GetActorChain()[0].GetDelegationId()) - require.Equal(t, []*basev0.WorkScopeV1{{ - ResourceKind: "repository", - Actions: []string{"write"}, - ResourceIds: []string{"repo-warden"}, - }}, exchanged.GetActorChain()[0].GetGrantedScopes()) - - for name, scopes := range map[string][]*basev0.WorkScopeV1{ - "new action": {{ - ResourceKind: "repository", - Actions: []string{"admin"}, - ResourceIds: []string{"repo-warden"}, - }}, - "new resource": {{ - ResourceKind: "repository", - Actions: []string{"write"}, - ResourceIds: []string{"repo-other"}, - }}, - "explicit to wildcard": {{ - ResourceKind: "repository", - Actions: []string{"write"}, - }}, - } { - t.Run(name, func(t *testing.T) { - _, _, exchangeErr := signer.ExchangeWorkContextAudience( - parentToken, - ExchangeWorkContextAudienceInput{ - Audience: "warden.gateway", - AttenuatedScopes: scopes, - }, - ) - require.ErrorIs(t, exchangeErr, ErrWorkContextInvalid) - require.Contains(t, exchangeErr.Error(), "widens authority") - }) - } -} - -func TestRequireWorkContextScopeUsesFinalActorEffectiveAuthority(t *testing.T) { - _, claims, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) - require.NoError(t, err) - - require.NoError(t, RequireWorkContextScope(claims, WorkContextScopeRequirement{ - ResourceKind: "repository", - Action: "write", - ResourceID: "repo-warden", - RequireExplicitResource: true, - })) - require.NoError(t, RequireWorkContextScope(claims, WorkContextScopeRequirement{ - ResourceKind: "evidence", - Action: "append", - ResourceID: "codefly.execution", - }), "empty resource IDs grant every resource of the kind") - err = RequireWorkContextScope(claims, WorkContextScopeRequirement{ - ResourceKind: "evidence", - Action: "append", - ResourceID: "codefly.execution", - RequireExplicitResource: true, - }) - require.ErrorIs(t, err, ErrWorkContextDenied, "explicit producer binding must reject wildcard authority") - - for _, denied := range []WorkContextScopeRequirement{ - {ResourceKind: "repository", Action: "write", ResourceID: "repo-codefly"}, - {ResourceKind: "repository", Action: "admin", ResourceID: "repo-warden"}, - {ResourceKind: "repository", Action: "write"}, - {ResourceKind: "deployment", Action: "write", ResourceID: "prod"}, - } { - err = RequireWorkContextScope(claims, denied) - require.ErrorIs(t, err, ErrWorkContextDenied) - } -} - -func TestRequireWorkContextScopeDistinguishesInvalidClaimsFromDenial(t *testing.T) { - err := RequireWorkContextScope(nil, WorkContextScopeRequirement{ - ResourceKind: "evidence", Action: "append", ResourceID: "codefly.execution", - }) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.NotErrorIs(t, err, ErrWorkContextDenied) - - _, claims, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) - require.NoError(t, err) - claims.ActorChain[0].GrantedScopes[0].Actions = []string{"write", "read"} - err = RequireWorkContextScope(claims, WorkContextScopeRequirement{ - ResourceKind: "repository", Action: "read", ResourceID: "repo-warden", - }) - require.ErrorIs(t, err, ErrWorkContextInvalid, "mutated non-canonical claims must fail closed") -} - -func TestWorkContextWireGolden(t *testing.T) { - token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) - require.NoError(t, err) - - // Shared with sdk-js. A byte-identical token proves payload field order, - // uint64 handling, sorting, base64url, and Ed25519 signing agree. - const expected = "eyJ0eXAiOiJjb2RlZmx5LndvcmstY29udGV4dC92MSIsImFsZ29yaXRobSI6IkVkMjU1MTkiLCJrZXlfaWQiOiJ3b3JrLWNvbnRleHQtdGVzdC0yMDI2LTA3IiwiaXNzdWVyIjoiaHR0cHM6Ly9hY2NvdW50cy5jb2RlZmx5LmRldi93b3JrLWNvbnRleHQiLCJhdWRpZW5jZSI6IndhcmRlbi5ldmlkZW5jZSIsIm5vdF9iZWZvcmVfdW5peCI6MTc4NDgxMDA5NiwiaXNzdWVkX2F0X3VuaXgiOjE3ODQ4MTAwOTYsImV4cGlyZXNfYXRfdW5peCI6MTc4NDgxMDM5Niwibm9uY2UiOiJub25jZS1maXhlZC1mb3ItZ29sZGVuIiwiYXV0aG9yaXphdGlvbl9yZXZpc2lvbiI6IjE4NDQ2NzQ0MDczNzA5NTUxNjE1IiwicmVwbGF5X3BvbGljeSI6ImlkZW1wb3RlbnQiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtY29kZWZseSIsIm93bmVyX3ByaW5jaXBhbF9pZCI6InByaW5jaXBhbC1hbnRvaW5lIiwidGFza19pZCI6InRhc2stcm9hZG1hcCIsInNlc3Npb25faWQiOiJzZXNzaW9uLXJvb3QiLCJhdXRob3JpdHlfc2NvcGVzIjpbeyJyZXNvdXJjZV9raW5kIjoiZXZpZGVuY2UiLCJhY3Rpb25zIjpbImFwcGVuZCJdLCJyZXNvdXJjZV9pZHMiOltdfSx7InJlc291cmNlX2tpbmQiOiJyZXBvc2l0b3J5IiwiYWN0aW9ucyI6WyJyZWFkIiwid3JpdGUiXSwicmVzb3VyY2VfaWRzIjpbInJlcG8tY29kZWZseSIsInJlcG8td2FyZGVuIl19XSwiYWN0b3JfY2hhaW4iOlt7InByaW5jaXBhbF9pZCI6ImFnZW50LWNsYXVkZS1jb2RlIiwicHJpbmNpcGFsX2tpbmQiOiJhZ2VudCIsImRlbGVnYXRpb25faWQiOiJkZWxlZ2F0aW9uLTEiLCJncmFudGVkX3Njb3BlcyI6W3sicmVzb3VyY2Vfa2luZCI6ImV2aWRlbmNlIiwiYWN0aW9ucyI6WyJhcHBlbmQiXSwicmVzb3VyY2VfaWRzIjpbXX0seyJyZXNvdXJjZV9raW5kIjoicmVwb3NpdG9yeSIsImFjdGlvbnMiOlsicmVhZCIsIndyaXRlIl0sInJlc291cmNlX2lkcyI6WyJyZXBvLXdhcmRlbiJdfV19XSwiYXR0cmlidXRpb25fdGVhbV9pZHMiOlsidGVhbS1haSIsInRlYW0tcGxhdGZvcm0iXSwid29ya3NwYWNlX2lkIjoid29ya3NwYWNlLWRldXMiLCJwcm9qZWN0X2lkIjoicHJvamVjdC13YXJkZW4ifQ.pVZhqvPljkv6SyFD9UAg_oKC4SPj4hIV1Ha0W33cCV04IeaayLDe0w8iVgbxy9wwE2AWY8dXbIMmvnVk0QQRAQ" - require.Equal(t, expected, token.Encoded()) -} - -// Shared with sdk-js. Each token is byte-identical to the wire golden except -// that one optional field the proto declares min_len=1 is present but empty. -// A conforming verifier in any language must reject all three. -func TestWorkContextGoldenRejectsPresentButEmptyOptionalFields(t *testing.T) { - goldens := map[string]string{ - "parent_session_id": "eyJ0eXAiOiJjb2RlZmx5LndvcmstY29udGV4dC92MSIsImFsZ29yaXRobSI6IkVkMjU1MTkiLCJrZXlfaWQiOiJ3b3JrLWNvbnRleHQtdGVzdC0yMDI2LTA3IiwiaXNzdWVyIjoiaHR0cHM6Ly9hY2NvdW50cy5jb2RlZmx5LmRldi93b3JrLWNvbnRleHQiLCJhdWRpZW5jZSI6IndhcmRlbi5ldmlkZW5jZSIsIm5vdF9iZWZvcmVfdW5peCI6MTc4NDgxMDA5NiwiaXNzdWVkX2F0X3VuaXgiOjE3ODQ4MTAwOTYsImV4cGlyZXNfYXRfdW5peCI6MTc4NDgxMDM5Niwibm9uY2UiOiJub25jZS1maXhlZC1mb3ItZ29sZGVuIiwiYXV0aG9yaXphdGlvbl9yZXZpc2lvbiI6IjE4NDQ2NzQ0MDczNzA5NTUxNjE1IiwicmVwbGF5X3BvbGljeSI6ImlkZW1wb3RlbnQiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtY29kZWZseSIsIm93bmVyX3ByaW5jaXBhbF9pZCI6InByaW5jaXBhbC1hbnRvaW5lIiwidGFza19pZCI6InRhc2stcm9hZG1hcCIsInNlc3Npb25faWQiOiJzZXNzaW9uLXJvb3QiLCJwYXJlbnRfc2Vzc2lvbl9pZCI6IiIsImF1dGhvcml0eV9zY29wZXMiOlt7InJlc291cmNlX2tpbmQiOiJldmlkZW5jZSIsImFjdGlvbnMiOlsiYXBwZW5kIl0sInJlc291cmNlX2lkcyI6W119LHsicmVzb3VyY2Vfa2luZCI6InJlcG9zaXRvcnkiLCJhY3Rpb25zIjpbInJlYWQiLCJ3cml0ZSJdLCJyZXNvdXJjZV9pZHMiOlsicmVwby1jb2RlZmx5IiwicmVwby13YXJkZW4iXX1dLCJhY3Rvcl9jaGFpbiI6W3sicHJpbmNpcGFsX2lkIjoiYWdlbnQtY2xhdWRlLWNvZGUiLCJwcmluY2lwYWxfa2luZCI6ImFnZW50IiwiZGVsZWdhdGlvbl9pZCI6ImRlbGVnYXRpb24tMSIsImdyYW50ZWRfc2NvcGVzIjpbeyJyZXNvdXJjZV9raW5kIjoiZXZpZGVuY2UiLCJhY3Rpb25zIjpbImFwcGVuZCJdLCJyZXNvdXJjZV9pZHMiOltdfSx7InJlc291cmNlX2tpbmQiOiJyZXBvc2l0b3J5IiwiYWN0aW9ucyI6WyJyZWFkIiwid3JpdGUiXSwicmVzb3VyY2VfaWRzIjpbInJlcG8td2FyZGVuIl19XX1dLCJhdHRyaWJ1dGlvbl90ZWFtX2lkcyI6WyJ0ZWFtLWFpIiwidGVhbS1wbGF0Zm9ybSJdLCJ3b3Jrc3BhY2VfaWQiOiJ3b3Jrc3BhY2UtZGV1cyIsInByb2plY3RfaWQiOiJwcm9qZWN0LXdhcmRlbiJ9.y4I8b_0ak8amI5-phVBK05RTeNSUmQWbhgoee6lKFfB7zbZvXSYZpnbJdJYLOVr2XQ9-vNcSfGKCa9CYhy8rAA", - "workspace_id": "eyJ0eXAiOiJjb2RlZmx5LndvcmstY29udGV4dC92MSIsImFsZ29yaXRobSI6IkVkMjU1MTkiLCJrZXlfaWQiOiJ3b3JrLWNvbnRleHQtdGVzdC0yMDI2LTA3IiwiaXNzdWVyIjoiaHR0cHM6Ly9hY2NvdW50cy5jb2RlZmx5LmRldi93b3JrLWNvbnRleHQiLCJhdWRpZW5jZSI6IndhcmRlbi5ldmlkZW5jZSIsIm5vdF9iZWZvcmVfdW5peCI6MTc4NDgxMDA5NiwiaXNzdWVkX2F0X3VuaXgiOjE3ODQ4MTAwOTYsImV4cGlyZXNfYXRfdW5peCI6MTc4NDgxMDM5Niwibm9uY2UiOiJub25jZS1maXhlZC1mb3ItZ29sZGVuIiwiYXV0aG9yaXphdGlvbl9yZXZpc2lvbiI6IjE4NDQ2NzQ0MDczNzA5NTUxNjE1IiwicmVwbGF5X3BvbGljeSI6ImlkZW1wb3RlbnQiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtY29kZWZseSIsIm93bmVyX3ByaW5jaXBhbF9pZCI6InByaW5jaXBhbC1hbnRvaW5lIiwidGFza19pZCI6InRhc2stcm9hZG1hcCIsInNlc3Npb25faWQiOiJzZXNzaW9uLXJvb3QiLCJhdXRob3JpdHlfc2NvcGVzIjpbeyJyZXNvdXJjZV9raW5kIjoiZXZpZGVuY2UiLCJhY3Rpb25zIjpbImFwcGVuZCJdLCJyZXNvdXJjZV9pZHMiOltdfSx7InJlc291cmNlX2tpbmQiOiJyZXBvc2l0b3J5IiwiYWN0aW9ucyI6WyJyZWFkIiwid3JpdGUiXSwicmVzb3VyY2VfaWRzIjpbInJlcG8tY29kZWZseSIsInJlcG8td2FyZGVuIl19XSwiYWN0b3JfY2hhaW4iOlt7InByaW5jaXBhbF9pZCI6ImFnZW50LWNsYXVkZS1jb2RlIiwicHJpbmNpcGFsX2tpbmQiOiJhZ2VudCIsImRlbGVnYXRpb25faWQiOiJkZWxlZ2F0aW9uLTEiLCJncmFudGVkX3Njb3BlcyI6W3sicmVzb3VyY2Vfa2luZCI6ImV2aWRlbmNlIiwiYWN0aW9ucyI6WyJhcHBlbmQiXSwicmVzb3VyY2VfaWRzIjpbXX0seyJyZXNvdXJjZV9raW5kIjoicmVwb3NpdG9yeSIsImFjdGlvbnMiOlsicmVhZCIsIndyaXRlIl0sInJlc291cmNlX2lkcyI6WyJyZXBvLXdhcmRlbiJdfV19XSwiYXR0cmlidXRpb25fdGVhbV9pZHMiOlsidGVhbS1haSIsInRlYW0tcGxhdGZvcm0iXSwid29ya3NwYWNlX2lkIjoiIiwicHJvamVjdF9pZCI6InByb2plY3Qtd2FyZGVuIn0.dmtRxR3riehJonFGLLKfhI22mJB9Sw4GhPSz5r9YElBv4QN8kKLL4XQAmZx1tluPvobRS_rchAyEHvKYiD2rBw", - "project_id": "eyJ0eXAiOiJjb2RlZmx5LndvcmstY29udGV4dC92MSIsImFsZ29yaXRobSI6IkVkMjU1MTkiLCJrZXlfaWQiOiJ3b3JrLWNvbnRleHQtdGVzdC0yMDI2LTA3IiwiaXNzdWVyIjoiaHR0cHM6Ly9hY2NvdW50cy5jb2RlZmx5LmRldi93b3JrLWNvbnRleHQiLCJhdWRpZW5jZSI6IndhcmRlbi5ldmlkZW5jZSIsIm5vdF9iZWZvcmVfdW5peCI6MTc4NDgxMDA5NiwiaXNzdWVkX2F0X3VuaXgiOjE3ODQ4MTAwOTYsImV4cGlyZXNfYXRfdW5peCI6MTc4NDgxMDM5Niwibm9uY2UiOiJub25jZS1maXhlZC1mb3ItZ29sZGVuIiwiYXV0aG9yaXphdGlvbl9yZXZpc2lvbiI6IjE4NDQ2NzQ0MDczNzA5NTUxNjE1IiwicmVwbGF5X3BvbGljeSI6ImlkZW1wb3RlbnQiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtY29kZWZseSIsIm93bmVyX3ByaW5jaXBhbF9pZCI6InByaW5jaXBhbC1hbnRvaW5lIiwidGFza19pZCI6InRhc2stcm9hZG1hcCIsInNlc3Npb25faWQiOiJzZXNzaW9uLXJvb3QiLCJhdXRob3JpdHlfc2NvcGVzIjpbeyJyZXNvdXJjZV9raW5kIjoiZXZpZGVuY2UiLCJhY3Rpb25zIjpbImFwcGVuZCJdLCJyZXNvdXJjZV9pZHMiOltdfSx7InJlc291cmNlX2tpbmQiOiJyZXBvc2l0b3J5IiwiYWN0aW9ucyI6WyJyZWFkIiwid3JpdGUiXSwicmVzb3VyY2VfaWRzIjpbInJlcG8tY29kZWZseSIsInJlcG8td2FyZGVuIl19XSwiYWN0b3JfY2hhaW4iOlt7InByaW5jaXBhbF9pZCI6ImFnZW50LWNsYXVkZS1jb2RlIiwicHJpbmNpcGFsX2tpbmQiOiJhZ2VudCIsImRlbGVnYXRpb25faWQiOiJkZWxlZ2F0aW9uLTEiLCJncmFudGVkX3Njb3BlcyI6W3sicmVzb3VyY2Vfa2luZCI6ImV2aWRlbmNlIiwiYWN0aW9ucyI6WyJhcHBlbmQiXSwicmVzb3VyY2VfaWRzIjpbXX0seyJyZXNvdXJjZV9raW5kIjoicmVwb3NpdG9yeSIsImFjdGlvbnMiOlsicmVhZCIsIndyaXRlIl0sInJlc291cmNlX2lkcyI6WyJyZXBvLXdhcmRlbiJdfV19XSwiYXR0cmlidXRpb25fdGVhbV9pZHMiOlsidGVhbS1haSIsInRlYW0tcGxhdGZvcm0iXSwid29ya3NwYWNlX2lkIjoid29ya3NwYWNlLWRldXMiLCJwcm9qZWN0X2lkIjoiIn0.x93RO6IxZaDujBg8NsmvQwiYW6FxkNdMUILIbHyKNU4rnjJZKLHo2I1o4_nTI811Kb0aHEgrk3dfqAE-Ev5eBw", - } - verifier := workContextTestVerifier(t, workContextTestTime) - for field, encoded := range goldens { - t.Run(field, func(t *testing.T) { - token, err := ParseWorkContextToken(encoded) - require.NoError(t, err) - _, err = verifier.Verify(token, WorkContextExpectations{}) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.ErrorContains(t, err, field+" must not be empty when present") - require.NotContains(t, err.Error(), "signature", "must be rejected by validation, not signature failure") - }) - } -} - -// A present optional field satisfies min_len=1 at length one; a proto-generated -// verifier accepts a single-space value, so this one must not reject it for -// emptiness. -func TestWorkContextAcceptsSingleCharacterOptionalFields(t *testing.T) { - input := workContextTestInput() - input.WorkspaceID = " " - input.ProjectID = " " - - token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(input) - require.NoError(t, err) - - verified, err := workContextTestVerifier(t, workContextTestTime).Verify(token, WorkContextExpectations{}) - require.NoError(t, err) - require.Equal(t, " ", verified.GetWorkspaceId()) - require.Equal(t, " ", verified.GetProjectId()) -} - -// StartTask omits the optional workspace_id/project_id when the caller passes an -// empty string, so the signed context carries them as absent rather than -// present-but-empty, and the verifier accepts an absent optional field. The -// present-but-empty half of the contract is pinned by -// TestWorkContextGoldenRejectsPresentButEmptyOptionalFields. -func TestWorkContextStartTaskOmitsEmptyOptionalFields(t *testing.T) { - input := workContextTestInput() - input.WorkspaceID = "" - input.ProjectID = "" - - token, claims, err := workContextTestSigner(t, workContextTestTime).StartTask(input) - require.NoError(t, err) - require.Nil(t, claims.WorkspaceId, "empty WorkspaceID must be omitted, not stored as present-but-empty") - require.Nil(t, claims.ProjectId, "empty ProjectID must be omitted, not stored as present-but-empty") - - _, err = workContextTestVerifier(t, workContextTestTime).Verify(token, WorkContextExpectations{}) - require.NoError(t, err, "a context with absent optional fields must verify") -} - -func TestWorkContextRejectsForgeryAndClaimSubstitution(t *testing.T) { - token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) - require.NoError(t, err) - verifier := workContextTestVerifier(t, workContextTestTime) - - segments := strings.Split(token.Encoded(), ".") - payload, err := base64.RawURLEncoding.DecodeString(segments[0]) - require.NoError(t, err) - payload[len(payload)/2] ^= 1 - forged, err := ParseWorkContextToken(base64.RawURLEncoding.EncodeToString(payload) + "." + segments[1]) - require.NoError(t, err) - _, err = verifier.Verify(forged, WorkContextExpectations{Audience: "warden.evidence"}) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.Contains(t, err.Error(), "signature") - - for name, expected := range map[string]WorkContextExpectations{ - "audience": {Audience: "other-service"}, - "tenant": {TenantID: "tenant-mind"}, - "owner": {OwnerPrincipalID: "principal-other"}, - "task": {TaskID: "task-other"}, - "session": {SessionID: "session-other"}, - } { - t.Run(name, func(t *testing.T) { - _, verifyErr := verifier.Verify(token, expected) - require.ErrorIs(t, verifyErr, ErrWorkContextInvalid) - require.Contains(t, verifyErr.Error(), "mismatch") - }) - } -} - -func TestWorkContextRejectsExpiredFutureAndExcessiveLifetime(t *testing.T) { - token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) - require.NoError(t, err) - - _, err = workContextTestVerifier(t, workContextTestTime.Add(7*time.Minute)).Verify(token, WorkContextExpectations{}) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.Contains(t, err.Error(), "expired") - - futureInput := workContextTestInput() - futureInput.NotBefore = workContextTestTime.Add(2 * time.Minute) - futureToken, _, err := workContextTestSigner(t, workContextTestTime).StartTask(futureInput) - require.NoError(t, err) - _, err = workContextTestVerifier(t, workContextTestTime).Verify(futureToken, WorkContextExpectations{}) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.Contains(t, err.Error(), "not active") - - longInput := workContextTestInput() - longInput.TTL = WorkContextMaxTTL + time.Second - _, _, err = workContextTestSigner(t, workContextTestTime).StartTask(longInput) - require.ErrorIs(t, err, ErrWorkContextInvalid) - require.Contains(t, err.Error(), "lifetime") -} - -func TestWorkContextChildSessionPreservesOwnershipAndAttenuates(t *testing.T) { - signer := workContextTestSigner(t, workContextTestTime) - parent, parentClaims, err := signer.StartTask(workContextTestInput()) - require.NoError(t, err) - - childToken, child, err := signer.StartChildSession(parent, StartChildSessionInput{ - SessionID: "session-child", - Audience: "warden.tools", - Actor: &basev0.WorkActorV1{ - PrincipalId: "tool-codefly-editor", - PrincipalKind: "tool", - DelegationId: "delegation-2", - GrantedScopes: []*basev0.WorkScopeV1{ - { - ResourceKind: "repository", - Actions: []string{"write"}, - ResourceIds: []string{"repo-warden"}, - }, - }, - }, - }) - require.NoError(t, err) - require.Equal(t, parentClaims.TenantId, child.TenantId) - require.Equal(t, parentClaims.OwnerPrincipalId, child.OwnerPrincipalId) - require.Equal(t, parentClaims.TaskId, child.TaskId) - require.Equal(t, "session-root", child.GetParentSessionId()) - require.Equal(t, "session-child", child.SessionId) - require.Len(t, child.ActorChain, 2) - - verified, err := workContextTestVerifier(t, workContextTestTime).Verify(childToken, WorkContextExpectations{ - Audience: "warden.tools", - TenantID: parentClaims.TenantId, - OwnerPrincipalID: parentClaims.OwnerPrincipalId, - TaskID: parentClaims.TaskId, - SessionID: "session-child", - ParentSessionID: stringPointer("session-root"), - }) - require.NoError(t, err) - require.Equal(t, "tool-codefly-editor", verified.ActorChain[1].PrincipalId) -} - -func TestWorkContextChildSessionRejectsScopeWidening(t *testing.T) { - signer := workContextTestSigner(t, workContextTestTime) - parent, _, err := signer.StartTask(workContextTestInput()) - require.NoError(t, err) - - cases := map[string][]*basev0.WorkScopeV1{ - "new action": { - {ResourceKind: "repository", Actions: []string{"admin"}, ResourceIds: []string{"repo-warden"}}, - }, - "new resource": { - {ResourceKind: "repository", Actions: []string{"read"}, ResourceIds: []string{"repo-mind"}}, - }, - "explicit to wildcard": { - {ResourceKind: "repository", Actions: []string{"read"}}, - }, - "new resource kind": { - {ResourceKind: "secrets", Actions: []string{"read"}}, - }, - } - for name, scopes := range cases { - t.Run(name, func(t *testing.T) { - _, _, childErr := signer.StartChildSession(parent, StartChildSessionInput{ - SessionID: "session-child", - Actor: &basev0.WorkActorV1{ - PrincipalId: "actor-child", - PrincipalKind: "agent", - DelegationId: "delegation-child", - GrantedScopes: scopes, - }, - }) - require.ErrorIs(t, childErr, ErrWorkContextInvalid) - require.Contains(t, childErr.Error(), "widens authority") - }) - } -} - -func TestWorkContextHeaderIsSDKOwned(t *testing.T) { - token, _, err := workContextTestSigner(t, workContextTestTime).StartTask(workContextTestInput()) - require.NoError(t, err) - request, err := http.NewRequest(http.MethodPost, "https://warden.example/receipts", nil) - require.NoError(t, err) - require.NoError(t, AttachWorkContext(request, token)) - require.Equal(t, token.Encoded(), request.Header.Get(WorkContextHeaderName)) - - extracted, err := WorkContextFromHeaders(request.Header) - require.NoError(t, err) - require.Equal(t, token.Encoded(), extracted.Encoded()) -} - -func TestWorkContextMalformedTokensFailClosed(t *testing.T) { - verifier := workContextTestVerifier(t, workContextTestTime) - for _, encoded := range []string{"", "one-segment", "a.b.c", "!!!.!!!"} { - token, parseErr := ParseWorkContextToken(encoded) - if parseErr == nil { - _, parseErr = verifier.Verify(token, WorkContextExpectations{}) - } - require.ErrorIs(t, parseErr, ErrWorkContextInvalid, encoded) - } -}