2026-02-04 15:49:25 +01:00

677 lines
21 KiB
Markdown

---
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: <permission-type>
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
```