diff --git a/app/cli/cmd/attestation_init.go b/app/cli/cmd/attestation_init.go index 3648812a0..58ecfe7df 100644 --- a/app/cli/cmd/attestation_init.go +++ b/app/cli/cmd/attestation_init.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. diff --git a/app/cli/pkg/action/attestation_init.go b/app/cli/pkg/action/attestation_init.go index e6c0786db..587821bfa 100644 --- a/app/cli/pkg/action/attestation_init.go +++ b/app/cli/pkg/action/attestation_init.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. @@ -27,6 +27,7 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/unmarshal" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter" clientAPI "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" + "github.com/chainloop-dev/chainloop/pkg/attestation/renderer" "github.com/chainloop-dev/chainloop/pkg/casclient" "github.com/chainloop-dev/chainloop/pkg/policies" "github.com/rs/zerolog" @@ -305,6 +306,22 @@ func (action *AttestationInit) Run(ctx context.Context, opts *AttestationInitRun // Don't fail the init - this is best-effort } + // Evaluate attestation-level policies at init phase + attClient := pb.NewAttestationServiceClient(action.CPConnection) + r, err := renderer.NewAttestationRenderer(action.c.CraftingState, attClient, "", "", nil, renderer.WithLogger(action.Logger)) + if err != nil { + return "", fmt.Errorf("creating attestation renderer: %w", err) + } + + statement, err := r.RenderStatement(ctx) + if err != nil { + return "", fmt.Errorf("rendering statement: %w", err) + } + + if err := action.c.EvaluateAttestationPolicies(ctx, attestationID, statement, policies.EvalPhaseInit); err != nil { + return "", fmt.Errorf("evaluating attestation policies: %w", err) + } + return attestationID, nil } diff --git a/app/cli/pkg/action/attestation_push.go b/app/cli/pkg/action/attestation_push.go index 046dd7efc..9a013b5b5 100644 --- a/app/cli/pkg/action/attestation_push.go +++ b/app/cli/pkg/action/attestation_push.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. @@ -27,6 +27,7 @@ import ( "github.com/chainloop-dev/chainloop/pkg/attestation/crafter" "github.com/chainloop-dev/chainloop/pkg/attestation/renderer" "github.com/chainloop-dev/chainloop/pkg/attestation/signer" + "github.com/chainloop-dev/chainloop/pkg/policies" "github.com/secure-systems-lab/go-securesystemslib/dsse" protobundle "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1" "google.golang.org/grpc" @@ -100,6 +101,7 @@ func (action *AttestationPush) Run(ctx context.Context, attestationID string, ru if err != nil { return nil, fmt.Errorf("creating status action: %w", err) } + attestationStatus, err := statusAction.Run(ctx, attestationID) if err != nil { return nil, fmt.Errorf("creating running status action: %w", err) @@ -196,10 +198,13 @@ func (action *AttestationPush) Run(ctx context.Context, attestationID string, ru } // Add attestation-level policy evaluations - if err := crafter.EvaluateAttestationPolicies(ctx, attestationID, statement); err != nil { + if err := crafter.EvaluateAttestationPolicies(ctx, attestationID, statement, policies.EvalPhasePush); err != nil { return nil, fmt.Errorf("evaluating attestation policies: %w", err) } + // Update the status result with the definitive push-phase evaluation against the final statement + attestationStatus.PolicyEvaluations, attestationStatus.HasPolicyViolations = getPolicyEvaluations(crafter) + // render final attestation with all the evaluated policies inside envelope, bundle, err := renderer.Render(ctx) if err != nil { diff --git a/app/cli/pkg/action/attestation_status.go b/app/cli/pkg/action/attestation_status.go index 70bccb691..2670eb482 100644 --- a/app/cli/pkg/action/attestation_status.go +++ b/app/cli/pkg/action/attestation_status.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. @@ -20,11 +20,9 @@ import ( "fmt" "time" - pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" pbc "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter" v1 "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" - "github.com/chainloop-dev/chainloop/pkg/attestation/renderer" "github.com/chainloop-dev/chainloop/pkg/attestation/renderer/chainloop" ) @@ -39,8 +37,7 @@ type AttestationStatus struct { *ActionsOpts c *crafter.Crafter // Do not show information about the project version release status - isPushed bool - skipPolicyEvaluation bool + isPushed bool } type AttestationStatusResult struct { @@ -93,19 +90,7 @@ func NewAttestationStatus(cfg *AttestationStatusOpts) (*AttestationStatus, error }, nil } -func WithSkipPolicyEvaluation() func(*AttestationStatus) { - return func(opts *AttestationStatus) { - opts.skipPolicyEvaluation = true - } -} - -type AttestationStatusOpt func(*AttestationStatus) - -func (action *AttestationStatus) Run(ctx context.Context, attestationID string, opts ...AttestationStatusOpt) (*AttestationStatusResult, error) { - for _, opt := range opts { - opt(action) - } - +func (action *AttestationStatus) Run(ctx context.Context, attestationID string) (*AttestationStatusResult, error) { c := action.c if initialized, err := c.AlreadyInitialized(ctx, attestationID); err != nil { @@ -141,27 +126,8 @@ func (action *AttestationStatus) Run(ctx context.Context, attestationID string, TimestampAuthority: att.GetSigningOptions().GetTimestampAuthorityUrl(), } - if !action.skipPolicyEvaluation { - // We need to render the statement to get the policy evaluations - attClient := pb.NewAttestationServiceClient(action.CPConnection) - renderer, err := renderer.NewAttestationRenderer(c.CraftingState, attClient, "", "", nil, renderer.WithLogger(action.Logger)) - if err != nil { - return nil, fmt.Errorf("rendering statement: %w", err) - } - - // We do not want to evaluate policies here during render since we want to do it in a separate step - statement, err := renderer.RenderStatement(ctx) - if err != nil { - return nil, fmt.Errorf("rendering statement: %w", err) - } - - // Add attestation-level policy evaluations - if err := c.EvaluateAttestationPolicies(ctx, attestationID, statement); err != nil { - return nil, fmt.Errorf("evaluating attestation policies: %w", err) - } - - res.PolicyEvaluations, res.HasPolicyViolations = getPolicyEvaluations(c) - } + // Read policy evaluations from crafting state (evaluation happens in init/push, not here) + res.PolicyEvaluations, res.HasPolicyViolations = getPolicyEvaluations(c) if v := workflowMeta.GetVersion(); v != nil { res.WorkflowMeta.ProjectVersion = &ProjectVersion{ 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 63cff04e1..da3e00ec3 100644 --- a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts +++ b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts @@ -3,6 +3,49 @@ import _m0 from "protobufjs/minimal"; export const protobufPackage = "workflowcontract.v1"; +/** + * Lifecycle phases that control when an attestation policy is evaluated. + * Only applicable to policies with kind ATTESTATION in PolicySpecV2. + */ +export enum AttestationPhase { + ATTESTATION_PHASE_UNSPECIFIED = 0, + INIT = 1, + PUSH = 3, + UNRECOGNIZED = -1, +} + +export function attestationPhaseFromJSON(object: any): AttestationPhase { + switch (object) { + case 0: + case "ATTESTATION_PHASE_UNSPECIFIED": + return AttestationPhase.ATTESTATION_PHASE_UNSPECIFIED; + case 1: + case "INIT": + return AttestationPhase.INIT; + case 3: + case "PUSH": + return AttestationPhase.PUSH; + case -1: + case "UNRECOGNIZED": + default: + return AttestationPhase.UNRECOGNIZED; + } +} + +export function attestationPhaseToJSON(object: AttestationPhase): string { + switch (object) { + case AttestationPhase.ATTESTATION_PHASE_UNSPECIFIED: + return "ATTESTATION_PHASE_UNSPECIFIED"; + case AttestationPhase.INIT: + return "INIT"; + case AttestationPhase.PUSH: + return "PUSH"; + case AttestationPhase.UNRECOGNIZED: + default: + return "UNRECOGNIZED"; + } +} + /** * Schema definition provided by the user to the tool * that defines the schema of the workflowRun @@ -537,6 +580,12 @@ export interface PolicySpecV2 { | undefined; /** if set, it will match any material supported by Chainloop */ kind: CraftingSchema_Material_MaterialType; + /** + * Controls at which attestation phases this policy is evaluated. + * Empty means evaluate at all phases (INIT and PUSH) for backwards compatibility. + * Only applicable when kind is ATTESTATION. + */ + attestationPhases: AttestationPhase[]; } /** Auto-matching policy specification */ @@ -2175,7 +2224,7 @@ export const PolicyInput = { }; function createBasePolicySpecV2(): PolicySpecV2 { - return { path: undefined, embedded: undefined, ref: undefined, kind: 0 }; + return { path: undefined, embedded: undefined, ref: undefined, kind: 0, attestationPhases: [] }; } export const PolicySpecV2 = { @@ -2192,6 +2241,11 @@ export const PolicySpecV2 = { if (message.kind !== 0) { writer.uint32(24).int32(message.kind); } + writer.uint32(42).fork(); + for (const v of message.attestationPhases) { + writer.int32(v); + } + writer.ldelim(); return writer; }, @@ -2230,6 +2284,23 @@ export const PolicySpecV2 = { message.kind = reader.int32() as any; continue; + case 5: + if (tag === 40) { + message.attestationPhases.push(reader.int32() as any); + + continue; + } + + if (tag === 42) { + const end2 = reader.uint32() + reader.pos; + while (reader.pos < end2) { + message.attestationPhases.push(reader.int32() as any); + } + + continue; + } + + break; } if ((tag & 7) === 4 || tag === 0) { break; @@ -2245,6 +2316,9 @@ export const PolicySpecV2 = { embedded: isSet(object.embedded) ? String(object.embedded) : undefined, ref: isSet(object.ref) ? String(object.ref) : undefined, kind: isSet(object.kind) ? craftingSchema_Material_MaterialTypeFromJSON(object.kind) : 0, + attestationPhases: Array.isArray(object?.attestationPhases) + ? object.attestationPhases.map((e: any) => attestationPhaseFromJSON(e)) + : [], }; }, @@ -2254,6 +2328,11 @@ export const PolicySpecV2 = { message.embedded !== undefined && (obj.embedded = message.embedded); message.ref !== undefined && (obj.ref = message.ref); message.kind !== undefined && (obj.kind = craftingSchema_Material_MaterialTypeToJSON(message.kind)); + if (message.attestationPhases) { + obj.attestationPhases = message.attestationPhases.map((e) => attestationPhaseToJSON(e)); + } else { + obj.attestationPhases = []; + } return obj; }, @@ -2267,6 +2346,7 @@ export const PolicySpecV2 = { message.embedded = object.embedded ?? undefined; message.ref = object.ref ?? undefined; message.kind = object.kind ?? 0; + message.attestationPhases = object.attestationPhases?.map((e) => e) || []; return message; }, }; diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.jsonschema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.jsonschema.json index adfd48624..7749248d9 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.jsonschema.json @@ -2,7 +2,53 @@ "$id": "workflowcontract.v1.PolicySpecV2.jsonschema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, + "patternProperties": { + "^(attestation_phases)$": { + "description": "Controls at which attestation phases this policy is evaluated.\n Empty means evaluate at all phases (INIT and PUSH) for backwards compatibility.\n Only applicable when kind is ATTESTATION.", + "items": { + "anyOf": [ + { + "enum": [ + "ATTESTATION_PHASE_UNSPECIFIED", + "INIT", + "PUSH" + ], + "title": "Attestation Phase", + "type": "string" + }, + { + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + ] + }, + "type": "array" + } + }, "properties": { + "attestationPhases": { + "description": "Controls at which attestation phases this policy is evaluated.\n Empty means evaluate at all phases (INIT and PUSH) for backwards compatibility.\n Only applicable when kind is ATTESTATION.", + "items": { + "anyOf": [ + { + "enum": [ + "ATTESTATION_PHASE_UNSPECIFIED", + "INIT", + "PUSH" + ], + "title": "Attestation Phase", + "type": "string" + }, + { + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + ] + }, + "type": "array" + }, "embedded": { "description": "embedded source code (only Rego supported currently)", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.schema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.schema.json index d15dda6a5..f1d5f5fa2 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.schema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.schema.json @@ -2,7 +2,53 @@ "$id": "workflowcontract.v1.PolicySpecV2.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, + "patternProperties": { + "^(attestationPhases)$": { + "description": "Controls at which attestation phases this policy is evaluated.\n Empty means evaluate at all phases (INIT and PUSH) for backwards compatibility.\n Only applicable when kind is ATTESTATION.", + "items": { + "anyOf": [ + { + "enum": [ + "ATTESTATION_PHASE_UNSPECIFIED", + "INIT", + "PUSH" + ], + "title": "Attestation Phase", + "type": "string" + }, + { + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + ] + }, + "type": "array" + } + }, "properties": { + "attestation_phases": { + "description": "Controls at which attestation phases this policy is evaluated.\n Empty means evaluate at all phases (INIT and PUSH) for backwards compatibility.\n Only applicable when kind is ATTESTATION.", + "items": { + "anyOf": [ + { + "enum": [ + "ATTESTATION_PHASE_UNSPECIFIED", + "INIT", + "PUSH" + ], + "title": "Attestation Phase", + "type": "string" + }, + { + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + ] + }, + "type": "array" + }, "embedded": { "description": "embedded source code (only Rego supported currently)", "type": "string" diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go index b7778d6fd..6f7a1d2e3 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.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. @@ -37,6 +37,57 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// Lifecycle phases that control when an attestation policy is evaluated. +// Only applicable to policies with kind ATTESTATION in PolicySpecV2. +type AttestationPhase int32 + +const ( + AttestationPhase_ATTESTATION_PHASE_UNSPECIFIED AttestationPhase = 0 + AttestationPhase_INIT AttestationPhase = 1 + AttestationPhase_PUSH AttestationPhase = 3 +) + +// Enum value maps for AttestationPhase. +var ( + AttestationPhase_name = map[int32]string{ + 0: "ATTESTATION_PHASE_UNSPECIFIED", + 1: "INIT", + 3: "PUSH", + } + AttestationPhase_value = map[string]int32{ + "ATTESTATION_PHASE_UNSPECIFIED": 0, + "INIT": 1, + "PUSH": 3, + } +) + +func (x AttestationPhase) Enum() *AttestationPhase { + p := new(AttestationPhase) + *p = x + return p +} + +func (x AttestationPhase) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (AttestationPhase) Descriptor() protoreflect.EnumDescriptor { + return file_workflowcontract_v1_crafting_schema_proto_enumTypes[0].Descriptor() +} + +func (AttestationPhase) Type() protoreflect.EnumType { + return &file_workflowcontract_v1_crafting_schema_proto_enumTypes[0] +} + +func (x AttestationPhase) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use AttestationPhase.Descriptor instead. +func (AttestationPhase) EnumDescriptor() ([]byte, []int) { + return file_workflowcontract_v1_crafting_schema_proto_rawDescGZIP(), []int{0} +} + type CraftingSchema_Runner_RunnerType int32 const ( @@ -88,11 +139,11 @@ func (x CraftingSchema_Runner_RunnerType) String() string { } func (CraftingSchema_Runner_RunnerType) Descriptor() protoreflect.EnumDescriptor { - return file_workflowcontract_v1_crafting_schema_proto_enumTypes[0].Descriptor() + return file_workflowcontract_v1_crafting_schema_proto_enumTypes[1].Descriptor() } func (CraftingSchema_Runner_RunnerType) Type() protoreflect.EnumType { - return &file_workflowcontract_v1_crafting_schema_proto_enumTypes[0] + return &file_workflowcontract_v1_crafting_schema_proto_enumTypes[1] } func (x CraftingSchema_Runner_RunnerType) Number() protoreflect.EnumNumber { @@ -232,11 +283,11 @@ func (x CraftingSchema_Material_MaterialType) String() string { } func (CraftingSchema_Material_MaterialType) Descriptor() protoreflect.EnumDescriptor { - return file_workflowcontract_v1_crafting_schema_proto_enumTypes[1].Descriptor() + return file_workflowcontract_v1_crafting_schema_proto_enumTypes[2].Descriptor() } func (CraftingSchema_Material_MaterialType) Type() protoreflect.EnumType { - return &file_workflowcontract_v1_crafting_schema_proto_enumTypes[1] + return &file_workflowcontract_v1_crafting_schema_proto_enumTypes[2] } func (x CraftingSchema_Material_MaterialType) Number() protoreflect.EnumNumber { @@ -1120,9 +1171,13 @@ type PolicySpecV2 struct { // *PolicySpecV2_Ref Source isPolicySpecV2_Source `protobuf_oneof:"source"` // if set, it will match any material supported by Chainloop - Kind CraftingSchema_Material_MaterialType `protobuf:"varint,3,opt,name=kind,proto3,enum=workflowcontract.v1.CraftingSchema_Material_MaterialType" json:"kind,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Kind CraftingSchema_Material_MaterialType `protobuf:"varint,3,opt,name=kind,proto3,enum=workflowcontract.v1.CraftingSchema_Material_MaterialType" json:"kind,omitempty"` + // Controls at which attestation phases this policy is evaluated. + // Empty means evaluate at all phases (INIT and PUSH) for backwards compatibility. + // Only applicable when kind is ATTESTATION. + AttestationPhases []AttestationPhase `protobuf:"varint,5,rep,packed,name=attestation_phases,json=attestationPhases,proto3,enum=workflowcontract.v1.AttestationPhase" json:"attestation_phases,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *PolicySpecV2) Reset() { @@ -1197,6 +1252,13 @@ func (x *PolicySpecV2) GetKind() CraftingSchema_Material_MaterialType { return CraftingSchema_Material_MATERIAL_TYPE_UNSPECIFIED } +func (x *PolicySpecV2) GetAttestationPhases() []AttestationPhase { + if x != nil { + return x.AttestationPhases + } + return nil +} + type isPolicySpecV2_Source interface { isPolicySpecV2_Source() } @@ -1976,12 +2038,13 @@ const file_workflowcontract_v1_crafting_schema_proto_rawDesc = "" + "\x14name.go_map_variable\x12:must contain only lowercase letters, numbers, and hyphens.\x1a'this.matches('^[a-zA-Z][a-zA-Z0-9_]*$')R\x04name\x12 \n" + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x1a\n" + "\brequired\x18\x03 \x01(\bR\brequired\x12\x18\n" + - "\adefault\x18\x04 \x01(\tR\adefault\"\xc4\x01\n" + + "\adefault\x18\x04 \x01(\tR\adefault\"\x9a\x02\n" + "\fPolicySpecV2\x12\x18\n" + "\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\x04 \x01(\tH\x00R\x03ref\x12W\n" + - "\x04kind\x18\x03 \x01(\x0e29.workflowcontract.v1.CraftingSchema.Material.MaterialTypeB\b\xbaH\x05\x82\x01\x02 \x03R\x04kindB\x0f\n" + + "\x04kind\x18\x03 \x01(\x0e29.workflowcontract.v1.CraftingSchema.Material.MaterialTypeB\b\xbaH\x05\x82\x01\x02 \x03R\x04kind\x12T\n" + + "\x12attestation_phases\x18\x05 \x03(\x0e2%.workflowcontract.v1.AttestationPhaseR\x11attestationPhasesB\x0f\n" + "\x06source\x12\x05\xbaH\x02\b\x01\"h\n" + "\tAutoMatch\x12\x18\n" + "\x04path\x18\x01 \x01(\tB\x02\x18\x01H\x00R\x04path\x12\x1c\n" + @@ -2014,7 +2077,11 @@ const file_workflowcontract_v1_crafting_schema_proto_rawDesc = "" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x1a\n" + "\boptional\x18\x03 \x01(\bR\boptional\x12A\n" + "\bpolicies\x18\x06 \x03(\v2%.workflowcontract.v1.PolicyAttachmentR\bpolicies:\x7f\xbaH|\x1az\n" + - "\x0egroup_material\x123if name is provided, type should have a valid value\x1a3!has(this.name) || has(this.name) && this.type != 0BMZKgithub.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1b\x06proto3" + "\x0egroup_material\x123if name is provided, type should have a valid value\x1a3!has(this.name) || has(this.name) && this.type != 0*I\n" + + "\x10AttestationPhase\x12!\n" + + "\x1dATTESTATION_PHASE_UNSPECIFIED\x10\x00\x12\b\n" + + "\x04INIT\x10\x01\x12\b\n" + + "\x04PUSH\x10\x03BMZKgithub.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1b\x06proto3" var ( file_workflowcontract_v1_crafting_schema_proto_rawDescOnce sync.Once @@ -2028,78 +2095,80 @@ func file_workflowcontract_v1_crafting_schema_proto_rawDescGZIP() []byte { return file_workflowcontract_v1_crafting_schema_proto_rawDescData } -var file_workflowcontract_v1_crafting_schema_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_workflowcontract_v1_crafting_schema_proto_enumTypes = make([]protoimpl.EnumInfo, 3) var file_workflowcontract_v1_crafting_schema_proto_msgTypes = make([]protoimpl.MessageInfo, 23) var file_workflowcontract_v1_crafting_schema_proto_goTypes = []any{ - (CraftingSchema_Runner_RunnerType)(0), // 0: workflowcontract.v1.CraftingSchema.Runner.RunnerType - (CraftingSchema_Material_MaterialType)(0), // 1: workflowcontract.v1.CraftingSchema.Material.MaterialType - (*CraftingSchema)(nil), // 2: workflowcontract.v1.CraftingSchema - (*CraftingSchemaV2)(nil), // 3: workflowcontract.v1.CraftingSchemaV2 - (*CraftingSchemaV2Spec)(nil), // 4: workflowcontract.v1.CraftingSchemaV2Spec - (*Annotation)(nil), // 5: workflowcontract.v1.Annotation - (*Policies)(nil), // 6: workflowcontract.v1.Policies - (*PolicyAttachment)(nil), // 7: workflowcontract.v1.PolicyAttachment - (*Policy)(nil), // 8: workflowcontract.v1.Policy - (*Metadata)(nil), // 9: workflowcontract.v1.Metadata - (*PolicySpec)(nil), // 10: workflowcontract.v1.PolicySpec - (*PolicyInput)(nil), // 11: workflowcontract.v1.PolicyInput - (*PolicySpecV2)(nil), // 12: workflowcontract.v1.PolicySpecV2 - (*AutoMatch)(nil), // 13: workflowcontract.v1.AutoMatch - (*PolicyGroupAttachment)(nil), // 14: workflowcontract.v1.PolicyGroupAttachment - (*PolicyGroup)(nil), // 15: workflowcontract.v1.PolicyGroup - (*CraftingSchema_Runner)(nil), // 16: workflowcontract.v1.CraftingSchema.Runner - (*CraftingSchema_Material)(nil), // 17: workflowcontract.v1.CraftingSchema.Material - nil, // 18: workflowcontract.v1.PolicyAttachment.WithEntry - (*PolicyAttachment_MaterialSelector)(nil), // 19: workflowcontract.v1.PolicyAttachment.MaterialSelector - nil, // 20: workflowcontract.v1.Metadata.AnnotationsEntry - nil, // 21: workflowcontract.v1.PolicyGroupAttachment.WithEntry - (*PolicyGroup_PolicyGroupSpec)(nil), // 22: workflowcontract.v1.PolicyGroup.PolicyGroupSpec - (*PolicyGroup_PolicyGroupPolicies)(nil), // 23: workflowcontract.v1.PolicyGroup.PolicyGroupPolicies - (*PolicyGroup_Material)(nil), // 24: workflowcontract.v1.PolicyGroup.Material + (AttestationPhase)(0), // 0: workflowcontract.v1.AttestationPhase + (CraftingSchema_Runner_RunnerType)(0), // 1: workflowcontract.v1.CraftingSchema.Runner.RunnerType + (CraftingSchema_Material_MaterialType)(0), // 2: workflowcontract.v1.CraftingSchema.Material.MaterialType + (*CraftingSchema)(nil), // 3: workflowcontract.v1.CraftingSchema + (*CraftingSchemaV2)(nil), // 4: workflowcontract.v1.CraftingSchemaV2 + (*CraftingSchemaV2Spec)(nil), // 5: workflowcontract.v1.CraftingSchemaV2Spec + (*Annotation)(nil), // 6: workflowcontract.v1.Annotation + (*Policies)(nil), // 7: workflowcontract.v1.Policies + (*PolicyAttachment)(nil), // 8: workflowcontract.v1.PolicyAttachment + (*Policy)(nil), // 9: workflowcontract.v1.Policy + (*Metadata)(nil), // 10: workflowcontract.v1.Metadata + (*PolicySpec)(nil), // 11: workflowcontract.v1.PolicySpec + (*PolicyInput)(nil), // 12: workflowcontract.v1.PolicyInput + (*PolicySpecV2)(nil), // 13: workflowcontract.v1.PolicySpecV2 + (*AutoMatch)(nil), // 14: workflowcontract.v1.AutoMatch + (*PolicyGroupAttachment)(nil), // 15: workflowcontract.v1.PolicyGroupAttachment + (*PolicyGroup)(nil), // 16: workflowcontract.v1.PolicyGroup + (*CraftingSchema_Runner)(nil), // 17: workflowcontract.v1.CraftingSchema.Runner + (*CraftingSchema_Material)(nil), // 18: workflowcontract.v1.CraftingSchema.Material + nil, // 19: workflowcontract.v1.PolicyAttachment.WithEntry + (*PolicyAttachment_MaterialSelector)(nil), // 20: workflowcontract.v1.PolicyAttachment.MaterialSelector + nil, // 21: workflowcontract.v1.Metadata.AnnotationsEntry + nil, // 22: workflowcontract.v1.PolicyGroupAttachment.WithEntry + (*PolicyGroup_PolicyGroupSpec)(nil), // 23: workflowcontract.v1.PolicyGroup.PolicyGroupSpec + (*PolicyGroup_PolicyGroupPolicies)(nil), // 24: workflowcontract.v1.PolicyGroup.PolicyGroupPolicies + (*PolicyGroup_Material)(nil), // 25: workflowcontract.v1.PolicyGroup.Material } var file_workflowcontract_v1_crafting_schema_proto_depIdxs = []int32{ - 17, // 0: workflowcontract.v1.CraftingSchema.materials:type_name -> workflowcontract.v1.CraftingSchema.Material - 16, // 1: workflowcontract.v1.CraftingSchema.runner:type_name -> workflowcontract.v1.CraftingSchema.Runner - 5, // 2: workflowcontract.v1.CraftingSchema.annotations:type_name -> workflowcontract.v1.Annotation - 6, // 3: workflowcontract.v1.CraftingSchema.policies:type_name -> workflowcontract.v1.Policies - 14, // 4: workflowcontract.v1.CraftingSchema.policy_groups:type_name -> workflowcontract.v1.PolicyGroupAttachment - 9, // 5: workflowcontract.v1.CraftingSchemaV2.metadata:type_name -> workflowcontract.v1.Metadata - 4, // 6: workflowcontract.v1.CraftingSchemaV2.spec:type_name -> workflowcontract.v1.CraftingSchemaV2Spec - 17, // 7: workflowcontract.v1.CraftingSchemaV2Spec.materials:type_name -> workflowcontract.v1.CraftingSchema.Material - 16, // 8: workflowcontract.v1.CraftingSchemaV2Spec.runner:type_name -> workflowcontract.v1.CraftingSchema.Runner - 6, // 9: workflowcontract.v1.CraftingSchemaV2Spec.policies:type_name -> workflowcontract.v1.Policies - 14, // 10: workflowcontract.v1.CraftingSchemaV2Spec.policy_groups:type_name -> workflowcontract.v1.PolicyGroupAttachment - 5, // 11: workflowcontract.v1.CraftingSchemaV2Spec.annotations:type_name -> workflowcontract.v1.Annotation - 7, // 12: workflowcontract.v1.Policies.materials:type_name -> workflowcontract.v1.PolicyAttachment - 7, // 13: workflowcontract.v1.Policies.attestation:type_name -> workflowcontract.v1.PolicyAttachment - 8, // 14: workflowcontract.v1.PolicyAttachment.embedded:type_name -> workflowcontract.v1.Policy - 19, // 15: workflowcontract.v1.PolicyAttachment.selector:type_name -> workflowcontract.v1.PolicyAttachment.MaterialSelector - 18, // 16: workflowcontract.v1.PolicyAttachment.with:type_name -> workflowcontract.v1.PolicyAttachment.WithEntry - 9, // 17: workflowcontract.v1.Policy.metadata:type_name -> workflowcontract.v1.Metadata - 10, // 18: workflowcontract.v1.Policy.spec:type_name -> workflowcontract.v1.PolicySpec - 20, // 19: workflowcontract.v1.Metadata.annotations:type_name -> workflowcontract.v1.Metadata.AnnotationsEntry - 1, // 20: workflowcontract.v1.PolicySpec.type:type_name -> workflowcontract.v1.CraftingSchema.Material.MaterialType - 12, // 21: workflowcontract.v1.PolicySpec.policies:type_name -> workflowcontract.v1.PolicySpecV2 - 11, // 22: workflowcontract.v1.PolicySpec.inputs:type_name -> workflowcontract.v1.PolicyInput - 13, // 23: workflowcontract.v1.PolicySpec.auto_match:type_name -> workflowcontract.v1.AutoMatch - 1, // 24: workflowcontract.v1.PolicySpecV2.kind:type_name -> workflowcontract.v1.CraftingSchema.Material.MaterialType - 21, // 25: workflowcontract.v1.PolicyGroupAttachment.with:type_name -> workflowcontract.v1.PolicyGroupAttachment.WithEntry - 9, // 26: workflowcontract.v1.PolicyGroup.metadata:type_name -> workflowcontract.v1.Metadata - 22, // 27: workflowcontract.v1.PolicyGroup.spec:type_name -> workflowcontract.v1.PolicyGroup.PolicyGroupSpec - 0, // 28: workflowcontract.v1.CraftingSchema.Runner.type:type_name -> workflowcontract.v1.CraftingSchema.Runner.RunnerType - 1, // 29: workflowcontract.v1.CraftingSchema.Material.type:type_name -> workflowcontract.v1.CraftingSchema.Material.MaterialType - 5, // 30: workflowcontract.v1.CraftingSchema.Material.annotations:type_name -> workflowcontract.v1.Annotation - 23, // 31: workflowcontract.v1.PolicyGroup.PolicyGroupSpec.policies:type_name -> workflowcontract.v1.PolicyGroup.PolicyGroupPolicies - 11, // 32: workflowcontract.v1.PolicyGroup.PolicyGroupSpec.inputs:type_name -> workflowcontract.v1.PolicyInput - 24, // 33: workflowcontract.v1.PolicyGroup.PolicyGroupPolicies.materials:type_name -> workflowcontract.v1.PolicyGroup.Material - 7, // 34: workflowcontract.v1.PolicyGroup.PolicyGroupPolicies.attestation:type_name -> workflowcontract.v1.PolicyAttachment - 1, // 35: workflowcontract.v1.PolicyGroup.Material.type:type_name -> workflowcontract.v1.CraftingSchema.Material.MaterialType - 7, // 36: workflowcontract.v1.PolicyGroup.Material.policies:type_name -> workflowcontract.v1.PolicyAttachment - 37, // [37:37] is the sub-list for method output_type - 37, // [37:37] is the sub-list for method input_type - 37, // [37:37] is the sub-list for extension type_name - 37, // [37:37] is the sub-list for extension extendee - 0, // [0:37] is the sub-list for field type_name + 18, // 0: workflowcontract.v1.CraftingSchema.materials:type_name -> workflowcontract.v1.CraftingSchema.Material + 17, // 1: workflowcontract.v1.CraftingSchema.runner:type_name -> workflowcontract.v1.CraftingSchema.Runner + 6, // 2: workflowcontract.v1.CraftingSchema.annotations:type_name -> workflowcontract.v1.Annotation + 7, // 3: workflowcontract.v1.CraftingSchema.policies:type_name -> workflowcontract.v1.Policies + 15, // 4: workflowcontract.v1.CraftingSchema.policy_groups:type_name -> workflowcontract.v1.PolicyGroupAttachment + 10, // 5: workflowcontract.v1.CraftingSchemaV2.metadata:type_name -> workflowcontract.v1.Metadata + 5, // 6: workflowcontract.v1.CraftingSchemaV2.spec:type_name -> workflowcontract.v1.CraftingSchemaV2Spec + 18, // 7: workflowcontract.v1.CraftingSchemaV2Spec.materials:type_name -> workflowcontract.v1.CraftingSchema.Material + 17, // 8: workflowcontract.v1.CraftingSchemaV2Spec.runner:type_name -> workflowcontract.v1.CraftingSchema.Runner + 7, // 9: workflowcontract.v1.CraftingSchemaV2Spec.policies:type_name -> workflowcontract.v1.Policies + 15, // 10: workflowcontract.v1.CraftingSchemaV2Spec.policy_groups:type_name -> workflowcontract.v1.PolicyGroupAttachment + 6, // 11: workflowcontract.v1.CraftingSchemaV2Spec.annotations:type_name -> workflowcontract.v1.Annotation + 8, // 12: workflowcontract.v1.Policies.materials:type_name -> workflowcontract.v1.PolicyAttachment + 8, // 13: workflowcontract.v1.Policies.attestation:type_name -> workflowcontract.v1.PolicyAttachment + 9, // 14: workflowcontract.v1.PolicyAttachment.embedded:type_name -> workflowcontract.v1.Policy + 20, // 15: workflowcontract.v1.PolicyAttachment.selector:type_name -> workflowcontract.v1.PolicyAttachment.MaterialSelector + 19, // 16: workflowcontract.v1.PolicyAttachment.with:type_name -> workflowcontract.v1.PolicyAttachment.WithEntry + 10, // 17: workflowcontract.v1.Policy.metadata:type_name -> workflowcontract.v1.Metadata + 11, // 18: workflowcontract.v1.Policy.spec:type_name -> workflowcontract.v1.PolicySpec + 21, // 19: workflowcontract.v1.Metadata.annotations:type_name -> workflowcontract.v1.Metadata.AnnotationsEntry + 2, // 20: workflowcontract.v1.PolicySpec.type:type_name -> workflowcontract.v1.CraftingSchema.Material.MaterialType + 13, // 21: workflowcontract.v1.PolicySpec.policies:type_name -> workflowcontract.v1.PolicySpecV2 + 12, // 22: workflowcontract.v1.PolicySpec.inputs:type_name -> workflowcontract.v1.PolicyInput + 14, // 23: workflowcontract.v1.PolicySpec.auto_match:type_name -> workflowcontract.v1.AutoMatch + 2, // 24: workflowcontract.v1.PolicySpecV2.kind:type_name -> workflowcontract.v1.CraftingSchema.Material.MaterialType + 0, // 25: workflowcontract.v1.PolicySpecV2.attestation_phases:type_name -> workflowcontract.v1.AttestationPhase + 22, // 26: workflowcontract.v1.PolicyGroupAttachment.with:type_name -> workflowcontract.v1.PolicyGroupAttachment.WithEntry + 10, // 27: workflowcontract.v1.PolicyGroup.metadata:type_name -> workflowcontract.v1.Metadata + 23, // 28: workflowcontract.v1.PolicyGroup.spec:type_name -> workflowcontract.v1.PolicyGroup.PolicyGroupSpec + 1, // 29: workflowcontract.v1.CraftingSchema.Runner.type:type_name -> workflowcontract.v1.CraftingSchema.Runner.RunnerType + 2, // 30: workflowcontract.v1.CraftingSchema.Material.type:type_name -> workflowcontract.v1.CraftingSchema.Material.MaterialType + 6, // 31: workflowcontract.v1.CraftingSchema.Material.annotations:type_name -> workflowcontract.v1.Annotation + 24, // 32: workflowcontract.v1.PolicyGroup.PolicyGroupSpec.policies:type_name -> workflowcontract.v1.PolicyGroup.PolicyGroupPolicies + 12, // 33: workflowcontract.v1.PolicyGroup.PolicyGroupSpec.inputs:type_name -> workflowcontract.v1.PolicyInput + 25, // 34: workflowcontract.v1.PolicyGroup.PolicyGroupPolicies.materials:type_name -> workflowcontract.v1.PolicyGroup.Material + 8, // 35: workflowcontract.v1.PolicyGroup.PolicyGroupPolicies.attestation:type_name -> workflowcontract.v1.PolicyAttachment + 2, // 36: workflowcontract.v1.PolicyGroup.Material.type:type_name -> workflowcontract.v1.CraftingSchema.Material.MaterialType + 8, // 37: workflowcontract.v1.PolicyGroup.Material.policies:type_name -> workflowcontract.v1.PolicyAttachment + 38, // [38:38] is the sub-list for method output_type + 38, // [38:38] is the sub-list for method input_type + 38, // [38:38] is the sub-list for extension type_name + 38, // [38:38] is the sub-list for extension extendee + 0, // [0:38] is the sub-list for field type_name } func init() { file_workflowcontract_v1_crafting_schema_proto_init() } @@ -2131,7 +2200,7 @@ func file_workflowcontract_v1_crafting_schema_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_workflowcontract_v1_crafting_schema_proto_rawDesc), len(file_workflowcontract_v1_crafting_schema_proto_rawDesc)), - NumEnums: 2, + NumEnums: 3, NumMessages: 23, NumExtensions: 0, NumServices: 0, diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto index 1e7975ad2..9d9185345 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto @@ -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. @@ -306,6 +306,14 @@ message PolicySpec { }; } +// Lifecycle phases that control when an attestation policy is evaluated. +// Only applicable to policies with kind ATTESTATION in PolicySpecV2. +enum AttestationPhase { + ATTESTATION_PHASE_UNSPECIFIED = 0; + INIT = 1; + PUSH = 3; +} + message PolicyInput { string name = 1 [(buf.validate.field) = { // NOTE: validations can not be shared yet https://github.com/bufbuild/protovalidate/issues/51 @@ -339,6 +347,11 @@ message PolicySpecV2 { CraftingSchema.Material.MaterialType kind = 3 [(buf.validate.field).enum = { not_in: [3] }]; + + // Controls at which attestation phases this policy is evaluated. + // Empty means evaluate at all phases (INIT and PUSH) for backwards compatibility. + // Only applicable when kind is ATTESTATION. + repeated AttestationPhase attestation_phases = 5; } // Auto-matching policy specification diff --git a/pkg/attestation/crafter/crafter.go b/pkg/attestation/crafter/crafter.go index 7d845ea74..99f3f8928 100644 --- a/pkg/attestation/crafter/crafter.go +++ b/pkg/attestation/crafter/crafter.go @@ -766,16 +766,23 @@ func (c *Crafter) addMaterial(ctx context.Context, m *schemaapi.CraftingSchema_M return mt, nil } -// EvaluateAttestationPolicies evaluates the attestation-level policies and stores them in the attestation state -func (c *Crafter) EvaluateAttestationPolicies(ctx context.Context, attestationID string, statement *intoto.Statement) error { +// EvaluateAttestationPolicies evaluates the attestation-level policies and stores them in the attestation state. +// The phase parameter controls which policies are evaluated based on their attestation_phases spec field. +func (c *Crafter) EvaluateAttestationPolicies(ctx context.Context, attestationID string, statement *intoto.Statement, phase policies.EvalPhase) error { // evaluate attestation-level policies - pv := policies.NewPolicyVerifier(c.CraftingState.GetPolicies(), c.attClient, c.Logger, policies.WithAllowedHostnames(c.CraftingState.Attestation.PoliciesAllowedHostnames...)) + pv := policies.NewPolicyVerifier(c.CraftingState.GetPolicies(), c.attClient, c.Logger, + policies.WithAllowedHostnames(c.CraftingState.Attestation.PoliciesAllowedHostnames...), + policies.WithEvalPhase(phase), + ) policyEvaluations, err := pv.VerifyStatement(ctx, statement) if err != nil { return fmt.Errorf("evaluating policies in statement: %w", err) } - pgv := policies.NewPolicyGroupVerifier(c.CraftingState.GetPolicyGroups(), c.CraftingState.GetPolicies(), c.attClient, c.Logger, policies.WithAllowedHostnames(c.CraftingState.Attestation.PoliciesAllowedHostnames...)) + pgv := policies.NewPolicyGroupVerifier(c.CraftingState.GetPolicyGroups(), c.CraftingState.GetPolicies(), c.attClient, c.Logger, + policies.WithAllowedHostnames(c.CraftingState.Attestation.PoliciesAllowedHostnames...), + policies.WithEvalPhase(phase), + ) policyGroupResults, err := pgv.VerifyStatement(ctx, statement) if err != nil { return fmt.Errorf("evaluating policy groups in statement: %w", err) @@ -801,11 +808,26 @@ func (c *Crafter) EvaluateAttestationPolicies(ctx context.Context, attestationID policyEvaluations = filteredPolicyEvaluations - // Since we are going to override the state, we want to keep the existing material-type policy evaluations + // Preserve existing evaluations that were not re-evaluated in this phase: + // - Material-level evaluations are always kept + // - Attestation-level evaluations are kept if they weren't re-evaluated (e.g., from a different phase) for _, ev := range c.CraftingState.Attestation.PolicyEvaluations { - // We can not use kind = ATTESTATION since that's a valid material kind if ev.MaterialName != "" { policyEvaluations = append(policyEvaluations, ev) + continue + } + + // Check if this attestation-level evaluation was re-evaluated in the current phase + var reEvaluated bool + for _, newEv := range policyEvaluations { + if proto.Equal(newEv.PolicyReference, ev.PolicyReference) && reflect.DeepEqual(newEv.With, ev.With) { + reEvaluated = true + break + } + } + + if !reEvaluated { + policyEvaluations = append(policyEvaluations, ev) } } diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index 2d6b47a59..625101765 100644 --- a/pkg/policies/policies.go +++ b/pkg/policies/policies.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. @@ -63,6 +63,15 @@ type Verifier interface { VerifyStatement(ctx context.Context, statement *intoto.Statement) ([]*v12.PolicyEvaluation, error) } +// EvalPhase represents the phase of the attestation lifecycle where evaluation is happening. +type EvalPhase int + +const ( + EvalPhaseUnspecified EvalPhase = iota + EvalPhaseInit + EvalPhasePush +) + type PolicyVerifier struct { policies *v1.Policies logger *zerolog.Logger @@ -71,6 +80,7 @@ type PolicyVerifier struct { allowedHostnames []string includeRawData bool enablePrint bool + evalPhase EvalPhase } var _ Verifier = (*PolicyVerifier)(nil) @@ -80,6 +90,7 @@ type PolicyVerifierOptions struct { IncludeRawData bool EnablePrint bool GRPCConn *grpc.ClientConn + EvalPhase EvalPhase } type PolicyVerifierOption func(*PolicyVerifierOptions) @@ -108,6 +119,12 @@ func WithGRPCConn(conn *grpc.ClientConn) PolicyVerifierOption { } } +func WithEvalPhase(phase EvalPhase) PolicyVerifierOption { + return func(o *PolicyVerifierOptions) { + o.EvalPhase = phase + } +} + func NewPolicyVerifier(policies *v1.Policies, client v13.AttestationServiceClient, logger *zerolog.Logger, opts ...PolicyVerifierOption) *PolicyVerifier { options := &PolicyVerifierOptions{} for _, opt := range opts { @@ -122,6 +139,7 @@ func NewPolicyVerifier(policies *v1.Policies, client v13.AttestationServiceClien allowedHostnames: options.AllowedHostnames, includeRawData: options.IncludeRawData, enablePrint: options.EnablePrint, + evalPhase: options.EvalPhase, } } @@ -167,6 +185,46 @@ type evalOpts struct { bindings map[string]string } +// shouldEvaluateAtPhase checks if a policy should be evaluated at the given phase. +// If no phases are specified, the policy runs at all phases (backwards compatible). +// If phase is EvalPhaseUnspecified (e.g. material evaluation), the policy always runs. +func shouldEvaluateAtPhase(phases []v1.AttestationPhase, phase EvalPhase) bool { + if len(phases) == 0 || phase == EvalPhaseUnspecified { + return true + } + + var target v1.AttestationPhase + switch phase { + case EvalPhaseInit: + target = v1.AttestationPhase_INIT + case EvalPhasePush: + target = v1.AttestationPhase_PUSH + default: + return true + } + + return slices.Contains(phases, target) +} + +// attestationPhasesForKind returns the aggregated attestation phases from +// PolicySpecV2 entries that match the given kind. For legacy policies (using +// spec.type + spec.path), returns nil (evaluate at all phases). +func attestationPhasesForKind(spec *v1.PolicySpec, kind v1.CraftingSchema_Material_MaterialType) []v1.AttestationPhase { + // Legacy single-kind policies don't have per-kind phase config + if spec.GetSource() != nil { + return nil + } + + var phases []v1.AttestationPhase + for _, s := range spec.GetPolicies() { + if s.GetKind() == v1.CraftingSchema_Material_MATERIAL_TYPE_UNSPECIFIED || s.GetKind() == kind { + phases = append(phases, s.GetAttestationPhases()...) + } + } + + return phases +} + func (pv *PolicyVerifier) evaluatePolicyAttachment(ctx context.Context, attachment *v1.PolicyAttachment, material []byte, opts *evalOpts) (*v12.PolicyEvaluation, error) { // load the policy policy policy, ref, err := pv.loadPolicySpec(ctx, attachment) @@ -174,6 +232,14 @@ func (pv *PolicyVerifier) evaluatePolicyAttachment(ctx context.Context, attachme return nil, NewPolicyError(err) } + // Skip policies not configured for the current attestation phase. + // Phases are defined per-kind in PolicySpecV2 entries. + phases := attestationPhasesForKind(policy.GetSpec(), opts.kind) + if !shouldEvaluateAtPhase(phases, pv.evalPhase) { + pv.logger.Debug().Str("policy", policy.GetMetadata().GetName()).Msg("skipping policy not configured for current attestation phase") + return nil, nil + } + var basePath string // if it's a file://, let's calculate the base path for loading referenced policies, from the loader ref if ref != nil { diff --git a/pkg/policies/policies_test.go b/pkg/policies/policies_test.go index cb0ee3db0..4e165cd42 100644 --- a/pkg/policies/policies_test.go +++ b/pkg/policies/policies_test.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. @@ -1250,6 +1250,116 @@ func (s *testSuite) TestMultiKindAWithIgnore() { } } +func (s *testSuite) TestAttestationPhaseFiltering() { + cases := []struct { + name string + policyRef string + phase EvalPhase + npolicies int + }{ + { + name: "no phases specified, runs at push", + policyRef: "file://testdata/workflow.yaml", + phase: EvalPhasePush, + npolicies: 1, + }, + { + name: "no phases specified, runs with unspecified phase", + policyRef: "file://testdata/workflow.yaml", + phase: EvalPhaseUnspecified, + npolicies: 1, + }, + { + name: "push-only policy, runs at push", + policyRef: "file://testdata/workflow_push_only.yaml", + phase: EvalPhasePush, + npolicies: 1, + }, + { + name: "push-only policy, skipped at init", + policyRef: "file://testdata/workflow_push_only.yaml", + phase: EvalPhaseInit, + npolicies: 0, + }, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + schema := &v12.CraftingSchema{ + Policies: &v12.Policies{ + Attestation: []*v12.PolicyAttachment{ + {Policy: &v12.PolicyAttachment_Ref{Ref: tc.policyRef}}, + }, + }, + } + verifier := NewPolicyVerifier(schema.Policies, nil, &s.logger, WithEvalPhase(tc.phase)) + statement := loadStatement("testdata/statement.json", &s.Suite) + + res, err := verifier.VerifyStatement(context.TODO(), statement) + s.Require().NoError(err) + s.Len(res, tc.npolicies) + }) + } +} + +func (s *testSuite) TestShouldEvaluateAtPhase() { + cases := []struct { + name string + phases []v12.AttestationPhase + phase EvalPhase + want bool + }{ + { + name: "empty phases, unspecified phase", + phases: nil, + phase: EvalPhaseUnspecified, + want: true, + }, + { + name: "empty phases, push phase", + phases: nil, + phase: EvalPhasePush, + want: true, + }, + { + name: "push only, matches push", + phases: []v12.AttestationPhase{v12.AttestationPhase_PUSH}, + phase: EvalPhasePush, + want: true, + }, + { + name: "push only, does not match init", + phases: []v12.AttestationPhase{v12.AttestationPhase_PUSH}, + phase: EvalPhaseInit, + want: false, + }, + { + name: "both phases, matches init", + phases: []v12.AttestationPhase{v12.AttestationPhase_INIT, v12.AttestationPhase_PUSH}, + phase: EvalPhaseInit, + want: true, + }, + { + name: "both phases, matches push", + phases: []v12.AttestationPhase{v12.AttestationPhase_INIT, v12.AttestationPhase_PUSH}, + phase: EvalPhasePush, + want: true, + }, + { + name: "with phases specified, unspecified phase always matches", + phases: []v12.AttestationPhase{v12.AttestationPhase_PUSH}, + phase: EvalPhaseUnspecified, + want: true, + }, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + s.Equal(tc.want, shouldEvaluateAtPhase(tc.phases, tc.phase)) + }) + } +} + type testSuite struct { suite.Suite diff --git a/pkg/policies/policy_groups_test.go b/pkg/policies/policy_groups_test.go index 7f8f2ac20..fcae4c74d 100644 --- a/pkg/policies/policy_groups_test.go +++ b/pkg/policies/policy_groups_test.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. @@ -612,3 +612,38 @@ func (s *groupsTestSuite) TestSkipBothMaterialAndAttestationPolicies() { s.Require().NoError(err) s.Len(attestationEvs, 0, "attestation policy should be skipped") } + +func (s *groupsTestSuite) TestAttestationPhaseFilteringInGroups() { + cases := []struct { + name string + phase EvalPhase + npolicies int + }{ + { + name: "push-only group policy runs at push", + phase: EvalPhasePush, + npolicies: 1, + }, + { + name: "push-only group policy skipped at init", + phase: EvalPhaseInit, + npolicies: 0, + }, + } + + for _, tc := range cases { + s.Run(tc.name, func() { + schema := &v1.CraftingSchema{ + PolicyGroups: []*v1.PolicyGroupAttachment{ + {Ref: "file://testdata/policy_group_push_only.yaml"}, + }, + } + verifier := NewPolicyGroupVerifier(schema.PolicyGroups, nil, nil, &s.logger, WithEvalPhase(tc.phase)) + statement := loadStatement("testdata/statement.json", &s.Suite) + + res, err := verifier.VerifyStatement(context.TODO(), statement) + s.Require().NoError(err) + s.Len(res, tc.npolicies) + }) + } +} diff --git a/pkg/policies/testdata/policy_group_push_only.yaml b/pkg/policies/testdata/policy_group_push_only.yaml new file mode 100644 index 000000000..10462947a --- /dev/null +++ b/pkg/policies/testdata/policy_group_push_only.yaml @@ -0,0 +1,9 @@ +apiVersion: workflowcontract.chainloop.dev/v1 +kind: PolicyGroup +metadata: + name: push-only-group + description: Group with a push-only attestation policy +spec: + policies: + attestation: + - ref: file://testdata/workflow_push_only.yaml diff --git a/pkg/policies/testdata/workflow_push_only.yaml b/pkg/policies/testdata/workflow_push_only.yaml new file mode 100644 index 000000000..a04ba4541 --- /dev/null +++ b/pkg/policies/testdata/workflow_push_only.yaml @@ -0,0 +1,10 @@ +apiVersion: workflowcontract.chainloop.dev/v1 +kind: Policy +metadata: + name: workflow-push-only +spec: + policies: + - kind: ATTESTATION + path: workflow.rego + attestation_phases: + - PUSH