Skip to content

Commit ef62a5b

Browse files
authored
Merge pull request #38 from OpenSPP/feat/api-outgoing-log
feat(spp_api_v2): add outgoing API log model, service, and auditor security
2 parents aca13d9 + f5fbed7 commit ef62a5b

48 files changed

Lines changed: 4762 additions & 94 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

spp_api_v2/__manifest__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"views/api_extension_views.xml",
3838
"views/api_path_views.xml",
3939
"views/consent_views.xml",
40+
"views/api_outgoing_log_views.xml",
4041
"views/menu.xml",
4142
],
4243
"assets": {},

spp_api_v2/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from . import api_audit_log
22
from . import api_client
3+
from . import api_outgoing_log
34
from . import api_client_scope
45
from . import api_extension
56
from . import api_filter_preset

spp_api_v2/models/api_audit_log.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,13 @@ class ApiAuditLog(models.Model):
4646

4747
ip_address = fields.Char(
4848
string="IP Address",
49+
groups="spp_api_v2.group_api_v2_auditor",
4950
help="Client IP address",
5051
)
5152

5253
user_agent = fields.Char(
5354
string="User Agent",
55+
groups="spp_api_v2.group_api_v2_auditor",
5456
help="Client user agent string",
5557
)
5658

@@ -122,6 +124,7 @@ class ApiAuditLog(models.Model):
122124
# ==========================================
123125
# For search operations
124126
search_parameters = fields.Json(
127+
groups="spp_api_v2.group_api_v2_auditor",
125128
help="Search parameters used (for search/export operations)",
126129
)
127130

@@ -131,10 +134,12 @@ class ApiAuditLog(models.Model):
131134

132135
# For read operations with field filtering
133136
fields_returned = fields.Json(
137+
groups="spp_api_v2.group_api_v2_auditor",
134138
help="List of fields returned in response (for _elements filtering)",
135139
)
136140

137141
extensions_returned = fields.Json(
142+
groups="spp_api_v2.group_api_v2_auditor",
138143
help="List of extensions returned in response",
139144
)
140145

@@ -172,6 +177,7 @@ class ApiAuditLog(models.Model):
172177
)
173178

