Skip to content

feat(moduleauthority): mint through the real host gateway in CI, type the 412, refuse a non-canonical organization - #36

Merged
antoinetoussaint-byte merged 3 commits into
mainfrom
issue-35-moduleauthority-mint-through-the-real-host-gateway-in
Oct 12, 2026
Merged

antoinetoussaint-byte merged 3 commits into
mainfrom
issue-35-moduleauthority-mint-through-the-real-host-gateway-in

Conversation

@antoinetoussaint-byte

@antoinetoussaint-byte antoinetoussaint-byte commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Closes #35

What this does

moduleauthority's broker contract was held only by host_fake_test.go, a hand-written fake that still accepted X-Codefly-Module-Secret after the v0.0.94 gateway stopped reading it. Now CI also mints through the host's real auth-gateway handlers at the ref SOURCE.txt records (v0.0.94). The PR also types the source-mint 412 and documents (and partly pre-checks) the organization refusal.

1. Real-gateway conformance (conformance/hostgateway/, new CI job host-gateway-conformance)

  • testdata/sdk_conformance_test.go is this repo's code, written as package main of the host's gateway. run.sh <host checkout> compiles it into module/services/auth-gateway/code with go test -overlay. The SDK is required (via a replace pointing at this working tree) in a temporary copy of the gateway's go.mod, passed with -modfile.
  • The script refuses a checkout that isn't at the SOURCE.txt ref, and checks that the checkout is unmodified both before and after the run. The host stays a read-only checkout, and nothing of it is copied or vendored here.
  • The test serves the host's real Gateway.ServeHTTP (from newGatewayHarness) over httptest. It fakes only accounts' three Mint* RPCs, on a real gRPC listener, with a hand grpc.ServiceDesc, exactly as gateway_modules_test.go, gateway_operation_context_test.go and gateway_source_operation_context_test.go do. The SDK's real moduleauthority.New client runs all three mints against it.
  • What it asserts:
    • All three mints are accepted.
    • Exactly one Authorization: Bearer, the internal token, and no secret header reach the gateway.
    • Accounts receives prefix, service_account_token, organization_id, binding and source_id (the source arm, with delegation_id empty).
    • The gateway forwards its own internal token and no module credential header.
    • A tenant-pinned client forwards no organization.
    • A rotated projected file is re-read per mint.
    • The 64 KiB bound agrees on both sides.
    • Each refusal maps from accounts code → gateway status → SDK sentinel, 16 rows covering all three mints. Each row also checks that no other sentinel matches and that the token does not appear in the error.
    • A wrong internal token is refused before accounts.
    • On every path, the gateway refuses with 401 before accounts: the legacy secret header, a missing bearer, a duplicate bearer, a non-Bearer scheme, a lowercase bearer and an empty bearer.
    • The three gateway answers the fake copies (D1, D3, D4), so the fake can't drift silently.
  • CI. gen-drift's inline "read SOURCE.txt + sparse checkout" steps moved into a composite action, .github/actions/host-source, which both jobs use. SOURCE.txt gains an (auth gateway: module/services/auth-gateway/code) line beside the proto path, so the gateway path is recorded once and not hardcoded in CI or the script.

Mutation proof. I broke moduleauthority/broker.go four ways, ran run.sh, and reverted:

Mutation Result
M1: organization_id → organizationId red: TestSDKConformanceThreeMints — accounts received work mint … organization_id="". The real gateway drops the misnamed key silently, so only the forwarded-field assertion catches this one.
M2: X-Codefly-Module-Secret sent instead of the bearer red: 5 tests, e.g. work-context mint: … HTTP 401: request refused
M3: source_id → sourceId red: 3 tests, e.g. source-operation-context mint: … HTTP 400
M4: binding → binding_id red: 5 tests, e.g. operation-context mint: … HTTP 400

Removing the new 412 mapping turns TestSDKConformanceRefusals/source_delegation_missing red, along with two unit tests. Removing the canonical-UUID check turns TestNewRefusesANonCanonicalOrganization red.

The runner fails rather than passing vacuously (review F1, F3):

  • It lists the tests first and requires exactly the 8 expected names, then requires each to --- PASS and none to --- SKIP.
  • It adds the SDK with one go get, then runs everything -mod=readonly, so an import no required module provides is an error, never an @latest lookup. This replaces -mod=mod.
  • It fails if the build gives the gateway a google.golang.org/grpc, google.golang.org/protobuf or sdk-go/workcontext other than the host's pin. The one exception is a version whose module tree is byte-identical, which diff -r checks on every run instead of a hand-kept allow-list.
    • Today that exception is workcontext b91ce91 → b3fb40d. Both trees are identical, including go.mod.
    • Failing strictly on that move would make the job permanently red over a change with no effect. Forcing the host's versions with replace would instead compile the SDK against versions it does not pin.
  • The golang.org/x/* moves are printed as warnings.

Proofs, run locally with run.sh <v0.0.94> -race exactly as CI runs it, then reverted:

Probe Result
Rename one test (TestSdkConformanceTokenBound) exit 1: "the conformance tests in the file are not the ones run.sh expects: < TestSDKConformanceTokenBound"
Rename all of them (the reviewer's vacuous case) exit 1, with the expected list diffed against an empty one
t.Skip in one test exit 1: "TestSDKConformanceNoOrganization did not run and pass"
Import rsc.io/quote, which no required module provides exit 1: "cannot find module providing package rsc.io/quote: import lookup disabled by -mod=readonly"
SDK requires grpc v1.86.0-dev exit 1: "the conformance build resolves google.golang.org/grpc v1.86.0-dev, the host pins v1.83.2, and their sources differ"

Keeping the test in step with the host (review F2). conformance/hostgateway/AGENTS.md ("When it goes red") and the refresh skill tell the two kinds of red apart:

  • Compile red: the host renamed one of the four names, newGatewayHarness's results, or an accountsv1 field. Re-bind newRealGateway and the accessors, and change no assertion.
  • Assertion red: the broker contract moved. Fix the client, or change an expectation only when citing the host change.

The clone recipe now fetches a 40-hex SHA as well as a tag. I tested both forms; they resolve to the same commit, 9f6a4aa.

Seams the host does not export. The gateway is a main package and its harness is a test helper, so nothing of it can be imported. The test therefore binds to four unexported host names: newGatewayHarness, (*Gateway).authz, (*ExtAuthz).backendConn and (*ExtAuthz).internalToken. It also uses the host's auth-gateway/pkg/gen/saas/accounts/v1 types. If the host renames any of these, the compile fails red; it never passes silently. Removing that coupling needs an exported conformance harness in module-saas-starter. Per instructions, I have not filed that; I'll note it on #35.

Fake (host_fake_test.go). It stays as the edge-case double.

  • Aligned with the real gateway:
    • D1: the legacy header beside a valid bearer is now ignored, not refused. assertProjectedBearer still asserts the client never sends it, and the conformance edge check does too.
    • D3: the prefix rule is validCatalogIdentity's exact pattern, at most 63 characters.
    • D4: binding/source validation now runs before the token verdict.
    • TestHostFakeFollowsTheRealGatewayOrder pins all three answers on the fake. The conformance job pins the same three on the real gateway.
  • Documented in the file header as deliberate divergences:
    • D2: the fake decodes the body into a map, so extra keys are visible.
    • D5: no test injects a work-context 403 or 412. The brokerMints table drives those statuses through a raw transport and says they are unreachable on the real gateway.
    • D6: work-context 502s are not modelled.
    • D7: one internal token, not current-or-previous.
    • D8: injected refusal bodies.

2. Source-mint 412 is typed

delegationRefusal maps 412 → ErrDelegationMissing on status alone, exactly as 403 → ErrDelegationInvalid; it reads no body. The v0.0.94 gateway sends 412 on that path only for DELEGATION_MISSING; any other FailedPrecondition becomes 502, which the conformance table pins. ErrDelegationMissing is un-deprecated. ErrDelegationRevoked stays deprecated, because telling it apart would need the body.

3. Organization refusals

  • The ErrInvalidCredentials and Credentials.OrganizationID doc comments, and the README's OrganizationID paragraph, now say that a missing or wrong organization surfaces as ErrInvalidCredentials (401) on the work mint and ErrPermissionDenied (403) on the operation mint.

  • Behaviour change: New refuses a non-canonical UUID OrganizationID with ErrInvalidCredentials. It uses accounts' own rule (uuid.Parse plus String() == org, from module_identity.go moduleMintOrganization), so it is no stricter than the host. The test covers ACME, uppercase, braced, urn:uuid:, unhyphenated and space-padded forms.

  • Who changes behaviour (review F4). I kept the check in New, even for a client that only makes source mints, which never send the field.

    • A client that makes work or operation mints used to fail at its first such mint with an untyped 400. It now fails at construction.
    • A source-only client with such a value used to work. It now fails at construction, so it should leave the field empty or correct it.

    Why construction, not mint time:

    • OrganizationID is part of the credentials the client is built with, and the README's New example already fails closed at startup on ErrInvalidCredentials.
    • Checking at mint time would make the same value valid or invalid depending on which methods ever run. A background job's misconfiguration would then surface hours after deploy.
    • A source-only module has no reason to set the field, and the review found no consumer in the mirrors that does.

    CHANGELOG, the OrganizationID doc comment and the README now say this precisely. TestNewRefusesANonCanonicalOrganization covers three things: refusal before any request, a canonical organization sent on both the work and operation mints, and a source-only client that sends none.

Fix or hack

  • Fix: 412 → ErrDelegationMissing, the organization check in New, and the doc comments. Each lives at the place that owns the behaviour, the SDK's error mapping and constructor.
  • Fix: fake alignment for D1, D3 and D4 (test code).
  • Fix: CI host checkout factored into one action; the gateway path recorded in SOURCE.txt.
  • Fix, with one labelled coupling: the conformance harness. It uses only Go tool capabilities (-overlay, -modfile), and nothing of the host is edited or copied. The coupling is the four unexported host names above. That is not a hack around a host defect, but the real fix for the coupling is a host-exported harness.
  • Inherent, not a stopgap (the review's correction): GOLANG_PROTOBUF_REGISTRATION_CONFLICT=ignore in run.sh.
    • The host's and the SDK's gen/saas/accounts/v1 register the same proto files, and the conformance binary links both.
    • Nothing under test resolves through the global registry: the broker is JSON over HTTP, and the accounts fake decodes with the host's own types.
    • With warn instead, a failure log would open with about 120 warning lines.
    • It is inherent to linking two copies of one schema into one test binary, and harmless while gen-drift proves both copies come from one schema.

Reused, and deliberately not

  • Reused:
    • gen-drift's SOURCE.txt-driven sparse checkout, now a shared composite action.
    • The host's own newGatewayHarness and its accounts-fake pattern.
    • The throwaway feat(datasource)!: invoke declared operations with host receipts #30 review test and REPORT.md: the replace direction (the host requires the SDK), the protobuf conflict handling, and the case list.
  • Not reused:
    • The throwaway test's in-place host go.mod edit. It is replaced by -modfile, so the host checkout is never modified.
    • Its testify dependency (this file uses only testing).
    • Its BodyStrictness probe and its t.Logf-only divergence probes. Each probe is now either an assertion or documented.
    • The fake was kept, not deleted. It still covers edge cases the real handlers cannot be driven into: redirects, oversized and broken bodies, expired or mismatched answers, and the authority endpoint.

Docs updated

  • README.md: the OrganizationID paragraph, the broker-error paragraph (412), and the regeneration section (the ref also moves the conformance contract).
  • CHANGELOG.md: a new Unreleased section, including the behaviour change.
  • AGENTS.md: the CI description, the "Where things live" row, and the "what CI runs" line.
  • New nested conformance/hostgateway/AGENTS.md.
  • .claude/skills/refresh-generated-api/SKILL.md: SOURCE.txt's gateway line, and running conformance when the ref moves.
  • Doc comments in client.go, source_context.go and broker_errors.go.

Verification (local, at the head of this branch)

  • GOWORK=off go build ./... && go vet ./... && go test -race -count=1 ./...: all packages ok.
  • gofmt -l . | grep -v '^gen/': empty.
  • API-boundary gate: TestPublicAPINamesEveryTypeItExposes, TestGateCatchesKnownBypasses and TestGateRejectsAVacuousPass all pass.
  • gen-drift: buf generate (buf 1.73.0) from v0.0.94's module/services/accounts/proto reproduces gen/ byte-for-byte. This PR does not change gen/.
  • conformance/hostgateway/run.sh <v0.0.94 checkout> -race: ok, on both a full clone and a sparse checkout of exactly the two paths CI checks out (2.1 MB). The host's git status is clean afterwards.
  • Facade parity: I touched no facade (accounts/, datasource/). datasource/parity_test.go passes unchanged.

Not verified

  • Accounts' real mint handlers are not run. They need a Kubernetes TokenReview and a database, so the accounts leg stays faked the way the host's gateway tests fake it. What accounts decides, for example that a shared module without an organization is PermissionDenied, was read from module_identity.go, not executed. The conformance test only proves how the real gateway relays each code.
  • Some dependency versions still differ from what the host pins. The golang.org/x/* modules the SDK raises are compiled into the gateway in this job and only warned about. grpc, protobuf and workcontext are gated as described above.
  • go get reports that the SDK's own pinned sdk-go/workcontext b3fb40d is retracted by its author. This predates this PR and I left it alone; it is out of scope here.
  • No live cluster run.
  • CI on head b29baf9: see the comment on this PR.

🤖 Generated with Claude Code

… the 412, refuse a non-canonical organization

The SDK's broker contract was held only by a hand-written fake, which still
accepted X-Codefly-Module-Secret after the v0.0.94 gateway stopped reading it.

- conformance/hostgateway: run.sh compiles a test into module-saas-starter's
  auth-gateway package (go test -overlay, -modfile) at the SOURCE.txt ref and
  drives the real moduleauthority client through the real gateway handlers;
  only accounts' gRPC mints are faked, as the host's own tests fake them. The
  host checkout is verified at the ref and unmodified before and after.
- CI: host-gateway-conformance job; the host checkout moves into a
  host-source composite action shared with gen-drift. SOURCE.txt records the
  auth gateway path.
- A source mint's 412 is ErrDelegationMissing (un-deprecated), by status
  alone, as 403 is ErrDelegationInvalid.
- New refuses a non-canonical UUID OrganizationID with ErrInvalidCredentials,
  accounts' own rule; the organization refusal (401 work mint, 403 operation
  mint) is documented.
- The fake follows the real gateway on D1, D3, D4; D2, D5-D8 are documented.

Closes #35

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…; say who the organization check changes

Review findings on #36 at a02c9b6:

- F1: run.sh lists the conformance tests first and requires exactly the
  expected eight, then requires each to PASS and none to SKIP. Renaming
  the tests used to exit 0 with "[no tests to run]".
- F3: after one `go get` of the SDK, everything runs -mod=readonly, so an
  import no required module provides is an error, never an @latest lookup.
  The build fails if grpc, protobuf or sdk-go/workcontext would differ from
  the host's pin, unless the two module trees are byte-identical (checked
  each run; today only workcontext b91ce91 -> b3fb40d). Other moves warn.
- F2: conformance/hostgateway/AGENTS.md and the refresh skill tell a
  compile red (re-bind the host-facing calls) from an assertion red (fix
  the client); the clone recipe handles a 40-hex SHA as well as a tag.
- F4: the canonical-UUID check stays in New. CHANGELOG, the OrganizationID
  doc and README now say it refuses source-only clients too, which used to
  work; the test covers refusal before any request and both mint paths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor Author

CI on head b29baf9 (run 38190972193) passed all three jobs. test and gen-drift passed. host-gateway-conformance printed "conformance: 8 tests ran and passed against module-saas-starter@v0.0.94". It noted the workcontext b91ce91 → b3fb40d move as byte-identical and printed the golang.org/x/* moves as warnings. This addresses review findings F1–F4 from the review at a02c9b6; the details are in the PR body.

…go get

Review N1 at b29baf9: `go get` resolved an import of moduleauthority that
the SDK's go.mod did not declare at @latest (rsc.io/quote v1.5.2) and the
run passed. run.sh now requires the SDK's own
`go list -mod=readonly -deps ./moduleauthority` to succeed first, so the
claim that nothing is resolved at @latest holds; run.sh, AGENTS.md and
CHANGELOG say what is checked where.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte merged commit 2ddf6b2 into main Oct 12, 2026
3 checks passed
@antoinetoussaint-byte
antoinetoussaint-byte deleted the issue-35-moduleauthority-mint-through-the-real-host-gateway-in branch October 12, 2026 01:27
@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor Author

Fixes N1 from the review at b29baf9. run.sh now requires the SDK's own go list -mod=readonly -deps ./moduleauthority to succeed before go get. If moduleauthority imports something the SDK's go.mod does not declare, the run fails instead of go get resolving it at @latest.

Proof, against the sparse v0.0.94 checkout with -race -v, as CI runs it:

  • With a probe file in moduleauthority that imports rsc.io/quote, the run exits 1 with "cannot find module providing package rsc.io/quote: import lookup disabled by -mod=readonly". No go: added rsc.io line appears.
  • Without the probe, it prints "8 tests ran and passed".

CI on head be431d1 (run 38191842390) passed all three jobs: test, gen-drift and host-gateway-conformance (8/8).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

moduleauthority: mint through the real host gateway in CI, type the 412 delegation-missing refusal, document organization refusals

1 participant