Partition durable events by tenant entitlement
This commit is contained in:
@@ -78,7 +78,7 @@ manifest = ModuleManifest(
|
|||||||
id="audit.recording-retention-and-outbox",
|
id="audit.recording-retention-and-outbox",
|
||||||
title="Operate audit recording and event delivery",
|
title="Operate audit recording and event delivery",
|
||||||
summary="Audit owns durable audit records, retention operations, and the transactional platform-event outbox.",
|
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.",
|
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. Worker dispatch is partitioned by tenant entitlement; an unavailable consumer retains its durable delivery and records an operator-required outcome instead of acknowledging the event. Retention and destructive retirement must preserve the configured evidence and recovery guarantees.",
|
||||||
documentation_types=("admin",),
|
documentation_types=("admin",),
|
||||||
audience=("auditor", "security_officer", "operator"),
|
audience=("auditor", "security_officer", "operator"),
|
||||||
related_modules=("policy", "ops"),
|
related_modules=("policy", "ops"),
|
||||||
|
|||||||
@@ -61,15 +61,21 @@ class SqlAuditOutbox:
|
|||||||
self,
|
self,
|
||||||
session: object,
|
session: object,
|
||||||
*,
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
tenantless_only: bool = False,
|
||||||
consumers: Sequence[DurableEventConsumer] = (),
|
consumers: Sequence[DurableEventConsumer] = (),
|
||||||
observer: EventDispatcher | None = publish_platform_event,
|
observer: EventDispatcher | None = publish_platform_event,
|
||||||
dispatcher: EventDispatcher | None = None,
|
dispatcher: EventDispatcher | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
|
if tenant_id is not None and tenantless_only:
|
||||||
|
raise ValueError(
|
||||||
|
"Tenant and tenantless event filters are mutually exclusive"
|
||||||
|
)
|
||||||
db = _session(session)
|
db = _session(session)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
consumers_by_id = _consumer_map(consumers)
|
consumers_by_id = _consumer_map(consumers)
|
||||||
rows = (
|
query = (
|
||||||
db.query(AuditOutboxEvent)
|
db.query(AuditOutboxEvent)
|
||||||
.filter(
|
.filter(
|
||||||
AuditOutboxEvent.status.in_(
|
AuditOutboxEvent.status.in_(
|
||||||
@@ -77,7 +83,20 @@ class SqlAuditOutbox:
|
|||||||
),
|
),
|
||||||
or_(AuditOutboxEvent.next_attempt_at.is_(None), AuditOutboxEvent.next_attempt_at <= now),
|
or_(AuditOutboxEvent.next_attempt_at.is_(None), AuditOutboxEvent.next_attempt_at <= now),
|
||||||
)
|
)
|
||||||
.order_by(AuditOutboxEvent.created_at.asc(), AuditOutboxEvent.id.asc())
|
)
|
||||||
|
if tenant_id:
|
||||||
|
query = query.filter(
|
||||||
|
AuditOutboxEvent.payload["tenant"]["id"].as_string()
|
||||||
|
== tenant_id
|
||||||
|
)
|
||||||
|
elif tenantless_only:
|
||||||
|
query = query.filter(
|
||||||
|
AuditOutboxEvent.payload["tenant"]["id"]
|
||||||
|
.as_string()
|
||||||
|
.is_(None)
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
query.order_by(AuditOutboxEvent.created_at.asc(), AuditOutboxEvent.id.asc())
|
||||||
.with_for_update(skip_locked=True)
|
.with_for_update(skip_locked=True)
|
||||||
.limit(max(1, min(int(limit), 500)))
|
.limit(max(1, min(int(limit), 500)))
|
||||||
.all()
|
.all()
|
||||||
@@ -179,18 +198,36 @@ class SqlAuditOutbox:
|
|||||||
self,
|
self,
|
||||||
session: object,
|
session: object,
|
||||||
*,
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
tenantless_only: bool = False,
|
||||||
before: datetime,
|
before: datetime,
|
||||||
limit: int = 500,
|
limit: int = 500,
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
|
if tenant_id is not None and tenantless_only:
|
||||||
|
raise ValueError(
|
||||||
|
"Tenant and tenantless event filters are mutually exclusive"
|
||||||
|
)
|
||||||
db = _session(session)
|
db = _session(session)
|
||||||
|
clauses = [
|
||||||
|
AuditOutboxEvent.status == "dispatched",
|
||||||
|
AuditOutboxEvent.dispatched_at.is_not(None),
|
||||||
|
AuditOutboxEvent.dispatched_at < before,
|
||||||
|
]
|
||||||
|
if tenant_id:
|
||||||
|
clauses.append(
|
||||||
|
AuditOutboxEvent.payload["tenant"]["id"].as_string()
|
||||||
|
== tenant_id
|
||||||
|
)
|
||||||
|
elif tenantless_only:
|
||||||
|
clauses.append(
|
||||||
|
AuditOutboxEvent.payload["tenant"]["id"]
|
||||||
|
.as_string()
|
||||||
|
.is_(None)
|
||||||
|
)
|
||||||
ids = tuple(
|
ids = tuple(
|
||||||
db.scalars(
|
db.scalars(
|
||||||
select(AuditOutboxEvent.id)
|
select(AuditOutboxEvent.id)
|
||||||
.where(
|
.where(*clauses)
|
||||||
AuditOutboxEvent.status == "dispatched",
|
|
||||||
AuditOutboxEvent.dispatched_at.is_not(None),
|
|
||||||
AuditOutboxEvent.dispatched_at < before,
|
|
||||||
)
|
|
||||||
.order_by(
|
.order_by(
|
||||||
AuditOutboxEvent.dispatched_at,
|
AuditOutboxEvent.dispatched_at,
|
||||||
AuditOutboxEvent.id,
|
AuditOutboxEvent.id,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from govoplan_audit.backend.outbox import SqlAuditOutbox
|
|||||||
from govoplan_core.core.events import (
|
from govoplan_core.core.events import (
|
||||||
DurableEventConsumer,
|
DurableEventConsumer,
|
||||||
EventActorRef,
|
EventActorRef,
|
||||||
|
EventTenantRef,
|
||||||
PlatformEvent,
|
PlatformEvent,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.institutional import (
|
from govoplan_core.core.institutional import (
|
||||||
@@ -114,6 +115,82 @@ class AuditOutboxTests(unittest.TestCase):
|
|||||||
delivery.delivery_key,
|
delivery.delivery_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_dispatch_partitions_pending_events_by_tenant(self) -> None:
|
||||||
|
Session = self._database()
|
||||||
|
outbox = SqlAuditOutbox()
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
with Session() as session:
|
||||||
|
first = outbox.enqueue(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type="files.file.created",
|
||||||
|
module_id="files",
|
||||||
|
tenant=EventTenantRef(id="tenant-1"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
second = outbox.enqueue(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type="files.file.created",
|
||||||
|
module_id="files",
|
||||||
|
tenant=EventTenantRef(id="tenant-2"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
counts = outbox.dispatch_pending(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
consumers=(
|
||||||
|
DurableEventConsumer(
|
||||||
|
consumer_id="tests.tenant-filter.v1",
|
||||||
|
handler=lambda event, _key: seen.append(
|
||||||
|
event.tenant.id if event.tenant else "system"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
observer=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, counts["selected"])
|
||||||
|
self.assertEqual(["tenant-1"], seen)
|
||||||
|
self.assertEqual("dispatched", first.status)
|
||||||
|
self.assertEqual("pending", second.status)
|
||||||
|
|
||||||
|
def test_dispatch_can_select_only_tenantless_system_events(self) -> None:
|
||||||
|
Session = self._database()
|
||||||
|
outbox = SqlAuditOutbox()
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
with Session() as session:
|
||||||
|
system = outbox.enqueue(
|
||||||
|
session,
|
||||||
|
PlatformEvent(type="system.ready", module_id="core"),
|
||||||
|
)
|
||||||
|
tenant = outbox.enqueue(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type="tenant.ready",
|
||||||
|
module_id="tenancy",
|
||||||
|
tenant=EventTenantRef(id="tenant-1"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
counts = outbox.dispatch_pending(
|
||||||
|
session,
|
||||||
|
tenantless_only=True,
|
||||||
|
consumers=(
|
||||||
|
DurableEventConsumer(
|
||||||
|
consumer_id="tests.system-filter.v1",
|
||||||
|
handler=lambda event, _key: seen.append(event.type),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
observer=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, counts["selected"])
|
||||||
|
self.assertEqual(["system.ready"], seen)
|
||||||
|
self.assertEqual("dispatched", system.status)
|
||||||
|
self.assertEqual("pending", tenant.status)
|
||||||
|
|
||||||
def test_outbox_preserves_institutional_context(self) -> None:
|
def test_outbox_preserves_institutional_context(self) -> None:
|
||||||
Session = self._database()
|
Session = self._database()
|
||||||
outbox = SqlAuditOutbox()
|
outbox = SqlAuditOutbox()
|
||||||
|
|||||||
Reference in New Issue
Block a user