Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
3 changes: 0 additions & 3 deletions spp_api_v2/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
"spp_registry",
"spp_consent",
"spp_vocabulary",
"spp_programs",
"spp_source_tracking",
],
"data": [
Expand All @@ -30,8 +29,6 @@
"data/api_path_data.xml",
"data/filter_config_individual.xml",
"data/filter_config_group.xml",
"data/filter_config_program.xml",
"data/filter_config_program_membership.xml",
"wizards/show_secret_wizard_views.xml",
"views/api_client_views.xml",
"views/api_extension_views.xml",
Expand Down
20 changes: 0 additions & 20 deletions spp_api_v2/data/api_path_data.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,24 +31,4 @@
name="filter_domain"
>[('is_registrant', '=', True), ('is_group', '=', True)]</field>
</record>

<!-- Program Resource Path -->
<record id="api_path_program" model="spp.api.path">
<field name="sequence">30</field>
<field name="name">Program</field>
<field name="model_id" ref="spp_programs.model_spp_program" />
<field name="description">Social protection programs</field>
<field name="allow_custom_filters">False</field>
<field name="max_filter_complexity">10</field>
</record>

<!-- ProgramMembership Resource Path -->
<record id="api_path_program_membership" model="spp.api.path">
<field name="sequence">40</field>
<field name="name">ProgramMembership</field>
<field name="model_id" ref="spp_programs.model_spp_program_membership" />
<field name="description">Beneficiary enrollments in programs</field>
<field name="allow_custom_filters">False</field>
<field name="max_filter_complexity">10</field>
</record>
</odoo>
8 changes: 0 additions & 8 deletions spp_api_v2/models/fastapi_endpoint_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,11 @@ def _get_fastapi_routers(self) -> list[APIRouter]:
from ..routers.filter import (
group_filter_router,
individual_filter_router,
program_filter_router,
program_membership_filter_router,
)
from ..routers.group import group_router
from ..routers.individual import individual_router
from ..routers.metadata import metadata_router
from ..routers.oauth import oauth_router
from ..routers.program import program_router
from ..routers.program_membership import program_membership_router

routers.extend(
[
Expand All @@ -49,10 +45,6 @@ def _get_fastapi_routers(self) -> list[APIRouter]:
group_filter_router,
batch_router,
bulk_router,
program_router,
program_filter_router,
program_membership_router,
program_membership_filter_router,
consent_router,
]
)
Expand Down
2 changes: 0 additions & 2 deletions spp_api_v2/routers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,3 @@
from . import individual
from . import metadata
from . import oauth
from . import program
from . import program_membership
61 changes: 2 additions & 59 deletions spp_api_v2/routers/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,17 @@
from ..services.filter_service import FilterService
from ..services.group_service import GroupService
from ..services.individual_service import IndividualService
from ..services.program_membership_service import ProgramMembershipService
from ..services.program_service import ProgramService

_logger = logging.getLogger(__name__)


# Router for filter metadata - mounted per resource
individual_filter_router = APIRouter(tags=["Individual"], prefix="/Individual")
group_filter_router = APIRouter(tags=["Group"], prefix="/Group")
program_filter_router = APIRouter(tags=["Program"], prefix="/Program")
program_membership_filter_router = APIRouter(tags=["ProgramMembership"], prefix="/ProgramMembership")


# Service mapping for resource types
# Service mapping for resource types. Companion modules (e.g. spp_api_v2_programs)
# register their own resources into this dict at import time.
RESOURCE_SERVICES = {
"Individual": {
"service_class": IndividualService,
Expand All @@ -45,18 +42,6 @@
"base_domain": [("is_registrant", "=", True), ("is_group", "=", True)],
"consent_type": "group",
},
"Program": {
"service_class": ProgramService,
"model": "spp.program",
"base_domain": [],
"consent_type": None,
},
"ProgramMembership": {
"service_class": ProgramMembershipService,
"model": "spp.program.membership",
"base_domain": [],
"consent_type": "program_membership",
},
}


Expand Down Expand Up @@ -300,45 +285,3 @@ async def search(
summary="Advanced Group Search",
description="Search groups with complex filter conditions",
)

# Register endpoints for Program
program_filter_router.add_api_route(
"/_filters",
_create_filter_metadata_endpoint("Program"),
methods=["GET"],
response_model=FilterMetadataResponse,
response_model_exclude_none=True,
summary="Get Program Filters",
description="Get available filters and presets for Program resource",
)

program_filter_router.add_api_route(
"/_search",
_create_search_endpoint("Program"),
methods=["POST"],
response_model=Bundle,
response_model_exclude_none=True,
summary="Advanced Program Search",
description="Search programs with complex filter conditions",
)

# Register endpoints for ProgramMembership
program_membership_filter_router.add_api_route(
"/_filters",
_create_filter_metadata_endpoint("ProgramMembership"),
methods=["GET"],
response_model=FilterMetadataResponse,
response_model_exclude_none=True,
summary="Get ProgramMembership Filters",
description="Get available filters and presets for ProgramMembership resource",
)

program_membership_filter_router.add_api_route(
"/_search",
_create_search_endpoint("ProgramMembership"),
methods=["POST"],
response_model=Bundle,
response_model_exclude_none=True,
summary="Advanced ProgramMembership Search",
description="Search program memberships with complex filter conditions",
)
2 changes: 0 additions & 2 deletions spp_api_v2/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,4 @@
from . import operation_outcome
from . import patch
from . import problem_detail
from . import program
from . import program_membership
from . import search_result
2 changes: 0 additions & 2 deletions spp_api_v2/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,5 @@
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
4 changes: 0 additions & 4 deletions spp_api_v2/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,6 @@
from . import test_organization_type_security
from . import test_pagination
from . import test_patch_api
from . import test_program_api
from . import test_program_membership_api
from . import test_program_membership_service
from . import test_program_service
from . import test_scope_enforcement
from . import test_schema_builder
from . import test_search_service
95 changes: 0 additions & 95 deletions spp_api_v2/tests/test_scope_enforcement.py
Original file line number Diff line number Diff line change
Expand Up @@ -487,101 +487,6 @@ def test_group_membership_history_requires_scope(self):
self.assertIn("group:read", data["detail"])


class TestScopeEnforcementProgramMembership(ApiV2HttpTestCase):
"""Test scope enforcement on Program Membership endpoints"""

def setUp(self):
super().setUp()
# Create test data
self.program = self.create_test_program(name="Test Program", target_type="individual")
self.individual = self.create_test_individual(identifier_value="SCOPE-PM-001")

# Create test membership
self.membership = self.create_test_membership(
partner=self.individual,
program=self.program,
state="enrolled",
)

# Base URL for program membership
self.pm_url = "/api/v2/spp/ProgramMembership"
self.pm_id_url = f"{self.pm_url}/urn:openspp:vocab:id-type%23test_national_id|SCOPE-PM-001"

def _make_client_without_scope(self, excluded_resource, excluded_action):
"""Create a client that has all scopes EXCEPT the specified one"""
scopes = []
# Give it a different resource scope to prove it's not resource-agnostic
other_resource = "individual" if excluded_resource == "program_membership" else "program_membership"
scopes.append({"resource": other_resource, "action": "all"})
client = self.create_api_client(
name=f"No {excluded_resource}:{excluded_action}",
scopes=scopes,
require_consent=False,
)
return client, self.generate_jwt_token(client)

def test_program_membership_create_requires_scope(self):
"""POST /ProgramMembership returns 403 without program_membership:create scope"""
client, token = self._make_client_without_scope("program_membership", "create")

# Create new individual for enrollment
self.create_test_individual(identifier_value="SCOPE-PM-NEW")

payload = {
"resourceType": "ProgramMembership",
"status": "enrolled",
"beneficiary": {
"reference": "Individual/urn:openspp:vocab:id-type#test_national_id|SCOPE-PM-NEW",
},
"program": {
"reference": "Program/urn:openspp:program|test-program",
},
}

response = self.url_open(
self.pm_url,
data=json.dumps(payload),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
},
)

self.assertEqual(response.status_code, 403)
data = json.loads(response.content)
self.assertIn("Missing required scope", data["detail"])
self.assertIn("program_membership:create", data["detail"])

def test_program_membership_update_requires_scope(self):
"""PUT /ProgramMembership/{id} returns 403 without program_membership:update scope"""
client, token = self._make_client_without_scope("program_membership", "update")

payload = {
"resourceType": "ProgramMembership",
"status": "paused",
"beneficiary": {
"reference": "Individual/urn:openspp:vocab:id-type#test_national_id|SCOPE-PM-001",
},
"program": {
"reference": "Program/urn:openspp:program|test-program",
},
}

response = self.url_put(
self.pm_id_url,
data=json.dumps(payload),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
},
)

self.assertEqual(response.status_code, 403)
data = json.loads(response.content)
self.assertIn("Missing required scope", data["detail"])
self.assertIn("program_membership:update", data["detail"])


class TestScopeIsolation(ApiV2HttpTestCase):
"""Test that scopes for one resource don't grant access to another"""

Expand Down
Loading
Loading