@@ -173,7 +173,8 @@ type SCIMUser struct {
173
173
Type string `json:"type"`
174
174
Display string `json:"display"`
175
175
} `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"`
177
178
Groups []interface {} `json:"groups"`
178
179
Meta struct {
179
180
ResourceType string `json:"resourceType"`
@@ -219,6 +220,11 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
219
220
return
220
221
}
221
222
223
+ if sUser .Active == nil {
224
+ _ = handlerutil .WriteError (rw , scim .NewHTTPError (http .StatusBadRequest , "invalidRequest" , xerrors .New ("active field is required" )))
225
+ return
226
+ }
227
+
222
228
email := ""
223
229
for _ , e := range sUser .Emails {
224
230
if e .Primary {
@@ -245,7 +251,7 @@ func (api *API) scimPostUser(rw http.ResponseWriter, r *http.Request) {
245
251
sUser .ID = dbUser .ID .String ()
246
252
sUser .UserName = dbUser .Username
247
253
248
- if sUser .Active && dbUser .Status == database .UserStatusSuspended {
254
+ if * sUser .Active && dbUser .Status == database .UserStatusSuspended {
249
255
//nolint:gocritic
250
256
newUser , err := api .Database .UpdateUserStatus (dbauthz .AsSystemRestricted (r .Context ()), database.UpdateUserStatusParams {
251
257
ID : dbUser .ID ,
@@ -380,29 +386,112 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) {
380
386
aReq .Old = dbUser
381
387
aReq .UserID = dbUser .ID
382
388
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
396
405
}
406
+ dbUser = userNew
397
407
} else {
398
- status = database .UserStatusSuspended
408
+ // Do not push an audit log if there is no change.
409
+ commitAudit (false )
399
410
}
400
411
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 {
402
491
//nolint:gocritic // needed for SCIM
403
492
userNew , err := api .Database .UpdateUserStatus (dbauthz .AsSystemRestricted (r .Context ()), database.UpdateUserStatusParams {
404
493
ID : dbUser .ID ,
405
- Status : status ,
494
+ Status : newStatus ,
406
495
UpdatedAt : dbtime .Now (),
407
496
})
408
497
if err != nil {
@@ -418,3 +507,31 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) {
418
507
aReq .New = dbUser
419
508
httpapi .Write (ctx , rw , http .StatusOK , sUser )
420
509
}
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