Fix: resolve tenants by identifier, not only internal tenant_id
Found via a real cross-service check while implementing key-cape's KEY-WP-0005-T02: key-cape's Go adapter called GET /tenants/tenant:coulomb/roles and got a genuine 404 for a tenant that existed. External callers (key-cape, flex-auth) only ever have a tenant's profile identifier, never tenant-engine's internal tenant_id (caller-chosen at creation, otherwise opaque). Every existing test happened to use identical strings for both fields, so this was invisible until a real, independent second caller exercised the documented contract. InMemoryTenantStore gained a _by_identifier index and a _resolve() helper every method calls first; create_tenant now also rejects a duplicate identifier under a different internal id (an oversight the same fix surfaced). 5 new tests, including the exact HTTP-level scenario with colon characters in the URL path. 65 total, all 60 pre-existing tests unaffected. Re-verified end-to-end for real: fresh flex-auth + tenant-engine + key-cape's actual adapter code, over real HTTP -- roles=[IAM] ok=true resolving by identifier. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
18f510070f
commit
31e237cfa6
4 changed files with 193 additions and 17 deletions
|
|
@ -37,7 +37,17 @@ class DomainEvent:
|
|||
|
||||
|
||||
class TenantStore(Protocol):
|
||||
"""Swappable persistence seam -- domain/ and api/ depend on this, not a backend."""
|
||||
"""Swappable persistence seam -- domain/ and api/ depend on this, not a backend.
|
||||
|
||||
Every method taking a `tenant_id` accepts either the tenant's internal
|
||||
id (caller-chosen at creation, e.g. from an admin tool) or its profile
|
||||
identifier (e.g. "tenant:friendly:binky") -- external callers like
|
||||
key-cape and flex-auth only ever have the identifier (it's the IAM
|
||||
Profile `tenant` claim value), never tenant-engine's internal id. See
|
||||
`_resolve_tenant_id` for why this had to be added after real
|
||||
cross-service testing caught the gap (found integrating key-cape's
|
||||
tenant_roles claim, KEY-WP-0005-T02).
|
||||
"""
|
||||
|
||||
def create_tenant(self, tenant: Tenant) -> None: ...
|
||||
|
||||
|
|
@ -57,6 +67,7 @@ class TenantStore(Protocol):
|
|||
class InMemoryTenantStore:
|
||||
def __init__(self) -> None:
|
||||
self._tenants: dict[str, Tenant] = {}
|
||||
self._by_identifier: dict[str, str] = {}
|
||||
self._grants: dict[str, dict[str, RoleGrant]] = {}
|
||||
self._plans: dict[str, PlanAssignment] = {}
|
||||
self._events: list[DomainEvent] = []
|
||||
|
|
@ -64,7 +75,10 @@ class InMemoryTenantStore:
|
|||
def create_tenant(self, tenant: Tenant) -> None:
|
||||
if tenant.tenant_id in self._tenants:
|
||||
raise TenantAlreadyExistsError(tenant.tenant_id)
|
||||
if tenant.identifier in self._by_identifier:
|
||||
raise TenantAlreadyExistsError(tenant.identifier)
|
||||
self._tenants[tenant.tenant_id] = tenant
|
||||
self._by_identifier[tenant.identifier] = tenant.tenant_id
|
||||
self._grants[tenant.tenant_id] = {}
|
||||
self._emit(
|
||||
"tenant_created",
|
||||
|
|
@ -73,17 +87,14 @@ class InMemoryTenantStore:
|
|||
)
|
||||
|
||||
def get_tenant(self, tenant_id: str) -> Tenant:
|
||||
try:
|
||||
return self._tenants[tenant_id]
|
||||
except KeyError:
|
||||
raise TenantNotFoundError(tenant_id) from None
|
||||
return self._tenants[self._resolve(tenant_id)]
|
||||
|
||||
def grant_role(self, grant: RoleGrant) -> None:
|
||||
self.get_tenant(grant.tenant_id)
|
||||
self._grants[grant.tenant_id][grant.grant_id] = grant
|
||||
resolved = self._resolve(grant.tenant_id)
|
||||
self._grants[resolved][grant.grant_id] = grant
|
||||
self._emit(
|
||||
"role_granted",
|
||||
grant.tenant_id,
|
||||
resolved,
|
||||
{
|
||||
"grant_id": grant.grant_id,
|
||||
"role": grant.role.value,
|
||||
|
|
@ -93,34 +104,52 @@ class InMemoryTenantStore:
|
|||
)
|
||||
|
||||
def revoke_role(self, *, tenant_id: str, grant_id: str, at: datetime) -> RoleGrant:
|
||||
self.get_tenant(tenant_id)
|
||||
resolved = self._resolve(tenant_id)
|
||||
try:
|
||||
grant = self._grants[tenant_id][grant_id]
|
||||
grant = self._grants[resolved][grant_id]
|
||||
except KeyError:
|
||||
raise GrantNotFoundError(grant_id) from None
|
||||
revoked = grant.revoke(at=at)
|
||||
self._grants[tenant_id][grant_id] = revoked
|
||||
self._grants[resolved][grant_id] = revoked
|
||||
self._emit(
|
||||
"role_revoked",
|
||||
tenant_id,
|
||||
resolved,
|
||||
{"grant_id": grant_id, "role": revoked.role.value},
|
||||
)
|
||||
return revoked
|
||||
|
||||
def active_roles(self, tenant_id: str) -> frozenset[CapabilityRole]:
|
||||
self.get_tenant(tenant_id)
|
||||
resolved = self._resolve(tenant_id)
|
||||
return frozenset(
|
||||
grant.role for grant in self._grants.get(tenant_id, {}).values() if grant.active
|
||||
grant.role for grant in self._grants.get(resolved, {}).values() if grant.active
|
||||
)
|
||||
|
||||
def assign_plan(self, assignment: PlanAssignment) -> None:
|
||||
self.get_tenant(assignment.tenant_id)
|
||||
self._plans[assignment.tenant_id] = assignment
|
||||
self._emit("plan_assigned", assignment.tenant_id, {"plan_id": assignment.plan_id})
|
||||
resolved = self._resolve(assignment.tenant_id)
|
||||
self._plans[resolved] = assignment
|
||||
self._emit("plan_assigned", resolved, {"plan_id": assignment.plan_id})
|
||||
|
||||
def events(self) -> list[DomainEvent]:
|
||||
return list(self._events)
|
||||
|
||||
def _resolve(self, tenant_id: str) -> str:
|
||||
"""Resolve an internal tenant_id or a profile identifier to the
|
||||
|
||||
canonical internal tenant_id every other private dict is keyed by.
|
||||
|
||||
External callers (key-cape, flex-auth) only ever have the
|
||||
identifier (the IAM Profile `tenant` claim value) -- they have no
|
||||
way to know a tenant's internal tenant_id, which is caller-chosen
|
||||
at creation time and otherwise opaque. Found the hard way: a real
|
||||
cross-service check (key-cape's KEY-WP-0005-T02 against a live
|
||||
tenant-engine) returned tenant_not_found for a tenant that
|
||||
genuinely existed, because the caller only had the identifier.
|
||||
"""
|
||||
resolved = self._by_identifier.get(tenant_id, tenant_id)
|
||||
if resolved not in self._tenants:
|
||||
raise TenantNotFoundError(tenant_id)
|
||||
return resolved
|
||||
|
||||
def _emit(self, event_type: str, tenant_id: str, payload: dict[str, Any]) -> None:
|
||||
self._events.append(
|
||||
DomainEvent(event_type=event_type, tenant_id=tenant_id, at=datetime.now(UTC), payload=payload)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue