diff --git a/.claude/skills/refresh-generated-api/SKILL.md b/.claude/skills/refresh-generated-api/SKILL.md index 7ff7f7a..39daeb5 100644 --- a/.claude/skills/refresh-generated-api/SKILL.md +++ b/.claude/skills/refresh-generated-api/SKILL.md @@ -85,7 +85,9 @@ does not carry: - **`SOURCE.txt`** — the release tag you checked out (or exact PR-head commit before a release), and the module version from upstream's `module/module.package.codefly.yaml`. CI resolves release pins through - `refs/tags/` so a same-named branch cannot select the source. + `refs/tags/` so a same-named branch cannot select the source. The same ref + is the gateway `conformance/hostgateway` mints through, at the + `(auth gateway: …)` path recorded there. - **`CHANGELOG.md`** — under the unreleased heading. `### Added` for new services, facades, and fields; `### Removed (breaking)` for anything the regeneration deleted, naming the symbols a consumer will fail to compile @@ -109,6 +111,20 @@ buf generate --template "$repo/buf.gen.yaml" -o /tmp/regen-check diff -r /tmp/regen-check/gen "$repo/gen" ``` +Moving the ref also moves the broker contract `moduleauthority` is held to. +Mint through the new ref's real gateway before the PR, not after CI says so +(`conformance/hostgateway/AGENTS.md`): + +```bash +conformance/hostgateway/run.sh "$host_checkout" -race +``` + +Read a red run before fixing it. If the test file does not compile at the new +ref, the host renamed what the file binds to: re-bind those calls and accessors, +and leave the assertions alone. If it compiles and an assertion fails, the +broker contract moved: fix the client. `conformance/hostgateway/AGENTS.md` +("When it goes red") tells the two apart. + Silence is the proof. This also catches a stale `SOURCE.txt`: if the diff is non-empty for a ref you did not change, the recorded ref is not the one the committed tree came from, and that is worth its own issue rather than a quiet diff --git a/.github/actions/host-source/action.yml b/.github/actions/host-source/action.yml new file mode 100644 index 0000000..98ac926 --- /dev/null +++ b/.github/actions/host-source/action.yml @@ -0,0 +1,77 @@ +name: host source +description: >- + Check out codefly-dev/module-saas-starter, read-only, at the ref SOURCE.txt + records: the accounts proto path SOURCE.txt records, and its auth gateway + path when asked. One reading of SOURCE.txt for every job that needs the host. +inputs: + sdk: + description: Where this repository is checked out. + default: sdk + path: + description: Where to check the host out. + default: source + gateway: + description: Also check out the auth gateway path SOURCE.txt records. + default: "false" +outputs: + ref: + description: The git ref checked out (refs/tags/ or a 40-hex commit). + value: ${{ steps.source.outputs.ref }} + proto: + description: The accounts public proto path, relative to the host checkout. + value: ${{ steps.source.outputs.proto }} + gateway: + description: The auth gateway Go module path, relative to the host checkout. + value: ${{ steps.source.outputs.gateway }} +runs: + using: composite + steps: + - name: read the recorded source ref + id: source + shell: bash + env: + SOURCE_TXT: ${{ inputs.sdk }}/SOURCE.txt + WITH_GATEWAY: ${{ inputs.gateway }} + run: | + set -euo pipefail + line=$(grep -m1 -oE 'codefly-dev/module-saas-starter@(v[0-9]+\.[0-9]+\.[0-9]+|[0-9a-f]{40})$' "$SOURCE_TXT" || true) + if [ -z "$line" ]; then + echo "::error file=SOURCE.txt::no 'codefly-dev/module-saas-starter@' ref found" + exit 1 + fi + proto=$(grep -m1 -oE 'accounts public proto: [^)]+' "$SOURCE_TXT" | sed 's/^accounts public proto: //' || true) + if [ -z "$proto" ]; then + echo "::error file=SOURCE.txt::no '(accounts public proto: )' line found" + exit 1 + fi + gateway=$(grep -m1 -oE 'auth gateway: [^)]+' "$SOURCE_TXT" | sed 's/^auth gateway: //' || true) + if [ "$WITH_GATEWAY" = "true" ] && [ -z "$gateway" ]; then + echo "::error file=SOURCE.txt::no '(auth gateway: )' line found" + exit 1 + fi + ref=${line#*@} + if [[ "$ref" == v* ]]; then + ref="refs/tags/$ref" + fi + paths=$proto + if [ "$WITH_GATEWAY" = "true" ]; then + paths=$(printf '%s\n%s' "$proto" "$gateway") + fi + { + echo "ref=$ref" + echo "proto=$proto" + echo "gateway=$gateway" + echo "paths<> "$GITHUB_OUTPUT" + echo "source: codefly-dev/module-saas-starter@${line#*@} ($(echo $paths))" + # The source repo is public, so the default token is enough. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: codefly-dev/module-saas-starter + ref: ${{ steps.source.outputs.ref }} + path: ${{ inputs.path }} + sparse-checkout: ${{ steps.source.outputs.paths }} + sparse-checkout-cone-mode: false + persist-credentials: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35524c6..3c8e8a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,36 +55,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: sdk - - name: read the recorded source ref - id: source - run: | - set -euo pipefail - line=$(grep -m1 -oE 'codefly-dev/module-saas-starter@(v[0-9]+\.[0-9]+\.[0-9]+|[0-9a-f]{40})$' sdk/SOURCE.txt || true) - if [ -z "$line" ]; then - echo "::error file=SOURCE.txt::no 'codefly-dev/module-saas-starter@' ref found" - exit 1 - fi - proto=$(grep -m1 -oE 'accounts public proto: [^)]+' sdk/SOURCE.txt | sed 's/^accounts public proto: //') - if [ -z "$proto" ]; then - echo "::error file=SOURCE.txt::no '(accounts public proto: )' line found" - exit 1 - fi - ref=${line#*@} - if [[ "$ref" == v* ]]; then - ref="refs/tags/$ref" - fi - echo "ref=$ref" >> "$GITHUB_OUTPUT" - echo "proto=$proto" >> "$GITHUB_OUTPUT" - echo "source: codefly-dev/module-saas-starter@${line#*@} ($proto)" - # The source repo is public, so the default token is enough. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: codefly-dev/module-saas-starter - ref: ${{ steps.source.outputs.ref }} - path: source - sparse-checkout: ${{ steps.source.outputs.proto }} - sparse-checkout-cone-mode: false - persist-credentials: false + - id: source + uses: ./sdk/.github/actions/host-source - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: sdk/go.mod @@ -113,3 +85,28 @@ jobs: git --no-pager diff --stat git --no-pager diff | head -n 400 exit 1 + + # The module broker contract, held against the host's REAL auth-gateway + # handlers at the ref SOURCE.txt records — not only against + # moduleauthority's fake, which once still accepted a header the host had + # stopped reading. run.sh compiles conformance/hostgateway's test into the + # host's gateway package by overlay; the checkout is never modified, and + # only accounts' gRPC mints are faked, as the host's own gateway tests do. + host-gateway-conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: sdk + - id: source + uses: ./sdk/.github/actions/host-source + with: + gateway: "true" + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: sdk/go.mod + cache-dependency-path: | + sdk/go.sum + source/${{ steps.source.outputs.gateway }}/go.sum + - name: mint through the real gateway + run: sdk/conformance/hostgateway/run.sh source -race -v diff --git a/AGENTS.md b/AGENTS.md index 30391ef..88b2bc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,8 +53,8 @@ Fleet standard — [handbook#68](https://github.com/obin-ai/handbook/issues/68). descriptor has meant a text-rewritten module path, not a bad protobuf release. - **Say what you did not verify.** Unverified is not the same as working. CI - (below) runs the build walk and the `gen/` drift check, nothing more — if - you could not exercise something else, the PR says so. + (below) runs the build walk, the `gen/` drift check and the host-gateway + mint, nothing more — if you could not exercise something else, the PR says so. ## Build and test @@ -79,7 +79,9 @@ export data for the boundary gate to run. There is no lint config — The same workflow's `gen-drift` job regenerates `gen/` from the ref in `SOURCE.txt` with this repo's `buf.gen.yaml` (buf itself pinned in the workflow) and fails on any difference — the reproduction check from the -`refresh-generated-api` skill, run for you. +`refresh-generated-api` skill, run for you. Its `host-gateway-conformance` job +mints through the host's *real* gateway handlers at that same ref +(`conformance/hostgateway/run.sh `; see the `AGENTS.md` there). ## Where things live @@ -92,6 +94,7 @@ workflow) and fails on any difference — the reproduction check from the | `workcontext/` | callee-side verification: HTTP, Connect, gRPC middleware plus scope checks | | `moduleauthority/` | a module's own authority: gateway broker mints; direct Accounts authority or explicitly selected gateway authority transport | | `gen/` | generated output. Regenerated, never edited | +| `conformance/hostgateway/` | `moduleauthority` minting through the host's real gateway at the `SOURCE.txt` ref | | `internal/apiboundary` | the public-API boundary gate, with fixtures that prove it still looks | | `internal/agentcontext` | the budget and frontmatter contract for this file and the skills | diff --git a/CHANGELOG.md b/CHANGELOG.md index 663cd1a..91ad4a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # Changelog +## Unreleased — the broker contract, held against the host's real gateway + +- **Fix (moduleauthority).** A source mint's HTTP 412 returns + `ErrDelegationMissing` again, wrapping the same `*BrokerError`: the source + has no active delegation, and a person must reconnect it. It is decided by + status alone, as 403 → `ErrDelegationInvalid` is — the v0.0.94 gateway + answers 412 on that path only for `DELEGATION_MISSING` (any other + FailedPrecondition is a 502) — so no response body is read. + `ErrDelegationMissing` is no longer deprecated. `ErrDelegationRevoked` + stays deprecated: telling it apart would need the body. +- **Behaviour change.** `moduleauthority.New` refuses an `OrganizationID` that + is not a canonical (lowercase, hyphenated) UUID with `ErrInvalidCredentials` + — accounts' own rule — whichever mints the client goes on to make. Who + changes behaviour: + - a client that mints module Work Contexts or operation contexts used to + fail at its first such mint with an untyped HTTP 400; it now fails at + construction; + - a client that only mints source operation contexts, which never send the + field, used to work with such a value; it now fails at construction. + Leave `OrganizationID` empty there, or correct it. + An uppercase, braced, `urn:uuid:`, unhyphenated or space-padded UUID is + refused; the empty string still means "no organization". +- Documented: a missing or wrong organization (a shared module naming none, a + tenant-pinned module naming another) is refused as `ErrInvalidCredentials` + (HTTP 401) on the module Work Context mint and `ErrPermissionDenied` + (HTTP 403) on the operation mint (`ErrInvalidCredentials` doc, README). +- **CI.** New `host-gateway-conformance` job: `conformance/hostgateway/run.sh` + mints with the SDK's real client through module-saas-starter's real + auth-gateway handlers at the `SOURCE.txt` ref, faking only accounts' gRPC + mints. The host checkout comes from a `host-source` composite action now + shared with `gen-drift`; `SOURCE.txt` records the gateway path beside the + proto path. The runner fails unless all eight expected tests are listed, + run and pass. It also fails if the build would give the gateway a grpc, + protobuf or sdk-go/workcontext whose source differs from the host's pin, + and it resolves nothing at `@latest`: `moduleauthority`'s imports must all + be declared by the SDK's go.mod (checked `-mod=readonly` before `go get` + adds it), and everything after that one `go get` runs `-mod=readonly`. +- `moduleauthority`'s fake gateway follows the real one's prefix rule, check + order, and indifference to the legacy module-secret header; its remaining, + deliberate divergences are listed in `host_fake_test.go`. + ## Unreleased — callable sources - Reject unsupported HTTP response encodings before reading the body or diff --git a/README.md b/README.md index 6db918e..c6d45a9 100644 --- a/README.md +++ b/README.md @@ -301,7 +301,16 @@ The SDK surfaces are: per organization, since each client caches its own module Work Context. A tenant-pinned module may omit the selector or name its own tenant. The host checks the module envelope and live installation; selecting an organization - grants no authority. Source mints ignore this selector and derive their + grants no authority. The selector must be a canonical UUID (lowercase, + hyphenated), as accounts requires; `New` refuses any other form with + `ErrInvalidCredentials` at construction, whichever mints the client will + make, rather than letting the first work or operation mint fail as an + untyped 400. A client that only mints source operation contexts leaves it + empty. A missing or wrong organization — a shared module that names none, or a + tenant-pinned module that names another — is refused as unproven + credentials: `ErrInvalidCredentials` (HTTP 401) from `ModuleWorkContext`, and + `ErrPermissionDenied` (HTTP 403) from `MintModuleOperationContext`. The host + does not say that the organization was the cause. Source mints ignore this selector and derive their organization from the source's delegation. Deployment and identity requirements are in [MODULE_INSTALLATION.md](https://github.com/codefly-dev/module-saas-starter/blob/v0.0.94/module/MODULE_INSTALLATION.md) and [Establishing what a caller is running](https://github.com/codefly-dev/module-saas-starter/blob/v0.0.94/module/services/accounts/AGENTS.md#establishing-what-a-caller-is-running). @@ -475,7 +484,10 @@ compatible dependency floor with SDK and consumer builds when refreshing it. The generation plugin versions remain pinned. Record the release tag in `SOURCE.txt`; CI checks out that tag through `refs/tags/` and verifies that regeneration leaves no diff. Before a host -release, an exact PR-head commit may be used for development. +release, an exact PR-head commit may be used for development. The same ref is +the host whose real auth-gateway handlers `moduleauthority` must mint through +in CI (`conformance/hostgateway/run.sh `), so moving it moves +the broker contract too. Regeneration and matching SDK releases are owned by `module-saas-starter` and this repository. Until release automation covers both, follow the @@ -596,8 +608,12 @@ with `errors.As`. It contains only `StatusCode` and `Classification` and headers are excluded entirely; classification never depends on reading a body. The 401, 403 and 503 sentinels remain `ErrInvalidCredentials`, `ErrPermissionDenied` (or `ErrDelegationInvalid` for source mints), and -`ErrExecutionUnestablished`. Body-based delegation distinctions are gone: source -403s all return `ErrDelegationInvalid`; 412s carry only `BrokerError`. +`ErrExecutionUnestablished`. A source mint's 412 is `ErrDelegationMissing`: the +source has no active delegation to this module, and a person must connect or +reconnect it — minting again will not help. The gateway answers 412 there only +for a missing delegation, so the status alone decides it. Body-based delegation +distinctions are gone: source 403s all return `ErrDelegationInvalid`, whether +the delegation was revoked or is otherwise unusable. `BrokerError.Path` and `BrokerError.Reason` have been removed. Transport and response read failures return `*moduleauthority.BrokerTransportError` diff --git a/SOURCE.txt b/SOURCE.txt index 995682c..a77a4d9 100644 --- a/SOURCE.txt +++ b/SOURCE.txt @@ -1,5 +1,6 @@ gen/ generated from codefly-dev/module-saas-starter@v0.0.94 (accounts public proto: module/services/accounts/proto) +(auth gateway: module/services/auth-gateway/code) module version: 0.1.0 (module/module.package.codefly.yaml) This SDK's release is matched to the saas-starter module version above. diff --git a/conformance/hostgateway/AGENTS.md b/conformance/hostgateway/AGENTS.md new file mode 100644 index 0000000..129a127 --- /dev/null +++ b/conformance/hostgateway/AGENTS.md @@ -0,0 +1,98 @@ +# Host-gateway conformance + +`moduleauthority` minting through the host's **real** auth-gateway handlers — +module-saas-starter's `module/services/auth-gateway/code` at the ref +`SOURCE.txt` records — rather than only through `moduleauthority`'s +hand-written fake. The fake once still accepted `X-Codefly-Module-Secret` +after the host stopped reading it, and every test here stayed green. + +```bash +# SOURCE.txt records a release tag or a 40-hex commit; this fetches either. +ref=$(grep -m1 -oE 'module-saas-starter@(v[0-9]+\.[0-9]+\.[0-9]+|[0-9a-f]{40})$' SOURCE.txt) +ref=${ref#*@} +case $ref in v*) spec="refs/tags/$ref:refs/tags/$ref" ;; *) spec=$ref ;; esac +git init -q /tmp/saas-host +git -C /tmp/saas-host fetch -q --depth 1 https://github.com/codefly-dev/module-saas-starter.git "$spec" +git -C /tmp/saas-host checkout -q "$ref" +conformance/hostgateway/run.sh /tmp/saas-host -race +``` + +CI's `host-gateway-conformance` job runs the same script on the checkout the +`host-source` action makes (the one `gen-drift` uses). + +The script fails, rather than passes, when: +- the tests in the file are not exactly the `expected` list at its top, or + any of them did not run and pass, or one skipped. A test added to the file + is added to that list too. +- the build gives the gateway a different `google.golang.org/grpc`, + `google.golang.org/protobuf` or `sdk-go/workcontext` from the one the host + pins. The one exception is a version whose module tree is byte-identical, + checked on every run. Today that is `workcontext` b91ce91 → b3fb40d. Other + modules the SDK moves, such as `golang.org/x/*`, are printed as warnings. +- `moduleauthority` imports a package the SDK's own `go.mod` does not declare, + which the SDK's `go list -mod=readonly -deps ./moduleauthority` refuses before + `go get` adds the SDK; or the test file imports a package no required module + provides, since everything after `go get` runs with `-mod=readonly`. Between + them, nothing is resolved at `@latest`. + +## How it is wired, and why + +- `testdata/sdk_conformance_test.go` is this repo's code but `package main` of + the host's gateway: the gateway is a `main` package and its harness + (`newGatewayHarness`) is a test helper, so nothing of it can be imported. + `run.sh` adds the file to that package with `go test -overlay` and requires + this working tree (by `replace`) in a temporary copy of the gateway's + `go.mod` passed with `-modfile`. The host checkout must be at the recorded + ref and is checked to be unmodified before and after. Nothing of the host is + copied here. +- The file binds to four unexported host names, listed at its top. A host + rename breaks the compile — red, never a silent pass. The fix for that + coupling is an exported harness in the host, not a copy here. +- Only accounts' three `Mint*` RPCs are faked, on a real gRPC listener, as the + host's own gateway tests fake them: the real mints need a Kubernetes + TokenReview and a database. What accounts *decides* is therefore not under + test — only what the gateway forwards to it and how it relays each answer. +- Requiring the SDK raises some module versions the host pins. The gateway is + compiled with those, so the three that change what it does are gated (see + above), and the rest are printed. +- The host's and this SDK's `gen/saas/accounts/v1` register the same proto + files in one binary, so the script sets + `GOLANG_PROTOBUF_REGISTRATION_CONFLICT=ignore`. Nothing under test resolves + through the global registry. + +## What it holds + +The three mints accepted, and exactly `prefix`, `service_account_token`, +`organization_id`, `binding` and `source_id` reaching accounts; each accounts +refusal → gateway status → SDK sentinel; the legacy header, a missing, +duplicate, non-Bearer or empty bearer refused before accounts; the 64 KiB +token bound; and the three gateway answers the fake copies (D1, D3, D4 in +`moduleauthority/host_fake_test.go`). + +## When it goes red + +First ask which kind of red it is. Two kinds look alike after a `SOURCE.txt` +move and need opposite fixes. + +- **The test file does not compile** (`[setup failed]` / `build failed`, + pointing into `zz_saas_sdk_go_conformance_test.go`). The host changed what + the file is bound to: + - one of the four unexported names; + - `newGatewayHarness`'s results; + - an `accountsv1` request or response field the fake accounts reads or + answers with. + + Re-bind the file at the new ref. Edit `newRealGateway` and the `Get…` + accessors to the host's new spelling, and change no assertion about status, + header, body or sentinel. This is the likeliest red after a ref move, and it + says nothing yet about the SDK. +- **It compiles and an assertion fails** (`--- FAIL`). The broker contract + moved, or the client broke it. That covers a path, a header, a JSON field + name, a status, or what reaches accounts. Fix the client, or realign the + fake. Change an expectation only when the host's own change says the + contract changed, and name that host commit in the PR. +- **`run.sh` fails before or after `go test`** on the ref, the test list, a + guarded module or the host checkout's state. Its message names the cause. + +A compile error that renames an accounts *request* field is both: re-bind the +accessor, then let the assertions say whether the SDK-facing wire changed. diff --git a/conformance/hostgateway/run.sh b/conformance/hostgateway/run.sh new file mode 100755 index 0000000..4392000 --- /dev/null +++ b/conformance/hostgateway/run.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# Mint through the host's REAL auth-gateway handlers with this SDK's real +# moduleauthority client. See AGENTS.md beside this file. +# +# conformance/hostgateway/run.sh [go test flags] +# +# The checkout must be at the ref SOURCE.txt records, and is never modified: +# the test file is added to the gateway package with `go test -overlay`, and +# the SDK is required (replaced by this working tree) in a copy of the +# gateway's go.mod passed with `-modfile`. Both live in a temporary directory. +# +# It fails unless every conformance test below was listed, ran and passed; and +# unless the build gives the gateway exactly the grpc, protobuf and +# sdk-go/workcontext it ships with (or a byte-identical module tree). +set -euo pipefail + +# Every conformance test, by name. A test added to the file is added here: a +# rename, a filter that matches nothing, or a skip is a failure, not a pass. +expected=( + TestSDKConformanceFakeGatewayStillFollowsThisOne + TestSDKConformanceNoOrganization + TestSDKConformanceRefusals + TestSDKConformanceRemovedAndMalformedCredentials + TestSDKConformanceRotatedProjectedFile + TestSDKConformanceThreeMints + TestSDKConformanceTokenBound + TestSDKConformanceWrongInternalToken +) + +# The modules whose version changes what the gateway under test does. Any +# other module the SDK moves is reported, not gated. +guarded=( + google.golang.org/grpc + google.golang.org/protobuf + github.com/codefly-dev/sdk-go/workcontext +) + +if [ $# -lt 1 ]; then + echo "usage: $0 [go test flags]" >&2 + exit 2 +fi + +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +sdk=$(cd "$here/../.." && pwd) +host=$(cd "$1" && pwd) +shift + +ref=$(grep -m1 -oE 'codefly-dev/module-saas-starter@(v[0-9]+\.[0-9]+\.[0-9]+|[0-9a-f]{40})$' "$sdk/SOURCE.txt" || true) +ref=${ref#*@} +gateway=$(grep -m1 -oE 'auth gateway: [^)]+' "$sdk/SOURCE.txt" | sed 's/^auth gateway: //' || true) +if [ -z "$ref" ] || [ -z "$gateway" ]; then + echo "SOURCE.txt records no module-saas-starter ref or no '(auth gateway: )' line" >&2 + exit 1 +fi + +# The contract under test is the one gen/ was generated from, and no other. +want=$(git -C "$host" rev-parse --verify -q "$ref^{commit}" || true) +have=$(git -C "$host" rev-parse HEAD) +if [ -z "$want" ] || [ "$want" != "$have" ]; then + echo "$host is at $have, not at $ref (SOURCE.txt); check out that ref" >&2 + exit 1 +fi +unmodified() { + if [ -n "$(git -C "$host" status --porcelain)" ]; then + echo "$host has local changes; the host must be an unmodified checkout" >&2 + git -C "$host" status --short >&2 + exit 1 + fi +} +unmodified + +pkg="$host/$gateway" +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +cp "$pkg/go.mod" "$work/go.mod" +cp "$pkg/go.sum" "$work/go.sum" +cat >"$work/overlay.json" </dev/null); then + echo "moduleauthority imports a package the SDK's go.mod does not declare;" >&2 + echo "go get would resolve it at @latest. Require it in go.mod first." >&2 + exit 1 +fi +go mod edit -replace=github.com/codefly-dev/saas-sdk-go="$sdk" "$work/go.mod" +go get "${modfile[@]}" github.com/codefly-dev/saas-sdk-go/moduleauthority@v0.0.0 +build=(-mod=readonly "${modfile[@]}" -overlay="$work/overlay.json") + +version() { awk -v m="$1" '$1 == m { print $2 }'; } +host_modules=$(go list -mod=readonly -m all) +build_modules=$(go list "${build[@]}" -m all) +mkdir "$work/download" +tree() { + (cd "$work/download" && go mod download -json "$1@$2" | sed -n 's/^ "Dir": "\(.*\)",$/\1/p') +} +for mod in "${guarded[@]}"; do + from=$(version "$mod" <<<"$host_modules") + to=$(version "$mod" <<<"$build_modules") + if [ "$from" = "$to" ]; then + continue + fi + if [ -n "$from" ] && [ -n "$to" ] && diff -r -q "$(tree "$mod" "$from")" "$(tree "$mod" "$to")" >/dev/null; then + echo "note: $mod $from -> $to in the conformance build; the two module trees are byte-identical" + continue + fi + echo "the conformance build resolves $mod ${to:-(absent)}, the host pins ${from:-(absent)}," >&2 + echo "and their sources differ: the gateway under test would not be the one the host ships" >&2 + exit 1 +done +moved=$(diff <(echo "$host_modules") <(echo "$build_modules") | grep '^>' || true) +if [ -n "$moved" ]; then + echo "warning: module versions in the conformance build that differ from the host's own:" + echo "$moved" +fi + +listed=$(go test "${build[@]}" -list '^TestSDKConformance' . | { grep '^Test' || true; } | sort) +if [ "$listed" != "$(printf '%s\n' "${expected[@]}" | sort)" ]; then + echo "the conformance tests in the file are not the ones run.sh expects:" >&2 + diff <(printf '%s\n' "${expected[@]}" | sort) <(echo "$listed") >&2 || true + exit 1 +fi + +status=0 +go test "${build[@]}" -run '^TestSDKConformance' -count=1 -v "$@" . | tee "$work/test.log" || status=$? +unmodified +if [ "$status" -ne 0 ]; then + exit "$status" +fi +for name in "${expected[@]}"; do + if ! grep -q "^--- PASS: $name (" "$work/test.log"; then + echo "$name did not run and pass" >&2 + exit 1 + fi +done +if grep -q -- '--- SKIP:' "$work/test.log"; then + echo "a conformance test was skipped:" >&2 + grep -- '--- SKIP:' "$work/test.log" >&2 + exit 1 +fi +echo "conformance: ${#expected[@]} tests ran and passed against module-saas-starter@$ref" diff --git a/conformance/hostgateway/testdata/sdk_conformance_test.go b/conformance/hostgateway/testdata/sdk_conformance_test.go new file mode 100644 index 0000000..6514750 --- /dev/null +++ b/conformance/hostgateway/testdata/sdk_conformance_test.go @@ -0,0 +1,633 @@ +package main + +// SDK conformance against the host's REAL auth-gateway. +// +// This file belongs to codefly-dev/saas-sdk-go, not to the host. run.sh +// compiles it into module-saas-starter's auth-gateway package (package main, +// module/services/auth-gateway/code at the ref SOURCE.txt records) with +// `go test -overlay`, so the host checkout stays byte-for-byte unmodified, and +// drives the SDK's real moduleauthority client against the host's real +// Gateway.ServeHTTP over httptest. +// +// Only accounts is faked, on a real gRPC listener and exactly the way the +// host's own gateway tests fake it (gateway_modules_test.go, +// gateway_operation_context_test.go, gateway_source_operation_context_test.go): +// accounts' real mints need a Kubernetes TokenReview and a database. +// +// The host exports no test seam for this, so the file binds to these +// unexported names of the host package, and to nothing else of it: +// +// - newGatewayHarness (gateway_test.go): the host's own gateway under test; +// - (*Gateway).authz, (*ExtAuthz).backendConn: where the harness's accounts +// connection is set, as the host's own tests set it; +// - (*ExtAuthz).internalToken: the perimeter credential the harness chose; +// - the accounts types in auth-gateway/pkg/gen/saas/accounts/v1. +// +// A host change to any of them fails this file's compilation: red, never a +// silent pass. + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + "time" + + accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" + + "github.com/codefly-dev/saas-sdk-go/moduleauthority" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + sdkPrefix = "documents" + sdkOrg = "66666666-6666-4666-8666-666666666666" + sdkSource = "33333333-3333-4333-8333-333333333333" + sdkDelegation = "22222222-2222-4222-8222-222222222222" + sdkTenant = "11111111-1111-4111-8111-111111111111" + sdkPrincipal = "00000000-0000-4000-8000-00000000beef" + sdkOwner = "44444444-4444-4444-8444-444444444444" + sdkBinding = "model" + + // accountsErrorDomain is the ErrorInfo domain of accounts' delegation + // refusals, spelled out rather than taken from the host's constant: it is + // the wire contract between accounts and the gateway. + accountsErrorDomain = "accounts.saas.codefly.dev" + + mintWork = "MintModuleWorkContext" + mintOperation = "MintModuleOperationContext" + mintSource = "MintSourceOperationContext" +) + +var mintPaths = map[string]string{ + mintWork: "/modules/_work-context", + mintOperation: "/modules/_operation-context", + mintSource: "/modules/_source-operation-context", +} + +// accountsCall is one mint the gateway forwarded to the accounts fake. +type accountsCall struct { + method string + internal []string + md metadata.MD + work *accountsv1.ModuleMintWorkContextRequest + op *accountsv1.ModuleMintOperationContextRequest + src *accountsv1.ModuleMintSourceOperationContextRequest +} + +type fakeAccounts struct { + mu sync.Mutex + calls []accountsCall + // refuse is accounts' answer per method, instead of a Work Context. + refuse map[string]error +} + +func (a *fakeAccounts) record(ctx context.Context, call accountsCall) error { + md, _ := metadata.FromIncomingContext(ctx) + call.md = md + call.internal = md.Get("x-codefly-internal-token") + a.mu.Lock() + defer a.mu.Unlock() + a.calls = append(a.calls, call) + return a.refuse[call.method] +} + +func (a *fakeAccounts) snapshot() []accountsCall { + a.mu.Lock() + defer a.mu.Unlock() + return slices.Clone(a.calls) +} + +// edgeRequest is one HTTP request as it reached the gateway. +type edgeRequest struct { + path string + header http.Header +} + +// realGateway is the host's gateway behind httptest, with what reached it at +// the edge and what it forwarded to accounts. +type realGateway struct { + server *httptest.Server + internalToken string + accounts *fakeAccounts + + mu sync.Mutex + edge []edgeRequest +} + +func (g *realGateway) BaseURL() string { return g.server.URL } +func (g *realGateway) HTTPClient() *http.Client { return g.server.Client() } + +func (g *realGateway) edgeRequests() []edgeRequest { + g.mu.Lock() + defer g.mu.Unlock() + return slices.Clone(g.edge) +} + +func unary(name string, handle func(context.Context, func(any) error) (any, error)) grpc.MethodDesc { + return grpc.MethodDesc{MethodName: name, Handler: func(_ any, ctx context.Context, dec func(any) error, _ grpc.UnaryServerInterceptor) (any, error) { + return handle(ctx, dec) + }} +} + +func newRealGateway(t *testing.T) *realGateway { + t.Helper() + gw, _, _, _ := newGatewayHarness(t) + accounts := &fakeAccounts{refuse: map[string]error{}} + expires := timestamppb.New(time.Now().Add(10 * time.Minute).UTC().Truncate(time.Second)) + + server := grpc.NewServer() + server.RegisterService(&grpc.ServiceDesc{ + ServiceName: "saas.accounts.v1.ModuleCapabilitiesService", + HandlerType: (*any)(nil), + Methods: []grpc.MethodDesc{ + unary(mintWork, func(ctx context.Context, dec func(any) error) (any, error) { + req := &accountsv1.ModuleMintWorkContextRequest{} + if err := dec(req); err != nil { + return nil, err + } + if err := accounts.record(ctx, accountsCall{method: mintWork, work: req}); err != nil { + return nil, err + } + return &accountsv1.ModuleMintWorkContextResponse{ + Token: "module-work-context.sig", ExpiresAt: expires, PrincipalId: sdkPrincipal, Tenant: sdkTenant, + }, nil + }), + unary(mintOperation, func(ctx context.Context, dec func(any) error) (any, error) { + req := &accountsv1.ModuleMintOperationContextRequest{} + if err := dec(req); err != nil { + return nil, err + } + if err := accounts.record(ctx, accountsCall{method: mintOperation, op: req}); err != nil { + return nil, err + } + return &accountsv1.ModuleMintOperationContextResponse{ + Token: "operation-context.sig", ExpiresAt: expires, PrincipalId: sdkPrincipal, Tenant: sdkTenant, + Audience: "modelservice", Binding: req.GetBinding(), + }, nil + }), + unary(mintSource, func(ctx context.Context, dec func(any) error) (any, error) { + req := &accountsv1.ModuleMintSourceOperationContextRequest{} + if err := dec(req); err != nil { + return nil, err + } + if err := accounts.record(ctx, accountsCall{method: mintSource, src: req}); err != nil { + return nil, err + } + return &accountsv1.ModuleMintSourceOperationContextResponse{ + Token: "source-context.sig", ExpiresAt: expires, PrincipalId: sdkPrincipal, Tenant: sdkTenant, + Audience: "ingest", Binding: "source-sync", DelegationId: sdkDelegation, SourceId: req.GetSourceId(), + OwnerPrincipalId: sdkOwner, + }, nil + }), + }, + Metadata: "saas/accounts/v1/module_registration.proto", + }, accounts) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + conn, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + gw.authz.backendConn = conn + + g := &realGateway{internalToken: gw.authz.internalToken, accounts: accounts} + g.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + g.mu.Lock() + g.edge = append(g.edge, edgeRequest{path: r.URL.Path, header: r.Header.Clone()}) + g.mu.Unlock() + gw.ServeHTTP(w, r) + })) + t.Cleanup(g.server.Close) + if g.internalToken == "" { + t.Fatal("the host harness configured no internal token") + } + return g +} + +// rotatingToken is a projected token source whose value a test changes +// between mints, counting each read. +type rotatingToken struct { + mu sync.Mutex + token string + reads int +} + +func (s *rotatingToken) ProjectedToken() (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.reads++ + return s.token, nil +} + +func (s *rotatingToken) set(token string) { + s.mu.Lock() + defer s.mu.Unlock() + s.token = token +} + +func newSDKClient(t *testing.T, g *realGateway, token moduleauthority.ProjectedTokenSource, org string) *moduleauthority.Client { + t.Helper() + client, err := moduleauthority.New( + moduleauthority.Seams{Gateway: g, InternalToken: g.internalToken}, + moduleauthority.Credentials{Prefix: sdkPrefix, Token: token, OrganizationID: org}, + ) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + return client +} + +func mint(ctx context.Context, client *moduleauthority.Client, method string) error { + var err error + switch method { + case mintWork: + _, err = client.ModuleWorkContext(ctx) + case mintOperation: + _, err = client.MintModuleOperationContext(ctx, sdkBinding) + case mintSource: + _, err = client.MintSourceOperationContext(ctx, sdkSource) + } + return err +} + +// assertEdge holds the request the SDK sent: exactly one projected bearer, +// the internal token, and no removed module-secret header. +func assertEdge(t *testing.T, g *realGateway, request edgeRequest, path, token string) { + t.Helper() + if request.path != path { + t.Fatalf("SDK called %s, want %s", request.path, path) + } + if got := request.header.Values("Authorization"); !slices.Equal(got, []string{"Bearer " + token}) { + t.Fatalf("SDK sent Authorization %d time(s), want exactly one Bearer carrying the current projected token", len(got)) + } + if got := request.header.Values("X-Codefly-Internal-Token"); !slices.Equal(got, []string{g.internalToken}) { + t.Fatal("SDK did not send the internal token exactly once") + } + if len(request.header.Values("X-Codefly-Module-Secret")) != 0 { + t.Fatal("SDK sent the removed X-Codefly-Module-Secret header") + } +} + +// assertForwarded holds what the gateway forwarded to accounts: its own +// internal token, and no credential header of the module's. +func assertForwarded(t *testing.T, g *realGateway, call accountsCall, method string) { + t.Helper() + if call.method != method { + t.Fatalf("accounts received %s, want %s", call.method, method) + } + if !slices.Equal(call.internal, []string{g.internalToken}) { + t.Fatalf("accounts received internal token %d time(s), want the gateway's own once", len(call.internal)) + } + if len(call.md.Get("x-codefly-module-secret")) != 0 || len(call.md.Get("authorization")) != 0 { + t.Fatal("the gateway forwarded a module credential header to accounts") + } +} + +// Every mint, through the real handlers: accepted, with exactly the fields +// accounts needs, and the token read afresh for each. +func TestSDKConformanceThreeMints(t *testing.T) { + g := newRealGateway(t) + token := &rotatingToken{token: "projected-1"} + client := newSDKClient(t, g, token, sdkOrg) + ctx := t.Context() + + work, err := client.ModuleWorkContext(ctx) + if err != nil { + t.Fatalf("work-context mint: %v", err) + } + if work.Encoded() != "module-work-context.sig" { + t.Fatalf("work context = %q", work.Encoded()) + } + + token.set("projected-2") + op, err := client.MintModuleOperationContext(ctx, sdkBinding) + if err != nil { + t.Fatalf("operation-context mint: %v", err) + } + if op.Token.Encoded() != "operation-context.sig" || op.BindingID != sdkBinding || op.Audience != "modelservice" || + op.Tenant != sdkTenant || op.PrincipalID != sdkPrincipal || !op.ExpiresAt.After(time.Now()) { + t.Fatalf("operation context = %+v", op) + } + + token.set("projected-3") + src, err := client.MintSourceOperationContext(ctx, sdkSource) + if err != nil { + t.Fatalf("source-operation-context mint: %v", err) + } + if src.Token.Encoded() != "source-context.sig" || src.SourceID != sdkSource || src.DelegationID != sdkDelegation || + src.OwnerPrincipalID != sdkOwner || src.Tenant != sdkTenant || src.Audience != "ingest" || src.BindingID != "source-sync" { + t.Fatalf("source operation context = %+v", src) + } + + edge := g.edgeRequests() + calls := g.accounts.snapshot() + if len(edge) != 3 || len(calls) != 3 || token.reads != 3 { + t.Fatalf("edge requests = %d, accounts calls = %d, token reads = %d; want 3 each", len(edge), len(calls), token.reads) + } + assertEdge(t, g, edge[0], mintPaths[mintWork], "projected-1") + assertEdge(t, g, edge[1], mintPaths[mintOperation], "projected-2") + assertEdge(t, g, edge[2], mintPaths[mintSource], "projected-3") + + assertForwarded(t, g, calls[0], mintWork) + if w := calls[0].work; w.GetPrefix() != sdkPrefix || w.GetServiceAccountToken() != "projected-1" || w.GetOrganizationId() != sdkOrg { + t.Fatalf("accounts received work mint prefix=%q token=%q organization_id=%q", w.GetPrefix(), w.GetServiceAccountToken(), w.GetOrganizationId()) + } + assertForwarded(t, g, calls[1], mintOperation) + if o := calls[1].op; o.GetPrefix() != sdkPrefix || o.GetServiceAccountToken() != "projected-2" || + o.GetBinding() != sdkBinding || o.GetOrganizationId() != sdkOrg { + t.Fatalf("accounts received operation mint prefix=%q token=%q binding=%q organization_id=%q", + o.GetPrefix(), o.GetServiceAccountToken(), o.GetBinding(), o.GetOrganizationId()) + } + assertForwarded(t, g, calls[2], mintSource) + if s := calls[2].src; s.GetPrefix() != sdkPrefix || s.GetServiceAccountToken() != "projected-3" || + s.GetSourceId() != sdkSource || s.GetDelegationId() != "" { + t.Fatalf("accounts received source mint prefix=%q token=%q source_id=%q delegation_id=%q", + s.GetPrefix(), s.GetServiceAccountToken(), s.GetSourceId(), s.GetDelegationId()) + } +} + +// A tenant-pinned module names no organization: none is forwarded. +func TestSDKConformanceNoOrganization(t *testing.T) { + g := newRealGateway(t) + client := newSDKClient(t, g, &rotatingToken{token: "projected-1"}, "") + for _, method := range []string{mintWork, mintOperation} { + if err := mint(t.Context(), client, method); err != nil { + t.Fatalf("%s: %v", method, err) + } + } + calls := g.accounts.snapshot() + if len(calls) != 2 || calls[0].work.GetOrganizationId() != "" || calls[1].op.GetOrganizationId() != "" { + t.Fatalf("accounts received an organization the module did not name: %d calls", len(calls)) + } +} + +// The kubelet rotates the projected file under a running process; the next +// mint presents the rotated, trimmed token. +func TestSDKConformanceRotatedProjectedFile(t *testing.T) { + g := newRealGateway(t) + path := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(path, []byte("rotation-a\n"), 0o600); err != nil { + t.Fatal(err) + } + client := newSDKClient(t, g, moduleauthority.ProjectedTokenFile(path), "") + if err := mint(t.Context(), client, mintOperation); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(" rotation-b "), 0o600); err != nil { + t.Fatal(err) + } + if err := mint(t.Context(), client, mintSource); err != nil { + t.Fatal(err) + } + calls := g.accounts.snapshot() + if len(calls) != 2 || calls[0].op.GetServiceAccountToken() != "rotation-a" || calls[1].src.GetServiceAccountToken() != "rotation-b" { + t.Fatal("accounts did not receive each mint's current projected token") + } +} + +// The SDK's 64 KiB bound is the gateway's: the largest token both accept, +// and one byte more both refuse. +func TestSDKConformanceTokenBound(t *testing.T) { + g := newRealGateway(t) + largest := strings.Repeat("a", 64*1024) + if err := mint(t.Context(), newSDKClient(t, g, &rotatingToken{token: largest}, ""), mintOperation); err != nil { + t.Fatalf("a 64 KiB token: %v", err) + } + if calls := g.accounts.snapshot(); len(calls) != 1 || calls[0].op.GetServiceAccountToken() != largest { + t.Fatal("accounts did not receive the 64 KiB token") + } + err := mint(t.Context(), newSDKClient(t, g, &rotatingToken{token: largest + "a"}, ""), mintOperation) + if !errors.Is(err, moduleauthority.ErrInvalidCredentials) || len(g.edgeRequests()) != 1 { + t.Fatalf("the SDK sent a token over 64 KiB (%v)", err) + } + if code := post(t, g, mintPaths[mintOperation], `{"prefix":"documents","binding":"model"}`, func(h http.Header) { + h.Set("Authorization", "Bearer "+largest+"a") + }); code != http.StatusUnauthorized { + t.Fatalf("the gateway answered %d to a token over 64 KiB, want 401", code) + } + if len(g.accounts.snapshot()) != 1 { + t.Fatal("the gateway forwarded a token over 64 KiB") + } +} + +func refusal(code codes.Code, reason string) error { + st := status.New(code, "refused") + if reason != "" { + var err error + if st, err = st.WithDetails(&errdetails.ErrorInfo{Reason: reason, Domain: accountsErrorDomain}); err != nil { + panic(err) + } + } + return st.Err() +} + +var sdkSentinels = []error{ + moduleauthority.ErrInvalidCredentials, + moduleauthority.ErrPermissionDenied, + moduleauthority.ErrExecutionUnestablished, + moduleauthority.ErrDelegationMissing, + moduleauthority.ErrDelegationInvalid, + moduleauthority.ErrDelegationRevoked, +} + +// Each refusal, from accounts' code through the real gateway's status to the +// SDK's error: the status is preserved, and the SDK types it with exactly one +// sentinel (or none). +func TestSDKConformanceRefusals(t *testing.T) { + for _, tc := range []struct { + name string + method string + accounts error + status int + sentinel error + }{ + // Work-context mint: accounts answers PermissionDenied for a module it + // cannot prove — and for an organization the module may not mint for + // (none named by a shared module, another named by a pinned one). + {"work unproven or wrong organization", mintWork, refusal(codes.PermissionDenied, ""), 401, moduleauthority.ErrInvalidCredentials}, + {"work execution unestablished", mintWork, refusal(codes.Unavailable, ""), 503, moduleauthority.ErrExecutionUnestablished}, + {"work invalid request", mintWork, refusal(codes.InvalidArgument, ""), 400, nil}, + {"work unauthenticated", mintWork, refusal(codes.Unauthenticated, ""), 502, nil}, + {"work failed precondition", mintWork, refusal(codes.FailedPrecondition, ""), 502, nil}, + + {"operation unproven", mintOperation, refusal(codes.Unauthenticated, ""), 401, moduleauthority.ErrInvalidCredentials}, + // The same organization refusal is a 403 on the operation mint. + {"operation denied or wrong organization", mintOperation, refusal(codes.PermissionDenied, ""), 403, moduleauthority.ErrPermissionDenied}, + {"operation invalid request", mintOperation, refusal(codes.InvalidArgument, ""), 400, nil}, + {"operation execution unestablished", mintOperation, refusal(codes.Unavailable, ""), 503, moduleauthority.ErrExecutionUnestablished}, + + {"source unproven", mintSource, refusal(codes.Unauthenticated, ""), 401, moduleauthority.ErrInvalidCredentials}, + {"source delegation missing", mintSource, refusal(codes.FailedPrecondition, "DELEGATION_MISSING"), 412, moduleauthority.ErrDelegationMissing}, + {"source other precondition", mintSource, refusal(codes.FailedPrecondition, "SOMETHING_ELSE"), 502, nil}, + {"source delegation revoked", mintSource, refusal(codes.PermissionDenied, "DELEGATION_REVOKED"), 403, moduleauthority.ErrDelegationInvalid}, + {"source delegation invalid", mintSource, refusal(codes.PermissionDenied, "DELEGATION_INVALID"), 403, moduleauthority.ErrDelegationInvalid}, + {"source forbidden without a reason", mintSource, refusal(codes.PermissionDenied, ""), 403, moduleauthority.ErrDelegationInvalid}, + {"source execution unestablished", mintSource, refusal(codes.Unavailable, ""), 503, moduleauthority.ErrExecutionUnestablished}, + } { + t.Run(tc.name, func(t *testing.T) { + g := newRealGateway(t) + g.accounts.refuse[tc.method] = tc.accounts + err := mint(t.Context(), newSDKClient(t, g, &rotatingToken{token: "projected-refused"}, sdkOrg), tc.method) + var be *moduleauthority.BrokerError + if !errors.As(err, &be) || be.StatusCode != tc.status { + t.Fatalf("accounts %v: SDK error %v, want a BrokerError with HTTP %d", status.Code(tc.accounts), err, tc.status) + } + for _, sentinel := range sdkSentinels { + if errors.Is(err, sentinel) != (sentinel == tc.sentinel) { + t.Fatalf("HTTP %d: SDK error %v, want only %v", tc.status, err, tc.sentinel) + } + } + if strings.Contains(err.Error(), "projected-refused") { + t.Fatal("the SDK error carries the projected token") + } + if calls := g.accounts.snapshot(); len(calls) != 1 || calls[0].method != tc.method { + t.Fatalf("accounts calls = %d, want one %s", len(calls), tc.method) + } + }) + } +} + +// A wrong perimeter credential is refused by the gateway itself, before +// accounts, and the SDK reports it as invalid credentials. +func TestSDKConformanceWrongInternalToken(t *testing.T) { + g := newRealGateway(t) + client, err := moduleauthority.New( + moduleauthority.Seams{Gateway: g, InternalToken: "not-the-internal-token"}, + moduleauthority.Credentials{Prefix: sdkPrefix, Token: &rotatingToken{token: "projected-1"}}, + ) + if err != nil { + t.Fatal(err) + } + for _, method := range []string{mintWork, mintOperation, mintSource} { + var be *moduleauthority.BrokerError + if err := mint(t.Context(), client, method); !errors.Is(err, moduleauthority.ErrInvalidCredentials) || !errors.As(err, &be) || be.StatusCode != 401 { + t.Fatalf("%s: %v, want ErrInvalidCredentials with HTTP 401", method, err) + } + } + if calls := g.accounts.snapshot(); len(calls) != 0 { + t.Fatalf("accounts was called %d time(s) behind a wrong internal token", len(calls)) + } +} + +func post(t *testing.T, g *realGateway, path, body string, headers func(http.Header)) int { + t.Helper() + request, err := http.NewRequestWithContext(t.Context(), http.MethodPost, g.server.URL+path, strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-Codefly-Internal-Token", g.internalToken) + headers(request.Header) + response, err := g.server.Client().Do(request) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + return response.StatusCode +} + +var mintBodies = map[string]string{ + mintWork: `{"prefix":"documents"}`, + mintOperation: `{"prefix":"documents","binding":"model"}`, + mintSource: `{"prefix":"documents","source_id":"` + sdkSource + `"}`, +} + +// The wire shape the SDK replaced, and malformed bearers, are refused at the +// gateway on every mint path; accounts never hears of them. +func TestSDKConformanceRemovedAndMalformedCredentials(t *testing.T) { + for _, tc := range []struct { + name string + headers func(http.Header) + }{ + {"legacy secret header", func(h http.Header) { h.Set("X-Codefly-Module-Secret", "removed-module-secret") }}, + {"missing bearer", func(http.Header) {}}, + {"duplicate bearer", func(h http.Header) { + h.Add("Authorization", "Bearer projected-a") + h.Add("Authorization", "Bearer projected-b") + }}, + {"non-Bearer scheme", func(h http.Header) { h.Set("Authorization", "Secret removed-module-secret") }}, + {"lowercase scheme", func(h http.Header) { h.Set("Authorization", "bearer projected-1") }}, + {"empty bearer", func(h http.Header) { h.Set("Authorization", "Bearer ") }}, + } { + for _, method := range []string{mintWork, mintOperation, mintSource} { + t.Run(tc.name+"/"+method, func(t *testing.T) { + g := newRealGateway(t) + if code := post(t, g, mintPaths[method], mintBodies[method], tc.headers); code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", code) + } + if calls := g.accounts.snapshot(); len(calls) != 0 { + t.Fatal("accounts was called") + } + }) + } + } +} + +// The three answers moduleauthority's fake gateway copies from this one +// (host_fake_test.go, D1, D3, D4). They are not the SDK's contract, but if +// the host changes them the fake no longer follows the real gateway and must +// be realigned: TestHostFakeFollowsTheRealGatewayOrder holds the fake to the +// same table. +func TestSDKConformanceFakeGatewayStillFollowsThisOne(t *testing.T) { + for _, tc := range []struct { + name string + method string + body string + bearer string + legacy bool + accounts error + want int + }{ + {"D1 legacy header beside a bearer is ignored", mintWork, mintBodies[mintWork], "projected-1", true, nil, http.StatusOK}, + {"D3 trailing hyphen prefix", mintWork, `{"prefix":"documents-"}`, "projected-1", false, nil, http.StatusBadRequest}, + {"D4 binding before token", mintOperation, `{"prefix":"documents","binding":""}`, "not-the-token", false, refusal(codes.Unauthenticated, ""), http.StatusBadRequest}, + {"D4 source before token", mintSource, `{"prefix":"documents"}`, "not-the-token", false, refusal(codes.Unauthenticated, ""), http.StatusBadRequest}, + } { + t.Run(tc.name, func(t *testing.T) { + g := newRealGateway(t) + if tc.accounts != nil { + g.accounts.refuse[tc.method] = tc.accounts + } + code := post(t, g, mintPaths[tc.method], tc.body, func(h http.Header) { + h.Set("Authorization", "Bearer "+tc.bearer) + if tc.legacy { + h.Set("X-Codefly-Module-Secret", "removed-module-secret") + } + }) + if code != tc.want { + t.Fatalf("status = %d, want %d", code, tc.want) + } + calls := g.accounts.snapshot() + if tc.want != http.StatusOK { + if len(calls) != 0 { + t.Fatal("accounts was called for a request the gateway refused") + } + return + } + if len(calls) != 1 { + t.Fatalf("accounts calls = %d, want 1", len(calls)) + } + assertForwarded(t, g, calls[0], tc.method) + }) + } +} diff --git a/moduleauthority/broker_errors.go b/moduleauthority/broker_errors.go index 1623489..ad481b2 100644 --- a/moduleauthority/broker_errors.go +++ b/moduleauthority/broker_errors.go @@ -24,7 +24,8 @@ const ( // only the HTTP status and its classification, and formats a fixed diagnostic // per class. Response bodies, headers and underlying errors are never retained. // Use errors.As to inspect it, including when wrapped with ErrInvalidCredentials -// (401), ErrPermissionDenied or ErrDelegationInvalid (403), or +// (401), ErrPermissionDenied or ErrDelegationInvalid (403), +// ErrDelegationMissing (412 from the source mint), or // ErrExecutionUnestablished (503). A 502 or 503 may be worth retrying; a refusal // must not be treated as a transient transport failure. type BrokerError struct { diff --git a/moduleauthority/broker_errors_test.go b/moduleauthority/broker_errors_test.go index 9a8cf29..4f7c65e 100644 --- a/moduleauthority/broker_errors_test.go +++ b/moduleauthority/broker_errors_test.go @@ -11,23 +11,30 @@ import ( "testing" ) +// brokerMintCase is one mint and the sentinel each mint types a status with. +// The statuses are driven by a raw transport, not by the host fake: they pin +// the SDK's mapping for any status, including some the v0.0.94 gateway never +// sends on that path (a 403 or 412 on the work-context mint — accounts' +// PermissionDenied is a 401 there). The real gateway's statuses per path are +// held by conformance/hostgateway. type brokerMintCase struct { - path string - call func(*Client) error - forbidden error + path string + call func(*Client) error + forbidden error + preconditionFailed error } func brokerMints(t *testing.T) []brokerMintCase { return []brokerMintCase{ - {hostWorkContextPath, func(c *Client) error { _, err := c.ModuleWorkContext(t.Context()); return err }, ErrPermissionDenied}, + {hostWorkContextPath, func(c *Client) error { _, err := c.ModuleWorkContext(t.Context()); return err }, ErrPermissionDenied, nil}, {hostOperationContextPath, func(c *Client) error { _, err := c.MintModuleOperationContext(t.Context(), "binding") return err - }, ErrPermissionDenied}, + }, ErrPermissionDenied, nil}, {hostSourceOperationContextPath, func(c *Client) error { _, err := c.MintSourceOperationContext(t.Context(), exampleSource) return err - }, ErrDelegationInvalid}, + }, ErrDelegationInvalid, ErrDelegationMissing}, } } @@ -115,6 +122,8 @@ func TestBrokerFailuresPreserveStatus(t *testing.T) { want = ErrInvalidCredentials case 403: want = mint.forbidden + case 412: + want = mint.preconditionFailed case 503: want = ErrExecutionUnestablished } diff --git a/moduleauthority/client.go b/moduleauthority/client.go index 798d736..deff822 100644 --- a/moduleauthority/client.go +++ b/moduleauthority/client.go @@ -32,6 +32,7 @@ import ( "time" codefly "github.com/codefly-dev/sdk-go/workcontext" + "github.com/google/uuid" v1 "github.com/codefly-dev/saas-sdk-go/gen/saas/accounts/v1" "github.com/codefly-dev/saas-sdk-go/gen/saas/accounts/v1/accountsv1connect" @@ -40,12 +41,15 @@ import ( const defaultRefreshSkew = 30 * time.Second var ( - // ErrInvalidCredentials: the module's credentials are missing or its - // projected token could not be read, is empty or exceeds 64 KiB, or the - // broker refused to prove the module (HTTP 401 — a token the host refused, - // another workload's token, a pod running a build the host does not approve, - // or a wrong internal token; - // the host does not say which). + // ErrInvalidCredentials: the module's credentials are missing, its + // OrganizationID is not a canonical UUID, or its projected token could not + // be read, is empty or exceeds 64 KiB; or the broker refused to prove the + // module (HTTP 401 — a token the host refused, another workload's token, a + // pod running a build the host does not approve, a wrong internal token, or, + // on the module Work Context mint, an organization the module may not mint + // for: a shared module that names none, or a tenant-pinned module that + // names another. The host does not say which). The operation mint refuses + // that organization case with ErrPermissionDenied (HTTP 403) instead. ErrInvalidCredentials = errors.New("module authority: invalid module credentials") // ErrExecutionUnestablished: the host could not establish what this pod is // running (HTTP 503) — its API server unreachable, or no execution binding @@ -122,9 +126,17 @@ type Credentials struct { Prefix string Token ProjectedTokenSource // OrganizationID selects the organization for module Work Context and - // operation-context mints. Required for a shared module; a tenant-pinned - // module may omit it or name its own tenant. The host checks the module's - // envelope and live installation; this selector grants no authority. + // operation-context mints, as a canonical (lowercase, hyphenated) UUID; + // New refuses any other form with ErrInvalidCredentials, as accounts would, + // even for a client that only mints source operation contexts: a + // misconfigured credential fails at construction, not at whichever mint + // first sends it. + // Required for a shared module; a tenant-pinned module may omit it or name + // its own tenant. The host checks the module's envelope and live + // installation; this selector grants no authority. A missing or wrong + // organization is refused as unproven credentials: ErrInvalidCredentials + // (HTTP 401) on the module Work Context mint, ErrPermissionDenied (HTTP 403) + // on the operation mint. // Use a separate Client for each organization: its cached module Work // Context belongs to that organization. Source-context mints ignore this // field because the source's delegation determines their organization. @@ -247,12 +259,20 @@ type Client struct { // ErrInvalidSeams, on a missing gateway, gateway base URL or internal token, // on an authority seam that is set but has no address, and on any plaintext seam to a non-loopback address the // consumer has not asserted protected; and with ErrInvalidCredentials on an -// empty prefix or no token source. It opens no connection and reads no token: -// the token is read before each mint. +// empty prefix, no token source, or an OrganizationID that is not a canonical +// UUID. It opens no connection and reads no token: the token is read before +// each mint. func New(seams Seams, credentials Credentials) (*Client, error) { if credentials.Prefix == "" || credentials.Token == nil { return nil, ErrInvalidCredentials } + if org := credentials.OrganizationID; org != "" { + // accounts' own rule (module_identity.go, moduleMintOrganization): any + // other spelling of a UUID is an InvalidArgument at the first mint. + if id, err := uuid.Parse(org); err != nil || id.String() != org { + return nil, fmt.Errorf("%w: the organization ID must be a canonical UUID", ErrInvalidCredentials) + } + } if seams.InternalToken == "" { return nil, fmt.Errorf("%w: the internal token is required", ErrInvalidSeams) } diff --git a/moduleauthority/host_fake_test.go b/moduleauthority/host_fake_test.go index 4b726b9..be323ff 100644 --- a/moduleauthority/host_fake_test.go +++ b/moduleauthority/host_fake_test.go @@ -41,6 +41,34 @@ import ( // server: the module-exposure check of grpc_auth_interceptor.go (internal // token, procedure served) and the module Work Context check of // module_capabilities_server.go. +// +// fakeGateway is the edge-case double; the contract itself is held by +// conformance/hostgateway, which mints through the host's REAL gateway +// handlers at the tag in SOURCE.txt. The fake follows the real gateway's +// perimeter, prefix rule and check order (internal token, then exactly one +// Bearer, then body, prefix and binding/source validation, then accounts' +// verdict). Where it still differs, it differs on purpose: +// +// - D2: it decodes the body into map[string]string and records every key, so +// an extra or misnamed field is visible to an exact-body assertion; the +// real gateway decodes into a struct and drops unknown keys silently. +// - D5: a test may inject any refusal on any path. The real gateway never +// answers 403 (or 412) on /modules/_work-context — accounts' +// PermissionDenied is a 401 there — so no test here injects one. +// - D6: accounts' Unauthenticated and FailedPrecondition on the work-context +// mint (a 502 from the real gateway) are not modelled; only injected +// statuses are. +// - D7: it accepts one internal token; the real gateway also accepts the +// previous one during rotation. +// - D8: refusal bodies are whatever a test injects; the real gateway sends +// fixed strings and ErrorInfo reasons. The client never reads them. +// +// D1 (the legacy X-Codefly-Module-Secret header beside a valid bearer: the +// fake used to refuse it, the real gateway ignores it), D3 (the prefix +// pattern) and D4 (the token verdict before binding/source validation) are +// aligned with the real gateway. That the client never sends the legacy header +// is asserted on the request itself (assertProjectedBearer), not left to the +// fake's verdict. const ( testInternalToken = "internal-perimeter-token" @@ -73,9 +101,13 @@ type failingToken struct{ err error } func (f failingToken) ProjectedToken() (string, error) { return "", f.err } -// catalogIdentity mirrors the gateway's validCatalogIdentity closely enough -// for a prefix: one lowercase DNS-ish segment. -var catalogIdentity = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`) +// catalogIdentity is the gateway's validCatalogIdentity (routing_artifact.go +// at v0.0.94): one DNS label, at most 63 characters. +var catalogIdentity = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$`) + +func validCatalogIdentity(value string) bool { + return len(value) <= 63 && catalogIdentity.MatchString(value) +} // refusal is a broker answer other than 200. type refusal struct { @@ -168,12 +200,8 @@ func (g *fakeGateway) serve(w http.ResponseWriter, r *http.Request) { plainError(w, http.StatusUnauthorized, "unauthorized") return } - // The real gateway no longer reads the legacy header. Refuse even its - // presence here as a stricter regression guard against sending it again. - if len(r.Header.Values("X-Codefly-Module-Secret")) != 0 { - plainError(w, http.StatusUnauthorized, "unauthorized") - return - } + // The real gateway reads no X-Codefly-Module-Secret header at all, so + // neither does this one: it is neither refused nor accepted on its own. // Exactly one Bearer token, trimmed and bounded as gateway_modules.go // lines 112–122 at v0.0.94 (the literal limit is independent of the SDK). authorization := r.Header.Values("Authorization") @@ -192,11 +220,27 @@ func (g *fakeGateway) serve(w http.ResponseWriter, r *http.Request) { return } g.bodies[r.URL.Path] = append(g.bodies[r.URL.Path], payload) - if !catalogIdentity.MatchString(payload["prefix"]) { + if !validCatalogIdentity(payload["prefix"]) { plainError(w, http.StatusBadRequest, "invalid prefix") return } - // accounts' verdict on the token: the gateway maps it per path. + // The gateway validates each path's own fields before it calls accounts. + switch r.URL.Path { + case hostOperationContextPath: + if binding := payload["binding"]; binding == "" || len(binding) > 128 { + plainError(w, http.StatusBadRequest, "invalid binding") + return + } + case hostSourceOperationContextPath: + delegation, source := payload["delegation_id"], payload["source_id"] + if (delegation == "") == (source == "") || len(delegation) > 64 || len(source) > 64 { + plainError(w, http.StatusBadRequest, "exactly one of delegation_id or source_id is required") + return + } + } + // accounts' verdict on the token, which the gateway maps to 401 on every + // path (PermissionDenied on the work-context mint, Unauthenticated on the + // operation mints). if payload["prefix"] != testPrefix || projected != g.projectedToken { plainError(w, http.StatusUnauthorized, "unauthorized") return @@ -214,10 +258,6 @@ func (g *fakeGateway) serve(w http.ResponseWriter, r *http.Request) { answer(w, map[string]string{"token": token, "expiresAt": expiresAt, "principalId": testPrincipal, "tenant": testTenant}) case hostOperationContextPath: binding := payload["binding"] - if binding == "" || len(binding) > 128 { - plainError(w, http.StatusBadRequest, "invalid binding") - return - } if rf, ok := g.refuse[hostOperationContextPath]; ok { rf.write(w) return @@ -230,11 +270,7 @@ func (g *fakeGateway) serve(w http.ResponseWriter, r *http.Request) { "tenant": testTenant, "audience": "modelservice", "binding": binding, }) case hostSourceOperationContextPath: - delegation, source := payload["delegation_id"], payload["source_id"] - if (delegation == "") == (source == "") || len(delegation) > 64 || len(source) > 64 { - plainError(w, http.StatusBadRequest, "exactly one of delegation_id or source_id is required") - return - } + source := payload["source_id"] if rf, ok := g.refuse[hostSourceOperationContextPath]; ok { rf.write(w) return diff --git a/moduleauthority/projected_token_test.go b/moduleauthority/projected_token_test.go index 01a5f0c..24dc5ba 100644 --- a/moduleauthority/projected_token_test.go +++ b/moduleauthority/projected_token_test.go @@ -146,7 +146,6 @@ func TestHostFakeRefusesRemovedOrMalformedCredentials(t *testing.T) { {name: "whitespace", authorization: []string{"Bearer \t"}}, {name: "oversized", authorization: []string{"Bearer " + strings.Repeat("x", 64*1024+1)}, acceptedToken: strings.Repeat("x", 64*1024+1)}, {name: "secret only", legacy: true}, - {name: "dual header", authorization: []string{"Bearer " + testProjectedToken}, legacy: true, acceptedToken: testProjectedToken}, } { t.Run(mint.path+"/"+tc.name, func(t *testing.T) { g := newFakeGateway(t) @@ -173,6 +172,43 @@ func TestHostFakeRefusesRemovedOrMalformedCredentials(t *testing.T) { } } +// The fake answers these three as the v0.0.94 gateway does (D1, D3, D4 in +// host_fake_test.go); conformance/hostgateway pins the same three answers on +// the real handlers, so the two cannot drift apart unnoticed. +func TestHostFakeFollowsTheRealGatewayOrder(t *testing.T) { + for _, tc := range []struct { + name string + path, body string + bearer string + legacy bool + want int + }{ + // D1: the legacy header beside a valid bearer is ignored, not refused. + {"legacy header ignored", hostWorkContextPath, `{"prefix":"documents"}`, testProjectedToken, true, http.StatusOK}, + // D3: one DNS label — a trailing hyphen is not a prefix. + {"trailing hyphen prefix", hostWorkContextPath, `{"prefix":"documents-"}`, testProjectedToken, false, http.StatusBadRequest}, + // D4: the binding is validated before accounts judges the token. + {"binding before token", hostOperationContextPath, `{"prefix":"documents","binding":""}`, "not-the-token", false, http.StatusBadRequest}, + {"source before token", hostSourceOperationContextPath, `{"prefix":"documents"}`, "not-the-token", false, http.StatusBadRequest}, + } { + t.Run(tc.name, func(t *testing.T) { + g := newFakeGateway(t) + request := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body)) + request.Header.Set("X-Codefly-Internal-Token", testInternalToken) + request.Header.Set("Authorization", "Bearer "+tc.bearer) + if tc.legacy { + request.Header.Set("X-Codefly-Module-Secret", "removed-credential") + } + response := httptest.NewRecorder() + g.serve(response, request) + // A token verdict ahead of validation would answer 401 here. + if response.Code != tc.want { + t.Fatalf("status = %d, want %d", response.Code, tc.want) + } + }) + } +} + func TestProjectedTokenFileBoundsItsRead(t *testing.T) { path := filepath.Join(t.TempDir(), "token") for _, size := range []int{64 * 1024, 64*1024 + 1} { diff --git a/moduleauthority/seams_test.go b/moduleauthority/seams_test.go index 7b6aff4..3592702 100644 --- a/moduleauthority/seams_test.go +++ b/moduleauthority/seams_test.go @@ -101,3 +101,53 @@ func TestPlaintextSeamsNeedLoopbackOrAnAssertion(t *testing.T) { t.Fatalf("TLS seams: %v", err) } } + +// accounts refuses any organization selector that is not a canonical UUID +// (InvalidArgument, an untyped 400 at the first work or operation mint); New +// refuses it before anything is sent, on the same rule — whichever mints the +// client would go on to make, source mints included, though they never send +// the field. +func TestNewRefusesANonCanonicalOrganization(t *testing.T) { + const org = "6f1c3e2a-9b4d-4c8e-a1f0-2d3b4c5e6f7a" + h := newHost(t) + for _, org := range []string{ + "ACME", + strings.ToUpper(org), + "{" + org + "}", + "urn:uuid:" + org, + strings.ReplaceAll(org, "-", ""), + " " + org, + } { + client, err := New(h.seams(), Credentials{Prefix: testPrefix, Token: staticToken(testProjectedToken), OrganizationID: org}) + if !errors.Is(err, ErrInvalidCredentials) || client != nil { + t.Fatalf("organization %q: error = %v, want %v and no client", org, err, ErrInvalidCredentials) + } + } + if calls, _, _, _ := h.gateway.snapshot(); len(calls) != 0 { + t.Fatalf("a refused construction reached the broker: %v", calls) + } + + // The work and operation mints send a canonical organization. + client := h.client(t, Credentials{Prefix: testPrefix, Token: staticToken(testProjectedToken), OrganizationID: org}) + if _, err := client.ModuleWorkContext(t.Context()); err != nil { + t.Fatalf("canonical organization, work mint: %v", err) + } + if _, err := client.MintModuleOperationContext(t.Context(), "model"); err != nil { + t.Fatalf("canonical organization, operation mint: %v", err) + } + // A source-only client leaves the selector empty, and sends none. + source := h.client(t, moduleCredentials) + if _, err := source.MintSourceOperationContext(t.Context(), exampleSource); err != nil { + t.Fatalf("source-only client: %v", err) + } + _, _, bodies, _ := h.gateway.snapshot() + if got := bodies[hostWorkContextPath]; len(got) != 1 || got[0]["organization_id"] != org { + t.Fatalf("work mint bodies = %v", got) + } + if got := bodies[hostOperationContextPath]; len(got) != 1 || got[0]["organization_id"] != org { + t.Fatalf("operation mint bodies = %v", got) + } + if got := bodies[hostSourceOperationContextPath]; len(got) != 1 || len(got[0]) != 2 { + t.Fatalf("source mint bodies = %v, want prefix and source_id only", got) + } +} diff --git a/moduleauthority/source_context.go b/moduleauthority/source_context.go index eb675ed..cba7065 100644 --- a/moduleauthority/source_context.go +++ b/moduleauthority/source_context.go @@ -9,11 +9,10 @@ import ( var ( // ErrDelegationMissing: the source has no active delegation to this - // module. A person must connect or reconnect the source to authorize its - // work. - // - // Deprecated: broker response bodies are excluded from errors. Source - // mints now return a BrokerError with status 412 without this sentinel. + // module (HTTP 412 from the source mint). A person must connect or + // reconnect the source to authorize its work; minting again will not. + // The status alone identifies it: the host answers 412 on this path only + // for a missing delegation, so no response body is read. ErrDelegationMissing = errors.New("module authority: the source has no active delegation to this module") // ErrDelegationRevoked: the delegation was revoked, explicitly or because // the person who made it left, lost the permission, or the source was @@ -56,13 +55,15 @@ type sourceOperationContextResponse struct { // mint. The caller names only the source; it can never pick an organization. // // A refusal is typed, and wraps the *BrokerError: +// - HTTP 412 is ErrDelegationMissing: a person must reconnect the source; // - HTTP 403 is ErrDelegationInvalid, since every 403 of this mint is a // delegation the module may not use; // - HTTP 401 is ErrInvalidCredentials; // - HTTP 503 is ErrExecutionUnestablished, and may be retried. // -// Other non-200 statuses (including 412) carry only BrokerError. Bodies are -// never read to distinguish missing or revoked delegations. Transport and read +// Other non-200 statuses carry only BrokerError. Only the status is read: +// bodies are never read, so a revoked delegation is not told apart from +// another unusable one. Transport and read // failures carry BrokerTransportError. A 502 may be retried. The result is never // cached, and a refusal must never be answered with the module's own authority. func (c *Client) MintSourceOperationContext(ctx context.Context, sourceID string) (SourceOperationContext, error) { @@ -115,6 +116,10 @@ func delegationRefusal(err error) error { return ErrExecutionUnestablished case http.StatusForbidden: return ErrDelegationInvalid + case http.StatusPreconditionFailed: + // gateway_modules.go at v0.0.94 answers 412 on this path only for + // accounts' DELEGATION_MISSING; any other FailedPrecondition is a 502. + return ErrDelegationMissing } return nil } diff --git a/moduleauthority/source_context_test.go b/moduleauthority/source_context_test.go index 08486d1..07a9340 100644 --- a/moduleauthority/source_context_test.go +++ b/moduleauthority/source_context_test.go @@ -46,7 +46,10 @@ func TestMintSourceOperationContextClassifiesByStatus(t *testing.T) { refuse refusal want error }{ - {"missing", refusal{http.StatusPreconditionFailed, "DELEGATION_MISSING"}, nil}, + {"missing", refusal{http.StatusPreconditionFailed, "DELEGATION_MISSING"}, ErrDelegationMissing}, + // The status alone types it: the gateway answers 412 on this path + // only for DELEGATION_MISSING, and the body is never read. + {"412 with another body", refusal{http.StatusPreconditionFailed, "SOMETHING_ELSE"}, ErrDelegationMissing}, {"revoked", refusal{http.StatusForbidden, "DELEGATION_REVOKED"}, ErrDelegationInvalid}, {"invalid", refusal{http.StatusForbidden, "DELEGATION_INVALID"}, ErrDelegationInvalid}, // A host that names no reason in its 403 (the body before the @@ -77,8 +80,7 @@ func TestMintSourceOperationContextClaimsNoDelegationRefusalItWasNotGiven(t *tes {http.StatusUnauthorized, "unauthorized"}, {http.StatusBadGateway, "source operation context unavailable"}, {http.StatusBadRequest, "exactly one of delegation_id or source_id is required"}, - {http.StatusPreconditionFailed, "SOMETHING_ELSE"}, - {http.StatusPreconditionFailed, "DELEGATION_MISSING"}, + {http.StatusServiceUnavailable, "DELEGATION_MISSING"}, } { client := brokerErrorClient(t, rf.status, testProjectedToken, func(*http.Request) io.ReadCloser { return io.NopCloser(strings.NewReader(rf.body))