--- name: lp-permissions description: Implements multi-tier permissions for league-planner using decorators (admin_only, staff_only, crud_decorator), session-based access, and token authentication. Use for access control. argument-hint: allowed-tools: Read, Write, Edit, Glob, Grep --- # League-Planner Permission System Implements the multi-tier permission system used in league-planner: decorators for view protection, session-based access control, token authentication, and the Membership model. ## When to Use - Adding permission checks to new views - Implementing role-based access control - Creating token-based authentication for external access - Setting up league/season-level permissions ## Permission Hierarchy ``` ┌─────────────────────────────────────────────────────────────────┐ │ SUPERUSER (is_superuser=True) │ │ └─ Full access to everything │ ├─────────────────────────────────────────────────────────────────┤ │ STAFF (is_staff=True) │ │ └─ League managers: League.managers M2M │ │ └─ Can manage leagues they're assigned to │ ├─────────────────────────────────────────────────────────────────┤ │ SPECTATORS │ │ └─ League spectators: League.spectators M2M │ │ └─ Read-only access to assigned leagues │ ├─────────────────────────────────────────────────────────────────┤ │ SEASON MEMBERS │ │ └─ Membership model with role field │ │ └─ Roles: admin, editor, viewer │ ├─────────────────────────────────────────────────────────────────┤ │ TOKEN-BASED ACCESS │ │ └─ Team.hashval - Single team view │ │ └─ Club.hashval - Club portal access │ │ └─ Stakeholder tokens - Special access │ └─────────────────────────────────────────────────────────────────┘ ``` ## Instructions ### Step 1: Choose the Right Decorator ```python # common/decorators.py - Available decorators from functools import wraps from django.http import HttpResponseForbidden from django.shortcuts import redirect def admin_only(view_func): """Restrict to superusers only.""" @wraps(view_func) def wrapper(request, *args, **kwargs): if not request.user.is_superuser: return HttpResponseForbidden("Admin access required") return view_func(request, *args, **kwargs) return wrapper def staff_only(view_func): """Restrict to staff and superusers.""" @wraps(view_func) def wrapper(request, *args, **kwargs): if not (request.user.is_staff or request.user.is_superuser): return HttpResponseForbidden("Staff access required") return view_func(request, *args, **kwargs) return wrapper def league_owner(view_func): """Require user to be league manager or superuser.""" @wraps(view_func) def wrapper(request, *args, **kwargs): if request.user.is_superuser: return view_func(request, *args, **kwargs) league_id = request.session.get('league') if not league_id: return HttpResponseForbidden("No league selected") from scheduler.models import League try: league = League.objects.get(pk=league_id) if request.user in league.managers.all(): return view_func(request, *args, **kwargs) except League.DoesNotExist: pass return HttpResponseForbidden("League ownership required") return wrapper def readonly_decorator(view_func): """Check if user has at least read access to current context.""" @wraps(view_func) def wrapper(request, *args, **kwargs): if request.user.is_superuser: return view_func(request, *args, **kwargs) league_id = request.session.get('league') if league_id: from scheduler.models import League try: league = League.objects.get(pk=league_id) if (request.user in league.managers.all() or request.user in league.spectators.all()): return view_func(request, *args, **kwargs) except League.DoesNotExist: pass return HttpResponseForbidden("Access denied") return wrapper def crud_decorator(model_class=None, require_edit=False): """ Complex permission checking for CRUD operations. Args: model_class: The model being accessed require_edit: If True, requires edit permission (not just view) """ def decorator(view_func): @wraps(view_func) def wrapper(request, *args, **kwargs): # Superuser bypass if request.user.is_superuser: return view_func(request, *args, **kwargs) # Check league-level permissions league_id = request.session.get('league') if league_id: from scheduler.models import League try: league = League.objects.get(pk=league_id) # Managers can edit if request.user in league.managers.all(): return view_func(request, *args, **kwargs) # Spectators can only view if not require_edit and request.user in league.spectators.all(): return view_func(request, *args, **kwargs) except League.DoesNotExist: pass # Check season membership season_id = request.session.get('season') if season_id: from scheduler.models import Membership try: membership = Membership.objects.get( season_id=season_id, user=request.user ) if require_edit: if membership.role in ('admin', 'editor'): return view_func(request, *args, **kwargs) else: return view_func(request, *args, **kwargs) except Membership.DoesNotExist: pass return HttpResponseForbidden("Permission denied") return wrapper return decorator ``` ### Step 2: Apply Decorators to Views ```python # scheduler/views.py from common.decorators import admin_only, staff_only, league_owner, crud_decorator @admin_only def admin_dashboard(request): """Only superusers can access.""" return render(request, 'admin_dashboard.html') @staff_only def staff_reports(request): """Staff and superusers can access.""" return render(request, 'staff_reports.html') @league_owner def manage_league(request): """League managers and superusers can access.""" league_id = request.session.get('league') league = League.objects.get(pk=league_id) return render(request, 'manage_league.html', {'league': league}) @crud_decorator(model_class=Scenario, require_edit=True) def edit_scenario(request, pk): """Requires edit permission on the scenario.""" scenario = Scenario.objects.get(pk=pk) # Edit logic return render(request, 'edit_scenario.html', {'scenario': scenario}) @crud_decorator(model_class=Scenario, require_edit=False) def view_scenario(request, pk): """Read-only access is sufficient.""" scenario = Scenario.objects.get(pk=pk) return render(request, 'view_scenario.html', {'scenario': scenario}) ``` ### Step 3: Token-Based Authentication ```python # scheduler/helpers.py import hashlib import secrets from django.conf import settings def getHash(type: str, obj) -> str: """ Generate a secure hash token for object access. Args: type: 'team', 'club', or 'stakeholder' obj: The object to generate hash for Returns: str: Secure hash token """ secret = settings.SECRET_KEY unique_id = f"{type}:{obj.pk}:{obj.created_at.isoformat()}" return hashlib.sha256(f"{secret}{unique_id}".encode()).hexdigest()[:32] def verify_team_token(token: str, team_id: int) -> bool: """Verify a team access token.""" from scheduler.models import Team try: team = Team.objects.get(pk=team_id) return team.hashval == token except Team.DoesNotExist: return False ``` ### Step 4: Session-Based Access Control ```python # scheduler/views.py - Token authentication views def singleteam_login(request, team_id: int, token: str): """Authenticate team token and set session.""" from scheduler.models import Team try: team = Team.objects.select_related('season', 'season__league').get(pk=team_id) except Team.DoesNotExist: return HttpResponseForbidden("Team not found") if team.hashval != token: return HttpResponseForbidden("Invalid token") # Set session variables for team access request.session['authorized_league'] = team.season.league_id request.session['authorized_team'] = team.pk request.session['league'] = team.season.league_id request.session['season'] = team.season_id return redirect('singleteam:dashboard', team_id=team.pk) def club_login(request, club_id: int, token: str): """Authenticate club token and set session.""" from scheduler.models import Club try: club = Club.objects.get(pk=club_id) except Club.DoesNotExist: return HttpResponseForbidden("Club not found") if club.hashval != token: return HttpResponseForbidden("Invalid token") # Set session for club access request.session['club'] = club.pk request.session['authorized_club'] = True return redirect('club:dashboard', club_id=club.pk) def check_team_access(request, team_id: int) -> bool: """Check if current session has access to team.""" authorized_team = request.session.get('authorized_team') if authorized_team == team_id: return True # Superuser always has access if request.user.is_authenticated and request.user.is_superuser: return True return False def check_club_access(request, club_id: int) -> bool: """Check if current session has access to club.""" return request.session.get('club') == club_id ``` ### Step 5: API Authentication ```python # api/authentication.py from rest_framework.authentication import BaseAuthentication from rest_framework.exceptions import AuthenticationFailed class TeamTokenAuthentication(BaseAuthentication): """Authenticate requests via X-Team-Token header.""" def authenticate(self, request): token = request.headers.get('X-Team-Token') if not token: return None from scheduler.models import Team try: team = Team.objects.select_related('season').get(hashval=token) except Team.DoesNotExist: raise AuthenticationFailed('Invalid team token') # Return (user, auth) tuple - auth contains the team return (None, team) def authenticate_header(self, request): return 'Team-Token' class APIKeyAuthentication(BaseAuthentication): """Authenticate requests via X-API-Key header.""" def authenticate(self, request): api_key = request.headers.get('X-API-Key') if not api_key: return None from django.conf import settings valid_keys = settings.API_KEYS # Check against known API keys for key_name, key_value in valid_keys.items(): if api_key == key_value: # Store key name in request for logging request._api_key_name = key_name return (None, {'api_key': key_name}) raise AuthenticationFailed('Invalid API key') class SessionOrTokenAuthentication(BaseAuthentication): """Try session auth first, then token auth.""" def authenticate(self, request): # Check session first if request.session.get('authorized_league'): return (request.user, 'session') # Try team token token = request.headers.get('X-Team-Token') if token: from scheduler.models import Team try: team = Team.objects.get(hashval=token) return (None, team) except Team.DoesNotExist: pass # Try API key api_key = request.headers.get('X-API-Key') if api_key: from django.conf import settings if api_key in settings.API_KEYS.values(): return (None, {'api_key': api_key}) return None ``` ## Patterns & Best Practices ### Membership Model Pattern ```python # scheduler/models.py class Membership(models.Model): """Season-level membership with roles.""" ROLE_CHOICES = [ ('admin', 'Administrator'), ('editor', 'Editor'), ('viewer', 'Viewer'), ] user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='memberships', ) season = models.ForeignKey( 'Season', on_delete=models.CASCADE, related_name='memberships', ) role = models.CharField( max_length=20, choices=ROLE_CHOICES, default='viewer', ) created_at = models.DateTimeField(auto_now_add=True) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='created_memberships', ) class Meta: unique_together = ['user', 'season'] def can_edit(self) -> bool: return self.role in ('admin', 'editor') def can_admin(self) -> bool: return self.role == 'admin' ``` ### Permission Mixin for Class-Based Views ```python # common/mixins.py from django.contrib.auth.mixins import AccessMixin from django.http import HttpResponseForbidden class LeagueManagerRequiredMixin(AccessMixin): """Verify user is league manager or superuser.""" def dispatch(self, request, *args, **kwargs): if not request.user.is_authenticated: return self.handle_no_permission() if request.user.is_superuser: return super().dispatch(request, *args, **kwargs) league_id = request.session.get('league') if league_id: from scheduler.models import League try: league = League.objects.get(pk=league_id) if request.user in league.managers.all(): return super().dispatch(request, *args, **kwargs) except League.DoesNotExist: pass return HttpResponseForbidden("League manager access required") class SeasonMemberRequiredMixin(AccessMixin): """Verify user is season member.""" require_edit = False def dispatch(self, request, *args, **kwargs): if not request.user.is_authenticated: return self.handle_no_permission() if request.user.is_superuser: return super().dispatch(request, *args, **kwargs) season_id = request.session.get('season') if season_id: from scheduler.models import Membership try: membership = Membership.objects.get( season_id=season_id, user=request.user ) if self.require_edit and not membership.can_edit(): return HttpResponseForbidden("Edit permission required") return super().dispatch(request, *args, **kwargs) except Membership.DoesNotExist: pass return HttpResponseForbidden("Season membership required") # Usage class ScenarioUpdateView(SeasonMemberRequiredMixin, UpdateView): require_edit = True model = Scenario template_name = 'scenario_form.html' ``` ### Middleware Permission Checks ```python # common/middleware.py from django.http import HttpResponseForbidden from django.shortcuts import redirect class LoginRequiredMiddleware: """Require login except for exempt URLs.""" EXEMPT_URLS = [ '/api/', '/singleteam/', '/clubs/', '/stakeholders/', '/accounts/login/', '/accounts/logout/', '/static/', '/media/', ] def __init__(self, get_response): self.get_response = get_response def __call__(self, request): if not request.user.is_authenticated: path = request.path_info if not any(path.startswith(url) for url in self.EXEMPT_URLS): return redirect('login') return self.get_response(request) class AdminMiddleware: """Restrict /admin/ to superusers only.""" def __init__(self, get_response): self.get_response = get_response def __call__(self, request): if request.path_info.startswith('/admin/'): if not request.user.is_authenticated: return redirect('login') if not request.user.is_superuser: return HttpResponseForbidden("Superuser access required") return self.get_response(request) ``` ## Examples ### Example 1: Protected API Endpoint ```python from rest_framework.decorators import api_view, authentication_classes, permission_classes from rest_framework.permissions import IsAuthenticated from api.authentication import TeamTokenAuthentication, APIKeyAuthentication @api_view(['GET']) @authentication_classes([TeamTokenAuthentication, APIKeyAuthentication]) def team_schedule(request, team_id: int): """Get schedule for a team - requires valid token.""" team = request.auth # The authenticated team if isinstance(team, dict): # API key auth - need to verify team access separately from scheduler.models import Team team = Team.objects.get(pk=team_id) elif team.pk != team_id: return Response({'error': 'Token does not match team'}, status=403) matches = Match.objects.filter( Q(home_team=team) | Q(away_team=team), scenario__is_active=True, ).select_related('home_team', 'away_team', 'day') return Response(MatchSerializer(matches, many=True).data) ``` ### Example 2: Multi-Level Permission Check ```python def check_scenario_permission(user, scenario, require_edit=False) -> bool: """ Check if user has permission to access/edit scenario. Checks in order: 1. Superuser 2. League manager 3. League spectator (view only) 4. Season membership """ if user.is_superuser: return True league = scenario.season.league # League managers can always edit if user in league.managers.all(): return True # League spectators can view if not require_edit and user in league.spectators.all(): return True # Check season membership try: membership = Membership.objects.get( season=scenario.season, user=user ) if require_edit: return membership.can_edit() return True except Membership.DoesNotExist: pass return False ``` ## Common Pitfalls - **Forgetting superuser bypass**: Always check `is_superuser` first in permission logic - **Session not set**: Ensure login views set all required session variables - **Token leakage**: Never log or expose hash tokens in responses - **Missing related data**: Use `select_related` when checking permissions to avoid N+1 - **Inconsistent checks**: Use centralized permission functions, not inline checks ## Verification Test permissions with: ```python # In Django shell from django.test import RequestFactory, Client from django.contrib.sessions.middleware import SessionMiddleware factory = RequestFactory() request = factory.get('/some-url/') # Add session middleware = SessionMiddleware(lambda r: None) middleware.process_request(request) request.session.save() # Set session values request.session['league'] = 1 request.session['authorized_team'] = 5 # Test decorator from common.decorators import league_owner @league_owner def test_view(request): return HttpResponse("OK") response = test_view(request) print(response.status_code) # 200 or 403 ```