Skip to content

Commit 322c56e

Browse files
authored
Merge pull request #39 from OpenSPP/feat/dci-client-improvements
feat(spp_dci_client): DCI client improvements and audit trail logging
2 parents 6438991 + 3ea418e commit 322c56e

File tree

5 files changed

+608
-5
lines changed

5 files changed

+608
-5
lines changed

spp_approval/models/approval_mixin.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,38 @@ def action_approve(self, comment=None):
332332
record._check_can_approve()
333333
record._do_approve(comment=comment)
334334

335+
def _action_approve_system(self, comment=None):
336+
"""System-initiated approval bypassing user permission checks.
337+
338+
Use this for automated approvals triggered by system events (e.g., DCI
339+
verification match, scheduled jobs) where there is no human approver.
340+
341+
This method uses sudo() to bypass access controls and skips the
342+
_check_can_approve() permission validation.
343+
344+
The underscore prefix is intentional — it prevents this method from being
345+
callable via Odoo's RPC interface, since it must not be exposed to users.
346+
347+
Args:
348+
comment: Optional approval comment for audit trail
349+
"""
350+
for record in self:
351+
if record.approval_state != "pending":
352+
_logger.warning(
353+
"Skipping system approval for %s %s: state is %s, not pending",
354+
record._name,
355+
record.id,
356+
record.approval_state,
357+
)
358+
continue
359+
record.sudo()._do_approve(comment=comment, auto=True) # nosemgrep: odoo-sudo-without-context
360+
_logger.info(
361+
"System auto-approved %s %s: %s",
362+
record._name,
363+
record.id,
364+
comment or "(no comment)",
365+
)
366+
335367
def _do_approve(self, comment=None, auto=False):
336368
"""Internal method to perform approval."""
337369
self.ensure_one()

spp_dci_client/models/data_source.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -326,10 +326,12 @@ def get_oauth2_token(self, force_refresh=False):
326326
_logger.info("Requesting new OAuth2 token for data source: %s", self.code)
327327

328328
try:
329+
# Use sudo() to access OAuth2 credentials which are restricted to administrators
330+
sudo_self = self.sudo() # nosemgrep: odoo-sudo-without-context
329331
token_data = {
330332
"grant_type": "client_credentials",
331-
"client_id": self.oauth2_client_id,
332-
"client_secret": self.oauth2_client_secret,
333+
"client_id": sudo_self.oauth2_client_id,
334+
"client_secret": sudo_self.oauth2_client_secret,
333335
}
334336

335337
if self.oauth2_scope:
@@ -358,9 +360,9 @@ def get_oauth2_token(self, force_refresh=False):
358360
if not access_token:
359361
raise UserError(_("OAuth2 token response did not contain access_token"))
360362

361-
# Cache token with expiry
363+
# Cache token with expiry (use sudo to write to restricted model)
362364
expires_at = now + timedelta(seconds=expires_in)
363-
self.write(
365+
sudo_self.write(
364366
{
365367
"_oauth2_access_token": access_token,
366368
"_oauth2_token_expires_at": expires_at,

spp_dci_client/services/client.py

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""DCI Client Service for making signed API requests."""
22

3+
import json
34
import logging
5+
import time
46
import uuid
57
from datetime import UTC, datetime
68
from typing import Any
@@ -904,6 +906,13 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
904906
# Get headers from data source (includes auth)
905907
headers = self.data_source.get_headers()
906908

909+
# Track timing and result for outgoing log
910+
start_time = time.monotonic()
911+
log_status = "success"
912+
log_status_code = None
913+
log_response_data = None
914+
log_error_detail = None
915+
907916
try:
908917
_logger.info(
909918
"Making DCI request to %s (action: %s)",
@@ -930,6 +939,13 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
930939
# Handle 401 Unauthorized - try refreshing token once
931940
if response.status_code == 401 and _retry_auth and self.data_source.auth_type == "oauth2":
932941
_logger.warning("Got 401 Unauthorized, clearing OAuth2 token cache and retrying with fresh token")
942+
log_status = "http_error"
943+
log_status_code = 401
944+
log_error_detail = "401 Unauthorized - retrying with fresh token"
945+
try:
946+
log_response_data = response.json()
947+
except json.JSONDecodeError:
948+
log_response_data = None
933949
self.data_source.clear_oauth2_token_cache()
934950
return self._make_request(endpoint, envelope, _retry_auth=False)
935951

@@ -938,6 +954,8 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
938954

939955
# Parse response
940956
response_data = response.json()
957+
log_status_code = response.status_code
958+
log_response_data = response_data
941959

942960
_logger.info(
943961
"DCI request successful (status: %s, message_id: %s)",
@@ -949,18 +967,23 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
949967
return response_data
950968

951969
except httpx.HTTPStatusError as e:
970+
log_status = "http_error"
971+
log_status_code = e.response.status_code
972+
952973
# Log technical details for troubleshooting
953974
technical_detail = f"DCI request failed with status {e.response.status_code}"
954975
response_text = e.response.text
955976
try:
956977
error_data = e.response.json()
978+
log_response_data = error_data
957979
if "header" in error_data and "status_reason_message" in error_data["header"]:
958980
technical_detail += f": {error_data['header']['status_reason_message']}"
959981
else:
960982
technical_detail += f": {response_text}"
961-
except Exception:
983+
except (json.JSONDecodeError, KeyError, TypeError):
962984
technical_detail += f": {response_text}"
963985

986+
log_error_detail = technical_detail
964987
_logger.error(technical_detail)
965988
_logger.error("Full response body: %s", response_text)
966989
_logger.error("Request envelope was: %s", envelope)
@@ -978,26 +1001,126 @@ def _make_request(self, endpoint: str, envelope: dict, _retry_auth: bool = True)
9781001
error_str = str(e).lower()
9791002
if "timeout" in error_str or "timed out" in error_str:
9801003
connection_type = "timeout"
1004+
log_status = "timeout"
9811005
elif "ssl" in error_str or "certificate" in error_str:
9821006
connection_type = "ssl"
1007+
log_status = "connection_error"
9831008
elif "name or service not known" in error_str or "nodename nor servname" in error_str:
9841009
connection_type = "dns"
1010+
log_status = "connection_error"
9851011
else:
9861012
connection_type = "connection"
1013+
log_status = "connection_error"
1014+
1015+
log_error_detail = technical_detail
9871016

9881017
# Show user-friendly message
9891018
user_msg = format_connection_error(connection_type, technical_detail)
9901019
raise UserError(user_msg) from e
9911020

9921021
except Exception as e:
1022+
log_status = "error"
1023+
9931024
# Log technical details for troubleshooting
9941025
technical_detail = f"Unexpected error during DCI request: {str(e)}"
1026+
log_error_detail = technical_detail
9951027
_logger.error(technical_detail, exc_info=True)
9961028

9971029
# Show generic user-friendly message
9981030
user_msg = _("An unexpected error occurred. Please contact your administrator.")
9991031
raise UserError(user_msg) from e
10001032

1033+
finally:
1034+
duration_ms = int((time.monotonic() - start_time) * 1000)
1035+
self._log_outgoing_call(
1036+
url=url,
1037+
endpoint=endpoint,
1038+
envelope=envelope,
1039+
response_data=log_response_data,
1040+
status_code=log_status_code,
1041+
duration_ms=duration_ms,
1042+
status=log_status,
1043+
error_detail=log_error_detail,
1044+
)
1045+
1046+
# =========================================================================
1047+
# OUTGOING LOG
1048+
# =========================================================================
1049+
1050+
def _log_outgoing_call(
1051+
self,
1052+
url: str,
1053+
endpoint: str,
1054+
envelope: dict,
1055+
response_data: dict | None,
1056+
status_code: int | None,
1057+
duration_ms: int,
1058+
status: str,
1059+
error_detail: str | None,
1060+
):
1061+
"""Log an outgoing API call to spp.api.outgoing.log (soft dependency).
1062+
1063+
Uses runtime check to avoid hard manifest dependency on spp_api_v2.
1064+
Uses a separate database cursor so log entries persist even when the
1065+
caller's transaction is rolled back (e.g., on UserError).
1066+
Logging failures are swallowed so they never block the actual request.
1067+
"""
1068+
try:
1069+
if "spp.api.outgoing.log" not in self.env:
1070+
return
1071+
1072+
from odoo.addons.spp_api_v2.services.outgoing_api_log_service import OutgoingApiLogService
1073+
1074+
# Capture values from the current env before opening a new cursor,
1075+
# since self.data_source won't be accessible from the new cursor's env.
1076+
service_code = getattr(self.data_source, "code", None) or "dci"
1077+
origin_record_id = self.data_source.id if hasattr(self.data_source, "id") else None
1078+
user_id = self.env.uid
1079+
sanitized_envelope = self._copy_envelope_for_log(envelope)
1080+
1081+
# Use a separate cursor so log entries survive transaction rollback.
1082+
with self.env.registry.cursor() as new_cr:
1083+
new_env = self.env(cr=new_cr)
1084+
service = OutgoingApiLogService(
1085+
new_env,
1086+
service_name="DCI Client",
1087+
service_code=service_code,
1088+
user_id=user_id,
1089+
)
1090+
1091+
service.log_call(
1092+
url=url,
1093+
endpoint=endpoint,
1094+
http_method="POST",
1095+
request_summary=sanitized_envelope,
1096+
response_summary=response_data,
1097+
response_status_code=status_code,
1098+
duration_ms=duration_ms,
1099+
origin_model="spp.dci.data.source",
1100+
origin_record_id=origin_record_id,
1101+
status=status,
1102+
error_detail=error_detail,
1103+
)
1104+
except Exception:
1105+
_logger.warning("Failed to log outgoing API call (non-blocking)", exc_info=True)
1106+
1107+
def _copy_envelope_for_log(self, envelope: dict) -> dict | None:
1108+
"""Copy the request envelope for audit log storage.
1109+
1110+
Returns a shallow copy suitable for JSON storage. The cryptographic
1111+
signature is preserved for auditability (non-repudiation).
1112+
1113+
Args:
1114+
envelope: Request envelope dict
1115+
1116+
Returns:
1117+
Copy of the envelope, or None if input is falsy
1118+
"""
1119+
if not envelope:
1120+
return None
1121+
1122+
return dict(envelope)
1123+
10011124
# =========================================================================
10021125
# HELPER METHODS
10031126
# =========================================================================

spp_dci_client/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33

44
from . import test_data_source
55
from . import test_client_service
6+
from . import test_outgoing_log_integration

0 commit comments

Comments
 (0)