Implement TEN-WP-0011 security layer conformance
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 37s
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 37s
Engine/PIP declaration is now checkable (layer.yaml plus a Tooling-client scan). Writes persist a decision record or the published fail-closed stance, live-lookup freshness is published, events_for is tenant-scoped, and mutation evidence drains to audit-core from a local outbox without blocking the mutation. Sender registration is requested as AUDIT-IN-0002. Boundary-contract amendment is requested as NET-IN-0002. Assistant: grok Assistant-Session: 01a04cea-e5e8-7081-a0fc-808ebbc35fa9
This commit is contained in:
parent
80961af91e
commit
672cf4da6e
40 changed files with 2285 additions and 361 deletions
106
src/tenant_engine/audit_core.py
Normal file
106
src/tenant_engine/audit_core.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"""Attributive emission to audit-core from the local outbox.
|
||||
|
||||
Trade (statute §9.6): mutation evidence is attributive. Emission is
|
||||
atomic with the local outbox (crash between mutation and insert is
|
||||
prevented). Drain to audit-core is after commit and MUST NOT fail a
|
||||
mutation. Completeness is not claimed. See docs/evidence-emission.md.
|
||||
|
||||
This module has no SQL against audit-core's store and no credential
|
||||
for it beyond a sender token used to POST /v1/events. The external
|
||||
copy cannot be rewritten through tenant-engine's runtime database
|
||||
credential because we do not hold that credential.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
|
||||
SCHEMA_VERSION = "audit-core.event.v1alpha1"
|
||||
SOURCE = "tenant-engine"
|
||||
|
||||
|
||||
def new_event_id() -> str:
|
||||
return str(uuid4())
|
||||
|
||||
|
||||
def envelope_for(
|
||||
*,
|
||||
event_id: str,
|
||||
event_type: str,
|
||||
tenant_id: str,
|
||||
observed_at: str,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"event_id": event_id,
|
||||
"observed_at": observed_at,
|
||||
"tenant": tenant_id,
|
||||
"scope": "tenant-engine",
|
||||
"source": SOURCE,
|
||||
"actor": payload.get("actor") or payload.get("granted_by") or payload.get("changed_by"),
|
||||
"action": event_type,
|
||||
"resource": f"tenant:{tenant_id}",
|
||||
"outcome": "recorded",
|
||||
"reason": payload.get("reason") or payload.get("authorization_reason"),
|
||||
"details": payload,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeliveryResult:
|
||||
event_id: str
|
||||
status: str # delivered | duplicate | retry | dead | skipped
|
||||
http_status: int | None = None
|
||||
detail: str = ""
|
||||
|
||||
|
||||
class AuditCoreClient:
|
||||
"""POST /v1/events. The only audit-core surface this engine holds."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
timeout_seconds: float = 3.0,
|
||||
token_file: str | None = None,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token_file = token_file
|
||||
self._client = httpx.Client(
|
||||
base_url=self.base_url,
|
||||
timeout=httpx.Timeout(timeout_seconds),
|
||||
transport=transport,
|
||||
)
|
||||
|
||||
def post_event(self, envelope: dict[str, Any]) -> DeliveryResult:
|
||||
event_id = str(envelope.get("event_id") or "")
|
||||
headers: dict[str, str] = {"Content-Type": "application/json"}
|
||||
if self.token_file:
|
||||
try:
|
||||
token = open(self.token_file, encoding="utf-8").read().strip()
|
||||
except OSError as exc:
|
||||
return DeliveryResult(event_id, "retry", None, f"token_unreadable:{exc}")
|
||||
if not token:
|
||||
return DeliveryResult(event_id, "retry", None, "token_empty")
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
try:
|
||||
response = self._client.post("/v1/events", json=envelope, headers=headers)
|
||||
except (httpx.HTTPError, OSError) as exc:
|
||||
return DeliveryResult(event_id, "retry", None, f"unreachable:{exc.__class__.__name__}")
|
||||
if response.status_code in (200, 202):
|
||||
status = "duplicate" if response.status_code == 200 else "delivered"
|
||||
return DeliveryResult(event_id, status, response.status_code)
|
||||
if response.status_code in (400, 409):
|
||||
return DeliveryResult(event_id, "dead", response.status_code, "rejected")
|
||||
if response.status_code in (401, 403):
|
||||
return DeliveryResult(event_id, "retry", response.status_code, "unauthorized")
|
||||
return DeliveryResult(event_id, "retry", response.status_code, "unavailable")
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
Loading…
Add table
Add a link
Reference in a new issue