jaaql-middleware-python 5.2.7__py3-none-any.whl → 5.3.2__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.
jaaql/constants.py CHANGED
@@ -187,6 +187,13 @@ ENDPOINT__internal_applications = "/internal/applications"
187
187
  ENDPOINT__internal_templates = "/internal/emails/templates"
188
188
  ENDPOINT__internal_accounts = "/internal/emails/accounts"
189
189
  ENDPOINT__is_alive = "/internal/is-alive"
190
+ ENDPOINT__deep_health = "/internal/deep-health"
191
+
192
+ # Synthetic query the BATON microcompiler injects into every queries.json so
193
+ # deep_health() can resolve a query through the cache. Must match the microcompiler
194
+ # constant HEALTH_QUERY_CACHE_KEY (index is always 0).
195
+ QUERY_CACHE_KEY__health = "__health__"
196
+ QUERY_CACHE_REF__health = QUERY_CACHE_KEY__health + ":0"
190
197
  ENDPOINT__report_sentinel_error = "/sentinel/reporting/error"
191
198
  ENDPOINT__install = "/internal/install"
192
199
  ENDPOINT__set_shared_var = "/set-shared-var"
@@ -211,5 +218,5 @@ ROLE__dba = "dba"
211
218
 
212
219
  PROTOCOL__postgres = "postgresql://"
213
220
 
214
- VERSION = "5.2.7"
221
+ VERSION = "5.3.2"
215
222
 
jaaql/db/db_interface.py CHANGED
@@ -125,7 +125,7 @@ class DBInterface(ABC):
125
125
  pass
126
126
 
127
127
  def execute_query_fetching_results(self, conn, query, parameters=None, echo=ECHO__none, as_objects=False, wait_hook: queue.Queue = None,
128
- requires_dba_check: bool = False):
128
+ requires_dba_check: bool = False, prepare: bool = False):
129
129
  if echo not in ECHO__allowed:
130
130
  allowed_echoes = ", ".join([str(allowed_echo) for allowed_echo in ECHO__allowed])
131
131
  raise HttpStatusException(ERR__unknown_echo % (str(echo), allowed_echoes), HTTPStatus.BAD_REQUEST)
@@ -149,7 +149,7 @@ class DBInterface(ABC):
149
149
  if wait_hook:
150
150
  wait_hook = None
151
151
 
152
- columns, type_codes, rows = self.execute_query(conn, query, new_parameters, wait_hook)
152
+ columns, type_codes, rows = self.execute_query(conn, query, new_parameters, wait_hook, prepare=prepare)
153
153
 
154
154
  ret = {
155
155
  RET__columns: columns,
@@ -225,7 +225,7 @@ class DBInterface(ABC):
225
225
  pass
226
226
 
227
227
  @abstractmethod
228
- def execute_query(self, conn, query, parameters: Optional[dict] = None, wait_hook: queue.Queue = None):
228
+ def execute_query(self, conn, query, parameters: Optional[dict] = None, wait_hook: queue.Queue = None, prepare: bool = False):
229
229
  pass
230
230
 
231
231
  @abstractmethod
@@ -1,5 +1,6 @@
1
1
  import uuid
2
2
 
3
+ import psycopg
3
4
  from psycopg import OperationalError
4
5
  from psycopg_pool import ConnectionPool, PoolClosed
5
6
  import queue
@@ -27,6 +28,65 @@ ERR__must_use_canned_query = "Must use canned query as you are not an admin!"
27
28
  QUERY__dba_query = "SELECT pg_has_role(datdba::regrole, 'MEMBER') FROM pg_database WHERE datname = %(database)s;"
28
29
  QUERY__dba_query_external = "SELECT pg_has_role(datdba::regrole, 'MEMBER') FROM pg_database WHERE datname = current_database();"
29
30
 
31
+ try:
32
+ # Pipeline mode needs libpq >= 14. create_app refuses to boot when this is False so a server
33
+ # can never silently degrade; the sequential fallback in execute_query exists only for
34
+ # non-server tooling that imports this module directly
35
+ PIPELINE_SUPPORTED = psycopg.Pipeline.is_supported()
36
+ except Exception:
37
+ PIPELINE_SUPPORTED = False
38
+
39
+
40
+ class JaaqlPGConnection(psycopg.Connection):
41
+ """
42
+ Connection whose per-checkout session-authorization statements (jaaql__set_session_authorization
43
+ / SET ROLE) are deferred at checkout and sent pipelined with the first real query, saving a
44
+ network round trip per checkout.
45
+
46
+ Safety property: authorization must never be skippable. Any cursor obtained through the normal
47
+ cursor() call while statements are still pending executes them eagerly first, so raw connection
48
+ usage cannot run as the pool user. execute_query claims the statements via
49
+ jaaql_take_pending_auth and is then responsible for sending them before (or batched with) the
50
+ query it executes.
51
+ """
52
+
53
+ _jaaql_pending_auth = None
54
+ _jaaql_flushing = False
55
+
56
+ def jaaql_set_pending_auth(self, statements: list):
57
+ self._jaaql_pending_auth = statements
58
+
59
+ def jaaql_has_pending_auth(self) -> bool:
60
+ return self._jaaql_pending_auth is not None
61
+
62
+ def jaaql_take_pending_auth(self):
63
+ pending, self._jaaql_pending_auth = self._jaaql_pending_auth, None
64
+ return pending
65
+
66
+ def jaaql_raw_cursor(self, *args, **kwargs):
67
+ # Bypasses the pending-authorization flush; the caller has taken responsibility for
68
+ # executing the pending statements itself
69
+ return super().cursor(*args, **kwargs)
70
+
71
+ def cursor(self, *args, **kwargs):
72
+ if self._jaaql_pending_auth is not None and not self._jaaql_flushing:
73
+ self._jaaql_flushing = True
74
+ try:
75
+ pending = self.jaaql_take_pending_auth()
76
+ with super().cursor() as flush_cursor:
77
+ for statement in pending:
78
+ flush_cursor.execute(statement)
79
+ finally:
80
+ self._jaaql_flushing = False
81
+ return super().cursor(*args, **kwargs)
82
+
83
+
84
+ def _statement_is_preparable(query: str) -> bool:
85
+ # Server-side PREPARE accepts a single statement only. A ';' anywhere except trailing means the
86
+ # text may hold multiple statements (a ';' inside a string literal merely skips the
87
+ # optimisation, which is the safe direction)
88
+ return ";" not in query.rstrip().rstrip(";")
89
+
30
90
 
31
91
  def _escape_unescaped_percent(query: str) -> str:
32
92
  # psycopg3 scans every '%' in the SQL when parameters are supplied and
@@ -75,25 +135,35 @@ class DBPGInterface(DBInterface):
75
135
  DBPGInterface.HOST_POOLS = {}
76
136
  DBPGInterface.HOST_POOLS_QUEUES = {}
77
137
 
138
+ @staticmethod
139
+ def _process_returned_conn(username: str, db_name: str, conn, do_reset: bool):
140
+ if isinstance(conn, JaaqlPGConnection) and conn.jaaql_has_pending_auth():
141
+ # The deferred authorization statements were never sent, so the session still runs as
142
+ # the pool user and there is nothing to reset. Clearing them here also stops the
143
+ # cursor() below from flushing them
144
+ conn.jaaql_take_pending_auth()
145
+ do_reset = False
146
+ if do_reset:
147
+ with conn.cursor() as cursor:
148
+ cursor.execute("RESET ROLE;")
149
+ did_close = False
150
+ if hasattr(conn, "jaaql_reset_key"):
151
+ try:
152
+ cursor.execute("SELECT jaaql_extension.jaaql__reset_session_authorization('" + str(conn.jaaql_reset_key) + "');")
153
+ except:
154
+ did_close = True
155
+ conn.close()
156
+ if not did_close:
157
+ cursor.execute("RESET ALL;")
158
+ conn.commit()
159
+ DBPGInterface.HOST_POOLS[username][db_name].putconn(conn)
160
+
78
161
  @staticmethod
79
162
  def put_conn_threaded(username: str, db_name: str, the_queue: queue.Queue):
80
163
  while True:
81
164
  try:
82
165
  conn, do_reset = the_queue.get()
83
- if do_reset:
84
- with conn.cursor() as cursor:
85
- cursor.execute("RESET ROLE;")
86
- did_close = False
87
- if hasattr(conn, "jaaql_reset_key"):
88
- try:
89
- cursor.execute("SELECT jaaql_extension.jaaql__reset_session_authorization('" + str(conn.jaaql_reset_key) + "');")
90
- except:
91
- did_close = True
92
- conn.close()
93
- if not did_close:
94
- cursor.execute("RESET ALL;")
95
- conn.commit()
96
- DBPGInterface.HOST_POOLS[username][db_name].putconn(conn)
166
+ DBPGInterface._process_returned_conn(username, db_name, conn, do_reset)
97
167
  except Exception:
98
168
  pass # Ignore, pool has likely been wiped
99
169
 
@@ -132,7 +202,8 @@ class DBPGInterface(DBInterface):
132
202
  self.conn_str = conn_str
133
203
 
134
204
  if self.db_name not in user_pool:
135
- the_pool = ConnectionPool(conn_str, min_size=PGCONN__min_conns, max_size=PGCONN__max_conns, max_lifetime=60 * 30)
205
+ the_pool = ConnectionPool(conn_str, min_size=PGCONN__min_conns, max_size=PGCONN__max_conns, max_lifetime=60 * 30,
206
+ connection_class=JaaqlPGConnection)
136
207
  the_pool.getconn(timeout=TIMEOUT)
137
208
  user_pool[self.db_name] = the_pool
138
209
  the_queue = queue.Queue()
@@ -147,49 +218,33 @@ class DBPGInterface(DBInterface):
147
218
  else:
148
219
  raise HttpStatusException(str(ex))
149
220
 
150
- def _get_conn(self, allow_operational: bool = True):
221
+ def _get_conn(self):
151
222
  conn = DBPGInterface.HOST_POOLS[self.username][self.db_name].getconn(timeout=TIMEOUT)
152
223
  conn.jaaql_reset_key = str(uuid.uuid4())
224
+ if isinstance(conn, JaaqlPGConnection):
225
+ # Discard any pending authorization left by a previous checkout that was returned
226
+ # without executing (defensive: every return path should already have cleared it).
227
+ # Without this a later checkout could execute a previous user's authorization
228
+ conn.jaaql_take_pending_auth()
153
229
  if self.role is not None or self.sub_role is not None:
154
- try:
155
- with conn.cursor() as cursor:
156
- if self.role is not None:
157
- try:
158
- cursor.execute("SELECT jaaql_extension.jaaql__set_session_authorization('" + self.role + "', '" + conn.jaaql_reset_key + "');")
159
- except InternalError as ex:
160
- if str(ex).startswith("role \"") and str(ex).endswith("\" does not exist"):
161
- raise HttpStatusException(ERR__invalid_token, HTTPStatus.UNAUTHORIZED)
162
- else:
163
- traceback.print_exc()
164
- raise ex
165
- except UndefinedFunction:
166
- raise HttpStatusException("Database '%s' has not been configured for usage with JAAQL. Please ask the dba to run 'configure_database_for_use_with_jaaql' from the jaaql database" % self.db_name)
167
- if self.sub_role is not None:
168
- cursor.execute("SET ROLE \"" + self.sub_role + "\"")
169
- except InvalidParameterValue:
170
- raise HttpStatusException(ERR__invalid_token, HTTPStatus.UNAUTHORIZED)
171
- except OperationalError as ex:
172
- if allow_operational:
173
- DBPGInterface.HOST_POOLS[self.username][self.db_name].close()
174
- DBPGInterface.HOST_POOLS[self.username][self.db_name] = ConnectionPool(self.conn_str, min_size=PGCONN__min_conns,
175
- max_size=PGCONN__max_conns, max_lifetime=60 * 30)
176
- try:
177
- DBPGInterface.HOST_POOLS[self.username][self.db_name].getconn(timeout=TIMEOUT)
178
- except OperationalError:
179
- self.close()
180
- raise HttpStatusException("Database \"" + self.db_name + "\" does not exist",
181
- CustomHTTPStatus.DATABASE_NO_EXIST)
182
-
183
- conn = self._get_conn(False)
184
- else:
185
- raise ex
186
-
230
+ # Deferred: sent pipelined with the first query by execute_query, or flushed eagerly by
231
+ # JaaqlPGConnection.cursor() if the connection is used rawly. Errors these statements
232
+ # raise are translated by _translate_session_auth_error at execution time; dead pool
233
+ # connections, previously detected here, are recycled by execute_query's
234
+ # OperationalError retry loop
235
+ pending = []
236
+ if self.role is not None:
237
+ pending.append("SELECT jaaql_extension.jaaql__set_session_authorization('" + self.role + "', '" + conn.jaaql_reset_key + "');")
238
+ if self.sub_role is not None:
239
+ pending.append("SET ROLE \"" + self.sub_role + "\"")
240
+ conn.jaaql_set_pending_auth(pending)
187
241
  return conn
188
242
 
189
243
  def get_pool(self):
190
244
  if self.db_name not in DBPGInterface.HOST_POOLS[self.username]:
191
245
  DBPGInterface.HOST_POOLS[self.username][self.db_name] = ConnectionPool(self.conn_str, min_size=PGCONN__min_conns,
192
- max_size=PGCONN__max_conns, max_lifetime=60 * 30)
246
+ max_size=PGCONN__max_conns, max_lifetime=60 * 30,
247
+ connection_class=JaaqlPGConnection)
193
248
  return DBPGInterface.HOST_POOLS[self.username][self.db_name]
