From 2a0586b4c9c8fff1274e00d09f0fa1baa2248506 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 28 Sep 2026 16:27:27 +0200 Subject: [PATCH] feat: compose fresh owner facts with explicit binding and evidence Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929 --- docs/owner-access-integration.md | 5 + docs/owner-facts-contract.md | 85 ++++++++++++ hub_core/security/facts.py | 112 +++++++++++++++ tests/test_owner_facts.py | 129 ++++++++++++++++++ ...WP-0012-netkingdom-platform-root-access.md | 27 ++++ 5 files changed, 358 insertions(+) create mode 100644 docs/owner-facts-contract.md create mode 100644 hub_core/security/facts.py create mode 100644 tests/test_owner_facts.py diff --git a/docs/owner-access-integration.md b/docs/owner-access-integration.md index 130df31..6ddd30a 100644 --- a/docs/owner-access-integration.md +++ b/docs/owner-access-integration.md @@ -32,6 +32,11 @@ production `FactSource` is configured. The runtime will not load fixture facts, query owner databases directly, forward Hub bearer tokens to other audiences, 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. + ## Audit Core adapter and sender admission `AuditCoreSink` implements the actual `docs/event-envelope.md` contract: diff --git a/docs/owner-facts-contract.md b/docs/owner-facts-contract.md new file mode 100644 index 0000000..e9beb70 --- /dev/null +++ b/docs/owner-facts-contract.md @@ -0,0 +1,85 @@ +# 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**. + +## Reviewed source + +- User Engine `b4cef6446bc10555b598ea74de1743bc9b03e7ba`: + `service.py::me` creates user/account/tenant-account/identity records for unknown + `(issuer, subject)`. It cannot be an authorization lookup. `identity_context` + reads linked identity/account and membership evidence without this provisioning, + but accepts an actor or target user ID, not an admitted Hub workload's lookup of + an arbitrary immutable identity. It does not establish Hub's root entitlement. + `PLATFORM_TENANT` is `platform:root`; token `platform-operator` roles are not + evidence of a live root grant. +- Tenant Engine `4ce89969c0cc0ab4ba111f1b4009aa137c94a68b`: + `GET /tenants/{tenant_id}` exposes lifecycle/version and accepts an `actor` + query for authorization. `/roles/live` returns tenant roles, not a person's + root entitlement. These route handlers do not authenticate a Hub workload. + An admitted authenticating gateway could supply that boundary, but no such + composition is established by these sources. + +These are source findings, not probes of deployed configurations. No owner +repository, database, grant or credential was modified. + +## Contract for owner disposition + +| Requirement | Owning decision | +| --- | --- | +| Side-effect-free lookup by exact issuer and subject | User Engine: existing identity only; unknown identity denies without provisioning | +| Current account state and explicit root grant | User Engine + NetKingdom: identity link, global and scoped account state, grant source/revocation/version, independent of JWT roles and Hub root-subject configuration | +| Platform naming | Both owners: explicit reviewed mapping of User Engine scope to canonical `tenant:platform`; no string substitution inferred by Hub | +| Current tenant lifecycle | Tenant Engine: canonical identifier bound to immutable record and version | +| Caller authentication | Each owner: dedicated Hub workload audience/identity and narrowly scoped read authorization; no end-user bearer forwarding | +| Freshness and evidence | Each owner: source observation timestamp and durable evidence/version reference; no stale cache on failure | +| Producer aliases | Account/identity owner: explicit current bindings, or an empty set; no aliases derived from display names | + +Owners must specify the actual route, request/response schema, authentication +mechanism and scoped grants before HTTP reader implementations can be written. +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 + +`hub_core.security.facts.PlatformFacts` implements `FactSource` with injected +`AccountReader` and `TenantReader`. The account reader returns a typed +`AccountObservation`; the tenant reader returns a `TenantObservation`. Readers +own authentication and wire verification. `account_active` must include the +current identity link, global account and required scoped-account validity; a +globally active account alone is insufficient. They receive identity references only, +not the end-user token. No concrete network reader or automatic environment +activation is provided. + +The host supplies the reviewed account-tenant identifier and mapping evidence +reference explicitly. That mapping is not an entitlement grant. This candidate +admits only human actors in `tenant:platform`; workload and cross-tenant lookups +remain separately scoped work. The shared controller still verifies the exact +root issuer/subject before consulting this source. + +Every request rereads both owners within one three-second budget. Results must +match exact identity/tenant bindings, contain strict booleans and nonempty evidence, +and be no older than five seconds or in the future. The result retains the oldest +source timestamp, so slow joining/policy/audit cannot refresh authority. Inactive +or withdrawn state remains a valid observation and is denied by the controller. +Failures expose only a sanitized 503 and never fall back to an earlier allow. +Cancellation propagates to the pending lookup. + +The evidence join is SHA-256 over canonical JSON containing the account, tenant +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 + +Local tests cover exact bindings, malformed state, stale/future observations, +oldest-timestamp preservation, bounded outages, repeated lookup, entitlement and +account suspension, tenant inactivity, and refusal before policy after withdrawal. +Fixtures implement the internal readers; they do not prove owner API acceptance. + +T01/T02 remain open for owner disposition of the table above, implementation of +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. diff --git a/hub_core/security/facts.py b/hub_core/security/facts.py new file mode 100644 index 0000000..513e067 --- /dev/null +++ b/hub_core/security/facts.py @@ -0,0 +1,112 @@ +"""Join admitted owner observations; this module defines no owner HTTP API. + +Reader implementations must authenticate their own workload and return current, +side-effect-free observations. A mapping reference is owner review evidence, +not an entitlement grant. Neither token roles nor static root allowlists suffice. +""" +from __future__ import annotations + +import asyncio +import hashlib +import json +import math +import time +from dataclasses import dataclass +from typing import Protocol + +from hub_core.security.boundary import LiveFacts +from hub_core.security.identity import AccessFailure, Actor + + +@dataclass(frozen=True) +class AccountObservation: + issuer: str + subject: str + tenant: str + account_active: bool + root_entitled: bool + checked_at: float + evidence_id: str + producer_addresses: frozenset[str] = frozenset() + + +@dataclass(frozen=True) +class TenantObservation: + identifier: str + active: bool + checked_at: float + evidence_id: str + + +class AccountReader(Protocol): + async def read(self, *, issuer: str, subject: str, tenant: str) -> AccountObservation: + """Authenticated read; unknown identities must not be provisioned.""" + ... + + +class TenantReader(Protocol): + async def read(self, *, identifier: str) -> TenantObservation: + """Authenticated current lifecycle lookup with owner evidence.""" + ... + + +class PlatformFacts: + """Root-first, platform-only composition with no authority cache. + + account_tenant and mapping_evidence require explicit owner-reviewed mapping + to tenant:platform. They carry no default alias or implicit role translation. + Workload and cross-tenant admission need a separately reviewed contract. + """ + def __init__(self, *, accounts: AccountReader, tenants: TenantReader, + account_tenant: str, mapping_evidence: str): + _reference(account_tenant) + _reference(mapping_evidence) + self.accounts, self.tenants = accounts, tenants + self.account_tenant, self.mapping_evidence = account_tenant, mapping_evidence + + async def resolve(self, actor: Actor, resource: str) -> LiveFacts: + if actor.principal_type != 'human' or actor.tenant != 'tenant:platform': + raise AccessFailure(403, 'unsupported_facts_principal') + try: + # A single budget includes both observations. Sequential reads avoid + # orphaned work on failure; preserve the oldest source timestamp. + async with asyncio.timeout(3): + account = await self.accounts.read(issuer=actor.issuer,subject=actor.subject, + tenant=self.account_tenant) + tenant = await self.tenants.read(identifier='tenant:platform') + if not isinstance(account,AccountObservation) or not isinstance(tenant,TenantObservation): + raise ValueError('typed owner observations required') + if (account.issuer,account.subject,account.tenant) != ( + actor.issuer,actor.subject,self.account_tenant): + raise ValueError('account binding mismatch') + if tenant.identifier != 'tenant:platform': + raise ValueError('tenant binding mismatch') + for value in (account.account_active,account.root_entitled,tenant.active): + if type(value) is not bool: + raise ValueError('explicit owner state required') + now = time.time() + for observed in (account,tenant): + _reference(observed.evidence_id) + if (type(observed.checked_at) not in {int,float} + or not math.isfinite(observed.checked_at) + or not 0 <= now-observed.checked_at <= 5): + raise ValueError('stale owner observation') + if not isinstance(account.producer_addresses,frozenset): + raise ValueError('immutable producer bindings required') + for address in account.producer_addresses: + _reference(address) + evidence = 'sha256:' + hashlib.sha256(json.dumps({ + 'account':account.evidence_id,'tenant':tenant.evidence_id, + 'mapping':self.mapping_evidence, + },sort_keys=True,separators=(',',':')).encode()).hexdigest() + 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) + except Exception as exc: + raise AccessFailure(503,'owner_facts_unavailable_or_untrusted') from exc + + +def _reference(value): + if not isinstance(value,str) or not value.strip(): + raise ValueError('nonempty owner reference required') diff --git a/tests/test_owner_facts.py b/tests/test_owner_facts.py new file mode 100644 index 0000000..e38d6bb --- /dev/null +++ b/tests/test_owner_facts.py @@ -0,0 +1,129 @@ +import asyncio +import time +from dataclasses import replace + +import pytest + +from hub_core.security.facts import AccountObservation, TenantObservation, PlatformFacts +from hub_core.security.identity import AccessFailure +from test_access_boundary import Owners + + +class Readers: + def __init__(self): + self.actor = Owners().actor + self.account = AccountObservation(self.actor.issuer,self.actor.subject,'owner:platform', + True,True,time.time(),'account:1',frozenset({'agent:root'})) + self.tenant = TenantObservation('tenant:platform',True,time.time(),'tenant:1') + self.calls = [] + + async def read(self,**kwargs): + self.calls.append(kwargs) + return self.tenant if 'identifier' in kwargs else self.account + + def facts(self): + return PlatformFacts(accounts=self,tenants=self,account_tenant='owner:platform', + mapping_evidence='review:platform-mapping') + + +def test_live_join_preserves_oldest_observation_and_detects_revocation(): + reader = Readers() + facts = reader.facts() + async def run(): + first = await facts.resolve(reader.actor,'/docs') + assert first.checked_at == min(reader.account.checked_at,reader.tenant.checked_at) + assert first.root_entitled and first.account_active and first.target_tenant_active + assert first.producer_addresses == frozenset({'agent:root'}) + reader.account = replace(reader.account,root_entitled=False,evidence_id='account:2') + second = await facts.resolve(reader.actor,'/docs') + assert not second.root_entitled and second.evidence_id != first.evidence_id + reader.account = replace(reader.account,account_active=False) + reader.tenant = replace(reader.tenant,active=False) + third = await facts.resolve(reader.actor,'/docs') + assert not third.account_active and not third.actor_tenant_active and not third.target_tenant_active + asyncio.run(run()) + assert len(reader.calls) == 6 + assert reader.calls[0] == dict(issuer=reader.actor.issuer,subject=reader.actor.subject,tenant='owner:platform') + + +@pytest.mark.parametrize('source,changes',[ + ('account',{'subject':'other'}),('account',{'issuer':'other'}), + ('account',{'tenant':'tenant:platform'}),('tenant',{'identifier':'other'}), + ('account',{'account_active':'true'}),('account',{'root_entitled':1}), + ('tenant',{'active':None}),('account',{'checked_at':1}), + ('tenant',{'checked_at':float('nan')}),('account',{'checked_at':float('inf')}), + ('account',{'checked_at':time.time()+100}),('tenant',{'evidence_id':''}), + ('account',{'producer_addresses':['agent:root']}), +]) +def test_malformed_mismatched_or_stale_observation_refuses(source,changes): + reader = Readers() + setattr(reader,source,replace(getattr(reader,source),**changes)) + with pytest.raises(AccessFailure,match='owner_facts_unavailable_or_untrusted'): + asyncio.run(reader.facts().resolve(reader.actor,'/docs')) + + +def test_owner_failure_has_no_cached_fallback_or_private_error(): + reader = Readers() + facts = reader.facts() + asyncio.run(facts.resolve(reader.actor,'/docs')) + async def unavailable(**kwargs): + raise RuntimeError('private owner details') + reader.read = unavailable + with pytest.raises(AccessFailure) as error: + asyncio.run(facts.resolve(reader.actor,'/docs')) + assert error.value.status == 503 and 'private' not in str(error.value) + + +@pytest.mark.parametrize('changes',[{'principal_type':'service'},{'tenant':'tenant:other'}]) +def test_unadmitted_principal_never_calls_owners(changes): + reader = Readers() + with pytest.raises(AccessFailure) as error: + asyncio.run(reader.facts().resolve(replace(reader.actor,**changes),'/docs')) + assert error.value.status == 403 and not reader.calls + + +def test_slow_join_does_not_refresh_account_observation(monkeypatch): + from types import SimpleNamespace + from hub_core.security import facts as module + reader = Readers() + now = time.time() + reader.account = replace(reader.account,checked_at=now-4.5) + reader.tenant = replace(reader.tenant,checked_at=now) + clock = SimpleNamespace(time=lambda: now) + monkeypatch.setattr(module,'time',clock) + async def read(**kwargs): + if 'identifier' in kwargs: + clock.time = lambda: now+1 + return reader.tenant + return reader.account + reader.read = read + with pytest.raises(AccessFailure): + asyncio.run(reader.facts().resolve(reader.actor,'/docs')) + + +@pytest.mark.parametrize('source,changes',[ + ('account',{'root_entitled':False}),('account',{'account_active':False}), + ('tenant',{'active':False}), +]) +def test_next_authorization_denies_withdrawn_owner_state(source,changes): + reader = Readers() + owners = Owners() + controller = owners.controller() + controller.facts = reader.facts() + async def run(): + await controller.authorize('verified-root','hub.read','/docs','first','digest') + setattr(reader,source,replace(getattr(reader,source),**changes)) + with pytest.raises(AccessFailure) as error: + await controller.authorize('verified-root','hub.read','/docs','next','digest') + assert error.value.status == 403 + assert len(owners.requests) == 1 + asyncio.run(run()) + + +def test_hung_owner_read_is_bounded(): + reader = Readers() + async def hung(**kwargs): + await asyncio.Event().wait() + reader.read = hung + with pytest.raises(AccessFailure,match='owner_facts_unavailable_or_untrusted'): + asyncio.run(reader.facts().resolve(reader.actor,'/docs')) diff --git a/workplans/HUB-WP-0012-netkingdom-platform-root-access.md b/workplans/HUB-WP-0012-netkingdom-platform-root-access.md index 561f832..6ae10ac 100644 --- a/workplans/HUB-WP-0012-netkingdom-platform-root-access.md +++ b/workplans/HUB-WP-0012-netkingdom-platform-root-access.md @@ -470,6 +470,33 @@ Validation: **411 ordinary tests**, **six disposable PostgreSQL tests** and **six Audit Core owner-source tests** pass. The full `make ci-check` inventory, build and isolated installed-wheel gates pass. +## Owner facts composition — 2026-09-28 + +Re-reviewed User Engine `b4cef644` and Tenant Engine `4ce89969`. User Engine's +read-only `identity_context` service method is useful, but no admitted Hub +workload identity lookup/current root-grant contract is established. `/me` +still provisions unknown identities. Tenant reads still authorize asserted actor +query text without authenticating the Hub caller in the reviewed handlers. + +Published a [concrete owner disposition contract](../docs/owner-facts-contract.md) +covering read-only identity lookup, global/scoped account and entitlement state, +explicit platform mapping, tenant lifecycle, dedicated caller authentication, +producer bindings, timestamps and evidence. Added `PlatformFacts`, an internal +composition of injected typed account/tenant readers, with no invented owner HTTP +endpoint. It performs fresh bounded reads, validates exact bindings/state, +preserves the oldest source observation and joins owner/mapping evidence. +Tests prove next-request withdrawal/suspension/inactivity denial before policy, +no cached fallback, and refusal of malformed/stale or unsupported observations. + +T01/T02 remain `progress`: owner HTTP readers, grants, mapping disposition and +live acceptance are still missing. No owner source, credential, database or +production deployment was changed. This is preparation for an admitted adapter, +not a working connection to User Engine or Tenant Engine. + +Validation: **433 ordinary tests** and **six disposable PostgreSQL tests** pass; +full `make ci-check` inventory/build/isolated-wheel checks pass. The facts module +is included in the distribution. + ## Acceptance checkpoints - [x] Architecture/source/runtime review captured; new implementation owner is hub-core