Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f0b8178
feat(spp_api_v2): add auditor security group for API log payloads
jeremi Feb 13, 2026
d0916c4
feat(spp_api_v2): add outgoing API log model, service, and menu
jeremi Feb 13, 2026
e36e14f
fix(spp_api_v2): add payload masking and URL sanitization to outgoing…
jeremi Feb 18, 2026
6438991
fix(spp_api_v2): remove url fallback from display_name to prevent sec…
jeremi Feb 18, 2026
028e4e7
fix(dci): allow non-admin users to use DCI verification
jeremi Feb 7, 2026
7bcc8e4
feat(spp_approval): add action_approve_system() for automated approvals
jeremi Feb 7, 2026
6507a5a
feat(spp_dci_client): log outgoing DCI requests to audit trail
jeremi Feb 13, 2026
b14a931
fix(spp_approval,spp_dci_client): prevent RPC exposure of system appr…
jeremi Feb 18, 2026
3ea418e
fix(spp_approval,spp_dci_client): add auto=True to system approval an…
jeremi Feb 18, 2026
c8937ac
feat(dci-demo): add DCI birth verification demo module
jeremi Feb 6, 2026
e09ac76
fix(dci-demo): handle reg_records in OpenCRVS SPDCI response format
jeremi Feb 7, 2026
65e5c98
fix(dci-demo): add auto-approve to wizard flow
jeremi Feb 7, 2026
64b4d6e
fix(dci-demo): hide data source selector when only one CRVS registry …
jeremi Feb 8, 2026
5fb4be8
refactor(spp_dci_demo): remove wizard, consolidate auto-approval in _…
jeremi Feb 9, 2026
43850e3
feat(spp_dci_demo,spp_mis_demo_v2): add Conditional Child Grant and d…
jeremi Feb 13, 2026
7ee52e1
fix(spp_dci_demo): invalidate membership cache before enrollment and …
jeremi Feb 18, 2026
b7ecdf8
fix(spp_dci_demo): remove deprecated manifest keys, fix XML IDs and s…
jeremi Feb 18, 2026
76e158e
fix(spp_dci_demo): fix XML ID naming and remaining semgrep finding
jeremi Feb 18, 2026
a9a731d
chore(spp_dci_demo): add auto-generated pyproject.toml from whool hook
jeremi Feb 18, 2026
322c56e
Merge pull request #39 from OpenSPP/feat/dci-client-improvements
emjay0921 Feb 20, 2026
eeeb530
Merge pull request #40 from OpenSPP/feat/dci-demo-module
emjay0921 Feb 20, 2026
f5fbed7
fix(spp_api_v2): fix 3 failing outgoing log tests
emjay0921 Feb 20, 2026
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
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
222 changes: 222 additions & 0 deletions spp_api_v2/models/api_outgoing_log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
# 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,
groups="spp_api_v2.group_api_v2_auditor",
help="Full URL called (may contain sensitive query parameters)",
)
Comment on lines 27 to 32

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The url field stores the full URL of outgoing API calls, which may contain sensitive information such as API keys or tokens in query parameters. Unlike the payload fields, the url field is not restricted to the group_api_v2_auditor group, making it visible to any user with 'viewer' access to the logs. Consider adding groups="spp_api_v2.group_api_v2_auditor" to this field or implementing logic to strip sensitive query parameters before logging.


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", "url", "timestamp")
def _compute_display_name(self):
for record in self:
timestamp_str = record.timestamp.strftime("%Y-%m-%d %H:%M") if record.timestamp else ""
path = record.endpoint or record.url or "API Call"
record.display_name = f"{record.http_method} {path} @ {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>
1 change: 1 addition & 0 deletions spp_api_v2/services/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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
Expand Down
Loading
Loading