feat: add authorized account lookup without provisioning
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
parent
b4cef6446b
commit
686da7c6bb
3 changed files with 195 additions and 0 deletions
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, field, replace
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any, Callable, Iterable, Mapping
|
from typing import Any, Callable, Iterable, Mapping
|
||||||
|
|
@ -105,6 +108,21 @@ class ReadinessReport:
|
||||||
checks: Mapping[str, bool]
|
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)
|
@dataclass(frozen=True)
|
||||||
class UserSession:
|
class UserSession:
|
||||||
actor: Actor
|
actor: Actor
|
||||||
|
|
@ -1766,6 +1784,44 @@ class UserEngineService:
|
||||||
)
|
)
|
||||||
return self._session(actor, user, account)
|
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(
|
def create_user(
|
||||||
self,
|
self,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
|
|
|
||||||
69
tests/test_account_authority.py
Normal file
69
tests/test_account_authority.py
Normal 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'))
|
||||||
70
workplans/USER-WP-0037-hub-authority-lookup.md
Normal file
70
workplans/USER-WP-0037-hub-authority-lookup.md
Normal 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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue