Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Security

## [0.3.5] - 2026-04-28

### Added

- Tools list endpoint and associated test.
- User roles endpoint and associated test; and a placeholder for the related permissions endpoint.
- Provider admin management endpoint placeholders and tests.

### Changed

- FGA Provider now stores tool client IDs and can handle multiple provider_admin roles per user per reporting
orgs to accommodate multiple tools being able to access the same reporting org via provider_admin.
- FGA Validator now ingests a list of tools that the user is an admin user for and the client id of the calling
application.
- FGA Validator restricts provider_admin access to only the client id associated with that tool.
- Tests for the FGA provider DB and validator.
- Changes to the tests for dataset and reporting org routes so that calls via provider_admin can use the correct client id.


## [0.3.4] - 2026-04-20

### Fixed
Expand Down
66 changes: 66 additions & 0 deletions alembic/versions/b2c6d1170383_add_client_id_to_tooldbmodel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""add client id to tooldbmodel

Revision ID: b2c6d1170383
Revises: 22951c5f0f0c
Create Date: 2026-04-27 13:06:36.123347

"""

from typing import Sequence, Union

import sqlalchemy as sa
import sqlmodel
from alembic import op
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision: str = "b2c6d1170383"
down_revision: Union[str, Sequence[str], None] = "22951c5f0f0c"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"finegrainedauthorisationdbmodel",
"role",
existing_type=postgresql.ENUM(
"CONTRIBUTOR", "EDITOR", "PROVIDER_ADMIN", "ADMIN", "CONTRIBUTOR_PENDING", name="role"
),
type_=sa.Enum(
"CONTRIBUTOR",
"EDITOR",
"PROVIDER_ADMIN",
"ADMIN",
"SUPER_ADMIN",
"CONTRIBUTOR_PENDING",
name="finegrainedauthorisationrole",
),
existing_nullable=False,
)
op.add_column("tooldbmodel", sa.Column("client_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False))
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("tooldbmodel", "client_id")
op.alter_column(
"finegrainedauthorisationdbmodel",
"role",
existing_type=sa.Enum(
"CONTRIBUTOR",
"EDITOR",
"PROVIDER_ADMIN",
"ADMIN",
"SUPER_ADMIN",
"CONTRIBUTOR_PENDING",
name="finegrainedauthorisationrole",
),
type_=postgresql.ENUM("CONTRIBUTOR", "EDITOR", "PROVIDER_ADMIN", "ADMIN", "CONTRIBUTOR_PENDING", name="role"),
existing_nullable=False,
)
# ### end Alembic commands ###
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.4"
version = "0.3.5"
requires-python = ">= 3.12.11"
readme = "README.md"
authors = [{name="IATI Secretariat", email="[email protected]"}]
Expand Down
7 changes: 6 additions & 1 deletion src/register_your_data_api/auth/authz.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ async def get_user_authnz(

try:
users_fgas = context.fine_grained_auth_provider.get_user_fine_grained_permissions(current_user_id)
users_tools = context.fine_grained_auth_provider.get_tools_for_user(current_user_id)
except FineGrainedAuthorisationIntegrityError as exc:
trace_id: UUID = uuid4()
raise RYDUserException(
Expand All @@ -41,7 +42,11 @@ async def get_user_authnz(
is_superadmin = context.fine_grained_auth_provider.is_user_a_superadmin(current_user_id)

user.fga_user_validator = FineGrainedAuthorisationUserValidator(
user_id=current_user_id, fine_grained_authorisations=users_fgas, is_superadmin=is_superadmin
user_id=current_user_id,
fine_grained_authorisations=users_fgas,
is_superadmin=is_superadmin,
client_id=user.client_id,
tools=users_tools,
)

return user
19 changes: 17 additions & 2 deletions src/register_your_data_api/auth/fga/fga_provider.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from abc import ABC, abstractmethod
from uuid import UUID

from .models import FineGrainedAuthorisationRoleAssociation
from .models import FineGrainedAuthorisationRoleAssociation, FineGrainedAuthorisationTool


class FineGrainedAuthorisationIntegrityError(Exception):
Expand All @@ -25,7 +25,7 @@ def get_user_associations_for_org(self, reporting_org: UUID) -> list[FineGrained
raise NotImplementedError

@abstractmethod
def get_user_role_for_org(self, user: UUID, org: UUID) -> FineGrainedAuthorisationRoleAssociation | None:
def get_user_roles_for_org(self, user: UUID, org: UUID) -> list[FineGrainedAuthorisationRoleAssociation]:
"""Returns a list of all the user's fine grained access roles for a specific organisation"""
raise NotImplementedError

