feat(idm): add SCIM provisioning preview
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from govoplan_core.security.http_fetch import HttpFetchResponse
|
||||
from govoplan_idm.backend.scim import (
|
||||
SCIM_GROUP_SCHEMA,
|
||||
SCIM_LIST_SCHEMA,
|
||||
SCIM_USER_SCHEMA,
|
||||
ScimClient,
|
||||
ScimError,
|
||||
ScimLocalProjection,
|
||||
ScimProfile,
|
||||
ScimProvisioningPlanner,
|
||||
ScimSnapshot,
|
||||
parse_scim_list_response,
|
||||
)
|
||||
|
||||
|
||||
def _profile(*, absent_user_action="review") -> ScimProfile:
|
||||
return ScimProfile(
|
||||
provider_id="institutional-idp",
|
||||
base_url="https://idp.example.test/scim/v2",
|
||||
credential_ref="core-credential:scim",
|
||||
immutable_match_attribute="urn:example:params:scim:schemas:extension:staff:2.0:User:personnelNumber",
|
||||
absent_user_action=absent_user_action,
|
||||
page_size=2,
|
||||
)
|
||||
|
||||
|
||||
def _user(resource_id: str, number: str, *, active: bool = True) -> dict[str, object]:
|
||||
return {
|
||||
"schemas": [
|
||||
SCIM_USER_SCHEMA,
|
||||
"urn:example:params:scim:schemas:extension:staff:2.0:User",
|
||||
],
|
||||
"id": resource_id,
|
||||
"userName": f"user-{number}",
|
||||
"active": active,
|
||||
"urn:example:params:scim:schemas:extension:staff:2.0:User:personnelNumber": number,
|
||||
"meta": {"version": f'W/"{resource_id}"'},
|
||||
}
|
||||
|
||||
|
||||
def _list(resources: list[dict[str, object]], *, total: int, start: int) -> bytes:
|
||||
return json.dumps(
|
||||
{
|
||||
"schemas": [SCIM_LIST_SCHEMA],
|
||||
"totalResults": total,
|
||||
"startIndex": start,
|
||||
"itemsPerPage": len(resources),
|
||||
"Resources": resources,
|
||||
}
|
||||
).encode()
|
||||
|
||||
|
||||
def test_scim_client_reads_complete_one_based_paginated_snapshot() -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
def transport(url, *, method, headers, body):
|
||||
calls.append(url)
|
||||
assert method == "GET" and body is None
|
||||
assert "Authorization" not in headers
|
||||
if "/Users?" in url and "startIndex=1" in url:
|
||||
payload = _list([_user("u-1", "100"), _user("u-2", "200")], total=3, start=1)
|
||||
elif "/Users?" in url:
|
||||
payload = _list([_user("u-3", "300")], total=3, start=3)
|
||||
else:
|
||||
payload = _list([], total=0, start=1)
|
||||
return HttpFetchResponse(200, {"Content-Type": "application/scim+json"}, payload)
|
||||
|
||||
snapshot = ScimClient(_profile(), transport=transport).fetch_snapshot()
|
||||
|
||||
assert snapshot.complete is True
|
||||
assert [item.resource_id for item in snapshot.users] == ["u-1", "u-2", "u-3"]
|
||||
assert snapshot.groups == ()
|
||||
assert snapshot.page_count == 3
|
||||
assert len(calls) == 3
|
||||
|
||||
|
||||
def test_planner_links_by_explicit_immutable_attribute_and_quarantines_collision() -> None:
|
||||
page = parse_scim_list_response(
|
||||
json.loads(_list([_user("u-1", "100"), _user("u-2", "200")], total=2, start=1)),
|
||||
resource_type="User",
|
||||
)
|
||||
snapshot = ScimSnapshot(
|
||||
provider_id="institutional-idp",
|
||||
observed_at=datetime(2026, 8, 23, tzinfo=UTC),
|
||||
users=page.resources,
|
||||
complete=True,
|
||||
page_count=1,
|
||||
)
|
||||
local = (
|
||||
ScimLocalProjection("local-100", "User", "100", revision=2),
|
||||
ScimLocalProjection("local-200-a", "User", "200", revision=1),
|
||||
ScimLocalProjection("local-200-b", "User", "200", revision=1),
|
||||
)
|
||||
|
||||
plan = ScimProvisioningPlanner(_profile()).plan(snapshot, local)
|
||||
|
||||
assert [(item.action, item.local_id) for item in plan.operations] == [
|
||||
("link", "local-100"),
|
||||
("quarantine", None),
|
||||
]
|
||||
assert len(plan.plan_sha256) == 64
|
||||
|
||||
|
||||
def test_absence_never_deactivates_from_incomplete_snapshot() -> None:
|
||||
local = (
|
||||
ScimLocalProjection(
|
||||
"local-100",
|
||||
"User",
|
||||
"100",
|
||||
revision=3,
|
||||
provider_resource_id="u-1",
|
||||
),
|
||||
)
|
||||
incomplete = ScimSnapshot(
|
||||
provider_id="institutional-idp",
|
||||
observed_at=datetime(2026, 8, 23, tzinfo=UTC),
|
||||
complete=False,
|
||||
)
|
||||
complete = ScimSnapshot(
|
||||
provider_id="institutional-idp",
|
||||
observed_at=datetime(2026, 8, 23, tzinfo=UTC),
|
||||
complete=True,
|
||||
)
|
||||
|
||||
first = ScimProvisioningPlanner(_profile(absent_user_action="deactivate")).plan(incomplete, local)
|
||||
second = ScimProvisioningPlanner(_profile(absent_user_action="deactivate")).plan(complete, local)
|
||||
|
||||
assert first.operations == ()
|
||||
assert first.warnings
|
||||
assert second.operations[0].action == "deactivate"
|
||||
assert second.operations[0].expected_local_revision == 3
|
||||
|
||||
|
||||
def test_groups_are_business_projections_and_not_access_grants() -> None:
|
||||
group = {
|
||||
"schemas": [SCIM_GROUP_SCHEMA],
|
||||
"id": "g-1",
|
||||
"displayName": "Payroll reviewers",
|
||||
"externalId": "group-100",
|
||||
"members": [{"value": "u-1"}],
|
||||
}
|
||||
profile = ScimProfile(
|
||||
provider_id="institutional-idp",
|
||||
base_url="https://idp.example.test/scim/v2",
|
||||
credential_ref="core-credential:scim",
|
||||
immutable_match_attribute="externalIdImmutable",
|
||||
)
|
||||
group["externalIdImmutable"] = "group-stable-100"
|
||||
page = parse_scim_list_response(
|
||||
{
|
||||
"schemas": [SCIM_LIST_SCHEMA],
|
||||
"totalResults": 1,
|
||||
"startIndex": 1,
|
||||
"itemsPerPage": 1,
|
||||
"Resources": [group],
|
||||
},
|
||||
resource_type="Group",
|
||||
)
|
||||
plan = ScimProvisioningPlanner(profile).plan(
|
||||
ScimSnapshot(
|
||||
provider_id="institutional-idp",
|
||||
observed_at=datetime(2026, 8, 23, tzinfo=UTC),
|
||||
groups=page.resources,
|
||||
complete=True,
|
||||
),
|
||||
(),
|
||||
)
|
||||
|
||||
assert plan.operations[0].resource_type == "Group"
|
||||
assert plan.operations[0].action == "create"
|
||||
assert profile.group_projection_mode == "business_membership_only"
|
||||
|
||||
|
||||
def test_parser_and_profile_reject_unsafe_identity_assumptions() -> None:
|
||||
with pytest.raises(ValueError, match="immutable"):
|
||||
ScimProfile(
|
||||
provider_id="idp",
|
||||
base_url="https://idp.example.test/scim/v2",
|
||||
credential_ref="credential",
|
||||
immutable_match_attribute="userName",
|
||||
)
|
||||
with pytest.raises(ScimError, match="ListResponse"):
|
||||
parse_scim_list_response({"Resources": []}, resource_type="User")
|
||||
|
||||
Reference in New Issue
Block a user