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[];
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: