|
| 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 |
0 commit comments