Skip to content

Commit 709b260

Browse files
committed
WPB-23434: Resolve SCIM PATCH emails review feedback
Handle Add on emails[...] value-path explicitly instead of rewriting to Replace: whole-entry Add appends (concat semantics) while sub-attribute Add delegates to the Replace path. Give ValuePath named record fields and drop the redundant valuePathFilter helper. Correct the create-on-absent NOTE to cite Entra's documented Add behaviour. Move the end-to-end email PATCH test to the new integration suite (patchScimUser helper + testSparPatchEmailValuePath) and remove the deprecated copy. Add hscim unit tests for the Add behaviour.
1 parent e4acc08 commit 709b260

6 files changed

Lines changed: 134 additions & 36 deletions

File tree

integration/test/API/Spar.hs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,20 @@ updateScimUser domain scimToken userId scimUser = do
112112
& scimCommonHeaders scimToken
113113
& addJSON body
114114

115+
patchScimUser ::
116+
(HasCallStack, MakesValue domain, MakesValue patchOp) =>
117+
domain ->
118+
String ->
119+
String ->
120+
patchOp ->
121+
App Response
122+
patchScimUser domain scimToken userId patchOp = do
123+
req <- baseRequest domain Spar Versioned $ joinHttpPath ["scim", "v2", "Users", userId]
124+
body <- make patchOp
125+
submit "PATCH" $ req
126+
& scimCommonHeaders scimToken
127+
& addJSON body
128+
115129
createScimUserGroup :: (HasCallStack, MakesValue domain, MakesValue scimUserGroup) => domain -> String -> scimUserGroup -> App Response
116130
createScimUserGroup domain token scimUserGroup = do
117131
req <- baseRequest domain Spar Versioned "/scim/v2/Groups"

integration/test/Test/Spar.hs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,57 @@ testSparExternalIdDifferentFromEmailWithIdp = do
195195
subject <- u %. "sso_id.subject" >>= asString
196196
subject `shouldContainString` currentExtId
197197

198+
testSparPatchEmailValuePath :: (HasCallStack) => App ()
199+
testSparPatchEmailValuePath = do
200+
(owner, tid, _) <- createTeam OwnDomain 1
201+
void $ setTeamFeatureStatus owner tid "sso" "enabled"
202+
void $ registerTestIdPWithMeta owner >>= getJSON 201
203+
tok <- createScimTokenV6 owner def >>= getJSON 200 >>= (%. "token") >>= asString
204+
email <- randomEmail
205+
extId <- randomExternalId
206+
-- a single work-typed email so the value-path filter matches in place
207+
-- (avoids the pickPrimary/pickFirst ambiguity in 'scimEmailsToEmailAddress')
208+
scimUser <-
209+
randomScimUserWithEmail extId email
210+
>>= setField "emails" (toJSON [object ["value" .= email, "type" .= ("work" :: String)]])
211+
userId <- createScimUser OwnDomain tok scimUser >>= getJSON 201 >>= (%. "id") >>= asString
212+
activateEmail OwnDomain email
213+
-- Exercise the real Entra payload end-to-end: Entra sends an 'Add' on
214+
-- emails[type eq "work"].value, and this confirms the new value propagates to
215+
-- Brig after activation. (The Add-vs-Replace regression guard for the
216+
-- whole-entry append semantics lives in the hscim unit test, since a .value
217+
-- Add on a matching entry delegates to the in-place update shared with
218+
-- Replace.)
219+
newEmail <- randomEmail
220+
let patchOp =
221+
object
222+
[ "schemas" .= (["urn:ietf:params:scim:api:messages:2.0:PatchOp" :: String]),
223+
"Operations"
224+
.= [ object
225+
[ "op" .= ("Add" :: String),
226+
"path" .= ("emails[type eq \"work\"].value" :: String),
227+
"value" .= newEmail
228+
]
229+
]
230+
]
231+
bindResponse (patchScimUser OwnDomain tok userId patchOp) $ \res -> do
232+
res.status `shouldMatchInt` 200
233+
-- SCIM side reflects the new value, type unchanged
234+
checkSparGetUserAndFindByExtId OwnDomain tok extId userId $ \u -> do
235+
(u %. "emails" >>= asList >>= assertOne >>= (%. "value")) `shouldMatch` newEmail
236+
(u %. "emails" >>= asList >>= assertOne >>= (%. "type")) `shouldMatch` ("work" :: String)
237+
-- before activation, Brig still holds the old email
238+
bindResponse (getUsersId OwnDomain [userId]) $ \res -> do
239+
res.status `shouldMatchInt` 200
240+
u <- res.json & asList >>= assertOne
241+
u %. "email" `shouldMatch` email
242+
-- after activation the new email propagated to Brig
243+
activateEmail OwnDomain newEmail
244+
bindResponse (getUsersId OwnDomain [userId]) $ \res -> do
245+
res.status `shouldMatchInt` 200
246+
u <- res.json & asList >>= assertOne
247+
u %. "email" `shouldMatch` newEmail
248+
198249
testSparExternalIdDifferentFromEmail :: (HasCallStack) => App ()
199250
testSparExternalIdDifferentFromEmail = do
200251
(owner, tid, _) <- createTeam OwnDomain 1

