Skip to content

Entra JIT Provisioning with Admin Override Implementation Guide

Date: 2025-10-30 Author: Development Team Status: Implementation Guide Prerequisites: REPORTS/2025-10-17_roles_privileges_assessment_and_recommendation.md

Overview

This guide provides precise, step-by-step instructions for implementing: 1. Dual Authentication: Support both Entra (Azure AD) and local users 2. IT-Managed Defaults: Entra security groups determine initial scopes on first login 3. JIT Provisioning: Entra users auto-created in database on first login 4. Local User Creation: CLI commands for creating password-based users 5. Admin Overrides: Slidefactory admins can modify user scopes via CLI 6. Database as Source of Truth: Runtime permissions always loaded from database 7. Configuration Migration: Move existing users from config file to database

Architecture Decisions

Dual Authentication Support

The system supports two authentication methods with equal capabilities:

Entra (Azure AD) Users: - Auto-provisioned via JIT on first login - Scopes assigned from Entra group mappings - auth_provider = "azure_ad" - No local password stored - Admins can override scopes via CLI

Local Users: - Created manually via CLI only - Scopes assigned during creation or via CLI - auth_provider = "local" - Password stored (bcrypt hashed) - Used for admin/service accounts

Common Features: - Both use scope-based authorization - Both stored in same app_users table - Both manageable via CLI - Both support scope overrides - Database is source of truth for both

Key Design Decisions

  • ✅ Entra groups define initial scopes (first login only)
  • ✅ Local users created via CLI only (no config file)
  • ✅ Database stores runtime scopes (source of truth)
  • ✅ Admin CLI can override individual user scopes
  • ✅ Group mappings in config file (IT manages)
  • scopes_overridden flag tracks manual changes
  • ✅ Entra group membership stored for audit/reset capability
  • ✅ Migrate existing config users to database
  • ✅ Emergency admin via environment variables only

Phase 1: Database Schema Changes

Step 1.1: Update User Model

File: app/auth/users/models.py

Action: Add new columns to User model

# Locate the User class definition (around line 8-18)
# ADD these columns to the existing model:

from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, JSON, DateTime

class User(Base):
    __tablename__ = "app_users"

    id = Column(Integer, primary_key=True, autoincrement=True)
    email = Column(String(255), unique=True, nullable=False, index=True)
    name = Column(String(255))
    password = Column(String(255))  # Nullable for Entra users
    attributes = Column(JSON)  # Legacy, will be deprecated
    active = Column(Boolean, default=True, nullable=False)

    # NEW COLUMNS - Add these:
    scopes = Column(JSON, nullable=False, default=list)  # Runtime source of truth
    auth_provider = Column(String(50), default="local")  # "local" or "azure_ad"
    entra_groups = Column(JSON)  # Last known Entra groups (for audit/reset)
    scopes_overridden = Column(Boolean, default=False, nullable=False)  # Track manual changes
    last_login = Column(DateTime)
    created_at = Column(DateTime, default=datetime.utcnow)

Verification: - Model imports are correct - All existing columns preserved - New columns have appropriate defaults

Step 1.2: Create Alembic Migration

Action: Generate migration for schema changes

# Generate migration
alembic revision --autogenerate -m "add entra jit provisioning fields to app_users"

# Review the generated migration file in alembic/versions/
# File will be named: YYYYMMDDHHMMSS_add_entra_jit_provisioning_fields_to_app_users.py

Manual Review Required:

Open the generated migration file and verify:

def upgrade() -> None:
    # Should contain operations similar to:
    op.add_column('app_users', sa.Column('scopes', sa.JSON(), nullable=False, server_default='[]'))
    op.add_column('app_users', sa.Column('auth_provider', sa.String(50), nullable=True, server_default='local'))
    op.add_column('app_users', sa.Column('entra_groups', sa.JSON(), nullable=True))
    op.add_column('app_users', sa.Column('scopes_overridden', sa.Boolean(), nullable=False, server_default='false'))
    op.add_column('app_users', sa.Column('last_login', sa.DateTime(), nullable=True))
    op.add_column('app_users', sa.Column('created_at', sa.DateTime(), nullable=True))

    # Add indexes for performance
    op.create_index('idx_app_users_scopes', 'app_users', ['scopes'], postgresql_using='gin')
    op.create_index('idx_app_users_auth_provider', 'app_users', ['auth_provider'])
    op.create_index('idx_app_users_active', 'app_users', ['active'], postgresql_where=sa.text('active = true'))

def downgrade() -> None:
    # Should drop everything added in upgrade()
    op.drop_index('idx_app_users_active', 'app_users')
    op.drop_index('idx_app_users_auth_provider', 'app_users')
    op.drop_index('idx_app_users_scopes', 'app_users')
    op.drop_column('app_users', 'created_at')
    op.drop_column('app_users', 'last_login')
    op.drop_column('app_users', 'scopes_overridden')
    op.drop_column('app_users', 'entra_groups')
    op.drop_column('app_users', 'auth_provider')
    op.drop_column('app_users', 'scopes')

If migration looks incorrect: Edit manually to match above

Step 1.3: Apply Migration

Action: Run migration on development database

# Check current version
alembic current

# Apply migration
alembic upgrade head

# Verify columns added
psql $DATABASE_URL -c "\d app_users"

Expected Output:

Column            | Type                     | Nullable | Default
------------------+--------------------------+----------+---------
scopes            | jsonb                    | not null | '[]'::jsonb
auth_provider     | character varying(50)    |          | 'local'
entra_groups      | jsonb                    |          |
scopes_overridden | boolean                  | not null | false
last_login        | timestamp                |          |
created_at        | timestamp                |          |


Phase 2: Auth Layer - JIT Provisioning Logic

Step 2.1: Create JIT Provisioning Module

File: app/auth/provisioning.py (NEW FILE)

Action: Create complete provisioning logic

"""
Just-in-Time (JIT) User Provisioning for Entra Authentication

Handles automatic user creation on first login with group-based scope assignment.
"""

import logging
from datetime import datetime
from typing import List, Optional
from sqlalchemy.orm import Session

from app.auth.users.models import User
from app.auth.entra_mappings import get_scopes_for_groups

logger = logging.getLogger(__name__)


def resolve_entra_group_names(group_ids: List[str]) -> List[str]:
    """
    Resolve Entra group IDs to display names via Microsoft Graph API.

    TODO: Implement Graph API integration when needed.
    For now, returns group IDs as-is (if your token includes group names directly).

    Args:
        group_ids: List of Entra group IDs or names from token

    Returns:
        List of resolved group names
    """
    # FUTURE IMPLEMENTATION:
    # from app.auth.graph import get_group_names
    # return get_group_names(group_ids)

    # Current: assumes token contains group names directly
    # If your token contains ObjectIDs instead, implement Graph API call here
    return group_ids


def compute_initial_scopes(entra_groups: List[str]) -> List[str]:
    """
    Compute initial scopes based on Entra group membership.

    Args:
        entra_groups: List of Entra group names

    Returns:
        List of scopes (union of all group scopes)
    """
    scopes = get_scopes_for_groups(entra_groups)
    logger.info(f"Computed initial scopes from groups {entra_groups}: {scopes}")
    return scopes


def provision_new_user(
    db: Session,
    email: str,
    name: str,
    entra_groups: List[str]
) -> User:
    """
    Create new user with group-based scopes.

    Args:
        db: Database session
        email: User email address
        name: User display name
        entra_groups: List of Entra group names

    Returns:
        Newly created User object
    """
    initial_scopes = compute_initial_scopes(entra_groups)

    user = User(
        email=email,
        name=name,
        password=None,  # Entra users don't have local passwords
        scopes=initial_scopes,
        auth_provider="azure_ad",
        entra_groups=entra_groups,
        scopes_overridden=False,
        active=True,
        created_at=datetime.utcnow(),
        last_login=datetime.utcnow()
    )

    db.add(user)
    db.commit()
    db.refresh(user)

    logger.info(
        f"JIT provisioned user {email} with {len(initial_scopes)} scopes "
        f"from groups: {entra_groups}"
    )

    return user


def update_existing_user_login(
    db: Session,
    user: User,
    entra_groups: List[str]
) -> User:
    """
    Update existing user's login timestamp and group membership.

    IMPORTANT: Does NOT update scopes (database is source of truth).
    Only updates audit fields.

    Args:
        db: Database session
        user: Existing User object
        entra_groups: Current Entra group names

    Returns:
        Updated User object
    """
    user.last_login = datetime.utcnow()
    user.entra_groups = entra_groups  # Update for audit purposes

    db.commit()
    db.refresh(user)

    logger.debug(f"Updated login timestamp for user {user.email}")

    return user


def get_or_create_user(
    db: Session,
    email: str,
    name: str,
    entra_group_ids: List[str]
) -> User:
    """
    Main entry point for JIT provisioning.

    - If user exists: Update login timestamp, return user
    - If user is new: Create with group-based scopes

    Args:
        db: Database session
        email: User email from token
        name: User name from token
        entra_group_ids: Group IDs or names from token

    Returns:
        User object (existing or newly created)
    """
    # Check if user exists
    user = db.query(User).filter(User.email == email).first()

    # Resolve group names
    entra_groups = resolve_entra_group_names(entra_group_ids)

    if user:
        # Existing user - just update login info
        return update_existing_user_login(db, user, entra_groups)
    else:
        # New user - JIT provision
        return provision_new_user(db, email, name, entra_groups)

Verification: - File imports correctly - All functions have type hints - Logging statements present - Error handling appropriate

Step 2.2: Update Azure AD Router

File: app/auth/azure_router.py

Action: Integrate JIT provisioning into callback

Locate: The azure_callback function (typically around line 50-100)

Replace the user loading logic with:

from app.auth.provisioning import get_or_create_user
from app.auth.auth import SessionData
from app.util.database import get_db

@router.get("/auth/azure/callback")
async def azure_callback(
    code: str,
    state: str = Query(None),
    db: Session = Depends(get_db)
):
    """
    Azure AD OAuth callback endpoint.
    Handles JIT user provisioning and session creation.
    """
    try:
        # Exchange authorization code for token
        token_data = await exchange_code_for_token(code)

        # Extract user information from token
        email = token_data.get("email") or token_data.get("preferred_username")
        name = token_data.get("name", email)
        entra_group_ids = token_data.get("groups", [])

        if not email:
            raise HTTPException(400, "Email not found in token")

        # JIT provision or load existing user
        user = get_or_create_user(
            db=db,
            email=email,
            name=name,
            entra_group_ids=entra_group_ids
        )

        if not user.active:
            raise HTTPException(403, "User account is deactivated")

        # Create session with scopes from database
        session_data = SessionData(
            id=user.id,
            email=user.email,
            name=user.name,
            scopes=user.scopes,  # Load from database
            attributes=user.attributes or {}  # Legacy support
        )

        # Store session
        session_id = str(uuid.uuid4())
        await backend.create(session_id, session_data)

        # Create response with session cookie
        redirect_url = state if state else "/"
        response = RedirectResponse(url=redirect_url, status_code=302)
        response.set_cookie(
            key="session_id",
            value=session_id,
            httponly=True,
            secure=settings.ENFORCE_HTTPS,
            samesite="lax"
        )

        logger.info(f"User {email} logged in via Azure AD with {len(user.scopes)} scopes")

        return response

    except Exception as e:
        logger.error(f"Azure AD callback error: {str(e)}", exc_info=True)
        raise HTTPException(500, f"Authentication failed: {str(e)}")

Verification: - Import statements added at top of file - Error handling preserves existing behavior - Session cookie settings match existing configuration - Logging includes relevant information

Step 2.3: Update SessionData Model

File: app/auth/auth.py

Action: Add scopes field to SessionData

Locate: The SessionData class definition

Modify to include scopes:

from pydantic import BaseModel
from typing import Optional, List

class SessionData(BaseModel):
    id: Optional[int] = None
    email: Optional[str] = None
    name: Optional[str] = None
    scopes: List[str] = []  # NEW: Add this field
    attributes: Optional[dict] = None  # Legacy

    def has_scope(self, scope: str) -> bool:
        """Check if session has a specific scope."""
        return scope in self.scopes or "*" in self.scopes

    def has_workflow_access(self, workflow: str, action: str = "read") -> bool:
        """Check workflow-specific access with fallback to global."""
        # 1. Try workflow-specific scope
        if self.has_scope(f"workflows:{workflow}:{action}"):
            return True
        # 2. Fall back to global workflow scope
        if self.has_scope(f"workflows:{action}"):
            return True
        # 3. Fall back to wildcard
        return self.has_scope("*")

    def has_resource_access(self, resource: str, action: str, workflow: str = None) -> bool:
        """Check resource access with optional workflow scoping."""
        # 1. Try workflow-specific resource scope
        if workflow and self.has_scope(f"{resource}:{workflow}:{action}"):
            return True
        # 2. Fall back to global resource scope
        if self.has_scope(f"{resource}:{action}"):
            return True
        # 3. Fall back to wildcard
        return self.has_scope("*")

Verification: - scopes field added with default empty list - Helper methods added - Existing fields preserved - No breaking changes to existing code


Phase 3: CLI Commands for Admin Overrides

Step 3.1: Create User Management CLI

File: cli/commands/user.py (NEW FILE)

Action: Create complete CLI module

"""
User Management CLI Commands

Provides tools for Slidefactory admins to manage users and scopes.
"""

import click
import sys
from datetime import datetime
from sqlalchemy.orm import Session
from tabulate import tabulate

from app.util.database import SessionLocal
from app.auth.users.models import User
from app.auth.entra_mappings import get_scopes_for_groups, list_all_groups


def print_success(message: str):
    """Print success message in green."""
    click.echo(click.style(f"✓ {message}", fg="green"))


def print_error(message: str):
    """Print error message in red."""
    click.echo(click.style(f"✗ {message}", fg="red"), err=True)


def print_warning(message: str):
    """Print warning message in yellow."""
    click.echo(click.style(f"⚠ {message}", fg="yellow"))


@click.group()
def user():
    """User management commands."""
    pass


@user.command("list")
@click.option("--show-groups", is_flag=True, help="Show Entra groups")
@click.option("--show-scopes", is_flag=True, help="Show all scopes")
@click.option("--provider", help="Filter by auth provider (local/azure_ad)")
def list_users(show_groups, show_scopes, provider):
    """List all users with their access information."""
    db = SessionLocal()
    try:
        query = db.query(User).order_by(User.email)

        if provider:
            query = query.filter(User.auth_provider == provider)

        users = query.all()

        if not users:
            print_warning("No users found")
            return

        # Prepare table data
        headers = ["Email", "Name", "Provider", "Active", "Overridden", "Scopes"]
        if show_groups:
            headers.append("Entra Groups")
        if show_scopes:
            headers.append("All Scopes")

        rows = []
        for user in users:
            row = [
                user.email,
                user.name or "",
                user.auth_provider,
                "Yes" if user.active else "No",
                "Yes" if user.scopes_overridden else "No",
                len(user.scopes or [])
            ]

            if show_groups:
                groups = ", ".join(user.entra_groups or []) if user.entra_groups else "N/A"
                row.append(groups)

            if show_scopes:
                scopes = ", ".join(user.scopes or []) if user.scopes else "None"
                row.append(scopes)

            rows.append(row)

        click.echo(tabulate(rows, headers=headers, tablefmt="simple"))
        click.echo(f"\nTotal: {len(users)} users")

    finally:
        db.close()


@user.command("show")
@click.argument("email")
def show_user(email):
    """Show detailed information for a specific user."""
    db = SessionLocal()
    try:
        user = db.query(User).filter(User.email == email).first()

        if not user:
            print_error(f"User {email} not found")
            sys.exit(1)

        click.echo(f"\n{'='*60}")
        click.echo(f"User: {user.email}")
        click.echo(f"{'='*60}")
        click.echo(f"Name:              {user.name or 'N/A'}")
        click.echo(f"Provider:          {user.auth_provider}")
        click.echo(f"Active:            {user.active}")
        click.echo(f"Scopes Overridden: {user.scopes_overridden}")
        click.echo(f"Created:           {user.created_at or 'Unknown'}")
        click.echo(f"Last Login:        {user.last_login or 'Never'}")

        click.echo(f"\nScopes ({len(user.scopes or [])}):")
        for scope in (user.scopes or []):
            click.echo(f"  - {scope}")

        if user.entra_groups:
            click.echo(f"\nEntra Groups ({len(user.entra_groups)}):")
            for group in user.entra_groups:
                click.echo(f"  - {group}")

        if user.attributes:
            click.echo(f"\nLegacy Attributes:")
            click.echo(f"  {user.attributes}")

        click.echo()

    finally:
        db.close()


@user.command("add-scope")
@click.argument("email")
@click.argument("scope")
def add_scope(email, scope):
    """Add a scope to a user."""
    db = SessionLocal()
    try:
        user = db.query(User).filter(User.email == email).first()

        if not user:
            print_error(f"User {email} not found")
            sys.exit(1)

        current_scopes = set(user.scopes or [])

        if scope in current_scopes:
            print_warning(f"User {email} already has scope: {scope}")
            return

        current_scopes.add(scope)
        user.scopes = sorted(list(current_scopes))
        user.scopes_overridden = True

        db.commit()

        print_success(f"Added scope '{scope}' to user {email}")
        click.echo(f"User now has {len(user.scopes)} scopes")

    finally:
        db.close()


@user.command("remove-scope")
@click.argument("email")
@click.argument("scope")
def remove_scope(email, scope):
    """Remove a scope from a user."""
    db = SessionLocal()
    try:
        user = db.query(User).filter(User.email == email).first()

        if not user:
            print_error(f"User {email} not found")
            sys.exit(1)

        current_scopes = set(user.scopes or [])

        if scope not in current_scopes:
            print_warning(f"User {email} does not have scope: {scope}")
            return

        current_scopes.discard(scope)
        user.scopes = sorted(list(current_scopes))
        user.scopes_overridden = True

        db.commit()

        print_success(f"Removed scope '{scope}' from user {email}")
        click.echo(f"User now has {len(user.scopes)} scopes")

    finally:
        db.close()


@user.command("set-scopes")
@click.argument("email")
@click.argument("scopes")  # Comma-separated
def set_scopes(email, scopes):
    """Replace all scopes for a user (comma-separated list)."""
    db = SessionLocal()
    try:
        user = db.query(User).filter(User.email == email).first()

        if not user:
            print_error(f"User {email} not found")
            sys.exit(1)

        new_scopes = [s.strip() for s in scopes.split(",") if s.strip()]

        if not new_scopes:
            print_error("No valid scopes provided")
            sys.exit(1)

        user.scopes = sorted(new_scopes)
        user.scopes_overridden = True

        db.commit()

        print_success(f"Set {len(new_scopes)} scopes for user {email}")
        for scope in new_scopes:
            click.echo(f"  - {scope}")

    finally:
        db.close()


@user.command("reset-to-groups")
@click.argument("email")
def reset_to_groups(email):
    """Reset user scopes to match their current Entra groups."""
    db = SessionLocal()
    try:
        user = db.query(User).filter(User.email == email).first()

        if not user:
            print_error(f"User {email} not found")
            sys.exit(1)

        if user.auth_provider != "azure_ad":
            print_error(f"User {email} is not an Entra user (provider: {user.auth_provider})")
            sys.exit(1)

        if not user.entra_groups:
            print_error(f"User {email} has no Entra group data stored")
            sys.exit(1)

        new_scopes = get_scopes_for_groups(user.entra_groups)

        click.echo(f"Resetting scopes based on groups: {', '.join(user.entra_groups)}")
        click.echo(f"New scopes ({len(new_scopes)}):")
        for scope in new_scopes:
            click.echo(f"  - {scope}")

        if not click.confirm("\nProceed with reset?"):
            print_warning("Reset cancelled")
            return

        user.scopes = new_scopes
        user.scopes_overridden = False

        db.commit()

        print_success(f"Reset scopes for user {email}")

    finally:
        db.close()


@user.command("activate")
@click.argument("email")
def activate_user(email):
    """Activate a user account."""
    db = SessionLocal()
    try:
        user = db.query(User).filter(User.email == email).first()

        if not user:
            print_error(f"User {email} not found")
            sys.exit(1)

        if user.active:
            print_warning(f"User {email} is already active")
            return

        user.active = True
        db.commit()

        print_success(f"Activated user {email}")

    finally:
        db.close()


@user.command("deactivate")
@click.argument("email")
def deactivate_user(email):
    """Deactivate a user account."""
    db = SessionLocal()
    try:
        user = db.query(User).filter(User.email == email).first()

        if not user:
            print_error(f"User {email} not found")
            sys.exit(1)

        if not user.active:
            print_warning(f"User {email} is already inactive")
            return

        user.active = False
        db.commit()

        print_success(f"Deactivated user {email}")

    finally:
        db.close()


@user.command("list-groups")
def list_entra_groups():
    """List all configured Entra group mappings."""
    groups = list_all_groups()

    click.echo(f"\nConfigured Entra Groups ({len(groups)}):")
    click.echo(f"{'='*60}")

    for group in groups:
        from app.auth.entra_mappings import get_group_mapping
        scopes = get_group_mapping(group)
        click.echo(f"\n{group}")
        click.echo(f"  Scopes: {len(scopes)}")
        for scope in scopes[:5]:  # Show first 5
            click.echo(f"    - {scope}")
        if len(scopes) > 5:
            click.echo(f"    ... and {len(scopes) - 5} more")

    click.echo()


if __name__ == "__main__":
    user()

Verification: - All commands defined - Error handling present - User-friendly output with colors - Confirmation prompts for destructive actions

Step 3.2: Register CLI Commands

File: cli/__main__.py or cli/cli.py

Action: Register user command group

Locate: Main CLI group definition

Add:

from cli.commands.user import user

# Register command group
cli.add_command(user)

Verification: - Test command registration: python -m cli user --help

Step 3.3: Add Required Dependencies

File: requirements.txt

Action: Add CLI dependencies if not present

# Check if these are already in requirements.txt
grep -E "click|tabulate" requirements.txt

# If missing, add:
click>=8.0.0
tabulate>=0.9.0

Verification: - Dependencies installed: pip install -r requirements.txt


Phase 3.5: Local User Management CLI

Goal: Add CLI commands for creating and managing local users

Step 3.5.1: Add Local User Creation Command

File: cli/commands/user.py

Action: Add create-local command to existing user command group

Insert after the deactivate_user function (before list_entra_groups):

# Scope presets for easy user creation
SCOPE_PRESETS = {
    'admin': ['*'],
    'workflow-admin': lambda workflow: [
        f'workflows:{workflow}:read',
        f'workflows:{workflow}:execute',
        f'templates:{workflow}:write',
        f'presentations:generate',
        f'results:write',
        f'contexts:write'
    ],
    'workflow-user': lambda workflow: [
        f'workflows:{workflow}:read',
        f'workflows:{workflow}:execute',
        f'templates:{workflow}:read',
        f'presentations:generate',
        f'presentations:read',
        f'results:read'
    ],
    'viewer': [
        'presentations:read',
        'results:read'
    ],
    'api-user': [
        'presentations:generate',
        'presentations:read',
        'results:read',
        'workflows:read'
    ]
}


@user.command("create-local")
@click.argument("email")
@click.option("--name", required=True, help="User display name")
@click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True, help="User password")
@click.option("--scopes", help="Comma-separated list of scopes")
@click.option("--preset", type=click.Choice(['admin', 'workflow-admin', 'workflow-user', 'viewer', 'api-user']),
              help="Use scope preset")
@click.option("--workflow", help="Workflow ID (required for workflow-* presets)")
def create_local_user(email, name, password, scopes, preset, workflow):
    """
    Create a new local user with password and scopes.

    Examples:
        # Create admin user
        python -m cli user create-local admin@example.com --name "Admin" --preset admin

        # Create workflow user
        python -m cli user create-local user@example.com --name "User" --preset workflow-user --workflow esg2

        # Create with custom scopes
        python -m cli user create-local user@example.com --name "User" --scopes "presentations:read,results:read"
    """
    import bcrypt
    from app.auth.users.models import User
    from app.util.database import SessionLocal

    db = SessionLocal()
    try:
        # Check if user already exists
        existing = db.query(User).filter(User.email == email).first()
        if existing:
            print_error(f"User {email} already exists")
            sys.exit(1)

        # Determine scopes
        if preset:
            if preset in ['workflow-admin', 'workflow-user']:
                if not workflow:
                    print_error(f"--workflow required for preset '{preset}'")
                    sys.exit(1)
                user_scopes = SCOPE_PRESETS[preset](workflow)
            else:
                user_scopes = SCOPE_PRESETS[preset]
        elif scopes:
            user_scopes = [s.strip() for s in scopes.split(",") if s.strip()]
            if not user_scopes:
                print_error("No valid scopes provided")
                sys.exit(1)
        else:
            print_error("Either --scopes or --preset must be provided")
            sys.exit(1)

        # Hash password
        password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')

        # Create user
        user = User(
            email=email,
            name=name,
            password=password_hash,
            scopes=user_scopes,
            auth_provider="local",
            scopes_overridden=False,  # Not overridden, this is initial assignment
            active=True,
            created_at=datetime.utcnow()
        )

        db.add(user)
        db.commit()

        print_success(f"Local user {email} created successfully!")
        click.echo(f"Name: {name}")
        click.echo(f"Provider: local")
        click.echo(f"Scopes ({len(user_scopes)}):")
        for scope in user_scopes:
            click.echo(f"  - {scope}")

    except Exception as e:
        print_error(f"Failed to create user: {str(e)}")
        sys.exit(1)
    finally:
        db.close()


