diff --git a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts index eb9266e35..04b8e1d2d 100644 --- a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts +++ b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts @@ -627,6 +627,13 @@ export interface PolicyGroupAttachment { with: { [key: string]: string }; /** policy names to skip (matched against metadata.name) */ skip: string[]; + /** + * Controls whether policy violations act as a gate for this group. + * - true: policy violations are blocking for this policy group + * - false: policy violations are non-blocking for this policy group + * - unset: inherit organization-level default behavior + */ + gate?: boolean | undefined; } export interface PolicyGroupAttachment_WithEntry { @@ -2448,7 +2455,7 @@ export const AutoMatch = { }; function createBasePolicyGroupAttachment(): PolicyGroupAttachment { - return { ref: "", with: {}, skip: [] }; + return { ref: "", with: {}, skip: [], gate: undefined }; } export const PolicyGroupAttachment = { @@ -2462,6 +2469,9 @@ export const PolicyGroupAttachment = { for (const v of message.skip) { writer.uint32(26).string(v!); } + if (message.gate !== undefined) { + writer.uint32(32).bool(message.gate); + } return writer; }, @@ -2496,6 +2506,13 @@ export const PolicyGroupAttachment = { message.skip.push(reader.string()); continue; + case 4: + if (tag !== 32) { + break; + } + + message.gate = reader.bool(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -2515,6 +2532,7 @@ export const PolicyGroupAttachment = { }, {}) : {}, skip: Array.isArray(object?.skip) ? object.skip.map((e: any) => String(e)) : [], + gate: isSet(object.gate) ? Boolean(object.gate) : undefined, }; }, @@ -2532,6 +2550,7 @@ export const PolicyGroupAttachment = { } else { obj.skip = []; } + message.gate !== undefined && (obj.gate = message.gate); return obj; }, @@ -2549,6 +2568,7 @@ export const PolicyGroupAttachment = { return acc; }, {}); message.skip = object.skip?.map((e) => e) || []; + message.gate = object.gate ?? undefined; return message; }, }; diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroupAttachment.jsonschema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroupAttachment.jsonschema.json index dff01af36..8fd71a4dd 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroupAttachment.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroupAttachment.jsonschema.json @@ -4,6 +4,10 @@ "additionalProperties": false, "description": "Represents a group attachment in a contract", "properties": { + "gate": { + "description": "Controls whether policy violations act as a gate for this group.\n - true: policy violations are blocking for this policy group\n - false: policy violations are non-blocking for this policy group\n - unset: inherit organization-level default behavior", + "type": "boolean" + }, "ref": { "description": "Group reference, it might be an URL or a provider reference", "minLength": 1, diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroupAttachment.schema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroupAttachment.schema.json index 8bebd1633..bed2a06f2 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroupAttachment.schema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroupAttachment.schema.json @@ -4,6 +4,10 @@ "additionalProperties": false, "description": "Represents a group attachment in a contract", "properties": { + "gate": { + "description": "Controls whether policy violations act as a gate for this group.\n - true: policy violations are blocking for this policy group\n - false: policy violations are non-blocking for this policy group\n - unset: inherit organization-level default behavior", + "type": "boolean" + }, "ref": { "description": "Group reference, it might be an URL or a provider reference", "minLength": 1, diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go index 21d9dc212..ff6c3c819 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go @@ -1408,7 +1408,12 @@ type PolicyGroupAttachment struct { // group arguments With map[string]string `protobuf:"bytes,2,rep,name=with,proto3" json:"with,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // policy names to skip (matched against metadata.name) - Skip []string `protobuf:"bytes,3,rep,name=skip,proto3" json:"skip,omitempty"` + Skip []string `protobuf:"bytes,3,rep,name=skip,proto3" json:"skip,omitempty"` + // Controls whether policy violations act as a gate for this group. + // - true: policy violations are blocking for this policy group + // - false: policy violations are non-blocking for this policy group + // - unset: inherit organization-level default behavior + Gate *bool `protobuf:"varint,4,opt,name=gate,proto3,oneof" json:"gate,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1464,6 +1469,13 @@ func (x *PolicyGroupAttachment) GetSkip() []string { return nil } +func (x *PolicyGroupAttachment) GetGate() bool { + if x != nil && x.Gate != nil { + return *x.Gate + } + return false +} + // Represents a group or policies type PolicyGroup struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2058,14 +2070,16 @@ const file_workflowcontract_v1_crafting_schema_proto_rawDesc = "" + "\x04path\x18\x01 \x01(\tB\x02\x18\x01H\x00R\x04path\x12\x1c\n" + "\bembedded\x18\x02 \x01(\tH\x00R\bembedded\x12\x12\n" + "\x03ref\x18\x03 \x01(\tH\x00R\x03refB\x0f\n" + - "\x06source\x12\x05\xbaH\x02\b\x01\"\xc9\x01\n" + + "\x06source\x12\x05\xbaH\x02\b\x01\"\xeb\x01\n" + "\x15PolicyGroupAttachment\x12\x19\n" + "\x03ref\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x03ref\x12H\n" + "\x04with\x18\x02 \x03(\v24.workflowcontract.v1.PolicyGroupAttachment.WithEntryR\x04with\x12\x12\n" + - "\x04skip\x18\x03 \x03(\tR\x04skip\x1a7\n" + + "\x04skip\x18\x03 \x03(\tR\x04skip\x12\x17\n" + + "\x04gate\x18\x04 \x01(\bH\x00R\x04gate\x88\x01\x01\x1a7\n" + "\tWithEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xc7\a\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\a\n" + + "\x05_gate\"\xc7\a\n" + "\vPolicyGroup\x12[\n" + "\vapi_version\x18\x01 \x01(\tB:\xbaH7r5R\x10chainloop.dev/v1R!workflowcontract.chainloop.dev/v1R\n" + "apiVersion\x12&\n" + @@ -2202,6 +2216,7 @@ func file_workflowcontract_v1_crafting_schema_proto_init() { (*AutoMatch_Embedded)(nil), (*AutoMatch_Ref)(nil), } + file_workflowcontract_v1_crafting_schema_proto_msgTypes[12].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto index 707a697b0..2f54c33ed 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto @@ -389,6 +389,11 @@ message PolicyGroupAttachment { map with = 2; // policy names to skip (matched against metadata.name) repeated string skip = 3; + // Controls whether policy violations act as a gate for this group. + // - true: policy violations are blocking for this policy group + // - false: policy violations are non-blocking for this policy group + // - unset: inherit organization-level default behavior + optional bool gate = 4; } // Represents a group or policies diff --git a/pkg/policies/policy_groups.go b/pkg/policies/policy_groups.go index d9a1bb220..f77bdd97f 100644 --- a/pkg/policies/policy_groups.go +++ b/pkg/policies/policy_groups.go @@ -1,5 +1,5 @@ // -// Copyright 2024-2025 The Chainloop Authors. +// Copyright 2024-2026 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -28,6 +28,7 @@ import ( intoto "github.com/in-toto/attestation/go/v1" "github.com/rs/zerolog" "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" ) type PolicyGroupVerifier struct { @@ -91,7 +92,7 @@ func (pgv *PolicyGroupVerifier) VerifyMaterial(ctx context.Context, material *ap return nil, NewPolicyError(err) } - ev, err := pgv.evaluatePolicyAttachment(ctx, policyAtt, subject, + ev, err := pgv.evaluatePolicyAttachment(ctx, applyGroupGate(policyAtt, groupAtt), subject, &evalOpts{kind: material.MaterialType, name: material.GetId(), bindings: groupArgs}, ) if err != nil { @@ -154,7 +155,7 @@ func (pgv *PolicyGroupVerifier) VerifyStatement(ctx context.Context, statement * return nil, NewPolicyError(err) } - ev, err := pgv.evaluatePolicyAttachment(ctx, attachment, material, + ev, err := pgv.evaluatePolicyAttachment(ctx, applyGroupGate(attachment, groupAtt), material, &evalOpts{kind: v1.CraftingSchema_Material_ATTESTATION, bindings: groupArgs}, ) if err != nil { @@ -181,6 +182,18 @@ func (pgv *PolicyGroupVerifier) VerifyStatement(ctx context.Context, statement * return result, nil } +func applyGroupGate(policyAtt *v1.PolicyAttachment, groupAtt *v1.PolicyGroupAttachment) *v1.PolicyAttachment { + if policyAtt == nil || groupAtt == nil || groupAtt.Gate == nil { + return policyAtt + } + + cloned := proto.Clone(policyAtt).(*v1.PolicyAttachment) + groupGate := groupAtt.GetGate() + cloned.Gate = &groupGate + + return cloned +} + type LoadPolicyGroupOptions struct { Client v13.AttestationServiceClient Logger *zerolog.Logger diff --git a/pkg/policies/policy_groups_test.go b/pkg/policies/policy_groups_test.go index fa7625f55..887d294d4 100644 --- a/pkg/policies/policy_groups_test.go +++ b/pkg/policies/policy_groups_test.go @@ -40,6 +40,10 @@ func TestPolicyGroups(t *testing.T) { suite.Run(t, new(groupsTestSuite)) } +func boolPtr(b bool) *bool { + return &b +} + func (s *groupsTestSuite) TestLoadGroupSpec() { var cases = []struct { name string @@ -647,3 +651,107 @@ func (s *groupsTestSuite) TestAttestationPhaseFilteringInGroups() { }) } } + +func (s *groupsTestSuite) TestApplyGroupGate() { + policyGate := false + groupGate := true + + cases := []struct { + name string + policyAtt *v1.PolicyAttachment + groupAtt *v1.PolicyGroupAttachment + expectedNil bool + expectedGate *bool + }{ + { + name: "nil policy attachment is returned as-is", + policyAtt: nil, + groupAtt: &v1.PolicyGroupAttachment{Gate: &groupGate}, + expectedNil: true, + }, + { + name: "unset group gate leaves policy unchanged", + policyAtt: &v1.PolicyAttachment{}, + groupAtt: &v1.PolicyGroupAttachment{}, + expectedGate: nil, + }, + { + name: "group gate sets policy gate when policy gate unset", + policyAtt: &v1.PolicyAttachment{}, + groupAtt: &v1.PolicyGroupAttachment{Gate: &groupGate}, + expectedGate: boolPtr(true), + }, + { + name: "group gate overrides policy gate", + policyAtt: &v1.PolicyAttachment{ + Gate: &policyGate, + }, + groupAtt: &v1.PolicyGroupAttachment{Gate: &groupGate}, + expectedGate: boolPtr(true), + }, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + got := applyGroupGate(tc.policyAtt, tc.groupAtt) + + if tc.expectedNil { + s.Nil(got) + return + } + + if tc.expectedGate == nil { + s.Nil(got.Gate) + return + } + + s.Require().NotNil(got.Gate) + s.Equal(*tc.expectedGate, got.GetGate()) + }) + } +} + +func (s *groupsTestSuite) TestVerifyMaterialInheritsGroupGate() { + schema := &v1.CraftingSchema{ + PolicyGroups: []*v1.PolicyGroupAttachment{ + { + Ref: "file://testdata/policy_group_multikind.yaml", + Gate: boolPtr(true), + }, + }, + } + + material := &api.Attestation_Material{ + M: &api.Attestation_Material_Artifact_{Artifact: &api.Attestation_Material_Artifact{ + Content: []byte(`{"specVersion": "1.4"}`), + }}, + MaterialType: v1.CraftingSchema_Material_OPENVEX, + InlineCas: true, + } + + verifier := NewPolicyGroupVerifier(schema.GetPolicyGroups(), nil, nil, &s.logger, WithDefaultGate(false)) + evs, err := verifier.VerifyMaterial(context.Background(), material, "") + + s.Require().NoError(err) + s.Len(evs, 1) + s.True(evs[0].GetGate()) +} + +func (s *groupsTestSuite) TestVerifyStatementInheritsGroupGate() { + schema := &v1.CraftingSchema{ + PolicyGroups: []*v1.PolicyGroupAttachment{ + { + Ref: "file://testdata/policy_group.yaml", + Gate: boolPtr(true), + }, + }, + } + + verifier := NewPolicyGroupVerifier(schema.GetPolicyGroups(), nil, nil, &s.logger, WithDefaultGate(false)) + statement := loadStatement("testdata/statement.json", &s.Suite) + + evs, err := verifier.VerifyStatement(context.Background(), statement) + s.Require().NoError(err) + s.Len(evs, 1) + s.True(evs[0].GetGate()) +}