Add CSRF-safe tenant identity administration
This commit is contained in:
parent
7b7fa97ce2
commit
e23674d343
4 changed files with 367 additions and 13 deletions
|
|
@ -15,7 +15,7 @@ from html import escape
|
|||
import json
|
||||
import secrets
|
||||
from typing import Any, Callable, Iterable, Mapping
|
||||
from urllib.parse import parse_qs
|
||||
from urllib.parse import parse_qs, urlencode
|
||||
|
||||
from user_engine.domain import AccountStatus
|
||||
from user_engine.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError
|
||||
|
|
@ -68,6 +68,14 @@ class PortalApplication:
|
|||
return self._dispatch(environ, start_response, str(correlation_id))
|
||||
except (ValidationError, ConflictError, ValueError) as exc:
|
||||
return self._error(start_response, "400 Bad Request", "invalid_request", str(exc), correlation_id)
|
||||
except RuntimeError:
|
||||
return self._error(
|
||||
start_response,
|
||||
"502 Bad Gateway",
|
||||
"provisioning_unavailable",
|
||||
"Identity provisioning is temporarily unavailable.",
|
||||
correlation_id,
|
||||
)
|
||||
except AuthorizationDenied:
|
||||
return self._error(start_response, "403 Forbidden", "access_denied", "Access denied.", correlation_id)
|
||||
except NotFoundError:
|
||||
|
|
@ -211,23 +219,143 @@ class PortalApplication:
|
|||
)
|
||||
),
|
||||
))
|
||||
return self._json(start_response, "200 OK", _jsonable(result), correlation_id)
|
||||
identity = self.service.link_identity(
|
||||
actor,
|
||||
user.user_id,
|
||||
issuer="urn:netkingdom:directory",
|
||||
subject=result.external_subject,
|
||||
provider=result.provider,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return self._json(start_response, "200 OK", {
|
||||
"provisioning": _jsonable(result),
|
||||
"identity": _jsonable(identity),
|
||||
}, correlation_id)
|
||||
if path.startswith("/api/v1/tenants/") and "/users/" in path and method == "PATCH":
|
||||
parts = path.split("/")
|
||||
tenant, user_id = parts[4], parts[6]
|
||||
body = self._body(environ)
|
||||
status = AccountStatus(str(body["status"]))
|
||||
result = self.service.set_tenant_account_status(
|
||||
actor, user_id, status, tenant=tenant, correlation_id=correlation_id
|
||||
idempotency_key = str(environ.get("HTTP_IDEMPOTENCY_KEY", ""))
|
||||
if len(idempotency_key) < 16:
|
||||
raise ValidationError("Idempotency-Key must contain at least 16 characters")
|
||||
result = self._change_status(
|
||||
actor, tenant, user_id, status,
|
||||
idempotency_key=idempotency_key,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return self._json(start_response, "200 OK", _jsonable(result), 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)
|
||||
return self._html(start_response, self._admin(tenant, memberships), correlation_id)
|
||||
return self._html(
|
||||
start_response,
|
||||
self._admin(tenant, memberships, self._csrf_token(environ)),
|
||||
correlation_id,
|
||||
)
|
||||
if path.startswith("/admin/") and method == "POST":
|
||||
parts = path.split("/")
|
||||
tenant = parts[2]
|
||||
self.service.resolve_tenant_context(actor, tenant)
|
||||
body = self._form_body(environ)
|
||||
self._require_csrf(environ, str(body.get("csrf_token", "")))
|
||||
if len(parts) == 4 and parts[3] == "users":
|
||||
user = self.service.create_user(
|
||||
actor,
|
||||
display_name=body.get("display_name"),
|
||||
primary_email=body.get("primary_email"),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
self.service.set_tenant_account_status(
|
||||
actor, user.user_id, AccountStatus.ACTIVE,
|
||||
tenant=tenant, correlation_id=correlation_id,
|
||||
)
|
||||
self.service.add_membership(
|
||||
actor, user.user_id, tenant=tenant, scope_type="tenant",
|
||||
scope_id=tenant, kind=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] == "users" and parts[5] == "provision":
|
||||
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")
|
||||
result = self.provisioning.provision(ProvisioningRequest(
|
||||
user_id=user.user_id,
|
||||
tenant=tenant,
|
||||
primary_email=user.primary_email,
|
||||
display_name=user.display_name,
|
||||
idempotency_key=f"portal-{user.user_id}-{tenant}",
|
||||
correlation_id=correlation_id,
|
||||
roles=tuple(
|
||||
item.kind for item in self.service.store.memberships_for_user(
|
||||
user.user_id, tenant=tenant
|
||||
)
|
||||
),
|
||||
))
|
||||
self.service.link_identity(
|
||||
actor, user.user_id, issuer="urn:netkingdom:directory",
|
||||
subject=result.external_subject, provider=result.provider,
|
||||
correlation_id=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] == "status":
|
||||
status = AccountStatus(str(body.get("status", "")))
|
||||
if status not in {AccountStatus.ACTIVE, AccountStatus.SUSPENDED}:
|
||||
raise ValidationError("browser lifecycle supports active or suspended")
|
||||
self._change_status(
|
||||
actor, tenant, parts[4], status,
|
||||
idempotency_key=f"portal-status-{parts[4]}-{status.value}",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
|
||||
return self._error(start_response, "404 Not Found", "not_found", "Resource not found.", correlation_id)
|
||||
|
||||
def _change_status(
|
||||
self,
|
||||
actor: Any,
|
||||
tenant: str,
|
||||
user_id: str,
|
||||
status: AccountStatus,
|
||||
*,
|
||||
idempotency_key: str,
|
||||
correlation_id: str,
|
||||
) -> 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,
|
||||
)
|
||||
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")
|
||||
return self.service.set_tenant_account_status(
|
||||
actor, user_id, status, tenant=tenant, correlation_id=correlation_id
|
||||
)
|
||||
|
||||
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")
|
||||
|
|
@ -264,6 +392,29 @@ class PortalApplication:
|
|||
raise ValidationError("request body must be an object")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _form_body(environ: Mapping[str, Any]) -> Mapping[str, str]:
|
||||
content_type = str(environ.get("CONTENT_TYPE", "")).partition(";")[0]
|
||||
if content_type != "application/x-www-form-urlencoded":
|
||||
raise ValidationError("form content type is required")
|
||||
length = min(int(environ.get("CONTENT_LENGTH") or 0), 65536)
|
||||
payload = environ["wsgi.input"].read(length).decode("utf-8")
|
||||
return {key: values[0] for key, values in parse_qs(payload).items()}
|
||||
|
||||
def _csrf_token(self, environ: Mapping[str, Any]) -> str:
|
||||
if self.oidc_client is None:
|
||||
raise AuthorizationDenied("browser session required")
|
||||
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
|
||||
token = self.oidc_client.csrf_token(session_id or "")
|
||||
if token is None:
|
||||
raise AuthorizationDenied("browser session required")
|
||||
return token
|
||||
|
||||
def _require_csrf(self, environ: Mapping[str, Any], supplied: str) -> None:
|
||||
expected = self._csrf_token(environ)
|
||||
if not supplied or not secrets.compare_digest(supplied, expected):
|
||||
raise AuthorizationDenied("invalid CSRF token")
|
||||
|
||||
@staticmethod
|
||||
def _page(environ: Mapping[str, Any]) -> tuple[int, int]:
|
||||
query = parse_qs(str(environ.get("QUERY_STRING", "")))
|
||||
|
|
@ -284,16 +435,56 @@ class PortalApplication:
|
|||
+ identity,
|
||||
)
|
||||
|
||||
def _admin(self, tenant: str, memberships: tuple[Any, ...]) -> str:
|
||||
def _admin(self, tenant: str, memberships: tuple[Any, ...], csrf_token: str) -> str:
|
||||
rows = "".join(
|
||||
f"<tr><td>{escape(item.user_id)}</td><td>{escape(item.kind)}</td><td>{escape(item.scope_id)}</td></tr>"
|
||||
self._admin_row(tenant, item, csrf_token)
|
||||
for item in memberships
|
||||
) or '<tr><td colspan="3">No members yet.</td></tr>'
|
||||
) or '<tr><td colspan="6">No members yet.</td></tr>'
|
||||
return self._page_html(
|
||||
f"{tenant} users",
|
||||
f"<h1>{escape(tenant)} users</h1><table><thead><tr><th>User</th><th>Role</th><th>Scope</th></tr></thead><tbody>{rows}</tbody></table>",
|
||||
f"""<h1>{escape(tenant)} users</h1>
|
||||
<section><h2>Add a user</h2>
|
||||
<form method="post" action="/admin/{escape(tenant)}/users">
|
||||
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
||||
<label>Display name <input name="display_name" required 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">Add user</button></form></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:
|
||||
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)}/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>"""
|
||||
)
|
||||
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>"
|
||||
)
|
||||
|
||||
def _redirect(self, start_response: StartResponse, location: str, correlation_id: str) -> list[bytes]:
|
||||
start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
|
||||
return [b""]
|
||||
|
||||
@staticmethod
|
||||
def _page_html(title: str, body: str) -> str:
|
||||
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
|
|
@ -305,7 +496,9 @@ header,main{{max-width:68rem;margin:auto;padding:1.25rem}}header{{border-bottom:
|
|||
h1{{font:clamp(2.2rem,7vw,5.5rem)/.98 Georgia,serif;max-width:13ch}}a{{color:var(--accent)}}
|
||||
.button{{display:inline-block;background:var(--accent);color:white;padding:.8rem 1.15rem;border-radius:.3rem;text-decoration:none}}
|
||||
table{{width:100%;border-collapse:collapse;background:#fff}}th,td{{padding:.75rem;text-align:left;border-bottom:1px solid var(--line)}}
|
||||
a:focus-visible{{outline:3px solid #e59f24;outline-offset:3px}}@media(max-width:640px){{body{{font-size:16px}}}}
|
||||
section{{margin:2rem 0}}form{{display:grid;gap:.8rem;max-width:42rem}}label{{display:grid;gap:.25rem}}
|
||||
input,select,button{{font:inherit;padding:.65rem}}button{{background:var(--accent);color:white;border:0;border-radius:.3rem;cursor:pointer}}
|
||||
a:focus-visible,input:focus-visible,select:focus-visible,button:focus-visible{{outline:3px solid #e59f24;outline-offset:3px}}@media(max-width:640px){{body{{font-size:16px}}table{{display:block;overflow-x:auto}}}}
|
||||
</style></head><body><header><strong>Railiance identity</strong></header><main>{body}</main></body></html>"""
|
||||
|
||||
def _html(self, start_response: StartResponse, body: str, correlation_id: str) -> list[bytes]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue