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 +8 -1
- jaaql/db/db_interface.py +3 -3
- jaaql/db/db_pg_interface.py +181 -58
- jaaql/db/db_utils_no_circ.py +2 -2
- jaaql/documentation/documentation_internal.py +34 -23
- jaaql/interpreter/interpret_jaaql.py +65 -20
- jaaql/jaaql.py +13 -0
- jaaql/mvc/base_controller.py +49 -19
- jaaql/mvc/base_model.py +38 -12
- jaaql/mvc/controller.py +10 -4
- jaaql/mvc/model.py +62 -12
- {jaaql_middleware_python-5.2.7.dist-info → jaaql_middleware_python-5.3.2.dist-info}/METADATA +6 -5
- {jaaql_middleware_python-5.2.7.dist-info → jaaql_middleware_python-5.3.2.dist-info}/RECORD +16 -16
- {jaaql_middleware_python-5.2.7.dist-info → jaaql_middleware_python-5.3.2.dist-info}/WHEEL +0 -0
- {jaaql_middleware_python-5.2.7.dist-info → jaaql_middleware_python-5.3.2.dist-info}/licenses/LICENSE.txt +0 -0
- {jaaql_middleware_python-5.2.7.dist-info → jaaql_middleware_python-5.3.2.dist-info}/top_level.txt +0 -0
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
|
|
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
|
jaaql/db/db_pg_interface.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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()
|
jaaql/db/db_utils_no_circ.py
CHANGED
|
@@ -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
|
-
|
|
790
|
-
|
|
841
|
+
literals, names = tokenize_statement(query, match_regex)
|
|
842
|
+
prepared_parts = []
|
|
791
843
|
found_parameters = []
|
|
792
844
|
found_parameter_dictionary = {}
|
|
793
845
|
|
|
794
|
-
for
|
|
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
|
-
|
|
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
|
-
|
|
874
|
+
prepared_parts.append(STATEMENT_null)
|
|
830
875
|
else:
|
|
831
876
|
if isinstance(parameters[match_str], MarkerPrepare):
|
|
832
877
|
if replace_with_nulls:
|
|
833
|
-
|
|
878
|
+
prepared_parts.append("NULL")
|
|
834
879
|
else:
|
|
835
|
-
|
|
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
|
-
|
|
884
|
+
prepared_parts.append(MARKER_DEFAULT)
|
|
840
885
|
else:
|
|
841
|
-
|
|
886
|
+
prepared_parts.append(STATEMENT_argument_begin + STATEMENT_paren_open + match_str + STATEMENT_paren_close + the_type)
|
|
842
887
|
|
|
843
|
-
|
|
888
|
+
prepared_parts.append(literals[-1])
|
|
844
889
|
|
|
845
|
-
return
|
|
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]
|
jaaql/mvc/base_controller.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
612
|
+
if ARG__response in view_func_args:
|
|
583
613
|
supply_dict[ARG__response] = jaaql_resp
|
|
584
614
|
|
|
585
|
-
if ARG__auth_token in
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
641
|
+
if ARG__headers in view_func_args:
|
|
612
642
|
supply_dict[ARG__headers] = dict(request.headers)
|
|
613
643
|
|
|
614
|
-
if ARG__body in
|
|
644
|
+
if ARG__body in view_func_args:
|
|
615
645
|
supply_dict[ARG__body] = request.get_data()
|
|
616
646
|
|
|
617
|
-
if ARG__args in
|
|
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
|
|
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
|
|
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(
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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,
|
|
162
|
-
|
|
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,
|
|
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
|
|
2279
|
-
argv += [
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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,
|
{jaaql_middleware_python-5.2.7.dist-info → jaaql_middleware_python-5.3.2.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jaaql-middleware-python
|
|
3
|
-
Version: 5.2
|
|
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.
|
|
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:
|
|
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.
|
|
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~=
|
|
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=
|
|
3
|
+
jaaql/constants.py,sha256=sfY4dvRV-sRuMwo6UcqD37XKFLPYNSzRD2VjM8CQNAs,7355
|
|
4
4
|
jaaql/generated_constants.py,sha256=pxmpTux7rrjJH6iqLb-R8RKJ4t0CeoGdnspjH5DiWgU,11327
|
|
5
|
-
jaaql/jaaql.py,sha256=
|
|
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=
|
|
13
|
-
jaaql/db/db_pg_interface.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
35
|
-
jaaql/mvc/base_model.py,sha256=
|
|
36
|
-
jaaql/mvc/controller.py,sha256=
|
|
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=
|
|
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.
|
|
70
|
-
jaaql_middleware_python-5.2.
|
|
71
|
-
jaaql_middleware_python-5.2.
|
|
72
|
-
jaaql_middleware_python-5.2.
|
|
73
|
-
jaaql_middleware_python-5.2.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
{jaaql_middleware_python-5.2.7.dist-info → jaaql_middleware_python-5.3.2.dist-info}/top_level.txt
RENAMED
|
File without changes
|