Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"}]
Expand Down
20 changes: 20 additions & 0 deletions src/register_your_data_api/auth/fga/fga_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
43 changes: 43 additions & 0 deletions src/register_your_data_api/auth/fga/fga_provider_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
36 changes: 36 additions & 0 deletions src/register_your_data_api/auth/fga/fga_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions src/register_your_data_api/data_handling/data_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
136 changes: 126 additions & 10 deletions src/register_your_data_api/routers/reporting_orgs.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
ReportingOrgCreateModel,
ReportingOrgUpdateModel,
ReportingOrgUserCreateModel,
ToolId,
ToolListResponse,
ToolMetadata,
UserReportingOrgDiscoverableMetadataRelation,
UserReportingOrgRelation,
UserReportingOrgRelationSingleResponse,
Expand Down Expand Up @@ -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,
)


Expand All @@ -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,
)
3 changes: 3 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import logging

logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
Loading
Loading