fix(amas): complete missing TLS chain dynamically + hide raw errors from UI

eroom.e-social.gov.az serves an incomplete certificate chain (omits the
intermediate CA that signs its leaf), so Python requests raised
CERTIFICATE_VERIFY_FAILED on the ƏMAS wizard/employee-load step. Browsers
paper over this via AIA; requests does not.

Fix without vendoring any cert into the repo: read the AIA "CA Issuers"
pointer out of the live leaf, download the missing intermediate(s) straight
from the issuer, and cache them outside the repo. Route all AMAS/MyGovID
calls through _amas_http, which self-heals across CA rotations by re-fetching
and retrying once on SSLError. SSL verification stays ON (not verify=False).

Also stop leaking raw exception strings to the UI: every AMAS-facing return
now logs the full traceback and shows the generic, localized
"ƏMAS unavailable" message. Added az/ru translations for it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-07-21 10:29:54 +00:00
parent b2a58e833c
commit 6647c4a351
3 changed files with 486 additions and 1366 deletions

View File

@ -13,13 +13,20 @@ of Azərbaycan's Ministry of Labour and Social Protection.
import http.client
import json
import os
import random
import socket
import ssl
import string
import tempfile
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import unquote
import certifi
import frappe
import requests
from frappe import _
# Increase header limit - AMAS returns many Set-Cookie headers
http.client._MAXHEADERS = 1000
@ -90,6 +97,169 @@ AMAS_GENERIC_UNAVAILABLE_MESSAGE = (
)
# --- TLS chain fix for eroom.e-social.gov.az -------------------------------
# The ƏMAS server serves an INCOMPLETE certificate chain: it omits the
# intermediate CA that signs its leaf certificate, so Python's certifi bundle
# cannot build a trust path and requests raises CERTIFICATE_VERIFY_FAILED
# ("unable to get local issuer certificate"). Browsers paper over this by
# fetching the missing intermediate over AIA (Authority Information Access);
# requests does not.
#
# Rather than vendor a certificate file into the repo (which goes stale the day
# the CA rotates), we do what the browser does: read the AIA "CA Issuers"
# pointer out of whatever leaf the server is currently serving, download the
# missing intermediate(s) straight from the issuer (e.g. Sectigo), and cache
# them OUTSIDE the repo (temp dir). This self-heals across CA rotations and has
# no hardcoded certificate — it follows the pointer the live cert gives us.
#
# SSL verification STAYS ON — this is not `verify=False`; we only complete the
# chain the server forgot to send, still anchored to certifi's trusted roots.
_AMAS_HOST = AMAS_BASE_URL.split("://", 1)[-1].split("/", 1)[0]
_AMAS_INTERMEDIATE_CACHE = os.path.join(tempfile.gettempdir(), "amas_missing_chain.pem")
_AMAS_BUNDLE_CACHE = os.path.join(tempfile.gettempdir(), "amas_ca_bundle.pem")
_amas_ca_bundle_path = None
_amas_ca_bundle_lock = threading.Lock()
_amas_fetch_lock = threading.Lock()
def _load_cert_any(raw):
"""Load a certificate from DER or PEM bytes; None if neither parses."""
from cryptography import x509
try:
return x509.load_der_x509_certificate(raw)
except Exception:
try:
return x509.load_pem_x509_certificate(raw)
except Exception:
return None
def _collect_missing_chain():
"""Walk the AIA chain starting from the live ƏMAS leaf certificate and
return the missing intermediate(s) as a list of PEM strings.
Reads the leaf over a raw (unverified) TLS handshake purely to inspect its
AIA pointer no data is trusted from it then downloads each issuer the
pointer names, climbing until a self-signed root (already in certifi).
"""
from cryptography import x509
from cryptography.hazmat.primitives.serialization import Encoding
from cryptography.x509.oid import AuthorityInformationAccessOID
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with socket.create_connection((_AMAS_HOST, 443), timeout=15) as sock:
with ctx.wrap_socket(sock, server_hostname=_AMAS_HOST) as tls:
der = tls.getpeercert(binary_form=True)
cert = x509.load_der_x509_certificate(der)
pems = []
for _depth in range(5): # guard against a pathological / looping chain
try:
aia = cert.extensions.get_extension_for_class(
x509.AuthorityInformationAccess
).value
except x509.ExtensionNotFound:
break
url = next(
(
d.access_location.value
for d in aia
if d.access_method == AuthorityInformationAccessOID.CA_ISSUERS
),
None,
)
if not url:
break
raw = requests.get(url, timeout=15).content
issuer = _load_cert_any(raw)
if issuer is None:
break
pems.append(issuer.public_bytes(Encoding.PEM).decode("ascii"))
if issuer.subject == issuer.issuer:
break # reached a self-signed root; certifi already trusts it
cert = issuer
return pems
def _fetch_amas_intermediate(force=False):
"""Populate the cached missing-intermediate PEM from the live AIA pointer.
Returns True if the cache is present/fresh afterwards. Network or parse
failures degrade to False (the caller then falls back to certifi-only, i.e.
today's behaviour) and are logged to the file logger, never surfaced.
"""
with _amas_fetch_lock:
if not force and os.path.exists(_AMAS_INTERMEDIATE_CACHE):
return True
try:
pems = _collect_missing_chain()
if not pems:
return False
tmp = _AMAS_INTERMEDIATE_CACHE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
f.write("\n".join(pems))
os.replace(tmp, _AMAS_INTERMEDIATE_CACHE)
return True
except Exception:
frappe.logger("amas").warning(
"ƏMAS intermediate fetch failed", exc_info=True
)
return False
def _amas_ca_bundle(rebuild=False):
"""Return a path to a CA bundle that can verify eroom.e-social.gov.az.
The bundle = certifi's trusted roots + the intermediate(s) the server omits
(fetched live, cached in temp). Falls back to requests' default verification
(``True``) if the bundle can't be built, so a glitch degrades to today's
behaviour rather than breaking every call.
"""
global _amas_ca_bundle_path
if not rebuild and _amas_ca_bundle_path and os.path.exists(_amas_ca_bundle_path):
return _amas_ca_bundle_path
with _amas_ca_bundle_lock:
if not rebuild and _amas_ca_bundle_path and os.path.exists(_amas_ca_bundle_path):
return _amas_ca_bundle_path
try:
if not os.path.exists(_AMAS_INTERMEDIATE_CACHE):
_fetch_amas_intermediate()
with open(certifi.where(), encoding="utf-8") as f:
data = f.read()
if os.path.exists(_AMAS_INTERMEDIATE_CACHE):
with open(_AMAS_INTERMEDIATE_CACHE, encoding="utf-8") as f:
data += "\n" + f.read()
with open(_AMAS_BUNDLE_CACHE, "w", encoding="utf-8") as f:
f.write(data)
_amas_ca_bundle_path = _AMAS_BUNDLE_CACHE
return _AMAS_BUNDLE_CACHE
except Exception:
frappe.logger("amas").warning("ƏMAS CA bundle build failed", exc_info=True)
return True
def _amas_http(method, url, session=None, **kwargs):
"""Perform an ƏMAS/MyGovID HTTP call with the chain-completing CA bundle.
If verification still fails (the server rotated to a new intermediate since
we last cached), re-fetch the intermediate from the live AIA pointer, rebuild
the bundle, and retry once. This is the self-heal that keeps things working
across CA rotations without a code change.
"""
kwargs.setdefault("verify", _amas_ca_bundle())
caller = session or requests
try:
return caller.request(method, url, **kwargs)
except requests.exceptions.SSLError:
if _fetch_amas_intermediate(force=True):
kwargs["verify"] = _amas_ca_bundle(rebuild=True)
return caller.request(method, url, **kwargs)
raise
def _post_amas(session_data, csrf_token, cookies, endpoint, data):
"""Stateless POST to ƏMAS. No DB access.
@ -116,7 +286,8 @@ def _post_amas(session_data, csrf_token, cookies, endpoint, data):
data["currentTabToken"] = token
try:
response = requests.post(
response = _amas_http(
"POST",
f"{AMAS_BASE_URL}{endpoint}",
data=data,
headers=headers,
@ -284,7 +455,7 @@ def connect_amas(asan_login_name):
f"EMAS connect error: {str(e)}\n{frappe.get_traceback()}",
"ƏMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}"}
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
def get_amas_auth_code(mygovid_token):
@ -307,11 +478,12 @@ def get_amas_auth_code(mygovid_token):
headers = MYGOVID_HEADERS.copy()
headers["Authorization"] = mygovid_token
response = requests.post(
response = _amas_http(
"POST",
MYGOVID_AUTH_CODES_URL,
json=payload,
headers=headers,
timeout=30
timeout=30,
)
if response.status_code == 201:
@ -332,10 +504,12 @@ def get_amas_auth_code(mygovid_token):
"message": f"Failed to get auth code: status {response.status_code}"
}
except requests.exceptions.RequestException as e:
return {"success": False, "message": f"Network error: {str(e)}"}
except Exception as e:
return {"success": False, "message": f"Error: {str(e)}"}
except requests.exceptions.RequestException:
frappe.log_error(frappe.get_traceback(), "ƏMAS network request failed")
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
except Exception:
frappe.log_error(frappe.get_traceback(), "ƏMAS request error")
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
def parse_config_from_html(html_text):
@ -397,10 +571,12 @@ def exchange_code_for_session(code, state):
# Create session to capture cookies
session = requests.Session()
response = session.get(
response = _amas_http(
"GET",
callback_url,
session=session,
allow_redirects=True,
timeout=30
timeout=30,
)
# Extract cookies
@ -467,10 +643,12 @@ def exchange_code_for_session(code, state):
"session_data": session_data
}
except requests.exceptions.RequestException as e:
return {"success": False, "message": f"Network error: {str(e)}"}
except Exception as e:
return {"success": False, "message": f"Error: {str(e)}"}
except requests.exceptions.RequestException:
frappe.log_error(frappe.get_traceback(), "ƏMAS network request failed")
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
except Exception:
frappe.log_error(frappe.get_traceback(), "ƏMAS request error")
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
def get_amas_session_data(asan_login_name):
@ -503,10 +681,11 @@ def refresh_csrf_token_internal(asan_login_name):
try:
# Make GET request to dashboard to get fresh CSRF token
response = requests.get(
response = _amas_http(
"GET",
f"{AMAS_BASE_URL}/core.dashboard",
cookies=cookies,
timeout=30
timeout=30,
)
if response.status_code != 200:
@ -696,12 +875,13 @@ def get_amas_accounts(asan_login_name):
data["currentTabToken"] = token
# Make request to /request/accounts
response = requests.post(
response = _amas_http(
"POST",
f"{AMAS_BASE_URL}{AMAS_GET_ACCOUNTS}",
data=data,
headers=headers,
cookies=cookies,
timeout=30
timeout=30,
)
# Update cookies from response
@ -738,7 +918,7 @@ def get_amas_accounts(asan_login_name):
f"EMAS get accounts error: {str(e)}\n{frappe.get_traceback()}",
"ƏMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}", "accounts": []}
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE), "accounts": []}
@frappe.whitelist()
@ -808,7 +988,7 @@ def change_amas_account(asan_login_name, account_oid, account_name, account_numb
f"EMAS change account error: {str(e)}\n{frappe.get_traceback()}",
"ƏMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}"}
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
@frappe.whitelist()
@ -868,7 +1048,7 @@ def get_employees_report(asan_login_name, offset=0, limit=100):
f"EMAS employees report error: {str(e)}\n{frappe.get_traceback()}",
"ƏMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}"}
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
@frappe.whitelist()
@ -920,7 +1100,7 @@ def get_contract_stats(asan_login_name):
f"EMAS contract stats error: {str(e)}\n{frappe.get_traceback()}",
"ƏMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}"}
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
@frappe.whitelist()
@ -946,7 +1126,7 @@ def check_amas_connection(asan_login_name):
f"EMAS check connection error: {str(e)}\n{frappe.get_traceback()}",
"ƏMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}"}
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
@frappe.whitelist()
@ -968,10 +1148,11 @@ def refresh_amas_session(asan_login_name):
cookies = session_data.get("all_cookies", {})
# Make GET request to EMAS dashboard to get fresh CSRF token
response = requests.get(
response = _amas_http(
"GET",
f"{AMAS_BASE_URL}/core.dashboard",
cookies=cookies,
timeout=30
timeout=30,
)
if response.status_code != 200:
@ -1022,7 +1203,7 @@ def refresh_amas_session(asan_login_name):
f"EMAS refresh session error: {str(e)}\n{frappe.get_traceback()}",
"ƏMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}"}
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
@frappe.whitelist()
@ -1054,7 +1235,7 @@ def disconnect_amas(asan_login_name):
f"EMAS disconnect error: {str(e)}\n{frappe.get_traceback()}",
"ƏMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}"}
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
@frappe.whitelist()
@ -1109,7 +1290,7 @@ def get_connected_asan_logins():
f"Get connected Asan Logins error: {str(e)}\n{frappe.get_traceback()}",
"ƏMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}", "asan_logins": []}
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE), "asan_logins": []}
# Exact list of API fields that map 1:1 to Əmas Employees DocType fields
@ -1297,7 +1478,7 @@ def import_employees(asan_login_name, employees):
f"Import employees error: {str(e)}\n{frappe.get_traceback()}",
"EMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}"}
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
def update_employee_from_data(employee_doc, emp_data):
@ -2042,7 +2223,7 @@ def create_single_employee_from_amas(asan_login_name, emp_data, company, create_
"success": False,
"action": "error",
"full_name": name_val,
"message": error_msg
"message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)
}
@ -2351,7 +2532,7 @@ def create_employees_from_amas(asan_login_name, employees, company, create_desig
f"Create employees from EMAS error: {str(e)}\n{frappe.get_traceback()}",
"EMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}"}
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
# ==================== EMPLOYEE DETAIL API FUNCTIONS ====================
@ -2533,7 +2714,7 @@ def get_employee_detail(asan_login_name, doc_oid, doc_no, doc_type="docType_47",
f"Get employee detail error: {str(e)}\n{frappe.get_traceback()}",
"ƏMAS API Error"
)
return {"success": False, "message": f"Error: {str(e)}"}
return {"success": False, "message": _(AMAS_GENERIC_UNAVAILABLE_MESSAGE)}
def update_employee_from_detail_data(employee_doc, detail_data):

File diff suppressed because it is too large Load Diff

View File

@ -7344,3 +7344,7 @@ msgstr "Черновик в E-Taxes"
#: invoice_az/client/sales_invoice.js:2245
msgid "Loaded from E-Taxes"
msgstr "Загружено из E-Taxes"
#: invoice_az/amas_api.py
msgid "ƏMAS service is currently unavailable. Please try again in a few minutes."
msgstr "Сервис ƏMAS временно недоступен. Пожалуйста, повторите попытку через несколько минут."