Skip to content

Commit 0cad50e

Browse files
authored
Restrict setting platfrom and platfrom_resource_identifier (#516)
* set platform to aiod * set platform_resource_identifier to aiod_entry_identifier * donot expose platform using POST endpoint * ruff formatting * create connector user in keycloak * remove setting platform and platform_resource_identifier in test env * platform name set using platform_names.py * update test_authorisation * not set platfrom and platform_resource_identifier in test * update tests post and get_count * do not set platform and platfrom_resource_identifier in test * debug test_router_contact * diable upload test * disable uploader for this PR * revert concept.py to original form * correct error code * add additional test * simplfy * remove deprecated v1, fix bugs, add env variable to docker-compose.yaml * fix test post merge conflict resolution * incorrect merge conflict resolution * contant method for status code, minor fix * update test * platform_resource_identifer = identifier, connector platform name is verified * small fixes * set platform to aiod * set platform_resource_identifier to aiod_entry_identifier * donot expose platform using POST endpoint * ruff formatting * create connector user in keycloak * remove setting platform and platform_resource_identifier in test env * platform name set using platform_names.py * update test_authorisation * not set platfrom and platform_resource_identifier in test * update tests post and get_count * do not set platform and platfrom_resource_identifier in test * debug test_router_contact * diable upload test * disable uploader for this PR * revert concept.py to original form * correct error code * add additional test * simplfy * remove deprecated v1, fix bugs, add env variable to docker-compose.yaml * fix test post merge conflict resolution * incorrect merge conflict resolution * contant method for status code, minor fix * update test * platform_resource_identifer = identifier, connector platform name is verified * small fixes * remove old unused code * improve test_router_get_count * remove commented code, minor fixes * resolve pr comments * remove dependency injection * user permission check to set platform is moved to register_resource_func()
1 parent ddd29cb commit 0cad50e

File tree

17 files changed

+175
-65
lines changed

17 files changed

+175
-65
lines changed

src/authentication.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434

3535
oidc = OpenIdConnect(openIdConnectUrl=KEYCLOAK_CONFIG.get("openid_connect_url"), auto_error=False)
3636

37-
3837
REVIEWER_ROLE = os.getenv("REVIEWER_ROLE_NAME")
3938
client_secret = os.getenv("KEYCLOAK_CLIENT_SECRET")
4039

@@ -66,10 +65,23 @@ def has_role(self, role: str) -> bool:
6665
def has_any_role(self, *roles: str) -> bool:
6766
return bool(set(roles) & self.roles)
6867

68+
def is_connector_for_platform(self, platform_name: str) -> bool:
69+
"""
70+
Check if the user is a connector for a specific platform.
71+
"""
72+
return f"platform_{platform_name}" in self.roles
73+
6974
@property
7075
def is_reviewer(self):
7176
return REVIEWER_ROLE in self.roles
7277

78+
@property
79+
def is_connector(self) -> bool:
80+
"""
81+
Check if the user is a connector.
82+
"""
83+
return any(role.startswith("platform_") for role in self.roles)
84+
7385

7486
async def _get_user(token) -> KeycloakUser:
7587
"""

src/database/model/concept/concept.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class AIoDConceptBase(SQLModel):
3131
schema_extra={"example": PlatformName.example},
3232
foreign_key="platform.name",
3333
)
34+
3435
platform_resource_identifier: str | None = Field(
3536
max_length=NORMAL,
3637
description="A unique identifier issued by the external platform that's specified in "
@@ -78,6 +79,7 @@ class AIoDConcept(AIoDConceptBase):
7879
foreign_key=AIoDEntryORM.__tablename__ + ".identifier",
7980
unique=True,
8081
)
82+
8183
aiod_entry: AIoDEntryORM = Relationship()
8284

8385
_id_generator: Callable[[], str] | None = None

src/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def counts() -> dict:
8484
+ parent_routers.router_list
8585
+ enum_routers.router_list
8686
+ search_routers.router_list
87-
+ uploader_routers.router_list
87+
# + uploader_routers.router_list # TODO: uploaders disabled until issue #538 is resolved.
8888
+ [review_router, user_router]
8989
):
9090
app.include_router(router.create(url_prefix))

src/routers/resource_router.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,10 +468,36 @@ def register_resource(
468468
resource_create: clz_create, # type: ignore
469469
user: KeycloakUser = Depends(get_user_or_raise),
470470
):
471+
platform = getattr(resource_create, "platform", None)
472+
platform_resource_identifier = getattr(
473+
resource_create, "platform_resource_identifier", None
474+
)
475+
if user.is_connector:
476+
# Check if connector belongs to the specific platform it is registering the resource for.
477+
if platform is None or not user.is_connector_for_platform(platform):
478+
raise HTTPException(
479+
status_code=HTTPStatus.FORBIDDEN,
480+
detail=f"No permission to upload assets for {platform} platform.",
481+
)
482+
if platform_resource_identifier is None:
483+
raise HTTPException(
484+
status_code=HTTPStatus.FORBIDDEN,
485+
detail=f"Platform resource identifier may not be none.",
486+
)
487+
488+
# 2. Normal user: must NOT provide platform/platform_resource_identifier
489+
else:
490+
if platform is not None or platform_resource_identifier is not None:
491+
raise HTTPException(
492+
status_code=HTTPStatus.FORBIDDEN,
493+
detail="No permission to set platform or platform resource identifier.",
494+
)
495+
471496
try:
472497
with DbSession() as session:
473498
try:
474499
resource = self.create_resource(session, resource_create)
500+
475501
register_user(user, session)
476502
set_permission(
477503
user, resource.aiod_entry, session, type_=PermissionType.ADMIN
@@ -485,13 +511,24 @@ def register_resource(
485511

486512
return register_resource
487513

488-
def create_resource(self, session: Session, resource_create_instance: SQLModel):
514+
def create_resource(
515+
self,
516+
session: Session,
517+
resource_create_instance: SQLModel,
518+
):
489519
"""Store a resource in the database"""
490520
resource = self.resource_class.from_orm(resource_create_instance)
491521
deserialize_resource_relationships(
492522
session, self.resource_class, resource, resource_create_instance
493523
)
494524
session.add(resource)
525+
session.flush()
526+
527+
if resource.platform is None and resource.platform_resource_identifier is None:
528+
# Set these fields as required for normal users
529+
resource.platform = PlatformName.aiod
530+
resource.platform_resource_identifier = resource.identifier
531+
495532
session.commit()
496533
return resource
497534

src/tests/authorization/test_authorization.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,9 +232,7 @@ def test_an_published_asset_is_not_pending_for_review(client, publication):
232232
def test_retrieving_single_submission_works(user: KeycloakUser, mode: ListMode, asset: int, reason: str, client: TestClient, publication_factory):
233233
publication = publication_factory()
234234
oldest = publication_factory()
235-
oldest.platform_resource_identifier = "OLDEST"
236235
newest = publication_factory()
237-
newest.platform_resource_identifier = "NEWEST"
238236

239237
register_asset(publication, owner=ALICE, status=EntryStatus.PUBLISHED)
240238
register_asset(oldest, owner=ALICE, status=EntryStatus.SUBMITTED)
Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
{
2-
"platform": "example",
3-
"platform_resource_identifier": "1",
42
"aiod_entry": {
53
}
64
}

src/tests/routers/generic/test_router_get_count.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,26 +60,37 @@ def test_get_count_detailed_happy_path(client_test_resource: TestClient):
6060
assert response_json == {"aiod": 1, "example": 2, "openml": 1}
6161
assert "deprecated" not in response.headers
6262

63-
63+
from database.model.concept.aiod_entry import EntryStatus, AIoDEntryORM
64+
# default platfrom is "aiod"
6465
def test_get_count_total(
6566
client: TestClient,
6667
person: Person,
6768
publication: Publication,
6869
contact: Contact,
6970
):
71+
7072
register_asset(person)
7173
register_asset(publication)
72-
register_asset(Publication(name="2"))
73-
register_asset(Publication(name="3"))
7474
register_asset(contact)
7575

76+
resources = [
77+
Publication(name="2", platform="example", platform_resource_identifier=2),
78+
Publication(name="3", platform="example", platform_resource_identifier=3)
79+
]
80+
for res in resources:
81+
res.aiod_entry = AIoDEntryORM()
82+
res.aiod_entry.status = EntryStatus.PUBLISHED
83+
84+
with DbSession() as session:
85+
session.add_all(resources)
86+
session.commit()
87+
7688
response = client.get("/counts")
7789
assert response.status_code == 200, response.json()
7890
response_json = response.json()
79-
8091
assert response_json == {
81-
"contacts": {"example": 1},
82-
"persons": {"example": 1},
83-
"publications": {"aiod": 2, "example": 1},
92+
"contacts": {"aiod": 1},
93+
"persons": {"aiod": 1},
94+
"publications": {"aiod": 1, "example": 2},
8495
}
8596
assert "deprecated" not in response.headers

src/tests/routers/generic/test_router_post.py

Lines changed: 69 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
import pytest
33
from starlette.testclient import TestClient
44

5-
from tests.testutils.users import logged_in_user
5+
from tests.testutils.users import logged_in_user, kc_connector_with_roles
6+
from database.model.platform.platform_names import PlatformName
7+
from http import HTTPStatus
68

79

810
@pytest.mark.parametrize(
@@ -13,20 +15,22 @@ def test_unicode(client_test_resource: TestClient, title: str, auto_publish: Non
1315
with logged_in_user():
1416
response = client_test_resource.post(
1517
"/test_resources/v0",
16-
json={"title": title, "platform": "example", "platform_resource_identifier": "1"},
18+
json={"title": title},
1719
headers={"Authorization": "Fake token"},
1820
)
1921
assert response.status_code == 200, response.json()
20-
identifier = response.json()['identifier']
22+
assert "identifier" in response.json()
23+
identifier = response.json()["identifier"]
2124

2225
response = client_test_resource.get(f"/test_resources/v0/{identifier}")
2326
assert response.status_code == 200, response.json()
2427
response_json = response.json()
2528
assert response_json["title"] == title
29+
assert response_json["platform"] == PlatformName.aiod
2630

2731

2832
def test_missing_value(client_test_resource: TestClient):
29-
body = {"platform": "example", "platform_resource_identifier": "1"}
33+
body: dict[str, str] = {}
3034
with logged_in_user():
3135
response = client_test_resource.post(
3236
"/test_resources/v0", json=body, headers={"Authorization": "Fake token"}
@@ -38,7 +42,7 @@ def test_missing_value(client_test_resource: TestClient):
3842

3943

4044
def test_null_value(client_test_resource: TestClient):
41-
body = {"title": None, "platform": "example", "platform_resource_identifier": "1"}
45+
body = {"title": None}
4246
with logged_in_user():
4347
response = client_test_resource.post(
4448
"/test_resources/v0", json=body, headers={"Authorization": "Fake token"}
@@ -52,57 +56,78 @@ def test_null_value(client_test_resource: TestClient):
5256
}
5357
]
5458

55-
59+
# Prevents a connector from posting the same item twice.
5660
def test_posting_same_item_twice(client_test_resource: TestClient):
5761
headers = {"Authorization": "Fake token"}
5862
body = {"title": "title1", "platform": "example", "platform_resource_identifier": "1"}
59-
with logged_in_user():
63+
connector_user = kc_connector_with_roles()
64+
65+
with logged_in_user(connector_user):
6066
response = client_test_resource.post("/test_resources/v0", json=body, headers=headers)
67+
6168
assert response.status_code == 200, response.json()
62-
identifier = response.json()['identifier']
69+
identifier = response.json()["identifier"]
6370
body = {"title": "title2", "platform": "example", "platform_resource_identifier": "1"}
64-
with logged_in_user():
71+
with logged_in_user(connector_user):
6572
response = client_test_resource.post("/test_resources/v0", json=body, headers=headers)
6673
assert response.status_code == 409, response.json()
6774
assert (
68-
response.json()["detail"] == "There already exists a test_resource with the same "
69-
f"platform and platform_resource_identifier, with identifier={identifier}."
75+
response.json()["detail"]
76+
== f"There already exists a test_resource with the same platform and platform_resource_identifier, with identifier={identifier}."
7077
)
7178

7279

7380
def test_posting_same_item_twice_but_deleted(
7481
client_test_resource: TestClient
7582
):
7683
headers = {"Authorization": "Fake token"}
77-
body = {"title": "title1", "platform": "example", "platform_resource_identifier": "1"}
84+
body = {"title": "title1"}
7885
with logged_in_user():
7986
response = client_test_resource.post("/test_resources/v0", json=body, headers=headers)
87+
identifier = response.json()["identifier"]
8088
assert response.status_code == 200, response.json()
8189
identifier = response.json()['identifier']
8290

8391
with logged_in_user():
8492
response = client_test_resource.delete(f"/test_resources/v0/{identifier}", headers=headers)
8593
assert response.status_code == 200, response.json()
8694

87-
body = {"title": "title2", "platform": "example", "platform_resource_identifier": "1"}
95+
body = {"title": "title2"}
8896
with logged_in_user():
8997
response = client_test_resource.post("/test_resources/v0", json=body, headers=headers)
9098
assert response.status_code == 200, response.json()
9199

92100

93-
def test_no_platform_no_platform_resource_identifier(
101+
def test_platform_and_platform_identifier_defaults_are_set_if_not_provided(
94102
client_test_resource: TestClient
95103
):
104+
"""
105+
The platform and platform_resource_identifier are set by the server.
106+
"""
96107
headers = {"Authorization": "Fake token"}
97108
body = {"title": "title1", "platform": None, "platform_resource_identifier": None}
98109
with logged_in_user():
99110
response = client_test_resource.post("/test_resources/v0", json=body, headers=headers)
100111
assert response.status_code == 200, response.json()
112+
101113
body = {"title": "title2", "platform": None, "platform_resource_identifier": None}
102114
with logged_in_user():
103115
response = client_test_resource.post("/test_resources/v0", json=body, headers=headers)
104116
assert response.status_code == 200, response.json()
105117

118+
def test_post_platform_and_platform_resource_identifier_rejected(
119+
client_test_resource: TestClient
120+
):
121+
headers = {"Authorization": "Fake token"}
122+
body = {"title": "title1", "platform": "aiod", "platform_resource_identifier": 2}
123+
with logged_in_user():
124+
response = client_test_resource.post("/test_resources/v0", json=body, headers=headers)
125+
126+
assert response.status_code == HTTPStatus.FORBIDDEN
127+
assert response.json()["detail"] == (
128+
"No permission to set platform or platform resource identifier.")
129+
130+
106131

107132
def test_no_platform_with_platform_resource_identifier(
108133
client_test_resource: TestClient
@@ -111,11 +136,10 @@ def test_no_platform_with_platform_resource_identifier(
111136
body = {"title": "title1", "platform": None, "platform_resource_identifier": "1"}
112137
with logged_in_user():
113138
response = client_test_resource.post("/test_resources/v0", json=body, headers=headers)
114-
assert response.status_code == 400, response.json()
139+
assert response.status_code == HTTPStatus.FORBIDDEN, response.json()
115140
assert (
116141
response.json()["detail"]
117-
== "If platform is NULL, platform_resource_identifier should also be "
118-
"NULL, and vice versa."
142+
== "No permission to set platform or platform resource identifier."
119143
)
120144

121145

@@ -126,21 +150,38 @@ def test_platform_with_no_platform_resource_identifier(
126150
body = {"title": "title1", "platform": "example", "platform_resource_identifier": None}
127151
with logged_in_user():
128152
response = client_test_resource.post("/test_resources/v0", json=body, headers=headers)
129-
assert response.status_code == 400, response.json()
153+
assert response.status_code == HTTPStatus.FORBIDDEN, response.json()
130154
assert (
131155
response.json()["detail"]
132-
== "If platform is NULL, platform_resource_identifier should also be "
133-
"NULL, and vice versa."
156+
== "No permission to set platform or platform resource identifier."
134157
)
135158

159+
def test_connector_can_post_to_valid_platform(
160+
client_test_resource: TestClient,
161+
):
162+
headers = {"Authorization": "Fake token"}
163+
connector_user = kc_connector_with_roles()
164+
body = {
165+
"title": "ConnectorResource",
166+
"platform": "example",
167+
"platform_resource_identifier": "conn-123"
168+
}
169+
with logged_in_user(connector_user):
170+
response = client_test_resource.post("/test_resources/v0", json=body, headers=headers)
171+
assert response.status_code == 200
172+
136173

137-
def test_non_existent_platform(client_test_resource: TestClient):
174+
def test_connector_cannot_post_to_other_platform(
175+
client_test_resource: TestClient,
176+
):
138177
headers = {"Authorization": "Fake token"}
139-
body = {"title": "title1", "platform": "this_does_not_exist", "platform_resource_identifier": 1}
140-
with logged_in_user():
178+
connector_user = kc_connector_with_roles()
179+
body = {
180+
"title": "ConnectorResource",
181+
"platform": "aiod",
182+
"platform_resource_identifier": "conn-123"
183+
}
184+
with logged_in_user(connector_user):
141185
response = client_test_resource.post("/test_resources/v0", json=body, headers=headers)
142-
assert response.status_code == 412
143-
assert (
144-
response.json()["detail"] == "Platform this_does_not_exist does not exist. You can "
145-
"register it using the POST platforms endpoint."
146-
)
186+
assert response.status_code == HTTPStatus.FORBIDDEN
187+
assert response.json()["detail"] == "No permission to upload assets for aiod platform."

0 commit comments

Comments
 (0)