Skip to content

Commit 05f68ba

Browse files
committed
Create async auth rules for user privileges
SDESK-7460
1 parent 3838d44 commit 05f68ba

10 files changed

+290
-11
lines changed

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,5 @@ exclude = '''
2020
[tool.pytest.ini_options]
2121
testpaths = ["tests", "superdesk", "apps", "content_api"]
2222
python_files = "*_test.py *_tests.py test_*.py tests_*.py tests.py test.py"
23-
asyncio_mode = "strict"
23+
asyncio_mode = "auto"
2424
asyncio_default_fixture_loop_scope = "function"
+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from typing import Any, cast
2+
3+
from quart_babel import gettext
4+
from superdesk.errors import SuperdeskApiError
5+
from superdesk.users.async_service import get_privileges, is_admin
6+
from superdesk.core.types import HTTP_METHOD, AuthRule, Request
7+
8+
9+
def required_privilege_rule(privilege: str) -> AuthRule:
10+
"""
11+
Creates an authentication rule that restricts access based on a specific privilege.
12+
13+
Args:
14+
privilege (str): The privilege required to access the resource.
15+
16+
Returns:
17+
AuthRule: An asynchronous rule function that checks if the user has the required privilege.
18+
19+
The rule verifies:
20+
- If the user is an admin, access is granted without further checks.
21+
- Otherwise, the user's privileges are retrieved and checked against the required privilege.
22+
23+
Raises:
24+
SuperdeskApiError: If the user does not have the required privilege.
25+
"""
26+
27+
async def internal_rule(request: Request):
28+
current_user = request.user
29+
30+
# TODO-ASYNC: Check if for admin we need to pull all privileges instead
31+
if is_admin(current_user):
32+
return None
33+
34+
user_role = request.storage.request.get("role")
35+
user_privileges = get_privileges(request.user, user_role)
36+
37+
if user_privileges.get(privilege, False):
38+
return None
39+
40+
raise SuperdeskApiError.forbiddenError(message=gettext("Insufficient privileges for the requested operation."))
41+
42+
return internal_rule
43+
44+
45+
def privilege_based_rules(rules: dict[HTTP_METHOD, str]) -> dict[str, AuthRule]:
46+
"""
47+
Generates a dictionary of authentication rules for HTTP methods based on privileges.
48+
49+
Args:
50+
rules (dict[HTTP_METHOD, str]): A mapping of HTTP methods (e.g., GET, POST) to the required privileges
51+
for those methods.
52+
53+
Returns:
54+
dict[str, list[AuthRule]]: A dictionary where each HTTP method is mapped to a list of authentication
55+
rules that restrict access based on the specified privilege.
56+
"""
57+
58+
auth_rules: dict[str, AuthRule] = {}
59+
for method, privilege_name in rules.items():
60+
auth_rules[method] = required_privilege_rule(privilege_name)
61+
62+
return auth_rules

superdesk/core/auth/token_auth.py

+28-4
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,35 @@
1+
import base64
2+
import arrow
3+
14
from typing import Any, cast
25
from datetime import timedelta
36

4-
from superdesk.core import get_app_config
7+
from superdesk.utc import utcnow
8+
from superdesk.core import json, get_app_config
59
from superdesk.core.types import Request
610
from superdesk import get_resource_service
711
from superdesk.errors import SuperdeskApiError
812
from superdesk.resource_fields import LAST_UPDATED, ID_FIELD
9-
from superdesk.utc import utcnow
1013

1114
from .user_auth import UserAuthProtocol
1215

1316

1417
class TokenAuthorization(UserAuthProtocol):
18+
@staticmethod
19+
def _decode_token(token: str) -> str:
20+
"""
21+
Tries to decode the auth header token. Decode logic based on internal logic from
22+
`werkzeug.datastructures.Authorization`
23+
"""
24+
try:
25+
return base64.b64decode(token).decode().partition(":")[0]
26+
except Exception:
27+
return token
28+
1529
async def authenticate(self, request: Request):
1630
token = request.get_header("Authorization")
1731
new_session = True
32+
1833
if token:
1934
token = token.strip()
2035
if token.lower().startswith(("token", "bearer")):
@@ -29,6 +44,10 @@ async def authenticate(self, request: Request):
2944

3045
# Check provided token is valid
3146
auth_service = get_resource_service("auth")
47+
48+
# tokens are no longer decoded internally by flask/quart
49+
# so we need to do it ourselves
50+
token = self._decode_token(token)
3251
auth_token = auth_service.find_one(token=token, req=None)
3352

3453
if not auth_token:
@@ -54,12 +73,15 @@ async def start_session(self, request: Request, user: dict[str, Any], **kwargs)
5473
await self.stop_session(request)
5574
raise SuperdeskApiError.unauthorizedError()
5675

57-
request.storage.session.set("session_token", auth_token)
76+
request.storage.session.set("session_token", json.dumps(auth_token))
5877
await super().start_session(request, user, **kwargs)
5978

6079
async def continue_session(self, request: Request, user: dict[str, Any], **kwargs) -> None:
6180
auth_token = request.storage.session.get("session_token")
6281

82+
if isinstance(auth_token, str):
83+
auth_token = json.loads(auth_token)
84+
6385
if not auth_token:
6486
await self.stop_session(request)
6587
raise SuperdeskApiError.unauthorizedError()
@@ -74,7 +96,9 @@ async def continue_session(self, request: Request, user: dict[str, Any], **kwarg
7496
now = utcnow()
7597
auth_updated = False
7698
session_update_seconds = cast(int, get_app_config("SESSION_UPDATE_SECONDS", 30))
77-
if auth_token[LAST_UPDATED] + timedelta(seconds=session_update_seconds) < now:
99+
auth_last_updated = arrow.get(auth_token[LAST_UPDATED])
100+
101+
if auth_last_updated + timedelta(seconds=session_update_seconds) < now:
78102
auth_service = get_resource_service("auth")
79103
auth_service.update_session({LAST_UPDATED: now})
80104
auth_updated = True

superdesk/core/auth/user_auth.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from typing import Any, cast
22

3+
from superdesk.utils import flatten
34
from superdesk.errors import SuperdeskApiError
45
from superdesk.core.types import Request, AuthRule
56

@@ -27,7 +28,10 @@ async def authorize(self, request: Request) -> Any | None:
2728
elif isinstance(endpoint_rules, dict):
2829
endpoint_rules = cast(list[AuthRule], endpoint_rules.get(request.method) or [])
2930

30-
for rule in self.get_default_auth_rules() + (endpoint_rules or []):
31+
auth_rules = self.get_default_auth_rules()
32+
auth_rules.append(endpoint_rules or [])
33+
34+
for rule in flatten(auth_rules):
3135
response = await rule(request)
3236
if response is not None:
3337
return response

superdesk/errors.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ def to_dict(self):
130130
"""Create dict for json response."""
131131
rv = {}
132132
rv[STATUS] = STATUS_ERR
133-
rv["_message"] = self.message or ""
133+
rv["_message"] = str(self.message or "")
134134
if hasattr(self, "payload"):
135135
rv[ISSUES] = self.payload
136136
return rv

superdesk/tests/__init__.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -479,8 +479,12 @@ async def setup_db_user(context, user):
479479

480480
user["password"] = original_password
481481
auth_data = json.dumps({"username": user["username"], "password": user["password"]})
482+
483+
context.headers.append(["Content-Type", "application/json"])
482484
auth_response = await context.client.post(
483-
get_prefixed_url(context.app, "/auth_db"), data=auth_data, headers=context.headers
485+
get_prefixed_url(context.app, "/auth_db"),
486+
data=auth_data,
487+
headers=context.headers,
484488
)
485489

486490
auth_data = json.loads(await auth_response.get_data())

superdesk/utils.py

+16
Original file line numberDiff line numberDiff line change
@@ -388,3 +388,19 @@ def format_content_type_name(item: Dict[str, str], default_name: str) -> str:
388388
"""
389389
name = item.get("output_name") or item.get("label", default_name)
390390
return re.compile("[^0-9a-zA-Z_]").sub("", name)
391+
392+
393+
def flatten(nested_list) -> list:
394+
"""
395+
Recursively flattens an arbitrarily nested list into a single flat list.
396+
397+
Returns:
398+
list: A single flat list containing all elements from the nested structure.
399+
"""
400+
result = []
401+
for i in nested_list:
402+
if isinstance(i, list):
403+
result.extend(flatten(i))
404+
else:
405+
result.append(i)
406+
return result
+17-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,27 @@
11
from quart_babel import lazy_gettext
22

3+
from superdesk.core.auth.privilege_rules import privilege_based_rules
34
from superdesk.core.module import Module
45
from superdesk.core.privileges import Privilege
6+
from superdesk.core.resources import ResourceConfig, ResourceModel, RestEndpointConfig
7+
8+
test_privileges = ["can_read", "can_create"]
9+
10+
resource_config = ResourceConfig(
11+
name="privileged_resource",
12+
data_class=ResourceModel,
13+
rest_endpoints=RestEndpointConfig(
14+
resource_methods=["GET", "POST"],
15+
auth=privilege_based_rules({"GET": test_privileges[0], "POST": test_privileges[1]}),
16+
),
17+
)
18+
519

620
module = Module(
721
name="tests.module_with_privileges",
22+
resources=[resource_config],
823
privileges=[
9-
Privilege(name="can_test", description=lazy_gettext("Test privilege can test")),
10-
Privilege(name="can_play", description=lazy_gettext("Test privilege can play")),
24+
Privilege(name="can_read", description=lazy_gettext("Test privilege can read")),
25+
Privilege(name="can_create", description=lazy_gettext("Test privilege can create")),
1126
],
1227
)

tests/core/privilege_rules_test.py

+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import pytest
2+
from unittest.mock import patch, MagicMock
3+
from superdesk.errors import SuperdeskApiError
4+
from superdesk.core.auth.privilege_rules import privilege_based_rules, required_privilege_rule
5+
6+
7+
@patch("superdesk.core.auth.privilege_rules.is_admin")
8+
@patch("superdesk.core.auth.privilege_rules.get_privileges")
9+
async def test_required_privilege_rule_admin(mock_get_privileges, mock_is_admin):
10+
mock_is_admin.return_value = True
11+
mock_request = MagicMock()
12+
13+
rule = required_privilege_rule("test_privilege")
14+
result = await rule(mock_request)
15+
16+
# admin user should pass without checking privileges
17+
assert result is None
18+
mock_get_privileges.assert_not_called()
19+
20+
21+
@patch("superdesk.core.auth.privilege_rules.is_admin")
22+
@patch("superdesk.core.auth.privilege_rules.get_privileges")
23+
async def test_required_privilege_rule_insufficient_privileges(mock_get_privileges, mock_is_admin):
24+
mock_is_admin.return_value = False
25+
mock_get_privileges.return_value = {"test_privilege": False}
26+
27+
mock_request = MagicMock()
28+
mock_request.user = "test_user"
29+
mock_request.storage.request.get.return_value = "user_role"
30+
31+
rule = required_privilege_rule("test_privilege")
32+
33+
with pytest.raises(SuperdeskApiError) as error:
34+
await rule(mock_request)
35+
36+
assert error.value.status_code == 403
37+
assert "Insufficient privileges" in str(error.value)
38+
mock_get_privileges.assert_called_once_with("test_user", "user_role")
39+
40+
41+
@patch("superdesk.core.auth.privilege_rules.is_admin")
42+
@patch("superdesk.core.auth.privilege_rules.get_privileges")
43+
async def test_required_privilege_rule_sufficient_privileges(mock_get_privileges, mock_is_admin):
44+
mock_is_admin.return_value = False
45+
mock_get_privileges.return_value = {"test_privilege": True}
46+
47+
mock_request = MagicMock()
48+
mock_request.user = "test_user"
49+
mock_request.storage.request.get.return_value = "user_role"
50+
51+
rule = required_privilege_rule("test_privilege")
52+
53+
result = await rule(mock_request)
54+
assert result is None
55+
mock_get_privileges.assert_called_once_with("test_user", "user_role")
56+
57+
58+
def test_privilege_based_rules():
59+
rules = {
60+
"GET": "read_articles",
61+
"POST": "create_articles",
62+
"DELETE": "delete_articles",
63+
}
64+
65+
auth_rules = privilege_based_rules(rules)
66+
67+
assert "GET" in auth_rules
68+
assert "POST" in auth_rules
69+
assert "DELETE" in auth_rules
70+
71+
assert len(auth_rules["GET"]) == 1
72+
assert len(auth_rules["POST"]) == 1
73+
assert len(auth_rules["DELETE"]) == 1

0 commit comments

Comments
 (0)