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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Security

## [0.3.3]

### Added

- New provider admin database models for the FGA database provider.

### Changed

- Refactored provider admin code in the FGA validator and database provider.

### Fixed

- Small fix to correct a failing test.


## [0.3.1] - 2026-03-17

### Added
Expand Down
63 changes: 63 additions & 0 deletions alembic/versions/69dbf30b36a0_added_provider_admin_tables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Added provider admin tables

Revision ID: 69dbf30b36a0
Revises: ec6411d99e0b
Create Date: 2026-03-24 11:43:56.481757

"""

from typing import Sequence, Union

import sqlalchemy as sa
import sqlmodel.sql.sqltypes
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "69dbf30b36a0"
down_revision: Union[str, Sequence[str], None] = "ec6411d99e0b"
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.create_table(
"tooldbmodel",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("provider", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"toolauthorisationdbmodel",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("tool", sa.Uuid(), nullable=False),
sa.Column("reporting_org", sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(
["tool"],
["tooldbmodel.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"tooluserdbmodel",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("tool", sa.Uuid(), nullable=False),
sa.Column("user", sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(
["tool"],
["tooldbmodel.id"],
),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("tooluserdbmodel")
op.drop_table("toolauthorisationdbmodel")
op.drop_table("tooldbmodel")
# ### 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.2"
version = "0.3.3"
requires-python = ">= 3.12.11"
readme = "README.md"
authors = [{name="IATI Secretariat", email="support@iatistandard.org"}]
Expand Down
20 changes: 18 additions & 2 deletions src/register_your_data_api/auth/authz.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from uuid import UUID
from uuid import UUID, uuid4

import starlette.requests
from fastapi import Security

from register_your_data_api.exceptions import RYDUserException

from ..util import Context # noqa
from .authn import parse_decoded_token
from .fga.fga_provider import FineGrainedAuthorisationIntegrityError
from .fga.fga_validator import FineGrainedAuthorisationUserValidator
from .models import UserAndCredentials

Expand All @@ -20,7 +23,20 @@ async def get_user_authnz(

current_user_id = UUID(user.user_id_crm)

users_fgas = context.fine_grained_auth_provider.get_user_fine_grained_permissions(current_user_id)
try:
users_fgas = context.fine_grained_auth_provider.get_user_fine_grained_permissions(current_user_id)
except FineGrainedAuthorisationIntegrityError as exc:
trace_id: UUID = uuid4()
raise RYDUserException(
user.user_id_crm,
user.client_id,
500,
app_msg=f"FGA Database integrity error with traceid={trace_id}",
audit_msg=f"There is an integrity issue with the authorisations in the FGA databse {exc}. "
f"METHOD={request.method} URL={request.url} CLIENT={request.client} TRACE_ID={trace_id}",
public_msg="There is a problem with your credentials. Please report this error to the provider "
f"of the tool you are using to access the IATI Registry quoting trace ID {trace_id}.",
)

is_superadmin = context.fine_grained_auth_provider.is_user_a_superadmin(current_user_id)

Expand Down
4 changes: 4 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 @@ -4,6 +4,10 @@
from .models import FineGrainedAuthorisationRoleAssociation


class FineGrainedAuthorisationIntegrityError(Exception):
pass


class FineGrainedAuthorisationProvider(ABC):

@abstractmethod
Expand Down
89 changes: 83 additions & 6 deletions src/register_your_data_api/auth/fga/fga_provider_db.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import collections
from uuid import UUID, uuid4

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

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


Expand All @@ -20,6 +21,24 @@ class SuperAdminUserDbModel(SQLModel, table=True):
is_superadmin: bool


class ToolDbModel(SQLModel, table=True):
id: UUID = Field(primary_key=True, default_factory=lambda: uuid4())
name: str
provider: str


class ToolAuthorisationDbModel(SQLModel, table=True):
id: UUID = Field(primary_key=True, default_factory=lambda: uuid4())
tool: UUID = Field(foreign_key="tooldbmodel.id")
reporting_org: UUID


class ToolUserDbModel(SQLModel, table=True):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This all looks good, my only thought so far is that ToolUserDbModel may (going forward) have the potential to be confusing, because the clients/customers of the 3rd party tools are also aptly described as "(3rd party) tool users". I wonder if ToolAdminUserDbModel might be more explanatory? Maybe that was discounted for another reason?

id: UUID = Field(primary_key=True, default_factory=lambda: uuid4())
tool: UUID = Field(foreign_key="tooldbmodel.id")
user: UUID
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add index?



class FineGrainedAuthorisationProviderDb(FineGrainedAuthorisationProvider):

_connection_str: str
Expand All @@ -37,7 +56,24 @@ def get_user_fine_grained_permissions(self, user: UUID) -> list[FineGrainedAutho
select(FineGrainedAuthorisationDbModel).where(FineGrainedAuthorisationDbModel.user == user)
).all()

return [FineGrainedAuthorisationRoleAssociation(**db_fga.model_dump()) for db_fga in user_db_fgas]
providers_reporting_orgs = session.exec(
select(ToolAuthorisationDbModel.reporting_org, ToolUserDbModel.user)
.join(ToolUserDbModel, col(ToolUserDbModel.tool) == col(ToolAuthorisationDbModel.tool))
.where(ToolUserDbModel.user == user)
).all()

if user_db_fgas and providers_reporting_orgs:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will block people from being both a provider admin for a tool, and also associated with a govern organisation in the normal way - is that correct? But some 3rd party tool providers publish datasets (whether or not they should or need to I don't know, but they do), so we might need to think that use-case through.

raise FineGrainedAuthorisationIntegrityError("User has both reporting org role(s) and is a tool user")

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
)
for x in providers_reporting_orgs
]

return associations

def get_user_associations_for_org(self, reporting_org: UUID) -> list[FineGrainedAuthorisationRoleAssociation]:
with Session(self._engine) as session:
Expand All @@ -47,7 +83,29 @@ def get_user_associations_for_org(self, reporting_org: UUID) -> list[FineGrained
)
).all()

return [FineGrainedAuthorisationRoleAssociation(**db_fga.model_dump()) for db_fga in user_db_fgas]
tool_users_for_org = session.exec(
select(ToolAuthorisationDbModel.reporting_org, ToolUserDbModel.user)
.join(ToolUserDbModel, col(ToolUserDbModel.tool) == col(ToolAuthorisationDbModel.tool))
.where(ToolAuthorisationDbModel.reporting_org == reporting_org)
).all()

associations = [FineGrainedAuthorisationRoleAssociation(**db_fga.model_dump()) for db_fga in user_db_fgas]
associations += [
FineGrainedAuthorisationRoleAssociation(
user=tool_user_for_org[1],
reporting_org=reporting_org,
role=FineGrainedAuthorisationRole.PROVIDER_ADMIN,
)
for tool_user_for_org in tool_users_for_org
]

if len(associations) > 1:
if collections.Counter([association.user for association in associations]).most_common(1)[0][1] > 1:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would also pick up if the FGA database gets into an error state where a user has two entries in there for the same org. I'm not suggesting this code needs to be changed, just flagging.

raise FineGrainedAuthorisationIntegrityError(
"Reporting org has user(s) that have both reporting org role and a provider admin role"
)

return associations

def get_user_role_for_org(self, user: UUID, org: UUID) -> FineGrainedAuthorisationRoleAssociation | None:
with Session(self._engine) as session:
Expand All @@ -58,10 +116,27 @@ def get_user_role_for_org(self, user: UUID, org: UUID) -> FineGrainedAuthorisati
)
).first()

if not user_role_for_org:
tool_user_for_org = session.exec(
select(ToolAuthorisationDbModel.reporting_org, ToolUserDbModel.user)
.join(ToolUserDbModel, col(ToolUserDbModel.tool) == col(ToolAuthorisationDbModel.tool))
.where((ToolAuthorisationDbModel.reporting_org == org) & (ToolUserDbModel.user == user))
).all()

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

if not user_role_for_org and not tool_user_for_org:
return None

return FineGrainedAuthorisationRoleAssociation(**user_role_for_org.model_dump())
if tool_user_for_org:
association = FineGrainedAuthorisationRoleAssociation(
user=user, reporting_org=org, role=FineGrainedAuthorisationRole.PROVIDER_ADMIN
)

if user_role_for_org:
association = FineGrainedAuthorisationRoleAssociation(**user_role_for_org.model_dump())

return association

def get_admin_users_for_org(self, org: UUID) -> list[FineGrainedAuthorisationRoleAssociation]:
with Session(self._engine) as session:
Expand Down Expand Up @@ -92,12 +167,14 @@ def create_user_fine_grained_authorisation(
with Session(self._engine) as session:
session.add(user_org_role_db)
session.commit()
# TODO: Check user doesn't have provider admin

def update_user_role_for_org(self, user_reporting_org_role: FineGrainedAuthorisationRoleAssociation) -> None:
user_org_role_db = FineGrainedAuthorisationDbModel(**user_reporting_org_role.model_dump())
with Session(self._engine) as session:
session.merge(user_org_role_db)
session.commit()
# TODO: Check user doesn't have provider admin

def delete_user_role_for_org(self, user_reporting_org_role: FineGrainedAuthorisationRoleAssociation) -> None:
with Session(self._engine) as session:
Expand Down
4 changes: 3 additions & 1 deletion src/register_your_data_api/auth/fga/fga_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ def get_permissions_for_role(self, user_role: FineGrainedAuthorisationRole) -> l
"delete-dataset",
]
permissions[FineGrainedAuthorisationRole.PROVIDER_ADMIN] = [
*permissions[FineGrainedAuthorisationRole.EDITOR],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this change in the requirements for provider admin permissions documented somewhere?

"read-org",
"read-dataset",
"update-dataset",
"update-dataset-visibility",
]
permissions[FineGrainedAuthorisationRole.ADMIN] = [
Expand Down
39 changes: 29 additions & 10 deletions src/register_your_data_api/routers/reporting_orgs.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,8 +502,6 @@ def get_reporting_org_users(
if u["id"] in user_ids_from_fga
}

user_ids_in_fga_not_suitecrm = user_ids_from_fga - {*names_emails_from_suitecrm.keys()}

# iterate over the provider admins, and add their names/emails to the dict
for provider_admin in users_for_org_from_fga:
if provider_admin.role == fga_models.FineGrainedAuthorisationRole.PROVIDER_ADMIN:
Expand All @@ -516,15 +514,36 @@ def get_reporting_org_users(
crm_user["data"][0]["attributes"]["email1"],
)

user_ids_in_fga_not_suitecrm = user_ids_from_fga - {*names_emails_from_suitecrm.keys()}
if user_ids_in_fga_not_suitecrm:
error_message = (
f"GET request to reporting-orgs/{org_id}/users by user id: {user.user_id_crm} "
f"but the following users associated with reporting org {org_id} in the FGA data "
f"store are not associated with that org in SuiteCRM: {user_ids_in_fga_not_suitecrm}"
)
context.app_logger.error(error_message)
context.audit_logger.error(error_message)
raise fastapi.HTTPException(500)
if any(
[
u.role != fga_models.FineGrainedAuthorisationRole.PROVIDER_ADMIN and u.user == uuid.UUID(user_id)
for u in users_for_org_from_fga
for user_id in user_ids_in_fga_not_suitecrm
]
):
trace_id: uuid.UUID = uuid.uuid4()
raise RYDUserException(
user.user_id_crm,
user.client_id,
500,
app_msg=(
f"GET request to reporting-orgs/{org_id}/users has found "
f"users that are not associated with that org in SuiteCRM: {user_ids_in_fga_not_suitecrm}. "
f"Trace id: {trace_id}"
),
audit_msg=(
f"GET request to reporting-orgs/{org_id}/users by user id: {user.user_id_crm} "
f"but the following users associated with reporting org {org_id} in the FGA data "
f"store are not associated with that org in SuiteCRM: {user_ids_in_fga_not_suitecrm}. "
f"Trace id: {trace_id}"
),
public_msg=(
"There is a problem getting the user list associated with this organisation. "
f"Please contact IATI Support quoting this error trace id: {trace_id}"
),
)

users_for_org = [
CRMUser(
Expand Down
21 changes: 18 additions & 3 deletions src/register_your_data_api/services/suitecrm_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

from register_your_data_api.auth import models as auth_models
from register_your_data_api.auth.fga import models as fga_models
from register_your_data_api.auth.fga.fga_provider import FineGrainedAuthorisationProvider
from register_your_data_api.auth.fga.fga_provider import (
FineGrainedAuthorisationIntegrityError,
FineGrainedAuthorisationProvider,
)
from register_your_data_api.data_handling.converters import (
get_discoverable_reporting_org_meta_from_suitecrm_response,
get_fga_role_as_str,
Expand All @@ -20,7 +23,7 @@
from register_your_data_api.util import Context


def get_reporting_orgs_for_user(
def get_reporting_orgs_for_user( # noqa: C901
context: Context,
requesting_user: auth_models.UserAndCredentials,
user_to_fetch: uuid.UUID,
Expand Down Expand Up @@ -66,7 +69,19 @@ def get_reporting_orgs_for_user(

total_records = orgs_for_user.get("meta", {}).get("total-records", 0)

users_roles = fga_provider.get_user_fine_grained_permissions(user_to_fetch)
try:
users_roles = fga_provider.get_user_fine_grained_permissions(user_to_fetch)
except FineGrainedAuthorisationIntegrityError as exc:
trace_id: uuid.UUID = uuid.uuid4()
raise RYDUserException(
requesting_user.user_id_crm,
requesting_user.client_id,
500,
app_msg=f"FGA Database integrity error with traceid={trace_id}",
audit_msg=f"FGA Database integrity error ({exc}) with traceid={trace_id}",
public_msg="There is a problem with your credentials. Please report this error to the provider "
f"of the tool you are using to access the IATI Registry quoting trace ID {trace_id}.",
)

reporting_orgs_list: list[UserReportingOrgRelation | UserReportingOrgDiscoverableMetadataRelation] = []

Expand Down
Loading
Loading