feat: consume account authority and refresh blocked dependencies
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
parent
6c7c899d75
commit
4153ad7305
11 changed files with 498 additions and 25 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
101
hub_core/security/account_reader.py
Normal file
101
hub_core/security/account_reader.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
141
tests/test_account_reader.py
Normal file
141
tests/test_account_reader.py
Normal file
|
|
@ -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())
|
||||
60
tests/test_user_engine_owner_contract.py
Normal file
60
tests/test_user_engine_owner_contract.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue