Skip to content

Commit b4b5948

Browse files
authored
Feat/read permissions (#509)
2 parents 3e4fe8e + 871df5c commit b4b5948

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+431
-308
lines changed

src/database/authorization.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@
33

44
import sqlalchemy
55
from sqlalchemy import Column
6-
from sqlmodel import SQLModel, Field, Relationship, select, Session, and_
6+
from sqlmodel import SQLModel, Field, Relationship, select, Session
77

88
from authentication import KeycloakUser
99
from database.model.concept.aiod_entry import AIoDEntryORM, EntryStatus
10-
from database.model.concept.concept import AIoDConcept
1110

1211

1312
class User(SQLModel, table=True): # type: ignore [call-arg]

src/database/model/resource_bundle/resource_bundle.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import List, Optional
1+
from typing import List
22
from sqlmodel import Relationship
33

44
from database.model.ai_resource.resource import AIResource, AIResourceBase

src/routers/resource_ai_asset_router.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from typing import Annotated
22

3-
from fastapi import APIRouter, HTTPException, status, Path
3+
from fastapi import APIRouter, HTTPException, status, Path, Depends
44
from fastapi.responses import RedirectResponse
55

6+
from authentication import get_user_or_none, KeycloakUser
67
from database.model.ai_asset.ai_asset import AIAsset
78
from .resource_router import ResourceRouter
89

@@ -56,9 +57,13 @@ def get_resource_content(
5657
int,
5758
Path(description=f"The index of the distribution within the {self.resource_name}"),
5859
],
60+
user: KeycloakUser | None = Depends(get_user_or_none),
5961
):
6062
metadata: AIAsset = self.get_resource(
61-
identifier=identifier, schema="aiod", platform=None
63+
identifier=identifier,
64+
schema="aiod",
65+
platform=None,
66+
user=user,
6267
) # type: ignore
6368

6469
distributions = metadata.distribution
@@ -88,8 +93,9 @@ def get_resource_content_default(
8893
identifier: Annotated[
8994
str, Path(description=f"The identifier of the {self.resource_name}")
9095
],
96+
user: KeycloakUser | None = Depends(get_user_or_none),
9197
):
92-
return get_resource_content(identifier=identifier, distribution_idx=0)
98+
return get_resource_content(identifier=identifier, distribution_idx=0, user=user)
9399

94100
if default:
95101
return get_resource_content_default

src/routers/resource_router.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import datetime
33
import traceback
44
from functools import partial
5+
from http import HTTPStatus
56
from typing import Annotated, Any, Literal, Sequence, Type, TypeVar, Union
67
from wsgiref.handlers import format_date_time
78

@@ -13,14 +14,14 @@
1314
from starlette.responses import JSONResponse
1415