Expand Down Expand Up @@ -65,3 +65,18 @@ def delete_all_fine_grained_authorisations_for_user(self, user: UUID) -> None:
def delete_all_fine_grained_authorisations_for_org(self, org: UUID) -> None:
"""Deletes all fine grained role associations for an organisation"""
raise NotImplementedError

@abstractmethod
def get_all_tools(self) -> list[FineGrainedAuthorisationTool]:
"""Get a list of all the tools stored in the database."""
raise NotImplementedError

@abstractmethod
def get_tools_for_user(self, user: UUID) -> list[FineGrainedAuthorisationTool]:
"""Get a list of all the tools for which the user is an admin user."""
raise NotImplementedError

@abstractmethod
def is_user_a_tool_adminuser(self, user: UUID) -> bool:
"""Returns True is user is an admin user for tools, else False"""
raise NotImplementedError
118 changes: 90 additions & 28 deletions src/register_your_data_api/auth/fga/fga_provider_db.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import collections
from collections import Counter
from uuid import UUID, uuid4

from sqlalchemy import Engine, delete
from sqlmodel import Field, Session, SQLModel, col, create_engine, select

from .fga_provider import FineGrainedAuthorisationIntegrityError, FineGrainedAuthorisationProvider
from .models import FineGrainedAuthorisationRole, FineGrainedAuthorisationRoleAssociation
from .models import FineGrainedAuthorisationRole, FineGrainedAuthorisationRoleAssociation, FineGrainedAuthorisationTool


class FineGrainedAuthorisationDbModel(SQLModel, table=True):
Expand All @@ -25,6 +25,7 @@ class ToolDbModel(SQLModel, table=True):
id: UUID = Field(primary_key=True, default_factory=lambda: uuid4())
name: str
provider: str
client_id: str


class ToolAuthorisationDbModel(SQLModel, table=True):
Expand Down Expand Up @@ -57,8 +58,9 @@ def get_user_fine_grained_permissions(self, user: UUID) -> list[FineGrainedAutho
).all()

providers_reporting_orgs = session.exec(
select(ToolAuthorisationDbModel.reporting_org, ToolAdminUserDbModel.user)
select(ToolAuthorisationDbModel.reporting_org, ToolAdminUserDbModel.user, ToolDbModel.id)
.join(ToolAdminUserDbModel, col(ToolAdminUserDbModel.tool) == col(ToolAuthorisationDbModel.tool))
.join(ToolDbModel, col(ToolDbModel.id) == col(ToolAuthorisationDbModel.tool))
.where(ToolAdminUserDbModel.user == user)
).all()

