diff --git a/CHANGELOG.md b/CHANGELOG.md index 006fd76..bad0438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +## [0.3.8] - 2026-07-01 + +### Added + +- End points for viewing, authorising, and revoking authorisation from, 3rd party tools. + ## [0.3.7] - 2026-05-11 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index fc18690..f7f7ba8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "register-your-data-api" -version = "0.3.7" +version = "0.3.8" requires-python = ">= 3.12.11" readme = "README.md" authors = [{name="IATI Secretariat", email="support@iatistandard.org"}] diff --git a/src/register_your_data_api/auth/fga/fga_provider.py b/src/register_your_data_api/auth/fga/fga_provider.py index ba26620..c665243 100644 --- a/src/register_your_data_api/auth/fga/fga_provider.py +++ b/src/register_your_data_api/auth/fga/fga_provider.py @@ -80,3 +80,23 @@ def get_tools_for_user(self, user: UUID) -> list[FineGrainedAuthorisationTool]: def is_user_a_tool_adminuser(self, user: UUID) -> bool: """Returns True is user is an admin user for tools, else False""" raise NotImplementedError + + @abstractmethod + def get_tools_for_organisation(self, org: UUID) -> list[FineGrainedAuthorisationTool]: + """Get a list of the tools authorised by the reporting organisation.""" + raise NotImplementedError + + @abstractmethod + def get_tool_by_id(self, tool_id: UUID) -> FineGrainedAuthorisationTool | None: + """Get a tool by its id, or None if no such tool exists.""" + raise NotImplementedError + + @abstractmethod + def authorise_tool_for_organisation(self, tool_id: UUID, reporting_org_id: UUID) -> None: + """Authorise a tool to act for a reporting organisation.""" + raise NotImplementedError + + @abstractmethod + def revoke_tool_authorisation_for_organisation(self, tool_id: UUID, reporting_org_id: UUID) -> None: + """Revoke a tool's authorisation to act for a reporting organisation.""" + raise NotImplementedError diff --git a/src/register_your_data_api/auth/fga/fga_provider_db.py b/src/register_your_data_api/auth/fga/fga_provider_db.py index 18b6a98..56854a6 100644 --- a/src/register_your_data_api/auth/fga/fga_provider_db.py +++ b/src/register_your_data_api/auth/fga/fga_provider_db.py @@ -268,3 +268,46 @@ def get_tools_for_user(self, user: UUID) -> list[FineGrainedAuthorisationTool]: def is_user_a_tool_adminuser(self, user: UUID) -> bool: return len(self.get_tools_for_user(user)) > 0 + + def get_tools_for_organisation(self, org: UUID) -> list[FineGrainedAuthorisationTool]: + """Get a list of the tools authorised by the reporting organisation.""" + + with Session(self._engine) as session: + db_tools = session.exec( + select(ToolDbModel).join(ToolAuthorisationDbModel).where(ToolAuthorisationDbModel.reporting_org == org) + ).all() + + return [FineGrainedAuthorisationTool(**db_tool.model_dump()) for db_tool in db_tools] + + return [] + + def get_tool_by_id(self, tool_id: UUID) -> FineGrainedAuthorisationTool | None: + """Get a tool by its id, or None if no such tool exists.""" + + with Session(self._engine) as session: + db_tool = session.exec(select(ToolDbModel).where(ToolDbModel.id == tool_id)).first() + + if db_tool is None: + return None + + return FineGrainedAuthorisationTool(**db_tool.model_dump()) + + def authorise_tool_for_organisation(self, tool_id: UUID, reporting_org_id: UUID) -> None: + """Authorise a tool to act for a reporting organisation.""" + + tool_authorisation_db = ToolAuthorisationDbModel(tool=tool_id, reporting_org=reporting_org_id) + with Session(self._engine) as session: + session.add(tool_authorisation_db) + session.commit() + + def revoke_tool_authorisation_for_organisation(self, tool_id: UUID, reporting_org_id: UUID) -> None: + """Revoke a tool's authorisation to act for a reporting organisation.""" + + delete_cmd = delete(ToolAuthorisationDbModel).where( + (ToolAuthorisationDbModel.tool == tool_id) # type: ignore + & (ToolAuthorisationDbModel.reporting_org == reporting_org_id) + ) + + with Session(self._engine) as session: + session.exec(delete_cmd) + session.commit() diff --git a/src/register_your_data_api/auth/fga/fga_validator.py b/src/register_your_data_api/auth/fga/fga_validator.py index 585beb2..a9538b9 100644 --- a/src/register_your_data_api/auth/fga/fga_validator.py +++ b/src/register_your_data_api/auth/fga/fga_validator.py @@ -121,6 +121,42 @@ def user_can_read_reporting_org(self, reporting_org_id: UUID) -> bool: return False + def user_can_read_reporting_org_tool_authorisations(self, reporting_org_id: UUID) -> bool: + """""" + + if self.is_superadmin: + return True + + role_for_org: FineGrainedAuthorisationRole | None = self.get_user_role_for_reporting_org(reporting_org_id) + + # Provider admins shouldn't have permission to view tools, because that would give them permission to see + # which other tools a user has authorised + if role_for_org is not None and role_for_org != FineGrainedAuthorisationRole.PROVIDER_ADMIN: + if "read-org" in self.get_permissions_for_role(role_for_org): + return True + + return False + + def user_can_update_reporting_org_tool_authorisations(self, reporting_org_id: UUID) -> bool: + """Whether the user may authorise or revoke a tool's permissions for an org. + + Only ADMINs and EDITORs of the org (and superadmins) may manage tool + authorisations: the check requires the "update-org" permission, which + contributors do not have. Provider admins are also excluded because they + shouldn't be able to change the list of tools the user has authorised. + """ + + if self.is_superadmin: + return True + + role_for_org: FineGrainedAuthorisationRole | None = self.get_user_role_for_reporting_org(reporting_org_id) + + if role_for_org is not None and role_for_org != FineGrainedAuthorisationRole.PROVIDER_ADMIN: + if "update-org" in self.get_permissions_for_role(role_for_org): + return True + + return False + def user_can_update_reporting_org(self, reporting_org_id: UUID) -> bool: if self.is_superadmin: diff --git a/src/register_your_data_api/data_handling/data_schemas.py b/src/register_your_data_api/data_handling/data_schemas.py index f0c57c7..abddd1e 100644 --- a/src/register_your_data_api/data_handling/data_schemas.py +++ b/src/register_your_data_api/data_handling/data_schemas.py @@ -27,6 +27,12 @@ def oid_str(self) -> str: return str(self.oid) +class ToolId(pydantic.BaseModel): + """Model for the authorise tool payload""" + + tid: uuid.UUID + + class CRMUser(pydantic.BaseModel): id: str name: str diff --git a/src/register_your_data_api/routers/reporting_orgs.py b/src/register_your_data_api/routers/reporting_orgs.py index d1e0f1f..d7f56c3 100644 --- a/src/register_your_data_api/routers/reporting_orgs.py +++ b/src/register_your_data_api/routers/reporting_orgs.py @@ -36,6 +36,9 @@ ReportingOrgCreateModel, ReportingOrgUpdateModel, ReportingOrgUserCreateModel, + ToolId, + ToolListResponse, + ToolMetadata, UserReportingOrgDiscoverableMetadataRelation, UserReportingOrgRelation, UserReportingOrgRelationSingleResponse, @@ -635,26 +638,99 @@ def get_reporting_org_tool_list( org_id: uuid.UUID, request: starlette.requests.Request, user: auth_models.UserAndCredentials = Security(authz.get_user_authnz, scopes=["ryd", "ryd:reporting_org:tool"]), -) -> JSONResponse: +) -> ToolListResponse: + """Handler for endpoint which returns a list of the tools that this organisation has authorised""" + + # Check that the user can read/manage the tool authorisations + context: Context = request.app.state.context + + if not user.validator.user_can_read_reporting_org_tool_authorisations(org_id): + context.audit_logger.error( + f"Request to read reporting org tools for org id: {org_id} by unauthorised user id: {user.user_id_crm}" + ) + raise HTTPException( + status_code=fastapi.status.HTTP_403_FORBIDDEN, + detail="There is a problem with your credentials. If this persists please report " + "error to the provider of the tool you are using to access the IATI Registry.", + ) + + tools_authorised_for_org: list[fga_models.FineGrainedAuthorisationTool] = ( + context.fine_grained_auth_provider.get_tools_for_organisation(org_id) + ) - raise fastapi.HTTPException( - status_code=fastapi.status.HTTP_501_NOT_IMPLEMENTED, - detail="Not yet implemented", + return ToolListResponse( + status="success", + error=None, + data=[ToolMetadata(**db_tool.model_dump()) for db_tool in tools_authorised_for_org], ) @router.post("/{org_id}/tools") def authorise_tool_permission_for_reporting_org( org_id: uuid.UUID, + payload: ToolId, request: starlette.requests.Request, user: auth_models.UserAndCredentials = Security( authz.get_user_authnz, scopes=["ryd", "ryd:reporting_org:tool:update"] ), ) -> JSONResponse: + """Handler for endpoint which authorises a tool to act for a reporting organisation""" + + context: Context = request.app.state.context - raise fastapi.HTTPException( - status_code=fastapi.status.HTTP_501_NOT_IMPLEMENTED, - detail="Not yet implemented", + # Check that the user can manage the tool authorisations for this org + if not user.validator.user_can_update_reporting_org_tool_authorisations(org_id): + context.audit_logger.error( + f"Request to authorise a tool for org id: {org_id} by unauthorised user id: {user.user_id_crm}" + ) + raise HTTPException( + status_code=fastapi.status.HTTP_403_FORBIDDEN, + detail="There is a problem with your credentials. If this persists please report " + "error to the provider of the tool you are using to access the IATI Registry.", + ) + + # The tool being authorised must exist. + assert_precondition_met( + user.user_id_crm, + user.client_id, + condition_func=lambda: context.fine_grained_auth_provider.get_tool_by_id(payload.tid) is not None, + status_code=fastapi.status.HTTP_404_NOT_FOUND, + audit_log_msg=( + f"Request to authorise non-existent tool id: {payload.tid} for org id: {org_id} " + f"by user id: {user.user_id_crm}" + ), + public_msg=f"There is no tool with ID {str(payload.tid)}.", + ) + + # The tool must not already be authorised for this org. + assert_precondition_met( + user.user_id_crm, + user.client_id, + condition_func=lambda: payload.tid + not in {tool.id for tool in context.fine_grained_auth_provider.get_tools_for_organisation(org_id)}, + status_code=fastapi.status.HTTP_409_CONFLICT, + audit_log_msg=( + f"Request to authorise tool id: {payload.tid} for org id: {org_id} by user id: " + f"{user.user_id_crm} but the tool is already authorised." + ), + public_msg=f"Tool with ID {str(payload.tid)} is already authorised for this organisation.", + ) + + context.fine_grained_auth_provider.authorise_tool_for_organisation(payload.tid, org_id) + + context.audit_logger.info( + format_log_msg( + request, + user.user_id_crm, + user.client_id, + f"Authorised tool id: {payload.tid} for org id: {org_id}", + include_client_id=True, + ) + ) + + return JSONResponse( + {"status": "success", "data": None, "error": None}, + status_code=fastapi.status.HTTP_201_CREATED, ) @@ -667,8 +743,48 @@ def revoke_tool_permission_for_reporting_org( authz.get_user_authnz, scopes=["ryd", "ryd:reporting_org:tool:update"] ), ) -> JSONResponse: + """Handler for endpoint which removes authorisation for a tool for the specified reporting organisation""" + + context: Context = request.app.state.context + + # Check that the user can manage the tool authorisations for this org + if not user.validator.user_can_update_reporting_org_tool_authorisations(org_id): + context.audit_logger.error( + f"Request to revoke a tool for org id: {org_id} by unauthorised user id: {user.user_id_crm}" + ) + raise HTTPException( + status_code=fastapi.status.HTTP_403_FORBIDDEN, + detail="There is a problem with your credentials. If this persists please report " + "error to the provider of the tool you are using to access the IATI Registry.", + ) + + # The tool must currently be authorised for this org (this also covers a non-existent tool id). + assert_precondition_met( + user.user_id_crm, + user.client_id, + condition_func=lambda: tool_id + in {tool.id for tool in context.fine_grained_auth_provider.get_tools_for_organisation(org_id)}, + status_code=fastapi.status.HTTP_404_NOT_FOUND, + audit_log_msg=( + f"Request to revoke tool id: {tool_id} for org id: {org_id} by user id: " + f"{user.user_id_crm} but the tool is not authorised for the org." + ), + public_msg=f"Tool with ID {str(tool_id)} is not authorised for this organisation.", + ) + + context.fine_grained_auth_provider.revoke_tool_authorisation_for_organisation(tool_id, org_id) + + context.audit_logger.info( + format_log_msg( + request, + user.user_id_crm, + user.client_id, + f"Revoked tool id: {tool_id} for org id: {org_id}", + include_client_id=True, + ) + ) - raise fastapi.HTTPException( - status_code=fastapi.status.HTTP_501_NOT_IMPLEMENTED, - detail="Not yet implemented", + return JSONResponse( + {"status": "success", "data": None, "error": None}, + status_code=fastapi.status.HTTP_200_OK, ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..cee7e9e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,3 @@ +import logging + +logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) diff --git a/tests/integration/test_reporting_org_routes.py b/tests/integration/test_reporting_org_routes.py index 58ca32f..026ea98 100644 --- a/tests/integration/test_reporting_org_routes.py +++ b/tests/integration/test_reporting_org_routes.py @@ -745,36 +745,283 @@ def test_reporting_org_list_users( assert users_returned == visible_users -def test_reporting_org_tool_management_not_implemented() -> None: +def test_reporting_org_tool_list_returns_authorised_tools() -> None: + """Tests that GET /reporting-orgs/{oid}/tools returns the tools authorised for that org. + + Tool One (configured in tests/helpers/mocking.py) is authorised for org + 552376ae-2aa7-98ab-d800-68daa9bfeb4a; Tool Two has no authorisation for any + org and so must not be returned. + """ + appAndContext = MockedAppAndContext() fastAPIapp = appAndContext.get_test_app() + org_id = "552376ae-2aa7-98ab-d800-68daa9bfeb4a" + with TestClient(fastAPIapp) as client: - # When implemented, this call should return a list of tools that have permission to - # edit records for an organisation. response = client.get( - "/api/v1/reporting-orgs/ab851a83-a384-3eb9-caf0-68db8125b067/tools", + f"/api/v1/reporting-orgs/{org_id}/tools", headers=appAndContext.get_valid_authorization_header(0), ) - assert response.status_code == 501 + assert response.status_code == 200 + + resp_json = response.json() + + assert resp_json["status"] == "success" + + tools_by_id = {tool["id"]: tool for tool in resp_json["data"]} + + tool_one_id = str(appAndContext._mocked_tool_ids[0]) + tool_two_id = str(appAndContext._mocked_tool_ids[1]) + + # Tool One is authorised for this org and should be returned. + assert tool_one_id in tools_by_id + assert tools_by_id[tool_one_id]["name"] == "Tool One" + assert tools_by_id[tool_one_id]["provider"] == "Tool Maker" + + # Tool Two has no authorisation for any org and should not be returned. + assert tool_two_id not in tools_by_id + + +def test_reporting_org_tool_list_forbidden_for_provider_admin() -> None: + """A provider admin must not be able to read an org's authorised tool list. + + User 4 is a provider admin for Tool One, which is authorised for org + 552376ae-2aa7-98ab-d800-68daa9bfeb4a. Although they can act on that org through their + tool, they do not belong to it, so reading its tool authorisations is forbidden. + """ + + appAndContext = MockedAppAndContext() + + fastAPIapp = appAndContext.get_test_app() - # When implemented, this call should authorise a tool to have permission to edit - # records for this organisation. + org_id = "552376ae-2aa7-98ab-d800-68daa9bfeb4a" + + with TestClient(fastAPIapp) as client: + response = client.get( + f"/api/v1/reporting-orgs/{org_id}/tools", + headers=appAndContext.get_valid_authorization_header(4, client_id="wUtu4EuLSlstjasC"), + ) + + assert response.status_code == 403 + + +def test_reporting_org_authorise_tool_adds_tool() -> None: + """A tool can be authorised for an organisation via POST /reporting-orgs/{oid}/tools. + + Tool Two is not authorised for any org in the mock data (tests/helpers/mocking.py). + After authorising it for org 552376ae-2aa7-98ab-d800-68daa9bfeb4a it should appear in + that org's authorised tool list. + """ + + appAndContext = MockedAppAndContext() + + fastAPIapp = appAndContext.get_test_app() + + org_id = "552376ae-2aa7-98ab-d800-68daa9bfeb4a" + tool_two_id = str(appAndContext._mocked_tool_ids[1]) + + with TestClient(fastAPIapp) as client: + # User 1 is an ADMIN of this org and so may authorise tools for it. response = client.post( - "/api/v1/reporting-orgs/ab851a83-a384-3eb9-caf0-68db8125b067/tools", - headers=appAndContext.get_valid_authorization_header(0), - json={"tid": "6a2d1ca1-b9c2-4bd3-a2a5-099178d1358d"}, + f"/api/v1/reporting-orgs/{org_id}/tools", + headers=appAndContext.get_valid_authorization_header(1), + json={"tid": tool_two_id}, + ) + + assert response.status_code == 201 + assert response.json()["status"] == "success" + + # The newly authorised tool should now appear in the org's tool list. + response = client.get( + f"/api/v1/reporting-orgs/{org_id}/tools", + headers=appAndContext.get_valid_authorization_header(1), ) - assert response.status_code == 501 + assert response.status_code == 200 + authorised_tool_ids = {tool["id"] for tool in response.json()["data"]} + assert tool_two_id in authorised_tool_ids + + +@pytest.mark.parametrize( + "user,reporting_org_id,client_id,status_code", + [ + (0, "552376ae-2aa7-98ab-d800-68daa9bfeb4a", "some_client", 201), # EDITOR of org + (1, "552376ae-2aa7-98ab-d800-68daa9bfeb4a", "some_client", 201), # ADMIN of org + (2, "552376ae-2aa7-98ab-d800-68daa9bfeb4a", "some_client", 201), # Superadmin + (3, "552376ae-2aa7-98ab-d800-68daa9bfeb4a", "some_client", 403), # CONTRIBUTOR of org - cannot authorise tools + (1, "ab851a83-a384-3eb9-caf0-68db8125b067", "some_client", 403), # Not a member of this org + (4, "552376ae-2aa7-98ab-d800-68daa9bfeb4a", "wUtu4EuLSlstjasC", 403), # Provider admin - not an org member + (6, "552376ae-2aa7-98ab-d800-68daa9bfeb4a", "some_client", 500), # Integrity error: org role + provider admin + ], +) +def test_reporting_org_authorise_tool_permissions( + user: int, reporting_org_id: str, client_id: str, status_code: int +) -> None: + """Only ADMINs and EDITORs of the org, or superadmins, may authorise a tool for it. + + Contributors (user 3) belong to the org but only have read access, so they cannot + authorise tools. Provider admins (user 4) are also excluded: although they can act on an + org through their tool, they do not belong to the org and so must not be able to authorise + further tools. Tool Two is used because it is not authorised for any org, so each request + tests the authorise/permission add check rather than the already-authorised path. + """ + + appAndContext = MockedAppAndContext() + + fastAPIapp = appAndContext.get_test_app() - # When implemented, this call should revoke permission for this tool to have - # permission to edit records for this organisation. + tool_two_id = str(appAndContext._mocked_tool_ids[1]) + + with TestClient(fastAPIapp) as client: + response = client.post( + f"/api/v1/reporting-orgs/{reporting_org_id}/tools", + headers=appAndContext.get_valid_authorization_header(user, client_id=client_id), + json={"tid": tool_two_id}, + ) + + assert response.status_code == status_code + + +def test_reporting_org_authorise_tool_already_authorised() -> None: + """Authorising a tool that is already authorised for the org returns 409 Conflict.""" + + appAndContext = MockedAppAndContext() + + fastAPIapp = appAndContext.get_test_app() + + # Tool One is already authorised for org 552376ae-2aa7-98ab-d800-68daa9bfeb4a. + org_id = "552376ae-2aa7-98ab-d800-68daa9bfeb4a" + tool_one_id = str(appAndContext._mocked_tool_ids[0]) + + with TestClient(fastAPIapp) as client: + response = client.post( + f"/api/v1/reporting-orgs/{org_id}/tools", + headers=appAndContext.get_valid_authorization_header(1), # ADMIN of org + json={"tid": tool_one_id}, + ) + + assert response.status_code == 409 + assert response.json()["status"] == "failed" + + +def test_reporting_org_authorise_tool_nonexistent() -> None: + """Authorising a tool that does not exist returns 404 Not Found.""" + + appAndContext = MockedAppAndContext() + + fastAPIapp = appAndContext.get_test_app() + + org_id = "552376ae-2aa7-98ab-d800-68daa9bfeb4a" + nonexistent_tool_id = "00000000-0000-0000-0000-000000000000" + + with TestClient(fastAPIapp) as client: + response = client.post( + f"/api/v1/reporting-orgs/{org_id}/tools", + headers=appAndContext.get_valid_authorization_header(1), # ADMIN of org + json={"tid": nonexistent_tool_id}, + ) + + assert response.status_code == 404 + assert response.json()["status"] == "failed" + + +def test_reporting_org_revoke_tool_removes_tool() -> None: + """A tool's authorisation can be revoked via DELETE /reporting-orgs/{oid}/tools/{tid}. + + Tool One is authorised for org 552376ae-2aa7-98ab-d800-68daa9bfeb4a in the + mock data (tests/helpers/mocking.py). After revoking it the tool should no + longer appear in that org's authorised tool list. + """ + + appAndContext = MockedAppAndContext() + + fastAPIapp = appAndContext.get_test_app() + + org_id = "552376ae-2aa7-98ab-d800-68daa9bfeb4a" + tool_one_id = str(appAndContext._mocked_tool_ids[0]) + + with TestClient(fastAPIapp) as client: + # User 1 is an ADMIN of this org and so may revoke tools for it. response = client.delete( - "/api/v1/reporting-orgs/ab851a83-a384-3eb9-caf0-68db8125b067/tools/6a2d1ca1-b9c2-4bd3-a2a5-099178d1358d", - headers=appAndContext.get_valid_authorization_header(0), + f"/api/v1/reporting-orgs/{org_id}/tools/{tool_one_id}", + headers=appAndContext.get_valid_authorization_header(1), + ) + + assert response.status_code == 200 + assert response.json()["status"] == "success" + + # The revoked tool should no longer appear in the org's tool list. + response = client.get( + f"/api/v1/reporting-orgs/{org_id}/tools", + headers=appAndContext.get_valid_authorization_header(1), + ) + + assert response.status_code == 200 + authorised_tool_ids = {tool["id"] for tool in response.json()["data"]} + assert tool_one_id not in authorised_tool_ids + + +@pytest.mark.parametrize( + "user,reporting_org_id,client_id,status_code", + [ + (0, "552376ae-2aa7-98ab-d800-68daa9bfeb4a", "some_client", 200), # EDITOR of org + (1, "552376ae-2aa7-98ab-d800-68daa9bfeb4a", "some_client", 200), # ADMIN of org + (2, "552376ae-2aa7-98ab-d800-68daa9bfeb4a", "some_client", 200), # Superadmin + (3, "552376ae-2aa7-98ab-d800-68daa9bfeb4a", "some_client", 403), # CONTRIBUTOR of org - cannot revoke tools + (1, "ab851a83-a384-3eb9-caf0-68db8125b067", "some_client", 403), # Not a member of this org + (4, "552376ae-2aa7-98ab-d800-68daa9bfeb4a", "wUtu4EuLSlstjasC", 403), # Provider admin - not an org member + (6, "552376ae-2aa7-98ab-d800-68daa9bfeb4a", "some_client", 500), # Integrity error: org role + provider admin + ], +) +def test_reporting_org_revoke_tool_permissions( + user: int, reporting_org_id: str, client_id: str, status_code: int +) -> None: + """Only ADMINs and EDITORs of the org, or superadmins, may revoke a tool's authorisation. + + Tool One is authorised for both 552376ae-2aa7-98ab-d800-68daa9bfeb4a and + ab851a83-a384-3eb9-caf0-68db8125b067, so each request targets a real + authorisation and exercises the permission check. + """ + + appAndContext = MockedAppAndContext() + + fastAPIapp = appAndContext.get_test_app() + + tool_one_id = str(appAndContext._mocked_tool_ids[0]) + + with TestClient(fastAPIapp) as client: + response = client.delete( + f"/api/v1/reporting-orgs/{reporting_org_id}/tools/{tool_one_id}", + headers=appAndContext.get_valid_authorization_header(user, client_id=client_id), + ) + + assert response.status_code == status_code + + +@pytest.mark.parametrize( + "tool_id", + [ + "6a2d1ca1-b9c2-4bd3-a2a5-099178d1358d", # Tool Two - exists but is not authorised for this org + "00000000-0000-0000-0000-000000000000", # No such tool + ], +) +def test_reporting_org_revoke_tool_not_authorised(tool_id: str) -> None: + """Revoking a tool that is not authorised for the org returns 404 Not Found.""" + + appAndContext = MockedAppAndContext() + + fastAPIapp = appAndContext.get_test_app() + + org_id = "552376ae-2aa7-98ab-d800-68daa9bfeb4a" + + with TestClient(fastAPIapp) as client: + response = client.delete( + f"/api/v1/reporting-orgs/{org_id}/tools/{tool_id}", + headers=appAndContext.get_valid_authorization_header(1), # ADMIN of org ) - assert response.status_code == 501 + assert response.status_code == 404 + assert response.json()["status"] == "failed"