feat: strengthen transactional audit delivery
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
# GovOPlaN Audit Codex Guide
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This repository owns durable audit records, audit administration surfaces, retention behavior, and the transactional platform-event outbox.
|
||||||
|
|
||||||
|
## Documentation Contract
|
||||||
|
|
||||||
|
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
|
||||||
|
- Keep feature content here; `govoplan-docs` projects it without importing Audit internals.
|
||||||
|
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- Store bounded evidence and trace context, not arbitrary feature payloads.
|
||||||
|
- Preserve transactional recording, retention, redaction, and retry guarantees.
|
||||||
@@ -10,7 +10,8 @@ from govoplan_core.core.access import (
|
|||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||||
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest
|
from govoplan_core.core.modules import DocumentationTopic, FrontendModule, MigrationSpec, ModuleContext, ModuleManifest
|
||||||
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
from govoplan_core.core.events import CAPABILITY_PLATFORM_EVENT_OUTBOX
|
from govoplan_core.core.events import CAPABILITY_PLATFORM_EVENT_OUTBOX
|
||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
@@ -55,6 +56,27 @@ manifest = ModuleManifest(
|
|||||||
version="0.1.8",
|
version="0.1.8",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||||
route_factory=_route_factory,
|
route_factory=_route_factory,
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="audit.read-authorized-evidence",
|
||||||
|
title="Read authorized audit evidence",
|
||||||
|
summary="Audit history explains who performed a governed action, when it happened, and which resource and trace context were involved.",
|
||||||
|
body="Audit views are permission- and tenant-scoped. Entries are evidence, not editable business records. Sensitive payloads may be redacted while stable resource, actor, outcome, request, run, and trace references remain available for investigation.",
|
||||||
|
documentation_types=("user",),
|
||||||
|
audience=("auditor", "tenant_admin", "operator"),
|
||||||
|
metadata={"kind": "reference"},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="audit.recording-retention-and-outbox",
|
||||||
|
title="Operate audit recording and event delivery",
|
||||||
|
summary="Audit owns durable audit records, retention operations, and the transactional platform-event outbox.",
|
||||||
|
body="Modules record bounded audit facts through the Audit capability. Governed platform events are committed to the outbox with retry and delivery metadata so a failed consumer does not erase the originating transaction. Retention and destructive retirement must preserve the configured evidence and recovery guarantees.",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("auditor", "security_officer", "operator"),
|
||||||
|
related_modules=("policy", "ops"),
|
||||||
|
metadata={"kind": "reference"},
|
||||||
|
),
|
||||||
|
),
|
||||||
frontend=FrontendModule(
|
frontend=FrontendModule(
|
||||||
module_id="audit",
|
module_id="audit",
|
||||||
package_name="@govoplan/audit-webui",
|
package_name="@govoplan/audit-webui",
|
||||||
@@ -91,6 +113,19 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_AUDIT_RETENTION: _audit_retention,
|
CAPABILITY_AUDIT_RETENTION: _audit_retention,
|
||||||
CAPABILITY_PLATFORM_EVENT_OUTBOX: _event_outbox,
|
CAPABILITY_PLATFORM_EVENT_OUTBOX: _event_outbox,
|
||||||
},
|
},
|
||||||
|
architecture=declared_module_architecture(
|
||||||
|
layer="governance_accountability",
|
||||||
|
kind="governance",
|
||||||
|
maturity="vertical_slice",
|
||||||
|
documentation_ref="docs/AUDIT_TRACE_CONTEXT.md",
|
||||||
|
test_ref="tests/test_audit_module_contract.py",
|
||||||
|
known_limits=("Cross-deployment archival and evidentiary export profiles are not yet reference-ready.",),
|
||||||
|
owned_concepts=("audit record", "audit retention", "transactional event outbox"),
|
||||||
|
non_owned_concepts=("domain record", "policy decision", "external effect"),
|
||||||
|
recovery_docs=("README.md",),
|
||||||
|
security_docs=("docs/AUDIT_TRACE_CONTEXT.md",),
|
||||||
|
operations_docs=("README.md",),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from govoplan_core.core.events import (
|
|||||||
ensure_event_trace,
|
ensure_event_trace,
|
||||||
publish_platform_event,
|
publish_platform_event,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.institutional import GovernedContextEnvelope
|
||||||
|
|
||||||
EventDispatcher = Callable[[PlatformEvent], None]
|
EventDispatcher = Callable[[PlatformEvent], None]
|
||||||
|
|
||||||
@@ -562,6 +563,19 @@ def _event_from_payload(payload: Mapping[str, Any]) -> PlatformEvent:
|
|||||||
subject=_object_ref(payload.get("subject")),
|
subject=_object_ref(payload.get("subject")),
|
||||||
resource=_object_ref(payload.get("resource")),
|
resource=_object_ref(payload.get("resource")),
|
||||||
classification=cast(EventClassification, str(payload.get("classification") or "internal")),
|
classification=cast(EventClassification, str(payload.get("classification") or "internal")),
|
||||||
|
institutional_context=_institutional_context(
|
||||||
|
payload.get("institutional_context")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _institutional_context(
|
||||||
|
value: object,
|
||||||
|
) -> GovernedContextEnvelope | None:
|
||||||
|
return (
|
||||||
|
GovernedContextEnvelope.from_mapping(value)
|
||||||
|
if isinstance(value, Mapping)
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ from govoplan_core.core.events import (
|
|||||||
EventActorRef,
|
EventActorRef,
|
||||||
PlatformEvent,
|
PlatformEvent,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
GovernedContextEnvelope,
|
||||||
|
InstitutionalReference,
|
||||||
|
TemporalRevision,
|
||||||
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -109,6 +114,60 @@ class AuditOutboxTests(unittest.TestCase):
|
|||||||
delivery.delivery_key,
|
delivery.delivery_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_outbox_preserves_institutional_context(self) -> None:
|
||||||
|
Session = self._database()
|
||||||
|
outbox = SqlAuditOutbox()
|
||||||
|
seen: list[PlatformEvent] = []
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
context = GovernedContextEnvelope(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
temporal=TemporalRevision(revision="decision:7", recorded_at=now),
|
||||||
|
decision_ref=InstitutionalReference(
|
||||||
|
kind="decision",
|
||||||
|
owner_module="committee",
|
||||||
|
object_id="decision-7",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version="7",
|
||||||
|
valid_at=now,
|
||||||
|
),
|
||||||
|
approval_refs=(
|
||||||
|
InstitutionalReference(
|
||||||
|
kind="approval",
|
||||||
|
owner_module="workflow",
|
||||||
|
object_id="approval-3",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version="3",
|
||||||
|
valid_at=now,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with Session() as session:
|
||||||
|
outbox.enqueue(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type="committee.decision.recorded",
|
||||||
|
module_id="committee",
|
||||||
|
institutional_context=context,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
outbox.dispatch_pending(
|
||||||
|
session,
|
||||||
|
consumers=(
|
||||||
|
DurableEventConsumer(
|
||||||
|
consumer_id="tests.institutional-context.v1",
|
||||||
|
event_types=frozenset({"committee.decision.recorded"}),
|
||||||
|
handler=lambda delivered, _key: seen.append(delivered),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("decision-7", seen[0].institutional_context.decision_ref.object_id)
|
||||||
|
self.assertEqual(
|
||||||
|
"approval-3",
|
||||||
|
seen[0].institutional_context.approval_refs[0].object_id,
|
||||||
|
)
|
||||||
|
|
||||||
def test_outbox_retries_then_quarantines_a_failed_consumer(self) -> None:
|
def test_outbox_retries_then_quarantines_a_failed_consumer(self) -> None:
|
||||||
Session = self._database()
|
Session = self._database()
|
||||||
outbox = SqlAuditOutbox(max_attempts=2)
|
outbox = SqlAuditOutbox(max_attempts=2)
|
||||||
|
|||||||
Reference in New Issue
Block a user