feat: compose fresh owner facts with explicit binding and evidence
Some checks failed
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / pytest-smoke (push) Failing after 2s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
tegwick 2026-09-28 16:27:27 +02:00
parent f11b948e8c
commit 2a0586b4c9
5 changed files with 358 additions and 0 deletions

View file

@ -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:

View file

@ -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.

112
hub_core/security/facts.py Normal file
View file

@ -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')

129
tests/test_owner_facts.py Normal file
View file

@ -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'))

View file

@ -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