Skip to content

Commit c502040

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 17402c5 commit c502040

7 files changed

Lines changed: 837 additions & 9 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: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717

1818
<record id="group_api_v2_create" model="res.groups">
1919
<field name="name">API V2: Create</field>
20-
<field name="comment">Technical group for API V2 create access. Grants permission to create new API clients and scopes.</field>
20+
<field
21+
name="comment"
22+
>Technical group for API V2 create access. Grants permission to create new API clients and scopes.</field>
2123
<field name="implied_ids" eval="[Command.link(ref('group_api_v2_read'))]" />
2224
</record>
2325

@@ -26,27 +28,54 @@
2628
<record id="group_api_v2_viewer" model="res.groups">
2729
<field name="name">Viewer</field>
2830
<field name="privilege_id" ref="privilege_api_v2" />
29-
<field name="comment">Can view API V2 clients, scopes, and consent records. Cannot modify data.</field>
31+
<field
32+
name="comment"
33+
>Can view API V2 clients, scopes, and consent records. Cannot modify data.</field>
3034
<field name="implied_ids" eval="[Command.link(ref('group_api_v2_read'))]" />
3135
</record>
3236

3337
<record id="group_api_v2_officer" model="res.groups">
3438
<field name="name">Officer</field>
3539
<field name="privilege_id" ref="privilege_api_v2" />
36-
<field name="comment">Can view and manage consent records. Can grant/revoke consent but cannot modify API clients.</field>
37-
<field name="implied_ids" eval="[Command.link(ref('group_api_v2_viewer')), Command.link(ref('group_api_v2_write'))]" />
40+
<field
41+
name="comment"
42+
>Can view and manage consent records. Can grant/revoke consent but cannot modify API clients.</field>
43+
<field
44+
name="implied_ids"
45+
eval="[Command.link(ref('group_api_v2_viewer')), Command.link(ref('group_api_v2_write'))]"
46+
/>
3847
</record>
3948

4049
<record id="group_api_v2_manager" model="res.groups">
4150
<field name="name">Manager</field>
4251
<field name="privilege_id" ref="privilege_api_v2" />
43-
<field name="comment">Full control over API V2 configuration including create/modify API clients and manage scopes.</field>
44-
<field name="implied_ids" eval="[Command.link(ref('group_api_v2_officer')), Command.link(ref('group_api_v2_create'))]" />
52+
<field
53+
name="comment"
54+
>Full control over API V2 configuration including create/modify API clients and manage scopes.</field>
55+
<field
56+
name="implied_ids"
57+
eval="[Command.link(ref('group_api_v2_officer')), Command.link(ref('group_api_v2_create'))]"
58+
/>
4559
</record>
4660

47-
<!-- Link manager to admin -->
48-
<record id="spp_security.group_spp_admin" model="res.groups">
49-
<field name="implied_ids" eval="[Command.link(ref('group_api_v2_manager'))]" />
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'))]" />
5069
</record>
5170

71+
<!-- Link manager and auditor to admin -->
72+
<record id="spp_security.group_spp_admin" model="res.groups">
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+
/>
80+
</record>
5281
</odoo>

spp_api_v2/security/privileges.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,10 @@
99
<field name="description">Access to API V2 management system</field>
1010
</record>
1111

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>
1218
</odoo>

0 commit comments

Comments
 (0)