From 9a555bc4a80da7bc269e13e6034f7e6d6703d44f Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Mon, 8 Jun 2026 23:25:48 +0200 Subject: [PATCH 1/4] feat: org setting to block attestations on released versions Add an opt-in organization-level setting, block_attestations_on_released_versions, that rejects new attestations targeting project versions that are already released (prerelease == false). Default is false, preserving current behavior. Enforcement happens at two points: at attestation init, transactionally with a row lock when resolving the project version, providing fail-fast feedback before any work is done; and at push in SaveAttestation, which acts as the authoritative gate closing the window where a version could be released between init and push. Both return a dedicated typed error mapped to a FailedPrecondition gRPC code so the CLI surfaces a clear message. The setting is exposed through the organization settings API and the chainloop organization update CLI command. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 146f4ecb-bdc9-4562-8b1b-2481ea10d7bf --- app/cli/cmd/organization_update.go | 20 +-- app/cli/documentation/cli-reference.mdx | 17 +-- app/cli/pkg/action/membership_list.go | 30 ++--- app/cli/pkg/action/org_update.go | 3 + .../api/controlplane/v1/organization.pb.go | 21 +++- .../api/controlplane/v1/organization.proto | 3 + .../controlplane/v1/response_messages.pb.go | 18 ++- .../controlplane/v1/response_messages.proto | 2 + .../frontend/controlplane/v1/organization.ts | 23 +++- .../controlplane/v1/response_messages.ts | 19 +++ .../controlplane.v1.OrgItem.jsonschema.json | 8 ++ .../controlplane.v1.OrgItem.schema.json | 8 ++ ...zationServiceUpdateRequest.jsonschema.json | 8 ++ ...ganizationServiceUpdateRequest.schema.json | 8 ++ app/controlplane/cmd/wire_gen.go | 1 + .../internal/service/attestation.go | 1 + app/controlplane/internal/service/context.go | 1 + .../internal/service/organization.go | 1 + app/controlplane/internal/service/service.go | 4 +- app/controlplane/pkg/biz/errors.go | 22 +++- app/controlplane/pkg/biz/organization.go | 3 + .../pkg/biz/testhelpers/wire_gen.go | 1 + app/controlplane/pkg/biz/workflowrun.go | 51 ++++++++ .../pkg/biz/workflowrun_integration_test.go | 115 ++++++++++++++++++ .../ent/migrate/migrations/20260608210839.sql | 2 + .../pkg/data/ent/migrate/migrations/atlas.sum | 3 +- .../pkg/data/ent/migrate/schema.go | 1 + app/controlplane/pkg/data/ent/mutation.go | 56 ++++++++- app/controlplane/pkg/data/ent/organization.go | 13 +- .../pkg/data/ent/organization/organization.go | 10 ++ .../pkg/data/ent/organization/where.go | 15 +++ .../pkg/data/ent/organization_create.go | 65 ++++++++++ .../pkg/data/ent/organization_update.go | 34 ++++++ app/controlplane/pkg/data/ent/runtime.go | 6 +- .../pkg/data/ent/schema/organization.go | 2 + app/controlplane/pkg/data/organization.go | 2 + app/controlplane/pkg/data/workflowrun.go | 26 ++-- 37 files changed, 570 insertions(+), 53 deletions(-) create mode 100644 app/controlplane/pkg/data/ent/migrate/migrations/20260608210839.sql 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/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/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/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..030a3acc3 100644 --- a/app/controlplane/pkg/biz/workflowrun.go +++ b/app/controlplane/pkg/biz/workflowrun.go @@ -116,6 +116,7 @@ type WorkflowRunRepo interface { type WorkflowRunUseCase struct { wfRunRepo WorkflowRunRepo wfRepo WorkflowRepo + orgRepo OrganizationRepo logger *log.Helper auditorUC *AuditorUseCase @@ -128,6 +129,7 @@ type WorkflowRunUseCase struct { type WorkflowRunUseCaseOpts struct { WfrRepo WorkflowRunRepo WfRepo WorkflowRepo + OrgRepo OrganizationRepo SigningUC *SigningUseCase AuditorUC *AuditorUseCase Logger log.Logger @@ -145,6 +147,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 +238,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 +252,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 +312,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 +381,34 @@ func WithSkipBundlePersistence() SaveAttestationOption { } } +// enforceReleasedVersionImmutability rejects pushing an attestation to a +// project version that is already released (prerelease == false) when the +// owning organization has enabled BlockAttestationsOnReleasedVersions. It is a +// no-op when the setting is disabled or the run lacks the required context. +func (uc *WorkflowRunUseCase) enforceReleasedVersionImmutability(ctx context.Context, run *WorkflowRun) error { + if run.Workflow == nil || run.ProjectVersion == nil { + return nil + } + + // A prerelease version is still mutable, no need to look up the org setting. + if run.ProjectVersion.Prerelease { + return nil + } + + org, err := uc.orgRepo.FindByID(ctx, run.Workflow.OrgID) + if err != nil { + return fmt.Errorf("finding organization: %w", err) + } else if org == nil { + return NewErrNotFound("organization") + } + + if org.BlockAttestationsOnReleasedVersions { + return NewErrReleasedVersionImmutable(run.ProjectVersion.Version) + } + + return 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 +423,19 @@ func (uc *WorkflowRunUseCase) SaveAttestation(ctx context.Context, id string, bu return nil, NewErrInvalidUUID(err) } + // Resolve the run so we can enforce the released-version immutability guard + // before doing any expensive work or persistence. + 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") + } + + if err := uc.enforceReleasedVersionImmutability(ctx, run); 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)) 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..ff1a34613 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) + } } } From 32d9c25196773fc526e4503aa49a3f03efa8169b Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Mon, 8 Jun 2026 23:34:36 +0200 Subject: [PATCH 2/4] feat(cli): show block-attestations-on-released-versions in org describe Surface the org-level setting in `chainloop organization describe` so admins can confirm whether the guard is enabled. Rendered only when on, matching the existing selective output style. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 146f4ecb-bdc9-4562-8b1b-2481ea10d7bf --- app/cli/cmd/organization_describe.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/cli/cmd/organization_describe.go b/app/cli/cmd/organization_describe.go index 6291cffcf..5c2d4655a 100644 --- a/app/cli/cmd/organization_describe.go +++ b/app/cli/cmd/organization_describe.go @@ -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}) } From 1729efbeb6802348e61f039bd55c54734e883264 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Mon, 8 Jun 2026 23:37:55 +0200 Subject: [PATCH 3/4] feat(cli): render FailedPrecondition errors cleanly Add codes.FailedPrecondition to the set of gRPC codes whose server message is surfaced verbatim, stripping the wrapped chain prefix. This makes the released-version immutability error read as a clear, actionable message on both attestation init and push. Also align the organization describe output to use a colon separator for the policy strategy line, consistent with the other settings rows. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 146f4ecb-bdc9-4562-8b1b-2481ea10d7bf --- app/cli/cmd/organization_describe.go | 2 +- app/cli/main.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/cli/cmd/organization_describe.go b/app/cli/cmd/organization_describe.go index 5c2d4655a..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, ", ")) } 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() From a70e3abf258672cd8f3a41d59d659a9af1d13fd0 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Tue, 9 Jun 2026 08:33:51 +0200 Subject: [PATCH 4/4] fix(controlplane): make released-version push check atomic The push-side guard previously read the project version's release status and then persisted the attestation in separate operations, leaving a TOCTOU window where a concurrent release could be bypassed (identified by cubic). Move the prerelease check into the persistence transaction and lock the project version row (FOR UPDATE) so the check and the attestation write are atomic and serialized against a concurrent release. The organization setting is resolved up front and threaded into the repo persistence methods, matching how the init path already passes the flag down. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: 146f4ecb-bdc9-4562-8b1b-2481ea10d7bf --- .../pkg/biz/mocks/WorkflowRunRepo.go | 44 +++++++++------ .../pkg/biz/referrer_integration_test.go | 10 ++-- app/controlplane/pkg/biz/workflowrun.go | 53 +++++++++---------- app/controlplane/pkg/data/workflowrun.go | 53 +++++++++++++++++-- 4 files changed, 107 insertions(+), 53 deletions(-) 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/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/workflowrun.go b/app/controlplane/pkg/biz/workflowrun.go index 030a3acc3..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) @@ -381,32 +386,24 @@ func WithSkipBundlePersistence() SaveAttestationOption { } } -// enforceReleasedVersionImmutability rejects pushing an attestation to a -// project version that is already released (prerelease == false) when the -// owning organization has enabled BlockAttestationsOnReleasedVersions. It is a -// no-op when the setting is disabled or the run lacks the required context. -func (uc *WorkflowRunUseCase) enforceReleasedVersionImmutability(ctx context.Context, run *WorkflowRun) error { - if run.Workflow == nil || run.ProjectVersion == nil { - return nil - } - - // A prerelease version is still mutable, no need to look up the org setting. - if run.ProjectVersion.Prerelease { - return nil +// 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 fmt.Errorf("finding organization: %w", err) + return false, fmt.Errorf("finding organization: %w", err) } else if org == nil { - return NewErrNotFound("organization") + return false, NewErrNotFound("organization") } - if org.BlockAttestationsOnReleasedVersions { - return NewErrReleasedVersionImmutable(run.ProjectVersion.Version) - } - - return nil + return org.BlockAttestationsOnReleasedVersions, nil } func (uc *WorkflowRunUseCase) SaveAttestation(ctx context.Context, id string, bundle []byte, opts ...SaveAttestationOption) (*v1.Hash, error) { @@ -423,8 +420,9 @@ func (uc *WorkflowRunUseCase) SaveAttestation(ctx context.Context, id string, bu return nil, NewErrInvalidUUID(err) } - // Resolve the run so we can enforce the released-version immutability guard - // before doing any expensive work or persistence. + // 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) @@ -432,7 +430,8 @@ func (uc *WorkflowRunUseCase) SaveAttestation(ctx context.Context, id string, bu return nil, NewErrNotFound("workflow run") } - if err := uc.enforceReleasedVersionImmutability(ctx, run); err != nil { + blockReleasedVersions, err := uc.orgBlocksReleasedVersions(ctx, run) + if err != nil { return nil, err } @@ -484,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/data/workflowrun.go b/app/controlplane/pkg/data/workflowrun.go index ff1a34613..612b14c92 100644 --- a/app/controlplane/pkg/data/workflowrun.go +++ b/app/controlplane/pkg/data/workflowrun.go @@ -251,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)) @@ -270,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 }