fix: recheck policy decision expiry after audit custody
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 3s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
tegwick 2026-09-28 15:56:52 +02:00
parent 72f3513954
commit f11b948e8c
7 changed files with 126 additions and 8 deletions

View file

@ -89,8 +89,15 @@ A separate refusal-audit attempt is bounded at three seconds.
The verifier requires Ed25519 signing, the submitted request digest, matching
request ID and structured actor/action/resource/tenant/context, enforced caller
provenance, policy version/digest, a decision age at most 30 seconds and a valid
allow lifetime. It never caches decisions. Every unimplemented obligation and
provenance, policy version/digest, a decision age below 30 seconds and a valid
allow lifetime. `Decision.valid_until` is a required finite epoch deadline, derived
from the earliest signed allow expiry, caller expiry and decision-time freshness
limit. The controller checks it before audit and again after durable acceptance,
before returning authority to HTTP, browser or direct SDK callers. Expiry during
custody returns `503 policy_decision_expired`; an authorization receipt is not
permission to execute after expiry. Custom policy adapters must supply this field
from verified provenance, never a fresh deadline invented at receipt time.
It never caches decisions. Every unimplemented obligation and
non-allow/non-deny effect fails closed; approval requirements cannot be waived.
A malformed/untrusted/unavailable decision returns 503, a verified denial 403,
and invalid authentication 401. Responses are `no-store` and do not expose backend

View file

@ -21,7 +21,8 @@ cancellation mark it unavailable immediately. Successful observations expire to
`stale` after ten seconds using a monotonic clock. Invalid user-token refusals
neither poison a previously healthy identity sample nor refresh its age. Invalid
facts and malformed policy adapter results do not count as success. A fact that
expires while policy/audit run also becomes unavailable. The aggregate is `ok`
expires while policy/audit run also becomes unavailable. A decision that expires
before dispatch marks policy unavailable, even if audit custody succeeded. The aggregate is `ok`
only when all four samples are fresh successes.
These process-local observations are diagnostics, never cached authorization.

View file

@ -73,12 +73,15 @@ class Decision:
allowed: bool
decision_id: str
policy_version: str
valid_until: float
caller: str = ""
signed_envelope: str | None = None
def __post_init__(self):
if type(self.allowed) is not bool or not self.decision_id or not self.policy_version:
raise ValueError("explicit boolean decision and provenance required")
if type(self.valid_until) not in {int, float} or not math.isfinite(self.valid_until):
raise ValueError("finite decision deadline required")
class Identity(Protocol):
@ -182,6 +185,7 @@ class AccessController:
raise AccessFailure(403, "root_entitlement_required")
context = Authorization(actor, action, resource, facts, correlation_id, request_digest)
decision = await self._dependency("policy", lambda: self.policy.evaluate(context))
self._check_decision_deadline(decision)
await self.append_audit({
"profile": PROFILE, "correlation_id": correlation_id,
"issuer": actor.issuer, "subject": actor.subject,
@ -196,6 +200,7 @@ class AccessController:
})
if not decision.allowed:
raise AccessFailure(403, "policy_denied")
self._check_decision_deadline(decision)
if actor.expires_at <= time.time():
raise AccessFailure(401, "expired_access_token")
if time.time() - facts.checked_at > 5:
@ -204,6 +209,11 @@ class AccessController:
return replace(context, decision_id=decision.decision_id,
policy_version=decision.policy_version, policy_caller=decision.caller)
def _check_decision_deadline(self, decision: Decision) -> None:
if time.time() >= decision.valid_until:
self._observations["policy"] = ("unavailable", time.monotonic())
raise AccessFailure(503, "policy_decision_expired")
async def _resolve_facts(self, actor, resource):
facts = await self.facts.resolve(actor, resource)
if (facts.issuer, facts.subject, facts.actor_tenant) != (

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import base64
import hashlib
import json
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from pathlib import Path
import httpx
@ -133,7 +133,7 @@ def verify_decision(envelope: dict, *, request: dict, keys: dict,
if not provenance["policy_version"] or not provenance["policy_package_digest"]:
raise ValueError("missing policy provenance")
age = (now - _time(provenance["decision_time"])).total_seconds()
if not 0 <= age <= 30:
if not 0 <= age < 30:
raise ValueError("stale decision")
if envelope.get("obligations"):
# No obligation is silently treated as satisfied. Owner-specific
@ -141,13 +141,16 @@ def verify_decision(envelope: dict, *, request: dict, keys: dict,
raise ValueError("unsupported decision obligations")
if envelope["effect"] not in {"allow", "deny"}:
raise ValueError("unsupported effect")
deadline = min(_time(caller_record["not_after"]),
_time(provenance["decision_time"]) + timedelta(seconds=30))
if envelope["effect"] == "allow":
lifetime = envelope["lifetime"]
if (lifetime["kind"] != "ttl" or not
_time(lifetime["not_before"]) <= now < _time(lifetime["expires_at"])):
raise ValueError("invalid decision lifetime")
deadline = min(deadline, _time(lifetime["expires_at"]))
return Decision(envelope["effect"] == "allow", envelope["id"], provenance["policy_version"],
caller, go_json(envelope).decode())
deadline.timestamp(), caller, go_json(envelope).decode())
class FlexPolicy:

View file

@ -43,7 +43,7 @@ class Owners:
self.requests.append(request)
if self.policy_down:
raise ConnectionError('private backend details')
return Decision(self.allow, 'decision:1', 'policy:v1')
return Decision(self.allow, 'decision:1', 'policy:v1', time.time()+30)
async def append(self, record):
if self.audit_down:
@ -220,7 +220,7 @@ def test_fact_strings_cannot_be_truthy_grants_and_denials_retain_actor():
with pytest.raises(ValueError):
replace(owners.facts, root_entitled='false')
with pytest.raises(ValueError):
Decision('allow', 'id', 'v1')
Decision('allow', 'id', 'v1', time.time()+30)
owners.actor = replace(owners.actor, subject='ordinary')
with TestClient(runtime(owners)) as client:
assert client.get('/docs', headers=HEADERS).status_code == 403

View file

@ -0,0 +1,76 @@
"""A verified decision must remain usable through audit custody and dispatch."""
import time
from dataclasses import replace
from datetime import timedelta
from types import SimpleNamespace
import pytest
from hub_core.runtime.tables import runtime_messages, runtime_outcome_outbox
from hub_core.security import boundary
from hub_core.security.boundary import Decision
from hub_core.security.policy import verify_decision
from test_access_boundary import HEADERS
from test_access_policy import case, sign
from test_outcome_audit import target, rows, message
@pytest.mark.parametrize('limit', ['decision', 'caller', 'freshness'])
def test_signed_policy_preserves_earliest_verified_deadline(limit):
request,envelope,now = case()
expected = now + timedelta(seconds=30)
if limit == 'decision':
expected = now + timedelta(seconds=1)
envelope['lifetime']['expires_at'] = expected.isoformat()
elif limit == 'caller':
expected = now + timedelta(seconds=2)
envelope['provenance']['caller']['not_after'] = expected.isoformat()
result = verify_decision(envelope,request=request,keys=sign(envelope),
caller='workload:hub',now=now)
assert result.valid_until == expected.timestamp()
@pytest.mark.parametrize('deadline', [None, True, '123', float('nan'), float('inf')])
def test_decision_requires_finite_deadline(deadline):
with pytest.raises(ValueError,match='deadline'):
Decision(True,'decision','policy',deadline)
@pytest.mark.parametrize('elapsed,status', [(0,202), (1,503), (2,503)])
def test_expiry_during_audit_prevents_business_commit(target,monkeypatch,elapsed,status):
client,store,owners,_ = target
now = time.time()
clock = SimpleNamespace(time=lambda: now,monotonic=time.monotonic)
monkeypatch.setattr(boundary,'time',clock)
async def facts(actor,resource):
return replace(owners.facts,checked_at=clock.time())
owners.resolve = facts
evaluate,append = owners.evaluate,owners.append
async def policy(request):
return replace(await evaluate(request),valid_until=now+1)
async def audit(record):
await append(record)
clock.time = lambda: now+elapsed
owners.evaluate,owners.append = policy,audit
response = client.post('/ports/messaging/messages',headers=HEADERS,json=message())
assert response.status_code == status
if status == 503:
assert response.json()['detail'] == 'policy_decision_expired'
assert not rows(store,runtime_messages)
assert not rows(store,runtime_outcome_outbox)
assert [record['outcome'] for record in owners.records] == ['authorized','refused']
assert owners.records[0]['correlation_id'] == owners.records[1]['correlation_id']
else:
assert len(rows(store,runtime_messages)) == len(rows(store,runtime_outcome_outbox)) == 1
def test_already_expired_adapter_decision_is_not_authorized(target):
client,store,owners,_ = target
async def policy(request):
return Decision(True,'expired','policy',1)
owners.evaluate = policy
response = client.post('/ports/messaging/messages',headers=HEADERS,json=message())
assert response.status_code == 503
assert [record['outcome'] for record in owners.records] == ['refused']
assert not rows(store,runtime_messages)
assert not rows(store,runtime_outcome_outbox)

View file

@ -449,6 +449,27 @@ Validation: `make ci-check` passed **399 ordinary tests** (two opt-in modules
skipped), inventory drift, distribution/isolated-wheel checks and **six disposable
PostgreSQL tests**.
## Decision lifetime continuation — 2026-09-28
Closed an authorization timing gap: policy verification previously discarded the
signed decision lifetime, allowing audit custody to finish after a decision had
expired. `Decision.valid_until` now carries the earliest signed allow expiry,
caller expiry and decision freshness deadline. It is mandatory and finite for
all policy adapters. The shared controller checks it before audit and again
before returning an allow to HTTP/browser/SDK callers. Expiry returns `503` and
marks policy readiness unavailable; the existing authorization receipt remains
an attempt, with no business mutation or committed outcome.
Local regression tests exercise each signed deadline source, invalid adapter
deadlines, already-expired decisions, exact-boundary expiry during custody and
a valid write. The [candidate profile](../docs/access-profile-v1.md) documents
the adapter contract change. T03/T04 remain `progress`; this does not establish
live owner admission or deployment acceptance.
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.
## Acceptance checkpoints
- [x] Architecture/source/runtime review captured; new implementation owner is hub-core