Skip to content

Commit 845b3e1

Browse files
feat(oauth2_provider): check for OAuth2ScopePermission on all APIViews (ansible#636)
Add a check for OAuth2ScopePermission on all active views in an application. - ANSIBLE_BASE_OAUTH2_PROVIDER_PERMISSIONS_CHECK_IGNORED_VIEWS django setting for setting ignores by class path. - Uses django's check framework, should be compatible with the related functionality. - Set as a deployment check, so it will not block app startup. AAP-26507
1 parent dc5ff4c commit 845b3e1

File tree

17 files changed

+429
-17
lines changed

17 files changed

+429
-17
lines changed

ansible_base/authentication/views/authenticator_users.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from django.http import Http404
88

99
from ansible_base.authentication.models import Authenticator
10-
from ansible_base.lib.utils.views.permissions import IsSuperuserOrAuditor
10+
from ansible_base.lib.utils.views.permissions import IsSuperuserOrAuditor, try_add_oauth2_scope_permission
1111

1212
logger = logging.getLogger('ansible_base.authentication.views.authenticator_users')
1313

@@ -22,7 +22,7 @@ def get_authenticator_user_view():
2222
raise ModuleNotFoundError()
2323

2424
class AuthenticatorPluginRelatedUsersView(user_viewset_view):
25-
permission_classes = [IsSuperuserOrAuditor]
25+
permission_classes = try_add_oauth2_scope_permission([IsSuperuserOrAuditor])
2626

2727
def get_queryset(self, **kwargs):
2828
# during unit testing we get the pk from kwargs

ansible_base/lib/dynamic_config/dynamic_urls.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
for url_type in url_types:
1111
globals()[url_type] = []
1212

13-
for app in getattr(settings, 'INSTALLED_APPS', []):
13+
installed_apps = getattr(settings, 'INSTALLED_APPS', [])
14+
for app in installed_apps:
1415
if app.startswith('ansible_base.'):
1516
if not importlib.util.find_spec(f'{app}.urls'):
1617
logger.debug(f'Module {app} does not specify urls.py')
1718
continue
1819
url_module = __import__(f'{app}.urls', fromlist=url_types)
1920
logger.debug(f'Including URLS from {app}.urls')
2021
for url_type in ['api_version_urls', 'root_urls', 'api_urls']:
21-
globals()[url_type].extend(getattr(url_module, url_type, []))
22+
urls = getattr(url_module, url_type, [])
23+
globals()[url_type].extend(urls)

ansible_base/lib/dynamic_config/settings_logic.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,8 @@ def get_dab_settings(
283283

284284
dab_data['ALLOW_OAUTH2_FOR_EXTERNAL_USERS'] = False
285285

286+
dab_data['ANSIBLE_BASE_OAUTH2_PROVIDER_PERMISSIONS_CHECK_DEFAULT_IGNORED_VIEWS'] = []
287+
286288
if caches is not None:
287289
dab_data['CACHES'] = copy(caches)
288290
# Ensure proper configuration for fallback cache

ansible_base/lib/utils/views/permissions.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
from rest_framework.permissions import SAFE_METHODS, BasePermission
22

3+
from ansible_base.lib.utils.settings import get_setting
4+
5+
oauth2_provider_installed = "ansible_base.oauth2_provider" in get_setting("INSTALLED_APPS", [])
6+
7+
8+
def try_add_oauth2_scope_permission(permission_classes: list):
9+
"""
10+
Attach OAuth2ScopePermission to the provided permission_classes list
11+
12+
:param permission_classes: list of rest_framework permissions
13+
:return: A list of permission_classes, including OAuth2ScopePermission
14+
if ansible_base.oauth2_provider is installed; otherwise the same
15+
permission_classes list supplied to the function
16+
"""
17+
if oauth2_provider_installed:
18+
from ansible_base.oauth2_provider.permissions import OAuth2ScopePermission
19+
20+
return [OAuth2ScopePermission] + permission_classes
21+
return permission_classes
22+
323

424
class IsSuperuser(BasePermission):
525
"""
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from typing import Type
2+
3+
from rest_framework.schemas.generators import EndpointEnumerator
4+
from rest_framework.views import APIView
5+
6+
7+
def get_api_view_functions(urlpatterns=None) -> set[Type[APIView]]:
8+
"""
9+
Extract view classes from a urlpatterns list using the show_urls helper functions
10+
11+
:param urlpatterns: django urlpatterns list
12+
:return: set of all view classes used by the urlpatterns list
13+
"""
14+
views = set()
15+
16+
enumerator = EndpointEnumerator()
17+
# Get all active APIViews from urlconf
18+
endpoints = enumerator.get_api_endpoints(patterns=urlpatterns)
19+
for _, _, func in endpoints:
20+
# ApiView.as_view() breadcrumb
21+
if hasattr(func, 'cls'):
22+
views.add(func.cls)
23+
24+
return views
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
from django.apps import AppConfig
2+
from django.core.checks import register
23

34

45
class Oauth2ProviderConfig(AppConfig):
56
default_auto_field = 'django.db.models.BigAutoField'
67
name = 'ansible_base.oauth2_provider'
78
label = 'dab_oauth2_provider'
9+
10+
def ready(self):
11+
# Load checks
12+
from ansible_base.oauth2_provider.checks.permisssions_check import oauth2_permission_scope_check
13+
14+
register(oauth2_permission_scope_check, "oauth2_permissions", deploy=True)

ansible_base/oauth2_provider/checks/__init__.py

Whitespace-only changes.
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
from typing import Iterable, Optional, Type, Union
2+
3+
from django.apps import AppConfig
4+
from django.conf import settings
5+
from django.core.checks import CheckMessage, Debug, Error, Warning
6+
from rest_framework.permissions import AllowAny, OperandHolder, OperationHolderMixin, SingleOperandHolder
7+
from rest_framework.views import APIView
8+
9+
from ansible_base.lib.utils.views.urls import get_api_view_functions
10+
from ansible_base.oauth2_provider.permissions import OAuth2ScopePermission
11+
12+
13+
class OAuth2ScopePermissionCheck:
14+
"""
15+
Class containing logic for checking view classes for the
16+
OAuth2ScopePermission permission_class, and aggregating
17+
CheckMessage's for django system checks.
18+
19+
:param ignore_list: List of python import path strings for view classes exempt from the check logic.
20+
:type ignore_list: list
21+
"""
22+
23+
def __init__(self, ignore_list: Iterable[str], generate_check_messages=True):
24+
self.messages: list[CheckMessage] = []
25+
self.current_view: Optional[Type[APIView]] = None
26+
self.ignore_list = ignore_list
27+
self.generate_check_messages = generate_check_messages
28+
29+
def check_message(self, message: CheckMessage):
30+
if self.generate_check_messages:
31+
self.messages.append(message)
32+
33+
# These are all warning or error conditions, this function is mostly saying to not invert OAuth2ScopePermissions
34+
# Returns False.
35+
def process_single_operand_holder(self, operand_holder: SingleOperandHolder) -> bool:
36+
# The only unary operand for permission classes provided by rest_framework is ~ (not)
37+
if self.parse_permission_class(operand_holder.op1_class):
38+
self.check_message(
39+
Warning(
40+
"~ (not) operand used on OAuth2ScopePermission, probably a bad idea.",
41+
id="ansible_base.oauth2_provider.W001",
42+
obj=self.current_view,
43+
)
44+
)
45+
46+
return False
47+
48+
def process_operand_holder(self, operand_holder: OperandHolder) -> bool:
49+
return self.parse_permission_class(operand_holder.op1_class) or self.parse_permission_class(operand_holder.op2_class)
50+
51+
# Check if permission class is present in nested operands
52+
# Sort of recursive? Reasonably this should not be an issue, so long as we don't recurse on an unknown OperationHolderMixin type
53+
def parse_permission_class(self, cls: Union[Type[OperationHolderMixin], OperationHolderMixin]) -> bool:
54+
# First, most likely case, we're dealing with a BasePermission subclass.
55+
if cls is OAuth2ScopePermission:
56+
return True
57+
elif isinstance(cls, SingleOperandHolder):
58+
# Warning or Error case: Will not accept OAuth2 permission nested in NOT
59+
return self.process_single_operand_holder(cls)
60+
elif isinstance(cls, OperandHolder):
61+
return self.process_operand_holder(cls)
62+
return False
63+
64+
def check_view(self, view_class: Type[APIView]) -> bool:
65+
"""
66+
Primary function of the OAuth2ScopePermissionCheck.
67+
68+
Checks if OAuth2ScopePermission is present on the supplied view's
69+
permission_classes; ignores classes that are not APIViews, or that are
70+
in the ignore_list.
71+
72+
Appends CheckMessages to self.messages as a side effect.
73+
74+
:param view_class: django View class or rest_framework ApiView class
75+
76+
:return: True if view_class uses the OAuth2ScopePermission permission
77+
class, or has some mitigating circumstance that prohibits it, such as
78+
view_class not using permission classes, or its import path being in
79+
self.ignore_list; returns False otherwise.
80+
"""
81+
if f"{view_class.__module__}.{view_class.__name__}" in self.ignore_list:
82+
self.check_message(
83+
Debug(
84+
"View class in the ignore list. Ignoring.",
85+
obj=view_class,
86+
id="ansible_base.oauth2_provider.D03",
87+
)
88+
)
89+
return True
90+
91+
self.current_view = view_class
92+
93+
for permission_class in getattr(self.current_view, "permission_classes", []):
94+
if self.parse_permission_class(permission_class):
95+
self.check_message(
96+
Debug(
97+
"Found OAuth2ScopePermission permission_class",
98+
obj=self.current_view,
99+
id="ansible_base.oauth2_provider.D02",
100+
)
101+
)
102+
return True
103+
104+
if not self.current_view.permission_classes or AllowAny in self.current_view.permission_classes:
105+
self.check_message(
106+
Debug(
107+
"View object is fully permissive, OAuth2ScopePermission is not required",
108+
obj=self.current_view,
109+
id="ansible_base.oauth2_provider.D04",
110+
)
111+
)
112+
return True
113+
114+
# if we went though the whole loop without finding a valid permission_class, raise an error
115+
self.check_message(
116+
Error(
117+
"View class has no valid usage of OAuth2ScopePermission",
118+
obj=self.current_view,
119+
id="ansible_base.oauth2_provider.E002",
120+
)
121+
)
122+
return False
123+
124+
125+
def view_in_app_configs(view_class: type, app_configs: Optional[list[AppConfig]]) -> bool:
126+
if app_configs:
127+
for app_config in app_configs:
128+
if view_class.__module__.startswith(app_config.name):
129+
return True
130+
return False
131+
return True
132+
133+
134+
def oauth2_permission_scope_check(app_configs: Optional[list[AppConfig]], **kwargs) -> list[CheckMessage]:
135+
"""
136+
Check for OAuth2ScopePermission permission class on all enabled views.
137+
138+
Ignore views in the ANSIBLE_BASE_OAUTH2_PROVIDER_PERMISSIONS_CHECK_IGNORED_VIEWS setting
139+
"""
140+
ignore_list = set(
141+
getattr(settings, "ANSIBLE_BASE_OAUTH2_PROVIDER_PERMISSIONS_CHECK_DEFAULT_IGNORED_VIEWS", [])
142+
+ getattr(settings, "ANSIBLE_BASE_OAUTH2_PROVIDER_PERMISSIONS_CHECK_IGNORED_VIEWS", [])
143+
)
144+
145+
check = OAuth2ScopePermissionCheck(ignore_list)
146+
147+
view_functions = get_api_view_functions()
148+
for view in view_functions:
149+
# Only run checks on included apps (or all if app_configs is None)
150+
if view_in_app_configs(view, app_configs):
151+
check.check_view(view)
152+
153+
return check.messages

ansible_base/oauth2_provider/views/application.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
from ansible_base.lib.utils.views.django_app_api import AnsibleBaseDjangoAppApiView
44
from ansible_base.lib.utils.views.permissions import IsSuperuserOrAuditor
55
from ansible_base.oauth2_provider.models import OAuth2Application
6+
from ansible_base.oauth2_provider.permissions import OAuth2ScopePermission
67
from ansible_base.oauth2_provider.serializers import OAuth2ApplicationSerializer
78

89

910
class OAuth2ApplicationViewSet(AnsibleBaseDjangoAppApiView, ModelViewSet):
1011
queryset = OAuth2Application.objects.all()
1112
serializer_class = OAuth2ApplicationSerializer
12-
permission_classes = [IsSuperuserOrAuditor]
13+
permission_classes = [OAuth2ScopePermission, IsSuperuserOrAuditor]

ansible_base/oauth2_provider/views/token.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from ansible_base.lib.utils.settings import get_setting
1111
from ansible_base.lib.utils.views.django_app_api import AnsibleBaseDjangoAppApiView
1212
from ansible_base.oauth2_provider.models import OAuth2AccessToken, OAuth2RefreshToken
13+
from ansible_base.oauth2_provider.permissions import OAuth2ScopePermission
1314
from ansible_base.oauth2_provider.serializers import OAuth2TokenSerializer
1415
from ansible_base.oauth2_provider.views.permissions import OAuth2TokenPermission
1516

@@ -73,4 +74,4 @@ def create_token_response(self, request):
7374
class OAuth2TokenViewSet(ModelViewSet, AnsibleBaseDjangoAppApiView):
7475
queryset = OAuth2AccessToken.objects.all()
7576
serializer_class = OAuth2TokenSerializer
76-
permission_classes = [OAuth2TokenPermission]
77+
permission_classes = [OAuth2ScopePermission, OAuth2TokenPermission]

0 commit comments

Comments
 (0)