Skip to content

Commit 52ed411

Browse files
authored
Merge pull request #68 from IATI/sk-error-handling--count-bug--user-reporting-orgs-endpoint
Add `/users/{id}/reporting-orgs' endpoint, improve handling of unknown clients, fix dataset count bug
2 parents 0ee8a77 + 687770f commit 52ed411

13 files changed

Lines changed: 318 additions & 90 deletions

File tree

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919

2020
### Security
2121

22+
## [0.3.0]
23+
24+
### Added
25+
26+
- Added the /users/{id}/reporting-orgs endpoint which allows lookup of a user's
27+
orgs by user id.
28+
29+
### Fixed
30+
31+
- Updated short name validation so that it rejects values with uppercase
32+
characters.
33+
- Improves error handling and logging for when clients connect with a
34+
misconfigured client ID.
35+
36+
## [0.2.9]
37+
38+
### Added
39+
40+
- Emails notifications to Secretariat admins when a user creates a new
41+
organisation and to organisation admins when a user requests to join their
42+
organisation.
43+
44+
## [0.2.8]
45+
46+
### Added
47+
48+
- Improved and adding more audit logging.
49+
50+
### Fixed
51+
52+
- Fixed the validation on dataset short_name which wasn't checking whether the
53+
record in the CRM was deleted.
54+
2255
## [0.2.7]
2356

2457
### Fixed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "register-your-data-api"
3-
version = "0.2.9"
3+
version = "0.3.0"
44
requires-python = ">= 3.12.11"
55
readme = "README.md"
66
authors = [{name="IATI Secretariat", email="support@iatistandard.org"}]

src/register_your_data_api/auth/authz.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,14 @@ async def get_user_authnz(
1818
if user.sub is None:
1919
raise RuntimeError # TODO: handle in proper way
2020

21-
# Read the user's details from Asgardeo to get their SuiteCRM UUID
22-
# user.user_id_crm = context._crm_uuid_provider.get_crm_uuid(user)
21+
current_user_id = UUID(user.user_id_crm)
2322

24-
# Read the user's fine grained authorisations
25-
users_fgas = context.fine_grained_auth_provider.get_user_fine_grained_permissions(UUID(user.user_id_crm))
23+
users_fgas = context.fine_grained_auth_provider.get_user_fine_grained_permissions(current_user_id)
2624

27-
is_superadmin = context.fine_grained_auth_provider.is_user_a_superadmin(UUID(user.user_id_crm))
25+
is_superadmin = context.fine_grained_auth_provider.is_user_a_superadmin(current_user_id)
2826

2927
user.fga_user_validator = FineGrainedAuthorisationUserValidator(
30-
fine_grained_authorisations=users_fgas, is_superadmin=is_superadmin
28+
user_id=current_user_id, fine_grained_authorisations=users_fgas, is_superadmin=is_superadmin
3129
)
3230

3331
return user

src/register_your_data_api/auth/fga/fga_validator.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
class FineGrainedAuthorisationUserValidator(pydantic.BaseModel):
99

10+
user_id: UUID
11+
1012
fine_grained_authorisations: list[FineGrainedAuthorisationRoleAssociation] | None
1113

1214
is_superadmin: bool
@@ -172,6 +174,12 @@ def user_can_modify_user_roles_for_reporting_org(self, reporting_org_id: UUID) -
172174

173175
return False
174176

177+
def user_can_read_users_reporting_orgs(self, user_id: UUID) -> bool:
178+
if self.is_superadmin:
179+
return True
180+
181+
return self.user_id == user_id
182+
175183
def get_users_fine_grained_associations(self) -> list[FineGrainedAuthorisationRoleAssociation]:
176184
if self.fine_grained_authorisations is None:
177185
return []

src/register_your_data_api/client_application_details_provider.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,6 @@ def get_client_application_details(self, client_id: str) -> ClientApplicationDet
5454

5555
if client_id not in self._CLIENT_APPLICATION_DETAILS:
5656
error_message = f"Unknown client application. Client id: {client_id} is not found in the list of clients"
57-
self._app_logger.error(error_message)
58-
self._audit_logger.error(error_message)
5957
raise ValueError(error_message)
6058

6159
return self._CLIENT_APPLICATION_DETAILS[client_id]

src/register_your_data_api/data_handling/data_schemas.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44

55
import pydantic
66

7-
alpha_numeric_hyphen_regex = re.compile(r"^[a-zA-Z0-9-_]+$")
7+
alpha_lowercase_numeric_hyphen_regex = re.compile(r"^[a-z0-9-_]+$")
88

99

10-
def validate_alpha_numeric_hyphen_str(value: str) -> str:
11-
if not alpha_numeric_hyphen_regex.match(value):
12-
raise ValueError("String must contain only alphanumeric characters, hyphens, or underscores")
10+
def validate_alpha_numeric_hyphen_lowercase_str(value: str) -> str:
11+
if not alpha_lowercase_numeric_hyphen_regex.match(value):
12+
raise ValueError("String must contain only lowercase alphanumeric characters, hyphens, or underscores")
1313
return value
1414

1515

@@ -62,7 +62,7 @@ class DatasetCreateModel(pydantic.BaseModel):
6262
human_readable_name: str
6363
licence_id: str
6464
owner_organisation_id: str
65-
short_name: Annotated[str, pydantic.AfterValidator(validate_alpha_numeric_hyphen_str)]
65+
short_name: Annotated[str, pydantic.AfterValidator(validate_alpha_numeric_hyphen_lowercase_str)]
6666
source_type: str
6767
url: str
6868
visibility: str
@@ -71,7 +71,7 @@ class DatasetCreateModel(pydantic.BaseModel):
7171
class DatasetUpdateModel(pydantic.BaseModel):
7272
human_readable_name: str
7373
licence_id: str
74-
short_name: Annotated[str, pydantic.AfterValidator(validate_alpha_numeric_hyphen_str)]
74+
short_name: Annotated[str, pydantic.AfterValidator(validate_alpha_numeric_hyphen_lowercase_str)]
7575
source_type: str
7676
url: str
7777
visibility: Literal["public", "private"] | None = None
@@ -124,7 +124,7 @@ class ReportingOrgUserCreateModel(pydantic.BaseModel):
124124
phone: str | None
125125
region: str | None
126126
reporting_source_type: str | None
127-
short_name: Annotated[str, pydantic.AfterValidator(validate_alpha_numeric_hyphen_str)]
127+
short_name: Annotated[str, pydantic.AfterValidator(validate_alpha_numeric_hyphen_lowercase_str)]
128128
website: str | None
129129

130130

src/register_your_data_api/dependencies.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import uuid
2+
13
import starlette.requests
24
from fastapi import Security
35

46
from register_your_data_api.auth import authz
7+
from register_your_data_api.exceptions import RYDUserException
58
from register_your_data_api.util import Context
69

710

@@ -13,7 +16,20 @@ def get_suitecrm_audit_headers(
1316

1417
context: Context = request.app.state.context
1518

16-
client_app_details = context.get_client_application_details(user.client_id)
19+
try:
20+
client_app_details = context.get_client_application_details(user.client_id)
21+
except ValueError as e:
22+
trace_id = uuid.uuid4()
23+
raise RYDUserException(
24+
user,
25+
400,
26+
f"trace_id: {trace_id} - details: unknown client ID",
27+
f"trace_id: {trace_id} - details: {e.args[0]}",
28+
(
29+
"There was a problem processing your request (invalid client ID). "
30+
f"Please contact IATI Support quoting trace_id: {trace_id}"
31+
),
32+
) from e
1733

1834
return {
1935
"IATI-Person-ID": user.user_id_crm,

src/register_your_data_api/routers/reporting_orgs.py

Lines changed: 7 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@
88
from fastapi import BackgroundTasks, Depends, Security
99
from fastapi.exceptions import HTTPException
1010
from fastapi.responses import JSONResponse
11-
from libsuitecrm import Filter, RequestFailed, SuiteCRM # type: ignore
11+
from libsuitecrm import Filter, SuiteCRM # type: ignore
1212

1313
from register_your_data_api import email_generator
1414
from register_your_data_api.background_tasks import ActionType, enqueue_task
1515
from register_your_data_api.dependencies import get_suitecrm_audit_headers
1616
from register_your_data_api.exceptions import RYDUserException
17+
from register_your_data_api.services.suitecrm_common import get_reporting_orgs_for_user
1718

1819
from ..auth import authz
1920
from ..auth import models as auth_models
@@ -22,7 +23,6 @@
2223
SUITECRM_REPORTING_ORG_FIELDS,
2324
get_dataset_actions_from_suitecrm_response,
2425
get_dataset_list_from_suitecrm_response,
25-
get_discoverable_reporting_org_meta_from_suitecrm_response,
2626
get_fga_role_as_str,
2727
get_reporting_org_meta_from_suitecrm_response,
2828
get_suitecrm_dict_from_reporting_org,
@@ -31,11 +31,9 @@
3131
CRMUser,
3232
CRMUserListResponse,
3333
DatasetReadModel,
34-
DiscoverableReportingOrgMetadata,
3534
PaginationQueryParams,
3635
ReportingOrgAction,
3736
ReportingOrgCreateModel,
38-
ReportingOrgMetadata,
3937
ReportingOrgUpdateModel,
4038
ReportingOrgUserCreateModel,
4139
UserReportingOrgDiscoverableMetadataRelation,
@@ -60,74 +58,11 @@ def get_reporting_orgs(
6058

6159
context: Context = request.app.state.context
6260

63-
crm: SuiteCRM = context.suitecrm_client_factory.get_client()
64-
65-
try:
66-
orgs_for_user = crm.get_relationship(
67-
"Contacts",
68-
user.user_id_crm,
69-
"Accounts",
70-
page_number=paging.page,
71-
page_size=paging.page_size,
72-
sort_field="name",
73-
sort_dir="ascending",
74-
filters=Filter().equal("iati_registry_discoverable", "1"),
75-
)
76-
except RequestFailed as e:
77-
error_id = uuid.uuid4()
78-
public_error_message = (
79-
"There was a problem fetching the list of reporting orgs you are associated with. "
80-
f"Please try again later, or contact IATI Support quoting error id: {error_id}"
81-
)
82-
context.app_logger.error(
83-
f"error id: {error_id} - user id: {user.user_id_crm} - GET /reporting-orgs - Problem when fetching "
84-
"the list of reporting organisations for this user from SuiteCRM. "
85-
f"Details: {str(e)}"
86-
)
87-
raise HTTPException(status_code=fastapi.status.HTTP_500_INTERNAL_SERVER_ERROR, detail=public_error_message)
88-
89-
total_records = orgs_for_user.get("meta", {}).get("total-records", 0)
90-
91-
reporting_orgs_list: list[UserReportingOrgRelation | UserReportingOrgDiscoverableMetadataRelation] = []
92-
93-
for reporting_org_from_suitecrm in orgs_for_user["data"]:
94-
role_for_org = user.validator.get_user_role_for_reporting_org(reporting_org_from_suitecrm["id"])
95-
96-
if role_for_org is None:
97-
context.app_logger.info(
98-
f"user id: {user.user_id_crm} - GET /reporting-orgs - user is associated with organisation "
99-
f"{reporting_org_from_suitecrm["id"]} in the CRM but has no role for that organisation in the FGA DB. "
100-
"Organisation was omitted from the list returned to the user."
101-
)
102-
continue
103-
104-
reporting_org_obj: ReportingOrgMetadata | DiscoverableReportingOrgMetadata
105-
106-
if role_for_org == fga_models.FineGrainedAuthorisationRole.CONTRIBUTOR_PENDING:
107-
reporting_org_obj = get_discoverable_reporting_org_meta_from_suitecrm_response(
108-
reporting_org_from_suitecrm["attributes"]
109-
)
110-
else:
111-
reporting_org_obj = get_reporting_org_meta_from_suitecrm_response(
112-
reporting_org_from_suitecrm["attributes"]
113-
)
114-
115-
reporting_orgs_list.append(
116-
UserReportingOrgRelation(
117-
id=reporting_org_from_suitecrm["id"],
118-
user_role=get_fga_role_as_str(role_for_org),
119-
metadata=reporting_org_obj,
120-
reporting_org_actions=(
121-
get_reporting_org_actions(crm, reporting_org_from_suitecrm["id"])
122-
if include_actions == "yes"
123-
else []
124-
),
125-
)
126-
)
127-
128-
reporting_orgs_list.sort(key=lambda org: org.metadata.human_readable_name.lower())
61+
total_records, reporting_orgs = get_reporting_orgs_for_user(
62+
context, user, uuid.UUID(user.user_id_crm), paging.page, paging.page_size
63+
)
12964

130-
return PaginatedResultsPage.create(reporting_orgs_list, paging.page, paging.page_size, total_records, request)
65+
return PaginatedResultsPage.create(reporting_orgs, paging.page, paging.page_size, total_records, request)
13166

13267

13368
@router.get("/{org_id}")
@@ -648,7 +583,7 @@ def get_reporting_org_datasets(
648583

649584
# 4. SuiteCRM doesn't tell us the total number of records, so we set page size = 1 and make a request
650585
total_records_resp = crm.get_records("IATI_Datasets", filters=filters, fields=["id"], page_number=1, page_size=1)
651-
total_records = total_records_resp.get("meta", {}).get("total-pages", 1)
586+
total_records = total_records_resp.get("meta", {}).get("total-pages", 0)
652587

653588
datasets = get_dataset_list_from_suitecrm_response(datasets_from_suitecrm)
654589

src/register_your_data_api/routers/users.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,18 @@
1313
from register_your_data_api import email_generator
1414
from register_your_data_api.background_tasks import ActionType, enqueue_task
1515
from register_your_data_api.dependencies import get_suitecrm_audit_headers
16+
from register_your_data_api.response_schemas import PaginatedResultsPage
17+
from register_your_data_api.services.suitecrm_common import get_reporting_orgs_for_user
1618

1719
from ..auth import authz
1820
from ..auth.fga import models as fga_models
1921
from ..auth.models import UserAndCredentials
2022
from ..data_handling.converters import get_fga_role_from_str
2123
from ..data_handling.data_schemas import (
2224
OrganisationId,
25+
PaginationQueryParams,
26+
UserReportingOrgDiscoverableMetadataRelation,
27+
UserReportingOrgRelation,
2328
UserRoleUpdateModel,
2429
)
2530
from ..exception_handlers import format_log_msg
@@ -221,6 +226,52 @@ def add_user_to_reporting_org(
221226
return JSONResponse({"data": None, "error": None, "status": "success"}, 200)
222227

223228

229+
@router.get("/{user_id}/reporting-orgs")
230+
def get_reporting_orgs(
231+
user_id: uuid.UUID,
232+
request: starlette.requests.Request,
233+
user: UserAndCredentials = Security(authz.get_user_authnz, scopes=["ryd", "ryd:reporting_org"]),
234+
paging: PaginationQueryParams = fastapi.Depends(),
235+
) -> PaginatedResultsPage[UserReportingOrgRelation | UserReportingOrgDiscoverableMetadataRelation]:
236+
237+
context: Context = request.app.state.context
238+
239+
crm: SuiteCRM = context.suitecrm_client_factory.get_client()
240+
241+
# 1. Check that the requested user exists in CRM
242+
assert_precondition_met(
243+
context,
244+
user,
245+
condition_func=lambda: check_crm_record_exists(crm, "Contacts", str(user_id)),
246+
public_msg=f"There is no user with ID {str(user_id)}.",
247+
status_code=fastapi.status.HTTP_404_NOT_FOUND,
248+
audit_log_msg=(
249+
f"user id: {user.user_id_crm} - GET /users/{user_id}reporting-orgs - Request to read reporting orgs for "
250+
f"user {user_id} but there is no such user in the CRM."
251+
),
252+
)
253+
254+
# 2. Check that the user has permission to the the requested user's reporting orgs
255+
assert_precondition_met(
256+
context,
257+
user,
258+
condition_func=lambda: user.validator.user_can_read_users_reporting_orgs(user_id),
259+
status_code=fastapi.status.HTTP_403_FORBIDDEN,
260+
public_msg=(
261+
"There is a problem with your credentials. If this persists please report this "
262+
"error to the provider of the tool you are using to access the IATI Register Your Data."
263+
),
264+
audit_log_msg=(
265+
f"user id: {user.user_id_crm} - GET /users/{user_id}reporting-orgs - Request to read reporting orgs for "
266+
f"user {user_id} by unauthorised user."
267+
),
268+
)
269+
270+
total_records, reporting_orgs = get_reporting_orgs_for_user(context, user, user_id, paging.page, paging.page_size)
271+
272+
return PaginatedResultsPage.create(reporting_orgs, paging.page, paging.page_size, total_records, request)
273+
274+
224275
@router.put("/{user_id}/reporting-org/{org_id}")
225276
def update_user_role_in_reporting_org(
226277
user_id: uuid.UUID,

0 commit comments

Comments
 (0)