anip-server 0.11.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,37 @@
1
+ """ANIP Server — delegation, audit, checkpoints, Merkle trees, sinks."""
2
+ from .delegation import DelegationEngine
3
+ from .permissions import discover_permissions
4
+ from .manifest import build_manifest
5
+ from .audit import AuditLog
6
+ from .merkle import MerkleTree
7
+ from .checkpoint import create_checkpoint, reconstruct_and_create_checkpoint, CheckpointPolicy, CheckpointScheduler
8
+ from .sinks import CheckpointSink, LocalFileSink
9
+ from .storage import StorageBackend, SQLiteStorage, InMemoryStorage
10
+ from .hashing import compute_entry_hash, canonical_bytes
11
+ from .retention_enforcer import RetentionEnforcer
12
+
13
+ try:
14
+ from .postgres import PostgresStorage
15
+ except ImportError:
16
+ pass # asyncpg not installed
17
+
18
+ __all__ = [
19
+ "DelegationEngine",
20
+ "discover_permissions",
21
+ "build_manifest",
22
+ "AuditLog",
23
+ "MerkleTree",
24
+ "create_checkpoint",
25
+ "reconstruct_and_create_checkpoint",
26
+ "CheckpointPolicy",
27
+ "CheckpointScheduler",
28
+ "CheckpointSink",
29
+ "LocalFileSink",
30
+ "StorageBackend",
31
+ "SQLiteStorage",
32
+ "InMemoryStorage",
33
+ "PostgresStorage",
34
+ "RetentionEnforcer",
35
+ "compute_entry_hash",
36
+ "canonical_bytes",
37
+ ]
anip_server/audit.py ADDED
@@ -0,0 +1,81 @@
1
+ """Audit log manager for ANIP services."""
2
+ from __future__ import annotations
3
+
4
+ import inspect
5
+ from collections.abc import Awaitable
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Callable
8
+
9
+ from .storage import StorageBackend
10
+
11
+
12
+ class AuditLog:
13
+ """Audit log backed by a StorageBackend.
14
+
15
+ Sequence numbers, hash chaining, and Merkle accumulation are now
16
+ handled by the storage layer (``append_audit_entry``).
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ storage: StorageBackend,
22
+ signer: Callable[[dict[str, Any]], str | Awaitable[str]] | None = None,
23
+ ) -> None:
24
+ self._storage = storage
25
+ self._signer = signer
26
+
27
+ async def log_entry(self, entry_data: dict[str, Any]) -> dict[str, Any]:
28
+ """Log an audit entry via the storage backend.
29
+
30
+ entry_data should contain: capability, token_id, root_principal, success,
31
+ and optionally: issuer, subject, parameters, result_summary, failure_type,
32
+ cost_actual, delegation_chain.
33
+
34
+ Returns the complete entry dict (with sequence_number, timestamp, previous_hash, signature).
35
+ """
36
+ now = datetime.now(timezone.utc).isoformat()
37
+ entry_for_storage = {
38
+ "timestamp": now,
39
+ "capability": entry_data["capability"],
40
+ "token_id": entry_data.get("token_id"),
41
+ "issuer": entry_data.get("issuer"),
42
+ "subject": entry_data.get("subject"),
43
+ "root_principal": entry_data.get("root_principal"),
44
+ "parameters": entry_data.get("parameters"),
45
+ "success": entry_data["success"],
46
+ "result_summary": entry_data.get("result_summary"),
47
+ "failure_type": entry_data.get("failure_type"),
48
+ "cost_actual": entry_data.get("cost_actual"),
49
+ "delegation_chain": entry_data.get("delegation_chain"),
50
+ "invocation_id": entry_data.get("invocation_id"),
51
+ "client_reference_id": entry_data.get("client_reference_id"),
52
+ "stream_summary": entry_data.get("stream_summary"),
53
+ "event_class": entry_data.get("event_class"),
54
+ "retention_tier": entry_data.get("retention_tier"),
55
+ "expires_at": entry_data.get("expires_at"),
56
+ "storage_redacted": entry_data.get("storage_redacted", False),
57
+ "entry_type": entry_data.get("entry_type"),
58
+ "grouping_key": entry_data.get("grouping_key"),
59
+ "aggregation_window": entry_data.get("aggregation_window"),
60
+ "aggregation_count": entry_data.get("aggregation_count"),
61
+ "first_seen": entry_data.get("first_seen"),
62
+ "last_seen": entry_data.get("last_seen"),
63
+ "representative_detail": entry_data.get("representative_detail"),
64
+ }
65
+
66
+ entry = await self._storage.append_audit_entry(entry_for_storage)
67
+
68
+ if self._signer:
69
+ sig = self._signer(entry)
70
+ if inspect.isawaitable(sig):
71
+ sig = await sig
72
+ entry["signature"] = sig
73
+ await self._storage.update_audit_signature(entry["sequence_number"], sig)
74
+ else:
75
+ entry["signature"] = None
76
+
77
+ return entry
78
+
79
+ async def query(self, **filters: Any) -> list[dict[str, Any]]:
80
+ """Query audit entries with optional filters."""
81
+ return await self._storage.query_audit_entries(**filters)
@@ -0,0 +1,205 @@
1
+ """Checkpoint policy and scheduling for ANIP audit logs."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import hashlib
6
+ import json
7
+ from collections.abc import Awaitable
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Callable
11
+
12
+ from .hashing import canonical_bytes as _canonical_bytes
13
+ from .merkle import MerkleTree
14
+ from .storage import StorageBackend
15
+
16
+
17
+ @dataclass
18
+ class CheckpointPolicy:
19
+ """Defines when a checkpoint should be created."""
20
+
21
+ entry_count: int | None = None
22
+ interval_seconds: int | None = None
23
+
24
+ def should_checkpoint(
25
+ self, entries_since_last: int, seconds_since_last: float = 0
26
+ ) -> bool:
27
+ if self.entry_count is not None and entries_since_last >= self.entry_count:
28
+ return True
29
+ if (
30
+ self.interval_seconds is not None
31
+ and seconds_since_last >= self.interval_seconds
32
+ ):
33
+ return True
34
+ return False
35
+
36
+
37
+ class CheckpointScheduler:
38
+ """Background scheduler that coordinates checkpoint generation."""
39
+
40
+ def __init__(
41
+ self,
42
+ interval_seconds: int,
43
+ create_fn: Callable[[], Awaitable[None]],
44
+ on_error: Callable[[str], None] | None = None,
45
+ ):
46
+ self._interval = interval_seconds
47
+ self._create_fn = create_fn
48
+ self._on_error = on_error
49
+ self._task: asyncio.Task[None] | None = None
50
+ self._last_run_at: str | None = None
51
+ self._last_error: str | None = None
52
+
53
+ @property
54
+ def last_run_at(self) -> str | None:
55
+ return self._last_run_at
56
+
57
+ @property
58
+ def last_error(self) -> str | None:
59
+ return self._last_error
60
+
61
+ def start(self) -> None:
62
+ loop = asyncio.get_running_loop()
63
+ self._task = loop.create_task(self._run())
64
+
65
+ def stop(self) -> None:
66
+ if self._task:
67
+ self._task.cancel()
68
+ self._task = None
69
+
70
+ async def _run(self) -> None:
71
+ while True:
72
+ await asyncio.sleep(self._interval)
73
+ try:
74
+ await self._create_fn()
75
+ self._last_run_at = datetime.now(timezone.utc).isoformat()
76
+ self._last_error = None
77
+ except asyncio.CancelledError:
78
+ raise
79
+ except Exception as e:
80
+ self._last_error = str(e)
81
+ if self._on_error:
82
+ self._on_error(str(e))
83
+
84
+
85
+ def create_checkpoint(
86
+ *,
87
+ merkle_snapshot: dict[str, Any],
88
+ service_id: str,
89
+ previous_checkpoint: dict[str, Any] | None,
90
+ sign_fn: Callable[[bytes], str] | None = None,
91
+ ) -> tuple[dict[str, Any], str]:
92
+ """Create a checkpoint body and sign it.
93
+
94
+ Returns (body_dict, signature_string).
95
+ """
96
+ if previous_checkpoint is None:
97
+ first_sequence = 1
98
+ prev_hash = None
99
+ checkpoint_number = 1
100
+ else:
101
+ first_sequence = (
102
+ previous_checkpoint.get("range", {}).get("last_sequence", 0) + 1
103
+ )
104
+ prev_body_canonical = json.dumps(
105
+ previous_checkpoint, separators=(",", ":"), sort_keys=True
106
+ ).encode()
107
+ prev_hash = f"sha256:{hashlib.sha256(prev_body_canonical).hexdigest()}"
108
+ prev_id = previous_checkpoint.get("checkpoint_id", "ckpt-0")
109
+ checkpoint_number = int(prev_id.split("-")[1]) + 1
110
+
111
+ last_sequence = merkle_snapshot["leaf_count"]
112
+ entry_count = last_sequence - first_sequence + 1
113
+
114
+ body = {
115
+ "version": "0.3",
116
+ "service_id": service_id,
117
+ "checkpoint_id": f"ckpt-{checkpoint_number}",
118
+ "range": {
119
+ "first_sequence": first_sequence,
120
+ "last_sequence": last_sequence,
121
+ },
122
+ "merkle_root": merkle_snapshot["root"],
123
+ "previous_checkpoint": prev_hash,
124
+ "timestamp": datetime.now(timezone.utc).isoformat(),
125
+ "entry_count": entry_count,
126
+ }
127
+
128
+ canonical_bytes = json.dumps(
129
+ body, separators=(",", ":"), sort_keys=True
130
+ ).encode()
131
+ signature = sign_fn(canonical_bytes) if sign_fn else ""
132
+
133
+ return body, signature
134
+
135
+
136
+ async def reconstruct_and_create_checkpoint(
137
+ *,
138
+ storage: StorageBackend,
139
+ service_id: str,
140
+ sign_fn: Callable[[bytes], str] | None = None,
141
+ ) -> tuple[dict[str, Any], str] | None:
142
+ """Reconstruct Merkle tree from storage and create a checkpoint.
143
+
144
+ Reads ALL audit entries from storage, rebuilds the cumulative Merkle
145
+ tree, and produces a new checkpoint covering everything up to the
146
+ current max sequence number.
147
+
148
+ Returns (body, signature) or None if no new entries since last checkpoint.
149
+ """
150
+ max_seq = await storage.get_max_audit_sequence()
151
+ if max_seq is None:
152
+ return None
153
+
154
+ checkpoints = await storage.get_checkpoints(limit=1)
155
+ last_cp = checkpoints[-1] if checkpoints else None
156
+ last_covered = last_cp["range"]["last_sequence"] if last_cp else 0
157
+
158
+ if max_seq <= last_covered:
159
+ return None # No new entries
160
+
161
+ # Full reconstruction from entry 1
162
+ entries = await storage.get_audit_entries_range(1, max_seq)
163
+
164
+ # Rebuild Merkle tree
165
+ tree = MerkleTree()
166
+ for entry in entries:
167
+ tree.add_leaf(_canonical_bytes(entry))
168
+
169
+ snapshot = tree.snapshot()
170
+
171
+ # For cumulative checkpoints the range always starts at 1 because the
172
+ # Merkle tree is rebuilt from the very first entry. create_checkpoint
173
+ # would normally compute first_sequence from the previous checkpoint's
174
+ # last_sequence, so we pass a synthetic copy with last_sequence=0 to
175
+ # force first_sequence=1 while keeping checkpoint_id for numbering.
176
+ synthetic_prev: dict[str, Any] | None = None
177
+ if last_cp is not None:
178
+ synthetic_prev = {
179
+ **last_cp,
180
+ "range": {**last_cp["range"], "last_sequence": 0},
181
+ }
182
+
183
+ body, signature = create_checkpoint(
184
+ merkle_snapshot=snapshot,
185
+ service_id=service_id,
186
+ previous_checkpoint=synthetic_prev,
187
+ sign_fn=sign_fn,
188
+ )
189
+
190
+ # Restore the correct previous_checkpoint hash (computed from the
191
+ # real stored checkpoint, not the synthetic copy).
192
+ if last_cp is not None:
193
+ prev_body_canonical = json.dumps(
194
+ last_cp, separators=(",", ":"), sort_keys=True
195
+ ).encode()
196
+ body["previous_checkpoint"] = (
197
+ f"sha256:{hashlib.sha256(prev_body_canonical).hexdigest()}"
198
+ )
199
+
200
+ # Re-sign with the corrected body if a sign_fn was provided.
201
+ if last_cp is not None and sign_fn is not None:
202
+ cb = json.dumps(body, separators=(",", ":"), sort_keys=True).encode()
203
+ signature = sign_fn(cb)
204
+
205
+ return body, signature