This commit is contained in:
David Kennedy 2025-02-10 19:23:50 -05:00
parent 303b74c458
commit d84aa421d9
No known key found for this signature in database
GPG key ID: 6528A5386E66B96B
8 changed files with 121 additions and 2 deletions

View file

@ -5,12 +5,14 @@ import logging
from django.conf import settings from django.conf import settings
from django.contrib.auth import logout as auth_logout from django.contrib.auth import logout as auth_logout
from django.contrib.auth import authenticate, login from django.contrib.auth import authenticate, login
from login_required import login_not_required
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import redirect from django.shortcuts import redirect
from urllib.parse import parse_qs, urlencode from urllib.parse import parse_qs, urlencode
from djangooidc.oidc import Client from djangooidc.oidc import Client
from djangooidc import exceptions as o_e from djangooidc import exceptions as o_e
from registrar.decorators import grant_access
from registrar.models import User from registrar.models import User
from registrar.views.utility.error_views import custom_500_error_view, custom_401_error_view from registrar.views.utility.error_views import custom_500_error_view, custom_401_error_view

View file

@ -200,6 +200,8 @@ MIDDLEWARE = [
"waffle.middleware.WaffleMiddleware", "waffle.middleware.WaffleMiddleware",
"registrar.registrar_middleware.CheckUserProfileMiddleware", "registrar.registrar_middleware.CheckUserProfileMiddleware",
"registrar.registrar_middleware.CheckPortfolioMiddleware", "registrar.registrar_middleware.CheckPortfolioMiddleware",
# Restrict access using Opt-Out approach
"registrar.registrar_middleware.RestrictAccessMiddleware",
] ]
# application object used by Django's built-in servers (e.g. `runserver`) # application object used by Django's built-in servers (e.g. `runserver`)

View file

@ -0,0 +1,72 @@
from functools import wraps
from django.http import JsonResponse
from django.core.exceptions import ObjectDoesNotExist
from registrar.models.domain import Domain
from registrar.models.user_domain_role import UserDomainRole
# Constants for clarity
ALL = "all"
IS_SUPERUSER = "is_superuser"
IS_STAFF = "is_staff"
IS_DOMAIN_MANAGER = "is_domain_manager"
def grant_access(*rules):
"""
Allows multiple rules in a single decorator call:
@grant_access(IS_STAFF, IS_SUPERUSER, IS_DOMAIN_MANAGER)
or multiple stacked decorators:
@grant_access(IS_SUPERUSER)
@grant_access(IS_DOMAIN_MANAGER)
"""
def decorator(view_func):
view_func.has_explicit_access = True # Mark as explicitly access-controlled
existing_rules = getattr(view_func, "_access_rules", set())
existing_rules.update(rules) # Support multiple rules in one call
view_func._access_rules = existing_rules # Store rules on the function
@wraps(view_func)
def wrapper(request, *args, **kwargs):
user = request.user
# Skip authentication if @login_not_required is applied
if getattr(view_func, "login_not_required", False):
return view_func(request, *args, **kwargs)
# Allow everyone if `ALL` is in rules
if ALL in view_func._access_rules:
return view_func(request, *args, **kwargs)
# Ensure user is authenticated
if not user.is_authenticated:
return JsonResponse({"error": "Authentication required"}, status=403)
conditions_met = []
if IS_STAFF in view_func._access_rules:
conditions_met.append(user.is_staff)
if not any(conditions_met) and IS_SUPERUSER in view_func._access_rules:
conditions_met.append(user.is_superuser)
if not any(conditions_met) and IS_DOMAIN_MANAGER in view_func._access_rules:
domain_id = kwargs.get('pk') or kwargs.get('domain_id')
if not domain_id:
return JsonResponse({"error": "Domain ID missing"}, status=400)
try:
domain = Domain.objects.get(pk=domain_id)
has_permission = UserDomainRole.objects.filter(
user=user, domain=domain
).exists()
conditions_met.append(has_permission)
except ObjectDoesNotExist:
return JsonResponse({"error": "Invalid Domain"}, status=404)
if not any(conditions_met):
return JsonResponse({"error": "Access Denied"}, status=403)
return view_func(request, *args, **kwargs)
return wrapper
return decorator

