jaaql-middleware-python 5.1.0__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
@@ -207,8 +207,9 @@ USERNAME__anonymous = "anonymous"
207
207
  PASSWORD__anonymous = "jaaql_public_password"
208
208
  ROLE__jaaql = "jaaql"
209
209
  ROLE__postgres = "postgres"
210
+ ROLE__dba = "dba"
210
211
 
211
212
  PROTOCOL__postgres = "postgresql://"
212
213
 
213
- VERSION = "5.1.0"
214
+ VERSION = "5.2.7"
214
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:
@@ -605,4 +605,26 @@ DOCUMENTATION__security_event = SwaggerDocumentation(
605
605
  ],
606
606
  response=RES__allow_all
607
607
  )
608
- )
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
+ )
jaaql/mvc/controller.py CHANGED
@@ -13,6 +13,7 @@ from flask import request
13
13
  import queue
14
14
 
15
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
16
17
 
17
18
 
18
19
  class JAAQLController(BaseJAAQLController):
@@ -221,3 +222,33 @@ class JAAQLController(BaseJAAQLController):
221
222
  {"state": http_inputs["state"], "oidc_cookie": request.cookies.get(COOKIE_OIDC)},
222
223
  response
223
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))
jaaql/mvc/model.py CHANGED
@@ -124,6 +124,51 @@ QUERY__purge_rendered_document = "DELETE FROM document_request WHERE completed i
124
124
  QUERY__fetch_rendered_document = "SELECT app.base_url, rd.uuid as document_id, 'pdf' as render_as, rd.file_name, rd.create_file, rd.completed, rd.encrypted_access_token as oauth_token FROM document_request rd INNER JOIN document_template able ON rd.template = able.name INNER JOIN application app ON app.name = rd.application WHERE rd.uuid = :document_id"
125
125
  QUERY__fetch_cron_jobs = "SELECT * FROM remote_procedure WHERE access = 'S'";
126
126
 
127
+
128
+ def _rewrite_endpoint_to_origin(endpoint_url: str, target_origin_url: str) -> str:
129
+ # Swap scheme+host of `endpoint_url` for those of `target_origin_url`,
130
+ # preserving path/query/fragment. Returns endpoint_url unchanged if either
131
+ # input is missing a scheme or host. Used to keep the OIDC auth iframe
132
+ # same-origin with the parent app — see fetch_redirect_uri.
133
+ if not endpoint_url or not target_origin_url:
134
+ return endpoint_url
135
+ parsed_endpoint = urllib.parse.urlparse(endpoint_url)
136
+ parsed_origin = urllib.parse.urlparse(target_origin_url)
137
+ if not parsed_origin.scheme or not parsed_origin.netloc:
138
+ return endpoint_url
139
+ return urllib.parse.urlunparse((
140
+ parsed_origin.scheme,
141
+ parsed_origin.netloc,
142
+ parsed_endpoint.path,
143
+ parsed_endpoint.params,
144
+ parsed_endpoint.query,
145
+ parsed_endpoint.fragment,
146
+ ))
147
+
148
+
149
+ def _keycloak_backchannel_origin(discovery_url: str) -> str:
150
+ # Canonical scheme+host that JAAQL uses for SERVER-side Keycloak calls
151
+ # (PAR, token, JWKS). With per-realm frontendUrl set, the discovery doc
152
+ # advertises endpoint URLs against the BROWSER host (the app's domain),
153
+ # which routes through the same-origin nginx proxy. That proxy terminates
154
+ # the middleware's mTLS handshake at the app's nginx and opens a fresh
155
+ # upstream TLS connection — the FAPI client cert never reaches Keycloak.
156
+ # So for backchannel we must skip the proxy.
157
+ #
158
+ # Source of truth: KEYCLOAK_URL env var (already set per-deployment).
159
+ # Fallback: the origin of user_registry.discovery_url, which is also the
160
+ # canonical server-side host for the deployment.
161
+ env_url = os.environ.get("KEYCLOAK_URL", "").strip().rstrip("/")
162
+ if env_url:
163
+ return env_url
164
+ if not discovery_url:
165
+ return ""
166
+ parsed = urllib.parse.urlparse(discovery_url)
167
+ if not parsed.scheme or not parsed.netloc:
168
+ return ""
169
+ return f"{parsed.scheme}://{parsed.netloc}"
170
+
171
+
127
172
  class JAAQLModel(BaseJAAQLModel):
128
173
  VERIFICATION_QUEUE = None
129
174
 
@@ -648,9 +693,16 @@ WHERE
648
693
  return discovery
649
694
 
650
695
  def fetch_jwks_client(self, database: str, provider: str, tenant: str, discovery):