Expand All @@ -68,7 +70,10 @@ def get_user_fine_grained_permissions(self, user: UUID) -> list[FineGrainedAutho
associations = [FineGrainedAuthorisationRoleAssociation(**db_fga.model_dump()) for db_fga in user_db_fgas]
associations += [
FineGrainedAuthorisationRoleAssociation(
reporting_org=x[0], user=x[1], role=FineGrainedAuthorisationRole.PROVIDER_ADMIN
reporting_org=x[0],
user=x[1],
role=FineGrainedAuthorisationRole.PROVIDER_ADMIN,
restricted_to_tool=x[2],
)
for x in providers_reporting_orgs
]
Expand All @@ -84,59 +89,92 @@ def get_user_associations_for_org(self, reporting_org: UUID) -> list[FineGrained
).all()

tool_admin_users_for_org = session.exec(
select(ToolAuthorisationDbModel.reporting_org, ToolAdminUserDbModel.user)
select(ToolAuthorisationDbModel.reporting_org, ToolAdminUserDbModel.user, ToolDbModel.id)
.join(ToolAdminUserDbModel, col(ToolAdminUserDbModel.tool) == col(ToolAuthorisationDbModel.tool))
.join(ToolDbModel, col(ToolDbModel.id) == col(ToolAuthorisationDbModel.tool))
.where(ToolAuthorisationDbModel.reporting_org == reporting_org)
).all()

associations = [FineGrainedAuthorisationRoleAssociation(**db_fga.model_dump()) for db_fga in user_db_fgas]
associations += [
regular_associations = [
FineGrainedAuthorisationRoleAssociation(**db_fga.model_dump()) for db_fga in user_db_fgas
]
if max(Counter([association.user for association in regular_associations]).values(), default=0) > 1:
raise FineGrainedAuthorisationIntegrityError(
"Reporting org has user(s) that have multiple reporting org roles"
)

# Check that provider admin users are unique for this reporting org - each user can only have provider
# admin by each tool (we cannot have two accesses via provider admin by the same tool).
provider_admin_associations = [
FineGrainedAuthorisationRoleAssociation(
user=tool_admin_user_for_org[1],
reporting_org=reporting_org,
role=FineGrainedAuthorisationRole.PROVIDER_ADMIN,
restricted_to_tool=tool_admin_user_for_org[2],
)
for tool_admin_user_for_org in tool_admin_users_for_org
]
if (
max(
Counter(
[(association.user, association.restricted_to_tool) for association in provider_admin_associations]
).values(),
default=0,
)
> 1
):
raise FineGrainedAuthorisationIntegrityError(
"Reporting org has provider admins with multiple conflicting tool admin roles"
)

if len(associations) > 1:
if collections.Counter([association.user for association in associations]).most_common(1)[0][1] > 1:
raise FineGrainedAuthorisationIntegrityError(
"Reporting org has user(s) that have multiple conflicting roles"
)
# Check that a provider admin is not in the list of regular associations.
if set([x.user for x in regular_associations]) & set([x.user for x in provider_admin_associations]):
raise FineGrainedAuthorisationIntegrityError(
"Reporting org has user(s) with both provider admin and reporting org roles"
)

return associations
return regular_associations + provider_admin_associations

def get_user_role_for_org(self, user: UUID, org: UUID) -> FineGrainedAuthorisationRoleAssociation | None:
def get_user_roles_for_org(self, user: UUID, org: UUID) -> list[FineGrainedAuthorisationRoleAssociation]:
with Session(self._engine) as session:
user_role_for_org = session.exec(
user_roles_for_org = session.exec(
select(FineGrainedAuthorisationDbModel).where(
(FineGrainedAuthorisationDbModel.user == user)
& (FineGrainedAuthorisationDbModel.reporting_org == org)
)
).first()
).all()

tool_admin_user_for_org = session.exec(
select(ToolAuthorisationDbModel.reporting_org, ToolAdminUserDbModel.user)
tool_admin_users_for_org = session.exec(
select(ToolAuthorisationDbModel.reporting_org, ToolAdminUserDbModel.user, ToolDbModel.id)
.join(ToolAdminUserDbModel, col(ToolAdminUserDbModel.tool) == col(ToolAuthorisationDbModel.tool))
.join(ToolDbModel, col(ToolDbModel.id) == col(ToolAuthorisationDbModel.tool))
.where((ToolAuthorisationDbModel.reporting_org == org) & (ToolAdminUserDbModel.user == user))
).all()

if tool_admin_user_for_org and user_role_for_org:
if tool_admin_users_for_org and user_roles_for_org:
raise FineGrainedAuthorisationIntegrityError("User has both reporting org role and a provider admin role")

if not user_role_for_org and not tool_admin_user_for_org:
return None
if not user_roles_for_org and not tool_admin_users_for_org:
return []

if tool_admin_user_for_org:
association = FineGrainedAuthorisationRoleAssociation(
user=user, reporting_org=org, role=FineGrainedAuthorisationRole.PROVIDER_ADMIN
)
if tool_admin_users_for_org:
associations = [
FineGrainedAuthorisationRoleAssociation(
user=user,
reporting_org=org,
role=FineGrainedAuthorisationRole.PROVIDER_ADMIN,
restricted_to_tool=tool_admin_user_for_org[2],
)
for tool_admin_user_for_org in tool_admin_users_for_org
]

if len(user_roles_for_org) > 1:
raise FineGrainedAuthorisationIntegrityError("User has multiple roles for this reporting org")

if user_role_for_org:
association = FineGrainedAuthorisationRoleAssociation(**user_role_for_org.model_dump())
if user_roles_for_org:
associations = [FineGrainedAuthorisationRoleAssociation(**user_roles_for_org[0].model_dump())]

return association
return associations

def get_admin_users_for_org(self, org: UUID) -> list[FineGrainedAuthorisationRoleAssociation]:
with Session(self._engine) as session:
Expand Down Expand Up @@ -206,3 +244,27 @@ def delete_all_fine_grained_authorisations_for_org(self, org: UUID) -> None:
session.commit()

return None

def get_all_tools(self) -> list[FineGrainedAuthorisationTool]:
"""Get a list of all the tools stored in the database."""
with Session(self._engine) as session:
db_tools = session.exec(select(ToolDbModel)).all()

return [FineGrainedAuthorisationTool(**db_tool.model_dump()) for db_tool in db_tools]

return []

def get_tools_for_user(self, user: UUID) -> list[FineGrainedAuthorisationTool]:
"""Get a list of all the tools for which the user is an admin user."""

with Session(self._engine) as session:
db_tools = session.exec(
select(ToolDbModel).join(ToolAdminUserDbModel).where(ToolAdminUserDbModel.user == user)
).all()

return [FineGrainedAuthorisationTool(**db_tool.model_dump()) for db_tool in db_tools]

return []

def is_user_a_tool_adminuser(self, user: UUID) -> bool:
return len(self.get_tools_for_user(user)) > 0
Loading
Loading