174179
error_detail = fields.Char(
180+
groups="spp_api_v2.group_api_v2_auditor",
175181
help="Error message (no PII)",
176182
)
177183

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
"""Audit log for outgoing API calls to external services."""
3+
4+
import logging
5+
6+
from odoo import api, fields, models
7+
8+
_logger = logging.getLogger(__name__)
9+
10+
11+
class ApiOutgoingLog(models.Model):
12+
"""
13+
Audit log for outgoing HTTP calls to external services.
14+
15+
Captures all outgoing API requests (DCI, webhooks, etc.) with
16+
request/response details for troubleshooting and compliance.
17+
"""
18+
19+
_name = "spp.api.outgoing.log"
20+
_description = "Outgoing API Log"
21+
_order = "timestamp desc"
22+
_rec_name = "display_name"
23+
24+
# ==========================================
25+
# Request - WHAT was sent
26+
# ==========================================
27+
url = fields.Char(
28+
required=True,
29+
index=True,
30+
groups="spp_api_v2.group_api_v2_auditor",
31+
help="Full URL called (may contain sensitive query parameters)",
32+
)
33+
34+
endpoint = fields.Char(
35+
index=True,
36+
help="Path portion of the URL, e.g. /registry/sync/search",
37+
)
38+
39+
http_method = fields.Selection(
40+
[
41+
("POST", "POST"),
42+
("GET", "GET"),
43+
("PUT", "PUT"),
44+
("PATCH", "PATCH"),
45+
("DELETE", "DELETE"),
46+
],
47+
default="POST",
48+
required=True,
49+
)
50+
51+
request_summary = fields.Json(
52+
groups="spp_api_v2.group_api_v2_auditor",
53+
help="Request payload (secrets redacted)",
54+
)
55+
56+
# ==========================================
57+
# Response - WHAT came back
58+
# ==========================================
59+
response_summary = fields.Json(
60+
groups="spp_api_v2.group_api_v2_auditor",
61+
help="Response payload",
62+
)
63+
64+
response_status_code = fields.Integer(
65+
index=True,
66+
)
67+
68+
# ==========================================
69+
# Context - WHO triggered it
70+
# ==========================================
71+
user_id = fields.Many2one(
72+
"res.users",
73+
default=lambda self: self.env.uid,
74+
index=True,
75+
)
76+
77+
origin_model = fields.Char(
78+
index=True,
79+
help="Model that triggered the call, e.g. spp.dci.data.source",
80+
)
81+
82+
origin_record_id = fields.Integer(
83+
help="Record ID that triggered the call",
84+
)
85+
86+
# ==========================================
87+
# Timestamps & Performance
88+
# ==========================================
89+
timestamp = fields.Datetime(
90+
required=True,
91+
default=fields.Datetime.now,
92+
index=True,
93+
)
94+
95+
duration_ms = fields.Integer(
96+
help="Request duration in milliseconds",
97+
)
98+
99+
# ==========================================
100+
# Service Context
101+
# ==========================================
102+
service_name = fields.Char(
103+
index=True,
104+
help="Human-readable service name, e.g. DCI Client",
105+
)
106+
107+
service_code = fields.Char(
108+
index=True,
109+
help="Machine-readable service code, e.g. crvs_main",
110+
)
111+
112+
# ==========================================
113+
# Result
114+
# ==========================================
115+
status = fields.Selection(
116+
[
117+
("success", "Success"),
118+
("http_error", "HTTP Error"),
119+
("connection_error", "Connection Error"),
120+
("timeout", "Timeout"),
121+
("error", "Error"),
122+
],
123+
default="success",
124+
required=True,
125+
index=True,
126+
)
127+
128+
error_detail = fields.Text(
129+
groups="spp_api_v2.group_api_v2_auditor",
130+
help="Error message or traceback",
131+
)
132+
133+
# ==========================================
134+
# Computed fields
135+
# ==========================================
136+
display_name = fields.Char(
137+
compute="_compute_display_name",
138+
store=True,
139+
)
140+
141+
@api.depends("http_method", "endpoint", "url", "timestamp")
142+
def _compute_display_name(self):
143+
for record in self:
144+
timestamp_str = record.timestamp.strftime("%Y-%m-%d %H:%M") if record.timestamp else ""
145+
path = record.endpoint or record.url or "API Call"
146+
record.display_name = f"{record.http_method} {path} @ {timestamp_str}"
147+
148+
# ==========================================
149+
# API Methods
150+
# ==========================================
151+
@api.model
152+
def log_call(
153+
self,
154+
url: str,
155+
http_method: str = "POST",
156+
endpoint: str = None,
157+
request_summary: dict = None,
158+
response_summary: dict = None,
159+
response_status_code: int = None,
160+
user_id: int = None,
161+
origin_model: str = None,
162+
origin_record_id: int = None,
163+
duration_ms: int = None,
164+
service_name: str = None,
165+
service_code: str = None,
166+
status: str = "success",
167+
error_detail: str = None,
168+
):
169+
"""
170+
Log an outgoing API call.
171+
172+
Args:
173+
url: Full URL called
174+
http_method: HTTP method (POST, GET, PUT, PATCH, DELETE)
175+
endpoint: Path portion of the URL
176+
request_summary: Request payload (secrets redacted)
177+
response_summary: Response payload
178+
response_status_code: HTTP response status code
179+
user_id: User who triggered the call
180+
origin_model: Odoo model that triggered the call
181+
origin_record_id: Record ID that triggered the call
182+
duration_ms: Request duration in milliseconds
183+
service_name: Human-readable service name
184+
service_code: Machine-readable service code
185+
status: Result status
186+
error_detail: Error message or traceback
187+
188+
Returns:
189+
Created spp.api.outgoing.log record
190+
"""
191+
vals = {
192+
"url": url,
193+
"http_method": http_method,
194+
"status": status,
195+
"timestamp": fields.Datetime.now(),
196+
}
197+
198+
# Optional fields
199+
if endpoint:
200+
vals["endpoint"] = endpoint
201+
if request_summary is not None:
202+
vals["request_summary"] = request_summary
203+
if response_summary is not None:
204+
vals["response_summary"] = response_summary
205+
if response_status_code is not None:
206+
vals["response_status_code"] = response_status_code
207+
if user_id is not None:
208+
vals["user_id"] = user_id
209+
if origin_model:
210+
vals["origin_model"] = origin_model
211+
if origin_record_id is not None:
212+
vals["origin_record_id"] = origin_record_id
213+
if duration_ms is not None:
214+
vals["duration_ms"] = duration_ms
215+
if service_name:
216+
vals["service_name"] = service_name
217+
if service_code:
218+
vals["service_code"] = service_code
219+
if error_detail:
220+
vals["error_detail"] = error_detail
221+
222+
return self.create(vals)

