diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 94c0c7ef62c56..df8046872fef7 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1985,6 +1985,35 @@ func (q *querier) GetOrganizationIDsByMemberIDs(ctx context.Context, ids []uuid. return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetOrganizationIDsByMemberIDs)(ctx, ids) } +func (q *querier) GetOrganizationResourceCountByID(ctx context.Context, organizationID uuid.UUID) (database.GetOrganizationResourceCountByIDRow, error) { + // Can read org members + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOrganizationMember.InOrg(organizationID)); err != nil { + return database.GetOrganizationResourceCountByIDRow{}, err + } + + // Can read org workspaces + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceWorkspace.InOrg(organizationID)); err != nil { + return database.GetOrganizationResourceCountByIDRow{}, err + } + + // Can read org groups + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceGroup.InOrg(organizationID)); err != nil { + return database.GetOrganizationResourceCountByIDRow{}, err + } + + // Can read org templates + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceTemplate.InOrg(organizationID)); err != nil { + return database.GetOrganizationResourceCountByIDRow{}, err + } + + // Can read org provisioner daemons + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceProvisionerDaemon.InOrg(organizationID)); err != nil { + return database.GetOrganizationResourceCountByIDRow{}, err + } + + return q.db.GetOrganizationResourceCountByID(ctx, organizationID) +} + func (q *querier) GetOrganizations(ctx context.Context, args database.GetOrganizationsParams) ([]database.Organization, error) { fetch := func(ctx context.Context, _ interface{}) ([]database.Organization, error) { return q.db.GetOrganizations(ctx, args) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 149051bd3bc64..ceb6acea3b691 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -809,6 +809,39 @@ func (s *MethodTestSuite) TestOrganization() { o := dbgen.Organization(s.T(), db, database.Organization{}) check.Args(o.ID).Asserts(o, policy.ActionRead).Returns(o) })) + s.Run("GetOrganizationResourceCountByID", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + o := dbgen.Organization(s.T(), db, database.Organization{}) + + t := dbgen.Template(s.T(), db, database.Template{ + CreatedBy: u.ID, + OrganizationID: o.ID, + }) + dbgen.Workspace(s.T(), db, database.WorkspaceTable{ + OrganizationID: o.ID, + OwnerID: u.ID, + TemplateID: t.ID, + }) + dbgen.Group(s.T(), db, database.Group{OrganizationID: o.ID}) + dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{ + OrganizationID: o.ID, + UserID: u.ID, + }) + + check.Args(o.ID).Asserts( + rbac.ResourceOrganizationMember.InOrg(o.ID), policy.ActionRead, + rbac.ResourceWorkspace.InOrg(o.ID), policy.ActionRead, + rbac.ResourceGroup.InOrg(o.ID), policy.ActionRead, + rbac.ResourceTemplate.InOrg(o.ID), policy.ActionRead, + rbac.ResourceProvisionerDaemon.InOrg(o.ID), policy.ActionRead, + ).Returns(database.GetOrganizationResourceCountByIDRow{ + WorkspaceCount: 1, + GroupCount: 1, + TemplateCount: 1, + MemberCount: 1, + ProvisionerKeyCount: 0, + }) + })) s.Run("GetDefaultOrganization", s.Subtest(func(db database.Store, check *expects) { o, _ := db.GetDefaultOrganization(context.Background()) check.Args().Asserts(o, policy.ActionRead).Returns(o) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 56e272c7ba048..19b077644f618 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -3976,6 +3976,54 @@ func (q *FakeQuerier) GetOrganizationIDsByMemberIDs(_ context.Context, ids []uui return getOrganizationIDsByMemberIDRows, nil } +func (q *FakeQuerier) GetOrganizationResourceCountByID(_ context.Context, organizationID uuid.UUID) (database.GetOrganizationResourceCountByIDRow, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + workspacesCount := 0 + for _, workspace := range q.workspaces { + if workspace.OrganizationID == organizationID { + workspacesCount++ + } + } + + groupsCount := 0 + for _, group := range q.groups { + if group.OrganizationID == organizationID { + groupsCount++ + } + } + + templatesCount := 0 + for _, template := range q.templates { + if template.OrganizationID == organizationID { + templatesCount++ + } + } + + organizationMembersCount := 0 + for _, organizationMember := range q.organizationMembers { + if organizationMember.OrganizationID == organizationID { + organizationMembersCount++ + } + } + + provKeyCount := 0 + for _, provKey := range q.provisionerKeys { + if provKey.OrganizationID == organizationID { + provKeyCount++ + } + } + + return database.GetOrganizationResourceCountByIDRow{ + WorkspaceCount: int64(workspacesCount), + GroupCount: int64(groupsCount), + TemplateCount: int64(templatesCount), + MemberCount: int64(organizationMembersCount), + ProvisionerKeyCount: int64(provKeyCount), + }, nil +} + func (q *FakeQuerier) GetOrganizations(_ context.Context, args database.GetOrganizationsParams) ([]database.Organization, error) { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 4d19aa65298a2..8f540866b180a 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1011,6 +1011,13 @@ func (m queryMetricsStore) GetOrganizationIDsByMemberIDs(ctx context.Context, id return organizations, err } +func (m queryMetricsStore) GetOrganizationResourceCountByID(ctx context.Context, organizationID uuid.UUID) (database.GetOrganizationResourceCountByIDRow, error) { + start := time.Now() + r0, r1 := m.s.GetOrganizationResourceCountByID(ctx, organizationID) + m.queryLatencies.WithLabelValues("GetOrganizationResourceCountByID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetOrganizations(ctx context.Context, args database.GetOrganizationsParams) ([]database.Organization, error) { start := time.Now() organizations, err := m.s.GetOrganizations(ctx, args) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 338945556284b..4113aa144125b 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2062,6 +2062,21 @@ func (mr *MockStoreMockRecorder) GetOrganizationIDsByMemberIDs(ctx, ids any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationIDsByMemberIDs", reflect.TypeOf((*MockStore)(nil).GetOrganizationIDsByMemberIDs), ctx, ids) } +// GetOrganizationResourceCountByID mocks base method. +func (m *MockStore) GetOrganizationResourceCountByID(ctx context.Context, organizationID uuid.UUID) (database.GetOrganizationResourceCountByIDRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOrganizationResourceCountByID", ctx, organizationID) + ret0, _ := ret[0].(database.GetOrganizationResourceCountByIDRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetOrganizationResourceCountByID indicates an expected call of GetOrganizationResourceCountByID. +func (mr *MockStoreMockRecorder) GetOrganizationResourceCountByID(ctx, organizationID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrganizationResourceCountByID", reflect.TypeOf((*MockStore)(nil).GetOrganizationResourceCountByID), ctx, organizationID) +} + // GetOrganizations mocks base method. func (m *MockStore) GetOrganizations(ctx context.Context, arg database.GetOrganizationsParams) ([]database.Organization, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index f36a7aeaf357a..9265a70e380c7 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -450,10 +450,10 @@ CREATE FUNCTION protect_deleting_organizations() RETURNS trigger AS $$ DECLARE workspace_count int; - template_count int; - group_count int; - member_count int; - provisioner_keys_count int; + template_count int; + group_count int; + member_count int; + provisioner_keys_count int; BEGIN workspace_count := ( SELECT count(*) as count FROM workspaces @@ -462,50 +462,69 @@ BEGIN AND workspaces.deleted = false ); - template_count := ( + template_count := ( SELECT count(*) as count FROM templates WHERE templates.organization_id = OLD.id AND templates.deleted = false ); - group_count := ( + group_count := ( SELECT count(*) as count FROM groups WHERE groups.organization_id = OLD.id ); - member_count := ( + member_count := ( SELECT count(*) as count FROM organization_members WHERE organization_members.organization_id = OLD.id ); - provisioner_keys_count := ( - Select count(*) as count FROM provisioner_keys - WHERE - provisioner_keys.organization_id = OLD.id - ); + provisioner_keys_count := ( + Select count(*) as count FROM provisioner_keys + WHERE + provisioner_keys.organization_id = OLD.id + ); -- Fail the deletion if one of the following: -- * the organization has 1 or more workspaces - -- * the organization has 1 or more templates - -- * the organization has 1 or more groups other than "Everyone" group - -- * the organization has 1 or more members other than the organization owner - -- * the organization has 1 or more provisioner keys + -- * the organization has 1 or more templates + -- * the organization has 1 or more groups other than "Everyone" group + -- * the organization has 1 or more members other than the organization owner + -- * the organization has 1 or more provisioner keys + -- Only create error message for resources that actually exist IF (workspace_count + template_count + provisioner_keys_count) > 0 THEN - RAISE EXCEPTION 'cannot delete organization: organization has % workspaces, % templates, and % provisioner keys that must be deleted first', workspace_count, template_count, provisioner_keys_count; + DECLARE + error_message text := 'cannot delete organization: organization has '; + error_parts text[] := '{}'; + BEGIN + IF workspace_count > 0 THEN + error_parts := array_append(error_parts, workspace_count || ' workspaces'); + END IF; + + IF template_count > 0 THEN + error_parts := array_append(error_parts, template_count || ' templates'); + END IF; + + IF provisioner_keys_count > 0 THEN + error_parts := array_append(error_parts, provisioner_keys_count || ' provisioner keys'); + END IF; + + error_message := error_message || array_to_string(error_parts, ', ') || ' that must be deleted first'; + RAISE EXCEPTION '%', error_message; + END; END IF; - IF (group_count) > 1 THEN + IF (group_count) > 1 THEN RAISE EXCEPTION 'cannot delete organization: organization has % groups that must be deleted first', group_count - 1; END IF; -- Allow 1 member to exist, because you cannot remove yourself. You can -- remove everyone else. Ideally, we only omit the member that matches -- the user_id of the caller, however in a trigger, the caller is unknown. - IF (member_count) > 1 THEN + IF (member_count) > 1 THEN RAISE EXCEPTION 'cannot delete organization: organization has % members that must be deleted first', member_count - 1; END IF; diff --git a/coderd/database/migrations/000310_update_protect_deleting_organization_function.down.sql b/coderd/database/migrations/000310_update_protect_deleting_organization_function.down.sql new file mode 100644 index 0000000000000..eebfcac2c9738 --- /dev/null +++ b/coderd/database/migrations/000310_update_protect_deleting_organization_function.down.sql @@ -0,0 +1,77 @@ +-- Drop trigger that uses this function +DROP TRIGGER IF EXISTS protect_deleting_organizations ON organizations; + +-- Revert the function to its original implementation +CREATE OR REPLACE FUNCTION protect_deleting_organizations() + RETURNS TRIGGER AS +$$ +DECLARE + workspace_count int; + template_count int; + group_count int; + member_count int; + provisioner_keys_count int; +BEGIN + workspace_count := ( + SELECT count(*) as count FROM workspaces + WHERE + workspaces.organization_id = OLD.id + AND workspaces.deleted = false + ); + + template_count := ( + SELECT count(*) as count FROM templates + WHERE + templates.organization_id = OLD.id + AND templates.deleted = false + ); + + group_count := ( + SELECT count(*) as count FROM groups + WHERE + groups.organization_id = OLD.id + ); + + member_count := ( + SELECT count(*) as count FROM organization_members + WHERE + organization_members.organization_id = OLD.id + ); + + provisioner_keys_count := ( + Select count(*) as count FROM provisioner_keys + WHERE + provisioner_keys.organization_id = OLD.id + ); + + -- Fail the deletion if one of the following: + -- * the organization has 1 or more workspaces + -- * the organization has 1 or more templates + -- * the organization has 1 or more groups other than "Everyone" group + -- * the organization has 1 or more members other than the organization owner + -- * the organization has 1 or more provisioner keys + + IF (workspace_count + template_count + provisioner_keys_count) > 0 THEN + RAISE EXCEPTION 'cannot delete organization: organization has % workspaces, % templates, and % provisioner keys that must be deleted first', workspace_count, template_count, provisioner_keys_count; + END IF; + + IF (group_count) > 1 THEN + RAISE EXCEPTION 'cannot delete organization: organization has % groups that must be deleted first', group_count - 1; + END IF; + + -- Allow 1 member to exist, because you cannot remove yourself. You can + -- remove everyone else. Ideally, we only omit the member that matches + -- the user_id of the caller, however in a trigger, the caller is unknown. + IF (member_count) > 1 THEN + RAISE EXCEPTION 'cannot delete organization: organization has % members that must be deleted first', member_count - 1; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Re-create trigger that uses this function +CREATE TRIGGER protect_deleting_organizations + BEFORE DELETE ON organizations + FOR EACH ROW + EXECUTE FUNCTION protect_deleting_organizations(); diff --git a/coderd/database/migrations/000310_update_protect_deleting_organization_function.up.sql b/coderd/database/migrations/000310_update_protect_deleting_organization_function.up.sql new file mode 100644 index 0000000000000..cacafc029222c --- /dev/null +++ b/coderd/database/migrations/000310_update_protect_deleting_organization_function.up.sql @@ -0,0 +1,96 @@ +DROP TRIGGER IF EXISTS protect_deleting_organizations ON organizations; + +-- Replace the function with the new implementation +CREATE OR REPLACE FUNCTION protect_deleting_organizations() + RETURNS TRIGGER AS +$$ +DECLARE + workspace_count int; + template_count int; + group_count int; + member_count int; + provisioner_keys_count int; +BEGIN + workspace_count := ( + SELECT count(*) as count FROM workspaces + WHERE + workspaces.organization_id = OLD.id + AND workspaces.deleted = false + ); + + template_count := ( + SELECT count(*) as count FROM templates + WHERE + templates.organization_id = OLD.id + AND templates.deleted = false + ); + + group_count := ( + SELECT count(*) as count FROM groups + WHERE + groups.organization_id = OLD.id + ); + + member_count := ( + SELECT count(*) as count FROM organization_members + WHERE + organization_members.organization_id = OLD.id + ); + + provisioner_keys_count := ( + Select count(*) as count FROM provisioner_keys + WHERE + provisioner_keys.organization_id = OLD.id + ); + + -- Fail the deletion if one of the following: + -- * the organization has 1 or more workspaces + -- * the organization has 1 or more templates + -- * the organization has 1 or more groups other than "Everyone" group + -- * the organization has 1 or more members other than the organization owner + -- * the organization has 1 or more provisioner keys + + -- Only create error message for resources that actually exist + IF (workspace_count + template_count + provisioner_keys_count) > 0 THEN + DECLARE + error_message text := 'cannot delete organization: organization has '; + error_parts text[] := '{}'; + BEGIN + IF workspace_count > 0 THEN + error_parts := array_append(error_parts, workspace_count || ' workspaces'); + END IF; + + IF template_count > 0 THEN + error_parts := array_append(error_parts, template_count || ' templates'); + END IF; + + IF provisioner_keys_count > 0 THEN + error_parts := array_append(error_parts, provisioner_keys_count || ' provisioner keys'); + END IF; + + error_message := error_message || array_to_string(error_parts, ', ') || ' that must be deleted first'; + RAISE EXCEPTION '%', error_message; + END; + END IF; + + IF (group_count) > 1 THEN + RAISE EXCEPTION 'cannot delete organization: organization has % groups that must be deleted first', group_count - 1; + END IF; + + -- Allow 1 member to exist, because you cannot remove yourself. You can + -- remove everyone else. Ideally, we only omit the member that matches + -- the user_id of the caller, however in a trigger, the caller is unknown. + IF (member_count) > 1 THEN + RAISE EXCEPTION 'cannot delete organization: organization has % members that must be deleted first', member_count - 1; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger to protect organizations from being soft deleted with existing resources +CREATE TRIGGER protect_deleting_organizations + BEFORE UPDATE ON organizations + FOR EACH ROW + WHEN (NEW.deleted = true AND OLD.deleted = false) + EXECUTE FUNCTION protect_deleting_organizations(); diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 0c4928e7ffb30..2c1dfcd9949ec 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -217,6 +217,7 @@ type sqlcQuerier interface { GetOrganizationByID(ctx context.Context, id uuid.UUID) (Organization, error) GetOrganizationByName(ctx context.Context, arg GetOrganizationByNameParams) (Organization, error) GetOrganizationIDsByMemberIDs(ctx context.Context, ids []uuid.UUID) ([]GetOrganizationIDsByMemberIDsRow, error) + GetOrganizationResourceCountByID(ctx context.Context, organizationID uuid.UUID) (GetOrganizationResourceCountByIDRow, error) GetOrganizations(ctx context.Context, arg GetOrganizationsParams) ([]Organization, error) GetOrganizationsByUserID(ctx context.Context, arg GetOrganizationsByUserIDParams) ([]Organization, error) GetParameterSchemasByJobID(ctx context.Context, jobID uuid.UUID) ([]ParameterSchema, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 837068f1fa03e..e557a8e66b897 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -3396,7 +3396,6 @@ func TestOrganizationDeleteTrigger(t *testing.T) { require.Error(t, err) // cannot delete organization: organization has 0 workspaces and 1 templates that must be deleted first require.ErrorContains(t, err, "cannot delete organization") - require.ErrorContains(t, err, "has 0 workspaces") require.ErrorContains(t, err, "1 templates") }) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 17ab7ef3e3fe7..dd6c2ec42d5e6 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5481,6 +5481,36 @@ func (q *sqlQuerier) GetOrganizationByName(ctx context.Context, arg GetOrganizat return i, err } +const getOrganizationResourceCountByID = `-- name: GetOrganizationResourceCountByID :one +SELECT + (SELECT COUNT(*) FROM workspaces WHERE workspaces.organization_id = $1 AND workspaces.deleted = false) AS workspace_count, + (SELECT COUNT(*) FROM groups WHERE groups.organization_id = $1) AS group_count, + (SELECT COUNT(*) FROM templates WHERE templates.organization_id = $1 AND templates.deleted = false) AS template_count, + (SELECT COUNT(*) FROM organization_members WHERE organization_members.organization_id = $1) AS member_count, + (SELECT COUNT(*) FROM provisioner_keys WHERE provisioner_keys.organization_id = $1) AS provisioner_key_count +` + +type GetOrganizationResourceCountByIDRow struct { + WorkspaceCount int64 `db:"workspace_count" json:"workspace_count"` + GroupCount int64 `db:"group_count" json:"group_count"` + TemplateCount int64 `db:"template_count" json:"template_count"` + MemberCount int64 `db:"member_count" json:"member_count"` + ProvisionerKeyCount int64 `db:"provisioner_key_count" json:"provisioner_key_count"` +} + +func (q *sqlQuerier) GetOrganizationResourceCountByID(ctx context.Context, organizationID uuid.UUID) (GetOrganizationResourceCountByIDRow, error) { + row := q.db.QueryRowContext(ctx, getOrganizationResourceCountByID, organizationID) + var i GetOrganizationResourceCountByIDRow + err := row.Scan( + &i.WorkspaceCount, + &i.GroupCount, + &i.TemplateCount, + &i.MemberCount, + &i.ProvisionerKeyCount, + ) + return i, err +} + const getOrganizations = `-- name: GetOrganizations :many SELECT id, name, description, created_at, updated_at, is_default, display_name, icon, deleted diff --git a/coderd/database/queries/organizations.sql b/coderd/database/queries/organizations.sql index 822b51c0aa8ba..d710a26ca9a46 100644 --- a/coderd/database/queries/organizations.sql +++ b/coderd/database/queries/organizations.sql @@ -66,6 +66,14 @@ WHERE user_id = $1 ); +-- name: GetOrganizationResourceCountByID :one +SELECT + (SELECT COUNT(*) FROM workspaces WHERE workspaces.organization_id = $1 AND workspaces.deleted = false) AS workspace_count, + (SELECT COUNT(*) FROM groups WHERE groups.organization_id = $1) AS group_count, + (SELECT COUNT(*) FROM templates WHERE templates.organization_id = $1 AND templates.deleted = false) AS template_count, + (SELECT COUNT(*) FROM organization_members WHERE organization_members.organization_id = $1) AS member_count, + (SELECT COUNT(*) FROM provisioner_keys WHERE provisioner_keys.organization_id = $1) AS provisioner_key_count; + -- name: InsertOrganization :one INSERT INTO organizations (id, "name", display_name, description, icon, created_at, updated_at, is_default) diff --git a/enterprise/coderd/organizations.go b/enterprise/coderd/organizations.go index 6cf91ec5b856a..5a7a4eb777f50 100644 --- a/enterprise/coderd/organizations.go +++ b/enterprise/coderd/organizations.go @@ -4,6 +4,7 @@ import ( "database/sql" "fmt" "net/http" + "strings" "github.com/google/uuid" "golang.org/x/xerrors" @@ -161,10 +162,41 @@ func (api *API) deleteOrganization(rw http.ResponseWriter, r *http.Request) { return nil }, nil) if err != nil { + orgResourcesRow, queryErr := api.Database.GetOrganizationResourceCountByID(ctx, organization.ID) + if queryErr != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error deleting organization.", + Detail: fmt.Sprintf("delete organization: %s", err.Error()), + }) + + return + } + + detailParts := make([]string, 0) + + addDetailPart := func(resource string, count int64) { + if count == 1 { + detailParts = append(detailParts, fmt.Sprintf("1 %s", resource)) + } else if count > 1 { + detailParts = append(detailParts, fmt.Sprintf("%d %ss", count, resource)) + } + } + + addDetailPart("workspace", orgResourcesRow.WorkspaceCount) + addDetailPart("template", orgResourcesRow.TemplateCount) + + // There will always be one member and group so instead we need to check that + // the count is greater than one. + addDetailPart("member", orgResourcesRow.MemberCount-1) + addDetailPart("group", orgResourcesRow.GroupCount-1) + + addDetailPart("provisioner key", orgResourcesRow.ProvisionerKeyCount) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error deleting organization.", - Detail: fmt.Sprintf("delete organization: %s", err.Error()), + Message: "Error deleting organization.", + Detail: fmt.Sprintf("This organization has %s that must be deleted first.", strings.Join(detailParts, ", ")), }) + return }