libs/hscim/src/Web/Scim/Filter.hs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,10 @@ data Filter
128128
-- TODO(arianvp): This is a slight simplification at the moment as we
129129
-- don't support the complete Filter grammar. This should be a
130130
-- valFilter, not a FILTER.
131-
data ValuePath = ValuePath AttrPath Filter
131+
data ValuePath = ValuePath
132+
{ valuePathAttrPath :: AttrPath,
133+
valuePathFilter :: Filter
134+
}
132135
deriving (Eq, Show)
133136

134137
-- | subAttr = "." ATTRNAME

libs/hscim/src/Web/Scim/Schema/User.hs

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,16 @@ applyUserOperation ::
316316
User tag ->
317317
Operation ->
318318
m (User tag)
319+
applyUserOperation user (Operation Add (Just (IntoValuePath vp mSub)) (Just val)) =
320+
case vp of
321+
ValuePath (AttrPath _ attr _) _
322+
| attr == "emails" -> addEmailsValuePath user vp mSub val
323+
| otherwise ->
324+
throwError
325+
( badRequest
326+
InvalidPath
327+
(Just "multi-valued PATCH is only supported for 'emails'")
328+
)
319329
applyUserOperation user (Operation Add path value) = applyUserOperation user (Operation Replace path value)
320330
applyUserOperation user (Operation Replace (Just (NormalPath (AttrPath _schema attr _subAttr))) (Just value)) =
321331
case attr of
@@ -383,19 +393,17 @@ applyUserOperation user (Operation Remove (Just (IntoValuePath vp _mSub)) _) =
383393
-- attribute that Spar persists. Other multi-valued attributes
384394
-- (@phoneNumbers@, @ims@, ...) remain unsupported and still fail as before.
385395
--
386-
-- NOTE on "create on absent": RFC 7644 §3.5.2.3 says a @Replace@ value-path
387-
-- that matches nothing is a no-op. Entra, however, emits an @Add@ (rewritten to
388-
-- @Replace@ in 'applyUserOperation') against @emails[type eq "work"].value@ to
389-
-- provision the address, expecting the entry to be created if absent. Every
390-
-- mainstream SCIM client/validator expects this create-on-absent behaviour for
391-
-- the email value-path, so we deviate from the RFC here: when the filter is
392-
-- @type eq <s>@ and no entry matches, we append
396+
-- NOTE on "create on absent": RFC 7644 §3.5.2.3 says a value-path @Replace@
397+
-- that matches nothing is a no-op. Microsoft Entra ID, however, provisions the
398+
-- email address with an @Add@ against @emails[type eq "work"].value@ (Entra uses
399+
-- @Add@ for both insert and update -- see
400+
-- <https://learn.microsoft.com/en-us/answers/questions/1693075/why-is-entra-id-sending-add-operations-instead-of>),
401+
-- expecting the entry to be created if absent. Both 'addEmailsValuePath' and the
402+
-- @Replace@ path therefore route the @.value@ sub-attribute through
403+
-- 'replaceEmailValue', which deviates from the RFC: when the filter is
404+
-- @type eq <s>@ and no entry matches, it appends
393405
-- @Email { typ = Just s, value = newVal, primary = Nothing }@.
394406

395-
-- | The 'Filter' embedded in a 'ValuePath'.
396-
valuePathFilter :: ValuePath -> Filter
397-
valuePathFilter (ValuePath _ flt) = flt
398-
399407
-- | Textual form of an 'Email' address, for string comparison.
400408
emailValueText :: Email -> Text
401409
emailValueText (Email _ addr _) =
@@ -472,6 +480,30 @@ decodeEmails val = case fromJSON val of
472480
Success (es' :: [Email]) -> pure es'
473481
_ -> (: []) <$> resultToScimError (fromJSON val)
474482

483+
-- | Handle an @Add@ on an @emails[...]@ value-path.
484+
--
485+
-- For the single-valued email sub-attributes (@.value@, @.type@, @.primary@) an
486+
-- @Add@ coincides with a @Replace@ (RFC 7644 §3.5.2.3): it sets the
487+
-- sub-attribute and, for @.value@, creates the entry on absent via
488+
-- 'replaceEmailValue'. For a whole-entry @Add@ (no sub-attribute) the value-path
489+
-- filter is intentionally ignored and the new entries are /appended/ rather than
490+
-- overwriting matches -- the concat semantics that distinguish @Add@ from
491+
-- @Replace@ for multi-valued attributes (where @Replace@ narrows the target set
492+
-- via the filter).
493+
addEmailsValuePath ::
494+
(MonadError ScimError m) =>
495+
User tag ->
496+
ValuePath ->
497+
Maybe SubAttr ->
498+
Value ->
499+
m (User tag)
500+
addEmailsValuePath user vp mSub val =
501+
case mSub of
502+
Just _ -> replaceEmailsValuePath user vp mSub val
503+
Nothing -> do
504+
newEmails <- decodeEmails val
505+
pure user {emails = emails user <> newEmails}
506+
475507
-- | Handle a @Replace@ on an @emails[...]@ value-path.
476508
replaceEmailsValuePath ::
477509
(MonadError ScimError m) =>

libs/hscim/test/Test/Schema/UserSpec.hs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,28 @@ spec = do
229229
result `shouldSatisfy` isRight
230230
let Right patched = result
231231
emails patched `shouldBe` [mkEmail "work" "arr@example.com"]
232+
it "Add on .value updates an existing matching email" $ do
233+
let Right p = emailValuePath
234+
operation = Operation Add (Just p) (Just (String "new@example.com"))
235+
result = User.applyPatch (mkUser [mkEmail "work" "old@example.com"]) (PatchOp [operation])
236+
result `shouldSatisfy` isRight
237+
let Right patched = result
238+
emails patched `shouldBe` [mkEmail "work" "new@example.com"]
239+
it "Add on .value creates a work email when none matches" $ do
240+
let Right p = emailValuePath
241+
operation = Operation Add (Just p) (Just (String "x@y.com"))
242+
result = User.applyPatch (mkUser []) (PatchOp [operation])
243+
result `shouldSatisfy` isRight
244+
let Right patched = result
245+
emails patched `shouldBe` [mkEmail "work" "x@y.com"]
246+
it "Add on a whole emails entry appends without overwriting" $ do
247+
let Right p = PatchOp.parsePath (User.supportedSchemas @PatchTag) "emails[type eq \"work\"]"
248+
newVal = object ["value" .= String "added@example.com", "type" .= String "work"]
249+
operation = Operation Add (Just p) (Just newVal)
250+
result = User.applyPatch (mkUser [mkEmail "work" "keep@example.com"]) (PatchOp [operation])
251+
result `shouldSatisfy` isRight
252+
let Right patched = result
253+
emails patched `shouldBe` [mkEmail "work" "keep@example.com", mkEmail "work" "added@example.com"]
232254
describe "JSON serialization" $ do
233255
it "handles all fields" $ do
234256
require prop_roundtrip

services/spar/test-integration/Test/Spar/Scim/UserSpec.hs

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2089,30 +2089,6 @@ specPatchUser = do
20892089
[replaceAttrib "externalId" externalId]
20902090
let user'' = Scim.value . Scim.thing $ storedUser'
20912091
liftIO $ Scim.User.externalId user'' `shouldBe` externalId
2092-
it "can update a user's email via the multi-valued 'emails' value-path" $ do
2093-
(tok, (_, tid, _idp)) <- registerIdPAndScimToken
2094-
-- Disable email verification so the patched email is activated directly,
2095-
-- without a separate activation step.
2096-
setSamlEmailValidation tid Feature.FeatureStatusDisabled
2097-
newEmail <- randomEmail
2098-
user <- randomScimUser
2099-
storedUser <- createUser tok user
2100-
let userid = scimUserId storedUser
2101-
let Right p = PatchOp.parsePath userSchemas "emails[type eq \"work\"].value"
2102-
operation =
2103-
PatchOp.Operation
2104-
PatchOp.Replace
2105-
(Just p)
2106-
(Just (toJSON (fromEmail newEmail)))
2107-
_ <- patchUser tok userid (PatchOp.PatchOp [operation])
2108-
-- the email propagated all the way to Brig and is reflected on a fresh GET
2109-
eventually $ do
2110-
storedUser'' <- getUser tok userid
2111-
liftIO $
2112-
Scim.Email.scimEmailsToEmailAddress
2113-
(Scim.User.emails (Scim.value (Scim.thing storedUser'')))
2114-
`shouldBe` Just newEmail
2115-
checkEmail userid (Just newEmail)
21162092
it "replace role works" $ testPatchRole replaceAttrib
21172093
it "add role works" $ testPatchRole addAttrib
21182094
it "replace with invalid input should fail" $ testPatchIvalidInput replaceAttrib

0 commit comments

Comments
 (0)