toolframe-audit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- toolframe_audit/__init__.py +8 -0
- toolframe_audit/docs/usage.md +77 -0
- toolframe_audit/events.py +210 -0
- toolframe_audit/migrations/0001_init.postgresql.sql +22 -0
- toolframe_audit/migrations/0001_init.sql +22 -0
- toolframe_audit/migrations/outbox.sql +9 -0
- toolframe_audit/outbox.py +162 -0
- toolframe_audit/page.py +478 -0
- toolframe_audit/plugin.py +205 -0
- toolframe_audit/service.py +254 -0
- toolframe_audit-0.1.0.dist-info/METADATA +67 -0
- toolframe_audit-0.1.0.dist-info/RECORD +15 -0
- toolframe_audit-0.1.0.dist-info/WHEEL +4 -0
- toolframe_audit-0.1.0.dist-info/entry_points.txt +2 -0
- toolframe_audit-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Audit event history
|
|
2
|
+
|
|
3
|
+
The Audit plugin keeps a durable, read-only event history for an application.
|
|
4
|
+
Use the `audit.events@1` service or the read-only `audit__list_events` MCP tool
|
|
5
|
+
to inspect events; both require the `audit.read` scope. The plugin does not
|
|
6
|
+
expose public caller fields for ingestion. Producers enqueue events through the
|
|
7
|
+
package's transactional outbox helper while committing their business mutation,
|
|
8
|
+
then use the bounded drain to deliver them to Audit.
|
|
9
|
+
|
|
10
|
+
## Reading events
|
|
11
|
+
|
|
12
|
+
Call `list_events` with an optional filter object:
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
service = context.services.require("audit.events", version=1)
|
|
16
|
+
page = service.call(
|
|
17
|
+
"list_events",
|
|
18
|
+
{
|
|
19
|
+
"limit": 50,
|
|
20
|
+
"source_owner": "billing",
|
|
21
|
+
"action": "invoice.paid",
|
|
22
|
+
"entity_type": "invoice",
|
|
23
|
+
"entity_id": "inv_123",
|
|
24
|
+
"since": "2026-01-01T00:00:00Z",
|
|
25
|
+
"until": "2026-01-31T23:59:59Z",
|
|
26
|
+
},
|
|
27
|
+
)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`limit` accepts 1–100 and defaults to 50. The Dashboard uses 25, 50, or 100
|
|
31
|
+
rows per page. `source_owner`, `action`, `entity_type`, and `entity_id` are
|
|
32
|
+
exact filters. `since` and `until` are UTC ISO 8601 timestamps. Omit empty
|
|
33
|
+
filters. Results are ordered newest first and contain `events`, the returned
|
|
34
|
+
row `count`, the effective `limit`, and `next_before`. The service may return
|
|
35
|
+
fewer than `limit` rows when its event-byte budget is reached. Pass a non-null
|
|
36
|
+
`next_before` back as `before` to continue toward older rows; stop when it is
|
|
37
|
+
`null`.
|
|
38
|
+
|
|
39
|
+
Each event includes its sequence, event ID, application and source identity,
|
|
40
|
+
occurred and received timestamps, action, entity identity, bounded metadata,
|
|
41
|
+
`source_attested_actor`, and `delivery_actor`. The source-attested actor is
|
|
42
|
+
what the producer attested when it created the event; it is not a cryptographic
|
|
43
|
+
verification of that historical actor. The delivery actor comes from the
|
|
44
|
+
host-admitted delivery context that accepted the event. Replay is safe for a
|
|
45
|
+
previously accepted event ID, while a conflicting payload for the same logical
|
|
46
|
+
ID is rejected.
|
|
47
|
+
|
|
48
|
+
The Dashboard's JSONL button exports only the currently displayed page (up to
|
|
49
|
+
100 events) and labels the file as a partial export. Continue through the
|
|
50
|
+
`next_before` cursor when a complete review is required.
|
|
51
|
+
|
|
52
|
+
Operators with the Manage plugins permission (`system.plugins.manage`) can use the
|
|
53
|
+
Dashboard Retention panel. First
|
|
54
|
+
preview a cutoff and batch limit. The preview records a `through_sequence`
|
|
55
|
+
high-water mark; the Dashboard preserves those inputs and requires explicit
|
|
56
|
+
confirmation before applying. Direct service callers supply the cutoff, limit
|
|
57
|
+
and `through_sequence` cap explicitly; the service does not store or bind preview
|
|
58
|
+
tokens. It never substitutes a newer sequence cap on apply. The persisted cutoff rejects later events whose
|
|
59
|
+
occurred timestamp is older than the cutoff, so pending producer outbox items
|
|
60
|
+
need attention.
|
|
61
|
+
|
|
62
|
+
## Producer and outbox integration
|
|
63
|
+
|
|
64
|
+
Use the public outbox helper exported by the package and the fixture under
|
|
65
|
+
`examples/outbox-producer/` as the integration reference. The business write
|
|
66
|
+
and enqueue must use the same owner SQL transaction. A finite drain delivers
|
|
67
|
+
each pending item to Audit and acknowledges it only after Audit accepts or
|
|
68
|
+
deduplicates it. If the process crashes between those two steps, replay uses
|
|
69
|
+
the event ID and does not create a second row. The helper captures the original
|
|
70
|
+
actor from trusted context; a replaying drain cannot turn that historical actor
|
|
71
|
+
into a new delivery identity, and callers cannot override the source owner.
|
|
72
|
+
|
|
73
|
+
Keep event metadata to the minimum needed for investigation. Redact tokens,
|
|
74
|
+
passwords, credentials, session material, and full request or response bodies
|
|
75
|
+
before enqueueing. Audit provides durable operational history with source and
|
|
76
|
+
delivery attribution; it is not a tamper-proof ledger or cryptographic proof
|
|
77
|
+
of the historical actor.
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Bounded event normalization and non-secret actor snapshots."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from toolframe_sdk import PluginError, Principal
|
|
12
|
+
|
|
13
|
+
MAX_METADATA_BYTES = 16_384
|
|
14
|
+
MAX_EVENT_BYTES = 20_480
|
|
15
|
+
MAX_PAGE = 100
|
|
16
|
+
MAX_SEQUENCE = 2**63 - 1
|
|
17
|
+
ACTOR_KINDS = (
|
|
18
|
+
"local",
|
|
19
|
+
"operator",
|
|
20
|
+
"password",
|
|
21
|
+
"mcp_key",
|
|
22
|
+
"user",
|
|
23
|
+
"delegated",
|
|
24
|
+
"anonymous",
|
|
25
|
+
"public",
|
|
26
|
+
"background",
|
|
27
|
+
)
|
|
28
|
+
EVENT_FIELDS = {
|
|
29
|
+
"event_id",
|
|
30
|
+
"occurred_at",
|
|
31
|
+
"action",
|
|
32
|
+
"entity_type",
|
|
33
|
+
"entity_id",
|
|
34
|
+
"metadata",
|
|
35
|
+
"source_attested_actor",
|
|
36
|
+
}
|
|
37
|
+
_SECRET_KEYS = {
|
|
38
|
+
"password",
|
|
39
|
+
"secret",
|
|
40
|
+
"token",
|
|
41
|
+
"authorization",
|
|
42
|
+
"cookie",
|
|
43
|
+
"credentials",
|
|
44
|
+
"credential",
|
|
45
|
+
"apikey",
|
|
46
|
+
"accesskey",
|
|
47
|
+
"privatekey",
|
|
48
|
+
"clientsecret",
|
|
49
|
+
"accesstoken",
|
|
50
|
+
"refreshtoken",
|
|
51
|
+
"idtoken",
|
|
52
|
+
"bearer",
|
|
53
|
+
"sessiontoken",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def invalid(message: str) -> PluginError:
|
|
58
|
+
return PluginError("audit_validation", message)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def text_field(value: Any, field: str, maximum: int) -> str:
|
|
62
|
+
if (
|
|
63
|
+
not isinstance(value, str)
|
|
64
|
+
or not value.strip()
|
|
65
|
+
or len(value) > maximum
|
|
66
|
+
or any(ord(char) < 32 for char in value)
|
|
67
|
+
):
|
|
68
|
+
raise invalid(f"{field} must be nonempty text of at most {maximum} characters.")
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def timestamp(value: Any, field: str = "occurred_at") -> str:
|
|
73
|
+
text_field(value, field, 40)
|
|
74
|
+
try:
|
|
75
|
+
parsed = datetime.fromisoformat(value)
|
|
76
|
+
if parsed.tzinfo is None:
|
|
77
|
+
raise ValueError
|
|
78
|
+
return (
|
|
79
|
+
parsed.astimezone(UTC)
|
|
80
|
+
.isoformat(timespec="microseconds")
|
|
81
|
+
.replace("+00:00", "Z")
|
|
82
|
+
)
|
|
83
|
+
except ValueError, OverflowError:
|
|
84
|
+
raise invalid(
|
|
85
|
+
f"{field} must be an ISO 8601 timestamp with a timezone."
|
|
86
|
+
) from None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def now() -> str:
|
|
90
|
+
return datetime.now(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def canonical(value: Any) -> str:
|
|
94
|
+
try:
|
|
95
|
+
result = json.dumps(
|
|
96
|
+
value,
|
|
97
|
+
sort_keys=True,
|
|
98
|
+
separators=(",", ":"),
|
|
99
|
+
allow_nan=False,
|
|
100
|
+
ensure_ascii=False,
|
|
101
|
+
)
|
|
102
|
+
result.encode("utf-8")
|
|
103
|
+
return result
|
|
104
|
+
except TypeError, ValueError, RecursionError, UnicodeEncodeError:
|
|
105
|
+
raise invalid("Event values must be finite JSON data.") from None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def actor(value: Any) -> dict:
|
|
109
|
+
if not isinstance(value, dict) or set(value) - {"kind", "user_id", "client_id"}:
|
|
110
|
+
raise invalid("Actor snapshots allow only kind, user_id and client_id.")
|
|
111
|
+
if value.get("kind") not in ACTOR_KINDS:
|
|
112
|
+
raise invalid("Actor kind is not supported.")
|
|
113
|
+
result = {"kind": value["kind"]}
|
|
114
|
+
for key in ("user_id", "client_id"):
|
|
115
|
+
if key in value:
|
|
116
|
+
result[key] = text_field(value[key], key, 256)
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def admitted(context, *, producer: bool = False) -> tuple[dict, dict]:
|
|
121
|
+
"""Trust only the SDK's host-delivered call context, never event parameters."""
|
|
122
|
+
info = context.call_info
|
|
123
|
+
owner = info.get("owner")
|
|
124
|
+
if (
|
|
125
|
+
not isinstance(owner, str)
|
|
126
|
+
or not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", owner)
|
|
127
|
+
or not all(
|
|
128
|
+
isinstance(info.get(key), str) and 0 < len(info[key]) <= 256
|
|
129
|
+
for key in ("owner_generation", "application_id", "root_id")
|
|
130
|
+
)
|
|
131
|
+
or "principal" not in info
|
|
132
|
+
or (producer and owner == context.plugin_id)
|
|
133
|
+
):
|
|
134
|
+
raise PluginError(
|
|
135
|
+
"audit_untrusted_context", "A host-admitted producer context is required."
|
|
136
|
+
)
|
|
137
|
+
principal = info["principal"]
|
|
138
|
+
if principal is None:
|
|
139
|
+
return info, {"kind": "background"}
|
|
140
|
+
try:
|
|
141
|
+
principal = Principal.model_validate(principal)
|
|
142
|
+
snapshot = {"kind": principal.kind}
|
|
143
|
+
for key in ("user_id", "client_id"):
|
|
144
|
+
if getattr(principal, key) is not None:
|
|
145
|
+
snapshot[key] = getattr(principal, key)
|
|
146
|
+
return info, actor(snapshot)
|
|
147
|
+
except Exception as error:
|
|
148
|
+
raise PluginError(
|
|
149
|
+
"audit_untrusted_context", "The admitted principal is invalid."
|
|
150
|
+
) from error
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _metadata(value: Any, depth: int = 0, budget: list[int] | None = None) -> None:
|
|
154
|
+
budget = [1024] if budget is None else budget
|
|
155
|
+
budget[0] -= 1
|
|
156
|
+
if budget[0] < 0 or depth > 8:
|
|
157
|
+
raise invalid(
|
|
158
|
+
"Metadata must have at most 1024 values including containers and the root, and 8 nesting levels."
|
|
159
|
+
)
|
|
160
|
+
if isinstance(value, dict):
|
|
161
|
+
for key, item in value.items():
|
|
162
|
+
if not isinstance(key, str) or len(key) > 128:
|
|
163
|
+
raise invalid("Metadata keys must be text of at most 128 characters.")
|
|
164
|
+
normalized = re.sub(r"[^a-z0-9]", "", key.lower())
|
|
165
|
+
if normalized in _SECRET_KEYS:
|
|
166
|
+
raise invalid(
|
|
167
|
+
"Metadata contains a credential or secret field; redact it at the source."
|
|
168
|
+
)
|
|
169
|
+
_metadata(item, depth + 1, budget)
|
|
170
|
+
elif isinstance(value, list):
|
|
171
|
+
for item in value:
|
|
172
|
+
_metadata(item, depth + 1, budget)
|
|
173
|
+
elif value is not None and not isinstance(value, (str, int, float, bool)):
|
|
174
|
+
raise invalid("Metadata must contain JSON values.")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def normalize_event(value: Any) -> tuple[dict, str, str]:
|
|
178
|
+
if not isinstance(value, dict) or set(value) != EVENT_FIELDS:
|
|
179
|
+
raise invalid(
|
|
180
|
+
"Event requires exactly the documented event fields; source overrides are forbidden."
|
|
181
|
+
)
|
|
182
|
+
event = {
|
|
183
|
+
"event_id": text_field(value["event_id"], "event_id", 128),
|
|
184
|
+
"occurred_at": timestamp(value["occurred_at"]),
|
|
185
|
+
"action": text_field(value["action"], "action", 128),
|
|
186
|
+
"entity_type": text_field(value["entity_type"], "entity_type", 128),
|
|
187
|
+
"entity_id": text_field(value["entity_id"], "entity_id", 256),
|
|
188
|
+
"source_attested_actor": actor(value["source_attested_actor"]),
|
|
189
|
+
}
|
|
190
|
+
metadata = value["metadata"]
|
|
191
|
+
if not isinstance(metadata, dict):
|
|
192
|
+
raise invalid("metadata must be a JSON object.")
|
|
193
|
+
_metadata(metadata)
|
|
194
|
+
if len(canonical(metadata).encode()) > MAX_METADATA_BYTES:
|
|
195
|
+
raise invalid("Metadata exceeds 16384 UTF-8 bytes.")
|
|
196
|
+
event["metadata"] = metadata
|
|
197
|
+
payload = canonical(event)
|
|
198
|
+
if len(payload.encode()) > MAX_EVENT_BYTES:
|
|
199
|
+
raise invalid("Event exceeds 20480 UTF-8 bytes.")
|
|
200
|
+
return event, payload, hashlib.sha256(payload.encode()).hexdigest()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def bounded_integer(value: Any, field: str, maximum: int) -> int:
|
|
204
|
+
if (
|
|
205
|
+
isinstance(value, bool)
|
|
206
|
+
or not isinstance(value, int)
|
|
207
|
+
or not 1 <= value <= maximum
|
|
208
|
+
):
|
|
209
|
+
raise invalid(f"{field} must be an integer between 1 and {maximum}.")
|
|
210
|
+
return value
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
CREATE TABLE audit_events (
|
|
2
|
+
sequence BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
3
|
+
application_id TEXT NOT NULL,
|
|
4
|
+
source_owner TEXT NOT NULL,
|
|
5
|
+
event_id TEXT NOT NULL,
|
|
6
|
+
source_generation TEXT NOT NULL,
|
|
7
|
+
occurred_at TEXT NOT NULL,
|
|
8
|
+
received_at TEXT NOT NULL,
|
|
9
|
+
action TEXT NOT NULL,
|
|
10
|
+
entity_type TEXT NOT NULL,
|
|
11
|
+
entity_id TEXT NOT NULL,
|
|
12
|
+
payload TEXT NOT NULL,
|
|
13
|
+
content_hash TEXT NOT NULL,
|
|
14
|
+
delivery_actor TEXT NOT NULL,
|
|
15
|
+
UNIQUE(application_id, source_owner, event_id)
|
|
16
|
+
);
|
|
17
|
+
CREATE INDEX audit_source_sequence ON audit_events(source_owner, sequence);
|
|
18
|
+
CREATE INDEX audit_action_sequence ON audit_events(action, sequence);
|
|
19
|
+
CREATE INDEX audit_entity_sequence ON audit_events(entity_type, entity_id, sequence);
|
|
20
|
+
CREATE INDEX audit_occurred_sequence ON audit_events(occurred_at, sequence);
|
|
21
|
+
CREATE TABLE audit_capacity (singleton INTEGER PRIMARY KEY CHECK(singleton = 1), event_count BIGINT NOT NULL, retention_cutoff TEXT);
|
|
22
|
+
INSERT INTO audit_capacity(singleton, event_count) VALUES (1, 0);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
CREATE TABLE audit_events (
|
|
2
|
+
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3
|
+
application_id TEXT NOT NULL,
|
|
4
|
+
source_owner TEXT NOT NULL,
|
|
5
|
+
event_id TEXT NOT NULL,
|
|
6
|
+
source_generation TEXT NOT NULL,
|
|
7
|
+
occurred_at TEXT NOT NULL,
|
|
8
|
+
received_at TEXT NOT NULL,
|
|
9
|
+
action TEXT NOT NULL,
|
|
10
|
+
entity_type TEXT NOT NULL,
|
|
11
|
+
entity_id TEXT NOT NULL,
|
|
12
|
+
payload TEXT NOT NULL,
|
|
13
|
+
content_hash TEXT NOT NULL,
|
|
14
|
+
delivery_actor TEXT NOT NULL,
|
|
15
|
+
UNIQUE(application_id, source_owner, event_id)
|
|
16
|
+
);
|
|
17
|
+
CREATE INDEX audit_source_sequence ON audit_events(source_owner, sequence);
|
|
18
|
+
CREATE INDEX audit_action_sequence ON audit_events(action, sequence);
|
|
19
|
+
CREATE INDEX audit_entity_sequence ON audit_events(entity_type, entity_id, sequence);
|
|
20
|
+
CREATE INDEX audit_occurred_sequence ON audit_events(occurred_at, sequence);
|
|
21
|
+
CREATE TABLE audit_capacity (singleton INTEGER PRIMARY KEY CHECK(singleton = 1), event_count BIGINT NOT NULL, retention_cutoff TEXT);
|
|
22
|
+
INSERT INTO audit_capacity(singleton, event_count) VALUES (1, 0);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
CREATE TABLE audit_outbox (
|
|
2
|
+
event_id TEXT PRIMARY KEY,
|
|
3
|
+
payload TEXT NOT NULL,
|
|
4
|
+
content_hash TEXT NOT NULL,
|
|
5
|
+
enqueued_at TEXT NOT NULL
|
|
6
|
+
);
|
|
7
|
+
CREATE INDEX audit_outbox_order ON audit_outbox(enqueued_at, event_id);
|
|
8
|
+
CREATE TABLE audit_outbox_capacity (singleton INTEGER PRIMARY KEY CHECK(singleton = 1), pending_count BIGINT NOT NULL);
|
|
9
|
+
INSERT INTO audit_outbox_capacity(singleton, pending_count) VALUES (1, 0);
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Reusable same-owner transactional enqueue and finite at-least-once delivery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
|
|
10
|
+
from toolframe_sdk import Migration, PluginError, StorageClient
|
|
11
|
+
|
|
12
|
+
from .events import admitted, bounded_integer, normalize_event, now
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def outbox_migration(*, revision="audit_outbox_v1", parent=None) -> Migration:
|
|
16
|
+
"""Append this migration to the producer's own linear store history."""
|
|
17
|
+
sql = files("toolframe_audit").joinpath("migrations/outbox.sql").read_text()
|
|
18
|
+
return Migration(
|
|
19
|
+
revision=revision, parent=parent, sql=sql, dialects={"postgresql": sql}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def enqueue(
|
|
24
|
+
context,
|
|
25
|
+
transaction: StorageClient,
|
|
26
|
+
*,
|
|
27
|
+
event_id: str,
|
|
28
|
+
action: str,
|
|
29
|
+
entity_type: str,
|
|
30
|
+
entity_id: str,
|
|
31
|
+
metadata: dict | None = None,
|
|
32
|
+
occurred_at: str | None = None,
|
|
33
|
+
max_pending: int = 1000,
|
|
34
|
+
) -> dict:
|
|
35
|
+
"""Enqueue inside the SAME live StorageClient transaction as the business write.
|
|
36
|
+
|
|
37
|
+
Identity and actor are captured from the admitted context. This function never
|
|
38
|
+
opens a second transaction and cannot override the producer owner or actor.
|
|
39
|
+
"""
|
|
40
|
+
if (
|
|
41
|
+
not isinstance(transaction, StorageClient)
|
|
42
|
+
or not transaction.token
|
|
43
|
+
or transaction.context is not context
|
|
44
|
+
):
|
|
45
|
+
raise PluginError(
|
|
46
|
+
"audit_outbox_transaction", "Pass the producer's live transaction client."
|
|
47
|
+
)
|
|
48
|
+
info = context.call_info
|
|
49
|
+
if (
|
|
50
|
+
"owner" not in info
|
|
51
|
+
and "application_id" not in info
|
|
52
|
+
and "principal" not in info
|
|
53
|
+
and isinstance(info.get("root_id"), str)
|
|
54
|
+
and info["root_id"]
|
|
55
|
+
and info.get("chain") == [context.plugin_id]
|
|
56
|
+
):
|
|
57
|
+
# The SDK's newly admitted task root has no visitor principal. Source
|
|
58
|
+
# fencing still happens at Core.invoke on the later collector call.
|
|
59
|
+
original_actor = {"kind": "background"}
|
|
60
|
+
else:
|
|
61
|
+
_, original_actor = admitted(context)
|
|
62
|
+
maximum = bounded_integer(max_pending, "max_pending", 100_000)
|
|
63
|
+
event, payload, content_hash = normalize_event(
|
|
64
|
+
{
|
|
65
|
+
"event_id": event_id,
|
|
66
|
+
"occurred_at": occurred_at if occurred_at is not None else now(),
|
|
67
|
+
"action": action,
|
|
68
|
+
"entity_type": entity_type,
|
|
69
|
+
"entity_id": entity_id,
|
|
70
|
+
"metadata": metadata if metadata is not None else {},
|
|
71
|
+
"source_attested_actor": original_actor,
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
result = await transaction.execute_returning(
|
|
75
|
+
"INSERT INTO audit_outbox(event_id, payload, content_hash, enqueued_at) VALUES (?, ?, ?, ?) "
|
|
76
|
+
"ON CONFLICT(event_id) DO NOTHING RETURNING event_id",
|
|
77
|
+
(event_id, payload, content_hash, now()),
|
|
78
|
+
)
|
|
79
|
+
if not result.get("rows"):
|
|
80
|
+
rows = await transaction.query(
|
|
81
|
+
"SELECT content_hash FROM audit_outbox WHERE event_id = ?", (event_id,)
|
|
82
|
+
)
|
|
83
|
+
if len(rows) != 1 or rows[0]["content_hash"] != content_hash:
|
|
84
|
+
raise PluginError(
|
|
85
|
+
"audit_event_conflict",
|
|
86
|
+
"The pending event ID already has different content.",
|
|
87
|
+
)
|
|
88
|
+
return event
|
|
89
|
+
capacity = await transaction.execute_returning(
|
|
90
|
+
"UPDATE audit_outbox_capacity SET pending_count = pending_count + 1 "
|
|
91
|
+
"WHERE singleton = 1 AND pending_count < ? RETURNING pending_count",
|
|
92
|
+
(maximum,),
|
|
93
|
+
)
|
|
94
|
+
if not capacity.get("rows"):
|
|
95
|
+
raise PluginError(
|
|
96
|
+
"audit_outbox_capacity",
|
|
97
|
+
"Outbox capacity is reached; the business transaction must roll back.",
|
|
98
|
+
)
|
|
99
|
+
return event
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def drain(
|
|
103
|
+
context, *, alias="audit", storage_alias="storage", limit=25, budget_seconds=10
|
|
104
|
+
) -> dict:
|
|
105
|
+
"""Deliver one short batch; acknowledge only accepted/deduplicated events.
|
|
106
|
+
|
|
107
|
+
Failures propagate without logging payloads. Successfully acknowledged prior
|
|
108
|
+
events remain settled; retry the remaining outbox in another admitted root.
|
|
109
|
+
"""
|
|
110
|
+
# context.task roots carry only root_id/deadline/chain. Core attaches complete
|
|
111
|
+
# source identity when this producer crosses the collector service boundary.
|
|
112
|
+
if (
|
|
113
|
+
not isinstance(context.call_info.get("root_id"), str)
|
|
114
|
+
or not context.call_info["root_id"]
|
|
115
|
+
):
|
|
116
|
+
raise PluginError("audit_untrusted_context", "Drain requires an admitted root.")
|
|
117
|
+
limit = bounded_integer(limit, "limit", 100)
|
|
118
|
+
budget_seconds = bounded_integer(budget_seconds, "budget_seconds", 30)
|
|
119
|
+
storage = context.storage(storage_alias)
|
|
120
|
+
deadline = time.monotonic() + budget_seconds
|
|
121
|
+
delivered = acknowledged = 0
|
|
122
|
+
async with asyncio.timeout(budget_seconds):
|
|
123
|
+
rows = await storage.query(
|
|
124
|
+
"SELECT event_id FROM audit_outbox ORDER BY enqueued_at, event_id LIMIT ?",
|
|
125
|
+
(limit,),
|
|
126
|
+
)
|
|
127
|
+
for row in rows:
|
|
128
|
+
pending = await storage.query(
|
|
129
|
+
"SELECT event_id, payload FROM audit_outbox WHERE event_id = ?",
|
|
130
|
+
(row["event_id"],),
|
|
131
|
+
)
|
|
132
|
+
if not pending: # Another finite drain may have acknowledged it.
|
|
133
|
+
continue
|
|
134
|
+
row = pending[0]
|
|
135
|
+
response = await context.call(
|
|
136
|
+
alias,
|
|
137
|
+
"ingest",
|
|
138
|
+
{"event": json.loads(row["payload"])},
|
|
139
|
+
timeout=max(0.01, deadline - time.monotonic()),
|
|
140
|
+
)
|
|
141
|
+
if (
|
|
142
|
+
not isinstance(response, dict)
|
|
143
|
+
or response.get("status") not in {"accepted", "duplicate"}
|
|
144
|
+
or response.get("event_id") != row["event_id"]
|
|
145
|
+
or response.get("source_owner") != context.plugin_id
|
|
146
|
+
):
|
|
147
|
+
raise PluginError(
|
|
148
|
+
"audit_outbox_delivery",
|
|
149
|
+
"Collector did not acknowledge this producer event.",
|
|
150
|
+
)
|
|
151
|
+
delivered += 1
|
|
152
|
+
async with storage.transaction() as transaction:
|
|
153
|
+
deleted = await transaction.execute_returning(
|
|
154
|
+
"DELETE FROM audit_outbox WHERE event_id = ? AND payload = ? RETURNING event_id",
|
|
155
|
+
(row["event_id"], row["payload"]),
|
|
156
|
+
)
|
|
157
|
+
if deleted.get("rows"):
|
|
158
|
+
await transaction.execute(
|
|
159
|
+
"UPDATE audit_outbox_capacity SET pending_count = pending_count - 1 WHERE singleton = 1"
|
|
160
|
+
)
|
|
161
|
+
acknowledged += 1
|
|
162
|
+
return {"delivered": delivered, "acknowledged": acknowledged, "limit": limit}
|