From ce4b8542bb5a4af2995fe6f9d998f9b51c4d86ca Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 11 Jun 2024 15:25:50 -0500 Subject: [PATCH] chore: implement api layer for listing organization members --- coderd/apidoc/docs.go | 67 ++++++++++++++++++++++++++++++++++ coderd/apidoc/swagger.json | 63 ++++++++++++++++++++++++++++++++ coderd/coderd.go | 1 + coderd/members.go | 41 +++++++++++++++++++++ coderd/members_test.go | 60 ++++++++++++++++++++++++++++++ codersdk/organizations.go | 5 +++ codersdk/users.go | 14 +++++++ docs/api/members.md | 67 ++++++++++++++++++++++++++++++++++ docs/api/schemas.md | 30 +++++++++++++++ site/src/api/typesGenerated.ts | 5 +++ 10 files changed, 353 insertions(+) create mode 100644 coderd/members_test.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 70988aa071afb..80db374931774 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -2245,6 +2245,43 @@ const docTemplate = `{ } } }, + "/organizations/{organization}/members": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Members" + ], + "summary": "List organization members", + "operationId": "list-organization-members", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.OrganizationMemberWithName" + } + } + } + } + } + }, "/organizations/{organization}/members/roles": { "get": { "security": [ @@ -10056,6 +10093,36 @@ const docTemplate = `{ } } }, + "codersdk.OrganizationMemberWithName": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.SlimRole" + } + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string" + } + } + }, "codersdk.PatchGroupRequest": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 32dd1565a5a28..069ffecea9c11 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1963,6 +1963,39 @@ } } }, + "/organizations/{organization}/members": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Members"], + "summary": "List organization members", + "operationId": "list-organization-members", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "organization", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.OrganizationMemberWithName" + } + } + } + } + } + }, "/organizations/{organization}/members/roles": { "get": { "security": [ @@ -9045,6 +9078,36 @@ } } }, + "codersdk.OrganizationMemberWithName": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "organization_id": { + "type": "string", + "format": "uuid" + }, + "roles": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.SlimRole" + } + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string" + } + } + }, "codersdk.PatchGroupRequest": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index f7f1f52ee5bea..e8a698de0de34 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -837,6 +837,7 @@ func New(options *Options) *API { }) }) r.Route("/members", func(r chi.Router) { + r.Get("/", api.listMembers) r.Route("/roles", func(r chi.Router) { r.Get("/", api.assignableOrgRoles) r.With(httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentCustomRoles)). diff --git a/coderd/members.go b/coderd/members.go index 3110cc51dbcf2..1877cad78a614 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -3,6 +3,8 @@ package coderd import ( "net/http" + "github.com/google/uuid" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/rbac" @@ -12,6 +14,36 @@ import ( "github.com/coder/coder/v2/codersdk" ) +// @Summary List organization members +// @ID list-organization-members +// @Security CoderSessionToken +// @Produce json +// @Tags Members +// @Param organization path string true "Organization ID" +// @Success 200 {object} []codersdk.OrganizationMemberWithName +// @Router /organizations/{organization}/members [get] +func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + organization = httpmw.OrganizationParam(r) + ) + + members, err := api.Database.OrganizationMembers(ctx, database.OrganizationMembersParams{ + OrganizationID: organization.ID, + UserID: uuid.Nil, + }) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.List(members, convertOrganizationMemberRow)) +} + // @Summary Assign role to organization member // @ID assign-role-to-organization-member // @Security CoderSessionToken @@ -73,3 +105,12 @@ func convertOrganizationMember(mem database.OrganizationMember) codersdk.Organiz } return convertedMember } + +func convertOrganizationMemberRow(row database.OrganizationMembersRow) codersdk.OrganizationMemberWithName { + convertedMember := codersdk.OrganizationMemberWithName{ + Username: row.Username, + OrganizationMember: convertOrganizationMember(row.OrganizationMember), + } + + return convertedMember +} diff --git a/coderd/members_test.go b/coderd/members_test.go new file mode 100644 index 0000000000000..250a594a150f5 --- /dev/null +++ b/coderd/members_test.go @@ -0,0 +1,60 @@ +package coderd_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestListMembers(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + owner := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, owner) + + client, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.ScopedRoleOrgAdmin(first.OrganizationID)) + + ctx := testutil.Context(t, testutil.WaitShort) + members, err := client.OrganizationMembers(ctx, first.OrganizationID) + require.NoError(t, err) + require.Len(t, members, 2) + require.ElementsMatch(t, + []uuid.UUID{first.UserID, user.ID}, + db2sdk.List(members, onlyIDs)) + }) + + // Calling it from a user without the org access. + t.Run("NotInOrg", func(t *testing.T) { + t.Parallel() + owner := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, owner) + + client, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.ScopedRoleOrgAdmin(first.OrganizationID)) + + ctx := testutil.Context(t, testutil.WaitShort) + org, err := owner.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{ + Name: "test", + DisplayName: "", + Description: "", + }) + require.NoError(t, err, "create organization") + + // 404 error is expected instead of a 403/401 to not leak existence of + // an organization. + _, err = client.OrganizationMembers(ctx, org.ID) + require.ErrorContains(t, err, "404") + }) +} + +func onlyIDs(u codersdk.OrganizationMemberWithName) uuid.UUID { + return u.UserID +} diff --git a/codersdk/organizations.go b/codersdk/organizations.go index f125dbca3dc58..d8f4bc4c2aea7 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -58,6 +58,11 @@ type OrganizationMember struct { Roles []SlimRole `db:"roles" json:"roles"` } +type OrganizationMemberWithName struct { + Username string `table:"username,default_sort" json:"username"` + OrganizationMember `table:"m,recursive_inline"` +} + type CreateOrganizationRequest struct { Name string `json:"name" validate:"required,organization_name"` // DisplayName will default to the same value as `Name` if not provided. diff --git a/codersdk/users.go b/codersdk/users.go index 003ede2f9bd60..f16780aa2eb7c 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -379,6 +379,20 @@ func (c *Client) UpdateUserPassword(ctx context.Context, user string, req Update 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) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + var members []OrganizationMemberWithName + return members, json.NewDecoder(res.Body).Decode(&members) +} + // UpdateUserRoles grants the userID the specified roles. // Include ALL roles the user has. func (c *Client) UpdateUserRoles(ctx context.Context, user string, req UpdateRoles) (User, error) { diff --git a/docs/api/members.md b/docs/api/members.md index ce7cc81f1762b..77ef260131e29 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -1,5 +1,72 @@ # Members +## List organization members + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /organizations/{organization}/members` + +### Parameters + +| Name | In | Type | Required | Description | +| -------------- | ---- | ------ | -------- | --------------- | +| `organization` | path | string | true | Organization ID | + +### 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", + "username": "string" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.OrganizationMemberWithName](schemas.md#codersdkorganizationmemberwithname) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| -------------------- | ----------------- | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» created_at` | string(date-time) | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» roles` | array | false | | | +| `»» display_name` | string | false | | | +| `»» name` | string | false | | | +| `»» organization_id` | string | false | | | +| `» updated_at` | string(date-time) | false | | | +| `» user_id` | string(uuid) | false | | | +| `» username` | string | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get member roles by organization ### Code samples diff --git a/docs/api/schemas.md b/docs/api/schemas.md index a825e21778c74..c8aacbc39439b 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3265,6 +3265,36 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `updated_at` | string | false | | | | `user_id` | string | false | | | +## codersdk.OrganizationMemberWithName + +```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", + "username": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ----------------- | ----------------------------------------------- | -------- | ------------ | ----------- | +| `created_at` | string | false | | | +| `organization_id` | string | false | | | +| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | +| `updated_at` | string | false | | | +| `user_id` | string | false | | | +| `username` | string | false | | | + ## codersdk.PatchGroupRequest ```json diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 971fae1149075..4610863299874 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -797,6 +797,11 @@ export interface OrganizationMember { readonly roles: readonly SlimRole[]; } +// From codersdk/organizations.go +export interface OrganizationMemberWithName extends OrganizationMember { + readonly username: string; +} + // From codersdk/pagination.go export interface Pagination { readonly after_id?: string;