starfish-audit 3.0.0a5__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.
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: starfish-audit
3
+ Version: 3.0.0a5
4
+ Summary: Starfish audit logging extension (console / callback / no-op audit loggers for server pull/push events)
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: starfish-protocol
7
+ Provides-Extra: dev
8
+ Requires-Dist: pytest>=7.0; extra == "dev"
9
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
10
+ Requires-Dist: httpx>=0.27; extra == "dev"
11
+ Requires-Dist: starfish-server; extra == "dev"
@@ -0,0 +1,53 @@
1
+ # starfish-audit
2
+
3
+ Starfish audit logging extension for Python — ready-made audit loggers for
4
+ recording the server's pull/push access events. The `AuditEntry` / `AuditLogger`
5
+ contract lives in `starfish-protocol`; this package ships the concrete loggers.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pip install starfish-server starfish-audit
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ Pass a logger to the sync router via `audit_logger`:
16
+
17
+ ```python
18
+ from starfish_server.router.route_builder import create_sync_router, SyncRouterOptions
19
+ from starfish_audit import ConsoleAuditLogger
20
+
21
+ router = create_sync_router(
22
+ SyncRouterOptions(
23
+ store=store,
24
+ config=config,
25
+ role_resolver=role_resolver,
26
+ audit_logger=ConsoleAuditLogger(),
27
+ ),
28
+ )
29
+ ```
30
+
31
+ Write your own sink with `CallbackAuditLogger` (sync or async callback):
32
+
33
+ ```python
34
+ from starfish_audit import CallbackAuditLogger, AuditEntry
35
+
36
+ async def _record(entry: AuditEntry) -> None:
37
+ await db.audit.insert(entry)
38
+
39
+ audit_logger = CallbackAuditLogger(_record)
40
+ ```
41
+
42
+ `NoopAuditLogger` discards entries.
43
+
44
+ The server **awaits** `record()` for each request, so the entry is durable before
45
+ the response is returned. Keep the sink fast and resilient: a slow logger adds
46
+ request latency and a raising one surfaces as a request error.
47
+
48
+ > Audit logging is server-side observability — this package depends only on
49
+ > `starfish-protocol` and registers no cap-cert validator. Note: the Python
50
+ > server currently emits audit entries on **push** operations (the TypeScript
51
+ > server emits on both pull and push).
52
+
53
+ See `docs/python/audit/` (and the TypeScript counterpart in `docs/ts/audit/`) for the full guide.
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "starfish-audit"
7
+ version = "3.0.0a5"
8
+ description = "Starfish audit logging extension (console / callback / no-op audit loggers for server pull/push events)"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "starfish-protocol",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ dev = [
16
+ "pytest>=7.0",
17
+ "pytest-asyncio>=0.21",
18
+ "httpx>=0.27",
19
+ "starfish-server",
20
+ ]
21
+
22
+ [tool.uv.sources]
23
+ starfish-protocol = { path = "../protocol", editable = true }
24
+ starfish-server = { path = "../server", editable = true }
25
+
26
+ [tool.pytest.ini_options]
27
+ asyncio_mode = "auto"
28
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,22 @@
1
+ """``starfish-audit`` — audit logging extension.
2
+
3
+ Ready-made audit loggers for recording the server's pull/push access events.
4
+ The ``AuditEntry`` / ``AuditLogger`` contract lives in ``starfish_protocol``;
5
+ this package re-exports them alongside the concrete loggers for convenience.
6
+ """
7
+
8
+ from starfish_protocol import AuditEntry, AuditLogger
9
+
10
+ from starfish_audit.audit import (
11
+ CallbackAuditLogger,
12
+ ConsoleAuditLogger,
13
+ NoopAuditLogger,
14
+ )
15
+
16
+ __all__ = [
17
+ "AuditEntry",
18
+ "AuditLogger",
19
+ "ConsoleAuditLogger",
20
+ "CallbackAuditLogger",
21
+ "NoopAuditLogger",
22
+ ]
@@ -0,0 +1,44 @@
1
+ """Concrete audit loggers for the Starfish server.
2
+
3
+ The ``AuditEntry`` dataclass and ``AuditLogger`` base class live in
4
+ ``starfish_protocol``; this module supplies the ready-made implementations.
5
+ """
6
+
7
+ import inspect
8
+ from typing import Awaitable, Callable
9
+
10
+ from starfish_protocol import AuditEntry, AuditLogger
11
+
12
+
13
+ class ConsoleAuditLogger(AuditLogger):
14
+ """Audit logger that writes to console."""
15
+
16
+ async def record(self, entry: AuditEntry) -> None:
17
+ status = "OK" if entry.success else "FAIL"
18
+ identity = entry.identity or "anonymous"
19
+ print(
20
+ f"[Starfish:AUDIT] {entry.action.upper()} {entry.collection} "
21
+ f"by {identity} → {status} ({entry.status_code})"
22
+ )
23
+
24
+
25
+ class CallbackAuditLogger(AuditLogger):
26
+ """Audit logger that delegates to a sync or async callback."""
27
+
28
+ def __init__(self, callback: Callable[[AuditEntry], None | Awaitable[None]]) -> None:
29
+ self._callback = callback
30
+
31
+ async def record(self, entry: AuditEntry) -> None:
32
+ result = self._callback(entry)
33
+ if inspect.isawaitable(result):
34
+ await result
35
+
36
+
37
+ class NoopAuditLogger(AuditLogger):
38
+ """No-op audit logger (discards entries)."""
39
+
40
+ async def record(self, entry: AuditEntry) -> None:
41
+ pass
42
+
43
+
44
+ __all__ = ["ConsoleAuditLogger", "CallbackAuditLogger", "NoopAuditLogger"]
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: starfish-audit
3
+ Version: 3.0.0a5
4
+ Summary: Starfish audit logging extension (console / callback / no-op audit loggers for server pull/push events)
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: starfish-protocol
7
+ Provides-Extra: dev
8
+ Requires-Dist: pytest>=7.0; extra == "dev"
9
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
10
+ Requires-Dist: httpx>=0.27; extra == "dev"
11
+ Requires-Dist: starfish-server; extra == "dev"
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ starfish_audit/__init__.py
4
+ starfish_audit/audit.py
5
+ starfish_audit.egg-info/PKG-INFO
6
+ starfish_audit.egg-info/SOURCES.txt
7
+ starfish_audit.egg-info/dependency_links.txt
8
+ starfish_audit.egg-info/requires.txt
9
+ starfish_audit.egg-info/top_level.txt
10
+ tests/test_audit.py
11
+ tests/test_audit_router.py
@@ -0,0 +1,7 @@
1
+ starfish-protocol
2
+
3
+ [dev]
4
+ pytest>=7.0
5
+ pytest-asyncio>=0.21
6
+ httpx>=0.27
7
+ starfish-server
@@ -0,0 +1 @@
1
+ starfish_audit
@@ -0,0 +1,55 @@
1
+ """Tests for audit logging."""
2
+
3
+ import pytest
4
+ from starfish_audit import AuditEntry, ConsoleAuditLogger, CallbackAuditLogger, NoopAuditLogger
5
+
6
+
7
+ def _make_entry() -> AuditEntry:
8
+ return AuditEntry(
9
+ timestamp=1234,
10
+ action="pull",
11
+ collection="settings",
12
+ identity="user-1",
13
+ document_key="users/user-1/settings",
14
+ success=True,
15
+ status_code=200,
16
+ )
17
+
18
+
19
+ @pytest.mark.asyncio
20
+ async def test_console_audit_logger(capsys):
21
+ logger = ConsoleAuditLogger()
22
+ await logger.record(_make_entry())
23
+ captured = capsys.readouterr()
24
+ assert "PULL" in captured.out
25
+ assert "settings" in captured.out
26
+
27
+
28
+ @pytest.mark.asyncio
29
+ async def test_callback_audit_logger_sync():
30
+ entries = []
31
+ logger = CallbackAuditLogger(lambda e: entries.append(e))
32
+ entry = _make_entry()
33
+ await logger.record(entry)
34
+ assert len(entries) == 1
35
+ assert entries[0] == entry
36
+
37
+
38
+ @pytest.mark.asyncio
39
+ async def test_callback_audit_logger_async():
40
+ entries = []
41
+ async def async_cb(e: AuditEntry) -> None:
42
+ entries.append(e)
43
+ logger = CallbackAuditLogger(async_cb)
44
+ entry = _make_entry()
45
+ await logger.record(entry)
46
+ assert len(entries) == 1
47
+ assert entries[0] == entry
48
+
49
+
50
+ @pytest.mark.asyncio
51
+ async def test_noop_audit_logger(capsys):
52
+ logger = NoopAuditLogger()
53
+ await logger.record(_make_entry())
54
+ captured = capsys.readouterr()
55
+ assert captured.out == ""
@@ -0,0 +1,227 @@
1
+ """AuditLogger must be called on push operations in the Python router.
2
+ This test suite verifies that
3
+ SyncRouterOptions.audit_logger is wired through the push handler.
4
+ """
5
+
6
+ import asyncio
7
+
8
+ import pytest
9
+ from fastapi import FastAPI, Request
10
+ from httpx import AsyncClient, ASGITransport
11
+
12
+ from starfish_server.config.schema import SyncConfig, CollectionConfig
13
+ from starfish_server.router.route_builder import create_sync_router, SyncRouterOptions, AuthResult
14
+ from starfish_audit import AuditLogger, AuditEntry
15
+ from tests.helpers import MemoryObjectStore
16
+
17
+
18
+ def _make_app_with_audit(audit_logger: AuditLogger) -> tuple[FastAPI, MemoryObjectStore]:
19
+ store = MemoryObjectStore()
20
+ config = SyncConfig(
21
+ version=1,
22
+ collections=[
23
+ CollectionConfig(
24
+ name="settings",
25
+ storagePath="users/{identity}/settings",
26
+ readRoles=["self"],
27
+ writeRoles=["self"],
28
+ encryption="none",
29
+ maxBodyBytes=65536,
30
+ ),
31
+ ],
32
+ )
33
+
34
+ async def role_resolver(request: Request) -> AuthResult:
35
+ return AuthResult(identity="user-1", roles=["self"])
36
+
37
+ router = create_sync_router(
38
+ SyncRouterOptions(
39
+ store=store,
40
+ config=config,
41
+ role_resolver=role_resolver,
42
+ audit_logger=audit_logger,
43
+ ),
44
+ )
45
+ app = FastAPI()
46
+ app.include_router(router)
47
+ return app, store
48
+
49
+
50
+ # ─── successful push emits audit entry ──────────────────────────────────
51
+
52
+ @pytest.mark.asyncio
53
+ async def test_successful_push_records_audit_entry():
54
+ """A 200 push must call audit_logger.record with success=True."""
55
+ recorded: list[AuditEntry] = []
56
+
57
+ class CapturingLogger(AuditLogger):
58
+ async def record(self, entry: AuditEntry) -> None:
59
+ recorded.append(entry)
60
+
61
+ app, _ = _make_app_with_audit(CapturingLogger())
62
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
63
+ resp = await client.post(
64
+ "/push/users/user-1/settings",
65
+ json={"data": {"x": 1}, "baseHash": None},
66
+ )
67
+
68
+ assert resp.status_code == 200
69
+ assert len(recorded) == 1
70
+ entry = recorded[0]
71
+ assert entry.action == "push"
72
+ assert entry.success is True
73
+ assert entry.status_code == 200
74
+ assert entry.collection == "settings"
75
+ assert entry.identity == "user-1"
76
+
77
+
78
+ # ─── conflict (409) push emits audit entry with failure ─────────────────
79
+
80
+ @pytest.mark.asyncio
81
+ async def test_conflict_push_records_audit_entry():
82
+ """ A 409 conflict push must call audit_logger.record with success=False."""
83
+ recorded: list[AuditEntry] = []
84
+
85
+ class CapturingLogger(AuditLogger):
86
+ async def record(self, entry: AuditEntry) -> None:
87
+ recorded.append(entry)
88
+
89
+ app, _ = _make_app_with_audit(CapturingLogger())
90
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
91
+ # First push succeeds
92
+ await client.post(
93
+ "/push/users/user-1/settings",
94
+ json={"data": {"x": 1}, "baseHash": None},
95
+ )
96
+ # Second push with wrong baseHash → 409
97
+ conflict_resp = await client.post(
98
+ "/push/users/user-1/settings",
99
+ json={"data": {"x": 2}, "baseHash": "wrong-hash"},
100
+ )
101
+
102
+ assert conflict_resp.status_code == 409
103
+ assert len(recorded) == 2 # both pushes recorded
104
+ conflict_entry = recorded[1]
105
+ assert conflict_entry.action == "push"
106
+ assert conflict_entry.success is False
107
+ assert conflict_entry.status_code == 409
108
+
109
+
110
+ # ─── no audit_logger → no error ────────────────────────────────────────
111
+
112
+ @pytest.mark.asyncio
113
+ async def test_push_without_audit_logger_does_not_crash():
114
+ """ When audit_logger is None (default), push must still work normally."""
115
+ store = MemoryObjectStore()
116
+ config = SyncConfig(
117
+ version=1,
118
+ collections=[
119
+ CollectionConfig(
120
+ name="settings",
121
+ storagePath="users/{identity}/settings",
122
+ readRoles=["self"],
123
+ writeRoles=["self"],
124
+ encryption="none",
125
+ maxBodyBytes=65536,
126
+ ),
127
+ ],
128
+ )
129
+
130
+ async def role_resolver(request: Request) -> AuthResult:
131
+ return AuthResult(identity="user-1", roles=["self"])
132
+
133
+ # No audit_logger passed → must default to None
134
+ router = create_sync_router(
135
+ SyncRouterOptions(store=store, config=config, role_resolver=role_resolver),
136
+ )
137
+ app = FastAPI()
138
+ app.include_router(router)
139
+
140
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
141
+ resp = await client.post(
142
+ "/push/users/user-1/settings",
143
+ json={"data": {"y": 2}, "baseHash": None},
144
+ )
145
+ assert resp.status_code == 200
146
+
147
+
148
+ # ─── auth denial (403) emits an audit entry with failure ────────────────
149
+
150
+ @pytest.mark.asyncio
151
+ async def test_denied_push_records_audit_entry():
152
+ """A 403 auth denial must call audit_logger.record with success=False.
153
+
154
+ Without this, only requests that pass the auth gate are ever audited — so
155
+ denied (401/403) attempts leave no trace in the trail.
156
+ """
157
+ recorded: list[AuditEntry] = []
158
+
159
+ class CapturingLogger(AuditLogger):
160
+ async def record(self, entry: AuditEntry) -> None:
161
+ recorded.append(entry)
162
+
163
+ store = MemoryObjectStore()
164
+ config = SyncConfig(
165
+ version=1,
166
+ collections=[
167
+ CollectionConfig(
168
+ name="settings",
169
+ storagePath="users/{identity}/settings",
170
+ readRoles=["admin"],
171
+ writeRoles=["admin"], # a role the caller never holds
172
+ encryption="none",
173
+ maxBodyBytes=65536,
174
+ ),
175
+ ],
176
+ )
177
+
178
+ async def role_resolver(request: Request) -> AuthResult:
179
+ return AuthResult(identity="user-1", roles=[]) # no roles → write denied
180
+
181
+ router = create_sync_router(
182
+ SyncRouterOptions(
183
+ store=store, config=config, role_resolver=role_resolver, audit_logger=CapturingLogger()
184
+ ),
185
+ )
186
+ app = FastAPI()
187
+ app.include_router(router)
188
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
189
+ resp = await client.post(
190
+ "/push/users/user-1/settings",
191
+ json={"data": {"x": 1}, "baseHash": None},
192
+ )
193
+
194
+ assert resp.status_code == 403
195
+ assert len(recorded) == 1
196
+ assert recorded[0].action == "push"
197
+ assert recorded[0].success is False
198
+ assert recorded[0].status_code == 403
199
+ assert recorded[0].collection == "settings"
200
+
201
+
202
+ @pytest.mark.asyncio
203
+ async def test_awaits_async_audit_logger_before_returning_push_response():
204
+ # Cross-language divergence. Python does `await audit_logger.record(...)`, so an
205
+ # async logger's write completes before the response is returned (this test).
206
+ # TS calls `opts.auditLogger.record(...)` WITHOUT await, so an async logger is
207
+ # fire-and-forget — the entry may not be written before the response, and a
208
+ # rejected logger becomes an unhandled rejection. This is the reference for the
209
+ # convergent durable behaviour; the TS side is pinned as it.fails in
210
+ # router-emission.test.ts.
211
+ audit_completed = False
212
+
213
+ class SlowLogger(AuditLogger):
214
+ async def record(self, entry: AuditEntry) -> None:
215
+ nonlocal audit_completed
216
+ await asyncio.sleep(0)
217
+ audit_completed = True
218
+
219
+ app, _ = _make_app_with_audit(SlowLogger())
220
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
221
+ resp = await client.post(
222
+ "/push/users/user-1/settings",
223
+ json={"data": {"x": 1}, "baseHash": None},
224
+ )
225
+
226
+ assert resp.status_code == 200
227
+ assert audit_completed is True # Python awaits the record call before responding