diff --git a/app/cli/cmd/organization_describe.go b/app/cli/cmd/organization_describe.go index 6291cffcf..4153e5ee1 100644 --- a/app/cli/cmd/organization_describe.go +++ b/app/cli/cmd/organization_describe.go @@ -51,7 +51,7 @@ func contextTableOutput(config *action.ConfigContextItem) error { gt.AppendSeparator() if m := config.CurrentMembership; m != nil { - orgInfo := fmt.Sprintf("%s (role=%s)\nPolicy strategy=%s", m.Org.Name, m.Role, m.Org.PolicyViolationBlockingStrategy) + orgInfo := fmt.Sprintf("%s (role=%s)\nPolicy strategy: %s", m.Org.Name, m.Role, m.Org.PolicyViolationBlockingStrategy) if len(m.Org.PolicyAllowedHostnames) > 0 { orgInfo += fmt.Sprintf("\nPolicy allowed hostnames: %v", strings.Join(m.Org.PolicyAllowedHostnames, ", ")) } @@ -60,6 +60,10 @@ func contextTableOutput(config *action.ConfigContextItem) error { orgInfo += fmt.Sprintf("\nAPI token auto-revoke after: %s days inactive", *m.Org.APITokenMaxDaysInactive) } + if m.Org.BlockAttestationsOnReleasedVersions { + orgInfo += "\nBlock attestations on released versions: enabled" + } + gt.AppendRow(table.Row{"Organization", orgInfo}) } diff --git a/app/cli/cmd/organization_update.go b/app/cli/cmd/organization_update.go index 9c916fbae..6b128e757 100644 --- a/app/cli/cmd/organization_update.go +++ b/app/cli/cmd/organization_update.go @@ -25,13 +25,14 @@ import ( func newOrganizationUpdateCmd() *cobra.Command { var ( - orgName string - blockOnPolicyViolation bool - policiesAllowedHostnames []string - preventImplicitWorkflowCreation bool - restrictContractCreation bool - apiTokenMaxDaysInactive string - enableAIAgentCollector bool + orgName string + blockOnPolicyViolation bool + policiesAllowedHostnames []string + preventImplicitWorkflowCreation bool + restrictContractCreation bool + apiTokenMaxDaysInactive string + enableAIAgentCollector bool + blockAttestationsOnReleasedVersions bool ) cmd := &cobra.Command{ @@ -59,6 +60,10 @@ func newOrganizationUpdateCmd() *cobra.Command { opts.EnableAIAgentCollector = &enableAIAgentCollector } + if cmd.Flags().Changed("block-attestations-on-released-versions") { + opts.BlockAttestationsOnReleasedVersions = &blockAttestationsOnReleasedVersions + } + if cmd.Flags().Changed("api-token-max-days-inactive") { days, err := strconv.Atoi(apiTokenMaxDaysInactive) if err != nil { @@ -90,5 +95,6 @@ func newOrganizationUpdateCmd() *cobra.Command { cmd.Flags().BoolVar(&restrictContractCreation, "restrict-contract-creation", false, "restrict contract creation (org-level and project-level) to only organization admins (owner/admin roles)") cmd.Flags().StringVar(&apiTokenMaxDaysInactive, "api-token-max-days-inactive", "", "maximum days of inactivity before API tokens are auto-revoked (e.g. '90', '0' to disable)") cmd.Flags().BoolVar(&enableAIAgentCollector, "enable-ai-agent-collector", false, "enable automatic AI agent config collection during attestation init") + cmd.Flags().BoolVar(&blockAttestationsOnReleasedVersions, "block-attestations-on-released-versions", false, "reject new attestations pushed to project versions that are already released") return cmd } diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 729ee6da9..95df8abfe 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -2908,14 +2908,15 @@ chainloop organization update [flags] Options ``` ---api-token-max-days-inactive string maximum days of inactivity before API tokens are auto-revoked (e.g. '90', '0' to disable) ---block set the default policy violation blocking strategy ---enable-ai-agent-collector enable automatic AI agent config collection during attestation init --h, --help help for update ---name string organization name ---policies-allowed-hostnames strings set the allowed hostnames for the policy engine ---prevent-implicit-workflow-creation prevent workflows and projects from being created implicitly during attestation init ---restrict-contract-creation restrict contract creation (org-level and project-level) to only organization admins (owner/admin roles) +--api-token-max-days-inactive string maximum days of inactivity before API tokens are auto-revoked (e.g. '90', '0' to disable) +--block set the default policy violation blocking strategy +--block-attestations-on-released-versions reject new attestations pushed to project versions that are already released +--enable-ai-agent-collector enable automatic AI agent config collection during attestation init +-h, --help help for update +--name string organization name +--policies-allowed-hostnames strings set the allowed hostnames for the policy engine +--prevent-implicit-workflow-creation prevent workflows and projects from being created implicitly during attestation init +--restrict-contract-creation restrict contract creation (org-level and project-level) to only organization admins (owner/admin roles) ``` Options inherited from parent commands diff --git a/app/cli/main.go b/app/cli/main.go index 38e357091..42d56ec01 100644 --- a/app/cli/main.go +++ b/app/cli/main.go @@ -67,6 +67,7 @@ func errorInfo(err error, logger zerolog.Logger) (string, int) { if errors.As(err, &gs) { knownCodes := []codes.Code{ codes.AlreadyExists, codes.InvalidArgument, codes.NotFound, codes.PermissionDenied, + codes.FailedPrecondition, } grpcStatus := gs.GRPCStatus() diff --git a/app/cli/pkg/action/membership_list.go b/app/cli/pkg/action/membership_list.go index 1d27964c6..07c737668 100644 --- a/app/cli/pkg/action/membership_list.go +++ b/app/cli/pkg/action/membership_list.go @@ -29,14 +29,15 @@ type MembershipList struct { } type OrgItem struct { - ID string `json:"id"` - Name string `json:"name"` - CreatedAt *time.Time `json:"createdAt"` - PolicyViolationBlockingStrategy string `json:"policyViolationBlockingStrategy"` - PolicyAllowedHostnames []string `json:"policyAllowedHostnames,omitempty"` - PreventImplicitWorkflowCreation bool `json:"preventImplicitWorkflowCreation"` - APITokenMaxDaysInactive *string `json:"apiTokenMaxDaysInactive,omitempty"` - EnableAIAgentCollector bool `json:"enableAiAgentCollector"` + ID string `json:"id"` + Name string `json:"name"` + CreatedAt *time.Time `json:"createdAt"` + PolicyViolationBlockingStrategy string `json:"policyViolationBlockingStrategy"` + PolicyAllowedHostnames []string `json:"policyAllowedHostnames,omitempty"` + PreventImplicitWorkflowCreation bool `json:"preventImplicitWorkflowCreation"` + APITokenMaxDaysInactive *string `json:"apiTokenMaxDaysInactive,omitempty"` + EnableAIAgentCollector bool `json:"enableAiAgentCollector"` + BlockAttestationsOnReleasedVersions bool `json:"blockAttestationsOnReleasedVersions"` } type MembershipItem struct { @@ -134,12 +135,13 @@ func (action *MembershipList) ListMembers(ctx context.Context, page int, pageSiz func pbOrgItemToAction(in *pb.OrgItem) *OrgItem { i := &OrgItem{ - ID: in.Id, - Name: in.Name, - CreatedAt: toTimePtr(in.CreatedAt.AsTime()), - PolicyAllowedHostnames: in.PolicyAllowedHostnames, - PreventImplicitWorkflowCreation: in.PreventImplicitWorkflowCreation, - EnableAIAgentCollector: in.EnableAiAgentCollector, + ID: in.Id, + Name: in.Name, + CreatedAt: toTimePtr(in.CreatedAt.AsTime()), + PolicyAllowedHostnames: in.PolicyAllowedHostnames, + PreventImplicitWorkflowCreation: in.PreventImplicitWorkflowCreation, + EnableAIAgentCollector: in.EnableAiAgentCollector, + BlockAttestationsOnReleasedVersions: in.BlockAttestationsOnReleasedVersions, } if in.DefaultPolicyViolationStrategy == pb.OrgItem_POLICY_VIOLATION_BLOCKING_STRATEGY_BLOCK { diff --git a/app/cli/pkg/action/org_update.go b/app/cli/pkg/action/org_update.go index 8ec2f15bf..02c60518b 100644 --- a/app/cli/pkg/action/org_update.go +++ b/app/cli/pkg/action/org_update.go @@ -40,6 +40,8 @@ type NewOrgUpdateOpts struct { APITokenMaxDaysInactive *int // EnableAIAgentCollector enables automatic AI agent config collection during attestation init EnableAIAgentCollector *bool + // BlockAttestationsOnReleasedVersions rejects new attestations pushed to project versions that are already released + BlockAttestationsOnReleasedVersions *bool } func (action *OrgUpdate) Run(ctx context.Context, name string, opts *NewOrgUpdateOpts) (*OrgItem, error) { @@ -51,6 +53,7 @@ func (action *OrgUpdate) Run(ctx context.Context, name string, opts *NewOrgUpdat PreventImplicitWorkflowCreation: opts.PreventImplicitWorkflowCreation, RestrictContractCreationToOrgAdmins: opts.RestrictContractCreation, EnableAiAgentCollector: opts.EnableAIAgentCollector, + BlockAttestationsOnReleasedVersions: opts.BlockAttestationsOnReleasedVersions, } if opts.PoliciesAllowedHostnames != nil { diff --git a/app/controlplane/api/controlplane/v1/organization.pb.go b/app/controlplane/api/controlplane/v1/organization.pb.go index 7dcb83ffd..c79b11f81 100644 --- a/app/controlplane/api/controlplane/v1/organization.pb.go +++ b/app/controlplane/api/controlplane/v1/organization.pb.go @@ -452,8 +452,10 @@ type OrganizationServiceUpdateRequest struct { ApiTokenMaxDaysInactive *int32 `protobuf:"varint,7,opt,name=api_token_max_days_inactive,json=apiTokenMaxDaysInactive,proto3,oneof" json:"api_token_max_days_inactive,omitempty"` // Enable automatic AI agent config collection during attestation init EnableAiAgentCollector *bool `protobuf:"varint,8,opt,name=enable_ai_agent_collector,json=enableAiAgentCollector,proto3,oneof" json:"enable_ai_agent_collector,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Reject new attestations pushed to project versions that are already released (prerelease == false) + BlockAttestationsOnReleasedVersions *bool `protobuf:"varint,9,opt,name=block_attestations_on_released_versions,json=blockAttestationsOnReleasedVersions,proto3,oneof" json:"block_attestations_on_released_versions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *OrganizationServiceUpdateRequest) Reset() { @@ -542,6 +544,13 @@ func (x *OrganizationServiceUpdateRequest) GetEnableAiAgentCollector() bool { return false } +func (x *OrganizationServiceUpdateRequest) GetBlockAttestationsOnReleasedVersions() bool { + if x != nil && x.BlockAttestationsOnReleasedVersions != nil { + return *x.BlockAttestationsOnReleasedVersions + } + return false +} + type OrganizationServiceUpdateResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Result *OrgItem `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` @@ -700,7 +709,7 @@ const file_controlplane_v1_organization_proto_rawDesc = "" + " OrganizationServiceCreateRequest\x12\x1b\n" + "\x04name\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x04name\"U\n" + "!OrganizationServiceCreateResponse\x120\n" + - "\x06result\x18\x01 \x01(\v2\x18.controlplane.v1.OrgItemR\x06result\"\xe9\x05\n" + + "\x06result\x18\x01 \x01(\v2\x18.controlplane.v1.OrgItemR\x06result\"\xf0\x06\n" + " OrganizationServiceUpdateRequest\x12\x1b\n" + "\x04name\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x04name\x12>\n" + "\x19block_on_policy_violation\x18\x02 \x01(\bH\x00R\x16blockOnPolicyViolation\x88\x01\x01\x12<\n" + @@ -709,12 +718,14 @@ const file_controlplane_v1_organization_proto_rawDesc = "" + "\"prevent_implicit_workflow_creation\x18\x05 \x01(\bH\x01R\x1fpreventImplicitWorkflowCreation\x88\x01\x01\x12Z\n" + "(restrict_contract_creation_to_org_admins\x18\x06 \x01(\bH\x02R#restrictContractCreationToOrgAdmins\x88\x01\x01\x12A\n" + "\x1bapi_token_max_days_inactive\x18\a \x01(\x05H\x03R\x17apiTokenMaxDaysInactive\x88\x01\x01\x12>\n" + - "\x19enable_ai_agent_collector\x18\b \x01(\bH\x04R\x16enableAiAgentCollector\x88\x01\x01B\x1c\n" + + "\x19enable_ai_agent_collector\x18\b \x01(\bH\x04R\x16enableAiAgentCollector\x88\x01\x01\x12Y\n" + + "'block_attestations_on_released_versions\x18\t \x01(\bH\x05R#blockAttestationsOnReleasedVersions\x88\x01\x01B\x1c\n" + "\x1a_block_on_policy_violationB%\n" + "#_prevent_implicit_workflow_creationB+\n" + ")_restrict_contract_creation_to_org_adminsB\x1e\n" + "\x1c_api_token_max_days_inactiveB\x1c\n" + - "\x1a_enable_ai_agent_collector\"U\n" + + "\x1a_enable_ai_agent_collectorB*\n" + + "(_block_attestations_on_released_versions\"U\n" + "!OrganizationServiceUpdateResponse\x120\n" + "\x06result\x18\x01 \x01(\v2\x18.controlplane.v1.OrgItemR\x06result\"?\n" + " OrganizationServiceDeleteRequest\x12\x1b\n" + diff --git a/app/controlplane/api/controlplane/v1/organization.proto b/app/controlplane/api/controlplane/v1/organization.proto index 24bbee8ea..d57b5bf5d 100644 --- a/app/controlplane/api/controlplane/v1/organization.proto +++ b/app/controlplane/api/controlplane/v1/organization.proto @@ -105,6 +105,9 @@ message OrganizationServiceUpdateRequest { // Enable automatic AI agent config collection during attestation init optional bool enable_ai_agent_collector = 8; + + // Reject new attestations pushed to project versions that are already released (prerelease == false) + optional bool block_attestations_on_released_versions = 9; } message OrganizationServiceUpdateResponse { diff --git a/app/controlplane/api/controlplane/v1/response_messages.pb.go b/app/controlplane/api/controlplane/v1/response_messages.pb.go index bb31e3d26..deb101414 100644 --- a/app/controlplane/api/controlplane/v1/response_messages.pb.go +++ b/app/controlplane/api/controlplane/v1/response_messages.pb.go @@ -2303,8 +2303,10 @@ type OrgItem struct { ApiTokenMaxDaysInactive *int32 `protobuf:"varint,9,opt,name=api_token_max_days_inactive,json=apiTokenMaxDaysInactive,proto3,oneof" json:"api_token_max_days_inactive,omitempty"` // Whether AI agent config collection is automatically enabled during attestation init EnableAiAgentCollector bool `protobuf:"varint,10,opt,name=enable_ai_agent_collector,json=enableAiAgentCollector,proto3" json:"enable_ai_agent_collector,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Whether new attestations are rejected on project versions that are already released (prerelease == false) + BlockAttestationsOnReleasedVersions bool `protobuf:"varint,11,opt,name=block_attestations_on_released_versions,json=blockAttestationsOnReleasedVersions,proto3" json:"block_attestations_on_released_versions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *OrgItem) Reset() { @@ -2407,6 +2409,13 @@ func (x *OrgItem) GetEnableAiAgentCollector() bool { return false } +func (x *OrgItem) GetBlockAttestationsOnReleasedVersions() bool { + if x != nil { + return x.BlockAttestationsOnReleasedVersions + } + return false +} + type CASBackendItem struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` @@ -3292,7 +3301,7 @@ const file_controlplane_v1_response_messages_proto_rawDesc = "" + "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" + "\n" + "updated_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x123\n" + - "\x04role\x18\x06 \x01(\x0e2\x1f.controlplane.v1.MembershipRoleR\x04role\"\xdc\x06\n" + + "\x04role\x18\x06 \x01(\x0e2\x1f.controlplane.v1.MembershipRoleR\x04role\"\xb2\a\n" + "\aOrgItem\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x129\n" + @@ -3306,7 +3315,8 @@ const file_controlplane_v1_response_messages_proto_rawDesc = "" + "(restrict_contract_creation_to_org_admins\x18\b \x01(\bR#restrictContractCreationToOrgAdmins\x12A\n" + "\x1bapi_token_max_days_inactive\x18\t \x01(\x05H\x00R\x17apiTokenMaxDaysInactive\x88\x01\x01\x129\n" + "\x19enable_ai_agent_collector\x18\n" + - " \x01(\bR\x16enableAiAgentCollector\"\xb4\x01\n" + + " \x01(\bR\x16enableAiAgentCollector\x12T\n" + + "'block_attestations_on_released_versions\x18\v \x01(\bR#blockAttestationsOnReleasedVersions\"\xb4\x01\n" + "\x1fPolicyViolationBlockingStrategy\x122\n" + ".POLICY_VIOLATION_BLOCKING_STRATEGY_UNSPECIFIED\x10\x00\x12,\n" + "(POLICY_VIOLATION_BLOCKING_STRATEGY_BLOCK\x10\x01\x12/\n" + diff --git a/app/controlplane/api/controlplane/v1/response_messages.proto b/app/controlplane/api/controlplane/v1/response_messages.proto index c1450d7f1..5dff8d273 100644 --- a/app/controlplane/api/controlplane/v1/response_messages.proto +++ b/app/controlplane/api/controlplane/v1/response_messages.proto @@ -391,6 +391,8 @@ message OrgItem { optional int32 api_token_max_days_inactive = 9; // Whether AI agent config collection is automatically enabled during attestation init bool enable_ai_agent_collector = 10; + // Whether new attestations are rejected on project versions that are already released (prerelease == false) + bool block_attestations_on_released_versions = 11; enum PolicyViolationBlockingStrategy { POLICY_VIOLATION_BLOCKING_STRATEGY_UNSPECIFIED = 0; diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/organization.ts b/app/controlplane/api/gen/frontend/controlplane/v1/organization.ts index f08e7f155..f74d09e06 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/organization.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/organization.ts @@ -89,7 +89,11 @@ export interface OrganizationServiceUpdateRequest { | number | undefined; /** Enable automatic AI agent config collection during attestation init */ - enableAiAgentCollector?: boolean | undefined; + enableAiAgentCollector?: + | boolean + | undefined; + /** Reject new attestations pushed to project versions that are already released (prerelease == false) */ + blockAttestationsOnReleasedVersions?: boolean | undefined; } export interface OrganizationServiceUpdateResponse { @@ -681,6 +685,7 @@ function createBaseOrganizationServiceUpdateRequest(): OrganizationServiceUpdate restrictContractCreationToOrgAdmins: undefined, apiTokenMaxDaysInactive: undefined, enableAiAgentCollector: undefined, + blockAttestationsOnReleasedVersions: undefined, }; } @@ -710,6 +715,9 @@ export const OrganizationServiceUpdateRequest = { if (message.enableAiAgentCollector !== undefined) { writer.uint32(64).bool(message.enableAiAgentCollector); } + if (message.blockAttestationsOnReleasedVersions !== undefined) { + writer.uint32(72).bool(message.blockAttestationsOnReleasedVersions); + } return writer; }, @@ -776,6 +784,13 @@ export const OrganizationServiceUpdateRequest = { message.enableAiAgentCollector = reader.bool(); continue; + case 9: + if (tag !== 72) { + break; + } + + message.blockAttestationsOnReleasedVersions = reader.bool(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -805,6 +820,9 @@ export const OrganizationServiceUpdateRequest = { ? Number(object.apiTokenMaxDaysInactive) : undefined, enableAiAgentCollector: isSet(object.enableAiAgentCollector) ? Boolean(object.enableAiAgentCollector) : undefined, + blockAttestationsOnReleasedVersions: isSet(object.blockAttestationsOnReleasedVersions) + ? Boolean(object.blockAttestationsOnReleasedVersions) + : undefined, }; }, @@ -826,6 +844,8 @@ export const OrganizationServiceUpdateRequest = { message.apiTokenMaxDaysInactive !== undefined && (obj.apiTokenMaxDaysInactive = Math.round(message.apiTokenMaxDaysInactive)); message.enableAiAgentCollector !== undefined && (obj.enableAiAgentCollector = message.enableAiAgentCollector); + message.blockAttestationsOnReleasedVersions !== undefined && + (obj.blockAttestationsOnReleasedVersions = message.blockAttestationsOnReleasedVersions); return obj; }, @@ -847,6 +867,7 @@ export const OrganizationServiceUpdateRequest = { message.restrictContractCreationToOrgAdmins = object.restrictContractCreationToOrgAdmins ?? undefined; message.apiTokenMaxDaysInactive = object.apiTokenMaxDaysInactive ?? undefined; message.enableAiAgentCollector = object.enableAiAgentCollector ?? undefined; + message.blockAttestationsOnReleasedVersions = object.blockAttestationsOnReleasedVersions ?? undefined; return message; }, }; diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts b/app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts index 0ff6e3514..58d3d7523 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/response_messages.ts @@ -894,6 +894,8 @@ export interface OrgItem { | undefined; /** Whether AI agent config collection is automatically enabled during attestation init */ enableAiAgentCollector: boolean; + /** Whether new attestations are rejected on project versions that are already released (prerelease == false) */ + blockAttestationsOnReleasedVersions: boolean; } export enum OrgItem_PolicyViolationBlockingStrategy { @@ -4403,6 +4405,7 @@ function createBaseOrgItem(): OrgItem { restrictContractCreationToOrgAdmins: false, apiTokenMaxDaysInactive: undefined, enableAiAgentCollector: false, + blockAttestationsOnReleasedVersions: false, }; } @@ -4438,6 +4441,9 @@ export const OrgItem = { if (message.enableAiAgentCollector === true) { writer.uint32(80).bool(message.enableAiAgentCollector); } + if (message.blockAttestationsOnReleasedVersions === true) { + writer.uint32(88).bool(message.blockAttestationsOnReleasedVersions); + } return writer; }, @@ -4518,6 +4524,13 @@ export const OrgItem = { message.enableAiAgentCollector = reader.bool(); continue; + case 11: + if (tag !== 88) { + break; + } + + message.blockAttestationsOnReleasedVersions = reader.bool(); + continue; } if ((tag & 7) === 4 || tag === 0) { break; @@ -4549,6 +4562,9 @@ export const OrgItem = { ? Number(object.apiTokenMaxDaysInactive) : undefined, enableAiAgentCollector: isSet(object.enableAiAgentCollector) ? Boolean(object.enableAiAgentCollector) : false, + blockAttestationsOnReleasedVersions: isSet(object.blockAttestationsOnReleasedVersions) + ? Boolean(object.blockAttestationsOnReleasedVersions) + : false, }; }, @@ -4574,6 +4590,8 @@ export const OrgItem = { message.apiTokenMaxDaysInactive !== undefined && (obj.apiTokenMaxDaysInactive = Math.round(message.apiTokenMaxDaysInactive)); message.enableAiAgentCollector !== undefined && (obj.enableAiAgentCollector = message.enableAiAgentCollector); + message.blockAttestationsOnReleasedVersions !== undefined && + (obj.blockAttestationsOnReleasedVersions = message.blockAttestationsOnReleasedVersions); return obj; }, @@ -4593,6 +4611,7 @@ export const OrgItem = { message.restrictContractCreationToOrgAdmins = object.restrictContractCreationToOrgAdmins ?? false; message.apiTokenMaxDaysInactive = object.apiTokenMaxDaysInactive ?? undefined; message.enableAiAgentCollector = object.enableAiAgentCollector ?? false; + message.blockAttestationsOnReleasedVersions = object.blockAttestationsOnReleasedVersions ?? false; return message; }, }; diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.OrgItem.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrgItem.jsonschema.json index 13d7cc327..ffe2d4ae6 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.OrgItem.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrgItem.jsonschema.json @@ -9,6 +9,10 @@ "minimum": -2147483648, "type": "integer" }, + "^(block_attestations_on_released_versions)$": { + "description": "Whether new attestations are rejected on project versions that are already released (prerelease == false)", + "type": "boolean" + }, "^(created_at)$": { "$ref": "google.protobuf.Timestamp.jsonschema.json" }, @@ -59,6 +63,10 @@ "minimum": -2147483648, "type": "integer" }, + "blockAttestationsOnReleasedVersions": { + "description": "Whether new attestations are rejected on project versions that are already released (prerelease == false)", + "type": "boolean" + }, "createdAt": { "$ref": "google.protobuf.Timestamp.jsonschema.json" }, diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.OrgItem.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrgItem.schema.json index 49ae180b9..0186ccd4f 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.OrgItem.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrgItem.schema.json @@ -9,6 +9,10 @@ "minimum": -2147483648, "type": "integer" }, + "^(blockAttestationsOnReleasedVersions)$": { + "description": "Whether new attestations are rejected on project versions that are already released (prerelease == false)", + "type": "boolean" + }, "^(createdAt)$": { "$ref": "google.protobuf.Timestamp.schema.json" }, @@ -59,6 +63,10 @@ "minimum": -2147483648, "type": "integer" }, + "block_attestations_on_released_versions": { + "description": "Whether new attestations are rejected on project versions that are already released (prerelease == false)", + "type": "boolean" + }, "created_at": { "$ref": "google.protobuf.Timestamp.schema.json" }, diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceUpdateRequest.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceUpdateRequest.jsonschema.json index 836ead451..523b1b814 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceUpdateRequest.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceUpdateRequest.jsonschema.json @@ -9,6 +9,10 @@ "minimum": -2147483648, "type": "integer" }, + "^(block_attestations_on_released_versions)$": { + "description": "Reject new attestations pushed to project versions that are already released (prerelease == false)", + "type": "boolean" + }, "^(block_on_policy_violation)$": { "description": "\"optional\" allow us to detect if the value is explicitly set", "type": "boolean" @@ -44,6 +48,10 @@ "minimum": -2147483648, "type": "integer" }, + "blockAttestationsOnReleasedVersions": { + "description": "Reject new attestations pushed to project versions that are already released (prerelease == false)", + "type": "boolean" + }, "blockOnPolicyViolation": { "description": "\"optional\" allow us to detect if the value is explicitly set", "type": "boolean" diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceUpdateRequest.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceUpdateRequest.schema.json index f7e8e9f4f..0fb092c93 100644 --- a/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceUpdateRequest.schema.json +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceUpdateRequest.schema.json @@ -9,6 +9,10 @@ "minimum": -2147483648, "type": "integer" }, + "^(blockAttestationsOnReleasedVersions)$": { + "description": "Reject new attestations pushed to project versions that are already released (prerelease == false)", + "type": "boolean" + }, "^(blockOnPolicyViolation)$": { "description": "\"optional\" allow us to detect if the value is explicitly set", "type": "boolean" @@ -44,6 +48,10 @@ "minimum": -2147483648, "type": "integer" }, + "block_attestations_on_released_versions": { + "description": "Reject new attestations pushed to project versions that are already released (prerelease == false)", + "type": "boolean" + }, "block_on_policy_violation": { "description": "\"optional\" allow us to detect if the value is explicitly set", "type": "boolean" diff --git a/app/controlplane/cmd/wire_gen.go b/app/controlplane/cmd/wire_gen.go index 9ebb39ec8..cf35e330c 100644 --- a/app/controlplane/cmd/wire_gen.go +++ b/app/controlplane/cmd/wire_gen.go @@ -220,6 +220,7 @@ func wireApp(contextContext context.Context, bootstrap *conf.Bootstrap, readerWr workflowRunUseCaseOpts := &biz.WorkflowRunUseCaseOpts{ WfrRepo: workflowRunRepo, WfRepo: workflowRepo, + OrgRepo: organizationRepo, SigningUC: signingUseCase, AuditorUC: auditorUseCase, Logger: logger, diff --git a/app/controlplane/internal/service/attestation.go b/app/controlplane/internal/service/attestation.go index afef06789..e76a5b10e 100644 --- a/app/controlplane/internal/service/attestation.go +++ b/app/controlplane/internal/service/attestation.go @@ -206,6 +206,7 @@ func (s *AttestationService) Init(ctx context.Context, req *cpAPI.AttestationSer UseLatestVersion: req.GetUseLatestVersion(), RequireExistingVersion: req.GetRequireExistingVersion(), MarkAsLatest: req.MarkAsLatest, + BlockReleasedVersions: org.BlockAttestationsOnReleasedVersions, } run, err := s.wrUseCase.Create(ctx, opts) diff --git a/app/controlplane/internal/service/context.go b/app/controlplane/internal/service/context.go index b24c91b8c..e98eed465 100644 --- a/app/controlplane/internal/service/context.go +++ b/app/controlplane/internal/service/context.go @@ -126,6 +126,7 @@ func bizOrgToPb(m *biz.Organization) *pb.OrgItem { PreventImplicitWorkflowCreation: m.PreventImplicitWorkflowCreation, RestrictContractCreationToOrgAdmins: m.RestrictContractCreationToOrgAdmins, EnableAiAgentCollector: m.EnableAIAgentCollector, + BlockAttestationsOnReleasedVersions: m.BlockAttestationsOnReleasedVersions, } if m.APITokenInactivityThresholdDays != nil { diff --git a/app/controlplane/internal/service/organization.go b/app/controlplane/internal/service/organization.go index 84020eeff..a426143f8 100644 --- a/app/controlplane/internal/service/organization.go +++ b/app/controlplane/internal/service/organization.go @@ -109,6 +109,7 @@ func (s *OrganizationService) Update(ctx context.Context, req *pb.OrganizationSe RestrictContractCreationToOrgAdmins: req.RestrictContractCreationToOrgAdmins, APITokenInactivityThresholdDays: apiTokenMaxDaysInactive, EnableAIAgentCollector: req.EnableAiAgentCollector, + BlockAttestationsOnReleasedVersions: req.BlockAttestationsOnReleasedVersions, }) if err != nil { return nil, handleUseCaseErr(err, s.log) diff --git a/app/controlplane/internal/service/service.go b/app/controlplane/internal/service/service.go index a035414e2..5f1ffd1ad 100644 --- a/app/controlplane/internal/service/service.go +++ b/app/controlplane/internal/service/service.go @@ -1,5 +1,5 @@ // -// Copyright 2023-2025 The Chainloop Authors. +// Copyright 2023-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. @@ -419,6 +419,8 @@ func handleUseCaseErr(err error, l *log.Helper) error { return status.Error(codes.Unimplemented, err.Error()) case biz.IsErrAlreadyExists(err): return status.Error(codes.AlreadyExists, err.Error()) + case biz.IsErrReleasedVersionImmutable(err): + return status.Error(codes.FailedPrecondition, err.Error()) default: return servicelogger.LogAndMaskErr(err, l) } diff --git a/app/controlplane/pkg/biz/errors.go b/app/controlplane/pkg/biz/errors.go index c3b7c00a7..9216ce8ae 100644 --- a/app/controlplane/pkg/biz/errors.go +++ b/app/controlplane/pkg/biz/errors.go @@ -1,5 +1,5 @@ // -// Copyright 2024 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. @@ -161,6 +161,26 @@ func IsErrAttestationStateConflict(err error) bool { return errors.As(err, &e) } +// ErrReleasedVersionImmutable is returned when the organization blocks new +// attestations on already-released (immutable) project versions and a push +// targets such a version. +type ErrReleasedVersionImmutable struct { + version string +} + +func NewErrReleasedVersionImmutable(version string) error { + return &ErrReleasedVersionImmutable{version} +} + +func (e *ErrReleasedVersionImmutable) Error() string { + return fmt.Sprintf("version %q is released and immutable: attestations cannot be added", e.version) +} + +func IsErrReleasedVersionImmutable(err error) bool { + var e *ErrReleasedVersionImmutable + return errors.As(err, &e) +} + type ErrAlreadyExists struct { err error } diff --git a/app/controlplane/pkg/biz/mocks/WorkflowRunRepo.go b/app/controlplane/pkg/biz/mocks/WorkflowRunRepo.go index 0c41eb7f5..c7b4a51cb 100644 --- a/app/controlplane/pkg/biz/mocks/WorkflowRunRepo.go +++ b/app/controlplane/pkg/biz/mocks/WorkflowRunRepo.go @@ -675,16 +675,16 @@ func (_c *WorkflowRunRepo_MarkAsFinished_Call) RunAndReturn(run func(ctx context } // SaveAttestationBundle provides a mock function for the type WorkflowRunRepo -func (_mock *WorkflowRunRepo) SaveAttestationBundle(ctx context.Context, ID uuid.UUID, digest string, bundle []byte) error { - ret := _mock.Called(ctx, ID, digest, bundle) +func (_mock *WorkflowRunRepo) SaveAttestationBundle(ctx context.Context, ID uuid.UUID, digest string, bundle []byte, blockReleasedVersions bool) error { + ret := _mock.Called(ctx, ID, digest, bundle, blockReleasedVersions) if len(ret) == 0 { panic("no return value specified for SaveAttestationBundle") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID, string, []byte) error); ok { - r0 = returnFunc(ctx, ID, digest, bundle) + if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID, string, []byte, bool) error); ok { + r0 = returnFunc(ctx, ID, digest, bundle, blockReleasedVersions) } else { r0 = ret.Error(0) } @@ -701,11 +701,12 @@ type WorkflowRunRepo_SaveAttestationBundle_Call struct { // - ID uuid.UUID // - digest string // - bundle []byte -func (_e *WorkflowRunRepo_Expecter) SaveAttestationBundle(ctx interface{}, ID interface{}, digest interface{}, bundle interface{}) *WorkflowRunRepo_SaveAttestationBundle_Call { - return &WorkflowRunRepo_SaveAttestationBundle_Call{Call: _e.mock.On("SaveAttestationBundle", ctx, ID, digest, bundle)} +// - blockReleasedVersions bool +func (_e *WorkflowRunRepo_Expecter) SaveAttestationBundle(ctx interface{}, ID interface{}, digest interface{}, bundle interface{}, blockReleasedVersions interface{}) *WorkflowRunRepo_SaveAttestationBundle_Call { + return &WorkflowRunRepo_SaveAttestationBundle_Call{Call: _e.mock.On("SaveAttestationBundle", ctx, ID, digest, bundle, blockReleasedVersions)} } -func (_c *WorkflowRunRepo_SaveAttestationBundle_Call) Run(run func(ctx context.Context, ID uuid.UUID, digest string, bundle []byte)) *WorkflowRunRepo_SaveAttestationBundle_Call { +func (_c *WorkflowRunRepo_SaveAttestationBundle_Call) Run(run func(ctx context.Context, ID uuid.UUID, digest string, bundle []byte, blockReleasedVersions bool)) *WorkflowRunRepo_SaveAttestationBundle_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -723,11 +724,16 @@ func (_c *WorkflowRunRepo_SaveAttestationBundle_Call) Run(run func(ctx context.C if args[3] != nil { arg3 = args[3].([]byte) } + var arg4 bool + if args[4] != nil { + arg4 = args[4].(bool) + } run( arg0, arg1, arg2, arg3, + arg4, ) }) return _c @@ -738,22 +744,22 @@ func (_c *WorkflowRunRepo_SaveAttestationBundle_Call) Return(err error) *Workflo return _c } -func (_c *WorkflowRunRepo_SaveAttestationBundle_Call) RunAndReturn(run func(ctx context.Context, ID uuid.UUID, digest string, bundle []byte) error) *WorkflowRunRepo_SaveAttestationBundle_Call { +func (_c *WorkflowRunRepo_SaveAttestationBundle_Call) RunAndReturn(run func(ctx context.Context, ID uuid.UUID, digest string, bundle []byte, blockReleasedVersions bool) error) *WorkflowRunRepo_SaveAttestationBundle_Call { _c.Call.Return(run) return _c } // SaveAttestationDigest provides a mock function for the type WorkflowRunRepo -func (_mock *WorkflowRunRepo) SaveAttestationDigest(ctx context.Context, ID uuid.UUID, digest string) error { - ret := _mock.Called(ctx, ID, digest) +func (_mock *WorkflowRunRepo) SaveAttestationDigest(ctx context.Context, ID uuid.UUID, digest string, blockReleasedVersions bool) error { + ret := _mock.Called(ctx, ID, digest, blockReleasedVersions) if len(ret) == 0 { panic("no return value specified for SaveAttestationDigest") } var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID, string) error); ok { - r0 = returnFunc(ctx, ID, digest) + if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID, string, bool) error); ok { + r0 = returnFunc(ctx, ID, digest, blockReleasedVersions) } else { r0 = ret.Error(0) } @@ -769,11 +775,12 @@ type WorkflowRunRepo_SaveAttestationDigest_Call struct { // - ctx context.Context // - ID uuid.UUID // - digest string -func (_e *WorkflowRunRepo_Expecter) SaveAttestationDigest(ctx interface{}, ID interface{}, digest interface{}) *WorkflowRunRepo_SaveAttestationDigest_Call { - return &WorkflowRunRepo_SaveAttestationDigest_Call{Call: _e.mock.On("SaveAttestationDigest", ctx, ID, digest)} +// - blockReleasedVersions bool +func (_e *WorkflowRunRepo_Expecter) SaveAttestationDigest(ctx interface{}, ID interface{}, digest interface{}, blockReleasedVersions interface{}) *WorkflowRunRepo_SaveAttestationDigest_Call { + return &WorkflowRunRepo_SaveAttestationDigest_Call{Call: _e.mock.On("SaveAttestationDigest", ctx, ID, digest, blockReleasedVersions)} } -func (_c *WorkflowRunRepo_SaveAttestationDigest_Call) Run(run func(ctx context.Context, ID uuid.UUID, digest string)) *WorkflowRunRepo_SaveAttestationDigest_Call { +func (_c *WorkflowRunRepo_SaveAttestationDigest_Call) Run(run func(ctx context.Context, ID uuid.UUID, digest string, blockReleasedVersions bool)) *WorkflowRunRepo_SaveAttestationDigest_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { @@ -787,10 +794,15 @@ func (_c *WorkflowRunRepo_SaveAttestationDigest_Call) Run(run func(ctx context.C if args[2] != nil { arg2 = args[2].(string) } + var arg3 bool + if args[3] != nil { + arg3 = args[3].(bool) + } run( arg0, arg1, arg2, + arg3, ) }) return _c @@ -801,7 +813,7 @@ func (_c *WorkflowRunRepo_SaveAttestationDigest_Call) Return(err error) *Workflo return _c } -func (_c *WorkflowRunRepo_SaveAttestationDigest_Call) RunAndReturn(run func(ctx context.Context, ID uuid.UUID, digest string) error) *WorkflowRunRepo_SaveAttestationDigest_Call { +func (_c *WorkflowRunRepo_SaveAttestationDigest_Call) RunAndReturn(run func(ctx context.Context, ID uuid.UUID, digest string, blockReleasedVersions bool) error) *WorkflowRunRepo_SaveAttestationDigest_Call { _c.Call.Return(run) return _c } diff --git a/app/controlplane/pkg/biz/organization.go b/app/controlplane/pkg/biz/organization.go index 3142f656e..4bfce6f6f 100644 --- a/app/controlplane/pkg/biz/organization.go +++ b/app/controlplane/pkg/biz/organization.go @@ -51,6 +51,8 @@ type Organization struct { APITokenInactivityThresholdDays *int // EnableAIAgentCollector enables automatic AI agent config collection during attestation init EnableAIAgentCollector bool + // BlockAttestationsOnReleasedVersions rejects new attestations pushed to project versions that are already released (prerelease == false) + BlockAttestationsOnReleasedVersions bool // Suspended indicates whether the organization is suspended Suspended bool } @@ -65,6 +67,7 @@ type OrganizationUpdateOpts struct { RestrictContractCreationToOrgAdmins *bool APITokenInactivityThresholdDays *int EnableAIAgentCollector *bool + BlockAttestationsOnReleasedVersions *bool } type OrganizationRepo interface { diff --git a/app/controlplane/pkg/biz/referrer_integration_test.go b/app/controlplane/pkg/biz/referrer_integration_test.go index 0cbd970be..ad62e12ef 100644 --- a/app/controlplane/pkg/biz/referrer_integration_test.go +++ b/app/controlplane/pkg/biz/referrer_integration_test.go @@ -556,7 +556,7 @@ func (s *referrerIntegrationTestSuite) TestGetFromRootProjectVersionFilter() { ProjectVersion: "v1.0.0", }) require.NoError(s.T(), err) - require.NoError(s.T(), s.Repos.WorkflowRunRepo.SaveAttestationDigest(ctx, run.ID, h.String())) + require.NoError(s.T(), s.Repos.WorkflowRunRepo.SaveAttestationDigest(ctx, run.ID, h.String(), false)) s.Run("attestation root is returned when project+version match", func() { got, _, err := s.Referrer.GetFromRootUser(ctx, h.String(), "", s.user.ID, nil, biz.WithProjectScope("test", "v1.0.0")) @@ -611,7 +611,7 @@ func (s *referrerIntegrationTestSuite) TestGetFromRootProjectVersionFilter() { ProjectVersion: "v3.0.0", }) require.NoError(s.T(), err) - require.NoError(s.T(), s.Repos.WorkflowRunRepo.SaveAttestationDigest(ctx, runV3.ID, newH)) + require.NoError(s.T(), s.Repos.WorkflowRunRepo.SaveAttestationDigest(ctx, runV3.ID, newH, false)) // project_name only (empty version) → SBOM returned via its in-project attestation. got, _, err := s.Referrer.GetFromRootUser(ctx, sbomDigest, "", s.user.ID, nil, biz.WithProjectScope("test", "")) @@ -651,7 +651,7 @@ func (s *referrerIntegrationTestSuite) TestGetFromRootProjectVersionFilter() { ProjectVersion: "v1.0.0", }) require.NoError(s.T(), err) - require.NoError(s.T(), s.Repos.WorkflowRunRepo.SaveAttestationDigest(ctx, runOther.ID, h.String())) + require.NoError(s.T(), s.Repos.WorkflowRunRepo.SaveAttestationDigest(ctx, runOther.ID, h.String(), false)) // RBAC: caller can see project "test" in org1 only — NOT "other-proj". rbac := map[biz.OrgID][]biz.ProjectID{s.org1UUID: {s.workflow1.ProjectID}} @@ -687,7 +687,7 @@ func (s *referrerIntegrationTestSuite) TestGetFromRootProjectVersionFilter() { ProjectVersion: "v1.0.0", }) require.NoError(s.T(), err) - require.NoError(s.T(), s.Repos.WorkflowRunRepo.SaveAttestationDigest(ctx, runPublic.ID, h.String())) + require.NoError(s.T(), s.Repos.WorkflowRunRepo.SaveAttestationDigest(ctx, runPublic.ID, h.String(), false)) // RBAC restricts the caller to project "test" — "public-proj" is NOT in their set. rbac := map[biz.OrgID][]biz.ProjectID{s.org1UUID: {s.workflow1.ProjectID}} @@ -706,7 +706,7 @@ func (s *referrerIntegrationTestSuite) TestGetFromRootProjectVersionFilter() { ProjectVersion: "v2.0.0", }) require.NoError(s.T(), err) - require.NoError(s.T(), s.Repos.WorkflowRunRepo.SaveAttestationDigest(ctx, run2.ID, "sha256:"+strings.Repeat("a", 64))) + require.NoError(s.T(), s.Repos.WorkflowRunRepo.SaveAttestationDigest(ctx, run2.ID, "sha256:"+strings.Repeat("a", 64), false)) // Paging past the first page must not skip the membership check (regression for the // firstPage gate that allowed a cursor to bypass version scoping). diff --git a/app/controlplane/pkg/biz/testhelpers/wire_gen.go b/app/controlplane/pkg/biz/testhelpers/wire_gen.go index 12ae10fe0..1d7a2446a 100644 --- a/app/controlplane/pkg/biz/testhelpers/wire_gen.go +++ b/app/controlplane/pkg/biz/testhelpers/wire_gen.go @@ -106,6 +106,7 @@ func WireTestData(contextContext context.Context, testDatabase *TestDatabase, t workflowRunUseCaseOpts := &biz.WorkflowRunUseCaseOpts{ WfrRepo: workflowRunRepo, WfRepo: workflowRepo, + OrgRepo: organizationRepo, SigningUC: signingUseCase, AuditorUC: auditorUseCase, Logger: logger, diff --git a/app/controlplane/pkg/biz/workflowrun.go b/app/controlplane/pkg/biz/workflowrun.go index b77afe329..8186fcaa0 100644 --- a/app/controlplane/pkg/biz/workflowrun.go +++ b/app/controlplane/pkg/biz/workflowrun.go @@ -99,11 +99,16 @@ type WorkflowRunRepo interface { FindByAttestationDigest(ctx context.Context, digest string) (*WorkflowRun, error) FindByIDInOrg(ctx context.Context, orgID, ID uuid.UUID) (*WorkflowRun, error) MarkAsFinished(ctx context.Context, ID uuid.UUID, status WorkflowRunStatus, reason string) error - SaveAttestationBundle(ctx context.Context, ID uuid.UUID, digest string, bundle []byte) error + // SaveAttestationBundle persists the digest and bundle. When + // blockReleasedVersions is set it transactionally rejects the write if the + // run's project version is already released (prerelease == false), locking + // the version row so the check is atomic with the write. + SaveAttestationBundle(ctx context.Context, ID uuid.UUID, digest string, bundle []byte, blockReleasedVersions bool) error // SaveAttestationDigest records the attestation digest on the workflow run // without writing a row in the attestation table. Used when the bundle is - // stored exclusively in CAS. - SaveAttestationDigest(ctx context.Context, ID uuid.UUID, digest string) error + // stored exclusively in CAS. blockReleasedVersions behaves as in + // SaveAttestationBundle. + SaveAttestationDigest(ctx context.Context, ID uuid.UUID, digest string, blockReleasedVersions bool) error GetBundle(ctx context.Context, wrID uuid.UUID) ([]byte, error) UpdatePolicyStatus(ctx context.Context, ID uuid.UUID, summary *chainloop.PolicyStatusSummary) error List(ctx context.Context, orgID uuid.UUID, f *RunListFilters, p *pagination.CursorOptions) ([]*WorkflowRun, string, error) @@ -116,6 +121,7 @@ type WorkflowRunRepo interface { type WorkflowRunUseCase struct { wfRunRepo WorkflowRunRepo wfRepo WorkflowRepo + orgRepo OrganizationRepo logger *log.Helper auditorUC *AuditorUseCase @@ -128,6 +134,7 @@ type WorkflowRunUseCase struct { type WorkflowRunUseCaseOpts struct { WfrRepo WorkflowRunRepo WfRepo WorkflowRepo + OrgRepo OrganizationRepo SigningUC *SigningUseCase AuditorUC *AuditorUseCase Logger log.Logger @@ -145,6 +152,7 @@ func NewWorkflowRunUseCase(opts *WorkflowRunUseCaseOpts) (*WorkflowRunUseCase, e return &WorkflowRunUseCase{ wfRunRepo: opts.WfrRepo, wfRepo: opts.WfRepo, + orgRepo: opts.OrgRepo, auditorUC: opts.AuditorUC, signingUseCase: opts.SigningUC, logger: log.NewHelper(logger), @@ -235,6 +243,9 @@ type WorkflowRunCreateOpts struct { UseLatestVersion bool RequireExistingVersion bool MarkAsLatest *bool + // BlockReleasedVersions rejects creating a run against an already-released + // (prerelease == false) project version. Set from the org-level setting. + BlockReleasedVersions bool } type WorkflowRunRepoCreateOpts struct { @@ -246,6 +257,9 @@ type WorkflowRunRepoCreateOpts struct { UseLatestVersion bool RequireExistingVersion bool MarkAsLatest *bool + // BlockReleasedVersions rejects creating a run against an already-released + // (prerelease == false) project version. + BlockReleasedVersions bool } // Create will add a new WorkflowRun, associate it to a schemaVersion and increment the counter in the associated workflow @@ -303,6 +317,7 @@ func (uc *WorkflowRunUseCase) Create(ctx context.Context, opts *WorkflowRunCreat UseLatestVersion: opts.UseLatestVersion, RequireExistingVersion: opts.RequireExistingVersion, MarkAsLatest: opts.MarkAsLatest, + BlockReleasedVersions: opts.BlockReleasedVersions, }) if err != nil { return nil, err @@ -371,6 +386,26 @@ func WithSkipBundlePersistence() SaveAttestationOption { } } +// orgBlocksReleasedVersions reports whether the run's organization rejects new +// attestations targeting already-released (immutable) project versions. The +// actual prerelease check happens transactionally at persistence time (see the +// repo SaveAttestation* methods) so it is atomic with the write and can't be +// bypassed by a concurrent release. +func (uc *WorkflowRunUseCase) orgBlocksReleasedVersions(ctx context.Context, run *WorkflowRun) (bool, error) { + if run.Workflow == nil { + return false, nil + } + + org, err := uc.orgRepo.FindByID(ctx, run.Workflow.OrgID) + if err != nil { + return false, fmt.Errorf("finding organization: %w", err) + } else if org == nil { + return false, NewErrNotFound("organization") + } + + return org.BlockAttestationsOnReleasedVersions, nil +} + func (uc *WorkflowRunUseCase) SaveAttestation(ctx context.Context, id string, bundle []byte, opts ...SaveAttestationOption) (*v1.Hash, error) { ctx, span := otelx.Start(ctx, workflowRunTracer, "WorkflowRunUseCase.SaveAttestation") defer span.End() @@ -385,6 +420,21 @@ func (uc *WorkflowRunUseCase) SaveAttestation(ctx context.Context, id string, bu return nil, NewErrInvalidUUID(err) } + // Resolve the run so we can tell whether the owning organization blocks + // attestations on released versions. The actual prerelease check is done + // transactionally at persistence time so it is atomic with the write. + run, err := uc.wfRunRepo.FindByID(ctx, runID) + if err != nil { + return nil, fmt.Errorf("finding workflow run: %w", err) + } else if run == nil { + return nil, NewErrNotFound("workflow run") + } + + blockReleasedVersions, err := uc.orgBlocksReleasedVersions(ctx, run) + if err != nil { + return nil, err + } + // calculate the content digest // Todo: this should be calculated in the use case digest, _, err := v1.SHA256(bytes.NewReader(bundle)) @@ -433,11 +483,11 @@ func (uc *WorkflowRunUseCase) SaveAttestation(ctx context.Context, id string, bu } if options.skipBundlePersistence { - if err := uc.wfRunRepo.SaveAttestationDigest(ctx, runID, digest.String()); err != nil { + if err := uc.wfRunRepo.SaveAttestationDigest(ctx, runID, digest.String(), blockReleasedVersions); err != nil { return nil, fmt.Errorf("saving attestation digest: %w", err) } } else { - if err := uc.wfRunRepo.SaveAttestationBundle(ctx, runID, digest.String(), bundle); err != nil { + if err := uc.wfRunRepo.SaveAttestationBundle(ctx, runID, digest.String(), bundle, blockReleasedVersions); err != nil { return nil, fmt.Errorf("saving attestation bundle: %w", err) } } diff --git a/app/controlplane/pkg/biz/workflowrun_integration_test.go b/app/controlplane/pkg/biz/workflowrun_integration_test.go index 3b188dd6b..de60b5295 100644 --- a/app/controlplane/pkg/biz/workflowrun_integration_test.go +++ b/app/controlplane/pkg/biz/workflowrun_integration_test.go @@ -287,6 +287,121 @@ func (s *workflowRunIntegrationTestSuite) TestSaveAttestation() { }) } +// setOrgBlockReleasedVersions toggles the org-level +// BlockAttestationsOnReleasedVersions setting directly through the repo. +func (s *workflowRunIntegrationTestSuite) setOrgBlockReleasedVersions(ctx context.Context, orgID string, enabled bool) { + orgUUID, err := uuid.Parse(orgID) + s.Require().NoError(err) + _, err = s.Repos.OrganizationRepo.Update(ctx, orgUUID, &biz.OrganizationUpdateOpts{ + BlockAttestationsOnReleasedVersions: &enabled, + }) + s.Require().NoError(err) +} + +// createReleasedVersion creates a run targeting versionName and then marks the +// resulting project version as released (prerelease == false). +func (s *workflowRunIntegrationTestSuite) createReleasedVersion(ctx context.Context, versionName string) *biz.WorkflowRun { + run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: versionName, + }) + s.Require().NoError(err) + s.Require().True(run.ProjectVersion.Prerelease) + + _, err = s.ProjectVersion.UpdateReleaseStatus(ctx, run.ProjectVersion.ID.String(), true) + s.Require().NoError(err) + + return run +} + +// TestReleasedVersionImmutability covers the org-level guard that rejects new +// attestations targeting already-released (immutable) project versions, both +// at init (WorkflowRun.Create) and at push (SaveAttestation). +func (s *workflowRunIntegrationTestSuite) TestReleasedVersionImmutability() { + ctx := context.Background() + _, bundleBytes := testBundle(s.T(), "testdata/attestations/bundle.json") + + initCases := []struct { + name string + version string + released bool // pre-create and release the version before the run + block bool // value of the BlockReleasedVersions guard + wantErr bool + }{ + {"rejects a released version when enabled", "rv-init-blocked", true, true, true}, + {"allows a pre-release version when enabled", "rv-init-prerelease", false, true, false}, + {"allows a released version when disabled", "rv-init-allowed", true, false, false}, + } + for _, tc := range initCases { + s.Run("init: "+tc.name, func() { + if tc.released { + s.createReleasedVersion(ctx, tc.version) + } + + run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: tc.version, + BlockReleasedVersions: tc.block, + }) + + if tc.wantErr { + s.Require().Error(err) + s.True(biz.IsErrReleasedVersionImmutable(err)) + s.Contains(err.Error(), "released and immutable") + return + } + + s.Require().NoError(err) + s.Equal(!tc.released, run.ProjectVersion.Prerelease) + }) + } + + s.Run("push: rejects saving an attestation to a released version when enabled", func() { + run := s.createReleasedVersion(ctx, "rv-push-blocked") + s.setOrgBlockReleasedVersions(ctx, s.org.ID, true) + defer s.setOrgBlockReleasedVersions(ctx, s.org.ID, false) + + _, err := s.WorkflowRun.SaveAttestation(ctx, run.ID.String(), bundleBytes) + s.Require().Error(err) + s.True(biz.IsErrReleasedVersionImmutable(err)) + }) + + s.Run("push: allows saving an attestation to a pre-release version when enabled", func() { + run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "rv-push-prerelease", + }) + s.Require().NoError(err) + + s.setOrgBlockReleasedVersions(ctx, s.org.ID, true) + defer s.setOrgBlockReleasedVersions(ctx, s.org.ID, false) + + _, err = s.WorkflowRun.SaveAttestation(ctx, run.ID.String(), bundleBytes) + s.Require().NoError(err) + }) + + s.Run("push: rejects when the version is released between init and push", func() { + // Run is created while the version is still a pre-release (passes the + // init guard), then the version is released before the push. + run, err := s.WorkflowRun.Create(ctx, &biz.WorkflowRunCreateOpts{ + WorkflowID: s.workflowOrg1.ID.String(), ContractRevision: s.contractVersion, CASBackendID: s.casBackend.ID, + RunnerType: "runnerType", RunnerRunURL: "runURL", ProjectVersion: "rv-push-race", + BlockReleasedVersions: true, + }) + s.Require().NoError(err) + + _, err = s.ProjectVersion.UpdateReleaseStatus(ctx, run.ProjectVersion.ID.String(), true) + s.Require().NoError(err) + + s.setOrgBlockReleasedVersions(ctx, s.org.ID, true) + defer s.setOrgBlockReleasedVersions(ctx, s.org.ID, false) + + _, err = s.WorkflowRun.SaveAttestation(ctx, run.ID.String(), bundleBytes) + s.Require().Error(err) + s.True(biz.IsErrReleasedVersionImmutable(err)) + }) +} + func (s *workflowRunIntegrationTestSuite) TestGetByIDInOrgOrPublic() { assert := assert.New(s.T()) ctx := context.Background() diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/20260608210839.sql b/app/controlplane/pkg/data/ent/migrate/migrations/20260608210839.sql new file mode 100644 index 000000000..01acb1da4 --- /dev/null +++ b/app/controlplane/pkg/data/ent/migrate/migrations/20260608210839.sql @@ -0,0 +1,2 @@ +-- Modify "organizations" table +ALTER TABLE "organizations" ADD COLUMN "block_attestations_on_released_versions" boolean NOT NULL DEFAULT false; diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum index 4eec11dab..14eff557b 100644 --- a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum +++ b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:q3yyXEwYvGrOTgtEzz+R3LpanOG7wt70Qg/6WiJNHFs= +h1:cpizrs7Ih7KS6e/UHvHSXhaClLfSgzfxcSneduY74XU= 20230706165452_init-schema.sql h1:VvqbNFEQnCvUVyj2iDYVQQxDM0+sSXqocpt/5H64k8M= 20230710111950-cas-backend.sql h1:A8iBuSzZIEbdsv9ipBtscZQuaBp3V5/VMw7eZH6GX+g= 20230712094107-cas-backends-workflow-runs.sql h1:a5rzxpVGyd56nLRSsKrmCFc9sebg65RWzLghKHh5xvI= @@ -136,3 +136,4 @@ h1:q3yyXEwYvGrOTgtEzz+R3LpanOG7wt70Qg/6WiJNHFs= 20260514150303.sql h1:0bGVXYN5rBP9Hn9x/ou8JgKotKiFbSKWGHX2dBH/wCA= 20260516210119.sql h1:rfBnXQwPnrhVYAp/OIiFPGcS+Tx1x9CAMSDPGs8HIT8= 20260527093110.sql h1:Jgq9xDyLakqIVMo3LZF4pPYAkBSc2G5qUK/IV9bzYc4= +20260608210839.sql h1:RfwH7Yf8FRzqPdJeNzfIVH5TwPEush04KMAv4K1c2zY= diff --git a/app/controlplane/pkg/data/ent/migrate/schema.go b/app/controlplane/pkg/data/ent/migrate/schema.go index 23354696c..6fd24cf6f 100644 --- a/app/controlplane/pkg/data/ent/migrate/schema.go +++ b/app/controlplane/pkg/data/ent/migrate/schema.go @@ -443,6 +443,7 @@ var ( {Name: "restrict_contract_creation_to_org_admins", Type: field.TypeBool, Default: false}, {Name: "api_token_inactivity_threshold_days", Type: field.TypeInt, Nullable: true}, {Name: "enable_ai_agent_collector", Type: field.TypeBool, Default: false}, + {Name: "block_attestations_on_released_versions", Type: field.TypeBool, Default: false}, {Name: "suspended", Type: field.TypeBool, Default: false}, } // OrganizationsTable holds the schema information for the "organizations" table. diff --git a/app/controlplane/pkg/data/ent/mutation.go b/app/controlplane/pkg/data/ent/mutation.go index 9fbe5b9d6..8a6f3d87e 100644 --- a/app/controlplane/pkg/data/ent/mutation.go +++ b/app/controlplane/pkg/data/ent/mutation.go @@ -9030,6 +9030,7 @@ type OrganizationMutation struct { api_token_inactivity_threshold_days *int addapi_token_inactivity_threshold_days *int enable_ai_agent_collector *bool + block_attestations_on_released_versions *bool suspended *bool clearedFields map[string]struct{} memberships map[uuid.UUID]struct{} @@ -9604,6 +9605,42 @@ func (m *OrganizationMutation) ResetEnableAiAgentCollector() { m.enable_ai_agent_collector = nil } +// SetBlockAttestationsOnReleasedVersions sets the "block_attestations_on_released_versions" field. +func (m *OrganizationMutation) SetBlockAttestationsOnReleasedVersions(b bool) { + m.block_attestations_on_released_versions = &b +} + +// BlockAttestationsOnReleasedVersions returns the value of the "block_attestations_on_released_versions" field in the mutation. +func (m *OrganizationMutation) BlockAttestationsOnReleasedVersions() (r bool, exists bool) { + v := m.block_attestations_on_released_versions + if v == nil { + return + } + return *v, true +} + +// OldBlockAttestationsOnReleasedVersions returns the old "block_attestations_on_released_versions" field's value of the Organization entity. +// If the Organization object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *OrganizationMutation) OldBlockAttestationsOnReleasedVersions(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldBlockAttestationsOnReleasedVersions is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldBlockAttestationsOnReleasedVersions requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldBlockAttestationsOnReleasedVersions: %w", err) + } + return oldValue.BlockAttestationsOnReleasedVersions, nil +} + +// ResetBlockAttestationsOnReleasedVersions resets all changes to the "block_attestations_on_released_versions" field. +func (m *OrganizationMutation) ResetBlockAttestationsOnReleasedVersions() { + m.block_attestations_on_released_versions = nil +} + // SetSuspended sets the "suspended" field. func (m *OrganizationMutation) SetSuspended(b bool) { m.suspended = &b @@ -10160,7 +10197,7 @@ func (m *OrganizationMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *OrganizationMutation) Fields() []string { - fields := make([]string, 0, 11) + fields := make([]string, 0, 12) if m.name != nil { fields = append(fields, organization.FieldName) } @@ -10191,6 +10228,9 @@ func (m *OrganizationMutation) Fields() []string { if m.enable_ai_agent_collector != nil { fields = append(fields, organization.FieldEnableAiAgentCollector) } + if m.block_attestations_on_released_versions != nil { + fields = append(fields, organization.FieldBlockAttestationsOnReleasedVersions) + } if m.suspended != nil { fields = append(fields, organization.FieldSuspended) } @@ -10222,6 +10262,8 @@ func (m *OrganizationMutation) Field(name string) (ent.Value, bool) { return m.APITokenInactivityThresholdDays() case organization.FieldEnableAiAgentCollector: return m.EnableAiAgentCollector() + case organization.FieldBlockAttestationsOnReleasedVersions: + return m.BlockAttestationsOnReleasedVersions() case organization.FieldSuspended: return m.Suspended() } @@ -10253,6 +10295,8 @@ func (m *OrganizationMutation) OldField(ctx context.Context, name string) (ent.V return m.OldAPITokenInactivityThresholdDays(ctx) case organization.FieldEnableAiAgentCollector: return m.OldEnableAiAgentCollector(ctx) + case organization.FieldBlockAttestationsOnReleasedVersions: + return m.OldBlockAttestationsOnReleasedVersions(ctx) case organization.FieldSuspended: return m.OldSuspended(ctx) } @@ -10334,6 +10378,13 @@ func (m *OrganizationMutation) SetField(name string, value ent.Value) error { } m.SetEnableAiAgentCollector(v) return nil + case organization.FieldBlockAttestationsOnReleasedVersions: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetBlockAttestationsOnReleasedVersions(v) + return nil case organization.FieldSuspended: v, ok := value.(bool) if !ok { @@ -10456,6 +10507,9 @@ func (m *OrganizationMutation) ResetField(name string) error { case organization.FieldEnableAiAgentCollector: m.ResetEnableAiAgentCollector() return nil + case organization.FieldBlockAttestationsOnReleasedVersions: + m.ResetBlockAttestationsOnReleasedVersions() + return nil case organization.FieldSuspended: m.ResetSuspended() return nil diff --git a/app/controlplane/pkg/data/ent/organization.go b/app/controlplane/pkg/data/ent/organization.go index 55eb4c642..d2d493849 100644 --- a/app/controlplane/pkg/data/ent/organization.go +++ b/app/controlplane/pkg/data/ent/organization.go @@ -39,6 +39,8 @@ type Organization struct { APITokenInactivityThresholdDays *int `json:"api_token_inactivity_threshold_days,omitempty"` // EnableAiAgentCollector holds the value of the "enable_ai_agent_collector" field. EnableAiAgentCollector bool `json:"enable_ai_agent_collector,omitempty"` + // BlockAttestationsOnReleasedVersions holds the value of the "block_attestations_on_released_versions" field. + BlockAttestationsOnReleasedVersions bool `json:"block_attestations_on_released_versions,omitempty"` // Suspended holds the value of the "suspended" field. Suspended bool `json:"suspended,omitempty"` // Edges holds the relations/edges for other nodes in the graph. @@ -160,7 +162,7 @@ func (*Organization) scanValues(columns []string) ([]any, error) { switch columns[i] { case organization.FieldPoliciesAllowedHostnames: values[i] = new([]byte) - case organization.FieldBlockOnPolicyViolation, organization.FieldPreventImplicitWorkflowCreation, organization.FieldRestrictContractCreationToOrgAdmins, organization.FieldEnableAiAgentCollector, organization.FieldSuspended: + case organization.FieldBlockOnPolicyViolation, organization.FieldPreventImplicitWorkflowCreation, organization.FieldRestrictContractCreationToOrgAdmins, organization.FieldEnableAiAgentCollector, organization.FieldBlockAttestationsOnReleasedVersions, organization.FieldSuspended: values[i] = new(sql.NullBool) case organization.FieldAPITokenInactivityThresholdDays: values[i] = new(sql.NullInt64) @@ -254,6 +256,12 @@ func (_m *Organization) assignValues(columns []string, values []any) error { } else if value.Valid { _m.EnableAiAgentCollector = value.Bool } + case organization.FieldBlockAttestationsOnReleasedVersions: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field block_attestations_on_released_versions", values[i]) + } else if value.Valid { + _m.BlockAttestationsOnReleasedVersions = value.Bool + } case organization.FieldSuspended: if value, ok := values[i].(*sql.NullBool); !ok { return fmt.Errorf("unexpected type %T for field suspended", values[i]) @@ -373,6 +381,9 @@ func (_m *Organization) String() string { builder.WriteString("enable_ai_agent_collector=") builder.WriteString(fmt.Sprintf("%v", _m.EnableAiAgentCollector)) builder.WriteString(", ") + builder.WriteString("block_attestations_on_released_versions=") + builder.WriteString(fmt.Sprintf("%v", _m.BlockAttestationsOnReleasedVersions)) + builder.WriteString(", ") builder.WriteString("suspended=") builder.WriteString(fmt.Sprintf("%v", _m.Suspended)) builder.WriteByte(')') diff --git a/app/controlplane/pkg/data/ent/organization/organization.go b/app/controlplane/pkg/data/ent/organization/organization.go index 386d48179..067a2b6ed 100644 --- a/app/controlplane/pkg/data/ent/organization/organization.go +++ b/app/controlplane/pkg/data/ent/organization/organization.go @@ -35,6 +35,8 @@ const ( FieldAPITokenInactivityThresholdDays = "api_token_inactivity_threshold_days" // FieldEnableAiAgentCollector holds the string denoting the enable_ai_agent_collector field in the database. FieldEnableAiAgentCollector = "enable_ai_agent_collector" + // FieldBlockAttestationsOnReleasedVersions holds the string denoting the block_attestations_on_released_versions field in the database. + FieldBlockAttestationsOnReleasedVersions = "block_attestations_on_released_versions" // FieldSuspended holds the string denoting the suspended field in the database. FieldSuspended = "suspended" // EdgeMemberships holds the string denoting the memberships edge name in mutations. @@ -135,6 +137,7 @@ var Columns = []string{ FieldRestrictContractCreationToOrgAdmins, FieldAPITokenInactivityThresholdDays, FieldEnableAiAgentCollector, + FieldBlockAttestationsOnReleasedVersions, FieldSuspended, } @@ -161,6 +164,8 @@ var ( DefaultRestrictContractCreationToOrgAdmins bool // DefaultEnableAiAgentCollector holds the default value on creation for the "enable_ai_agent_collector" field. DefaultEnableAiAgentCollector bool + // DefaultBlockAttestationsOnReleasedVersions holds the default value on creation for the "block_attestations_on_released_versions" field. + DefaultBlockAttestationsOnReleasedVersions bool // DefaultSuspended holds the default value on creation for the "suspended" field. DefaultSuspended bool // DefaultID holds the default value on creation for the "id" field. @@ -220,6 +225,11 @@ func ByEnableAiAgentCollector(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldEnableAiAgentCollector, opts...).ToFunc() } +// ByBlockAttestationsOnReleasedVersions orders the results by the block_attestations_on_released_versions field. +func ByBlockAttestationsOnReleasedVersions(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldBlockAttestationsOnReleasedVersions, opts...).ToFunc() +} + // BySuspended orders the results by the suspended field. func BySuspended(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldSuspended, opts...).ToFunc() diff --git a/app/controlplane/pkg/data/ent/organization/where.go b/app/controlplane/pkg/data/ent/organization/where.go index 92d139d1e..6d4f204bc 100644 --- a/app/controlplane/pkg/data/ent/organization/where.go +++ b/app/controlplane/pkg/data/ent/organization/where.go @@ -101,6 +101,11 @@ func EnableAiAgentCollector(v bool) predicate.Organization { return predicate.Organization(sql.FieldEQ(FieldEnableAiAgentCollector, v)) } +// BlockAttestationsOnReleasedVersions applies equality check predicate on the "block_attestations_on_released_versions" field. It's identical to BlockAttestationsOnReleasedVersionsEQ. +func BlockAttestationsOnReleasedVersions(v bool) predicate.Organization { + return predicate.Organization(sql.FieldEQ(FieldBlockAttestationsOnReleasedVersions, v)) +} + // Suspended applies equality check predicate on the "suspended" field. It's identical to SuspendedEQ. func Suspended(v bool) predicate.Organization { return predicate.Organization(sql.FieldEQ(FieldSuspended, v)) @@ -401,6 +406,16 @@ func EnableAiAgentCollectorNEQ(v bool) predicate.Organization { return predicate.Organization(sql.FieldNEQ(FieldEnableAiAgentCollector, v)) } +// BlockAttestationsOnReleasedVersionsEQ applies the EQ predicate on the "block_attestations_on_released_versions" field. +func BlockAttestationsOnReleasedVersionsEQ(v bool) predicate.Organization { + return predicate.Organization(sql.FieldEQ(FieldBlockAttestationsOnReleasedVersions, v)) +} + +// BlockAttestationsOnReleasedVersionsNEQ applies the NEQ predicate on the "block_attestations_on_released_versions" field. +func BlockAttestationsOnReleasedVersionsNEQ(v bool) predicate.Organization { + return predicate.Organization(sql.FieldNEQ(FieldBlockAttestationsOnReleasedVersions, v)) +} + // SuspendedEQ applies the EQ predicate on the "suspended" field. func SuspendedEQ(v bool) predicate.Organization { return predicate.Organization(sql.FieldEQ(FieldSuspended, v)) diff --git a/app/controlplane/pkg/data/ent/organization_create.go b/app/controlplane/pkg/data/ent/organization_create.go index 492d312e3..5f7aa0790 100644 --- a/app/controlplane/pkg/data/ent/organization_create.go +++ b/app/controlplane/pkg/data/ent/organization_create.go @@ -157,6 +157,20 @@ func (_c *OrganizationCreate) SetNillableEnableAiAgentCollector(v *bool) *Organi return _c } +// SetBlockAttestationsOnReleasedVersions sets the "block_attestations_on_released_versions" field. +func (_c *OrganizationCreate) SetBlockAttestationsOnReleasedVersions(v bool) *OrganizationCreate { + _c.mutation.SetBlockAttestationsOnReleasedVersions(v) + return _c +} + +// SetNillableBlockAttestationsOnReleasedVersions sets the "block_attestations_on_released_versions" field if the given value is not nil. +func (_c *OrganizationCreate) SetNillableBlockAttestationsOnReleasedVersions(v *bool) *OrganizationCreate { + if v != nil { + _c.SetBlockAttestationsOnReleasedVersions(*v) + } + return _c +} + // SetSuspended sets the "suspended" field. func (_c *OrganizationCreate) SetSuspended(v bool) *OrganizationCreate { _c.mutation.SetSuspended(v) @@ -379,6 +393,10 @@ func (_c *OrganizationCreate) defaults() { v := organization.DefaultEnableAiAgentCollector _c.mutation.SetEnableAiAgentCollector(v) } + if _, ok := _c.mutation.BlockAttestationsOnReleasedVersions(); !ok { + v := organization.DefaultBlockAttestationsOnReleasedVersions + _c.mutation.SetBlockAttestationsOnReleasedVersions(v) + } if _, ok := _c.mutation.Suspended(); !ok { v := organization.DefaultSuspended _c.mutation.SetSuspended(v) @@ -412,6 +430,9 @@ func (_c *OrganizationCreate) check() error { if _, ok := _c.mutation.EnableAiAgentCollector(); !ok { return &ValidationError{Name: "enable_ai_agent_collector", err: errors.New(`ent: missing required field "Organization.enable_ai_agent_collector"`)} } + if _, ok := _c.mutation.BlockAttestationsOnReleasedVersions(); !ok { + return &ValidationError{Name: "block_attestations_on_released_versions", err: errors.New(`ent: missing required field "Organization.block_attestations_on_released_versions"`)} + } if _, ok := _c.mutation.Suspended(); !ok { return &ValidationError{Name: "suspended", err: errors.New(`ent: missing required field "Organization.suspended"`)} } @@ -491,6 +512,10 @@ func (_c *OrganizationCreate) createSpec() (*Organization, *sqlgraph.CreateSpec) _spec.SetField(organization.FieldEnableAiAgentCollector, field.TypeBool, value) _node.EnableAiAgentCollector = value } + if value, ok := _c.mutation.BlockAttestationsOnReleasedVersions(); ok { + _spec.SetField(organization.FieldBlockAttestationsOnReleasedVersions, field.TypeBool, value) + _node.BlockAttestationsOnReleasedVersions = value + } if value, ok := _c.mutation.Suspended(); ok { _spec.SetField(organization.FieldSuspended, field.TypeBool, value) _node.Suspended = value @@ -823,6 +848,18 @@ func (u *OrganizationUpsert) UpdateEnableAiAgentCollector() *OrganizationUpsert return u } +// SetBlockAttestationsOnReleasedVersions sets the "block_attestations_on_released_versions" field. +func (u *OrganizationUpsert) SetBlockAttestationsOnReleasedVersions(v bool) *OrganizationUpsert { + u.Set(organization.FieldBlockAttestationsOnReleasedVersions, v) + return u +} + +// UpdateBlockAttestationsOnReleasedVersions sets the "block_attestations_on_released_versions" field to the value that was provided on create. +func (u *OrganizationUpsert) UpdateBlockAttestationsOnReleasedVersions() *OrganizationUpsert { + u.SetExcluded(organization.FieldBlockAttestationsOnReleasedVersions) + return u +} + // SetSuspended sets the "suspended" field. func (u *OrganizationUpsert) SetSuspended(v bool) *OrganizationUpsert { u.Set(organization.FieldSuspended, v) @@ -1040,6 +1077,20 @@ func (u *OrganizationUpsertOne) UpdateEnableAiAgentCollector() *OrganizationUpse }) } +// SetBlockAttestationsOnReleasedVersions sets the "block_attestations_on_released_versions" field. +func (u *OrganizationUpsertOne) SetBlockAttestationsOnReleasedVersions(v bool) *OrganizationUpsertOne { + return u.Update(func(s *OrganizationUpsert) { + s.SetBlockAttestationsOnReleasedVersions(v) + }) +} + +// UpdateBlockAttestationsOnReleasedVersions sets the "block_attestations_on_released_versions" field to the value that was provided on create. +func (u *OrganizationUpsertOne) UpdateBlockAttestationsOnReleasedVersions() *OrganizationUpsertOne { + return u.Update(func(s *OrganizationUpsert) { + s.UpdateBlockAttestationsOnReleasedVersions() + }) +} + // SetSuspended sets the "suspended" field. func (u *OrganizationUpsertOne) SetSuspended(v bool) *OrganizationUpsertOne { return u.Update(func(s *OrganizationUpsert) { @@ -1426,6 +1477,20 @@ func (u *OrganizationUpsertBulk) UpdateEnableAiAgentCollector() *OrganizationUps }) } +// SetBlockAttestationsOnReleasedVersions sets the "block_attestations_on_released_versions" field. +func (u *OrganizationUpsertBulk) SetBlockAttestationsOnReleasedVersions(v bool) *OrganizationUpsertBulk { + return u.Update(func(s *OrganizationUpsert) { + s.SetBlockAttestationsOnReleasedVersions(v) + }) +} + +// UpdateBlockAttestationsOnReleasedVersions sets the "block_attestations_on_released_versions" field to the value that was provided on create. +func (u *OrganizationUpsertBulk) UpdateBlockAttestationsOnReleasedVersions() *OrganizationUpsertBulk { + return u.Update(func(s *OrganizationUpsert) { + s.UpdateBlockAttestationsOnReleasedVersions() + }) +} + // SetSuspended sets the "suspended" field. func (u *OrganizationUpsertBulk) SetSuspended(v bool) *OrganizationUpsertBulk { return u.Update(func(s *OrganizationUpsert) { diff --git a/app/controlplane/pkg/data/ent/organization_update.go b/app/controlplane/pkg/data/ent/organization_update.go index d17b05860..acf0951ed 100644 --- a/app/controlplane/pkg/data/ent/organization_update.go +++ b/app/controlplane/pkg/data/ent/organization_update.go @@ -189,6 +189,20 @@ func (_u *OrganizationUpdate) SetNillableEnableAiAgentCollector(v *bool) *Organi return _u } +// SetBlockAttestationsOnReleasedVersions sets the "block_attestations_on_released_versions" field. +func (_u *OrganizationUpdate) SetBlockAttestationsOnReleasedVersions(v bool) *OrganizationUpdate { + _u.mutation.SetBlockAttestationsOnReleasedVersions(v) + return _u +} + +// SetNillableBlockAttestationsOnReleasedVersions sets the "block_attestations_on_released_versions" field if the given value is not nil. +func (_u *OrganizationUpdate) SetNillableBlockAttestationsOnReleasedVersions(v *bool) *OrganizationUpdate { + if v != nil { + _u.SetBlockAttestationsOnReleasedVersions(*v) + } + return _u +} + // SetSuspended sets the "suspended" field. func (_u *OrganizationUpdate) SetSuspended(v bool) *OrganizationUpdate { _u.mutation.SetSuspended(v) @@ -618,6 +632,9 @@ func (_u *OrganizationUpdate) sqlSave(ctx context.Context) (_node int, err error if value, ok := _u.mutation.EnableAiAgentCollector(); ok { _spec.SetField(organization.FieldEnableAiAgentCollector, field.TypeBool, value) } + if value, ok := _u.mutation.BlockAttestationsOnReleasedVersions(); ok { + _spec.SetField(organization.FieldBlockAttestationsOnReleasedVersions, field.TypeBool, value) + } if value, ok := _u.mutation.Suspended(); ok { _spec.SetField(organization.FieldSuspended, field.TypeBool, value) } @@ -1197,6 +1214,20 @@ func (_u *OrganizationUpdateOne) SetNillableEnableAiAgentCollector(v *bool) *Org return _u } +// SetBlockAttestationsOnReleasedVersions sets the "block_attestations_on_released_versions" field. +func (_u *OrganizationUpdateOne) SetBlockAttestationsOnReleasedVersions(v bool) *OrganizationUpdateOne { + _u.mutation.SetBlockAttestationsOnReleasedVersions(v) + return _u +} + +// SetNillableBlockAttestationsOnReleasedVersions sets the "block_attestations_on_released_versions" field if the given value is not nil. +func (_u *OrganizationUpdateOne) SetNillableBlockAttestationsOnReleasedVersions(v *bool) *OrganizationUpdateOne { + if v != nil { + _u.SetBlockAttestationsOnReleasedVersions(*v) + } + return _u +} + // SetSuspended sets the "suspended" field. func (_u *OrganizationUpdateOne) SetSuspended(v bool) *OrganizationUpdateOne { _u.mutation.SetSuspended(v) @@ -1656,6 +1687,9 @@ func (_u *OrganizationUpdateOne) sqlSave(ctx context.Context) (_node *Organizati if value, ok := _u.mutation.EnableAiAgentCollector(); ok { _spec.SetField(organization.FieldEnableAiAgentCollector, field.TypeBool, value) } + if value, ok := _u.mutation.BlockAttestationsOnReleasedVersions(); ok { + _spec.SetField(organization.FieldBlockAttestationsOnReleasedVersions, field.TypeBool, value) + } if value, ok := _u.mutation.Suspended(); ok { _spec.SetField(organization.FieldSuspended, field.TypeBool, value) } diff --git a/app/controlplane/pkg/data/ent/runtime.go b/app/controlplane/pkg/data/ent/runtime.go index 4a64aa96c..85d4e2b6f 100644 --- a/app/controlplane/pkg/data/ent/runtime.go +++ b/app/controlplane/pkg/data/ent/runtime.go @@ -225,8 +225,12 @@ func init() { organizationDescEnableAiAgentCollector := organizationFields[10].Descriptor() // organization.DefaultEnableAiAgentCollector holds the default value on creation for the enable_ai_agent_collector field. organization.DefaultEnableAiAgentCollector = organizationDescEnableAiAgentCollector.Default.(bool) + // organizationDescBlockAttestationsOnReleasedVersions is the schema descriptor for block_attestations_on_released_versions field. + organizationDescBlockAttestationsOnReleasedVersions := organizationFields[11].Descriptor() + // organization.DefaultBlockAttestationsOnReleasedVersions holds the default value on creation for the block_attestations_on_released_versions field. + organization.DefaultBlockAttestationsOnReleasedVersions = organizationDescBlockAttestationsOnReleasedVersions.Default.(bool) // organizationDescSuspended is the schema descriptor for suspended field. - organizationDescSuspended := organizationFields[11].Descriptor() + organizationDescSuspended := organizationFields[12].Descriptor() // organization.DefaultSuspended holds the default value on creation for the suspended field. organization.DefaultSuspended = organizationDescSuspended.Default.(bool) // organizationDescID is the schema descriptor for id field. diff --git a/app/controlplane/pkg/data/ent/schema/organization.go b/app/controlplane/pkg/data/ent/schema/organization.go index b91b13d50..be2cc386d 100644 --- a/app/controlplane/pkg/data/ent/schema/organization.go +++ b/app/controlplane/pkg/data/ent/schema/organization.go @@ -60,6 +60,8 @@ func (Organization) Fields() []ent.Field { field.Int("api_token_inactivity_threshold_days").Optional().Nillable(), // enable_ai_agent_collector enables automatic AI agent config collection during attestation init field.Bool("enable_ai_agent_collector").Default(false), + // block_attestations_on_released_versions rejects new attestations pushed to project versions that are already released (prerelease == false) + field.Bool("block_attestations_on_released_versions").Default(false), // Suspended orgs are blocked from all operations. field.Bool("suspended").Default(false), } diff --git a/app/controlplane/pkg/data/organization.go b/app/controlplane/pkg/data/organization.go index 0bd651e0d..2919e8246 100644 --- a/app/controlplane/pkg/data/organization.go +++ b/app/controlplane/pkg/data/organization.go @@ -103,6 +103,7 @@ func (r *OrganizationRepo) Update(ctx context.Context, id uuid.UUID, updateOpts SetNillablePreventImplicitWorkflowCreation(updateOpts.PreventImplicitWorkflowCreation). SetNillableRestrictContractCreationToOrgAdmins(updateOpts.RestrictContractCreationToOrgAdmins). SetNillableEnableAiAgentCollector(updateOpts.EnableAIAgentCollector). + SetNillableBlockAttestationsOnReleasedVersions(updateOpts.BlockAttestationsOnReleasedVersions). SetUpdatedAt(time.Now()) if updateOpts.PoliciesAllowedHostnames != nil { @@ -182,6 +183,7 @@ func entOrgToBizOrg(eu *ent.Organization) *biz.Organization { RestrictContractCreationToOrgAdmins: eu.RestrictContractCreationToOrgAdmins, APITokenInactivityThresholdDays: eu.APITokenInactivityThresholdDays, EnableAIAgentCollector: eu.EnableAiAgentCollector, + BlockAttestationsOnReleasedVersions: eu.BlockAttestationsOnReleasedVersions, Suspended: eu.Suspended, } } diff --git a/app/controlplane/pkg/data/workflowrun.go b/app/controlplane/pkg/data/workflowrun.go index f7150cf9f..612b14c92 100644 --- a/app/controlplane/pkg/data/workflowrun.go +++ b/app/controlplane/pkg/data/workflowrun.go @@ -91,14 +91,17 @@ func (r *WorkflowRunRepo) Create(ctx context.Context, opts *biz.WorkflowRunRepoC versionCreated := false // Create version and workflow in a transaction if err = WithTx(ctx, r.data.DB, func(tx *ent.Tx) error { - if version == nil { + markAsLatest := opts.MarkAsLatest != nil && *opts.MarkAsLatest + switch { + case version == nil: version, err = createProjectVersionWithTx(ctx, tx, wf.ProjectID, opts.ProjectVersion, true, opts.MarkAsLatest) if err != nil { return fmt.Errorf("creating version: %w", err) } versionCreated = true - } else if opts.MarkAsLatest != nil && *opts.MarkAsLatest { - // Re-read version inside the transaction with a row lock to avoid promoting a concurrently released version + case opts.BlockReleasedVersions || markAsLatest: + // Re-read the version inside the transaction with a row lock so a + // concurrent release can't slip past these checks. fresh, err := tx.ProjectVersion.Query().ForUpdate(). Where(projectversion.ID(version.ID), projectversion.ProjectID(wf.ProjectID), projectversion.DeletedAtIsNil()). Only(ctx) @@ -106,15 +109,22 @@ func (r *WorkflowRunRepo) Create(ctx context.Context, opts *biz.WorkflowRunRepoC if ent.IsNotFound(err) { return biz.NewErrNotFound("Version") } - return fmt.Errorf("loading version for promotion: %w", err) + return fmt.Errorf("loading version: %w", err) } - if !fresh.Prerelease { - return biz.NewErrValidationStr("cannot promote a released version to latest") + // Reject new attestations against an already-released (immutable) version. + if opts.BlockReleasedVersions && !fresh.Prerelease { + return biz.NewErrReleasedVersionImmutable(fresh.Version) } - if err := promoteVersionToLatestWithTx(ctx, tx, wf.ProjectID, fresh.ID); err != nil { - return fmt.Errorf("promoting version to latest: %w", err) + if markAsLatest { + if !fresh.Prerelease { + return biz.NewErrValidationStr("cannot promote a released version to latest") + } + + if err := promoteVersionToLatestWithTx(ctx, tx, wf.ProjectID, fresh.ID); err != nil { + return fmt.Errorf("promoting version to latest: %w", err) + } } } @@ -241,11 +251,15 @@ func (r *WorkflowRunRepo) FindByIDInOrg(ctx context.Context, orgID, id uuid.UUID // SaveAttestationBundle persists the attestation digest on the workflow run and the bundle bytes // in the linked attestation row within a single transaction. -func (r *WorkflowRunRepo) SaveAttestationBundle(ctx context.Context, id uuid.UUID, digest string, bundle []byte) error { +func (r *WorkflowRunRepo) SaveAttestationBundle(ctx context.Context, id uuid.UUID, digest string, bundle []byte, blockReleasedVersions bool) error { ctx, span := otelx.Start(ctx, workflowRunRepoTracer, "WorkflowRunRepo.SaveAttestationBundle") defer span.End() return WithTx(ctx, r.data.DB, func(tx *ent.Tx) error { + if err := assertVersionAcceptsAttestationWithTx(ctx, tx, id, blockReleasedVersions); err != nil { + return err + } + if err := tx.WorkflowRun.UpdateOneID(id).SetAttestationDigest(digest).Exec(ctx); err != nil { if ent.IsNotFound(err) { return biz.NewErrNotFound(fmt.Sprintf("workflow run with id %s not found", id)) @@ -260,13 +274,52 @@ func (r *WorkflowRunRepo) SaveAttestationBundle(ctx context.Context, id uuid.UUI }) } -func (r *WorkflowRunRepo) SaveAttestationDigest(ctx context.Context, id uuid.UUID, digest string) error { - if err := r.data.DB.WorkflowRun.UpdateOneID(id).SetAttestationDigest(digest).Exec(ctx); err != nil { +func (r *WorkflowRunRepo) SaveAttestationDigest(ctx context.Context, id uuid.UUID, digest string, blockReleasedVersions bool) error { + ctx, span := otelx.Start(ctx, workflowRunRepoTracer, "WorkflowRunRepo.SaveAttestationDigest") + defer span.End() + + return WithTx(ctx, r.data.DB, func(tx *ent.Tx) error { + if err := assertVersionAcceptsAttestationWithTx(ctx, tx, id, blockReleasedVersions); err != nil { + return err + } + + if err := tx.WorkflowRun.UpdateOneID(id).SetAttestationDigest(digest).Exec(ctx); err != nil { + if ent.IsNotFound(err) { + return biz.NewErrNotFound(fmt.Sprintf("workflow run with id %s not found", id)) + } + return err + } + return nil + }) +} + +// assertVersionAcceptsAttestationWithTx rejects persisting an attestation when +// blockReleasedVersions is set and the run's project version is already +// released (prerelease == false). It locks the version row (FOR UPDATE) so the +// check is atomic with the attestation write that follows in the same +// transaction, serializing against a concurrent release. It is a no-op when +// blockReleasedVersions is false. +func assertVersionAcceptsAttestationWithTx(ctx context.Context, tx *ent.Tx, runID uuid.UUID, blockReleasedVersions bool) error { + if !blockReleasedVersions { + return nil + } + + version, err := tx.WorkflowRun.Query(). + Where(workflowrun.ID(runID)). + QueryVersion(). + ForUpdate(). + Only(ctx) + if err != nil { if ent.IsNotFound(err) { - return biz.NewErrNotFound(fmt.Sprintf("workflow run with id %s not found", id)) + return biz.NewErrNotFound(fmt.Sprintf("workflow run with id %s not found", runID)) } - return err + return fmt.Errorf("locking project version: %w", err) + } + + if !version.Prerelease { + return biz.NewErrReleasedVersionImmutable(version.Version) } + return nil }