Skip to content
Open
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
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
140 changes: 3 additions & 137 deletions spp_api_v2/tests/test_scope_enforcement.py
Original file line number Diff line number Diff line change
Expand Up @@ -487,114 +487,16 @@ 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"""

def setUp(self):
super().setUp()
# Create test data for all resource types
# Create test data for the resource types served by the base module.
# Program / program-membership isolation lives in spp_api_v2_programs
# (the program endpoints moved there in OP#1081).
self.individual = self.create_test_individual(identifier_value="ISOLATION-IND-001")
self.group = self.create_test_group(identifier_value="ISOLATION-GRP-001")
self.program = self.create_test_program(name="Isolation Test Program")
self.membership = self.create_test_membership(
partner=self.individual,
program=self.program,
)

def test_individual_scope_does_not_grant_group_access(self):
"""individual:read scope does not grant access to group endpoints"""
Expand Down Expand Up @@ -634,42 +536,6 @@ def test_group_scope_does_not_grant_individual_access(self):
self.assertIn("Missing required scope", data["detail"])
self.assertIn("individual:read", data["detail"])

def test_individual_scope_does_not_grant_program_membership_access(self):
"""individual:create scope does not grant access to program_membership endpoints"""
# Create client with only individual:create scope
client = self.create_api_client(
name="Individual Create Only Client",
scopes=[{"resource": "individual", "action": "create"}],
require_consent=False,
)
token = self.generate_jwt_token(client)

# Try to create program membership
payload = {
"resourceType": "ProgramMembership",
"status": "enrolled",
"beneficiary": {
"reference": "Individual/urn:openspp:vocab:id-type#test_national_id|ISOLATION-IND-001",
},
"program": {
"reference": "Program/urn:openspp:program|isolation-test-program",
},
}

response = self.url_open(
"/api/v2/spp/ProgramMembership",
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_read_scope_does_not_grant_create_access(self):
"""individual:read scope does not grant create access"""
# Create client with only individual:read scope
Expand Down
Loading
Loading