audit-framework-jsonl 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,18 @@
1
+ """audit-framework-jsonl — append-only JSONL file sink for audit-framework.
2
+
3
+ The reference :class:`~audit_framework.core.ports.ExternalSink` implementation:
4
+ copy ``sink.py`` as the template for your own sink (Splunk HEC, Elasticsearch,
5
+ syslog, …).
6
+ """
7
+
8
+ from audit_framework_jsonl.plugin import register
9
+ from audit_framework_jsonl.sink import JsonlFileSink
10
+
11
+ import importlib.metadata as _md
12
+
13
+ try:
14
+ __version__ = _md.version("audit-framework-jsonl")
15
+ except _md.PackageNotFoundError: # running from source without an install
16
+ __version__ = "0.0.0+unknown"
17
+
18
+ __all__ = ["JsonlFileSink", "register", "__version__"]
@@ -0,0 +1,20 @@
1
+ """Plugin registration for the JSONL file sink.
2
+
3
+ The core never imports this module directly; it is reached either via the
4
+ ``audit_framework.plugins`` entry point declared in ``pyproject.toml`` (picked
5
+ up by :meth:`PluginRegistry.discover_entrypoints`) or by an explicit module
6
+ path passed to :meth:`PluginRegistry.load_from_config`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from audit_framework_jsonl.sink import JsonlFileSink
14
+
15
+ __all__ = ["register"]
16
+
17
+
18
+ def register(registry: Any) -> None:
19
+ """Register :class:`JsonlFileSink` as the ``file_jsonl`` external sink."""
20
+ registry.register("external_sink", "file_jsonl", JsonlFileSink)
@@ -0,0 +1,135 @@
1
+ """JsonlFileSink — an append-only JSON-Lines :class:`ExternalSink`.
2
+
3
+ This is the **reference sink implementation** for the audit-framework plugin
4
+ system: the simplest possible adapter, written so a customer can copy it as the
5
+ template for their own sink (Splunk HEC, Elasticsearch, syslog, …). The recipe
6
+ every sink follows:
7
+
8
+ 1. Expose a stable :pyattr:`sink_name` (matched against ``AuditPolicy.sinks``).
9
+ 2. Implement ``async emit(event, context)`` — forward one event, best-effort.
10
+ 3. Implement ``async health_check()`` — report whether the downstream is usable.
11
+ 4. Register the class in a ``register(registry)`` function (see ``plugin.py``).
12
+
13
+ This sink appends one compact JSON object per line to a file. Writes are
14
+ serialised with an :class:`asyncio.Lock` and offloaded with
15
+ :func:`asyncio.to_thread`, so concurrent ``emit`` calls neither interleave nor
16
+ block the event loop. Optional rotation is supported by date (one file per UTC
17
+ day) and/or by size (roll the current file aside once it would exceed a byte
18
+ threshold).
19
+
20
+ Only the standard library is used here; the ``audit_framework`` import is for
21
+ type hints and is part of this plugin's declared dependency.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import json
28
+ import os
29
+ from datetime import datetime, timezone
30
+ from pathlib import Path
31
+ from typing import Callable, Optional
32
+
33
+ from audit_framework.core.models import AuditEvent, PipelineContext
34
+
35
+ __all__ = ["JsonlFileSink"]
36
+
37
+
38
+ def _utc_now() -> datetime:
39
+ return datetime.now(timezone.utc)
40
+
41
+
42
+ class JsonlFileSink:
43
+ """Appends each audit event as one JSON line to a file.
44
+
45
+ Parameters
46
+ ----------
47
+ path:
48
+ Destination file (its parent directories are created on demand).
49
+ name:
50
+ The :pyattr:`sink_name` used for per-policy sink filtering.
51
+ daily:
52
+ When True, write to a date-stamped file ``<stem>-YYYY-MM-DD<suffix>``
53
+ so each UTC day gets its own file.
54
+ max_bytes:
55
+ When set, roll the current file aside (``<stem>.<timestamp><suffix>``)
56
+ before a write that would push it past this size. Composes with
57
+ ``daily``.
58
+ clock:
59
+ Injectable time source (returns an aware :class:`datetime`); used for
60
+ rotation stamps. Defaults to ``datetime.now(timezone.utc)``.
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ path: str | os.PathLike[str],
66
+ *,
67
+ name: str = "file_jsonl",
68
+ daily: bool = False,
69
+ max_bytes: Optional[int] = None,
70
+ clock: Callable[[], datetime] = _utc_now,
71
+ ) -> None:
72
+ self._base = Path(path)
73
+ self._name = name
74
+ self._daily = daily
75
+ self._max_bytes = max_bytes
76
+ self._clock = clock
77
+ self._lock = asyncio.Lock()
78
+
79
+ @property
80
+ def sink_name(self) -> str:
81
+ """Stable identifier matched against ``AuditPolicy.sinks``."""
82
+ return self._name
83
+
84
+ async def emit(self, event: AuditEvent, context: PipelineContext) -> None:
85
+ """Append ``event`` (as one JSON line) to the current target file.
86
+
87
+ Best-effort and serialised: a raised OSError propagates so the
88
+ ``SinkFanOutMiddleware`` can record the failure, but it never corrupts a
89
+ concurrent write (the lock guarantees one writer at a time).
90
+ """
91
+ line = json.dumps(
92
+ event.to_dict(), separators=(",", ":"), ensure_ascii=False, default=str
93
+ )
94
+ async with self._lock:
95
+ await asyncio.to_thread(self._write, line)
96
+
97
+ async def health_check(self) -> bool:
98
+ """Return True if the target directory exists (or can be) and is writable."""
99
+ return await asyncio.to_thread(self._check_writable)
100
+
101
+ # ----------------------------------------------------------------- #
102
+ # Blocking helpers (run inside asyncio.to_thread) #
103
+ # ----------------------------------------------------------------- #
104
+ def _write(self, line: str) -> None:
105
+ path = self._target_path()
106
+ path.parent.mkdir(parents=True, exist_ok=True)
107
+ if self._max_bytes is not None and path.exists():
108
+ projected = path.stat().st_size + len(line.encode("utf-8")) + 1
109
+ if projected > self._max_bytes:
110
+ self._rollover(path)
111
+ with path.open("a", encoding="utf-8") as fh:
112
+ fh.write(line + "\n")
113
+
114
+ def _check_writable(self) -> bool:
115
+ try:
116
+ parent = self._base.parent
117
+ parent.mkdir(parents=True, exist_ok=True)
118
+ return os.access(parent, os.W_OK)
119
+ except OSError:
120
+ return False
121
+
122
+ def _target_path(self) -> Path:
123
+ if not self._daily:
124
+ return self._base
125
+ stamp = self._clock().strftime("%Y-%m-%d")
126
+ return self._base.with_name(f"{self._base.stem}-{stamp}{self._base.suffix}")
127
+
128
+ def _rollover(self, path: Path) -> None:
129
+ stamp = self._clock().strftime("%Y%m%dT%H%M%S")
130
+ target = path.with_name(f"{path.stem}.{stamp}{path.suffix}")
131
+ suffix = 0
132
+ while target.exists(): # never clobber an existing rolled file
133
+ suffix += 1
134
+ target = path.with_name(f"{path.stem}.{stamp}.{suffix}{path.suffix}")
135
+ path.rename(target)
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: audit-framework-jsonl
3
+ Version: 0.1.0
4
+ Summary: Append-only JSONL file ExternalSink for audit-framework — the reference sink plugin.
5
+ Project-URL: Homepage, https://github.com/vanmarkic/audit-logger
6
+ Project-URL: Repository, https://github.com/vanmarkic/audit-logger
7
+ License-Expression: MIT
8
+ Keywords: audit,audit-log,jsonl,plugin,siem,sink
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: audit-framework<0.2,>=0.1
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8.0; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # audit-framework-jsonl
16
+
17
+ An append-only **JSON-Lines file sink** for
18
+ [`audit-framework`](../audit-framework) — and the **reference implementation**
19
+ every other `ExternalSink` (Splunk HEC, Elasticsearch, syslog, …) can be copied
20
+ from.
21
+
22
+ It implements the `ExternalSink` port: one compact JSON object per line,
23
+ appended to a file. Writes are serialised and offloaded off the event loop, with
24
+ optional rotation by date and/or size.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install audit-framework-jsonl
30
+ ```
31
+
32
+ ## Use
33
+
34
+ ```python
35
+ from audit_framework_jsonl.sink import JsonlFileSink
36
+ from audit_framework.core.middlewares.sink_fanout import SinkFanOutMiddleware
37
+
38
+ sink = JsonlFileSink("/var/log/audit/audit.jsonl", daily=True, max_bytes=50_000_000)
39
+ pipeline.use(SinkFanOutMiddleware([sink])) # fan events out to this sink
40
+ ```
41
+
42
+ Or wire it by configuration through the registry — it advertises itself under
43
+ the `audit_framework.plugins` entry point as the `file_jsonl` external sink:
44
+
45
+ ```python
46
+ registry.discover_entrypoints() # finds file_jsonl
47
+ SinkClass = registry.get("external_sink", "file_jsonl")
48
+ sink = SinkClass("/var/log/audit/audit.jsonl")
49
+ ```
50
+
51
+ Each emitted line is `event.to_dict()` serialised compactly, e.g.:
52
+
53
+ ```json
54
+ {"actor_id":"alice","action":"DELETE","resource_type":"contract","resource_id":"c-42",...}
55
+ ```
56
+
57
+ ### Options
58
+
59
+ | Param | Effect |
60
+ |---|---|
61
+ | `name` | The `sink_name` used for per-policy sink filtering (default `"file_jsonl"`). |
62
+ | `daily` | Write to a date-stamped file `audit-YYYY-MM-DD.jsonl` (one per UTC day). |
63
+ | `max_bytes` | Roll the current file aside (`audit.<timestamp>.jsonl`) before it exceeds this size. Composes with `daily`. |
64
+ | `clock` | Injectable time source for rotation stamps (testing). |
65
+
66
+ ## Writing your own sink
67
+
68
+ `sink.py` is deliberately tiny — copy it and change four things:
69
+
70
+ 1. a stable `sink_name` (matched against `AuditPolicy.sinks`),
71
+ 2. `async emit(event, context)` — forward `event.to_dict()` to your platform (best-effort; raise on permanent failure so the pipeline records it),
72
+ 3. `async health_check()` — report reachability,
73
+ 4. a `register(registry)` that calls `registry.register("external_sink", "<name>", YourSink)` (+ an `audit_framework.plugins` entry point in `pyproject.toml`).
74
+
75
+ ## Development
76
+
77
+ ```bash
78
+ pip install -e ".[dev]"
79
+ pytest # 9 stdlib-only tests (tmp files; no infrastructure)
80
+ ```
81
+
82
+ ## License
83
+
84
+ MIT
@@ -0,0 +1,7 @@
1
+ audit_framework_jsonl/__init__.py,sha256=WGJYvRCkbIduq_J9EoK-NdCgBbpd1ingyLzTAYps0NY,620
2
+ audit_framework_jsonl/plugin.py,sha256=NictSLREHWUCswa4hjTHFtck3zi6_-_CHoCM3P2y_hY,662
3
+ audit_framework_jsonl/sink.py,sha256=wnuXm4HVKkB_oX_GqHqoXWFf7qVmaKOFtbsILynjMa0,5175
4
+ audit_framework_jsonl-0.1.0.dist-info/METADATA,sha256=otv8QdTsRnxOPhT-BQ7GB8572NbyRXlwvIoPenApV90,2925
5
+ audit_framework_jsonl-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
6
+ audit_framework_jsonl-0.1.0.dist-info/entry_points.txt,sha256=NKdDnahYuY9Hcl4awH4ANCxV6IUKfpZxGAuOToKpi9M,77
7
+ audit_framework_jsonl-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [audit_framework.plugins]
2
+ file_jsonl = audit_framework_jsonl.plugin:register