Expose provider-neutral identity reconciliation
This commit is contained in:
parent
de2c02dc6d
commit
a336f594e7
4 changed files with 189 additions and 18 deletions
|
|
@ -3,20 +3,29 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.error import HTTPError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
from user_engine.ports import ProvisioningRequest, ProvisioningResult
|
from user_engine.ports import IdentityDriftResult, ProvisioningRequest, ProvisioningResult
|
||||||
|
|
||||||
|
|
||||||
class HTTPIdentityProvisioningAdapter:
|
class HTTPIdentityProvisioningAdapter:
|
||||||
def __init__(self, *, base_url: str, bearer_token: str, timeout: float = 10) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
base_url: str,
|
||||||
|
bearer_token: str,
|
||||||
|
timeout: float = 10,
|
||||||
|
retry_delay: float = 0.1,
|
||||||
|
) -> None:
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
self.bearer_token = bearer_token.strip()
|
self.bearer_token = bearer_token.strip()
|
||||||
if not self.bearer_token:
|
if not self.bearer_token:
|
||||||
raise ValueError("bearer token must not be empty")
|
raise ValueError("bearer token must not be empty")
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
self.retry_delay = retry_delay
|
||||||
|
|
||||||
def provision(self, request: ProvisioningRequest) -> ProvisioningResult:
|
def provision(self, request: ProvisioningRequest) -> ProvisioningResult:
|
||||||
return self._post("/v1/identities/provision", {
|
return self._post("/v1/identities/provision", {
|
||||||
|
|
@ -38,6 +47,24 @@ class HTTPIdentityProvisioningAdapter:
|
||||||
def deprovision(self, *, external_subject: str, idempotency_key: str, correlation_id: str) -> ProvisioningResult:
|
def deprovision(self, *, external_subject: str, idempotency_key: str, correlation_id: str) -> ProvisioningResult:
|
||||||
return self._lifecycle("deprovision", external_subject, idempotency_key, correlation_id)
|
return self._lifecycle("deprovision", external_subject, idempotency_key, correlation_id)
|
||||||
|
|
||||||
|
def drift(
|
||||||
|
self,
|
||||||
|
request: ProvisioningRequest,
|
||||||
|
*,
|
||||||
|
external_subject: str,
|
||||||
|
desired_status: str = "active",
|
||||||
|
) -> IdentityDriftResult:
|
||||||
|
return self._desired("drift", request, external_subject, desired_status)
|
||||||
|
|
||||||
|
def reconcile(
|
||||||
|
self,
|
||||||
|
request: ProvisioningRequest,
|
||||||
|
*,
|
||||||
|
external_subject: str,
|
||||||
|
desired_status: str = "active",
|
||||||
|
) -> IdentityDriftResult:
|
||||||
|
return self._desired("reconcile", request, external_subject, desired_status)
|
||||||
|
|
||||||
def _lifecycle(self, action: str, subject: str, key: str, correlation_id: str) -> ProvisioningResult:
|
def _lifecycle(self, action: str, subject: str, key: str, correlation_id: str) -> ProvisioningResult:
|
||||||
return self._post(f"/v1/identities/{action}", {
|
return self._post(f"/v1/identities/{action}", {
|
||||||
"external_subject": subject,
|
"external_subject": subject,
|
||||||
|
|
@ -45,22 +72,34 @@ class HTTPIdentityProvisioningAdapter:
|
||||||
"correlation_id": correlation_id,
|
"correlation_id": correlation_id,
|
||||||
})
|
})
|
||||||
|
|
||||||
def _post(self, path: str, payload: dict[str, Any]) -> ProvisioningResult:
|
def _desired(
|
||||||
request = Request(
|
self,
|
||||||
self.base_url + path,
|
action: str,
|
||||||
data=json.dumps(payload).encode(),
|
request: ProvisioningRequest,
|
||||||
headers={
|
external_subject: str,
|
||||||
"Authorization": f"Bearer {self.bearer_token}",
|
desired_status: str,
|
||||||
"Content-Type": "application/json",
|
) -> IdentityDriftResult:
|
||||||
},
|
result = self._request(f"/v1/identities/{action}", {
|
||||||
method="POST",
|
"external_subject": external_subject,
|
||||||
|
"user_id": request.user_id,
|
||||||
|
"tenant": request.tenant,
|
||||||
|
"primary_email": request.primary_email,
|
||||||
|
"display_name": request.display_name,
|
||||||
|
"idempotency_key": request.idempotency_key,
|
||||||
|
"correlation_id": request.correlation_id,
|
||||||
|
"roles": request.roles,
|
||||||
|
"desired_status": desired_status,
|
||||||
|
})
|
||||||
|
return IdentityDriftResult(
|
||||||
|
provider=str(result["provider"]),
|
||||||
|
external_subject=str(result["external_subject"]),
|
||||||
|
status=str(result["status"]),
|
||||||
|
drift=tuple(str(item) for item in result.get("drift", ())),
|
||||||
|
changed=tuple(str(item) for item in result.get("changed", ())),
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
with urlopen(request, timeout=self.timeout) as response:
|
def _post(self, path: str, payload: dict[str, Any]) -> ProvisioningResult:
|
||||||
result = json.loads(response.read())
|
result = self._request(path, payload)
|
||||||
except HTTPError as exc:
|
|
||||||
message = exc.read(4096).decode("utf-8", "replace")
|
|
||||||
raise RuntimeError(f"identity provisioning failed ({exc.code}): {message}") from exc
|
|
||||||
return ProvisioningResult(
|
return ProvisioningResult(
|
||||||
provider=str(result["provider"]),
|
provider=str(result["provider"]),
|
||||||
external_subject=str(result["external_subject"]),
|
external_subject=str(result["external_subject"]),
|
||||||
|
|
@ -72,3 +111,30 @@ class HTTPIdentityProvisioningAdapter:
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _request(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
request = Request(
|
||||||
|
self.base_url + path,
|
||||||
|
data=json.dumps(payload).encode(),
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self.bearer_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=self.timeout) as response:
|
||||||
|
result = json.loads(response.read())
|
||||||
|
break
|
||||||
|
except HTTPError as exc:
|
||||||
|
if exc.code not in {502, 503, 504} or attempt:
|
||||||
|
message = exc.read(4096).decode("utf-8", "replace")
|
||||||
|
raise RuntimeError(
|
||||||
|
f"identity provisioning failed ({exc.code}): {message}"
|
||||||
|
) from exc
|
||||||
|
except URLError as exc:
|
||||||
|
if attempt:
|
||||||
|
raise RuntimeError("identity provisioning dependency unavailable") from exc
|
||||||
|
time.sleep(self.retry_delay)
|
||||||
|
return dict(result)
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,15 @@ class ProvisioningResult:
|
||||||
password_setup_url: str | None = None
|
password_setup_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class IdentityDriftResult:
|
||||||
|
provider: str
|
||||||
|
external_subject: str
|
||||||
|
status: str
|
||||||
|
drift: tuple[str, ...] = ()
|
||||||
|
changed: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
class IdentityProvisioningPort(Protocol):
|
class IdentityProvisioningPort(Protocol):
|
||||||
"""Lifecycle seam owned by NetKingdom adapters, not the user domain."""
|
"""Lifecycle seam owned by NetKingdom adapters, not the user domain."""
|
||||||
|
|
||||||
|
|
@ -87,6 +96,24 @@ class IdentityProvisioningPort(Protocol):
|
||||||
) -> ProvisioningResult:
|
) -> ProvisioningResult:
|
||||||
"""Remove or tombstone an identity according to provider policy."""
|
"""Remove or tombstone an identity according to provider policy."""
|
||||||
|
|
||||||
|
def drift(
|
||||||
|
self,
|
||||||
|
request: ProvisioningRequest,
|
||||||
|
*,
|
||||||
|
external_subject: str,
|
||||||
|
desired_status: str = "active",
|
||||||
|
) -> IdentityDriftResult:
|
||||||
|
"""Inspect provider state without changing it or exposing credentials."""
|
||||||
|
|
||||||
|
def reconcile(
|
||||||
|
self,
|
||||||
|
request: ProvisioningRequest,
|
||||||
|
*,
|
||||||
|
external_subject: str,
|
||||||
|
desired_status: str = "active",
|
||||||
|
) -> IdentityDriftResult:
|
||||||
|
"""Converge managed provider state toward the requested lifecycle."""
|
||||||
|
|
||||||
|
|
||||||
class UserEngineStore(Protocol):
|
class UserEngineStore(Protocol):
|
||||||
"""Durable persistence boundary for user-engine service behavior.
|
"""Durable persistence boundary for user-engine service behavior.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import io
|
||||||
import json
|
import json
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
from urllib.error import URLError
|
||||||
|
|
||||||
from user_engine.adapters.provisioning import HTTPIdentityProvisioningAdapter
|
from user_engine.adapters.provisioning import HTTPIdentityProvisioningAdapter
|
||||||
from user_engine.ports import ProvisioningRequest
|
from user_engine.ports import ProvisioningRequest
|
||||||
|
|
@ -41,3 +42,66 @@ class ProvisioningAdapterTests(unittest.TestCase):
|
||||||
request = opener.call_args.args[0]
|
request = opener.call_args.args[0]
|
||||||
self.assertEqual("Bearer secret", request.headers["Authorization"])
|
self.assertEqual("Bearer secret", request.headers["Authorization"])
|
||||||
self.assertIn(b"idem-1234567890123456", request.data)
|
self.assertIn(b"idem-1234567890123456", request.data)
|
||||||
|
|
||||||
|
@patch("user_engine.adapters.provisioning.urlopen")
|
||||||
|
def test_drift_and_reconcile_use_provider_neutral_desired_state(self, opener):
|
||||||
|
opener.side_effect = [
|
||||||
|
Response(json.dumps({
|
||||||
|
"provider": "netkingdom-lldap",
|
||||||
|
"external_subject": "person",
|
||||||
|
"status": "drifted",
|
||||||
|
"drift": ["status:mismatch"],
|
||||||
|
"changed": [],
|
||||||
|
}).encode()),
|
||||||
|
Response(json.dumps({
|
||||||
|
"provider": "netkingdom-lldap",
|
||||||
|
"external_subject": "person",
|
||||||
|
"status": "reconciled",
|
||||||
|
"drift": [],
|
||||||
|
"changed": ["group:removed:netkingdom-suspended"],
|
||||||
|
}).encode()),
|
||||||
|
]
|
||||||
|
adapter = HTTPIdentityProvisioningAdapter(
|
||||||
|
base_url="http://provisioner", bearer_token="secret"
|
||||||
|
)
|
||||||
|
request = ProvisioningRequest(
|
||||||
|
user_id="person",
|
||||||
|
tenant="tenant:friendly:binky",
|
||||||
|
primary_email="person@example.test",
|
||||||
|
display_name="Person",
|
||||||
|
idempotency_key="idem-1234567890123456",
|
||||||
|
correlation_id="corr-1",
|
||||||
|
roles=("user",),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
("status:mismatch",),
|
||||||
|
adapter.drift(request, external_subject="person").drift,
|
||||||
|
)
|
||||||
|
reconciled = adapter.reconcile(request, external_subject="person")
|
||||||
|
self.assertEqual("reconciled", reconciled.status)
|
||||||
|
self.assertEqual(
|
||||||
|
("group:removed:netkingdom-suspended",), reconciled.changed
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("user_engine.adapters.provisioning.urlopen")
|
||||||
|
def test_idempotent_request_retries_one_transient_provider_failure(self, opener):
|
||||||
|
opener.side_effect = [
|
||||||
|
URLError("temporary"),
|
||||||
|
Response(json.dumps({
|
||||||
|
"provider": "netkingdom-lldap",
|
||||||
|
"external_subject": "person",
|
||||||
|
"status": "active",
|
||||||
|
}).encode()),
|
||||||
|
]
|
||||||
|
adapter = HTTPIdentityProvisioningAdapter(
|
||||||
|
base_url="http://provisioner",
|
||||||
|
bearer_token="secret",
|
||||||
|
retry_delay=0,
|
||||||
|
)
|
||||||
|
result = adapter.reactivate(
|
||||||
|
external_subject="person",
|
||||||
|
idempotency_key="idem-1234567890123456",
|
||||||
|
correlation_id="corr-1",
|
||||||
|
)
|
||||||
|
self.assertEqual("active", result.status)
|
||||||
|
self.assertEqual(2, opener.call_count)
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,14 @@ invokes a provider-neutral provisioning port with roles, correlation, and a
|
||||||
required idempotency key. Remaining API breadth and OpenAPI/outbox work keep
|
required idempotency key. Remaining API breadth and OpenAPI/outbox work keep
|
||||||
this task in progress.
|
this task in progress.
|
||||||
|
|
||||||
|
2026-07-29 increment: `IdentityProvisioningPort` now includes provider-neutral
|
||||||
|
read-only drift inspection and active reconciliation. The HTTP adapter carries
|
||||||
|
the same desired identity envelope used for provisioning, returns only
|
||||||
|
machine-readable differences and changes, and retries one transient dependency
|
||||||
|
failure under the required idempotency key. NetKingdom's live LLDAP adapter
|
||||||
|
proved drift detection, convergence, and replay-safe cleanup. Remaining API
|
||||||
|
breadth and OpenAPI/outbox delivery keep this task in progress.
|
||||||
|
|
||||||
## T03 - Implement self-service login and registration UX
|
## T03 - Implement self-service login and registration UX
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
@ -161,6 +169,12 @@ accessibility, and restore matrix remains.
|
||||||
The admin increment adds explicit regression coverage for missing/wrong CSRF,
|
The admin increment adds explicit regression coverage for missing/wrong CSRF,
|
||||||
required API idempotency, provider-link persistence, and suspension calls.
|
required API idempotency, provider-link persistence, and suspension calls.
|
||||||
|
|
||||||
|
2026-07-29 evidence: the provider-neutral adapter suite now covers drift
|
||||||
|
inspection, convergence results, transient retry, and replay-safe identity
|
||||||
|
cleanup; the full user-engine suite passes 107 tests with three provider
|
||||||
|
integration skips. A live disposable identity was suspended, reported as
|
||||||
|
drifted, reconciled to `in_sync`, and removed without leaving directory state.
|
||||||
|
|
||||||
## T07 - Binky production acceptance
|
## T07 - Binky production acceptance
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue