Skip to content

Commit 677ab50

Browse files
committed
feat: add endpoint for partial updates to org sync mapping
1 parent 0ad46d5 commit 677ab50

File tree

8 files changed

+336
-6
lines changed

8 files changed

+336
-6
lines changed

coderd/idpsync/idpsync.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import (
2626
type IDPSync interface {
2727
OrganizationSyncEntitled() bool
2828
OrganizationSyncSettings(ctx context.Context, db database.Store) (*OrganizationSyncSettings, error)
29-
UpdateOrganizationSettings(ctx context.Context, db database.Store, settings OrganizationSyncSettings) error
29+
UpdateOrganizationSyncSettings(ctx context.Context, db database.Store, settings OrganizationSyncSettings) error
3030
// OrganizationSyncEnabled returns true if all OIDC users are assigned
3131
// to organizations via org sync settings.
3232
// This is used to know when to disable manual org membership assignment.
@@ -70,6 +70,9 @@ type IDPSync interface {
7070
SyncRoles(ctx context.Context, db database.Store, user database.User, params RoleParams) error
7171
}
7272

73+
// AGPLIDPSync implements the IDPSync interface
74+
var _ IDPSync = AGPLIDPSync{}
75+
7376
// AGPLIDPSync is the configuration for syncing user information from an external
7477
// IDP. All related code to syncing user information should be in this package.
7578
type AGPLIDPSync struct {

coderd/idpsync/organization.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func (AGPLIDPSync) OrganizationSyncEnabled(_ context.Context, _ database.Store)
3434
return false
3535
}
3636

37-
func (s AGPLIDPSync) UpdateOrganizationSettings(ctx context.Context, db database.Store, settings OrganizationSyncSettings) error {
37+
func (s AGPLIDPSync) UpdateOrganizationSyncSettings(ctx context.Context, db database.Store, settings OrganizationSyncSettings) error {
3838
rlv := s.Manager.Resolver(db)
3939
err := s.SyncSettings.Organization.SetRuntimeValue(ctx, rlv, &settings)
4040
if err != nil {

coderd/runtimeconfig/resolver.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import (
1212
"github.com/coder/coder/v2/coderd/database"
1313
)
1414

15+
// NoopResolver implements the Resolver interface
16+
var _ Resolver = &NoopResolver{}
17+
1518
// NoopResolver is a useful test device.
1619
type NoopResolver struct{}
1720

@@ -31,6 +34,9 @@ func (NoopResolver) DeleteRuntimeConfig(context.Context, string) error {
3134
return ErrEntryNotFound
3235
}
3336

37+
// StoreResolver implements the Resolver interface
38+
var _ Resolver = &StoreResolver{}
39+
3440
// StoreResolver uses the database as the underlying store for runtime settings.
3541
type StoreResolver struct {
3642
db Store

codersdk/idpsync.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ import (
1212
"golang.org/x/xerrors"
1313
)
1414

15+
type IDPSyncMapping[ResourceIdType any] struct {
16+
// The IdP claim the user has
17+
Given string
18+
// The ID of the Coder resource the user should be added to
19+
Gets ResourceIdType
20+
}
21+
1522
type GroupSyncSettings struct {
1623
// Field is the name of the claim field that specifies what groups a user
1724
// should be in. If empty, no groups will be synced.
@@ -137,6 +144,26 @@ func (c *Client) PatchOrganizationIDPSyncSettings(ctx context.Context, req Organ
137144
return resp, json.NewDecoder(res.Body).Decode(&resp)
138145
}
139146

147+
// If the same mapping is present in both Add and Remove, Remove will take presidence.
148+
type PatchOrganizationIDPSyncMappingRequest struct {
149+
Add []IDPSyncMapping[uuid.UUID]
150+
Remove []IDPSyncMapping[uuid.UUID]
151+
}
152+
153+
func (c *Client) PatchOrganizationIDPSyncMapping(ctx context.Context, req PatchOrganizationIDPSyncMappingRequest) (OrganizationSyncSettings, error) {
154+
res, err := c.Request(ctx, http.MethodPatch, "/api/v2/settings/idpsync/organization/mapping", req)
155+
if err != nil {
156+
return OrganizationSyncSettings{}, xerrors.Errorf("make request: %w", err)
157+
}
158+
defer res.Body.Close()
159+
160+
if res.StatusCode != http.StatusOK {
161+
return OrganizationSyncSettings{}, ReadBodyAsError(res)
162+
}
163+
var resp OrganizationSyncSettings
164+
return resp, json.NewDecoder(res.Body).Decode(&resp)
165+
}
166+
140167
func (c *Client) GetAvailableIDPSyncFields(ctx context.Context) ([]string, error) {
141168
res, err := c.Request(ctx, http.MethodGet, "/api/v2/settings/idpsync/available-fields", nil)
142169
if err != nil {

enterprise/coderd/coderd.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,9 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
295295
r.Route("/organization", func(r chi.Router) {
296296
r.Get("/", api.organizationIDPSyncSettings)
297297
r.Patch("/", api.patchOrganizationIDPSyncSettings)
298+
r.Patch("/mapping", api.patchOrganizationIDPSyncMapping)
298299
})
300+
299301
r.Get("/available-fields", api.deploymentIDPSyncClaimFields)
300302
r.Get("/field-values", api.deploymentIDPSyncClaimFieldValues)
301303
})
@@ -307,11 +309,12 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
307309
httpmw.ExtractOrganizationParam(api.Database),
308310
)
309311
r.Route("/organizations/{organization}/settings", func(r chi.Router) {
310-
r.Get("/idpsync/available-fields", api.organizationIDPSyncClaimFields)
311312
r.Get("/idpsync/groups", api.groupIDPSyncSettings)
312313
r.Patch("/idpsync/groups", api.patchGroupIDPSyncSettings)
313314
r.Get("/idpsync/roles", api.roleIDPSyncSettings)
314315
r.Patch("/idpsync/roles", api.patchRoleIDPSyncSettings)
316+
317+
r.Get("/idpsync/available-fields", api.organizationIDPSyncClaimFields)
315318
r.Get("/idpsync/field-values", api.organizationIDPSyncClaimFieldValues)
316319
})
317320
})

enterprise/coderd/enidpsync/organizations_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ func TestOrganizationSync(t *testing.T) {
300300
// Create a new sync object
301301
sync := enidpsync.NewSync(logger, runtimeconfig.NewManager(), caseData.Entitlements, caseData.Settings)
302302
if caseData.RuntimeSettings != nil {
303-
err := sync.UpdateOrganizationSettings(ctx, rdb, *caseData.RuntimeSettings)
303+
err := sync.UpdateOrganizationSyncSettings(ctx, rdb, *caseData.RuntimeSettings)
304304
require.NoError(t, err)
305305
}
306306

enterprise/coderd/idpsync.go

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/coder/coder/v2/coderd/idpsync"
1515
"github.com/coder/coder/v2/coderd/rbac"
1616
"github.com/coder/coder/v2/coderd/rbac/policy"
17+
"github.com/coder/coder/v2/coderd/util/slice"
1718
"github.com/coder/coder/v2/codersdk"
1819
)
1920

@@ -292,7 +293,7 @@ func (api *API) patchOrganizationIDPSyncSettings(rw http.ResponseWriter, r *http
292293
}
293294
aReq.Old = *existing
294295

295-
err = api.IDPSync.UpdateOrganizationSettings(sysCtx, api.Database, idpsync.OrganizationSyncSettings{
296+
err = api.IDPSync.UpdateOrganizationSyncSettings(sysCtx, api.Database, idpsync.OrganizationSyncSettings{
296297
Field: req.Field,
297298
// We do not check if the mappings point to actual organizations.
298299
Mapping: req.Mapping,
@@ -317,6 +318,96 @@ func (api *API) patchOrganizationIDPSyncSettings(rw http.ResponseWriter, r *http
317318
})
318319
}
319320

321+
// @Summary Update organization IdP Sync settings
322+
// @ID update-organization-idp-sync-settings
323+
// @Security CoderSessionToken
324+
// @Produce json
325+
// @Accept json
326+
// @Tags Enterprise
327+
// @Success 200 {object} codersdk.OrganizationSyncSettings
328+
// @Param request body codersdk.PatchOrganizationIDPSyncMappingRequest true "Description of the mappings to add and remove"
329+
// @Router /settings/idpsync/organization/mapping [patch]
330+
func (api *API) patchOrganizationIDPSyncMapping(rw http.ResponseWriter, r *http.Request) {
331+
ctx := r.Context()
332+
auditor := *api.AGPL.Auditor.Load()
333+
aReq, commitAudit := audit.InitRequest[idpsync.OrganizationSyncSettings](rw, &audit.RequestParams{
334+
Audit: auditor,
335+
Log: api.Logger,
336+
Request: r,
337+
Action: database.AuditActionWrite,
338+
})
339+
defer commitAudit()
340+
341+
if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceIdpsyncSettings) {
342+
httpapi.Forbidden(rw)
343+
return
344+
}
345+
346+
var req codersdk.PatchOrganizationIDPSyncMappingRequest
347+
if !httpapi.Read(ctx, rw, r, &req) {
348+
return
349+
}
350+
351+
var settings idpsync.OrganizationSyncSettings
352+
//nolint:gocritic // Requires system context to update runtime config
353+
sysCtx := dbauthz.AsSystemRestricted(ctx)
354+
err := api.Database.InTx(func(tx database.Store) error {
355+
existing, err := api.IDPSync.OrganizationSyncSettings(sysCtx, tx)
356+
if err != nil {
357+
return err
358+
}
359+
aReq.Old = *existing
360+
361+
newMapping := make(map[string][]uuid.UUID)
362+
363+
// Copy existing mapping
364+
for key, ids := range existing.Mapping {
365+
newMapping[key] = append(newMapping[key], ids...)
366+
}
367+
368+
// Add unique entries
369+
for _, mapping := range req.Add {
370+
if !slice.Contains(newMapping[mapping.Given], mapping.Gets) {
371+
newMapping[mapping.Given] = append(newMapping[mapping.Given], mapping.Gets)
372+
}
373+
}
374+
375+
// Remove entries
376+
for _, mapping := range req.Remove {
377+
for i, it := range newMapping[mapping.Given] {
378+
if it == mapping.Gets {
379+
newMapping[mapping.Given] = append(newMapping[mapping.Given][:i], newMapping[mapping.Given][i+1:]...)
380+
}
381+
}
382+
}
383+
384+
settings = idpsync.OrganizationSyncSettings{
385+
Field: existing.Field,
386+
Mapping: newMapping,
387+
AssignDefault: existing.AssignDefault,
388+
}
389+
390+
err = api.IDPSync.UpdateOrganizationSyncSettings(sysCtx, tx, settings)
391+
if err != nil {
392+
return err
393+
}
394+
395+
return nil
396+
}, &database.TxOptions{})
397+
398+
if err != nil {
399+
httpapi.InternalServerError(rw, err)
400+
return
401+
}
402+
403+
aReq.New = settings
404+
httpapi.Write(ctx, rw, http.StatusOK, codersdk.OrganizationSyncSettings{
405+
Field: settings.Field,
406+
Mapping: settings.Mapping,
407+
AssignDefault: settings.AssignDefault,
408+
})
409+
}
410+
320411
// @Summary Get the available organization idp sync claim fields
321412
// @ID get-the-available-organization-idp-sync-claim-fields
322413
// @Security CoderSessionToken

0 commit comments

Comments
 (0)