Implement role-based account journeys with database and browser acceptance suites
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
parent
75750c0036
commit
1127f852dd
24 changed files with 1554 additions and 148 deletions
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import copy
|
||||
import os
|
||||
from threading import RLock
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterable, Iterator, Mapping, cast
|
||||
|
|
@ -78,6 +79,17 @@ class InMemoryUserEngineStore:
|
|||
default=None, init=False, repr=False
|
||||
)
|
||||
|
||||
_lifecycle_lock: object = field(default_factory=RLock, repr=False, compare=False)
|
||||
|
||||
@contextmanager
|
||||
def tenant_lifecycle_guard(self, tenant: str) -> Iterator[None]:
|
||||
# One reentrant lock also protects the in-memory transaction snapshot.
|
||||
with self._lifecycle_lock:
|
||||
yield
|
||||
|
||||
def outbox_history(self) -> tuple[OutboxEvent, ...]:
|
||||
return tuple(self.outbox_events)
|
||||
|
||||
def migrate(self) -> None:
|
||||
"""Apply the standalone schema migration manifest."""
|
||||
self.schema_version = SCHEMA_VERSION
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ pooling, securing, and observing those connections.
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from threading import RLock
|
||||
from contextlib import contextmanager
|
||||
from importlib.resources import files
|
||||
from typing import Any, Iterable, Iterator, Mapping, Protocol, cast
|
||||
|
|
@ -80,6 +81,32 @@ class PostgresUserEngineStore:
|
|||
|
||||
def __init__(self, connection: PostgresConnection) -> None:
|
||||
self.connection = connection
|
||||
self._lifecycle_lock = RLock()
|
||||
self._transaction_depth = 0
|
||||
self._transaction_failed = False
|
||||
|
||||
@contextmanager
|
||||
def tenant_lifecycle_guard(self, tenant: str) -> Iterator[None]:
|
||||
# Session locks survive the service's local commits and cover provider
|
||||
# side effects. Separate processes use the same tenant-scoped DB lock.
|
||||
with self._lifecycle_lock:
|
||||
key = "user-engine:tenant-lifecycle:" + tenant
|
||||
with self._cursor() as cursor:
|
||||
cursor.execute("SELECT pg_advisory_lock(hashtextextended(%s, 0))", (key,))
|
||||
try:
|
||||
yield
|
||||
except BaseException:
|
||||
# An aborted transaction cannot execute the unlock query.
|
||||
self.connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
with self._cursor() as cursor:
|
||||
cursor.execute("SELECT pg_advisory_unlock(hashtextextended(%s, 0))", (key,))
|
||||
|
||||
def outbox_history(self) -> tuple[OutboxEvent, ...]:
|
||||
with self._cursor() as cursor:
|
||||
cursor.execute("SELECT payload FROM user_engine_outbox_events ORDER BY occurred_at, event_id")
|
||||
return tuple(cast(OutboxEvent, self._decode_payload_row("outbox_events", row)) for row in cursor.fetchall())
|
||||
|
||||
@property
|
||||
def schema_version(self) -> str | None:
|
||||
|
|
@ -97,16 +124,26 @@ class PostgresUserEngineStore:
|
|||
|
||||
@contextmanager
|
||||
def transaction(self) -> Iterator[None]:
|
||||
begin = getattr(self.connection, "begin", None)
|
||||
if callable(begin):
|
||||
begin()
|
||||
outer = self._transaction_depth == 0
|
||||
if outer:
|
||||
self._transaction_failed = False
|
||||
begin = getattr(self.connection, "begin", None)
|
||||
if callable(begin): begin()
|
||||
self._transaction_depth += 1
|
||||
try:
|
||||
yield
|
||||
except Exception:
|
||||
self.connection.rollback()
|
||||
except BaseException:
|
||||
self._transaction_failed = True
|
||||
if outer: self.connection.rollback()
|
||||
raise
|
||||
else:
|
||||
self.connection.commit()
|
||||
if outer:
|
||||
if self._transaction_failed:
|
||||
self.connection.rollback()
|
||||
raise RuntimeError("nested transaction failed")
|
||||
self.connection.commit()
|
||||
finally:
|
||||
self._transaction_depth -= 1
|
||||
|
||||
def save_user(self, user: User) -> None:
|
||||
self._upsert_record(user)
|
||||
|
|
|
|||
|
|
@ -39,6 +39,14 @@ class HTTPIdentityProvisioningAdapter:
|
|||
"preferred_username": request.preferred_username,
|
||||
})
|
||||
|
||||
def tenant_access(self, *, external_subject: str, tenant: str, roles: tuple[str, ...],
|
||||
enabled: bool, idempotency_key: str, correlation_id: str) -> ProvisioningResult:
|
||||
return self._post("/v1/identities/tenant-access", {
|
||||
"external_subject": external_subject, "tenant": tenant, "roles": roles,
|
||||
"enabled": enabled, "idempotency_key": idempotency_key,
|
||||
"correlation_id": correlation_id,
|
||||
})
|
||||
|
||||
def suspend(self, *, external_subject: str, idempotency_key: str, correlation_id: str) -> ProvisioningResult:
|
||||
return self._lifecycle("suspend", external_subject, idempotency_key, correlation_id)
|
||||
|
||||
|
|
|
|||
|
|
@ -175,6 +175,12 @@ class IdentityProvisioningPort(Protocol):
|
|||
def provision(self, request: ProvisioningRequest) -> ProvisioningResult:
|
||||
"""Create or resume an external login identity."""
|
||||
|
||||
def tenant_access(
|
||||
self, *, external_subject: str, tenant: str, roles: tuple[str, ...],
|
||||
enabled: bool, idempotency_key: str, correlation_id: str,
|
||||
) -> ProvisioningResult:
|
||||
"""Change only this tenant's directory groups, preserving identity and other tenants."""
|
||||
|
||||
def suspend(
|
||||
self, *, external_subject: str, idempotency_key: str, correlation_id: str
|
||||
) -> ProvisioningResult:
|
||||
|
|
@ -422,6 +428,12 @@ class UserEngineStore(Protocol):
|
|||
def append_outbox(self, event: OutboxEvent) -> None:
|
||||
"""Append an outbox event."""
|
||||
|
||||
def tenant_lifecycle_guard(self, tenant: str):
|
||||
"""Serialize a tenant's lifecycle/role changes across provider and local writes."""
|
||||
|
||||
def outbox_history(self) -> tuple[OutboxEvent, ...]:
|
||||
"""Return delivery records including failed and completed attempts."""
|
||||
|
||||
def pending_outbox(self) -> tuple[OutboxEvent, ...]:
|
||||
"""Return pending outbox events in write order."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1837,7 +1837,69 @@ class UserEngineService:
|
|||
)
|
||||
return updated
|
||||
|
||||
def set_tenant_account_status(
|
||||
def authorize_tenant_member_action(
|
||||
self, actor: Actor, user_id: str, *, tenant: str, correlation_id: str,
|
||||
) -> User:
|
||||
"""Authorize before any credential-provider side effect or target readout."""
|
||||
self.resolve_tenant_context(actor, tenant)
|
||||
if not {"tenant-admin", PLATFORM_OPERATOR_ROLE}.intersection(actor.roles):
|
||||
raise AuthorizationDenied("tenant administrator role required")
|
||||
memberships = self.store.memberships_for_user(user_id, tenant=tenant)
|
||||
if not any(m.scope_type == "tenant" and m.scope_id == tenant for m in memberships):
|
||||
raise NotFoundError("account is not a member of this tenant")
|
||||
self._authorize(actor, action="tenant.account.update",
|
||||
resource_type="user-engine:tenant-account",
|
||||
resource_id=f"{tenant}:{user_id}", tenant=tenant,
|
||||
correlation_id=correlation_id, target_user_id=user_id)
|
||||
return self._require_user(user_id)
|
||||
|
||||
def validate_tenant_role_change(self, actor: Actor, user_id: str, role: str, *, tenant: str, correlation_id: str) -> None:
|
||||
self.authorize_tenant_member_action(actor, user_id, tenant=tenant, correlation_id=correlation_id)
|
||||
if role not in {"user", "tenant-admin"}:
|
||||
raise ValidationError("Choose User or Tenant administrator.")
|
||||
self._authorize(actor, action="membership.write", resource_type="user-engine:membership",
|
||||
resource_id=f"{tenant}:{user_id}:tenant:{tenant}", tenant=tenant,
|
||||
correlation_id=correlation_id, target_user_id=user_id,
|
||||
context={"scope_type":"tenant", "scope_id":tenant, "kind":role})
|
||||
if role != "tenant-admin":
|
||||
self.require_admin_successor(user_id, tenant=tenant)
|
||||
|
||||
def set_tenant_role(self, actor: Actor, user_id: str, role: str, *, tenant: str, correlation_id: str) -> None:
|
||||
with self.store.tenant_lifecycle_guard(tenant):
|
||||
self._set_tenant_role(actor, user_id, role, tenant=tenant, correlation_id=correlation_id)
|
||||
|
||||
def _set_tenant_role(self, actor: Actor, user_id: str, role: str, *, tenant: str, correlation_id: str) -> None:
|
||||
self.validate_tenant_role_change(actor, user_id, role, tenant=tenant, correlation_id=correlation_id)
|
||||
with self.store.transaction():
|
||||
for membership in self.store.memberships_for_user(user_id, tenant=tenant):
|
||||
if membership.scope_type == "tenant" and membership.scope_id == tenant:
|
||||
self.store.save_membership(replace(membership, kind=role, freshness_version=correlation_id))
|
||||
self._record_mutation(actor, action="membership.write", subject=user_id, tenant=tenant,
|
||||
correlation_id=correlation_id, decision_id=None,
|
||||
event_type="membership.role_changed", aggregate_id=user_id,
|
||||
payload={"user_id":user_id, "tenant":tenant, "role":role})
|
||||
|
||||
def require_admin_successor(self, user_id: str, *, tenant: str) -> None:
|
||||
"""Do not deactivate the last active tenant administrator."""
|
||||
members = self.store.memberships_for_tenant(tenant)
|
||||
admins = {m.user_id for m in members if m.scope_type == "tenant"
|
||||
and m.scope_id == tenant and m.kind == "tenant-admin"}
|
||||
if user_id not in admins:
|
||||
return
|
||||
account = self.store.tenant_account(tenant, user_id)
|
||||
if account is not None and account.status != AccountStatus.ACTIVE:
|
||||
return
|
||||
for other in admins - {user_id}:
|
||||
state = self.store.tenant_account(tenant, other)
|
||||
if state is not None and state.status == AccountStatus.ACTIVE:
|
||||
return
|
||||
raise ConflictError("Assign another active tenant administrator before disabling this account.")
|
||||
|
||||
def set_tenant_account_status(self, actor: Actor, user_id: str, status: AccountStatus, *, tenant: str, correlation_id: str | None = None) -> TenantAccount:
|
||||
with self.store.tenant_lifecycle_guard(tenant):
|
||||
return self._set_tenant_account_status(actor, user_id, status, tenant=tenant, correlation_id=correlation_id)
|
||||
|
||||
def _set_tenant_account_status(
|
||||
self,
|
||||
actor: Actor,
|
||||
user_id: str,
|
||||
|
|
@ -1863,6 +1925,8 @@ class UserEngineService:
|
|||
)
|
||||
updated = replace(account, status=status)
|
||||
with self.store.transaction():
|
||||
if status in {AccountStatus.SUSPENDED, AccountStatus.DISABLED}:
|
||||
self.require_admin_successor(user_id, tenant=tenant_context.tenant)
|
||||
self.store.save_tenant_account(updated)
|
||||
self._record_mutation(
|
||||
actor,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ def postgres_provider_test_config(
|
|||
environ: Mapping[str, str] | None = None,
|
||||
) -> tuple[PostgresProviderTestConfig | None, str | None]:
|
||||
"""Return live test config or a skip reason."""
|
||||
env = environ or os.environ
|
||||
env = os.environ if environ is None else environ
|
||||
dsn = env.get(POSTGRES_TEST_DSN_ENV, "").strip()
|
||||
if not dsn:
|
||||
return None, f"{POSTGRES_TEST_DSN_ENV} is not set"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from contextvars import ContextVar
|
|||
from dataclasses import asdict, is_dataclass, replace
|
||||
from enum import Enum
|
||||
from html import escape
|
||||
import time
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
|
@ -349,6 +350,8 @@ class PortalApplication:
|
|||
|
||||
actor = self._actor(environ)
|
||||
self._set_account_navigation(environ, actor)
|
||||
if path.startswith("/api/v1/tenants/"):
|
||||
self._require_tenant_admin(actor, path.split("/")[4])
|
||||
if path == "/api/v1/me" and method == "GET":
|
||||
return self._json(start_response, "200 OK", _jsonable(self.service.me(self._claims(environ), correlation_id=correlation_id)), correlation_id)
|
||||
if path == "/api/v1/me/profile" and method == "PATCH":
|
||||
|
|
@ -383,13 +386,25 @@ class PortalApplication:
|
|||
body = self._form_body(environ)
|
||||
self._require_csrf(environ, str(body.get("csrf_token", "")))
|
||||
self.service.me(self._claims(environ), correlation_id=correlation_id)
|
||||
self.service.update_self_service_profile(
|
||||
actor, display_name=str(body.get("display_name", "")),
|
||||
consent_accepted=body.get("consent_accepted") == "yes",
|
||||
consent_version="portal-terms-v1",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return self._redirect(start_response, "/onboarding", correlation_id)
|
||||
try:
|
||||
self.service.update_self_service_profile(
|
||||
actor, display_name=str(body.get("display_name", "")),
|
||||
consent_accepted=body.get("consent_accepted") == "yes",
|
||||
consent_version="portal-terms-v1", correlation_id=correlation_id,
|
||||
)
|
||||
except ValidationError:
|
||||
name = escape(str(body.get("display_name", ""))[:201])
|
||||
csrf = escape(self._csrf_token(environ))
|
||||
checked = " checked" if body.get("consent_accepted") == "yes" else ""
|
||||
page = self._page_html("Check your profile", f'''<h1>Check your profile</h1>
|
||||
<p role="alert">Enter a display name of 1 to 200 characters. Your profile has not been saved.</p>
|
||||
<form method="post" action="/onboarding/profile"><input type="hidden" name="csrf_token" value="{csrf}">
|
||||
<label>Display name <input name="display_name" value="{name}" required maxlength="200" aria-invalid="true"></label>
|
||||
<label><input type="checkbox" name="consent_accepted" value="yes"{checked}> I accept portal terms version 1</label>
|
||||
<button type="submit">Save profile</button></form><p><a href="/onboarding">Cancel</a></p>''')
|
||||
return self._html(start_response, page, correlation_id, status="400 Bad Request")
|
||||
return self._html(start_response, self._page_html("Profile saved",
|
||||
'<h1>Profile saved</h1><p role="status">Your profile changes have been saved.</p><p><a href="/onboarding">Return to my account</a></p>'), correlation_id)
|
||||
if path.startswith("/onboarding/") and "/steps/" in path and path.endswith("/complete") and method == "POST":
|
||||
body = self._form_body(environ)
|
||||
self._require_csrf(environ, str(body.get("csrf_token", "")))
|
||||
|
|
@ -511,6 +526,7 @@ class PortalApplication:
|
|||
raise ValidationError("identity provisioning is unavailable")
|
||||
parts = path.split("/")
|
||||
tenant, user_id = parts[5], parts[7]
|
||||
self.service.authorize_tenant_member_action(actor, user_id, tenant=tenant, correlation_id=correlation_id)
|
||||
idempotency_key = self._idempotency_key(environ)
|
||||
user = self.service.store.user(user_id)
|
||||
if user is None:
|
||||
|
|
@ -531,10 +547,9 @@ class PortalApplication:
|
|||
)
|
||||
recovery = {"status": provisioned.status, "changed": ("identity",)}
|
||||
else:
|
||||
reconciled = self.provisioning.reconcile(
|
||||
request, external_subject=identity.subject, desired_status="active"
|
||||
)
|
||||
recovery = {"status": reconciled.status, "drift": reconciled.drift, "changed": reconciled.changed}
|
||||
self._change_status(actor, tenant, user_id, AccountStatus.ACTIVE,
|
||||
idempotency_key=idempotency_key, correlation_id=correlation_id)
|
||||
recovery = {"status": "tenant_active", "changed": ("tenant_access",)}
|
||||
account = self.service.set_tenant_account_status(
|
||||
actor, user_id, AccountStatus.ACTIVE, tenant=tenant,
|
||||
correlation_id=correlation_id,
|
||||
|
|
@ -654,7 +669,8 @@ class PortalApplication:
|
|||
if path.startswith("/api/v1/tenants/") and "/invitations/" in path and method == "POST":
|
||||
parts = path.split("/")
|
||||
tenant, invitation_id, action = parts[4], parts[6], parts[7]
|
||||
self.service.resolve_tenant_context(actor, tenant)
|
||||
self._require_tenant_admin(actor, tenant)
|
||||
self._require_invitation_tenant(invitation_id, tenant)
|
||||
expected = self._expected_version(environ)
|
||||
if action == "resend":
|
||||
value = self.service.resend_family_invitation(
|
||||
|
|
@ -674,10 +690,11 @@ class PortalApplication:
|
|||
raise ValidationError("identity provisioning is unavailable")
|
||||
parts = path.split("/")
|
||||
tenant, user_id = parts[4], parts[6]
|
||||
self.service.resolve_tenant_context(actor, tenant)
|
||||
self.service.authorize_tenant_member_action(actor, user_id, tenant=tenant, correlation_id=correlation_id)
|
||||
user = self.service.store.user(user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("user not found")
|
||||
self._require_setup_access(tenant, user_id)
|
||||
idempotency_key = str(environ.get("HTTP_IDEMPOTENCY_KEY", ""))
|
||||
if len(idempotency_key) < 16:
|
||||
raise ValidationError("Idempotency-Key must contain at least 16 characters")
|
||||
|
|
@ -730,21 +747,29 @@ class PortalApplication:
|
|||
idempotency_key = str(environ.get("HTTP_IDEMPOTENCY_KEY", ""))
|
||||
if len(idempotency_key) < 16:
|
||||
raise ValidationError("Idempotency-Key must contain at least 16 characters")
|
||||
identity = next(iter(self.service.store.identities_for_user(user_id)), None)
|
||||
if identity is not None:
|
||||
self.provisioning.deprovision(
|
||||
external_subject=identity.subject,
|
||||
idempotency_key=idempotency_key,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
account = self.service.set_tenant_account_status(
|
||||
actor, user_id, AccountStatus.DISABLED,
|
||||
tenant=tenant, correlation_id=correlation_id,
|
||||
)
|
||||
account = self._change_status(actor, tenant, user_id, AccountStatus.DISABLED,
|
||||
idempotency_key=idempotency_key, correlation_id=correlation_id)
|
||||
return self._json(start_response, "200 OK", {
|
||||
"status": "removed", "tenant_account": _jsonable(account),
|
||||
"provider_identity_removed": identity is not None,
|
||||
"provider_identity_removed": False,
|
||||
}, correlation_id)
|
||||
if path in {"/platform/operations", "/platform/operations/replay"}:
|
||||
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
|
||||
if method == "POST" and path.endswith("/replay"):
|
||||
body = self._form_body(environ)
|
||||
self._require_csrf(environ, str(body.get("csrf_token", "")))
|
||||
event = self.service.store.outbox_event(str(body.get("event_id", "")))
|
||||
if event is None:
|
||||
raise NotFoundError("delivery record not found")
|
||||
if event.delivered_at is not None or event.claimed_by:
|
||||
raise ConflictError("Delivery is already completed or being processed. Refresh its status.")
|
||||
self.service.replay_outbox(actor, event.event_id, correlation_id=correlation_id)
|
||||
return self._redirect(start_response, "/platform/operations?"+urlencode({"event_id":event.event_id}), correlation_id)
|
||||
if method != "GET" or path.endswith("/replay"):
|
||||
raise NotFoundError("operations route not found")
|
||||
query = parse_qs(str(environ.get("QUERY_STRING", "")))
|
||||
return self._html(start_response, self._operations_page(actor,
|
||||
self._csrf_token(environ), query.get("event_id", [""])[0], correlation_id), correlation_id)
|
||||
if path == "/platform" and method == "GET":
|
||||
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
|
||||
return self._html(
|
||||
|
|
@ -764,19 +789,26 @@ class PortalApplication:
|
|||
idempotency_key=f"portal-tenant-{tenant}", correlation_id=correlation_id,
|
||||
)
|
||||
email = str(body.get("admin_email", ""))
|
||||
if email:
|
||||
user = self.service.create_user(
|
||||
actor, display_name=body.get("admin_display_name"),
|
||||
primary_email=email, correlation_id=correlation_id,
|
||||
)
|
||||
self.service.set_tenant_account_status(
|
||||
actor, user.user_id, AccountStatus.INVITED,
|
||||
tenant=tenant, correlation_id=correlation_id,
|
||||
)
|
||||
self.service.add_membership(
|
||||
actor, user.user_id, tenant=tenant, scope_type="tenant",
|
||||
scope_id=tenant, kind="tenant-admin", correlation_id=correlation_id,
|
||||
)
|
||||
with self.service.store.tenant_lifecycle_guard(tenant), self.service.store.transaction():
|
||||
existing_admin = any(
|
||||
m.scope_type == "tenant" and m.scope_id == tenant and m.kind == "tenant-admin"
|
||||
and (u := self.service.store.user(m.user_id)) is not None
|
||||
and (u.primary_email or "").casefold() == email.casefold()
|
||||
for m in self.service.store.memberships_for_tenant(tenant)
|
||||
) if email else False
|
||||
if email and not existing_admin:
|
||||
user = self.service.create_user(
|
||||
actor, display_name=body.get("admin_display_name"),
|
||||
primary_email=email, correlation_id=correlation_id,
|
||||
)
|
||||
self.service.set_tenant_account_status(
|
||||
actor, user.user_id, AccountStatus.INVITED,
|
||||
tenant=tenant, correlation_id=correlation_id,
|
||||
)
|
||||
self.service.add_membership(
|
||||
actor, user.user_id, tenant=tenant, scope_type="tenant",
|
||||
scope_id=tenant, kind="tenant-admin", correlation_id=correlation_id,
|
||||
)
|
||||
return self._html(
|
||||
start_response,
|
||||
self._platform_result(result, tenant, bool(email)), correlation_id,
|
||||
|
|
@ -834,6 +866,13 @@ class PortalApplication:
|
|||
version = str(body.get("version", ""))
|
||||
if not version.isdigit():
|
||||
raise ValidationError("the current record version is required")
|
||||
if operation in {"retire", "reactivate"}:
|
||||
current = self.tenant_management.tenant(tenant=tenant, correlation_id=correlation_id)
|
||||
preview = self._confirm_change(environ, start_response, body, str(current.version),
|
||||
f"{operation.capitalize()} {tenant}",
|
||||
"This changes the tenant lifecycle. Existing application sessions may take time to reflect the change. Review the tenant and reason before confirming.", correlation_id)
|
||||
if preview is not None:
|
||||
return preview
|
||||
metadata = {
|
||||
key: str(body[key]) for key in ("display_name", "contact_email")
|
||||
if str(body.get(key, "")).strip()
|
||||
|
|
@ -853,8 +892,13 @@ class PortalApplication:
|
|||
correlation_id,
|
||||
)
|
||||
if path.startswith("/admin/") and method == "GET":
|
||||
tenant = path.split("/")[2]
|
||||
self.service.resolve_tenant_context(actor, tenant)
|
||||
tenant = unquote(path.split("/")[2])
|
||||
self._require_tenant_admin(actor, tenant)
|
||||
if path.endswith("/activity"):
|
||||
self.service.tenant_diagnostics(actor, tenant=tenant, correlation_id=correlation_id)
|
||||
return self._html(start_response, self._audit_page(tenant), correlation_id)
|
||||
if len(path.split("/")) != 3:
|
||||
raise NotFoundError("tenant page not found")
|
||||
memberships = self.service.store.memberships_for_tenant(tenant)
|
||||
invitations = self.service.store.family_invitations_for_tenant(tenant)
|
||||
diagnostics = self.service.tenant_diagnostics(
|
||||
|
|
@ -870,10 +914,23 @@ class PortalApplication:
|
|||
)
|
||||
if path.startswith("/admin/") and method == "POST":
|
||||
parts = path.split("/")
|
||||
tenant = parts[2]
|
||||
self.service.resolve_tenant_context(actor, tenant)
|
||||
tenant = unquote(parts[2])
|
||||
self._require_tenant_admin(actor, tenant)
|
||||
if len(parts) == 6 and parts[3] == "users":
|
||||
self.service.authorize_tenant_member_action(actor, parts[4], tenant=tenant, correlation_id=correlation_id)
|
||||
body = self._form_body(environ)
|
||||
self._require_csrf(environ, str(body.get("csrf_token", "")))
|
||||
if len(parts) == 6 and parts[3] == "users" and parts[5] in {"status", "remove", "recover", "role"}:
|
||||
user = self.service.store.user(parts[4])
|
||||
state = self.service.store.tenant_account(tenant, parts[4])
|
||||
snapshot = repr((state, self.service.store.memberships_for_user(parts[4], tenant=tenant)))
|
||||
preview = self._confirm_change(environ, start_response, body, snapshot,
|
||||
f"{parts[5].capitalize()} account in {tenant}",
|
||||
f"Account: {user.display_name or user.user_id}. This action applies to this tenant. Other tenant access and the shared login are retained.", correlation_id)
|
||||
if preview is not None:
|
||||
return preview
|
||||
if len(parts) == 4 and parts[3] in {"users", "invitations"} and body.get("role", "user") not in {"user", "tenant-admin"}:
|
||||
raise ValidationError("Choose User or Tenant administrator.")
|
||||
if len(parts) == 4 and parts[3] == "users":
|
||||
user = self.service.create_user(
|
||||
actor,
|
||||
|
|
@ -904,6 +961,7 @@ class PortalApplication:
|
|||
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
|
||||
if len(parts) == 6 and parts[3] == "invitations":
|
||||
invitation_id, action = parts[4], parts[5]
|
||||
self._require_invitation_tenant(invitation_id, tenant)
|
||||
version = int(body.get("version", "0"))
|
||||
if action == "resend":
|
||||
self.service.resend_family_invitation(
|
||||
|
|
@ -925,6 +983,7 @@ class PortalApplication:
|
|||
user = self.service.store.user(user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("user not found")
|
||||
self._require_setup_access(tenant, user_id)
|
||||
result = self.provisioning.provision(ProvisioningRequest(
|
||||
user_id=user.user_id,
|
||||
tenant=tenant,
|
||||
|
|
@ -947,12 +1006,29 @@ class PortalApplication:
|
|||
return self._html(
|
||||
start_response,
|
||||
self._password_setup_handoff(
|
||||
result.password_setup_url, tenant
|
||||
result.password_setup_url, tenant, result.external_subject
|
||||
),
|
||||
correlation_id,
|
||||
)
|
||||
query = urlencode({"provisioned": user.user_id, "status": result.status})
|
||||
return self._redirect(start_response, f"/admin/{tenant}?{query}", correlation_id)
|
||||
if len(parts) == 6 and parts[3] == "users" and parts[5] == "role":
|
||||
role = str(body.get("role", ""))
|
||||
user_id = parts[4]
|
||||
with self.service.store.tenant_lifecycle_guard(tenant):
|
||||
self.service.validate_tenant_role_change(actor, user_id, role, tenant=tenant, correlation_id=correlation_id)
|
||||
identity = next((i for i in self.service.store.identities_for_user(user_id) if i.provider == "netkingdom-lldap"), None)
|
||||
account = self.service.store.tenant_account(tenant, user_id)
|
||||
if identity is not None:
|
||||
if not callable(getattr(self.provisioning, "tenant_access", None)):
|
||||
raise ValidationError("Tenant-scoped identity changes are unavailable.")
|
||||
enabled = account is not None and account.status == AccountStatus.ACTIVE
|
||||
result = self.provisioning.tenant_access(external_subject=identity.subject, tenant=tenant,
|
||||
roles=(role,), enabled=enabled, idempotency_key=f"portal-role-{tenant}-{user_id}-{role}", correlation_id=correlation_id)
|
||||
if result.status != ("tenant_active" if enabled else "tenant_disabled"):
|
||||
raise RuntimeError("tenant role change not confirmed")
|
||||
self.service.set_tenant_role(actor, user_id, role, tenant=tenant, correlation_id=correlation_id)
|
||||
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
|
||||
if len(parts) == 6 and parts[3] == "users" and parts[5] == "status":
|
||||
status = AccountStatus(str(body.get("status", "")))
|
||||
if status not in {AccountStatus.ACTIVE, AccountStatus.SUSPENDED}:
|
||||
|
|
@ -964,20 +1040,8 @@ class PortalApplication:
|
|||
)
|
||||
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
|
||||
if len(parts) == 6 and parts[3] == "users" and parts[5] == "remove":
|
||||
if self.provisioning is None:
|
||||
raise ValidationError("identity provisioning is unavailable")
|
||||
user_id = parts[4]
|
||||
identity = next(iter(self.service.store.identities_for_user(user_id)), None)
|
||||
if identity is not None:
|
||||
self.provisioning.deprovision(
|
||||
external_subject=identity.subject,
|
||||
idempotency_key=f"portal-remove-{tenant}-{user_id}",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
self.service.set_tenant_account_status(
|
||||
actor, user_id, AccountStatus.DISABLED,
|
||||
tenant=tenant, correlation_id=correlation_id,
|
||||
)
|
||||
self._change_status(actor, tenant, parts[4], AccountStatus.DISABLED,
|
||||
idempotency_key=f"portal-remove-{tenant}-{parts[4]}", correlation_id=correlation_id)
|
||||
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
|
||||
if len(parts) == 6 and parts[3] == "users" and parts[5] == "recover":
|
||||
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
|
||||
|
|
@ -1003,9 +1067,8 @@ class PortalApplication:
|
|||
correlation_id=correlation_id,
|
||||
)
|
||||
else:
|
||||
self.provisioning.reconcile(
|
||||
request, external_subject=identity.subject, desired_status="active"
|
||||
)
|
||||
self._change_status(actor, tenant, user_id, AccountStatus.ACTIVE,
|
||||
idempotency_key=request.idempotency_key, correlation_id=correlation_id)
|
||||
self.service.set_tenant_account_status(
|
||||
actor, user_id, AccountStatus.ACTIVE,
|
||||
tenant=tenant, correlation_id=correlation_id,
|
||||
|
|
@ -1013,7 +1076,11 @@ class PortalApplication:
|
|||
return self._redirect(start_response, f"/admin/{tenant}?recovered={user_id}", correlation_id)
|
||||
return self._error(start_response, "404 Not Found", "not_found", "Resource not found.", correlation_id)
|
||||
|
||||
def _change_status(
|
||||
def _change_status(self, actor: Any, tenant: str, user_id: str, status: AccountStatus, *, idempotency_key: str, correlation_id: str) -> Any:
|
||||
with self.service.store.tenant_lifecycle_guard(tenant):
|
||||
return self._change_status_locked(actor, tenant, user_id, status, idempotency_key=idempotency_key, correlation_id=correlation_id)
|
||||
|
||||
def _change_status_locked(
|
||||
self,
|
||||
actor: Any,
|
||||
tenant: str,
|
||||
|
|
@ -1025,34 +1092,103 @@ class PortalApplication:
|
|||
) -> Any:
|
||||
if self.provisioning is None:
|
||||
raise ValidationError("identity provisioning is unavailable")
|
||||
self.service.resolve_tenant_context(actor, tenant)
|
||||
identity = next(
|
||||
(
|
||||
item for item in self.service.store.identities_for_user(user_id)
|
||||
if item.provider == "netkingdom-lldap"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if identity is None:
|
||||
raise ValidationError("user has no managed login identity")
|
||||
if status == AccountStatus.SUSPENDED:
|
||||
self.provisioning.suspend(
|
||||
external_subject=identity.subject,
|
||||
idempotency_key=idempotency_key,
|
||||
correlation_id=correlation_id,
|
||||
self.service.authorize_tenant_member_action(actor, user_id, tenant=tenant, correlation_id=correlation_id)
|
||||
if status not in {AccountStatus.ACTIVE, AccountStatus.SUSPENDED, AccountStatus.DISABLED}:
|
||||
raise ValidationError("unsupported tenant account status")
|
||||
if status != AccountStatus.ACTIVE:
|
||||
self.service.require_admin_successor(user_id, tenant=tenant)
|
||||
identity = next((item for item in self.service.store.identities_for_user(user_id)
|
||||
if item.provider == "netkingdom-lldap"), None)
|
||||
if identity is not None:
|
||||
if not callable(getattr(self.provisioning, "tenant_access", None)):
|
||||
raise ValidationError("Tenant-scoped identity changes are unavailable. No shared login was changed.")
|
||||
result = self.provisioning.tenant_access(
|
||||
external_subject=identity.subject, tenant=tenant,
|
||||
roles=tuple(m.kind for m in self.service.store.memberships_for_user(user_id, tenant=tenant)
|
||||
if m.scope_type == "tenant" and m.scope_id == tenant),
|
||||
enabled=status == AccountStatus.ACTIVE,
|
||||
idempotency_key=idempotency_key, correlation_id=correlation_id,
|
||||
)
|
||||
elif status == AccountStatus.ACTIVE:
|
||||
self.provisioning.reactivate(
|
||||
external_subject=identity.subject,
|
||||
idempotency_key=idempotency_key,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
else:
|
||||
raise ValidationError("provider lifecycle supports active or suspended")
|
||||
expected = "tenant_active" if status == AccountStatus.ACTIVE else "tenant_disabled"
|
||||
if result.status != expected:
|
||||
raise RuntimeError("tenant access change not confirmed")
|
||||
return self.service.set_tenant_account_status(
|
||||
actor, user_id, status, tenant=tenant, correlation_id=correlation_id
|
||||
actor, user_id, status, tenant=tenant, correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
def _confirm_change(self, environ: Mapping[str, Any], start_response: StartResponse,
|
||||
body: Mapping[str, str], snapshot: str, title: str, explanation: str,
|
||||
correlation_id: str) -> list[bytes] | None:
|
||||
path = str(environ.get("PATH_INFO", ""))
|
||||
fields = {k: v for k, v in body.items() if k != "confirm_token"}
|
||||
state = hashlib.sha256(snapshot.encode()).hexdigest()
|
||||
def signature(stamp: str) -> str:
|
||||
material = json.dumps([path, fields, state, stamp], sort_keys=True).encode()
|
||||
return hmac.new(self.trusted_proxy_secret.encode(), material, hashlib.sha256).hexdigest()
|
||||
supplied = str(body.get("confirm_token", ""))
|
||||
if supplied:
|
||||
stamp, _, digest = supplied.partition(".")
|
||||
if not stamp.isdigit() or not 0 <= time.time()-int(stamp) <= 600 or not hmac.compare_digest(digest, signature(stamp)):
|
||||
raise ConflictError("The confirmation expired or the account changed. Refresh and review the action again.")
|
||||
return None
|
||||
stamp = str(int(time.time()))
|
||||
hidden = "".join(f'<input type="hidden" name="{escape(k)}" value="{escape(v)}">' for k,v in fields.items())
|
||||
details = "".join(f'<li>{escape(k.replace("_", " "))}: {escape(v)}</li>' for k,v in fields.items() if k in {"status", "operation", "reason", "version", "role"})
|
||||
page = self._page_html(title, f'<h1>{escape(title)}?</h1><p>{escape(explanation)}</p><ul>{details}</ul>'
|
||||
f'<form method="post" action="{escape(path)}">{hidden}<input type="hidden" name="confirm_token" value="{stamp}.{signature(stamp)}">'
|
||||
'<button type="submit">Confirm change</button></form><p><a href="/">Cancel without changes</a></p>')
|
||||
return self._html(start_response, page, correlation_id)
|
||||
|
||||
def _audit_page(self, tenant: str) -> str:
|
||||
records = [r for r in self.service.audit_records() if r.tenant == tenant][-100:]
|
||||
rows = "".join(f'<tr><td>{escape(r.recorded_at.isoformat())}</td><td>{escape(r.action)}</td><td>{escape(r.actor.preferred_username or r.actor.subject)}</td><td>{escape(r.correlation_id)}</td></tr>' for r in reversed(records))
|
||||
return self._page_html("Account activity", f'<h1>Account activity</h1><p>Tenant: {escape(tenant)}. Most recent 100 recorded actions. A recorded request is not proof of delivery or effective application access.</p>'
|
||||
'<table><thead><tr><th>Time</th><th>Action</th><th>Actor</th><th>Support reference</th></tr></thead><tbody>'
|
||||
+ (rows or '<tr><td colspan="4">No recorded activity yet.</td></tr>') + '</tbody></table>'
|
||||
f'<p><a href="/admin/{escape(tenant)}">Return to tenant administration</a></p>')
|
||||
|
||||
@staticmethod
|
||||
def _delivery_status(event: Any) -> str:
|
||||
if event.delivered_at: return "Accepted by delivery adapter; receipt by the person is unverified"
|
||||
if event.dead_lettered_at: return "Delivery stopped after repeated failures"
|
||||
if event.failed_at: return "Delivery failed; retry pending"
|
||||
if event.claimed_by: return "Being processed"
|
||||
return "Queued for delivery"
|
||||
|
||||
def _operations_page(self, actor: Any, csrf: str, event_id: str, correlation_id: str) -> str:
|
||||
self.service.tenant_diagnostics(actor, tenant=PLATFORM_TENANT, correlation_id=correlation_id)
|
||||
events = list(self.service.store.outbox_history())[-100:]
|
||||
if event_id:
|
||||
event = self.service.store.outbox_event(event_id)
|
||||
if event is None: raise NotFoundError("delivery record not found")
|
||||
events = [event]
|
||||
rows = ""
|
||||
for event in events:
|
||||
action = ""
|
||||
if event.delivered_at is None and not event.claimed_by and (event.failed_at or event.dead_lettered_at):
|
||||
action = f'<form method="post" action="/platform/operations/replay"><input type="hidden" name="csrf_token" value="{escape(csrf)}"><input type="hidden" name="event_id" value="{escape(event.event_id)}"><button type="submit">Queue a retry</button></form>'
|
||||
rows += f'<tr><td>{escape(event.event_id)}</td><td>{escape(event.tenant)}</td><td>{escape(event.event_type)}</td><td>{escape(self._delivery_status(event))}</td><td>{escape(event.correlation_id)}</td><td>{action}</td></tr>'
|
||||
return self._page_html("Service recovery", '<h1>Service recovery</h1><p>This view shows local delivery records. Live sign-in, email receipt and authenticator health are not verified here.</p>'
|
||||
'<form method="get" action="/platform/operations"><label>Delivery record ID <input name="event_id"></label><button type="submit">Find delivery</button></form>'
|
||||
'<table><thead><tr><th>Delivery</th><th>Tenant</th><th>Kind</th><th>Status</th><th>Support reference</th><th>Recovery</th></tr></thead><tbody>'
|
||||
+ (rows or '<tr><td colspan="6">No delivery records. This does not prove mail was received.</td></tr>')
|
||||
+ '</tbody></table><p>Queued retries are processed by the delivery worker. Check the record again for the result.</p><p><a href="/platform">Return to platform administration</a></p>')
|
||||
|
||||
def _require_setup_access(self, tenant: str, user_id: str) -> None:
|
||||
account = self.service.store.tenant_account(tenant, user_id)
|
||||
if account is not None and account.status in {AccountStatus.SUSPENDED, AccountStatus.DISABLED}:
|
||||
raise ConflictError("Reactivate this tenant account before creating a password setup link.")
|
||||
|
||||
def _require_tenant_admin(self, actor: Any, tenant: str) -> None:
|
||||
self.service.resolve_tenant_context(actor, tenant)
|
||||
if not {"tenant-admin", "platform-operator"}.intersection(actor.roles):
|
||||
raise AuthorizationDenied("tenant administrator role required")
|
||||
|
||||
def _require_invitation_tenant(self, invitation_id: str, tenant: str) -> None:
|
||||
invitation = self.service.store.family_invitation(invitation_id)
|
||||
if invitation is None or invitation.tenant != tenant:
|
||||
raise NotFoundError("invitation not found in this tenant")
|
||||
|
||||
def _claims(self, environ: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if self.oidc_client is not None:
|
||||
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
|
||||
|
|
@ -1698,18 +1834,28 @@ Use the login name they provide; it may differ from your display name.</p></sect
|
|||
) -> str:
|
||||
rows = "".join(
|
||||
self._admin_row(tenant, item, platform_operator, csrf_token)
|
||||
for item in memberships
|
||||
for item in memberships if item.scope_type == "tenant" and item.scope_id == tenant
|
||||
) or '<tr><td colspan="6">No members yet.</td></tr>'
|
||||
invitation_rows = "".join(
|
||||
self._invitation_admin_row(tenant, item, csrf_token)
|
||||
for item in invitations
|
||||
) or '<tr><td colspan="5">No invitations yet.</td></tr>'
|
||||
progress_items = []
|
||||
for journey in self.service.store.onboarding_journeys_for_tenant(tenant):
|
||||
user = self.service.store.user(journey.user_id)
|
||||
name = user.display_name or user.user_id if user else "Account"
|
||||
pending = [step for step in journey.steps if step.status.value not in {"completed", "skipped", "cancelled"}]
|
||||
steps = "; ".join(step.title + " — " + step.status.value.replace("_", " ") for step in pending)
|
||||
progress_items.append(f'<li>{escape(name)}: {escape(journey.status.value.replace("_", " "))}. {escape(steps)}'
|
||||
'<p>Ask the person to open My account for profile steps, or use sign-in help for provider steps.</p></li>')
|
||||
progress = "".join(progress_items) or "<li>No additional onboarding journeys are recorded.</li>"
|
||||
diagnostic_items = "".join(
|
||||
f"<li>{escape(item.replace('_', ' '))}</li>" for item in diagnostics.issues
|
||||
) or "<li>No lifecycle gaps detected.</li>"
|
||||
return self._page_html(
|
||||
f"{tenant} users",
|
||||
f"""<h1>{escape(tenant)} users</h1>
|
||||
<p><a href="/admin/{escape(tenant)}/activity">Account activity and support references</a> · <a href="/security">Sign-in recovery help</a></p>
|
||||
<section><h2>Add a user</h2>
|
||||
<form method="post" action="/admin/{escape(tenant)}/users">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
|
|
@ -1725,7 +1871,8 @@ Use the login name they provide; it may differ from your display name.</p></sect
|
|||
<label>Role <select name="role"><option value="user">User</option><option value="tenant-admin">Tenant administrator</option></select></label>
|
||||
<button type="submit">Send invitation</button></form></section>
|
||||
<section><h2>Invitations</h2><table><thead><tr><th>Email</th><th>Role</th><th>Status</th><th>Expires</th><th>Action</th></tr></thead><tbody>{invitation_rows}</tbody></table></section>
|
||||
<section aria-labelledby="lifecycle-gaps"><h2 id="lifecycle-gaps">Lifecycle diagnostics</h2><p>Diagnostics contain machine-readable gap categories only; credentials and factor evidence are never displayed.</p><ul>{diagnostic_items}</ul></section>
|
||||
<section aria-labelledby="lifecycle-gaps"><h2 id="lifecycle-gaps">Lifecycle diagnostics</h2><p>Check the account and invitation states below. Password and authenticator status are not available here.</p><ul>{diagnostic_items}</ul></section>
|
||||
<section><h2>Onboarding follow-up</h2><ul>{progress}</ul><p>These are recorded workflow states. Missing password or authenticator evidence is not proof of completion.</p></section>
|
||||
<section><h2>Members</h2><table><thead><tr><th>User</th><th>Email</th><th>Role</th><th>Status</th><th>Directory</th><th>Action</th></tr></thead><tbody>{rows}</tbody></table></section>""",
|
||||
)
|
||||
|
||||
|
|
@ -1737,51 +1884,39 @@ Use the login name they provide; it may differ from your display name.</p></sect
|
|||
<form method="post" action="/admin/{escape(tenant)}/invitations/{escape(invitation.invitation_id)}/expire">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}"><input type="hidden" name="version" value="{invitation.version}"><button type="submit">Expire</button></form>"""
|
||||
expires = invitation.expires_at.isoformat() if invitation.expires_at else "—"
|
||||
events = [e for e in self.service.store.outbox_history() if e.tenant == tenant
|
||||
and e.aggregate_id == invitation.invitation_id and e.event_type in {"family_invitation.created", "family_invitation.resent", "family_member.invited"}]
|
||||
delivery = self._delivery_status(events[-1]) if events else "Delivery status unavailable"
|
||||
return (
|
||||
f"<tr><td>{escape(invitation.primary_email)}</td><td>{escape(invitation.role)}</td>"
|
||||
f"<td>{escape(invitation.status.value)}</td><td>{escape(expires)}</td><td>{actions}</td></tr>"
|
||||
f"<td>{escape(invitation.status.value)}<p>{escape(delivery)}</p></td><td>{escape(expires)}</td><td>{actions}</td></tr>"
|
||||
)
|
||||
|
||||
def _admin_row(
|
||||
self, tenant: str, membership: Any,
|
||||
platform_operator: bool, csrf_token: str,
|
||||
) -> str:
|
||||
def _admin_row(self, tenant: str, membership: Any, platform_operator: bool, csrf_token: str) -> str:
|
||||
user = self.service.store.user(membership.user_id)
|
||||
identities = self.service.store.identities_for_user(membership.user_id)
|
||||
directory = next(
|
||||
(item for item in identities if item.provider == "netkingdom-lldap"),
|
||||
None,
|
||||
)
|
||||
tenant_account = self.service.store.tenant_account(tenant, membership.user_id)
|
||||
status = tenant_account.status if tenant_account else AccountStatus.INVITED
|
||||
action = (
|
||||
f"""<span>Linked as {escape(directory.subject)}</span>
|
||||
<form method="post" action="/admin/{escape(tenant)}/users/{escape(membership.user_id)}/provision">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
<button type="submit">Create password setup link</button></form>
|
||||
<form method="post" action="/admin/{escape(tenant)}/users/{escape(membership.user_id)}/status">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
<input type="hidden" name="status" value="{'active' if status == AccountStatus.SUSPENDED else 'suspended'}">
|
||||
<button type="submit">{'Reactivate' if status == AccountStatus.SUSPENDED else 'Suspend'}</button></form>"""
|
||||
if directory
|
||||
else f"""<form method="post" action="/admin/{escape(tenant)}/users/{escape(membership.user_id)}/provision">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
<button type="submit">Create login</button></form>"""
|
||||
)
|
||||
action += f"""<form method="post" action="/admin/{escape(tenant)}/users/{escape(membership.user_id)}/remove">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
<button type="submit">Remove account</button></form>"""
|
||||
if platform_operator:
|
||||
action += f"""<form method="post" action="/admin/{escape(tenant)}/users/{escape(membership.user_id)}/recover">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
<button type="submit">Recover identity</button></form>"""
|
||||
return (
|
||||
f"<tr><td>{escape(user.display_name or membership.user_id) if user else escape(membership.user_id)}</td>"
|
||||
f"<td>{escape(user.primary_email or '') if user else ''}</td>"
|
||||
f"<td>{escape(membership.kind)}</td>"
|
||||
f"<td>{escape(status.value)}</td>"
|
||||
f"<td>{'linked' if directory else 'pending'}</td><td>{action}</td></tr>"
|
||||
)
|
||||
directory = next((i for i in self.service.store.identities_for_user(membership.user_id)
|
||||
if i.provider == "netkingdom-lldap"), None)
|
||||
account = self.service.store.tenant_account(tenant, membership.user_id)
|
||||
status = account.status if account else AccountStatus.INVITED
|
||||
inactive = status in {AccountStatus.SUSPENDED, AccountStatus.DISABLED}
|
||||
root = f"/admin/{quote(tenant, safe='')}/users/{quote(membership.user_id, safe='')}"
|
||||
def form(action: str, label: str, **fields: str) -> str:
|
||||
hidden = "".join(f'<input type="hidden" name="{escape(k)}" value="{escape(v)}">' for k,v in fields.items())
|
||||
return f'<form method="post" action="{root}/{action}"><input type="hidden" name="csrf_token" value="{escape(csrf_token)}">{hidden}<button type="submit">{label}</button></form>'
|
||||
login = (f'<p>Login name: <strong>{escape(self._directory_login(directory.subject))}</strong></p>'
|
||||
'<p>Password and authenticator status are not available here.</p>') if directory else '<p>Login not created. Prepare a login before asking this person to sign in.</p>'
|
||||
actions = ""
|
||||
if not inactive:
|
||||
actions += form("provision", "Create password setup link" if directory else "Create login")
|
||||
actions += form("status", "Reactivate" if inactive else "Suspend", status="active" if inactive else "suspended")
|
||||
if status != AccountStatus.DISABLED:
|
||||
actions += form("remove", "Remove account")
|
||||
next_role = "user" if membership.kind == "tenant-admin" else "tenant-admin"
|
||||
actions += form("role", "Make user" if next_role == "user" else "Make tenant administrator", role=next_role)
|
||||
if platform_operator: actions += form("recover", "Recover identity")
|
||||
return (f'<tr><td>{escape(user.display_name or membership.user_id) if user else escape(membership.user_id)}</td>'
|
||||
f'<td>{escape(user.primary_email or "") if user else ""}</td><td>{escape(membership.kind)}</td>'
|
||||
f'<td>{escape(status.value)} for this tenant</td><td>{login}</td><td>{actions}</td></tr>')
|
||||
|
||||
def _invitation_acceptance(self, invitation: Any, csrf_token: str) -> str:
|
||||
return self._page_html(
|
||||
|
|
@ -1948,13 +2083,19 @@ Use the login name they provide; it may differ from your display name.</p></sect
|
|||
f"<li><span>{escape(step.title)} — {escape(step.status.value)}</span>{gap}{action}</li>"
|
||||
)
|
||||
|
||||
def _password_setup_handoff(self, setup_url: str, tenant: str) -> str:
|
||||
@staticmethod
|
||||
def _directory_login(subject: str) -> str:
|
||||
match = re.fullmatch(r"uid=([A-Za-z0-9._-]+),ou=people,dc=netkingdom,dc=local", subject)
|
||||
return match.group(1) if match else subject
|
||||
|
||||
def _password_setup_handoff(self, setup_url: str, tenant: str, subject: str = "") -> str:
|
||||
if not setup_url.startswith("https://"):
|
||||
raise ValidationError("password setup handoff must use HTTPS")
|
||||
return self._page_html(
|
||||
"Password setup",
|
||||
"<h1>Login identity created</h1>"
|
||||
"<p>The password is handled only by the NetKingdom identity "
|
||||
"<h1>Login ready for password setup</h1>"
|
||||
+ f"<p>Login name: <strong>{escape(self._directory_login(subject))}</strong>. Use this name when signing in; it may differ from the display name.</p>"
|
||||
+ "<p>The password is handled only by the NetKingdom identity "
|
||||
"surface. This short-lived link is single use.</p>"
|
||||
f'<p><a class="button" rel="noreferrer" href="{escape(setup_url)}">'
|
||||
"Continue to password setup</a></p>"
|
||||
|
|
@ -1972,7 +2113,7 @@ Use the login name they provide; it may differ from your display name.</p></sect
|
|||
return
|
||||
links = '<a href="/">Home</a><a href="/onboarding">My account</a><a href="/security">Sign-in security</a>'
|
||||
if "platform-operator" in actor.roles:
|
||||
links += '<a href="/platform">Platform administration</a>'
|
||||
links += '<a href="/platform">Platform administration</a><a href="/platform/operations">Service recovery</a>'
|
||||
elif "tenant-admin" in actor.roles:
|
||||
links += f'<a href="/admin/{escape(quote(actor.tenant, safe=""))}">Manage users</a>'
|
||||
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
|
||||
|
|
@ -2001,10 +2142,10 @@ a:focus-visible,input:focus-visible,select:focus-visible,button:focus-visible{{o
|
|||
|
||||
def _html(
|
||||
self, start_response: StartResponse, body: str, correlation_id: str,
|
||||
*, extra_headers: list[tuple[str, str]] | None = None,
|
||||
*, extra_headers: list[tuple[str, str]] | None = None, status: str = "200 OK",
|
||||
) -> list[bytes]:
|
||||
data = body.encode()
|
||||
start_response("200 OK", [
|
||||
start_response(status, [
|
||||
("Content-Type", "text/html; charset=utf-8"),
|
||||
("Content-Length", str(len(data))),
|
||||
*(extra_headers or []),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue