2026-02-04 15:29:11 +01:00

21 KiB

name description argument-hint allowed-tools
lp-permissions 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. <permission-type> 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

# 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

# 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

# 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

# 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

# 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

# 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

# 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

# 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

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

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:

# 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