feat: add authorized account lookup without provisioning
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 4s
Build and Publish Container Image / build-and-push (push) Successful in 58s
Account journey acceptance / journeys (push) Successful in 9s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
tegwick 2026-09-28 18:18:42 +02:00
parent b4cef6446b
commit 686da7c6bb
3 changed files with 195 additions and 0 deletions

View file

@ -2,6 +2,9 @@
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field, replace
from datetime import datetime, timedelta
from typing import Any, Callable, Iterable, Mapping
@ -105,6 +108,21 @@ class ReadinessReport:
checks: Mapping[str, bool]
@dataclass(frozen=True)
class AccountAuthority:
"""Current account state only; not a root entitlement or tenant lifecycle grant."""
issuer: str
subject: str
tenant: str
user_id: str
account_id: str
identity_id: str
account_status: AccountStatus
tenant_account_status: AccountStatus | None
observed_at: datetime
decision_id: str | None
@dataclass(frozen=True)
class UserSession:
actor: Actor
@ -1766,6 +1784,44 @@ class UserEngineService:
)
return self._session(actor, user, account)
def lookup_account_authority(
self, caller: Actor, *, issuer: str, subject: str, tenant: str,
correlation_id: str | None = None,
) -> AccountAuthority:
"""Authorize a read of an existing identity; never call provisioning /me.
The host must authenticate caller independently of the lookup target.
This action needs its own policy grant; caller roles do not grant it here.
Missing scoped accounts are explicit, never defaulted to active.
"""
if any(not isinstance(value, str) or not value.strip()
for value in (issuer, subject, tenant)):
raise ValidationError("exact issuer, subject and tenant are required")
correlation_id = correlation_id or new_id("corr")
lookup_id = "sha256:" + hashlib.sha256(json.dumps(
[issuer, subject, tenant], separators=(",", ":"),
).encode()).hexdigest()
decision = self._authorize(
caller, action="account.authority.read",
resource_type="user-engine:account-authority", resource_id=lookup_id,
tenant=tenant, correlation_id=correlation_id,
context={"lookup_issuer": issuer, "lookup_subject": subject},
)
observed_at = utc_now()
identity = self.store.find_identity(issuer, subject)
if identity is None:
raise NotFoundError("identity link not found")
user = self._require_user(identity.user_id)
account = self._require_account(user.user_id)
scoped = self.store.tenant_account(tenant, user.user_id)
return AccountAuthority(
issuer=issuer, subject=subject, tenant=tenant, user_id=user.user_id,
account_id=account.account_id, identity_id=identity.identity_id,
account_status=account.status,
tenant_account_status=scoped.status if scoped is not None else None,
observed_at=observed_at, decision_id=decision.decision_id,
)
def create_user(
self,
actor: Actor,