Skip to content

Commit c7c34da

Browse files
authored
Add assets/permissions/{identifier} endpoint that can be used to request a list of all permissions pertaining to that asset (#655)
Add endpoint to show permission, use Keycloak API more directly
1 parent 732238d commit c7c34da

File tree

5 files changed

+126
-14
lines changed

5 files changed

+126
-14
lines changed

docs/using/upload.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,16 @@ their submission. An example body of the `POST` request to `/reviews` could look
201201
```
202202
The comment may be up to 1800 characters long, so detailed feedback can be given.
203203

204+
## Sharing Access
205+
By default, items are private when they are in draft or under submission.
206+
When items are published, they are publicly readable.
207+
However, you are the only person with the ability to edit or remove them.
208+
209+
You can allow others to see assets which are still private, or give users administrator
210+
or write permissions for individual assets. For more information, see the `/assets/permissions`
211+
endpoints.
212+
213+
204214
## Notes
205215
Assets registered by users will automatically be associated with the "AIoD" platform,
206216
indicating that it's a direct registration. Users cannot associate their registered

src/authentication.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from dotenv import load_dotenv
2727
from fastapi import HTTPException, Security, status
2828
from fastapi.security import OpenIdConnect
29-
from keycloak import KeycloakOpenID, KeycloakAdmin
29+
from keycloak import KeycloakOpenID, KeycloakAdmin, KeycloakGetError
3030

3131
from config import KEYCLOAK_CONFIG
3232

@@ -177,16 +177,24 @@ async def get_user_or_raise(token=Security(oidc)) -> KeycloakUser:
177177

178178

179179
def get_user_by_username(username: str) -> KeycloakUser | None:
180-
"""Gets the keycloak user by its username. `user.roles` will always be empty."""
181-
users = keycloak_api().get_users(query={"username": username, "exact": True})
182-
if not users:
183-
return None
184-
185-
if len(users) > 1:
186-
raise NotImplementedError(
187-
f"Multiple users with username {username} found, expected behavior undefined."
180+
"""Gets the keycloak user by their username. `user.roles` will always be empty."""
181+
if sub := keycloak_api().get_user_id(username):
182+
return KeycloakUser(
183+
name=username,
184+
roles=set(), # Not included with the call
185+
_subject_identifier=sub,
188186
)
189-
user = users[0]
187+
return None
188+
189+
190+
def get_user_by_sub(sub: str) -> KeycloakUser | None:
191+
"""Gets the keycloak user by their sub. `user.roles` will always be empty."""
192+
try:
193+
user = keycloak_api().get_user(sub)
194+
except KeycloakGetError as e:
195+
if e.error_message == "User not found":
196+
return None
197+
raise
190198
return KeycloakUser(
191199
name=user.get("username"),
192200
roles=set(), # Not included with the call

src/routers/asset_router.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import re
22
from http import HTTPStatus
33
from fastapi import APIRouter, Depends, HTTPException, Body
4-
from sqlmodel import Session
4+
from sqlmodel import Session, select
5+
import logging
56

6-
from authentication import KeycloakUser, get_user_or_none, get_user_or_raise, get_user_by_username
7+
from authentication import (
8+
KeycloakUser,
9+
get_user_or_none,
10+
get_user_or_raise,
11+
get_user_by_username,
12+
get_user_by_sub,
13+
)
714
from database.authorization import user_can_administer, set_permission, Permission, register_user
815
from database.session import get_session
916
from database.model.helper_functions import get_asset_by_identifier
@@ -12,6 +19,8 @@
1219
from database.authorization import user_can_read, PermissionType
1320
from versioning import Version
1421

22+
logger = logging.getLogger(__file__)
23+
1524

1625
def create(url_prefix: str = "", version: Version = Version.LATEST) -> APIRouter:
1726
router = APIRouter()
@@ -76,6 +85,34 @@ def add_or_update_permission(
7685
session.delete(permission)
7786
session.commit()
7887

88+
@router.get(
89+
"/assets/permissions/{identifier}",
90+
tags=["Assets"],
91+
description="Show the permissions for this asset. Requires admin rights of the asset.",
92+
)
93+
def show_permission(
94+
identifier: str,
95+
session: Session = Depends(get_session),
96+
current_user: KeycloakUser = Depends(get_user_or_raise),
97+
):
98+
_, resource = get_asset_by_identifier(identifier, session)
99+
if not user_can_administer(current_user, resource.aiod_entry):
100+
raise HTTPException(
101+
status_code=HTTPStatus.FORBIDDEN,
102+
detail=f"You are not allowed to see permissions for asset {identifier}.",
103+
)
104+
105+
permissions = select(Permission).where(
106+
Permission.aiod_entry_identifier == resource.aiod_entry.identifier
107+
)
108+
users = []
109+
for permission in session.scalars(permissions).all():
110+
if (user := get_user_by_sub(permission.user_identifier)) is not None:
111+
users.append({"name": user.name, "permission": permission.type_})
112+
else:
113+
logger.warning(f"Could not find user for sub {permission.user_identifier}.")
114+
return users
115+
79116
@router.get(
80117
f"/assets/{{identifier}}",
81118
tags=["Assets"],
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[{"id": "alice000-0000-0000-0000-000000000000", "username": "Alice", "emailVerified": "False", "createdTimestamp": 1739270119298, "enabled": "True", "totp": "False", "disableableCredentialTypes": [], "requiredActions": [], "notBefore": 0, "access": {"manageGroupMembership": "False", "view": "True", "mapRoles": "False", "impersonate": "False", "manage": "False"}}]

src/tests/test_asset_router.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33

44
import pytest
55
from http import HTTPStatus
6+
7+
from sqlmodel import select
68
from starlette.testclient import TestClient
79
import responses
810

9-
from database.authorization import register_user, PermissionType, Permission
11+
from database.authorization import register_user, PermissionType, Permission, set_permission
1012
from database.model.agent.organisation import Organisation
1113
from database.session import DbSession
1214

@@ -80,7 +82,7 @@ def test_add_permission_by_name(
8082
)
8183
request_mock.add(
8284
responses.GET,
83-
"http://keycloak:8080/aiod-auth/admin/realms/aiod/users?username=Bob&exact=True&max=100&first=0",
85+
"http://keycloak:8080/aiod-auth/admin/realms/aiod/users?username=bob&max=1&exact=True",
8486
json=users_response,
8587
)
8688

@@ -96,3 +98,57 @@ def test_add_permission_by_name(
9698
permission = session.get(Permission, {"aiod_entry_identifier": 1 , "user_identifier": BOB._subject_identifier})
9799
assert permission is not None
98100
assert permission.type_ == PermissionType.WRITE
101+
102+
103+
def test_show_permission(
104+
client: TestClient,
105+
publication: Publication,
106+
):
107+
identifier = register_asset(publication, owner=ALICE)
108+
with DbSession() as session:
109+
register_user(BOB, session)
110+
publication = session.scalar(select(Publication))
111+
set_permission(BOB, publication.aiod_entry, session, type_=PermissionType.WRITE)
112+
session.commit()
113+
114+
cached_api_token = path_test_resources() / "authentication" / "admin_connect.json"
115+
with cached_api_token.open("r") as f:
116+
connect_response = json.load(f)
117+
118+
cached_user_response = path_test_resources() / "authentication" / "query_alice.json"
119+
with cached_user_response.open("r") as f:
120+
alice_response = json.load(f)
121+
alice_response = alice_response[0] # default json returns "multiple" user response
122+
123+
cached_user_response = path_test_resources() / "authentication" / "query_bob.json"
124+
with cached_user_response.open("r") as f:
125+
bob_response = json.load(f)
126+
bob_response = bob_response[0] # default json returns "multiple" user response
127+
128+
# The second time around the cached KeycloakAdmin is used, so the connect is not called
129+
with responses.RequestsMock(assert_all_requests_are_fired=False) as request_mock:
130+
request_mock.add(
131+
responses.POST,
132+
"http://keycloak:8080/aiod-auth/realms/aiod/protocol/openid-connect/token",
133+
json=connect_response,
134+
)
135+
request_mock.add(
136+
responses.GET,
137+
"http://keycloak:8080/aiod-auth/admin/realms/aiod/users/bob00000-0000-0000-0000-000000000000",
138+
json=bob_response,
139+
)
140+
141+
request_mock.add(
142+
responses.GET,
143+
"http://keycloak:8080/aiod-auth/admin/realms/aiod/users/alice000-0000-0000-0000-000000000000",
144+
json=alice_response,
145+
)
146+
147+
with logged_in_user(ALICE):
148+
response = client.get(
149+
f"/assets/permissions/{identifier}",
150+
headers={"Authorization": "fake-token"},
151+
)
152+
assert response.status_code == HTTPStatus.OK
153+
server_permissions = {p["name"]: p["permission"] for p in response.json()}
154+
assert server_permissions == {"Alice": "admin", "Bob": "write"}

0 commit comments

Comments
 (0)