dashtro 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.
File without changes
File without changes
@@ -0,0 +1,49 @@
1
+ from decouple import config
2
+
3
+ from .audit_client import SqliteAuditClient
4
+ from .postgres_audit_client import PostgresAuditClient
5
+ from .postgres_client import PostgresAuth, PostgresData
6
+ from .sqlite_client import SqliteAuth, SqliteData
7
+
8
+ # mongodb is not yet migrated to the FastAPI data API, so it stays unsupported.
9
+ _DB_TYPE = config("DB_TYPE", default="sqlite")
10
+ _SUPPORTED_DB_TYPES = ("sqlite", "postgres")
11
+
12
+
13
+ def get_data_client():
14
+ if _DB_TYPE == "postgres":
15
+ return PostgresData()
16
+ if _DB_TYPE == "sqlite":
17
+ return SqliteData()
18
+ raise NotImplementedError(
19
+ f"DB_TYPE={_DB_TYPE!r} is not supported yet; use one of {_SUPPORTED_DB_TYPES}"
20
+ )
21
+
22
+
23
+ def get_auth_client():
24
+ if _DB_TYPE == "postgres":
25
+ return PostgresAuth()
26
+ if _DB_TYPE == "sqlite":
27
+ return SqliteAuth()
28
+ raise NotImplementedError(
29
+ f"DB_TYPE={_DB_TYPE!r} is not supported yet; use one of {_SUPPORTED_DB_TYPES}"
30
+ )
31
+
32
+
33
+ def get_audit_client() -> SqliteAuditClient | PostgresAuditClient:
34
+ if _DB_TYPE == "postgres":
35
+ return PostgresAuditClient()
36
+ return SqliteAuditClient()
37
+
38
+
39
+ __all__ = [
40
+ "SqliteData",
41
+ "SqliteAuth",
42
+ "SqliteAuditClient",
43
+ "PostgresData",
44
+ "PostgresAuth",
45
+ "PostgresAuditClient",
46
+ "get_data_client",
47
+ "get_auth_client",
48
+ "get_audit_client",
49
+ ]
@@ -0,0 +1,11 @@
1
+ def get_actor(request) -> dict:
2
+ """Return the actor verified by CMSAuthMiddleware for this request."""
3
+ return request.state.actor
4
+
5
+
6
+ def get_client_ip(request) -> str:
7
+ """Extract the real client IP from a FastAPI Request."""
8
+ forwarded_for = request.headers.get("x-forwarded-for")
9
+ if forwarded_for:
10
+ return forwarded_for.split(",")[0].strip()
11
+ return request.client.host if request.client else ""
@@ -0,0 +1,31 @@
1
+ from fastapi import Header, HTTPException
2
+
3
+
4
+ def require_api_key(operation: str):
5
+ """Dependency factory: verifies X-API-Key and that it carries `operation` ('read'/'write')."""
6
+
7
+ def _dependency(x_api_key: str = Header(default=None)) -> dict:
8
+ if not x_api_key:
9
+ raise HTTPException(status_code=401, detail="X-API-Key header missing")
10
+ from api.utils import get_auth_client
11
+
12
+ db_auth = get_auth_client()
13
+ key_info = db_auth.verify_api_key(x_api_key)
14
+ if not key_info:
15
+ raise HTTPException(status_code=401, detail="Invalid or revoked API key")
16
+ if operation not in (key_info.get("scopes") or []):
17
+ raise HTTPException(
18
+ status_code=403, detail=f"API key does not have '{operation}' access"
19
+ )
20
+ return key_info
21
+
22
+ return _dependency
23
+
24
+
25
+ def check_key_scope(key_info: dict, project_id: str, collection_name: str):
26
+ """Raises 403 if the key is scoped to a different project or collection set."""
27
+ if key_info.get("project_id") and key_info["project_id"] != project_id:
28
+ raise HTTPException(status_code=403, detail="API key is not scoped to this project")
29
+ collections = key_info.get("collections") or []
30
+ if collections and collection_name not in collections:
31
+ raise HTTPException(status_code=403, detail="API key is not scoped to this collection")
@@ -0,0 +1,237 @@
1
+ import json
2
+ import sqlite3
3
+ import threading
4
+ import uuid
5
+ from datetime import UTC, datetime
6
+
7
+ from decouple import config
8
+
9
+
10
+ class SqliteAuditClient:
11
+ _instance = None
12
+
13
+ def __new__(cls, *args, **kwargs):
14
+ if not cls._instance:
15
+ cls._instance = super().__new__(cls)
16
+ cls._instance._local = threading.local()
17
+ cls._instance._ensure_table()
18
+ return cls._instance
19
+
20
+ @staticmethod
21
+ def _create_connection():
22
+ db_path = config("SQLITE_DB_PATH", default="db.sqlite3")
23
+ conn = sqlite3.connect(db_path, check_same_thread=False)
24
+ conn.row_factory = sqlite3.Row
25
+ return conn
26
+
27
+ @property
28
+ def connection(self):
29
+ local = self._local
30
+ if not hasattr(local, "conn") or local.conn is None:
31
+ local.conn = self._create_connection()
32
+ return local.conn
33
+
34
+ @connection.setter
35
+ def connection(self, value):
36
+ self._local.conn = value
37
+
38
+ def get_cursor(self):
39
+ try:
40
+ self.connection.execute("SELECT 1")
41
+ except (sqlite3.ProgrammingError, sqlite3.InterfaceError):
42
+ self.connection = self._create_connection()
43
+ return self.connection.cursor()
44
+
45
+ def _ensure_table(self):
46
+ cursor = self.get_cursor()
47
+ cursor.execute("""
48
+ CREATE TABLE IF NOT EXISTS cms_audit_logs (
49
+ id TEXT PRIMARY KEY,
50
+ user_id TEXT NOT NULL DEFAULT 'anonymous',
51
+ user_email TEXT NOT NULL DEFAULT 'anonymous',
52
+ action TEXT NOT NULL,
53
+ resource_type TEXT NOT NULL,
54
+ resource_id TEXT DEFAULT '',
55
+ resource_name TEXT DEFAULT '',
56
+ project_id TEXT DEFAULT '',
57
+ workspace_name TEXT DEFAULT '',
58
+ details TEXT DEFAULT '{}',
59
+ ip_address TEXT DEFAULT '',
60
+ created_at TEXT NOT NULL
61
+ );
62
+ """)
63
+ cursor.execute(
64
+ "CREATE INDEX IF NOT EXISTS idx_audit_created_at ON cms_audit_logs(created_at DESC);"
65
+ )
66
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_audit_action ON cms_audit_logs(action);")
67
+ cursor.execute(
68
+ "CREATE INDEX IF NOT EXISTS idx_audit_resource_type ON cms_audit_logs(resource_type);"
69
+ )
70
+ self.connection.commit()
71
+ cursor.close()
72
+
73
+ def log(
74
+ self,
75
+ action: str,
76
+ resource_type: str,
77
+ user_id: str = "anonymous",
78
+ user_email: str = "anonymous",
79
+ resource_id: str = "",
80
+ resource_name: str = "",
81
+ project_id: str = "",
82
+ workspace_name: str = "",
83
+ details: dict | None = None,
84
+ ip_address: str = "",
85
+ ):
86
+ log_id = str(uuid.uuid4().hex)
87
+ created_at = datetime.now(tz=UTC).isoformat()
88
+ details_json = json.dumps(details or {})
89
+ cursor = self.get_cursor()
90
+ cursor.execute(
91
+ """INSERT INTO cms_audit_logs
92
+ (id, user_id, user_email, action, resource_type, resource_id, resource_name,
93
+ project_id, workspace_name, details, ip_address, created_at)
94
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
95
+ (
96
+ log_id,
97
+ user_id,
98
+ user_email,
99
+ action,
100
+ resource_type,
101
+ resource_id,
102
+ resource_name,
103
+ project_id,
104
+ workspace_name,
105
+ details_json,
106
+ ip_address,
107
+ created_at,
108
+ ),
109
+ )
110
+ self.connection.commit()
111
+ cursor.close()
112
+
113
+ def get_logs(
114
+ self,
115
+ action: str | None = None,
116
+ resource_type: str | None = None,
117
+ project_id: str | None = None,
118
+ user_id: str | None = None,
119
+ from_date: str | None = None,
120
+ to_date: str | None = None,
121
+ limit: int = 100,
122
+ offset: int = 0,
123
+ ) -> dict:
124
+ where_clauses = []
125
+ params = []
126
+
127
+ if action:
128
+ where_clauses.append("action = ?")
129
+ params.append(action)
130
+ if resource_type:
131
+ where_clauses.append("resource_type = ?")
132
+ params.append(resource_type)
133
+ if project_id:
134
+ where_clauses.append("project_id = ?")
135
+ params.append(project_id)
136
+ if user_id:
137
+ where_clauses.append("user_id = ?")
138
+ params.append(user_id)
139
+ if from_date:
140
+ where_clauses.append("created_at >= ?")
141
+ params.append(from_date)
142
+ if to_date:
143
+ where_clauses.append("created_at <= ?")
144
+ params.append(to_date)
145
+
146
+ where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
147
+
148
+ cursor = self.get_cursor()
149
+ cursor.execute(f"SELECT COUNT(*) FROM cms_audit_logs {where_sql}", params)
150
+ total = cursor.fetchone()[0]
151
+
152
+ cursor.execute(
153
+ f"SELECT * FROM cms_audit_logs {where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?",
154
+ [*params, limit, offset],
155
+ )
156
+ rows = cursor.fetchall()
157
+ cursor.close()
158
+
159
+ logs = []
160
+ for r in rows:
161
+ logs.append(
162
+ {
163
+ "id": r["id"],
164
+ "user_id": r["user_id"],
165
+ "user_email": r["user_email"],
166
+ "action": r["action"],
167
+ "resource_type": r["resource_type"],
168
+ "resource_id": r["resource_id"],
169
+ "resource_name": r["resource_name"],
170
+ "project_id": r["project_id"],
171
+ "workspace_name": r["workspace_name"],
172
+ "details": json.loads(r["details"] or "{}"),
173
+ "ip_address": r["ip_address"],
174
+ "created_at": r["created_at"],
175
+ }
176
+ )
177
+
178
+ return {"total": total, "logs": logs}
179
+
180
+ def get_heatmap_data(self, year: int, month: int | None = None) -> list[dict]:
181
+ """Return per-day operation counts and top action for a year or a single month."""
182
+ import calendar as cal
183
+
184
+ if month:
185
+ days_in_month = cal.monthrange(year, month)[1]
186
+ start = f"{year:04d}-{month:02d}-01"
187
+ end = f"{year:04d}-{month:02d}-{days_in_month:02d}"
188
+ else:
189
+ start = f"{year:04d}-01-01"
190
+ end = f"{year:04d}-12-31"
191
+
192
+ cursor = self.get_cursor()
193
+ cursor.execute(
194
+ """
195
+ SELECT date(created_at) AS day, action, COUNT(*) AS cnt
196
+ FROM cms_audit_logs
197
+ WHERE date(created_at) BETWEEN ? AND ?
198
+ GROUP BY day, action
199
+ ORDER BY day, cnt DESC
200
+ """,
201
+ (start, end),
202
+ )
203
+ rows = cursor.fetchall()
204
+ cursor.close()
205
+
206
+ # Aggregate: total count per day + top action
207
+ day_map: dict[str, dict] = {}
208
+ for r in rows:
209
+ day = r["day"]
210
+ if day not in day_map:
211
+ day_map[day] = {"count": 0, "top_action": r["action"]}
212
+ day_map[day]["count"] += r["cnt"]
213
+
214
+ # Fill in every calendar day (zero counts included)
215
+ result = []
216
+ if month:
217
+ days_in_month = cal.monthrange(year, month)[1]
218
+ for d in range(1, days_in_month + 1):
219
+ day_str = f"{year:04d}-{month:02d}-{d:02d}"
220
+ entry = day_map.get(day_str, {"count": 0, "top_action": None})
221
+ result.append(
222
+ {"date": day_str, "count": entry["count"], "top_action": entry["top_action"]}
223
+ )
224
+ else:
225
+ import datetime
226
+
227
+ current = datetime.date(year, 1, 1)
228
+ end_date = datetime.date(year, 12, 31)
229
+ while current <= end_date:
230
+ day_str = current.isoformat()
231
+ entry = day_map.get(day_str, {"count": 0, "top_action": None})
232
+ result.append(
233
+ {"date": day_str, "count": entry["count"], "top_action": entry["top_action"]}
234
+ )
235
+ current += datetime.timedelta(days=1)
236
+
237
+ return result
@@ -0,0 +1,183 @@
1
+ import hashlib
2
+ import os
3
+ import uuid
4
+ from datetime import UTC, datetime, timedelta
5
+
6
+ import certifi
7
+ import jwt
8
+ from api.utils.schema import Schema
9
+ from decouple import config
10
+ from pymongo import MongoClient
11
+
12
+
13
+ class MongoDBClient:
14
+ _instance = None
15
+
16
+ def __new__(cls, *args, **kwargs):
17
+ if not cls._instance:
18
+ cls._instance = super().__new__(cls, *args, **kwargs)
19
+ mongo_uri = config("MONGO_URI")
20
+ db_name = config("MONGO_DB_NAME")
21
+ client = MongoClient(mongo_uri, tlsCAFile=certifi.where())
22
+ cls._instance.db = client[db_name]
23
+ return cls._instance
24
+
25
+
26
+ class MongoDBAuth(MongoDBClient):
27
+ def __init__(self):
28
+ MongoDBClient.__init__(self)
29
+
30
+ def create_user(self, email: str, password: str):
31
+ user_id = str(uuid.uuid4().hex)
32
+ salt = os.urandom(16).hex()
33
+ hashed = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 260000).hex()
34
+ self.db["cms_users"].insert_one(
35
+ {
36
+ "_id": user_id,
37
+ "email": email,
38
+ "password": f"{salt}${hashed}",
39
+ }
40
+ )
41
+ return user_id
42
+
43
+ def verify_id_token(self, id_token):
44
+ secret = config("JWT_SECRET_KEY")
45
+ try:
46
+ payload = jwt.decode(id_token, secret, algorithms=["HS256"])
47
+ return {
48
+ "uid": payload["uid"],
49
+ "email": payload["email"],
50
+ "email_verified": payload.get("email_verified", True),
51
+ }
52
+ except jwt.ExpiredSignatureError:
53
+ raise Exception("Token verification failed: Token has expired") from None
54
+ except jwt.InvalidTokenError as e:
55
+ raise Exception(f"Token verification failed: {str(e)}") from e
56
+
57
+ def login_user(self, email: str, password: str):
58
+ user = self.db["cms_users"].find_one({"email": email})
59
+ stored = user.get("password", "")
60
+ salt, _, hashed = stored.partition("$")
61
+ check = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 260000).hex()
62
+ if not user or check != hashed:
63
+ raise Exception("Login failed: INVALID_CREDENTIALS")
64
+
65
+ secret = config("JWT_SECRET_KEY")
66
+ now = datetime.now(tz=UTC)
67
+ payload = {
68
+ "uid": str(user["_id"]),
69
+ "email": user["email"],
70
+ "email_verified": True,
71
+ "exp": now + timedelta(hours=1),
72
+ "iat": now,
73
+ }
74
+ id_token = jwt.encode(payload, secret, algorithm="HS256")
75
+ refresh_payload = {**payload, "exp": now + timedelta(days=30)}
76
+ refresh_token = jwt.encode(refresh_payload, secret, algorithm="HS256")
77
+
78
+ return {
79
+ "idToken": id_token,
80
+ "refreshToken": refresh_token,
81
+ "localId": str(user["_id"]),
82
+ }
83
+
84
+ def get_admin_token_id(self):
85
+ admin_email = config("ADMIN_EMAIL")
86
+ admin_password = config("ADMIN_PASSWORD")
87
+ return self.login_user(admin_email, admin_password).get("idToken")
88
+
89
+
90
+ class MongoDBData(MongoDBClient, Schema):
91
+ def __init__(self):
92
+ MongoDBClient.__init__(self)
93
+ schema_data = list(self.db["cms_schema"].find())
94
+ schema_collections_data = list(self.db["cms_schema_collections"].find())
95
+ Schema.__init__(self, schema_data, schema_collections_data)
96
+
97
+ def get_schema(self, raw_schema_data: list):
98
+ return {str(doc["_id"]): doc.get("data", {}) for doc in raw_schema_data}
99
+
100
+ async def fetch_document_data(self, workspace_name, collection_id, document_id):
101
+ doc = self.db["cms_workspace_data"].find_one(
102
+ {
103
+ "workspace_name": workspace_name,
104
+ "collection_id": collection_id,
105
+ "document_id": document_id,
106
+ }
107
+ )
108
+ return doc["data"] if doc else None
109
+
110
+ def set_document_data(self, path, data):
111
+ parts = path.strip("/").split("/")
112
+
113
+ if parts[0] == "_schema" and len(parts) == 2:
114
+ self.db["cms_schema"].replace_one(
115
+ {"_id": parts[1]}, {"_id": parts[1], "data": data}, upsert=True
116
+ )
117
+ elif parts[0] == "_schema_collections" and len(parts) == 2:
118
+ self.db["cms_schema_collections"].replace_one(
119
+ {"_id": parts[1]}, {"_id": parts[1], "data": data}, upsert=True
120
+ )
121
+ elif parts[0] == "_workspace" and len(parts) == 4:
122
+ workspace_name, collection_id, document_id = parts[1], parts[2], parts[3]
123
+ self.db["cms_workspace_data"].replace_one(
124
+ {
125
+ "workspace_name": workspace_name,
126
+ "collection_id": collection_id,
127
+ "document_id": document_id,
128
+ },
129
+ {
130
+ "workspace_name": workspace_name,
131
+ "collection_id": collection_id,
132
+ "document_id": document_id,
133
+ "data": data,
134
+ },
135
+ upsert=True,
136
+ )
137
+
138
+ def delete_document_data(self, path):
139
+ parts = path.strip("/").split("/")
140
+
141
+ if parts[0] == "_schema" and len(parts) == 2:
142
+ self.db["cms_schema"].delete_one({"_id": parts[1]})
143
+ elif parts[0] == "_schema_collections" and len(parts) == 2:
144
+ self.db["cms_schema_collections"].delete_one({"_id": parts[1]})
145
+ elif parts[0] == "_workspace" and len(parts) == 4:
146
+ workspace_name, collection_id, document_id = parts[1], parts[2], parts[3]
147
+ self.db["cms_workspace_data"].delete_one(
148
+ {
149
+ "workspace_name": workspace_name,
150
+ "collection_id": collection_id,
151
+ "document_id": document_id,
152
+ }
153
+ )
154
+
155
+ def delete_collection_data(self, path):
156
+ parts = path.strip("/").split("/")
157
+ if parts[0] != "_workspace" or len(parts) != 3:
158
+ return
159
+ workspace_name, collection_id = parts[1], parts[2]
160
+ self.db["cms_workspace_data"].delete_many(
161
+ {
162
+ "workspace_name": workspace_name,
163
+ "collection_id": collection_id,
164
+ }
165
+ )
166
+
167
+ @staticmethod
168
+ def get_realtime_content(path):
169
+ client = MongoDBClient()
170
+ doc = client.db["cms_realtime"].find_one({"_id": path})
171
+ return doc["data"] if doc else None
172
+
173
+ @staticmethod
174
+ def set_realtime_content(path, value):
175
+ client = MongoDBClient()
176
+ client.db["cms_realtime"].replace_one(
177
+ {"_id": path}, {"_id": path, "data": value}, upsert=True
178
+ )
179
+
180
+ @staticmethod
181
+ def delete_realtime_content(path):
182
+ client = MongoDBClient()
183
+ client.db["cms_realtime"].delete_one({"_id": path})