diff --git a/app/controlplane/api/controlplane/v1/workflow_contract.pb.go b/app/controlplane/api/controlplane/v1/workflow_contract.pb.go index 7e781bb3a..81dbf5878 100644 --- a/app/controlplane/api/controlplane/v1/workflow_contract.pb.go +++ b/app/controlplane/api/controlplane/v1/workflow_contract.pb.go @@ -580,8 +580,13 @@ type WorkflowContractServiceApplyRequest struct { BatchPolicyNames []string `protobuf:"bytes,3,rep,name=batch_policy_names,json=batchPolicyNames,proto3" json:"batch_policy_names,omitempty"` // Same as batch_policy_names, for policy groups created/updated in the same batch. BatchPolicyGroupNames []string `protobuf:"bytes,4,rep,name=batch_policy_group_names,json=batchPolicyGroupNames,proto3" json:"batch_policy_group_names,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Raw representation (json or yaml) of the policies created/updated in the same batch apply. + // Only used on dry runs, ignored on a real apply (which persists batch resources first). + // When present, the specs of batch-local references are used to validate their inputs and + // whether the attachment can apply to a material, as a real apply does. + BatchPolicies [][]byte `protobuf:"bytes,5,rep,name=batch_policies,json=batchPolicies,proto3" json:"batch_policies,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *WorkflowContractServiceApplyRequest) Reset() { @@ -642,6 +647,13 @@ func (x *WorkflowContractServiceApplyRequest) GetBatchPolicyGroupNames() []strin return nil } +func (x *WorkflowContractServiceApplyRequest) GetBatchPolicies() [][]byte { + if x != nil { + return x.BatchPolicies + } + return nil +} + type WorkflowContractServiceApplyResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Result *WorkflowContractItem `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` @@ -872,13 +884,14 @@ const file_controlplane_v1_workflow_contract_proto_rawDesc = "" + "$WorkflowContractServiceDeleteRequest\x12\x97\x01\n" + "\x04name\x18\x01 \x01(\tB\x82\x01\xbaH\x7f\xba\x01|\n" + "\rname.dns-1123\x12:must contain only lowercase letters, numbers, and hyphens.\x1a/this.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')R\x04name\"'\n" + - "%WorkflowContractServiceDeleteResponse\"\xc4\x01\n" + + "%WorkflowContractServiceDeleteResponse\"\xeb\x01\n" + "#WorkflowContractServiceApplyRequest\x12\x1d\n" + "\n" + "raw_schema\x18\x01 \x01(\fR\trawSchema\x12\x17\n" + "\adry_run\x18\x02 \x01(\bR\x06dryRun\x12,\n" + "\x12batch_policy_names\x18\x03 \x03(\tR\x10batchPolicyNames\x127\n" + - "\x18batch_policy_group_names\x18\x04 \x03(\tR\x15batchPolicyGroupNames\"\xa4\x03\n" + + "\x18batch_policy_group_names\x18\x04 \x03(\tR\x15batchPolicyGroupNames\x12%\n" + + "\x0ebatch_policies\x18\x05 \x03(\fR\rbatchPolicies\"\xa4\x03\n" + "$WorkflowContractServiceApplyResponse\x12=\n" + "\x06result\x18\x01 \x01(\v2%.controlplane.v1.WorkflowContractItemR\x06result\x12 \n" + "\tunchanged\x18\x02 \x01(\bB\x02\x18\x01R\tunchanged\x12\x18\n" + diff --git a/app/controlplane/api/controlplane/v1/workflow_contract.proto b/app/controlplane/api/controlplane/v1/workflow_contract.proto index 4169bdd83..fe31f9508 100644 --- a/app/controlplane/api/controlplane/v1/workflow_contract.proto +++ b/app/controlplane/api/controlplane/v1/workflow_contract.proto @@ -127,6 +127,11 @@ message WorkflowContractServiceApplyRequest { repeated string batch_policy_names = 3; // Same as batch_policy_names, for policy groups created/updated in the same batch. repeated string batch_policy_group_names = 4; + // Raw representation (json or yaml) of the policies created/updated in the same batch apply. + // Only used on dry runs, ignored on a real apply (which persists batch resources first). + // When present, the specs of batch-local references are used to validate their inputs and + // whether the attachment can apply to a material, as a real apply does. + repeated bytes batch_policies = 5; } message WorkflowContractServiceApplyResponse { diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/workflow_contract.ts b/app/controlplane/api/gen/frontend/controlplane/v1/workflow_contract.ts index c60d110e9..a55f52e89 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/workflow_contract.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/workflow_contract.ts @@ -79,6 +79,13 @@ export interface WorkflowContractServiceApplyRequest { batchPolicyNames: string[]; /** Same as batch_policy_names, for policy groups created/updated in the same batch. */ batchPolicyGroupNames: string[]; + /** + * Raw representation (json or yaml) of the policies created/updated in the same batch apply. + * Only used on dry runs, ignored on a real apply (which persists batch resources first). + * When present, the specs of batch-local references are used to validate their inputs and + * whether the attachment can apply to a material, as a real apply does. + */ + batchPolicies: Uint8Array[]; } export interface WorkflowContractServiceApplyResponse { @@ -1009,7 +1016,13 @@ export const WorkflowContractServiceDeleteResponse = { }; function createBaseWorkflowContractServiceApplyRequest(): WorkflowContractServiceApplyRequest { - return { rawSchema: new Uint8Array(0), dryRun: false, batchPolicyNames: [], batchPolicyGroupNames: [] }; + return { + rawSchema: new Uint8Array(0), + dryRun: false, + batchPolicyNames: [], + batchPolicyGroupNames: [], + batchPolicies: [], + }; } export const WorkflowContractServiceApplyRequest = { @@ -1026,6 +1039,9 @@ export const WorkflowContractServiceApplyRequest = { for (const v of message.batchPolicyGroupNames) { writer.uint32(34).string(v!); } + for (const v of message.batchPolicies) { + writer.uint32(42).bytes(v!); + } return writer; }, @@ -1064,6 +1080,13 @@ export const WorkflowContractServiceApplyRequest = { message.batchPolicyGroupNames.push(reader.string()); continue; + case 5: + if (tag !== 42) { + break; + } + + message.batchPolicies.push(reader.bytes()); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -1083,6 +1106,9 @@ export const WorkflowContractServiceApplyRequest = { batchPolicyGroupNames: Array.isArray(object?.batchPolicyGroupNames) ? object.batchPolicyGroupNames.map((e: any) => String(e)) : [], + batchPolicies: Array.isArray(object?.batchPolicies) + ? object.batchPolicies.map((e: any) => bytesFromBase64(e)) + : [], }; }, @@ -1101,6 +1127,11 @@ export const WorkflowContractServiceApplyRequest = { } else { obj.batchPolicyGroupNames = []; } + if (message.batchPolicies) { + obj.batchPolicies = message.batchPolicies.map((e) => base64FromBytes(e !== undefined ? e : new Uint8Array(0))); + } else { + obj.batchPolicies = []; + } return obj; }, @@ -1118,6 +1149,7 @@ export const WorkflowContractServiceApplyRequest = { message.dryRun = object.dryRun ?? false; message.batchPolicyNames = object.batchPolicyNames?.map((e) => e) || []; message.batchPolicyGroupNames = object.batchPolicyGroupNames?.map((e) => e) || []; + message.batchPolicies = object.batchPolicies?.map((e) => e) || []; return message; }, }; diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyRequest.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyRequest.jsonschema.json index 1824bcc9e..506ebfd0e 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyRequest.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyRequest.jsonschema.json @@ -3,6 +3,14 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "patternProperties": { + "^(batch_policies)$": { + "description": "Raw representation (json or yaml) of the policies created/updated in the same batch apply.\n Only used on dry runs, ignored on a real apply (which persists batch resources first).\n When present, the specs of batch-local references are used to validate their inputs and\n whether the attachment can apply to a material, as a real apply does.", + "items": { + "pattern": "^[A-Za-z0-9+/]*={0,2}$", + "type": "string" + }, + "type": "array" + }, "^(batch_policy_group_names)$": { "description": "Same as batch_policy_names, for policy groups created/updated in the same batch.", "items": { @@ -28,6 +36,14 @@ } }, "properties": { + "batchPolicies": { + "description": "Raw representation (json or yaml) of the policies created/updated in the same batch apply.\n Only used on dry runs, ignored on a real apply (which persists batch resources first).\n When present, the specs of batch-local references are used to validate their inputs and\n whether the attachment can apply to a material, as a real apply does.", + "items": { + "pattern": "^[A-Za-z0-9+/]*={0,2}$", + "type": "string" + }, + "type": "array" + }, "batchPolicyGroupNames": { "description": "Same as batch_policy_names, for policy groups created/updated in the same batch.", "items": { diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyRequest.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyRequest.schema.json index 15856b96a..61b04c3d0 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyRequest.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.WorkflowContractServiceApplyRequest.schema.json @@ -3,6 +3,14 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "patternProperties": { + "^(batchPolicies)$": { + "description": "Raw representation (json or yaml) of the policies created/updated in the same batch apply.\n Only used on dry runs, ignored on a real apply (which persists batch resources first).\n When present, the specs of batch-local references are used to validate their inputs and\n whether the attachment can apply to a material, as a real apply does.", + "items": { + "pattern": "^[A-Za-z0-9+/]*={0,2}$", + "type": "string" + }, + "type": "array" + }, "^(batchPolicyGroupNames)$": { "description": "Same as batch_policy_names, for policy groups created/updated in the same batch.", "items": { @@ -28,6 +36,14 @@ } }, "properties": { + "batch_policies": { + "description": "Raw representation (json or yaml) of the policies created/updated in the same batch apply.\n Only used on dry runs, ignored on a real apply (which persists batch resources first).\n When present, the specs of batch-local references are used to validate their inputs and\n whether the attachment can apply to a material, as a real apply does.", + "items": { + "pattern": "^[A-Za-z0-9+/]*={0,2}$", + "type": "string" + }, + "type": "array" + }, "batch_policy_group_names": { "description": "Same as batch_policy_names, for policy groups created/updated in the same batch.", "items": { diff --git a/app/controlplane/internal/service/attestation.go b/app/controlplane/internal/service/attestation.go index f76bedea5..c22b643b4 100644 --- a/app/controlplane/internal/service/attestation.go +++ b/app/controlplane/internal/service/attestation.go @@ -809,7 +809,7 @@ func (s *AttestationService) FindOrCreateWorkflow(ctx context.Context, req *cpAP // contract validation if req.GetContractBytes() != nil { - if err = s.workflowContractUseCase.ValidateContractPolicies(ctx, req.GetContractBytes(), token, nil, nil); err != nil { + if err = s.workflowContractUseCase.ValidateContractPolicies(ctx, req.GetContractBytes(), token, nil, nil, nil); err != nil { return nil, handleUseCaseErr(ctx, err, s.log) } } diff --git a/app/controlplane/internal/service/workflowcontract.go b/app/controlplane/internal/service/workflowcontract.go index f1530e56b..2cdc52566 100644 --- a/app/controlplane/internal/service/workflowcontract.go +++ b/app/controlplane/internal/service/workflowcontract.go @@ -146,7 +146,7 @@ func (s *WorkflowContractService) Create(ctx context.Context, req *pb.WorkflowCo } if len(req.RawContract) != 0 { - if err = s.contractUseCase.ValidateContractPolicies(ctx, req.RawContract, token, nil, nil); err != nil { + if err = s.contractUseCase.ValidateContractPolicies(ctx, req.RawContract, token, nil, nil, nil); err != nil { return nil, handleUseCaseErr(ctx, err, s.log) } } @@ -214,7 +214,7 @@ func (s *WorkflowContractService) Update(ctx context.Context, req *pb.WorkflowCo // Validate the contract policies if the raw contract is provided if len(req.RawContract) != 0 { - if err = s.contractUseCase.ValidateContractPolicies(ctx, req.RawContract, token, nil, nil); err != nil { + if err = s.contractUseCase.ValidateContractPolicies(ctx, req.RawContract, token, nil, nil, nil); err != nil { return nil, handleUseCaseErr(ctx, err, s.log) } } @@ -259,12 +259,17 @@ func (s *WorkflowContractService) Apply(ctx context.Context, req *pb.WorkflowCon // the resources may not be persisted yet. A real apply persists batch resources before the // contract, so it must always validate fully and never trust the client-supplied batch lists. var batchPolicyNames, batchPolicyGroupNames []string + var batchPolicies map[string]*schemav1.Policy if dryRun { batchPolicyNames = req.GetBatchPolicyNames() batchPolicyGroupNames = req.GetBatchPolicyGroupNames() + + if batchPolicies, err = parseBatchPolicies(req.GetBatchPolicies()); err != nil { + return nil, err + } } - if err = s.contractUseCase.ValidateContractPolicies(ctx, req.RawSchema, token, batchPolicyNames, batchPolicyGroupNames); err != nil { + if err = s.contractUseCase.ValidateContractPolicies(ctx, req.RawSchema, token, batchPolicyNames, batchPolicyGroupNames, batchPolicies); err != nil { return nil, handleUseCaseErr(ctx, err, s.log) } @@ -526,6 +531,34 @@ func validateAndExtractMetadata(rawContract []byte, explicitName, explicitDesc s return name, description, nil } +// parseBatchPolicies parses the raw policies sent with a batch apply, indexed by policy name +func parseBatchPolicies(raws [][]byte) (map[string]*schemav1.Policy, error) { + if len(raws) == 0 { + return nil, nil + } + + parsed := make(map[string]*schemav1.Policy, len(raws)) + for _, raw := range raws { + format, err := unmarshal.IdentifyFormat(raw) + if err != nil { + return nil, errors.BadRequest("invalid", fmt.Sprintf("invalid batch policy: %s", err)) + } + + policy := &schemav1.Policy{} + if err := unmarshal.FromRaw(raw, format, policy, true); err != nil { + return nil, errors.BadRequest("invalid", fmt.Sprintf("invalid batch policy: %s", err)) + } + + if policy.GetMetadata().GetName() == "" { + return nil, errors.BadRequest("invalid", "invalid batch policy: missing metadata name") + } + + parsed[policy.GetMetadata().GetName()] = policy + } + + return parsed, nil +} + // extractMetadata attempts to extract the name from metadata.name in v2 contracts func extractMetadata(rawContract []byte) (*schemav1.Metadata, error) { if len(rawContract) == 0 { diff --git a/app/controlplane/internal/service/workflowcontract_test.go b/app/controlplane/internal/service/workflowcontract_test.go index 6fcb1ea47..ef3ad3b3c 100644 --- a/app/controlplane/internal/service/workflowcontract_test.go +++ b/app/controlplane/internal/service/workflowcontract_test.go @@ -22,6 +22,62 @@ import ( "github.com/stretchr/testify/require" ) +const batchPolName = "batch-pol" + +func TestParseBatchPolicies(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raws [][]byte + wantNames []string + wantErr bool + }{ + {name: "no policies"}, + { + name: "valid kind-less policy", + raws: [][]byte{[]byte(`apiVersion: chainloop.dev/v1 +kind: Policy +metadata: + name: batch-pol +spec: + policies: + - embedded: | + package main +`)}, + wantNames: []string{batchPolName}, + }, + { + name: "only a metadata name is not a valid policy", + raws: [][]byte{[]byte("metadata:\n name: batch-pol\n")}, + wantErr: true, + }, + { + name: "missing metadata name", + raws: [][]byte{[]byte("apiVersion: chainloop.dev/v1\nkind: Policy\nspec:\n policies:\n - embedded: package main\n")}, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := parseBatchPolicies(tc.raws) + if tc.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Len(t, got, len(tc.wantNames)) + for _, n := range tc.wantNames { + assert.Contains(t, got, n) + } + }) + } +} + func TestExtractMetadata(t *testing.T) { t.Parallel() diff --git a/app/controlplane/pkg/biz/workflowcontract.go b/app/controlplane/pkg/biz/workflowcontract.go index b8d8d5b69..3c72aa154 100644 --- a/app/controlplane/pkg/biz/workflowcontract.go +++ b/app/controlplane/pkg/biz/workflowcontract.go @@ -507,8 +507,9 @@ func (uc *WorkflowContractUseCase) RevisionWouldChange(ctx context.Context, orgI // exist. batchPolicyNames and batchPolicyGroupNames carry the names of resources being // created/updated in the same batch apply: references to those are treated as known instead of // being resolved against the registry, since they may not be persisted yet (e.g. during -// dry-run). Remote references are still validated regardless. -func (uc *WorkflowContractUseCase) ValidateContractPolicies(ctx context.Context, rawSchema []byte, token string, batchPolicyNames, batchPolicyGroupNames []string) error { +// dry-run). Remote references are still validated regardless. batchPolicies optionally carries the +// specs of those policies by name, so their inputs and material applicability are validated too. +func (uc *WorkflowContractUseCase) ValidateContractPolicies(ctx context.Context, rawSchema []byte, token string, batchPolicyNames, batchPolicyGroupNames []string, batchPolicies map[string]*schemav1.Policy) error { // Validate that externally provided policies exist c, err := identifyUnMarshalAndValidateRawContract(rawSchema) if err != nil { @@ -521,12 +522,12 @@ func (uc *WorkflowContractUseCase) ValidateContractPolicies(ctx context.Context, // DEPRECATED: v1 schema is deprecated, use v2 Contract format instead schema := c.Schema for _, att := range schema.GetPolicies().GetAttestation() { - if _, err := uc.findAndValidatePolicy(ctx, att, token, batchPolicyNames); err != nil { + if _, err := uc.findAndValidatePolicy(ctx, att, token, batchPolicyNames, batchPolicies); err != nil { return NewErrValidation(err) } } for _, att := range schema.GetPolicies().GetMaterials() { - if _, err := uc.findAndValidatePolicy(ctx, att, token, batchPolicyNames); err != nil { + if err := uc.findAndValidateMaterialPolicy(ctx, att, token, batchPolicyNames, batchPolicies, schema.GetMaterials()); err != nil { return NewErrValidation(err) } } @@ -540,12 +541,12 @@ func (uc *WorkflowContractUseCase) ValidateContractPolicies(ctx context.Context, spec := c.Schemav2.GetSpec() if spec.GetPolicies() != nil { for _, att := range spec.GetPolicies().GetAttestation() { - if _, err := uc.findAndValidatePolicy(ctx, att, token, batchPolicyNames); err != nil { + if _, err := uc.findAndValidatePolicy(ctx, att, token, batchPolicyNames, batchPolicies); err != nil { return NewErrValidation(err) } } for _, att := range spec.GetPolicies().GetMaterials() { - if _, err := uc.findAndValidatePolicy(ctx, att, token, batchPolicyNames); err != nil { + if err := uc.findAndValidateMaterialPolicy(ctx, att, token, batchPolicyNames, batchPolicies, spec.GetMaterials()); err != nil { return NewErrValidation(err) } } @@ -578,7 +579,7 @@ func (uc *WorkflowContractUseCase) ValidatePolicyAttachment(ctx context.Context, return nil } -func (uc *WorkflowContractUseCase) findAndValidatePolicy(ctx context.Context, att *schemav1.PolicyAttachment, token string, batchPolicyNames []string) (*schemav1.Policy, error) { +func (uc *WorkflowContractUseCase) findAndValidatePolicy(ctx context.Context, att *schemav1.PolicyAttachment, token string, batchPolicyNames []string, batchPolicies map[string]*schemav1.Policy) (*schemav1.Policy, error) { var policy *schemav1.Policy if att.GetEmbedded() != nil { @@ -593,21 +594,24 @@ func (uc *WorkflowContractUseCase) findAndValidatePolicy(ctx context.Context, at // treat references to them as known instead of resolving against the registry. Only // bare references (no provider/org) can name a batch-local policy; references that // explicitly target a remote provider or org are always validated. - if pr.Provider == "" && pr.OrgName == "" && slices.Contains(batchPolicyNames, pr.Name) { - return nil, nil - } - // Validate attachment - if err := uc.ValidatePolicyAttachment(ctx, pr.Provider, att, token); err != nil { - return nil, err - } + if pr.Provider == "" && pr.OrgName == "" && (slices.Contains(batchPolicyNames, pr.Name) || batchPolicies[pr.Name] != nil) { + // Use the batch spec when the caller sent it, so the same checks as a real apply + // run. Without it the policy is only known and nothing else can be validated. + policy = batchPolicies[pr.Name] + } else { + // Validate attachment + if err := uc.ValidatePolicyAttachment(ctx, pr.Provider, att, token); err != nil { + return nil, err + } - // The control plane is a non-CLI caller, so resolve the true latest revision - // and skip CLI-version compatibility gating. - remotePolicy, err := uc.GetPolicy(ctx, pr.Provider, pr.Name, pr.OrgName, "", token, policies.WithIgnoreCLICompatibility()) - if err != nil { - return nil, err + // The control plane is a non-CLI caller, so resolve the true latest revision + // and skip CLI-version compatibility gating. + remotePolicy, err := uc.GetPolicy(ctx, pr.Provider, pr.Name, pr.OrgName, "", token, policies.WithIgnoreCLICompatibility()) + if err != nil { + return nil, err + } + policy = remotePolicy.Policy } - policy = remotePolicy.Policy } if policy != nil { @@ -625,6 +629,22 @@ func (uc *WorkflowContractUseCase) findAndValidatePolicy(ctx context.Context, at return policy, nil } +// findAndValidateMaterialPolicy validates a material policy attachment and, when its +// policy can be resolved, checks that the attachment can apply to at least one material. +func (uc *WorkflowContractUseCase) findAndValidateMaterialPolicy(ctx context.Context, att *schemav1.PolicyAttachment, token string, batchPolicyNames []string, batchPolicies map[string]*schemav1.Policy, materials []*schemav1.CraftingSchema_Material) error { + policy, err := uc.findAndValidatePolicy(ctx, att, token, batchPolicyNames, batchPolicies) + if err != nil { + return err + } + + // the policy might not be available in this context (local or batch-local policy) + if policy == nil { + return nil + } + + return loader.ValidateMaterialAttachment(policy, att, materials) +} + func (uc *WorkflowContractUseCase) findAndValidatePolicyGroup(ctx context.Context, att *schemav1.PolicyGroupAttachment, token string, batchPolicyGroupNames []string) (*schemav1.PolicyGroup, error) { if !loader.IsProviderScheme(att.GetRef()) { // Otherwise, don't return an error, as it might consist of a local policy, not available in this context @@ -686,7 +706,7 @@ func (uc *WorkflowContractUseCase) validateSkipList(ctx context.Context, group * for _, groupMaterial := range policies.GetMaterials() { for _, policyAtt := range groupMaterial.GetPolicies() { // Group member policies come from an already-resolved remote group, never batch-local. - policy, err := uc.findAndValidatePolicy(ctx, policyAtt, token, nil) + policy, err := uc.findAndValidatePolicy(ctx, policyAtt, token, nil, nil) if err != nil { return fmt.Errorf("failed to get policy name during skip list validation: %w", err) } @@ -696,7 +716,7 @@ func (uc *WorkflowContractUseCase) validateSkipList(ctx context.Context, group * // Collect attestation policy names for _, policyAtt := range policies.GetAttestation() { - policy, err := uc.findAndValidatePolicy(ctx, policyAtt, token, nil) + policy, err := uc.findAndValidatePolicy(ctx, policyAtt, token, nil, nil) if err != nil { return fmt.Errorf("failed to get policy name during skip list validation: %w", err) } diff --git a/app/controlplane/pkg/biz/workflowcontract_policies_test.go b/app/controlplane/pkg/biz/workflowcontract_policies_test.go new file mode 100644 index 000000000..55c17f260 --- /dev/null +++ b/app/controlplane/pkg/biz/workflowcontract_policies_test.go @@ -0,0 +1,202 @@ +// +// Copyright 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. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package biz + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path" + "testing" + + schemav1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + cpolicies "github.com/chainloop-dev/chainloop/app/controlplane/pkg/policies" + "github.com/go-kratos/kratos/v2/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const batchKindless = "batch-kindless" + +// remote policies served by the fake policy provider, by name +var providerPolicies = map[string]string{ + "kindless": `apiVersion: chainloop.dev/v1 +kind: Policy +metadata: + name: kindless +spec: + policies: + - embedded: | + package main + import rego.v1 + violations contains msg if { false; msg := "" } +`, + "sbom-only": `apiVersion: chainloop.dev/v1 +kind: Policy +metadata: + name: sbom-only +spec: + policies: + - kind: SBOM_CYCLONEDX_JSON + embedded: | + package main + import rego.v1 + violations contains msg if { false; msg := "" } +`, +} + +func newFakePolicyProvider(t *testing.T) *cpolicies.Registry { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // the validate endpoint is optional for providers, a 404 skips it + raw, ok := providerPolicies[path.Base(r.URL.Path)] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + + _ = json.NewEncoder(w).Encode(cpolicies.ProviderResponse{ + Digest: "sha256:4a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9", + Raw: &cpolicies.RawMessage{Body: []byte(raw), Format: "FORMAT_YAML"}, + }) + })) + t.Cleanup(server.Close) + + registry, err := cpolicies.NewRegistry(log.DefaultLogger, &cpolicies.NewRegistryConfig{Name: "builtin", URL: server.URL, Default: true}) + require.NoError(t, err) + + return registry +} + +func contractWithMaterialPolicy(policyName, selector string) []byte { + return fmt.Appendf(nil, `apiVersion: chainloop.dev/v1 +kind: Contract +metadata: + name: my-contract +spec: + materials: + - name: my-image + type: CONTAINER_IMAGE + - name: sbom + type: SBOM_CYCLONEDX_JSON + policies: + materials: + - ref: %s + %s +`, policyName, selector) +} + +func TestValidateContractPoliciesMaterialApplicability(t *testing.T) { + uc := NewWorkflowContractUseCase(nil, newFakePolicyProvider(t), nil, log.DefaultLogger) + + testCases := []struct { + name string + contract []byte + wantErr string + }{ + { + name: "kind-less policy without selector", + contract: contractWithMaterialPolicy("kindless", ""), + wantErr: `policy "kindless" applies to any material type and requires a material selector (selector.name)`, + }, + { + name: "kind-less policy with selector", + contract: contractWithMaterialPolicy("kindless", "selector: {name: my-image}"), + }, + { + name: "typed policy with selector of an unsupported material", + contract: contractWithMaterialPolicy("sbom-only", "selector: {name: my-image}"), + wantErr: `policy "sbom-only" does not support material "my-image" of type CONTAINER_IMAGE (supported types: [SBOM_CYCLONEDX_JSON])`, + }, + { + name: "v1 contract with kind-less policy without selector", + contract: []byte(`schemaVersion: v1 +materials: + - name: my-image + type: CONTAINER_IMAGE +policies: + materials: + - ref: kindless +`), + wantErr: `policy "kindless" applies to any material type and requires a material selector (selector.name)`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := uc.ValidateContractPolicies(context.Background(), tc.contract, "token", nil, nil, nil) + if tc.wantErr == "" { + assert.NoError(t, err) + return + } + + require.Error(t, err) + assert.True(t, IsErrValidation(err)) + assert.ErrorContains(t, err, tc.wantErr) + }) + } +} + +func TestValidateContractPoliciesBatchLocalApplicability(t *testing.T) { + uc := NewWorkflowContractUseCase(nil, newFakePolicyProvider(t), nil, log.DefaultLogger) + + kindless := &schemav1.Policy{ + Metadata: &schemav1.Metadata{Name: batchKindless}, + Spec: &schemav1.PolicySpec{ + Policies: []*schemav1.PolicySpecV2{{Source: &schemav1.PolicySpecV2_Embedded{Embedded: "package main"}}}, + }, + } + + testCases := []struct { + name string + contract []byte + specs map[string]*schemav1.Policy + wantErr string + }{ + { + name: "batch-local kind-less policy without selector", + contract: contractWithMaterialPolicy(batchKindless, ""), + specs: map[string]*schemav1.Policy{batchKindless: kindless}, + wantErr: `policy "batch-kindless" applies to any material type and requires a material selector (selector.name)`, + }, + { + name: "batch-local kind-less policy with selector", + contract: contractWithMaterialPolicy(batchKindless, "selector: {name: my-image}"), + specs: map[string]*schemav1.Policy{batchKindless: kindless}, + }, + { + name: "batch-local policy without a spec is only known", + contract: contractWithMaterialPolicy(batchKindless, ""), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := uc.ValidateContractPolicies(context.Background(), tc.contract, "token", []string{batchKindless}, nil, tc.specs) + if tc.wantErr == "" { + assert.NoError(t, err) + return + } + + require.Error(t, err) + assert.True(t, IsErrValidation(err)) + assert.ErrorContains(t, err, tc.wantErr) + }) + } +} diff --git a/pkg/policies/attachment_validation.go b/pkg/policies/attachment_validation.go new file mode 100644 index 000000000..aadf3d4a3 --- /dev/null +++ b/pkg/policies/attachment_validation.go @@ -0,0 +1,78 @@ +// +// Copyright 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. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policies + +import ( + "errors" + "fmt" + + v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" +) + +// ErrAttachmentNeverApplies is returned when a material policy attachment can +// never be evaluated on any material, so it would silently be a no-op. +var ErrAttachmentNeverApplies = errors.New("policy attachment never applies") + +// ValidateMaterialAttachment checks that a material policy attachment can apply +// to at least one material, using the same rule as the policy evaluation: +// - a policy without material types applies only to the material named by +// the attachment selector, so the selector is required. +// - a typed policy whose selector names, by exact match, a contract material +// of a type the policy does not support never applies. +// +// materials are the materials declared in the contract. A selector that names a +// material not declared in the contract is accepted, because that material can +// be added at attestation time. +func ValidateMaterialAttachment(policy *v1.Policy, att *v1.PolicyAttachment, materials []*v1.CraftingSchema_Material) error { + policyName := policy.GetMetadata().GetName() + if policyName == "" { + policyName = att.GetRef() + } + + specTypes := getPolicyTypes(policy) + selector := att.GetSelector() + + if !canMatchAnyMaterial(specTypes, selector) { + return fmt.Errorf("%w: policy %q applies to any material type and requires a material selector (selector.name)", ErrAttachmentNeverApplies, policyName) + } + + // Only an exact selector names a single material. Other match modes, such as PREFIX, + // can match materials added at attestation time. + switch selector.GetMatchMode() { + case v1.PolicyAttachment_MaterialSelector_UNSPECIFIED, v1.PolicyAttachment_MaterialSelector_EXACT: + default: + return nil + } + + if selector.GetName() == "" { + return nil + } + + for _, m := range materials { + if !nameMatches(selector.GetName(), selector.GetMatchMode(), m.GetName()) { + continue + } + + // material names are unique in a contract + if !attachmentApplies(specTypes, selector, m.GetName(), m.GetType()) { + return fmt.Errorf("%w: policy %q does not support material %q of type %s (supported types: %v)", + ErrAttachmentNeverApplies, policyName, m.GetName(), m.GetType(), specTypes) + } + return nil + } + + return nil +} diff --git a/pkg/policies/attachment_validation_test.go b/pkg/policies/attachment_validation_test.go new file mode 100644 index 000000000..9151dd844 --- /dev/null +++ b/pkg/policies/attachment_validation_test.go @@ -0,0 +1,133 @@ +// +// Copyright 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. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policies + +import ( + "testing" + + v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + materialSBOM = "sbom" + materialImage = "image" +) + +func TestValidateMaterialAttachment(t *testing.T) { + kindless := &v1.Policy{ + Metadata: &v1.Metadata{Name: "upstream-workflow-attested"}, + Spec: &v1.PolicySpec{}, + } + kindlessV2 := &v1.Policy{ + Metadata: &v1.Metadata{Name: "kindless-v2"}, + Spec: &v1.PolicySpec{ + Policies: []*v1.PolicySpecV2{{Source: &v1.PolicySpecV2_Path{Path: "p.rego"}}}, + }, + } + sbomPolicy := &v1.Policy{ + Metadata: &v1.Metadata{Name: "sbom-policy"}, + Spec: &v1.PolicySpec{ + Policies: []*v1.PolicySpecV2{ + {Kind: v1.CraftingSchema_Material_SBOM_SPDX_JSON}, + {Kind: v1.CraftingSchema_Material_SBOM_CYCLONEDX_JSON}, + }, + }, + } + + materials := []*v1.CraftingSchema_Material{ + {Name: materialSBOM, Type: v1.CraftingSchema_Material_SBOM_CYCLONEDX_JSON}, + {Name: materialImage, Type: v1.CraftingSchema_Material_CONTAINER_IMAGE}, + } + + testCases := []struct { + name string + policy *v1.Policy + selector *v1.PolicyAttachment_MaterialSelector + wantErr bool + wantMsg string + }{ + { + name: "kind-less policy without selector", + policy: kindless, + wantErr: true, + wantMsg: `policy attachment never applies: policy "upstream-workflow-attested" applies to any material type and requires a material selector (selector.name)`, + }, + { + name: "kind-less v2 policy without selector", + policy: kindlessV2, + wantErr: true, + }, + { + name: "kind-less policy with selector", + policy: kindless, + selector: &v1.PolicyAttachment_MaterialSelector{Name: materialImage}, + }, + { + name: "kind-less policy with selector of a material not in the contract", + policy: kindless, + selector: &v1.PolicyAttachment_MaterialSelector{Name: "added-at-runtime"}, + }, + { + name: "typed policy without selector", + policy: sbomPolicy, + }, + { + name: "typed policy with selector of a supported material", + policy: sbomPolicy, + selector: &v1.PolicyAttachment_MaterialSelector{Name: materialSBOM}, + }, + { + name: "typed policy with selector of an unsupported contract material", + policy: sbomPolicy, + selector: &v1.PolicyAttachment_MaterialSelector{Name: materialImage}, + wantErr: true, + wantMsg: `policy attachment never applies: policy "sbom-policy" does not support material "image" of type CONTAINER_IMAGE (supported types: [SBOM_SPDX_JSON SBOM_CYCLONEDX_JSON])`, + }, + { + name: "typed policy with prefix selector is not checked against contract materials", + policy: sbomPolicy, + selector: &v1.PolicyAttachment_MaterialSelector{Name: materialImage, MatchMode: v1.PolicyAttachment_MaterialSelector_PREFIX}, + }, + { + name: "typed policy with selector of a material not in the contract", + policy: sbomPolicy, + selector: &v1.PolicyAttachment_MaterialSelector{Name: "added-at-runtime"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + att := &v1.PolicyAttachment{ + Policy: &v1.PolicyAttachment_Ref{Ref: "chainloop://some-policy"}, + Selector: tc.selector, + } + + err := ValidateMaterialAttachment(tc.policy, att, materials) + if !tc.wantErr { + assert.NoError(t, err) + return + } + + require.Error(t, err) + assert.ErrorIs(t, err, ErrAttachmentNeverApplies) + if tc.wantMsg != "" { + assert.EqualError(t, err, tc.wantMsg) + } + }) + } +} diff --git a/pkg/policies/policies.go b/pkg/policies/policies.go index f4aff7795..8b2e3fc79 100644 --- a/pkg/policies/policies.go +++ b/pkg/policies/policies.go @@ -1015,27 +1015,30 @@ func (pv *PolicyVerifier) shouldApplyPolicy(ctx context.Context, policyAtt *v1.P return false, fmt.Errorf("failed to load policy attachment %q: %w", policyAtt.GetRef(), err) } - materialType := material.GetMaterialType() - filteredName := policyAtt.GetSelector().GetName() - specTypes := getPolicyTypes(spec) + return attachmentApplies(getPolicyTypes(spec), policyAtt.GetSelector(), material.GetId(), material.GetMaterialType()), nil +} +// attachmentApplies is the pure applicability rule of shouldApplyPolicy, shared with +// ValidateMaterialAttachment so that contract validation and evaluation can't drift. +func attachmentApplies(specTypes []v1.CraftingSchema_Material_MaterialType, selector *v1.PolicyAttachment_MaterialSelector, materialName string, materialType v1.CraftingSchema_Material_MaterialType) bool { // if spec has a type, and it's different to the material type, skip if len(specTypes) > 0 && !slices.Contains(specTypes, materialType) { - // types don't match, continue - return false, nil + return false } - if filteredName != "" && !nameMatches(filteredName, policyAtt.GetSelector().GetMatchMode(), material.GetId()) { + filteredName := selector.GetName() + if filteredName != "" && !nameMatches(filteredName, selector.GetMatchMode(), materialName) { // a filter exists and doesn't match - return false, nil + return false } - // no type nor name to match, we can't guess anything - if len(specTypes) == 0 && filteredName == "" { - return false, nil - } + return canMatchAnyMaterial(specTypes, selector) +} - return true, nil +// canMatchAnyMaterial reports whether an attachment has something to match materials on. +// A policy without types and an attachment without a selector name can't match any material. +func canMatchAnyMaterial(specTypes []v1.CraftingSchema_Material_MaterialType, selector *v1.PolicyAttachment_MaterialSelector) bool { + return len(specTypes) > 0 || selector.GetName() != "" } // nameMatches reports whether a material name satisfies a selector/group-material