@user.command("change-password")
@click.argument("email")
@click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True, help="New password")
def change_password(email, password):
    """Change password for a local user."""
    import bcrypt
    from app.auth.users.models import User
    from app.util.database import SessionLocal

    db = SessionLocal()
    try:
        user = db.query(User).filter(User.email == email).first()

        if not user:
            print_error(f"User {email} not found")
            sys.exit(1)

        if user.auth_provider != "local":
            print_error(f"Cannot change password for {user.auth_provider} user")
            sys.exit(1)

        # Hash new password
        password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
        user.password = password_hash

        db.commit()

        print_success(f"Password changed for user {email}")

    except Exception as e:
        print_error(f"Failed to change password: {str(e)}")
        sys.exit(1)
    finally:
        db.close()

Verification: - Commands added correctly - Imports present at top of file - Password hashing uses bcrypt - Scope presets defined

Step 3.5.2: Add bcrypt Dependency

File: requirements.txt

Action: Ensure bcrypt is available

# Check if bcrypt is already in requirements.txt
grep bcrypt requirements.txt

# If missing, add:
bcrypt>=4.0.0

Step 3.5.3: Test Local User Creation

Action: Verify CLI commands work

# Test creating local admin user
python -m cli user create-local admin@test.com --name "Test Admin" --preset admin

# Test creating workflow user
python -m cli user create-local esg.user@test.com --name "ESG User" --preset workflow-user --workflow esg2

# Test listing users
python -m cli user list

# Test showing specific user
python -m cli user show admin@test.com

# Test changing password
python -m cli user change-password admin@test.com

Expected Output: - Users created successfully - Passwords hashed in database - Scopes assigned correctly - auth_provider set to "local"


Phase 4: Testing

Step 4.1: Create Unit Tests

File: tests/unit/auth/test_provisioning.py (NEW FILE)

Action: Create test suite for JIT provisioning

"""
Unit tests for JIT user provisioning.
"""

import pytest
from datetime import datetime
from unittest.mock import Mock, patch

from app.auth.provisioning import (
    compute_initial_scopes,
    provision_new_user,
    update_existing_user_login,
    get_or_create_user
)
from app.auth.users.models import User


def test_compute_initial_scopes_single_group():
    """Test scope computation with single group."""
    groups = ["slidefactory-viewers"]
    scopes = compute_initial_scopes(groups)

    assert "presentations:read" in scopes
    assert "results:read" in scopes


def test_compute_initial_scopes_multiple_groups():
    """Test scope computation with multiple groups (union)."""
    groups = ["slidefactory-viewers", "slidefactory-esg-team"]
    scopes = compute_initial_scopes(groups)

    # Should have scopes from both groups
    assert "presentations:read" in scopes  # From viewers
    assert "workflows:esg2:execute" in scopes  # From esg-team


def test_compute_initial_scopes_admin():
    """Test that admin group gets wildcard scope."""
    groups = ["slidefactory-admins"]
    scopes = compute_initial_scopes(groups)

    assert "*" in scopes


def test_compute_initial_scopes_no_matching_groups():
    """Test default scopes when no groups match."""
    groups = ["some-unrelated-group"]
    scopes = compute_initial_scopes(groups)

    # Should get default scopes
    assert "presentations:read" in scopes
    assert len(scopes) == 1


def test_provision_new_user(db_session):
    """Test creating a new user with JIT provisioning."""
    email = "newuser@example.com"
    name = "New User"
    groups = ["slidefactory-users"]

    user = provision_new_user(db_session, email, name, groups)

    assert user.email == email
    assert user.name == name
    assert user.auth_provider == "azure_ad"
    assert user.scopes_overridden is False
    assert user.active is True
    assert len(user.scopes) > 0
    assert "presentations:read" in user.scopes


def test_update_existing_user_login(db_session):
    """Test updating login timestamp for existing user."""
    # Create existing user
    user = User(
        email="existing@example.com",
        name="Existing User",
        scopes=["templates:read"],
        auth_provider="azure_ad",
        scopes_overridden=True,
        active=True
    )
    db_session.add(user)
    db_session.commit()

    # Update login with different groups
    new_groups = ["slidefactory-admins"]
    updated_user = update_existing_user_login(db_session, user, new_groups)

    # Scopes should NOT change (overridden)
    assert updated_user.scopes == ["templates:read"]
    assert updated_user.entra_groups == new_groups
    assert updated_user.last_login is not None


def test_get_or_create_user_new(db_session):
    """Test get_or_create with new user."""
    email = "test@example.com"
    name = "Test User"
    groups = ["slidefactory-users"]

    user = get_or_create_user(db_session, email, name, groups)

    assert user.email == email
    assert user.id is not None


def test_get_or_create_user_existing(db_session):
    """Test get_or_create with existing user."""
    email = "existing@example.com"

    # Create user
    existing = User(
        email=email,
        name="Original Name",
        scopes=["custom:scope"],
        auth_provider="azure_ad",
        active=True
    )
    db_session.add(existing)
    db_session.commit()
    existing_id = existing.id

    # Try to provision again with different groups
    user = get_or_create_user(db_session, email, "Different Name", ["slidefactory-admins"])

    # Should return same user, scopes unchanged
    assert user.id == existing_id
    assert user.scopes == ["custom:scope"]
    assert user.last_login is not None


@pytest.fixture
def db_session():
    """Mock database session for testing."""
    from sqlalchemy import create_engine
    from sqlalchemy.orm import sessionmaker
    from app.auth.users.models import Base

    engine = create_engine("sqlite:///:memory:")
    Base.metadata.create_all(engine)
    Session = sessionmaker(bind=engine)
    session = Session()

    yield session

    session.close()

Verification: - Run tests: pytest tests/unit/auth/test_provisioning.py -v - All tests should pass

Step 4.2: Create Integration Tests

File: tests/integration/test_entra_auth_flow.py (NEW FILE)

Action: Test full authentication flow

"""
Integration tests for Entra authentication flow.
"""

import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch, Mock

from app.main import app
from app.auth.users.models import User


@pytest.fixture
def client():
    """Test client."""
    return TestClient(app)


@pytest.fixture
def mock_entra_token():
    """Mock Entra token response."""
    return {
        "email": "testuser@example.com",
        "name": "Test User",
        "groups": ["slidefactory-users"]
    }


def test_azure_callback_new_user(client, db_session, mock_entra_token):
    """Test Azure callback creates new user on first login."""
    with patch("app.auth.azure_router.exchange_code_for_token") as mock_exchange:
        mock_exchange.return_value = mock_entra_token

        response = client.get("/auth/azure/callback?code=test_code")

        assert response.status_code == 302  # Redirect

        # Check user was created
        user = db_session.query(User).filter(
            User.email == "testuser@example.com"
        ).first()

        assert user is not None
        assert user.auth_provider == "azure_ad"
        assert len(user.scopes) > 0
        assert user.scopes_overridden is False


def test_azure_callback_existing_user(client, db_session, mock_entra_token):
    """Test Azure callback updates existing user."""
    # Create existing user with custom scopes
    existing = User(
        email="testuser@example.com",
        name="Test User",
        scopes=["custom:scope", "*"],
        auth_provider="azure_ad",
        scopes_overridden=True,
        active=True
    )
    db_session.add(existing)
    db_session.commit()

    with patch("app.auth.azure_router.exchange_code_for_token") as mock_exchange:
        mock_exchange.return_value = mock_entra_token

        response = client.get("/auth/azure/callback?code=test_code")

        assert response.status_code == 302

        # Check scopes were NOT changed
        db_session.refresh(existing)
        assert existing.scopes == ["custom:scope", "*"]
        assert existing.last_login is not None


def test_azure_callback_inactive_user(client, db_session, mock_entra_token):
    """Test that inactive users cannot log in."""
    # Create inactive user
    inactive = User(
        email="testuser@example.com",
        name="Test User",
        scopes=["presentations:read"],
        auth_provider="azure_ad",
        active=False
    )
    db_session.add(inactive)
    db_session.commit()

    with patch("app.auth.azure_router.exchange_code_for_token") as mock_exchange:
        mock_exchange.return_value = mock_entra_token

        response = client.get("/auth/azure/callback?code=test_code")

        assert response.status_code == 403

Verification: - Run tests: pytest tests/integration/test_entra_auth_flow.py -v - Requires database connection

Step 4.3: Manual Testing Checklist

File: REPORTS/2025-10-30_entra_jit_manual_testing_checklist.md (NEW FILE)

Action: Create testing checklist

# Entra JIT Provisioning - Manual Testing Checklist

## Pre-Testing Setup

- [ ] Database migration applied successfully
- [ ] Entra group mappings configured in `app/auth/entra_mappings.py`
- [ ] Azure AD application configured with group claims
- [ ] Test users added to appropriate Entra groups

## Test Cases

### 1. New User First Login

- [ ] User logs in via Azure AD
- [ ] User is auto-created in database
- [ ] User has correct scopes based on Entra groups
- [ ] `auth_provider` is set to "azure_ad"
- [ ] `scopes_overridden` is False
- [ ] `entra_groups` field populated
- [ ] User can access resources matching their scopes
- [ ] User cannot access resources outside their scopes

### 2. Existing User Subsequent Login

- [ ] User logs in again
- [ ] Scopes remain unchanged (database is source of truth)
- [ ] `last_login` timestamp updated
- [ ] Session works correctly
- [ ] Access control unchanged from first login

### 3. Admin Scope Override

- [ ] Run: `python -m cli user show <email>`
- [ ] Verify user information displays correctly
- [ ] Run: `python -m cli user add-scope <email> "templates:write"`
- [ ] Scope added successfully
- [ ] `scopes_overridden` flag set to True
- [ ] User logs in again
- [ ] New scope is active
- [ ] Can now access previously restricted resource

### 4. Scope Reset to Groups

- [ ] User has overridden scopes
- [ ] Run: `python -m cli user reset-to-groups <email>`
- [ ] Scopes reset to match Entra groups
- [ ] `scopes_overridden` flag set to False
- [ ] User logs in
- [ ] Access matches group-based scopes

### 5. Multi-Group User

- [ ] User is member of multiple Entra groups
- [ ] User logs in
- [ ] Has union of scopes from all groups
- [ ] Can access resources from any group

### 6. User with No Matching Groups

- [ ] User in Entra but not in any mapped groups
- [ ] User logs in
- [ ] Gets default scopes only
- [ ] Limited access as expected

### 7. Admin User

- [ ] User in "slidefactory-admins" group
- [ ] User logs in
- [ ] Has wildcard scope "*"
- [ ] Can access all resources

### 8. Inactive User

- [ ] Run: `python -m cli user deactivate <email>`
- [ ] User attempts to log in
- [ ] Login rejected with 403 error
- [ ] Run: `python -m cli user activate <email>`
- [ ] User can log in again

### 9. CLI Commands

- [ ] `python -m cli user list` - shows all users
- [ ] `python -m cli user list --show-groups` - shows Entra groups
- [ ] `python -m cli user list --show-scopes` - shows all scopes
- [ ] `python -m cli user show <email>` - shows detailed info
- [ ] `python -m cli user add-scope <email> <scope>` - adds scope
- [ ] `python -m cli user remove-scope <email> <scope>` - removes scope
- [ ] `python -m cli user set-scopes <email> "scope1,scope2"` - replaces scopes
- [ ] `python -m cli user list-groups` - shows group mappings

### 10. Error Handling

- [ ] Invalid Entra token handled gracefully
- [ ] Missing email in token handled
- [ ] Database connection errors handled
- [ ] CLI with invalid user email shows error

## Performance Testing

- [ ] First login completes in < 2 seconds
- [ ] Subsequent logins complete in < 1 second
- [ ] CLI commands respond quickly
- [ ] No N+1 query issues

## Security Testing

- [ ] Session cookies are httpOnly
- [ ] Session cookies are secure (in HTTPS mode)
- [ ] Inactive users cannot log in
- [ ] Scope checks enforce permissions correctly
- [ ] No scope bypass vulnerabilities

## Regression Testing

- [ ] Existing local users still work
- [ ] API key authentication still works
- [ ] Existing sessions still valid
- [ ] Legacy attributes still supported (if needed)

Verification: - Use checklist during testing - Mark items as complete - Document any issues found


Phase 4.5: Configuration Users Migration

Goal: Migrate existing users from config.py to database

Step 4.5.1: Create Migration Script

File: scripts/migrate_config_users_to_database.py (NEW FILE)

Action: Create migration utility

"""
Migrate users from config.py ALLOWED_USERS to database.

This script imports users from the hardcoded config file into the database,
converting legacy attributes to scope-based permissions.
"""

import sys
from datetime import datetime
from typing import Dict, List
import bcrypt

from app.util.database import SessionLocal
from app.auth.users.models import User


def convert_attributes_to_scopes(attributes: dict) -> List[str]:
    """
    Convert legacy attributes format to scope-based format.

    Args:
        attributes: Legacy attributes dict with workflows, checks, prompts, etc.

    Returns:
        List of scopes
    """
    scopes = []

    # Convert workflow access
    for workflow in attributes.get('workflows', []):
        scopes.append(f"workflows:{workflow}:read")
        scopes.append(f"workflows:{workflow}:execute")

    # Convert resource permissions
    resource_map = {
        'checks': 'checks',
        'prompts': 'prompts',
        'contexts': 'contexts',
        'users': 'users',
        'files': 'templates',
        'templates': 'templates'
    }

    for attr_key, scope_resource in resource_map.items():
        perms = attributes.get(attr_key, [])
        if 'view' in perms:
            scopes.append(f"{scope_resource}:read")
        if 'admin' in perms:
            scopes.append(f"{scope_resource}:write")
            scopes.append(f"{scope_resource}:delete")

    # Add common permissions based on workflows
    if attributes.get('workflows'):
        # If user has workflows, they probably generate presentations
        if 'presentations:generate' not in scopes:
            scopes.append('presentations:generate')
        if 'presentations:read' not in scopes:
            scopes.append('presentations:read')
        if 'results:read' not in scopes:
            scopes.append('results:read')

    return sorted(list(set(scopes)))


def migrate_config_users(dry_run: bool = True) -> Dict[str, str]:
    """
    Migrate users from config.py to database.

    Args:
        dry_run: If True, don't commit changes

    Returns:
        Dict mapping email to status (created, updated, skipped, error)
    """
    # Import config (this will fail if ALLOWED_USERS is already removed)
    try:
        from app.config import ALLOWED_USERS
    except ImportError:
        print("ERROR: Cannot import ALLOWED_USERS from app.config")
        print("Either config has already been cleaned up, or there's an import error")
        sys.exit(1)

    if not ALLOWED_USERS:
        print("No users found in ALLOWED_USERS")
        return {}

    db = SessionLocal()
    results = {}

    try:
        print(f"\n{'='*60}")
        print(f"Migrating {len(ALLOWED_USERS)} users from config.py")
        print(f"Dry Run: {dry_run}")
        print(f"{'='*60}\n")

        for email, user_config in ALLOWED_USERS.items():
            try:
                # Check if user already exists
                existing = db.query(User).filter(User.email == email).first()

                if existing:
                    print(f"⚠  SKIP: {email} (already exists in database)")
                    results[email] = "skipped"
                    continue

                # Convert attributes to scopes
                attributes = user_config.get('attributes', {})
                scopes = convert_attributes_to_scopes(attributes)

                # Generate a random password (will be reset by admin)
                temp_password = f"RESET_ME_{datetime.utcnow().timestamp()}"
                password_hash = bcrypt.hashpw(temp_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')

                # Create user
                user = User(
                    id=user_config.get('id') if user_config.get('id', 0) > 0 else None,  # Preserve positive IDs
                    email=email,
                    name=user_config.get('name', email.split('@')[0]),
                    password=password_hash,
                    scopes=scopes,
                    auth_provider="local",
                    attributes=attributes,  # Preserve for reference
                    scopes_overridden=False,
                    active=True,
                    created_at=datetime.utcnow()
                )

                if dry_run:
                    print(f"✓  WOULD CREATE: {email}")
                    print(f"   Name: {user.name}")
                    print(f"   Scopes ({len(scopes)}): {', '.join(scopes[:5])}{'...' if len(scopes) > 5 else ''}")
                    results[email] = "would_create"
                else:
                    db.add(user)
                    db.flush()  # Get the ID without committing
                    print(f"✓  CREATED: {email} (ID: {user.id})")
                    print(f"   Name: {user.name}")
                    print(f"   Scopes ({len(scopes)}): {', '.join(scopes[:5])}{'...' if len(scopes) > 5 else ''}")
                    results[email] = "created"

            except Exception as e:
                print(f"✗  ERROR: {email} - {str(e)}")
                results[email] = f"error: {str(e)}"
                if not dry_run:
                    db.rollback()

        if not dry_run:
            db.commit()
            print(f"\n{'='*60}")
            print("✓ Migration completed and committed to database")
            print(f"{'='*60}\n")
        else:
            print(f"\n{'='*60}")
            print("This was a DRY RUN - no changes made")
            print("Run with --execute to apply changes")
            print(f"{'='*60}\n")

    finally:
        db.close()

    return results


def print_summary(results: Dict[str, str]):
    """Print migration summary."""
    from collections import Counter

    counts = Counter(results.values())

    print("\nMigration Summary:")
    print(f"  Created:      {counts.get('created', 0)}")
    print(f"  Would Create: {counts.get('would_create', 0)}")
    print(f"  Skipped:      {counts.get('skipped', 0)}")
    print(f"  Errors:       {sum(1 for v in results.values() if v.startswith('error'))}")
    print()


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description="Migrate users from config.py to database")
    parser.add_argument("--execute", action="store_true", help="Actually perform migration (default is dry-run)")
    args = parser.parse_args()

    results = migrate_config_users(dry_run=not args.execute)
    print_summary(results)

    if not args.execute:
        print("To execute migration, run: python scripts/migrate_config_users_to_database.py --execute")

