Skip to content

Commit 1136dfe

Browse files
committed
chore: implement SCIM PUT endpoint, protect against missing active
1 parent b39becb commit 1136dfe

File tree

3 files changed

+409
-22
lines changed

3 files changed

+409
-22
lines changed

enterprise/coderd/coderd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
488488
r.Post("/", api.scimPostUser)
489489
r.Get("/{id}", api.scimGetUser)
490490
r.Patch("/{id}", api.scimPatchUser)
491+
r.Put("/{id}", api.scimPutUser)
491492
})
492493
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
493494
u := r.URL.String()

enterprise/coderd/scim.go

Lines changed: 135 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,8 @@ type SCIMUser struct {
173173
Type string `json:"type"`
174174
Display string `json:"display"`
175175
} `json:"emails"`
176-
Active bool `json:"active"`
176+
// Active is a ptr to prevent the empty value from being interpreted as false.
177+
Active *bool `json:"active"`
177178
Groups []interface{} `json:"groups"`
178179
Meta struct {
179180
ResourceType string `json:"resourceType"`
@@ -219,6 +220,11 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
219220
return
220221
}
221222

223+
if sUser.Active == nil {
224+
_ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidRequest", xerrors.New("active field is required")))
225+
return
226+
}
227+
222228
email := ""
223229
for _, e := range sUser.Emails {
224230
if e.Primary {
@@ -245,7 +251,7 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
245251
sUser.ID = dbUser.ID.String()
246252
sUser.UserName = dbUser.Username
247253

248-
if sUser.Active && dbUser.Status == database.UserStatusSuspended {
254+
if *sUser.Active && dbUser.Status == database.UserStatusSuspended {
249255
//nolint:gocritic
250256
newUser, err := api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{
251257
ID: dbUser.ID,
@@ -380,29 +386,112 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) {
380386
aReq.Old = dbUser
381387
aReq.UserID = dbUser.ID
382388

383-
var status database.UserStatus
384-
if sUser.Active {
385-
switch dbUser.Status {
386-
case database.UserStatusActive:
387-
// Keep the user active
388-
status = database.UserStatusActive
389-
case database.UserStatusDormant, database.UserStatusSuspended:
390-
// Move (or keep) as dormant
391-
status = database.UserStatusDormant
392-
default:
393-
// If the status is unknown, just move them to dormant.
394-
// The user will get transitioned to Active after logging in.
395-
status = database.UserStatusDormant
389+
if sUser.Active == nil {
390+
_ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidRequest", xerrors.New("active field is required")))
391+
return
392+
}
393+
394+
newStatus := scimUserStatus(dbUser, *sUser.Active)
395+
if dbUser.Status != newStatus {
396+
//nolint:gocritic // needed for SCIM
397+
userNew, err := api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{
398+
ID: dbUser.ID,
399+
Status: newStatus,
400+
UpdatedAt: dbtime.Now(),
401+
})
402+
if err != nil {
403+
_ = handlerutil.WriteError(rw, err) // internal error
404+
return
396405
}
406+
dbUser = userNew
397407
} else {
398-
status = database.UserStatusSuspended
408+
// Do not push an audit log if there is no change.
409+
commitAudit(false)
399410
}
400411

401-
if dbUser.Status != status {
412+
aReq.New = dbUser
413+
httpapi.Write(ctx, rw, http.StatusOK, sUser)
414+
}
415+
416+
// scimPutUser supports suspending and activating users only.
417+
// TODO: SCIM specification requires that the PUT method should replace the entire user object.
418+
// At present, our fields read as 'immutable' except for the 'active' field.
419+
// See: https://datatracker.ietf.org/doc/html/rfc7644#section-3.5.1
420+
//
421+
// @Summary SCIM 2.0: Replace user account
422+
// @ID scim-replace-user-status
423+
// @Security Authorization
424+
// @Produce application/scim+json
425+
// @Tags Enterprise
426+
// @Param id path string true "User ID" format(uuid)
427+
// @Param request body coderd.SCIMUser true "Replace user request"
428+
// @Success 200 {object} codersdk.User
429+
// @Router /scim/v2/Users/{id} [put]
430+
func (api *API) scimPutUser(rw http.ResponseWriter, r *http.Request) {
431+
ctx := r.Context()
432+
if !api.scimVerifyAuthHeader(r) {
433+
scimUnauthorized(rw)
434+
return
435+
}
436+
437+
auditor := *api.AGPL.Auditor.Load()
438+
aReq, commitAudit := audit.InitRequestWithCancel[database.User](rw, &audit.RequestParams{
439+
Audit: auditor,
440+
Log: api.Logger,
441+
Request: r,
442+
Action: database.AuditActionWrite,
443+
})
444+
445+
defer commitAudit(true)
446+
447+
id := chi.URLParam(r, "id")
448+
449+
var sUser SCIMUser
450+
err := json.NewDecoder(r.Body).Decode(&sUser)
451+
if err != nil {
452+
_ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidRequest", err))
453+
return
454+
}
455+
sUser.ID = id
456+
if sUser.Active == nil {
457+
_ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidRequest", xerrors.New("active field is required")))
458+
return
459+
}
460+
461+
uid, err := uuid.Parse(id)
462+
if err != nil {
463+
_ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "invalidId", xerrors.Errorf("id must be a uuid: %w", err)))
464+
return
465+
}
466+
467+
//nolint:gocritic // needed for SCIM
468+
dbUser, err := api.Database.GetUserByID(dbauthz.AsSystemRestricted(ctx), uid)
469+
if err != nil {
470+
_ = handlerutil.WriteError(rw, err) // internal error
471+
return
472+
}
473+
aReq.Old = dbUser
474+
aReq.UserID = dbUser.ID
475+
476+
// Technically our immutability rules dictate that we should not allow
477+
// fields to be changed. According to the SCIM specification, this error should
478+
// be returned.
479+
// This immutability enforcement only exists because we have not implemented it
480+
// yet. If these rules are causing errors, this code should be updated to allow
481+
// the fields to be changed.
482+
// TODO: Currently ignoring a lot of the SCIM fields. Coder's SCIM implementation
483+
// is very basic and only supports active status changes.
484+
if immutabilityViolation(dbUser.Username, sUser.UserName) {
485+
_ = handlerutil.WriteError(rw, scim.NewHTTPError(http.StatusBadRequest, "mutability", xerrors.Errorf("username is currently an immutable field, and cannot be changed. Current: %s, New: %s", dbUser.Username, sUser.UserName)))
486+
return
487+
}
488+
489+
newStatus := scimUserStatus(dbUser, *sUser.Active)
490+
if dbUser.Status != newStatus {
402491
//nolint:gocritic // needed for SCIM
403492
userNew, err := api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{
404493
ID: dbUser.ID,
405-
Status: status,
494+
Status: newStatus,
406495
UpdatedAt: dbtime.Now(),
407496
})
408497
if err != nil {
@@ -418,3 +507,31 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) {
418507
aReq.New = dbUser
419508
httpapi.Write(ctx, rw, http.StatusOK, sUser)
420509
}
510+
511+
func immutabilityViolation[T comparable](old, new T) bool {
512+
var empty T
513+
if new == empty {
514+
// No change
515+
return false
516+
}
517+
return old != new
518+
}
519+
520+
func scimUserStatus(user database.User, active bool) database.UserStatus {
521+
if !active {
522+
return database.UserStatusSuspended
523+
}
524+
525+
switch user.Status {
526+
case database.UserStatusActive:
527+
// Keep the user active
528+
return database.UserStatusActive
529+
case database.UserStatusDormant, database.UserStatusSuspended:
530+
// Move (or keep) as dormant
531+
return database.UserStatusDormant
532+
default:
533+
// If the status is unknown, just move them to dormant.
534+
// The user will get transitioned to Active after logging in.
535+
return database.UserStatusDormant
536+
}
537+
}

0 commit comments

Comments
 (0)