Skip to content

Commit f0b8178

Browse files
committed
feat(spp_api_v2): add auditor security group for API log payloads
Restrict sensitive PII fields in API logs to a dedicated group_api_v2_auditor group. Regular viewers can see log metadata (timestamp, endpoint, status, duration) but not payloads or error details. - Add privilege_api_v2_auditor and group_api_v2_auditor - Auditor implies Viewer; Admin implies Auditor - Restrict outgoing log: request_summary, response_summary, error_detail - Restrict audit log: search_parameters, fields_returned, extensions_returned, ip_address, user_agent, error_detail - Hide payload pages and error_detail in outgoing log form view - Add field-level security tests for both models
1 parent 5ac7496 commit f0b8178

7 files changed

Lines changed: 818 additions & 2 deletions

File tree

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

0 commit comments

Comments
 (0)