Skip to content

Commit 8e06ad4

Browse files
authored
chore: add organization member api + cli (#13577)
1 parent 4699ade commit 8e06ad4

File tree

10 files changed

+335
-6
lines changed

10 files changed

+335
-6
lines changed

cli/organization.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ func (r *RootCmd) organizations() *serpent.Command {
2929
r.currentOrganization(),
3030
r.switchOrganization(),
3131
r.createOrganization(),
32-
r.organizationRoles(),
3332
r.organizationMembers(),
33+
r.organizationRoles(),
3434
},
3535
}
3636

cli/organizationmembers.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ func (r *RootCmd) organizationMembers() *serpent.Command {
1919
Children: []*serpent.Command{
2020
r.listOrganizationMembers(),
2121
r.assignOrganizationRoles(),
22+
r.addOrganizationMember(),
2223
},
2324
Handler: func(inv *serpent.Invocation) error {
2425
return inv.Command.HelpHandler(inv)
@@ -28,6 +29,37 @@ func (r *RootCmd) organizationMembers() *serpent.Command {
2829
return cmd
2930
}
3031

32+
func (r *RootCmd) addOrganizationMember() *serpent.Command {
33+
client := new(codersdk.Client)
34+
35+
cmd := &serpent.Command{
36+
Use: "add <username | user_id>",
37+
Short: "Add a new member to the current organization",
38+
Middleware: serpent.Chain(
39+
r.InitClient(client),
40+
serpent.RequireNArgs(1),
41+
),
42+
Handler: func(inv *serpent.Invocation) error {
43+
ctx := inv.Context()
44+
organization, err := CurrentOrganization(r, inv, client)
45+
if err != nil {
46+
return err
47+
}
48+
user := inv.Args[0]
49+
50+
_, err = client.PostOrganizationMember(ctx, organization.ID, user)
51+
if err != nil {
52+
return xerrors.Errorf("could not add member to organization: %w", err)
53+
}
54+
55+
_, _ = fmt.Fprintln(inv.Stdout, "Organization member added")
56+
return nil
57+
},
58+
}
59+
60+
return cmd
61+
}
62+
3163
func (r *RootCmd) assignOrganizationRoles() *serpent.Command {
3264
client := new(codersdk.Client)
3365

cli/organizationmembers_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/coder/coder/v2/cli/clitest"
1010
"github.com/coder/coder/v2/coderd/coderdtest"
1111
"github.com/coder/coder/v2/coderd/rbac"
12+
"github.com/coder/coder/v2/codersdk"
1213
"github.com/coder/coder/v2/testutil"
1314
)
1415

@@ -34,3 +35,40 @@ func TestListOrganizationMembers(t *testing.T) {
3435
require.Contains(t, buf.String(), owner.UserID.String())
3536
})
3637
}
38+
39+
func TestAddOrganizationMembers(t *testing.T) {
40+
t.Parallel()
41+
42+
t.Run("OK", func(t *testing.T) {
43+
t.Parallel()
44+
45+
ownerClient := coderdtest.New(t, &coderdtest.Options{})
46+
owner := coderdtest.CreateFirstUser(t, ownerClient)
47+
_, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
48+
49+
ctx := testutil.Context(t, testutil.WaitMedium)
50+
//nolint:gocritic // must be an owner, only owners can create orgs
51+
otherOrg, err := ownerClient.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
52+
Name: "Other",
53+
DisplayName: "",
54+
Description: "",
55+
Icon: "",
56+
})
57+
require.NoError(t, err, "create another organization")
58+
59+
inv, root := clitest.New(t, "organization", "members", "add", "--organization", otherOrg.ID.String(), user.Username)
60+
//nolint:gocritic // must be an owner
61+
clitest.SetupConfig(t, ownerClient, root)
62+
63+
buf := new(bytes.Buffer)
64+
inv.Stdout = buf
65+
err = inv.WithContext(ctx).Run()
66+
require.NoError(t, err)
67+
68+
//nolint:gocritic // must be an owner
69+
members, err := ownerClient.OrganizationMembers(ctx, otherOrg.ID)
70+
require.NoError(t, err)
71+
72+
require.Len(t, members, 2)
73+
})
74+
}

coderd/apidoc/docs.go

Lines changed: 41 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 37 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -845,11 +845,24 @@ func New(options *Options) *API {
845845
})
846846

847847
r.Route("/{user}", func(r chi.Router) {
848-
r.Use(
849-
httpmw.ExtractOrganizationMemberParam(options.Database),
850-
)
851-
r.Put("/roles", api.putMemberRoles)
852-
r.Post("/workspaces", api.postWorkspacesByOrganization)
848+
r.Group(func(r chi.Router) {
849+
r.Use(
850+
// Adding a member requires "read" permission
851+
// on the site user. So limited to owners and user-admins.
852+
// TODO: Allow org-admins to add users via some new permission? Or give them
853+
// read on site users.
854+
httpmw.ExtractUserParam(options.Database),
855+
)
856+
r.Post("/", api.postOrganizationMember)
857+
})
858+
859+
r.Group(func(r chi.Router) {
860+
r.Use(
861+
httpmw.ExtractOrganizationMemberParam(options.Database),
862+
)
863+
r.Put("/roles", api.putMemberRoles)
864+
r.Post("/workspaces", api.postWorkspacesByOrganization)
865+
})
853866
})
854867
})
855868
})

