Skip to content

Commit 85bf949

Browse files
committed
Add full api restriction setting (#15656)
Signed-off-by: Yuriy Savinkin <[email protected]>
1 parent 14808cb commit 85bf949

File tree

5 files changed

+181
-0
lines changed

5 files changed

+181
-0
lines changed

awx/api/conf.py

+55
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1+
# Python
2+
from functools import lru_cache
3+
14
# Django
5+
from django.urls import resolve, URLResolver, URLPattern
6+
from django.urls.exceptions import Resolver404
27
from django.utils.translation import gettext_lazy as _
38

49
# Django REST Framework
510
from rest_framework import serializers
611

712
# AWX
813
from awx.conf import fields, register, register_validate
14+
from awx.settings.defaults import ANONYMOUS_ACCESS_API_ALLOWED_PATHS as DEFAULT_ALLOWED_PATHS
915

1016

1117
register(
@@ -66,11 +72,60 @@
6672
category_slug='authentication',
6773
)
6874

75+
register(
76+
'RESTRICT_API_ANONYMOUS_ACCESS',
77+
field_class=fields.BooleanField,
78+
default=False,
79+
label=_('Restrict Anonymous API Access'),
80+
help_text=_('If true, all API endpoints except those specified in "Allowed URLs for Anonymous Access" will require authentication.'),
81+
category=_('Authentication'),
82+
category_slug='authentication',
83+
)
84+
85+
register(
86+
'ANONYMOUS_ACCESS_API_ALLOWED_PATHS',
87+
field_class=fields.StringListField,
88+
default=DEFAULT_ALLOWED_PATHS,
89+
label=_('Allowed URLs for Anonymous Access'),
90+
help_text=_('A list of API endpoints that can be accessed without authentication, even when "Restrict Anonymous API Access" is enabled.'),
91+
category=_('Authentication'),
92+
category_slug='authentication',
93+
)
94+
6995

7096
def authentication_validate(serializer, attrs):
7197
if attrs.get('DISABLE_LOCAL_AUTH', False):
7298
raise serializers.ValidationError(_("There are no remote authentication systems configured."))
7399
return attrs
74100

75101

102+
@lru_cache(maxsize=128)
103+
def validate_url_path(path):
104+
"""Validate and cache the result for a given URL path."""
105+
try:
106+
resolve(path)
107+
except Resolver404:
108+
return False
109+
return True
110+
111+
112+
def allowed_urls_validate(serializer, attrs):
113+
'''
114+
Validation for allowed URLs in ANONYMOUS_ACCESS_API_ALLOWED_PATHS
115+
This ensures that administrators provide resolvable URLs
116+
and include the required default URLs for core functionality.
117+
'''
118+
paths = attrs.get('ANONYMOUS_ACCESS_API_ALLOWED_PATHS', [])
119+
invalid_paths = [path for path in paths if not validate_url_path(path)]
120+
121+
if invalid_paths:
122+
raise serializers.ValidationError(_(f"Invalid paths: {', '.join(invalid_paths)}"))
123+
124+
missing_paths = [path for path in DEFAULT_ALLOWED_PATHS if path not in paths]
125+
if missing_paths:
126+
attrs['ANONYMOUS_ACCESS_API_ALLOWED_PATHS'] = missing_paths + paths
127+
return attrs
128+
129+
76130
register_validate('authentication', authentication_validate)
131+
register_validate('authentication', allowed_urls_validate)
+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import pytest
2+
from rest_framework import serializers
3+
4+
from awx.api.conf import allowed_urls_validate
5+
from awx.settings.defaults import ANONYMOUS_ACCESS_API_ALLOWED_PATHS as DEFAULT_ALLOWED_PATHS
6+
7+
8+
class TestAllowedUrlsValidator:
9+
10+
def test_ok_validator(self):
11+
attrs = {'ANONYMOUS_ACCESS_API_ALLOWED_PATHS': DEFAULT_ALLOWED_PATHS}
12+
allowed_urls_validate(None, attrs)
13+
14+
def test_all_validator(self):
15+
attrs = {'ANONYMOUS_ACCESS_API_ALLOWED_PATHS': []}
16+
allowed_urls_validate(None, attrs)
17+
assert attrs.get('ANONYMOUS_ACCESS_API_ALLOWED_PATHS') == DEFAULT_ALLOWED_PATHS
18+
19+
def test_wrong_path_validator(self):
20+
attrs = {'ANONYMOUS_ACCESS_API_ALLOWED_PATHS': DEFAULT_ALLOWED_PATHS + ['not_a_path']}
21+
22+
with pytest.raises(serializers.ValidationError):
23+
allowed_urls_validate(None, attrs)

awx/main/middleware.py

+29
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@
1212
from django.contrib.auth import logout
1313
from django.db.migrations.recorder import MigrationRecorder
1414
from django.db import connection
15+
from django.http import JsonResponse
1516
from django.shortcuts import redirect
1617
from django.utils.deprecation import MiddlewareMixin
18+
from django.utils.translation import gettext_lazy as _
1719
from django.urls import reverse, resolve
20+
from rest_framework import status
1821

1922
from awx.main import migrations
2023
from awx.main.utils.profiling import AWXProfiler
@@ -223,3 +226,29 @@ def process_request(self, request):
223226
request.urlconf = self._url_optional(prefix)
224227
else:
225228
request.urlconf = 'awx.urls'
229+
230+
231+
class AnonymousAccessRestrictionMiddleware(MiddlewareMixin):
232+
"""
233+
Restrict anonymous access to API endpoints if RESTRICT_API_ANONYMOUS_ACCESS = True,
234+
except for those listed in the ANONYMOUS_ACCESS_API_ALLOWED_PATHS.
235+
If access is restricted, unauthenticated users will receive a 401 Unauthorized response.
236+
"""
237+
238+
@functools.lru_cache
239+
def get_allowed_paths(self):
240+
return set(settings.ANONYMOUS_ACCESS_API_ALLOWED_PATHS)
241+
242+
def process_request(self, request):
243+
if not request.path.startswith('/api'):
244+
return
245+
246+
if settings.RESTRICT_API_ANONYMOUS_ACCESS and request.path not in self.get_allowed_paths():
247+
if not request.user.is_authenticated:
248+
msg = _('Anonymous API access restricted.') + _(' To establish a login session, visit') + ' /api/login/.'
249+
return JsonResponse(
250+
{
251+
'detail': msg,
252+
},
253+
status=status.HTTP_401_UNAUTHORIZED,
254+
)
+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import pytest
2+
from unittest.mock import patch
3+
4+
from django.contrib.auth.models import AnonymousUser
5+
from django.http import JsonResponse, HttpResponse
6+
from rest_framework.test import APIRequestFactory
7+
8+
from awx.main.middleware import AnonymousAccessRestrictionMiddleware
9+
10+
11+
@pytest.fixture
12+
def access_restriction_middleware():
13+
return AnonymousAccessRestrictionMiddleware(lambda request: HttpResponse())
14+
15+
16+
@pytest.fixture
17+
def mock_user(is_authenticated):
18+
return type("User", (), {"is_authenticated": is_authenticated})()
19+
20+
21+
class TestAnonymousAccessRestrictionMiddleware:
22+
@pytest.mark.parametrize(
23+
"is_authenticated,expected_response",
24+
[
25+
(False, JsonResponse), # Anonymous user, restricted path
26+
(True, None), # Authenticated user, not restricted
27+
],
28+
)
29+
@patch("django.conf.settings.RESTRICT_API_ANONYMOUS_ACCESS", True)
30+
@patch("django.conf.settings.ANONYMOUS_ACCESS_API_ALLOWED_PATHS", ["/api/public"])
31+
def test_restricted_access_to_authenticated_only_path(self, access_restriction_middleware, mock_user, is_authenticated, expected_response):
32+
request = APIRequestFactory().get("/api/secure-data")
33+
request.user = mock_user
34+
response = access_restriction_middleware.process_request(request)
35+
36+
if expected_response:
37+
assert isinstance(response, expected_response)
38+
assert response.status_code == 401
39+
else:
40+
assert response is None
41+
42+
@patch("django.conf.settings.RESTRICT_API_ANONYMOUS_ACCESS", True)
43+
@patch("django.conf.settings.ANONYMOUS_ACCESS_API_ALLOWED_PATHS", ["/api/public"])
44+
def test_allowed_path_for_anonymous_user(self, access_restriction_middleware):
45+
"""Test that anonymous users can access paths in the allowed list."""
46+
request = APIRequestFactory().get("/api/public")
47+
request.user = AnonymousUser()
48+
49+
response = access_restriction_middleware.process_request(request)
50+
assert response is None
51+
52+
@patch("django.conf.settings.RESTRICT_API_ANONYMOUS_ACCESS", False)
53+
def test_anonymous_access_when_restriction_disabled(self, access_restriction_middleware):
54+
"""Test that anonymous access is allowed when the restriction is disabled."""
55+
request = APIRequestFactory().get("/api/secure-data")
56+
request.user = AnonymousUser() # Anonymous user
57+
58+
response = access_restriction_middleware.process_request(request)
59+
assert response is None
60+
61+
def test_non_api_path_is_skipped(self, access_restriction_middleware):
62+
"""Test that non-API paths are skipped by the middleware."""
63+
request = APIRequestFactory().get("/")
64+
request.user = AnonymousUser()
65+
66+
response = access_restriction_middleware.process_request(request)
67+
assert response is None

awx/settings/defaults.py

+7
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,7 @@
893893
'crum.CurrentRequestUserMiddleware',
894894
'awx.main.middleware.URLModificationMiddleware',
895895
'awx.main.middleware.SessionTimeoutMiddleware',
896+
'awx.main.middleware.AnonymousAccessRestrictionMiddleware',
896897
]
897898

898899
# Secret header value to exchange for websockets responsible for distributing websocket messages.
@@ -1057,3 +1058,9 @@
10571058

10581059
# feature flags
10591060
FLAGS = {}
1061+
1062+
# Restrict access to all endpoints with exception for ANONYMOUS_ACCESS_API_ALLOWED_PATHS
1063+
RESTRICT_API_ANONYMOUS_ACCESS = False
1064+
1065+
# These are the essential endpoints required for authentication and token management for RESTRICT_API_ANONYMOUS_ACCESS.
1066+
ANONYMOUS_ACCESS_API_ALLOWED_PATHS = ['/api/login/']

0 commit comments

Comments
 (0)