OpenSPP as DCI Server#

This guide is for developers implementing OpenSPP as a DCI server to expose registry data to external systems.

Overview#

When acting as a DCI server, OpenSPP exposes its beneficiary registry to authorized external systems such as:

  • National MIS dashboards (reporting and analytics)

  • Other social protection programs (coordination and deduplication)

  • Research institutions (anonymized data for analysis)

  • Audit systems (compliance verification)

Architecture#

        sequenceDiagram
    participant External as External System
    participant Auth as OAuth2 Endpoint
    participant API as DCI Server
    participant Service as Search Service
    participant DB as res.partner

    External->>Auth: POST /oauth2/client/token
    Auth-->>External: access_token

    External->>API: POST /registry/sync/search<br/>(Authorization: Bearer token)
    API->>Service: execute_search(request)
    Service->>DB: search([domain])
    DB-->>Service: partners
    Service->>Service: map to DCI Person
    Service-->>API: DCISearchResponse
    API-->>External: 200 OK with results
    

Module Structure#

Core Server Modules#

Module

Purpose

spp_dci_server

Base server infrastructure with sync/async search endpoints

spp_dci_server_social

Social Registry-specific implementation

spp_dci_api_server

FastAPI endpoint registration

Key Components#

spp_dci_server/
├── routers/
│   ├── auth.py           # OAuth2 token endpoint
│   ├── registry.py       # Search, subscribe, notify endpoints
│   └── wellknown.py      # JWKS and location metadata
├── services/
│   ├── search_service.py         # Query execution and result mapping
│   ├── subscription_service.py   # Event subscription management
│   └── consent_adapter.py        # Privacy and consent checks
├── models/
│   ├── dci_client.py             # API client credentials
│   ├── dci_transaction.py        # Async transaction tracking
│   └── dci_subscription.py       # Event subscriptions
└── schemas/
    ├── envelope.py       # DCI message envelope
    ├── search.py         # Search request/response schemas
    └── person.py         # Person/Group data schemas

Implementing Subscriptions#

Allow external systems to subscribe to registry events:

# spp_dci_server/routers/registry.py
@router.post("/subscribe", status_code=202)
async def subscribe(
    request: DCISubscribeRequest,
    token: dict = Depends(verify_token),
    env = Depends(get_env)
):
    """Subscribe to registry events"""
    service = SubscriptionService(env)
    subscription_code = service.create_subscription(request)

    return {
        "signature": "",
        "header": {
            "version": "1.0.0",
            "message_id": str(uuid.uuid4()),
            "message_ts": datetime.utcnow().isoformat(),
            "action": "on-subscribe",
            "status": "succ",
            "sender_id": env['ir.config_parameter'].get_param('dci.sender_id'),
            "receiver_id": request.header.sender_id
        },
        "message": {
            "subscription_code": subscription_code,
            "expires": request.message.subscribe_criteria.frequency.end_time
        }
    }

Subscription Service#

# spp_dci_server/services/subscription_service.py
class SubscriptionService:
    """Manage DCI event subscriptions"""

    def create_subscription(self, request: DCISubscribeRequest) -> str:
        """Create new subscription, return subscription_code"""
        subscription = self.env['spp.dci.subscription'].create({
            'subscriber_id': request.header.sender_id,
            'callback_uri': request.header.sender_uri,
            'reg_type': request.message.subscribe_criteria.reg_type,
            'reg_event_type': request.message.subscribe_criteria.reg_event_type,
            'filter_expression': json.dumps(request.message.subscribe_criteria.filter),
            'start_time': request.message.subscribe_criteria.frequency.start_time,
            'end_time': request.message.subscribe_criteria.frequency.end_time,
            'state': 'active'
        })
        return subscription.code

    def notify_subscribers(self, event_type: str, records: list):
        """Triggered by Odoo signals when records change"""
        subscriptions = self.env['spp.dci.subscription'].search([
            ('reg_event_type', '=', event_type),
            ('state', '=', 'active')
        ])

        for sub in subscriptions:
            # Queue notification job
            sub.with_delay(
                channel='root.dci'
            )._send_notification(event_type, records)

Trigger Notifications#

# spp_dci_server/models/res_partner.py
from odoo import models, api

class ResPartnerDCI(models.Model):
    _inherit = 'res.partner'

    @api.model_create_multi
    def create(self, vals_list):
        """Trigger DCI notifications on registrant creation"""
        records = super().create(vals_list)
        registrants = records.filtered(lambda r: r.is_registrant)

        if registrants:
            self.env['spp.dci.subscription']._trigger_notifications(
                'REGISTRATION', registrants
            )

        return records

    def write(self, vals):
        """Trigger DCI notifications on registrant update"""
        result = super().write(vals)

        if self.filtered(lambda r: r.is_registrant):
            self.env['spp.dci.subscription']._trigger_notifications(
                'UPDATE', self
            )

        return result

Testing#

Unit Test: Search Service#

# spp_dci_server/tests/test_search_service.py
from odoo.tests.common import TransactionCase

class TestSearchService(TransactionCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.partner = cls.env['res.partner'].create({
            'name': 'Test Person',
            'given_name': 'John',
            'family_name': 'Doe',
            'birthdate': '1990-01-15',
            'is_registrant': True
        })
        cls.id_type = cls.env['spp.id.type'].create({
            'name': 'National ID',
            'namespace_uri': 'urn:gov:national-id'
        })
        cls.env['spp.registry.id'].create({
            'partner_id': cls.partner.id,
            'id_type_id': cls.id_type.id,
            'value': 'TEST-123456'
        })

    def test_search_by_identifier(self):
        """Test idtype-value search"""
        service = SearchService(self.env, 'test_client')
        request = self._build_search_request(
            query_type='idtype-value',
            query={
                'type': 'urn:gov:national-id',
                'value': 'TEST-123456'
            }
        )
        response = service.execute_search(request)

        self.assertEqual(response.message.search_response[0].status, 'succ')
        records = response.message.search_response[0].data['reg_records']
        self.assertEqual(len(records), 1)
        self.assertEqual(records[0]['name']['given_name'], 'John')

Deployment#

Configuration#

# Environment variables
DCI_SENDER_ID=openspp.example.org
DCI_JWT_SECRET=<secure-random-string>
DCI_SIGNING_KEY_PATH=/etc/openspp/dci_key.pem

# System parameters (via Odoo UI or ir.config_parameter)
dci.sender_id = openspp.example.org
dci.callback_base_url = https://openspp.example.org/api/v2/dci

API Endpoint Registration#

# spp_dci_api_server/__manifest__.py
{
    'name': 'OpenSPP DCI API Server',
    'depends': ['spp_dci_server', 'fastapi'],
    'data': ['data/fastapi_endpoint.xml'],
    'auto_install': True,
}
<!-- spp_dci_api_server/data/fastapi_endpoint.xml -->
<odoo>
    <record id="fastapi_endpoint_dci" model="fastapi.endpoint">
        <field name="name">DCI Registry API</field>
        <field name="root_path">/api/v2/dci</field>
        <field name="app">spp_dci_server.app:app</field>
        <field name="user_id" ref="base.public_user"/>
    </record>
</odoo>

See Also#