Verification: - Script imports correctly - Conversion logic matches Phase 1 from original recommendation - Dry-run mode available - Error handling present

Step 4.5.2: Test Migration (Dry Run)

Action: Run migration in dry-run mode

# Create scripts directory if needed
mkdir -p scripts

# Run dry-run
python scripts/migrate_config_users_to_database.py

# Review output - verify conversion looks correct

Expected Output:

============================================================
Migrating 3 users from config.py
Dry Run: True
============================================================

✓  WOULD CREATE: admin@example.com
   Name: Admin User
   Scopes (5): checks:read, contexts:read, presentations:generate...
✓  WOULD CREATE: user@example.com
   Name: User
   Scopes (8): presentations:generate, results:read, templates:esg2:read...

============================================================
This was a DRY RUN - no changes made
Run with --execute to apply changes
============================================================

Migration Summary:
  Created:      0
  Would Create: 2
  Skipped:      0
  Errors:       0

Step 4.5.3: Execute Migration

Action: Actually migrate users

# Execute migration
python scripts/migrate_config_users_to_database.py --execute

# Verify users created
python -m cli user list --show-scopes

# Test login with migrated user
# (will need to reset password first)
python -m cli user change-password admin@example.com

Expected Outcome: - All config users migrated to database - Scopes correctly converted - Users can log in after password reset

Step 4.5.4: Update .gitignore

File: .gitignore

Action: Ensure migration script outputs aren't committed

# Add to .gitignore if not present
echo "scripts/*.log" >> .gitignore
echo "scripts/migration_*.json" >> .gitignore

Phase 5: Documentation

Step 5.1: Update CLAUDE.md

File: CLAUDE.md

Action: Add section on Entra authentication

Add after the existing "Authentication" section:

### Entra (Azure AD) Authentication with JIT Provisioning

**Architecture**: IT-managed group mappings with admin overrides

**Key Components**:
- `app/auth/entra_mappings.py`: Group-to-scope mappings (IT manages)
- `app/auth/provisioning.py`: JIT user creation logic
- `app/auth/azure_router.py`: OAuth callback with provisioning
- `cli/commands/user.py`: Admin tools for user management

**First Login Flow**:
1. User authenticates via Azure AD
2. System resolves Entra group membership
3. User auto-created with group-based scopes
4. Session created with scopes from database

**Subsequent Logins**:
1. User authenticates via Azure AD
2. System loads existing user from database
3. Scopes loaded from database (NOT from groups)
4. Session created with database scopes

**Admin Override**:
```bash
# Add scope to user
python -m cli user add-scope user@example.com "templates:write"

# Remove scope from user
python -m cli user remove-scope user@example.com "workflows:esg3:execute"

# Replace all scopes
python -m cli user set-scopes user@example.com "presentations:read,results:read"

# Reset to Entra group defaults
python -m cli user reset-to-groups user@example.com

# List users
python -m cli user list --show-groups --show-scopes

Modifying Group Mappings (IT only): 1. Edit app/auth/entra_mappings.py 2. Add/modify entries in ENTRA_GROUP_SCOPES dict 3. Restart application 4. New mappings apply to NEW users only 5. Existing users unaffected (use CLI to update)

Important Notes: - Group mappings apply ONLY on first login - Database is source of truth for runtime permissions - Admins can override any user's scopes - Changes take effect on next login - scopes_overridden flag tracks manual changes

### Step 5.2: Create IT Guide

**File**: `docs/IT_GUIDE_ENTRA_SETUP.md` (NEW FILE)

**Action**: Create guide for IT team

```markdown
# IT Guide: Entra Integration Setup

## Overview

S5 Slidefactory uses Azure AD (Entra) for authentication with Just-in-Time (JIT) user provisioning.

**Your Role**: Define which Entra security groups get which application permissions.

## Configuration File

**Location**: `app/auth/entra_mappings.py`

This file maps Entra security group names to application scopes.

### Example Configuration

