Skip to content

Commit 0aba144

Browse files
authored
Merge pull request #107 from python-scim/10-one-primary
Only allow one primary complex attribute value to be true
2 parents a4fdefa + ac66269 commit 0aba144

File tree

3 files changed

+115
-0
lines changed

3 files changed

+115
-0
lines changed

doc/changelog.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ Deprecated
2121
- Defining ``schemas`` with a default value is deprecated. Use ``__schema__ = URN("...")`` instead.
2222
- ``Error.make_*_error()`` methods are deprecated. Use ``<Exception>.to_error()`` instead.
2323

24+
Fixed
25+
^^^^^
26+
- Only allow one primary complex attribute value to be true. :issue:`10`
27+
2428
[0.5.2] - 2026-01-22
2529
--------------------
2630

scim2_models/base.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,49 @@ def check_replacement_request_mutability(
364364
cls._check_mutability_issues(original, obj)
365365
return obj
366366

367+
@model_validator(mode="after")
368+
def check_primary_attribute_uniqueness(self, info: ValidationInfo) -> Self:
369+
"""Validate that only one attribute can be marked as primary in multi-valued lists.
370+
371+
Per RFC 7643 Section 2.4: The primary attribute value 'true' MUST appear no more than once.
372+
"""
373+
scim_context = info.context.get("scim") if info.context else None
374+
if not scim_context or scim_context == Context.DEFAULT:
375+
return self
376+
377+
for field_name in self.__class__.model_fields:
378+
if not self.get_field_multiplicity(field_name):
379+
continue
380+
381+
field_value = getattr(self, field_name)
382+
if field_value is None:
383+
continue
384+
385+
element_type = self.get_field_root_type(field_name)
386+
if (
387+
element_type is None
388+
or not isclass(element_type)
389+
or not issubclass(element_type, PydanticBaseModel)
390+
or "primary" not in element_type.model_fields
391+
):
392+
continue
393+
394+
primary_count = sum(
395+
1 for item in field_value if getattr(item, "primary", None) is True
396+
)
397+
398+
if primary_count > 1:
399+
raise PydanticCustomError(
400+
"primary_uniqueness_error",
401+
"Field '{field_name}' has {count} items marked as primary, but only one is allowed per RFC 7643",
402+
{
403+
"field_name": field_name,
404+
"count": primary_count,
405+
},
406+
)
407+
408+
return self
409+
367410
@classmethod
368411
def _check_mutability_issues(
369412
cls, original: "BaseModel", replacement: "BaseModel"

tests/test_user.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import datetime
22

3+
import pytest
4+
from pydantic import ValidationError
5+
36
from scim2_models import Address
47
from scim2_models import Email
58
from scim2_models import Im
69
from scim2_models import PhoneNumber
710
from scim2_models import Photo
811
from scim2_models import Reference
912
from scim2_models import User
13+
from scim2_models.context import Context
1014

1115

1216
def test_minimal_user(load_sample):
@@ -124,3 +128,67 @@ def test_full_user(load_sample):
124128
obj.meta.location
125129
== "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646"
126130
)
131+
132+
133+
def test_multiple_emails_without_primary_is_valid():
134+
"""Test that multiple emails without any primary attribute is valid."""
135+
user_data = {
136+
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
137+
"userName": "testuser",
138+
"emails": [
139+
{"value": "test1@example.com", "type": "work"},
140+
{"value": "test2@example.com", "type": "home"},
141+
],
142+
}
143+
user = User.model_validate(user_data, scim_ctx=Context.RESOURCE_CREATION_REQUEST)
144+
assert user.user_name == "testuser"
145+
146+
147+
def test_single_primary_email_is_valid():
148+
"""Test that exactly one primary email is valid per RFC 7643."""
149+
user_data = {
150+
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
151+
"userName": "testuser",
152+
"emails": [
153+
{"value": "primary@example.com", "type": "work", "primary": True},
154+
{"value": "secondary@example.com", "type": "home", "primary": False},
155+
],
156+
}
157+
user = User.model_validate(user_data, scim_ctx=Context.RESOURCE_CREATION_REQUEST)
158+
assert user.emails[0].primary is True
159+
assert user.emails[1].primary is False
160+
161+
162+
def test_multiple_primary_emails_rejected():
163+
"""Test that multiple primary emails are rejected per RFC 7643."""
164+
user_data = {
165+
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
166+
"userName": "testuser",
167+
"emails": [
168+
{"value": "primary1@example.com", "primary": True},
169+
{"value": "primary2@example.com", "primary": True},
170+
],
171+
}
172+
173+
with pytest.raises(ValidationError) as exc_info:
174+
User.model_validate(user_data, scim_ctx=Context.RESOURCE_CREATION_REQUEST)
175+
176+
error = exc_info.value.errors()[0]
177+
assert error["type"] == "primary_uniqueness_error"
178+
assert "emails" in error["ctx"]["field_name"]
179+
assert error["ctx"]["count"] == 2
180+
181+
182+
@pytest.mark.parametrize("scim_ctx", [None, Context.DEFAULT])
183+
def test_multiple_primary_validation_skipped_without_strict_context(scim_ctx):
184+
"""Test that primary validation is skipped when no strict SCIM context is provided."""
185+
user_data = {
186+
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
187+
"userName": "testuser",
188+
"emails": [
189+
{"value": "primary1@example.com", "primary": True},
190+
{"value": "primary2@example.com", "primary": True},
191+
],
192+
}
193+
user = User.model_validate(user_data, scim_ctx=scim_ctx)
194+
assert len(user.emails) == 2

0 commit comments

Comments
 (0)