651
- jwks_url = discovery.get("jwks_uri").replace("localhost", "host.docker.internal")
696
+ jwks_url = discovery.get("jwks_uri")
652
697
  if not jwks_url:
653
698
  raise Exception(f"Discovery document for {provider}, {tenant} did not have JWKS url")
699
+ # Force backchannel: skip the app same-origin proxy and hit Keycloak directly.
700
+ # JWKS doesn't require mTLS, but keeping all backchannel calls on the same
701
+ # canonical host avoids surprise routing and an extra hop. KEYCLOAK_URL env
702
+ # is the source of truth; if absent we leave the discovery URL untouched.
703
+ backchannel_origin = os.environ.get("KEYCLOAK_URL", "").strip().rstrip("/")
704
+ if backchannel_origin:
705
+ jwks_url = _rewrite_endpoint_to_origin(jwks_url, backchannel_origin)
654
706
  lookup = database + ":" + provider + ":" + tenant
655
707
  jwks = self.JWKS_CACHE.get(lookup)
656
708
  if not jwks:
@@ -697,13 +749,25 @@ WHERE
697
749
  discovery = self.fetch_discovery_content(database, provider, tenant, user_registry[KG__user_registry__discovery_url])
698
750
  jwk_client = self.fetch_jwks_client(database, provider, tenant, discovery)
699
751
 
700
- expected_issuer = os.environ.get("OIDC_ISSUER", "").strip() or discovery.get("issuer")
701
- if not expected_issuer:
702
- raise Exception(f"Failed to fetch issuer in discovery document for {provider}, {tenant}")
752
+ oidc_issuer_override = os.environ.get("OIDC_ISSUER", "").strip()
753
+ if oidc_issuer_override:
754
+ expected_issuer = oidc_issuer_override
755
+ else:
756
+ expected_issuer = discovery.get("issuer")
757
+ if not expected_issuer:
758
+ raise Exception(f"Failed to fetch issuer in discovery document for {provider}, {tenant}")
759
+ # JARMS and id_tokens echo Keycloak's view of the host the BROWSER
760
+ # hit — which, with the same-origin proxy in place, is the app's
761
+ # base_url, not the server-side discovery host. Rewrite the
762
+ # discovery issuer to match so jwt.decode(...issuer=...) lines up.
763
+ expected_issuer = _rewrite_endpoint_to_origin(expected_issuer, application_tuple[KG__application__base_url])
703
764
 
704
765
  token_endpoint = discovery.get("token_endpoint")
705
766
  if not token_endpoint:
706
767
  raise Exception(f"No token endpoint for {provider}, {tenant}")
768
+ # Force backchannel: hit Keycloak directly (skip the app same-origin proxy)
769
+ # so the FAPI client cert presented at TLS handshake actually reaches Keycloak.
770
+ token_endpoint = _rewrite_endpoint_to_origin(token_endpoint, _keycloak_backchannel_origin(user_registry[KG__user_registry__discovery_url]))
707
771
  allowed_algs = discovery.get("id_token_signing_alg_values_supported")
708
772
  if not allowed_algs:
709
773
  raise Exception(f"No allowed algs for {provider}, {tenant}")
@@ -782,7 +846,7 @@ WHERE
782
846
  })
783
847
 
784
848
  token_response = self.idp_session.post(
785
- token_endpoint.replace("localhost", "host.docker.internal"),
849
+ token_endpoint,
786
850
  data=token_request_payload,
787
851
  **kwargs
788
852
  )
@@ -850,6 +914,47 @@ WHERE
850
914
  response.response_code = HTTPStatus.FOUND
851
915
  response.raw_headers["Location"] = oidc_state[KEY__redirect_uri]
852
916
 
917
+ def _run_federation_procedure(self, database_user_registry, application, schema,
918
+ account_id, provider, tenant, email, sub, id_payload):
919
+ # Runs the app database's federation procedure (e.g. linking the JAAQL account to an
920
+ # app user row). Safe to call on every login: the procedure is expected to be idempotent.
921
+ if database_user_registry is None or application is None or schema is None:
922
+ return
923
+
924
+ db_params = {"tenant": tenant, "application": application, "account_id": account_id, "provider": provider,
925
+ "email": email, "sub": sub}
926
+ parameters = fetch_parameters_for_federation_procedure(self.jaaql_lookup_connection,
927
+ database_user_registry[KG__database_user_registry__federation_procedure])
928
+ for claim in parameters:
929
+ claim_name = claim[KG__federation_procedure_parameter__name]
930
+ db_params[claim_name] = id_payload.get(claim_name, "")
931
+
932
+ procedure_name = database_user_registry[KG__database_user_registry__federation_procedure]
933
+ if re.match(REGEX__dmbs_procedure_name, procedure_name) is None:
934
+ raise HttpStatusException("Unsafe data federation procedure")
935
+
936
+ procedure_params = []
937
+ for key, _ in db_params.items():
938
+ if re.match(REGEX__dmbs_object_name, key) is None:
939
+ raise HttpStatusException("Unsafe data federation parameter " + key)
940
+ procedure_params.append(f"{key} => :{key}")
941
+
942
+ submit_data = {
943
+ KEY__application: application,
944
+ KEY_query: f"SELECT * FROM \"{procedure_name}\"({', '.join(procedure_params)});",
945
+ KEY__schema: schema,
946
+ KEY__parameters: db_params
947
+ }
948
+
949
+ print("Preparing federating procedure")
950
+
951
+ submit(self.vault, self.config, self.get_db_crypt_key(),
952
+ self.jaaql_lookup_connection, submit_data, ROLE__jaaql,
953
+ None, self.cached_canned_query_service, as_objects=True, singleton=True)
954
+
955
+ print("Federated user")
956
+ print(submit_data)
957
+
853
958
  def _federate_and_issue_jwt(self, sub, provider, tenant, id_payload, ip_address, response,
854
959
  application=None, schema=None, database_user_registry=None):
