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_overriddenflag 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:
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¶
- Identify the Entra security group name
-
Must match exactly as it appears in Azure AD
-
Determine required scopes
-
See "Available Scopes" section below
-
Add entry to
ENTRA_GROUP_SCOPES: -
Test in development
- Add test user to group
- Have them log in
-
Verify they have correct access
-
Deploy to production
- Merge changes to main branch
-
CI/CD will redeploy automatically
-
Important: Existing users are NOT affected
- Only NEW users get new scopes on first login
- To update existing users, contact Slidefactory admins
Available Scopes¶
Administrative¶
*- Full access (use sparingly!)
Workflows¶
workflows:read- View all workflowsworkflows:{workflow_id}:read- View specific workflowworkflows:{workflow_id}:execute- Execute specific workflow
Templates¶
templates:read- Read all templatestemplates:write- Create/update all templatestemplates:delete- Delete any templatetemplates:{workflow_id}:read- Read templates for specific workflowtemplates:{workflow_id}:write- Manage templates for specific workflow
Presentations¶
presentations:read- View presentationspresentations:generate- Generate new presentations
Results¶
results:read- View resultsresults:write- Create/update resultsresults:delete- Delete results
Contexts¶
contexts:read- View documents/contextscontexts:write- Manage documents/contexts
Common Patterns¶
Pattern: Viewer (Read-Only)¶
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¶
Testing Your Changes¶
- Add test user to new group in Azure AD
- Have test user log in to Slidefactory
- Verify user created (admins can check via CLI)
- Test access:
- User should access permitted resources
- 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:
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):
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:
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¶
app/auth/entra_mappings.py- Group-to-scope configuration (IT-managed)app/auth/provisioning.py- JIT provisioning logic for Entra userscli/commands/user.py- Admin CLI commands (list, create, manage users)scripts/migrate_config_users_to_database.py- Migration script for config userstests/unit/auth/test_provisioning.py- Unit teststests/integration/test_entra_auth_flow.py- Integration testsREPORTS/2025-10-30_entra_jit_manual_testing_checklist.md- Testing checklistdocs/IT_GUIDE_ENTRA_SETUP.md- IT team guide for Entra groupsalembic/versions/YYYYMMDD_add_entra_jit_provisioning_fields.py- Database migration
Files Modified¶
app/auth/users/models.py- Add new columns (scopes, auth_provider, etc.)app/auth/auth.py- Add scopes to SessionData with helper methodsapp/auth/azure_router.py- Integrate JIT provisioning in callbackapp/auth/router.py- Add emergency admin fallback to loginapp/config.py- Remove ALLOWED_USERS, add emergency admin env varscli/__main__.py- Register user command groupCLAUDE.md- Add comprehensive Entra auth documentationREADME.md- Add user management sectionrequirements.txt- Add dependencies (click, tabulate, bcrypt).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¶
- Review this guide with team
- Approve implementation plan
- Assign developer to execute
- Schedule deployment window
- Notify IT team of upcoming changes
- Execute Phase 1 (database schema)
- Continue through phases sequentially
- Test thoroughly before production
- Deploy to production
- 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¶
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¶
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