Skip to content

Commit be91cda

Browse files
committed
feat: resolve policy evaluations from CAS in workflow-run View API
Update WorkflowRunService.View() to resolve policy evaluations from the CAS-stored bundle (via PolicyEvaluationsRef) instead of relying solely on inline attestation predicate data. This prepares consumers for the eventual removal of inline policy evaluation content. On any CAS resolution failure, gracefully falls back to inline evaluations with a warning log. Closes #2950 Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
1 parent fa49310 commit be91cda

6 files changed

Lines changed: 234 additions & 18 deletions

File tree

app/controlplane/cmd/wire.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ func newAuthAllowList(conf *conf.Bootstrap) *pkgConf.AllowList {
138138
var cacheProviderSet = wire.NewSet(
139139
newMembershipsCache,
140140
newClaimsCache,
141+
newPolicyEvalBundleCache,
141142
)
142143

143144
func newClaimsCache(conn *nats.Conn, logger log.Logger) (cache.Cache[*jwt.MapClaims], error) {
@@ -164,6 +165,18 @@ func newMembershipsCache(conn *nats.Conn, logger log.Logger) (cache.Cache[*entit
164165
return cache.New[*entities.Membership](opts...)
165166
}
166167

168+
func newPolicyEvalBundleCache(conn *nats.Conn, logger log.Logger) (cache.Cache[[]byte], error) {
169+
l := log.NewHelper(logger)
170+
backend := "memory"
171+
opts := []cache.Option{cache.WithTTL(24 * time.Hour), cache.WithLogger(&kratosLogAdapter{h: l})}
172+
if conn != nil {
173+
backend = "nats"
174+
opts = append(opts, cache.WithNATS(conn, "chainloop-policy-eval-bundles"))
175+
}
176+
l.Infow("msg", "cache initialized", "bucket", "chainloop-policy-eval-bundles", "backend", backend, "ttl", "24h")
177+
return cache.New[[]byte](opts...)
178+
}
179+
167180
// kratosLogAdapter adapts kratos log.Helper (Debugw(...interface{})) to cache.Logger (Debugw(string, ...any)).
168181
type kratosLogAdapter struct{ h *log.Helper }
169182

app/controlplane/cmd/wire_gen.go

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

app/controlplane/internal/service/attestation.go

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -533,7 +533,7 @@ func (s *AttestationService) GetPolicyGroup(ctx context.Context, req *cpAPI.Atte
533533
}}, nil
534534
}
535535

536-
func bizAttestationToPb(att *biz.Attestation) (*cpAPI.AttestationItem, error) {
536+
func bizAttestationToPb(att *biz.Attestation, predicate chainloop.NormalizablePredicate) (*cpAPI.AttestationItem, error) {
537537
if att == nil || att.Envelope == nil {
538538
return nil, nil
539539
}
@@ -543,11 +543,6 @@ func bizAttestationToPb(att *biz.Attestation) (*cpAPI.AttestationItem, error) {
543543
return nil, err
544544
}
545545

546-
predicate, err := chainloop.ExtractPredicate(att.Envelope)
547-
if err != nil {
548-
return nil, fmt.Errorf("error extracting predicate from attestation: %w", err)
549-
}
550-
551546
materials, err := extractMaterials(predicate.GetMaterials())
552547
if err != nil {
553548
return nil, fmt.Errorf("error extracting materials from attestation: %w", err)

app/controlplane/internal/service/workflowrun.go

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2024-2025 The Chainloop Authors.
2+
// Copyright 2024-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -16,6 +16,7 @@
1616
package service
1717

1818
import (
19+
"bytes"
1920
"context"
2021
"fmt"
2122
"slices"
@@ -25,9 +26,12 @@ import (
2526
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz"
2627
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz"
2728
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/pagination"
29+
chainloop "github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop"
30+
"github.com/chainloop-dev/chainloop/pkg/cache"
2831
"github.com/chainloop-dev/chainloop/pkg/credentials"
2932
errors "github.com/go-kratos/kratos/v2/errors"
3033
"github.com/google/uuid"
34+
intoto "github.com/in-toto/attestation/go/v1"
3135
"google.golang.org/protobuf/types/known/timestamppb"
3236
)
3337

@@ -40,6 +44,9 @@ type WorkflowRunService struct {
4044
workflowContractUseCase *biz.WorkflowContractUseCase
4145
projectUseCase *biz.ProjectUseCase
4246
credsReader credentials.Reader
47+
casClient biz.CASClient
48+
casMappingUC *biz.CASMappingUseCase
49+
policyEvalCache cache.Cache[[]byte]
4350
}
4451

4552
type NewWorkflowRunServiceOpts struct {
@@ -48,6 +55,9 @@ type NewWorkflowRunServiceOpts struct {
4855
WorkflowContractUC *biz.WorkflowContractUseCase
4956
ProjectUC *biz.ProjectUseCase
5057
CredsReader credentials.Reader
58+
CASClient biz.CASClient
59+
CASMappingUC *biz.CASMappingUseCase
60+
PolicyEvalCache cache.Cache[[]byte]
5161
Opts []NewOpt
5262
}
5363

@@ -59,9 +69,56 @@ func NewWorkflowRunService(opts *NewWorkflowRunServiceOpts) *WorkflowRunService
5969
workflowContractUseCase: opts.WorkflowContractUC,
6070
projectUseCase: opts.ProjectUC,
6171
credsReader: opts.CredsReader,
72+
casClient: opts.CASClient,
73+
casMappingUC: opts.CASMappingUC,
74+
policyEvalCache: opts.PolicyEvalCache,
6275
}
6376
}
6477

78+
type casResolvedPredicate struct {
79+
chainloop.NormalizablePredicate
80+
evals map[string][]*chainloop.PolicyEvaluation
81+
}
82+
83+
func (p *casResolvedPredicate) GetPolicyEvaluations() map[string][]*chainloop.PolicyEvaluation {
84+
return p.evals
85+
}
86+
87+
func (s *WorkflowRunService) resolvePolicyEvaluations(
88+
ctx context.Context,
89+
ref *intoto.ResourceDescriptor,
90+
orgID uuid.UUID,
91+
) (map[string][]*chainloop.PolicyEvaluation, error) {
92+
if ref == nil {
93+
return nil, nil
94+
}
95+
96+
hexDigest, ok := ref.Digest["sha256"]
97+
if !ok {
98+
return nil, fmt.Errorf("no sha256 digest in policy evaluations ref")
99+
}
100+
digest := fmt.Sprintf("sha256:%s", hexDigest)
101+
102+
if cached, found, err := s.policyEvalCache.Get(ctx, digest); err == nil && found {
103+
return chainloop.PolicyEvaluationsFromBundle(cached)
104+
}
105+
106+
mapping, err := s.casMappingUC.FindCASMappingForDownloadByOrg(ctx, digest, []uuid.UUID{orgID}, nil)
107+
if err != nil {
108+
return nil, fmt.Errorf("finding CAS mapping: %w", err)
109+
}
110+
111+
var buf bytes.Buffer
112+
if err := s.casClient.Download(ctx, string(mapping.CASBackend.Provider), mapping.CASBackend.SecretName, &buf, digest); err != nil {
113+
return nil, fmt.Errorf("downloading policy eval bundle: %w", err)
114+
}
115+
116+
data := buf.Bytes()
117+
_ = s.policyEvalCache.Set(ctx, digest, data)
118+
119+
return chainloop.PolicyEvaluationsFromBundle(data)
120+
}
121+
65122
func (s *WorkflowRunService) List(ctx context.Context, req *pb.WorkflowRunServiceListRequest) (*pb.WorkflowRunServiceListResponse, error) {
66123
currentOrg, err := requireCurrentOrg(ctx)
67124
if err != nil {
@@ -185,7 +242,24 @@ func (s *WorkflowRunService) View(ctx context.Context, req *pb.WorkflowRunServic
185242
verificationResult = bizVerificationToPb(vr)
186243
}
187244

188-
attestation, err := bizAttestationToPb(run.Attestation)
245+
var predicate chainloop.NormalizablePredicate
246+
if run.Attestation != nil && run.Attestation.Envelope != nil {
247+
predicate, err = chainloop.ExtractPredicate(run.Attestation.Envelope)
248+
if err != nil {
249+
return nil, handleUseCaseErr(err, s.log)
250+
}
251+
252+
if ref := predicate.GetPolicyEvaluationsRef(); ref != nil {
253+
resolved, resolveErr := s.resolvePolicyEvaluations(ctx, ref, run.Workflow.OrgID)
254+
if resolveErr != nil {
255+
s.log.Warnw("msg", "failed to resolve policy evaluations from CAS, using inline", "err", resolveErr)
256+
} else if resolved != nil {
257+
predicate = &casResolvedPredicate{NormalizablePredicate: predicate, evals: resolved}
258+
}
259+
}
260+
}
261+
262+
attestation, err := bizAttestationToPb(run.Attestation, predicate)
189263
if err != nil {
190264
return nil, handleUseCaseErr(err, s.log)
191265
}

pkg/attestation/renderer/chainloop/v02.go

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -288,33 +288,49 @@ func (r *RendererV02) predicate() (*structpb.Struct, error) {
288288
return predicate, nil
289289
}
290290

291-
// collect all policy evaluations grouped by material and returns if there is a policy violation
292291
func mappedPolicyEvaluations(att *v1.Attestation) (map[string][]*PolicyEvaluation, bool, error) {
293-
var hasPolicyViolations bool
294-
result := map[string][]*PolicyEvaluation{}
292+
return groupEvaluations(att.GetPolicyEvaluations())
293+
}
294+
295+
// PolicyEvaluationsFromBundle deserializes a PolicyEvaluationBundle from protojson bytes
296+
// and returns evaluations grouped by material name.
297+
func PolicyEvaluationsFromBundle(data []byte) (map[string][]*PolicyEvaluation, error) {
298+
var bundle v1.PolicyEvaluationBundle
299+
if err := protojson.Unmarshal(data, &bundle); err != nil {
300+
return nil, fmt.Errorf("unmarshaling policy evaluation bundle: %w", err)
301+
}
302+
303+
evals, _, err := groupEvaluations(bundle.GetEvaluations())
304+
return evals, err
305+
}
306+
307+
func groupEvaluations(evals []*v1.PolicyEvaluation) (map[string][]*PolicyEvaluation, bool, error) {
308+
var hasViolations bool
309+
result := make(map[string][]*PolicyEvaluation)
295310

296-
for _, p := range att.GetPolicyEvaluations() {
311+
for _, p := range evals {
297312
keyName := p.MaterialName
298313
if keyName == "" {
299314
keyName = AttPolicyEvaluation
300315
}
301316

302-
ev, err := renderEvaluation(p)
317+
ev, err := RenderEvaluation(p)
303318
if err != nil {
304319
return nil, false, err
305320
}
306321

307322
if len(ev.Violations) > 0 {
308-
hasPolicyViolations = true
323+
hasViolations = true
309324
}
310325

311326
result[keyName] = append(result[keyName], ev)
312327
}
313328

314-
return result, hasPolicyViolations, nil
329+
return result, hasViolations, nil
315330
}
316331

317-
func renderEvaluation(ev *v1.PolicyEvaluation) (*PolicyEvaluation, error) {
332+
// RenderEvaluation converts a proto PolicyEvaluation to the JSON-serializable struct.
333+
func RenderEvaluation(ev *v1.PolicyEvaluation) (*PolicyEvaluation, error) {
318334
// Map violations
319335
violations := make([]*PolicyViolation, 0)
320336
for _, vi := range ev.Violations {

pkg/attestation/renderer/chainloop/v02_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,103 @@ func TestPredicatePolicyEvaluationsRef(t *testing.T) {
395395
}
396396
}
397397

398+
func TestPolicyEvaluationsFromBundle(t *testing.T) {
399+
tests := []struct {
400+
name string
401+
bundle *api.PolicyEvaluationBundle
402+
wantLen map[string]int // expected number of evaluations per material
403+
wantErr bool
404+
}{
405+
{
406+
name: "groups evaluations by material name",
407+
bundle: &api.PolicyEvaluationBundle{
408+
Evaluations: []*api.PolicyEvaluation{
409+
{Name: "check-sbom", MaterialName: "sbom"},
410+
{Name: "check-sarif", MaterialName: "sarif"},
411+
{Name: "check-sbom-format", MaterialName: "sbom"},
412+
},
413+
},
414+
wantLen: map[string]int{"sbom": 2, "sarif": 1},
415+
},
416+
{
417+
name: "empty material name uses attestation key",
418+
bundle: &api.PolicyEvaluationBundle{
419+
Evaluations: []*api.PolicyEvaluation{
420+
{Name: "att-policy", MaterialName: ""},
421+
},
422+
},
423+
wantLen: map[string]int{AttPolicyEvaluation: 1},
424+
},
425+
{
426+
name: "empty bundle returns empty map",
427+
bundle: &api.PolicyEvaluationBundle{},
428+
wantLen: map[string]int{},
429+
},
430+
{
431+
name: "invalid data returns error",
432+
bundle: nil, // we'll pass invalid bytes directly
433+
wantErr: true,
434+
},
435+
{
436+
name: "preserves violations and fields",
437+
bundle: &api.PolicyEvaluationBundle{
438+
Evaluations: []*api.PolicyEvaluation{
439+
{
440+
Name: "vuln-check",
441+
MaterialName: "image",
442+
Description: "checks for vulns",
443+
Violations: []*api.PolicyEvaluation_Violation{
444+
{Subject: "CVE-2024-1234", Message: "critical vuln found"},
445+
},
446+
Skipped: true,
447+
SkipReasons: []string{"not applicable"},
448+
},
449+
},
450+
},
451+
wantLen: map[string]int{"image": 1},
452+
},
453+
}
454+
455+
for _, tc := range tests {
456+
t.Run(tc.name, func(t *testing.T) {
457+
var data []byte
458+
if tc.bundle != nil {
459+
var err error
460+
data, err = protojson.Marshal(tc.bundle)
461+
require.NoError(t, err)
462+
} else if tc.wantErr {
463+
data = []byte("not valid protojson")
464+
}
465+
466+
result, err := PolicyEvaluationsFromBundle(data)
467+
if tc.wantErr {
468+
assert.Error(t, err)
469+
return
470+
}
471+
require.NoError(t, err)
472+
473+
// Check grouping counts
474+
assert.Len(t, result, len(tc.wantLen))
475+
for key, expectedCount := range tc.wantLen {
476+
assert.Len(t, result[key], expectedCount, "key=%s", key)
477+
}
478+
479+
// For the violations test case, verify field mapping
480+
if tc.name == "preserves violations and fields" {
481+
ev := result["image"][0]
482+
assert.Equal(t, "vuln-check", ev.Name)
483+
assert.Equal(t, "image", ev.MaterialName)
484+
assert.Equal(t, "checks for vulns", ev.Description)
485+
assert.True(t, ev.Skipped)
486+
assert.Equal(t, []string{"not applicable"}, ev.SkipReasons)
487+
require.Len(t, ev.Violations, 1)
488+
assert.Equal(t, "CVE-2024-1234", ev.Violations[0].Subject)
489+
assert.Equal(t, "critical vuln found", ev.Violations[0].Message)
490+
}
491+
})
492+
}
493+
}
494+
398495
func TestPolicyEvaluationsField(t *testing.T) {
399496
raw, err := os.ReadFile("testdata/attestation-pe-snake.json")
400497
require.NoError(t, err)

0 commit comments

Comments
 (0)