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.contrib.auth import logout as auth_logout
from django.contrib.auth import authenticate, login
from login_required import login_not_required
from django.http import HttpResponseRedirect
from django.shortcuts import redirect
from urllib.parse import parse_qs, urlencode
from djangooidc.oidc import Client
from djangooidc import exceptions as o_e
from registrar.decorators import grant_access
from registrar.models import User
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",
"registrar.registrar_middleware.CheckUserProfileMiddleware",
"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`)

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 re
from urllib.parse import parse_qs
from django.conf import settings
from django.urls import reverse
from django.http import HttpResponseRedirect
from django.http import JsonResponse
from django.urls import resolve
from registrar.models import User
from waffle.decorators import flag_is_active
@ -170,3 +174,38 @@ class CheckPortfolioMiddleware:
request.session["portfolio"] = request.user.get_first_portfolio()
else:
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.core.paginator import Paginator
from registrar.decorators import grant_access, ALL
from registrar.models import DomainRequest
from django.utils.dateformat import format
from django.contrib.auth.decorators import login_required
@ -7,7 +8,7 @@ from django.urls import reverse
from django.db.models import Q
@login_required
@grant_access(ALL)
def get_domain_requests_json(request):
"""Given the current request,
get all domain requests that are associated with the request user and exclude the APPROVED ones.

View file

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

View file

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