From d177d479751b7bb2d22c5316de426ad3fd7609a1 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 12 Sep 2026 00:51:57 +0200 Subject: [PATCH] fix: select existing tenants by name in the operator portal Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- src/user_engine/adapters/local.py | 3 ++ src/user_engine/adapters/postgres.py | 12 +++++ src/user_engine/ports.py | 3 ++ src/user_engine/testing/store_conformance.py | 1 + src/user_engine/web.py | 50 +++++++++++++++---- tests/test_portal_navigation.py | 34 +++++++++++++ tests/test_postgres_store_adapter.py | 7 +++ ...-WP-0025-operator-navigation-and-logout.md | 30 +++++++++-- 8 files changed, 127 insertions(+), 13 deletions(-) diff --git a/src/user_engine/adapters/local.py b/src/user_engine/adapters/local.py index 9b584ae..dbb7ca6 100644 --- a/src/user_engine/adapters/local.py +++ b/src/user_engine/adapters/local.py @@ -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 diff --git a/src/user_engine/adapters/postgres.py b/src/user_engine/adapters/postgres.py index 1abc547..c76f243 100644 --- a/src/user_engine/adapters/postgres.py +++ b/src/user_engine/adapters/postgres.py @@ -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) diff --git a/src/user_engine/ports.py b/src/user_engine/ports.py index 1814244..9ceef94 100644 --- a/src/user_engine/ports.py +++ b/src/user_engine/ports.py @@ -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.""" diff --git a/src/user_engine/testing/store_conformance.py b/src/user_engine/testing/store_conformance.py index 6a0fc2a..af10a7d 100644 --- a/src/user_engine/testing/store_conformance.py +++ b/src/user_engine/testing/store_conformance.py @@ -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) diff --git a/src/user_engine/web.py b/src/user_engine/web.py index f0c114e..9c9a58d 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -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:

Your password and MFA remain on the identity-provider surface.

""", ) - 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'
  • ' + f'{escape(tenant.rsplit(":", 1)[-1])} ' + f'({escape(tenant)})
  • ' + for tenant in self._known_membership_tenants() + ) or '
  • No tenants with user memberships are recorded yet.
  • ' + message = f'

    {escape(error)}

    ' if error else "" return self._page_html( "Platform administration", f"""

    Platform administration

    +

    Manage an existing tenant

    +{message} +

    Choose a tenant to manage its users:

    +
    + +

    Enter a listed name, such as demo-company. You can also use a full identifier, such as tenant:trial:demo-company.

    + +

    Create tenant

    @@ -1703,12 +1740,7 @@ class PortalApplication:
    -

    Manage an existing tenant

    -
    - - -
    -

    Tenant records, metadata, and retirement are owned by the tenant authority.

    """, +""", ) def _platform_tenant(self, record: Any, csrf_token: str) -> str: diff --git a/tests/test_portal_navigation.py b/tests/test_portal_navigation.py index 52f5081..4e5d496 100644 --- a/tests/test_portal_navigation.py +++ b/tests/test_portal_navigation.py @@ -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',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') diff --git a/tests/test_postgres_store_adapter.py b/tests/test_postgres_store_adapter.py index cf230b9..0597db4 100644 --- a/tests/test_postgres_store_adapter.py +++ b/tests/test_postgres_store_adapter.py @@ -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 diff --git a/workplans/USER-WP-0025-operator-navigation-and-logout.md b/workplans/USER-WP-0025-operator-navigation-and-logout.md index f6ab994..218563b 100644 --- a/workplans/USER-WP-0025-operator-navigation-and-logout.md +++ b/workplans/USER-WP-0025-operator-navigation-and-logout.md @@ -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