diff --git a/docs/owner-access-integration.md b/docs/owner-access-integration.md index 6ddd30a..11719d3 100644 --- a/docs/owner-access-integration.md +++ b/docs/owner-access-integration.md @@ -35,7 +35,10 @@ or use `/me` as an account-provisioning side effect. The follow-up [owner facts contract](owner-facts-contract.md) pins the re-reviewed sources and owner decisions needed for actual HTTP readers. `PlatformFacts` now provides the typed, bounded Hub-side join with live revocation and evidence -binding; it intentionally has no fabricated network endpoints or default grants. +binding. As of 2026-10-04, `UserEngineAccountReader` implements the actual +owner HTTP route and accepted custody observation contract. Tenant HTTP admission, +real Hub credentials and complete live composition remain open; see the updated +contract for the exact dependencies. No default grant is supplied. ## Audit Core adapter and sender admission diff --git a/docs/owner-facts-contract.md b/docs/owner-facts-contract.md index 654270e..b174b09 100644 --- a/docs/owner-facts-contract.md +++ b/docs/owner-facts-contract.md @@ -1,9 +1,12 @@ # Platform authority facts: owner review contract -HUB-WP-0012 T01/T02, 2026-09-28. Proposed owner contract and implemented Hub-side -composition; **no admitted HTTP adapter or production authority source**. +HUB-WP-0012 T01/T02, reviewed 2026-10-04. Hub-side composition and User Engine +HTTP client are implemented; **no real Hub caller admission or complete live +account/tenant composition is claimed**. The current client contract and remaining +owner requirements are in [the October 4 update](#implemented-account-reader--2026-10-04); +the earlier sections preserve the initial review. -## Reviewed source +## Historical source review — 2026-09-28 - User Engine `b4cef6446bc10555b598ea74de1743bc9b03e7ba`: `service.py::me` creates user/account/tenant-account/identity records for unknown @@ -33,7 +36,7 @@ This closes the read-only **service primitive**, not the wire contract or admitt Hub workload. USER-WP-0037 owns root-grant/mapping disposition and authenticated HTTP exposure/private acceptance. No database, live grant or credential changed. -## Contract for owner disposition +## Initial contract for owner disposition — 2026-09-28 | Requirement | Owning decision | | --- | --- | @@ -51,7 +54,7 @@ The Python types below are an internal normalization seam, **not a proposed HTTP endpoint that already exists**. Missing/unknown/ambiguous results must raise and deny; they must never synthesize an active account. -## Implemented composition +## Initial composition — 2026-09-28 `hub_core.security.facts.PlatformFacts` implements `FactSource` with injected `AccountReader` and `TenantReader`. The account reader returns a typed @@ -81,7 +84,7 @@ and mapping references. Readers/owners must retain the referenced evidence for reconstruction; this hash is a join identifier, not a signature or independent proof of owner authenticity. No account payload or credential enters the join. -## Acceptance cases and remaining work +## Initial acceptance cases and remaining work — 2026-09-28 Local tests cover exact bindings, malformed state, stale/future observations, oldest-timestamp preservation, bounded outages, repeated lookup, entitlement and @@ -93,3 +96,77 @@ real authenticated readers, and isolated owner-source integration tests proving unknown lookups create nothing. Private acceptance must then demonstrate root allow, ordinary/forged identity denial, next-request revocation and owner outages with real admitted callers. No public exposure follows from this contract. + +## Implemented account reader — 2026-10-04 + +The historical missing-endpoint assessment above is superseded by User Engine +`a9360842f84fae89857c2b1a298efc64d079ee7b`, USER-WP-0037 and accepted +NetKingdom ADR-0017 revision 2 (`45a189a39167eef62709df0e857309f7ddc59029`). +`UserEngineAccountReader` implements the existing +`POST /api/v1/platform/account-authority/lookup` wire contract. Its independently +configured bearer maps to the fixed admitted workload at User Engine; it never +forwards the human's Hub token or invents root entitlement. + +A host injects a `UserEngineAccountReader(base_url=..., token_file=Path(...), +client=...)` into `PlatformFacts(accounts=..., tenants=reviewed_tenant_reader, +account_tenant="platform", mapping_evidence="NK-ADR-0017:2")`. The HTTP client +must be lifecycle-owned, with `trust_env=False` and no ambient authorization or +cookies (as in the existing security composition). The token file is dedicated +to this User Engine caller, not the PDP/audit/browser credential. Nothing is +automatically enabled by environment variables or this documentation example. + +The reviewed custody adapter and its real service integration fixture require +lookup tenant **`platform`**. This differs from User Engine's legacy operator +context `platform:root`; neither is silently normalized by the reader. The +canonical Hub actor/target remains `tenant:platform`. Before live enablement, +owners must confirm the real account has the intended scoped account and that +this exact mapping is admitted; a missing scoped account is inactive. + +The response must bind issuer, subject and lookup tenant, identity/account/user +references, decision reference, known global/scoped status, and timezone-aware +observation time. `root_grant:null` means no entitlement. A non-null grant must +match exact issuer/subject, `scope_type:tenant`, `scope_id:platform`, relation +`platform-root-custodian`, owner `net-kingdom-custody`, source record +`nk-custody:platform-root`, positive integer revision and valid timestamps. +Unknown/expired/revoked or differently scoped observations fail closed. + +Hub deliberately keeps its stricter five-second source age on every read/write, +even though the canon permits up to fifteen minutes elsewhere. The earliest +account/custody timestamp is retained, and the grant's absolute expiry travels +through AccountObservation and LiveFacts to the final post-audit check. The +reader trusts User Engine's authenticated observation; User Engine owns durable +custody revision/rollback validation. A content digest identifies retained +account/grant evidence but does not independently authenticate the publisher. +No producer aliases are supplied by this contract, so the result has none. + +Every call rereads a bounded workload credential, uses non-local HTTPS and a +three-second total budget, limits the response to 64 KiB, rejects redirects, +compressed responses and nonzero Age headers, and exposes sanitized failures. +No caching or same-token fallback exists. User Engine's current environment-based +receiver credential rotation still needs an owner rollout/revocation receipt. + +## Remaining dependencies — 2026-10-04 + +| Hub tasks | Owner and required artifact | What unblocks Hub | +| --- | --- | --- | +| 0012-T01 | net-kingdom, user-engine, tenant-engine, access-engine and platform surface owners: reviewed action/audience/tenant/enforcement matrix with disputed rows resolved | Signed-off source/profile mapping; executable per-owner acceptance cases | +| 0012-T02 | user-engine USER-WP-0037-T03 + railiance-platform/rapp-user-engine: actual Hub workload identity, dedicated credential delivery/rotation reference, TLS origin and scoped account mapping | Wire this implemented client to an admitted private endpoint and prove allow/deny/withdrawal/outage without owner payloads or credentials in evidence | +| 0012-T02 | tenant-engine + deployment owner: authenticated Hub caller binding for authoritative lifecycle read, canonical identifier/record version and observation time | Implement TenantReader against that admitted response; do not treat actor query text or roles/live as workload authentication | +| 0012-T02 | net-kingdom/KeyCape + custody owner: actual immutable root identity, approved nonempty custody grant where intended, Hub audience/redirect registration and attended AAL2/recovery/revocation receipts | Real positive root journey; the accepted empty custody source proves denial only | +| 0012-T03 | access-engine + platform owner: Hub protected-system policy, authenticated workload grant, pinned trusted public keys and credential delivery; audit-core + platform owner: Hub sender classes including hub.operation.committed, operational custody and rotation/failure receipts | Compose real PDP/audit dependencies; existing FLEX-WP-0024/0025 source completion does not admit Hub | +| 0012-T04 | rapp-core-hub plus T02/T03 owners: pinned private image/migration/composition, network boundary, explicit owner-bound messaging/event producer aliases and rollback | Execute real browser/API/MCP, direct-service bypass, revocation and audit-outage acceptance | +| 0012-T05 | ops-hub first, other inventoried extensions, activity-core/Repo Manager/inbox caller owners: per-audience identity/delegation/tenant contract and host enforcement | Root and workload allow plus anonymous/non-root/wrong-audience/tenant denial with separate attribution | +| 0012-T06 | platform/Railiance owners after T04/T05: attended per-surface management and audit receipts | M1 requires every inventory row, not a navigation link or broad provisioning decision | +| 0012-T07 | rapp-core-hub RAPPCOREHUB-WP-0002-T05 after M1: explicit public-enable authorization, TLS/bypass/rollback packet | Package and enable only the reviewed public release | +| 0012-T08 | identity/tenant/policy owners after M1: roles, delegated principals and stored tenant provenance | Phase 2 implementation can start without inferring tenant authority from legacy data | + +These requirements update the existing workplans; they are not messages claiming +external owners have accepted new work. No control was relaxed or live credential +retrieved in this review. Workplans 0006/0011 independently retain A5/A4 cutover +and inbox source-feed/retention/reader-admission requirements. + +Local verification: `make ci-check` covers normalization, transport refusal, +rotation, withdrawal and post-audit expiry. To exercise the actual owner route +and serializer with synthetic authority, run +`HUB_CORE_USER_ENGINE_SOURCE=/home/worsch/user-engine .venv/bin/python -m pytest -q tests/test_user_engine_owner_contract.py`. +The owner suite is explicit opt-in and does not contact a live endpoint. diff --git a/hub_core/security/account_reader.py b/hub_core/security/account_reader.py new file mode 100644 index 0000000..beae1b3 --- /dev/null +++ b/hub_core/security/account_reader.py @@ -0,0 +1,101 @@ +"""User Engine account-authority HTTP reader; no credential issuance or fallback.""" +from __future__ import annotations + +import asyncio +import hashlib +import json +import time +from datetime import datetime +from pathlib import Path + +import httpx + +from hub_core.security.facts import AccountObservation +from hub_core.security.identity import AccessFailure, require_https +from hub_core.security.policy import parse_json + +STATUSES = {'invited', 'active', 'disabled', 'suspended', 'deletion_pending', 'deleted'} +GRANT = {'scope_type': 'tenant', 'scope_id': 'platform', + 'relation': 'platform-root-custodian', 'owner_system': 'net-kingdom-custody'} + + +def timestamp(value): + if not isinstance(value, str): + raise ValueError('owner timestamp required') + result = datetime.fromisoformat(value.replace('Z', '+00:00')) + if result.utcoffset() is None: + raise ValueError('owner timestamp timezone required') + return result.timestamp() + + +def normalize_account(body, *, issuer, subject, tenant, now): + if not isinstance(body, dict) or (body['issuer'], body['subject'], body['tenant']) != (issuer, subject, tenant): + raise ValueError('account binding mismatch') + for key in ('identity_id', 'user_id', 'account_id', 'decision_id'): + if not isinstance(body[key], str) or not body[key].strip(): + raise ValueError('owner evidence missing') + if body['account_status'] not in STATUSES or body['tenant_account_status'] not in STATUSES | {None}: + raise ValueError('unknown account state') + observed = timestamp(body['observed_at']) + if not 0 <= now-observed <= 5: + raise ValueError('stale account observation') + # An explicit null is a valid no-grant result. Missing field is an old or + # incomplete authority contract, not permission to infer entitlement. + grant = body['root_grant'] + deadline = None + if grant is not None: + if tenant != 'platform' or set(grant) != {'subject','grant','source','observed_at','expires_at'}: + raise ValueError('unsupported grant profile') + if grant['subject'] != {'issuer':issuer,'subject':subject} or grant['grant'] != GRANT: + raise ValueError('grant identity or scope mismatch') + source = grant['source'] + if (set(source) != {'record_id','revision'} or source['record_id'] != 'nk-custody:platform-root' + or type(source['revision']) is not int or source['revision'] < 1): + raise ValueError('invalid custody provenance') + grant_time = timestamp(grant['observed_at']) + deadline = timestamp(grant['expires_at']) + if not 0 <= now-grant_time <= 5 or not now < deadline <= grant_time+900: + raise ValueError('stale custody observation') + observed = min(observed, grant_time) + evidence = {key: body[key] for key in ('issuer','subject','tenant','identity_id','user_id', + 'account_id','decision_id','account_status','tenant_account_status','observed_at','root_grant')} + digest = hashlib.sha256(json.dumps(evidence,sort_keys=True,separators=(',',':')).encode()).hexdigest() + return AccountObservation(issuer,subject,tenant, + body['account_status'] == body['tenant_account_status'] == 'active', + grant is not None, observed, 'user-engine:sha256:'+digest, valid_until=deadline) + + +class UserEngineAccountReader: + def __init__(self, *, base_url: str, token_file: Path, client: httpx.AsyncClient): + require_https(base_url) + if not isinstance(token_file,Path) or not token_file.is_absolute(): + raise ValueError('absolute dedicated workload token path required') + self.base_url, self.token_file, self.client = base_url.rstrip('/'), token_file, client + + async def read(self, *, issuer: str, subject: str, tenant: str) -> AccountObservation: + try: + async with asyncio.timeout(3): + with self.token_file.open('rb') as stream: + raw = stream.read(16385) + if len(raw) > 16384: + raise ValueError('oversized credential') + token = raw.decode('ascii').strip() + if not token or any(ord(c) < 33 or ord(c) > 126 for c in token): + raise ValueError('invalid credential') + async with self.client.stream('POST',self.base_url+'/api/v1/platform/account-authority/lookup', + json={'issuer':issuer,'subject':subject,'tenant':tenant}, + headers={'Authorization':'Bearer '+token,'Cache-Control':'no-cache', + 'Accept-Encoding':'identity'},timeout=3,follow_redirects=False) as response: + if response.status_code != 200 or response.headers.get('age','0') != '0': + raise ValueError('owner lookup refused or cached') + if response.headers.get('content-encoding','identity') != 'identity': + raise ValueError('encoded authority response') + data = bytearray() + async for chunk in response.aiter_bytes(): + data.extend(chunk) + if len(data) > 65536: + raise ValueError('oversized authority response') + return normalize_account(parse_json(bytes(data)),issuer=issuer,subject=subject, + tenant=tenant,now=time.time()) + except Exception as exc: + raise AccessFailure(503,'account_authority_unavailable_or_untrusted') from exc diff --git a/hub_core/security/boundary.py b/hub_core/security/boundary.py index 4df905a..a918a76 100644 --- a/hub_core/security/boundary.py +++ b/hub_core/security/boundary.py @@ -41,6 +41,7 @@ class LiveFacts: checked_at: float evidence_id: str producer_addresses: frozenset[str] = frozenset() + valid_until: float | None = None def __post_init__(self): for value in (self.account_active, self.actor_tenant_active, @@ -49,6 +50,9 @@ class LiveFacts: raise ValueError("authoritative state must be boolean") if type(self.checked_at) not in {int, float} or not math.isfinite(self.checked_at): raise ValueError("finite fact observation time required") + if self.valid_until is not None and (type(self.valid_until) not in {int,float} + or not math.isfinite(self.valid_until)): + raise ValueError("finite fact authority deadline required") for value in (self.issuer, self.subject, self.actor_tenant, self.target_tenant, self.evidence_id, *self.producer_addresses): if not isinstance(value, str) or not value: @@ -203,7 +207,7 @@ class AccessController: self._check_decision_deadline(decision) if actor.expires_at <= time.time(): raise AccessFailure(401, "expired_access_token") - if time.time() - facts.checked_at > 5: + if time.time() - facts.checked_at > 5 or (facts.valid_until is not None and time.time() >= facts.valid_until): self._observations["facts"] = ("unavailable", time.monotonic()) raise AccessFailure(503, "facts_expired_during_authorization") return replace(context, decision_id=decision.decision_id, @@ -220,6 +224,8 @@ class AccessController: actor.issuer, actor.subject, actor.tenant ) or not 0 <= time.time() - facts.checked_at <= 5 or not facts.evidence_id: raise AccessFailure(503, "untrusted_or_stale_facts") + if facts.valid_until is not None and time.time() >= facts.valid_until: + raise AccessFailure(503, "expired_owner_facts") return facts diff --git a/hub_core/security/facts.py b/hub_core/security/facts.py index 513e067..d318c52 100644 --- a/hub_core/security/facts.py +++ b/hub_core/security/facts.py @@ -28,6 +28,7 @@ class AccountObservation: checked_at: float evidence_id: str producer_addresses: frozenset[str] = frozenset() + valid_until: float | None = None @dataclass(frozen=True) @@ -91,6 +92,10 @@ class PlatformFacts: or not math.isfinite(observed.checked_at) or not 0 <= now-observed.checked_at <= 5): raise ValueError('stale owner observation') + if account.valid_until is not None and ( + type(account.valid_until) not in {int,float} + or not math.isfinite(account.valid_until) or now >= account.valid_until): + raise ValueError('expired owner authority') if not isinstance(account.producer_addresses,frozenset): raise ValueError('immutable producer bindings required') for address in account.producer_addresses: @@ -102,7 +107,7 @@ class PlatformFacts: return LiveFacts(actor.issuer,actor.subject,actor.tenant,'tenant:platform', account.account_active,tenant.active,tenant.active, account.root_entitled,min(account.checked_at,tenant.checked_at), - evidence,account.producer_addresses) + evidence,account.producer_addresses,account.valid_until) except Exception as exc: raise AccessFailure(503,'owner_facts_unavailable_or_untrusted') from exc diff --git a/tests/test_account_reader.py b/tests/test_account_reader.py new file mode 100644 index 0000000..f99cc4a --- /dev/null +++ b/tests/test_account_reader.py @@ -0,0 +1,141 @@ +import asyncio +import time +from datetime import datetime, timezone +from types import SimpleNamespace + +import httpx +import pytest + +from hub_core.security.account_reader import GRANT, UserEngineAccountReader, normalize_account +from hub_core.security.facts import PlatformFacts, TenantObservation +from hub_core.security.identity import AccessFailure +from hub_core.security import boundary +from test_access_boundary import Owners + + +def iso(value): + return datetime.fromtimestamp(value,timezone.utc).isoformat() + + +def body(now): + return dict(issuer='https://issuer.example',subject='immutable-root',tenant='platform', + identity_id='identity:1',user_id='user:1',account_id='account:1',decision_id='decision:1', + account_status='active',tenant_account_status='active',observed_at=iso(now),root_grant={ + 'subject':{'issuer':'https://issuer.example','subject':'immutable-root'}, + 'grant':dict(GRANT),'source':{'record_id':'nk-custody:platform-root','revision':1}, + 'observed_at':iso(now),'expires_at':iso(now+900)}) + + +def normalize(value,now): + return normalize_account(value,issuer='https://issuer.example',subject='immutable-root', + tenant='platform',now=now) + + +def test_current_owner_shape_preserves_time_scope_and_no_grant(): + now=float(int(time.time())) + value=body(now) + value['root_grant']['observed_at']=iso(now-2) + value['root_grant']['expires_at']=iso(now+10) + result=normalize(value,now) + assert result.root_entitled and result.account_active + assert result.checked_at == pytest.approx(now-2) + assert result.valid_until == pytest.approx(now+10) + assert not result.producer_addresses + value['root_grant']=None + withdrawn=normalize(value,now) + assert not withdrawn.root_entitled and withdrawn.valid_until is None + assert withdrawn.evidence_id != result.evidence_id + + +@pytest.mark.parametrize('status',['disabled','suspended','deleted','invited','deletion_pending',None]) +def test_inactive_or_missing_scoped_account_never_active(status): + now=float(int(time.time())); value=body(now); value['tenant_account_status']=status + assert not normalize(value,now).account_active + + +@pytest.mark.parametrize('mutate',[ + lambda b: b.update(subject='other'), lambda b: b.update(issuer='other'), + lambda b: b.update(tenant='other'), lambda b: b.update(decision_id=None), + lambda b: b.update(account_status=True), lambda b: b.update(tenant_account_status='unknown'), + lambda b: b.update(observed_at=iso(time.time()-6)), + lambda b: b.update(observed_at=iso(time.time()+6)), + lambda b: b.update(observed_at='2026-10-04T10:00:00'), + lambda b: b.pop('root_grant'), + lambda b: b['root_grant']['subject'].update(subject='other'), + lambda b: b['root_grant']['grant'].update(scope_id='other'), + lambda b: b['root_grant']['grant'].update(owner_system='user-engine'), + lambda b: b['root_grant']['source'].update(revision=True), + lambda b: b['root_grant']['source'].update(revision=0), + lambda b: b['root_grant']['source'].update(record_id='other'), + lambda b: b['root_grant'].update(observed_at=iso(time.time()-6)), + lambda b: b['root_grant'].update(expires_at=iso(time.time()-1)), + lambda b: b['root_grant'].update(expires_at=iso(time.time()+901)), + lambda b: b['root_grant'].update(revoked_at=iso(time.time())), +]) +def test_mismatched_stale_or_incomplete_owner_facts_refuse(mutate): + now=float(int(time.time())); value=body(now); mutate(value) + with pytest.raises((ValueError,KeyError,TypeError)): + normalize(value,now) + + +def test_http_rotates_credential_and_rechecks_withdrawal(tmp_path): + token=tmp_path/'workload'; token.write_text('fixture-first') + seen=[]; value=body(time.time()) + def respond(request): + import json + assert request.url.path == '/api/v1/platform/account-authority/lookup' + assert json.loads(request.content) == dict(issuer=value['issuer'],subject=value['subject'],tenant=value['tenant']) + seen.append(request.headers['authorization']) + return httpx.Response(200,json=value) + async def run(): + async with httpx.AsyncClient(transport=httpx.MockTransport(respond),trust_env=False) as client: + reader=UserEngineAccountReader(base_url='https://owner.example',token_file=token,client=client) + args={key:value[key] for key in ('issuer','subject','tenant')} + assert (await reader.read(**args)).root_entitled + token.write_text('fixture-second'); value['root_grant']=None + assert not (await reader.read(**args)).root_entitled + token.unlink() + with pytest.raises(AccessFailure): await reader.read(**args) + asyncio.run(run()) + assert seen == ['Bearer fixture-first','Bearer fixture-second'] + + +@pytest.mark.parametrize('response',[ + httpx.Response(302,headers={'Location':'https://other.example'}), + httpx.Response(403,text='private details'), httpx.Response(404), httpx.Response(503), + httpx.Response(200,content=b'x'*65537), httpx.Response(200,content=b'{"a":1,"a":2}'), + httpx.Response(200,json={},headers={'Age':'1'}), +]) +def test_http_refuses_redirect_cache_outage_or_invalid_body(tmp_path,response): + token=tmp_path/'workload'; token.write_text('fixture') + calls=[] + def respond(request): calls.append(request); return response + async def run(): + async with httpx.AsyncClient(transport=httpx.MockTransport(respond),trust_env=False) as client: + reader=UserEngineAccountReader(base_url='https://owner.example',token_file=token,client=client) + with pytest.raises(AccessFailure,match='account_authority_unavailable_or_untrusted'): + await reader.read(issuer='iss',subject='sub',tenant='platform') + asyncio.run(run()); assert len(calls)==1 + + +def test_grant_deadline_survives_join_and_blocks_after_audit(monkeypatch): + now=float(int(time.time())); value=body(now); value['root_grant']['expires_at']=iso(now+1) + account=normalize(value,now) + class Accounts: + async def read(self,**kwargs): return account + class Tenants: + async def read(self,**kwargs): return TenantObservation('tenant:platform',True,now,'tenant:1') + owners=Owners(); controller=owners.controller() + controller.facts=PlatformFacts(accounts=Accounts(),tenants=Tenants(), + account_tenant='platform',mapping_evidence='NK-ADR-0017:2') + clock=SimpleNamespace(time=lambda:now,monotonic=time.monotonic) + monkeypatch.setattr(boundary,'time',clock) + original=owners.append + async def append(record): + await original(record); clock.time=lambda: now+2 + owners.append=append + async def run(): + with pytest.raises(AccessFailure,match='facts_expired_during_authorization'): + await controller.authorize('verified-root','hub.read','/docs','correlation','digest') + assert controller.readiness_checks()['access_facts']=='unavailable' + asyncio.run(run()) diff --git a/tests/test_user_engine_owner_contract.py b/tests/test_user_engine_owner_contract.py new file mode 100644 index 0000000..c666889 --- /dev/null +++ b/tests/test_user_engine_owner_contract.py @@ -0,0 +1,60 @@ +"""Opt-in real User Engine HTTP source; all identities/grants are local fixtures.""" +import asyncio +import os +from pathlib import Path +import sys +import time + +import httpx +import pytest + +source = os.getenv('HUB_CORE_USER_ENGINE_SOURCE') +if not source: + pytest.skip('set HUB_CORE_USER_ENGINE_SOURCE for owner-source contract tests',allow_module_level=True) +sys.path.insert(0,str(Path(source)/'src')) + +from user_engine.adapters.local import InMemoryUserEngineStore, LocalAuthorizationCheckPort +from user_engine.service import UserEngineService +from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims +from user_engine.web import PortalApplication +from hub_core.security.account_reader import UserEngineAccountReader +from hub_core.security.identity import AccessFailure +from test_account_reader import body + + +@pytest.mark.parametrize('grant_present',[False,True]) +def test_real_owner_route_serialization_and_unknown_identity(tmp_path,grant_present): + from dataclasses import replace + from user_engine.domain import PrincipalType + fixture=body(float(int(time.time()))) + class Custody: + def observe(self,**kwargs): return fixture['root_grant'] if grant_present else None + class FixturePolicy(LocalAuthorizationCheckPort): + def check(self,request): + # Synthetic PDP receipt; standalone owner defaults have no decision ID. + return replace(super().check(request),decision_id='fixture-owner-decision') + store=InMemoryUserEngineStore(); store.migrate() + service=UserEngineService(store=store,identity_adapter=FixtureIdentityClaimsAdapter(), + authorization=FixturePolicy(),custody=Custody()) + claims=human_actor_claims() + claims.update(iss=fixture['issuer'],sub=fixture['subject'],tenant='platform') + session=service.me(claims) + caller=replace(session.actor,subject='fixture-hub-workload',principal_type=PrincipalType.SERVICE) + token='fixture-hub-token-at-least-24-characters' + app=PortalApplication(service,trusted_proxy_secret='fixture-proxy-secret-at-least-24-characters', + login_url='https://issuer.example/login',hub_authority_caller=caller,hub_authority_token=token) + credential=tmp_path/'token'; credential.write_text(token) + before=repr(store.__dict__) + with httpx.Client(transport=httpx.WSGITransport(app=app)) as owner: + def send(request): + response=owner.post(str(request.url),headers=request.headers,content=request.content) + return httpx.Response(response.status_code,headers=response.headers,content=response.content) + async def run(): + async with httpx.AsyncClient(transport=httpx.MockTransport(send),trust_env=False) as client: + reader=UserEngineAccountReader(base_url='https://owner.example',token_file=credential,client=client) + result=await reader.read(issuer=fixture['issuer'],subject=fixture['subject'],tenant='platform') + assert result.account_active and result.root_entitled == grant_present + with pytest.raises(AccessFailure): + await reader.read(issuer=fixture['issuer'],subject='unknown',tenant='platform') + asyncio.run(run()) + assert repr(store.__dict__)==before diff --git a/tools/check_installed_package.py b/tools/check_installed_package.py index ae1e75d..bd656e6 100644 --- a/tools/check_installed_package.py +++ b/tools/check_installed_package.py @@ -15,6 +15,7 @@ from hub_core.runtime.app import create_app from hub_core.runtime.config import RuntimeSettings from hub_core.runtime.inbox_projection import create_inbox_projection_router from hub_core.security.boundary import iter_routes, route_key +from hub_core.security.account_reader import UserEngineAccountReader from hub_core.security.browser import BROWSER_ROUTES, create_browser_router diff --git a/workplans/HUB-WP-0006-repository-classification-navigation.md b/workplans/HUB-WP-0006-repository-classification-navigation.md index cabfc27..a58be26 100644 --- a/workplans/HUB-WP-0006-repository-classification-navigation.md +++ b/workplans/HUB-WP-0006-repository-classification-navigation.md @@ -9,7 +9,7 @@ flavor: implementation owner: codex topic_slug: custodian created: "2026-08-22" -updated: "2026-08-22" +updated: "2026-10-04" parent_project: prj-state-hub-retirement parent_workplan: STATE-WP-0079 related: @@ -148,8 +148,8 @@ tests. ```task id: HUB-WP-0006-T06 status: wait -depends_on: [HUB-WP-0006-T02, HUB-WP-0006-T03, HUB-WP-0006-T04, HUB-WP-0006-T05, STATE-WP-0079] -blocking_reason: "Waits on T02-T05 and the State Hub cutover window under STATE-WP-0079." +depends_on: [STATE-WP-0079-T04] +blocking_reason: "Publisher admission is complete; State Hub and selected A5/A4 consumer owners must supply parity cases, caller/alias mapping, a bounded switch window and rollback/traffic receipts." priority: high state_hub_task_id: "7c8225f7-03dc-57b0-a668-c3d781bd828a" ``` @@ -207,3 +207,22 @@ gate closes. - [x] Rebuildable durable ingestion passes provenance and failure checks - [x] HTTP/MCP navigation reads only the derived projection - [ ] A5/A4 consumer comparison and rollback evidence recorded + +## Dependency review — 2026-10-04 + +RAPPCOREHUB-WP-0003-T04/T05 are done. The owner's retained receipt +`rapp-core-hub/docs/evidence/2026-10-03-private-publisher-admission.json` +records Helm revision 31, 123/123 publisher acceptance with zero errors and +`repo_manager_projection: ok`; rollback revision 30. This supersedes the older +credential, registry correction, placement and candidate-admission blockers +above. These are reviewed owner receipts, not a new live probe this session. + +The remaining T06 gate is STATE-WP-0079-T04's A5/A4 consumer cutover, as confirmed +by `state-hub/docs/retirement-cutover-slice-plan.md`. State Hub must enumerate the +actual callers/legacy responses, alias expectations and comparison cases; selected +UI/client owners must provide a candidate read smoke and reversible switch plan. +State Hub retains authority for the cutover window, old-route fallback and metered +acceptance. Resume T06 when that comparison packet exists; use the deployed +projection to run it and attach parity/rollback receipts. Do not count publisher +readiness as a caller switch. All local implementation tasks remain done and this +workplan remains blocked on the external acceptance packet. diff --git a/workplans/HUB-WP-0011-statehub-inbox-freshness-and-cutover.md b/workplans/HUB-WP-0011-statehub-inbox-freshness-and-cutover.md index 3b36a49..1dba3e2 100644 --- a/workplans/HUB-WP-0011-statehub-inbox-freshness-and-cutover.md +++ b/workplans/HUB-WP-0011-statehub-inbox-freshness-and-cutover.md @@ -9,7 +9,7 @@ flavor: residual owner: codex topic_slug: infotech created: "2026-09-05" -updated: "2026-09-27" +updated: "2026-10-04" origin: residual origin_ref: HUB-WP-0010 related: @@ -22,10 +22,11 @@ state_hub_workstream_id: "3c8034fc-fd90-58f5-99bc-99b4f93e5ee1" ```task id: HUB-WP-0011-T01 -status: todo +status: wait flavor: residual priority: high state_hub_task_id: "588e4b58-4694-513c-8e7a-1fab38fb6ce3" +blocking_reason: "State Hub must define a consistent source revision/cursor, read/archive changes, explicit deletions/retention and recovery semantics for the derived inbox; packaging owner must admit refresh/staleness operation." ``` The deployed inbox reader is an explicitly labeled one-time snapshot pilot. @@ -57,6 +58,8 @@ status: wait flavor: residual priority: high state_hub_task_id: "35c5ae8b-f357-512f-af40-bc5915f3c02d" +depends_on: [HUB-WP-0012-T02, HUB-WP-0012-T03, HUB-WP-0012-T05] +blocking_reason: "Selected reader owner must specify principal, recipient/broadcast/alias scope; identity/platform owners must deliver a dedicated workload grant and credential under the shared access contract." ``` Replace pilot operator authentication with the reviewed caller-specific access @@ -80,6 +83,8 @@ status: wait flavor: residual priority: high state_hub_task_id: "61c727a5-f4b1-54c0-b634-7d71a5416496" +depends_on: [HUB-WP-0011-T01, HUB-WP-0011-T02] +blocking_reason: "Needs fresh parity and caller-admission receipts plus a State Hub/client-owner authorized reversible reader switch." ``` After T01/T02 pass, produce live freshness and parity receipts for one State Hub @@ -87,3 +92,21 @@ inbox reader; verify rollback to the current State Hub endpoint. Then execute that bounded reader switch with the concrete deployment authorization. Retire its compatibility dependency only after metered acceptance. Message-writer cutover and all other route families remain separate STATE-WP-0079 work. + +## Dependency review — 2026-10-04 + +T01 is now `wait`, not runnable `todo`. STATE-WP-0079's T08/T09 record confirms +State Hub remains the message writer; that ownership is no longer an unanswered +question. The unprovided contract is how its mutable inbox is replicated: + +| Receiving task | Required owner artifact | Resume/acceptance condition | +| --- | --- | --- | +| T01 | state-hub: versioned consistent snapshot/change-feed format, monotonic revision or cursor, read/archive changes, deletion versus retention semantics, replay/compaction recovery and source timestamp | Implement atomic derived refresh only against that contract; prove replay, out-of-order refusal, deletion treatment and freshness loss without writing source messages | +| T01 | rapp-core-hub: deployment/refresh runner, admitted source-read identity, selected maximum lag and stale-source behavior | Test restart/recovery and publish bounded freshness evidence; historical pilot hash is not freshness | +| T02 | Selected reader owner + NetKingdom/access-engine/platform: exact workload identity/audience/actions, recipient and broadcast visibility, canonical/alias resolution and delivered credential reference | Prove allowed scope and denied other recipients without the pilot operator token | +| T03 | state-hub + selected client owner: named single reader, parity sample/window, rollback endpoint and switch authorization | Rehearse rollback, execute only that switch, then retain metered acceptance | + +No source feed or revised retention contract was found in the reviewed source +handoff. The existing one-shot importer correctly refuses changed rows; no +unconditional overwrite or speculative tombstone policy was added. All three +tasks now wait on explicit inputs, so the workplan remains blocked. diff --git a/workplans/HUB-WP-0012-netkingdom-platform-root-access.md b/workplans/HUB-WP-0012-netkingdom-platform-root-access.md index 7a23e36..0ea2145 100644 --- a/workplans/HUB-WP-0012-netkingdom-platform-root-access.md +++ b/workplans/HUB-WP-0012-netkingdom-platform-root-access.md @@ -10,7 +10,7 @@ owner: codex topic_slug: infotech status_reason: "All open tasks require owner-reviewed contracts, credentials, or live infrastructure outside hub-core; no further source progress is possible from this repo alone." created: "2026-09-28" -updated: "2026-10-03" +updated: "2026-10-04" related: - HUB-WP-0009 - HUB-WP-0011 @@ -58,7 +58,7 @@ status: wait blocked_on: message-from:net-kingdom priority: high state_hub_task_id: "204f4fb0-e240-5558-8790-5985517b85e0" -blocking_reason: "Contract reviewers' (NetKingdom, user-engine, tenant-engine, flex-auth) sign-off on the inventory and disputed rows" +blocking_reason: "NetKingdom, User/Tenant Engine, access-engine and platform owners must review current per-surface action/audience/tenant mappings and return explicit dispositions for unresolved inventory rows." ``` Owner: hub-core; contract reviewers: NetKingdom, user-engine, tenant-engine, @@ -89,11 +89,11 @@ platform-root login or enforcement test is claimed by inventory validation. ```task id: HUB-WP-0012-T02 status: wait -depends_on: [HUB-WP-0012-T01] +depends_on: [HUB-WP-0012-T01, USER-WP-0037-T03] blocked_on: message-from:net-kingdom priority: high state_hub_task_id: "9a955fbf-f289-51b7-9682-bbe471694595" -blocking_reason: "NetKingdom/KeyCape live root login, AAL2 enrollment acceptance, and User/Tenant Engine authenticated HTTP readers (contract published in docs/owner-facts-contract.md, no owner endpoint admitted yet)" +blocking_reason: "Account HTTP source and custody mapping now exist; needs admitted Hub workload transport, Tenant Engine authenticated lifecycle contract, real immutable root/custody grant and attended issuer/AAL2/private-journey receipts." ``` Live admission depends on T01; independently testable source may proceed. Owners: NetKingdom/KeyCape, user-engine and tenant-engine; @@ -118,10 +118,10 @@ step-up implementation is actually required. id: HUB-WP-0012-T03 status: wait depends_on: [HUB-WP-0012-T01] -blocked_on: message-from:flex-auth +blocked_on: message-from:access-engine priority: high state_hub_task_id: "feea8f20-aad4-583b-958a-a8efeb9c133c" -blocking_reason: "flex-auth production PDP deployment and signing-key delivery from platform owners (audit-core-senders routing entry to ops-mason/OpenBao is unresolved; no Hub sender credential minted)" +blocking_reason: "access-engine must admit Hub protected-system policy/caller and signed-decision trust; platform and audit-core must deliver separate Hub PDP/audit credentials, keys and durable receiver admission with failure/rotation receipts." ``` Depends on T01; live acceptance also needs T02. flex-auth owns the protected @@ -147,7 +147,7 @@ status: wait depends_on: [HUB-WP-0012-T02, HUB-WP-0012-T03] priority: high state_hub_task_id: "15bc3cae-4575-56c9-afd2-e1e348109ca9" -blocking_reason: "T02/T03 live admission; private full-root browser/API/MCP journeys need a real root login and PDP, which do not exist yet" +blocking_reason: "Local boundary/client source is implemented; T02/T03 admission plus rapp-core-hub private pinned composition are required for real browser/API/MCP, bypass, revocation and rollback acceptance." ``` Live admission depends on T02/T03; the default-deny source seam may proceed. Add the reusable verified actor/tenant context and local @@ -172,7 +172,7 @@ status: wait depends_on: [HUB-WP-0012-T04] priority: high state_hub_task_id: "6d29854e-e894-5340-9e13-8866e80246f4" -blocking_reason: "T04 live admission" +blocking_reason: "T04 admission and ops-hub/other extension owners must supply authenticated host composition, per-audience caller grants and root/non-root/wrong-tenant acceptance receipts." ``` Depends on T04. Publish the versioned extension security profile and harness. @@ -221,7 +221,7 @@ another service. Any missing backend integration keeps M1 open. ```task id: HUB-WP-0012-T07 status: wait -depends_on: [RAPPCOREHUB-WP-0002-T05] +depends_on: [HUB-WP-0012-T06, RAPPCOREHUB-WP-0002-T05] priority: high state_hub_task_id: "0c15cb82-7c59-5a44-8bf0-857ea4438642" blocking_reason: "M1 acceptance; owner: rapp-core-hub via RAPPCOREHUB-WP-0002-T05" @@ -244,9 +244,11 @@ Ingress just to repair the old public stabilization check ahead of this gate. ```task id: HUB-WP-0012-T08 -status: todo +status: wait priority: medium state_hub_task_id: "59a0672c-560e-5b43-9d83-9f90d498698f" +depends_on: [HUB-WP-0012-T06] +blocking_reason: "Phase 2 starts after M1; requires owner-agreed tenant provenance, delegated actor/workload model and tenant-admin permissions. It is not an M1 prerequisite." ``` Deferred until M1; not an M1 dependency. Hub Core with NetKingdom, User/Tenant @@ -534,7 +536,7 @@ T03 tracks authenticated workload HTTP exposure and private acceptance. Hub's without treating it as a connected HTTP adapter. T01/T02 here remain `progress`. No Tenant Engine source, live credential/grant or deployment was changed. -## Blocked pending owner action — 2026-09-28 +## Historical blocked assessment — 2026-09-28 (superseded below) Reviewed every remaining task against hub-core's own tree for further source-only implementation. `hub_core/security/facts.py` already exposes a typed @@ -562,3 +564,38 @@ change. Planning completion is not implementation completion. No live authenticated root session or entitlement was tested in the 2026-09-28 review. + +## Blocked-task review and local implementation — 2026-10-04 + +The September assertion that no account HTTP contract exists was stale. Reviewed +User Engine `a9360842` (USER-WP-0037-T02 done; T03 waiting), accepted +NK-ADR-0017 revision 2 and NetKingdom `45a189a3`. User Engine now exposes +`POST /api/v1/platform/account-authority/lookup` and returns optional current +custody evidence. Its October 3 live acceptance established an empty no-grant +source, not an actual root grant or an admitted Hub caller. + +Implemented `UserEngineAccountReader` against that exact route: distinct rotating +workload credential file, non-local HTTPS, bounded response/deadline, no redirects +or cached-response fallback, exact issuer/subject/account state binding, explicit +null-grant denial, and strict custody owner/scope/source/revision/time checks. +The owner's current lookup tenant is `platform`; its grant scope is `platform`, +while Hub's actor/target tenant remains `tenant:platform`. Host mapping remains +explicit. No inferred `platform:root` substitution, producer alias or token role +grants authority. Account and custody observation ages remain bounded to five +seconds; custody expiry survives the facts join and is rechecked after audit. + +No live credential was read and no endpoint was enabled or deployed. The remaining +owner requirements and exact resume artifacts are in +[the updated owner contract](../docs/owner-facts-contract.md#remaining-dependencies--2026-10-04). +T01-T08 are all `wait` after completing this available local slice, and the +workplan stays `blocked`. In particular, T08 cannot appear runnable before M1. +Existing signing/fact-provenance source work FLEX-WP-0024/0025 is finished; the +T03 dependency is Hub-specific access-engine admission, not reimplementation of +those finished workplans. A broad credential decision alone does not satisfy T06. + +Validation for this review: `make ci-check` passed **469 ordinary tests** +(three opt-in modules skipped), inventory checks, build/isolated-wheel checks +and **six disposable PostgreSQL tests**. The explicitly enabled User Engine +source integration passed **two tests** against its real WSGI route/serializer +with fixture credentials, custody and a synthetic owner policy receipt. These +are local source-contract tests, not admission or positive live-root evidence.