qodalis-cli-server-plugin-audit 1.0.0b18__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.
- qodalis_cli_audit/__init__.py +48 -0
- qodalis_cli_audit/cli_audit_builder.py +124 -0
- qodalis_cli_audit/cli_audit_controller.py +129 -0
- qodalis_cli_audit/in_memory_audit_store.py +69 -0
- qodalis_cli_server_plugin_audit-1.0.0b18.dist-info/METADATA +22 -0
- qodalis_cli_server_plugin_audit-1.0.0b18.dist-info/RECORD +8 -0
- qodalis_cli_server_plugin_audit-1.0.0b18.dist-info/WHEEL +4 -0
- qodalis_cli_server_plugin_audit-1.0.0b18.dist-info/licenses/LICENSE +21 -0
|
@@ -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()
|
|
@@ -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,8 @@
|
|
|
1
|
+
qodalis_cli_audit/__init__.py,sha256=snEoB6Hmv9-FwARJo1NrWTb-Cnx7yoKpEfKi4YM_gqg,1133
|
|
2
|
+
qodalis_cli_audit/cli_audit_builder.py,sha256=fNw9KxRbQ7yBEqKlREtlPsMyGY7_cmopu9EAsEkrFwE,4755
|
|
3
|
+
qodalis_cli_audit/cli_audit_controller.py,sha256=ZGXd0v3zoBjjOp82WXYDk1Ffs5Lk1I9b-FHwMt9z8UA,4989
|
|
4
|
+
qodalis_cli_audit/in_memory_audit_store.py,sha256=Sjzr4Nc95Pxq2OdX8PHGvGInzWnx1Iur5MSh8j2wD1Y,2815
|
|
5
|
+
qodalis_cli_server_plugin_audit-1.0.0b18.dist-info/METADATA,sha256=chRBORZ97R-1tYfnbWqvJQjP4Ux_HLEhETgVZWPyf_w,1067
|
|
6
|
+
qodalis_cli_server_plugin_audit-1.0.0b18.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
7
|
+
qodalis_cli_server_plugin_audit-1.0.0b18.dist-info/licenses/LICENSE,sha256=uagko_O2JboeVOW3cldvxKW4QIDjTHCCk5T32qqg_As,1079
|
|
8
|
+
qodalis_cli_server_plugin_audit-1.0.0b18.dist-info/RECORD,,
|
|
@@ -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.
|