From 0984f5d78cf9ebe5590439d21f16bde8e847324a Mon Sep 17 00:00:00 2001 From: Simon K <6615834+simon-20@users.noreply.github.com> Date: Thu, 25 Jun 2026 09:35:43 +0100 Subject: [PATCH 1/7] test: add tests for view and add tools auths --- .../integration/test_reporting_org_routes.py | 190 +++++++++++++++++- 1 file changed, 181 insertions(+), 9 deletions(-) diff --git a/tests/integration/test_reporting_org_routes.py b/tests/integration/test_reporting_org_routes.py index 58ca32f..7ed0026 100644 --- a/tests/integration/test_reporting_org_routes.py +++ b/tests/integration/test_reporting_org_routes.py @@ -751,15 +751,6 @@ def test_reporting_org_tool_management_not_implemented() -> None: fastAPIapp = appAndContext.get_test_app() 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", - headers=appAndContext.get_valid_authorization_header(0), - ) - - assert response.status_code == 501 - # When implemented, this call should authorise a tool to have permission to edit # records for this organisation. response = client.post( @@ -778,3 +769,184 @@ def test_reporting_org_tool_management_not_implemented() -> None: ) assert response.status_code == 501 + + +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: + response = client.get( + f"/api/v1/reporting-orgs/{org_id}/tools", + headers=appAndContext.get_valid_authorization_header(0), + ) + + 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() + + 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( + 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 == 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 + (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 users belonging to the org, or superadmins, may authorise a tool for it. + + Provider admins (user 4) are deliberately 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 exercises the permission check rather than the already-authorised path. + """ + + appAndContext = MockedAppAndContext() + + fastAPIapp = appAndContext.get_test_app() + + 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" From 414da8616e0b38a723f0fc1022051539ae219419 Mon Sep 17 00:00:00 2001 From: Simon K <6615834+simon-20@users.noreply.github.com> Date: Thu, 25 Jun 2026 09:36:30 +0100 Subject: [PATCH 2/7] feat: implement get_reporting_org_tool_list --- .../auth/fga/fga_provider.py | 5 ++++ .../auth/fga/fga_provider_db.py | 12 ++++++++ .../auth/fga/fga_validator.py | 16 ++++++++++ .../routers/reporting_orgs.py | 29 ++++++++++++++++--- 4 files changed, 58 insertions(+), 4 deletions(-) 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..1776de8 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,8 @@ 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 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..fb6a416 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,15 @@ 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 [] 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..71f87b6 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,22 @@ 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(self, reporting_org_id: UUID) -> bool: if self.is_superadmin: diff --git a/src/register_your_data_api/routers/reporting_orgs.py b/src/register_your_data_api/routers/reporting_orgs.py index d1e0f1f..cd4b59a 100644 --- a/src/register_your_data_api/routers/reporting_orgs.py +++ b/src/register_your_data_api/routers/reporting_orgs.py @@ -36,6 +36,8 @@ ReportingOrgCreateModel, ReportingOrgUpdateModel, ReportingOrgUserCreateModel, + ToolListResponse, + ToolMetadata, UserReportingOrgDiscoverableMetadataRelation, UserReportingOrgRelation, UserReportingOrgRelationSingleResponse, @@ -635,11 +637,30 @@ 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""" - raise fastapi.HTTPException( - status_code=fastapi.status.HTTP_501_NOT_IMPLEMENTED, - detail="Not yet implemented", + # 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) + ) + + return ToolListResponse( + status="success", + error=None, + data=[ToolMetadata(**db_tool.model_dump()) for db_tool in tools_authorised_for_org], ) From e67e7c013d3011b62f19761ed3bbbb555c173e68 Mon Sep 17 00:00:00 2001 From: Simon K <6615834+simon-20@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:47:39 +0100 Subject: [PATCH 3/7] test: reduce sqlite log noise, add tool auth tests --- tests/conftest.py | 3 + .../integration/test_reporting_org_routes.py | 137 ++++++++++++++---- 2 files changed, 109 insertions(+), 31 deletions(-) create mode 100644 tests/conftest.py 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 7ed0026..026ea98 100644 --- a/tests/integration/test_reporting_org_routes.py +++ b/tests/integration/test_reporting_org_routes.py @@ -745,32 +745,6 @@ def test_reporting_org_list_users( assert users_returned == visible_users -def test_reporting_org_tool_management_not_implemented() -> None: - appAndContext = MockedAppAndContext() - - fastAPIapp = appAndContext.get_test_app() - - with TestClient(fastAPIapp) as client: - # When implemented, this call should authorise a tool to have permission to edit - # records for this organisation. - 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"}, - ) - - assert response.status_code == 501 - - # When implemented, this call should revoke permission for this tool to have - # permission to edit records for this organisation. - 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), - ) - - assert response.status_code == 501 - - def test_reporting_org_tool_list_returns_authorised_tools() -> None: """Tests that GET /reporting-orgs/{oid}/tools returns the tools authorised for that org. @@ -877,6 +851,7 @@ def test_reporting_org_authorise_tool_adds_tool() -> None: (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 @@ -885,12 +860,13 @@ def test_reporting_org_authorise_tool_adds_tool() -> None: def test_reporting_org_authorise_tool_permissions( user: int, reporting_org_id: str, client_id: str, status_code: int ) -> None: - """Only users belonging to the org, or superadmins, may authorise a tool for it. + """Only ADMINs and EDITORs of the org, or superadmins, may authorise a tool for it. - Provider admins (user 4) are deliberately 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 exercises the permission check rather than the already-authorised path. + 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() @@ -950,3 +926,102 @@ def test_reporting_org_authorise_tool_nonexistent() -> None: 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( + 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 == 404 + assert response.json()["status"] == "failed" From d5a296e395748664b6be49f6bb10475f31b22964 Mon Sep 17 00:00:00 2001 From: Simon K <6615834+simon-20@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:49:56 +0100 Subject: [PATCH 4/7] feat: add methods to view, auth, revoke tools This commits adds methods to the FGA Provider to get the list of tool authorisations, and add/remove tools from that list. It also adds a method to the user validator class that checks whether a user can update reporting org tool authorisations. --- .../auth/fga/fga_provider.py | 15 +++++++++ .../auth/fga/fga_provider_db.py | 31 +++++++++++++++++++ .../auth/fga/fga_validator.py | 20 ++++++++++++ 3 files changed, 66 insertions(+) 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 1776de8..c665243 100644 --- a/src/register_your_data_api/auth/fga/fga_provider.py +++ b/src/register_your_data_api/auth/fga/fga_provider.py @@ -85,3 +85,18 @@ def is_user_a_tool_adminuser(self, user: UUID) -> bool: 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 fb6a416..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 @@ -280,3 +280,34 @@ def get_tools_for_organisation(self, org: UUID) -> list[FineGrainedAuthorisation 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 71f87b6..a9538b9 100644 --- a/src/register_your_data_api/auth/fga/fga_validator.py +++ b/src/register_your_data_api/auth/fga/fga_validator.py @@ -137,6 +137,26 @@ def user_can_read_reporting_org_tool_authorisations(self, reporting_org_id: UUID 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: From ee54ce32c12e4b27252958c2fe2f7de8517d898f Mon Sep 17 00:00:00 2001 From: Simon K <6615834+simon-20@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:11:27 +0100 Subject: [PATCH 5/7] feat: implements endpoints to auth & revoke tools This commit implements the two end points to authorise a tool and revoke the authorisation for a tool. --- .../data_handling/data_schemas.py | 6 + .../routers/reporting_orgs.py | 107 +++++++++++++++++- 2 files changed, 107 insertions(+), 6 deletions(-) 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 cd4b59a..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,7 @@ ReportingOrgCreateModel, ReportingOrgUpdateModel, ReportingOrgUserCreateModel, + ToolId, ToolListResponse, ToolMetadata, UserReportingOrgDiscoverableMetadataRelation, @@ -667,15 +668,69 @@ def get_reporting_org_tool_list( @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""" - raise fastapi.HTTPException( - status_code=fastapi.status.HTTP_501_NOT_IMPLEMENTED, - detail="Not yet implemented", + 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 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, ) @@ -688,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, ) From cbb0f1160ff159b8a327ec08584bb2643e00be98 Mon Sep 17 00:00:00 2001 From: Simon K <6615834+simon-20@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:13:32 +0100 Subject: [PATCH 6/7] docs: update CHANGELOG - list/auth/revoke tool --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) 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 From 80b89d6fca721643d47437ec6b5f13f56ccccd44 Mon Sep 17 00:00:00 2001 From: Simon K <6615834+simon-20@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:14:09 +0100 Subject: [PATCH 7/7] chore(release): 0.3.8 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"}]