855
960
  account = None
@@ -862,6 +967,18 @@ WHERE
862
967
  email_verified = id_payload.get('email_verified') if require_email_verification else True
863
968
  if email_verified:
864
969
  mark_account_registered(self.jaaql_lookup_connection, account[KG__account__id])
970
+ account[KG__account__email_verified] = True
971
+
972
+ # Re-run the federation procedure for already-known accounts so the app-side
973
+ # person<->account linkage self-heals when the app user was created after the
974
+ # account was first federated. Best-effort: a federation procedure failure (e.g. the
975
+ # app user does not exist yet) must not break an otherwise valid login.
976
+ try:
977
+ self._run_federation_procedure(database_user_registry, application, schema,
978
+ account[KG__account__id], provider, tenant,
979
+ id_payload.get('email'), sub, id_payload)
980
+ except Exception as e:
981
+ print(f"Federation procedure (existing-account re-link) skipped: {e}")
865
982
 
866
983
  except HttpSingletonStatusException:
867
984
  # User does not exist, federate it
@@ -874,40 +991,8 @@ WHERE
874
991
  print("new federated user with account id " + account_id)
875
992
  account = account__select(self.jaaql_lookup_connection, self.get_db_crypt_key(), account_id)
876
993
 
877
- if database_user_registry is not None and application is not None and schema is not None:
878
- db_params = {"tenant": tenant, "application": application, "account_id": account_id, "provider": provider,
879
- "email": email, "sub": sub}
880
- parameters = fetch_parameters_for_federation_procedure(self.jaaql_lookup_connection,
881
- database_user_registry[KG__database_user_registry__federation_procedure])
882
- for claim in parameters:
883
- claim_name = claim[KG__federation_procedure_parameter__name]
884
- db_params[claim_name] = id_payload.get(claim_name, "")
885
-
886
- procedure_name = database_user_registry[KG__database_user_registry__federation_procedure]
887
- if re.match(REGEX__dmbs_procedure_name, procedure_name) is None:
888
- raise HttpStatusException("Unsafe data federation procedure")
889
-
890
- procedure_params = []
891
- for key, _ in db_params.items():
892
- if re.match(REGEX__dmbs_object_name, key) is None:
893
- raise HttpStatusException("Unsafe data federation parameter " + key)
894
- procedure_params.append(f"{key} => :{key}")
895
-
896
- submit_data = {
897
- KEY__application: application,
898
- KEY_query: f"SELECT * FROM \"{procedure_name}\"({', '.join(procedure_params)});",
899
- KEY__schema: schema,
900
- KEY__parameters: db_params
901
- }
902
-
903
- print("Preparing federating procedure")
904
-
905
- submit(self.vault, self.config, self.get_db_crypt_key(),
906
- self.jaaql_lookup_connection, submit_data, ROLE__jaaql,
907
- None, self.cached_canned_query_service, as_objects=True, singleton=True)
908
-
909
- print("Federated user")
910
- print(submit_data)
994
+ self._run_federation_procedure(database_user_registry, application, schema,
995
+ account_id, provider, tenant, email, sub, id_payload)
911
996
 
912
997
  # Insert into federation.federated_user on the app database
913
998
  if database_user_registry is not None:
@@ -987,7 +1072,7 @@ WHERE
987
1072
  attributes=get_cookie_attrs(self.vigilant_sessions, False, self.is_container),
988
1073
  is_https=self.is_https)
989
1074
 
990
- response.set_cookie(COOKIE_LOGIN_MARKER, value="true", is_https=True, attributes=get_sloppy_cookie_attrs())
1075
+ response.set_cookie(COOKIE_LOGIN_MARKER, value="true", is_https=self.is_https, attributes=get_sloppy_cookie_attrs())
991
1076
 
