fix: select existing tenants by name in the operator portal
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
3758a4bb5e
commit
d177d47975
8 changed files with 127 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue