Report tenant grouping from the authority record
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

tenant-engine has made grouping mutable through its own reclassification
route, so a tenant created as tenant:small:acme can report grouping "large".
The identifier's grouping segment is now historical and must not be parsed.

TenantRecord dropped the field entirely, so the portal read discarded the one
safe source of a tenant's classification and left an operator with nothing but
the identifier to infer from — exactly the mistake the change creates. The
record and adapter now carry grouping, the operator screen shows it with a
note that the identifier segment is not the grouping, and the OpenAPI schema
documents where to read it.

Also corrects the UpdateTenant description, which still claimed grouping was
immutable. It is mutable, but never as metadata, because it resolves a
tenant's spend ceiling.

No reclassification control is offered here: that route is not deployed yet
and, per tenant-engine, wants its own permission rather than riding on rename.

Full suite: 149 tests, 3 provider-gated skips.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-18 10:56:54 +02:00
parent 4e0f25acf2
commit 47a58d5cc2
7 changed files with 31 additions and 2 deletions

View file

@ -30,6 +30,7 @@
| workplan | USER-WP-0020 | finished | — | workplans/USER-WP-0020-self-service-and-user-administration-portal.md |
| workplan | USER-WP-0021 | active | — | workplans/USER-WP-0021-portal-product-expansion.md |
| workplan | USER-WP-0022 | blocked | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md |
| workplan | USER-WP-0023 | active | — | workplans/USER-WP-0023-flex-auth-caller-identity.md |
| task | USER-WP-0001-T1 | done | — | workplans/USER-WP-0001-preparation-and-interface-adoption.md |
| task | USER-WP-0001-T2 | done | — | workplans/USER-WP-0001-preparation-and-interface-adoption.md |
| task | USER-WP-0001-T3 | done | — | workplans/USER-WP-0001-preparation-and-interface-adoption.md |
@ -165,3 +166,6 @@
| task | USER-WP-0022-T03 | cancel | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md |
| task | USER-WP-0022-T04 | cancel | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md |
| task | USER-WP-0022-T05 | progress | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md |
| task | USER-WP-0023-T01 | done | — | workplans/USER-WP-0023-flex-auth-caller-identity.md |
| task | USER-WP-0023-T02 | done | — | workplans/USER-WP-0023-flex-auth-caller-identity.md |
| task | USER-WP-0023-T03 | wait | — | workplans/USER-WP-0023-flex-auth-caller-identity.md |

View file

@ -379,8 +379,10 @@ components:
metadata:
type: object
description: >-
Mutable tenant metadata. tenant_id, identifier, and grouping are
immutable at the authority and are rejected here.
Mutable tenant metadata. tenant_id and identifier are immutable at
the authority and are rejected here. grouping is mutable but only
through the authority's own reclassification route, never as
metadata, because it resolves a tenant's spend ceiling.
minProperties: 1
properties:
display_name: {type: string, minLength: 1, maxLength: 200}
@ -396,6 +398,13 @@ components:
external_ref: {type: string}
lifecycle: {type: string, enum: [active, retired, unknown]}
version: {type: integer, minimum: 0}
grouping:
type: string
nullable: true
description: >-
Authoritative classification, reported by the tenant authority.
Read it from here; never parse it from the identifier, whose own
grouping segment is historical once a tenant is reclassified.
display_name: {type: string, nullable: true}
contact_email: {type: string, nullable: true}
retired_at: {type: string, nullable: true}

View file

@ -176,6 +176,7 @@ class HTTPTenantManagementAdapter:
external_ref=str(payload.get("tenant_id") or identifier),
lifecycle=str(payload.get("lifecycle") or "unknown"),
version=int(payload.get("version") or 0),
grouping=payload.get("grouping"),
display_name=payload.get("display_name"),
contact_email=payload.get("contact_email"),
retired_at=payload.get("retired_at"),

View file

@ -128,6 +128,9 @@ class TenantRecord:
external_ref: str
lifecycle: str
version: int
# Authoritative classification. The identifier's own grouping segment is
# historical after a reclassification, so it must never be parsed for this.
grouping: str | None = None
display_name: str | None = None
contact_email: str | None = None
retired_at: str | None = None

View file

@ -1694,6 +1694,7 @@ class PortalApplication:
f"Tenant {record.tenant}",
f"""<h1>{escape(record.tenant)}</h1>
<p>Lifecycle <strong>{escape(record.lifecycle)}</strong> at version {record.version}.</p>
<p>Grouping <strong>{escape(record.grouping or 'not reported')}</strong>, as reported by the tenant authority. The identifier's own segment is historical after a reclassification and is not the grouping.</p>
{replayed}
{metadata_form}
<section aria-labelledby="tenant-lifecycle"><h2 id="tenant-lifecycle">Lifecycle</h2>

View file

@ -96,6 +96,16 @@ class TenantLifecycleAdapterTests(unittest.TestCase):
self.assertEqual(record.version, 1)
self.assertFalse(record.replayed)
def test_read_reports_the_authoritative_grouping(self):
"""Grouping must come from the record; the identifier segment is historical."""
request, record = self._call(
"tenant",
response={**RECORD, "identifier": "tenant:small:acme", "grouping": "large"},
tenant="tenant:small:acme", correlation_id="corr-1",
)
self.assertEqual(record.tenant, "tenant:small:acme")
self.assertEqual(record.grouping, "large")
def test_update_sends_a_version_etag_and_the_allow_listed_change(self):
request, record = self._call(
"update_tenant", response={**RECORD, "display_name": "Binky Ltd", "version": 2},

View file

@ -847,6 +847,7 @@ class PortalApplicationTests(unittest.TestCase):
)
self.assertEqual("200 OK", page["status"])
self.assertIn(b"Retire tenant", html)
self.assertIn(b"not reported", html)
self.assertIn(b'name="version" value="1"', html)
forged, _ = invoke(