anip-server 0.11.0__tar.gz
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.
- anip_server-0.11.0/PKG-INFO +21 -0
- anip_server-0.11.0/README.md +15 -0
- anip_server-0.11.0/pyproject.toml +36 -0
- anip_server-0.11.0/setup.cfg +4 -0
- anip_server-0.11.0/src/anip_server/__init__.py +37 -0
- anip_server-0.11.0/src/anip_server/audit.py +81 -0
- anip_server-0.11.0/src/anip_server/checkpoint.py +205 -0
- anip_server-0.11.0/src/anip_server/delegation.py +624 -0
- anip_server-0.11.0/src/anip_server/hashing.py +23 -0
- anip_server-0.11.0/src/anip_server/manifest.py +47 -0
- anip_server-0.11.0/src/anip_server/merkle.py +227 -0
- anip_server-0.11.0/src/anip_server/permissions.py +70 -0
- anip_server-0.11.0/src/anip_server/postgres.py +593 -0
- anip_server-0.11.0/src/anip_server/retention_enforcer.py +103 -0
- anip_server-0.11.0/src/anip_server/sinks.py +29 -0
- anip_server-0.11.0/src/anip_server/storage.py +890 -0
- anip_server-0.11.0/src/anip_server.egg-info/PKG-INFO +21 -0
- anip_server-0.11.0/src/anip_server.egg-info/SOURCES.txt +30 -0
- anip_server-0.11.0/src/anip_server.egg-info/dependency_links.txt +1 -0
- anip_server-0.11.0/src/anip_server.egg-info/requires.txt +9 -0
- anip_server-0.11.0/src/anip_server.egg-info/top_level.txt +1 -0
- anip_server-0.11.0/tests/test_audit.py +125 -0
- anip_server-0.11.0/tests/test_checkpoint.py +28 -0
- anip_server-0.11.0/tests/test_checkpoint_reconstruction.py +81 -0
- anip_server-0.11.0/tests/test_delegation.py +114 -0
- anip_server-0.11.0/tests/test_horizontal.py +88 -0
- anip_server-0.11.0/tests/test_manifest.py +21 -0
- anip_server-0.11.0/tests/test_merkle.py +56 -0
- anip_server-0.11.0/tests/test_permissions.py +58 -0
- anip_server-0.11.0/tests/test_postgres.py +401 -0
- anip_server-0.11.0/tests/test_retention_enforcer.py +51 -0
- anip_server-0.11.0/tests/test_storage.py +514 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: anip-server
|
|
3
|
+
Version: 0.11.0
|
|
4
|
+
Summary: ANIP server primitives — delegation, audit, checkpoints, Merkle trees
|
|
5
|
+
Author-email: ANIP Protocol <team@anip.dev>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Repository, https://github.com/anip-protocol/anip
|
|
8
|
+
Keywords: anip,agent,protocol
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: anip-core==0.11.0
|
|
16
|
+
Requires-Dist: anip-crypto==0.11.0
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
20
|
+
Provides-Extra: postgres
|
|
21
|
+
Requires-Dist: asyncpg>=0.29; extra == "postgres"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# anip-server
|
|
2
|
+
|
|
3
|
+
ANIP server primitives — delegation, audit, checkpoints, Merkle trees
|
|
4
|
+
|
|
5
|
+
Part of the [ANIP](https://github.com/anip-protocol/anip) protocol ecosystem.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install anip-server
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Documentation
|
|
14
|
+
|
|
15
|
+
See the [ANIP repository](https://github.com/anip-protocol/anip) for full documentation.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "anip-server"
|
|
3
|
+
version = "0.11.0"
|
|
4
|
+
description = "ANIP server primitives — delegation, audit, checkpoints, Merkle trees"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"anip-core==0.11.0",
|
|
8
|
+
"anip-crypto==0.11.0",
|
|
9
|
+
]
|
|
10
|
+
authors = [{ name = "ANIP Protocol", email = "team@anip.dev" }]
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
keywords = ["anip", "agent", "protocol"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"License :: OSI Approved :: Apache Software License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.23"]
|
|
23
|
+
postgres = ["asyncpg>=0.29"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Repository = "https://github.com/anip-protocol/anip"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["setuptools>=68.0"]
|
|
30
|
+
build-backend = "setuptools.build_meta"
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
asyncio_mode = "auto"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
@@ -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
|
+
]
|
|
@@ -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
|