Skip to content

Commit 785e2b6

Browse files
authored
Allow users to configure permissions for their assets (#638)
* add endpoint for managing asset permissions * Add admin role for modifying resources * Update assign permissions to allow the use of subject identifier * Allow deletion of permission by subject identifier * Support removal of permissions by administrators * Use the service account to query for the user * Add admin user to aoid realm and view-users role to aiod-api client * Add test for adding user by nickname * Force exact name match * add doc of new users and roles * minor corrections/clarifications
1 parent 8acca8a commit 785e2b6

File tree

15 files changed

+265
-36
lines changed

15 files changed

+265
-36
lines changed

.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ REDIRECT_URIS=http://${HOSTNAME}/docs/oauth2-redirect
1818
POST_LOGOUT_REDIRECT_URIS=http://${HOSTNAME}/aiod-auth/realms/aiod/protocol/openid-connect/logout
1919
AIOD_KEYCLOAK_PORT=8080
2020
REVIEWER_ROLE_NAME=review_aiod_resources
21+
ADMIN_ROLE_NAME=admin_aiod_resources
2122

2223
EGICHECKINALIAS=
2324

authentication/import/aiod-realm.json

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@
5353
"clientRole" : false,
5454
"containerId" : "3df7e07d-ebbd-41c4-bc0c-1ba0e1a40ac5",
5555
"attributes" : { }
56+
}, {
57+
"id" : "22918626-68f6-4bc4-802a-96f8262a2d44",
58+
"name" : "admin_aiod_resources",
59+
"description" : "Allow the modification or deletion of any asset on the platform, as well as assigning or removing any permissions.",
60+
"composite" : false,
61+
"clientRole" : false,
62+
"containerId" : "3df7e07d-ebbd-41c4-bc0c-1ba0e1a40ac5",
63+
"attributes" : { }
5664
}, {
5765
"id" : "25a45d6d-fe0d-4855-945f-f5a27ec86ad0",
5866
"name" : "uma_authorization",
@@ -1424,7 +1432,7 @@
14241432
"subType" : "authenticated",
14251433
"subComponents" : { },
14261434
"config" : {
1427-
"allowed-protocol-mapper-types" : [ "saml-user-attribute-mapper", "oidc-full-name-mapper", "oidc-usermodel-property-mapper", "saml-role-list-mapper", "oidc-address-mapper", "oidc-sha256-pairwise-sub-mapper", "saml-user-property-mapper", "oidc-usermodel-attribute-mapper" ]
1435+
"allowed-protocol-mapper-types" : [ "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-property-mapper", "oidc-address-mapper", "saml-user-property-mapper", "saml-role-list-mapper", "saml-user-attribute-mapper", "oidc-full-name-mapper", "oidc-usermodel-attribute-mapper" ]
14281436
}
14291437
}, {
14301438
"id" : "10f8b9b2-1038-4c98-b7a5-a9ac88fed69e",
@@ -1466,7 +1474,7 @@
14661474
"subType" : "anonymous",
14671475
"subComponents" : { },
14681476
"config" : {
1469-
"allowed-protocol-mapper-types" : [ "saml-user-attribute-mapper", "oidc-usermodel-attribute-mapper", "oidc-address-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-full-name-mapper", "saml-user-property-mapper", "saml-role-list-mapper", "oidc-usermodel-property-mapper" ]
1477+
"allowed-protocol-mapper-types" : [ "oidc-usermodel-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-property-mapper", "saml-user-attribute-mapper", "oidc-address-mapper", "oidc-full-name-mapper", "saml-user-property-mapper", "saml-role-list-mapper" ]
14701478
}
14711479
}, {
14721480
"id" : "1d21f027-e0ae-4b80-b95e-f21d9426f115",

authentication/import/aiod-users-0.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
11
{
22
"realm" : "aiod",
33
"users" : [ {
4+
"id" : "ca9c5ba7-0c53-437e-98d0-8bba5eaac5f0",
5+
"username" : "admin",
6+
"emailVerified" : false,
7+
"createdTimestamp" : 1761654821859,
8+
"enabled" : true,
9+
"totp" : false,
10+
"credentials" : [ {
11+
"id" : "20e60836-478e-4e77-bfcf-7730324cf1cd",
12+
"type" : "password",
13+
"userLabel" : "My password",
14+
"createdDate" : 1761654837022,
15+
"secretData" : "{\"value\":\"YDfyAyfKhCIdefMBzk/aieDFuuaLrhzYtAPPeWlipnLR062Pw+gGO0XfpcZbypiXBsuKI4O5QXmJy6mnwHZWgw==\",\"salt\":\"gQLs0a7jinjZC9V9n7SFdg==\",\"additionalParameters\":{}}",
16+
"credentialData" : "{\"hashIterations\":210000,\"algorithm\":\"pbkdf2-sha512\",\"additionalParameters\":{}}"
17+
} ],
18+
"disableableCredentialTypes" : [ ],
19+
"requiredActions" : [ ],
20+
"realmRoles" : [ "admin_aiod_resources", "default-roles-aiod" ],
21+
"notBefore" : 0,
22+
"groups" : [ ]
23+
}, {
424
"id" : "5e827ac1-259d-406a-b3de-cf4f79f88364",
525
"username" : "reviewer",
626
"emailVerified" : false,
@@ -33,6 +53,7 @@
3353
"requiredActions" : [ ],
3454
"realmRoles" : [ "default-roles-aiod" ],
3555
"clientRoles" : {
56+
"realm-management" : [ "view-users" ],
3657
"aiod-api" : [ "uma_protection" ]
3758
},
3859
"notBefore" : 0,

docs/developer/authentication.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ For authentication, we use a [keycloak](https://www.keycloak.org) service.
44
For development, make sure to use the `USE_LOCAL_DEV=true` environment variable so that the local
55
keycloak server is configured with default users:
66

7-
| User | Password | Role(s) | Comment |
8-
|------|----------|-------------------------------------------------------|---------|
9-
| user | password | default-roles-aiod, offline_access, uma_authorization | |
7+
| User | Password | Role(s) | Comment |
8+
|----------|----------|-------------------------------------------------------|---------|
9+
| user | password | default-roles-aiod, offline_access, uma_authorization | |
10+
| reviewer | password | default-roles-aiod, review_aiod_resources | |
11+
| admin | password | default-roles-aiod, admin_aiod_resources | |
1012

1113
For a description of the roles, see ["AIoD Keycloak Roles"](../hosting/authentication.md#roles).
1214
With the local development configuration, you will only be able to authenticate with keycloak users (OAuth2, password) not by other means.

docs/hosting/authentication.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ The normal flow for granting individual users permissions for individual assets
4343
These are the roles the metadata catalogue uses (`*` in a role indicates its defined for each asset type individually):
4444

4545
* `review_aiod_resources`: identifies the user as having permission to view asset submissions and review them.
46+
* `admin_aiod_resources`: treats the user as an owner of every asset on the platform.
4647
* `read_*`: allows the user read access to all assets on the platform, regardless of the asset-specific permissions.
4748
* `update_*`: allows the user update permission for all assets on the platform, regardless of the asset-specific permissions.
4849
* `delete_*`: allows the user delete permission for all assets on the platform, regardless of the asset-specific permissions.

src/authentication.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@
1919
"""
2020

2121
import dataclasses
22+
import functools
2223
import logging
2324
import os
2425

2526
from dotenv import load_dotenv
2627
from fastapi import HTTPException, Security, status
2728
from fastapi.security import OpenIdConnect
28-
from keycloak import KeycloakOpenID
29+
from keycloak import KeycloakOpenID, KeycloakAdmin
2930

3031
from config import KEYCLOAK_CONFIG
3132

@@ -35,6 +36,7 @@
3536
oidc = OpenIdConnect(openIdConnectUrl=KEYCLOAK_CONFIG.get("openid_connect_url"), auto_error=False)
3637

3738
REVIEWER_ROLE = os.getenv("REVIEWER_ROLE_NAME")
39+
ADMIN_ROLE = os.getenv("ADMIN_ROLE_NAME")
3840
client_secret = os.getenv("KEYCLOAK_CLIENT_SECRET")
3941

4042
keycloak_openid = KeycloakOpenID(
@@ -46,6 +48,16 @@
4648
)
4749

4850

51+
@functools.cache
52+
def keycloak_api() -> KeycloakAdmin:
53+
return KeycloakAdmin(
54+
server_url=KEYCLOAK_CONFIG.get("server_url"),
55+
realm_name=KEYCLOAK_CONFIG.get("realm"),
56+
client_id=KEYCLOAK_CONFIG.get("client_id"),
57+
client_secret_key=os.getenv("KEYCLOAK_CLIENT_SECRET"),
58+
)
59+
60+
4961
def assert_required_settings_configured() -> None:
5062
# These variables are required for the API to function, so we provide context beyond KeyError.
5163
# Should be managed together with other settings in the future (#67)
@@ -75,6 +87,10 @@ def is_connector_for_platform(self, platform_name: str) -> bool:
7587
def is_reviewer(self):
7688
return REVIEWER_ROLE in self.roles
7789

90+
@property
91+
def is_admin(self):
92+
return ADMIN_ROLE in self.roles
93+
7894
@property
7995
def is_connector(self) -> bool:
8096
"""
@@ -160,6 +176,24 @@ async def get_user_or_raise(token=Security(oidc)) -> KeycloakUser:
160176
) from err
161177

162178

179+
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."
188+
)
189+
user = users[0]
190+
return KeycloakUser(
191+
name=user.get("username"),
192+
roles=set(), # Not included with the call
193+
_subject_identifier=user.get("id"),
194+
)
195+
196+
163197
class InvalidUserError(Exception):
164198
"""Raise an error on invalid userinfo or inactive user."""
165199

src/database/authorization.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ class Permission(SQLModel, table=True): # type: ignore [call-arg]
5555
def _user_has_permission(
5656
user: KeycloakUser, aiod_entry: AIoDEntryORM, *, at_least: PermissionType
5757
) -> bool:
58-
return any(
58+
return user.is_admin or any(
5959
permission.user_identifier == user._subject_identifier and permission.type_ >= at_least
6060
for permission in aiod_entry.permissions
6161
)

src/routers/asset_router.py

Lines changed: 78 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,81 @@
1+
import re
12
from http import HTTPStatus
2-
from fastapi import APIRouter, Depends, HTTPException
3+
from fastapi import APIRouter, Depends, HTTPException, Body
34
from sqlmodel import Session
45

5-
from authentication import KeycloakUser, get_user_or_none
6+
from authentication import KeycloakUser, get_user_or_none, get_user_or_raise, get_user_by_username
7+
from database.authorization import user_can_administer, set_permission, Permission, register_user
68
from database.session import get_session
79
from routers.helper_functions import get_asset_type_by_abbreviation
810
from routers.resource_routers import versioned_routers
911
from database.model.concept.aiod_entry import EntryStatus
10-
from database.authorization import user_can_read
12+
from database.authorization import user_can_read, PermissionType
1113
from versioning import Version
1214

1315

1416
def create(url_prefix: str = "", version: Version = Version.LATEST) -> APIRouter:
1517
router = APIRouter()
1618

19+
@router.post(
20+
"/assets/permissions",
21+
tags=["Assets"],
22+
description="Manage permissions that a user has for an asset.",
23+
)
24+
def add_or_update_permission(
25+
asset_identifier: str = Body(
26+
description="The identifier of the asset for which to update the permission."
27+
),
28+
user: str = Body(
29+
description="The username or subject identifier of the user.",
30+
examples=["jsmith01", "4a80f256-3928-4cfa-ba66-5e22bb36fc01"],
31+
),
32+
permission_type: PermissionType | None = Body(
33+
description="The permission to add for the user. "
34+
"If not set, their permissions will be removed.",
35+
default=None,
36+
),
37+
session: Session = Depends(get_session),
38+
current_user: KeycloakUser = Depends(get_user_or_raise),
39+
):
40+
_, resource = get_asset_by_identifier(asset_identifier, session)
41+
if not user_can_administer(current_user, resource.aiod_entry):
42+
raise HTTPException(
43+
status_code=HTTPStatus.FORBIDDEN,
44+
detail=f"You are not allowed to update permissions for asset {asset_identifier}.",
45+
)
46+
sub_pattern = r"\S{8}(-\S{4}){3}-\S{12}"
47+
if re.match(sub_pattern, user):
48+
other = KeycloakUser(name="unknown", roles=set(), _subject_identifier=user)
49+
else:
50+
other = get_user_by_username(user) # type: ignore[assignment]
51+
if not other:
52+
raise HTTPException(
53+
status_code=HTTPStatus.NOT_FOUND,
54+
detail=f"User with name {user!r} not found.",
55+
)
56+
57+
register_user(other, session) # Should be replaced by KC pushing to REST API
58+
if other._subject_identifier == current_user._subject_identifier:
59+
# This request is more likely to be an accident than on purpose.
60+
# Additionally, we do not want to allow people to accidentally remove all
61+
# administrators from an asset which this restriction ensures.
62+
raise HTTPException(
63+
status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
64+
detail="You cannot change permissions that pertain to yourself.",
65+
)
66+
if permission_type:
67+
set_permission(other, resource.aiod_entry, session, type_=permission_type)
68+
session.commit()
69+
else:
70+
key = {
71+
"user_identifier": other._subject_identifier,
72+
"aiod_entry_identifier": resource.aiod_entry.identifier,
73+
}
74+
permission = session.get(Permission, key)
75+
if permission:
76+
session.delete(permission)
77+
session.commit()
78+
1779
@router.get(
1880
f"/assets/{{identifier}}",
1981
tags=["Assets"],
@@ -27,17 +89,7 @@ def asset(
2789
"""
2890
Get the resource identified by AIoD identifier, return in aiod schema.
2991
"""
30-
asset_type_map = get_asset_type_by_abbreviation()
31-
prefix = identifier.split("_")[0]
32-
model_class = asset_type_map.get(prefix)
33-
34-
if not model_class:
35-
raise HTTPException(
36-
status_code=HTTPStatus.NOT_FOUND,
37-
detail=f"Unknown asset type with identifier '{identifier}'",
38-
)
39-
40-
resource = session.get(model_class, identifier)
92+
model_class, resource = get_asset_by_identifier(identifier, session)
4193

4294
if not resource or resource.date_deleted is not None:
4395
raise HTTPException(
@@ -66,4 +118,16 @@ def asset(
66118
detail=f"No router found to deserialize asset of type '{model_class.__name__}'",
67119
)
68120

121+
def get_asset_by_identifier(identifier, session):
122+
asset_type_map = get_asset_type_by_abbreviation()
123+
prefix = identifier.split("_")[0]
124+
model_class = asset_type_map.get(prefix)
125+
if not model_class:
126+
raise HTTPException(
127+
status_code=HTTPStatus.NOT_FOUND,
128+
detail=f"Unknown asset type with identifier '{identifier}'",
129+
)
130+
resource = session.get(model_class, identifier)
131+
return model_class, resource
132+
69133
return router

src/routers/review_router.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ def _review_resource(
192192
user: KeycloakUser = Depends(get_user_or_raise),
193193
session: Session = Depends(get_session),
194194
):
195-
if not user.is_reviewer:
195+
if not (user.is_reviewer or user.is_admin):
196196
raise HTTPException(
197197
status_code=status.HTTP_403_FORBIDDEN,
198198
detail="You must have reviewing privileges to use this endpoint.",

src/tests/authorization/test_authorization.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,69 @@
66
import pytest
77
from starlette.testclient import TestClient
88

9-
from authentication import KeycloakUser
9+
from authentication import KeycloakUser, ADMIN_ROLE
1010
from database.authorization import (
11-
PermissionType, user_can_read, user_can_write, user_can_administer, set_permission,
11+
PermissionType, user_can_read, user_can_write, user_can_administer, set_permission, Permission,
1212
)
1313
from database.model.concept.aiod_entry import EntryStatus
1414
from database.review import Decision, ReviewCreate
1515
from database.session import DbSession
1616
from database.model.knowledge_asset.publication import Publication
1717
from routers.review_router import ListMode
1818
from tests.testutils.users import ALICE, BOB, REVIEWER, _register_user_in_db, \
19-
logged_in_user, register_asset
19+
logged_in_user, register_asset, kc_user_with_roles
2020
from versioning import Version
2121

2222

23+
def test_admin_can_delete_asset(client, publication):
24+
identifier = register_asset(publication, owner=ALICE, status=EntryStatus.PUBLISHED)
25+
with logged_in_user(kc_user_with_roles(ADMIN_ROLE)):
26+
response = client.delete(
27+
f"/publications/{identifier}", headers={"Authorization": "Fake token"}
28+
)
29+
assert response.status_code == HTTPStatus.OK, response.json()
30+
31+
def test_admin_can_remove_permission(client, publication):
32+
identifier = register_asset(publication, owner=ALICE, status=EntryStatus.PUBLISHED)
33+
with logged_in_user(kc_user_with_roles(ADMIN_ROLE)):
34+
response = client.post(
35+
f"/assets/permissions",
36+
json={
37+
"asset_identifier": identifier,
38+
"user": ALICE._subject_identifier,
39+
"permission_type": None,
40+
},
41+
headers={"Authorization": "Fake token"}
42+
)
43+
assert response.status_code == HTTPStatus.OK, response.json()
44+
45+
with DbSession() as session:
46+
permission = session.get(Permission, {"user_identifier": ALICE._subject_identifier, "aiod_entry_identifier": 1})
47+
assert permission is None
48+
49+
50+
def test_admin_can_change_permission(client, publication):
51+
identifier = register_asset(publication, owner=ALICE, status=EntryStatus.PUBLISHED)
52+
_register_user_in_db(BOB)
53+
54+
with logged_in_user(kc_user_with_roles(ADMIN_ROLE)):
55+
response = client.post(
56+
f"/assets/permissions",
57+
json={
58+
"asset_identifier": identifier,
59+
"user": BOB._subject_identifier,
60+
"permission_type": PermissionType.ADMIN.value,
61+
},
62+
headers={"Authorization": "Fake token"}
63+
)
64+
assert response.status_code == HTTPStatus.OK, response.json()
65+
66+
with DbSession() as session:
67+
permission = session.get(Permission, {"user_identifier": BOB._subject_identifier, "aiod_entry_identifier": 1})
68+
assert permission is not None
69+
assert permission.type_ == PermissionType.ADMIN
70+
71+
2372
def test_user_must_be_logged_in_to_publish(client, publication):
2473
response = client.post("/publications", content=publication.json(), headers=None)
2574
assert response.status_code == HTTPStatus.UNAUTHORIZED

0 commit comments

Comments
 (0)