194
249
 
195
250
  def get_conn(self, retry_closed_pool: bool = True):
@@ -199,7 +254,8 @@ class DBPGInterface(DBInterface):
199
254
  # Jaaql has likely been reinstalled, this pool has been forcibly closed
200
255
  if retry_closed_pool:
201
256
  DBPGInterface.HOST_POOLS[self.username][self.db_name] = ConnectionPool(self.conn_str, min_size=PGCONN__min_conns,
202
- max_size=PGCONN__max_conns, max_lifetime=60 * 30)
257
+ max_size=PGCONN__max_conns, max_lifetime=60 * 30,
258
+ connection_class=JaaqlPGConnection)
203
259
  conn = self.get_conn(retry_closed_pool=False)
204
260
  if not conn:
205
261
  raise ex
@@ -221,18 +277,36 @@ class DBPGInterface(DBInterface):
221
277
  DBPGInterface.HOST_POOLS_QUEUES[self.username].pop(self.db_name)
222
278
 
223
279
  def check_dba(self, conn, wait_hook: queue.Queue = None):
224
- columns, _, rows = self.execute_query(conn, QUERY__dba_query, parameters={KEY__database: self.db_name}, wait_hook=wait_hook)
280
+ columns, _, rows = self.execute_query(conn, QUERY__dba_query, parameters={KEY__database: self.db_name}, wait_hook=wait_hook, prepare=True)
225
281
  if not rows[0]:
226
282
  raise HttpStatusException(ERR__must_use_canned_query)
227
283
 
228
- def execute_query(self, conn, query, parameters=None, wait_hook: queue.Queue = None):
284
+ def _translate_session_auth_error(self, ex):
285
+ """
286
+ The deferred session-authorization statements execute batched with the first query, so
287
+ their errors surface during execute_query rather than at checkout. This translates them to
288
+ the same exceptions the eager checkout path used to raise. Returns None when the error is
289
+ not recognisably from the authorization statements (the caller re-raises it untouched, so
290
+ a query's own error keeps its normal handling)
291
+ """
292
+ if isinstance(ex, InternalError):
293
+ if str(ex).startswith("role \"") and str(ex).endswith("\" does not exist"):
294
+ return HttpStatusException(ERR__invalid_token, HTTPStatus.UNAUTHORIZED)
295
+ if isinstance(ex, UndefinedFunction) and "jaaql__set_session_authorization" in str(ex):
296
+ return HttpStatusException("Database '%s' has not been configured for usage with JAAQL. Please ask the dba to run 'configure_database_for_use_with_jaaql' from the jaaql database" % self.db_name)
297
+ if isinstance(ex, InvalidParameterValue) and "role" in str(ex).lower():
298
+ return HttpStatusException(ERR__invalid_token, HTTPStatus.UNAUTHORIZED)
299
+ return None
300
+
301
+ def execute_query(self, conn, query, parameters=None, wait_hook: queue.Queue = None, prepare: bool = False):
229
302
  x = 0
230
303
  err = None
231
304
  while x < PGCONN__max_conns:
232
305
  x += 1
233
306
  try:
234
- with conn.cursor() as cursor:
235
- do_prepare = False
307
+ cursor_factory = conn.jaaql_raw_cursor if isinstance(conn, JaaqlPGConnection) else conn.cursor
308
+ with cursor_factory() as cursor:
309
+ do_prepare = prepare and _statement_is_preparable(query)
236
310
 
237
311
  if wait_hook:
238
312
  res, err, code = wait_hook.get()
@@ -241,16 +315,65 @@ class DBPGInterface(DBInterface):
241
315
  raise Exception(err)
242
316
  raise UserUnauthorized()
243
317
 
244
- if parameters is None or len(parameters.keys()) == 0:
245
- cursor.execute(query, prepare=do_prepare)
318
+ def execute_main_query():
319
+ if parameters is None or len(parameters.keys()) == 0:
320
+ cursor.execute(query, prepare=do_prepare)
321
+ else:
322
+ cursor.execute(_escape_unescaped_percent(query), parameters, prepare=do_prepare)
323
+
324
+ # Claimed only now, after the wait_hook: if verification rejects the request,
325
+ # the statements remain pending and the putback thread discards them unsent
326
+ pending_auth = conn.jaaql_take_pending_auth() if isinstance(conn, JaaqlPGConnection) else None
327
+
328
+ if pending_auth:
329
+ auth_cursor = conn.jaaql_raw_cursor()
330
+ try:
331
+ # Pipeline mode is unsafe for two kinds of statement, so restrict it to
332
+ # single-command queries on transactional (non-autocommit) connections;
333
+ # everything else falls back to sequential execution:
334
+ # - it forces the extended protocol on every execute, which rejects a
335
+ # multi-command string ("cannot insert multiple commands into a
336
+ # prepared statement"); the sequential fallback runs the main query
337
+ # under the simple protocol that permits several commands.
338
+ # - it wraps statements in an implicit transaction, which statements
339
+ # that cannot run in a transaction block (CREATE DATABASE, VACUUM,
340
+ # CREATE INDEX CONCURRENTLY, ...) reject. JAAQL runs those with
341
+ # autocommit=True, so skipping the pipeline there keeps them standalone.
342
+ if PIPELINE_SUPPORTED and _statement_is_preparable(query) and not conn.autocommit:
343
+ with conn.pipeline():
344
+ for statement in pending_auth:
345
+ auth_cursor.execute(statement)
346
+ execute_main_query()
347
+ else:
348
+ for statement in pending_auth:
349
+ auth_cursor.execute(statement)
350
+ execute_main_query()
351
+ except (InternalError, UndefinedFunction, InvalidParameterValue) as ex:
352
+ translated = self._translate_session_auth_error(ex)
353
+ if translated is not None:
354
+ raise translated
355
+ if isinstance(ex, InternalError):
356
+ traceback.print_exc()
357
+ raise ex
358
+ finally:
359
+ try:
360
+ auth_cursor.close()
361
+ except Exception:
362
+ pass
246
363
  else:
247
- cursor.execute(_escape_unescaped_percent(query), parameters, prepare=do_prepare)
364
+ execute_main_query()
365
+
248
366
  if cursor.description is None:
249
367
  return [], [], []
250
368
  else:
251
369
  return [desc[0] for desc in cursor.description], [desc.type_code for desc in cursor.description], cursor.fetchall()
252
370
  except OperationalError as ex:
253
371
  if ex.sqlstate is None or ex.sqlstate.startswith("08") or ex.sqlstate.startswith("57"):
372
+ if isinstance(conn, JaaqlPGConnection):
373
+ # This putconn bypasses the reset queue, so make sure no unsent
374
+ # authorization statements ride back into the pool where check() or a
375
+ # later checkout could flush them as the wrong user
376
+ conn.jaaql_take_pending_auth()
254
377
  DBPGInterface.HOST_POOLS[self.username][self.db_name].putconn(conn)
255
378
  DBPGInterface.HOST_POOLS[self.username][self.db_name].check()
256
379
  conn = self.get_conn()
@@ -68,7 +68,7 @@ def get_required_db(vault, config, jaaql_connection: DBInterface, inputs: dict,
68
68
 
69
69
  def submit(vault, config, db_crypt_key, jaaql_connection: DBInterface, inputs: dict, account_id: str, verification_hook: Queue = None,
70
70
  cached_canned_query_service=None, as_objects: bool = False, singleton: bool = False, keep_alive_conn: bool = False,
71
- conn=None, interface: DBInterface = None, db_cache=None):
71
+ conn=None, interface: DBInterface = None, db_cache=None, prepare_statements: bool = False):
72
72
  if not isinstance(inputs, dict):
73
73
  raise HttpStatusException("Expected object or string input")
74
74
 
@@ -80,7 +80,7 @@ def submit(vault, config, db_crypt_key, jaaql_connection: DBInterface, inputs: d
80
80
  ).transform(inputs, skip_commit=inputs.get(KEY__read_only), wait_hook=verification_hook,
81
81
  encryption_key=db_crypt_key, conn=conn,
82
82
  canned_query_service=cached_canned_query_service, prevent_unused_parameters=prevent_unused,
83
- and_return_connection_mid_transaction=keep_alive_conn)
83
+ and_return_connection_mid_transaction=keep_alive_conn, prepare_statements=prepare_statements)
84
84
 
85
85
  if as_objects:
86
86
  ret = objectify(ret, singleton=singleton)
@@ -108,6 +108,17 @@ DOCUMENTATION__is_alive = SwaggerDocumentation(
108
108
  )
109
109
  )
110
110
 
111
+ DOCUMENTATION__deep_health = SwaggerDocumentation(
112
+ security=False,
113
+ tags="Deep Health",
114
+ methods=SwaggerMethod(
115
+ name="Deep health check",
116
+ description="Resolves a query through the compiled query cache and runs it, so a stale, partial or "
117
+ "unloaded cache fails the check (unlike is-alive, which only pings the database)",
118
+ method=REST__GET
119
+ )
120
+ )
121
+
111
122
  DOCUMENTATION__clean = SwaggerDocumentation(
112
123
  tags="Installation",
113
124
  methods=SwaggerMethod(
@@ -605,26 +616,26 @@ DOCUMENTATION__security_event = SwaggerDocumentation(
605
616
  ],
606
617
  response=RES__allow_all
607
618
  )
608
- )
609
-
610
- DOCUMENTATION__report_sentinel_error = SwaggerDocumentation(
611
- tags="Reporting",
612
- security=False, # PUBLIC: also receives self-posts from the in-core sentinel reporter (SENTINEL_URL=_)
613
- methods=SwaggerMethod(
614
- name="Report error",
615
- description="Logs an application error (user_agent + ip_address encrypted at rest) and triggers Sentinel "
616
- "alert processing. Never returns 500 (would otherwise make JAAQL report itself recursively).",
617
- method=REST__POST,
618
- body=[
619
- SwaggerArgumentResponse(name="location", description="URL/location where the error occurred", arg_type=str, example=["https://app/index.html"]),
620
- SwaggerArgumentResponse(name="source_file", description="Source file the error occurred in", arg_type=str, example=["common.js"]),
621
- SwaggerArgumentResponse(name="error_condensed", description="Condensed error (truncated to 200 chars for grouping)", arg_type=str, example=["Uncaught TypeError"]),
622
- SwaggerArgumentResponse(name="stacktrace", description="Full stacktrace", arg_type=str, example=["at <anonymous>:1:16"]),
623
- SwaggerArgumentResponse(name="version", description="Product version", arg_type=str, example=["1.2.3"]),
624
- SwaggerArgumentResponse(name="source_system", description="Originating system/application", arg_type=str, example=["JAAQL"]),
625
- SwaggerArgumentResponse(name="file_line_number", description="Line number", arg_type=int, required=False, condition="If available", example=[123]),
626
- SwaggerArgumentResponse(name="file_col_number", description="Column number", arg_type=int, required=False, condition="If available", example=[21]),
627
- SwaggerArgumentResponse(name="user_agent", description="Browser user agent (encrypted at rest). Absent on internal self-posts.", arg_type=str, required=False, condition="If present", example=["Mozilla/5.0"]),
628
- ]
629
- )
630
- )
619
+ )
620
+
621
+ DOCUMENTATION__report_sentinel_error = SwaggerDocumentation(
622
+ tags="Reporting",
623
+ security=False, # PUBLIC: also receives self-posts from the in-core sentinel reporter (SENTINEL_URL=_)
624
+ methods=SwaggerMethod(
625
+ name="Report error",
626
+ description="Logs an application error (user_agent + ip_address encrypted at rest) and triggers Sentinel "
627
+ "alert processing. Never returns 500 (would otherwise make JAAQL report itself recursively).",
628
+ method=REST__POST,
629
+ body=[
630
+ SwaggerArgumentResponse(name="location", description="URL/location where the error occurred", arg_type=str, example=["https://app/index.html"]),
631
+ SwaggerArgumentResponse(name="source_file", description="Source file the error occurred in", arg_type=str, example=["common.js"]),
632
+ SwaggerArgumentResponse(name="error_condensed", description="Condensed error (truncated to 200 chars for grouping)", arg_type=str, example=["Uncaught TypeError"]),
633
+ SwaggerArgumentResponse(name="stacktrace", description="Full stacktrace", arg_type=str, example=["at <anonymous>:1:16"]),
634
+ SwaggerArgumentResponse(name="version", description="Product version", arg_type=str, example=["1.2.3"]),
635
+ SwaggerArgumentResponse(name="source_system", description="Originating system/application", arg_type=str, example=["JAAQL"]),
636
+ SwaggerArgumentResponse(name="file_line_number", description="Line number", arg_type=int, required=False, condition="If available", example=[123]),
637
+ SwaggerArgumentResponse(name="file_col_number", description="Column number", arg_type=int, required=False, condition="If available", example=[21]),
638
+ SwaggerArgumentResponse(name="user_agent", description="Browser user agent (encrypted at rest). Absent on internal self-posts.", arg_type=str, required=False, condition="If present", example=["Mozilla/5.0"]),
639
+ ]
640
+ )
641
+ )
@@ -18,6 +18,7 @@ from jaaql.constants import KEY__position, KEY__file, KEY__application, KEY__err
18
18
  from jaaql.exceptions.jaaql_interpretable_handled_errors import DatabaseOperationalError, HandledProcedureError, UnhandledQueryError, UnhandledProcedureError, \
19
19
  SingletonExpected
20
20
  from typing import Union
21
+ from functools import lru_cache
21
22
 
22
23
 
23
24
  ERR_malformed_statement = "Malformed query, expecting string or dictionary"
@@ -112,6 +113,42 @@ class MarkerPrepare:
112
113
  self.count = count
113
114
 
114
115
 
