677 lines
21 KiB
Markdown
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
|
|
```
|