qodalis-cli-server-plugin-audit 1.0.0b18__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,44 @@
1
+ # Byte-compiled / optimized
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Distribution / packaging
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ .eggs/
11
+ *.egg
12
+
13
+ # Virtual environments
14
+ .venv/
15
+ venv/
16
+ env/
17
+
18
+ # Environment variables
19
+ .env
20
+
21
+ # Testing
22
+ .pytest_cache/
23
+ .coverage
24
+ htmlcov/
25
+ TestResults/
26
+
27
+ # Type checking / linting
28
+ .mypy_cache/
29
+ .ruff_cache/
30
+
31
+ # C extensions
32
+ *.so
33
+
34
+ # IDEs and editors
35
+ .idea/
36
+ .vscode/*
37
+ !.vscode/settings.json
38
+ !.vscode/launch.json
39
+ !.vscode/extensions.json
40
+
41
+ # System files
42
+ .DS_Store
43
+ Thumbs.db
44
+ node_modules/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 Qodalis Solutions
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.5
2
+ Name: qodalis-cli-server-plugin-audit
3
+ Version: 1.0.0b18
4
+ Summary: Audit log plugin for Qodalis CLI servers — who did what, to whom and when, and its API
5
+ Project-URL: Homepage, https://github.com/qodalis-solutions/cli-server-python
6
+ Project-URL: Repository, https://github.com/qodalis-solutions/cli-server-python
7
+ Project-URL: Issues, https://github.com/qodalis-solutions/cli-server-python/issues
8
+ Author-email: Nicolae Lupei <nicolae.lupei@qodalis.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: audit,audit-log,cli,plugin,qodalis
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: fastapi>=0.100.0
22
+ Requires-Dist: qodalis-cli-server-abstractions>=1.0.0b17
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "qodalis-cli-server-plugin-audit"
7
+ version = "1.0.0b18"
8
+ description = "Audit log plugin for Qodalis CLI servers — who did what, to whom and when, and its API"
9
+ readme = { text = "", content-type = "text/plain" }
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Nicolae Lupei", email = "nicolae.lupei@qodalis.com" },
14
+ ]
15
+ keywords = ["cli", "qodalis", "audit", "audit-log", "plugin"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ ]
26
+ dependencies = [
27
+ "qodalis-cli-server-abstractions>=1.0.0b17",
28
+ "fastapi>=0.100.0",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/qodalis-solutions/cli-server-python"
33
+ Repository = "https://github.com/qodalis-solutions/cli-server-python"
34
+ Issues = "https://github.com/qodalis-solutions/cli-server-python/issues"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["qodalis_cli_audit"]
38
+
39
+ [tool.hatch.build.targets.sdist]
40
+ include = ["qodalis_cli_audit/**"]
@@ -0,0 +1,48 @@
1
+ """Qodalis CLI audit plugin — the audit log (who did what, to whom or what,
2
+ and when), its in-memory store and its API at ``/api/v1/qcli/audit``."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from qodalis_cli_server_abstractions.audit import (
7
+ AuditEntry,
8
+ AuditMetadata,
9
+ AuditQueryOptions,
10
+ IAuditStore,
11
+ )
12
+
13
+ from .cli_audit_builder import PREFIX, CliAuditBuilder, CliAuditPlugin, get_audit_plugin
14
+ from .cli_audit_controller import (
15
+ AUDIT_CLEAR,
16
+ AUDIT_CLEARED_EVENT,
17
+ AUDIT_READ,
18
+ DEFAULT_AUDIT_LIMIT,
19
+ MAX_AUDIT_LIMIT,
20
+ create_audit_router,
21
+ )
22
+ from .in_memory_audit_store import (
23
+ FAILED_LOGIN_EVENT,
24
+ MAX_ENTRIES,
25
+ MAX_FAILED_LOGIN_ENTRIES,
26
+ InMemoryAuditStore,
27
+ )
28
+
29
+ __all__ = [
30
+ "AUDIT_CLEAR",
31
+ "AUDIT_CLEARED_EVENT",
32
+ "AUDIT_READ",
33
+ "AuditEntry",
34
+ "AuditMetadata",
35
+ "AuditQueryOptions",
36
+ "CliAuditBuilder",
37
+ "CliAuditPlugin",
38
+ "DEFAULT_AUDIT_LIMIT",
39
+ "FAILED_LOGIN_EVENT",
40
+ "IAuditStore",
41
+ "InMemoryAuditStore",
42
+ "MAX_AUDIT_LIMIT",
43
+ "MAX_ENTRIES",
44
+ "MAX_FAILED_LOGIN_ENTRIES",
45
+ "PREFIX",
46
+ "create_audit_router",
47
+ "get_audit_plugin",
48
+ ]
@@ -0,0 +1,124 @@
1
+ """Fluent builder for the audit plugin.
2
+
3
+ Usage::
4
+
5
+ audit_plugin = CliAuditBuilder().build() # in memory, the latest 1000 entries
6
+ # or keep the log elsewhere:
7
+ audit_plugin = CliAuditBuilder().set_store(MyAuditStore()).build()
8
+ result.mount_plugin(audit_plugin)
9
+
10
+ The users plugin mounts one itself (with its own ``set_audit_store``, if any),
11
+ so a host that only mounts the users plugin keeps its audit log. A server
12
+ serves one audit log: mounting a second audit plugin (or the same one again)
13
+ adds no routes; an explicitly set store replaces the default one, and of two
14
+ set stores the one mounted last is kept.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Awaitable, Callable, Optional
21
+
22
+ from fastapi import APIRouter, Request
23
+
24
+ from qodalis_cli_server_abstractions.audit import IAuditStore
25
+
26
+ from .cli_audit_controller import create_audit_router
27
+ from .in_memory_audit_store import InMemoryAuditStore
28
+
29
+ PREFIX = "/api/v1/qcli/audit"
30
+
31
+ #: Attribute of the server result holding the audit plugin that serves it.
32
+ _SERVER_ATTR = "_qcli_audit_plugin"
33
+
34
+ #: ``authorize(request, permission)`` returns the caller or raises an
35
+ #: ``HTTPException`` (rendered as ``{"error": detail}``).
36
+ Authorizer = Callable[[Request, str], Awaitable[Any]]
37
+ StoreListener = Callable[[IAuditStore], None]
38
+
39
+
40
+ def get_audit_plugin(server: Any) -> Optional[CliAuditPlugin]:
41
+ """The audit plugin mounted on ``server`` (a ``CliServerResult``), or ``None``."""
42
+ return getattr(server, _SERVER_ATTR, None)
43
+
44
+
45
+ @dataclass
46
+ class CliAuditPlugin:
47
+ """The built audit plugin: mount it with ``CliServerResult.mount_plugin``.
48
+
49
+ ``store`` is the log; record a host's own changes with
50
+ ``await plugin.store.log(AuditEntry(...))``.
51
+ """
52
+
53
+ prefix: str
54
+ store: IAuditStore
55
+ #: ``True`` when the store was set (not the default in-memory one).
56
+ store_set: bool = False
57
+ #: Checks the caller of the audit routes itself. ``None`` (default)
58
+ #: leaves it to the server's route policy (``audit:read`` / ``audit:clear``
59
+ #: with the users plugin's server protection). The users plugin sets it
60
+ #: when it does not protect the whole server.
61
+ authorize: Optional[Authorizer] = None
62
+ router: APIRouter = field(init=False, repr=False)
63
+ _listeners: list = field(default_factory=list, repr=False)
64
+
65
+ def __post_init__(self) -> None:
66
+ self.router = create_audit_router(self)
67
+
68
+ def use_store(self, store: IAuditStore, *, explicit: bool = True) -> None:
69
+ """Keep the log in ``store`` from now on (listeners are told)."""
70
+ self.store_set = self.store_set or explicit
71
+ if store is self.store:
72
+ return
73
+ self.store = store
74
+ for listener in list(self._listeners):
75
+ listener(store)
76
+
77
+ def on_store_changed(self, listener: StoreListener) -> None:
78
+ """Call ``listener(store)`` whenever the store is replaced (e.g. the
79
+ users plugin records its events in the store of the audit plugin it
80
+ joined)."""
81
+ self._listeners.append(listener)
82
+
83
+ def before_mount(self, server: Any) -> bool:
84
+ """Hook of ``CliServerResult.mount_plugin``: ``False`` when the server
85
+ already has an audit plugin (this one or another), which then serves
86
+ both; the routes are added once."""
87
+ serving = get_audit_plugin(server)
88
+ if serving is None:
89
+ setattr(server, _SERVER_ATTR, self)
90
+ return True
91
+ if serving is not self:
92
+ serving._join(self)
93
+ return False
94
+
95
+ def _join(self, other: CliAuditPlugin) -> None:
96
+ """``other`` was mounted on a server this plugin serves: one log."""
97
+ if other.store_set:
98
+ self.use_store(other.store)
99
+ if self.authorize is None and other.authorize is not None:
100
+ self.authorize = other.authorize
101
+ # ``other`` (and whoever follows it, e.g. the users plugin) records
102
+ # in this plugin's store, now and after it is replaced.
103
+ other.use_store(self.store, explicit=False)
104
+ self.on_store_changed(lambda store: other.use_store(store, explicit=False))
105
+
106
+
107
+ class CliAuditBuilder:
108
+ """Fluent builder for the audit plugin."""
109
+
110
+ def __init__(self) -> None:
111
+ self._store: IAuditStore | None = None
112
+
113
+ def set_store(self, store: IAuditStore) -> CliAuditBuilder:
114
+ """Keep the log in ``store`` (default: in memory, the latest 1000
115
+ entries, failed sign-ins apart, at most 200)."""
116
+ self._store = store
117
+ return self
118
+
119
+ def build(self) -> CliAuditPlugin:
120
+ return CliAuditPlugin(
121
+ prefix=PREFIX,
122
+ store=self._store or InMemoryAuditStore(),
123
+ store_set=self._store is not None,
124
+ )
@@ -0,0 +1,129 @@
1
+ """The audit log API — ``/api/v1/qcli/audit``.
2
+
3
+ ``GET`` lists entries (``audit:read`` on a protected server), ``DELETE``
4
+ clears the log (``audit:clear``). The admin dashboard's Audit page and the
5
+ CLI's ``audit`` command read it. Refusals answer ``{"error": "..."}``, like
6
+ the users API.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from fastapi import APIRouter, HTTPException, Request, Response
14
+ from fastapi.responses import JSONResponse
15
+
16
+ from qodalis_cli_server_abstractions.audit import AuditEntry, AuditQueryOptions
17
+
18
+ if TYPE_CHECKING:
19
+ from .cli_audit_builder import CliAuditPlugin
20
+
21
+ #: Entries returned when the request names no limit.
22
+ DEFAULT_AUDIT_LIMIT = 100
23
+ #: The most entries one request returns, so a database store never loads the
24
+ #: whole log.
25
+ MAX_AUDIT_LIMIT = 1000
26
+ #: The event recorded after the log was cleared.
27
+ AUDIT_CLEARED_EVENT = "audit_cleared"
28
+
29
+ AUDIT_READ = "audit:read"
30
+ AUDIT_CLEAR = "audit:clear"
31
+
32
+ #: Key under ``scope["state"]`` where an auth plugin stores the caller
33
+ #: (shared with ``qodalis_cli.security``; kept local so the plugin does not
34
+ #: depend on the server package).
35
+ AUTH_STATE_KEY = "qcli_auth"
36
+
37
+ BAD_PAGING = "limit and offset must be integers >= 0"
38
+
39
+
40
+ def _error(status: int, message: Any) -> JSONResponse:
41
+ return JSONResponse({"error": str(message)}, status_code=status)
42
+
43
+
44
+ def _scope_caller(request: Request) -> Any:
45
+ state = request.scope.get("state")
46
+ if isinstance(state, dict):
47
+ return state.get(AUTH_STATE_KEY)
48
+ return getattr(state, AUTH_STATE_KEY, None) if state is not None else None
49
+
50
+
51
+ def _paging(request: Request, name: str) -> int | None:
52
+ """``None`` when absent or empty; raises ``ValueError`` when not an integer >= 0."""
53
+ raw = request.query_params.get(name)
54
+ if raw is None or raw == "":
55
+ return None
56
+ value = int(raw.strip()) # ValueError for "x", "1.5", ...
57
+ if value < 0:
58
+ raise ValueError(name)
59
+ return value
60
+
61
+
62
+ def create_audit_router(plugin: CliAuditPlugin) -> APIRouter:
63
+ """The audit routes. They read ``plugin.store`` on every request, so a
64
+ store set after the router was built is used."""
65
+ router = APIRouter(tags=["audit"])
66
+
67
+ async def caller_of(request: Request, permission: str) -> Any:
68
+ if plugin.authorize is not None:
69
+ return await plugin.authorize(request, permission)
70
+ caller = _scope_caller(request)
71
+ # Defensive: the route policy of an auth plugin already checked it.
72
+ can = getattr(caller, "can", None)
73
+ if callable(can) and not can(permission):
74
+ raise HTTPException(403, f"missing permission: {permission}")
75
+ return caller
76
+
77
+ async def list_entries(request: Request) -> Any:
78
+ """Entries newest first, filtered by ``event``, ``username`` (the user
79
+ an entry is about) and ``actor`` (who did it), all exact and
80
+ case-insensitive; paged with ``limit`` (default 100, at most 1000) and
81
+ ``offset``."""
82
+ try:
83
+ await caller_of(request, AUDIT_READ)
84
+ except HTTPException as exc:
85
+ return _error(exc.status_code, exc.detail)
86
+ try:
87
+ limit = _paging(request, "limit")
88
+ offset = _paging(request, "offset")
89
+ except ValueError:
90
+ return _error(400, BAD_PAGING)
91
+ query = request.query_params
92
+ entries = await plugin.store.get_entries(AuditQueryOptions(
93
+ event=query.get("event") or None,
94
+ username=query.get("username") or None,
95
+ actor=query.get("actor") or None,
96
+ limit=DEFAULT_AUDIT_LIMIT if limit is None else min(limit, MAX_AUDIT_LIMIT),
97
+ offset=offset or None,
98
+ ))
99
+ return [entry.to_dict() for entry in entries]
100
+
101
+ async def clear_entries(request: Request) -> Response:
102
+ """Clears the log, then records who cleared it (``audit_cleared``)."""
103
+ try:
104
+ caller = await caller_of(request, AUDIT_CLEAR)
105
+ except HTTPException as exc:
106
+ return _error(exc.status_code, exc.detail)
107
+ await plugin.store.clear()
108
+ user = getattr(caller, "user", None)
109
+ name = getattr(user, "name", None)
110
+ user_id = getattr(user, "id", None)
111
+ await plugin.store.log(AuditEntry(
112
+ event=AUDIT_CLEARED_EVENT,
113
+ username=name or "",
114
+ user_id=user_id,
115
+ details="audit log cleared",
116
+ actor=name,
117
+ actor_id=user_id,
118
+ metadata={"remoteAddress": request.client.host if request.client else None},
119
+ ))
120
+ return Response(status_code=204)
121
+
122
+ # Both "" and "/" so neither form redirects.
123
+ for path, in_schema in (("", True), ("/", False)):
124
+ router.add_api_route(path, list_entries, methods=["GET"], include_in_schema=in_schema)
125
+ router.add_api_route(
126
+ path, clear_entries, methods=["DELETE"], status_code=204,
127
+ response_class=Response, include_in_schema=in_schema,
128
+ )
129
+ return router
@@ -0,0 +1,69 @@
1
+ """In-memory implementation of :class:`IAuditStore`.
2
+
3
+ Keeps the latest 1000 entries; failed sign-ins (``login_failed``) are capped
4
+ separately (the latest 200) so a flood of them cannot evict the rest.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import itertools
10
+ from collections import deque
11
+
12
+ from qodalis_cli_server_abstractions.audit import AuditEntry, AuditQueryOptions, IAuditStore
13
+
14
+ MAX_ENTRIES = 1000
15
+ MAX_FAILED_LOGIN_ENTRIES = 200
16
+ #: The event of a failed sign-in (users plugin), kept apart from the rest.
17
+ FAILED_LOGIN_EVENT = "login_failed"
18
+
19
+
20
+ class InMemoryAuditStore(IAuditStore):
21
+ def __init__(
22
+ self,
23
+ max_entries: int = MAX_ENTRIES,
24
+ max_failed_login_entries: int = MAX_FAILED_LOGIN_ENTRIES,
25
+ ) -> None:
26
+ self.max_entries = max_entries if max_entries > 0 else MAX_ENTRIES
27
+ self.max_failed_login_entries = (
28
+ max_failed_login_entries if max_failed_login_entries > 0 else MAX_FAILED_LOGIN_ENTRIES
29
+ )
30
+ self._seq = itertools.count()
31
+ self._entries: deque[tuple[int, AuditEntry]] = deque(maxlen=self.max_entries)
32
+ self._failed: deque[tuple[int, AuditEntry]] = deque(maxlen=self.max_failed_login_entries)
33
+
34
+ async def log(self, entry: AuditEntry) -> None:
35
+ target = self._failed if entry.event == FAILED_LOGIN_EVENT else self._entries
36
+ target.append((next(self._seq), entry))
37
+
38
+ async def get_entries(self, options: AuditQueryOptions | None = None) -> list[AuditEntry]:
39
+ # Newest recorded first.
40
+ merged = sorted([*self._entries, *self._failed], key=lambda item: item[0], reverse=True)
41
+ result = [entry for _, entry in merged]
42
+ if options:
43
+ if options.event:
44
+ event = options.event.lower()
45
+ result = [e for e in result if e.event.lower() == event]
46
+ if options.username:
47
+ username = options.username.lower()
48
+ result = [e for e in result if e.username.lower() == username]
49
+ if options.actor:
50
+ actor = options.actor.lower()
51
+ result = [e for e in result if (e.actor or "").lower() == actor]
52
+ if options.offset is not None and options.offset > 0:
53
+ result = result[options.offset:]
54
+ if options.limit is not None and options.limit >= 0:
55
+ result = result[: options.limit]
56
+ return result
57
+
58
+ async def prune(self, before: int) -> int:
59
+ removed = 0
60
+ for entries in (self._entries, self._failed):
61
+ kept = [item for item in entries if item[1].timestamp >= before]
62
+ removed += len(entries) - len(kept)
63
+ entries.clear()
64
+ entries.extend(kept)
65
+ return removed
66
+
67
+ async def clear(self) -> None:
68
+ self._entries.clear()
69
+ self._failed.clear()