feat(idm): add SCIM provisioning preview
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-23 11:20:42 +02:00
parent 21e8f0bc39
commit 3dd7766b08
6 changed files with 922 additions and 8 deletions
+18
View File
@@ -0,0 +1,18 @@
# SCIM 2.0 provisioning foundation
IDM uses SCIM 2.0 as the first provisioning boundary. OIDC remains the authentication boundary: a successful login is not provisioning evidence, and a SCIM resource does not grant application authority.
## Reconciliation model
The connector reads RFC 7643 User and Group resources using RFC 7644 one-based pagination. A snapshot is complete only after every advertised page for both collections has been read without totals changing. An outage, malformed page, pagination stall, or configured item limit fails the snapshot; it never implies that an external object was deleted.
Each binding must select a provider-owned immutable match attribute. User name, display name, and email are deliberately rejected as defaults because they are mutable and collision-prone. The SCIM provider `id` is retained after linking, `externalId` remains provider/client correlation when supplied, and the source representation is digest-bound.
The dry-run planner emits create, link, update, deactivate, or quarantine operations with expected local revisions. Duplicate provider IDs, multiple immutable matches, and changes to a bound immutable value are quarantined. Deactivation is possible only from a complete snapshot and only under a reviewed provider policy; review is the default.
## Authority boundary
SCIM Users can become candidates for Identity-owned people and accounts. SCIM Groups and memberships are projected only as business membership facts into IDM. They never become Access roles, permissions, or authorization decisions automatically. Organizations continues to own organization structures and functions, and Access continues to own application authority.
This slice performs discovery and deterministic planning only. Applying a plan requires a later governed execution slice with persisted provider configuration, operator review, audit evidence, idempotency, conflict checks, and recovery.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-idm"
version = "0.1.20"
version = "0.1.21"
description = "GovOPlaN identity management bridge module."
readme = "README.md"
requires-python = ">=3.12"
+158 -6
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
from govoplan_core.core.access import (
@@ -46,7 +47,15 @@ from govoplan_core.core.modules import (
RoleTemplate,
)
from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.provider_governance import (
ExternalProviderDeclaration,
ExternalProviderRuntimeState,
ExternalProviderStateContext,
ExternalProviderStateProviderRegistration,
ProviderBehaviorDeclaration,
ProviderObjectDeclaration,
declared_module_architecture,
)
from govoplan_core.db.base import Base
from govoplan_idm.backend.db import models as idm_models # noqa: F401 - populate metadata
from govoplan_idm.backend.dsar_provider import IDM_DSAR_CAPABILITY
@@ -54,9 +63,10 @@ from govoplan_idm.backend.workflow_definitions import (
function_assignment_workflow_definitions,
)
from govoplan_idm.backend.search_source import create_idm_search_source
from govoplan_idm.backend.scim import SCIM_EXTERNAL_PROVIDER_ID
MODULE_VERSION = "0.1.20"
MODULE_VERSION = "0.1.21"
IDM_READ_SCOPES = (
"idm:organization_assignment:read",
@@ -226,6 +236,82 @@ def _idm_dsar_provider(context: ModuleContext) -> object:
return IdmDsarProvider()
SCIM_PROVIDER = ExternalProviderDeclaration(
id=SCIM_EXTERNAL_PROVIDER_ID,
module_id="idm",
label="SCIM 2.0 identity provisioning",
maturity="read",
operations=("discover", "read", "preview", "dry_run"),
objects=(
ProviderObjectDeclaration(
object_type="user",
field_groups=(
"provider_identity",
"immutable_match",
"lifecycle",
"source_revision",
),
authority_modes=("external_authoritative", "external_mirror"),
default_authority_mode="external_authoritative",
),
ProviderObjectDeclaration(
object_type="group",
field_groups=(
"provider_identity",
"immutable_match",
"business_membership",
"source_revision",
),
authority_modes=("external_authoritative", "external_mirror"),
default_authority_mode="external_authoritative",
),
),
behavior=ProviderBehaviorDeclaration(
revision_tokens="SCIM meta.version and a canonical source digest are retained for every resource.",
concurrency="Plans carry the expected local revision and must be rebuilt after local or remote change.",
freshness="A snapshot is current only after every advertised User and Group page has completed.",
health="Transport, schema, pagination, mapping, collision, and completeness failures are reported separately.",
max_read_items=10000,
idempotency="Provider resource id, configured immutable match, source digest, and plan digest prevent duplicate projection effects.",
retry="Failed reads are retried as a new complete snapshot; an incomplete attempt has no absence effects.",
timeout_seconds=30,
conflicts="Duplicate provider ids, ambiguous matches, and changed immutable values are quarantined.",
outcome_unknown="Read outages and partial snapshots never infer deletion or deactivation.",
outcome_unknown_supported=True,
evidence="Provider id, resource id, version, source digest, plan digest, expected local revision, and review outcome are retained.",
correction="Correct the provider data or mapping and produce a new complete snapshot and plan.",
rollback="Applied identity lifecycle changes require governed compensating revisions, not history rewriting.",
compensation="A later reviewed plan can reactivate or correct a projection while preserving prior evidence.",
reconciliation="Read complete one-based pages, match existing bindings by provider id, then use the configured immutable attribute.",
outage="Existing identities and memberships remain available with stale provider status; no absence action runs.",
classifications=("personal", "confidential", "restricted"),
purposes=("identity provisioning", "business membership reconciliation"),
retention="Identity, IDM, audit, and provider-evidence policies govern their respective retained facts.",
secret_handling="Only an Access credential-envelope reference is configured; bearer values are never retained in snapshots or plans.",
),
documentation_topic_ids=("idm.scim-provisioning",),
)
def _scim_provider_states(
context: ExternalProviderStateContext,
) -> tuple[ExternalProviderRuntimeState, ...]:
del context
return (
ExternalProviderRuntimeState(
provider_id=SCIM_EXTERNAL_PROVIDER_ID,
observed_at=datetime.now(UTC),
configured=False,
active=False,
health="inactive",
freshness="not_applicable",
conflict="not_applicable",
recovery="unknown",
detail="No persisted SCIM provider binding is available in the preview-only slice.",
),
)
manifest = ModuleManifest(
id="idm",
name="IDM",
@@ -356,7 +442,72 @@ manifest = ModuleManifest(
workflow_definitions=function_assignment_workflow_definitions(
module_version=MODULE_VERSION,
),
external_providers=(SCIM_PROVIDER,),
external_provider_state_providers=(
ExternalProviderStateProviderRegistration(
module_id="idm",
provider_id=SCIM_EXTERNAL_PROVIDER_ID,
provider=_scim_provider_states,
),
),
documentation=(
DocumentationTopic(
id="idm.scim-provisioning",
title="Preview SCIM 2.0 identity provisioning",
summary="Read a complete provider snapshot and review deterministic User and Group projection changes before any local effect.",
body=(
"SCIM 2.0 is the first provisioning boundary; OIDC remains responsible for authentication. Configure a provider URL, reusable Access credential envelope, explicit immutable matching attribute, case policy, page size, and missing-user policy. IDM reads one-based RFC 7644 pages and accepts absence as evidence only after every User and Group page completes with stable totals. The dry-run links an existing provider id first, then the configured immutable value; collisions and changed immutable values are quarantined. Missing users default to review and can be configured for deactivation only from a complete snapshot. Groups remain business membership facts and never grant Access roles or permissions. Applying plans is not available in this slice."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "operator", "module_admin", "auditor"),
related_modules=("identity", "organizations", "access", "audit"),
conditions=(
DocumentationCondition(
any_scopes=("idm:settings:read", "idm:settings:write"),
),
),
links=(
DocumentationLink(
label="SCIM provisioning boundary",
href="docs/SCIM_PROVISIONING.md",
kind="repository",
),
),
translations={
"de": {
"title": "SCIM-2.0-Identitätsbereitstellung vorab prüfen",
"summary": "Einen vollständigen Anbieterstand lesen und deterministische Änderungen an Benutzer- und Gruppenprojektionen vor jeder lokalen Wirkung prüfen.",
"body": "SCIM 2.0 ist die erste Bereitstellungsgrenze; OIDC bleibt für die Authentifizierung zuständig. Konfigurieren Sie Anbieter-URL, wiederverwendbaren Access-Berechtigungsnachweis, ein ausdrücklich unveränderliches Abgleichsattribut, Groß-/Kleinschreibungsregel, Seitengröße und Richtlinie für fehlende Benutzer. IDM liest einsbasierte RFC-7644-Seiten und wertet Abwesenheit erst dann als Nachweis, wenn alle Benutzer- und Gruppenseiten mit stabiler Gesamtzahl vollständig sind. Der Vorabplan ordnet zuerst eine bestehende Anbieter-ID und danach den konfigurierten unveränderlichen Wert zu; Mehrdeutigkeiten und geänderte unveränderliche Werte werden unter Quarantäne gestellt. Fehlende Benutzer erfordern standardmäßig Prüfung und dürfen nur nach einem vollständigen Stand richtliniengesteuert deaktiviert werden. Gruppen bleiben fachliche Mitgliedschaftstatsachen und erteilen niemals Access-Rollen oder -Rechte. Die Ausführung der Pläne ist in diesem Abschnitt nicht verfügbar.",
}
},
metadata={
"kind": "workflow",
"prerequisites": [
"The provider exposes RFC 7643 User and Group resources through SCIM 2.0.",
"A provider-owned immutable match attribute has been selected and collision-tested.",
"Authentication is held in a scoped Access credential envelope.",
],
"steps": [
"Read every User and Group page into one complete snapshot.",
"Review schema, pagination, collision, and immutable-value diagnostics.",
"Review each create, link, update, deactivate, or quarantine operation and its expected local revision.",
"Discard and rebuild the plan after any provider, mapping, or local revision change.",
],
"limitations": [
"This release previews but does not apply SCIM provisioning plans.",
"Cursor pagination is not used until advertised and covered by a provider target test.",
"SCIM group membership never becomes Access authority automatically.",
],
"consequences": [
"An incomplete or failed snapshot cannot deactivate a local identity.",
"A changed immutable value or collision blocks automatic linking.",
"A complete snapshot can propose deactivation only when the tenant policy explicitly selects it.",
],
"verification": "Compare page totals, source and plan digests, expected local revisions, collision diagnostics, and the absence policy before approving a later execution.",
},
order=25,
),
DocumentationTopic(
id="idm.privacy.data-subject-requests",
title="Review IDM data in a data-subject request",
@@ -727,12 +878,13 @@ manifest = ModuleManifest(
maturity="vertical_slice",
documentation_ref="docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md",
test_ref="tests/test_assignment_workflow.py",
known_limits=("External directory provisioning remains outside the reference workflow.",),
known_limits=("SCIM provisioning is a deterministic preview; governed plan execution is not implemented yet.",),
supported_authority_modes=("external_authoritative", "external_mirror"),
owned_concepts=("function assignment", "assignment delegation", "acting-for assignment", "assignment request", "typed group", "identity relationship"),
non_owned_concepts=("identity", "organization function", "application role", "workflow runtime"),
recovery_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md", "docs/TYPED_RELATIONSHIPS.md"),
security_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md", "docs/TYPED_RELATIONSHIPS.md"),
operations_docs=("README.md",),
recovery_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md", "docs/TYPED_RELATIONSHIPS.md", "docs/SCIM_PROVISIONING.md"),
security_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md", "docs/TYPED_RELATIONSHIPS.md", "docs/SCIM_PROVISIONING.md"),
operations_docs=("README.md", "docs/SCIM_PROVISIONING.md"),
),
)
+553
View File
@@ -0,0 +1,553 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import UTC, datetime
import hashlib
import json
from typing import Literal, Protocol
from urllib.parse import urlencode, urljoin, urlsplit
from govoplan_core.security.http_fetch import HttpFetchResponse, fetch_http, validate_http_url
SCIM_LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group"
SCIM_EXTERNAL_PROVIDER_ID = "idm.scim2"
MAX_SCIM_PAGE_SIZE = 500
MAX_SCIM_RESULTS = 10_000
MAX_SCIM_RESPONSE_BYTES = 8 * 1024 * 1024
ScimResourceType = Literal["User", "Group"]
ScimPlanAction = Literal["create", "link", "update", "deactivate", "quarantine"]
class ScimError(RuntimeError):
"""Stable, sanitized SCIM discovery and planning error."""
class ScimTransport(Protocol):
def __call__(
self,
url: str,
*,
method: str,
headers: Mapping[str, str],
body: bytes | None,
) -> HttpFetchResponse: ...
@dataclass(frozen=True, slots=True)
class ScimProfile:
"""Non-secret provider-neutral SCIM 2.0 reconciliation policy."""
provider_id: str
base_url: str
credential_ref: str
immutable_match_attribute: str
immutable_match_case_exact: bool = True
absent_user_action: Literal["review", "deactivate"] = "review"
group_projection_mode: Literal["business_membership_only"] = "business_membership_only"
page_size: int = 200
def __post_init__(self) -> None:
for name in ("provider_id", "credential_ref", "immutable_match_attribute"):
value = str(getattr(self, name) or "").strip()
if not value or len(value) > 255:
raise ValueError(f"SCIM {name.replace('_', ' ')} is required and limited to 255 characters.")
object.__setattr__(self, name, value)
if self.immutable_match_attribute in {"id", "userName", "emails", "displayName"}:
raise ValueError(
"SCIM matching requires an explicitly governed immutable attribute, not a mutable login, email, or display field."
)
if not 1 <= self.page_size <= MAX_SCIM_PAGE_SIZE:
raise ValueError(f"SCIM page_size must be between 1 and {MAX_SCIM_PAGE_SIZE}.")
object.__setattr__(
self,
"base_url",
validate_http_url(self.base_url, label="SCIM base URL").rstrip("/"),
)
@dataclass(frozen=True, slots=True)
class ScimResource:
resource_type: ScimResourceType
resource_id: str
external_id: str | None
version: str | None
active: bool
display_name: str
attributes: Mapping[str, object]
source_sha256: str
@dataclass(frozen=True, slots=True)
class ScimSnapshot:
provider_id: str
observed_at: datetime
users: tuple[ScimResource, ...] = ()
groups: tuple[ScimResource, ...] = ()
complete: bool = False
page_count: int = 0
@dataclass(frozen=True, slots=True)
class ScimLocalProjection:
local_id: str
resource_type: ScimResourceType
immutable_match_value: str
revision: int
active: bool = True
provider_resource_id: str | None = None
source_sha256: str | None = None
def __post_init__(self) -> None:
if not self.local_id.strip() or not self.immutable_match_value.strip():
raise ValueError("SCIM local projections require local and immutable-match identity.")
if self.revision < 1:
raise ValueError("SCIM local projection revisions must be positive.")
@dataclass(frozen=True, slots=True)
class ScimPlanOperation:
action: ScimPlanAction
resource_type: ScimResourceType
provider_resource_id: str | None
local_id: str | None
immutable_match_value: str | None
source_sha256: str | None
expected_local_revision: int | None
reason: str
@dataclass(frozen=True, slots=True)
class ScimProvisioningPlan:
provider_id: str
observed_at: datetime
snapshot_complete: bool
operations: tuple[ScimPlanOperation, ...]
plan_sha256: str
warnings: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class _ScimPage:
resources: tuple[ScimResource, ...]
total_results: int
start_index: int
items_per_page: int
@dataclass(slots=True)
class ScimClient:
profile: ScimProfile
bearer_token: str | None = field(default=None, repr=False)
transport: ScimTransport | None = field(default=None, repr=False)
timeout_seconds: int = 30
def fetch_snapshot(self) -> ScimSnapshot:
observed_at = datetime.now(UTC)
users, user_pages = self._fetch_collection("Users", "User")
groups, group_pages = self._fetch_collection("Groups", "Group")
return ScimSnapshot(
provider_id=self.profile.provider_id,
observed_at=observed_at,
users=users,
groups=groups,
complete=True,
page_count=user_pages + group_pages,
)
def _fetch_collection(
self,
path: str,
resource_type: ScimResourceType,
) -> tuple[tuple[ScimResource, ...], int]:
resources: list[ScimResource] = []
start_index = 1
page_count = 0
expected_total: int | None = None
while True:
page = self._fetch_page(path, resource_type, start_index=start_index)
page_count += 1
if page.start_index != start_index:
raise ScimError("SCIM provider returned a non-matching startIndex.")
if expected_total is None:
expected_total = page.total_results
elif page.total_results != expected_total:
raise ScimError("SCIM totalResults changed during the snapshot.")
resources.extend(page.resources)
if len(resources) > MAX_SCIM_RESULTS:
raise ScimError(f"SCIM snapshot exceeds the governed limit of {MAX_SCIM_RESULTS} resources.")
if len(resources) >= page.total_results:
if len(resources) != page.total_results:
raise ScimError("SCIM pagination returned more resources than totalResults.")
return tuple(resources), page_count
if page.items_per_page < 1 or not page.resources:
raise ScimError("SCIM pagination did not make progress.")
start_index += len(page.resources)
def _fetch_page(
self,
path: str,
resource_type: ScimResourceType,
*,
start_index: int,
) -> _ScimPage:
query = urlencode({"startIndex": start_index, "count": self.profile.page_size})
url = self._url(f"{path}?{query}")
headers = {"Accept": "application/scim+json, application/json"}
if self.bearer_token:
headers["Authorization"] = f"Bearer {self.bearer_token}"
if self.transport is not None:
response = self.transport(url, method="GET", headers=headers, body=None)
else:
if not self.bearer_token:
raise ScimError(
"SCIM authentication is unavailable; resolve the configured credential envelope first."
)
response = fetch_http(
url,
method="GET",
headers=headers,
timeout=self.timeout_seconds,
max_bytes=MAX_SCIM_RESPONSE_BYTES,
label="SCIM 2.0 provider",
redirect_sensitive_headers=("Authorization",),
)
if response.status != 200:
raise ScimError(f"SCIM collection read returned HTTP {response.status}.")
if len(response.body) > MAX_SCIM_RESPONSE_BYTES:
raise ScimError("SCIM response exceeded the safety limit.")
try:
payload = json.loads(response.body)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ScimError("SCIM provider returned malformed JSON.") from exc
return parse_scim_list_response(payload, resource_type=resource_type)
def _url(self, relative_path: str) -> str:
url = urljoin(f"{self.profile.base_url}/", relative_path)
if _origin(urlsplit(url)) != _origin(urlsplit(self.profile.base_url)):
raise ScimError("SCIM endpoint escaped the configured provider origin.")
return url
class ScimProvisioningPlanner:
"""Build a deterministic dry-run; it never mutates Identity, IDM, or Access."""
def __init__(self, profile: ScimProfile) -> None:
self.profile = profile
def plan(
self,
snapshot: ScimSnapshot,
local_projections: Sequence[ScimLocalProjection],
) -> ScimProvisioningPlan:
if snapshot.provider_id != self.profile.provider_id:
raise ScimError("SCIM snapshot belongs to another provider binding.")
operations: list[ScimPlanOperation] = []
warnings: list[str] = []
locals_by_type = {
resource_type: [item for item in local_projections if item.resource_type == resource_type]
for resource_type in ("User", "Group")
}
for resource_type, resources in (("User", snapshot.users), ("Group", snapshot.groups)):
operations.extend(
self._plan_resource_type(
resource_type,
resources,
locals_by_type[resource_type],
snapshot_complete=snapshot.complete,
)
)
if not snapshot.complete:
warnings.append(
"The snapshot is incomplete; absence-based deactivation is suppressed."
)
if self.profile.absent_user_action == "review":
warnings.append(
"Missing SCIM users are quarantined for review instead of being deactivated automatically."
)
payload = {
"provider_id": snapshot.provider_id,
"observed_at": snapshot.observed_at.isoformat(),
"snapshot_complete": snapshot.complete,
"operations": [_operation_dict(item) for item in operations],
"warnings": warnings,
}
return ScimProvisioningPlan(
provider_id=snapshot.provider_id,
observed_at=snapshot.observed_at,
snapshot_complete=snapshot.complete,
operations=tuple(operations),
plan_sha256=hashlib.sha256(_canonical_json(payload)).hexdigest(),
warnings=tuple(warnings),
)
def _plan_resource_type(
self,
resource_type: ScimResourceType,
resources: Sequence[ScimResource],
local: Sequence[ScimLocalProjection],
*,
snapshot_complete: bool,
) -> list[ScimPlanOperation]:
by_provider_id: dict[str, list[ScimLocalProjection]] = {}
by_match: dict[str, list[ScimLocalProjection]] = {}
for item in local:
if item.provider_resource_id:
by_provider_id.setdefault(item.provider_resource_id, []).append(item)
by_match.setdefault(self._match_key(item.immutable_match_value), []).append(item)
seen_remote_ids: set[str] = set()
matched_local_ids: set[str] = set()
operations: list[ScimPlanOperation] = []
for resource in resources:
if resource.resource_type != resource_type:
raise ScimError("SCIM snapshot resource type is inconsistent.")
if resource.resource_id in seen_remote_ids:
raise ScimError("SCIM snapshot contains duplicate provider resource ids.")
seen_remote_ids.add(resource.resource_id)
match_value = _required_match_value(
resource.attributes,
self.profile.immutable_match_attribute,
)
bound = by_provider_id.get(resource.resource_id, [])
if len(bound) > 1:
operations.append(
_quarantine(resource, match_value, "Multiple local objects are bound to the same SCIM resource id.")
)
continue
if bound:
item = bound[0]
matched_local_ids.add(item.local_id)
if self._match_key(item.immutable_match_value) != self._match_key(match_value):
operations.append(
_quarantine(resource, match_value, "The immutable match value changed for an existing binding.", item)
)
elif item.source_sha256 != resource.source_sha256 or item.active != resource.active:
operations.append(
_operation("update", resource, match_value, "The bound SCIM source revision changed.", item)
)
continue
candidates = [
item
for item in by_match.get(self._match_key(match_value), [])
if item.provider_resource_id is None
]
if len(candidates) > 1:
operations.append(
_quarantine(resource, match_value, "The immutable match value resolves to multiple local candidates.")
)
elif candidates:
item = candidates[0]
matched_local_ids.add(item.local_id)
operations.append(
_operation("link", resource, match_value, "One unbound local object matched the configured immutable attribute.", item)
)
else:
operations.append(
_operation("create", resource, match_value, "No local object matched the configured immutable attribute.")
)
if snapshot_complete:
for item in local:
if not item.provider_resource_id or item.local_id in matched_local_ids:
continue
if item.provider_resource_id in seen_remote_ids:
continue
action: ScimPlanAction = "quarantine"
reason = "The bound SCIM object is absent from a complete snapshot and requires review."
if resource_type == "User" and self.profile.absent_user_action == "deactivate":
action = "deactivate"
reason = "The bound SCIM user is absent from a complete snapshot under the reviewed deactivation policy."
operations.append(
ScimPlanOperation(
action=action,
resource_type=resource_type,
provider_resource_id=item.provider_resource_id,
local_id=item.local_id,
immutable_match_value=item.immutable_match_value,
source_sha256=None,
expected_local_revision=item.revision,
reason=reason,
)
)
return operations
def _match_key(self, value: str) -> str:
normalized = value.strip()
return normalized if self.profile.immutable_match_case_exact else normalized.casefold()
def parse_scim_list_response(
payload: object,
*,
resource_type: ScimResourceType,
) -> _ScimPage:
if not isinstance(payload, Mapping):
raise ScimError("SCIM ListResponse must be a JSON object.")
schemas = payload.get("schemas")
if not isinstance(schemas, list) or SCIM_LIST_SCHEMA not in schemas:
raise ScimError("SCIM response is missing the ListResponse schema.")
total_results = _nonnegative_int(payload.get("totalResults"), "totalResults")
start_index = _positive_int(payload.get("startIndex", 1), "startIndex")
items = payload.get("Resources", [])
if not isinstance(items, list):
raise ScimError("SCIM Resources must be an array.")
items_per_page = _nonnegative_int(payload.get("itemsPerPage", len(items)), "itemsPerPage")
if items_per_page != len(items):
raise ScimError("SCIM itemsPerPage does not match the returned resource count.")
expected_schema = SCIM_USER_SCHEMA if resource_type == "User" else SCIM_GROUP_SCHEMA
parsed: list[ScimResource] = []
for item in items:
if not isinstance(item, Mapping):
raise ScimError("SCIM resources must be JSON objects.")
resource_schemas = item.get("schemas")
if not isinstance(resource_schemas, list) or expected_schema not in resource_schemas:
raise ScimError(f"SCIM {resource_type} is missing its core schema.")
resource_id = _required_text(item.get("id"), f"SCIM {resource_type} id")
if resource_type == "User":
display_name = _required_text(item.get("userName"), "SCIM User userName")
active_value = item.get("active", True)
if not isinstance(active_value, bool):
raise ScimError("SCIM User active must be boolean.")
active = active_value
else:
display_name = _required_text(item.get("displayName"), "SCIM Group displayName")
active = True
meta = item.get("meta")
version = None
if meta is not None:
if not isinstance(meta, Mapping):
raise ScimError("SCIM resource meta must be an object.")
version = _optional_text(meta.get("version"))
external_id = _optional_text(item.get("externalId"))
normalized = dict(item)
parsed.append(
ScimResource(
resource_type=resource_type,
resource_id=resource_id,
external_id=external_id,
version=version,
active=active,
display_name=display_name,
attributes=normalized,
source_sha256=hashlib.sha256(_canonical_json(normalized)).hexdigest(),
)
)
return _ScimPage(
resources=tuple(parsed),
total_results=total_results,
start_index=start_index,
items_per_page=items_per_page,
)
def _required_match_value(attributes: Mapping[str, object], attribute: str) -> str:
value: object = attributes.get(attribute)
if value is None and "." in attribute:
value = attributes
for part in attribute.split("."):
if not isinstance(value, Mapping):
value = None
break
value = value.get(part)
return _required_text(value, f"SCIM immutable attribute {attribute}")
def _operation(
action: ScimPlanAction,
resource: ScimResource,
match_value: str,
reason: str,
local: ScimLocalProjection | None = None,
) -> ScimPlanOperation:
return ScimPlanOperation(
action=action,
resource_type=resource.resource_type,
provider_resource_id=resource.resource_id,
local_id=local.local_id if local else None,
immutable_match_value=match_value,
source_sha256=resource.source_sha256,
expected_local_revision=local.revision if local else None,
reason=reason,
)
def _quarantine(
resource: ScimResource,
match_value: str,
reason: str,
local: ScimLocalProjection | None = None,
) -> ScimPlanOperation:
return _operation("quarantine", resource, match_value, reason, local)
def _operation_dict(value: ScimPlanOperation) -> dict[str, object]:
return {
"action": value.action,
"resource_type": value.resource_type,
"provider_resource_id": value.provider_resource_id,
"local_id": value.local_id,
"immutable_match_value": value.immutable_match_value,
"source_sha256": value.source_sha256,
"expected_local_revision": value.expected_local_revision,
"reason": value.reason,
}
def _canonical_json(value: object) -> bytes:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def _origin(parts) -> tuple[str, str, int | None]:
return (
parts.scheme.casefold(),
(parts.hostname or "").casefold(),
parts.port or (443 if parts.scheme.casefold() == "https" else 80),
)
def _required_text(value: object, label: str) -> str:
if not isinstance(value, str) or not value.strip() or len(value.strip()) > 500:
raise ScimError(f"{label} is required and limited to 500 characters.")
return value.strip()
def _optional_text(value: object) -> str | None:
if value is None:
return None
if not isinstance(value, str) or not value.strip() or len(value.strip()) > 500:
raise ScimError("SCIM optional text values must be non-empty strings limited to 500 characters.")
return value.strip()
def _nonnegative_int(value: object, label: str) -> int:
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
raise ScimError(f"SCIM {label} must be a non-negative integer.")
return value
def _positive_int(value: object, label: str) -> int:
parsed = _nonnegative_int(value, label)
if parsed < 1:
raise ScimError(f"SCIM {label} must be positive.")
return parsed
__all__ = [
"SCIM_EXTERNAL_PROVIDER_ID",
"SCIM_GROUP_SCHEMA",
"SCIM_LIST_SCHEMA",
"SCIM_USER_SCHEMA",
"ScimClient",
"ScimError",
"ScimLocalProjection",
"ScimProfile",
"ScimProvisioningPlan",
"ScimProvisioningPlanner",
"ScimResource",
"ScimSnapshot",
"parse_scim_list_response",
]
+191
View File
@@ -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")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/idm-webui",
"version": "0.1.20",
"version": "0.1.21",
"private": true,
"type": "module",
"main": "src/index.ts",