116
+ # Statements larger than this are tokenized on every call rather than cached, bounding the
117
+ # memory the cache can hold (canned/compiled queries are far below this)
118
+ TOKEN_CACHE__max_statement_len = 16384
119
+
120
+
121
+ def _tokenize_statement_uncached(query: str, match_regex: str):
122
+ literals = []
123
+ names = []
124
+ last_index = 0
125
+ for match in re.finditer(match_regex, query):
126
+ start_pos = match.regs[0][0]
127
+ if start_pos != 0 and query[start_pos - 1] == REGEX_query_argument[0]:
128
+ continue
129
+ end_pos = match.regs[0][1]
130
+ literals.append(query[last_index:start_pos])
131
+ names.append(query[start_pos + 1:end_pos])
132
+ last_index = end_pos
133
+ literals.append(query[last_index:])
134
+ return tuple(literals), tuple(names)
135
+
136
+
137
+ _tokenize_statement_cached = lru_cache(maxsize=1024)(_tokenize_statement_uncached)
138
+
139
+
140
+ def tokenize_statement(query: str, match_regex: str):
141
+ """
142
+ Splits a statement into literal SQL fragments and the argument names found between them, so the
143
+ (backtracking-heavy) not-in-quotes argument regex runs once per distinct statement text instead
144
+ of on every request. The skip of matches directly preceded by ':' (postgres '::' casts) is part
145
+ of tokenization, mirroring the previous inline check in pre_prepare_statement.
146
+ """
147
+ if len(query) <= TOKEN_CACHE__max_statement_len:
148
+ return _tokenize_statement_cached(query, match_regex)
149
+ return _tokenize_statement_uncached(query, match_regex)
150
+
151
+
115
152
  class InterpretJAAQL:
116
153
 
117
154
  def __init__(self, db_interface: DBInterface, jaaql_db_interface: DBInterface = None):
@@ -121,7 +158,7 @@ class InterpretJAAQL:
121
158
  def transform(self, operation: Union[dict, str], conn=None, skip_commit: bool = False, wait_hook: queue.Queue = None,
122
159
  encryption_key: bytes = None, autocommit: bool = False, canned_query_service=None, prevent_unused_parameters: bool = True,
123
160
  do_prepare_only: str = False, and_return_connection_mid_transaction: bool = False, attempt_fetch_domain_types: bool = False,
124
- psql: list = None, pre_psql: str = None):
161
+ psql: list = None, pre_psql: str = None, prepare_statements: bool = False):
125
162
  if (not isinstance(operation, dict)) and (not isinstance(operation, str)):
126
163
  raise HttpStatusException(ERR_malformed_operation_type, HTTPStatus.BAD_REQUEST)
127
164
 
@@ -143,6 +180,9 @@ class InterpretJAAQL:
143
180
  ret = {}
144
181
  err = None
145
182
  query_key = None
183
+ # Keys whose statements are server-authored (canned) and single: safe and worthwhile to
184
+ # execute as postgres prepared statements so parse/plan is skipped on hot connections
185
+ canned_keys = set()
146
186
 
147
187
  if autocommit:
148
188
  conn.autocommit = autocommit
@@ -174,6 +214,7 @@ class InterpretJAAQL:
174
214
  query = {"query": {KEY_query: canned_query, KEY_assert: operation.get(KEY_assert), KEY_decrypt: operation.get(KEY_decrypt),
175
215
  KEY_parameters: {}}}
176
216
  check_required = False
217
+ canned_keys.add("query")
177
218
  else:
178
219
  is_dict_query = isinstance(query, dict)
179
220
  if not is_dict_query:
@@ -187,15 +228,19 @@ class InterpretJAAQL:
187
228
  query[key] = {KEY_query: val, KEY_assert: None, KEY_decrypt: None, KEY_parameters: {}}
188
229
  else:
189
230
  if KEY_store in val:
231
+ store_all_canned = True
190
232
  for sub_key, sub_val in val.items():
191
233
  if sub_key in [KEY_parameters, KEY_decrypt, KEY_store]:
192
234
  continue
193
235
  if isinstance(sub_val, str):
194
236
  all_replaced = False
237
+ store_all_canned = False
195
238
  else:
196
239
  canned_query = canned_query_service.get_canned_query(operation[KEY__application],
197
240
  sub_val[KEY__file], sub_val[KEY__position])
198
241
  val[sub_key] = canned_query
242
+ if store_all_canned:
243
+ canned_keys.add(key)
199
244
  if KEY_parameters not in val:
200
245
  val[KEY_parameters] = {}
201
246
  if KEY_decrypt not in val:
@@ -208,6 +253,7 @@ class InterpretJAAQL:
208
253
  else:
209
254
  fetched_query = canned_query_service.get_canned_query(operation[KEY__application],
210
255
  val[KEY_query][KEY__file], val[KEY_query][KEY__position])
256
+ canned_keys.add(key)
211
257
  query[key] = {KEY_query: fetched_query, KEY_assert: val.get(KEY_assert), KEY_decrypt: val.get(KEY_decrypt),
212
258
  KEY_parameters: val.get(KEY_parameters, {})}
213
259
  check_required = not all_replaced
@@ -465,7 +511,8 @@ ORDER BY d.column_name;
465
511
  }
466
512
  else:
467
513
  res = self.db_interface.execute_query_fetching_results(conn, last_query, found_params, wait_hook=wait_hook,
468
- requires_dba_check=check_required and canned_query_service is not None)
514
+ requires_dba_check=check_required and canned_query_service is not None,
515
+ prepare=prepare_statements or query_key in canned_keys)
469
516
 
470
517
  if do_prepare_only and not attempt_fetch_domain_types and not psql:
471
518
  self.db_interface.execute_query_fetching_results(conn, "DEALLOCATE _jaaql_query_check_" + do_prepare_only, found_params,
@@ -771,6 +818,11 @@ ORDER BY d.column_name;
771
818
  return [element]
772
819
 
773
820
  def encrypt_literals(self, query, encryption_key: bytes = None, match_regex: str = REGEX_enc_literals):
821
+ # The default pattern can only match if the literal prefix #' is present; the substring
822
+ # check is C-speed and skips the regex scan for the common case of no encrypted literals
823
+ if match_regex is REGEX_enc_literals and "#'" not in query:
824
+ return query
825
+
774
826
  new_query = ""
775
827
  last_end_match = 0
776
828
 
@@ -786,22 +838,15 @@ ORDER BY d.column_name;
786
838
  def pre_prepare_statement(self, query, parameters, match_regex: str = REGEX_query_argument, encryption_key: bytes = None,
787
839
  require_presence: bool = True, for_prepare: bool = False, skipped_singletons: list[str] = None,
788
840
  replace_with_nulls: bool = False):
789
- prepared = ""
790
- last_index = 0
841
+ literals, names = tokenize_statement(query, match_regex)
842
+ prepared_parts = []
791
843
  found_parameters = []
792
844
  found_parameter_dictionary = {}
793
845
 
794
- for match in re.finditer(match_regex, query):
846
+ for token_idx, match_str in enumerate(names):
795
847
  is_skipped_default = False
796
- start_pos = match.regs[0][0]
797
- if start_pos != 0:
798
- if query[start_pos - 1] == REGEX_query_argument[0]:
799
- continue
800
- end_pos = match.regs[0][1]
801
- match_str = query[start_pos + 1:end_pos]
802
848
 
803
- prepared += query[last_index:start_pos]
804
- last_index = end_pos
849
+ prepared_parts.append(literals[token_idx])
805
850
 
806
851
  if match_str not in parameters and for_prepare:
807
852
  parameters[match_str] = MarkerPrepare(len(parameters))
@@ -826,20 +871,20 @@ ORDER BY d.column_name;
826
871
  found_parameter_dictionary[match_str] = param
827
872
 
828
873
  if parameters[match_str] is None and not isinstance(parameters[match_str], MarkerPrepare):
829
- prepared += STATEMENT_null
874
+ prepared_parts.append(STATEMENT_null)
830
875
  else:
831
876
  if isinstance(parameters[match_str], MarkerPrepare):
832
877
  if replace_with_nulls:
833
- prepared += "NULL"
878
+ prepared_parts.append("NULL")
834
879
  else:
835
- prepared += "$" + str(int(parameters[match_str].count) + 1)
880
+ prepared_parts.append("$" + str(int(parameters[match_str].count) + 1))
836
881
  else:
837
882
  the_type = self.get_format_type(match_str, parameters[match_str])
838
883
  if is_skipped_default:
839
- prepared += MARKER_DEFAULT
884
+ prepared_parts.append(MARKER_DEFAULT)
840
885
  else:
841
- prepared += STATEMENT_argument_begin + STATEMENT_paren_open + match_str + STATEMENT_paren_close + the_type
886
+ prepared_parts.append(STATEMENT_argument_begin + STATEMENT_paren_open + match_str + STATEMENT_paren_close + the_type)
842
887
 
843
- prepared += query[last_index:]
888
+ prepared_parts.append(literals[-1])
844
889
 
845
- return prepared, found_parameter_dictionary
890
+ return STATEMENT_empty.join(prepared_parts), found_parameter_dictionary
jaaql/jaaql.py CHANGED
@@ -93,6 +93,19 @@ def create_app(override_config_path: str = None, controllers: [JAAQLControllerIn
93
93
 
94
94
  logging.getLogger("werkzeug").handlers.append(SensitiveHandler())
95
95
 
96
+ import psycopg
97
+ print("psycopg implementation: " + psycopg.pq.__impl__ + " (libpq " + str(psycopg.pq.version()) + ")")
98
+ if psycopg.pq.__impl__ == "python":
99
+ print("WARNING: psycopg is running the pure-python protocol implementation. Install psycopg[c] "
100
+ "(or psycopg[binary]) so the C implementation is used - it is significantly faster")
101
+
102
+ from jaaql.db.db_pg_interface import PIPELINE_SUPPORTED
103
+ if not PIPELINE_SUPPORTED:
104
+ print("FATAL: psycopg pipeline mode is unavailable (it requires libpq >= 14, found libpq " +
105
+ str(psycopg.pq.version()) + "). Session-authorization batching depends on it and JAAQL "
106
+ "refuses to boot degraded rather than silently fall back to per-statement round trips")
107
+ exit(1)
108
+
96
109
  config = load_config(is_gunicorn, override_config_path)
97
110
 
98
111
  mfa_name = config[CONFIG_KEY__security][CONFIG_KEY_SECURITY__mfa_label]
@@ -9,6 +9,11 @@ from werkzeug.exceptions import InternalServerError, HTTPException
9
9
  import inspect
10
10
  import json
11
11
  import requests
12
+
13
+ try:
14
+ import orjson
15
+ except ImportError:
16
+ orjson = None
12
17
  from datetime import datetime, date, time
13
18
  from jaaql.exceptions.custom_http_status import CustomHTTPStatus
14
19
  from jaaql.exceptions.jaaql_interpretable_handled_errors import UnhandledJaaqlServerError, NotYetInstalled
@@ -126,6 +131,27 @@ class JAAQLJSONProvider(DefaultJSONProvider):
126
131
  json_serial
127
132
  ) # type: ignore[assignment]
128
133
 
134
+ # Flask removed the JSON_SORT_KEYS config in 2.3, so the app-level setting of it below no
135
+ # longer has any effect; the provider attribute is what controls sorting now. Sorting is both
136
+ # unwanted (per that config) and slower, and orjson does not sort either
137
+ sort_keys = False
138
+
139
+ if orjson is not None:
140
+ # OPT_PASSTHROUGH_DATETIME routes datetime/date/time through json_serial so the wire format
141
+ # (e.g. microseconds stripped from datetimes) stays identical to the stdlib provider.
142
+ # orjson natively serializes UUIDs and dataclasses to the same output json_serial produces;
143
+ # Decimal is not natively supported so it still reaches json_serial via default=.
144
+ _ORJSON_OPTS = orjson.OPT_PASSTHROUGH_DATETIME | orjson.OPT_NON_STR_KEYS
145
+
146
+ def dumps(self, obj: t.Any, **kwargs: t.Any) -> str:
147
+ opts = self._ORJSON_OPTS
148
+ if kwargs.get("indent"):
149
+ opts |= orjson.OPT_INDENT_2
150
+ return orjson.dumps(obj, default=json_serial, option=opts).decode("utf-8")
151
+
152
+ def loads(self, s: t.Union[str, bytes], **kwargs: t.Any) -> t.Any:
153
+ return orjson.loads(s)
154
+
129
155
 
130
156
  class BaseJAAQLController:
131
157
 
@@ -460,6 +486,10 @@ class BaseJAAQLController:
460
486
  swagger_documentation = documentation_as_lists[0]
461
487
 
462
488
  def wrap_func(view_func):
489
+ # getfullargspec is expensive and the result never changes for a given view function, so
490
+ # resolve it once at registration instead of ~20 times per request
491
+ view_func_args = inspect.getfullargspec(view_func).args
492
+
463
493
  @wraps(view_func)
464
494
  def routed_function(view_func_local, **kwargs):
465
495
  if not self.model.has_installed and not route.startswith('/internal'):
@@ -540,12 +570,12 @@ class BaseJAAQLController:
540
570
  throw_ex = None
541
571
  ex_msg = None
542
572
  try:
543
- if ARG__http_inputs in inspect.getfullargspec(view_func_local).args or any([key in inspect.getfullargspec(view_func_local).args for key in kwargs.keys()]):
573
+ if ARG__http_inputs in view_func_args or any([key in view_func_args for key in kwargs.keys()]):
544
574
  validated = BaseJAAQLController.get_input_as_dictionary(the_method, self.is_prod, **kwargs)
545
- if ARG__http_inputs in inspect.getfullargspec(view_func_local).args:
575
+ if ARG__http_inputs in view_func_args:
546
576
  supply_dict[ARG__http_inputs] = validated
547
577
 
548
- if ARG__account_id in inspect.getfullargspec(view_func_local).args:
578
+ if ARG__account_id in view_func_args:
549
579
  if not swagger_documentation.security:
550
580
  raise Exception(ERR__method_required_account_id)
551
581
  supply_dict[ARG__account_id] = account_id
@@ -554,7 +584,7 @@ class BaseJAAQLController:
554
584
  supply_dict[ARG__verification_hook] = verification_hook
555
585
 
556
586
  # Do not close created interfaces as they are re-used
557
- for arg in inspect.getfullargspec(view_func_local).args:
587
+ for arg in view_func_args:
558
588
  if arg.startswith(ARG_START__connection):
559
589
  if not swagger_documentation.security:
560
590
  raise Exception(ERR__method_required_user_connection)
@@ -562,66 +592,66 @@ class BaseJAAQLController:
562
592
  supply_dict[arg] = create_interface_for_db(self.model.vault, self.model.config, account_id, connect_db,
563
593
  sub_role=account_id)
564
594
 
565
- for arg in inspect.getfullargspec(view_func_local).args:
595
+ for arg in view_func_args:
566
596
  if arg.startswith(ARG_START__jaaql_connection):
567
597
  connect_db = arg.split(ARG_START__jaaql_connection)[1]
568
598
  supply_dict[arg] = create_interface_for_db(self.model.vault, self.model.config, account_id, connect_db,
569
599
  sub_role=ROLE__jaaql)
570
600
 
571
- if ARG__username in inspect.getfullargspec(view_func_local).args:
601
+ if ARG__username in view_func_args:
572
602
  if not swagger_documentation.security:
573
603
  raise Exception(ERR__method_required_username)
574
604
  supply_dict[ARG__username] = username
575
605
 
576
- if ARG__ip_address in inspect.getfullargspec(view_func_local).args:
606
+ if ARG__ip_address in view_func_args:
577
607
  supply_dict[ARG__ip_address] = ip_addr
578
608
 
579
- if ARG__request_headers in inspect.getfullargspec(view_func_local).args:
609
+ if ARG__request_headers in view_func_args:
580
610
  supply_dict[ARG__request_headers] = request.headers
581
611
 
582
- if ARG__response in inspect.getfullargspec(view_func_local).args:
612
+ if ARG__response in view_func_args:
583
613
  supply_dict[ARG__response] = jaaql_resp
584
614
 
585
- if ARG__auth_token in inspect.getfullargspec(view_func_local).args:
615
+ if ARG__auth_token in view_func_args:
586
616
  if not swagger_documentation.security:
587
617
  raise Exception(ERR__method_required_token)
588
618
  supply_dict[ARG__auth_token] = request.headers.get(HEADER__security)
589
619
  if supply_dict[ARG__auth_token] is None:
590
620
  supply_dict[ARG__auth_token] = auth_cookie
591
621
 
592
- if ARG__auth_token_for_refresh in inspect.getfullargspec(view_func_local).args:
622
+ if ARG__auth_token_for_refresh in view_func_args:
593
623
  supply_dict[ARG__auth_token_for_refresh] = request.headers.get(HEADER__security)
594
624
  if supply_dict[ARG__auth_token_for_refresh] is None:
595
625
  supply_dict[ARG__auth_token_for_refresh] = auth_cookie
596
626
 
597
- if ARG__connection in inspect.getfullargspec(view_func_local).args:
627
+ if ARG__connection in view_func_args:
598
628
  if not swagger_documentation.security:
599
629
  raise Exception(ERR__method_required_connection)
600
630
  supply_dict[ARG__connection] = create_interface_for_db(self.model.vault, self.model.config, account_id, DB__jaaql)
601
631
 
602
- if ARG__is_the_anonymous_user in inspect.getfullargspec(view_func_local).args:
632
+ if ARG__is_the_anonymous_user in view_func_args:
603
633
  if not swagger_documentation.security:
604
634
  raise Exception(ERR__method_required_is_the_anonymous_user)
605
635
  supply_dict[ARG__is_the_anonymous_user] = is_public
606
636
 
607
637
  self.perform_profile(request_id, "Fetch args")
608
- if ARG__profiler in inspect.getfullargspec(view_func_local).args:
638
+ if ARG__profiler in view_func_args:
609
639
  supply_dict[ARG__profiler] = Profiler(request_id, self.do_profiling)
610
640
 
611
- if ARG__headers in inspect.getfullargspec(view_func_local).args:
641
+ if ARG__headers in view_func_args:
612
642
  supply_dict[ARG__headers] = dict(request.headers)
613
643
 
614
- if ARG__body in inspect.getfullargspec(view_func_local).args:
644
+ if ARG__body in view_func_args:
615
645
  supply_dict[ARG__body] = request.get_data()
616
646
 
617
- if ARG__args in inspect.getfullargspec(view_func_local).args:
647
+ if ARG__args in view_func_args:
618
648
  supply_dict[ARG__args] = dict(request.args)
619
649
 
620
- if ARG__flask_request in inspect.getfullargspec(view_func_local).args:
650
+ if ARG__flask_request in view_func_args:
621
651
  supply_dict[ARG__flask_request] = request
622
652
 
623
653
  for k, v in kwargs.items():
624
- if k in inspect.getfullargspec(view_func_local).args:
654
+ if k in view_func_args:
625
655
  supply_dict[k] = v
626
656
 
627
657
  resp = view_func_local(**supply_dict)
jaaql/mvc/base_model.py CHANGED
@@ -27,6 +27,11 @@ import time
27
27
  import os
28
28
  from os.path import join
29
29
 
30
+ # Absolute path (inside the container) of the compiled query cache written by the
31
+ # BATON microcompiler / reset_app.sh at deploy and at boot. Re-read on flush and on
32
+ # the cache-miss self-heal in Model.execute.
33
+ PATH__query_cache = "/queries/queries.json"
34
+
30
35
 
31
36
  CONFIG_KEY_security = "SECURITY"
32
37
  CONFIG_KEY_SECURITY__mfa_label = "mfa_label"
@@ -187,18 +192,38 @@ class BaseJAAQLModel:
187
192
 
188
193
  def reload_cache(self):
189
194
  print("Received cache flush instruction")
190
- if os.path.exists("/queries/queries.json"):
191
- try:
192
- self.query_caches = json.loads(open("/queries/queries.json", "r").read())
193
- for key, encoded_list in self.query_caches["queries"].items():
194
- # Replace each encoded string in the list with its decoded version.
195
- self.query_caches["queries"][key] = [
196
- base64.b64decode(encoded).decode('utf-8') for encoded in encoded_list
197
- ]
198
- self.db_cache = 1
199
- print("Loaded query cache")
200
- except:
201
- traceback.print_exc()
195
+ if not os.path.exists(PATH__query_cache):
196
+ return
197
+ try:
198
+ mtime = os.path.getmtime(PATH__query_cache)
199
+ with open(PATH__query_cache, "r") as cache_file:
200
+ loaded = json.loads(cache_file.read())
201
+ # Decode the whole cache into a fresh dict and only publish it once
202
+ # every entry has parsed and decoded. A queries.json that is truncated
203
+ # or partial (a deploy/boot still mid-write - see BATON writeAllSQL) or
204
+ # otherwise malformed must never replace an already-good cache;
205
+ # otherwise the process serves a broken cache until it is restarted.
206
+ decoded_queries = {
207
+ key: [base64.b64decode(encoded).decode("utf-8") for encoded in encoded_list]
208
+ for key, encoded_list in loaded["queries"].items()
209
+ }
210
+ loaded["queries"] = decoded_queries
211
+ self.query_caches = loaded
212
+ self.query_caches_mtime = mtime
213
+ self.db_cache = 1
214
+ print("Loaded query cache (%d files)" % len(decoded_queries))
215
+ except Exception:
216
+ # Keep the previous good cache. A later flush, or the cache-miss
217
+ # self-heal in Model.execute, will retry once queries.json is whole.
218
+ traceback.print_exc()
219
+
220
+ def query_cache_is_stale(self):
221
+ # True when queries.json on disk differs from the version currently loaded,
222
+ # i.e. a newer (or first) cache is available to (re)load.
223
+ try:
224
+ return os.path.getmtime(PATH__query_cache) != self.query_caches_mtime
225
+ except OSError:
226
+ return False
202
227
 
203
228
  def set_jaaql_lookup_connection(self):
204
229
  if self.vault.has_obj(VAULT_KEY__jaaql_lookup_connection):
@@ -216,6 +241,7 @@ class BaseJAAQLModel:
216
241
  self.is_container = is_container
217
242
 
218
243
  self.query_caches = {}
244
+ self.query_caches_mtime = None
219
245
  self.db_cache = None
220
246
 
221
247
  self.prevent_arbitrary_queries = os.environ.get("PREVENT_ARBITRARY_QUERIES", "false") == "true"
jaaql/mvc/controller.py CHANGED
@@ -71,6 +71,10 @@ class JAAQLController(BaseJAAQLController):
71
71
  def is_alive():
72
72
  self.model.is_alive()
73
73
 
74
+ @self.publish_route(ENDPOINT__deep_health, DOCUMENTATION__deep_health)
75
+ def deep_health():
76
+ self.model.deep_health()
77
+
74
78
  @self.publish_route('/accounts', DOCUMENTATION__create_account)
75
79
  def accounts(connection: DBInterface, http_inputs: dict):
76
80
  registered = http_inputs.get(KEY__registered, True)
@@ -158,8 +162,10 @@ class JAAQLController(BaseJAAQLController):
158
162
 
159
163
  @self.publish_route('/secure/<application>/<name>', DOCUMENTATION__secure_webhook)
160
164
  def handle_secure_webhook(application: str, name: str, body: bytes, headers: dict, args: dict,
161
- response: JAAQLResponse, account_id: str):
162
- self.model.handle_webhook(application, name, body, headers, args, response, account_id)
165
+ response: JAAQLResponse, username: str):
166
+ # Pass the username, not the account id: the proc runtime forwards this value as the bypass
167
+ # emulation user (dbms_user), which the server resolves via account.username
168
+ self.model.handle_webhook(application, name, body, headers, args, response, username)
163
169
 
164
170
  @self.publish_route('/remote_procedure', DOCUMENTATION__remote_procedures)
165
171
  def handle_remote_procedure(http_inputs: dict, is_the_anonymous_user: bool, auth_token: str, username: str, ip_address: str, account_id: str):
@@ -238,7 +244,7 @@ class JAAQLController(BaseJAAQLController):
238
244
  http_inputs["ip_address"] = ip_address # plaintext -> #ip_address (field-level encrypted)
239
245
  inserted = self.model.submit(
240
246
  {KEY_query: ins_error, KEY_parameters: http_inputs, KEY__application: "sentinel"},
241
- ROLE__dba, as_objects=True, singleton=True
247
+ ROLE__dba, as_objects=True, singleton=True, server_authored_query=True
242
248
  )
243
249
  self.model.submit(
244
250
  {
@@ -246,7 +252,7 @@ class JAAQLController(BaseJAAQLController):
246
252
  KEY_parameters: {"id": inserted["error_id"], "raw_ip_address": ip_address},
247
253
  KEY__application: "sentinel"
248
254
  },
249
- ROLE__dba
255
+ ROLE__dba, server_authored_query=True
250
256
  )
251
257
  except Exception as ex:
252
258
  import traceback
jaaql/mvc/model.py CHANGED
@@ -1472,6 +1472,16 @@ WHERE
1472
1472
  if "PostgreSQL " not in version:
1473
1473
  raise HttpStatusException(ERR__keep_alive_failed)
1474
1474
 
1475
+ def deep_health(self):
1476
+ # Deep, unauthenticated health check. is_alive() only pings the DB and so
1477
+ # stayed green while a stale/partial query cache broke every real request.
1478
+ # This resolves a query THROUGH the query cache - the same lookup execute()
1479
+ # performs, including the cache-miss self-heal - and runs it, so an empty,
1480
+ # partial or stale cache fails the check. The health query is injected into
1481
+ # every compiled queries.json by the BATON microcompiler.
1482
+ sql = self._lookup_cached_query(QUERY_CACHE_REF__health)
1483
+ execute_supplied_statement_singleton(self.jaaql_lookup_connection, sql, as_objects=True)
1484
+
1475
1485
  def install(self, db_connection_string: str, jaaql_password: str, super_db_password: str, install_key: str, allow_uninstall: bool,
1476
1486
  do_reboot: bool = True, jaaql_db_password: str = None):
1477
1487
  if self.jaaql_lookup_connection is None:
@@ -2254,7 +2264,7 @@ WHERE
2254
2264
  raise HttpStatusException("Could not intepret remote procedure result", HTTPStatus.INTERNAL_SERVER_ERROR)
2255
2265
 
2256
2266
  def handle_webhook(self, application: str, name: str, body: bytes, headers: dict, args: dict,
2257
- response: JAAQLResponse, account_id: str | None):
2267
+ response: JAAQLResponse, username: str | None):
2258
2268
  rpc = remote_procedure__select(self.jaaql_lookup_connection, application, name)