992
1077
  return account, address
993
1078
 
@@ -1125,6 +1210,15 @@ WHERE
1125
1210
  if not auth_endpoint:
1126
1211
  raise Exception("Authorization endpoint not found in discovery document")
1127
1212
 
1213
+ # Rewrite host to the application's own origin so the OIDC login iframe
1214
+ # stays same-origin with the parent page (password managers refuse to
1215
+ # autofill cross-origin iframes). The nginx proxy in the app container
1216
+ # forwards /realms/* and /resources/* back to the identity host.
1217
+ # Only the host changes; the path (/realms/<R>/protocol/openid-connect/auth)
1218
+ # is preserved verbatim so the proxy can route it as-is. PAR, token,
1219
+ # JWKS and discovery endpoints are server-to-server and stay on identity.
1220
+ auth_endpoint = _rewrite_endpoint_to_origin(auth_endpoint, application[KG__application__base_url])
1221
+
1128
1222
  parameters = fetch_parameters_for_federation_procedure(self.jaaql_lookup_connection,
1129
1223
  database_user_registry[KG__database_user_registry__federation_procedure])
1130
1224
  scope_list = [parameter[KG__federation_procedure_parameter__name] for parameter in parameters]
@@ -1177,7 +1271,11 @@ WHERE
1177
1271
  par_endpoint = discovery.get("pushed_authorization_request_endpoint")
1178
1272
  if not par_endpoint:
1179
1273
  raise Exception("Pushed Authorization Request endpoint not found in discovery document.")
1180
- par_endpoint = par_endpoint.replace("localhost", "host.docker.internal")
1274
+ # Force backchannel calls to go DIRECT to Keycloak (skip the app's
1275
+ # same-origin proxy, which would terminate mTLS at the app's nginx
1276
+ # and never forward the FAPI client cert to identity). See
1277
+ # _keycloak_backchannel_origin.
1278
+ par_endpoint = _rewrite_endpoint_to_origin(par_endpoint, _keycloak_backchannel_origin(user_registry[KG__user_registry__discovery_url]))
1181
1279
 
1182
1280
  par_payload = {
1183
1281
  "client_id": database_user_registry[KG__database_user_registry__client_id],
@@ -1274,15 +1372,18 @@ WHERE
1274
1372
  with open(file_path, 'r') as file:
1275
1373
  lines = file.readlines()
1276
1374
 
1375
+ # Strip existing QUIC/HTTP3 lines so they can be cleanly re-added
1376
+ # in the second pass without preventing the config update.
1377
+ lines = [l for l in lines if "listen 443 quic" not in l
1378
+ and "listen [::]:443 quic" not in l
1379
+ and "http3 on;" not in l.strip()
1380
+ and not (l.strip().startswith("add_header Alt-Svc") and "h3=" in l)]
1381
+
1277
1382
  # Initialize variables to track the current state and to store updated lines
1278
1383
  in_section = False
1279
1384
  updated_lines = []
1280
1385
 
1281
- do_write = True
1282
-
1283
1386
  for line in lines:
1284
- if "listen 443 quic" in line:
1285
- do_write = False
1286
1387
  if line.strip().startswith('charset'):
1287
1388
  in_section = True
1288
1389
  continue # Skip to the next iteration
@@ -1312,10 +1413,18 @@ WHERE
1312
1413
  if not in_localhost or not line.strip().startswith("limit_req"): # Ignore limit req for local requests
1313
1414
  second_iteration_updated_lines.append(line)
1314
1415
 
1416
+ # Defensive: if the input had `listen 443 ssl` (Certbot installed it) but
1417
+ # our output dropped it, refuse to write. Better to leave Certbot's working
1418
+ # config in place than overwrite with a broken one.
1419
+ input_had_ssl = any('listen 443 ssl' in l for l in open(file_path).readlines())
1420
+ output_has_ssl = any('listen 443 ssl' in l for l in second_iteration_updated_lines)
1421
+ if input_had_ssl and not output_has_ssl:
1422
+ print('set_web_config aborting: would have lost listen 443 ssl', file=sys.stderr)
1423
+ return
1424
+
1315
1425
  # Write the updated content back to the file
1316
- if do_write:
1317
- with open(file_path, 'w') as file:
1318
- file.writelines(second_iteration_updated_lines)
1426
+ with open(file_path, 'w') as file:
1427
+ file.writelines(second_iteration_updated_lines)
1319
1428
 
1320
1429
  subprocess.call(['nginx', '-s', 'reload'])
1321
1430
  else:
@@ -1687,6 +1796,14 @@ WHERE
1687
1796
  submit_data, account_id, None, self.cached_canned_query_service,
1688
1797
  as_objects=True, singleton=True)
1689
1798
 
1799
+ # Inject the JAAQL system email params so templates can deep-link back to
1800
+ # the app via {{JAAQL__APP_URL}} (and {{JAAQL__APP_NAME}} / {{JAAQL__EMAIL_ADDRESS}}).
1801
+ # The replacement set was otherwise the data-view row only, so these tokens
1802
+ # went unresolved on every send path (e.g. cloud-proc alert mail).
1803
+ email_replacement_data[EMAIL_PARAM__app_url] = self.replace_default_app_url(app[KG__application__base_url])
1804
+ email_replacement_data[EMAIL_PARAM__app_name] = app[KG__application__name]
1805
+ email_replacement_data[EMAIL_PARAM__email_address] = username
1806
+
1690
1807
  document_templates = fetch_document_templates_for_email_template(self.jaaql_lookup_connection, inputs[KEY__application],
1691
1808
  inputs[KEY__template])
1692
1809
  attachments = [
@@ -2074,6 +2191,17 @@ WHERE
2074
2191
  finally:
2075
2192
  app_connection.close()
2076
2193
 
2194
+ # Link the freshly-created app user (the person row created by the gate procedure
2195
+ # above) to this account now, instead of waiting for a first login that will never
2196
+ # trigger federation (the account already exists by then). Best-effort: do not fail
2197
+ # user creation if the federation procedure errors.
2198
+ try:
2199
+ self._run_federation_procedure(registry, application, "default",
2200
+ new_account_id, provider, tenant,
2201
+ username, user_id, {"email": username})
2202
+ except Exception as e:
2203
+ print(f"Federation procedure (admin create user) skipped: {e}")
2204
+
2077
2205
  return {
2078
2206
  "temporary_password": temp_pw,
2079
2207
  "response": response_obj,
@@ -2329,7 +2457,7 @@ WHERE
2329
2457
  application = application__select(self.jaaql_lookup_connection, inputs[KEY__application])
2330
2458
  redirect_uri = inputs.get(KEY__redirect_uri, "")
2331
2459
  post_logout_url = application[KG__application__base_url] + "/" + redirect_uri
2332
- response.set_cookie(COOKIE_LOGIN_MARKER, value="", is_https=True, attributes=get_sloppy_cookie_attrs())
2460
+ response.set_cookie(COOKIE_LOGIN_MARKER, value="", is_https=self.is_https, attributes=get_sloppy_cookie_attrs())
2333
2461
  response.response_code = HTTPStatus.FOUND
2334
2462
  response.raw_headers["Location"] = "/.auth/logout?post_logout_redirect_uri=" + urllib.parse.quote(post_logout_url)
2335
2463
  return
@@ -2393,7 +2521,7 @@ WHERE
2393
2521
  # Kill your own app session immediately
2394
2522
  response.delete_cookie(COOKIE_JAAQL_AUTH, self.is_https)
2395
2523
  # Optional: clear the UI helper marker
2396
- response.set_cookie(COOKIE_LOGIN_MARKER, value="", is_https=True, attributes=get_sloppy_cookie_attrs())
2524
+ response.set_cookie(COOKIE_LOGIN_MARKER, value="", is_https=self.is_https, attributes=get_sloppy_cookie_attrs())
2397
2525
 
2398
2526
  # Build front-channel logout URL (Keycloak requires either id_token_hint OR client_id with post_logout_redirect_uri)
2399
2527
  url = urllib.parse.urlsplit(end_session)
@@ -27,26 +27,36 @@ BEGIN
27
27
 
28
28
  -- Federation schema: stores OIDC identity linkage for federated/invited users
29
29
  PERFORM dblink_exec('dbname=' || database, 'CREATE SCHEMA IF NOT EXISTS federation;');
30
+ -- The public-schema copies are kept for backwards compatibility with code that
31
+ -- references the domains unqualified. The federation-schema copies are what the
32
+ -- federation tables themselves type against, so they survive a `DROP SCHEMA public
33
+ -- CASCADE` (e.g. from JAAQL's own folder-migration step in migrations_manager_service.py).
30
34
  PERFORM dblink_exec('dbname=' || database, '
31
35
  DO $d$ BEGIN CREATE DOMAIN postgres_user_id AS character varying(63); EXCEPTION WHEN duplicate_object THEN NULL; END $d$;
32
36
  ');
33
37
  PERFORM dblink_exec('dbname=' || database, '
34
38
  DO $d$ BEGIN CREATE DOMAIN oidc_subject_id AS character varying(255); EXCEPTION WHEN duplicate_object THEN NULL; END $d$;
35
39
  ');
40
+ PERFORM dblink_exec('dbname=' || database, '
41
+ DO $d$ BEGIN CREATE DOMAIN federation.postgres_user_id AS character varying(63); EXCEPTION WHEN duplicate_object THEN NULL; END $d$;
42
+ ');
43
+ PERFORM dblink_exec('dbname=' || database, '
44
+ DO $d$ BEGIN CREATE DOMAIN federation.oidc_subject_id AS character varying(255); EXCEPTION WHEN duplicate_object THEN NULL; END $d$;
45
+ ');
36
46
  PERFORM dblink_exec('dbname=' || database, '
37
47
  CREATE TABLE IF NOT EXISTS federation.account (
38
- account_id postgres_user_id NOT NULL,
48
+ account_id federation.postgres_user_id NOT NULL,
39
49
  PRIMARY KEY (account_id)
40
50
  );
41
51
  ');
42
52
  PERFORM dblink_exec('dbname=' || database, '
43
53
  CREATE TABLE IF NOT EXISTS federation.federated_user (
44
- account_id postgres_user_id NOT NULL,
45
- sub oidc_subject_id NOT NULL,
46
- registered_at timestamptz NOT NULL DEFAULT NOW(),
47
- tenant character varying(256) NOT NULL,
48
- provider character varying(256) NOT NULL,
49
- is_active boolean NOT NULL DEFAULT TRUE,
54
+ account_id federation.postgres_user_id NOT NULL,
55
+ sub federation.oidc_subject_id NOT NULL,
56
+ registered_at timestamptz NOT NULL DEFAULT NOW(),
57
+ tenant character varying(256) NOT NULL,
58
+ provider character varying(256) NOT NULL,
59
+ is_active boolean NOT NULL DEFAULT TRUE,
50
60
  encrypted_email character varying(512),
51
61
  PRIMARY KEY (tenant, provider, sub),
52
62
  FOREIGN KEY (account_id) REFERENCES federation.account (account_id)
@@ -393,16 +393,64 @@ def _run_folder_migration(folder_path: str, folder_name: str,
393
393
  _run_psql_file(pre_wipe, "dba", app_database)
394
394
 
395
395
  # Step 3: Wipe public schema.
396
+ #
397
+ # DROP SCHEMA public CASCADE wipes any extension installed in public (unaccent,
398
+ # pg_trgm, pgcrypto, ...) AND any column typed against a public-schema domain.
399
+ # JAAQL's federation tables historically typed account_id/sub against
400
+ # public.postgres_user_id / public.oidc_subject_id, so the cascade silently
401
+ # removed those columns. We snapshot extensions before the drop and restore
402
+ # them afterwards, and migrate the federation column types onto the federation-
403
+ # schema copies of the domains so they survive the wipe in future.
396
404
  print(f" Wiping public schema", file=sys.stderr)
405
+
406
+ public_extensions_raw = _run_psql_query(
407
+ "SELECT extname FROM pg_extension "
408
+ "WHERE extnamespace = 'public'::regnamespace AND extname <> 'plpgsql_check' "
409
+ "ORDER BY extname;",
410
+ "postgres", app_database,
411
+ )
412
+ public_extensions = [e.strip() for e in public_extensions_raw.split("\n") if e.strip()]
413
+ print(f" Preserving public-schema extensions: {public_extensions or '(none)'}", file=sys.stderr)
414
+
415
+ # Re-house federation domains and any federation columns that still type against
416
+ # the public-schema versions. Idempotent: healthy / already-migrated systems no-op.
397
417
  _run_psql_query(
418
+ "CREATE SCHEMA IF NOT EXISTS federation; "
419
+ "DO $d$ BEGIN CREATE DOMAIN federation.postgres_user_id AS character varying(63); EXCEPTION WHEN duplicate_object THEN NULL; END $d$; "
420
+ "DO $d$ BEGIN CREATE DOMAIN federation.oidc_subject_id AS character varying(255); EXCEPTION WHEN duplicate_object THEN NULL; END $d$; "
421
+ "DO $alter$ BEGIN "
422
+ " IF EXISTS (SELECT 1 FROM information_schema.columns "
423
+ " WHERE table_schema='federation' AND table_name='account' "
424
+ " AND column_name='account_id' AND udt_schema='public' AND udt_name='postgres_user_id') THEN "
425
+ " ALTER TABLE federation.account ALTER COLUMN account_id TYPE federation.postgres_user_id; "
426
+ " END IF; "
427
+ " IF EXISTS (SELECT 1 FROM information_schema.columns "
428
+ " WHERE table_schema='federation' AND table_name='federated_user' "
429
+ " AND column_name='account_id' AND udt_schema='public' AND udt_name='postgres_user_id') THEN "
430
+ " ALTER TABLE federation.federated_user ALTER COLUMN account_id TYPE federation.postgres_user_id; "
431
+ " END IF; "
432
+ " IF EXISTS (SELECT 1 FROM information_schema.columns "
433
+ " WHERE table_schema='federation' AND table_name='federated_user' "
434
+ " AND column_name='sub' AND udt_schema='public' AND udt_name='oidc_subject_id') THEN "
435
+ " ALTER TABLE federation.federated_user ALTER COLUMN sub TYPE federation.oidc_subject_id; "
436
+ " END IF; "
437
+ "END $alter$;",
438
+ "postgres", app_database,
439
+ )
440
+
441
+ drop_recreate = (
398
442
  "SET search_path = public, pg_catalog; "
399
443
  "DROP EXTENSION IF EXISTS plpgsql_check; "
400
444
  "DROP SCHEMA IF EXISTS public CASCADE; "
401
445
  "CREATE SCHEMA public; "
402
446
  "GRANT USAGE, CREATE ON SCHEMA public TO PUBLIC; "
403
- "CREATE EXTENSION IF NOT EXISTS plpgsql_check SCHEMA public;",
404
- "postgres", app_database,
447
+ "CREATE EXTENSION IF NOT EXISTS plpgsql_check SCHEMA public; "
405
448
  )
449
+ for ext in public_extensions:
450
+ # extname comes from pg_extension; safe to interpolate as a quoted identifier.
451
+ drop_recreate += f'CREATE EXTENSION IF NOT EXISTS "{ext}" SCHEMA public; '
452
+
453
+ _run_psql_query(drop_recreate, "postgres", app_database)
406
454
 
407
455
  # Step 4: Rebuild schema from data model (migration pt1).
408
456
  print(f" Rebuilding schema via migration pt1: {pt1}", file=sys.stderr)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jaaql-middleware-python
3
- Version: 5.1.0
3
+ Version: 5.2.7
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
@@ -1,6 +1,6 @@
1
1
  jaaql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  jaaql/config_constants.py,sha256=puoR6jfbXw8oXXEfbYNn2ysGwgiGTyuDjOj7F8acsTE,470
3
- jaaql/constants.py,sha256=XmSmSc2EHXh08i4RO0-iOD56QpjUTdrAxFtyN_-Mzzc,6976
3
+ jaaql/constants.py,sha256=Me_r4pBC0TxJZsC4uCYdI23WIgs5YnTkuvqLWPOfUu8,6994
4
4
  jaaql/generated_constants.py,sha256=pxmpTux7rrjJH6iqLb-R8RKJ4t0CeoGdnspjH5DiWgU,11327
5
5
  jaaql/jaaql.py,sha256=0jTYtpDYgKRNKU889wMI495k_h-EzQuGEQqrqHNhNrg,5829
6
6
  jaaql/patch.py,sha256=VE4B6jA492NCIxNIewbynh92jxVulmRFtB9hNEe6q94,149
@@ -10,11 +10,11 @@ jaaql/config/config-test.ini,sha256=ap9OFqCCbYdWqmUKq0p9GD1IkM4CzeTXDV5gbQGQZoQ,
10
10
  jaaql/config/config.ini,sha256=o1orXNYRFgf09MKzZBQ3v0oMzWn7N6KwvX5YV0WJtns,321
11
11
  jaaql/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
12
  jaaql/db/db_interface.py,sha256=A_daGWC6hP3M1EewNuUU5o5PL_ab2EIzrm3ZDoFxddQ,7639
13
- jaaql/db/db_pg_interface.py,sha256=bxHnEg0X6VgpWwhbYYk9YHMWsAMryLlRu9-84tNiZu8,11516
13
+ jaaql/db/db_pg_interface.py,sha256=rzoJw_XTxIqGtuQjawNxfgRAaxQs1m1MoKMOibEK0tY,12643
14
14
  jaaql/db/db_utils.py,sha256=xZCdtWT9semzLL-NAiwMFx_38Wv96BBv60pfPYNDnCc,7761
15
15
  jaaql/db/db_utils_no_circ.py,sha256=E3jxF4O0KXUnK9yxLQYxfqDTfkAwBFJSS3sX12A1row,4593
16
16
  jaaql/documentation/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
- jaaql/documentation/documentation_internal.py,sha256=cT_AI5z1_wgZb_Ml5C3DU-F5cnZVjATfv_RyewKCDVk,20436
17
+ jaaql/documentation/documentation_internal.py,sha256=ecqvo-lN1Kl0_ce-Ohj75-nZFS6GmIActJjBC-sg1pM,22404
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
@@ -33,19 +33,19 @@ jaaql/migrations/migrations.py,sha256=XE46AzNmfAg9qiatIL_6ctjqiiuVMHUNEcVHhmuh4h
33
33
  jaaql/mvc/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
34
  jaaql/mvc/base_controller.py,sha256=FCBX6pVkUh3yeLHWbw_2ht2G0L2L3vTdk2T_qcC47cI,39102
35
35
  jaaql/mvc/base_model.py,sha256=ovNPYpd6TmJfC7qSAAxVXeejv5qVBC2Ht8qAoNR5pxE,15609
36
- jaaql/mvc/controller.py,sha256=jKiUn9r6neuchKGhlrHqa8cvI1rG7G5B_Eh7OJOVXS0,12587
36
+ jaaql/mvc/controller.py,sha256=lnZBYmwzJd81bdiCkic1asOa_2veEr0U-M_vNFFTS9w,14405
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=Vo4fvMbhslqw2I6kbTNktZLV8I2DJhdAlkMnEu3uNxA,121672
41
+ jaaql/mvc/model.py,sha256=p_gAw5_0GAb14PPKs1O99mRfYjtL7f3BVqyaMLcgKT4,129446
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
45
45
  jaaql/openapi/swagger_documentation.py,sha256=J1YwR36JJGNJGqPEUMraDoeqgldRLy1_2xR-i_ZPtmQ,29021
46
46
  jaaql/scripts/01.install_domains.generated.sql,sha256=9queSyDfgMEwy7G3GV4orWlnt9VCZ6jWa_ZbYOR_w-0,3005
47
47
  jaaql/scripts/02.install_super_user.exceptions.sql,sha256=XAHnq_3hVyLn0IZm5j_BNn-Ea2NBDkRqo-Wonk7SNuU,3198
48
- jaaql/scripts/03.install_super_user.handwritten.sql,sha256=VjpogeW1Vor1WmY3ONGEcea920X6xi-3QLyQI_nyRl4,5488
48
+ jaaql/scripts/03.install_super_user.handwritten.sql,sha256=OrBRxh_QLk8FQnG9KvgFqe_hY1A0qhko2mGav7lu1qM,6352
49
49
  jaaql/scripts/04.install_jaaql_data_structures.generated.sql,sha256=isnGN4otZNPn1FwCM-W-r3HBj1tLgnp7jx47tF_maq8,7847
50
50
  jaaql/scripts/05.install_static_data.generated.sql,sha256=v6b6at8Ju5S750-uFwdvL4TvIrUAOmCutJ7eNTMreP8,18989
51
51
  jaaql/scripts/06.install_jaaql.exceptions.sql,sha256=A_N1KoTmPFcQ2KwGgkD4AcAFrs8nELcQGDm96sINBSs,324
@@ -54,7 +54,7 @@ jaaql/scripts/ZZZZ.reset_references.sql,sha256=Va0v04IBRUa7x_eE43jLO2xUQMHwYM4NP
54
54
  jaaql/scripts/swagger_template.html,sha256=Os7U11wiww5sk1J_wINbyC2id6yvvV6rbxASd5iUWiA,4198
55
55
  jaaql/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
56
56
  jaaql/services/cached_canned_query_service.py,sha256=Kq_0WwtUXBGhYJB4uhSKBn8KXuX0LHrfsBP90NZQRuc,2688
57
- jaaql/services/migrations_manager_service.py,sha256=ReXS2kN49OeVZWK3krzhXsrL1MTG2uyofZYGNCswIho,22039
57
+ jaaql/services/migrations_manager_service.py,sha256=GkzX8traQcV0Q6TBp9uf8VPM8hMldlAnqXWp0FkwSIg,25072
58
58
  jaaql/services/patch_mms.py,sha256=chae_LNafHuOhewgJrSRe-FnxBi1kgs4kK6lyn-V59A,296
59
59
  jaaql/services/patch_shared_var_service.py,sha256=qZw6Kl86Tvz3_YK-Y1VZUx8T28-lg80qiwOEkXmlCFw,117
60
60
  jaaql/services/shared_var_service.py,sha256=gcaj1x5C5m3DOcV85jrkIe4Uc_C8mW9nNdqvdHb0eXs,1109
@@ -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.1.0.dist-info/licenses/LICENSE.txt,sha256=wr50eN0TnkrCSuNwyiI81FLZqmrW3q0JqRlVVQIrGA0,18134
70
- jaaql_middleware_python-5.1.0.dist-info/METADATA,sha256=Sx9SWssXTsTpEkmqqBr2V6j1SjasGRtpdh5bGeTqKd4,1945
71
- jaaql_middleware_python-5.1.0.dist-info/WHEEL,sha256=zaaOINJESkSfm_4HQVc5ssNzHCPXhJm0kEUakpsEHaU,91
72
- jaaql_middleware_python-5.1.0.dist-info/top_level.txt,sha256=k5aKVQy77bPyfyNHITvo5m3FqdSAiiXbjT5S9D9KzgU,6
73
- jaaql_middleware_python-5.1.0.dist-info/RECORD,,
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,,