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.
@@ -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
@@ -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
+ )