fix: recheck policy decision expiry after audit custody
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e747-8f27-7242-8df8-8bc44f88c929
This commit is contained in:
parent
72f3513954
commit
f11b948e8c
7 changed files with 126 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) != (
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
76
tests/test_decision_deadline.py
Normal file
76
tests/test_decision_deadline.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue