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,

View file

@ -0,0 +1,69 @@
import unittest
from dataclasses import replace
from user_engine.adapters.local import InMemoryUserEngineStore
from user_engine.domain import AccountStatus, AuthorizationEffect
from user_engine.errors import AuthorizationDenied, NotFoundError
from user_engine.service import UserEngineService
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, StaticAuthorizationCheckPort, human_actor_claims
class AccountAuthorityTests(unittest.TestCase):
def setUp(self):
self.store = InMemoryUserEngineStore()
self.policy = StaticAuthorizationCheckPort()
self.service = UserEngineService(store=self.store,identity_adapter=FixtureIdentityClaimsAdapter(),authorization=self.policy)
self.session = self.service.me(human_actor_claims())
self.caller = replace(self.session.actor,subject='fixture-workload')
def lookup(self,**overrides):
return self.service.lookup_account_authority(self.caller,**{
'issuer':self.session.actor.issuer,'subject':self.session.actor.subject,
'tenant':self.session.actor.tenant,**overrides})
def test_existing_lookup_does_not_mutate_and_keeps_caller_distinct(self):
before = repr(self.store.__dict__)
result = self.lookup()
self.assertEqual(repr(self.store.__dict__),before)
self.assertEqual(result.identity_id,self.session.identities[0].identity_id)
self.assertEqual(result.account_status,AccountStatus.ACTIVE)
self.assertEqual(result.tenant_account_status,AccountStatus.ACTIVE)
request = self.policy.requests[-1]
self.assertEqual(request.action,'account.authority.read')
self.assertEqual(request.actor.subject,'fixture-workload')
self.assertEqual(request.context['lookup_subject'],self.session.actor.subject)
self.assertIsNotNone(result.observed_at.tzinfo)
def test_unknown_identity_does_not_provision(self):
before = repr(self.store.__dict__)
with self.assertRaises(NotFoundError): self.lookup(subject='unknown')
with self.assertRaises(NotFoundError): self.lookup(issuer='https://other.example')
self.assertEqual(repr(self.store.__dict__),before)
def test_missing_scoped_account_is_explicit(self):
self.assertIsNone(self.lookup(tenant='unknown-tenant').tenant_account_status)
def test_current_global_and_scoped_status_are_returned_each_time(self):
self.lookup()
self.store.save_account(replace(self.session.account,status=AccountStatus.DISABLED))
scoped = self.store.tenant_account(self.session.actor.tenant,self.session.user.user_id)
self.store.save_tenant_account(replace(scoped,status=AccountStatus.DISABLED))
result = self.lookup()
self.assertEqual(result.account_status,AccountStatus.DISABLED)
self.assertEqual(result.tenant_account_status,AccountStatus.DISABLED)
def test_denial_precedes_target_identity_lookup(self):
self.policy.effect = AuthorizationEffect.DENY
original = self.store.find_identity
def checked(issuer,subject):
if subject == self.session.actor.subject:
raise AssertionError('target lookup before authorization')
return original(issuer,subject)
self.store.find_identity = checked
with self.assertRaises(AuthorizationDenied): self.lookup()
def test_does_not_emit_root_grants_or_profile_details(self):
result = self.lookup()
self.assertFalse(hasattr(result,'root_entitled'))
self.assertFalse(hasattr(result,'roles'))
self.assertFalse(hasattr(result,'primary_email'))

View file

@ -0,0 +1,70 @@
---
id: USER-WP-0037
type: workplan
title: "Read-only account authority for Hub integration"
domain: communication
repo: user-engine
status: active
owner: codex
topic_slug: communication
created: "2026-09-28"
updated: "2026-09-28"
related:
- HUB-WP-0012
---
# Hub authority lookup
Owner-side implementation for HUB-WP-0012. Source work is authorized; real
workload grants, root entitlements and deployment acceptance remain separate.
## Existing account lookup without provisioning
```task
id: USER-WP-0037-T01
status: done
priority: high
```
Add an authorized service lookup for exact issuer/subject and scoped tenant.
Authorize the caller separately before target lookup. Unknown identities never
provision; return current global/scoped account state without roles or profile
PII. Preserve source observation time and identity/account references.
Implemented `UserEngineService.lookup_account_authority` with the separate
`account.authority.read` action and a digest-bound issuer/subject/tenant resource.
Caller identity is distinct from the target. Missing tenant-account state is
explicit `None`, not active. AccountAuthority contains no root grant, token roles
or profile/email data. Authorization denial may emit the existing audit record;
this operation does not provision or mutate domain records.
Validation: `make test` ran 270 tests successfully (eight optional skips), including
six new lookup checks; layer conformance passed. No HTTP endpoint, live grant or
production deployment was added. T02/T03 retain those outstanding owner gates.
## Root entitlement and mapping disposition
```task
id: USER-WP-0037-T02
status: todo
priority: high
```
With NetKingdom, specify explicit current root-grant custody/revocation and
platform:root to tenant:platform mapping. Do not derive a grant from JWT roles,
a username, or Hub's configured root subject. Define retainable evidence for
account and entitlement observations.
## Authenticated workload transport and private acceptance
```task
id: USER-WP-0037-T03
status: todo
priority: high
```
Expose a narrowly scoped read API with an independently authenticated Hub
workload, policy action account.authority.read, exact target binding and no
end-user bearer forwarding. Add HTTP schema and denial/no-provisioning tests,
then admit real caller credentials through their owner and verify withdrawal,
outages and private Hub integration. A service method alone is not this gate.