feat: compose fresh owner facts with explicit binding and evidence
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
parent
f11b948e8c
commit
2a0586b4c9
5 changed files with 358 additions and 0 deletions
|
|
@ -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:
|
||||
|
|
|
|||
85
docs/owner-facts-contract.md
Normal file
85
docs/owner-facts-contract.md
Normal 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
112
hub_core/security/facts.py
Normal 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
129
tests/test_owner_facts.py
Normal 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'))
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue