Expand portal onboarding and administration
This commit is contained in:
parent
3d334479e9
commit
8229c6dd33
17 changed files with 1601 additions and 32 deletions
|
|
@ -17,11 +17,11 @@ import secrets
|
|||
from typing import Any, Callable, Iterable, Mapping
|
||||
from urllib.parse import parse_qs, urlencode
|
||||
|
||||
from user_engine.domain import AccountStatus
|
||||
from user_engine.domain import AccountStatus, FamilyMemberSpec
|
||||
from user_engine.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError
|
||||
from user_engine.oidc import OIDCClient, cookie_value
|
||||
from user_engine.ports import IdentityProvisioningPort, ProvisioningRequest
|
||||
from user_engine.service import UserEngineService
|
||||
from user_engine.ports import IdentityProvisioningPort, ProvisioningRequest, TenantManagementPort
|
||||
from user_engine.service import PLATFORM_TENANT, UserEngineService
|
||||
|
||||
StartResponse = Callable[[str, list[tuple[str, str]]], Any]
|
||||
|
||||
|
|
@ -52,6 +52,8 @@ class PortalApplication:
|
|||
public_registration: bool = True,
|
||||
oidc_client: OIDCClient | None = None,
|
||||
provisioning: IdentityProvisioningPort | None = None,
|
||||
tenant_management: TenantManagementPort | None = None,
|
||||
outbox_delivery: Callable[[Any], None] | None = None,
|
||||
) -> None:
|
||||
if len(trusted_proxy_secret) < 24:
|
||||
raise ValueError("trusted proxy secret must contain at least 24 characters")
|
||||
|
|
@ -61,12 +63,16 @@ class PortalApplication:
|
|||
self.public_registration = public_registration
|
||||
self.oidc_client = oidc_client
|
||||
self.provisioning = provisioning
|
||||
self.tenant_management = tenant_management
|
||||
self.outbox_delivery = outbox_delivery
|
||||
|
||||
def __call__(self, environ: Mapping[str, Any], start_response: StartResponse) -> Iterable[bytes]:
|
||||
correlation_id = environ.get("HTTP_X_REQUEST_ID") or f"corr_{secrets.token_hex(12)}"
|
||||
try:
|
||||
return self._dispatch(environ, start_response, str(correlation_id))
|
||||
except (ValidationError, ConflictError, ValueError) as exc:
|
||||
except ConflictError as exc:
|
||||
return self._error(start_response, "409 Conflict", "conflict", str(exc), correlation_id)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
return self._error(start_response, "400 Bad Request", "invalid_request", str(exc), correlation_id)
|
||||
except RuntimeError:
|
||||
return self._error(
|
||||
|
|
@ -97,7 +103,14 @@ class PortalApplication:
|
|||
raise AuthorizationDenied("metrics require the trusted workload marker")
|
||||
return self._metrics(start_response, correlation_id)
|
||||
if path in {"/login", "/oidc/start"}:
|
||||
location = self.oidc_client.begin() if self.oidc_client else self.login_url
|
||||
query = parse_qs(str(environ.get("QUERY_STRING", "")))
|
||||
tenant_hint = query.get("tenant_hint", [None])[0]
|
||||
if tenant_hint is not None and not str(tenant_hint).startswith("tenant:"):
|
||||
raise ValidationError("tenant_hint must be a tenant identifier")
|
||||
location = (
|
||||
self.oidc_client.begin(tenant_hint=str(tenant_hint) if tenant_hint else None)
|
||||
if self.oidc_client else self.login_url
|
||||
)
|
||||
start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
|
||||
return [b""]
|
||||
if path == "/oidc/callback":
|
||||
|
|
@ -133,6 +146,196 @@ class PortalApplication:
|
|||
actor = self._actor(environ)
|
||||
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":
|
||||
self._idempotency_key(environ)
|
||||
self.service.me(self._claims(environ), correlation_id=correlation_id)
|
||||
body = self._body(environ)
|
||||
updated = self.service.update_self_service_profile(
|
||||
actor, display_name=str(body.get("display_name", "")),
|
||||
consent_accepted=bool(body.get("consent_accepted", False)),
|
||||
consent_version=str(body.get("consent_version") or "portal-terms-v1"),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return self._json(start_response, "200 OK", _jsonable(updated), correlation_id)
|
||||
if path == "/onboarding" and method == "GET":
|
||||
session = self.service.me(self._claims(environ), correlation_id=correlation_id)
|
||||
memberships = self.service.store.memberships_for_user(session.user.user_id)
|
||||
journeys = self.service.store.onboarding_journeys_for_user(session.user.user_id)
|
||||
query = parse_qs(str(environ.get("QUERY_STRING", "")))
|
||||
selected_tenant = query.get("tenant", [session.actor.tenant])[0]
|
||||
allowed_tenants = {item.tenant for item in memberships} | {session.actor.tenant}
|
||||
if selected_tenant not in allowed_tenants:
|
||||
raise AuthorizationDenied("tenant selection is not a membership")
|
||||
return self._html(
|
||||
start_response,
|
||||
self._onboarding(
|
||||
session, memberships, journeys, str(selected_tenant),
|
||||
self._csrf_token(environ),
|
||||
),
|
||||
correlation_id,
|
||||
)
|
||||
if path == "/onboarding/profile" and method == "POST":
|
||||
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)
|
||||
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", "")))
|
||||
parts = path.split("/")
|
||||
journey_id, step_key = parts[2], parts[4]
|
||||
session = self.service.me(self._claims(environ), correlation_id=correlation_id)
|
||||
journey = self.service.store.onboarding_journey(journey_id)
|
||||
if journey is None or journey.user_id != session.user.user_id:
|
||||
raise NotFoundError("onboarding journey not found")
|
||||
step = next((item for item in journey.steps if item.step_key == step_key), None)
|
||||
if step is None:
|
||||
raise NotFoundError("onboarding step not found")
|
||||
if step.subsystem != "user-engine" or step.handoff is not None:
|
||||
raise AuthorizationDenied("subsystem-owned steps require their handoff")
|
||||
self.service.complete_onboarding_step(
|
||||
actor, journey_id, step_key, correlation_id=correlation_id
|
||||
)
|
||||
return self._redirect(start_response, "/onboarding", correlation_id)
|
||||
if path.startswith("/invitations/") and method == "GET":
|
||||
invitation = self.service.store.family_invitation(path.split("/")[2])
|
||||
if invitation is None:
|
||||
raise NotFoundError("invitation not found")
|
||||
self.service.resolve_tenant_context(actor, invitation.tenant)
|
||||
return self._html(
|
||||
start_response,
|
||||
self._invitation_acceptance(invitation, self._csrf_token(environ)),
|
||||
correlation_id,
|
||||
)
|
||||
if path.startswith("/invitations/") and method == "POST":
|
||||
body = self._form_body(environ)
|
||||
self._require_csrf(environ, str(body.get("csrf_token", "")))
|
||||
self.service.accept_family_invitation(
|
||||
self._claims(environ), path.split("/")[2], correlation_id=correlation_id
|
||||
)
|
||||
return self._redirect(start_response, "/onboarding", correlation_id)
|
||||
if path == "/api/v1/platform/tenants" and method == "POST":
|
||||
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
|
||||
if self.tenant_management is None:
|
||||
raise ValidationError("tenant management is unavailable")
|
||||
idempotency_key = self._idempotency_key(environ)
|
||||
body = self._body(environ)
|
||||
tenant = str(body.get("tenant") or "")
|
||||
if not tenant.startswith("tenant:") or tenant == PLATFORM_TENANT:
|
||||
raise ValidationError("a non-platform tenant identifier is required")
|
||||
result = self.tenant_management.create_tenant(
|
||||
tenant=tenant,
|
||||
display_name=str(body.get("display_name") or tenant),
|
||||
idempotency_key=idempotency_key,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
admin = body.get("first_admin")
|
||||
bootstrap = None
|
||||
if admin is not None:
|
||||
if not isinstance(admin, Mapping):
|
||||
raise ValidationError("first_admin must be an object")
|
||||
user = self.service.create_user(
|
||||
actor, display_name=admin.get("display_name"),
|
||||
primary_email=admin.get("primary_email"),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
account = self.service.set_tenant_account_status(
|
||||
actor, user.user_id, AccountStatus.INVITED,
|
||||
tenant=tenant, correlation_id=correlation_id,
|
||||
)
|
||||
membership = self.service.add_membership(
|
||||
actor, user.user_id, tenant=tenant, scope_type="tenant",
|
||||
scope_id=tenant, kind="tenant-admin",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
bootstrap = {"user": user, "tenant_account": account, "membership": membership}
|
||||
return self._json(start_response, "201 Created", {
|
||||
"tenant": _jsonable(result), "first_admin": _jsonable(bootstrap),
|
||||
}, correlation_id)
|
||||
if path == "/api/v1/platform/outbox/deliver" and method == "POST":
|
||||
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
|
||||
if self.outbox_delivery is None:
|
||||
raise ValidationError("outbox delivery is unavailable")
|
||||
body = self._body(environ)
|
||||
events = self.service.deliver_outbox(
|
||||
actor, self.outbox_delivery,
|
||||
worker_id=str(body.get("worker_id") or "portal-operator"),
|
||||
max_attempts=int(body.get("max_attempts") or 3),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return self._json(start_response, "200 OK", {"items": _jsonable(events)}, correlation_id)
|
||||
if path.startswith("/api/v1/platform/outbox/") and path.endswith("/replay") and method == "POST":
|
||||
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
|
||||
event = self.service.replay_outbox(
|
||||
actor, path.split("/")[5], correlation_id=correlation_id
|
||||
)
|
||||
return self._json(start_response, "200 OK", _jsonable(event), correlation_id)
|
||||
if path.startswith("/api/v1/platform/tenants/") and path.endswith("/recover") and method == "POST":
|
||||
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
|
||||
if self.provisioning is None:
|
||||
raise ValidationError("identity provisioning is unavailable")
|
||||
parts = path.split("/")
|
||||
tenant, user_id = parts[5], parts[7]
|
||||
idempotency_key = self._idempotency_key(environ)
|
||||
user = self.service.store.user(user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("user not found")
|
||||
request = ProvisioningRequest(
|
||||
user_id=user_id, tenant=tenant, primary_email=user.primary_email,
|
||||
display_name=user.display_name, idempotency_key=idempotency_key,
|
||||
correlation_id=correlation_id,
|
||||
roles=tuple(item.kind for item in self.service.store.memberships_for_user(user_id, tenant=tenant)),
|
||||
)
|
||||
identity = next(iter(self.service.store.identities_for_user(user_id)), None)
|
||||
if identity is None:
|
||||
provisioned = self.provisioning.provision(request)
|
||||
self.service.link_identity(
|
||||
actor, user_id, issuer="urn:netkingdom:directory",
|
||||
subject=provisioned.external_subject, provider=provisioned.provider,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
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}
|
||||
account = self.service.set_tenant_account_status(
|
||||
actor, user_id, AccountStatus.ACTIVE, tenant=tenant,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return self._json(start_response, "200 OK", {
|
||||
"recovery": _jsonable(recovery), "tenant_account": _jsonable(account),
|
||||
}, correlation_id)
|
||||
if path.startswith("/api/v1/invitations/") and path.endswith("/claim") and method == "POST":
|
||||
invitation_id = path.split("/")[4]
|
||||
accepted = self.service.accept_family_invitation(
|
||||
self._claims(environ), invitation_id, correlation_id=correlation_id
|
||||
)
|
||||
return self._json(start_response, "200 OK", _jsonable(accepted), correlation_id)
|
||||
if path.startswith("/api/v1/onboarding/") and "/steps/" in path and path.endswith("/complete") and method == "POST":
|
||||
self._idempotency_key(environ)
|
||||
parts = path.split("/")
|
||||
journey_id, step_key = parts[4], parts[6]
|
||||
session = self.service.me(self._claims(environ), correlation_id=correlation_id)
|
||||
journey = self.service.store.onboarding_journey(journey_id)
|
||||
if journey is None or journey.user_id != session.user.user_id:
|
||||
raise NotFoundError("onboarding journey not found")
|
||||
step = next((item for item in journey.steps if item.step_key == step_key), None)
|
||||
if step is None:
|
||||
raise NotFoundError("onboarding step not found")
|
||||
if step.subsystem != "user-engine" or step.handoff is not None:
|
||||
raise AuthorizationDenied("subsystem-owned steps require their handoff")
|
||||
updated = self.service.complete_onboarding_step(
|
||||
actor, journey_id, step_key, correlation_id=correlation_id
|
||||
)
|
||||
return self._json(start_response, "200 OK", _jsonable(updated), correlation_id)
|
||||
if path == "/api/v1/registrations" and method == "POST":
|
||||
if not self.public_registration:
|
||||
raise AuthorizationDenied("public registration disabled")
|
||||
|
|
@ -198,6 +401,45 @@ class PortalApplication:
|
|||
"membership": _jsonable(membership),
|
||||
"provisioning_status": "pending",
|
||||
}, correlation_id)
|
||||
if path.startswith("/api/v1/tenants/") and path.endswith("/invitations"):
|
||||
tenant = path.split("/")[4]
|
||||
self.service.resolve_tenant_context(actor, tenant)
|
||||
if method == "GET":
|
||||
items = self.service.store.family_invitations_for_tenant(tenant)
|
||||
return self._json(start_response, "200 OK", {"items": _jsonable(items)}, correlation_id)
|
||||
if method == "POST":
|
||||
body = self._body(environ)
|
||||
invited = self.service.invite_family_member(
|
||||
actor,
|
||||
tenant=tenant,
|
||||
family_scope_id=str(body.get("scope_id") or tenant),
|
||||
application_id=str(body.get("application_id") or "app.user-portal"),
|
||||
member=FamilyMemberSpec(
|
||||
primary_email=str(body.get("primary_email") or ""),
|
||||
display_name=body.get("display_name"),
|
||||
role=str(body.get("role") or "user"),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return self._json(start_response, "201 Created", _jsonable(invited), correlation_id)
|
||||
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)
|
||||
expected = self._expected_version(environ)
|
||||
if action == "resend":
|
||||
value = self.service.resend_family_invitation(
|
||||
actor, invitation_id, correlation_id=correlation_id,
|
||||
expected_version=expected,
|
||||
)
|
||||
elif action == "expire":
|
||||
value = self.service.revoke_family_invitation(
|
||||
actor, invitation_id, correlation_id=correlation_id,
|
||||
expected_version=expected,
|
||||
)
|
||||
else:
|
||||
raise NotFoundError("invitation action not found")
|
||||
return self._json(start_response, "200 OK", _jsonable(value), correlation_id)
|
||||
if path.startswith("/api/v1/tenants/") and path.endswith("/provision") and method == "POST":
|
||||
if self.provisioning is None:
|
||||
raise ValidationError("identity provisioning is unavailable")
|
||||
|
|
@ -250,13 +492,80 @@ class PortalApplication:
|
|||
correlation_id=correlation_id,
|
||||
)
|
||||
return self._json(start_response, "200 OK", _jsonable(result), correlation_id)
|
||||
if path.startswith("/api/v1/tenants/") and "/users/" in path and method == "DELETE":
|
||||
if self.provisioning is None:
|
||||
raise ValidationError("identity provisioning is unavailable")
|
||||
parts = path.split("/")
|
||||
tenant, user_id = parts[4], parts[6]
|
||||
self.service.resolve_tenant_context(actor, tenant)
|
||||
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,
|
||||
)
|
||||
return self._json(start_response, "200 OK", {
|
||||
"status": "removed", "tenant_account": _jsonable(account),
|
||||
"provider_identity_removed": identity is not None,
|
||||
}, correlation_id)
|
||||
if path == "/platform" and method == "GET":
|
||||
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
|
||||
return self._html(
|
||||
start_response, self._platform(self._csrf_token(environ)), correlation_id
|
||||
)
|
||||
if path == "/platform/tenants" and method == "POST":
|
||||
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
|
||||
if self.tenant_management is None:
|
||||
raise ValidationError("tenant management is unavailable")
|
||||
body = self._form_body(environ)
|
||||
self._require_csrf(environ, str(body.get("csrf_token", "")))
|
||||
tenant = str(body.get("tenant", ""))
|
||||
if not tenant.startswith("tenant:") or tenant == PLATFORM_TENANT:
|
||||
raise ValidationError("a non-platform tenant identifier is required")
|
||||
result = self.tenant_management.create_tenant(
|
||||
tenant=tenant, display_name=str(body.get("display_name") or tenant),
|
||||
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,
|
||||
)
|
||||
return self._html(
|
||||
start_response,
|
||||
self._platform_result(result, tenant, bool(email)), correlation_id,
|
||||
)
|
||||
if path.startswith("/admin/") and method == "GET":
|
||||
tenant = path.split("/")[2]
|
||||
self.service.resolve_tenant_context(actor, tenant)
|
||||
memberships = self.service.store.memberships_for_tenant(tenant)
|
||||
invitations = self.service.store.family_invitations_for_tenant(tenant)
|
||||
diagnostics = self.service.tenant_diagnostics(
|
||||
actor, tenant=tenant, correlation_id=correlation_id
|
||||
)
|
||||
return self._html(
|
||||
start_response,
|
||||
self._admin(tenant, memberships, self._csrf_token(environ)),
|
||||
self._admin(
|
||||
tenant, memberships, invitations, diagnostics,
|
||||
"platform-operator" in actor.roles, self._csrf_token(environ),
|
||||
),
|
||||
correlation_id,
|
||||
)
|
||||
if path.startswith("/admin/") and method == "POST":
|
||||
|
|
@ -282,6 +591,33 @@ class PortalApplication:
|
|||
correlation_id=correlation_id,
|
||||
)
|
||||
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
|
||||
if len(parts) == 4 and parts[3] == "invitations":
|
||||
self.service.invite_family_member(
|
||||
actor, tenant=tenant, family_scope_id=tenant,
|
||||
application_id="app.user-portal",
|
||||
member=FamilyMemberSpec(
|
||||
primary_email=str(body.get("primary_email", "")),
|
||||
display_name=body.get("display_name"),
|
||||
role=str(body.get("role", "user")),
|
||||
), correlation_id=correlation_id,
|
||||
)
|
||||
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]
|
||||
version = int(body.get("version", "0"))
|
||||
if action == "resend":
|
||||
self.service.resend_family_invitation(
|
||||
actor, invitation_id, expected_version=version,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
elif action == "expire":
|
||||
self.service.revoke_family_invitation(
|
||||
actor, invitation_id, expected_version=version,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
else:
|
||||
raise NotFoundError("invitation action not found")
|
||||
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
|
||||
if len(parts) == 6 and parts[3] == "users" and parts[5] == "provision":
|
||||
if self.provisioning is None:
|
||||
raise ValidationError("identity provisioning is unavailable")
|
||||
|
|
@ -327,6 +663,54 @@ class PortalApplication:
|
|||
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] == "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,
|
||||
)
|
||||
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)
|
||||
if self.provisioning is None:
|
||||
raise ValidationError("identity provisioning is unavailable")
|
||||
user_id = parts[4]
|
||||
user = self.service.store.user(user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("user not found")
|
||||
request = ProvisioningRequest(
|
||||
user_id=user_id, tenant=tenant, primary_email=user.primary_email,
|
||||
display_name=user.display_name,
|
||||
idempotency_key=f"portal-recover-{tenant}-{user_id}",
|
||||
correlation_id=correlation_id,
|
||||
roles=tuple(item.kind for item in self.service.store.memberships_for_user(user_id, tenant=tenant)),
|
||||
)
|
||||
identity = next(iter(self.service.store.identities_for_user(user_id)), None)
|
||||
if identity is None:
|
||||
result = self.provisioning.provision(request)
|
||||
self.service.link_identity(
|
||||
actor, user_id, issuer="urn:netkingdom:directory",
|
||||
subject=result.external_subject, provider=result.provider,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
else:
|
||||
self.provisioning.reconcile(
|
||||
request, external_subject=identity.subject, desired_status="active"
|
||||
)
|
||||
self.service.set_tenant_account_status(
|
||||
actor, user_id, AccountStatus.ACTIVE,
|
||||
tenant=tenant, correlation_id=correlation_id,
|
||||
)
|
||||
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(
|
||||
|
|
@ -435,9 +819,24 @@ class PortalApplication:
|
|||
limit = max(1, min(100, int(query.get("limit", ["25"])[0])))
|
||||
return offset, limit
|
||||
|
||||
@staticmethod
|
||||
def _expected_version(environ: Mapping[str, Any]) -> int:
|
||||
value = str(environ.get("HTTP_IF_MATCH", "")).strip().strip('"')
|
||||
if not value.isdigit():
|
||||
raise ValidationError("If-Match invitation version is required")
|
||||
return int(value)
|
||||
|
||||
@staticmethod
|
||||
def _idempotency_key(environ: Mapping[str, Any]) -> str:
|
||||
value = str(environ.get("HTTP_IDEMPOTENCY_KEY", ""))
|
||||
if len(value) < 16:
|
||||
raise ValidationError("Idempotency-Key must contain at least 16 characters")
|
||||
return value
|
||||
|
||||
def _home(self, actor: Any | None) -> str:
|
||||
identity = (
|
||||
f"<p>Signed in as <strong>{escape(actor.preferred_username)}</strong>.</p>"
|
||||
'<p><a class="button" href="/onboarding">Continue onboarding</a></p>'
|
||||
if actor is not None
|
||||
else f'<p><a class="button" href="/login">Sign in with KeyCape</a></p>'
|
||||
)
|
||||
|
|
@ -448,11 +847,22 @@ class PortalApplication:
|
|||
+ identity,
|
||||
)
|
||||
|
||||
def _admin(self, tenant: str, memberships: tuple[Any, ...], csrf_token: str) -> str:
|
||||
def _admin(
|
||||
self, tenant: str, memberships: tuple[Any, ...],
|
||||
invitations: tuple[Any, ...], diagnostics: Any,
|
||||
platform_operator: bool, csrf_token: str,
|
||||
) -> str:
|
||||
rows = "".join(
|
||||
self._admin_row(tenant, item, csrf_token)
|
||||
self._admin_row(tenant, item, platform_operator, csrf_token)
|
||||
for item in memberships
|
||||
) 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>'
|
||||
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>
|
||||
|
|
@ -463,10 +873,35 @@ class PortalApplication:
|
|||
<label>Email <input name="primary_email" type="email" required autocomplete="email"></label>
|
||||
<label>Role <select name="role"><option value="user">User</option><option value="tenant-admin">Tenant administrator</option></select></label>
|
||||
<button type="submit">Add user</button></form></section>
|
||||
<section><h2>Invite a user</h2>
|
||||
<form method="post" action="/admin/{escape(tenant)}/invitations">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
<label>Display name <input name="display_name" autocomplete="name"></label>
|
||||
<label>Email <input name="primary_email" type="email" required autocomplete="email"></label>
|
||||
<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><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>""",
|
||||
)
|
||||
|
||||
def _admin_row(self, tenant: str, membership: Any, csrf_token: str) -> str:
|
||||
def _invitation_admin_row(self, tenant: str, invitation: Any, csrf_token: str) -> str:
|
||||
actions = ""
|
||||
if invitation.status.value == "pending":
|
||||
actions = f"""<form method="post" action="/admin/{escape(tenant)}/invitations/{escape(invitation.invitation_id)}/resend">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}"><input type="hidden" name="version" value="{invitation.version}"><button type="submit">Resend</button></form>
|
||||
<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 "—"
|
||||
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>"
|
||||
)
|
||||
|
||||
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(
|
||||
|
|
@ -489,6 +924,13 @@ class PortalApplication:
|
|||
<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>"
|
||||
|
|
@ -497,6 +939,98 @@ class PortalApplication:
|
|||
f"<td>{'linked' if directory else 'pending'}</td><td>{action}</td></tr>"
|
||||
)
|
||||
|
||||
def _invitation_acceptance(self, invitation: Any, csrf_token: str) -> str:
|
||||
return self._page_html(
|
||||
"Accept invitation",
|
||||
f"""<h1>Join {escape(invitation.tenant)}</h1>
|
||||
<p>You were invited as <strong>{escape(invitation.role)}</strong>. The invitation expires at {escape(invitation.expires_at.isoformat() if invitation.expires_at else 'the tenant policy deadline')}.</p>
|
||||
<form method="post" action="/invitations/{escape(invitation.invitation_id)}">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
<button type="submit">Accept invitation</button></form>
|
||||
<p>Your password and MFA remain on the identity-provider surface.</p>""",
|
||||
)
|
||||
|
||||
def _platform(self, csrf_token: str) -> str:
|
||||
return self._page_html(
|
||||
"Platform administration",
|
||||
f"""<h1>Platform administration</h1>
|
||||
<section aria-labelledby="create-tenant"><h2 id="create-tenant">Create tenant</h2>
|
||||
<form method="post" action="/platform/tenants">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
<label>Tenant identifier <input name="tenant" required pattern="tenant:.+" placeholder="tenant:friendly:example"></label>
|
||||
<label>Display name <input name="display_name" required></label>
|
||||
<fieldset><legend>First administrator (optional)</legend>
|
||||
<label>Name <input name="admin_display_name" autocomplete="name"></label>
|
||||
<label>Email <input name="admin_email" type="email" autocomplete="email"></label></fieldset>
|
||||
<button type="submit">Create tenant</button></form></section>""",
|
||||
)
|
||||
|
||||
def _platform_result(self, result: Any, tenant: str, admin_prepared: bool) -> str:
|
||||
return self._page_html(
|
||||
"Tenant created",
|
||||
f"""<h1>Tenant {escape(result.status)}</h1>
|
||||
<p><strong>{escape(tenant)}</strong> was processed by the tenant authority.</p>
|
||||
<p>{'The first administrator is prepared and awaiting onboarding.' if admin_prepared else 'No first administrator was requested.'}</p>
|
||||
<p><a class="button" href="/admin/{escape(tenant)}">Open tenant administration</a></p>
|
||||
<p><a href="/platform">Return to platform administration</a></p>""",
|
||||
)
|
||||
|
||||
def _onboarding(
|
||||
self, session: Any, memberships: tuple[Any, ...], journeys: tuple[Any, ...],
|
||||
selected_tenant: str, csrf_token: str,
|
||||
) -> str:
|
||||
membership_items = "".join(
|
||||
f"<li><a href=\"/onboarding?{urlencode({'tenant': item.tenant})}\">{escape(item.tenant)}</a> — {escape(item.kind)}</li>"
|
||||
for item in memberships
|
||||
) or "<li>No tenant memberships yet.</li>"
|
||||
journey_items = "".join(
|
||||
self._onboarding_journey_item(item, csrf_token) for item in journeys
|
||||
) or "<li>No additional onboarding steps are required.</li>"
|
||||
verification = "Verified by your identity provider" if session.actor.assurance else "Verification pending"
|
||||
consent_checked = " checked" if session.user.consented_at else ""
|
||||
return self._page_html(
|
||||
"Onboarding",
|
||||
f"""<h1>Welcome, {escape(session.user.display_name or session.actor.preferred_username or session.user.user_id)}</h1>
|
||||
<section aria-labelledby="verification"><h2 id="verification">Email and sign-in</h2><p>{escape(verification)}</p><p>Passwords and MFA are managed by your identity provider.</p></section>
|
||||
<section aria-labelledby="profile"><h2 id="profile">Profile and consent</h2>
|
||||
<form method="post" action="/onboarding/profile"><input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
<label>Display name <input name="display_name" required maxlength="200" autocomplete="name" value="{escape(session.user.display_name or '')}"></label>
|
||||
<label><input name="consent_accepted" type="checkbox" value="yes"{consent_checked}> I accept portal terms version 1</label>
|
||||
<button type="submit">Save profile</button></form></section>
|
||||
<section aria-labelledby="tenants"><h2 id="tenants">Tenant access</h2><p>Viewing <strong>{escape(selected_tenant)}</strong>.</p><ul>{membership_items}</ul>
|
||||
<p><a href="/login?{urlencode({'tenant_hint': selected_tenant})}">Reauthenticate in this tenant</a> to change the authoritative login context.</p></section>
|
||||
<section aria-labelledby="steps"><h2 id="steps">Onboarding progress</h2><ul>{journey_items}</ul></section>""",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _onboarding_journey_item(journey: Any, csrf_token: str) -> str:
|
||||
steps = "".join(
|
||||
PortalApplication._onboarding_step_item(journey.journey_id, step, csrf_token)
|
||||
for step in journey.steps
|
||||
)
|
||||
return (
|
||||
f"<li><strong>{escape(journey.status.value)}</strong>"
|
||||
f"<ol>{steps}</ol></li>"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _onboarding_step_item(journey_id: str, step: Any, csrf_token: str) -> str:
|
||||
action = ""
|
||||
if (
|
||||
step.status.value == "in_progress"
|
||||
and step.subsystem == "user-engine"
|
||||
and step.handoff is None
|
||||
):
|
||||
action = f"""<form method="post" action="/onboarding/{escape(journey_id)}/steps/{escape(step.step_key)}/complete">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
<button type="submit">Mark complete</button></form>"""
|
||||
elif step.handoff is not None or step.subsystem != "user-engine":
|
||||
action = "<span>Continue on the provider-owned surface; this page will resume after its callback.</span>"
|
||||
gap = f" <span>Support category: {escape(step.lifecycle_gap)}</span>" if step.lifecycle_gap else ""
|
||||
return (
|
||||
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:
|
||||
if not setup_url.startswith("https://"):
|
||||
raise ValidationError("password setup handoff must use HTTPS")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue