diff --git a/CLAUDE.md b/CLAUDE.md index 8dc736a3c..b7b050240 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -254,3 +254,6 @@ All commits must meet these criteria: Code reviews are required for all submissions via GitHub pull requests. - make sure golang code is always formatted and golang-ci-lint is run + +- I do not want you to be in the co-author signoff +- make sure all go code is go-fmt \ No newline at end of file diff --git a/app/cli/cmd/organization.go b/app/cli/cmd/organization.go index 6bc72bda3..154bca1fc 100644 --- a/app/cli/cmd/organization.go +++ b/app/cli/cmd/organization.go @@ -32,6 +32,7 @@ func newOrganizationCmd() *cobra.Command { newOrganizationUpdateCmd(), newOrganizationSet(), newOrganizationLeaveCmd(), + newOrganizationDeleteCmd(), newOrganizationDescribeCmd(), newOrganizationAPITokenCmd(), newOrganizationMemberCmd(), diff --git a/app/cli/cmd/organization_delete.go b/app/cli/cmd/organization_delete.go new file mode 100644 index 000000000..d8e127ffc --- /dev/null +++ b/app/cli/cmd/organization_delete.go @@ -0,0 +1,59 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "fmt" + + "github.com/chainloop-dev/chainloop/app/cli/internal/action" + "github.com/spf13/cobra" +) + +func newOrganizationDeleteCmd() *cobra.Command { + var orgName string + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete an organization", + Long: "Delete an organization. Only organization owners can delete an organization.", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + fmt.Printf("You are about to delete the organization %q\n", orgName) + fmt.Println("This action will permanently delete the organization and all its data.") + + // Ask for confirmation + if err := confirmDeletion(); err != nil { + return err + } + + if err := action.NewOrganizationDelete(actionOpts).Run(ctx, orgName); err != nil { + return fmt.Errorf("deleting organization: %w", err) + } + + // Clear local state if we just deleted the current organization + if err := setLocalOrganization(""); err != nil { + return fmt.Errorf("writing config file: %w", err) + } + + logger.Info().Str("organization", orgName).Msg("Organization deleted") + return nil + }, + } + + cmd.Flags().StringVar(&orgName, "name", "", "organization name") + cobra.CheckErr(cmd.MarkFlagRequired("name")) + return cmd +} diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 61e361423..bc59f8b2a 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -2193,6 +2193,41 @@ Options inherited from parent commands -y, --yes Skip confirmation ``` +### chainloop organization delete + +Delete an organization + +Synopsis + +Delete an organization. Only organization owners can delete an organization. + +``` +chainloop organization delete [flags] +``` + +Options + +``` +-h, --help help for delete +--name string organization name +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "api.cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "api.cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + ### chainloop organization describe Describe the current organization diff --git a/app/cli/internal/action/organization_delete.go b/app/cli/internal/action/organization_delete.go new file mode 100644 index 000000000..2637a2fce --- /dev/null +++ b/app/cli/internal/action/organization_delete.go @@ -0,0 +1,36 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package action + +import ( + "context" + + pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1" +) + +type OrganizationDelete struct { + cfg *ActionsOpts +} + +func NewOrganizationDelete(cfg *ActionsOpts) *OrganizationDelete { + return &OrganizationDelete{cfg} +} + +func (action *OrganizationDelete) Run(ctx context.Context, orgName string) error { + client := pb.NewOrganizationServiceClient(action.cfg.CPConnection) + _, err := client.Delete(ctx, &pb.OrganizationServiceDeleteRequest{Name: orgName}) + return err +} diff --git a/app/controlplane/api/controlplane/v1/organization.pb.go b/app/controlplane/api/controlplane/v1/organization.pb.go index 04062d9c8..ca4d311fb 100644 --- a/app/controlplane/api/controlplane/v1/organization.pb.go +++ b/app/controlplane/api/controlplane/v1/organization.pb.go @@ -578,6 +578,91 @@ func (x *OrganizationServiceUpdateResponse) GetResult() *OrgItem { return nil } +type OrganizationServiceDeleteRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` +} + +func (x *OrganizationServiceDeleteRequest) Reset() { + *x = OrganizationServiceDeleteRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_controlplane_v1_organization_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *OrganizationServiceDeleteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OrganizationServiceDeleteRequest) ProtoMessage() {} + +func (x *OrganizationServiceDeleteRequest) ProtoReflect() protoreflect.Message { + mi := &file_controlplane_v1_organization_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OrganizationServiceDeleteRequest.ProtoReflect.Descriptor instead. +func (*OrganizationServiceDeleteRequest) Descriptor() ([]byte, []int) { + return file_controlplane_v1_organization_proto_rawDescGZIP(), []int{10} +} + +func (x *OrganizationServiceDeleteRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type OrganizationServiceDeleteResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *OrganizationServiceDeleteResponse) Reset() { + *x = OrganizationServiceDeleteResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_controlplane_v1_organization_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *OrganizationServiceDeleteResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OrganizationServiceDeleteResponse) ProtoMessage() {} + +func (x *OrganizationServiceDeleteResponse) ProtoReflect() protoreflect.Message { + mi := &file_controlplane_v1_organization_proto_msgTypes[11] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OrganizationServiceDeleteResponse.ProtoReflect.Descriptor instead. +func (*OrganizationServiceDeleteResponse) Descriptor() ([]byte, []int) { + return file_controlplane_v1_organization_proto_rawDescGZIP(), []int{11} +} + var File_controlplane_v1_organization_proto protoreflect.FileDescriptor var file_controlplane_v1_organization_proto_rawDesc = []byte{ @@ -682,55 +767,68 @@ var file_controlplane_v1_organization_proto_rawDesc = []byte{ 0x73, 0x65, 0x12, 0x30, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x72, 0x67, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x06, 0x72, 0x65, - 0x73, 0x75, 0x6c, 0x74, 0x32, 0xa4, 0x05, 0x0a, 0x13, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x6f, 0x0a, 0x06, - 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x12, 0x31, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, - 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x72, 0x65, 0x61, - 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x32, 0x2e, 0x63, 0x6f, 0x6e, 0x74, - 0x72, 0x6f, 0x6c, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x72, 0x67, 0x61, - 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, - 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6f, 0x0a, - 0x06, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x31, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, - 0x6c, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, - 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x55, 0x70, 0x64, - 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x32, 0x2e, 0x63, 0x6f, 0x6e, - 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x72, 0x67, - 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x8a, - 0x01, 0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x73, 0x68, 0x69, - 0x70, 0x73, 0x12, 0x3a, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x6c, 0x61, 0x6e, - 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x65, 0x6d, 0x62, - 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3b, - 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e, 0x76, 0x31, - 0x2e, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, - 0x76, 0x69, 0x63, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x73, 0x68, - 0x69, 0x70, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x8d, 0x01, 0x0a, 0x10, - 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, - 0x12, 0x3b, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e, - 0x76, 0x31, 0x2e, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, - 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x65, 0x6d, 0x62, - 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3c, 0x2e, + 0x73, 0x75, 0x6c, 0x74, 0x22, 0x3f, 0x0a, 0x20, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, + 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x23, 0x0a, 0x21, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x44, 0x65, 0x6c, 0x65, + 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x95, 0x06, 0x0a, 0x13, 0x4f, + 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, + 0x63, 0x65, 0x12, 0x6f, 0x0a, 0x06, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x12, 0x31, 0x2e, 0x63, + 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4f, + 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, + 0x63, 0x65, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x32, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e, 0x76, + 0x31, 0x2e, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, + 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x6f, 0x0a, 0x06, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x31, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x73, - 0x68, 0x69, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x8d, 0x01, 0x0a, 0x10, - 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, - 0x12, 0x3b, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e, + 0x69, 0x63, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x32, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, - 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x6d, 0x62, - 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3c, 0x2e, - 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e, 0x76, 0x31, 0x2e, - 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x73, - 0x68, 0x69, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x4c, 0x5a, 0x4a, 0x67, - 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x6c, - 0x6f, 0x6f, 0x70, 0x2d, 0x64, 0x65, 0x76, 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x6c, 0x6f, 0x6f, - 0x70, 0x2f, 0x61, 0x70, 0x70, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x6c, 0x61, - 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x6c, - 0x61, 0x6e, 0x65, 0x2f, 0x76, 0x31, 0x3b, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x33, + 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6f, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x31, + 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e, 0x76, 0x31, + 0x2e, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x32, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x6c, 0x61, 0x6e, 0x65, + 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x8a, 0x01, 0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x65, + 0x6d, 0x62, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x73, 0x12, 0x3a, 0x2e, 0x63, 0x6f, 0x6e, 0x74, + 0x72, 0x6f, 0x6c, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x72, 0x67, 0x61, + 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x4c, + 0x69, 0x73, 0x74, 0x4d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3b, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x70, + 0x6c, 0x61, 0x6e, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x4c, 0x69, 0x73, 0x74, 0x4d, + 0x65, 0x6d, 0x62, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x8d, 0x01, 0x0a, 0x10, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x65, 0x6d, + 0x62, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x12, 0x3b, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, + 0x6c, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, + 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x44, 0x65, 0x6c, + 0x65, 0x74, 0x65, 0x4d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3c, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x6c, + 0x61, 0x6e, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, + 0x4d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x8d, 0x01, 0x0a, 0x10, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x6d, + 0x62, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x12, 0x3b, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, + 0x6c, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, + 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x55, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x4d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3c, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x6c, + 0x61, 0x6e, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x4d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x42, 0x4c, 0x5a, 0x4a, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, + 0x2f, 0x63, 0x68, 0x61, 0x69, 0x6e, 0x6c, 0x6f, 0x6f, 0x70, 0x2d, 0x64, 0x65, 0x76, 0x2f, 0x63, + 0x68, 0x61, 0x69, 0x6e, 0x6c, 0x6f, 0x6f, 0x70, 0x2f, 0x61, 0x70, 0x70, 0x2f, 0x63, 0x6f, 0x6e, + 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x63, 0x6f, + 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2f, 0x76, 0x31, 0x3b, 0x76, 0x31, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -745,7 +843,7 @@ func file_controlplane_v1_organization_proto_rawDescGZIP() []byte { return file_controlplane_v1_organization_proto_rawDescData } -var file_controlplane_v1_organization_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_controlplane_v1_organization_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_controlplane_v1_organization_proto_goTypes = []interface{}{ (*OrganizationServiceListMembershipsRequest)(nil), // 0: controlplane.v1.OrganizationServiceListMembershipsRequest (*OrganizationServiceListMembershipsResponse)(nil), // 1: controlplane.v1.OrganizationServiceListMembershipsResponse @@ -757,33 +855,37 @@ var file_controlplane_v1_organization_proto_goTypes = []interface{}{ (*OrganizationServiceCreateResponse)(nil), // 7: controlplane.v1.OrganizationServiceCreateResponse (*OrganizationServiceUpdateRequest)(nil), // 8: controlplane.v1.OrganizationServiceUpdateRequest (*OrganizationServiceUpdateResponse)(nil), // 9: controlplane.v1.OrganizationServiceUpdateResponse - (MembershipRole)(0), // 10: controlplane.v1.MembershipRole - (*OffsetPaginationRequest)(nil), // 11: controlplane.v1.OffsetPaginationRequest - (*OrgMembershipItem)(nil), // 12: controlplane.v1.OrgMembershipItem - (*OffsetPaginationResponse)(nil), // 13: controlplane.v1.OffsetPaginationResponse - (*OrgItem)(nil), // 14: controlplane.v1.OrgItem + (*OrganizationServiceDeleteRequest)(nil), // 10: controlplane.v1.OrganizationServiceDeleteRequest + (*OrganizationServiceDeleteResponse)(nil), // 11: controlplane.v1.OrganizationServiceDeleteResponse + (MembershipRole)(0), // 12: controlplane.v1.MembershipRole + (*OffsetPaginationRequest)(nil), // 13: controlplane.v1.OffsetPaginationRequest + (*OrgMembershipItem)(nil), // 14: controlplane.v1.OrgMembershipItem + (*OffsetPaginationResponse)(nil), // 15: controlplane.v1.OffsetPaginationResponse + (*OrgItem)(nil), // 16: controlplane.v1.OrgItem } var file_controlplane_v1_organization_proto_depIdxs = []int32{ - 10, // 0: controlplane.v1.OrganizationServiceListMembershipsRequest.role:type_name -> controlplane.v1.MembershipRole - 11, // 1: controlplane.v1.OrganizationServiceListMembershipsRequest.pagination:type_name -> controlplane.v1.OffsetPaginationRequest - 12, // 2: controlplane.v1.OrganizationServiceListMembershipsResponse.result:type_name -> controlplane.v1.OrgMembershipItem - 13, // 3: controlplane.v1.OrganizationServiceListMembershipsResponse.pagination:type_name -> controlplane.v1.OffsetPaginationResponse - 10, // 4: controlplane.v1.OrganizationServiceUpdateMembershipRequest.role:type_name -> controlplane.v1.MembershipRole - 12, // 5: controlplane.v1.OrganizationServiceUpdateMembershipResponse.result:type_name -> controlplane.v1.OrgMembershipItem - 14, // 6: controlplane.v1.OrganizationServiceCreateResponse.result:type_name -> controlplane.v1.OrgItem - 14, // 7: controlplane.v1.OrganizationServiceUpdateResponse.result:type_name -> controlplane.v1.OrgItem + 12, // 0: controlplane.v1.OrganizationServiceListMembershipsRequest.role:type_name -> controlplane.v1.MembershipRole + 13, // 1: controlplane.v1.OrganizationServiceListMembershipsRequest.pagination:type_name -> controlplane.v1.OffsetPaginationRequest + 14, // 2: controlplane.v1.OrganizationServiceListMembershipsResponse.result:type_name -> controlplane.v1.OrgMembershipItem + 15, // 3: controlplane.v1.OrganizationServiceListMembershipsResponse.pagination:type_name -> controlplane.v1.OffsetPaginationResponse + 12, // 4: controlplane.v1.OrganizationServiceUpdateMembershipRequest.role:type_name -> controlplane.v1.MembershipRole + 14, // 5: controlplane.v1.OrganizationServiceUpdateMembershipResponse.result:type_name -> controlplane.v1.OrgMembershipItem + 16, // 6: controlplane.v1.OrganizationServiceCreateResponse.result:type_name -> controlplane.v1.OrgItem + 16, // 7: controlplane.v1.OrganizationServiceUpdateResponse.result:type_name -> controlplane.v1.OrgItem 6, // 8: controlplane.v1.OrganizationService.Create:input_type -> controlplane.v1.OrganizationServiceCreateRequest 8, // 9: controlplane.v1.OrganizationService.Update:input_type -> controlplane.v1.OrganizationServiceUpdateRequest - 0, // 10: controlplane.v1.OrganizationService.ListMemberships:input_type -> controlplane.v1.OrganizationServiceListMembershipsRequest - 2, // 11: controlplane.v1.OrganizationService.DeleteMembership:input_type -> controlplane.v1.OrganizationServiceDeleteMembershipRequest - 4, // 12: controlplane.v1.OrganizationService.UpdateMembership:input_type -> controlplane.v1.OrganizationServiceUpdateMembershipRequest - 7, // 13: controlplane.v1.OrganizationService.Create:output_type -> controlplane.v1.OrganizationServiceCreateResponse - 9, // 14: controlplane.v1.OrganizationService.Update:output_type -> controlplane.v1.OrganizationServiceUpdateResponse - 1, // 15: controlplane.v1.OrganizationService.ListMemberships:output_type -> controlplane.v1.OrganizationServiceListMembershipsResponse - 3, // 16: controlplane.v1.OrganizationService.DeleteMembership:output_type -> controlplane.v1.OrganizationServiceDeleteMembershipResponse - 5, // 17: controlplane.v1.OrganizationService.UpdateMembership:output_type -> controlplane.v1.OrganizationServiceUpdateMembershipResponse - 13, // [13:18] is the sub-list for method output_type - 8, // [8:13] is the sub-list for method input_type + 10, // 10: controlplane.v1.OrganizationService.Delete:input_type -> controlplane.v1.OrganizationServiceDeleteRequest + 0, // 11: controlplane.v1.OrganizationService.ListMemberships:input_type -> controlplane.v1.OrganizationServiceListMembershipsRequest + 2, // 12: controlplane.v1.OrganizationService.DeleteMembership:input_type -> controlplane.v1.OrganizationServiceDeleteMembershipRequest + 4, // 13: controlplane.v1.OrganizationService.UpdateMembership:input_type -> controlplane.v1.OrganizationServiceUpdateMembershipRequest + 7, // 14: controlplane.v1.OrganizationService.Create:output_type -> controlplane.v1.OrganizationServiceCreateResponse + 9, // 15: controlplane.v1.OrganizationService.Update:output_type -> controlplane.v1.OrganizationServiceUpdateResponse + 11, // 16: controlplane.v1.OrganizationService.Delete:output_type -> controlplane.v1.OrganizationServiceDeleteResponse + 1, // 17: controlplane.v1.OrganizationService.ListMemberships:output_type -> controlplane.v1.OrganizationServiceListMembershipsResponse + 3, // 18: controlplane.v1.OrganizationService.DeleteMembership:output_type -> controlplane.v1.OrganizationServiceDeleteMembershipResponse + 5, // 19: controlplane.v1.OrganizationService.UpdateMembership:output_type -> controlplane.v1.OrganizationServiceUpdateMembershipResponse + 14, // [14:20] is the sub-list for method output_type + 8, // [8:14] is the sub-list for method input_type 8, // [8:8] is the sub-list for extension type_name 8, // [8:8] is the sub-list for extension extendee 0, // [0:8] is the sub-list for field type_name @@ -917,6 +1019,30 @@ func file_controlplane_v1_organization_proto_init() { return nil } } + file_controlplane_v1_organization_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*OrganizationServiceDeleteRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_controlplane_v1_organization_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*OrganizationServiceDeleteResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } file_controlplane_v1_organization_proto_msgTypes[0].OneofWrappers = []interface{}{} file_controlplane_v1_organization_proto_msgTypes[8].OneofWrappers = []interface{}{} @@ -926,7 +1052,7 @@ func file_controlplane_v1_organization_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_controlplane_v1_organization_proto_rawDesc, NumEnums: 0, - NumMessages: 10, + NumMessages: 12, NumExtensions: 0, NumServices: 1, }, diff --git a/app/controlplane/api/controlplane/v1/organization.proto b/app/controlplane/api/controlplane/v1/organization.proto index e515f017f..2637d3c2f 100644 --- a/app/controlplane/api/controlplane/v1/organization.proto +++ b/app/controlplane/api/controlplane/v1/organization.proto @@ -26,6 +26,9 @@ option go_package = "github.com/chainloop-dev/chainloop/app/controlplane/api/con service OrganizationService { rpc Create(OrganizationServiceCreateRequest) returns (OrganizationServiceCreateResponse); rpc Update(OrganizationServiceUpdateRequest) returns (OrganizationServiceUpdateResponse); + // Delete an organization + // Only organization owners can delete the organization + rpc Delete(OrganizationServiceDeleteRequest) returns (OrganizationServiceDeleteResponse); // List members in the organization rpc ListMemberships(OrganizationServiceListMembershipsRequest) returns (OrganizationServiceListMembershipsResponse); @@ -95,3 +98,9 @@ message OrganizationServiceUpdateRequest { message OrganizationServiceUpdateResponse { OrgItem result = 1; } + +message OrganizationServiceDeleteRequest { + string name = 1 [(buf.validate.field).string.min_len = 1]; +} + +message OrganizationServiceDeleteResponse {} diff --git a/app/controlplane/api/controlplane/v1/organization_grpc.pb.go b/app/controlplane/api/controlplane/v1/organization_grpc.pb.go index 3b94b7b8b..c690303b1 100644 --- a/app/controlplane/api/controlplane/v1/organization_grpc.pb.go +++ b/app/controlplane/api/controlplane/v1/organization_grpc.pb.go @@ -36,6 +36,7 @@ const _ = grpc.SupportPackageIsVersion7 const ( OrganizationService_Create_FullMethodName = "/controlplane.v1.OrganizationService/Create" OrganizationService_Update_FullMethodName = "/controlplane.v1.OrganizationService/Update" + OrganizationService_Delete_FullMethodName = "/controlplane.v1.OrganizationService/Delete" OrganizationService_ListMemberships_FullMethodName = "/controlplane.v1.OrganizationService/ListMemberships" OrganizationService_DeleteMembership_FullMethodName = "/controlplane.v1.OrganizationService/DeleteMembership" OrganizationService_UpdateMembership_FullMethodName = "/controlplane.v1.OrganizationService/UpdateMembership" @@ -47,6 +48,9 @@ const ( type OrganizationServiceClient interface { Create(ctx context.Context, in *OrganizationServiceCreateRequest, opts ...grpc.CallOption) (*OrganizationServiceCreateResponse, error) Update(ctx context.Context, in *OrganizationServiceUpdateRequest, opts ...grpc.CallOption) (*OrganizationServiceUpdateResponse, error) + // Delete an organization + // Only organization owners can delete the organization + Delete(ctx context.Context, in *OrganizationServiceDeleteRequest, opts ...grpc.CallOption) (*OrganizationServiceDeleteResponse, error) // List members in the organization ListMemberships(ctx context.Context, in *OrganizationServiceListMembershipsRequest, opts ...grpc.CallOption) (*OrganizationServiceListMembershipsResponse, error) // Delete member from the organization @@ -82,6 +86,15 @@ func (c *organizationServiceClient) Update(ctx context.Context, in *Organization return out, nil } +func (c *organizationServiceClient) Delete(ctx context.Context, in *OrganizationServiceDeleteRequest, opts ...grpc.CallOption) (*OrganizationServiceDeleteResponse, error) { + out := new(OrganizationServiceDeleteResponse) + err := c.cc.Invoke(ctx, OrganizationService_Delete_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *organizationServiceClient) ListMemberships(ctx context.Context, in *OrganizationServiceListMembershipsRequest, opts ...grpc.CallOption) (*OrganizationServiceListMembershipsResponse, error) { out := new(OrganizationServiceListMembershipsResponse) err := c.cc.Invoke(ctx, OrganizationService_ListMemberships_FullMethodName, in, out, opts...) @@ -115,6 +128,9 @@ func (c *organizationServiceClient) UpdateMembership(ctx context.Context, in *Or type OrganizationServiceServer interface { Create(context.Context, *OrganizationServiceCreateRequest) (*OrganizationServiceCreateResponse, error) Update(context.Context, *OrganizationServiceUpdateRequest) (*OrganizationServiceUpdateResponse, error) + // Delete an organization + // Only organization owners can delete the organization + Delete(context.Context, *OrganizationServiceDeleteRequest) (*OrganizationServiceDeleteResponse, error) // List members in the organization ListMemberships(context.Context, *OrganizationServiceListMembershipsRequest) (*OrganizationServiceListMembershipsResponse, error) // Delete member from the organization @@ -135,6 +151,9 @@ func (UnimplementedOrganizationServiceServer) Create(context.Context, *Organizat func (UnimplementedOrganizationServiceServer) Update(context.Context, *OrganizationServiceUpdateRequest) (*OrganizationServiceUpdateResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Update not implemented") } +func (UnimplementedOrganizationServiceServer) Delete(context.Context, *OrganizationServiceDeleteRequest) (*OrganizationServiceDeleteResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Delete not implemented") +} func (UnimplementedOrganizationServiceServer) ListMemberships(context.Context, *OrganizationServiceListMembershipsRequest) (*OrganizationServiceListMembershipsResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ListMemberships not implemented") } @@ -193,6 +212,24 @@ func _OrganizationService_Update_Handler(srv interface{}, ctx context.Context, d return interceptor(ctx, in, info, handler) } +func _OrganizationService_Delete_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OrganizationServiceDeleteRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(OrganizationServiceServer).Delete(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: OrganizationService_Delete_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(OrganizationServiceServer).Delete(ctx, req.(*OrganizationServiceDeleteRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _OrganizationService_ListMemberships_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(OrganizationServiceListMembershipsRequest) if err := dec(in); err != nil { @@ -262,6 +299,10 @@ var OrganizationService_ServiceDesc = grpc.ServiceDesc{ MethodName: "Update", Handler: _OrganizationService_Update_Handler, }, + { + MethodName: "Delete", + Handler: _OrganizationService_Delete_Handler, + }, { MethodName: "ListMemberships", Handler: _OrganizationService_ListMemberships_Handler, diff --git a/app/controlplane/api/gen/frontend/controlplane/v1/organization.ts b/app/controlplane/api/gen/frontend/controlplane/v1/organization.ts index 8f729c14b..bed45bdb1 100644 --- a/app/controlplane/api/gen/frontend/controlplane/v1/organization.ts +++ b/app/controlplane/api/gen/frontend/controlplane/v1/organization.ts @@ -82,6 +82,13 @@ export interface OrganizationServiceUpdateResponse { result?: OrgItem; } +export interface OrganizationServiceDeleteRequest { + name: string; +} + +export interface OrganizationServiceDeleteResponse { +} + function createBaseOrganizationServiceListMembershipsRequest(): OrganizationServiceListMembershipsRequest { return { membershipId: undefined, name: undefined, email: undefined, role: undefined, pagination: undefined }; } @@ -827,6 +834,114 @@ export const OrganizationServiceUpdateResponse = { }, }; +function createBaseOrganizationServiceDeleteRequest(): OrganizationServiceDeleteRequest { + return { name: "" }; +} + +export const OrganizationServiceDeleteRequest = { + encode(message: OrganizationServiceDeleteRequest, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.name !== "") { + writer.uint32(10).string(message.name); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): OrganizationServiceDeleteRequest { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseOrganizationServiceDeleteRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + if (tag !== 10) { + break; + } + + message.name = reader.string(); + continue; + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(object: any): OrganizationServiceDeleteRequest { + return { name: isSet(object.name) ? String(object.name) : "" }; + }, + + toJSON(message: OrganizationServiceDeleteRequest): unknown { + const obj: any = {}; + message.name !== undefined && (obj.name = message.name); + return obj; + }, + + create, I>>( + base?: I, + ): OrganizationServiceDeleteRequest { + return OrganizationServiceDeleteRequest.fromPartial(base ?? {}); + }, + + fromPartial, I>>( + object: I, + ): OrganizationServiceDeleteRequest { + const message = createBaseOrganizationServiceDeleteRequest(); + message.name = object.name ?? ""; + return message; + }, +}; + +function createBaseOrganizationServiceDeleteResponse(): OrganizationServiceDeleteResponse { + return {}; +} + +export const OrganizationServiceDeleteResponse = { + encode(_: OrganizationServiceDeleteResponse, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): OrganizationServiceDeleteResponse { + const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseOrganizationServiceDeleteResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skipType(tag & 7); + } + return message; + }, + + fromJSON(_: any): OrganizationServiceDeleteResponse { + return {}; + }, + + toJSON(_: OrganizationServiceDeleteResponse): unknown { + const obj: any = {}; + return obj; + }, + + create, I>>( + base?: I, + ): OrganizationServiceDeleteResponse { + return OrganizationServiceDeleteResponse.fromPartial(base ?? {}); + }, + + fromPartial, I>>( + _: I, + ): OrganizationServiceDeleteResponse { + const message = createBaseOrganizationServiceDeleteResponse(); + return message; + }, +}; + export interface OrganizationService { Create( request: DeepPartial, @@ -836,6 +951,14 @@ export interface OrganizationService { request: DeepPartial, metadata?: grpc.Metadata, ): Promise; + /** + * Delete an organization + * Only organization owners can delete the organization + */ + Delete( + request: DeepPartial, + metadata?: grpc.Metadata, + ): Promise; /** List members in the organization */ ListMemberships( request: DeepPartial, @@ -863,6 +986,7 @@ export class OrganizationServiceClientImpl implements OrganizationService { this.rpc = rpc; this.Create = this.Create.bind(this); this.Update = this.Update.bind(this); + this.Delete = this.Delete.bind(this); this.ListMemberships = this.ListMemberships.bind(this); this.DeleteMembership = this.DeleteMembership.bind(this); this.UpdateMembership = this.UpdateMembership.bind(this); @@ -890,6 +1014,17 @@ export class OrganizationServiceClientImpl implements OrganizationService { ); } + Delete( + request: DeepPartial, + metadata?: grpc.Metadata, + ): Promise { + return this.rpc.unary( + OrganizationServiceDeleteDesc, + OrganizationServiceDeleteRequest.fromPartial(request), + metadata, + ); + } + ListMemberships( request: DeepPartial, metadata?: grpc.Metadata, @@ -972,6 +1107,29 @@ export const OrganizationServiceUpdateDesc: UnaryMethodDefinitionish = { } as any, }; +export const OrganizationServiceDeleteDesc: UnaryMethodDefinitionish = { + methodName: "Delete", + service: OrganizationServiceDesc, + requestStream: false, + responseStream: false, + requestType: { + serializeBinary() { + return OrganizationServiceDeleteRequest.encode(this).finish(); + }, + } as any, + responseType: { + deserializeBinary(data: Uint8Array) { + const value = OrganizationServiceDeleteResponse.decode(data); + return { + ...value, + toObject() { + return value; + }, + }; + }, + } as any, +}; + export const OrganizationServiceListMembershipsDesc: UnaryMethodDefinitionish = { methodName: "ListMemberships", service: OrganizationServiceDesc, diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceDeleteRequest.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceDeleteRequest.jsonschema.json new file mode 100644 index 000000000..71cbdeb07 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceDeleteRequest.jsonschema.json @@ -0,0 +1,13 @@ +{ + "$id": "controlplane.v1.OrganizationServiceDeleteRequest.jsonschema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "name": { + "minLength": 1, + "type": "string" + } + }, + "title": "Organization Service Delete Request", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceDeleteRequest.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceDeleteRequest.schema.json new file mode 100644 index 000000000..359a2e0aa --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceDeleteRequest.schema.json @@ -0,0 +1,13 @@ +{ + "$id": "controlplane.v1.OrganizationServiceDeleteRequest.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "name": { + "minLength": 1, + "type": "string" + } + }, + "title": "Organization Service Delete Request", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceDeleteResponse.jsonschema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceDeleteResponse.jsonschema.json new file mode 100644 index 000000000..6d3170055 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceDeleteResponse.jsonschema.json @@ -0,0 +1,8 @@ +{ + "$id": "controlplane.v1.OrganizationServiceDeleteResponse.jsonschema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": {}, + "title": "Organization Service Delete Response", + "type": "object" +} diff --git a/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceDeleteResponse.schema.json b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceDeleteResponse.schema.json new file mode 100644 index 000000000..a2a6b87e8 --- /dev/null +++ b/app/controlplane/api/gen/jsonschema/controlplane.v1.OrganizationServiceDeleteResponse.schema.json @@ -0,0 +1,8 @@ +{ + "$id": "controlplane.v1.OrganizationServiceDeleteResponse.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": {}, + "title": "Organization Service Delete Response", + "type": "object" +} diff --git a/app/controlplane/internal/service/auth.go b/app/controlplane/internal/service/auth.go index 124779280..21794c91d 100644 --- a/app/controlplane/internal/service/auth.go +++ b/app/controlplane/internal/service/auth.go @@ -464,7 +464,7 @@ func (svc *AuthService) DeleteAccount(ctx context.Context, _ *pb.AuthServiceDele } if err := svc.userUseCase.DeleteUser(ctx, user.ID); err != nil { - return nil, sl.LogAndMaskErr(err, svc.log) + return nil, handleUseCaseErr(err, svc.log) } return &pb.AuthServiceDeleteAccountResponse{}, nil diff --git a/app/controlplane/internal/service/organization.go b/app/controlplane/internal/service/organization.go index 58c4a198e..6db796efb 100644 --- a/app/controlplane/internal/service/organization.go +++ b/app/controlplane/internal/service/organization.go @@ -97,6 +97,19 @@ func (s *OrganizationService) Update(ctx context.Context, req *pb.OrganizationSe return &pb.OrganizationServiceUpdateResponse{Result: bizOrgToPb(org)}, nil } +func (s *OrganizationService) Delete(ctx context.Context, req *pb.OrganizationServiceDeleteRequest) (*pb.OrganizationServiceDeleteResponse, error) { + currentUser, err := requireCurrentUser(ctx) + if err != nil { + return nil, err + } + + if err := s.orgUC.DeleteByUser(ctx, req.Name, currentUser.ID); err != nil { + return nil, handleUseCaseErr(err, s.log) + } + + return &pb.OrganizationServiceDeleteResponse{}, nil +} + func (s *OrganizationService) ListMemberships(ctx context.Context, req *pb.OrganizationServiceListMembershipsRequest) (*pb.OrganizationServiceListMembershipsResponse, error) { currentOrg, err := requireCurrentOrg(ctx) if err != nil { diff --git a/app/controlplane/internal/service/user.go b/app/controlplane/internal/service/user.go index bba3e1d2b..c97f103f1 100644 --- a/app/controlplane/internal/service/user.go +++ b/app/controlplane/internal/service/user.go @@ -85,7 +85,7 @@ func (s *UserService) DeleteMembership(ctx context.Context, req *pb.DeleteMember return nil, err } - err = s.membershipUC.LeaveAndDeleteOrg(ctx, currentUser.ID, req.MembershipId) + err = s.membershipUC.Leave(ctx, currentUser.ID, req.MembershipId) if err != nil && biz.IsNotFound(err) { return nil, errors.NotFound("not found", err.Error()) } else if err != nil { diff --git a/app/controlplane/pkg/biz/membership.go b/app/controlplane/pkg/biz/membership.go index e6e3347d4..032ff942b 100644 --- a/app/controlplane/pkg/biz/membership.go +++ b/app/controlplane/pkg/biz/membership.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2025 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. @@ -96,57 +96,6 @@ func NewMembershipUseCase(repo MembershipRepo, orgUC *OrganizationUseCase, audit return &MembershipUseCase{repo: repo, orgUseCase: orgUC, logger: log.NewHelper(logger), userRepo: userRepo, auditor: auditor} } -// LeaveAndDeleteOrg deletes a membership (and the org i) from the database associated with the current user -// and the associated org if the user is the only member -func (uc *MembershipUseCase) LeaveAndDeleteOrg(ctx context.Context, userID, membershipID string) error { - membershipUUID, err := uuid.Parse(membershipID) - if err != nil { - return NewErrInvalidUUID(err) - } - - userUUID, err := uuid.Parse(userID) - if err != nil { - return NewErrInvalidUUID(err) - } - - // Check that the provided membershipID in fact belongs to a membership from the user - m, err := uc.repo.FindByIDInUser(ctx, userUUID, membershipUUID) - if err != nil { - return fmt.Errorf("failed to find membership: %w", err) - } else if m == nil { - return NewErrNotFound("membership") - } - - uc.logger.Infow("msg", "Deleting membership", "user_id", userID, "membership_id", m.ID.String()) - if err := uc.repo.Delete(ctx, membershipUUID); err != nil { - return fmt.Errorf("failed to delete membership: %w", err) - } - - uc.auditor.Dispatch(ctx, &events.OrgUserLeft{ - OrgBase: &events.OrgBase{ - OrgID: &m.OrganizationID, - OrgName: m.Org.Name, - }, - }, &m.OrganizationID) - - // Check number of members in the org - // If it's the only one, delete the org - _, membershipCount, err := uc.repo.FindByOrg(ctx, m.OrganizationID, &ListByOrgOpts{}, pagination.NewDefaultOffsetPaginationOpts()) - if err != nil { - return fmt.Errorf("failed to find memberships in org: %w", err) - } - - if membershipCount == 0 { - // Delete the org - uc.logger.Infow("msg", "Deleting organization", "organization_id", m.OrganizationID.String()) - if err := uc.orgUseCase.Delete(ctx, m.OrganizationID.String()); err != nil { - return fmt.Errorf("failed to delete org: %w", err) - } - } - - return nil -} - // DeleteOther just deletes a membership from the database // but ensures that the user is not deleting itself from the org func (uc *MembershipUseCase) DeleteOther(ctx context.Context, orgID, userID, membershipID string) error { @@ -441,3 +390,79 @@ func (uc *MembershipUseCase) GetOrgsAndRBACInfoForUser(ctx context.Context, user return userOrgs, projectIDs, nil } + +// isUserSoleOwner checks if the user is the only owner in the organization +func (uc *MembershipUseCase) isUserSoleOwner(ctx context.Context, orgID uuid.UUID, userID uuid.UUID) (bool, error) { + // Get all memberships in the organization with owner role + ownerRole := authz.RoleOwner + owners, _, err := uc.repo.FindByOrg(ctx, orgID, &ListByOrgOpts{ + Role: &ownerRole, + }, pagination.NewDefaultOffsetPaginationOpts()) + if err != nil { + return false, fmt.Errorf("failed to find owners: %w", err) + } + + // Check if user is an owner and count total owners + userIsOwner := false + for _, owner := range owners { + if owner.User != nil && owner.User.ID == userID.String() { + userIsOwner = true + } + } + + // If user is not an owner, they can leave freely + if !userIsOwner { + return false, nil + } + + // If user is an owner and there's only one owner (them), they are the sole owner + return len(owners) == 1, nil +} + +// Leave allows a user to leave an organization with proper owner validation +// This function never automatically deletes organizations +func (uc *MembershipUseCase) Leave(ctx context.Context, userID, membershipID string) error { + membershipUUID, err := uuid.Parse(membershipID) + if err != nil { + return NewErrInvalidUUID(err) + } + + userUUID, err := uuid.Parse(userID) + if err != nil { + return NewErrInvalidUUID(err) + } + + // Check that the provided membershipID belongs to this user + m, err := uc.repo.FindByIDInUser(ctx, userUUID, membershipUUID) + if err != nil { + return fmt.Errorf("failed to find membership: %w", err) + } else if m == nil { + return NewErrNotFound("membership") + } + + // Check if the user is the sole owner + isSoleOwner, err := uc.isUserSoleOwner(ctx, m.OrganizationID, userUUID) + if err != nil { + return fmt.Errorf("failed to check ownership: %w", err) + } + + if isSoleOwner { + return NewErrValidationStr("cannot leave organization as the sole owner. Please add another owner or delete the organization instead") + } + + uc.logger.Infow("msg", "User leaving organization", "user_id", userID, "membership_id", m.ID.String()) + + // Delete the membership + if err := uc.repo.Delete(ctx, membershipUUID); err != nil { + return fmt.Errorf("failed to delete membership: %w", err) + } + + uc.auditor.Dispatch(ctx, &events.OrgUserLeft{ + OrgBase: &events.OrgBase{ + OrgID: &m.OrganizationID, + OrgName: m.Org.Name, + }, + }, &m.OrganizationID) + + return nil +} diff --git a/app/controlplane/pkg/biz/membership_integration_test.go b/app/controlplane/pkg/biz/membership_integration_test.go index 9f78ff51e..27d3242d7 100644 --- a/app/controlplane/pkg/biz/membership_integration_test.go +++ b/app/controlplane/pkg/biz/membership_integration_test.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2025 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. @@ -85,38 +85,40 @@ func (s *membershipIntegrationTestSuite) TestDeleteWithOrg() { sharedOrg, err := s.Organization.CreateWithRandomName(ctx) s.NoError(err) - mUser, err := s.Membership.Create(ctx, userOrg.ID, user.ID, biz.WithCurrentMembership()) + mUser, err := s.Membership.Create(ctx, userOrg.ID, user.ID, biz.WithMembershipRole(authz.RoleOwner), biz.WithCurrentMembership()) s.NoError(err) - mUserSharedOrg, err := s.Membership.Create(ctx, sharedOrg.ID, user.ID, biz.WithCurrentMembership()) + mUserSharedOrg, err := s.Membership.Create(ctx, sharedOrg.ID, user.ID, biz.WithMembershipRole(authz.RoleOwner), biz.WithCurrentMembership()) s.NoError(err) - mUser2SharedOrg, err := s.Membership.Create(ctx, sharedOrg.ID, user2.ID, biz.WithCurrentMembership()) + mUser2SharedOrg, err := s.Membership.Create(ctx, sharedOrg.ID, user2.ID, biz.WithMembershipRole(authz.RoleOwner), biz.WithCurrentMembership()) s.NoError(err) s.T().Run("invalid userID", func(t *testing.T) { - err := s.Membership.LeaveAndDeleteOrg(ctx, "invalid", mUser.ID.String()) + err := s.Membership.Leave(ctx, "invalid", mUser.ID.String()) s.True(biz.IsErrInvalidUUID(err)) }) s.T().Run("invalid orgID", func(t *testing.T) { - err := s.Membership.LeaveAndDeleteOrg(ctx, user.ID, "invalid") + err := s.Membership.Leave(ctx, user.ID, "invalid") s.True(biz.IsErrInvalidUUID(err)) }) s.T().Run("membership ID from another user", func(t *testing.T) { - err := s.Membership.LeaveAndDeleteOrg(ctx, user.ID, mUser2SharedOrg.ID.String()) + err := s.Membership.Leave(ctx, user.ID, mUser2SharedOrg.ID.String()) s.True(biz.IsNotFound(err)) }) - s.T().Run("delete the membership when the only member", func(t *testing.T) { - err := s.Membership.LeaveAndDeleteOrg(ctx, user.ID, mUser.ID.String()) - s.NoError(err) - // The org should also be deleted + s.Run("cannot leave when the only member (sole owner)", func() { + err := s.Membership.Leave(ctx, user.ID, mUser.ID.String()) + s.Require().Error(err) + s.True(biz.IsErrValidation(err)) + s.Contains(err.Error(), "sole owner") + // The org should still exist _, err = s.Organization.FindByID(ctx, userOrg.ID) - s.True(biz.IsNotFound(err)) + s.NoError(err) }) - s.T().Run("delete the membership when there are more than 1 member", func(t *testing.T) { - err := s.Membership.LeaveAndDeleteOrg(ctx, user.ID, mUserSharedOrg.ID.String()) + s.Run("can leave when there are more than 1 member", func() { + err := s.Membership.Leave(ctx, user.ID, mUserSharedOrg.ID.String()) s.NoError(err) // The org should not be deleted got, err := s.Organization.FindByID(ctx, sharedOrg.ID) @@ -130,11 +132,84 @@ func (s *membershipIntegrationTestSuite) TestDeleteWithOrg() { s.Equal(user2.ID, members[0].User.ID) }) - s.T().Run("we can remove the latest member", func(t *testing.T) { - err := s.Membership.LeaveAndDeleteOrg(ctx, user2.ID, mUser2SharedOrg.ID.String()) - s.NoError(err) + s.Run("cannot leave when would become sole owner", func() { + err := s.Membership.Leave(ctx, user2.ID, mUser2SharedOrg.ID.String()) + s.Error(err) + s.True(biz.IsErrValidation(err)) + s.Contains(err.Error(), "sole owner") + // The org should still exist _, err = s.Organization.FindByID(ctx, sharedOrg.ID) - s.True(biz.IsNotFound(err)) + s.NoError(err) + }) +} + +func (s *membershipIntegrationTestSuite) TestLeaveHappyPath() { + ctx := context.Background() + + // Create users + user1, err := s.User.UpsertByEmail(ctx, "owner1@test.com", nil) + s.NoError(err) + user2, err := s.User.UpsertByEmail(ctx, "owner2@test.com", nil) + s.NoError(err) + + // Create organization with multiple owners + org, err := s.Organization.CreateWithRandomName(ctx) + s.NoError(err) + + // Add both users as owners + membership1, err := s.Membership.Create(ctx, org.ID, user1.ID, biz.WithMembershipRole(authz.RoleOwner)) + s.NoError(err) + membership2, err := s.Membership.Create(ctx, org.ID, user2.ID, biz.WithMembershipRole(authz.RoleOwner), biz.WithCurrentMembership()) + s.NoError(err) + + // Verify initial state - both users are owners + members, count, err := s.Membership.ByOrg(ctx, org.ID, &biz.ListByOrgOpts{}, nil) + s.NoError(err) + s.Len(members, 2) + s.Equal(2, count) + + s.Run("owner can leave when another owner remains", func() { + // user1 can leave because user2 will still be an owner + err := s.Membership.Leave(ctx, user1.ID, membership1.ID.String()) + s.NoError(err) + + // Organization should still exist + gotOrg, err := s.Organization.FindByID(ctx, org.ID) + s.NoError(err) + s.NotNil(gotOrg) + + // Only user2 should remain as member + members, count, err := s.Membership.ByOrg(ctx, org.ID, &biz.ListByOrgOpts{}, nil) + s.NoError(err) + s.Len(members, 1) + s.Equal(1, count) + s.Equal(user2.ID, members[0].User.ID) + s.Equal(authz.RoleOwner, members[0].Role) + + // user1 should no longer have any memberships in this org + user1Memberships, err := s.Membership.ByUser(ctx, user1.ID) + s.NoError(err) + s.Empty(user1Memberships) // user1 should have no memberships left + }) + + s.Run("last remaining owner cannot leave", func() { + // user2 is now the sole owner and cannot leave + err := s.Membership.Leave(ctx, user2.ID, membership2.ID.String()) + s.Error(err) + s.True(biz.IsErrValidation(err)) + s.Contains(err.Error(), "sole owner") + + // Organization should still exist + gotOrg, err := s.Organization.FindByID(ctx, org.ID) + s.NoError(err) + s.NotNil(gotOrg) + + // user2 should still be the only member + members, count, err := s.Membership.ByOrg(ctx, org.ID, &biz.ListByOrgOpts{}, nil) + s.NoError(err) + s.Len(members, 1) + s.Equal(1, count) + s.Equal(user2.ID, members[0].User.ID) }) } @@ -370,7 +445,7 @@ func (s *membershipIntegrationTestSuite) TestDeleteCleanup() { // Delete the user's membership s.Run("delete user membership", func() { - err := s.Membership.LeaveAndDeleteOrg(ctx, user.ID, membershipUser.ID.String()) + err := s.Membership.Leave(ctx, user.ID, membershipUser.ID.String()) s.NoError(err) // Check that the organization still exists (since there's still admin user) @@ -414,7 +489,7 @@ func (s *membershipIntegrationTestSuite) TestDeleteWithGroups() { orgUUID := uuid.MustParse(org.ID) // Add user to organization - membershipUser, err := s.Membership.Create(ctx, org.ID, user.ID, biz.WithMembershipRole(authz.RoleAdmin), biz.WithCurrentMembership()) + membershipUser, err := s.Membership.Create(ctx, org.ID, user.ID, biz.WithMembershipRole(authz.RoleOwner), biz.WithCurrentMembership()) s.NoError(err) // Create multiple groups with the user as maintainer @@ -465,33 +540,35 @@ func (s *membershipIntegrationTestSuite) TestDeleteWithGroups() { }) // Delete the user's membership - s.Run("delete user membership with groups", func() { - err := s.Membership.LeaveAndDeleteOrg(ctx, user.ID, membershipUser.ID.String()) - s.NoError(err) + s.Run("cannot delete user membership when sole owner", func() { + err := s.Membership.Leave(ctx, user.ID, membershipUser.ID.String()) + s.Error(err) + s.True(biz.IsErrValidation(err)) + s.Contains(err.Error(), "sole owner") - // The organization should be deleted since this was the only user + // The organization should still exist since sole owners cannot leave _, err = s.Organization.FindByID(ctx, org.ID) - s.True(biz.IsNotFound(err), "Organization should be deleted") + s.NoError(err) - // All groups should be soft-deleted + // Groups should still exist since user couldn't leave (no cleanup happened) _, err = s.Group.Get(ctx, orgUUID, &biz.IdentityReference{ID: &group1.ID}) - s.True(biz.IsNotFound(err), "Group 1 should be deleted") + s.NoError(err, "Group 1 should still exist") _, err = s.Group.Get(ctx, orgUUID, &biz.IdentityReference{ID: &group2.ID}) - s.True(biz.IsNotFound(err), "Group 2 should be deleted") + s.NoError(err, "Group 2 should still exist since user couldn't leave") - // The project should be deleted + // The project should still exist since no cleanup happened _, err = s.Project.FindProjectByReference(ctx, org.ID, &biz.IdentityReference{ID: projectRef.ID}) - s.True(biz.IsNotFound(err), "Project should be deleted") + s.NoError(err, "Project should still exist since user couldn't leave") - // Verify group memberships are marked as deleted + // Verify group memberships still exist since user couldn't leave group1Mem, group1Err := s.Repos.GroupRepo.FindGroupMembershipByGroupAndID(ctx, group1.ID, userUUID) - s.True(biz.IsNotFound(group1Err)) - s.Nil(group1Mem) + s.NoError(group1Err) + s.NotNil(group1Mem) group2Mem, group2Err := s.Repos.GroupRepo.FindGroupMembershipByGroupAndID(ctx, group2.ID, userUUID) - s.True(biz.IsNotFound(group2Err)) - s.Nil(group2Mem) + s.NoError(group2Err) + s.NotNil(group2Mem) }) } diff --git a/app/controlplane/pkg/biz/organization.go b/app/controlplane/pkg/biz/organization.go index 9890cfefd..02ef20707 100644 --- a/app/controlplane/pkg/biz/organization.go +++ b/app/controlplane/pkg/biz/organization.go @@ -290,6 +290,43 @@ func (uc *OrganizationUseCase) Delete(ctx context.Context, id string) error { return uc.orgRepo.Delete(ctx, orgUUID) } +// DeleteByUser deletes an organization initiated by a user with owner validation +// Only organization owners can delete an organization +func (uc *OrganizationUseCase) DeleteByUser(ctx context.Context, orgName, userID string) error { + // Find organization by name + org, err := uc.orgRepo.FindByName(ctx, orgName) + if err != nil { + return err + } else if org == nil { + return NewErrNotFound("organization") + } + + orgUUID, err := uuid.Parse(org.ID) + if err != nil { + return NewErrInvalidUUID(err) + } + + userUUID, err := uuid.Parse(userID) + if err != nil { + return NewErrInvalidUUID(err) + } + + // Check if user is an owner of the organization + m, err := uc.membershipRepo.FindByOrgAndUser(ctx, orgUUID, userUUID) + if err != nil { + return fmt.Errorf("failed to find owners: %w", err) + } + + if m == nil || m.Role != authz.RoleOwner { + return NewErrValidationStr("only organization owners can delete the organization") + } + + uc.logger.Infow("msg", "User deleting organization", "user_id", userID, "organization_id", org.ID) + + // Use the existing Delete method to handle the actual deletion + return uc.Delete(ctx, org.ID) +} + // AutoOnboardOrganizations creates the organizations specified in the onboarding config and assigns the user to them // with the specified role if they are not already a member. func (uc *OrganizationUseCase) AutoOnboardOrganizations(ctx context.Context, userID string) error { diff --git a/app/controlplane/pkg/biz/user.go b/app/controlplane/pkg/biz/user.go index c1b4547bf..8a81e6b11 100644 --- a/app/controlplane/pkg/biz/user.go +++ b/app/controlplane/pkg/biz/user.go @@ -93,23 +93,42 @@ func NewUserUseCase(opts *NewUserUseCaseParams) *UserUseCase { } // DeleteUser deletes the user, related memberships and organization if needed +// Safe approach: blocks deletion if user is sole owner of any organizations func (uc *UserUseCase) DeleteUser(ctx context.Context, userID string) error { uc.logger.Infow("msg", "Deleting Account", "user_id", userID) + + userUUID, err := uuid.Parse(userID) + if err != nil { + return NewErrInvalidUUID(err) + } + memberships, err := uc.membershipUseCase.ByUser(ctx, userID) if err != nil { return err } - // Iterate on user memberships, delete org if the user is the only member + // Check if user is the sole owner of any organizations + soleOwnerOrgs := []string{} for _, m := range memberships { - if err := uc.membershipUseCase.LeaveAndDeleteOrg(ctx, userID, m.ID.String()); err != nil { - return fmt.Errorf("failed to delete membership: %w", err) + isSoleOwner, err := uc.membershipUseCase.isUserSoleOwner(ctx, m.OrganizationID, userUUID) + if err != nil { + return fmt.Errorf("failed to check ownership for org %s: %w", m.Org.Name, err) + } + if isSoleOwner { + soleOwnerOrgs = append(soleOwnerOrgs, m.Org.Name) } } - userUUID, err := uuid.Parse(userID) - if err != nil { - return NewErrInvalidUUID(err) + // Block deletion if user is sole owner of any organizations + if len(soleOwnerOrgs) > 0 { + return NewErrValidationStr(fmt.Sprintf("cannot delete account: you are the sole owner of organizations (%s). Please transfer ownership or delete these organizations first", strings.Join(soleOwnerOrgs, ", "))) + } + + // Safely leave all organizations (user is not sole owner of any) + for _, m := range memberships { + if err := uc.membershipUseCase.Leave(ctx, userID, m.ID.String()); err != nil { + return fmt.Errorf("failed to leave organization %s: %w", m.Org.Name, err) + } } uc.logger.Infow("msg", "User deleted", "user_id", userID) diff --git a/app/controlplane/pkg/biz/user_integration_test.go b/app/controlplane/pkg/biz/user_integration_test.go index 7364e7578..0b8df966a 100644 --- a/app/controlplane/pkg/biz/user_integration_test.go +++ b/app/controlplane/pkg/biz/user_integration_test.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2025 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. @@ -80,28 +80,53 @@ User mapping: func (s *userIntegrationTestSuite) TestDeleteUser() { ctx := context.Background() - err := s.User.DeleteUser(ctx, s.userOne.ID) - s.NoError(err) + s.Run("cannot delete user when sole owner", func() { + // User deletion should be blocked because userOne is sole owner of userOneOrg + err := s.User.DeleteUser(ctx, s.userOne.ID) + s.Error(err) + s.True(biz.IsErrValidation(err)) + s.Contains(err.Error(), "sole owner") - // Organization where the user is the only member got deleted - gotOrgOne, err := s.Organization.FindByID(ctx, s.userOneOrg.ID) - s.Error(err) - s.True(biz.IsNotFound(err)) - s.Nil(gotOrgOne) + // Both organizations should still exist since deletion was blocked + gotOrgOne, err := s.Organization.FindByID(ctx, s.userOneOrg.ID) + s.NoError(err) + s.NotNil(gotOrgOne) - // Organization that it's shared with another user is still present - gotSharedOrg, err := s.Organization.FindByID(ctx, s.sharedOrg.ID) - s.NoError(err) - s.NotNil(gotSharedOrg) + gotSharedOrg, err := s.Organization.FindByID(ctx, s.sharedOrg.ID) + s.NoError(err) + s.NotNil(gotSharedOrg) - // user and associated memberships have been deleted - gotUser, err := s.User.FindByID(ctx, s.userOne.ID) - s.NoError(err) - s.Nil(gotUser) + // User should still exist since deletion was blocked + gotUser, err := s.User.FindByID(ctx, s.userOne.ID) + s.NoError(err) + s.NotNil(gotUser) - gotMembership, err := s.Membership.ByUser(ctx, s.userOne.ID) - s.NoError(err) - s.Empty(gotMembership) + // Memberships should still exist since deletion was blocked + gotMembership, err := s.Membership.ByUser(ctx, s.userOne.ID) + s.NoError(err) + s.NotEmpty(gotMembership) + }) + + s.Run("can delete user when not sole owner", func() { + // userTwo is an owner but not sole owner (userOne is also owner of sharedOrg) + err := s.User.DeleteUser(ctx, s.userTwo.ID) + s.NoError(err) + + // sharedOrg should still exist since userOne is still an owner + gotSharedOrg, err := s.Organization.FindByID(ctx, s.sharedOrg.ID) + s.NoError(err) + s.NotNil(gotSharedOrg) + + // userTwo should be deleted + gotUser, err := s.User.FindByID(ctx, s.userTwo.ID) + s.NoError(err) + s.Nil(gotUser) + + // userTwo's memberships should be gone + gotMembership, err := s.Membership.ByUser(ctx, s.userTwo.ID) + s.NoError(err) + s.Empty(gotMembership) + }) } func (s *userIntegrationTestSuite) TestCurrentMembership() { @@ -117,8 +142,8 @@ func (s *userIntegrationTestSuite) TestCurrentMembership() { s.NoError(err) s.Equal(s.sharedOrg, got.Org) - // and it contains the default role - s.Equal(authz.RoleViewer, got.Role) + // and it contains the owner role (set in test setup) + s.Equal(authz.RoleOwner, got.Role) }) s.Run("they have more orgs but none of them is the default, it will return the first one as default", func() { @@ -126,7 +151,7 @@ func (s *userIntegrationTestSuite) TestCurrentMembership() { s.NoError(err) s.True(m.Current) // leave the current org - err = s.Membership.LeaveAndDeleteOrg(ctx, s.userOne.ID, m.ID.String()) + err = s.Membership.Leave(ctx, s.userOne.ID, m.ID.String()) s.NoError(err) // none of the orgs is marked as current @@ -146,12 +171,46 @@ func (s *userIntegrationTestSuite) TestCurrentMembership() { }) s.Run("it will fail if there are no memberships", func() { - // none of the orgs is marked as current + // Create a test user who is not an owner so they can leave + testUser, err := s.User.UpsertByEmail(ctx, "test-no-membership@test.com", nil) + s.NoError(err) + + // Create a new org and make both testUser and userOne owners + testOrg, err := s.Organization.CreateWithRandomName(ctx) + s.NoError(err) + + // Add testUser as owner + _, err = s.Membership.Create(ctx, testOrg.ID, testUser.ID, biz.WithMembershipRole(authz.RoleOwner)) + s.NoError(err) + + // Add userOne as viewer (so they can leave) + _, err = s.Membership.Create(ctx, testOrg.ID, s.userOne.ID, biz.WithMembershipRole(authz.RoleViewer)) + s.NoError(err) + + // Now userOne can leave because testUser is also an owner mems, _ := s.Membership.ByUser(ctx, s.userOne.ID) - s.Len(mems, 1) - // leave the current org - err := s.Membership.LeaveAndDeleteOrg(ctx, s.userOne.ID, mems[0].ID.String()) + // Find the membership for testOrg + var testOrgMembership *biz.Membership + for _, m := range mems { + if m.OrganizationID.String() == testOrg.ID { + testOrgMembership = m + break + } + } + s.NotNil(testOrgMembership) + + // userOne leaves testOrg (allowed because testUser is still owner) + err = s.Membership.Leave(ctx, s.userOne.ID, testOrgMembership.ID.String()) s.NoError(err) + + // Now userOne leaves their original organizations by deleting them directly + // since they are sole owners, they can delete the orgs instead of leaving + err = s.Organization.Delete(ctx, s.userOneOrg.ID) + s.NoError(err) + err = s.Organization.Delete(ctx, s.sharedOrg.ID) + s.NoError(err) + + // Verify userOne has no memberships left mems, _ = s.Membership.ByUser(ctx, s.userOne.ID) s.Len(mems, 0) @@ -224,15 +283,15 @@ func (s *userIntegrationTestSuite) SetupTest() { // Create User 1 s.userOne, err = s.User.UpsertByEmail(ctx, "user-1@test.com", nil) assert.NoError(err) - // Attach both orgs - _, err = s.Membership.Create(ctx, s.userOneOrg.ID, s.userOne.ID) + // Attach both orgs - make user owner of userOneOrg and owner of sharedOrg + _, err = s.Membership.Create(ctx, s.userOneOrg.ID, s.userOne.ID, biz.WithMembershipRole(authz.RoleOwner)) assert.NoError(err) - _, err = s.Membership.Create(ctx, s.sharedOrg.ID, s.userOne.ID, biz.WithCurrentMembership()) + _, err = s.Membership.Create(ctx, s.sharedOrg.ID, s.userOne.ID, biz.WithMembershipRole(authz.RoleOwner), biz.WithCurrentMembership()) assert.NoError(err) - // Create User 2 and attach shared org + // Create User 2 and attach shared org as owner too s.userTwo, err = s.User.UpsertByEmail(ctx, "user-2@test.com", nil) assert.NoError(err) - _, err = s.Membership.Create(ctx, s.sharedOrg.ID, s.userTwo.ID, biz.WithCurrentMembership()) + _, err = s.Membership.Create(ctx, s.sharedOrg.ID, s.userTwo.ID, biz.WithMembershipRole(authz.RoleOwner), biz.WithCurrentMembership()) assert.NoError(err) }