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.
@@ -0,0 +1,8 @@
1
+ """Audit's public collector identifier and transactional producer helpers."""
2
+
3
+ from .outbox import drain, enqueue, outbox_migration
4
+
5
+ __version__ = "0.1.0"
6
+ SERVICE_NAME = "audit.events"
7
+
8
+ __all__ = ["SERVICE_NAME", "drain", "enqueue", "outbox_migration"]
@@ -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}