```python
ENTRA_GROUP_SCOPES = {
    "slidefactory-admins": [
        "*"  # Full access
    ],

    "slidefactory-esg-team": [
        "workflows:esg2:read",
        "workflows:esg2:execute",
        "templates:esg2:read",
        "presentations:generate",
        "presentations:read",
        "results:read"
    ],

    "slidefactory-viewers": [
        "presentations:read",
        "results:read"
    ]
}

How to Add a New Group Mapping

  1. Identify the Entra security group name
  2. Must match exactly as it appears in Azure AD

  3. Determine required scopes

  4. See "Available Scopes" section below

  5. Add entry to ENTRA_GROUP_SCOPES:

    "your-group-name": [
        "scope1",
        "scope2"
    ]
    

  6. Test in development

  7. Add test user to group
  8. Have them log in
  9. Verify they have correct access

  10. Deploy to production

  11. Merge changes to main branch
  12. CI/CD will redeploy automatically

  13. Important: Existing users are NOT affected

  14. Only NEW users get new scopes on first login
  15. To update existing users, contact Slidefactory admins

Available Scopes

Administrative

  • * - Full access (use sparingly!)

Workflows

  • workflows:read - View all workflows
  • workflows:{workflow_id}:read - View specific workflow
  • workflows:{workflow_id}:execute - Execute specific workflow

Templates

  • templates:read - Read all templates
  • templates:write - Create/update all templates
  • templates:delete - Delete any template
  • templates:{workflow_id}:read - Read templates for specific workflow
  • templates:{workflow_id}:write - Manage templates for specific workflow

Presentations

  • presentations:read - View presentations
  • presentations:generate - Generate new presentations

Results

  • results:read - View results
  • results:write - Create/update results
  • results:delete - Delete results

Contexts

  • contexts:read - View documents/contexts
  • contexts:write - Manage documents/contexts

Common Patterns

Pattern: Viewer (Read-Only)

"group-name": [
    "presentations:read",
    "results:read"
]

Pattern: Workflow Team Member

"esg-team": [
    "workflows:esg2:read",
    "workflows:esg2:execute",
    "templates:esg2:read",
    "presentations:generate",
    "presentations:read",
    "results:read"
]

Pattern: Workflow Administrator

"esg-admins": [
    "workflows:esg2:read",
    "workflows:esg2:execute",
    "templates:esg2:write",
    "templates:esg2:delete",
    "presentations:generate",
    "presentations:read",
    "results:write",
    "contexts:write"
]

Pattern: Platform Administrator

"platform-admins": [
    "*"
]

Testing Your Changes

  1. Add test user to new group in Azure AD
  2. Have test user log in to Slidefactory
  3. Verify user created (admins can check via CLI)
  4. Test access:
  5. User should access permitted resources
  6. User should be blocked from restricted resources

Troubleshooting

User not getting expected scopes

Possible causes: 1. Group name mismatch (case-sensitive!) 2. User not actually in group (check Azure AD) 3. Azure app not configured to send group claims 4. User already exists (change won't apply)

Solutions: 1. Verify group name spelling in config 2. Check user's group membership in Azure AD 3. Check Azure app registration group claims config 4. Contact Slidefactory admin to update existing user

User in multiple groups

Users inherit scopes from all their groups (union).

Example: - User in "viewers" → gets presentations:read - User also in "esg-team" → gets presentations:read + esg scopes - Total: Combined scopes from both groups

Updating Existing Users

Group mappings only apply to NEW users on first login.

To update existing users: 1. Contact Slidefactory administrators 2. Provide user email and desired scopes 3. Admin will update via CLI:

python -m cli user add-scope user@example.com "new:scope"

Support

Questions? Contact Slidefactory development team.

Phase 5.5: Configuration Cleanup

Goal: Remove hardcoded users from config, keep emergency admin only

Prerequisites: Phase 4.5 completed (users migrated to database)

Step 5.5.1: Verify Migration Complete

Action: Confirm all users migrated successfully

# Check migration status
python scripts/migrate_config_users_to_database.py

# Should show all users as "skipped" (already exist)

# Compare counts
python -c "from app.config import ALLOWED_USERS; print(f'Config users: {len(ALLOWED_USERS)}')"
python -m cli user list --provider local | tail -1

Expected: Number of local users in DB ≥ number in config

Step 5.5.2: Add Emergency Admin Environment Variables

File: .env

Action: Add emergency admin credentials

# Add to .env file
EMERGENCY_ADMIN_EMAIL=admin@yourdomain.com
EMERGENCY_ADMIN_PASSWORD=your-secure-password-here

# Or set in environment
export EMERGENCY_ADMIN_EMAIL=admin@yourdomain.com
export EMERGENCY_ADMIN_PASSWORD=your-secure-password-here

Important: - Use strong password - Different from any database user - For emergency access only - Not stored in database

Step 5.5.3: Update Configuration

File: app/config.py

Action: Remove ALLOWED_USERS, add emergency admin

Find the ALLOWED_USERS dictionary (around line 38-92):

ALLOWED_USERS = {
    "user@example.com": {
        # ... lots of users ...
    }
}

Replace with:

# Emergency admin for bootstrapping (optional)
# Only used if user is not found in database
# Should be set via environment variables, not committed to git
EMERGENCY_ADMIN_EMAIL = os.environ.get("EMERGENCY_ADMIN_EMAIL")
EMERGENCY_ADMIN_PASSWORD = os.environ.get("EMERGENCY_ADMIN_PASSWORD")

# ALLOWED_USERS removed - all users now in database
# Use CLI to manage users: python -m cli user create-local <email>

Verification: - ALLOWED_USERS completely removed - Emergency admin env vars defined - Comments explain new approach

Step 5.5.4: Update Auth Router for Emergency Admin

File: app/auth/router.py

Action: Add emergency admin fallback to login

Find the login function (typically handles password verification)

Add emergency admin check BEFORE database lookup:

@router.post("/auth/login")
async def login(
    username: str = Form(...),
    password: str = Form(...),
    db: Session = Depends(get_db)
):
    """
    Login endpoint with emergency admin fallback.
    """
    from app.config import EMERGENCY_ADMIN_EMAIL, EMERGENCY_ADMIN_PASSWORD

    # Check emergency admin first (if configured)
    if (EMERGENCY_ADMIN_EMAIL and EMERGENCY_ADMIN_PASSWORD and
        username == EMERGENCY_ADMIN_EMAIL and
        password == EMERGENCY_ADMIN_PASSWORD):

        # Create session with full access
        session_data = SessionData(
            id=-1,  # Special ID for emergency admin
            email=username,
            name="Emergency Admin",
            scopes=["*"],  # Full access
            attributes={}
        )

        session_id = str(uuid.uuid4())
        await backend.create(session_id, session_data)

        response = RedirectResponse(url="/", status_code=302)
        response.set_cookie(
            key="session_id",
            value=session_id,
            httponly=True,
            secure=settings.ENFORCE_HTTPS,
            samesite="lax"
        )

        logger.warning(f"Emergency admin login: {username}")
        return response

    # Otherwise, check database (existing code continues below)
    user = db.query(User).filter(User.email == username).first()

    if not user:
        raise HTTPException(401, "Invalid credentials")

    # ... rest of existing login logic ...

Verification: - Emergency admin checked first - Falls through to database lookup - Warning logged for emergency admin use - Regular flow unchanged

Step 5.5.5: Test Without Config Users

Action: Verify system works with database-only users

# Restart application
./start.sh

# Test local user login (migrated from config)
# Navigate to /auth/login
# Enter credentials for migrated user

# Test emergency admin (if configured)
# Login with EMERGENCY_ADMIN_EMAIL/PASSWORD

# Test Entra login
# Navigate to /auth/azure/login

# Verify all auth methods work

Expected: - Local users (from database) can log in - Emergency admin can log in (if configured) - Entra users can log in - No errors about missing ALLOWED_USERS

Step 5.5.6: Update Documentation

File: README.md

Action: Document new user management approach

Add section on user management:

## User Management

### Authentication Methods

S5 Slidefactory supports two authentication methods:

1. **Azure AD (Entra)** - Recommended for most users
   - Auto-provisioning on first login
   - Permissions based on Entra security groups
   - No password management required

2. **Local Users** - For admin/service accounts
   - Created via CLI
   - Password-based authentication
   - Used when Entra access not available

### Creating Local Users

```bash
# Create admin user
python -m cli user create-local admin@example.com --name "Admin" --preset admin

# Create workflow-specific user
python -m cli user create-local user@example.com --name "User" --preset workflow-user --workflow esg2

# Create with custom scopes
python -m cli user create-local user@example.com --name "User" --scopes "presentations:read,results:read"

Managing User Permissions

# View all users
python -m cli user list --show-scopes

# Add scope to user
python -m cli user add-scope user@example.com "templates:write"

# Remove scope
python -m cli user remove-scope user@example.com "workflows:esg3:execute"

# Change password (local users only)
python -m cli user change-password user@example.com

Emergency Admin

For emergency access, set environment variables:

export EMERGENCY_ADMIN_EMAIL=admin@yourdomain.com
export EMERGENCY_ADMIN_PASSWORD=secure-password

Use only for bootstrapping or emergency recovery.

Verification: - README updated with user management info - CLI commands documented - Emergency admin explained


Phase 6: Deployment

Step 6.1: Pre-Deployment Checklist

## Pre-Deployment Checklist

### Code Changes
- [ ] All files created/modified as per guide
- [ ] Code passes linting: `flake8 app/ cli/`
- [ ] Unit tests pass: `pytest tests/unit/auth/`
- [ ] Integration tests pass: `pytest tests/integration/`
- [ ] No merge conflicts

### Database
- [ ] Migration created: `alembic revision --autogenerate`
- [ ] Migration reviewed manually
- [ ] Migration tested on development database
- [ ] Rollback tested: `alembic downgrade -1`

### Configuration
- [ ] `app/auth/entra_mappings.py` created
- [ ] Group mappings configured correctly
- [ ] Group names match Azure AD exactly
- [ ] Default fallback scopes defined

### Documentation
- [ ] CLAUDE.md updated
- [ ] IT guide created
- [ ] Manual testing checklist created
- [ ] This report completed

### Azure AD Configuration
- [ ] App registration includes group claims
- [ ] Test users assigned to groups
- [ ] OAuth redirect URI configured
- [ ] Client ID/secret in environment variables

### Environment Variables
- [ ] `AZURE_CLIENT_ID` set
- [ ] `AZURE_CLIENT_SECRET` set
- [ ] `AZURE_TENANT_ID` set
- [ ] `DATABASE_URL` correct
- [ ] All existing env vars preserved

Step 6.2: Deployment Steps

Development Environment

# 1. Apply database migration
alembic upgrade head

# 2. Verify migration
alembic current
psql $DATABASE_URL -c "\d app_users"

# 3. Restart application
./start.sh

# 4. Test with browser
# - Navigate to /auth/azure/login
# - Complete OAuth flow
# - Verify user created

# 5. Test CLI commands
python -m cli user list
python -m cli user show <your-email>

Production Environment

# 1. Create database backup
pg_dump $DATABASE_URL > backup_before_entra_jit_$(date +%Y%m%d).sql

# 2. Apply migration (in maintenance window if preferred)
alembic upgrade head

# 3. Verify migration
alembic current

# 4. Deploy code
git checkout main
git pull origin main
docker-compose build
docker-compose up -d

# 5. Verify deployment
curl -I https://your-domain.com/health

# 6. Monitor logs
docker-compose logs -f web

# 7. Test authentication
# - Have test user log in
# - Verify user created correctly
# - Test access controls

