jaaql-middleware-python 4.34.3__py3-none-any.whl → 5.2.7__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
@@ -91,6 +91,8 @@ VAULT_KEY__super_db_credentials = "super_db_credentials"
91
91
  VAULT_KEY__jaaql_lookup_connection = "jaaql_lookup_connection"
92
92
  VAULT_KEY__allow_jaaql_uninstall = "Allow jaaql uninstall"
93
93
  VAULT_KEY__jaaql_db_password = "Jaaql DB Password"
94
+ VAULT_KEY__postgres_bootstrap_password = "postgres_bootstrap_password"
95
+ VAULT_KEY__keycloak_realm_admin_secret = "keycloak_realm_admin_secret"
94
96
 
95
97
  # Used for re-installation
96
98
  VAULT_KEY__db_connection_string = "db_connection_string"
@@ -205,8 +207,9 @@ USERNAME__anonymous = "anonymous"
205
207
  PASSWORD__anonymous = "jaaql_public_password"
206
208
  ROLE__jaaql = "jaaql"
207
209
  ROLE__postgres = "postgres"
210
+ ROLE__dba = "dba"
208
211
 
209
212
  PROTOCOL__postgres = "postgresql://"
210
213
 
211
- VERSION = "4.34.3"
214
+ VERSION = "5.2.7"
212
215
 
@@ -28,6 +28,40 @@ QUERY__dba_query = "SELECT pg_has_role(datdba::regrole, 'MEMBER') FROM pg_databa
28
28
  QUERY__dba_query_external = "SELECT pg_has_role(datdba::regrole, 'MEMBER') FROM pg_database WHERE datname = current_database();"
29
29
 
30
30
 
31
+ def _escape_unescaped_percent(query: str) -> str:
32
+ # psycopg3 scans every '%' in the SQL when parameters are supplied and
33
+ # rejects anything that isn't a placeholder or '%%'. JAAQL uses named
34
+ # parameters only (:parameter -> %(name)s), so positional %s/%b/%t must
35
+ # never survive: any '%s' in author SQL is a literal inside a string
36
+ # (LIKE '%saas%', LIKE '%twynstra%', etc.), not a placeholder. Escape
37
+ # everything except '%%' and '%(name)X'.
38
+ out = []
39
+ i = 0
40
+ n = len(query)
41
+ while i < n:
42
+ if query[i] != '%':
43
+ out.append(query[i])
44
+ i += 1
45
+ continue
46
+
47
+ nxt = query[i + 1] if i + 1 < n else ''
48
+ if nxt == '%':
49
+ out.append('%%')
50
+ i += 2
51
+ elif nxt == '(':
52
+ close = query.find(')', i + 2)
53
+ if close == -1 or close + 1 >= n:
54
+ out.append('%%')
55
+ i += 1
56
+ else:
57
+ out.append(query[i:close + 2])
58
+ i = close + 2
59
+ else:
60
+ out.append('%%')
61
+ i += 1
62
+ return ''.join(out)
63
+
64
+
31
65
  class DBPGInterface(DBInterface):
32
66
 
33
67
  HOST_POOLS = {}
@@ -210,7 +244,7 @@ class DBPGInterface(DBInterface):
210
244
  if parameters is None or len(parameters.keys()) == 0:
211
245
  cursor.execute(query, prepare=do_prepare)
212
246
  else:
213
- cursor.execute(query, parameters, prepare=do_prepare)
247
+ cursor.execute(_escape_unescaped_percent(query), parameters, prepare=do_prepare)
214
248
  if cursor.description is None:
215
249
  return [], [], []
216
250
  else:
@@ -367,12 +367,40 @@ DOCUMENTATION__oidc_exchange_code = SwaggerDocumentation(
367
367
  name="Fetch OIDC code",
368
368
  description="Exchanges OIDC auth code for auth token, returns the token",
369
369
  method=REST__GET,
370
- arguments=SwaggerArgumentResponse(
371
- name="response",
372
- description="The OIDC response JWT object",
373
- arg_type=str,
374
- example=["eyJ..."]
375
- ),
370
+ arguments=[
371
+ SwaggerArgumentResponse(
372
+ name="response",
373
+ description="The OIDC response JWT object (FAPI/JARM mode)",
374
+ arg_type=str,
375
+ required=False,
376
+ condition="When USE_FAPI_ADVANCED or standard JARM is enabled",
377
+ example=["eyJ..."]
378
+ ),
379
+ SwaggerArgumentResponse(
380
+ name="code",
381
+ description="The authorization code (basic OIDC mode)",
382
+ arg_type=str,
383
+ required=False,
384
+ condition="When USE_OIDC_BASIC is enabled",
385
+ example=["abc123"]
386
+ ),
387
+ SwaggerArgumentResponse(
388
+ name="state",
389
+ description="The state parameter (basic OIDC mode)",
390
+ arg_type=str,
391
+ required=False,
392
+ condition="When USE_OIDC_BASIC is enabled",
393
+ example=["xyz789"]
394
+ ),
395
+ SwaggerArgumentResponse(
396
+ name="session_state",
397
+ description="The session state parameter (Azure AD / OpenID Connect session management)",
398
+ arg_type=str,
399
+ required=False,
400
+ condition="When the identity provider includes session management",
401
+ example=["0022d22a-3a6c-953c-be05-80a155fda337"]
402
+ )
403
+ ],
376
404
  response=SwaggerFlatResponse(
377
405
  description="URL",
378
406
  code=HTTPStatus.FOUND,
@@ -381,6 +409,16 @@ DOCUMENTATION__oidc_exchange_code = SwaggerDocumentation(
381
409
  )
382
410
  )
383
411
 
412
+ DOCUMENTATION__resend_verify_email = SwaggerDocumentation(
413
+ tags="oidc",
414
+ security=False,
415
+ methods=SwaggerMethod(
416
+ name="Resend verification email",
417
+ description="Triggers Keycloak to resend a verification email for a given email address",
418
+ method=REST__POST,
419
+ )
420
+ )
421
+
384
422
  DOCUMENTATION__jwks = SwaggerDocumentation(
385
423
  tags="jwks",
386
424
  security=False,
@@ -567,4 +605,26 @@ DOCUMENTATION__security_event = SwaggerDocumentation(
567
605
  ],
568
606
  response=RES__allow_all
569
607
  )
570
- )
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
+ )
@@ -127,10 +127,24 @@ class UserUnauthorized(JaaqlInterpretableHandledError):
127
127
  )
128
128
 
129
129
 
130
- class UnhandledJaaqlServerError(JaaqlInterpretableHandledError):
130
+ class EmailConfirmationRequired(JaaqlInterpretableHandledError):
131
131
  def __init__(self, descriptor=None):
132
132
  super().__init__(
133
133
  error_code=1010,
134
+ http_response_code=403,
135
+ table_name=None,
136
+ message="Your email confirmation grace period has expired. Please confirm your email to continue.",
137
+ column_name=None,
138
+ _set=None,
139
+ index=None,
140
+ descriptor=descriptor
141
+ )
142
+
143
+
144
+ class UnhandledJaaqlServerError(JaaqlInterpretableHandledError):
145
+ def __init__(self, descriptor=None):
146
+ super().__init__(
147
+ error_code=1099,
134
148
  http_response_code=500,
135
149
  table_name=None,
136
150
  message="An unhandled exception has occurred with JAAQL.",
@@ -35,6 +35,7 @@ from jaaql.exceptions.http_status_exception import *
35
35
  ARG__http_inputs = "http_inputs"
36
36
  ARG__account_id = "account_id"
37
37
  ARG__ip_address = "ip_address"
38
+ ARG__request_headers = "request_headers"
38
39
  ARG__response = "response"
39
40
  ARG__username = "username"
40
41
  ARG__profiler = "profiler"
@@ -496,7 +497,16 @@ class BaseJAAQLController:
496
497
  if auth_cookie is not None:
497
498
  security_key = auth_cookie
498
499
 
499
- if swagger_documentation.security:
500
+ easyauth_resolved = False
501
+ if self.model.use_easyauth and security_key is None:
502
+ easyauth_principal_id = request.headers.get("X-MS-CLIENT-PRINCIPAL-ID")
503
+ if easyauth_principal_id:
504
+ account_id, username, ip_id, is_public, remember_me = \
505
+ self.model.authenticate_via_easyauth(request.headers, ip_addr, jaaql_resp)
506
+ easyauth_resolved = True
507
+ self.perform_profile(request_id, "EasyAuth")
508
+
509
+ if swagger_documentation.security and not easyauth_resolved:
500
510
  bypass_super = request.headers.get(HEADER__security_bypass)
501
511
  bypass_jaaql = request.headers.get(HEADER__security_bypass_jaaql)
502
512
  bypass_user = request.headers.get(HEADER__security_specify_user)
@@ -566,6 +576,9 @@ class BaseJAAQLController:
566
576
  if ARG__ip_address in inspect.getfullargspec(view_func_local).args:
567
577
  supply_dict[ARG__ip_address] = ip_addr
568
578
 
579
+ if ARG__request_headers in inspect.getfullargspec(view_func_local).args:
580
+ supply_dict[ARG__request_headers] = request.headers
581
+
569
582
  if ARG__response in inspect.getfullargspec(view_func_local).args:
570
583
  supply_dict[ARG__response] = jaaql_resp
571
584
 
jaaql/mvc/base_model.py CHANGED
@@ -18,6 +18,7 @@ from jaaql.config_constants import *
18
18
  from jaaql.email.email_manager import EmailManager
19
19
  from os.path import dirname
20
20
  from jaaql.services.cached_canned_query_service import CachedCannedQueryService
21
+ from jaaql.utilities.bootstrap_secrets import get_or_seed_vault_secret
21
22
  import threading
22
23
 
23
24
  import uuid
@@ -236,6 +237,19 @@ class BaseJAAQLModel:
236
237
 
237
238
  self.application_url = os.environ.get("SERVER_ADDRESS", "")
238
239
  self.use_fapi_advanced = os.environ.get("USE_FAPI_ADVANCED", "").lower() == "true"
240
+ self.use_oidc_basic = os.environ.get("USE_OIDC_BASIC", "").lower() == "true"
241
+ if self.use_oidc_basic:
242
+ # Basic OIDC mode intentionally disables the advanced FAPI/JARM/PAR flow.
243
+ self.use_fapi_advanced = False
244
+
245
+ self.use_easyauth = os.environ.get("USE_EASYAUTH", "").lower() == "true"
246
+ if self.use_easyauth:
247
+ if self.use_oidc_basic or self.use_fapi_advanced:
248
+ raise Exception("USE_EASYAUTH cannot be combined with USE_OIDC_BASIC or USE_FAPI_ADVANCED")
249
+ print("EasyAuth mode enabled. Container MUST be behind Azure EasyAuth (App Service or Container Apps).")
250
+ self.easyauth_provider = os.environ.get("EASYAUTH_PROVIDER", "azure")
251
+ self.easyauth_tenant = os.environ.get("EASYAUTH_TENANT", "easyauth")
252
+ self.easyauth_provisioned = False
239
253
 
240
254
  self.fapi_pem = None
241
255
  self.fapi_cert = None
@@ -293,16 +307,27 @@ class BaseJAAQLModel:
293
307
 
294
308
  self.reload_lock = threading.Lock()
295
309
 
296
- if not self.vault.has_obj(VAULT_KEY__jaaql_local_access_key):
297
- self.vault.insert_obj(VAULT_KEY__jaaql_local_access_key, str(uuid.uuid4()))
298
-
299
- if not self.vault.has_obj(VAULT_KEY__super_local_access_key):
300
- self.vault.insert_obj(VAULT_KEY__super_local_access_key, str(uuid.uuid4()))
301
-
302
- self.local_jaaql_access_key = os.environ.get(ENVIRON__JAAQL__JAAQL_BYPASS_KEY,
303
- self.vault.get_obj(VAULT_KEY__super_local_access_key) if self.is_container else "00000-00000")
304
- self.local_super_access_key = os.environ.get(ENVIRON__JAAQL__SUPER_BYPASS_KEY,
305
- self.vault.get_obj(VAULT_KEY__super_local_access_key) if self.is_container else "00000-00000")
310
+ jaaql_bypass_key = get_or_seed_vault_secret(
311
+ self.vault,
312
+ VAULT_KEY__jaaql_local_access_key,
313
+ ENVIRON__JAAQL__JAAQL_BYPASS_KEY,
314
+ generate_if_missing=True
315
+ )
316
+ super_bypass_key = get_or_seed_vault_secret(
317
+ self.vault,
318
+ VAULT_KEY__super_local_access_key,
319
+ ENVIRON__JAAQL__SUPER_BYPASS_KEY,
320
+ generate_if_missing=True
321
+ )
322
+ get_or_seed_vault_secret(
323
+ self.vault,
324
+ VAULT_KEY__keycloak_realm_admin_secret,
325
+ "KEYCLOAK_REALM_ADMIN_SECRET",
326
+ generate_if_missing=False
327
+ )
328
+
329
+ self.local_jaaql_access_key = jaaql_bypass_key if self.is_container else "00000-00000"
330
+ self.local_super_access_key = super_bypass_key if self.is_container else "00000-00000"
306
331
 
307
332
  if self.vault.has_obj(VAULT_KEY__jaaql_lookup_connection):
308
333
  if self.is_container:
jaaql/mvc/controller.py CHANGED
@@ -1,3 +1,5 @@
1
+ from http import HTTPStatus
2
+
1
3
  from jaaql.mvc.exception_queries import SECURITY_EVENT_TYPE__create, SECURITY_EVENT_TYPE__delete, \
2
4
  SECURITY_EVENT_TYPE__reset
3
5
  from jaaql.mvc.model import JAAQLModel
@@ -10,7 +12,8 @@ from jaaql.db.db_interface import DBInterface
10
12
  from flask import request
11
13
  import queue
12
14
 
13
- from jaaql.utilities.utils_no_project_imports import COOKIE_OIDC
15
+ from jaaql.utilities.utils_no_project_imports import COOKIE_OIDC, COOKIE_OIDC_RETURN
16
+ from jaaql.interpreter.interpret_jaaql import KEY_query, KEY_parameters
14
17
 
15
18
 
16
19
  class JAAQLController(BaseJAAQLController):
@@ -171,16 +174,26 @@ class JAAQLController(BaseJAAQLController):
171
174
  return self.model.fetch_user_registries_for_tenant(http_inputs)
172
175
 
173
176
  @self.publish_route('/oidc-redirect-url', DOCUMENTATION__oidc_redirect_url)
174
- def fetch_redirect_url(http_inputs: dict, response: JAAQLResponse):
175
- self.model.fetch_redirect_uri(http_inputs, response)
177
+ def fetch_redirect_url(http_inputs: dict, response: JAAQLResponse, request_headers=None, ip_address=None):
178
+ self.model.fetch_redirect_uri(http_inputs, response, request_headers=request_headers, ip_address=ip_address)
176
179
 
177
180
  @self.publish_route(ENDPOINT__oidc_get_token, DOCUMENTATION__oidc_exchange_code)
178
181
  def exchange_auth_code(http_inputs: dict, ip_address: str, response: JAAQLResponse):
179
182
  try:
180
183
  self.model.exchange_auth_code(http_inputs, request.cookies.get(COOKIE_OIDC), ip_address, response)
181
- except: # Sometimes this can fail when the user didn't exist before
184
+ except Exception as e:
185
+ import traceback
186
+ traceback.print_exc()
187
+ print(f"OIDC code exchange failed: {e}")
188
+ # Redirect to the saved return URL if available (survives OIDC cookie consumption),
189
+ # otherwise fall back to the app root
190
+ return_url = request.cookies.get(COOKIE_OIDC_RETURN)
182
191
  response.response_code = HTTPStatus.FOUND
183
- response.raw_headers["Location"] = self.model.url.replace("_", "localhost")
192
+ response.raw_headers["Location"] = return_url if return_url else self.model.url.replace("_", "localhost")
193
+
194
+ @self.publish_route('/resend-verify-email', DOCUMENTATION__resend_verify_email)
195
+ def resend_verify_email(http_inputs: dict):
196
+ self.model.resend_verification_email(http_inputs.get("email", ""))
184
197
 
185
198
  @self.publish_route('/.well-known/jwks', DOCUMENTATION__jwks)
186
199
  def fetch_jwks():
@@ -209,3 +222,33 @@ class JAAQLController(BaseJAAQLController):
209
222
  {"state": http_inputs["state"], "oidc_cookie": request.cookies.get(COOKIE_OIDC)},
210
223
  response
211
224
  )
