From e47b11cdf8e74f953a3c632a51fae9f9134ef8de Mon Sep 17 00:00:00 2001 From: Miguel Martinez Date: Fri, 20 Feb 2026 14:54:26 +0100 Subject: [PATCH 1/4] feat(policies): add attestation phase lifecycle control to policy evaluation Add an AttestationPhase enum (INIT, STATUS, PUSH) and an attestation_phases field on PolicySpecV2 so policy authors can control at which attestation lifecycle phase their policy is evaluated. When no phases are specified the policy runs at all phases, preserving backwards compatibility. The init command now passes EvalPhaseInit when calling status after initialization, so policies can distinguish between init-time and explicit status-time evaluation. Closes #2764 Signed-off-by: Miguel Martinez --- app/cli/cmd/attestation_init.go | 5 +- app/cli/pkg/action/attestation_push.go | 5 +- app/cli/pkg/action/attestation_status.go | 13 +- .../workflowcontract/v1/crafting_schema.ts | 88 ++++++- ...owcontract.v1.PolicySpecV2.jsonschema.json | 48 ++++ ...rkflowcontract.v1.PolicySpecV2.schema.json | 48 ++++ .../workflowcontract/v1/crafting_schema.pb.go | 234 ++++++++++++------ .../workflowcontract/v1/crafting_schema.proto | 16 +- pkg/attestation/crafter/crafter.go | 15 +- pkg/policies/policies.go | 71 +++++- pkg/policies/policies_test.go | 148 ++++++++++- pkg/policies/policy_groups_test.go | 37 ++- .../testdata/policy_group_push_only.yaml | 9 + pkg/policies/testdata/workflow_push_only.yaml | 10 + .../testdata/workflow_status_only.yaml | 10 + 15 files changed, 662 insertions(+), 95 deletions(-) create mode 100644 pkg/policies/testdata/policy_group_push_only.yaml create mode 100644 pkg/policies/testdata/workflow_push_only.yaml create mode 100644 pkg/policies/testdata/workflow_status_only.yaml diff --git a/app/cli/cmd/attestation_init.go b/app/cli/cmd/attestation_init.go index 3648812a0..ba60ca51f 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. @@ -21,6 +21,7 @@ import ( "github.com/chainloop-dev/chainloop/app/cli/cmd/output" "github.com/chainloop-dev/chainloop/app/cli/pkg/action" + "github.com/chainloop-dev/chainloop/pkg/policies" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -127,7 +128,7 @@ func newAttestationInitCmd() *cobra.Command { return newGracefulError(err) } - res, err := statusAction.Run(cmd.Context(), attestationID) + res, err := statusAction.Run(cmd.Context(), attestationID, action.WithStatusEvalPhase(policies.EvalPhaseInit)) if err != nil { return newGracefulError(err) } diff --git a/app/cli/pkg/action/attestation_push.go b/app/cli/pkg/action/attestation_push.go index 046dd7efc..f42515a17 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" @@ -196,7 +197,7 @@ 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) } diff --git a/app/cli/pkg/action/attestation_status.go b/app/cli/pkg/action/attestation_status.go index 70bccb691..27975947d 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. @@ -26,6 +26,7 @@ import ( 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" + "github.com/chainloop-dev/chainloop/pkg/policies" ) type AttestationStatusOpts struct { @@ -41,6 +42,7 @@ type AttestationStatus struct { // Do not show information about the project version release status isPushed bool skipPolicyEvaluation bool + evalPhase policies.EvalPhase } type AttestationStatusResult struct { @@ -90,6 +92,7 @@ func NewAttestationStatus(cfg *AttestationStatusOpts) (*AttestationStatus, error ActionsOpts: cfg.ActionsOpts, c: c, isPushed: cfg.isPushed, + evalPhase: policies.EvalPhaseStatus, }, nil } @@ -99,6 +102,12 @@ func WithSkipPolicyEvaluation() func(*AttestationStatus) { } } +func WithStatusEvalPhase(phase policies.EvalPhase) func(*AttestationStatus) { + return func(opts *AttestationStatus) { + opts.evalPhase = phase + } +} + type AttestationStatusOpt func(*AttestationStatus) func (action *AttestationStatus) Run(ctx context.Context, attestationID string, opts ...AttestationStatusOpt) (*AttestationStatusResult, error) { @@ -156,7 +165,7 @@ func (action *AttestationStatus) Run(ctx context.Context, attestationID string, } // Add attestation-level policy evaluations - if err := c.EvaluateAttestationPolicies(ctx, attestationID, statement); err != nil { + if err := c.EvaluateAttestationPolicies(ctx, attestationID, statement, action.evalPhase); err != nil { return nil, fmt.Errorf("evaluating attestation policies: %w", err) } 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..7fcbe9535 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,55 @@ 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, + STATUS = 2, + 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 2: + case "STATUS": + return AttestationPhase.STATUS; + 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.STATUS: + return "STATUS"; + 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 +586,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, STATUS, and PUSH) for backwards compatibility. + * Only applicable when kind is ATTESTATION. + */ + attestationPhases: AttestationPhase[]; } /** Auto-matching policy specification */ @@ -2175,7 +2230,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 +2247,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 +2290,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 +2322,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 +2334,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 +2352,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..acc31b501 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,55 @@ "$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, STATUS, and PUSH) for backwards compatibility.\n Only applicable when kind is ATTESTATION.", + "items": { + "anyOf": [ + { + "enum": [ + "ATTESTATION_PHASE_UNSPECIFIED", + "INIT", + "STATUS", + "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, STATUS, and PUSH) for backwards compatibility.\n Only applicable when kind is ATTESTATION.", + "items": { + "anyOf": [ + { + "enum": [ + "ATTESTATION_PHASE_UNSPECIFIED", + "INIT", + "STATUS", + "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..e3f09e7c5 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,55 @@ "$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, STATUS, and PUSH) for backwards compatibility.\n Only applicable when kind is ATTESTATION.", + "items": { + "anyOf": [ + { + "enum": [ + "ATTESTATION_PHASE_UNSPECIFIED", + "INIT", + "STATUS", + "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, STATUS, and PUSH) for backwards compatibility.\n Only applicable when kind is ATTESTATION.", + "items": { + "anyOf": [ + { + "enum": [ + "ATTESTATION_PHASE_UNSPECIFIED", + "INIT", + "STATUS", + "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..6883e1b5c 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,60 @@ 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_STATUS AttestationPhase = 2 + AttestationPhase_PUSH AttestationPhase = 3 +) + +// Enum value maps for AttestationPhase. +var ( + AttestationPhase_name = map[int32]string{ + 0: "ATTESTATION_PHASE_UNSPECIFIED", + 1: "INIT", + 2: "STATUS", + 3: "PUSH", + } + AttestationPhase_value = map[string]int32{ + "ATTESTATION_PHASE_UNSPECIFIED": 0, + "INIT": 1, + "STATUS": 2, + "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 +142,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 +286,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 +1174,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, STATUS, 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 +1255,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 +2041,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 +2080,13 @@ 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*U\n" + + "\x10AttestationPhase\x12!\n" + + "\x1dATTESTATION_PHASE_UNSPECIFIED\x10\x00\x12\b\n" + + "\x04INIT\x10\x01\x12\n" + + "\n" + + "\x06STATUS\x10\x02\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 +2100,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 +2205,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..8aa04f11c 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,15 @@ 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; + STATUS = 2; + 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 +348,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, STATUS, 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..69a87059f 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) diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index 2d6b47a59..c310dec2a 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,16 @@ 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 + EvalPhaseStatus + EvalPhasePush +) + type PolicyVerifier struct { policies *v1.Policies logger *zerolog.Logger @@ -71,6 +81,7 @@ type PolicyVerifier struct { allowedHostnames []string includeRawData bool enablePrint bool + evalPhase EvalPhase } var _ Verifier = (*PolicyVerifier)(nil) @@ -80,6 +91,7 @@ type PolicyVerifierOptions struct { IncludeRawData bool EnablePrint bool GRPCConn *grpc.ClientConn + EvalPhase EvalPhase } type PolicyVerifierOption func(*PolicyVerifierOptions) @@ -108,6 +120,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 +140,7 @@ func NewPolicyVerifier(policies *v1.Policies, client v13.AttestationServiceClien allowedHostnames: options.AllowedHostnames, includeRawData: options.IncludeRawData, enablePrint: options.EnablePrint, + evalPhase: options.EvalPhase, } } @@ -167,6 +186,48 @@ 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 EvalPhaseStatus: + target = v1.AttestationPhase_STATUS + 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 +235,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..ab7a95895 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,152 @@ func (s *testSuite) TestMultiKindAWithIgnore() { } } +func (s *testSuite) TestAttestationPhaseFiltering() { + cases := []struct { + name string + policyRef string + phase EvalPhase + npolicies int + }{ + { + name: "no phases specified, runs at status", + policyRef: "file://testdata/workflow.yaml", + phase: EvalPhaseStatus, + npolicies: 1, + }, + { + 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 status", + policyRef: "file://testdata/workflow_push_only.yaml", + phase: EvalPhaseStatus, + npolicies: 0, + }, + { + name: "status-only policy, runs at status", + policyRef: "file://testdata/workflow_status_only.yaml", + phase: EvalPhaseStatus, + npolicies: 1, + }, + { + name: "status-only policy, skipped at push", + policyRef: "file://testdata/workflow_status_only.yaml", + phase: EvalPhasePush, + 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, status phase", + phases: nil, + phase: EvalPhaseStatus, + want: true, + }, + { + name: "empty phases, push phase", + phases: nil, + phase: EvalPhasePush, + want: true, + }, + { + name: "status only, matches status", + phases: []v12.AttestationPhase{v12.AttestationPhase_STATUS}, + phase: EvalPhaseStatus, + want: true, + }, + { + name: "status only, does not match push", + phases: []v12.AttestationPhase{v12.AttestationPhase_STATUS}, + phase: EvalPhasePush, + want: false, + }, + { + name: "push only, matches push", + phases: []v12.AttestationPhase{v12.AttestationPhase_PUSH}, + phase: EvalPhasePush, + want: true, + }, + { + name: "push only, does not match status", + phases: []v12.AttestationPhase{v12.AttestationPhase_PUSH}, + phase: EvalPhaseStatus, + want: false, + }, + { + name: "both phases, matches status", + phases: []v12.AttestationPhase{v12.AttestationPhase_STATUS, v12.AttestationPhase_PUSH}, + phase: EvalPhaseStatus, + want: true, + }, + { + name: "both phases, matches push", + phases: []v12.AttestationPhase{v12.AttestationPhase_STATUS, 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..ea4584dc3 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 status", + phase: EvalPhaseStatus, + 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 diff --git a/pkg/policies/testdata/workflow_status_only.yaml b/pkg/policies/testdata/workflow_status_only.yaml new file mode 100644 index 000000000..d76a2f8fd --- /dev/null +++ b/pkg/policies/testdata/workflow_status_only.yaml @@ -0,0 +1,10 @@ +apiVersion: workflowcontract.chainloop.dev/v1 +kind: Policy +metadata: + name: workflow-status-only +spec: + policies: + - kind: ATTESTATION + path: workflow.rego + attestation_phases: + - STATUS From 3462538156d501b59796567687c22e30c6759f9a Mon Sep 17 00:00:00 2001 From: Miguel Martinez Date: Fri, 20 Feb 2026 15:44:09 +0100 Subject: [PATCH 2/4] feat(policies): decouple policy display from evaluation and preserve cross-phase results Remove skipPolicyEvaluation in favor of eval phase hooks. Status always evaluates and displays policy results from crafting state. EvaluateAttestationPolicies now preserves attestation-level evaluations from other phases instead of replacing them. Signed-off-by: Miguel Martinez --- app/cli/pkg/action/attestation_status.go | 44 +++++++++--------------- pkg/attestation/crafter/crafter.go | 19 ++++++++-- 2 files changed, 34 insertions(+), 29 deletions(-) diff --git a/app/cli/pkg/action/attestation_status.go b/app/cli/pkg/action/attestation_status.go index 27975947d..697a5b556 100644 --- a/app/cli/pkg/action/attestation_status.go +++ b/app/cli/pkg/action/attestation_status.go @@ -40,9 +40,8 @@ type AttestationStatus struct { *ActionsOpts c *crafter.Crafter // Do not show information about the project version release status - isPushed bool - skipPolicyEvaluation bool - evalPhase policies.EvalPhase + isPushed bool + evalPhase policies.EvalPhase } type AttestationStatusResult struct { @@ -96,12 +95,6 @@ func NewAttestationStatus(cfg *AttestationStatusOpts) (*AttestationStatus, error }, nil } -func WithSkipPolicyEvaluation() func(*AttestationStatus) { - return func(opts *AttestationStatus) { - opts.skipPolicyEvaluation = true - } -} - func WithStatusEvalPhase(phase policies.EvalPhase) func(*AttestationStatus) { return func(opts *AttestationStatus) { opts.evalPhase = phase @@ -150,28 +143,25 @@ 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) - } + // Render the statement and evaluate attestation-level policies using the configured phase + attClient := pb.NewAttestationServiceClient(action.CPConnection) + r, err := renderer.NewAttestationRenderer(c.CraftingState, attClient, "", "", nil, renderer.WithLogger(action.Logger)) + if err != nil { + return nil, fmt.Errorf("creating attestation renderer: %w", err) + } - // Add attestation-level policy evaluations - if err := c.EvaluateAttestationPolicies(ctx, attestationID, statement, action.evalPhase); err != nil { - return nil, fmt.Errorf("evaluating attestation policies: %w", err) - } + statement, err := r.RenderStatement(ctx) + if err != nil { + return nil, fmt.Errorf("rendering statement: %w", err) + } - res.PolicyEvaluations, res.HasPolicyViolations = getPolicyEvaluations(c) + if err := c.EvaluateAttestationPolicies(ctx, attestationID, statement, action.evalPhase); err != nil { + return nil, fmt.Errorf("evaluating attestation policies: %w", err) } + // Always read policy evaluations from crafting state + res.PolicyEvaluations, res.HasPolicyViolations = getPolicyEvaluations(c) + if v := workflowMeta.GetVersion(); v != nil { res.WorkflowMeta.ProjectVersion = &ProjectVersion{ Version: v.GetVersion(), diff --git a/pkg/attestation/crafter/crafter.go b/pkg/attestation/crafter/crafter.go index 69a87059f..99f3f8928 100644 --- a/pkg/attestation/crafter/crafter.go +++ b/pkg/attestation/crafter/crafter.go @@ -808,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) } } From 6153d6c62fbae6806efe1cd4b44fe9576c32160c Mon Sep 17 00:00:00 2001 From: Miguel Martinez Date: Fri, 20 Feb 2026 15:58:23 +0100 Subject: [PATCH 3/4] feat(policies): decouple policy display from evaluation and preserve cross-phase results Remove skipPolicyEvaluation in favor of eval phase hooks. Status always reads policy evaluations from crafting state regardless of evaluation. EvaluateAttestationPolicies now preserves attestation-level evaluations from other phases instead of replacing them. Add WithSkipPolicyEvaluation to disable evaluation entirely while still displaying existing results from state. Push uses this for its internal status call and evaluates manually with EvalPhasePush afterward. Signed-off-by: Miguel Martinez --- app/cli/pkg/action/attestation_push.go | 7 ++++- app/cli/pkg/action/attestation_status.go | 37 +++++++++++++++--------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/app/cli/pkg/action/attestation_push.go b/app/cli/pkg/action/attestation_push.go index f42515a17..e65b6ed49 100644 --- a/app/cli/pkg/action/attestation_push.go +++ b/app/cli/pkg/action/attestation_push.go @@ -101,7 +101,9 @@ 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) + + // we do not want to evaluate policies here since we do it manually later on + attestationStatus, err := statusAction.Run(ctx, attestationID, WithSkipPolicyEvaluation()) if err != nil { return nil, fmt.Errorf("creating running status action: %w", err) } @@ -201,6 +203,9 @@ func (action *AttestationPush) Run(ctx context.Context, attestationID string, ru 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 697a5b556..f4ef85cc2 100644 --- a/app/cli/pkg/action/attestation_status.go +++ b/app/cli/pkg/action/attestation_status.go @@ -42,6 +42,8 @@ type AttestationStatus struct { // Do not show information about the project version release status isPushed bool evalPhase policies.EvalPhase + // skipEvaluation disables policy evaluation entirely; only existing evaluations from state are shown + skipEvaluation bool } type AttestationStatusResult struct { @@ -101,6 +103,13 @@ func WithStatusEvalPhase(phase policies.EvalPhase) func(*AttestationStatus) { } } +// WithSkipPolicyEvaluation disables policy evaluation; only existing evaluations from crafting state are displayed. +func WithSkipPolicyEvaluation() func(*AttestationStatus) { + return func(opts *AttestationStatus) { + opts.skipEvaluation = true + } +} + type AttestationStatusOpt func(*AttestationStatus) func (action *AttestationStatus) Run(ctx context.Context, attestationID string, opts ...AttestationStatusOpt) (*AttestationStatusResult, error) { @@ -143,23 +152,25 @@ func (action *AttestationStatus) Run(ctx context.Context, attestationID string, TimestampAuthority: att.GetSigningOptions().GetTimestampAuthorityUrl(), } - // Render the statement and evaluate attestation-level policies using the configured phase - attClient := pb.NewAttestationServiceClient(action.CPConnection) - r, err := renderer.NewAttestationRenderer(c.CraftingState, attClient, "", "", nil, renderer.WithLogger(action.Logger)) - if err != nil { - return nil, fmt.Errorf("creating attestation renderer: %w", err) - } + if !action.skipEvaluation { + // Render the statement and evaluate attestation-level policies using the configured phase + attClient := pb.NewAttestationServiceClient(action.CPConnection) + r, err := renderer.NewAttestationRenderer(c.CraftingState, attClient, "", "", nil, renderer.WithLogger(action.Logger)) + if err != nil { + return nil, fmt.Errorf("creating attestation renderer: %w", err) + } - statement, err := r.RenderStatement(ctx) - if err != nil { - return nil, fmt.Errorf("rendering statement: %w", err) - } + statement, err := r.RenderStatement(ctx) + if err != nil { + return nil, fmt.Errorf("rendering statement: %w", err) + } - if err := c.EvaluateAttestationPolicies(ctx, attestationID, statement, action.evalPhase); err != nil { - return nil, fmt.Errorf("evaluating attestation policies: %w", err) + if err := c.EvaluateAttestationPolicies(ctx, attestationID, statement, action.evalPhase); err != nil { + return nil, fmt.Errorf("evaluating attestation policies: %w", err) + } } - // Always read policy evaluations from crafting state + // Always read policy evaluations from crafting state regardless of evaluation res.PolicyEvaluations, res.HasPolicyViolations = getPolicyEvaluations(c) if v := workflowMeta.GetVersion(); v != nil { From c61a2990dcd432eb614dd130da4855bb7ae432c4 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Date: Fri, 20 Feb 2026 16:53:29 +0100 Subject: [PATCH 4/4] feat(policies): remove STATUS phase and make status action read-only Remove the STATUS phase from the AttestationPhase enum, simplifying the attestation lifecycle to INIT and PUSH. Policy evaluation is moved out of the status action into the init action explicitly, following the same pattern push already uses. The status command becomes a pure read-only observer that displays existing evaluations from crafting state. Signed-off-by: Miguel Martinez --- app/cli/cmd/attestation_init.go | 3 +- app/cli/pkg/action/attestation_init.go | 19 ++++++- app/cli/pkg/action/attestation_push.go | 3 +- app/cli/pkg/action/attestation_status.go | 50 ++---------------- .../workflowcontract/v1/crafting_schema.ts | 8 +-- ...owcontract.v1.PolicySpecV2.jsonschema.json | 6 +-- ...rkflowcontract.v1.PolicySpecV2.schema.json | 6 +-- .../workflowcontract/v1/crafting_schema.pb.go | 11 ++-- .../workflowcontract/v1/crafting_schema.proto | 3 +- pkg/policies/policies.go | 3 -- pkg/policies/policies_test.go | 52 +++---------------- pkg/policies/policy_groups_test.go | 4 +- .../testdata/workflow_status_only.yaml | 10 ---- 13 files changed, 42 insertions(+), 136 deletions(-) delete mode 100644 pkg/policies/testdata/workflow_status_only.yaml diff --git a/app/cli/cmd/attestation_init.go b/app/cli/cmd/attestation_init.go index ba60ca51f..58ecfe7df 100644 --- a/app/cli/cmd/attestation_init.go +++ b/app/cli/cmd/attestation_init.go @@ -21,7 +21,6 @@ import ( "github.com/chainloop-dev/chainloop/app/cli/cmd/output" "github.com/chainloop-dev/chainloop/app/cli/pkg/action" - "github.com/chainloop-dev/chainloop/pkg/policies" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -128,7 +127,7 @@ func newAttestationInitCmd() *cobra.Command { return newGracefulError(err) } - res, err := statusAction.Run(cmd.Context(), attestationID, action.WithStatusEvalPhase(policies.EvalPhaseInit)) + res, err := statusAction.Run(cmd.Context(), attestationID) if err != nil { return newGracefulError(err) } 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 e65b6ed49..9a013b5b5 100644 --- a/app/cli/pkg/action/attestation_push.go +++ b/app/cli/pkg/action/attestation_push.go @@ -102,8 +102,7 @@ func (action *AttestationPush) Run(ctx context.Context, attestationID string, ru return nil, fmt.Errorf("creating status action: %w", err) } - // we do not want to evaluate policies here since we do it manually later on - attestationStatus, err := statusAction.Run(ctx, attestationID, WithSkipPolicyEvaluation()) + attestationStatus, err := statusAction.Run(ctx, attestationID) if err != nil { return nil, fmt.Errorf("creating running status action: %w", err) } diff --git a/app/cli/pkg/action/attestation_status.go b/app/cli/pkg/action/attestation_status.go index f4ef85cc2..2670eb482 100644 --- a/app/cli/pkg/action/attestation_status.go +++ b/app/cli/pkg/action/attestation_status.go @@ -20,13 +20,10 @@ 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" - "github.com/chainloop-dev/chainloop/pkg/policies" ) type AttestationStatusOpts struct { @@ -40,10 +37,7 @@ type AttestationStatus struct { *ActionsOpts c *crafter.Crafter // Do not show information about the project version release status - isPushed bool - evalPhase policies.EvalPhase - // skipEvaluation disables policy evaluation entirely; only existing evaluations from state are shown - skipEvaluation bool + isPushed bool } type AttestationStatusResult struct { @@ -93,30 +87,10 @@ func NewAttestationStatus(cfg *AttestationStatusOpts) (*AttestationStatus, error ActionsOpts: cfg.ActionsOpts, c: c, isPushed: cfg.isPushed, - evalPhase: policies.EvalPhaseStatus, }, nil } -func WithStatusEvalPhase(phase policies.EvalPhase) func(*AttestationStatus) { - return func(opts *AttestationStatus) { - opts.evalPhase = phase - } -} - -// WithSkipPolicyEvaluation disables policy evaluation; only existing evaluations from crafting state are displayed. -func WithSkipPolicyEvaluation() func(*AttestationStatus) { - return func(opts *AttestationStatus) { - opts.skipEvaluation = 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 { @@ -152,25 +126,7 @@ func (action *AttestationStatus) Run(ctx context.Context, attestationID string, TimestampAuthority: att.GetSigningOptions().GetTimestampAuthorityUrl(), } - if !action.skipEvaluation { - // Render the statement and evaluate attestation-level policies using the configured phase - attClient := pb.NewAttestationServiceClient(action.CPConnection) - r, err := renderer.NewAttestationRenderer(c.CraftingState, attClient, "", "", nil, renderer.WithLogger(action.Logger)) - if err != nil { - return nil, fmt.Errorf("creating attestation renderer: %w", err) - } - - statement, err := r.RenderStatement(ctx) - if err != nil { - return nil, fmt.Errorf("rendering statement: %w", err) - } - - if err := c.EvaluateAttestationPolicies(ctx, attestationID, statement, action.evalPhase); err != nil { - return nil, fmt.Errorf("evaluating attestation policies: %w", err) - } - } - - // Always read policy evaluations from crafting state regardless of evaluation + // 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 { 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 7fcbe9535..da3e00ec3 100644 --- a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts +++ b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts @@ -10,7 +10,6 @@ export const protobufPackage = "workflowcontract.v1"; export enum AttestationPhase { ATTESTATION_PHASE_UNSPECIFIED = 0, INIT = 1, - STATUS = 2, PUSH = 3, UNRECOGNIZED = -1, } @@ -23,9 +22,6 @@ export function attestationPhaseFromJSON(object: any): AttestationPhase { case 1: case "INIT": return AttestationPhase.INIT; - case 2: - case "STATUS": - return AttestationPhase.STATUS; case 3: case "PUSH": return AttestationPhase.PUSH; @@ -42,8 +38,6 @@ export function attestationPhaseToJSON(object: AttestationPhase): string { return "ATTESTATION_PHASE_UNSPECIFIED"; case AttestationPhase.INIT: return "INIT"; - case AttestationPhase.STATUS: - return "STATUS"; case AttestationPhase.PUSH: return "PUSH"; case AttestationPhase.UNRECOGNIZED: @@ -588,7 +582,7 @@ export interface PolicySpecV2 { kind: CraftingSchema_Material_MaterialType; /** * Controls at which attestation phases this policy is evaluated. - * Empty means evaluate at all phases (INIT, STATUS, and PUSH) for backwards compatibility. + * Empty means evaluate at all phases (INIT and PUSH) for backwards compatibility. * Only applicable when kind is ATTESTATION. */ attestationPhases: AttestationPhase[]; 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 acc31b501..7749248d9 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.jsonschema.json @@ -4,14 +4,13 @@ "additionalProperties": false, "patternProperties": { "^(attestation_phases)$": { - "description": "Controls at which attestation phases this policy is evaluated.\n Empty means evaluate at all phases (INIT, STATUS, and PUSH) for backwards compatibility.\n Only applicable when kind is ATTESTATION.", + "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", - "STATUS", "PUSH" ], "title": "Attestation Phase", @@ -29,14 +28,13 @@ }, "properties": { "attestationPhases": { - "description": "Controls at which attestation phases this policy is evaluated.\n Empty means evaluate at all phases (INIT, STATUS, and PUSH) for backwards compatibility.\n Only applicable when kind is ATTESTATION.", + "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", - "STATUS", "PUSH" ], "title": "Attestation Phase", 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 e3f09e7c5..f1d5f5fa2 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.schema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.schema.json @@ -4,14 +4,13 @@ "additionalProperties": false, "patternProperties": { "^(attestationPhases)$": { - "description": "Controls at which attestation phases this policy is evaluated.\n Empty means evaluate at all phases (INIT, STATUS, and PUSH) for backwards compatibility.\n Only applicable when kind is ATTESTATION.", + "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", - "STATUS", "PUSH" ], "title": "Attestation Phase", @@ -29,14 +28,13 @@ }, "properties": { "attestation_phases": { - "description": "Controls at which attestation phases this policy is evaluated.\n Empty means evaluate at all phases (INIT, STATUS, and PUSH) for backwards compatibility.\n Only applicable when kind is ATTESTATION.", + "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", - "STATUS", "PUSH" ], "title": "Attestation Phase", diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go index 6883e1b5c..6f7a1d2e3 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go @@ -44,7 +44,6 @@ type AttestationPhase int32 const ( AttestationPhase_ATTESTATION_PHASE_UNSPECIFIED AttestationPhase = 0 AttestationPhase_INIT AttestationPhase = 1 - AttestationPhase_STATUS AttestationPhase = 2 AttestationPhase_PUSH AttestationPhase = 3 ) @@ -53,13 +52,11 @@ var ( AttestationPhase_name = map[int32]string{ 0: "ATTESTATION_PHASE_UNSPECIFIED", 1: "INIT", - 2: "STATUS", 3: "PUSH", } AttestationPhase_value = map[string]int32{ "ATTESTATION_PHASE_UNSPECIFIED": 0, "INIT": 1, - "STATUS": 2, "PUSH": 3, } ) @@ -1176,7 +1173,7 @@ type PolicySpecV2 struct { // 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"` // Controls at which attestation phases this policy is evaluated. - // Empty means evaluate at all phases (INIT, STATUS, and PUSH) for backwards compatibility. + // 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 @@ -2080,12 +2077,10 @@ 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 != 0*U\n" + + "\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\n" + - "\n" + - "\x06STATUS\x10\x02\x12\b\n" + + "\x04INIT\x10\x01\x12\b\n" + "\x04PUSH\x10\x03BMZKgithub.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1b\x06proto3" var ( diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto index 8aa04f11c..9d9185345 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto @@ -311,7 +311,6 @@ message PolicySpec { enum AttestationPhase { ATTESTATION_PHASE_UNSPECIFIED = 0; INIT = 1; - STATUS = 2; PUSH = 3; } @@ -350,7 +349,7 @@ message PolicySpecV2 { }]; // Controls at which attestation phases this policy is evaluated. - // Empty means evaluate at all phases (INIT, STATUS, and PUSH) for backwards compatibility. + // Empty means evaluate at all phases (INIT and PUSH) for backwards compatibility. // Only applicable when kind is ATTESTATION. repeated AttestationPhase attestation_phases = 5; } diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index c310dec2a..625101765 100644 --- a/pkg/policies/policies.go +++ b/pkg/policies/policies.go @@ -69,7 +69,6 @@ type EvalPhase int const ( EvalPhaseUnspecified EvalPhase = iota EvalPhaseInit - EvalPhaseStatus EvalPhasePush ) @@ -198,8 +197,6 @@ func shouldEvaluateAtPhase(phases []v1.AttestationPhase, phase EvalPhase) bool { switch phase { case EvalPhaseInit: target = v1.AttestationPhase_INIT - case EvalPhaseStatus: - target = v1.AttestationPhase_STATUS case EvalPhasePush: target = v1.AttestationPhase_PUSH default: diff --git a/pkg/policies/policies_test.go b/pkg/policies/policies_test.go index ab7a95895..4e165cd42 100644 --- a/pkg/policies/policies_test.go +++ b/pkg/policies/policies_test.go @@ -1257,12 +1257,6 @@ func (s *testSuite) TestAttestationPhaseFiltering() { phase EvalPhase npolicies int }{ - { - name: "no phases specified, runs at status", - policyRef: "file://testdata/workflow.yaml", - phase: EvalPhaseStatus, - npolicies: 1, - }, { name: "no phases specified, runs at push", policyRef: "file://testdata/workflow.yaml", @@ -1282,21 +1276,9 @@ func (s *testSuite) TestAttestationPhaseFiltering() { npolicies: 1, }, { - name: "push-only policy, skipped at status", + name: "push-only policy, skipped at init", policyRef: "file://testdata/workflow_push_only.yaml", - phase: EvalPhaseStatus, - npolicies: 0, - }, - { - name: "status-only policy, runs at status", - policyRef: "file://testdata/workflow_status_only.yaml", - phase: EvalPhaseStatus, - npolicies: 1, - }, - { - name: "status-only policy, skipped at push", - policyRef: "file://testdata/workflow_status_only.yaml", - phase: EvalPhasePush, + phase: EvalPhaseInit, npolicies: 0, }, } @@ -1333,30 +1315,12 @@ func (s *testSuite) TestShouldEvaluateAtPhase() { phase: EvalPhaseUnspecified, want: true, }, - { - name: "empty phases, status phase", - phases: nil, - phase: EvalPhaseStatus, - want: true, - }, { name: "empty phases, push phase", phases: nil, phase: EvalPhasePush, want: true, }, - { - name: "status only, matches status", - phases: []v12.AttestationPhase{v12.AttestationPhase_STATUS}, - phase: EvalPhaseStatus, - want: true, - }, - { - name: "status only, does not match push", - phases: []v12.AttestationPhase{v12.AttestationPhase_STATUS}, - phase: EvalPhasePush, - want: false, - }, { name: "push only, matches push", phases: []v12.AttestationPhase{v12.AttestationPhase_PUSH}, @@ -1364,20 +1328,20 @@ func (s *testSuite) TestShouldEvaluateAtPhase() { want: true, }, { - name: "push only, does not match status", + name: "push only, does not match init", phases: []v12.AttestationPhase{v12.AttestationPhase_PUSH}, - phase: EvalPhaseStatus, + phase: EvalPhaseInit, want: false, }, { - name: "both phases, matches status", - phases: []v12.AttestationPhase{v12.AttestationPhase_STATUS, v12.AttestationPhase_PUSH}, - phase: EvalPhaseStatus, + 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_STATUS, v12.AttestationPhase_PUSH}, + phases: []v12.AttestationPhase{v12.AttestationPhase_INIT, v12.AttestationPhase_PUSH}, phase: EvalPhasePush, want: true, }, diff --git a/pkg/policies/policy_groups_test.go b/pkg/policies/policy_groups_test.go index ea4584dc3..fcae4c74d 100644 --- a/pkg/policies/policy_groups_test.go +++ b/pkg/policies/policy_groups_test.go @@ -625,8 +625,8 @@ func (s *groupsTestSuite) TestAttestationPhaseFilteringInGroups() { npolicies: 1, }, { - name: "push-only group policy skipped at status", - phase: EvalPhaseStatus, + name: "push-only group policy skipped at init", + phase: EvalPhaseInit, npolicies: 0, }, } diff --git a/pkg/policies/testdata/workflow_status_only.yaml b/pkg/policies/testdata/workflow_status_only.yaml deleted file mode 100644 index d76a2f8fd..000000000 --- a/pkg/policies/testdata/workflow_status_only.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: workflowcontract.chainloop.dev/v1 -kind: Policy -metadata: - name: workflow-status-only -spec: - policies: - - kind: ATTESTATION - path: workflow.rego - attestation_phases: - - STATUS