dataman-engine 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.
- dataman/__init__.py +18 -0
- dataman/cli.py +731 -0
- dataman/core/__init__.py +0 -0
- dataman/core/apps.py +9 -0
- dataman/core/audit.py +171 -0
- dataman/core/auth.py +106 -0
- dataman/core/crypto.py +173 -0
- dataman/core/fields.py +120 -0
- dataman/core/masking.py +107 -0
- dataman/core/middleware.py +46 -0
- dataman/core/models.py +175 -0
- dataman/core/permissions.py +110 -0
- dataman/core/router.py +112 -0
- dataman/core/signals.py +56 -0
- dataman/core/templates/admin/dashboard.html +1399 -0
- dataman/core/templates/admin/login.html +377 -0
- dataman/core/urls.py +472 -0
- dataman/core/views.py +678 -0
- dataman/django_setup.py +192 -0
- dataman/py.typed +0 -0
- dataman_engine-0.1.0.dist-info/METADATA +259 -0
- dataman_engine-0.1.0.dist-info/RECORD +24 -0
- dataman_engine-0.1.0.dist-info/WHEEL +4 -0
- dataman_engine-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
from .models import APILog
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class APILoggingMiddleware:
|
|
7
|
+
"""Middleware to log API request metrics for the dashboard."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, get_response):
|
|
10
|
+
self.get_response = get_response
|
|
11
|
+
|
|
12
|
+
def __call__(self, request):
|
|
13
|
+
path = request.path
|
|
14
|
+
# Only log requests to actual data endpoints,
|
|
15
|
+
# exclude internal/dashboard/docs/health routes.
|
|
16
|
+
if (
|
|
17
|
+
not path.startswith("/api/")
|
|
18
|
+
or path.startswith("/api/schema")
|
|
19
|
+
or path.startswith("/api/docs")
|
|
20
|
+
or path.startswith("/api/redoc")
|
|
21
|
+
or path.startswith("/api/_internal/")
|
|
22
|
+
or path.startswith("/api/health")
|
|
23
|
+
or path.startswith("/health")
|
|
24
|
+
):
|
|
25
|
+
return self.get_response(request)
|
|
26
|
+
|
|
27
|
+
start_time = time.time()
|
|
28
|
+
response = self.get_response(request)
|
|
29
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
30
|
+
|
|
31
|
+
# Handle IP Address correctly behind proxies
|
|
32
|
+
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
|
33
|
+
if x_forwarded_for:
|
|
34
|
+
ip = x_forwarded_for.split(",")[0]
|
|
35
|
+
else:
|
|
36
|
+
ip = request.META.get("REMOTE_ADDR")
|
|
37
|
+
|
|
38
|
+
APILog.objects.create(
|
|
39
|
+
method=request.method,
|
|
40
|
+
path=path,
|
|
41
|
+
status_code=response.status_code,
|
|
42
|
+
duration_ms=duration_ms,
|
|
43
|
+
ip_address=ip,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return response
|
dataman/core/models.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import importlib
|
|
3
|
+
import secrets
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from django.db import models
|
|
8
|
+
|
|
9
|
+
# Registry of discovered tables, their databases, and module prefixes
|
|
10
|
+
TABLE_REGISTRY: dict[str, dict] = {}
|
|
11
|
+
|
|
12
|
+
RESERVED_ROOT_DIRS = {
|
|
13
|
+
".agents",
|
|
14
|
+
".git",
|
|
15
|
+
".pytest_cache",
|
|
16
|
+
".ruff_cache",
|
|
17
|
+
".system_generated",
|
|
18
|
+
".venv",
|
|
19
|
+
"build",
|
|
20
|
+
"coverage_hack",
|
|
21
|
+
"dist",
|
|
22
|
+
"migrations",
|
|
23
|
+
"scratch",
|
|
24
|
+
"src",
|
|
25
|
+
"tests",
|
|
26
|
+
"__pycache__",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def generate_key():
|
|
31
|
+
return secrets.token_hex(20)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class APIToken(models.Model):
|
|
35
|
+
"""Service Token for DataMan API Access."""
|
|
36
|
+
|
|
37
|
+
prefix = models.CharField(max_length=8, unique=True)
|
|
38
|
+
hashed_secret = models.CharField(max_length=128)
|
|
39
|
+
name = models.CharField(max_length=255)
|
|
40
|
+
scopes = models.JSONField(default=list)
|
|
41
|
+
created_at = models.DateTimeField(auto_now_add=True)
|
|
42
|
+
expires_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
|
43
|
+
is_active = models.BooleanField(default=True, db_index=True)
|
|
44
|
+
|
|
45
|
+
class Meta:
|
|
46
|
+
app_label = "dataman"
|
|
47
|
+
db_table = "dataman_apitoken"
|
|
48
|
+
|
|
49
|
+
def __str__(self):
|
|
50
|
+
return f"{self.name} ({self.prefix}...)"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class APILog(models.Model):
|
|
54
|
+
"""Telemetry data for API endpoint analysis."""
|
|
55
|
+
|
|
56
|
+
timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
|
|
57
|
+
method = models.CharField(max_length=10)
|
|
58
|
+
path = models.CharField(max_length=255, db_index=True)
|
|
59
|
+
status_code = models.IntegerField()
|
|
60
|
+
duration_ms = models.IntegerField()
|
|
61
|
+
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
|
62
|
+
|
|
63
|
+
class Meta:
|
|
64
|
+
app_label = "dataman"
|
|
65
|
+
db_table = "dataman_apilog"
|
|
66
|
+
ordering = ["-timestamp"]
|
|
67
|
+
|
|
68
|
+
def __str__(self):
|
|
69
|
+
return f"{self.method} {self.path} - {self.status_code} ({self.duration_ms}ms)"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AuditLog(models.Model):
|
|
73
|
+
"""Enterprise Audit Trail for Security and Compliance."""
|
|
74
|
+
|
|
75
|
+
timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
|
|
76
|
+
event_type = models.CharField(max_length=60, db_index=True)
|
|
77
|
+
actor = models.CharField(max_length=255, db_index=True, blank=True, default="")
|
|
78
|
+
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
|
79
|
+
user_agent = models.TextField(blank=True, default="")
|
|
80
|
+
status_code = models.IntegerField(null=True, blank=True)
|
|
81
|
+
severity = models.CharField(max_length=20, default="INFO", db_index=True)
|
|
82
|
+
details = models.JSONField(default=dict)
|
|
83
|
+
|
|
84
|
+
class Meta:
|
|
85
|
+
app_label = "dataman"
|
|
86
|
+
db_table = "dataman_auditlog"
|
|
87
|
+
ordering = ["-timestamp"]
|
|
88
|
+
|
|
89
|
+
def __str__(self):
|
|
90
|
+
return f"[{self.timestamp}] [{self.severity}] {self.event_type} - {self.actor or 'System'}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _discover_and_load_models():
|
|
94
|
+
"""Discovers and registers models across all configured database directories."""
|
|
95
|
+
cwd = Path.cwd()
|
|
96
|
+
if str(cwd) not in sys.path:
|
|
97
|
+
sys.path.append(str(cwd))
|
|
98
|
+
|
|
99
|
+
discovered_candidates: list[tuple[str, str, str]] = []
|
|
100
|
+
# candidate tuple: (db_name, table_name, module_prefix)
|
|
101
|
+
|
|
102
|
+
# 1. Check 'databases/' container directory: databases/<db_name>/<table_name>
|
|
103
|
+
databases_dir = cwd / "databases"
|
|
104
|
+
if databases_dir.exists() and databases_dir.is_dir():
|
|
105
|
+
for db_dir in databases_dir.iterdir():
|
|
106
|
+
if db_dir.is_dir() and not db_dir.name.startswith((".", "_")):
|
|
107
|
+
for tbl_dir in db_dir.iterdir():
|
|
108
|
+
if tbl_dir.is_dir() and (tbl_dir / "models.py").exists():
|
|
109
|
+
discovered_candidates.append(
|
|
110
|
+
(
|
|
111
|
+
db_dir.name,
|
|
112
|
+
tbl_dir.name,
|
|
113
|
+
f"databases.{db_dir.name}.{tbl_dir.name}",
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# 2. Check root-level database directories: <db_name>/<table_name>
|
|
118
|
+
for item in cwd.iterdir():
|
|
119
|
+
if (
|
|
120
|
+
item.is_dir()
|
|
121
|
+
and not item.name.startswith((".", "_"))
|
|
122
|
+
and item.name not in RESERVED_ROOT_DIRS
|
|
123
|
+
and item.name not in ("databases", "tables")
|
|
124
|
+
):
|
|
125
|
+
# Check if this root folder has subdirectories with models.py
|
|
126
|
+
for sub in item.iterdir():
|
|
127
|
+
if sub.is_dir() and (sub / "models.py").exists():
|
|
128
|
+
discovered_candidates.append(
|
|
129
|
+
(item.name, sub.name, f"{item.name}.{sub.name}")
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# 3. Check legacy/default 'tables/' directory: tables/<table_name>
|
|
133
|
+
tables_dir = cwd / "tables"
|
|
134
|
+
if tables_dir.exists() and tables_dir.is_dir():
|
|
135
|
+
for tbl_dir in tables_dir.iterdir():
|
|
136
|
+
if tbl_dir.is_dir() and (tbl_dir / "models.py").exists():
|
|
137
|
+
discovered_candidates.append(
|
|
138
|
+
("default", tbl_dir.name, f"tables.{tbl_dir.name}")
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
for db_name, tbl_name, module_prefix in discovered_candidates:
|
|
142
|
+
models_module_name = f"{module_prefix}.models"
|
|
143
|
+
try:
|
|
144
|
+
mod = importlib.import_module(models_module_name)
|
|
145
|
+
# Find models defined in this module
|
|
146
|
+
for attr_name in dir(mod):
|
|
147
|
+
attr = getattr(mod, attr_name)
|
|
148
|
+
if (
|
|
149
|
+
isinstance(attr, type)
|
|
150
|
+
and issubclass(attr, models.Model)
|
|
151
|
+
and attr.__module__ == models_module_name
|
|
152
|
+
):
|
|
153
|
+
# Check if config overrides database
|
|
154
|
+
target_db = db_name
|
|
155
|
+
with contextlib.suppress(Exception):
|
|
156
|
+
cfg_mod = importlib.import_module(f"{module_prefix}.config")
|
|
157
|
+
if hasattr(cfg_mod, "DATABASE") and cfg_mod.DATABASE:
|
|
158
|
+
target_db = str(cfg_mod.DATABASE)
|
|
159
|
+
|
|
160
|
+
attr._dataman_db = target_db
|
|
161
|
+
entry = {
|
|
162
|
+
"database": target_db,
|
|
163
|
+
"table_name": tbl_name,
|
|
164
|
+
"module_prefix": module_prefix,
|
|
165
|
+
"model": attr,
|
|
166
|
+
}
|
|
167
|
+
TABLE_REGISTRY[attr.__name__] = entry
|
|
168
|
+
TABLE_REGISTRY[attr.__name__.lower()] = entry
|
|
169
|
+
TABLE_REGISTRY[tbl_name.lower()] = entry
|
|
170
|
+
TABLE_REGISTRY[f"{target_db}-{tbl_name.lower()}"] = entry
|
|
171
|
+
except Exception as e:
|
|
172
|
+
print(f"Failed to load model from {models_module_name}: {e}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
_discover_and_load_models()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from rest_framework import permissions
|
|
2
|
+
|
|
3
|
+
from dataman.core.audit import log_audit_event
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HasTableScope(permissions.BasePermission):
|
|
7
|
+
"""
|
|
8
|
+
Ensures that the incoming request token has the required scope
|
|
9
|
+
to access this table and action.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def has_permission(self, request, view):
|
|
13
|
+
# Allow superusers (admin panel logins) to do anything
|
|
14
|
+
if getattr(request.user, "is_superuser", False):
|
|
15
|
+
return True
|
|
16
|
+
|
|
17
|
+
table_name = getattr(view, "basename", "").lower()
|
|
18
|
+
method = request.method.lower()
|
|
19
|
+
|
|
20
|
+
action_map = {
|
|
21
|
+
"get": "read",
|
|
22
|
+
"head": "read",
|
|
23
|
+
"options": "read",
|
|
24
|
+
"post": "write",
|
|
25
|
+
"put": "write",
|
|
26
|
+
"patch": "write",
|
|
27
|
+
"delete": "write",
|
|
28
|
+
}
|
|
29
|
+
action = action_map.get(method, "read")
|
|
30
|
+
required_scope = f"{table_name}:{action}"
|
|
31
|
+
|
|
32
|
+
if not hasattr(request, "auth") or not request.auth:
|
|
33
|
+
log_audit_event(
|
|
34
|
+
event_type="PERMISSION_DENIED",
|
|
35
|
+
request=request,
|
|
36
|
+
details={
|
|
37
|
+
"table": table_name,
|
|
38
|
+
"action": action,
|
|
39
|
+
"required_scope": required_scope,
|
|
40
|
+
"reason": "No authentication token provided",
|
|
41
|
+
},
|
|
42
|
+
severity="WARNING",
|
|
43
|
+
status_code=403,
|
|
44
|
+
)
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
token = request.auth
|
|
48
|
+
if not hasattr(token, "scopes"):
|
|
49
|
+
log_audit_event(
|
|
50
|
+
event_type="PERMISSION_DENIED",
|
|
51
|
+
actor=f"Token:{getattr(token, 'prefix', 'unknown')}",
|
|
52
|
+
request=request,
|
|
53
|
+
details={
|
|
54
|
+
"table": table_name,
|
|
55
|
+
"action": action,
|
|
56
|
+
"required_scope": required_scope,
|
|
57
|
+
"reason": "Token object missing scopes",
|
|
58
|
+
},
|
|
59
|
+
severity="WARNING",
|
|
60
|
+
status_code=403,
|
|
61
|
+
)
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
from dataman.core.models import TABLE_REGISTRY
|
|
65
|
+
|
|
66
|
+
clean_table = table_name.split("-")[-1]
|
|
67
|
+
required_scope = f"{clean_table}:{action}"
|
|
68
|
+
namespaced_scope = f"{table_name}:{action}"
|
|
69
|
+
|
|
70
|
+
possible_scopes = {
|
|
71
|
+
required_scope,
|
|
72
|
+
namespaced_scope,
|
|
73
|
+
f"{clean_table}:*",
|
|
74
|
+
f"{table_name}:*",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
reg_entry = TABLE_REGISTRY.get(table_name) or TABLE_REGISTRY.get(clean_table)
|
|
78
|
+
if reg_entry and "database" in reg_entry:
|
|
79
|
+
db_name = reg_entry["database"]
|
|
80
|
+
possible_scopes.update(
|
|
81
|
+
{
|
|
82
|
+
f"{db_name}-{clean_table}:{action}",
|
|
83
|
+
f"{db_name}-{clean_table}:*",
|
|
84
|
+
f"{db_name}:{clean_table}:{action}",
|
|
85
|
+
f"{db_name}:{clean_table}:*",
|
|
86
|
+
f"{db_name}:{action}",
|
|
87
|
+
f"{db_name}:*",
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if "*" in token.scopes:
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
has_scope = any(s in token.scopes for s in possible_scopes)
|
|
95
|
+
if not has_scope:
|
|
96
|
+
log_audit_event(
|
|
97
|
+
event_type="PERMISSION_DENIED",
|
|
98
|
+
actor=f"Token:{getattr(token, 'prefix', 'unknown')}",
|
|
99
|
+
request=request,
|
|
100
|
+
details={
|
|
101
|
+
"table": table_name,
|
|
102
|
+
"action": action,
|
|
103
|
+
"required_scope": required_scope,
|
|
104
|
+
"token_scopes": token.scopes,
|
|
105
|
+
"reason": "Missing required scope",
|
|
106
|
+
},
|
|
107
|
+
severity="WARNING",
|
|
108
|
+
status_code=403,
|
|
109
|
+
)
|
|
110
|
+
return has_scope
|
dataman/core/router.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger("dataman.router")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DataManDatabaseRouter:
|
|
9
|
+
"""
|
|
10
|
+
Routes database operations to the designated database for each DataMan model.
|
|
11
|
+
Models can have `_dataman_db` or `_meta.dataman_db` specified, or be registered in TABLE_REGISTRY.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
DJANGO_INTERNAL_APPS = {
|
|
15
|
+
"auth",
|
|
16
|
+
"contenttypes",
|
|
17
|
+
"sessions",
|
|
18
|
+
"admin",
|
|
19
|
+
"messages",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
def _get_target_db(self, model: Any) -> str:
|
|
23
|
+
if not model:
|
|
24
|
+
return "default"
|
|
25
|
+
if hasattr(model, "_dataman_db") and model._dataman_db:
|
|
26
|
+
return str(model._dataman_db)
|
|
27
|
+
if hasattr(model, "_meta") and hasattr(model._meta, "dataman_db"):
|
|
28
|
+
return str(model._meta.dataman_db)
|
|
29
|
+
|
|
30
|
+
# Lookup in TABLE_REGISTRY
|
|
31
|
+
with contextlib.suppress(Exception):
|
|
32
|
+
from dataman.core.models import TABLE_REGISTRY
|
|
33
|
+
|
|
34
|
+
model_name = getattr(model, "__name__", "") or (
|
|
35
|
+
getattr(model._meta, "object_name", "")
|
|
36
|
+
if hasattr(model, "_meta")
|
|
37
|
+
else ""
|
|
38
|
+
)
|
|
39
|
+
if model_name:
|
|
40
|
+
for reg_name, entry in TABLE_REGISTRY.items():
|
|
41
|
+
if reg_name.lower() == model_name.lower():
|
|
42
|
+
return entry.get("database", "default")
|
|
43
|
+
|
|
44
|
+
return "default"
|
|
45
|
+
|
|
46
|
+
def db_for_read(self, model: Any, **hints: Any) -> str:
|
|
47
|
+
"""Determines which database to use for read operations."""
|
|
48
|
+
if (
|
|
49
|
+
hasattr(model, "_meta")
|
|
50
|
+
and model._meta.app_label in self.DJANGO_INTERNAL_APPS
|
|
51
|
+
):
|
|
52
|
+
return "default"
|
|
53
|
+
return self._get_target_db(model)
|
|
54
|
+
|
|
55
|
+
def db_for_write(self, model: Any, **hints: Any) -> str:
|
|
56
|
+
"""Determines which database to use for write operations."""
|
|
57
|
+
if (
|
|
58
|
+
hasattr(model, "_meta")
|
|
59
|
+
and model._meta.app_label in self.DJANGO_INTERNAL_APPS
|
|
60
|
+
):
|
|
61
|
+
return "default"
|
|
62
|
+
return self._get_target_db(model)
|
|
63
|
+
|
|
64
|
+
def allow_relation(self, obj1: Any, obj2: Any, **hints: Any) -> bool | None:
|
|
65
|
+
"""
|
|
66
|
+
Determines if a relationship between obj1 and obj2 is allowed.
|
|
67
|
+
Allowed if both objects belong to the same database.
|
|
68
|
+
"""
|
|
69
|
+
db1 = self._get_target_db(obj1)
|
|
70
|
+
db2 = self._get_target_db(obj2)
|
|
71
|
+
if db1 == db2:
|
|
72
|
+
return True
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def allow_migrate(
|
|
76
|
+
self, db: str, app_label: str, model_name: str | None = None, **hints: Any
|
|
77
|
+
) -> bool:
|
|
78
|
+
"""
|
|
79
|
+
Determines if a migration is allowed to run on the specified database.
|
|
80
|
+
App-level dependency checks return True to satisfy multi-db migration plans.
|
|
81
|
+
Model-level table creation is routed strictly to the designated database.
|
|
82
|
+
"""
|
|
83
|
+
# Internal DataMan models always live on 'default' database
|
|
84
|
+
if model_name and model_name.lower() in ("apitoken", "apilog", "auditlog"):
|
|
85
|
+
return db == "default"
|
|
86
|
+
|
|
87
|
+
# Lookup model in TABLE_REGISTRY
|
|
88
|
+
target_name = (model_name or "").lower()
|
|
89
|
+
model = hints.get("model")
|
|
90
|
+
if model:
|
|
91
|
+
target_name = getattr(model, "__name__", "") or getattr(
|
|
92
|
+
model._meta, "object_name", target_name
|
|
93
|
+
)
|
|
94
|
+
target_name = target_name.lower()
|
|
95
|
+
|
|
96
|
+
if target_name:
|
|
97
|
+
with contextlib.suppress(Exception):
|
|
98
|
+
from dataman.core.models import TABLE_REGISTRY
|
|
99
|
+
|
|
100
|
+
for reg_name, entry in TABLE_REGISTRY.items():
|
|
101
|
+
if (
|
|
102
|
+
reg_name.lower() == target_name
|
|
103
|
+
or entry.get("table_name", "").lower() == target_name
|
|
104
|
+
):
|
|
105
|
+
target_db = entry.get("database", "default")
|
|
106
|
+
return db == target_db
|
|
107
|
+
|
|
108
|
+
if model:
|
|
109
|
+
target_db = self._get_target_db(model)
|
|
110
|
+
return db == target_db
|
|
111
|
+
|
|
112
|
+
return True
|
dataman/core/signals.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from django.contrib.auth.signals import (
|
|
2
|
+
user_logged_in,
|
|
3
|
+
user_logged_out,
|
|
4
|
+
user_login_failed,
|
|
5
|
+
)
|
|
6
|
+
from django.dispatch import receiver
|
|
7
|
+
|
|
8
|
+
from .audit import log_audit_event
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@receiver(user_logged_in)
|
|
12
|
+
def audit_user_logged_in(sender, request, user, **kwargs):
|
|
13
|
+
"""Audit successful user login."""
|
|
14
|
+
log_audit_event(
|
|
15
|
+
event_type="ADMIN_LOGIN_SUCCESS",
|
|
16
|
+
actor=getattr(user, "username", str(user)),
|
|
17
|
+
request=request,
|
|
18
|
+
details={
|
|
19
|
+
"email": getattr(user, "email", ""),
|
|
20
|
+
"is_superuser": getattr(user, "is_superuser", False),
|
|
21
|
+
},
|
|
22
|
+
severity="INFO",
|
|
23
|
+
status_code=200,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@receiver(user_login_failed)
|
|
28
|
+
def audit_user_login_failed(sender, credentials, request, **kwargs):
|
|
29
|
+
"""Audit failed login attempt."""
|
|
30
|
+
username = (
|
|
31
|
+
credentials.get("username", "<unknown>")
|
|
32
|
+
if isinstance(credentials, dict)
|
|
33
|
+
else "<unknown>"
|
|
34
|
+
)
|
|
35
|
+
log_audit_event(
|
|
36
|
+
event_type="ADMIN_LOGIN_FAILED",
|
|
37
|
+
actor=username,
|
|
38
|
+
request=request,
|
|
39
|
+
details={"attempted_username": username},
|
|
40
|
+
severity="WARNING",
|
|
41
|
+
status_code=401,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@receiver(user_logged_out)
|
|
46
|
+
def audit_user_logged_out(sender, request, user, **kwargs):
|
|
47
|
+
"""Audit user logout."""
|
|
48
|
+
if user:
|
|
49
|
+
log_audit_event(
|
|
50
|
+
event_type="ADMIN_LOGOUT",
|
|
51
|
+
actor=getattr(user, "username", str(user)),
|
|
52
|
+
request=request,
|
|
53
|
+
details={"email": getattr(user, "email", "")},
|
|
54
|
+
severity="INFO",
|
|
55
|
+
status_code=200,
|
|
56
|
+
)
|