2259
2269
  if rpc[KG__remote_procedure__access] != RPC_ACCESS__webhook:
2260
2270
  raise HttpStatusException("Procedure type is not type webhook")
@@ -2275,17 +2285,19 @@ WHERE
2275
2285
 
2276
2286
  argv = shlex.split(base_cmd) # ["node","--import","./__imports__.js","RemoteProcedures/...entrypoint.js"]
2277
2287
  argv += [encoded_headers, encoded_args, encoded_body]
2278
- if account_id is not None:
2279
- argv += [account_id]
2288
+ if username is not None:
2289
+ argv += [username] # consumed as webhook.dbms_user: the user the proc emulates via the bypass key
2280
2290
 
2281
2291
  # No shell; pass argv list. Capture output as text.
2292
+ # Webhooks are implicit system access on the BATON side, so they need the super bypass key
2282
2293
  result = subprocess.run(
2283
2294
  argv,
2284
2295
  cwd=cwd, # None if no "cd … &&" in the command
2285
2296
  stdout=subprocess.PIPE,
2286
2297
  stderr=subprocess.PIPE,
2287
2298
  text=True,
2288
- check=False
2299
+ check=False,
2300
+ env=self.env_with_super_bypass_key()
2289
2301
  )
2290
2302
 
2291
2303
  try:
@@ -2333,6 +2345,13 @@ WHERE
2333
2345
  { **proc, KG__remote_procedure__application: inputs[KEY__application] })
2334
2346
 
2335
2347
 
2348
+ def env_with_super_bypass_key(self):
2349
+ # System crons and webhooks authenticate back to JAAQL with the super bypass key, which the BATON runtime
2350
+ # reads from this env var. get_or_seed_vault_secret scrubs the var from os.environ at startup, so spawned
2351
+ # children no longer inherit it - it must be re-injected here. Never inject it for private/public remote
2352
+ # procedures (handle_procedure): those run code on behalf of a normal user and must not hold the master key
2353
+ return {**os.environ, ENVIRON__JAAQL__SUPER_BYPASS_KEY: self.local_super_access_key}
2354
+
2336
2355
  def execute_cron_jobs(self, ip_address: str):
2337
2356
  if ip_address not in IPS__local:
2338
2357
  raise UserUnauthorized()
@@ -2349,7 +2368,8 @@ WHERE
2349
2368
  cron[KEY__command],
2350
2369
  shell=True,
2351
2370
  start_new_session=True, # detach from gunicorn worker
2352
- text=True
2371
+ text=True,
2372
+ env=self.env_with_super_bypass_key()
2353
2373
  )
2354
2374
 
2355
2375
  def get_last_successful_build_time(self):
@@ -2371,6 +2391,27 @@ WHERE
2371
2391
 
2372
2392
  os.kill(int(open("app.pid", "r").read()), signal.SIGUSR1)
2373
2393
 
2394
+ def _lookup_cached_query(self, trimmed: str):
2395
+ name = trimmed.split(":")[0]
2396
+ index = int(trimmed.split(":")[1])
2397
+ try:
2398
+ return self.query_caches["queries"][name][index]
2399
+ except (KeyError, IndexError):
2400
+ # The compiled query is absent from the in-memory cache. This happens
2401
+ # when the container loaded queries.json before / while a deploy or
2402
+ # boot was (re)writing it, leaving a stale or partial cache that then
2403
+ # serves KeyErrors on the missing queries until the process restarts.
2404
+ # If a newer queries.json is on disk, reload once and retry so the
2405
+ # process self-heals in place instead of staying wedged.
2406
+ if self.query_cache_is_stale():
2407
+ self.reload_cache()
2408
+ try:
2409
+ return self.query_caches["queries"][name][index]
2410
+ except (KeyError, IndexError):
2411
+ pass
2412
+ raise HttpStatusException("Compiled query '%s' is not present in the query cache" % name,
2413
+ response_code=HTTPStatus.INTERNAL_SERVER_ERROR)
2414
+
2374
2415
  def execute(self, inputs: dict, account_id: str, verification_hook: Queue = None, as_objects: bool = False, singleton: bool = False):
2375
2416
  if not self.query_caches:
2376
2417
  self.reload_cache()
@@ -2385,16 +2426,22 @@ WHERE
2385
2426
  self.db_cache = {itm[KG__application_schema__name]: itm for itm in schemas}
2386
2427
  print("Loaded DB Cache")
2387
2428
 
2429
+ # Snapshot the schema cache before resolving queries: a cache-miss self-heal
2430
+ # inside _lookup_cached_query calls reload_cache(), which resets self.db_cache
2431
+ # to 1. submit() expects the materialised schema dict (or None), so pass the
2432
+ # snapshot - the reload still forces a schema refetch on the next execute.
2433
+ db_cache = self.db_cache
2434
+
2388
2435
  for key, val in inputs["query"].items():
2389
2436
  if isinstance(val, dict):
2390
- trimmed = val["query"].strip()
2391
- val["query"] = self.query_caches["queries"][trimmed.split(":")[0]][int(trimmed.split(":")[1])]
2437
+ val["query"] = self._lookup_cached_query(val["query"].strip())
2392
2438
  else:
2393
- trimmed = val.strip()
2394
- inputs["query"][key] = self.query_caches["queries"][trimmed.split(":")[0]][int(trimmed.split(":")[1])]
2439
+ inputs["query"][key] = self._lookup_cached_query(val.strip())
2395
2440
 
2441
+ # Queries here come from the compiled application query cache (server-authored, single
2442
+ # statements), so let postgres execute them as prepared statements
2396
2443
  return submit(self.vault, self.config, self.get_db_crypt_key(), self.jaaql_lookup_connection, inputs, account_id, verification_hook,
2397
- self.cached_canned_query_service, as_objects=as_objects, singleton=singleton, db_cache=self.db_cache)
2444
+ self.cached_canned_query_service, as_objects=as_objects, singleton=singleton, db_cache=db_cache, prepare_statements=True)
2398
2445
 
2399
2446
  def call_proc(self, inputs: dict, account_id: str, verification_hook: Queue = None, as_objects: bool = False, singleton: bool = False):
2400
2447
  parameters = inputs["parameters"]
@@ -2434,8 +2481,11 @@ WHERE
2434
2481
  return submit(self.vault, self.config, self.get_db_crypt_key(), self.jaaql_lookup_connection, inputs, account_id, verification_hook,
2435
2482
  self.cached_canned_query_service, as_objects=as_objects, singleton=singleton, db_cache=None)
2436
2483
 
2437
- def submit(self, inputs: dict, account_id: str, verification_hook: Queue = None, as_objects: bool = False, singleton: bool = False, ip_address: str = None):
2438
- if ip_address not in IPS__local and self.prevent_arbitrary_queries:
2484
+ def submit(self, inputs: dict, account_id: str, verification_hook: Queue = None, as_objects: bool = False, singleton: bool = False, ip_address: str = None,
2485
+ server_authored_query: bool = False):
2486
+ # server_authored_query asserts the query text is a code literal, not client input. It exempts the caller from the
2487
+ # PREVENT_ARBITRARY_QUERIES guard, which exists to stop remote clients supplying their own query text
2488
+ if not server_authored_query and ip_address not in IPS__local and self.prevent_arbitrary_queries:
2439
2489
  raise UnhandledJaaqlServerError("Not allowed to send queries to server!")
2440
2490
 