225
+
226
+ @self.publish_route(ENDPOINT__report_sentinel_error, DOCUMENTATION__report_sentinel_error, True)
227
+ def report_sentinel_error(http_inputs: dict, ip_address: str):
228
+ # Public error-reporting endpoint, moved here from the deprecated jaaql-sentinel-middleware.
229
+ # MUST NEVER return a 500: a 500 makes JAAQL self-report (SENTINEL_URL=_) and recurse.
230
+ ins_error = (
231
+ "insert into error (location, source_file, user_agent, ip_address, error_condensed, stacktrace, "
232
+ "file_line_number, file_col_number, version, source_system) "
233
+ "VALUES (:location, :source_file, #user_agent, #ip_address, left(:error_condensed, 200), :stacktrace, "
234
+ ":file_line_number, :file_col_number, :version, :source_system) "
235
+ "RETURNING id::text as error_id"
236
+ )
237
+ try:
238
+ http_inputs["ip_address"] = ip_address # plaintext -> #ip_address (field-level encrypted)
239
+ inserted = self.model.submit(
240
+ {KEY_query: ins_error, KEY_parameters: http_inputs, KEY__application: "sentinel"},
241
+ ROLE__dba, as_objects=True, singleton=True
242
+ )
243
+ self.model.submit(
244
+ {
245
+ KEY_query: 'SELECT "error.process_alert"(:id, :raw_ip_address)',
246
+ KEY_parameters: {"id": inserted["error_id"], "raw_ip_address": ip_address},
247
+ KEY__application: "sentinel"
248
+ },
249
+ ROLE__dba
250
+ )
251
+ except Exception as ex:
252
+ import traceback
253
+ traceback.print_exc()
254
+ raise HttpStatusException(str(ex))
@@ -1309,6 +1309,7 @@ KG__account__sub = "sub"
1309
1309
  KG__account__username = "username"
1310
1310
  KG__account__email = "email"
1311
1311
  KG__account__email_verified = "email_verified"
1312
+ KG__account__created_timestamp = "created_timestamp"
1312
1313
  KG__account__deletion_timestamp = "deletion_timestamp"
1313
1314
  KG__account__provider = "provider"
1314
1315
  KG__account__tenant = "tenant"