Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
48724fd
feat!: add batch endpoint for adding organization members
jakehwll Apr 23, 2026
f8c18f8
🤖 chore: merge main into jakehwll/devex-112-organizations-batch-endpoint
jakehwll Jun 22, 2026
e8eef0e
🤖 fix(coderd): address Emyrk review on batch org member endpoint
jakehwll Jun 22, 2026
875c34a
🤖 test(coderd): cover deleted-user rejection in batch add members
jakehwll Jun 23, 2026
fd443ef
🤖 fix(coderd): align deprecated org member @ID with summary
jakehwll Jun 23, 2026
b81945a
🤖 refactor(coderd): tighten batch org members handler and authz
jakehwll Jun 23, 2026
20eef3e
🤖 refactor(codersdk): tighten AddOrganizationMembersRequest validation
jakehwll Jun 25, 2026
964e05e
Merge branch 'main' into jakehwll/devex-112-organizations-batch-endpoint
jakehwll Jul 20, 2026
69e5d6f
🤖 chore(docs): regenerate members API reference
jakehwll Jul 20, 2026
9126b51
🤖 refactor(coderd): dedupe OIDC org-sync block message and cover dupl…
jakehwll Jul 20, 2026
23d38a0
🤖 refactor(site): remove dead addOrganizationMember API method
jakehwll Jul 20, 2026
c61b66d
🤖 refactor(coderd): address batch org member review nits
jakehwll Jul 20, 2026
d20e4b8
🤖 docs(codersdk): clarify PostOrganizationMembers returns only new me…
jakehwll Jul 20, 2026
9f18770
🤖 test(coderd): cover batch org member auth, validation bounds, and U…
jakehwll Jul 20, 2026
2562329
🤖 fix(coderd/members): restore add-member summary so swagger IDs match
jakehwll Jul 20, 2026
6ec918f
🤖 test(enterprise/coderd): correct batch add-member auth test rationale
jakehwll Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 60 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -1588,6 +1588,7 @@ func New(options *Options) *API {
r.Get("/paginated-members", api.paginatedMembers)
r.Route("/members", func(r chi.Router) {
r.Get("/", api.listMembers)
r.Post("/", api.postOrganizationMembers)
r.Route("/roles", func(r chi.Router) {
r.Get("/", api.assignableOrgRoles)
})
Expand Down
71 changes: 48 additions & 23 deletions coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -1697,6 +1697,38 @@ func scopedOrgRoleIdentifiers(names []string, orgID uuid.UUID) []rbac.RoleIdenti
return out
}

// authorizeOrganizationMemberRoleAssignment authorizes granting the given roles
// when adding a member to an organization. It does not authorize the member
// insert itself; callers must separately authorize ActionCreate on the
// organization_member object. The org's default_org_member_roles are implied at
// request time by GetAuthorizationUserRoles, so canAssignRoles must cover the
// full effective set (the explicit roles, organization-member, plus the
// defaults). Both the single-member and batch insert wrappers share this
// preamble so the role-assignment check cannot drift between them.
func (q *querier) authorizeOrganizationMemberRoleAssignment(ctx context.Context, organizationID uuid.UUID, roles []string) error {
orgRoles, err := q.convertToOrganizationRoles(organizationID, roles)
if err != nil {
return xerrors.Errorf("converting to organization roles: %w", err)
}

org, err := q.db.GetOrganizationByID(ctx, organizationID)
if err != nil {
return xerrors.Errorf("get organization: %w", err)
}
defaultRoles, err := q.convertToOrganizationRoles(organizationID, org.DefaultOrgMemberRoles)
if err != nil {
return xerrors.Errorf("convert default member roles: %w", err)
}

// All roles are added roles. Org member is always implied.
addedRoles := slices.Concat(
orgRoles,
[]rbac.RoleIdentifier{rbac.ScopedRoleOrgMember(organizationID)},
defaultRoles,
)
return q.canAssignRoles(ctx, organizationID, addedRoles, []rbac.RoleIdentifier{})
}

func (q *querier) AcquireLock(ctx context.Context, id int64) error {
return q.db.AcquireLock(ctx, id)
}
Expand Down Expand Up @@ -6201,35 +6233,28 @@ func (q *querier) InsertOrganization(ctx context.Context, arg database.InsertOrg
}

func (q *querier) InsertOrganizationMember(ctx context.Context, arg database.InsertOrganizationMemberParams) (database.OrganizationMember, error) {
orgRoles, err := q.convertToOrganizationRoles(arg.OrganizationID, arg.Roles)
if err != nil {
return database.OrganizationMember{}, xerrors.Errorf("converting to organization roles: %w", err)
if err := q.authorizeOrganizationMemberRoleAssignment(ctx, arg.OrganizationID, arg.Roles); err != nil {
return database.OrganizationMember{}, err
}

// The org's default_org_member_roles are implied at request time by
// GetAuthorizationUserRoles. Include them in canAssignRoles so the
// caller is required to be authorized to grant the full effective set
// (the explicit roles, organization-member, plus the defaults).
org, err := q.db.GetOrganizationByID(ctx, arg.OrganizationID)
if err != nil {
return database.OrganizationMember{}, xerrors.Errorf("get organization: %w", err)
}
defaultRoles, err := q.convertToOrganizationRoles(arg.OrganizationID, org.DefaultOrgMemberRoles)
if err != nil {
return database.OrganizationMember{}, xerrors.Errorf("convert default member roles: %w", err)
// Scope the create authorization to the specific user being added.
obj := rbac.ResourceOrganizationMember.InOrg(arg.OrganizationID).WithID(arg.UserID)
return insert(q.log, q.auth, obj, q.db.InsertOrganizationMember)(ctx, arg)
}

func (q *querier) InsertOrganizationMembersBatch(ctx context.Context, arg database.InsertOrganizationMembersBatchParams) ([]database.OrganizationMember, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-8] The batch InsertOrganizationMembersBatch authorization preamble is a verbatim copy of the singular InsertOrganizationMember, and the two have already drifted. (Pariston P3, Ryosuke P3, Razor P3, Robin P3, Knov P3, Zoro P2)

Lines 6236-6258 (convertToOrganizationRoles -> GetOrganizationByID -> default-role expansion -> canAssignRoles) are line-for-line identical to 6204-6226. The only intended difference is the return type; the unintended one is that the singular path authorizes ResourceOrganizationMember.InOrg(org).WithID(arg.UserID) (6231) while the batch drops .WithID (6263).

Six reviewers flagged this independently. This is security-sensitive authorization logic in two hand-maintained copies: the next tightening of canAssignRoles or the role model lands on one path and silently skips the other. Kurapika verified the current .WithID divergence (prior CRF-7) is harmless (the batch check is equal-or-stricter, never looser), so this is a latent maintenance hazard rather than a live bug. Fix: extract a shared authorizeOrgMemberInsert(ctx, orgID, roles) helper both wrappers call, so they diverge only at the final authorizeContext object and the .WithID choice becomes a deliberate, visible decision. Zoro rated this P2 for the security-sensitivity; set at P3 because there is no current authorization impact.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Comment by Coder Agents on behalf of Jake Howell

Done in c61b66d388. Extracted authorizeOrganizationMemberInsert(ctx, orgID, roles), shared by both InsertOrganizationMember and InsertOrganizationMembersBatch. They now diverge only in the final authorize object, so the .WithID difference is a deliberate, visible one-liner in each wrapper and the role-assignment preamble can't drift.

if err := q.authorizeOrganizationMemberRoleAssignment(ctx, arg.OrganizationID, arg.Roles); err != nil {
return nil, err
}

// All roles are added roles. Org member is always implied.
//nolint:gocritic
addedRoles := append(orgRoles, rbac.ScopedRoleOrgMember(arg.OrganizationID))
addedRoles = append(addedRoles, defaultRoles...)
err = q.canAssignRoles(ctx, arg.OrganizationID, addedRoles, []rbac.RoleIdentifier{})
if err != nil {
return database.OrganizationMember{}, err
// The batch inserts multiple users, so the create permission is authorized
// at the organization scope rather than a single user ID.
obj := rbac.ResourceOrganizationMember.InOrg(arg.OrganizationID)
if err := q.authorizeContext(ctx, policy.ActionCreate, obj); err != nil {
return nil, err
}

obj := rbac.ResourceOrganizationMember.InOrg(arg.OrganizationID).WithID(arg.UserID)
return insert(q.log, q.auth, obj, q.db.InsertOrganizationMember)(ctx, arg)
return q.db.InsertOrganizationMembersBatch(ctx, arg)
}

func (q *querier) InsertPreset(ctx context.Context, arg database.InsertPresetParams) (database.TemplateVersionPreset, error) {
Expand Down
19 changes: 19 additions & 0 deletions coderd/database/dbauthz/dbauthz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2509,6 +2509,25 @@ func (s *MethodTestSuite) TestOrganization() {
rbac.ResourceOrganizationMember.InOrg(o.ID).WithID(u.ID), policy.ActionCreate,
)
}))
s.Run("InsertOrganizationMembersBatch", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
o := testutil.Fake(s.T(), faker, database.Organization{DefaultOrgMemberRoles: []string{codersdk.RoleOrganizationAdmin}})
u1 := testutil.Fake(s.T(), faker, database.User{})
u2 := testutil.Fake(s.T(), faker, database.User{})
arg := database.InsertOrganizationMembersBatchParams{
OrganizationID: o.ID,
UserIds: []uuid.UUID{u1.ID, u2.ID},
Roles: []string{codersdk.RoleOrganizationAdmin},
}
dbm.EXPECT().GetOrganizationByID(gomock.Any(), o.ID).Return(o, nil).AnyTimes()
dbm.EXPECT().InsertOrganizationMembersBatch(gomock.Any(), arg).Return([]database.OrganizationMember{
{OrganizationID: o.ID, UserID: u1.ID, Roles: arg.Roles},
{OrganizationID: o.ID, UserID: u2.ID, Roles: arg.Roles},
}, nil).AnyTimes()
check.Args(arg).Asserts(
rbac.ResourceAssignOrgRole.InOrg(o.ID), policy.ActionAssign,
rbac.ResourceOrganizationMember.InOrg(o.ID), policy.ActionCreate,
)
}))
s.Run("InsertPreset", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
arg := database.InsertPresetParams{TemplateVersionID: uuid.New(), Name: "test"}
dbm.EXPECT().InsertPreset(gomock.Any(), arg).Return(database.TemplateVersionPreset{}, nil).AnyTimes()
Expand Down
8 changes: 8 additions & 0 deletions coderd/database/dbmetrics/querymetrics.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions coderd/database/dbmock/dbmock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions coderd/database/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading