fix: select existing tenants by name in the operator portal
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Successful in 49s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-12 00:51:57 +02:00
parent 3758a4bb5e
commit d177d47975
8 changed files with 127 additions and 13 deletions

View file

@ -316,6 +316,9 @@ class InMemoryUserEngineStore:
if membership.tenant == tenant
)
def membership_tenants(self) -> tuple[str, ...]:
return tuple(sorted({membership.tenant for membership in self.memberships.values()}))
def values_for_user(self, user_id: str) -> tuple[ProfileValue, ...]:
return tuple(
value for value in self.profile_values.values() if value.user_id == user_id

View file

@ -157,6 +157,18 @@ class PostgresUserEngineStore:
self._query_records("memberships", tenant=tenant),
)
def membership_tenants(self) -> tuple[str, ...]:
with self._cursor() as cursor:
cursor.execute(
"SELECT DISTINCT tenant FROM user_engine_records "
"WHERE record_type = %s AND tenant IS NOT NULL ORDER BY tenant",
("memberships",),
)
return tuple(
str(row["tenant"] if isinstance(row, Mapping) else row[0])
for row in cursor.fetchall()
)
def save_application(self, application: Application) -> None:
self._upsert_record(application)

View file

@ -282,6 +282,9 @@ class UserEngineStore(Protocol):
def memberships_for_tenant(self, tenant: str) -> tuple[Membership, ...]:
"""Return memberships scoped to a tenant."""
def membership_tenants(self) -> tuple[str, ...]:
"""Return distinct tenant identifiers referenced by membership records."""
def save_application(self, application: Application) -> None:
"""Create or replace an application registration."""

View file

@ -477,6 +477,7 @@ def _write_reference_records(store: UserEngineStore) -> dict[str, Any]:
store.save_identity(identity)
store.save_tenant_account(tenant_account)
store.save_membership(membership)
assert membership.tenant in store.membership_tenants()
store.save_application(application)
store.save_binding(binding)
store.save_catalog(catalog)

View file

@ -738,11 +738,26 @@ class PortalApplication:
if path == "/platform/tenant" and method == "GET":
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
query = parse_qs(str(environ.get("QUERY_STRING", "")))
lookup = query.get("tenant", [""])[0]
lookup = query.get("tenant", [""])[0].strip()
view = query.get("view", ["lifecycle"])[0]
if view not in {"lifecycle", "users"}:
raise ValidationError("unknown tenant view")
if not lookup.startswith("tenant:") or lookup == PLATFORM_TENANT:
if not lookup.startswith("tenant:"):
matches = tuple(
tenant for tenant in self._known_membership_tenants()
if tenant.rsplit(":", 1)[-1] == lookup
)
if len(matches) != 1:
message = (
"Several tenants use that name. Choose a tenant below."
if matches else
"No tenant with users matches that name. Choose a tenant below or enter its full identifier."
)
return self._html(start_response, self._platform(
self._csrf_token(environ), error=message,
), correlation_id)
lookup = matches[0]
if lookup == PLATFORM_TENANT:
raise ValidationError("a non-platform tenant identifier is required")
return self._redirect(
start_response,
@ -1690,10 +1705,32 @@ class PortalApplication:
<p>Your password and MFA remain on the identity-provider surface.</p>""",
)
def _platform(self, csrf_token: str) -> str:
def _known_membership_tenants(self) -> tuple[str, ...]:
# A navigation index of user-owned memberships, not an authority inventory.
return tuple(
tenant for tenant in self.service.store.membership_tenants()
if tenant.startswith("tenant:") and tenant != PLATFORM_TENANT
)
def _platform(self, csrf_token: str, *, error: str = "") -> str:
known_tenants = "".join(
f'<li><a href="/admin/{escape(quote(tenant, safe=""))}">'
f'{escape(tenant.rsplit(":", 1)[-1])}</a> '
f'<small>({escape(tenant)})</small></li>'
for tenant in self._known_membership_tenants()
) or '<li>No tenants with user memberships are recorded yet.</li>'
message = f'<p role="alert">{escape(error)}</p>' if error else ""
return self._page_html(
"Platform administration",
f"""<h1>Platform administration</h1>
<section aria-labelledby="manage-tenant"><h2 id="manage-tenant">Manage an existing tenant</h2>
{message}
<p>Choose a tenant to manage its users:</p><ul>{known_tenants}</ul>
<form method="get" action="/platform/tenant">
<label>Tenant name or full identifier <input name="tenant" required placeholder="demo-company" aria-describedby="tenant-lookup-help"></label>
<p id="tenant-lookup-help">Enter a listed name, such as demo-company. You can also use a full identifier, such as tenant:trial:demo-company.</p>
<button type="submit" name="view" value="users">Manage users</button>
<button type="submit" name="view" value="lifecycle">Open tenant lifecycle</button></form></section>
<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)}">
@ -1703,12 +1740,7 @@ class PortalApplication:
<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>
<section aria-labelledby="manage-tenant"><h2 id="manage-tenant">Manage an existing tenant</h2>
<form method="get" action="/platform/tenant">
<label>Tenant identifier <input name="tenant" required pattern="tenant:.+" placeholder="tenant:friendly:example"></label>
<button type="submit" name="view" value="users">Manage users</button>
<button type="submit" name="view" value="lifecycle">Open tenant lifecycle</button></form>
<p>Tenant records, metadata, and retirement are owned by the tenant authority.</p></section>""",
""",
)
def _platform_tenant(self, record: Any, csrf_token: str) -> str: