From f11b948e8c8b7286d9442058aa9b2111d24662b1 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 28 Sep 2026 15:56:52 +0200 Subject: [PATCH] fix: recheck policy decision expiry after audit custody Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929 --- docs/access-profile-v1.md | 11 ++- docs/access-readiness.md | 3 +- hub_core/security/boundary.py | 10 +++ hub_core/security/policy.py | 9 ++- tests/test_access_boundary.py | 4 +- tests/test_decision_deadline.py | 76 +++++++++++++++++++ ...WP-0012-netkingdom-platform-root-access.md | 21 +++++ 7 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 tests/test_decision_deadline.py diff --git a/docs/access-profile-v1.md b/docs/access-profile-v1.md index 10b7311..bdfbc05 100644 --- a/docs/access-profile-v1.md +++ b/docs/access-profile-v1.md @@ -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 diff --git a/docs/access-readiness.md b/docs/access-readiness.md index 3d22560..b1b34ca 100644 --- a/docs/access-readiness.md +++ b/docs/access-readiness.md @@ -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. diff --git a/hub_core/security/boundary.py b/hub_core/security/boundary.py index 0decaf5..4df905a 100644 --- a/hub_core/security/boundary.py +++ b/hub_core/security/boundary.py @@ -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) != ( diff --git a/hub_core/security/policy.py b/hub_core/security/policy.py index b921564..5e6b34c 100644 --- a/hub_core/security/policy.py +++ b/hub_core/security/policy.py @@ -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: diff --git a/tests/test_access_boundary.py b/tests/test_access_boundary.py index 1c7e6e9..0eee967 100644 --- a/tests/test_access_boundary.py +++ b/tests/test_access_boundary.py @@ -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 diff --git a/tests/test_decision_deadline.py b/tests/test_decision_deadline.py new file mode 100644 index 0000000..122fd76 --- /dev/null +++ b/tests/test_decision_deadline.py @@ -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) diff --git a/workplans/HUB-WP-0012-netkingdom-platform-root-access.md b/workplans/HUB-WP-0012-netkingdom-platform-root-access.md index d2b0856..561f832 100644 --- a/workplans/HUB-WP-0012-netkingdom-platform-root-access.md +++ b/workplans/HUB-WP-0012-netkingdom-platform-root-access.md @@ -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