Files
sneakyklaus/docs/decisions/0003-participant-session-scoping.md
Phil Skentelbery eaafa78cf3 feat: add Participant and MagicToken models with automatic migrations
Implements Phase 2 infrastructure for participant registration and authentication:

Database Models:
- Add Participant model with exchange scoping and soft deletes
- Add MagicToken model for passwordless authentication
- Add participants relationship to Exchange model
- Include proper indexes and foreign key constraints

Migration Infrastructure:
- Generate Alembic migration for new models
- Create entrypoint.sh script for automatic migrations on container startup
- Update Containerfile to use entrypoint script and include uv binary
- Remove db.create_all() in favor of migration-based schema management

This establishes the foundation for implementing stories 4.1-4.3, 5.1-5.3, and 10.1.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 16:23:47 -07:00

12 KiB

0003. Participant Session Scoping

Date: 2025-12-22

Status

Accepted

Context

Participants in Sneaky Klaus can register for multiple Secret Santa exchanges using the same email address. Each exchange is independent, and we need to decide how to handle authentication and sessions when a participant belongs to multiple exchanges.

Requirements

  1. Privacy: Participants in one exchange should not see data from other exchanges
  2. Simplicity: Authentication should remain frictionless (magic links)
  3. Independence: Each exchange operates independently
  4. Security: Sessions must be properly isolated

Options Considered

We evaluated three approaches for handling participants across multiple exchanges:

Decision

We will implement exchange-scoped sessions where:

  1. Each participant registration creates a separate Participant record per exchange
  2. Each magic link creates a session scoped to a single exchange
  3. Participant data and matches are isolated per exchange
  4. To access a different exchange, participant must use that exchange's magic link

Rationale

Separate Participant Records:

  • Alice registering for "Family Christmas" and "Office Party" creates two distinct Participant records
  • Each record has its own ID, name, gift ideas, and preferences
  • Email is the same, but records are independent
  • Simple data model with clear foreign key relationships

Exchange-Scoped Sessions:

  • Magic link authentication creates session with: {'user_id': participant_id, 'exchange_id': exchange_id}
  • Session grants access only to the associated exchange
  • Participant cannot view or modify data from other exchanges in same session
  • Clean security boundary

Multiple Exchanges Require Multiple Logins:

  • Participant must authenticate separately for each exchange
  • Each exchange's magic link creates a new session (replacing previous session)
  • No "switch exchange" functionality - use appropriate magic link
  • Simple to implement and reason about

Consequences

Positive

  • Security: Clear isolation between exchanges; no risk of data leakage
  • Simplicity: Straightforward implementation with no complex multi-exchange logic
  • Data Model: Clean foreign key relationships; each participant belongs to exactly one exchange
  • Privacy: Participants in Exchange A cannot discover participants in Exchange B
  • Scalability: No need for complex access control lists or permission systems
  • Testing: Easy to test; each exchange operates independently

Negative

  • User Experience: Participant in multiple exchanges must keep multiple magic links
  • Email Volume: Separate confirmation emails for each exchange registration
  • No Unified View: Participant cannot see all their exchanges in one dashboard
  • Duplicate Data: Same participant name/preferences stored multiple times

Neutral

  • Email Address: Same email can appear in multiple exchanges (expected behavior)
  • Session Management: Only one active participant session at a time (last magic link wins)
  • Magic Link Storage: Participant should save/bookmark magic links for each exchange

Implementation Details

Database Schema

# Two separate Participant records for Alice in two exchanges
Participant(
    id=100,
    exchange_id=1,  # Family Christmas
    email="alice@example.com",
    name="Alice Smith",
    gift_ideas="Books"
)

Participant(
    id=200,
    exchange_id=2,  # Office Party
    email="alice@example.com",
    name="Alice Smith",
    gift_ideas="Coffee mug"
)

Session Structure

# Family Christmas session
session = {
    'user_id': 100,
    'user_type': 'participant',
    'exchange_id': 1
}

# Office Party session (replaces Family Christmas session)
session = {
    'user_id': 200,
    'user_type': 'participant',
    'exchange_id': 2
}

Route Protection

@app.route('/participant/exchange/<int:exchange_id>')
@participant_required
@exchange_access_required
def view_exchange(exchange_id):
    """
    Participant can only view exchange if:
    1. They are authenticated (participant_required)
    2. Their session's exchange_id matches the route's exchange_id
    """
    # g.participant.exchange_id must equal exchange_id
    # Otherwise: 403 Forbidden

User Experience Implications

Registration Email

When Alice registers for both exchanges, she receives two emails:

Email 1 (Family Christmas):

Subject: Welcome to Family Christmas!

Hi Alice,

You've successfully registered for the Secret Santa exchange!

[Access My Registration] (magic link for Family Christmas)

Email 2 (Office Party):

Subject: Welcome to Office Party!

Hi Alice,

You've successfully registered for the Secret Santa exchange!

[Access My Registration] (magic link for Office Party)

Accessing Multiple Exchanges

Scenario: Alice clicks "Family Christmas" magic link, views her assignment, then clicks "Office Party" magic link.

Behavior:

  1. "Family Christmas" link creates session with exchange_id=1
  2. Alice views Family Christmas dashboard
  3. "Office Party" link creates NEW session with exchange_id=2 (replaces previous)
  4. Alice now views Office Party dashboard
  5. To return to Family Christmas, must click Family Christmas magic link again

Recommendation: Advise participants to:

  • Bookmark magic links for each exchange
  • Keep confirmation emails for future access
  • Request new magic links anytime via registration page

Alternatives Considered

Alternative 1: Unified Multi-Exchange Sessions

Approach: Create a single participant identity across all exchanges.

How it would work:

  • Single Participant record per email (not per exchange)
  • Many-to-many relationship: Participant ←→ Exchange
  • Session grants access to all exchanges for that email
  • Dashboard shows all exchanges participant is in

Why rejected:

  • Complexity: Requires many-to-many schema, complex access control
  • Privacy concerns: Easier to accidentally leak cross-exchange data
  • Name conflicts: Participant might use different names in different exchanges
  • Preferences diverge: Gift ideas, reminder settings differ per exchange
  • Admin complexity: Harder to reason about "removing participant from exchange"

Alternative 2: Multi-Exchange Sessions with Switching

Approach: Session grants access to all exchanges, with UI to switch active exchange.

How it would work:

  • Separate Participant records (like chosen approach)
  • Session contains list of participant_ids: {'participant_ids': [100, 200]}
  • UI dropdown to "switch active exchange"
  • Active exchange stored in session: {'active_exchange_id': 1}

Why rejected:

  • Complexity: Session management more complex
  • Security risk: Easier to introduce bugs that show wrong exchange data
  • Marginal UX benefit: Switching requires UI action anyway; magic link is simpler
  • Testing burden: Must test exchange switching logic
  • Session size: Session grows with number of exchanges

Alternative 3: No Multiple Exchange Support

Approach: Enforce email uniqueness globally across all exchanges.

How it would work:

  • Email can only be used in one exchange per installation
  • Attempting to register with same email in second exchange fails

Why rejected:

  • User frustration: Reasonable to participate in multiple exchanges
  • Workaround temptation: Users would use alice+family@example.com, alice+work@example.com
  • Use case mismatch: Common scenario is family member organizing multiple exchanges

Security Considerations

Attack Vector: Exchange Data Leakage

Threat: Participant in Exchange A attempts to view data from Exchange B.

Mitigation:

  • All participant routes check session['exchange_id'] matches route parameter
  • Database queries filter by both participant_id AND exchange_id
  • No API or UI allows listing exchanges for an email

Example Protection:

@exchange_access_required
def view_exchange(exchange_id):
    # Decorator checks: g.participant.exchange_id == exchange_id
    # If mismatch: 403 Forbidden, redirect to correct exchange

Attack Vector: Session Hijacking

Threat: Attacker steals participant session cookie, accesses exchange.

Mitigation:

  • Standard session security (HttpOnly, Secure, SameSite=Lax)
  • Session scoped to single exchange limits damage
  • 7-day session expiration
  • No sensitive financial or personal data stored

Attack Vector: Email Enumeration Across Exchanges

Threat: Attacker checks if email is registered in multiple exchanges.

Mitigation:

  • Magic link request returns generic success message
  • No API reveals which exchanges an email is registered in
  • Rate limiting prevents automated enumeration

Testing Strategy

Unit Tests

def test_participant_isolated_by_exchange():
    """Test that participants are isolated per exchange."""
    # Create two exchanges
    exchange1 = create_exchange(name="Exchange 1")
    exchange2 = create_exchange(name="Exchange 2")

    # Register Alice in both
    alice1 = register_participant(exchange1.slug, {
        'email': 'alice@example.com',
        'name': 'Alice',
        'gift_ideas': 'Books'
    })

    alice2 = register_participant(exchange2.slug, {
        'email': 'alice@example.com',
        'name': 'Alice',
        'gift_ideas': 'Coffee'
    })

    # Different participant IDs
    assert alice1.id != alice2.id

    # Different exchange IDs
    assert alice1.exchange_id == exchange1.id
    assert alice2.exchange_id == exchange2.id

def test_session_scoping():
    """Test that session grants access only to associated exchange."""
    # Create session for exchange 1
    create_participant_session(participant_id=100, exchange_id=1)

    # Can access exchange 1
    with app.test_client() as client:
        response = client.get('/participant/exchange/1')
        assert response.status_code == 200

        # Cannot access exchange 2
        response = client.get('/participant/exchange/2')
        assert response.status_code == 403

Integration Tests

def test_multiple_exchange_magic_links():
    """Test magic links for different exchanges create appropriate sessions."""
    # Register for exchange 1
    token1 = register_and_get_magic_link('exchange1-slug', 'alice@example.com')

    # Register for exchange 2
    token2 = register_and_get_magic_link('exchange2-slug', 'alice@example.com')

    with app.test_client() as client:
        # Use token 1
        client.get(f'/auth/participant/magic/{token1}')

        # Should have access to exchange 1
        response = client.get('/participant/exchange/1')
        assert response.status_code == 200

        # Use token 2 (creates new session)
        client.get(f'/auth/participant/magic/{token2}')

        # Should now have access to exchange 2
        response = client.get('/participant/exchange/2')
        assert response.status_code == 200

        # No longer have access to exchange 1 (session replaced)
        response = client.get('/participant/exchange/1')
        assert response.status_code == 403

Future Considerations

Potential Enhancement: Multi-Exchange Dashboard

If user feedback indicates strong need for unified view, could implement:

Approach:

  • Add route: /participant/all (no auth required, email verification only)
  • Participant enters email, receives magic link
  • Magic link validates email, shows read-only list of all exchanges for that email
  • Each exchange has "Access" button → sends exchange-specific magic link
  • Dashboard itself is stateless (no session), just email verification

Benefits:

  • Participants can see all their exchanges in one place
  • Still maintains exchange-scoped sessions for actual data access
  • Optional feature; magic links still work independently

Implementation Complexity: Medium (new routes, new email template, new UI)

Recommendation: Defer until Phase 8 or later based on user feedback

References