queryapigate 0.5.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.

Potentially problematic release.


This version of queryapigate might be problematic. Click here for more details.

queryapigate/store.py ADDED
@@ -0,0 +1,433 @@
1
+ """Persistence: the connection registry and the saved-query files (plain JSON on disk).
2
+
3
+ Everything that touches those files goes through here so that access stays confined to the
4
+ configured home folder and read-modify-write cycles are serialised.
5
+ """
6
+ import json
7
+ import os
8
+ import re
9
+ import threading
10
+ import uuid
11
+ from datetime import datetime
12
+
13
+ from . import config
14
+ from .errors import ApiError
15
+
16
+ # Serialises read-modify-write cycles on the JSON files this app manages.
17
+ lock = threading.RLock()
18
+
19
+ _ENV_REF_RE = re.compile(r'\$\{(\w+)\}')
20
+ _FILENAME_RE = re.compile(r'^[\w .\-]{1,100}$')
21
+ _ENC_PREFIX = 'enc:' # marks a connection password as Fernet-encrypted - see _encrypt_password()
22
+
23
+
24
+ def write_json_atomic(path, data):
25
+ tmp_path = f'{path}.{uuid.uuid4().hex}.tmp'
26
+ with open(tmp_path, 'w') as f:
27
+ json.dump(data, f, indent=4)
28
+ os.replace(tmp_path, path)
29
+
30
+
31
+ def now():
32
+ return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
33
+
34
+
35
+ # --------------------------------------------------------------------------------------
36
+ # Audit log: a durable record of administrative changes (API keys, connections, saved
37
+ # queries) - distinct from execution_history below (query *runs*) and from the ephemeral,
38
+ # stdout-only access log (app.decorate()). See app.py's route handlers for what calls this
39
+ # and with what `changes` shape; this module only persists whatever it's given.
40
+ # --------------------------------------------------------------------------------------
41
+
42
+ def record_audit(actor, action, target, changes=None):
43
+ """Append one entry (newest last, capped at config.audit_log_limit() - same shape as execution_history
44
+ below) and, when QUERYAPIGATE_AUDIT_LOG_EXPORT_FILE is set, also append it there - a second, never-capped
45
+ copy for retention beyond audit_log.json's own rolling window. Never raises: an audit-log write failing
46
+ should not block the action it's recording, the same trade-off record_execution() already makes for
47
+ query-run history; the two writes are independently fault-tolerant so a problem with one (e.g. the
48
+ export path's directory missing) never suppresses the other."""
49
+ entry = {'timestamp': now(), 'actor': actor, 'action': action, 'target': target, 'changes': changes}
50
+ try:
51
+ with lock:
52
+ path = config.audit_log_file()
53
+ try:
54
+ with open(path, 'r') as f:
55
+ entries = json.load(f).get('entries', [])
56
+ except (FileNotFoundError, json.JSONDecodeError):
57
+ entries = []
58
+ entries.append(entry)
59
+ del entries[:-config.audit_log_limit()]
60
+ config.home().mkdir(parents=True, exist_ok=True)
61
+ write_json_atomic(path, {'entries': entries})
62
+ except OSError:
63
+ pass
64
+ export_path = config.audit_log_export_file()
65
+ if export_path is not None:
66
+ try:
67
+ with lock, open(export_path, 'a') as f:
68
+ f.write(json.dumps(entry, default=str) + '\n')
69
+ except OSError:
70
+ pass
71
+
72
+
73
+ def read_audit_log():
74
+ try:
75
+ with open(config.audit_log_file(), 'r') as f:
76
+ return json.load(f).get('entries', [])
77
+ except (FileNotFoundError, json.JSONDecodeError):
78
+ return []
79
+
80
+
81
+ # --------------------------------------------------------------------------------------
82
+ # Connections
83
+ # --------------------------------------------------------------------------------------
84
+
85
+ def read_connections():
86
+ try:
87
+ with open(config.connections_file(), 'r') as f:
88
+ data = json.load(f)
89
+ except FileNotFoundError:
90
+ return {}
91
+ return data.get('connections', {})
92
+
93
+
94
+ def _expand_env(value):
95
+ """Replace ${VAR} references in a connection value with the environment variable's content."""
96
+ if not isinstance(value, str):
97
+ return value
98
+
99
+ def replace(match):
100
+ name = match.group(1)
101
+ if name not in os.environ:
102
+ raise ApiError(f"Environment variable '{name}' referenced by the connection is not set", 500)
103
+ return os.environ[name]
104
+
105
+ return _ENV_REF_RE.sub(replace, value)
106
+
107
+
108
+ def _is_env_ref(value):
109
+ return isinstance(value, str) and bool(_ENV_REF_RE.fullmatch(value))
110
+
111
+
112
+ def _is_encrypted(value):
113
+ return isinstance(value, str) and value.startswith(_ENC_PREFIX)
114
+
115
+
116
+ def _get_fernet():
117
+ """The configured Fernet instance for connection-password encryption at rest (QUERYAPIGATE_SECRET_KEY), or
118
+ None when unset - encryption at rest is opt-in. The key's format is already validated at startup
119
+ (config.check_settings()), so a Fernet() call here should not itself fail."""
120
+ key = config.secret_key()
121
+ if not key:
122
+ return None
123
+ from cryptography.fernet import Fernet
124
+ return Fernet(key.encode('utf-8'))
125
+
126
+
127
+ def _encrypt_password(value):
128
+ """Encrypt a literal connection password for storage, if QUERYAPIGATE_SECRET_KEY is set - a ${VAR} reference
129
+ or an already-encrypted value passes through unchanged (never double-encrypted, and a ${VAR} reference
130
+ is not a secret stored in this file at all). Returns the value unchanged when no key is configured -
131
+ today's behaviour, unaffected without opting in."""
132
+ if not isinstance(value, str) or not value or _is_env_ref(value) or _is_encrypted(value):
133
+ return value
134
+ fernet = _get_fernet()
135
+ if fernet is None:
136
+ return value
137
+ return _ENC_PREFIX + fernet.encrypt(value.encode('utf-8')).decode('ascii')
138
+
139
+
140
+ def _decrypt_password(value):
141
+ """Decrypt a stored connection password for actual use, at the moment a connection is opened
142
+ (get_connection()) - never held decrypted anywhere else. Fails clearly rather than silently: a missing
143
+ or rotated QUERYAPIGATE_SECRET_KEY must not pass ciphertext to the driver, or silently fall back to treating
144
+ it as a literal, the same "fail closed, say why" precedent is_expired() already sets for a malformed
145
+ stored value that could otherwise fail dangerously quiet."""
146
+ if not _is_encrypted(value):
147
+ return value
148
+ fernet = _get_fernet()
149
+ if fernet is None:
150
+ raise ApiError("This connection's password is encrypted but QUERYAPIGATE_SECRET_KEY is not set - it "
151
+ 'cannot be decrypted', 500)
152
+ from cryptography.fernet import InvalidToken
153
+ try:
154
+ return fernet.decrypt(value[len(_ENC_PREFIX):].encode('ascii')).decode('utf-8')
155
+ except InvalidToken:
156
+ raise ApiError("This connection's password could not be decrypted - QUERYAPIGATE_SECRET_KEY may have "
157
+ 'been rotated since it was encrypted', 500) from None
158
+
159
+
160
+ def get_connection(connection_name):
161
+ """Return the usable (active, env-expanded, password-decrypted) details of a named connection."""
162
+ if not isinstance(connection_name, str) or not connection_name:
163
+ raise ApiError('Connection name is missing')
164
+ details = read_connections().get(connection_name)
165
+ if not details:
166
+ raise ApiError(f"Connection '{connection_name}' not found", 404)
167
+ if not details.get('active', False):
168
+ raise ApiError(f"The connection '{connection_name}' is currently not active. "
169
+ "To use it, it must be set to active.", 403)
170
+ if details.get('db') not in config.SUPPORTED_DB_TYPES:
171
+ raise ApiError('Unsupported database type')
172
+ resolved = {key: _expand_env(value) for key, value in details.items()}
173
+ if 'password' in resolved:
174
+ resolved['password'] = _decrypt_password(resolved['password'])
175
+ return resolved
176
+
177
+
178
+ def _is_plaintext_password(password):
179
+ """A literal, unprotected password - not a ${VAR} reference and not already encrypted at rest. Used
180
+ only for the startup warning and the auto-migration on start (plaintext_password_connections(),
181
+ encrypt_plaintext_passwords_in_place()) - see mask_passwords() below for the broader "is this a real
182
+ secret value" check used for API responses, which masks an encrypted value too."""
183
+ return bool(password) and not _is_env_ref(password) and not _is_encrypted(password)
184
+
185
+
186
+ def mask_passwords(connections):
187
+ """Hide any real secret value in API responses - a literal password and an encrypted one both mask to
188
+ the same PASSWORD_MASK; only a ${VAR} reference (never a secret itself, just a pointer to one) is shown
189
+ as written."""
190
+ def mask(conn):
191
+ password = conn.get('password')
192
+ if password and not _is_env_ref(password):
193
+ return {**conn, 'password': config.PASSWORD_MASK}
194
+ return conn
195
+
196
+ return {name: mask(conn) for name, conn in connections.items()}
197
+
198
+
199
+ def plaintext_password_connections():
200
+ """Names of connections whose password is a literal string on disk rather than a ${VAR} reference to
201
+ an environment variable *or* already encrypted at rest - used only for the startup warning in
202
+ app.create_app(). An empty password (common for local/dev databases with none) is not flagged."""
203
+ return sorted(name for name, conn in read_connections().items() if _is_plaintext_password(conn.get('password')))
204
+
205
+
206
+ def encrypt_plaintext_passwords_in_place():
207
+ """Called once at startup when QUERYAPIGATE_SECRET_KEY is set: encrypts any connection password that's still
208
+ a literal, so a connection saved before the key existed benefits immediately rather than waiting for its
209
+ next PATCH /connections - "don't require a one-time manual migration step" from the start."""
210
+ with lock:
211
+ connections = read_connections()
212
+ changed = False
213
+ for details in connections.values():
214
+ password = details.get('password')
215
+ if _is_plaintext_password(password):
216
+ details['password'] = _encrypt_password(password)
217
+ changed = True
218
+ if changed:
219
+ write_json_atomic(config.connections_file(), {'connections': connections})
220
+
221
+
222
+ def encrypted_password_connections():
223
+ """Names of connections whose password is encrypted at rest - used only for the startup warning that
224
+ fires when QUERYAPIGATE_SECRET_KEY is missing but encrypted passwords already exist on disk (a rotated-out
225
+ or removed key, most likely): those connections cannot be used until the key is restored."""
226
+ return sorted(name for name, conn in read_connections().items() if _is_encrypted(conn.get('password')))
227
+
228
+
229
+ def update_connections(connections):
230
+ for name, details in connections.items():
231
+ if not isinstance(details, dict) or details.get('db') not in config.SUPPORTED_DB_TYPES:
232
+ raise ApiError(f"Connection '{name}' must be an object whose 'db' is one of: "
233
+ f"{', '.join(config.SUPPORTED_DB_TYPES)}")
234
+ with lock:
235
+ existing = read_connections()
236
+ for name, details in connections.items():
237
+ # GET masks passwords, so a client echoing the mask back means "keep the current one" - reused
238
+ # exactly as stored (already encrypted, if it was), never re-encrypted.
239
+ if details.get('password') == config.PASSWORD_MASK and name in existing:
240
+ details = {**details, 'password': existing[name].get('password', '')}
241
+ elif 'password' in details:
242
+ # A genuinely new literal password (or ${VAR} reference, which _encrypt_password() passes
243
+ # through unchanged) - encrypted here if QUERYAPIGATE_SECRET_KEY is set, stored as given otherwise.
244
+ details = {**details, 'password': _encrypt_password(details['password'])}
245
+ existing[name] = details
246
+ config.home().mkdir(parents=True, exist_ok=True)
247
+ write_json_atomic(config.connections_file(), {'connections': existing})
248
+
249
+
250
+ def delete_connection(name):
251
+ with lock:
252
+ existing = read_connections()
253
+ if name not in existing:
254
+ raise ApiError(f"Connection '{name}' not found", 404)
255
+ del existing[name]
256
+ write_json_atomic(config.connections_file(), {'connections': existing})
257
+
258
+
259
+ # --------------------------------------------------------------------------------------
260
+ # Saved queries (all access is confined to the saved_sql folder)
261
+ # --------------------------------------------------------------------------------------
262
+
263
+ def saved_path_for_name(filename):
264
+ """Path of the saved-query file for a client-supplied name (without extension)."""
265
+ if not isinstance(filename, str) or not _FILENAME_RE.match(filename) or filename.startswith('.'):
266
+ raise ApiError("Filename may only contain letters, digits, spaces, '.', '_' and '-'")
267
+ return os.path.join(config.saved_sql_dir(), f'{filename}.json')
268
+
269
+
270
+ def resolve_saved_file(ref):
271
+ """Resolve a client-supplied file reference to an existing .json file inside the saved_sql folder.
272
+
273
+ Accepts a bare name ("cht" or "cht.json"), a path relative to the home folder
274
+ ("saved_sql/cht.json") or an absolute path - as long as it ends up in the saved_sql folder.
275
+ """
276
+ if not isinstance(ref, str) or not ref.strip():
277
+ raise ApiError('Filename is missing')
278
+ saved_dir = str(config.saved_sql_dir())
279
+ if os.path.isabs(ref):
280
+ candidate = ref
281
+ elif os.sep in ref or '/' in ref:
282
+ candidate = os.path.join(config.home(), ref)
283
+ else:
284
+ candidate = os.path.join(saved_dir, ref if ref.endswith('.json') else f'{ref}.json')
285
+ candidate = os.path.realpath(candidate)
286
+ if os.path.dirname(candidate) != os.path.realpath(saved_dir) or not candidate.endswith('.json'):
287
+ raise ApiError('Only .json files inside the saved_sql folder can be accessed', 403)
288
+ if not os.path.isfile(candidate):
289
+ raise ApiError('File not found', 404)
290
+ return candidate
291
+
292
+
293
+ def query_name(path):
294
+ """The canonical saved-query name for a path resolve_saved_file() returned - the same string an admin
295
+ types into an API key's ``queries`` grant (see apikeys.py), regardless of which of resolve_saved_file()'s
296
+ accepted reference forms (bare name, relative path, absolute path) the caller originally used."""
297
+ return os.path.splitext(os.path.basename(path))[0]
298
+
299
+
300
+ def load_versions(path):
301
+ try:
302
+ with open(path, 'r') as f:
303
+ content = json.load(f)
304
+ except json.JSONDecodeError:
305
+ raise ApiError('Saved query file is not valid JSON', 500) from None
306
+ if not isinstance(content, dict):
307
+ raise ApiError('Saved query file has an unexpected structure', 500)
308
+ return content
309
+
310
+
311
+ def version_numbers(content):
312
+ return [int(v) for v in content if v.isdigit()]
313
+
314
+
315
+ def select_version(content, version=None):
316
+ """Return (number, data) for the requested version, or the latest one when none is given."""
317
+ if version is None:
318
+ numbers = version_numbers(content)
319
+ if not numbers:
320
+ raise ApiError('No valid versions found in the file')
321
+ version = max(numbers)
322
+ data = content.get(str(version))
323
+ if not isinstance(data, dict):
324
+ raise ApiError(f'Version {version} not found', 404)
325
+ return int(version), data
326
+
327
+
328
+ def save_version(filename, fields):
329
+ """Store ``fields`` as the next version of a saved query; returns (uuid, version number)."""
330
+ path = saved_path_for_name(filename)
331
+ query_uuid = str(uuid.uuid4())
332
+ timestamp = now()
333
+ with lock:
334
+ os.makedirs(config.saved_sql_dir(), exist_ok=True)
335
+ versions = load_versions(path) if os.path.exists(path) else {}
336
+ number = max(version_numbers(versions), default=0) + 1
337
+ versions[str(number)] = {
338
+ 'uuid': query_uuid,
339
+ **fields,
340
+ 'created_at': timestamp,
341
+ 'last_modified_at': timestamp,
342
+ 'status': 'active',
343
+ 'version': number,
344
+ 'execution_history': [],
345
+ }
346
+ write_json_atomic(path, versions)
347
+ return query_uuid, number
348
+
349
+
350
+ def delete_saved(ref, version=None):
351
+ """Delete a saved query file, or a single version of it (the file goes with its last version)."""
352
+ path = resolve_saved_file(ref)
353
+ with lock:
354
+ if version is None:
355
+ os.remove(path)
356
+ return
357
+ content = load_versions(path)
358
+ if str(version) not in content:
359
+ raise ApiError(f'Version {version} not found', 404)
360
+ del content[str(version)]
361
+ if version_numbers(content):
362
+ write_json_atomic(path, content)
363
+ else:
364
+ os.remove(path)
365
+
366
+
367
+ def record_execution(path, version, entry):
368
+ """Append to a saved version's execution_history (newest last, capped). Never raises."""
369
+ try:
370
+ with lock:
371
+ content = load_versions(path)
372
+ data = content.get(str(version))
373
+ if not isinstance(data, dict):
374
+ return
375
+ history = data.setdefault('execution_history', [])
376
+ history.append(entry)
377
+ del history[:-config.HISTORY_LIMIT]
378
+ write_json_atomic(path, content)
379
+ except (OSError, ApiError):
380
+ pass
381
+
382
+
383
+ def latest_versions():
384
+ """(name, version number, data) for the newest version of every saved query; unreadable files are skipped."""
385
+ saved_dir = str(config.saved_sql_dir())
386
+ if not os.path.isdir(saved_dir):
387
+ return []
388
+ found = []
389
+ for filename in sorted(os.listdir(saved_dir)):
390
+ path = os.path.join(saved_dir, filename)
391
+ if not (filename.endswith('.json') and os.path.isfile(path)):
392
+ continue
393
+ try:
394
+ number, data = select_version(load_versions(path))
395
+ except ApiError:
396
+ continue
397
+ found.append((filename[:-5], number, data))
398
+ return found
399
+
400
+
401
+ def list_saved():
402
+ """Return every saved query with its versions' metadata (SQL text is not included).
403
+
404
+ A saved_sql folder that does not exist yet (nothing has ever been saved) is an empty list, not an
405
+ error - consistent with latest_versions() above and with how a list endpoint should behave.
406
+ """
407
+ saved_dir = str(config.saved_sql_dir())
408
+ if not os.path.isdir(saved_dir):
409
+ return []
410
+ files = []
411
+ for filename in os.listdir(saved_dir):
412
+ file_path = os.path.join(saved_dir, filename)
413
+ if not (os.path.isfile(file_path) and filename.endswith('.json')):
414
+ continue
415
+ try:
416
+ content = load_versions(file_path)
417
+ except ApiError:
418
+ continue
419
+ versions = [{
420
+ 'version': int(version),
421
+ 'author': data.get('author'),
422
+ 'description': data.get('description'),
423
+ 'tags': data.get('tags', []),
424
+ 'query_parameters': data.get('query_parameters', {}),
425
+ 'connection_name': data.get('connection_name'),
426
+ 'created_at': data.get('created_at'),
427
+ 'last_modified_at': data.get('last_modified_at'),
428
+ 'status': data.get('status'),
429
+ 'execution_history': data.get('execution_history', []),
430
+ } for version, data in content.items() if version.isdigit() and isinstance(data, dict)]
431
+ versions.sort(key=lambda v: v['version'])
432
+ files.append({'filename': filename[:-5], 'versions': versions})
433
+ return files