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:

View file

@ -55,6 +55,40 @@ class PortalNavigationTests(unittest.TestCase):
invalid, _ = self.get('/platform/tenant',query=urlencode({'tenant':'tenant:trial:demo-company','view':'https://evil.test'}))
self.assertEqual('400 Bad Request',invalid['status'])
def add_tenant_user(self, tenant, email):
response, _ = invoke(
self.app, '/admin/'+tenant+'/users', method='POST',
cookie='ue_session=operator', form={
'csrf_token':'operator-csrf', 'display_name':'Demo',
'primary_email':email, 'role':'user',
},
)
self.assertEqual('303 See Other', response['status'])
def test_existing_tenant_is_selectable_and_short_name_resolves(self):
self.add_tenant_user('tenant:trial:demo-company','one@example.test')
self.add_tenant_user('tenant:trial:demo-company','two@example.test')
response, body = self.get('/platform')
self.assertEqual('200 OK', response['status'])
self.assertIn(b'href="/admin/tenant%3Atrial%3Ademo-company">demo-company</a>',body)
self.assertEqual(('tenant:trial:demo-company',),self.app.service.store.membership_tenants())
response, _ = self.get('/platform/tenant',query=urlencode({'tenant':' demo-company ','view':'users'}))
self.assertEqual('/admin/tenant%3Atrial%3Ademo-company',response['headers']['Location'])
denied, _ = self.get('/platform',who='member')
self.assertEqual('403 Forbidden',denied['status'])
def test_ambiguous_or_unknown_short_names_do_not_guess_a_tenant(self):
self.add_tenant_user('tenant:trial:demo-company','one@example.test')
self.add_tenant_user('tenant:friendly:demo-company','two@example.test')
for name, message in [('demo-company',b'Several tenants'),('unknown',b'No tenant with users')]:
response,body=self.get('/platform/tenant',query=urlencode({'tenant':name,'view':'users'}))
self.assertEqual('200 OK',response['status'])
self.assertNotIn('Location',response['headers'])
self.assertIn(message,body)
self.assertIn(b'role="alert"',body)
response,_=self.get('/platform/tenant',query=urlencode({'tenant':'tenant:trial:demo-company','view':'users'}))
self.assertEqual('/admin/tenant%3Atrial%3Ademo-company',response['headers']['Location'])
def test_navigation_does_not_leak_between_operator_member_and_anonymous(self):
self.get('/platform')
_, member = self.get('/onboarding',who='member')

View file

@ -158,6 +158,13 @@ class _FakePostgresCursor:
self._rows = sorted(counts.items())
return
if normalized.startswith("select distinct tenant"):
self._rows = [(tenant,) for tenant in sorted({
record.tenant for (record_type, _), record in self.connection.records.items()
if record_type == values[0] and record.tenant is not None
})]
return
record_type = values[0]
filter_columns = [
column

View file

@ -43,10 +43,10 @@ page explaining the remaining shared sign-in session. Expired sessions may clear
their stale cookie without affecting another session. This is portal logout;
provider-wide sign-out/account switching remains the explicit next task below.
Validation: make test passes (175 tests, three optional integration skips) and
layer conformance passes. Six regressions cover operator access without membership,
ordinary-user denial, navigation isolation, logout CSRF, session invalidation and
expired-session cleanup. Eight local Chromium checks pass for operator guidance, navigation to tenant
Validation: make test passes (177 tests, three optional integration skips) and
layer conformance passes. Eight regressions cover operator access without membership,
ordinary-user denial, navigation isolation, logout CSRF, session invalidation,
expired-session cleanup, short-name resolution and ambiguous-name refusal. Ten local Chromium checks pass for operator guidance, navigation to tenant
users, mobile navigation, visible logout, session cookie removal and denied
access after logout. Immutable image promotion and native verification are T02.
@ -56,6 +56,7 @@ access after logout. Immutable image promotion and native verification are T02.
id: USER-WP-0025-T02
status: progress
priority: high
needs_human: false
state_hub_task_id: "67e2feca-ffcd-55a0-b0ce-2bab43c862e4"
```
@ -67,6 +68,27 @@ native operator navigation/logout when the operator is available. Deployment
restarts invalidate the portal's in-memory sessions, so provide the sign-in URL.
Actual demo-user Create login and password setup remain RAPPS-WP-0014-T02.
Release deployed on 2026-09-12: source 655dce7165eace6374da8a58e6a20a826f1a1a51,
Forgejo smoke 119 and publication 120 succeeded. Runtime now pins
forgejo.coulomb.social/coulomb/user-engine@sha256:38e110b0e30edc56900f8adea2da9c720e271f73543ff73ac905106308ac2f4d
with 1/1 Ready at unchanged 50m CPU/64Mi memory requests. The exact UID,
resourceVersion and previous image were tested before the image-only patch;
server dry-run and rollout succeeded. Public health and readiness return 200,
/logged-out serves the new scoped explanation, and unauthenticated tenant
administration remains 403. Local Chromium passed eight synthetic UI checks.
The operator confirmed native login and logout work on 2026-09-12. Tenant
selection then triggered browser format validation. The follow-up adds clickable
tenant names from the distinct membership index and accepts a unique short name
for lookup, retaining exact identifiers for ambiguity. This index is User
Engine membership data, not a complete Tenant Engine inventory. Unknown and
ambiguous names return inline guidance without guessing a tenant or changing
memberships. Known-tenant selection appears before tenant creation.
Follow-up validation: 177 tests (three optional skips), layer conformance and ten
local Chromium checks pass. The browser submits demo-company without a pattern
error and reaches its users; direct name selection works too. Publication,
promotion and native tenant-selection confirmation follow in this task.
## Coordinate provider-wide sign-out and account switching
```task