Skip to content

Commit d7ae4fa

Browse files
committed
feat(artifact-cas): verify uploaded content against the declared digest
The Artifact CAS keys stored artifacts by their client-declared SHA256. This change adds end-to-end content-integrity verification to the upload path so the canonical key is only ever written from bytes CAS has confirmed hash to that digest, across all object-store backends (S3, S3 access point, Azure Blob), in addition to the OCI backend's existing layer-digest check. The CAS service streams each upload to a temporary file on a local staging volume, computes its SHA256 while receiving, and verifies it against the declared digest before handing the verified file to the backend. A mismatch is rejected with InvalidArgument and nothing is written to the backend. The upload path is consolidated into a single spill-then-upload flow shared by all backends, replacing the per-backend streaming branch and the StreamingUploader interface. The staging directory is configured through staging_dir and is required: the CAS refuses to start without one, and proves it is writable before serving traffic, so a misconfiguration surfaces at boot rather than as a healthy service that fails every upload. The Helm chart mounts a dedicated emptyDir for it. Every exit path of the upload handler removes its own staging file, so the staging volume is not left holding content once a request ends. Assisted-by: Claude Code Signed-off-by: Javier Rodriguez <javier@chainloop.dev> Chainloop-Trace-Sessions: 5d0fc605-25b0-4bb3-900c-2c2a8fb4922f
1 parent 4c7a81f commit d7ae4fa

27 files changed

Lines changed: 858 additions & 506 deletions

‎app/artifact-cas/cmd/main.go‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
package main
1717

1818
import (
19+
"errors"
1920
"flag"
21+
"fmt"
2022
"os"
2123
"time"
2224

@@ -121,6 +123,12 @@ func main() {
121123

122124
_ = logger.Log(log.LevelInfo, "msg", "starting artifact-cas service", "version", Version)
123125

126+
// Ensure the upload staging directory is configured and writable before we
127+
// accept any traffic: without it no upload can be verified.
128+
if err := prepareStagingDir(&bc); err != nil {
129+
panic(err)
130+
}
131+
124132
flush, err := initSentry(&bc, logger)
125133
defer flush()
126134
if err != nil {
@@ -152,6 +160,44 @@ func newProtoValidator() (protovalidate.Validator, error) {
152160
return protovalidate.New()
153161
}
154162

163+
// prepareStagingDir creates the upload staging directory and proves it is
164+
// writable. It must be the same directory the service is configured with (see
165+
// serviceOpts / conf.staging_dir).
166+
//
167+
// staging_dir is required: uploads are verified by spilling them here first, so
168+
// a missing or unwritable directory means no upload can succeed. Failing at
169+
// startup surfaces that immediately, rather than letting the service report
170+
// healthy and reject every upload. The CAS container runs with a read-only root
171+
// filesystem and /tmp is a read-only secret mount, so the configured directory is
172+
// the only place uploads can be staged.
173+
//
174+
// The directory stays clean on its own: each upload removes its staging file on
175+
// every exit path, and the emptyDir backing it is cleared by Kubernetes when the
176+
// Pod is removed from the node.
177+
func prepareStagingDir(bc *conf.Bootstrap) error {
178+
dir := bc.GetStagingDir()
179+
if dir == "" {
180+
return errors.New("staging_dir is required: it must point at a writable, pod-local directory")
181+
}
182+
183+
if err := os.MkdirAll(dir, 0o700); err != nil {
184+
return fmt.Errorf("creating staging dir %q: %w", dir, err)
185+
}
186+
187+
// MkdirAll succeeds on a directory that already exists but cannot be written
188+
// to, so probe it rather than discovering the problem on the first upload.
189+
probe, err := os.CreateTemp(dir, ".writable-probe-*")
190+
if err != nil {
191+
return fmt.Errorf("staging dir %q is not writable: %w", dir, err)
192+
}
193+
_ = probe.Close()
194+
if err := os.Remove(probe.Name()); err != nil {
195+
return fmt.Errorf("removing staging dir probe file: %w", err)
196+
}
197+
198+
return nil
199+
}
200+
155201
func initSentry(c *conf.Bootstrap, logger log.Logger) (cleanupFunc func(), err error) {
156202
cleanupFunc = func() {
157203
sentry.Flush(2 * time.Second)

‎app/artifact-cas/cmd/main_test.go‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package main
17+
18+
import (
19+
"os"
20+
"path/filepath"
21+
"testing"
22+
23+
"github.com/chainloop-dev/chainloop/app/artifact-cas/internal/conf"
24+
"github.com/stretchr/testify/require"
25+
)
26+
27+
// TestPrepareStagingDir: the staging directory is a hard startup requirement.
28+
// Every way it can be unusable must fail the boot, because a CAS that starts
29+
// without one reports healthy and then rejects every upload.
30+
func TestPrepareStagingDir(t *testing.T) {
31+
testCases := []struct {
32+
name string
33+
// dir builds the staging_dir value for the case; t.TempDir() gives each
34+
// case its own scratch space.
35+
dir func(t *testing.T) string
36+
expectErr string
37+
}{
38+
{
39+
name: "unconfigured is rejected rather than defaulted",
40+
dir: func(*testing.T) string { return "" },
41+
expectErr: "staging_dir is required",
42+
},
43+
{
44+
name: "an existing writable directory is accepted",
45+
dir: func(t *testing.T) string { return t.TempDir() },
46+
},
47+
{
48+
name: "a missing directory is created",
49+
dir: func(t *testing.T) string {
50+
return filepath.Join(t.TempDir(), "nested", "staging")
51+
},
52+
},
53+
{
54+
name: "an existing but unwritable directory is rejected",
55+
dir: func(t *testing.T) string {
56+
dir := filepath.Join(t.TempDir(), "read-only")
57+
require.NoError(t, os.Mkdir(dir, 0o500))
58+
t.Cleanup(func() { _ = os.Chmod(dir, 0o700) })
59+
return dir
60+
},
61+
expectErr: "not writable",
62+
},
63+
}
64+
65+
for _, tc := range testCases {
66+
t.Run(tc.name, func(t *testing.T) {
67+
if os.Geteuid() == 0 && tc.expectErr == "not writable" {
68+
t.Skip("root ignores directory permissions")
69+
}
70+
71+
dir := tc.dir(t)
72+
err := prepareStagingDir(&conf.Bootstrap{StagingDir: dir})
73+
74+
if tc.expectErr != "" {
75+
require.ErrorContains(t, err, tc.expectErr)
76+
return
77+
}
78+
79+
require.NoError(t, err)
80+
require.DirExists(t, dir)
81+
82+
// The writability probe must not survive the check.
83+
entries, err := os.ReadDir(dir)
84+
require.NoError(t, err)
85+
require.Empty(t, entries, "the probe file must be removed")
86+
})
87+
}
88+
}

‎app/artifact-cas/cmd/wire.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,11 @@ func wireApp(*conf.Bootstrap, *conf.Server, *conf.Auth, credentials.Reader, log.
5151
)
5252
}
5353

54-
func serviceOpts(l log.Logger, audit *service.AuditDispatcher) []service.NewOpt {
54+
func serviceOpts(l log.Logger, audit *service.AuditDispatcher, bc *conf.Bootstrap) []service.NewOpt {
5555
return []service.NewOpt{
5656
service.WithLogger(l),
5757
service.WithAuditDispatcher(audit),
58+
service.WithStagingDir(bc.GetStagingDir()),
5859
}
5960
}
6061

‎app/artifact-cas/cmd/wire_gen.go‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎app/artifact-cas/configs/config.devel.yaml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,8 @@ observability:
3939

4040
auth:
4141
public_key_path: ${PUBLIC_KEY_PATH:../../devel/devkeys/cas.pub}
42+
43+
# Local directory where uploads are staged and verified against the declared
44+
# digest before being sent to the backend. Required: there is no fallback.
45+
# In Kubernetes this points at a dedicated per-pod emptyDir.
46+
staging_dir: ${STAGING_DIR:/tmp/chainloop-cas-staging}

‎app/artifact-cas/internal/conf/conf.pb.go‎

Lines changed: 18 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎app/artifact-cas/internal/conf/conf.proto‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ message Bootstrap {
2828
// Optional NATS server configuration to publish audit events to the
2929
// control-plane-owned stream. When unset, event publishing is disabled.
3030
NatsServer nats_server = 5;
31+
// Local directory where uploads (and, later, downloads) are staged on disk
32+
// and verified against the declared digest before reaching the backend. It
33+
// must be a writable volume; in production a dedicated emptyDir is mounted
34+
// here (NOT tmpfs/RAM, and NOT the /tmp secret mount). When unset the service
35+
// falls back to the OS temp dir, which is only appropriate for local dev.
36+
string staging_dir = 6;
3137

3238
message NatsServer {
3339
// NATS server URI, e.g. "nats://localhost:4222"

‎app/artifact-cas/internal/service/auditor_test.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ func testUploadedEntry() auditor.LogEntry {
7171

7272
const testOrgID = "1089bb36-e27b-428b-8009-d015c8737c54"
7373

74+
// testStoredSecretID is the credentials handle the fake JWT claims carry.
75+
const testStoredSecretID = "secret-id"
76+
7477
func TestAuditDispatcherDispatch(t *testing.T) {
7578
tests := []struct {
7679
name string

0 commit comments

Comments
 (0)