2441
2491
  return submit(self.vault, self.config, self.get_db_crypt_key(), self.jaaql_lookup_connection, inputs, account_id, verification_hook,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jaaql-middleware-python
3
- Version: 5.2.7
3
+ Version: 5.3.2
4
4
  Summary: The jaaql package, allowing for rapid development and deployment of RESTful HTTP applications
5
5
  Home-page: https://github.com/JAAQL/JAAQL-middleware-python
6
6
  Author: Software Quality Measurement and Improvement bv
@@ -10,19 +10,20 @@ Description-Content-Type: text/markdown
10
10
  License-File: LICENSE.txt
11
11
  Requires-Dist: jaaql-monitor~=1.6.3
12
12
  Requires-Dist: psycopg[binary]~=3.2.13
13
- Requires-Dist: Pillow~=11.2.1
13
+ Requires-Dist: Pillow~=11.3.0
14
14
  Requires-Dist: cryptography~=44.0.2
15
15
  Requires-Dist: flask~=3.1.0
16
- Requires-Dist: coverage~=7.4.3
16
+ Requires-Dist: orjson~=3.11.9
17
+ Requires-Dist: coverage~=7.15.2
17
18
  Requires-Dist: psycopg-pool~=3.2.1
18
19
  Requires-Dist: pyjwt~=2.10.1
19
- Requires-Dist: pyyaml~=6.0.1
20
+ Requires-Dist: pyyaml~=6.0.3
20
21
  Requires-Dist: werkzeug~=3.1.3
21
22
  Requires-Dist: argon2-cffi~=23.1.0
22
23
  Requires-Dist: pyotp~=2.9.0
23
24
  Requires-Dist: qrcode~=7.4.2
24
25
  Requires-Dist: gunicorn~=23.0.0
25
- Requires-Dist: gevent~=24.11.1
26
+ Requires-Dist: gevent~=25.9.1
26
27
  Requires-Dist: requests~=2.32.3
27
28
  Requires-Dist: pyzbar~=0.1.9
28
29
  Requires-Dist: parsys-requests-unixsocket~=0.3.2
@@ -1,20 +1,20 @@
1
1
  jaaql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  jaaql/config_constants.py,sha256=puoR6jfbXw8oXXEfbYNn2ysGwgiGTyuDjOj7F8acsTE,470
3
- jaaql/constants.py,sha256=Me_r4pBC0TxJZsC4uCYdI23WIgs5YnTkuvqLWPOfUu8,6994
3
+ jaaql/constants.py,sha256=sfY4dvRV-sRuMwo6UcqD37XKFLPYNSzRD2VjM8CQNAs,7355
4
4
  jaaql/generated_constants.py,sha256=pxmpTux7rrjJH6iqLb-R8RKJ4t0CeoGdnspjH5DiWgU,11327
5
- jaaql/jaaql.py,sha256=0jTYtpDYgKRNKU889wMI495k_h-EzQuGEQqrqHNhNrg,5829
5
+ jaaql/jaaql.py,sha256=bfiEW9A8PlZIVjDJd_LOhCLBL8flc_f7w0umR6EWbU4,6614
6
6
  jaaql/patch.py,sha256=VE4B6jA492NCIxNIewbynh92jxVulmRFtB9hNEe6q94,149
7
7
  jaaql/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
8
  jaaql/config/config-docker.ini,sha256=2UNtdwHQWZhANgxnrHvtxnDW0C0MuX042IZITMXNsZc,354
9
9
  jaaql/config/config-test.ini,sha256=ap9OFqCCbYdWqmUKq0p9GD1IkM4CzeTXDV5gbQGQZoQ,283
10
10
  jaaql/config/config.ini,sha256=o1orXNYRFgf09MKzZBQ3v0oMzWn7N6KwvX5YV0WJtns,321
11
11
  jaaql/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- jaaql/db/db_interface.py,sha256=A_daGWC6hP3M1EewNuUU5o5PL_ab2EIzrm3ZDoFxddQ,7639
13
- jaaql/db/db_pg_interface.py,sha256=rzoJw_XTxIqGtuQjawNxfgRAaxQs1m1MoKMOibEK0tY,12643
12
+ jaaql/db/db_interface.py,sha256=n4pwRHeGpkbmTOvZDe8VvqFf2l4QsE9GbhUBzW335m0,7702
13
+ jaaql/db/db_pg_interface.py,sha256=3w7_uMUmZ7RedqIQ_hZry5HDtfUK9kCdazHra3TYuDQ,19597
14
14
  jaaql/db/db_utils.py,sha256=xZCdtWT9semzLL-NAiwMFx_38Wv96BBv60pfPYNDnCc,7761
15
- jaaql/db/db_utils_no_circ.py,sha256=E3jxF4O0KXUnK9yxLQYxfqDTfkAwBFJSS3sX12A1row,4593
15
+ jaaql/db/db_utils_no_circ.py,sha256=WLhk6EwsnTdLGUAjq3RZBH3FzgwEumh45A24WWysWmQ,4666
16
16
  jaaql/documentation/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
- jaaql/documentation/documentation_internal.py,sha256=ecqvo-lN1Kl0_ce-Ohj75-nZFS6GmIActJjBC-sg1pM,22404
17
+ jaaql/documentation/documentation_internal.py,sha256=_tU92TI4Sd8AXj3b7-UOKCN1h4VR7SaakIv2ATC45Bo,22783
18
18
  jaaql/documentation/documentation_public.py,sha256=XU1Smw-1heWhCs-FDqTCTVCkAI2COHEy6fk2fhrwFN4,10932
19
19
  jaaql/documentation/documentation_shared.py,sha256=gTw3rT2hC6LTlJMXnwmxhSTh3xUetIJPtIhldptXF84,6654
20
20
  jaaql/email/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -27,18 +27,18 @@ jaaql/exceptions/http_status_exception.py,sha256=rTFegFibZ9C_G3BzdLCACOkZc3k1VNS
27
27
  jaaql/exceptions/jaaql_interpretable_handled_errors.py,sha256=846c3MLeQ9VQjs9f4WM6H4rszh4tEGDLgBRd8nEMCc8,11180
28
28
  jaaql/exceptions/not_yet_implement_exception.py,sha256=T7QC-I7ciWuFFp7C_jIJRMDlpshVdy_01bVuEnu-Txs,321
29
29
  jaaql/interpreter/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
- jaaql/interpreter/interpret_jaaql.py,sha256=MezWeC76eaQrVLkpQlN93xFfxeaIebvVfom6-r9Dl-o,39156
30
+ jaaql/interpreter/interpret_jaaql.py,sha256=UQUukf5xMyN_IWNVpZtO058QW5a0VPuH25TVnEEgGUk,41510
31
31
  jaaql/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
32
32
  jaaql/migrations/migrations.py,sha256=XE46AzNmfAg9qiatIL_6ctjqiiuVMHUNEcVHhmuh4hA,4195
33
33
  jaaql/mvc/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
- jaaql/mvc/base_controller.py,sha256=FCBX6pVkUh3yeLHWbw_2ht2G0L2L3vTdk2T_qcC47cI,39102
35
- jaaql/mvc/base_model.py,sha256=ovNPYpd6TmJfC7qSAAxVXeejv5qVBC2Ht8qAoNR5pxE,15609
36
- jaaql/mvc/controller.py,sha256=lnZBYmwzJd81bdiCkic1asOa_2veEr0U-M_vNFFTS9w,14405
34
+ jaaql/mvc/base_controller.py,sha256=I2EzDz3ace2f0Kjp9GNJk4A2cupKV4xINMZKbTJyYjI,39976
35
+ jaaql/mvc/base_model.py,sha256=SJ2HOFNIqxhmNVIkt_zVtomVcwPBT3mxU8xCRbc7aVs,16916
36
+ jaaql/mvc/controller.py,sha256=UV4hWAFeTrpRjLgPQSKTRxrGMIrycwM6hi7ulfqk03o,14794
37
37
  jaaql/mvc/controller_interface.py,sha256=Wx8fA4lrqb_PPwkLNjJDxDloHB5JxmNt1Gv3SsDFqT4,274
38
38
  jaaql/mvc/exception_queries.py,sha256=lF65cj9KgJgRvyzxDjPDM05rHFSHF0jy1V8B77H9Rvo,7899
39
39
  jaaql/mvc/generated_queries.py,sha256=z1Jsyu-Y6D-Xd8LYvefzwv8C01NaBjHaHI7mcvv-WEk,72232
40
40
  jaaql/mvc/handmade_queries.py,sha256=kyXZ7L_AoxfcPR4u3r5So8khlRD6_sGQ39JnSAHDsEA,938
41
- jaaql/mvc/model.py,sha256=p_gAw5_0GAb14PPKs1O99mRfYjtL7f3BVqyaMLcgKT4,129446
41
+ jaaql/mvc/model.py,sha256=NAKI651kg8HdMY-4IaxkZbussKRo0b-M34cT3Bz2l-U,132860
42
42
  jaaql/mvc/model_interface.py,sha256=YgAjaAiIQL1UtIJYgvkl_C3r6PVo-8Z3MXtwzyIwKnc,257
43
43
  jaaql/mvc/response.py,sha256=F4WvR9h1JcWF3Y6cj3n5HpVIqnwiMpNqfE8D29ZHbSE,860
44
44
  jaaql/openapi/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -66,8 +66,8 @@ jaaql/utilities/options.py,sha256=75M7-oSeY4wmHGz3-su_c36yf_Csv9HxvTG7aRlGR0A,49
66
66
  jaaql/utilities/utils.py,sha256=nVYVhuHtWSjdaJaXe9eC1Usja56NO-V9SoBIlcHAK8Q,5166
67
67
  jaaql/utilities/utils_no_project_imports.py,sha256=YwcskR15dAqKdEiuF8zGx-tzwWkclSGil_zm_qZ8ylU,4626
68
68
  jaaql/utilities/vault.py,sha256=DudyQNtMtaRLL71IGQX0AI16Tg1id98BHTCQPyUNkmE,2243
69
- jaaql_middleware_python-5.2.7.dist-info/licenses/LICENSE.txt,sha256=wr50eN0TnkrCSuNwyiI81FLZqmrW3q0JqRlVVQIrGA0,18134
70
- jaaql_middleware_python-5.2.7.dist-info/METADATA,sha256=kRCQ9esMuc6bK2pFTKu3optqYTDsgwnkFxDGn00_gLI,1945
71
- jaaql_middleware_python-5.2.7.dist-info/WHEEL,sha256=zaaOINJESkSfm_4HQVc5ssNzHCPXhJm0kEUakpsEHaU,91
72
- jaaql_middleware_python-5.2.7.dist-info/top_level.txt,sha256=k5aKVQy77bPyfyNHITvo5m3FqdSAiiXbjT5S9D9KzgU,6
73
- jaaql_middleware_python-5.2.7.dist-info/RECORD,,
69
+ jaaql_middleware_python-5.3.2.dist-info/licenses/LICENSE.txt,sha256=wr50eN0TnkrCSuNwyiI81FLZqmrW3q0JqRlVVQIrGA0,18134
70
+ jaaql_middleware_python-5.3.2.dist-info/METADATA,sha256=cN4zk-t4FHTjS1N16a6CldkX4G5BqynXlvWUp2qjRUU,1976
71
+ jaaql_middleware_python-5.3.2.dist-info/WHEEL,sha256=zaaOINJESkSfm_4HQVc5ssNzHCPXhJm0kEUakpsEHaU,91
72
+ jaaql_middleware_python-5.3.2.dist-info/top_level.txt,sha256=k5aKVQy77bPyfyNHITvo5m3FqdSAiiXbjT5S9D9KzgU,6
73
+ jaaql_middleware_python-5.3.2.dist-info/RECORD,,