diff --git a/.vscode/settings.json b/.vscode/settings.json index c95554245cab5..c824ea4edb783 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -195,7 +195,6 @@ "**.pb.go": true, "**/*.gen.json": true, "**/testdata/*": true, - "**Generated.ts": true, "coderd/apidoc/**": true, "docs/api/*.md": true, "docs/templates/*.md": true, diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 6dde991904811..34c4c6b529d19 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1987,6 +1987,82 @@ const docTemplate = `{ } } } + }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Organizations" + ], + "summary": "Delete organization", + "operationId": "delete-organization", + "parameters": [ + { + "type": "string", + "description": "Organization ID or name", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + }, + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Organizations" + ], + "summary": "Update organization", + "operationId": "update-organization", + "parameters": [ + { + "type": "string", + "description": "Organization ID or name", + "name": "organization", + "in": "path", + "required": true + }, + { + "description": "Patch organization request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateOrganizationRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.Organization" + } + } + } } }, "/organizations/{organization}/groups": { @@ -12099,6 +12175,17 @@ const docTemplate = `{ } } }, + "codersdk.UpdateOrganizationRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, "codersdk.UpdateRoles": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d52e3c515d7d2..43aacb5e0cc32 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1732,6 +1732,72 @@ } } } + }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Organizations"], + "summary": "Delete organization", + "operationId": "delete-organization", + "parameters": [ + { + "type": "string", + "description": "Organization ID or name", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + }, + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Organizations"], + "summary": "Update organization", + "operationId": "update-organization", + "parameters": [ + { + "type": "string", + "description": "Organization ID or name", + "name": "organization", + "in": "path", + "required": true + }, + { + "description": "Patch organization request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateOrganizationRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.Organization" + } + } + } } }, "/organizations/{organization}/groups": { @@ -10958,6 +11024,15 @@ } } }, + "codersdk.UpdateOrganizationRequest": { + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string" + } + } + }, "codersdk.UpdateRoles": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 80f77d92ee672..9ee21a23cf79f 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -812,6 +812,8 @@ func New(options *Options) *API { httpmw.ExtractOrganizationParam(options.Database), ) r.Get("/", api.organization) + r.Patch("/", api.patchOrganization) + r.Delete("/", api.deleteOrganization) r.Post("/templateversions", api.postTemplateVersionsByOrganization) r.Route("/templates", func(r chi.Router) { r.Post("/", api.postTemplateByOrganization) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index bfb28ece948c3..0ab78e75fe196 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -984,6 +984,10 @@ func (q *querier) DeleteOldWorkspaceAgentStats(ctx context.Context) error { return q.db.DeleteOldWorkspaceAgentStats(ctx) } +func (q *querier) DeleteOrganization(ctx context.Context, id uuid.UUID) error { + return deleteQ(q.log, q.auth, q.db.GetOrganizationByID, q.db.DeleteOrganization)(ctx, id) +} + func (q *querier) DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error { if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil { return err @@ -2853,6 +2857,13 @@ func (q *querier) UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg dat return q.db.UpdateOAuth2ProviderAppSecretByID(ctx, arg) } +func (q *querier) UpdateOrganization(ctx context.Context, arg database.UpdateOrganizationParams) (database.Organization, error) { + fetch := func(ctx context.Context, arg database.UpdateOrganizationParams) (database.Organization, error) { + return q.db.GetOrganizationByID(ctx, arg.ID) + } + return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateOrganization)(ctx, arg) +} + func (q *querier) UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg database.UpdateProvisionerDaemonLastSeenAtParams) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceProvisionerDaemon); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index e2b6171b587c3..8e84f4644b91e 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -624,7 +624,7 @@ func (s *MethodTestSuite) TestOrganization() { s.Run("InsertOrganization", s.Subtest(func(db database.Store, check *expects) { check.Args(database.InsertOrganizationParams{ ID: uuid.New(), - Name: "random", + Name: "new-org", }).Asserts(rbac.ResourceOrganization, policy.ActionCreate) })) s.Run("InsertOrganizationMember", s.Subtest(func(db database.Store, check *expects) { @@ -639,6 +639,23 @@ func (s *MethodTestSuite) TestOrganization() { rbac.ResourceAssignRole.InOrg(o.ID), policy.ActionAssign, rbac.ResourceOrganizationMember.InOrg(o.ID).WithID(u.ID), policy.ActionCreate) })) + s.Run("UpdateOrganization", s.Subtest(func(db database.Store, check *expects) { + o := dbgen.Organization(s.T(), db, database.Organization{ + Name: "something-unique", + }) + check.Args(database.UpdateOrganizationParams{ + ID: o.ID, + Name: "something-different", + }).Asserts(o, policy.ActionUpdate) + })) + s.Run("DeleteOrganization", s.Subtest(func(db database.Store, check *expects) { + o := dbgen.Organization(s.T(), db, database.Organization{ + Name: "doomed", + }) + check.Args( + o.ID, + ).Asserts(o, policy.ActionDelete) + })) s.Run("UpdateMemberRoles", s.Subtest(func(db database.Store, check *expects) { o := dbgen.Organization(s.T(), db, database.Organization{}) u := dbgen.User(s.T(), db, database.User{}) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 0a8fe6e24a8a6..5f2ebbff25003 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -33,15 +33,18 @@ import ( var validProxyByHostnameRegex = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) -var errForeignKeyConstraint = &pq.Error{ - Code: "23503", - Message: "update or delete on table violates foreign key constraint", -} - -var errDuplicateKey = &pq.Error{ - Code: "23505", - Message: "duplicate key value violates unique constraint", -} +// A full mapping of error codes from pq v1.10.9 can be found here: +// https://github.com/lib/pq/blob/2a217b94f5ccd3de31aec4152a541b9ff64bed05/error.go#L75 +var ( + errForeignKeyConstraint = &pq.Error{ + Code: "23503", // "foreign_key_violation" + Message: "update or delete on table violates foreign key constraint", + } + errUniqueConstraint = &pq.Error{ + Code: "23505", // "unique_violation" + Message: "duplicate key value violates unique constraint", + } +) // New returns an in-memory fake of the database. func New() database.Store { @@ -1601,6 +1604,19 @@ func (q *FakeQuerier) DeleteOldWorkspaceAgentStats(_ context.Context) error { return nil } +func (q *FakeQuerier) DeleteOrganization(_ context.Context, id uuid.UUID) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, org := range q.organizations { + if org.ID == id && !org.IsDefault { + q.organizations = append(q.organizations[:i], q.organizations[i+1:]...) + return nil + } + } + return sql.ErrNoRows +} + func (q *FakeQuerier) DeleteReplicasUpdatedBefore(_ context.Context, before time.Time) error { q.mutex.Lock() defer q.mutex.Unlock() @@ -5823,7 +5839,7 @@ func (q *FakeQuerier) InsertDBCryptKey(_ context.Context, arg database.InsertDBC for _, key := range q.dbcryptKeys { if key.Number == arg.Number { - return errDuplicateKey + return errUniqueConstraint } } @@ -5927,7 +5943,7 @@ func (q *FakeQuerier) InsertGroup(_ context.Context, arg database.InsertGroupPar for _, group := range q.groups { if group.OrganizationID == arg.OrganizationID && group.Name == arg.Name { - return database.Group{}, errDuplicateKey + return database.Group{}, errUniqueConstraint } } @@ -5958,7 +5974,7 @@ func (q *FakeQuerier) InsertGroupMember(_ context.Context, arg database.InsertGr for _, member := range q.groupMembers { if member.GroupID == arg.GroupID && member.UserID == arg.UserID { - return errDuplicateKey + return errUniqueConstraint } } @@ -6042,7 +6058,7 @@ func (q *FakeQuerier) InsertOAuth2ProviderApp(_ context.Context, arg database.In for _, app := range q.oauth2ProviderApps { if app.Name == arg.Name { - return database.OAuth2ProviderApp{}, errDuplicateKey + return database.OAuth2ProviderApp{}, errUniqueConstraint } } @@ -6423,7 +6439,7 @@ func (q *FakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam for _, user := range q.users { if user.Username == arg.Username && !user.Deleted { - return database.User{}, errDuplicateKey + return database.User{}, errUniqueConstraint } } @@ -6836,7 +6852,7 @@ func (q *FakeQuerier) InsertWorkspaceProxy(_ context.Context, arg database.Inser lastRegionID := int32(0) for _, p := range q.workspaceProxies { if !p.Deleted && p.Name == arg.Name { - return database.WorkspaceProxy{}, errDuplicateKey + return database.WorkspaceProxy{}, errUniqueConstraint } if p.RegionID > lastRegionID { lastRegionID = p.RegionID @@ -7230,7 +7246,7 @@ func (q *FakeQuerier) UpdateOAuth2ProviderAppByID(_ context.Context, arg databas for _, app := range q.oauth2ProviderApps { if app.Name == arg.Name && app.ID != arg.ID { - return database.OAuth2ProviderApp{}, errDuplicateKey + return database.OAuth2ProviderApp{}, errUniqueConstraint } } @@ -7278,6 +7294,33 @@ func (q *FakeQuerier) UpdateOAuth2ProviderAppSecretByID(_ context.Context, arg d return database.OAuth2ProviderAppSecret{}, sql.ErrNoRows } +func (q *FakeQuerier) UpdateOrganization(_ context.Context, arg database.UpdateOrganizationParams) (database.Organization, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.Organization{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + // Enforce the unique constraint, because the API endpoint relies on the database catching + // non-unique names during updates. + for _, org := range q.organizations { + if org.Name == arg.Name && org.ID != arg.ID { + return database.Organization{}, errUniqueConstraint + } + } + + for i, org := range q.organizations { + if org.ID == arg.ID { + org.Name = arg.Name + q.organizations[i] = org + return org, nil + } + } + return database.Organization{}, sql.ErrNoRows +} + func (q *FakeQuerier) UpdateProvisionerDaemonLastSeenAt(_ context.Context, arg database.UpdateProvisionerDaemonLastSeenAtParams) error { err := validateDatabaseType(arg) if err != nil { @@ -7875,7 +7918,7 @@ func (q *FakeQuerier) UpdateWorkspace(_ context.Context, arg database.UpdateWork continue } if other.Name == arg.Name { - return database.Workspace{}, errDuplicateKey + return database.Workspace{}, errUniqueConstraint } } @@ -8215,7 +8258,7 @@ func (q *FakeQuerier) UpdateWorkspaceProxy(_ context.Context, arg database.Updat for _, p := range q.workspaceProxies { if p.Name == arg.Name && p.ID != arg.ID { - return database.WorkspaceProxy{}, errDuplicateKey + return database.WorkspaceProxy{}, errUniqueConstraint } } diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 1b59724a6ea21..bb5a38ef82c61 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -284,6 +284,13 @@ func (m metricsStore) DeleteOldWorkspaceAgentStats(ctx context.Context) error { return err } +func (m metricsStore) DeleteOrganization(ctx context.Context, id uuid.UUID) error { + start := time.Now() + r0 := m.s.DeleteOrganization(ctx, id) + m.queryLatencies.WithLabelValues("DeleteOrganization").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error { start := time.Now() err := m.s.DeleteReplicasUpdatedBefore(ctx, updatedAt) @@ -1845,6 +1852,13 @@ func (m metricsStore) UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg return r0, r1 } +func (m metricsStore) UpdateOrganization(ctx context.Context, arg database.UpdateOrganizationParams) (database.Organization, error) { + start := time.Now() + r0, r1 := m.s.UpdateOrganization(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateOrganization").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg database.UpdateProvisionerDaemonLastSeenAtParams) error { start := time.Now() r0 := m.s.UpdateProvisionerDaemonLastSeenAt(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 128b76cfcd0c6..90d7a20eb6ff8 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -455,6 +455,20 @@ func (mr *MockStoreMockRecorder) DeleteOldWorkspaceAgentStats(arg0 any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldWorkspaceAgentStats", reflect.TypeOf((*MockStore)(nil).DeleteOldWorkspaceAgentStats), arg0) } +// DeleteOrganization mocks base method. +func (m *MockStore) DeleteOrganization(arg0 context.Context, arg1 uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteOrganization", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteOrganization indicates an expected call of DeleteOrganization. +func (mr *MockStoreMockRecorder) DeleteOrganization(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOrganization", reflect.TypeOf((*MockStore)(nil).DeleteOrganization), arg0, arg1) +} + // DeleteReplicasUpdatedBefore mocks base method. func (m *MockStore) DeleteReplicasUpdatedBefore(arg0 context.Context, arg1 time.Time) error { m.ctrl.T.Helper() @@ -3881,6 +3895,21 @@ func (mr *MockStoreMockRecorder) UpdateOAuth2ProviderAppSecretByID(arg0, arg1 an return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOAuth2ProviderAppSecretByID", reflect.TypeOf((*MockStore)(nil).UpdateOAuth2ProviderAppSecretByID), arg0, arg1) } +// UpdateOrganization mocks base method. +func (m *MockStore) UpdateOrganization(arg0 context.Context, arg1 database.UpdateOrganizationParams) (database.Organization, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateOrganization", arg0, arg1) + ret0, _ := ret[0].(database.Organization) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateOrganization indicates an expected call of UpdateOrganization. +func (mr *MockStoreMockRecorder) UpdateOrganization(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateOrganization", reflect.TypeOf((*MockStore)(nil).UpdateOrganization), arg0, arg1) +} + // UpdateProvisionerDaemonLastSeenAt mocks base method. func (m *MockStore) UpdateProvisionerDaemonLastSeenAt(arg0 context.Context, arg1 database.UpdateProvisionerDaemonLastSeenAtParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 8c75b9dcb53a9..a590ae87bc8fd 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -74,6 +74,7 @@ type sqlcQuerier interface { // Logs can take up a lot of space, so it's important we clean up frequently. DeleteOldWorkspaceAgentLogs(ctx context.Context) error DeleteOldWorkspaceAgentStats(ctx context.Context) error + DeleteOrganization(ctx context.Context, id uuid.UUID) error DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error DeleteTailnetAgent(ctx context.Context, arg DeleteTailnetAgentParams) (DeleteTailnetAgentRow, error) DeleteTailnetClient(ctx context.Context, arg DeleteTailnetClientParams) (DeleteTailnetClientRow, error) @@ -368,6 +369,7 @@ type sqlcQuerier interface { UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error) UpdateOAuth2ProviderAppByID(ctx context.Context, arg UpdateOAuth2ProviderAppByIDParams) (OAuth2ProviderApp, error) UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg UpdateOAuth2ProviderAppSecretByIDParams) (OAuth2ProviderAppSecret, error) + UpdateOrganization(ctx context.Context, arg UpdateOrganizationParams) (Organization, error) UpdateProvisionerDaemonLastSeenAt(ctx context.Context, arg UpdateProvisionerDaemonLastSeenAtParams) error UpdateProvisionerJobByID(ctx context.Context, arg UpdateProvisionerJobByIDParams) error UpdateProvisionerJobWithCancelByID(ctx context.Context, arg UpdateProvisionerJobWithCancelByIDParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index c38de30b4cb84..8f5a879d75f5c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3934,6 +3934,19 @@ func (q *sqlQuerier) UpdateMemberRoles(ctx context.Context, arg UpdateMemberRole return i, err } +const deleteOrganization = `-- name: DeleteOrganization :exec +DELETE FROM + organizations +WHERE + id = $1 AND + is_default = false +` + +func (q *sqlQuerier) DeleteOrganization(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteOrganization, id) + return err +} + const getDefaultOrganization = `-- name: GetDefaultOrganization :one SELECT id, name, description, created_at, updated_at, is_default @@ -4126,6 +4139,37 @@ func (q *sqlQuerier) InsertOrganization(ctx context.Context, arg InsertOrganizat return i, err } +const updateOrganization = `-- name: UpdateOrganization :one +UPDATE + organizations +SET + updated_at = $1, + name = $2 +WHERE + id = $3 +RETURNING id, name, description, created_at, updated_at, is_default +` + +type UpdateOrganizationParams struct { + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Name string `db:"name" json:"name"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateOrganization(ctx context.Context, arg UpdateOrganizationParams) (Organization, error) { + row := q.db.QueryRowContext(ctx, updateOrganization, arg.UpdatedAt, arg.Name, arg.ID) + var i Organization + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + &i.IsDefault, + ) + return i, err +} + const getParameterSchemasByJobID = `-- name: GetParameterSchemasByJobID :many SELECT id, created_at, job_id, name, description, default_source_scheme, default_source_value, allow_override_source, default_destination_scheme, allow_override_destination, default_refresh, redisplay_value, validation_error, validation_condition, validation_type_system, validation_value_type, index diff --git a/coderd/database/queries/organizations.sql b/coderd/database/queries/organizations.sql index e809b386926a3..9d5cec1324fe6 100644 --- a/coderd/database/queries/organizations.sql +++ b/coderd/database/queries/organizations.sql @@ -53,3 +53,20 @@ INSERT INTO VALUES -- If no organizations exist, and this is the first, make it the default. ($1, $2, $3, $4, $5, (SELECT TRUE FROM organizations LIMIT 1) IS NULL) RETURNING *; + +-- name: UpdateOrganization :one +UPDATE + organizations +SET + updated_at = @updated_at, + name = @name +WHERE + id = @id +RETURNING *; + +-- name: DeleteOrganization :exec +DELETE FROM + organizations +WHERE + id = $1 AND + is_default = false; diff --git a/coderd/organizations.go b/coderd/organizations.go index e5098a9697caf..2a43ed2a7011a 100644 --- a/coderd/organizations.go +++ b/coderd/organizations.go @@ -118,6 +118,97 @@ func (api *API) postOrganizations(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusCreated, convertOrganization(organization)) } +// @Summary Update organization +// @ID update-organization +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Organizations +// @Param organization path string true "Organization ID or name" +// @Param request body codersdk.UpdateOrganizationRequest true "Patch organization request" +// @Success 200 {object} codersdk.Organization +// @Router /organizations/{organization} [patch] +func (api *API) patchOrganization(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + organization := httpmw.OrganizationParam(r) + + var req codersdk.UpdateOrganizationRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + // "default" is a reserved name that always refers to the default org (much like the way we + // use "me" for users). + if req.Name == codersdk.DefaultOrganization { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Organization name %q is reserved.", codersdk.DefaultOrganization), + }) + return + } + + organization, err := api.Database.UpdateOrganization(ctx, database.UpdateOrganizationParams{ + ID: organization.ID, + UpdatedAt: dbtime.Now(), + Name: req.Name, + }) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if database.IsUniqueViolation(err) { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: fmt.Sprintf("Organization already exists with the name %q.", req.Name), + Validations: []codersdk.ValidationError{{ + Field: "name", + Detail: "This value is already in use and should be unique.", + }}, + }) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error updating organization.", + Detail: fmt.Sprintf("update organization: %s", err.Error()), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, convertOrganization(organization)) +} + +// @Summary Delete organization +// @ID delete-organization +// @Security CoderSessionToken +// @Produce json +// @Tags Organizations +// @Param organization path string true "Organization ID or name" +// @Success 200 {object} codersdk.Response +// @Router /organizations/{organization} [delete] +func (api *API) deleteOrganization(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + organization := httpmw.OrganizationParam(r) + + if organization.IsDefault { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Default organization cannot be deleted.", + }) + return + } + + err := api.Database.DeleteOrganization(ctx, organization.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error deleting organization.", + Detail: fmt.Sprintf("delete organization: %s", err.Error()), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{ + Message: "Organization has been deleted.", + }) +} + // convertOrganization consumes the database representation and outputs an API friendly representation. func convertOrganization(organization database.Organization) codersdk.Organization { return codersdk.Organization{ diff --git a/coderd/organizations_test.go b/coderd/organizations_test.go index e176c7a6d858c..8ce39c5593d90 100644 --- a/coderd/organizations_test.go +++ b/coderd/organizations_test.go @@ -1,7 +1,6 @@ package coderd_test import ( - "context" "net/http" "testing" @@ -16,9 +15,7 @@ func TestMultiOrgFetch(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) makeOrgs := []string{"foo", "bar", "baz"} for _, name := range makeOrgs { @@ -38,9 +35,7 @@ func TestOrganizationsByUser(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) orgs, err := client.OrganizationsByUser(ctx, codersdk.Me) require.NoError(t, err) @@ -62,9 +57,7 @@ func TestOrganizationByUserAndName(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) coderdtest.CreateFirstUser(t, client) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) _, err := client.OrganizationByUserAndName(ctx, codersdk.Me, "nothing") var apiErr *codersdk.Error @@ -77,9 +70,7 @@ func TestOrganizationByUserAndName(t *testing.T) { client := coderdtest.New(t, nil) first := coderdtest.CreateFirstUser(t, client) other, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) org, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ Name: "another", @@ -95,9 +86,7 @@ func TestOrganizationByUserAndName(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) org, err := client.Organization(ctx, user.OrganizationID) require.NoError(t, err) @@ -112,9 +101,7 @@ func TestPostOrganizationsByUser(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) org, err := client.Organization(ctx, user.OrganizationID) require.NoError(t, err) @@ -130,9 +117,7 @@ func TestPostOrganizationsByUser(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) _, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ Name: "new", @@ -140,3 +125,130 @@ func TestPostOrganizationsByUser(t *testing.T) { require.NoError(t, err) }) } + +func TestPatchOrganizationsByUser(t *testing.T) { + t.Parallel() + t.Run("Conflict", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitMedium) + + originalOrg, err := client.Organization(ctx, user.OrganizationID) + require.NoError(t, err) + o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "something-unique", + }) + require.NoError(t, err) + + _, err = client.UpdateOrganization(ctx, o.ID.String(), codersdk.UpdateOrganizationRequest{ + Name: originalOrg.Name, + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusConflict, apiErr.StatusCode()) + }) + + t.Run("ReservedName", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitMedium) + + o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "something-unique", + }) + require.NoError(t, err) + + _, err = client.UpdateOrganization(ctx, o.ID.String(), codersdk.UpdateOrganizationRequest{ + Name: codersdk.DefaultOrganization, + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + }) + + t.Run("UpdateById", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitMedium) + + o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "new", + }) + require.NoError(t, err) + + o, err = client.UpdateOrganization(ctx, o.ID.String(), codersdk.UpdateOrganizationRequest{ + Name: "new-new", + }) + require.NoError(t, err) + require.Equal(t, "new-new", o.Name) + }) + + t.Run("UpdateByName", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitMedium) + + o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "new", + }) + require.NoError(t, err) + + o, err = client.UpdateOrganization(ctx, o.Name, codersdk.UpdateOrganizationRequest{ + Name: "new-new", + }) + require.NoError(t, err) + require.Equal(t, "new-new", o.Name) + }) +} + +func TestDeleteOrganizationsByUser(t *testing.T) { + t.Parallel() + t.Run("Default", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitMedium) + + o, err := client.Organization(ctx, user.OrganizationID) + require.NoError(t, err) + + err = client.DeleteOrganization(ctx, o.ID.String()) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + }) + + t.Run("DeleteById", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitMedium) + + o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "doomed", + }) + require.NoError(t, err) + + err = client.DeleteOrganization(ctx, o.ID.String()) + require.NoError(t, err) + }) + + t.Run("DeleteByName", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitMedium) + + o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "doomed", + }) + require.NoError(t, err) + + err = client.DeleteOrganization(ctx, o.Name) + require.NoError(t, err) + }) +} diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 4c9cf81c497d3..646eae71d2475 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -55,6 +55,14 @@ type OrganizationMember struct { Roles []SlimRole `db:"roles" json:"roles"` } +type CreateOrganizationRequest struct { + Name string `json:"name" validate:"required,username"` +} + +type UpdateOrganizationRequest struct { + Name string `json:"name" validate:"required,username"` +} + // CreateTemplateVersionRequest enables callers to create a new Template Version. type CreateTemplateVersionRequest struct { Name string `json:"name,omitempty" validate:"omitempty,template_version_name"` @@ -187,6 +195,55 @@ func (c *Client) Organization(ctx context.Context, id uuid.UUID) (Organization, return c.OrganizationByName(ctx, id.String()) } +// CreateOrganization creates an organization and adds the user making the request as an owner. +func (c *Client) CreateOrganization(ctx context.Context, req CreateOrganizationRequest) (Organization, error) { + res, err := c.Request(ctx, http.MethodPost, "/api/v2/organizations", req) + if err != nil { + return Organization{}, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusCreated { + return Organization{}, ReadBodyAsError(res) + } + + var org Organization + return org, json.NewDecoder(res.Body).Decode(&org) +} + +// UpdateOrganization will update information about the corresponding organization, based on +// the UUID/name provided as `orgID`. +func (c *Client) UpdateOrganization(ctx context.Context, orgID string, req UpdateOrganizationRequest) (Organization, error) { + res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/organizations/%s", orgID), req) + if err != nil { + return Organization{}, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return Organization{}, ReadBodyAsError(res) + } + + var organization Organization + return organization, json.NewDecoder(res.Body).Decode(&organization) +} + +// DeleteOrganization will remove the corresponding organization from the deployment, based on +// the UUID/name provided as `orgID`. +func (c *Client) DeleteOrganization(ctx context.Context, orgID string) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/organizations/%s", orgID), nil) + if err != nil { + return xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return ReadBodyAsError(res) + } + + return nil +} + // ProvisionerDaemons returns provisioner daemons available. func (c *Client) ProvisionerDaemons(ctx context.Context) ([]ProvisionerDaemon, error) { res, err := c.Request(ctx, http.MethodGet, diff --git a/codersdk/users.go b/codersdk/users.go index 80ca583141c9b..003ede2f9bd60 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -203,10 +203,6 @@ type OAuthConversionResponse struct { UserID uuid.UUID `json:"user_id" format:"uuid"` } -type CreateOrganizationRequest struct { - Name string `json:"name" validate:"required,username"` -} - // AuthMethods contains authentication method information like whether they are enabled or not or custom text, etc. type AuthMethods struct { TermsOfServiceURL string `json:"terms_of_service_url,omitempty"` @@ -587,22 +583,6 @@ func (c *Client) OrganizationByUserAndName(ctx context.Context, user string, nam return org, json.NewDecoder(res.Body).Decode(&org) } -// CreateOrganization creates an organization and adds the provided user as an admin. -func (c *Client) CreateOrganization(ctx context.Context, req CreateOrganizationRequest) (Organization, error) { - res, err := c.Request(ctx, http.MethodPost, "/api/v2/organizations", req) - if err != nil { - return Organization{}, err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusCreated { - return Organization{}, ReadBodyAsError(res) - } - - var org Organization - return org, json.NewDecoder(res.Body).Decode(&org) -} - // AuthMethods returns types of authentication available to the user. func (c *Client) AuthMethods(ctx context.Context) (AuthMethods, error) { res, err := c.Request(ctx, http.MethodGet, "/api/v2/users/authmethods", nil) diff --git a/docs/api/organizations.md b/docs/api/organizations.md index 478c8aba56648..c6f4514eb9bad 100644 --- a/docs/api/organizations.md +++ b/docs/api/organizations.md @@ -177,3 +177,98 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization} \ | 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Organization](schemas.md#codersdkorganization) | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Delete organization + +### Code samples + +```shell +# Example request using curl +curl -X DELETE http://coder-server:8080/api/v2/organizations/{organization} \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`DELETE /organizations/{organization}` + +### Parameters + +| Name | In | Type | Required | Description | +| -------------- | ---- | ------ | -------- | ----------------------- | +| `organization` | path | string | true | Organization ID or name | + +### Example responses + +> 200 Response + +```json +{ + "detail": "string", + "message": "string", + "validations": [ + { + "detail": "string", + "field": "string" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Response](schemas.md#codersdkresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Update organization + +### Code samples + +```shell +# Example request using curl +curl -X PATCH http://coder-server:8080/api/v2/organizations/{organization} \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PATCH /organizations/{organization}` + +> Body parameter + +```json +{ + "name": "string" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| -------------- | ---- | ---------------------------------------------------------------------------------- | -------- | -------------------------- | +| `organization` | path | string | true | Organization ID or name | +| `body` | body | [codersdk.UpdateOrganizationRequest](schemas.md#codersdkupdateorganizationrequest) | true | Patch organization request | + +### Example responses + +> 200 Response + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "is_default": true, + "name": "string", + "updated_at": "2019-08-24T14:15:22Z" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Organization](schemas.md#codersdkorganization) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index d1b6c6a3d82e0..67fb461ee1b0b 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -5361,6 +5361,20 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `url` | string | false | | URL to download the latest release of Coder. | | `version` | string | false | | Version is the semantic version for the latest release of Coder. | +## codersdk.UpdateOrganizationRequest + +```json +{ + "name": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------ | ------ | -------- | ------------ | ----------- | +| `name` | string | true | | | + ## codersdk.UpdateRoles ```json diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a809b10220993..db1b39fdbed26 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -223,7 +223,7 @@ export interface CreateGroupRequest { readonly quota_allowance: number; } -// From codersdk/users.go +// From codersdk/organizations.go export interface CreateOrganizationRequest { readonly name: string; } @@ -1318,6 +1318,11 @@ export interface UpdateCheckResponse { readonly url: string; } +// From codersdk/organizations.go +export interface UpdateOrganizationRequest { + readonly name: string; +} + // From codersdk/users.go export interface UpdateRoles { readonly roles: readonly string[];