diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3ee75d77e46c3..31330cd175222 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2395,6 +2395,45 @@ const docTemplate = `{ } } } + }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Members" + ], + "summary": "Remove organization member", + "operationId": "remove-organization-member", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.OrganizationMember" + } + } + } } }, "/organizations/{organization}/members/{user}/roles": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 9acd484da021f..254eaa54c46dd 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2097,6 +2097,41 @@ } } } + }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Members"], + "summary": "Remove organization member", + "operationId": "remove-organization-member", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User ID, name, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.OrganizationMember" + } + } + } } }, "/organizations/{organization}/members/{user}/roles": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 7a697b58b7929..cc2de344a2cee 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -860,6 +860,7 @@ func New(options *Options) *API { r.Use( httpmw.ExtractOrganizationMemberParam(options.Database), ) + r.Delete("/", api.deleteOrganizationMember) r.Put("/roles", api.putMemberRoles) r.Post("/workspaces", api.postWorkspacesByOrganization) }) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 8aec14eb7bf47..f32e176754fa1 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1035,6 +1035,16 @@ 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) DeleteOrganizationMember(ctx context.Context, arg database.DeleteOrganizationMemberParams) error { + return deleteQ[database.OrganizationMember](q.log, q.auth, func(ctx context.Context, arg database.DeleteOrganizationMemberParams) (database.OrganizationMember, error) { + member, err := database.ExpectOne(q.OrganizationMembers(ctx, database.OrganizationMembersParams(arg))) + if err != nil { + return database.OrganizationMember{}, err + } + return member.OrganizationMember, nil + }, q.db.DeleteOrganizationMember)(ctx, arg) +} + func (q *querier) DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt time.Time) error { if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 96b0e35874186..17c0d76c4ef31 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -628,6 +628,23 @@ func (s *MethodTestSuite) TestOrganization() { rbac.ResourceAssignOrgRole.InOrg(o.ID), policy.ActionAssign, rbac.ResourceOrganizationMember.InOrg(o.ID).WithID(u.ID), policy.ActionCreate) })) + s.Run("DeleteOrganizationMember", s.Subtest(func(db database.Store, check *expects) { + o := dbgen.Organization(s.T(), db, database.Organization{}) + u := dbgen.User(s.T(), db, database.User{}) + member := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{UserID: u.ID, OrganizationID: o.ID}) + + check.Args(database.DeleteOrganizationMemberParams{ + OrganizationID: o.ID, + UserID: u.ID, + }).Asserts( + // Reads the org member before it tries to delete it + member, policy.ActionRead, + member, policy.ActionDelete). + // SQL Filter returns a 404 + WithNotAuthorized("no rows"). + WithCancelled("no rows"). + Errors(sql.ErrNoRows) + })) s.Run("UpdateOrganization", s.Subtest(func(db database.Store, check *expects) { o := dbgen.Organization(s.T(), db, database.Organization{ Name: "something-unique", diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 92961d4cc84ed..47d75e831469e 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -1632,6 +1632,24 @@ func (q *FakeQuerier) DeleteOrganization(_ context.Context, id uuid.UUID) error return sql.ErrNoRows } +func (q *FakeQuerier) DeleteOrganizationMember(_ context.Context, arg database.DeleteOrganizationMemberParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + deleted := slices.DeleteFunc(q.data.organizationMembers, func(member database.OrganizationMember) bool { + return member.OrganizationID == arg.OrganizationID && member.UserID == arg.UserID + }) + if len(deleted) == 0 { + return sql.ErrNoRows + } + return nil +} + func (q *FakeQuerier) DeleteReplicasUpdatedBefore(_ context.Context, before time.Time) error { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 1891fe6f999e9..dc4c52a31faaf 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -291,6 +291,13 @@ func (m metricsStore) DeleteOrganization(ctx context.Context, id uuid.UUID) erro return r0 } +func (m metricsStore) DeleteOrganizationMember(ctx context.Context, arg database.DeleteOrganizationMemberParams) error { + start := time.Now() + r0 := m.s.DeleteOrganizationMember(ctx, arg) + m.queryLatencies.WithLabelValues("DeleteOrganizationMember").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) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index b49d3e7f06c76..35f0312b7b20f 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -469,6 +469,20 @@ func (mr *MockStoreMockRecorder) DeleteOrganization(arg0, arg1 any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOrganization", reflect.TypeOf((*MockStore)(nil).DeleteOrganization), arg0, arg1) } +// DeleteOrganizationMember mocks base method. +func (m *MockStore) DeleteOrganizationMember(arg0 context.Context, arg1 database.DeleteOrganizationMemberParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteOrganizationMember", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteOrganizationMember indicates an expected call of DeleteOrganizationMember. +func (mr *MockStoreMockRecorder) DeleteOrganizationMember(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOrganizationMember", reflect.TypeOf((*MockStore)(nil).DeleteOrganizationMember), arg0, arg1) +} + // DeleteReplicasUpdatedBefore mocks base method. func (m *MockStore) DeleteReplicasUpdatedBefore(arg0 context.Context, arg1 time.Time) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index f87e6015b517e..50645ab1e5eb5 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -75,6 +75,7 @@ type sqlcQuerier interface { DeleteOldWorkspaceAgentLogs(ctx context.Context) error DeleteOldWorkspaceAgentStats(ctx context.Context) error DeleteOrganization(ctx context.Context, id uuid.UUID) error + DeleteOrganizationMember(ctx context.Context, arg DeleteOrganizationMemberParams) 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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ac05a3f26d061..2c0b21824ad28 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3756,6 +3756,25 @@ func (q *sqlQuerier) UpdateOAuth2ProviderAppSecretByID(ctx context.Context, arg return i, err } +const deleteOrganizationMember = `-- name: DeleteOrganizationMember :exec +DELETE + FROM + organization_members + WHERE + organization_id = $1 AND + user_id = $2 +` + +type DeleteOrganizationMemberParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` +} + +func (q *sqlQuerier) DeleteOrganizationMember(ctx context.Context, arg DeleteOrganizationMemberParams) error { + _, err := q.db.ExecContext(ctx, deleteOrganizationMember, arg.OrganizationID, arg.UserID) + return err +} + const getOrganizationIDsByMemberIDs = `-- name: GetOrganizationIDsByMemberIDs :many SELECT user_id, array_agg(organization_id) :: uuid [ ] AS "organization_IDs" diff --git a/coderd/database/queries/organizationmembers.sql b/coderd/database/queries/organizationmembers.sql index d32d9a8e8abc8..4722973d38589 100644 --- a/coderd/database/queries/organizationmembers.sql +++ b/coderd/database/queries/organizationmembers.sql @@ -36,6 +36,15 @@ INSERT INTO VALUES ($1, $2, $3, $4, $5) RETURNING *; +-- name: DeleteOrganizationMember :exec +DELETE + FROM + organization_members + WHERE + organization_id = @organization_id AND + user_id = @user_id +; + -- name: GetOrganizationIDsByMemberIDs :many SELECT diff --git a/coderd/members.go b/coderd/members.go index d958f401bb9b3..3352eefc3b3d6 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -62,6 +62,38 @@ func (api *API) postOrganizationMember(rw http.ResponseWriter, r *http.Request) httpapi.Write(ctx, rw, http.StatusOK, resp[0]) } +// @Summary Remove organization member +// @ID remove-organization-member +// @Security CoderSessionToken +// @Produce json +// @Tags Members +// @Param organization path string true "Organization ID" +// @Param user path string true "User ID, name, or me" +// @Success 200 {object} codersdk.OrganizationMember +// @Router /organizations/{organization}/members/{user} [delete] +func (api *API) deleteOrganizationMember(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + organization = httpmw.OrganizationParam(r) + member = httpmw.OrganizationMemberParam(r) + ) + + err := api.Database.DeleteOrganizationMember(ctx, database.DeleteOrganizationMemberParams{ + OrganizationID: organization.ID, + UserID: member.UserID, + }) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, "organization member removed") +} + // @Summary List organization members // @ID list-organization-members // @Security CoderSessionToken diff --git a/coderd/members_test.go b/coderd/members_test.go index 1f7e0ff56ae09..db80b28ad1fbb 100644 --- a/coderd/members_test.go +++ b/coderd/members_test.go @@ -1,6 +1,7 @@ package coderd_test import ( + "net/http" "testing" "github.com/google/uuid" @@ -114,6 +115,63 @@ func TestListMembers(t *testing.T) { }) } +func TestRemoveMember(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + owner := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, owner) + orgAdminClient, orgAdmin := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.ScopedRoleOrgAdmin(first.OrganizationID)) + _, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitMedium) + // Verify the org of 3 members + members, err := orgAdminClient.OrganizationMembers(ctx, first.OrganizationID) + require.NoError(t, err) + require.Len(t, members, 3) + require.ElementsMatch(t, + []uuid.UUID{first.UserID, user.ID, orgAdmin.ID}, + db2sdk.List(members, onlyIDs)) + + // Delete a member + err = orgAdminClient.DeleteOrganizationMember(ctx, first.OrganizationID, user.Username) + require.NoError(t, err) + + members, err = orgAdminClient.OrganizationMembers(ctx, first.OrganizationID) + require.NoError(t, err) + require.Len(t, members, 2) + require.ElementsMatch(t, + []uuid.UUID{first.UserID, orgAdmin.ID}, + db2sdk.List(members, onlyIDs)) + }) + + t.Run("MemberNotInOrg", func(t *testing.T) { + t.Parallel() + owner := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, owner) + orgAdminClient, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.ScopedRoleOrgAdmin(first.OrganizationID)) + + ctx := testutil.Context(t, testutil.WaitMedium) + // nolint:gocritic // requires owner to make a new org + org, _ := owner.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "other", + DisplayName: "", + Description: "", + Icon: "", + }) + + _, user := coderdtest.CreateAnotherUser(t, owner, org.ID) + + // Delete a user that is not in the organization + err := orgAdminClient.DeleteOrganizationMember(ctx, first.OrganizationID, user.Username) + require.Error(t, err) + var apiError *codersdk.Error + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusNotFound, apiError.StatusCode()) + }) +} + func onlyIDs(u codersdk.OrganizationMemberWithName) uuid.UUID { return u.UserID } diff --git a/codersdk/users.go b/codersdk/users.go index 5cf01405af0d4..f99015b50bde5 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -393,6 +393,19 @@ func (c *Client) PostOrganizationMember(ctx context.Context, organizationID uuid return member, json.NewDecoder(res.Body).Decode(&member) } +// DeleteOrganizationMember removes a user from an organization +func (c *Client) DeleteOrganizationMember(ctx context.Context, organizationID uuid.UUID, user string) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/organizations/%s/members/%s", organizationID, user), nil) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ReadBodyAsError(res) + } + return nil +} + // OrganizationMembers lists all members in an organization func (c *Client) OrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]OrganizationMemberWithName, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/", organizationID), nil) diff --git a/docs/api/members.md b/docs/api/members.md index 2f0c8a97a9892..1a9beae285157 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -363,6 +363,54 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Remove organization member + +### Code samples + +```shell +# Example request using curl +curl -X DELETE http://coder-server:8080/api/v2/organizations/{organization}/members/{user} \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`DELETE /organizations/{organization}/members/{user}` + +### Parameters + +| Name | In | Type | Required | Description | +| -------------- | ---- | ------ | -------- | -------------------- | +| `organization` | path | string | true | Organization ID | +| `user` | path | string | true | User ID, name, or me | + +### Example responses + +> 200 Response + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "roles": [ + { + "display_name": "string", + "name": "string", + "organization_id": "string" + } + ], + "updated_at": "2019-08-24T14:15:22Z", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.OrganizationMember](schemas.md#codersdkorganizationmember) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Assign role to organization member ### Code samples