1516
from authentication import KeycloakUser, get_user_or_none, get_user_or_raise
16-
from config import KEYCLOAK_CONFIG
1717
from converters.schema_converters.schema_converter import SchemaConverter
1818
from database.authorization import (
1919
user_can_administer,
2020
set_permission,
2121
register_user,
2222
PermissionType,
2323
user_can_write,
24+
user_can_read,
2425
)
2526
from database.model.ai_resource.resource import AIResource
2627
from database.model.concept.aiod_entry import AIoDEntryORM, EntryStatus
@@ -32,7 +33,7 @@
3233
resource_read,
3334
)
3435
from database.model.serializers import deserialize_resource_relationships
35-
from database.review import Decision, Review, Submission, ReviewCreate, SubmissionCreate
36+
from database.review import Submission, SubmissionCreate
3637
from database.session import DbSession
3738
from dependencies.filtering import ResourceFilters, ResourceFiltersParams
3839
from dependencies.pagination import Pagination, PaginationParams
@@ -211,7 +212,7 @@ def get_resources(
211212
user: KeycloakUser | None = None,
212213
platform: str | None = None,
213214
):
214-
"""Fetch all resources of this platform in given schema, using pagination"""
215+
"""Fetch all published resources of this platform in given schema, using pagination"""
215216
_raise_error_on_invalid_schema(self._possible_schemas, schema)
216217
with DbSession(autoflush=False) as session:
217218
try:
@@ -244,6 +245,17 @@ def get_resource(
244245
resource: Any = self._retrieve_resource_and_post_process(
245246
session, identifier, user, platform=platform
246247
)
248+
if resource.aiod_entry.status != EntryStatus.PUBLISHED:
249+
if user is None:
250+
raise HTTPException(
251+
status_code=HTTPStatus.UNAUTHORIZED,
252+
detail="This asset is not published. It requires authentication to access.",
253+
)
254+
if not user_can_read(user, resource.aiod_entry):
255+
raise HTTPException(
256+
status_code=HTTPStatus.FORBIDDEN,
257+
detail="You are not allowed to view this resource.",
258+
)
247259
if schema != "aiod":
248260
return self.schema_converters[schema].convert(session, resource)
249261
return self._wrap_with_headers(self.resource_class_read.from_orm(resource))
@@ -276,7 +288,7 @@ def get_resources(
276288

277289
def get_resource_count_func(self):
278290
"""
279-
Gets the total number of resources from the database.
291+
Gets the total number of published resources from the database.
280292
This function returns a function (instead of being that function directly) because the
281293
docstring and the variables are dynamic, and used in Swagger.
282294
"""
@@ -291,7 +303,11 @@ def get_resource_count(
291303
if not detailed:
292304
return (
293305
session.query(self.resource_class)
294-
.where(is_(self.resource_class.date_deleted, None))
306+
.join(self.resource_class.aiod_entry, isouter=True)
307+
.where(
308+
is_(self.resource_class.date_deleted, None),
309+
AIoDEntryORM.status == EntryStatus.PUBLISHED,
310+
)
295311
.count()
296312
)
297313
else:
@@ -300,7 +316,11 @@ def get_resource_count(
300316
self.resource_class.platform,
301317
func.count(self.resource_class.identifier),
302318
)
303-
.where(is_(self.resource_class.date_deleted, None))
319+
.join(self.resource_class.aiod_entry, isouter=True)
320+
.where(
321+
is_(self.resource_class.date_deleted, None),
322+
AIoDEntryORM.status == EntryStatus.PUBLISHED,
323+
)
304324
.group_by(self.resource_class.platform)
305325
.all()
306326
)
@@ -612,8 +632,8 @@ def _retrieve_resources(
612632
platform: str | None = None,
613633
) -> Sequence[type[RESOURCE_MODEL]]:
614634
"""
615-
Retrieve a sequence of resources from the database based on the provided identifier,
616-
platform and resource filters (if applicable).
635+
Retrieve a sequence of published resources from the database based on the
636+
provided identifier, platform and resource filters (if applicable).
617637
"""
618638
where_clause = and_(
619639
is_(self.resource_class.date_deleted, None),
@@ -624,6 +644,7 @@ def _retrieve_resources(
624644
AIoDEntryORM.date_modified < resource_filters.date_modified_before
625645
if resource_filters.date_modified_before is not None
626646
else True,
647+
AIoDEntryORM.status == EntryStatus.PUBLISHED,
627648
)
628649
query = (
629650
select(self.resource_class)

src/tests/authorization/test_authorization.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ def test_new_asset_is_draft(client, publication, mocked_privileged_token: Mock):
3636
)
3737
assert response.status_code == HTTPStatus.OK, response.json()
3838

39-
server_data = client.get(f"/publications/v1/{response.json()['identifier']}").json()
39+
server_data = client.get(
40+
f"/publications/v1/{response.json()['identifier']}",
41+
headers={"Authorization": "Fake token"},
42+
).json()
4043
assert server_data["aiod_entry"]["status"] == EntryStatus.DRAFT
4144

4245

@@ -291,7 +294,10 @@ def test_user_can_edit_asset_in_draft(publication, client):
291294
headers={"Authorization": "Fake token"},
292295
)
293296
assert response.status_code == HTTPStatus.OK, response.json()
294-
updated_publication = client.get(f"/publications/v1/{identifier}").json()
297+
updated_publication = client.get(
298+
f"/publications/v1/{identifier}",
299+
headers={"Authorization": "Fake token"},
300+
).json()
295301
assert updated_publication["name"] == new_name
296302

297303

@@ -351,7 +357,12 @@ def test_reviewer_can_reject_submission(publication, client):
351357
)
352358
assert response.status_code == HTTPStatus.OK, response.json()
353359

354-
response = client.get(f"/publications/v1/{identifier}")
360+
# Because the rejected asset is back in draft status, it requires authentication to access.
361+
with logged_in_user(ALICE):
362+
response = client.get(
363+
f"/publications/v1/{identifier}",
364+
headers={"Authorization": "Fake token"},
365+
)
355366
assert response.status_code == HTTPStatus.OK, response.json()
356367
assert response.json()["aiod_entry"]["status"] == EntryStatus.DRAFT
357368

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import copy
22
from http import HTTPStatus
3-
from unittest.mock import Mock
43

54
import pytest
65
from starlette.testclient import TestClient
76

87
from database.model.concept.aiod_entry import EntryStatus
8+
from tests.testutils.users import logged_in_user, ALICE
99

1010

1111
@pytest.fixture()
@@ -19,31 +19,34 @@ def publication_body(body_asset: dict) -> dict:
1919
return body
2020

2121

22-
def test_entry_status_can_update_on_put(
22+
def test_entry_status_does_not_update_on_put(
2323
client: TestClient,
24-
mocked_privileged_token: Mock,
2524
publication_body: dict,
2625
):
27-
response = client.post(
28-
"/publications/v1", json=publication_body, headers={"Authorization": "Fake token"}
29-
)
26+
with logged_in_user(ALICE):
27+
response = client.post(
28+
"/publications/v1", json=publication_body, headers={"Authorization": "Fake token"}
29+
)
3030
assert response.status_code == 200, response.json()
3131

3232
# Default is DRAFT
33-
response = client.get("/publications/v1/1")
33+
with logged_in_user(ALICE):
34+
response = client.get("/publications/v1/1", headers={"Authorization": "Fake token"})
3435
assert response.status_code == 200, response.json()
3536
assert response.json()["aiod_entry"]["status"] == EntryStatus.DRAFT
3637
identifier = response.json()["identifier"]
3738

3839
publication_body["aiod_entry"]["status"] = EntryStatus.PUBLISHED
39-
response = client.put(
40-
f"/publications/v1/{identifier}",
41-
json=publication_body,
42-
headers={"Authorization": "Fake token"},
43-
)
40+
with logged_in_user(ALICE):
41+
response = client.put(
42+
f"/publications/v1/{identifier}",
43+
json=publication_body,
44+
headers={"Authorization": "Fake token"},
45+
)
4446
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, response.json()
4547

4648
# Status is not updated to published
47-
response = client.get(f"/publications/v1/{identifier}")
49+
with logged_in_user(ALICE):
50+
response = client.get("/publications/v1/1", headers={"Authorization": "Fake token"})
4851
assert response.status_code == 200, response.json()
4952
assert response.json()["aiod_entry"]["status"] == EntryStatus.DRAFT

src/tests/database/deletion/test_hard_delete.py

Lines changed: 13 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from database.deletion import hard_delete
66
from database.model.concept.aiod_entry import AIoDEntryORM, EntryStatus
77
from database.session import DbSession
8-
from tests.testutils.test_resource import factory, TestResource
8+
from tests.testutils.test_resource import factory_test_resource, TestResource
99

1010

1111
def test_hard_delete():
@@ -14,34 +14,18 @@ def test_hard_delete():
1414
with DbSession() as session:
1515
session.add_all(
1616
[
17-
factory(
18-
title="test_resource_to_keep",
19-
platform="example",
20-
platform_resource_identifier=1,
21-
status=EntryStatus.DRAFT,
22-
date_deleted=None,
23-
),
24-
factory(
25-
title="test_resource_to_keep_2",
26-
platform="example",
27-
platform_resource_identifier=2,
28-
status=EntryStatus.DRAFT,
29-
date_deleted=now,
30-
),
31-
factory(
32-
title="my_test_resource",
33-
platform="example",
34-
platform_resource_identifier=3,
35-
status=EntryStatus.DRAFT,
36-
date_deleted=deletion_time,
37-
),
38-
factory(
39-
title="second_test_resource",
40-
platform="example",
41-
platform_resource_identifier=4,
42-
status=EntryStatus.DRAFT,
43-
date_deleted=deletion_time,
44-
),
17+
factory_test_resource(title="test_resource_to_keep", status=EntryStatus.DRAFT,
18+
platform="example", platform_resource_identifier=1,
19+
date_deleted=None),
20+
factory_test_resource(title="test_resource_to_keep_2", status=EntryStatus.DRAFT,
21+
platform="example", platform_resource_identifier=2,
22+
date_deleted=now),
23+
factory_test_resource(title="my_test_resource", status=EntryStatus.DRAFT,
24+
platform="example", platform_resource_identifier=3,
25+
date_deleted=deletion_time),
26+
factory_test_resource(title="second_test_resource", status=EntryStatus.DRAFT,
27+
platform="example", platform_resource_identifier=4,
28+
date_deleted=deletion_time),
4529
]
4630
)
4731
session.commit()

0 commit comments

Comments
 (0)