Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,7 @@ docker/.env.production
.actrc
scripts/run_ci_local.sh
.github/workflows/ci-local.yml


# DCI demo credentials (local only)
spp_dci_demo/data/dci_data_source.xml
1 change: 1 addition & 0 deletions spp_api_v2/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"views/api_extension_views.xml",
"views/api_path_views.xml",
"views/consent_views.xml",
"views/api_outgoing_log_views.xml",
"views/menu.xml",
],
"assets": {},
Expand Down
1 change: 1 addition & 0 deletions spp_api_v2/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from . import api_audit_log
from . import api_client
from . import api_outgoing_log
from . import api_client_scope
from . import api_extension
from . import api_filter_preset
Expand Down
6 changes: 6 additions & 0 deletions spp_api_v2/models/api_audit_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,13 @@ class ApiAuditLog(models.Model):

ip_address = fields.Char(
string="IP Address",
groups="spp_api_v2.group_api_v2_auditor",
help="Client IP address",
)

user_agent = fields.Char(
string="User Agent",
groups="spp_api_v2.group_api_v2_auditor",
help="Client user agent string",
)

Expand Down Expand Up @@ -122,6 +124,7 @@ class ApiAuditLog(models.Model):
# ==========================================
# For search operations
search_parameters = fields.Json(
groups="spp_api_v2.group_api_v2_auditor",
help="Search parameters used (for search/export operations)",
)

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

# For read operations with field filtering
fields_returned = fields.Json(
groups="spp_api_v2.group_api_v2_auditor",
help="List of fields returned in response (for _elements filtering)",
)

extensions_returned = fields.Json(
groups="spp_api_v2.group_api_v2_auditor",
help="List of extensions returned in response",
)

Expand Down Expand Up @@ -172,6 +177,7 @@ class ApiAuditLog(models.Model):
)

error_detail = fields.Char(
groups="spp_api_v2.group_api_v2_auditor",
help="Error message (no PII)",
)

Expand Down
220 changes: 220 additions & 0 deletions spp_api_v2/models/api_outgoing_log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
"""Audit log for outgoing API calls to external services."""

import logging

from odoo import api, fields, models

_logger = logging.getLogger(__name__)


class ApiOutgoingLog(models.Model):
"""
Audit log for outgoing HTTP calls to external services.

Captures all outgoing API requests (DCI, webhooks, etc.) with
request/response details for troubleshooting and compliance.
"""

_name = "spp.api.outgoing.log"
_description = "Outgoing API Log"
_order = "timestamp desc"
_rec_name = "display_name"

# ==========================================
# Request - WHAT was sent
# ==========================================
url = fields.Char(
required=True,
index=True,
help="Full URL called",
)

endpoint = fields.Char(
index=True,
help="Path portion of the URL, e.g. /registry/sync/search",
)

http_method = fields.Selection(
[
("POST", "POST"),
("GET", "GET"),
("PUT", "PUT"),
("PATCH", "PATCH"),
("DELETE", "DELETE"),
],
default="POST",
required=True,
)

request_summary = fields.Json(
groups="spp_api_v2.group_api_v2_auditor",
help="Request payload (secrets redacted)",
)

# ==========================================
# Response - WHAT came back
# ==========================================
response_summary = fields.Json(
groups="spp_api_v2.group_api_v2_auditor",
help="Response payload",
)

response_status_code = fields.Integer(
index=True,
)

# ==========================================
# Context - WHO triggered it
# ==========================================
user_id = fields.Many2one(
"res.users",
default=lambda self: self.env.uid,
index=True,
)

origin_model = fields.Char(
index=True,
help="Model that triggered the call, e.g. spp.dci.data.source",
)

origin_record_id = fields.Integer(
help="Record ID that triggered the call",
)

# ==========================================
# Timestamps & Performance
# ==========================================
timestamp = fields.Datetime(
required=True,
default=fields.Datetime.now,
index=True,
)

duration_ms = fields.Integer(
help="Request duration in milliseconds",
)

# ==========================================
# Service Context
# ==========================================
service_name = fields.Char(
index=True,
help="Human-readable service name, e.g. DCI Client",
)

service_code = fields.Char(
index=True,
help="Machine-readable service code, e.g. crvs_main",
)

# ==========================================
# Result
# ==========================================
status = fields.Selection(
[
("success", "Success"),
("http_error", "HTTP Error"),
("connection_error", "Connection Error"),
("timeout", "Timeout"),
("error", "Error"),
],
default="success",
required=True,
index=True,
)

error_detail = fields.Text(
groups="spp_api_v2.group_api_v2_auditor",
help="Error message or traceback",
)

# ==========================================
# Computed fields
# ==========================================
display_name = fields.Char(
compute="_compute_display_name",
store=True,
)

@api.depends("http_method", "endpoint", "timestamp")
def _compute_display_name(self):
for record in self:
timestamp_str = record.timestamp.strftime("%Y-%m-%d %H:%M") if record.timestamp else ""
record.display_name = f"{record.http_method} {record.endpoint or record.url} @ {timestamp_str}"

# ==========================================
# API Methods
# ==========================================
@api.model
def log_call(
self,
url: str,
http_method: str = "POST",
endpoint: str = None,
request_summary: dict = None,
response_summary: dict = None,
response_status_code: int = None,
user_id: int = None,
origin_model: str = None,
origin_record_id: int = None,
duration_ms: int = None,
service_name: str = None,
service_code: str = None,
status: str = "success",
error_detail: str = None,
):
"""
Log an outgoing API call.

Args:
url: Full URL called
http_method: HTTP method (POST, GET, PUT, PATCH, DELETE)
endpoint: Path portion of the URL
request_summary: Request payload (secrets redacted)
response_summary: Response payload
response_status_code: HTTP response status code
user_id: User who triggered the call
origin_model: Odoo model that triggered the call
origin_record_id: Record ID that triggered the call
duration_ms: Request duration in milliseconds
service_name: Human-readable service name
service_code: Machine-readable service code
status: Result status
error_detail: Error message or traceback

Returns:
Created spp.api.outgoing.log record
"""
vals = {
"url": url,
"http_method": http_method,
"status": status,
"timestamp": fields.Datetime.now(),
}

# Optional fields
if endpoint:
vals["endpoint"] = endpoint
if request_summary is not None:
vals["request_summary"] = request_summary
if response_summary is not None:
vals["response_summary"] = response_summary
if response_status_code is not None:
vals["response_status_code"] = response_status_code
if user_id is not None:
vals["user_id"] = user_id
if origin_model:
vals["origin_model"] = origin_model
if origin_record_id is not None:
vals["origin_record_id"] = origin_record_id
if duration_ms is not None:
vals["duration_ms"] = duration_ms
if service_name:
vals["service_name"] = service_name
if service_code:
vals["service_code"] = service_code
if error_detail:
vals["error_detail"] = error_detail

return self.create(vals)
20 changes: 18 additions & 2 deletions spp_api_v2/security/groups.xml
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,24 @@
/>
</record>

<!-- Link manager to admin -->
<!-- Standalone auditor group (opt-in checkbox, not part of privilege radio) -->
<record id="group_api_v2_auditor" model="res.groups">
<field name="name">API V2: Auditor</field>
<field name="privilege_id" ref="privilege_api_v2_auditor" />
<field
name="comment"
>Can view sensitive payload data in API logs (request/response bodies, search parameters, IP addresses). Implies Viewer for menu access.</field>
<field name="implied_ids" eval="[Command.link(ref('group_api_v2_viewer'))]" />
</record>

<!-- Link manager and auditor to admin -->
<record id="spp_security.group_spp_admin" model="res.groups">
<field name="implied_ids" eval="[Command.link(ref('group_api_v2_manager'))]" />
<field
name="implied_ids"
eval="[
Command.link(ref('group_api_v2_manager')),
Command.link(ref('group_api_v2_auditor')),
]"
/>
</record>
</odoo>
3 changes: 3 additions & 0 deletions spp_api_v2/security/ir.model.access.csv
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,6 @@ access_spp_api_client_show_secret_wizard_admin,spp.api.client.show.secret.wizard
access_spp_api_audit_log_viewer,spp.api.audit.log viewer,model_spp_api_audit_log,group_api_v2_viewer,1,0,0,0
access_spp_api_audit_log_officer,spp.api.audit.log officer,model_spp_api_audit_log,group_api_v2_officer,1,0,1,0
access_spp_api_audit_log_manager,spp.api.audit.log manager,model_spp_api_audit_log,group_api_v2_manager,1,0,1,0
access_spp_api_outgoing_log_viewer,spp.api.outgoing.log viewer,model_spp_api_outgoing_log,group_api_v2_viewer,1,0,0,0
access_spp_api_outgoing_log_officer,spp.api.outgoing.log officer,model_spp_api_outgoing_log,group_api_v2_officer,1,0,1,0
access_spp_api_outgoing_log_manager,spp.api.outgoing.log manager,model_spp_api_outgoing_log,group_api_v2_manager,1,0,1,0
7 changes: 7 additions & 0 deletions spp_api_v2/security/privileges.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,11 @@
<field name="category_id" ref="spp_security.category_spp_api" />
<field name="description">Access to API V2 management system</field>
</record>

<!-- Standalone privilege for auditor (renders as checkbox since only one group) -->
<record id="privilege_api_v2_auditor" model="res.groups.privilege">
<field name="name">API V2 Auditor</field>
<field name="category_id" ref="spp_security.category_spp_api" />
<field name="description">View sensitive payload data in API logs</field>
</record>
</odoo>
2 changes: 2 additions & 0 deletions spp_api_v2/services/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from . import api_audit_service
from . import auth_service
from . import outgoing_api_log_service
from . import bundle_service
from . import consent_service
from . import filter_service
from . import group_service
from . import individual_service
from . import program_membership_service
from . import program_service
from . import schema_builder
from . import search_service
Loading
Loading