Add Entra ID authentication with role-based access control

Implement Microsoft Entra ID (Azure AD) authentication with user and admin roles.

Features:
- OAuth 2.0 authentication flow using MSAL
- User and admin role-based access control
- Tenant ownership and access management
- Server-side session storage
- Protected routes with authentication decorators

Authentication:
- Users authenticate via Microsoft organizational accounts
- Admin role automatically assigned to DEFAULT_ADMIN_EMAIL
- User profiles synced from Microsoft Graph API

Access Control:
- Admins: Full access to all tenants and server settings
- Users: Can create and manage their own tenants only
- Tenant association: Auto-created when user adds products
- API endpoints remain open for AR application access

Database Changes:
- New users table with email, azure_oid, name, and role
- New user_tenants table for many-to-many tenant ownership
- Case-insensitive admin email comparison

Configuration:
- Environment-based config via .env file
- Azure AD credentials (client ID, secret, tenant ID)
- Configurable redirect URI and admin email
- Updated to Flask 2.2.5 for compatibility
This commit is contained in:
2025-10-20 19:36:52 -04:00
parent 32ce69057b
commit 795068c015
17 changed files with 775 additions and 15 deletions

253
src/app/models/user.py Normal file
View File

@@ -0,0 +1,253 @@
"""User model for authentication and authorization"""
import sqlite3
from datetime import datetime
from typing import Optional, List, Dict
from .base import get_db
class UserModel:
"""Model for managing users and their roles"""
ROLE_USER = 'user'
ROLE_ADMIN = 'admin'
@staticmethod
def create_table():
"""Create the users and user_tenants tables if they don't exist"""
with get_db() as conn:
cursor = conn.cursor()
# Users table
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
azure_oid TEXT UNIQUE,
name TEXT,
role TEXT NOT NULL DEFAULT 'user',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# User-Tenant association table (users can own multiple tenants)
cursor.execute('''
CREATE TABLE IF NOT EXISTS user_tenants (
user_id INTEGER NOT NULL,
tenant_id TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, tenant_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
)
''')
conn.commit()
@staticmethod
def get_by_email(email: str) -> Optional[Dict]:
"""Get user by email address"""
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
'SELECT id, email, azure_oid, name, role, created_at, updated_at FROM users WHERE email = ?',
(email,)
)
row = cursor.fetchone()
if row:
return {
'id': row[0],
'email': row[1],
'azure_oid': row[2],
'name': row[3],
'role': row[4],
'created_at': row[5],
'updated_at': row[6]
}
return None
@staticmethod
def get_by_id(user_id: int) -> Optional[Dict]:
"""Get user by ID"""
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
'SELECT id, email, azure_oid, name, role, created_at, updated_at FROM users WHERE id = ?',
(user_id,)
)
row = cursor.fetchone()
if row:
return {
'id': row[0],
'email': row[1],
'azure_oid': row[2],
'name': row[3],
'role': row[4],
'created_at': row[5],
'updated_at': row[6]
}
return None
@staticmethod
def get_by_azure_oid(azure_oid: str) -> Optional[Dict]:
"""Get user by Azure Object ID"""
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
'SELECT id, email, azure_oid, name, role, created_at, updated_at FROM users WHERE azure_oid = ?',
(azure_oid,)
)
row = cursor.fetchone()
if row:
return {
'id': row[0],
'email': row[1],
'azure_oid': row[2],
'name': row[3],
'role': row[4],
'created_at': row[5],
'updated_at': row[6]
}
return None
@staticmethod
def create(email: str, name: str = None, azure_oid: str = None, role: str = ROLE_USER) -> Dict:
"""Create a new user"""
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
'''INSERT INTO users (email, azure_oid, name, role)
VALUES (?, ?, ?, ?)''',
(email, azure_oid, name, role)
)
conn.commit()
user_id = cursor.lastrowid
return UserModel.get_by_id(user_id)
@staticmethod
def update(user_id: int, **kwargs) -> Optional[Dict]:
"""Update user information"""
allowed_fields = {'email', 'azure_oid', 'name', 'role'}
update_fields = {k: v for k, v in kwargs.items() if k in allowed_fields}
if not update_fields:
return UserModel.get_by_id(user_id)
update_fields['updated_at'] = datetime.now().isoformat()
set_clause = ', '.join([f'{k} = ?' for k in update_fields.keys()])
values = list(update_fields.values())
values.append(user_id)
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
f'UPDATE users SET {set_clause} WHERE id = ?',
values
)
conn.commit()
return UserModel.get_by_id(user_id)
@staticmethod
def get_or_create_from_azure(email: str, name: str = None, azure_oid: str = None, default_admin_email: str = None) -> Dict:
"""Get existing user or create new one from Azure AD login"""
# Try to find by Azure OID first
if azure_oid:
user = UserModel.get_by_azure_oid(azure_oid)
if user:
# Update email/name if changed
if user['email'] != email or user['name'] != name:
return UserModel.update(user['id'], email=email, name=name)
return user
# Try to find by email
user = UserModel.get_by_email(email)
if user:
# Update Azure OID if not set
if azure_oid and not user['azure_oid']:
return UserModel.update(user['id'], azure_oid=azure_oid, name=name)
return user
# Create new user
# Case-insensitive comparison for admin email
role = UserModel.ROLE_ADMIN if (default_admin_email and email.lower() == default_admin_email.lower()) else UserModel.ROLE_USER
return UserModel.create(email=email, name=name, azure_oid=azure_oid, role=role)
@staticmethod
def add_tenant(user_id: int, tenant_id: str):
"""Associate a tenant with a user"""
with get_db() as conn:
cursor = conn.cursor()
try:
cursor.execute(
'INSERT INTO user_tenants (user_id, tenant_id) VALUES (?, ?)',
(user_id, tenant_id)
)
conn.commit()
except sqlite3.IntegrityError:
# Already exists, that's fine
pass
@staticmethod
def remove_tenant(user_id: int, tenant_id: str):
"""Remove tenant association from user"""
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
'DELETE FROM user_tenants WHERE user_id = ? AND tenant_id = ?',
(user_id, tenant_id)
)
conn.commit()
@staticmethod
def get_user_tenants(user_id: int) -> List[str]:
"""Get all tenant IDs associated with a user"""
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
'SELECT tenant_id FROM user_tenants WHERE user_id = ?',
(user_id,)
)
return [row[0] for row in cursor.fetchall()]
@staticmethod
def has_access_to_tenant(user_id: int, tenant_id: str) -> bool:
"""Check if user has access to a specific tenant"""
# Admin has access to everything
user = UserModel.get_by_id(user_id)
if user and user['role'] == UserModel.ROLE_ADMIN:
return True
# Check if user owns the tenant
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
'SELECT 1 FROM user_tenants WHERE user_id = ? AND tenant_id = ?',
(user_id, tenant_id)
)
return cursor.fetchone() is not None
@staticmethod
def is_admin(user_id: int) -> bool:
"""Check if user has admin role"""
user = UserModel.get_by_id(user_id)
return user and user['role'] == UserModel.ROLE_ADMIN
@staticmethod
def get_all() -> List[Dict]:
"""Get all users"""
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
'SELECT id, email, azure_oid, name, role, created_at, updated_at FROM users ORDER BY email'
)
rows = cursor.fetchall()
return [{
'id': row[0],
'email': row[1],
'azure_oid': row[2],
'name': row[3],
'role': row[4],
'created_at': row[5],
'updated_at': row[6]
} for row in rows]