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/.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 49e302d..66dbc13 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 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,32 @@ 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. 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 -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 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..8e888f4 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'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). +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'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..5e733f7 100644 --- a/gateway_image_test.go +++ b/gateway_image_test.go @@ -3,15 +3,17 @@ 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) + t.Fatalf("resources.ParseImageLock: %v", err) } const want = "ghcr.io/codefly-dev/service-object-storage@sha256:1205da0743e56b68c12c754438476e8fdd0f05cdb08ce1c9687ddb8d8f0e1669" if got := image.FullName(); got != want { @@ -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,9 +55,9 @@ 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) + 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) 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") + } +}