# 8. Notify IT team
# - Send IT_GUIDE_ENTRA_SETUP.md
# - Provide support contact

Step 6.3: Rollback Plan

If issues occur after deployment:

# 1. Rollback database migration
alembic downgrade -1

# 2. Verify rollback
alembic current

# 3. Deploy previous code version
git checkout <previous-commit-hash>
docker-compose build
docker-compose up -d

# 4. Restore database from backup (if needed)
psql $DATABASE_URL < backup_before_entra_jit_YYYYMMDD.sql

# 5. Verify system operational
# - Test existing user logins
# - Test API key authentication
# - Check core functionality

Step 6.4: Post-Deployment Validation

# Run through manual testing checklist
cat REPORTS/2025-10-30_entra_jit_manual_testing_checklist.md

# Test scenarios:
# 1. New user first login
# 2. Existing user subsequent login
# 3. Admin user with "*" scope
# 4. User with multiple groups
# 5. User deactivation
# 6. CLI commands

# Monitor for errors
tail -f logs/application.log | grep -i "error\|exception"

# Check database
psql $DATABASE_URL -c "SELECT email, auth_provider, array_length(scopes, 1) as scope_count, scopes_overridden FROM app_users WHERE auth_provider = 'azure_ad';"

Summary

Files Created

  1. app/auth/entra_mappings.py - Group-to-scope configuration (IT-managed)
  2. app/auth/provisioning.py - JIT provisioning logic for Entra users
  3. cli/commands/user.py - Admin CLI commands (list, create, manage users)
  4. scripts/migrate_config_users_to_database.py - Migration script for config users
  5. tests/unit/auth/test_provisioning.py - Unit tests
  6. tests/integration/test_entra_auth_flow.py - Integration tests
  7. REPORTS/2025-10-30_entra_jit_manual_testing_checklist.md - Testing checklist
  8. docs/IT_GUIDE_ENTRA_SETUP.md - IT team guide for Entra groups
  9. alembic/versions/YYYYMMDD_add_entra_jit_provisioning_fields.py - Database migration

Files Modified

  1. app/auth/users/models.py - Add new columns (scopes, auth_provider, etc.)
  2. app/auth/auth.py - Add scopes to SessionData with helper methods
  3. app/auth/azure_router.py - Integrate JIT provisioning in callback
  4. app/auth/router.py - Add emergency admin fallback to login
  5. app/config.py - Remove ALLOWED_USERS, add emergency admin env vars
  6. cli/__main__.py - Register user command group
  7. CLAUDE.md - Add comprehensive Entra auth documentation
  8. README.md - Add user management section
  9. requirements.txt - Add dependencies (click, tabulate, bcrypt)
  10. .gitignore - Add migration script outputs

Database Changes

  • New columns: scopes (JSONB), auth_provider (varchar), entra_groups (JSONB), scopes_overridden (boolean), last_login (timestamp), created_at (timestamp)
  • New indexes:
  • idx_app_users_scopes (GIN index for JSONB queries)
  • idx_app_users_auth_provider (filter by auth type)
  • idx_app_users_active (partial index for active users)

Key Behaviors

Dual Authentication: - ✅ Entra users: Auto-provisioned on first login via JIT - ✅ Local users: Created via CLI with password - ✅ Both types use identical scope-based authorization - ✅ Both stored in same database table

Permission Management: - ✅ IT controls Entra group → scope mappings via config file - ✅ Admins control individual user scopes via CLI - ✅ Entra: First login creates user with group scopes - ✅ Subsequent logins: Scopes always loaded from database - ✅ Admin overrides persist across logins - ✅ Reset option to restore Entra group defaults - ✅ Database is runtime source of truth

Migration: - ✅ Existing config users migrated to database - ✅ Attributes converted to scopes - ✅ Config file cleaned up (ALLOWED_USERS removed) - ✅ Emergency admin via environment variables only

Timeline Estimate

  • Phase 1 (Database Schema): 2 hours
  • Phase 2 (Auth Layer - JIT): 3 hours
  • Phase 3 (CLI - Scope Management): 3 hours
  • Phase 3.5 (CLI - Local Users): 2 hours
  • Phase 4 (Testing): 4 hours
  • Phase 4.5 (Migration Script): 2 hours
  • Phase 5 (Documentation): 2 hours
  • Phase 5.5 (Config Cleanup): 1 hour
  • Phase 6 (Deployment): 2 hours
  • Total: 21 hours (~2.5 days)

Risk Assessment

  • Risk Level: Medium
  • Mitigation: Backward compatible, comprehensive testing, rollback plan
  • Critical Path: Database migration, Azure callback integration

Next Steps

  1. Review this guide with team
  2. Approve implementation plan
  3. Assign developer to execute
  4. Schedule deployment window
  5. Notify IT team of upcoming changes
  6. Execute Phase 1 (database schema)
  7. Continue through phases sequentially
  8. Test thoroughly before production
  9. Deploy to production
  10. Monitor and support post-deployment

Appendix: Common CLI Commands Reference

User Listing and Information

# List all users
python -m cli user list

# List with details
python -m cli user list --show-groups --show-scopes

# Filter by authentication provider
python -m cli user list --provider local      # Local users only
python -m cli user list --provider azure_ad   # Entra users only

# Show detailed information for specific user
python -m cli user show user@example.com

Local User Management

# Create local user with admin access
python -m cli user create-local admin@example.com --name "Admin User" --preset admin

# Create workflow-specific user
python -m cli user create-local esg.user@example.com \
  --name "ESG Team Member" \
  --preset workflow-user \
  --workflow esg2

# Create workflow admin
python -m cli user create-local esg.admin@example.com \
  --name "ESG Admin" \
  --preset workflow-admin \
  --workflow esg2

# Create user with custom scopes
python -m cli user create-local custom@example.com \
  --name "Custom User" \
  --scopes "presentations:read,results:read,workflows:read"

# Create API-only user (for automation)
python -m cli user create-local api@example.com \
  --name "API User" \
  --preset api-user

# Change password for local user
python -m cli user change-password user@example.com

Scope Management (Both Local and Entra Users)

# Add scope to any user
python -m cli user add-scope user@example.com "templates:write"

# Remove scope from any user
python -m cli user remove-scope user@example.com "workflows:esg3:execute"

# Replace all scopes for a user
python -m cli user set-scopes user@example.com "presentations:read,results:read"

# Reset Entra user to group defaults (Entra users only)
python -m cli user reset-to-groups user@example.com

User Activation/Deactivation

# Deactivate user (prevents login)
python -m cli user deactivate user@example.com

# Reactivate user
python -m cli user activate user@example.com

Entra Group Management

# List configured Entra group mappings
python -m cli user list-groups

Migration

# Migrate users from config.py to database (dry-run)
python scripts/migrate_config_users_to_database.py

# Execute migration
python scripts/migrate_config_users_to_database.py --execute

Common Workflows

Scenario: New workflow team onboarding

# Option 1: Entra users (recommended)
# 1. IT adds users to Entra group "slidefactory-newworkflow-team"
# 2. IT adds group mapping to app/auth/entra_mappings.py
# 3. Users log in via Azure AD - auto-provisioned

# Option 2: Local users
# Create team members
python -m cli user create-local member1@example.com \
  --name "Team Member 1" \
  --preset workflow-user \
  --workflow newworkflow

python -m cli user create-local member2@example.com \
  --name "Team Member 2" \
  --preset workflow-user \
  --workflow newworkflow

# Create workflow admin
python -m cli user create-local admin@example.com \
  --name "Workflow Admin" \
  --preset workflow-admin \
  --workflow newworkflow

Scenario: Grant temporary access

# Add temporary permission
python -m cli user add-scope contractor@example.com "workflows:project:read"
python -m cli user add-scope contractor@example.com "workflows:project:execute"

# Later, revoke access
python -m cli user remove-scope contractor@example.com "workflows:project:read"
python -m cli user remove-scope contractor@example.com "workflows:project:execute"

# Or deactivate entirely
python -m cli user deactivate contractor@example.com

Scenario: Promote user to admin

# Replace their scopes with admin wildcard
python -m cli user set-scopes user@example.com "*"

Scenario: Fix Entra user with wrong permissions

# Option 1: Override specific scopes
python -m cli user add-scope user@example.com "missing:scope"

# Option 2: Reset to current Entra groups
python -m cli user reset-to-groups user@example.com

# Option 3: Completely override (no longer follows groups)
python -m cli user set-scopes user@example.com "custom,scopes,here"

End of Implementation Guide