View file

@ -3,9 +3,13 @@ Contains middleware used in settings.py
""" """
import logging import logging
import re
from urllib.parse import parse_qs from urllib.parse import parse_qs
from django.conf import settings
from django.urls import reverse from django.urls import reverse
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.http import JsonResponse
from django.urls import resolve
from registrar.models import User from registrar.models import User
from waffle.decorators import flag_is_active from waffle.decorators import flag_is_active
@ -170,3 +174,38 @@ class CheckPortfolioMiddleware:
request.session["portfolio"] = request.user.get_first_portfolio() request.session["portfolio"] = request.user.get_first_portfolio()
else: else:
request.session["portfolio"] = request.user.get_first_portfolio() request.session["portfolio"] = request.user.get_first_portfolio()
class RestrictAccessMiddleware:
""" Middleware that blocks all views unless explicitly permitted """
def __init__(self, get_response):
self.get_response = get_response
self.ignored_paths = [re.compile(pattern) for pattern in getattr(settings, "LOGIN_REQUIRED_IGNORE_PATHS", [])]
def __call__(self, request):
# Allow requests that match LOGIN_REQUIRED_IGNORE_PATHS
if any(pattern.match(request.path) for pattern in self.ignored_paths):
return self.get_response(request)
# Try to resolve the view function
try:
resolver_match = resolve(request.path_info)
view_func = resolver_match.func
app_name = resolver_match.app_name # Get app name of resolved view
except Exception:
return JsonResponse({"error": "Not Found"}, status=404)
# Auto-allow Django's built-in admin views (but NOT custom /admin/* views)
if app_name == "admin":
return self.get_response(request)
# Skip access restriction if the view explicitly allows unauthenticated access
if getattr(view_func, "login_required", True) is False:
return self.get_response(request)
# Enforce explicit access fules for other views
if not getattr(view_func, "has_explicit_access", False):
return JsonResponse({"error": "Access Denied"}, status=403)
return self.get_response(request)

View file

@ -1,5 +1,6 @@
from django.http import JsonResponse from django.http import JsonResponse
from django.core.paginator import Paginator from django.core.paginator import Paginator
from registrar.decorators import grant_access, ALL
from registrar.models import DomainRequest from registrar.models import DomainRequest
from django.utils.dateformat import format from django.utils.dateformat import format
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
@ -7,7 +8,7 @@ from django.urls import reverse
from django.db.models import Q from django.db.models import Q
@login_required @grant_access(ALL)
def get_domain_requests_json(request): def get_domain_requests_json(request):
"""Given the current request, """Given the current request,
get all domain requests that are associated with the request user and exclude the APPROVED ones. get all domain requests that are associated with the request user and exclude the APPROVED ones.

View file

@ -1,6 +1,7 @@
import logging import logging
from django.http import JsonResponse from django.http import JsonResponse
from django.core.paginator import Paginator from django.core.paginator import Paginator
from registrar.decorators import grant_access, ALL
from registrar.models import UserDomainRole, Domain, DomainInformation, User from registrar.models import UserDomainRole, Domain, DomainInformation, User
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.urls import reverse from django.urls import reverse
@ -9,7 +10,7 @@ from django.db.models import Q
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@login_required @grant_access(ALL)
def get_domains_json(request): def get_domains_json(request):
"""Given the current request, """Given the current request,
get all domains that are associated with the UserDomainRole object""" get all domains that are associated with the UserDomainRole object"""

View file

@ -1,6 +1,8 @@
from django.shortcuts import render from django.shortcuts import render
from registrar.decorators import grant_access, ALL
@grant_access(ALL)
def index(request): def index(request):
"""This page is available to anyone without logging in.""" """This page is available to anyone without logging in."""
context = {} context = {}