feat(coderd): add endpoints for editing and deleting organizations (#13287)

This commit is contained in:
Kayla Washburn-Love
2024-05-21 12:46:31 -06:00
committed by GitHub
parent 0a86d6d176
commit 3f1e9c038a
19 changed files with 757 additions and 63 deletions
-1
View File
@@ -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,
+87
View File
@@ -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": {
+75
View File
@@ -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": {
+2
View File
@@ -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)
+11
View File
@@ -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
+18 -1
View File
@@ -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{})
+61 -18
View File
@@ -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
}
}
+14
View File
@@ -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)
+29
View File
@@ -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()
+2
View File
@@ -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
+44
View File
@@ -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
+17
View File
@@ -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;
+91
View File
@@ -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{
+134 -22
View File
@@ -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)
})
}
+57
View File
@@ -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,
-20
View File
@@ -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)
+95
View File
@@ -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).
+14
View File
@@ -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
+6 -1
View File
@@ -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[];