Skip to content

Commit 6fb0282

Browse files
authored
feat: store policy evaluations in CAS during attestation push (#2949)
Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
1 parent c7f0b9b commit 6fb0282

13 files changed

Lines changed: 606 additions & 164 deletions

File tree

app/cli/cmd/attestation_push.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222

2323
schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
2424
"github.com/spf13/cobra"
25+
"github.com/spf13/viper"
2526
"google.golang.org/grpc/codes"
2627
"google.golang.org/grpc/status"
2728

@@ -71,9 +72,15 @@ func newAttestationPushCmd() *cobra.Command {
7172
return fmt.Errorf("getting executable information: %w", err)
7273
}
7374
a, err := action.NewAttestationPush(&action.AttestationPushOpts{
74-
ActionsOpts: ActionOpts, KeyPath: pkPath, BundlePath: bundle,
75-
CLIVersion: info.Version, CLIDigest: info.Digest,
76-
LocalStatePath: attestationLocalStatePath,
75+
ActionsOpts: ActionOpts,
76+
KeyPath: pkPath,
77+
BundlePath: bundle,
78+
CLIVersion: info.Version,
79+
CLIDigest: info.Digest,
80+
CASURI: viper.GetString(confOptions.CASAPI.viperKey),
81+
CASCAPath: viper.GetString(confOptions.CASCA.viperKey),
82+
ConnectionInsecure: apiInsecure(),
83+
LocalStatePath: attestationLocalStatePath,
7784
SignServerOpts: &action.SignServerOpts{
7885
CAPath: signServerCAPath,
7986
AuthClientCertPath: signServerAuthCertPath,

app/cli/pkg/action/attestation_push.go

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
package action
1717

1818
import (
19+
"bytes"
1920
"context"
21+
"crypto/sha256"
2022
"encoding/json"
2123
"fmt"
2224
"os"
@@ -25,9 +27,13 @@ import (
2527
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
2628
"github.com/chainloop-dev/chainloop/pkg/attestation"
2729
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter"
30+
v1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
2831
"github.com/chainloop-dev/chainloop/pkg/attestation/renderer"
32+
crChainloop "github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop"
2933
"github.com/chainloop-dev/chainloop/pkg/attestation/signer"
34+
"github.com/chainloop-dev/chainloop/pkg/casclient"
3035
"github.com/chainloop-dev/chainloop/pkg/policies"
36+
intoto "github.com/in-toto/attestation/go/v1"
3137
"github.com/secure-systems-lab/go-securesystemslib/dsse"
3238
protobundle "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1"
3339
"google.golang.org/grpc"
@@ -38,9 +44,11 @@ import (
3844
type AttestationPushOpts struct {
3945
*ActionsOpts
4046
KeyPath, CLIVersion, CLIDigest, BundlePath string
41-
42-
LocalStatePath string
43-
SignServerOpts *SignServerOpts
47+
CASURI string
48+
CASCAPath string
49+
ConnectionInsecure bool
50+
LocalStatePath string
51+
SignServerOpts *SignServerOpts
4452
}
4553

4654
// SignServerOpts holds SignServer integration options
@@ -60,6 +68,9 @@ type AttestationResult struct {
6068
type AttestationPush struct {
6169
*ActionsOpts
6270
keyPath, cliVersion, cliDigest, bundlePath string
71+
casURI string
72+
casCAPath string
73+
connectionInsecure bool
6374
localStatePath string
6475
signServerOpts *SignServerOpts
6576
*newCrafterOpts
@@ -68,14 +79,17 @@ type AttestationPush struct {
6879
func NewAttestationPush(cfg *AttestationPushOpts) (*AttestationPush, error) {
6980
opts := []crafter.NewOpt{crafter.WithLogger(&cfg.Logger), crafter.WithAuthRawToken(cfg.AuthTokenRaw)}
7081
return &AttestationPush{
71-
ActionsOpts: cfg.ActionsOpts,
72-
keyPath: cfg.KeyPath,
73-
cliVersion: cfg.CLIVersion,
74-
cliDigest: cfg.CLIDigest,
75-
bundlePath: cfg.BundlePath,
76-
signServerOpts: cfg.SignServerOpts,
77-
localStatePath: cfg.LocalStatePath,
78-
newCrafterOpts: &newCrafterOpts{cpConnection: cfg.CPConnection, opts: opts},
82+
ActionsOpts: cfg.ActionsOpts,
83+
keyPath: cfg.KeyPath,
84+
cliVersion: cfg.CLIVersion,
85+
cliDigest: cfg.CLIDigest,
86+
bundlePath: cfg.BundlePath,
87+
casURI: cfg.CASURI,
88+
casCAPath: cfg.CASCAPath,
89+
connectionInsecure: cfg.ConnectionInsecure,
90+
signServerOpts: cfg.SignServerOpts,
91+
localStatePath: cfg.LocalStatePath,
92+
newCrafterOpts: &newCrafterOpts{cpConnection: cfg.CPConnection, opts: opts},
7993
}, nil
8094
}
8195

@@ -205,6 +219,29 @@ func (action *AttestationPush) Run(ctx context.Context, attestationID string, ru
205219
// Update the status result with the definitive push-phase evaluation against the final statement
206220
attestationStatus.PolicyEvaluations, attestationStatus.HasPolicyViolations = getPolicyEvaluations(crafter)
207221

222+
// Upload policy evaluations bundle to CAS when an external backend is available
223+
if evaluations := crafter.CraftingState.GetAttestation().GetPolicyEvaluations(); !crafter.CraftingState.DryRun && len(evaluations) > 0 {
224+
casBackend := &casclient.CASBackend{Name: "not-set"}
225+
workflowRunID := crafter.CraftingState.GetAttestation().GetWorkflow().GetWorkflowRunId()
226+
_, connectionCloserFn, getCASErr := getCASBackend(ctx, attClient, workflowRunID, action.casCAPath, action.casURI, action.connectionInsecure, action.Logger, casBackend)
227+
if connectionCloserFn != nil {
228+
// nolint: errcheck
229+
defer connectionCloserFn()
230+
}
231+
232+
if getCASErr != nil || casBackend.Uploader == nil {
233+
action.Logger.Debug().Msg("CAS backend is inline, skipping policy evaluations bundle upload")
234+
} else {
235+
ref, uploadErr := uploadPolicyEvaluationsBundle(ctx, evaluations, casBackend.Uploader)
236+
if uploadErr != nil {
237+
return nil, fmt.Errorf("uploading policy evaluations bundle to CAS: %w", uploadErr)
238+
}
239+
if ref != nil {
240+
renderer.SetPolicyEvaluationsRef(ref)
241+
}
242+
}
243+
}
244+
208245
// render final attestation with all the evaluated policies inside
209246
envelope, bundle, err := renderer.Render(ctx)
210247
if err != nil {
@@ -318,3 +355,31 @@ func decodeEnvelope(rawEnvelope []byte) (*dsse.Envelope, error) {
318355

319356
return envelope, nil
320357
}
358+
359+
// uploadPolicyEvaluationsBundle serializes policy evaluations as a protobuf bundle,
360+
// uploads to CAS, and returns a ResourceDescriptor referencing the uploaded object.
361+
// Returns (nil, nil) when there are no evaluations or no uploader.
362+
func uploadPolicyEvaluationsBundle(ctx context.Context, evaluations []*v1.PolicyEvaluation, uploader casclient.Uploader) (*intoto.ResourceDescriptor, error) {
363+
if len(evaluations) == 0 || uploader == nil {
364+
return nil, nil
365+
}
366+
367+
bundle := &v1.PolicyEvaluationBundle{Evaluations: evaluations}
368+
data, err := protojson.Marshal(bundle)
369+
if err != nil {
370+
return nil, fmt.Errorf("marshaling policy evaluation bundle: %w", err)
371+
}
372+
373+
hexDigest := fmt.Sprintf("%x", sha256.Sum256(data))
374+
digest := fmt.Sprintf("sha256:%s", hexDigest)
375+
376+
if _, err := uploader.Upload(ctx, bytes.NewReader(data), "policy-evaluations.json", digest); err != nil {
377+
return nil, fmt.Errorf("uploading policy evaluation bundle: %w", err)
378+
}
379+
380+
return &intoto.ResourceDescriptor{
381+
Name: "policy-evaluations",
382+
Digest: map[string]string{"sha256": hexDigest},
383+
MediaType: crChainloop.PolicyEvaluationsBundleMediaType,
384+
}, nil
385+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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 action
17+
18+
import (
19+
"context"
20+
"crypto/sha256"
21+
"fmt"
22+
"testing"
23+
24+
v1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1"
25+
"github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop"
26+
"github.com/chainloop-dev/chainloop/pkg/casclient"
27+
casclientmock "github.com/chainloop-dev/chainloop/pkg/casclient/mocks"
28+
"github.com/stretchr/testify/assert"
29+
"github.com/stretchr/testify/mock"
30+
"github.com/stretchr/testify/require"
31+
"google.golang.org/protobuf/encoding/protojson"
32+
)
33+
34+
func TestUploadPolicyEvaluationsBundle(t *testing.T) {
35+
testCases := []struct {
36+
name string
37+
evaluations []*v1.PolicyEvaluation
38+
uploader func(t *testing.T) casclient.Uploader
39+
wantRef bool
40+
wantErr bool
41+
}{
42+
{
43+
name: "nil evaluations returns nil ref",
44+
evaluations: nil,
45+
wantRef: false,
46+
},
47+
{
48+
name: "empty evaluations returns nil ref",
49+
evaluations: []*v1.PolicyEvaluation{},
50+
wantRef: false,
51+
},
52+
{
53+
name: "nil uploader returns nil ref",
54+
evaluations: []*v1.PolicyEvaluation{
55+
{Name: "test-policy"},
56+
},
57+
wantRef: false,
58+
},
59+
{
60+
name: "successful upload returns ref with correct digest and media type",
61+
evaluations: []*v1.PolicyEvaluation{
62+
{Name: "test-policy", MaterialName: "sbom"},
63+
},
64+
uploader: func(t *testing.T) casclient.Uploader {
65+
t.Helper()
66+
m := casclientmock.NewUploader(t)
67+
m.On("Upload", mock.Anything, mock.Anything, "policy-evaluations.json", mock.MatchedBy(func(digest string) bool {
68+
return len(digest) > 7 && digest[:7] == "sha256:"
69+
})).Return(&casclient.UpDownStatus{Filename: "policy-evaluations.json"}, nil)
70+
return m
71+
},
72+
wantRef: true,
73+
},
74+
{
75+
name: "upload failure returns error",
76+
evaluations: []*v1.PolicyEvaluation{
77+
{Name: "test-policy"},
78+
},
79+
uploader: func(t *testing.T) casclient.Uploader {
80+
t.Helper()
81+
m := casclientmock.NewUploader(t)
82+
m.On("Upload", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, fmt.Errorf("upload failed"))
83+
return m
84+
},
85+
wantErr: true,
86+
},
87+
}
88+
89+
for _, tc := range testCases {
90+
t.Run(tc.name, func(t *testing.T) {
91+
var uploader casclient.Uploader
92+
if tc.uploader != nil {
93+
uploader = tc.uploader(t)
94+
}
95+
96+
ref, err := uploadPolicyEvaluationsBundle(context.Background(), tc.evaluations, uploader)
97+
if tc.wantErr {
98+
require.Error(t, err)
99+
return
100+
}
101+
102+
require.NoError(t, err)
103+
104+
if !tc.wantRef {
105+
assert.Nil(t, ref)
106+
return
107+
}
108+
109+
require.NotNil(t, ref)
110+
assert.Equal(t, "policy-evaluations", ref.Name)
111+
assert.Equal(t, chainloop.PolicyEvaluationsBundleMediaType, ref.MediaType)
112+
assert.NotEmpty(t, ref.Digest["sha256"])
113+
114+
// Verify the digest matches what we'd expect from serializing the bundle
115+
bundle := &v1.PolicyEvaluationBundle{Evaluations: tc.evaluations}
116+
data, err := protojson.Marshal(bundle)
117+
require.NoError(t, err)
118+
expectedDigest := fmt.Sprintf("%x", sha256.Sum256(data))
119+
assert.Equal(t, expectedDigest, ref.Digest["sha256"])
120+
})
121+
}
122+
}

app/controlplane/api/gen/frontend/attestation/v1/crafting_state.ts

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

app/controlplane/pkg/biz/casmapping.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,5 +240,15 @@ func (uc *CASMappingUseCase) LookupDigestsInAttestation(att *dsse.Envelope, dige
240240
}
241241
}
242242

243+
// Include the policy evaluations bundle if stored in CAS
244+
if ref := predicate.GetPolicyEvaluationsRef(); ref != nil {
245+
if d, ok := ref.Digest["sha256"]; ok {
246+
references = append(references, &CASMappingLookupRef{
247+
Name: ref.Name,
248+
Digest: fmt.Sprintf("sha256:%s", d),
249+
})
250+
}
251+
}
252+
243253
return references, nil
244254
}

0 commit comments

Comments
 (0)