From 5e3bc8bf8efece3a363773df5f3666b4c1dfd985 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:51:14 -0400 Subject: [PATCH 1/2] fix: adopt core v0.18.1 shared service plumbing --- .github/workflows/ci.yml | 2 +- .github/workflows/publish-gateway-image.yml | 60 +------ AGENTS.md | 34 +++- README.md | 54 ++++-- cmd/image-sbom/main.go | 6 +- .../service-object-storage}/auth_test.go | 33 ++-- cmd/service-object-storage/main.go | 6 +- composition_test.go | 11 +- gateway_image_test.go | 27 +-- go.mod | 2 +- go.sum | 4 +- image_release_preparation_test.go | 60 ++----- internal/auth/auth.go | 97 ---------- internal/config/config.go | 38 +--- internal/config/config_test.go | 4 +- internal/imageevidence/imageevidence.go | 166 +----------------- main.go | 34 +--- minio_custody.go | 3 +- minio_custody_e2e_test.go | 6 +- runtime.go | 21 +-- runtime_e2e_test.go | 6 +- sbom_e2e_test.go | 8 +- sbom_test.go | 45 ++--- 23 files changed, 196 insertions(+), 531 deletions(-) rename {internal/auth => cmd/service-object-storage}/auth_test.go (88%) delete mode 100644 internal/auth/auth.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db2fc3f..293a06a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: # Pinned to an immutable core commit SHA, not @main: a mutable ref lets an # upstream change alter this repo's gate — which runs with this repo's token # — with no commit here. Bump deliberately alongside the go.mod core version. - uses: codefly-dev/core/.github/workflows/go-service-ci.yml@29bc3dbdda86cc5b3432f17c249bc49301a2edad + uses: codefly-dev/core/.github/workflows/go-service-ci.yml@ea49e7a968ef5f2fd325b1c41f823a47eb20141c # v0.18.1 # The shared go-service-ci runs `go test` without the race detector; this # repo's events hub, Watch streams and readiness monitor are concurrent, so diff --git a/.github/workflows/publish-gateway-image.yml b/.github/workflows/publish-gateway-image.yml index 2e608d9..57470a7 100644 --- a/.github/workflows/publish-gateway-image.yml +++ b/.github/workflows/publish-gateway-image.yml @@ -17,61 +17,15 @@ on: required: true type: string -env: - IMAGE: ghcr.io/codefly-dev/service-object-storage - jobs: publish: - runs-on: ubuntu-latest permissions: contents: read packages: write - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Validate the upcoming agent version - id: version - env: - VERSION: ${{ inputs.version }} - run: | - set -euo pipefail - if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then - echo "version must be a stable semantic version without a v prefix" >&2 - exit 1 - fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - # The version label is how the release tells this image apart from the one - # published for an earlier version: both are digests in the same - # repository, and a lock left unrefreshed would otherwise publish the - # previous release's bytes under the new tag. - - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 - id: build - with: - context: . - platforms: linux/amd64,linux/arm64 - labels: org.opencontainers.image.version=${{ steps.version.outputs.version }} - outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true - - name: Record the digest - env: - DIGEST: ${{ steps.build.outputs.digest }} - run: | - set -euo pipefail - mkdir -p release-evidence - jq -n --arg name "$IMAGE" --arg digest "$DIGEST" '{name: $name, digest: $digest}' > release-evidence/gateway-image.json - { - echo 'Commit this as `gateway-image.json`, then tag the release:' - echo - echo '```json' - cat release-evidence/gateway-image.json - echo '```' - } >> "$GITHUB_STEP_SUMMARY" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: gateway-image-lock-${{ steps.version.outputs.version }} - path: release-evidence/gateway-image.json - if-no-files-found: error + uses: codefly-dev/core/.github/workflows/publish-service-image.yml@ea49e7a968ef5f2fd325b1c41f823a47eb20141c # v0.18.1 + with: + version: ${{ inputs.version }} + lock-file: gateway-image.json + platforms: linux/amd64,linux/arm64 + secrets: + registry-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 49e302d..42349ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,19 +12,22 @@ and the local/deployed profiles. This file is how to work in the repo. - The contract and its stubs: `proto/codefly/storage/v0`, `gen/`. - The gateway server: `cmd/service-object-storage` and `internal/**` — the - backends, the per-replica Watch event hub, caller auth, the readiness probe, + backends, the per-replica Watch event hub, auth configuration, the readiness probe, and the normalization of backend errors to gRPC codes. - The codefly agent that runs the gateway as a first-class service: the repo root (`main.go`, `runtime.go`, `builder.go`, `agent.codefly.yaml`) and `templates/`, the Kubernetes manifests the Builder renders. -- SBOM evidence for the image it ships: `cmd/image-sbom`, - `internal/imageevidence`. +- The evidence command and image declaration: `cmd/image-sbom` and + `internal/imageevidence`. Core owns subject derivation, scanning and indexing. ## What it does not own - The CLI and the agent framework — `codefly-dev/cli`, and `codefly-dev/core`, which is both a `go.mod` dependency and the home of the shared CI workflows - this repo calls (pinned here by commit SHA, never `@main`). + this repo calls (pinned here by commit SHA, never `@main`). Core v0.18.1 also + owns `callertoken`, the gateway image-lock parser and shared image evidence. + Do not restore service-local copies. Pin dependencies with + `codefly agent deps --pin ` so every owned lock is checked. - The backing stores. Behaviour differences between MinIO, S3, GCS and Azure are absorbed in `internal/backend/`, but a broken store is not a bug here. - Consumers. They declare a `service-dependency` and dial the endpoint; nothing @@ -109,14 +112,31 @@ one workflow, in that order. The tag must match `version:` in that file resolves the *previous* image, which exists, so nothing fails at run time and the evidence describes an image the agent never runs. The shipped platform list lives in `internal/imageevidence` and a test asserts it against -the release workflow. +the inputs to core's reusable image publisher. Prepare the gateway before publishing the agent: use `codefly publish --dry-run` to determine the next version, then dispatch `publish-gateway-image.yml` with -that explicit `version` (without `v`). Adopt its `gateway-image-lock-` +that explicit `version` (without `v`). Adopt its `gateway-image.json-` artifact as `gateway-image.json` in a reviewed PR. Only then run normal `codefly publish`; the tag gate still checks the image label against the bumped -agent version and refuses a stale lock. +agent version and refuses a stale lock. The reusable publisher checks out with +`persist-credentials: false` and refuses a commit outside the default branch's +history. It changes no release tag or lock in this repository. + +The gateway lock accepts only its expected registry/name, a lowercase SHA-256 +digest, and the two known JSON keys; an empty digest means unpublished. MinIO's +source metadata has its own schema but shares core's digest validation. Registry +evidence rejects bare tags and declares one subject per shipped platform; local +evidence reads its image ID and actual platform from Docker. + +Caller tokens are now 64 hex characters, also used for generated MinIO passwords +and custody IDs. Only exact health Check/Watch RPCs bypass the token; Health/List +requires it. An empty interceptor token fails closed, while the gateway's +explicit anonymous profile continues to install no interceptors. + +For an agent checkout, run `codefly agent ci` for source, release builds and +fresh generated-service conformance. `codefly ci run` needs a service workspace +and reports `no workspace found` at this repository root. ## How to behave when something does not work diff --git a/README.md b/README.md index 91ef301..6808a0a 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,18 @@ With `SOS_AUTH_TOKEN` set, every RPC — unary and streaming, `Capabilities` included — must carry the secret as the `x-codefly-token` gRPC metadata header, the same key the codefly host already uses to authenticate agent plugins. Anything else is rejected with `UNAUTHENTICATED` before it reaches a backend. -The token is compared in constant time. +Core v0.18.1's `callertoken` package compares the token in constant time. +Only the exact `/grpc.health.v1.Health/Check` and +`/grpc.health.v1.Health/Watch` RPCs are exempt; `Health/List` requires a token. +Generated tokens are 64 lowercase hex characters (32 random bytes), previously +48. Operator-provided tokens retain their configured value. The same generator +supplies local MinIO passwords and custody identifiers. + +An empty token passed to a core interceptor fails closed. The gateway installs +interceptors only when `SOS_AUTH_TOKEN` is nonempty: the explicit anonymous +profile continues to install none. Core's `CheckListener` preserves the refusal +of both or neither auth settings; its errors name the configuration and exposure, +while deployment and migration guidance lives here. Which layer enforces caller identity, per profile: @@ -322,10 +333,21 @@ The agent runs that image by digest, not by tag: `gateway-image.json` records the digest and the agent pins it, so the container a release runs — and reports evidence for — is the one that was built for it rather than whatever the tag serves now. The digest only exists once the image is built, so the image is -published ahead of the release tag by `publish-gateway-image.yml`; commit the -digest it prints, then tag. `release.yml` creates the `:` and `:latest` -tags from that digest and refuses one that was not built for the version being -released. +published ahead of the release tag by `publish-gateway-image.yml`. That caller +uses core v0.18.1's `publish-service-image.yml`, pinned by commit SHA, with the +upcoming stable version. It checks out without persisting credentials and +refuses a commit not reachable from this repository's default branch. Adopt +its `gateway-image.json-` artifact in a reviewed PR before the +coordinator runs `codefly publish`; never hand-tag. `release.yml` creates the +`:` and `:latest` image tags from that digest and refuses one that was +not built for the version being released. + +Core's `resources.ParseImageLock` reads the gateway lock. It requires exactly +`ghcr.io/codefly-dev/service-object-storage` and `sha256:` followed by 64 +lowercase hexadecimal characters, refuses unknown fields (including `tag`) and +trailing JSON documents, and reports an empty digest as `ErrImageUnpublished`. +MinIO retains its separate source/release/revision lock schema and uses core's +same lowercase SHA-256 validation. ## Supply chain evidence @@ -347,16 +369,20 @@ claim coverage of something the deployed service does not run. `SOS_GATEWAY_IMAGE` is honored. An override is a local build no registry serves, so it is inventoried through the Docker daemon — which needs `syft` on PATH — and covers the single platform the daemon holds. Its subject carries the image -ID the daemon resolved the override to: the daemon binds a scan to that ID, so -an override whose reference the daemon does not hold is refused rather than -inventoried under an identity nothing named. +ID and actual platform the daemon resolved the override to. Core scans that +immutable ID even if a local tag moves after inspection; caller-declared local +platforms are refused. An override whose reference the daemon does not hold is +refused rather than inventoried under an identity nothing named. A failed scan, an unresolvable image, or a digest other than the one requested is reported as an error, never as partial or complete coverage. `cmd/image-sbom` writes that evidence to disk through the same scanner the agent -uses, so a published document is the agent's own output rather than a second, -drifting copy of it: +uses: core's `ImageSubjects` derives registry subjects through +`ExpectedFromImageReference` (which refuses bare tags), and +`CollectImageEvidence` / `WriteImageEvidenceIndex` collect and index them. +The Builder and command both validate explicit expected digests through core's +`ImageForSubject` path: ```bash # the published multi-architecture image: one document per shipped platform @@ -372,8 +398,11 @@ so it still names the image it describes once separated from the directory it was written in. Every release runs the same command and publishes the documents and an `index.txt` naming each digest and platform, as build artifacts of `.github/workflows/release.yml`. The platform list lives only in -`internal/imageevidence`, and a test asserts it against the build step that -publishes the image. +`internal/imageevidence`, which now contains only the service declaration and +calls core. A test asserts the platforms against the reusable publisher's +inputs. Document filenames include repository, platform and digest; each index +row contains platform (`-` if unstated), repository plus scanned digest, filename +and canonical inventory checksum (not the checksum of the formatted file). ## Develop @@ -400,7 +429,6 @@ proto/codefly/storage/v0/ the uniform API gen/ generated gRPC stubs internal/backend/ Backend interface + s3, gcs, azure, minio, mem internal/events/ in-process write-event hub behind Watch -internal/auth/ caller authentication on the gRPC surface internal/server/ gRPC ObjectStorage implementation internal/config/ env configuration cmd/service-object-storage/ the server binary diff --git a/cmd/image-sbom/main.go b/cmd/image-sbom/main.go index 0ddf8fc..e92a97b 100644 --- a/cmd/image-sbom/main.go +++ b/cmd/image-sbom/main.go @@ -13,6 +13,8 @@ import ( "fmt" "os" + "github.com/codefly-dev/core/agents/services/sbom" + "github.com/codefly-dev/service-object-storage/internal/imageevidence" ) @@ -34,12 +36,12 @@ func main() { fmt.Fprintf(os.Stderr, "image-sbom: %v\n", err) os.Exit(1) } - documents, err := imageevidence.Collect(ctx, *out, subjects) + documents, err := sbom.CollectImageEvidence(ctx, *out, subjects) if err != nil { fmt.Fprintf(os.Stderr, "image-sbom: %v\n", err) os.Exit(1) } - if err := imageevidence.WriteIndex(*out, documents); err != nil { + if err := sbom.WriteImageEvidenceIndex(*out, documents); err != nil { fmt.Fprintf(os.Stderr, "image-sbom: %v\n", err) os.Exit(1) } diff --git a/internal/auth/auth_test.go b/cmd/service-object-storage/auth_test.go similarity index 88% rename from internal/auth/auth_test.go rename to cmd/service-object-storage/auth_test.go index 23e9444..14f202d 100644 --- a/internal/auth/auth_test.go +++ b/cmd/service-object-storage/auth_test.go @@ -1,9 +1,7 @@ -package auth_test +package main import ( "context" - grpchealth "google.golang.org/grpc/health" - healthv1 "google.golang.org/grpc/health/grpc_health_v1" "io" "net" "testing" @@ -13,11 +11,13 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" + grpchealth "google.golang.org/grpc/health" + healthv1 "google.golang.org/grpc/health/grpc_health_v1" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" + "github.com/codefly-dev/core/callertoken" storagev0 "github.com/codefly-dev/service-object-storage/gen/codefly/storage/v0" - "github.com/codefly-dev/service-object-storage/internal/auth" "github.com/codefly-dev/service-object-storage/internal/backend" "github.com/codefly-dev/service-object-storage/internal/backend/mem" "github.com/codefly-dev/service-object-storage/internal/events" @@ -106,8 +106,8 @@ func startGateway(t *testing.T) string { hub := events.NewHub() s := grpc.NewServer( - grpc.ChainUnaryInterceptor(auth.UnaryInterceptor(serverToken)), - grpc.ChainStreamInterceptor(auth.StreamInterceptor(serverToken)), + grpc.ChainUnaryInterceptor(callertoken.UnaryServerInterceptor(serverToken)), + grpc.ChainStreamInterceptor(callertoken.StreamServerInterceptor(serverToken)), ) storagev0.RegisterObjectStorageServer(s, server.New(be, hub, probetest.Monitor(t, be))) healthv1.RegisterHealthServer(s, grpchealth.NewServer()) @@ -132,9 +132,9 @@ func TestUnauthenticatedPeerIsRefused(t *testing.T) { peers := map[string]storagev0.ObjectStorageClient{ "no credentials": dial(t, address), - "wrong token": dial(t, address, auth.DialOption("00000000000000000000000000000000")), - "prefix of token": dial(t, address, auth.DialOption(serverToken[:len(serverToken)-1])), - "empty token": dial(t, address, auth.DialOption("")), + "wrong token": dial(t, address, callertoken.DialOption("00000000000000000000000000000000")), + "prefix of token": dial(t, address, callertoken.DialOption(serverToken[:len(serverToken)-1])), + "empty token": dial(t, address, callertoken.DialOption("")), } for peerName, client := range peers { @@ -156,7 +156,7 @@ func TestUnauthenticatedPeerIsRefused(t *testing.T) { // Unimplemented) still proves the interceptor let the call through; Watch has // nothing to deliver, so it only has to not be refused before the deadline. func TestAuthorizedPeerReachesTheHandler(t *testing.T) { - client := dial(t, startGateway(t), auth.DialOption(serverToken)) + client := dial(t, startGateway(t), callertoken.DialOption(serverToken)) for rpcName, call := range rpcs { t.Run(rpcName, func(t *testing.T) { @@ -172,9 +172,9 @@ func TestAuthorizedPeerReachesTheHandler(t *testing.T) { // the same metadata key the codefly host uses for agent plugins, so a caller // built against that convention interoperates. func TestTokenTravelsUnderTheEcosystemKey(t *testing.T) { - require.Equal(t, "x-codefly-token", auth.MetadataKey) + require.Equal(t, "x-codefly-token", callertoken.MetadataKey) - client := dial(t, startGateway(t), grpc.WithUnaryInterceptor(withMetadata(auth.MetadataKey, serverToken))) + client := dial(t, startGateway(t), grpc.WithUnaryInterceptor(withMetadata(callertoken.MetadataKey, serverToken))) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, err := client.Capabilities(ctx, &storagev0.CapabilitiesRequest{}) @@ -200,8 +200,8 @@ func withMetadata(key, value string) grpc.UnaryClientInterceptor { } } -// TestHealthNeedsNoToken pins the one exemption: the standard gRPC health -// service answers a peer that presents no token. A readiness probe — the +// TestHealthNeedsNoToken pins the exact exemptions: health Check and Watch +// answer a peer that presents no token. A readiness probe — the // Codefly CLI's, a load balancer's, a mesh's — carries none, and a guarded // health service made the gateway look permanently unready to all of them // (document-store's integration graph never came up under Codefly 0.1.161, @@ -223,4 +223,9 @@ func TestHealthNeedsNoToken(t *testing.T) { first, err := stream.Recv() require.NoError(t, err) require.Equal(t, healthv1.HealthCheckResponse_SERVING, first.GetStatus()) + + _, err = healthv1.NewHealthClient(conn).List(ctx, &healthv1.HealthListRequest{}) + require.Equal(t, codes.Unauthenticated, status.Code(err)) + _, err = healthv1.NewHealthClient(conn).List(metadata.AppendToOutgoingContext(ctx, callertoken.MetadataKey, serverToken), &healthv1.HealthListRequest{}) + require.NoError(t, err) } diff --git a/cmd/service-object-storage/main.go b/cmd/service-object-storage/main.go index cff696d..d8bcff0 100644 --- a/cmd/service-object-storage/main.go +++ b/cmd/service-object-storage/main.go @@ -17,8 +17,8 @@ import ( grpchealth "google.golang.org/grpc/health" healthv1 "google.golang.org/grpc/health/grpc_health_v1" + "github.com/codefly-dev/core/callertoken" storagev0 "github.com/codefly-dev/service-object-storage/gen/codefly/storage/v0" - "github.com/codefly-dev/service-object-storage/internal/auth" "github.com/codefly-dev/service-object-storage/internal/backend" "github.com/codefly-dev/service-object-storage/internal/config" "github.com/codefly-dev/service-object-storage/internal/events" @@ -73,8 +73,8 @@ func run() error { if cfg.AuthToken != "" { authMode = "token" options = append(options, - grpc.ChainUnaryInterceptor(auth.UnaryInterceptor(cfg.AuthToken)), - grpc.ChainStreamInterceptor(auth.StreamInterceptor(cfg.AuthToken)), + grpc.ChainUnaryInterceptor(callertoken.UnaryServerInterceptor(cfg.AuthToken)), + grpc.ChainStreamInterceptor(callertoken.StreamServerInterceptor(cfg.AuthToken)), ) } diff --git a/composition_test.go b/composition_test.go index 2e6ff3d..36e99c1 100644 --- a/composition_test.go +++ b/composition_test.go @@ -15,6 +15,7 @@ import ( "github.com/codefly-dev/core/agents/services" agenttesting "github.com/codefly-dev/core/agents/testing" + "github.com/codefly-dev/core/callertoken" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" runtimev0 "github.com/codefly-dev/core/generated/go/codefly/services/runtime/v0" @@ -27,7 +28,6 @@ import ( "gopkg.in/yaml.v3" storagev0 "github.com/codefly-dev/service-object-storage/gen/codefly/storage/v0" - "github.com/codefly-dev/service-object-storage/internal/auth" "github.com/codefly-dev/service-object-storage/internal/backend" "github.com/codefly-dev/service-object-storage/internal/backend/mem" "github.com/codefly-dev/service-object-storage/internal/events" @@ -159,6 +159,11 @@ func TestResolveGatewayToken(t *testing.T) { if err != nil { t.Fatalf("resolveGatewayToken: %v", err) } + for _, token := range []string{first, second} { + if len(token) != 64 || strings.Trim(token, "0123456789abcdef") != "" { + t.Errorf("generated token must be 64 lowercase hex characters") + } + } if first == "" || first == second { t.Errorf("generated tokens must be non-empty and per-run: %q, %q", first, second) } @@ -223,8 +228,8 @@ func startAuthenticatedGateway(t *testing.T, token string) string { } hub := events.NewHub() srv := grpc.NewServer( - grpc.ChainUnaryInterceptor(auth.UnaryInterceptor(token)), - grpc.ChainStreamInterceptor(auth.StreamInterceptor(token)), + grpc.ChainUnaryInterceptor(callertoken.UnaryServerInterceptor(token)), + grpc.ChainStreamInterceptor(callertoken.StreamServerInterceptor(token)), ) storagev0.RegisterObjectStorageServer(srv, server.New(be, hub, probetest.Monitor(t, be))) go func() { _ = srv.Serve(lis) }() diff --git a/gateway_image_test.go b/gateway_image_test.go index b086824..e759e5b 100644 --- a/gateway_image_test.go +++ b/gateway_image_test.go @@ -3,13 +3,15 @@ package main import ( "os" "testing" + + "github.com/codefly-dev/core/resources" ) func TestParseGatewayImageLock(t *testing.T) { - image, err := parseGatewayImageLock([]byte(`{ + image, err := resources.ParseImageLock([]byte(`{ "name": "ghcr.io/codefly-dev/service-object-storage", "digest": "sha256:1205da0743e56b68c12c754438476e8fdd0f05cdb08ce1c9687ddb8d8f0e1669" - }`)) + }`), "service-object-storage") if err != nil { t.Fatalf("parseGatewayImageLock: %v", err) } @@ -25,15 +27,20 @@ func TestParseGatewayImageLock(t *testing.T) { // rather than carried into a scan. func TestParseGatewayImageLockRejectsAnythingButASHA256Digest(t *testing.T) { for name, lock := range map[string]string{ - "no name": `{"digest": "sha256:1205da0743e56b68c12c754438476e8fdd0f05cdb08ce1c9687ddb8d8f0e1669"}`, - "no digest": `{"name": "ghcr.io/codefly-dev/service-object-storage"}`, - "tag as digest": `{"name": "ghcr.io/codefly-dev/service-object-storage", "digest": "0.0.3"}`, - "other algorithm": `{"name": "ghcr.io/codefly-dev/service-object-storage", "digest": "sha512:1205da07"}`, - "truncated": `{"name": "ghcr.io/codefly-dev/service-object-storage", "digest": "sha256:1205da07"}`, - "not hex": `{"name": "ghcr.io/codefly-dev/service-object-storage", "digest": "sha256:zzzzda0743e56b68c12c754438476e8fdd0f05cdb08ce1c9687ddb8d8f0e1669"}`, + "wrong image": `{"name": "ghcr.io/codefly-dev/another-service", "digest": "sha256:1205da0743e56b68c12c754438476e8fdd0f05cdb08ce1c9687ddb8d8f0e1669"}`, + "wrong registry": `{"name": "example.org/service-object-storage", "digest": "sha256:1205da0743e56b68c12c754438476e8fdd0f05cdb08ce1c9687ddb8d8f0e1669"}`, + "uppercase digest": `{"name": "ghcr.io/codefly-dev/service-object-storage", "digest": "sha256:1205DA0743E56B68C12C754438476E8FDD0F05CDB08CE1C9687DDB8D8F0E1669"}`, + "extra tag": `{"name": "ghcr.io/codefly-dev/service-object-storage", "digest": "sha256:1205da0743e56b68c12c754438476e8fdd0f05cdb08ce1c9687ddb8d8f0e1669", "tag": "latest"}`, + "extra document": `{"name": "ghcr.io/codefly-dev/service-object-storage", "digest": "sha256:1205da0743e56b68c12c754438476e8fdd0f05cdb08ce1c9687ddb8d8f0e1669"} {}`, + "no name": `{"digest": "sha256:1205da0743e56b68c12c754438476e8fdd0f05cdb08ce1c9687ddb8d8f0e1669"}`, + "no digest": `{"name": "ghcr.io/codefly-dev/service-object-storage"}`, + "tag as digest": `{"name": "ghcr.io/codefly-dev/service-object-storage", "digest": "0.0.3"}`, + "other algorithm": `{"name": "ghcr.io/codefly-dev/service-object-storage", "digest": "sha512:1205da07"}`, + "truncated": `{"name": "ghcr.io/codefly-dev/service-object-storage", "digest": "sha256:1205da07"}`, + "not hex": `{"name": "ghcr.io/codefly-dev/service-object-storage", "digest": "sha256:zzzzda0743e56b68c12c754438476e8fdd0f05cdb08ce1c9687ddb8d8f0e1669"}`, } { t.Run(name, func(t *testing.T) { - if _, err := parseGatewayImageLock([]byte(lock)); err == nil { + if _, err := resources.ParseImageLock([]byte(lock), "service-object-storage"); err == nil { t.Error("accepted a lock that pins no image") } }) @@ -48,7 +55,7 @@ func TestGatewayImageMatchesTheLockOnDisk(t *testing.T) { if err != nil { t.Fatalf("read gateway-image.json: %v", err) } - expected, err := parseGatewayImageLock(lock) + expected, err := resources.ParseImageLock(lock, "service-object-storage") if err != nil { t.Fatalf("parseGatewayImageLock: %v", err) } diff --git a/go.mod b/go.mod index c5b7d6f..a236627 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.44 github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3 github.com/aws/smithy-go v1.27.8 - github.com/codefly-dev/core v0.17.0 + github.com/codefly-dev/core v0.18.1 github.com/containerd/errdefs v1.0.0 github.com/docker/docker v28.5.2+incompatible github.com/gofrs/flock v0.13.1 diff --git a/go.sum b/go.sum index 0efb624..556d807 100644 --- a/go.sum +++ b/go.sum @@ -122,8 +122,8 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= -github.com/codefly-dev/core v0.17.0 h1:Ckhs4URD9ip/zY6WrWkBKHxavS81B6QxZzRkaTwv3dg= -github.com/codefly-dev/core v0.17.0/go.mod h1:dSSLXlN1AbnzWNvsseJYbJnAYcitUsPjaxFCsrwb8mw= +github.com/codefly-dev/core v0.18.1 h1:W1RtH/Hjly+rh+BQlWB2HYYO3jdQmAe+bkhERro85bU= +github.com/codefly-dev/core v0.18.1/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= diff --git a/image_release_preparation_test.go b/image_release_preparation_test.go index 3ddccca..b7532bc 100644 --- a/image_release_preparation_test.go +++ b/image_release_preparation_test.go @@ -1,10 +1,6 @@ package main import ( - "os" - "os/exec" - "path/filepath" - "strings" "testing" "github.com/stretchr/testify/require" @@ -21,53 +17,19 @@ func TestImagePreparationRequiresTheUpcomingVersion(t *testing.T) { } `yaml:"workflow_dispatch"` } Jobs map[string]struct { - Steps []struct { - ID, Uses, Run string - Env, With map[string]string - } + Uses string + With, Secrets, Permissions map[string]string } } require.NoError(t, yaml.Unmarshal([]byte(workflowBody(t, "publish-gateway-image.yml")), &workflow)) require.True(t, workflow.On.Dispatch.Inputs["version"].Required) - var validation string - artifact := false - for _, step := range workflow.Jobs["publish"].Steps { - if step.ID == "version" { - require.Equal(t, "${{ inputs.version }}", step.Env["VERSION"]) - validation = step.Run - } - if strings.HasPrefix(step.Uses, "actions/upload-artifact@") { - require.Equal(t, "gateway-image-lock-${{ steps.version.outputs.version }}", step.With["name"]) - require.Equal(t, "release-evidence/gateway-image.json", step.With["path"]) - require.Equal(t, "error", step.With["if-no-files-found"]) - artifact = true - } - } - require.NotEmpty(t, validation) - require.True(t, artifact) - for _, tc := range []struct { - version string - valid bool - }{ - {"9.8.7", true}, - {"0.0.0", true}, - {"", false}, - {"v9.8.7", false}, - {"9.8", false}, - {"09.8.7", false}, - {"9.8.7\nother=value", false}, - } { - t.Run(tc.version, func(t *testing.T) { - output := filepath.Join(t.TempDir(), "output") - command := exec.Command("bash", "-c", validation) - command.Env = append(os.Environ(), "VERSION="+tc.version, "GITHUB_OUTPUT="+output) - payload, err := command.CombinedOutput() - require.Equal(t, tc.valid, err == nil, "%s: %s", err, payload) - if tc.valid { - actual, err := os.ReadFile(output) - require.NoError(t, err) - require.Equal(t, "version="+tc.version+"\n", string(actual)) - } - }) - } + job := workflow.Jobs["publish"] + // Core owns input validation and lock writing. This repo owns the wiring: + // the version must reach that workflow and the artifact must name our lock. + require.Equal(t, "codefly-dev/core/.github/workflows/publish-service-image.yml@ea49e7a968ef5f2fd325b1c41f823a47eb20141c", job.Uses) + require.Equal(t, "${{ inputs.version }}", job.With["version"]) + require.Equal(t, "gateway-image.json", job.With["lock-file"]) + require.Equal(t, "${{ secrets.GITHUB_TOKEN }}", job.Secrets["registry-token"]) + require.Equal(t, "read", job.Permissions["contents"]) + require.Equal(t, "write", job.Permissions["packages"]) } diff --git a/internal/auth/auth.go b/internal/auth/auth.go deleted file mode 100644 index 71207b1..0000000 --- a/internal/auth/auth.go +++ /dev/null @@ -1,97 +0,0 @@ -// Package auth enforces caller authentication on the gateway's gRPC surface. -// It mirrors the mechanism the codefly host already uses to talk to agent -// plugins (core's agents.AuthMetadataKey): a shared per-session secret carried -// as the "x-codefly-token" metadata header and compared in constant time. The -// key is redeclared here rather than imported so the gateway image does not -// link the agent runtime. -package auth - -import ( - "context" - "crypto/subtle" - "strings" - - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/metadata" - "google.golang.org/grpc/status" -) - -// MetadataKey is the gRPC metadata key carrying the bearer token. Lowercase per -// gRPC convention — metadata keys are case-insensitive but the wire form is -// lowercase. -const MetadataKey = "x-codefly-token" - -// UnaryInterceptor rejects every unary call that does not present token. -func UnaryInterceptor(token string) grpc.UnaryServerInterceptor { - return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { - if !IsHealthMethod(info.FullMethod) { - if err := verify(ctx, token); err != nil { - return nil, err - } - } - return handler(ctx, req) - } -} - -// HealthServicePrefix is the standard gRPC health service. Its Check and Watch -// carry no data and no authority: a readiness probe — the Codefly CLI's, a -// load balancer's, a mesh's — presents no token, so a guarded health service -// makes the gateway look permanently unready to everything that would route to -// it. Every other method stays behind the token. -const HealthServicePrefix = "/grpc.health.v1.Health/" - -// IsHealthMethod reports whether fullMethod belongs to the gRPC health service. -func IsHealthMethod(fullMethod string) bool { - return strings.HasPrefix(fullMethod, HealthServicePrefix) -} - -// StreamInterceptor rejects every streaming call that does not present token. -// gRPC runs it before the handler observes a single frame, so an unauthorized -// Put is refused at stream open rather than after its header is accepted. -func StreamInterceptor(token string) grpc.StreamServerInterceptor { - return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { - if !IsHealthMethod(info.FullMethod) { - if err := verify(ss.Context(), token); err != nil { - return err - } - } - return handler(srv, ss) - } -} - -// DialOption is how a client presents token on every call it makes to the -// gateway. -func DialOption(token string) grpc.DialOption { - return grpc.WithPerRPCCredentials(bearer(token)) -} - -func verify(ctx context.Context, expected string) error { - md, ok := metadata.FromIncomingContext(ctx) - if !ok { - return status.Error(codes.Unauthenticated, "missing "+MetadataKey) - } - presented := md.Get(MetadataKey) - // An empty presented value would compare equal to an empty expected one, so - // it is refused as absent rather than reaching the comparison. - if len(presented) == 0 || presented[0] == "" { - return status.Error(codes.Unauthenticated, "missing "+MetadataKey) - } - if subtle.ConstantTimeCompare([]byte(presented[0]), []byte(expected)) != 1 { - return status.Error(codes.Unauthenticated, "invalid "+MetadataKey) - } - return nil -} - -// bearer carries the token as per-RPC credentials. -type bearer string - -func (b bearer) GetRequestMetadata(context.Context, ...string) (map[string]string, error) { - return map[string]string{MetadataKey: string(b)}, nil -} - -// RequireTransportSecurity is false: the local profile reaches the gateway over -// the Docker host bridge without TLS, and the deployed profile terminates -// transport security at the mesh. Demanding it here would make the credential -// unusable on both. -func (b bearer) RequireTransportSecurity() bool { return false } diff --git a/internal/config/config.go b/internal/config/config.go index 3db49cf..449ed0e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "github.com/codefly-dev/service-object-storage/internal/auth" + "github.com/codefly-dev/core/callertoken" "github.com/codefly-dev/service-object-storage/internal/backend" ) @@ -18,7 +18,7 @@ import ( type Config struct { ListenAddr string // AuthToken is the shared secret every caller must present as the - // auth.MetadataKey metadata header, whitespace-trimmed: a secret delivered + // callertoken.MetadataKey metadata header, whitespace-trimmed: a secret delivered // through a YAML block scalar or an env file arrives with a trailing // newline, and enforcing that byte would reject every client that sends the // secret the operator thinks they configured. Empty means the listener is @@ -66,7 +66,10 @@ func FromEnv() (Config, error) { if cfg.Backend.Bucket == "" { return Config{}, fmt.Errorf("SOS_BUCKET is required") } - if err := checkListenerIsAuthenticated(cfg.AuthToken); err != nil { + if err := callertoken.CheckListener(cfg.AuthToken, envBool("SOS_ALLOW_ANONYMOUS", false), callertoken.Listener{ + TokenVar: "SOS_AUTH_TOKEN", AnonymousVar: "SOS_ALLOW_ANONYMOUS", + Exposure: "has bucket-wide read/write/delete/presign", + }); err != nil { return Config{}, err } switch cfg.Backend.ProbeStrategy { @@ -128,35 +131,6 @@ func checkBackendIsComplete(b backend.Config) error { return nil } -// checkListenerIsAuthenticated refuses to resolve a configuration that would -// serve bucket-wide read/write/delete/presign to anyone who can reach the -// listen port. The gateway always binds every interface inside its container — -// Docker port publishing cannot forward to a loopback-bound process — so the -// listen address says nothing about who can reach it, and the token is the only -// thing the gateway itself can enforce. -func checkListenerIsAuthenticated(token string) error { - anonymous := envBool("SOS_ALLOW_ANONYMOUS", false) - if token != "" { - // Both set is a contradiction, not a preference: "here is a credential" - // and "accept callers with no credential" cannot both be the intent, and - // silently picking one leaves the operator believing the other. It is - // also the shape a half-finished migration takes — a token added to the - // Secret while the manifest still carries the opt-out. - if anonymous { - return fmt.Errorf("SOS_AUTH_TOKEN and SOS_ALLOW_ANONYMOUS=true are mutually exclusive: " + - "unset SOS_ALLOW_ANONYMOUS to enforce the token, or unset SOS_AUTH_TOKEN to accept anonymous callers") - } - return nil - } - if anonymous { - return nil - } - return fmt.Errorf("SOS_AUTH_TOKEN is required: without it every caller that can reach the listener has bucket-wide read/write/delete/presign. "+ - "Set SOS_AUTH_TOKEN to a shared secret and have clients send it as the %q gRPC metadata header, "+ - "or set SOS_ALLOW_ANONYMOUS=true if the listener is confined to a private boundary enforced elsewhere (cluster NetworkPolicy / service-mesh mTLS)", - auth.MetadataKey) -} - func env(key, def string) string { if v := os.Getenv(key); v != "" { return v diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0b74578..c17c52a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -25,14 +25,14 @@ func probeEnv(t *testing.T) { // TestFromEnv_RefusesUnauthenticatedListener is the startup guard: the gateway // must not come up serving bucket-wide read/write/delete to whoever can reach -// its port, and the refusal has to tell an existing user how to move forward. +// its port, and the refusal has to name the conflicting configuration and its exposure. func TestFromEnv_RefusesUnauthenticatedListener(t *testing.T) { t.Setenv("SOS_BUCKET", "documents") t.Setenv("SOS_ENDPOINT", "127.0.0.1:9000") _, err := config.FromEnv() require.Error(t, err) - for _, want := range []string{"SOS_AUTH_TOKEN", "x-codefly-token", "SOS_ALLOW_ANONYMOUS"} { + for _, want := range []string{"SOS_AUTH_TOKEN", "bucket-wide read/write/delete/presign", "SOS_ALLOW_ANONYMOUS"} { require.Contains(t, err.Error(), want) } } diff --git a/internal/imageevidence/imageevidence.go b/internal/imageevidence/imageevidence.go index e5fada2..9ecdee0 100644 --- a/internal/imageevidence/imageevidence.go +++ b/internal/imageevidence/imageevidence.go @@ -1,6 +1,5 @@ -// Package imageevidence holds the one description of the images this service -// ships: the platforms the release pipeline publishes, the subjects that -// describe them, and the CycloneDX documents inventorying them. +// Package imageevidence declares the images this service ships and their +// platforms. Core owns subject derivation, scanning and evidence indexing. // // The agent's Builder.SBOM, the release command, and the drift test all read it // from here. A second copy of the platform list is how a platform gets added to @@ -9,11 +8,6 @@ package imageevidence import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" "github.com/codefly-dev/core/agents/services/sbom" builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" @@ -22,160 +16,18 @@ import ( // Platforms are the platforms the release pipeline publishes the gateway image // for, and therefore the platforms evidence has to cover: a multi-architecture // image is not inventoried by scanning one of its children. It is asserted -// against .github/workflows/release.yml. +// against the reusable workflow inputs in publish-gateway-image.yml. var Platforms = []string{"linux/amd64", "linux/arm64"} // Role is the gateway's purpose within the service, carried on every subject. const Role = "runtime" -// Subjects describes the images the service ships. A published image is -// multi-architecture and contributes one subject per shipped platform, and the -// digest-pinned reference it is named by is what binds each scan to the image -// that was built. A local image was built and never pushed, so the daemon holds -// exactly one platform of it and the subject names none: the platform is read -// back from the image rather than asserted by the caller. -// -// A local subject carries the image ID instead, because the daemon resolves -// whatever reference it is handed to that ID: a pin sitting only in the -// reference is not the identity such a scan binds evidence to. +// Subjects supplies this service's image declaration to core's shared evidence +// path. A local image omits Platforms: core reads its platform from the daemon. func Subjects(ctx context.Context, service, reference string, local bool) ([]*builderv0.ImageSubject, error) { - if local { - id, err := localImageID(ctx, reference) - if err != nil { - return nil, err - } - return []*builderv0.ImageSubject{{ - Reference: reference, - Digest: id, - Role: Role, - Service: service, - Source: builderv0.ImageSourceKind_IMAGE_SOURCE_KIND_DOCKER_DAEMON, - }}, nil - } - subjects := make([]*builderv0.ImageSubject, 0, len(Platforms)) - for _, platform := range Platforms { - subjects = append(subjects, &builderv0.ImageSubject{ - Reference: reference, - Platform: platform, - Role: Role, - Service: service, - Source: builderv0.ImageSourceKind_IMAGE_SOURCE_KIND_REGISTRY, - }) - } - return subjects, nil -} - -// localImageID reports the identity the Docker daemon holds an image under. -func localImageID(ctx context.Context, reference string) (string, error) { - command := exec.CommandContext(ctx, "docker", "image", "inspect", reference, "--format", "{{.Id}}") - var stderr strings.Builder - command.Stderr = &stderr - out, err := command.Output() - if err != nil { - // An absent image, an absent daemon and an absent docker are different - // things to go fix, and only docker's own message tells them apart. - return "", fmt.Errorf("resolve local image %s: %w: %s", reference, err, strings.TrimSpace(stderr.String())) - } - id := strings.TrimSpace(string(out)) - if !strings.HasPrefix(id, "sha256:") { - return "", fmt.Errorf("local image %s reports id %q, which is not a sha256 digest", reference, id) - } - return id, nil -} - -// Document is one platform's inventory, written to disk and named by the digest -// it is bound to. -type Document struct { - Reference string - Digest string - Platform string - Path string - SHA256 string -} - -// Collect inventories every subject through the shared scanner and writes one -// CycloneDX document each. The document carries the scanned digest in its own -// root component, so an exported artifact still names the image it describes -// rather than relying on the filename to say so. -// -// A single failed scan fails the whole call: publishing evidence for some -// platforms while silently omitting others is the false coverage claim this -// evidence exists to prevent. -func Collect(ctx context.Context, dir string, subjects []*builderv0.ImageSubject) ([]Document, error) { - if len(subjects) == 0 { - return nil, fmt.Errorf("no image subjects to inventory") - } - if err := os.MkdirAll(dir, 0o755); err != nil { - return nil, fmt.Errorf("create evidence directory: %w", err) - } - documents := make([]Document, 0, len(subjects)) - for _, subject := range subjects { - result, err := sbom.Image(ctx, sbom.ImageRequest{ - Reference: subject.GetReference(), - Platform: subject.GetPlatform(), - Source: sbom.SourceOf(subject), - }) - if err != nil { - return nil, fmt.Errorf("inventory %s: %w", describe(subject), err) - } - encoded, err := sbom.MarshalCycloneDXJSON(result.Bom) - if err != nil { - return nil, fmt.Errorf("encode %s: %w", describe(subject), err) - } - path := filepath.Join(dir, documentName(result.Platform, result.Digest)) - if err := os.WriteFile(path, encoded, 0o644); err != nil { - return nil, fmt.Errorf("write %s: %w", path, err) - } - documents = append(documents, Document{ - Reference: subject.GetReference(), - Digest: result.Digest, - Platform: result.Platform, - Path: path, - SHA256: result.SHA256, - }) - } - return documents, nil -} - -// WriteIndex records which image and platform each document covers, so a -// release asset is readable without opening every document. -func WriteIndex(dir string, documents []Document) error { - var index strings.Builder - for _, document := range documents { - fmt.Fprintf(&index, "%s %s@%s %s %s\n", - document.Platform, referenceName(document.Reference), document.Digest, - filepath.Base(document.Path), document.SHA256) - } - return os.WriteFile(filepath.Join(dir, "index.txt"), []byte(index.String()), 0o644) -} - -func documentName(platform, digest string) string { - return fmt.Sprintf("object-storage-%s-%s.cdx.json", - strings.ReplaceAll(platform, "/", "-"), - strings.TrimPrefix(digest, "sha256:")) -} - -func describe(subject *builderv0.ImageSubject) string { - if platform := subject.GetPlatform(); platform != "" { - return subject.GetReference() + " on " + platform - } - return subject.GetReference() -} - -// referenceName strips any tag or digest so the index names the repository and -// the digest actually scanned, rather than the tag that was asked for. -func referenceName(reference string) string { - if base, _, found := strings.Cut(reference, "@"); found { - reference = base - } - if slash := strings.LastIndex(reference, "/"); slash >= 0 { - if colon := strings.LastIndex(reference[slash:], ":"); colon >= 0 { - return reference[:slash+colon] - } - return reference - } - if colon := strings.LastIndex(reference, ":"); colon >= 0 { - return reference[:colon] + image := sbom.PublishedImage{Reference: reference, Service: service, Role: Role} + if !local { + image.Platforms = Platforms } - return reference + return sbom.ImageSubjects(ctx, image, local) } diff --git a/main.go b/main.go index dade045..fa4c812 100644 --- a/main.go +++ b/main.go @@ -14,7 +14,6 @@ package main import ( "context" "embed" - "encoding/hex" "encoding/json" "fmt" "os" @@ -52,36 +51,7 @@ var requirements = builders.NewDependencies(agent.Name, // release.yml then creates the consumer-resolvable tags from it and refuses a // digest that was not built for the version being released. // SOS_GATEWAY_IMAGE overrides it (tests use a locally-built image). -var gatewayImage = shared.Must(parseGatewayImageLock(gatewayImageLockJSON)) - -// gatewayImageLock is the recorded identity of the published gateway image. It -// carries no tag: the tag is the agent version, and a second copy of it is one -// more thing to disagree with agent.codefly.yaml. -type gatewayImageLock struct { - Name string `json:"name"` - Digest string `json:"digest"` -} - -func parseGatewayImageLock(content []byte) (*resources.DockerImage, error) { - var lock gatewayImageLock - if err := json.Unmarshal(content, &lock); err != nil { - return nil, fmt.Errorf("parse gateway image lock: %w", err) - } - if lock.Name == "" { - return nil, fmt.Errorf("gateway image name is required") - } - if !isSHA256Digest(lock.Digest) { - return nil, fmt.Errorf("gateway image digest %q must be a sha256 digest", lock.Digest) - } - return &resources.DockerImage{Name: lock.Name, Digest: lock.Digest}, nil -} - -// isSHA256Digest reports whether digest is a complete sha256 image digest. -func isSHA256Digest(digest string) bool { - algorithm, encoded, found := strings.Cut(digest, ":") - decoded, err := hex.DecodeString(encoded) - return found && algorithm == "sha256" && err == nil && len(decoded) == 32 -} +var gatewayImage = shared.Must(resources.ParseImageLock(gatewayImageLockJSON, "service-object-storage")) // gatewayImageOverrideEnv names a locally built gateway image to run instead of // the published one. @@ -132,7 +102,7 @@ func parseMinioImageLock(content []byte) (*resources.DockerImage, error) { if lock.Name == "" { return nil, fmt.Errorf("minio image name is required") } - if !isSHA256Digest(lock.Digest) { + if !resources.IsSHA256Digest(lock.Digest) { return nil, fmt.Errorf("minio image digest %q must be a sha256 digest", lock.Digest) } if !strings.HasPrefix(lock.Release, "RELEASE.") { diff --git a/minio_custody.go b/minio_custody.go index 83ac6ab..083e57f 100644 --- a/minio_custody.go +++ b/minio_custody.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/codefly-dev/core/callertoken" "github.com/codefly-dev/core/resources" "github.com/codefly-dev/core/runners/dockerrun" "github.com/codefly-dev/core/wool" @@ -204,7 +205,7 @@ func ensureMinIOCustody(root, owner, bucket string, present bool) error { if err = claimEmptyCustodyRoot(root); err != nil { return err } - id, genErr := randomSecret() + id, genErr := callertoken.Generate() if genErr != nil { return genErr } diff --git a/minio_custody_e2e_test.go b/minio_custody_e2e_test.go index 845d982..e62c71f 100644 --- a/minio_custody_e2e_test.go +++ b/minio_custody_e2e_test.go @@ -15,6 +15,7 @@ import ( "testing" "time" + "github.com/codefly-dev/core/callertoken" runtimev0 "github.com/codefly-dev/core/generated/go/codefly/services/runtime/v0" "github.com/codefly-dev/core/runners/dockerrun" "github.com/codefly-dev/core/shared" @@ -28,7 +29,6 @@ import ( "google.golang.org/protobuf/proto" storagev0 "github.com/codefly-dev/service-object-storage/gen/codefly/storage/v0" - "github.com/codefly-dev/service-object-storage/internal/auth" ) func TestMinIOPersistentCustody(t *testing.T) { @@ -54,7 +54,7 @@ func TestMinIOPersistentCustody(t *testing.T) { require.NoError(t, err) out, err := exec.Command("docker", "inspect", "-f", `{{(index (index .NetworkSettings.Ports "9464/tcp") 0).HostPort}}`, endpoint).Output() require.NoError(t, err) - conn, err := grpc.NewClient("localhost:"+strings.TrimSpace(string(out)), grpc.WithTransportCredentials(insecure.NewCredentials()), auth.DialOption(token)) + conn, err := grpc.NewClient("localhost:"+strings.TrimSpace(string(out)), grpc.WithTransportCredentials(insecure.NewCredentials()), callertoken.DialOption(token)) require.NoError(t, err) t.Cleanup(func() { _ = conn.Close() }) return storagev0.NewObjectStorageClient(conn) @@ -288,7 +288,7 @@ func TestMinIOBootstrapResumesAfterInterruption(t *testing.T) { rt, _, _ := loadedRuntime(t, ctx) defer func() { require.NoError(t, rt.teardown(context.Background())) }() require.NoError(t, rt.LoadConfiguration(ctx, nil)) - // Exact persisted state when freeHostPort, randomSecret, image fetch or + // Exact persisted state when freeHostPort, callertoken.Generate, image fetch or // ContainerStart fails after successful custody preflight. _, release, err := rt.prepareMinIOData(ctx) require.NoError(t, err) diff --git a/runtime.go b/runtime.go index c9cd67d..b24c3b8 100644 --- a/runtime.go +++ b/runtime.go @@ -2,9 +2,7 @@ package main import ( "context" - "crypto/rand" "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "fmt" @@ -20,6 +18,7 @@ import ( "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/status" + "github.com/codefly-dev/core/callertoken" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" runtimev0 "github.com/codefly-dev/core/generated/go/codefly/services/runtime/v0" @@ -29,7 +28,6 @@ import ( "github.com/codefly-dev/core/wool" storagev0 "github.com/codefly-dev/service-object-storage/gen/codefly/storage/v0" - "github.com/codefly-dev/service-object-storage/internal/auth" ) // gatewayContainerPort is the port the gateway listens on inside its container @@ -222,7 +220,7 @@ func (s *Runtime) resolveGatewayToken() (string, error) { if s.conf.authToken != "" { return s.conf.authToken, nil } - return randomSecret() + return callertoken.Generate() } var errMinIOCustody = errors.New("MinIO custody preflight failed") @@ -253,7 +251,7 @@ func (s *Runtime) startLocalMinIOWithRelease(ctx context.Context) (unlock func() } s.minioHostPort = port - password, err := randomSecret() + password, err := callertoken.Generate() if err != nil { return nil, s.Wool.Wrapf(err, "cannot generate minio credentials") } @@ -569,7 +567,7 @@ func (s *Runtime) WaitForReady(ctx context.Context) error { conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(insecure.NewCredentials()), - auth.DialOption(s.gatewayToken)) + callertoken.DialOption(s.gatewayToken)) if err != nil { return s.Wool.Wrapf(err, "cannot create gateway client for %s", address) } @@ -663,14 +661,3 @@ func freeHostPort() (uint16, error) { defer l.Close() return uint16(l.Addr().(*net.TCPAddr).Port), nil } - -// randomSecret returns an unguessable hex secret. Both the MinIO root password -// and the gateway bearer are generated this way, so neither published port is -// reachable with a well-known credential. -func randomSecret() (string, error) { - b := make([]byte, 24) - if _, err := rand.Read(b); err != nil { - return "", err - } - return hex.EncodeToString(b), nil -} diff --git a/runtime_e2e_test.go b/runtime_e2e_test.go index 6adf19a..c1a1735 100644 --- a/runtime_e2e_test.go +++ b/runtime_e2e_test.go @@ -34,6 +34,7 @@ import ( "testing" "time" + "github.com/codefly-dev/core/callertoken" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" runtimev0 "github.com/codefly-dev/core/generated/go/codefly/services/runtime/v0" @@ -48,7 +49,6 @@ import ( "google.golang.org/grpc/status" storagev0 "github.com/codefly-dev/service-object-storage/gen/codefly/storage/v0" - "github.com/codefly-dev/service-object-storage/internal/auth" ) // TestRuntimeEndToEndPutGet scaffolds a service through the Builder, drives the @@ -129,7 +129,7 @@ func TestRuntimeEndToEndPutGet(t *testing.T) { conn, err := grpc.NewClient(endpoint, grpc.WithTransportCredentials(insecure.NewCredentials()), - auth.DialOption(token)) + callertoken.DialOption(token)) require.NoError(t, err) defer conn.Close() client := storagev0.NewObjectStorageClient(conn) @@ -218,7 +218,7 @@ func requireUnauthenticatedPeerIsRefused(t *testing.T, endpoint string) { t.Helper() peers := map[string][]grpc.DialOption{ "no credentials": nil, - "wrong token": {auth.DialOption("0000000000000000000000000000000000000000000000000000000000000000")}, + "wrong token": {callertoken.DialOption("0000000000000000000000000000000000000000000000000000000000000000")}, } for peerName, extra := range peers { conn, err := grpc.NewClient(endpoint, diff --git a/sbom_e2e_test.go b/sbom_e2e_test.go index fee6908..3b71652 100644 --- a/sbom_e2e_test.go +++ b/sbom_e2e_test.go @@ -50,8 +50,12 @@ func TestImageSBOMInventoriesTheGatewayImage(t *testing.T) { if got := subjects[0].GetReference(); got != os.Getenv(gatewayImageOverrideEnv) { t.Errorf("subject reference = %q, want the override", got) } - if got := subjects[0].GetPlatform(); got != "" { - t.Errorf("subject platform = %q, want it read from the image rather than assumed", got) + platform, err := exec.Command("docker", "image", "inspect", os.Getenv(gatewayImageOverrideEnv), "--format", "{{.Os}}/{{.Architecture}}").Output() + if err != nil { + t.Fatalf("inspect daemon platform: %v", err) + } + if got, want := subjects[0].GetPlatform(), strings.TrimSpace(string(platform)); got != want { + t.Errorf("subject platform = %q, want the daemon's %q", got, want) } if err := sbom.RequirePinned(subjects[0]); err != nil { t.Errorf("override subject is not pinned: %v", err) diff --git a/sbom_test.go b/sbom_test.go index f8d5f5b..cc0b6a3 100644 --- a/sbom_test.go +++ b/sbom_test.go @@ -202,7 +202,7 @@ func TestReleaseEvidenceComesFromTheSharedCommand(t *testing.T) { t.Error("the release workflow loops over its own platform list; it must read imageevidence.Platforms through cmd/image-sbom") } if strings.Contains(workflowBody(t, "publish-gateway-image.yml"), "for platform in") { - t.Error("the publish workflow loops over its own platform list; the platforms it builds are read from its build-push step") + t.Error("the publish workflow loops over its own platform list; the platforms it builds are passed to the reusable workflow") } } @@ -223,41 +223,23 @@ func expectedFromReleasePipeline(t *testing.T, reference string) []*builderv0.Im return expected } -// publishedPlatforms reads the platforms the pipeline actually builds, from the -// step that pushes the image rather than from any step that happens to carry a -// platform list. +// publishedPlatforms reads the platforms passed to core's image publisher, +// independently of the service declaration used to derive evidence subjects. func publishedPlatforms(t *testing.T) []string { t.Helper() var workflow struct { Jobs map[string]struct { - Steps []struct { - Uses string `yaml:"uses"` - With struct { - Platforms string `yaml:"platforms"` - } `yaml:"with"` - } `yaml:"steps"` - } `yaml:"jobs"` + With struct{ Platforms string } + } } if err := yaml.Unmarshal([]byte(workflowBody(t, "publish-gateway-image.yml")), &workflow); err != nil { t.Fatalf("parse publish workflow: %v", err) } - var published []string - for _, job := range workflow.Jobs { - for _, step := range job.Steps { - if !strings.Contains(step.Uses, "docker/build-push-action") { - continue - } - for _, platform := range strings.Split(step.With.Platforms, ",") { - if platform = strings.TrimSpace(platform); platform != "" { - published = append(published, platform) - } - } - } + platforms := workflow.Jobs["publish"].With.Platforms + if platforms == "" { + t.Fatal("the publish workflow declares no platforms") } - if len(published) == 0 { - t.Fatal("the publish workflow's build-push step publishes no platforms") - } - return published + return strings.Split(platforms, ",") } func releaseWorkflow(t *testing.T) string { @@ -633,3 +615,12 @@ func evidenceFor(subjects []*builderv0.ImageSubject) []*builderv0.ImageSBOM { } return images } + +// A registry tag can move between declaring the subjects and scanning them. +// The service's declaration must reach core's pin validation before any scan. +func TestPublishedEvidenceRefusesABareTag(t *testing.T) { + _, err := imageevidence.Subjects(context.Background(), testService, "ghcr.io/codefly-dev/service-object-storage:latest", false) + if err == nil { + t.Fatal("accepted a mutable registry tag as an evidence subject") + } +} From 65315e944d0b52b6b17e49fa0bb1da7906879d99 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:51:20 -0400 Subject: [PATCH 2/2] fix: address shared plumbing release review --- .github/workflows/release.yml | 2 +- AGENTS.md | 7 ++++--- README.md | 8 ++++---- gateway_image_test.go | 4 ++-- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b2b5cc..f6939dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -143,5 +143,5 @@ jobs: # carries secrets: inherit, so a mutable ref would hand an upstream change # this repo's GH_PAT with no commit here — the risk ci.yml pins against, # on the one call that actually inherits the token. Bump deliberately. - uses: codefly-dev/core/.github/workflows/go-service-release.yml@08e37db5962cd98ddf77f8fb7dffe810a7ca1fc9 + uses: codefly-dev/core/.github/workflows/go-service-release.yml@ea49e7a968ef5f2fd325b1c41f823a47eb20141c # v0.18.1 secrets: inherit diff --git a/AGENTS.md b/AGENTS.md index 42349ee..66dbc13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ and the local/deployed profiles. This file is how to work in the repo. - The CLI and the agent framework — `codefly-dev/cli`, and `codefly-dev/core`, which is both a `go.mod` dependency and the home of the shared CI workflows - this repo calls (pinned here by commit SHA, never `@main`). Core v0.18.1 also + this repo calls (pinned here by commit SHA, never `@main`). Core also owns `callertoken`, the gateway image-lock parser and shared image evidence. Do not restore service-local copies. Pin dependencies with `codefly agent deps --pin ` so every owned lock is checked. @@ -112,7 +112,8 @@ one workflow, in that order. The tag must match `version:` in that file resolves the *previous* image, which exists, so nothing fails at run time and the evidence describes an image the agent never runs. The shipped platform list lives in `internal/imageevidence` and a test asserts it against -the inputs to core's reusable image publisher. +the inputs to core's reusable image publisher. Core's agent-release workflow +accepts only pushed tags whose commits are reachable from the default branch. Prepare the gateway before publishing the agent: use `codefly publish --dry-run` to determine the next version, then dispatch `publish-gateway-image.yml` with @@ -129,7 +130,7 @@ source metadata has its own schema but shares core's digest validation. Registry evidence rejects bare tags and declares one subject per shipped platform; local evidence reads its image ID and actual platform from Docker. -Caller tokens are now 64 hex characters, also used for generated MinIO passwords +Caller tokens are 64 hex characters, also used for generated MinIO passwords and custody IDs. Only exact health Check/Watch RPCs bypass the token; Health/List requires it. An empty interceptor token fails closed, while the gateway's explicit anonymous profile continues to install no interceptors. diff --git a/README.md b/README.md index 6808a0a..8e888f4 100644 --- a/README.md +++ b/README.md @@ -174,11 +174,11 @@ With `SOS_AUTH_TOKEN` set, every RPC — unary and streaming, `Capabilities` included — must carry the secret as the `x-codefly-token` gRPC metadata header, the same key the codefly host already uses to authenticate agent plugins. Anything else is rejected with `UNAUTHENTICATED` before it reaches a backend. -Core v0.18.1's `callertoken` package compares the token in constant time. +Core's `callertoken` package compares the token in constant time. Only the exact `/grpc.health.v1.Health/Check` and `/grpc.health.v1.Health/Watch` RPCs are exempt; `Health/List` requires a token. -Generated tokens are 64 lowercase hex characters (32 random bytes), previously -48. Operator-provided tokens retain their configured value. The same generator +Generated tokens are 64 lowercase hex characters (32 random bytes). +Operator-provided tokens retain their configured value. The same generator supplies local MinIO passwords and custody identifiers. An empty token passed to a core interceptor fails closed. The gateway installs @@ -334,7 +334,7 @@ the digest and the agent pins it, so the container a release runs — and report evidence for — is the one that was built for it rather than whatever the tag serves now. The digest only exists once the image is built, so the image is published ahead of the release tag by `publish-gateway-image.yml`. That caller -uses core v0.18.1's `publish-service-image.yml`, pinned by commit SHA, with the +uses core's `publish-service-image.yml`, pinned by commit SHA, with the upcoming stable version. It checks out without persisting credentials and refuses a commit not reachable from this repository's default branch. Adopt its `gateway-image.json-` artifact in a reviewed PR before the diff --git a/gateway_image_test.go b/gateway_image_test.go index e759e5b..5e733f7 100644 --- a/gateway_image_test.go +++ b/gateway_image_test.go @@ -13,7 +13,7 @@ func TestParseGatewayImageLock(t *testing.T) { "digest": "sha256:1205da0743e56b68c12c754438476e8fdd0f05cdb08ce1c9687ddb8d8f0e1669" }`), "service-object-storage") if err != nil { - t.Fatalf("parseGatewayImageLock: %v", err) + t.Fatalf("resources.ParseImageLock: %v", err) } const want = "ghcr.io/codefly-dev/service-object-storage@sha256:1205da0743e56b68c12c754438476e8fdd0f05cdb08ce1c9687ddb8d8f0e1669" if got := image.FullName(); got != want { @@ -57,7 +57,7 @@ func TestGatewayImageMatchesTheLockOnDisk(t *testing.T) { } expected, err := resources.ParseImageLock(lock, "service-object-storage") if err != nil { - t.Fatalf("parseGatewayImageLock: %v", err) + t.Fatalf("resources.ParseImageLock: %v", err) } if got, want := gatewayImage.FullName(), expected.FullName(); got != want { t.Errorf("gateway image = %q, want the recorded %q", got, want)