spp_api_v2/security/groups.xml

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,24 @@
5858
/>
5959
</record>
6060

61-
<!-- Link manager to admin -->
61+
<!-- Standalone auditor group (opt-in checkbox, not part of privilege radio) -->
62+
<record id="group_api_v2_auditor" model="res.groups">
63+
<field name="name">API V2: Auditor</field>
64+
<field name="privilege_id" ref="privilege_api_v2_auditor" />
65+
<field
66+
name="comment"
67+
>Can view sensitive payload data in API logs (request/response bodies, search parameters, IP addresses). Implies Viewer for menu access.</field>
68+
<field name="implied_ids" eval="[Command.link(ref('group_api_v2_viewer'))]" />
69+
</record>
70+
71+
<!-- Link manager and auditor to admin -->
6272
<record id="spp_security.group_spp_admin" model="res.groups">
63-
<field name="implied_ids" eval="[Command.link(ref('group_api_v2_manager'))]" />
73+
<field
74+
name="implied_ids"
75+
eval="[
76+
Command.link(ref('group_api_v2_manager')),
77+
Command.link(ref('group_api_v2_auditor')),
78+
]"
79+
/>
6480
</record>
6581
</odoo>

spp_api_v2/security/ir.model.access.csv

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,6 @@ access_spp_api_client_show_secret_wizard_admin,spp.api.client.show.secret.wizard
5757
access_spp_api_audit_log_viewer,spp.api.audit.log viewer,model_spp_api_audit_log,group_api_v2_viewer,1,0,0,0
5858
access_spp_api_audit_log_officer,spp.api.audit.log officer,model_spp_api_audit_log,group_api_v2_officer,1,0,1,0
5959
access_spp_api_audit_log_manager,spp.api.audit.log manager,model_spp_api_audit_log,group_api_v2_manager,1,0,1,0
60+
access_spp_api_outgoing_log_viewer,spp.api.outgoing.log viewer,model_spp_api_outgoing_log,group_api_v2_viewer,1,0,0,0
61+
access_spp_api_outgoing_log_officer,spp.api.outgoing.log officer,model_spp_api_outgoing_log,group_api_v2_officer,1,0,1,0
62+
access_spp_api_outgoing_log_manager,spp.api.outgoing.log manager,model_spp_api_outgoing_log,group_api_v2_manager,1,0,1,0

spp_api_v2/security/privileges.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,11 @@
88
<field name="category_id" ref="spp_security.category_spp_api" />
99
<field name="description">Access to API V2 management system</field>
1010
</record>
11+
12+
<!-- Standalone privilege for auditor (renders as checkbox since only one group) -->
13+
<record id="privilege_api_v2_auditor" model="res.groups.privilege">
14+
<field name="name">API V2 Auditor</field>
15+
<field name="category_id" ref="spp_security.category_spp_api" />
16+
<field name="description">View sensitive payload data in API logs</field>
17+
</record>
1118
</odoo>

spp_api_v2/services/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from . import api_audit_service
22
from . import auth_service
3+
from . import outgoing_api_log_service
34
from . import bundle_service
45
from . import consent_service
56
from . import filter_service

0 commit comments

Comments
 (0)