coderd/members.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,59 @@ import (
99

1010
"github.com/coder/coder/v2/coderd/database"
1111
"github.com/coder/coder/v2/coderd/database/db2sdk"
12+
"github.com/coder/coder/v2/coderd/database/dbtime"
1213
"github.com/coder/coder/v2/coderd/httpapi"
1314
"github.com/coder/coder/v2/coderd/httpmw"
1415
"github.com/coder/coder/v2/coderd/rbac"
1516
"github.com/coder/coder/v2/codersdk"
1617
)
1718

19+
// @Summary Add organization member
20+
// @ID add-organization-member
21+
// @Security CoderSessionToken
22+
// @Produce json
23+
// @Tags Members
24+
// @Param organization path string true "Organization ID"
25+
// @Param user path string true "User ID, name, or me"
26+
// @Success 200 {object} codersdk.OrganizationMember
27+
// @Router /organizations/{organization}/members/{user} [post]
28+
func (api *API) postOrganizationMember(rw http.ResponseWriter, r *http.Request) {
29+
var (
30+
ctx = r.Context()
31+
organization = httpmw.OrganizationParam(r)
32+
user = httpmw.UserParam(r)
33+
)
34+
35+
member, err := api.Database.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{
36+
OrganizationID: organization.ID,
37+
UserID: user.ID,
38+
CreatedAt: dbtime.Now(),
39+
UpdatedAt: dbtime.Now(),
40+
Roles: []string{},
41+
})
42+
if httpapi.Is404Error(err) {
43+
httpapi.ResourceNotFound(rw)
44+
return
45+
}
46+
if err != nil {
47+
httpapi.InternalServerError(rw, err)
48+
return
49+
}
50+
51+
resp, err := convertOrganizationMembers(ctx, api.Database, []database.OrganizationMember{member})
52+
if err != nil {
53+
httpapi.InternalServerError(rw, err)
54+
return
55+
}
56+
57+
if len(resp) == 0 {
58+
httpapi.InternalServerError(rw, xerrors.Errorf("marshal member"))
59+
return
60+
}
61+
62+
httpapi.Write(ctx, rw, http.StatusOK, resp[0])
63+
}
64+
1865
// @Summary List organization members
1966
// @ID list-organization-members
2067
// @Security CoderSessionToken

coderd/members_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,65 @@ import (
1313
"github.com/coder/coder/v2/testutil"
1414
)
1515

16+
func TestAddMember(t *testing.T) {
17+
t.Parallel()
18+
19+
t.Run("OK", func(t *testing.T) {
20+
t.Parallel()
21+
owner := coderdtest.New(t, nil)
22+
first := coderdtest.CreateFirstUser(t, owner)
23+
ctx := testutil.Context(t, testutil.WaitMedium)
24+
org, err := owner.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
25+
Name: "other",
26+
DisplayName: "",
27+
Description: "",
28+
Icon: "",
29+
})
30+
require.NoError(t, err)
31+
32+
// Make a user not in the second organization
33+
_, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID)
34+
35+
members, err := owner.OrganizationMembers(ctx, org.ID)
36+
require.NoError(t, err)
37+
require.Len(t, members, 1) // Verify just the 1 member
38+
39+
// Add user to org
40+
_, err = owner.PostOrganizationMember(ctx, org.ID, user.Username)
41+
require.NoError(t, err)
42+
43+
members, err = owner.OrganizationMembers(ctx, org.ID)
44+
require.NoError(t, err)
45+
// Owner + new member
46+
require.Len(t, members, 2)
47+
require.ElementsMatch(t,
48+
[]uuid.UUID{first.UserID, user.ID},
49+
db2sdk.List(members, onlyIDs))
50+
})
51+
52+
t.Run("UserNotExists", func(t *testing.T) {
53+
t.Parallel()
54+
owner := coderdtest.New(t, nil)
55+
_ = coderdtest.CreateFirstUser(t, owner)
56+
ctx := testutil.Context(t, testutil.WaitMedium)
57+
58+
org, err := owner.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
59+
Name: "other",
60+
DisplayName: "",
61+
Description: "",
62+
Icon: "",
63+
})
64+
require.NoError(t, err)
65+
66+
// Add user to org
67+
_, err = owner.PostOrganizationMember(ctx, org.ID, uuid.NewString())
68+
require.Error(t, err)
69+
var apiErr *codersdk.Error
70+
require.ErrorAs(t, err, &apiErr)
71+
require.Contains(t, apiErr.Message, "must be an existing")
72+
})
73+
}
74+
1675
func TestListMembers(t *testing.T) {
1776
t.Parallel()
1877

codersdk/users.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,20 @@ func (c *Client) UpdateUserPassword(ctx context.Context, user string, req Update
379379
return nil
380380
}
381381

382+
// PostOrganizationMember adds a user to an organization
383+
func (c *Client) PostOrganizationMember(ctx context.Context, organizationID uuid.UUID, user string) (OrganizationMember, error) {
384+
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/organizations/%s/members/%s", organizationID, user), nil)
385+
if err != nil {
386+
return OrganizationMember{}, err
387+
}
388+
defer res.Body.Close()
389+
if res.StatusCode != http.StatusOK {
390+
return OrganizationMember{}, ReadBodyAsError(res)
391+
}
392+
var member OrganizationMember
393+
return member, json.NewDecoder(res.Body).Decode(&member)
394+
}
395+
382396
// OrganizationMembers lists all members in an organization
383397
func (c *Client) OrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]OrganizationMemberWithName, error) {
384398
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/", organizationID), nil)

0 commit comments

Comments
 (0)