auditX 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.
- auditx/__init__.py +79 -0
- auditx/__main__.py +75 -0
- auditx/core.py +564 -0
- auditx-0.1.0.dist-info/METADATA +192 -0
- auditx-0.1.0.dist-info/RECORD +8 -0
- auditx-0.1.0.dist-info/WHEEL +5 -0
- auditx-0.1.0.dist-info/entry_points.txt +2 -0
- auditx-0.1.0.dist-info/top_level.txt +1 -0
auditx/__init__.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
auditX — ERP-grade audit logging for trading and service businesses.
|
|
3
|
+
|
|
4
|
+
Install:
|
|
5
|
+
pip install auditX
|
|
6
|
+
|
|
7
|
+
Quick start:
|
|
8
|
+
from auditx import audit, RequestContext, BusinessModule, AuditAction
|
|
9
|
+
|
|
10
|
+
RequestContext.set(user="admin", company_id="CO-001", branch_id="BR-YGN")
|
|
11
|
+
audit.log_transaction(
|
|
12
|
+
"Sales invoice posted",
|
|
13
|
+
module=BusinessModule.SALES,
|
|
14
|
+
action=AuditAction.POST,
|
|
15
|
+
reference_no="SI-2026-0042",
|
|
16
|
+
amount=1_250_000,
|
|
17
|
+
)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any, Optional
|
|
23
|
+
|
|
24
|
+
from auditx.core import (
|
|
25
|
+
AuditAction,
|
|
26
|
+
AuditEntry,
|
|
27
|
+
AuditLogger,
|
|
28
|
+
BusinessModule,
|
|
29
|
+
LogLevel,
|
|
30
|
+
RequestContext,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
34
|
+
__all__ = [
|
|
35
|
+
"__version__",
|
|
36
|
+
"AuditAction",
|
|
37
|
+
"AuditEntry",
|
|
38
|
+
"AuditLogger",
|
|
39
|
+
"BusinessModule",
|
|
40
|
+
"LogLevel",
|
|
41
|
+
"RequestContext",
|
|
42
|
+
"audit",
|
|
43
|
+
"configure",
|
|
44
|
+
"create_logger",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
_default_logger: Optional[AuditLogger] = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def create_logger(**kwargs: Any) -> AuditLogger:
|
|
51
|
+
"""Create a new AuditLogger instance with custom settings."""
|
|
52
|
+
return AuditLogger(**kwargs)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def configure(**kwargs: Any) -> AuditLogger:
|
|
56
|
+
"""Replace the global `audit` singleton (e.g. set log_dir, disable console)."""
|
|
57
|
+
global _default_logger
|
|
58
|
+
_default_logger = AuditLogger(**kwargs)
|
|
59
|
+
return _default_logger
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get_default_logger() -> AuditLogger:
|
|
63
|
+
global _default_logger
|
|
64
|
+
if _default_logger is None:
|
|
65
|
+
_default_logger = AuditLogger(log_dir="logs")
|
|
66
|
+
return _default_logger
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _AuditProxy:
|
|
70
|
+
"""Lazy proxy so `from auditx import audit` uses the configured singleton."""
|
|
71
|
+
|
|
72
|
+
def __getattr__(self, name: str) -> Any:
|
|
73
|
+
return getattr(_get_default_logger(), name)
|
|
74
|
+
|
|
75
|
+
def __repr__(self) -> str:
|
|
76
|
+
return repr(_get_default_logger())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
audit = _AuditProxy()
|
auditx/__main__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Run demo: python -m auditx"""
|
|
2
|
+
|
|
3
|
+
from auditx.core import AuditAction, BusinessModule
|
|
4
|
+
from auditx import RequestContext, audit
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main() -> None:
|
|
8
|
+
RequestContext.set(
|
|
9
|
+
user="admin",
|
|
10
|
+
user_role="manager",
|
|
11
|
+
company_id="CO-001",
|
|
12
|
+
branch_id="BR-YGN",
|
|
13
|
+
ip="192.168.1.10",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
print("auditX demo — writing logs to ./logs/\n")
|
|
17
|
+
|
|
18
|
+
audit.log_security("User login successful", action=AuditAction.LOGIN, user="admin", success=True)
|
|
19
|
+
|
|
20
|
+
audit.log_transaction(
|
|
21
|
+
"Sales invoice posted",
|
|
22
|
+
module=BusinessModule.SALES,
|
|
23
|
+
action=AuditAction.POST,
|
|
24
|
+
reference_no="SI-2026-0042",
|
|
25
|
+
amount=1_250_000,
|
|
26
|
+
entity_type="sales_invoice",
|
|
27
|
+
entity_id="inv-42",
|
|
28
|
+
party="Golden Trading Co.",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
audit.log_inventory(
|
|
32
|
+
"Stock issued for sales order",
|
|
33
|
+
action=AuditAction.TRANSFER,
|
|
34
|
+
product_id="SKU-1001",
|
|
35
|
+
product_name="LED Panel 24W",
|
|
36
|
+
quantity=50,
|
|
37
|
+
warehouse_id="WH-MAIN",
|
|
38
|
+
reference_no="SI-2026-0042",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
audit.log_service(
|
|
42
|
+
"Service job completed",
|
|
43
|
+
action=AuditAction.UPDATE,
|
|
44
|
+
job_id="SVC-889",
|
|
45
|
+
customer_id="CUST-220",
|
|
46
|
+
technician="U Kyaw",
|
|
47
|
+
service_type="AC Maintenance",
|
|
48
|
+
status_label="completed",
|
|
49
|
+
amount=85_000,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
audit.log_change(
|
|
53
|
+
"Customer credit limit updated",
|
|
54
|
+
module=BusinessModule.CRM,
|
|
55
|
+
action=AuditAction.UPDATE,
|
|
56
|
+
entity_type="customer",
|
|
57
|
+
entity_id="CUST-220",
|
|
58
|
+
old_values={"credit_limit": 500_000},
|
|
59
|
+
new_values={"credit_limit": 1_000_000},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
for _ in range(7):
|
|
63
|
+
audit.log_security(
|
|
64
|
+
"Failed login attempt",
|
|
65
|
+
action=AuditAction.LOGIN,
|
|
66
|
+
user="attacker",
|
|
67
|
+
ip="192.168.1.50",
|
|
68
|
+
success=False,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
print("\nDone. Check logs/audit.jsonl and logs/security.jsonl")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
main()
|
auditx/core.py
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
"""Core audit logger implementation for auditX."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import socket
|
|
9
|
+
import threading
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Callable, Optional
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from colorama import Fore, init
|
|
18
|
+
|
|
19
|
+
init(autoreset=True)
|
|
20
|
+
_COLORAMA = True
|
|
21
|
+
except ImportError:
|
|
22
|
+
_COLORAMA = False
|
|
23
|
+
Fore = type("Fore", (), {"GREEN": "", "YELLOW": "", "RED": "", "CYAN": "", "WHITE": "", "MAGENTA": "", "BLUE": ""})()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LogLevel(str, Enum):
|
|
27
|
+
DEBUG = "DEBUG"
|
|
28
|
+
INFO = "INFO"
|
|
29
|
+
NORMAL = "NORMAL"
|
|
30
|
+
WARNING = "WARNING"
|
|
31
|
+
ERROR = "ERROR"
|
|
32
|
+
CRITICAL = "CRITICAL"
|
|
33
|
+
AUDIT = "AUDIT"
|
|
34
|
+
SECURITY = "SECURITY"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BusinessModule(str, Enum):
|
|
38
|
+
AUTH = "auth"
|
|
39
|
+
SALES = "sales"
|
|
40
|
+
PURCHASE = "purchase"
|
|
41
|
+
INVENTORY = "inventory"
|
|
42
|
+
ACCOUNTING = "accounting"
|
|
43
|
+
SERVICE = "service"
|
|
44
|
+
CRM = "crm"
|
|
45
|
+
HR = "hr"
|
|
46
|
+
REPORTING = "reporting"
|
|
47
|
+
SYSTEM = "system"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AuditAction(str, Enum):
|
|
51
|
+
CREATE = "CREATE"
|
|
52
|
+
READ = "READ"
|
|
53
|
+
UPDATE = "UPDATE"
|
|
54
|
+
DELETE = "DELETE"
|
|
55
|
+
APPROVE = "APPROVE"
|
|
56
|
+
REJECT = "REJECT"
|
|
57
|
+
VOID = "VOID"
|
|
58
|
+
POST = "POST"
|
|
59
|
+
PAYMENT = "PAYMENT"
|
|
60
|
+
REFUND = "REFUND"
|
|
61
|
+
TRANSFER = "TRANSFER"
|
|
62
|
+
ADJUST = "ADJUST"
|
|
63
|
+
LOGIN = "LOGIN"
|
|
64
|
+
LOGOUT = "LOGOUT"
|
|
65
|
+
EXPORT = "EXPORT"
|
|
66
|
+
IMPORT = "IMPORT"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class AuditEntry:
|
|
71
|
+
timestamp: str
|
|
72
|
+
level: str
|
|
73
|
+
module: str
|
|
74
|
+
action: str
|
|
75
|
+
description: str
|
|
76
|
+
user: str = "SYSTEM"
|
|
77
|
+
user_role: str = ""
|
|
78
|
+
company_id: str = ""
|
|
79
|
+
branch_id: str = ""
|
|
80
|
+
entity_type: str = ""
|
|
81
|
+
entity_id: str = ""
|
|
82
|
+
reference_no: str = ""
|
|
83
|
+
amount: Optional[float] = None
|
|
84
|
+
currency: str = "MMK"
|
|
85
|
+
old_values: dict[str, Any] = field(default_factory=dict)
|
|
86
|
+
new_values: dict[str, Any] = field(default_factory=dict)
|
|
87
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
88
|
+
request_id: str = ""
|
|
89
|
+
session_id: str = ""
|
|
90
|
+
ip: str = ""
|
|
91
|
+
hostname: str = ""
|
|
92
|
+
method: str = ""
|
|
93
|
+
endpoint: str = ""
|
|
94
|
+
status: int = 0
|
|
95
|
+
success: bool = True
|
|
96
|
+
|
|
97
|
+
def to_dict(self) -> dict[str, Any]:
|
|
98
|
+
data = asdict(self)
|
|
99
|
+
return {k: v for k, v in data.items() if v not in (None, "", {}, [])}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class RequestContext:
|
|
103
|
+
"""Carries user and tenant context across a request or background job."""
|
|
104
|
+
|
|
105
|
+
_local = threading.local()
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def set(
|
|
109
|
+
cls,
|
|
110
|
+
*,
|
|
111
|
+
user: str = "SYSTEM",
|
|
112
|
+
user_role: str = "",
|
|
113
|
+
company_id: str = "",
|
|
114
|
+
branch_id: str = "",
|
|
115
|
+
session_id: str = "",
|
|
116
|
+
request_id: str = "",
|
|
117
|
+
ip: str = "",
|
|
118
|
+
) -> None:
|
|
119
|
+
cls._local.user = user
|
|
120
|
+
cls._local.user_role = user_role
|
|
121
|
+
cls._local.company_id = company_id
|
|
122
|
+
cls._local.branch_id = branch_id
|
|
123
|
+
cls._local.session_id = session_id
|
|
124
|
+
cls._local.request_id = request_id or str(uuid.uuid4())
|
|
125
|
+
cls._local.ip = ip
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def get(cls) -> dict[str, str]:
|
|
129
|
+
return {
|
|
130
|
+
"user": getattr(cls._local, "user", "SYSTEM"),
|
|
131
|
+
"user_role": getattr(cls._local, "user_role", ""),
|
|
132
|
+
"company_id": getattr(cls._local, "company_id", ""),
|
|
133
|
+
"branch_id": getattr(cls._local, "branch_id", ""),
|
|
134
|
+
"session_id": getattr(cls._local, "session_id", ""),
|
|
135
|
+
"request_id": getattr(cls._local, "request_id", ""),
|
|
136
|
+
"ip": getattr(cls._local, "ip", ""),
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def clear(cls) -> None:
|
|
141
|
+
for attr in ("user", "user_role", "company_id", "branch_id", "session_id", "request_id", "ip"):
|
|
142
|
+
if hasattr(cls._local, attr):
|
|
143
|
+
delattr(cls._local, attr)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class AuditLogger:
|
|
147
|
+
"""
|
|
148
|
+
ERP audit logger with structured JSON trails, console output, and domain helpers.
|
|
149
|
+
|
|
150
|
+
Log files (under log_dir):
|
|
151
|
+
- audit.jsonl — immutable audit trail (one JSON object per line)
|
|
152
|
+
- app.log — human-readable application log
|
|
153
|
+
- security.jsonl — security-sensitive events
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
_LEVEL_COLORS = {
|
|
157
|
+
LogLevel.DEBUG.value: Fore.WHITE,
|
|
158
|
+
LogLevel.INFO.value: Fore.CYAN,
|
|
159
|
+
LogLevel.NORMAL.value: Fore.GREEN,
|
|
160
|
+
LogLevel.WARNING.value: Fore.YELLOW,
|
|
161
|
+
LogLevel.ERROR.value: Fore.RED,
|
|
162
|
+
LogLevel.CRITICAL.value: Fore.RED,
|
|
163
|
+
LogLevel.AUDIT.value: Fore.MAGENTA,
|
|
164
|
+
LogLevel.SECURITY.value: Fore.RED,
|
|
165
|
+
"EXTREME": Fore.RED,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
_METHOD_COLORS = {
|
|
169
|
+
"GET": Fore.CYAN,
|
|
170
|
+
"POST": Fore.GREEN,
|
|
171
|
+
"PUT": Fore.YELLOW,
|
|
172
|
+
"PATCH": Fore.YELLOW,
|
|
173
|
+
"DELETE": Fore.RED,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
def __init__(
|
|
177
|
+
self,
|
|
178
|
+
log_dir: str | Path = "logs",
|
|
179
|
+
app_log_file: str = "app.log",
|
|
180
|
+
audit_log_file: str = "audit.jsonl",
|
|
181
|
+
security_log_file: str = "security.jsonl",
|
|
182
|
+
max_file_bytes: int = 10 * 1024 * 1024,
|
|
183
|
+
backup_count: int = 5,
|
|
184
|
+
console: bool = True,
|
|
185
|
+
on_audit: Optional[Callable[[AuditEntry], None]] = None,
|
|
186
|
+
):
|
|
187
|
+
self.log_dir = Path(log_dir)
|
|
188
|
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
|
|
190
|
+
self.app_log_path = self.log_dir / app_log_file
|
|
191
|
+
self.audit_log_path = self.log_dir / audit_log_file
|
|
192
|
+
self.security_log_path = self.log_dir / security_log_file
|
|
193
|
+
|
|
194
|
+
self.max_file_bytes = max_file_bytes
|
|
195
|
+
self.backup_count = backup_count
|
|
196
|
+
self.console = console
|
|
197
|
+
self.on_audit = on_audit
|
|
198
|
+
|
|
199
|
+
self.hostname = socket.gethostname()
|
|
200
|
+
try:
|
|
201
|
+
self.default_ip = socket.gethostbyname(self.hostname)
|
|
202
|
+
except socket.gaierror:
|
|
203
|
+
self.default_ip = "127.0.0.1"
|
|
204
|
+
|
|
205
|
+
self._lock = threading.Lock()
|
|
206
|
+
self._ip_counter: dict[str, int] = {}
|
|
207
|
+
self._rate_limit_window: dict[str, datetime.datetime] = {}
|
|
208
|
+
|
|
209
|
+
logger_name = f"auditx.app.{id(self)}"
|
|
210
|
+
self._app_logger = logging.getLogger(logger_name)
|
|
211
|
+
if not self._app_logger.handlers:
|
|
212
|
+
handler = logging.FileHandler(self.app_log_path, encoding="utf-8")
|
|
213
|
+
handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s"))
|
|
214
|
+
self._app_logger.addHandler(handler)
|
|
215
|
+
self._app_logger.setLevel(logging.DEBUG)
|
|
216
|
+
self._app_logger.propagate = False
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _iso_timestamp() -> str:
|
|
220
|
+
return datetime.datetime.now(datetime.timezone.utc).astimezone().isoformat(timespec="seconds")
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _display_timestamp() -> str:
|
|
224
|
+
return datetime.datetime.now().strftime("%d/%m/%Y %I:%M:%S %p")
|
|
225
|
+
|
|
226
|
+
def _format_level(self, level: str) -> str:
|
|
227
|
+
level = level.upper()
|
|
228
|
+
if _COLORAMA:
|
|
229
|
+
color = self._LEVEL_COLORS.get(level, Fore.WHITE)
|
|
230
|
+
return color + level
|
|
231
|
+
return level
|
|
232
|
+
|
|
233
|
+
def _format_method(self, method: str) -> str:
|
|
234
|
+
method = method.upper()
|
|
235
|
+
if _COLORAMA:
|
|
236
|
+
color = self._METHOD_COLORS.get(method, Fore.WHITE)
|
|
237
|
+
return color + method
|
|
238
|
+
return method
|
|
239
|
+
|
|
240
|
+
def _rotate_if_needed(self, path: Path) -> None:
|
|
241
|
+
if not path.exists() or path.stat().st_size < self.max_file_bytes:
|
|
242
|
+
return
|
|
243
|
+
for i in range(self.backup_count - 1, 0, -1):
|
|
244
|
+
src = path.with_suffix(path.suffix + f".{i}")
|
|
245
|
+
dst = path.with_suffix(path.suffix + f".{i + 1}")
|
|
246
|
+
if src.exists():
|
|
247
|
+
src.replace(dst)
|
|
248
|
+
path.replace(path.with_suffix(path.suffix + ".1"))
|
|
249
|
+
|
|
250
|
+
def _append_line(self, path: Path, line: str) -> None:
|
|
251
|
+
with self._lock:
|
|
252
|
+
self._rotate_if_needed(path)
|
|
253
|
+
with path.open("a", encoding="utf-8") as f:
|
|
254
|
+
f.write(line + "\n")
|
|
255
|
+
|
|
256
|
+
def _emit_console(self, entry: AuditEntry) -> None:
|
|
257
|
+
if not self.console:
|
|
258
|
+
return
|
|
259
|
+
ts = self._display_timestamp()
|
|
260
|
+
method = self._format_method(entry.method) if entry.method else "N/A"
|
|
261
|
+
level = self._format_level(entry.level)
|
|
262
|
+
ref = f" [{entry.reference_no}]" if entry.reference_no else ""
|
|
263
|
+
entity = f" {entry.entity_type}:{entry.entity_id}" if entry.entity_type else ""
|
|
264
|
+
amount = f" {entry.amount:,.2f} {entry.currency}" if entry.amount is not None else ""
|
|
265
|
+
print(
|
|
266
|
+
f"{ts} | {level} | {entry.module}/{entry.action} | "
|
|
267
|
+
f"{method} {entry.endpoint} | {entry.status} | "
|
|
268
|
+
f"{entry.description}{ref}{entity}{amount} | "
|
|
269
|
+
f"{entry.user}@{entry.branch_id or 'HQ'} | req:{entry.request_id[:8]} | IP:{entry.ip}"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def _write_audit(self, entry: AuditEntry) -> None:
|
|
273
|
+
self._append_line(self.audit_log_path, json.dumps(entry.to_dict(), ensure_ascii=False, default=str))
|
|
274
|
+
|
|
275
|
+
if entry.level in (LogLevel.SECURITY.value, LogLevel.CRITICAL.value, "EXTREME"):
|
|
276
|
+
self._append_line(self.security_log_path, json.dumps(entry.to_dict(), ensure_ascii=False, default=str))
|
|
277
|
+
|
|
278
|
+
if self.on_audit:
|
|
279
|
+
try:
|
|
280
|
+
self.on_audit(entry)
|
|
281
|
+
except Exception as exc:
|
|
282
|
+
self._app_logger.error("on_audit callback failed: %s", exc)
|
|
283
|
+
|
|
284
|
+
def _build_entry(
|
|
285
|
+
self,
|
|
286
|
+
*,
|
|
287
|
+
description: str,
|
|
288
|
+
level: str = LogLevel.AUDIT.value,
|
|
289
|
+
module: str = BusinessModule.SYSTEM.value,
|
|
290
|
+
action: str = AuditAction.READ.value,
|
|
291
|
+
user: Optional[str] = None,
|
|
292
|
+
user_role: str = "",
|
|
293
|
+
company_id: str = "",
|
|
294
|
+
branch_id: str = "",
|
|
295
|
+
entity_type: str = "",
|
|
296
|
+
entity_id: str = "",
|
|
297
|
+
reference_no: str = "",
|
|
298
|
+
amount: Optional[float] = None,
|
|
299
|
+
currency: str = "MMK",
|
|
300
|
+
old_values: Optional[dict[str, Any]] = None,
|
|
301
|
+
new_values: Optional[dict[str, Any]] = None,
|
|
302
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
303
|
+
request_id: str = "",
|
|
304
|
+
session_id: str = "",
|
|
305
|
+
ip: Optional[str] = None,
|
|
306
|
+
method: str = "",
|
|
307
|
+
endpoint: str = "",
|
|
308
|
+
status: int = 200,
|
|
309
|
+
success: bool = True,
|
|
310
|
+
) -> AuditEntry:
|
|
311
|
+
ctx = RequestContext.get()
|
|
312
|
+
return AuditEntry(
|
|
313
|
+
timestamp=self._iso_timestamp(),
|
|
314
|
+
level=level.upper(),
|
|
315
|
+
module=module,
|
|
316
|
+
action=action,
|
|
317
|
+
description=description,
|
|
318
|
+
user=user or ctx.get("user", "SYSTEM"),
|
|
319
|
+
user_role=user_role or ctx.get("user_role", ""),
|
|
320
|
+
company_id=company_id or ctx.get("company_id", ""),
|
|
321
|
+
branch_id=branch_id or ctx.get("branch_id", ""),
|
|
322
|
+
entity_type=entity_type,
|
|
323
|
+
entity_id=str(entity_id) if entity_id else "",
|
|
324
|
+
reference_no=reference_no,
|
|
325
|
+
amount=amount,
|
|
326
|
+
currency=currency,
|
|
327
|
+
old_values=old_values or {},
|
|
328
|
+
new_values=new_values or {},
|
|
329
|
+
metadata=metadata or {},
|
|
330
|
+
request_id=request_id or ctx.get("request_id", "") or str(uuid.uuid4()),
|
|
331
|
+
session_id=session_id or ctx.get("session_id", ""),
|
|
332
|
+
ip=ip or ctx.get("ip") or self.default_ip,
|
|
333
|
+
hostname=self.hostname,
|
|
334
|
+
method=method.upper() if method else "",
|
|
335
|
+
endpoint=endpoint,
|
|
336
|
+
status=status,
|
|
337
|
+
success=success,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
def check_rate_limit(self, ip: str, limit: int = 5, window_seconds: int = 60) -> bool:
|
|
341
|
+
now = datetime.datetime.now()
|
|
342
|
+
window_start = self._rate_limit_window.get(ip)
|
|
343
|
+
|
|
344
|
+
if window_start is None or (now - window_start).total_seconds() > window_seconds:
|
|
345
|
+
self._rate_limit_window[ip] = now
|
|
346
|
+
self._ip_counter[ip] = 1
|
|
347
|
+
return True
|
|
348
|
+
|
|
349
|
+
self._ip_counter[ip] = self._ip_counter.get(ip, 0) + 1
|
|
350
|
+
return self._ip_counter[ip] <= limit
|
|
351
|
+
|
|
352
|
+
def reset_rate_limit(self, ip: str) -> None:
|
|
353
|
+
self._ip_counter.pop(ip, None)
|
|
354
|
+
self._rate_limit_window.pop(ip, None)
|
|
355
|
+
|
|
356
|
+
def log(
|
|
357
|
+
self,
|
|
358
|
+
description: str,
|
|
359
|
+
*,
|
|
360
|
+
level: str = LogLevel.AUDIT.value,
|
|
361
|
+
module: str = BusinessModule.SYSTEM.value,
|
|
362
|
+
action: str = AuditAction.READ.value,
|
|
363
|
+
**kwargs: Any,
|
|
364
|
+
) -> AuditEntry:
|
|
365
|
+
entry = self._build_entry(
|
|
366
|
+
description=description,
|
|
367
|
+
level=level,
|
|
368
|
+
module=module,
|
|
369
|
+
action=action,
|
|
370
|
+
**kwargs,
|
|
371
|
+
)
|
|
372
|
+
self._emit_console(entry)
|
|
373
|
+
self._write_audit(entry)
|
|
374
|
+
return entry
|
|
375
|
+
|
|
376
|
+
def log_change(
|
|
377
|
+
self,
|
|
378
|
+
description: str,
|
|
379
|
+
*,
|
|
380
|
+
module: BusinessModule | str,
|
|
381
|
+
action: AuditAction | str,
|
|
382
|
+
entity_type: str,
|
|
383
|
+
entity_id: str,
|
|
384
|
+
old_values: dict[str, Any],
|
|
385
|
+
new_values: dict[str, Any],
|
|
386
|
+
reference_no: str = "",
|
|
387
|
+
**kwargs: Any,
|
|
388
|
+
) -> AuditEntry:
|
|
389
|
+
return self.log(
|
|
390
|
+
description=description,
|
|
391
|
+
level=LogLevel.AUDIT.value,
|
|
392
|
+
module=module.value if isinstance(module, BusinessModule) else module,
|
|
393
|
+
action=action.value if isinstance(action, AuditAction) else action,
|
|
394
|
+
entity_type=entity_type,
|
|
395
|
+
entity_id=entity_id,
|
|
396
|
+
reference_no=reference_no,
|
|
397
|
+
old_values=old_values,
|
|
398
|
+
new_values=new_values,
|
|
399
|
+
**kwargs,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
def log_transaction(
|
|
403
|
+
self,
|
|
404
|
+
description: str,
|
|
405
|
+
*,
|
|
406
|
+
module: BusinessModule,
|
|
407
|
+
action: AuditAction,
|
|
408
|
+
reference_no: str,
|
|
409
|
+
amount: float,
|
|
410
|
+
currency: str = "MMK",
|
|
411
|
+
entity_type: str = "",
|
|
412
|
+
entity_id: str = "",
|
|
413
|
+
party: str = "",
|
|
414
|
+
**kwargs: Any,
|
|
415
|
+
) -> AuditEntry:
|
|
416
|
+
metadata = kwargs.pop("metadata", {}) or {}
|
|
417
|
+
if party:
|
|
418
|
+
metadata["party"] = party
|
|
419
|
+
return self.log(
|
|
420
|
+
description=description,
|
|
421
|
+
level=LogLevel.AUDIT.value,
|
|
422
|
+
module=module.value,
|
|
423
|
+
action=action.value,
|
|
424
|
+
reference_no=reference_no,
|
|
425
|
+
amount=amount,
|
|
426
|
+
currency=currency,
|
|
427
|
+
entity_type=entity_type,
|
|
428
|
+
entity_id=entity_id,
|
|
429
|
+
metadata=metadata,
|
|
430
|
+
**kwargs,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
def log_inventory(
|
|
434
|
+
self,
|
|
435
|
+
description: str,
|
|
436
|
+
*,
|
|
437
|
+
action: AuditAction,
|
|
438
|
+
product_id: str,
|
|
439
|
+
product_name: str = "",
|
|
440
|
+
quantity: float,
|
|
441
|
+
unit: str = "pcs",
|
|
442
|
+
warehouse_id: str = "",
|
|
443
|
+
reference_no: str = "",
|
|
444
|
+
**kwargs: Any,
|
|
445
|
+
) -> AuditEntry:
|
|
446
|
+
metadata = kwargs.pop("metadata", {}) or {}
|
|
447
|
+
metadata.update({"product_name": product_name, "quantity": quantity, "unit": unit, "warehouse_id": warehouse_id})
|
|
448
|
+
return self.log(
|
|
449
|
+
description=description,
|
|
450
|
+
level=LogLevel.AUDIT.value,
|
|
451
|
+
module=BusinessModule.INVENTORY.value,
|
|
452
|
+
action=action.value,
|
|
453
|
+
entity_type="product",
|
|
454
|
+
entity_id=product_id,
|
|
455
|
+
reference_no=reference_no,
|
|
456
|
+
metadata=metadata,
|
|
457
|
+
**kwargs,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
def log_service(
|
|
461
|
+
self,
|
|
462
|
+
description: str,
|
|
463
|
+
*,
|
|
464
|
+
action: AuditAction,
|
|
465
|
+
job_id: str,
|
|
466
|
+
customer_id: str = "",
|
|
467
|
+
technician: str = "",
|
|
468
|
+
service_type: str = "",
|
|
469
|
+
status_label: str = "",
|
|
470
|
+
amount: Optional[float] = None,
|
|
471
|
+
**kwargs: Any,
|
|
472
|
+
) -> AuditEntry:
|
|
473
|
+
metadata = kwargs.pop("metadata", {}) or {}
|
|
474
|
+
metadata.update({
|
|
475
|
+
"customer_id": customer_id,
|
|
476
|
+
"technician": technician,
|
|
477
|
+
"service_type": service_type,
|
|
478
|
+
"status": status_label,
|
|
479
|
+
})
|
|
480
|
+
return self.log(
|
|
481
|
+
description=description,
|
|
482
|
+
level=LogLevel.AUDIT.value,
|
|
483
|
+
module=BusinessModule.SERVICE.value,
|
|
484
|
+
action=action.value,
|
|
485
|
+
entity_type="service_job",
|
|
486
|
+
entity_id=job_id,
|
|
487
|
+
amount=amount,
|
|
488
|
+
metadata=metadata,
|
|
489
|
+
**kwargs,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
def log_security(
|
|
493
|
+
self,
|
|
494
|
+
description: str,
|
|
495
|
+
*,
|
|
496
|
+
action: AuditAction = AuditAction.LOGIN,
|
|
497
|
+
user: str = "UNKNOWN",
|
|
498
|
+
ip: Optional[str] = None,
|
|
499
|
+
success: bool = True,
|
|
500
|
+
**kwargs: Any,
|
|
501
|
+
) -> AuditEntry:
|
|
502
|
+
level = LogLevel.SECURITY.value if not success else LogLevel.AUDIT.value
|
|
503
|
+
check_ip = ip or RequestContext.get().get("ip") or self.default_ip
|
|
504
|
+
|
|
505
|
+
if not self.check_rate_limit(check_ip):
|
|
506
|
+
description = "Rate limit exceeded - possible brute force"
|
|
507
|
+
level = LogLevel.CRITICAL.value
|
|
508
|
+
success = False
|
|
509
|
+
|
|
510
|
+
return self.log(
|
|
511
|
+
description=description,
|
|
512
|
+
level=level,
|
|
513
|
+
module=BusinessModule.AUTH.value,
|
|
514
|
+
action=action.value,
|
|
515
|
+
user=user,
|
|
516
|
+
ip=check_ip,
|
|
517
|
+
success=success,
|
|
518
|
+
**kwargs,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
def debug(self, message: str, **extra: Any) -> None:
|
|
522
|
+
self._app_logger.debug(self._format_app_message(message, extra))
|
|
523
|
+
|
|
524
|
+
def info(self, message: str, **extra: Any) -> None:
|
|
525
|
+
self._app_logger.info(self._format_app_message(message, extra))
|
|
526
|
+
|
|
527
|
+
def warning(self, message: str, **extra: Any) -> None:
|
|
528
|
+
self._app_logger.warning(self._format_app_message(message, extra))
|
|
529
|
+
|
|
530
|
+
def error(self, message: str, **extra: Any) -> None:
|
|
531
|
+
self._app_logger.error(self._format_app_message(message, extra))
|
|
532
|
+
|
|
533
|
+
def critical(self, message: str, **extra: Any) -> None:
|
|
534
|
+
self._app_logger.critical(self._format_app_message(message, extra))
|
|
535
|
+
|
|
536
|
+
@staticmethod
|
|
537
|
+
def _format_app_message(message: str, extra: dict[str, Any]) -> str:
|
|
538
|
+
if not extra:
|
|
539
|
+
return message
|
|
540
|
+
return f"{message} | {json.dumps(extra, ensure_ascii=False, default=str)}"
|
|
541
|
+
|
|
542
|
+
def auditing(
|
|
543
|
+
self,
|
|
544
|
+
description: str,
|
|
545
|
+
level: str = "NORMAL",
|
|
546
|
+
user: str = "SYSTEM",
|
|
547
|
+
method: str = "N/A",
|
|
548
|
+
endpoint: str = "/",
|
|
549
|
+
status: int = 200,
|
|
550
|
+
ip: Optional[str] = None,
|
|
551
|
+
**kwargs: Any,
|
|
552
|
+
) -> AuditEntry:
|
|
553
|
+
return self.log(
|
|
554
|
+
description=description,
|
|
555
|
+
level=level,
|
|
556
|
+
module=kwargs.pop("module", BusinessModule.SYSTEM.value),
|
|
557
|
+
action=kwargs.pop("action", AuditAction.READ.value),
|
|
558
|
+
user=user,
|
|
559
|
+
method=method,
|
|
560
|
+
endpoint=endpoint,
|
|
561
|
+
status=status,
|
|
562
|
+
ip=ip,
|
|
563
|
+
**kwargs,
|
|
564
|
+
)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: auditX
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ERP-grade audit logging for trading and service businesses
|
|
5
|
+
Author: Myanmar Smart Business Hub
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/MyanmarSmartBusinessHub/auditX
|
|
8
|
+
Project-URL: Documentation, https://github.com/MyanmarSmartBusinessHub/auditX#readme
|
|
9
|
+
Keywords: audit,logging,erp,compliance,trading,inventory
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: System :: Logging
|
|
20
|
+
Classifier: Topic :: Office/Business
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: colorama>=0.4.6
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build; extra == "dev"
|
|
26
|
+
Requires-Dist: twine; extra == "dev"
|
|
27
|
+
|
|
28
|
+
# auditX
|
|
29
|
+
|
|
30
|
+
ERP-grade audit logging for **trading** and **service** businesses. Track who did what, when, and where — with structured JSON trails suitable for compliance, SIEM, and multi-branch operations.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install auditX
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**From source (development):**
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
git clone https://github.com/MyanmarSmartBusinessHub/auditX.git
|
|
42
|
+
cd auditX
|
|
43
|
+
pip install -e .
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from auditx import audit, RequestContext, BusinessModule, AuditAction
|
|
50
|
+
|
|
51
|
+
# Set user/tenant context once per request or job
|
|
52
|
+
RequestContext.set(
|
|
53
|
+
user="admin",
|
|
54
|
+
user_role="manager",
|
|
55
|
+
company_id="CO-001",
|
|
56
|
+
branch_id="BR-YGN",
|
|
57
|
+
ip="192.168.1.10",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Security / auth
|
|
61
|
+
audit.log_security("User login successful", action=AuditAction.LOGIN, user="admin")
|
|
62
|
+
|
|
63
|
+
# Trading — sales, purchase, payments
|
|
64
|
+
audit.log_transaction(
|
|
65
|
+
"Sales invoice posted",
|
|
66
|
+
module=BusinessModule.SALES,
|
|
67
|
+
action=AuditAction.POST,
|
|
68
|
+
reference_no="SI-2026-0042",
|
|
69
|
+
amount=1_250_000,
|
|
70
|
+
entity_type="sales_invoice",
|
|
71
|
+
entity_id="inv-42",
|
|
72
|
+
party="Golden Trading Co.",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Inventory
|
|
76
|
+
audit.log_inventory(
|
|
77
|
+
"Stock issued for sales order",
|
|
78
|
+
action=AuditAction.TRANSFER,
|
|
79
|
+
product_id="SKU-1001",
|
|
80
|
+
product_name="LED Panel 24W",
|
|
81
|
+
quantity=50,
|
|
82
|
+
warehouse_id="WH-MAIN",
|
|
83
|
+
reference_no="SI-2026-0042",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Service jobs
|
|
87
|
+
audit.log_service(
|
|
88
|
+
"Service job completed",
|
|
89
|
+
action=AuditAction.UPDATE,
|
|
90
|
+
job_id="SVC-889",
|
|
91
|
+
customer_id="CUST-220",
|
|
92
|
+
technician="U Kyaw",
|
|
93
|
+
service_type="AC Maintenance",
|
|
94
|
+
status_label="completed",
|
|
95
|
+
amount=85_000,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Field-level change audit (before/after)
|
|
99
|
+
audit.log_change(
|
|
100
|
+
"Customer credit limit updated",
|
|
101
|
+
module=BusinessModule.CRM,
|
|
102
|
+
action=AuditAction.UPDATE,
|
|
103
|
+
entity_type="customer",
|
|
104
|
+
entity_id="CUST-220",
|
|
105
|
+
old_values={"credit_limit": 500_000},
|
|
106
|
+
new_values={"credit_limit": 1_000_000},
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Run the demo
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
python -m auditx
|
|
114
|
+
# or after install:
|
|
115
|
+
auditx-demo
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Configuration
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from auditx import configure, create_logger
|
|
122
|
+
|
|
123
|
+
# Reconfigure the global singleton at app startup
|
|
124
|
+
configure(log_dir="/var/log/my-erp", console=False)
|
|
125
|
+
|
|
126
|
+
# Or create separate loggers per tenant/service
|
|
127
|
+
tenant_logger = create_logger(log_dir="logs/tenant-acme", console=False)
|
|
128
|
+
tenant_logger.log_transaction(...)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Log files
|
|
132
|
+
|
|
133
|
+
| File | Purpose |
|
|
134
|
+
|------|---------|
|
|
135
|
+
| `logs/audit.jsonl` | Immutable audit trail (one JSON object per line) |
|
|
136
|
+
| `logs/security.jsonl` | Auth failures, rate limits, critical security events |
|
|
137
|
+
| `logs/app.log` | General application messages (`audit.info()`, etc.) |
|
|
138
|
+
|
|
139
|
+
## Flask / FastAPI middleware pattern
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from auditx import RequestContext, audit, AuditAction
|
|
143
|
+
|
|
144
|
+
def set_audit_context(user, request):
|
|
145
|
+
RequestContext.set(
|
|
146
|
+
user=user.username,
|
|
147
|
+
user_role=user.role,
|
|
148
|
+
company_id=user.company_id,
|
|
149
|
+
branch_id=user.branch_id,
|
|
150
|
+
request_id=request.headers.get("X-Request-ID", ""),
|
|
151
|
+
ip=request.remote_addr,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# On login
|
|
155
|
+
audit.log_security("Login", action=AuditAction.LOGIN, user=username, success=True)
|
|
156
|
+
|
|
157
|
+
# On logout
|
|
158
|
+
RequestContext.clear()
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Database hook (optional)
|
|
162
|
+
|
|
163
|
+
Persist audit entries to your database:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
def save_to_db(entry):
|
|
167
|
+
db.execute(
|
|
168
|
+
"INSERT INTO audit_log (data) VALUES (?)",
|
|
169
|
+
[json.dumps(entry.to_dict())],
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
from auditx import configure
|
|
173
|
+
configure(log_dir="logs", on_audit=save_to_db)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Public API
|
|
177
|
+
|
|
178
|
+
| Export | Description |
|
|
179
|
+
|--------|-------------|
|
|
180
|
+
| `audit` | Global logger singleton |
|
|
181
|
+
| `AuditLogger` | Create custom logger instances |
|
|
182
|
+
| `configure()` | Reconfigure the global singleton |
|
|
183
|
+
| `create_logger()` | Factory for new logger instances |
|
|
184
|
+
| `RequestContext` | Thread-local user/tenant context |
|
|
185
|
+
| `AuditEntry` | Structured audit record dataclass |
|
|
186
|
+
| `BusinessModule` | `SALES`, `PURCHASE`, `INVENTORY`, `SERVICE`, etc. |
|
|
187
|
+
| `AuditAction` | `CREATE`, `UPDATE`, `POST`, `PAYMENT`, `LOGIN`, etc. |
|
|
188
|
+
| `LogLevel` | `DEBUG`, `INFO`, `AUDIT`, `SECURITY`, `CRITICAL`, etc. |
|
|
189
|
+
|
|
190
|
+
## License
|
|
191
|
+
|
|
192
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
auditx/__init__.py,sha256=TKXAtacKRYIvnt0DC3jPx7c0gG7_3EQCvYDfrO3ia6c,1892
|
|
2
|
+
auditx/__main__.py,sha256=6bMUnEUlpm_KDaWpLDrNJLH_zgy1MaO1ZQ9sJ4FWIZs,2025
|
|
3
|
+
auditx/core.py,sha256=yTNb-DnyTSmRVOos7paM8XO2utjBmcrTzX0L1aSvFJU,18690
|
|
4
|
+
auditx-0.1.0.dist-info/METADATA,sha256=YYkTldmFz0H-_R8QKTkclaZ1NN2I3b_xFTusO3juvZc,5285
|
|
5
|
+
auditx-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
+
auditx-0.1.0.dist-info/entry_points.txt,sha256=LhIWp6BS3EpIgX2a9Z2pB3CAGIfNTvwlf3KkNZDJCEk,53
|
|
7
|
+
auditx-0.1.0.dist-info/top_level.txt,sha256=pBVlpYKvdJyWQ_hmeEG1vjf9T7gxzcLelfVCwElSo08,7
|
|
8
|
+
auditx-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
auditx
|