Files

642 lines
24 KiB
Python

# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# Module: models.py
# Description: SQLAlchemy database models for multi-tenant form management.
# Includes Tenant, FormConfig, Submission, and User models.
# ==============================================================================
"""
Database models for Formeleon.
This module defines the core data models using Flask-SQLAlchemy:
- Tenant: Mandant/Client information with public keys and allowed origins
- FormConfig: Dynamic form configurations with JSON structure
- Submission: Form submissions with JSON payload data
- User: Admin users for the management interface
- AuditLog: Tracking of administrative actions
All models support Flask-Migrate for database migrations.
"""
import json
from datetime import datetime, timezone
from typing import Optional, Any
from enum import Enum
from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash, check_password_hash
# Initialize SQLAlchemy without binding to a Flask app yet
# This will be bound in app.py after configuration is loaded
db = SQLAlchemy()
class UserRole(Enum):
"""
Enumeration of user roles for access control.
Roles:
ADMIN: Full access to all tenants and configurations
TENANT_ADMIN: Admin access limited to specific tenant(s)
USER: Standard user with form creation/view permissions
VIEWER: Read-only access to forms and submissions
"""
ADMIN = 'admin'
TENANT_ADMIN = 'tenant_admin'
USER = 'user'
VIEWER = 'viewer'
class SubmissionStatus(Enum):
"""
Enumeration of submission processing statuses.
Statuses:
PENDING: Submission received but not yet processed
PROCESSED: Submission has been processed successfully
FAILED: Submission processing failed
SPAM: Submission flagged as spam (reCAPTCHA failed)
"""
PENDING = 'pending'
PROCESSED = 'processed'
FAILED = 'failed'
SPAM = 'spam'
class TenantStatus(Enum):
"""
Enumeration of tenant statuses.
Statuses:
ACTIVE: Tenant is active and can receive submissions
SUSPENDED: Tenant is temporarily suspended
INACTIVE: Tenant is inactive/disabled
"""
ACTIVE = 'active'
SUSPENDED = 'suspended'
INACTIVE = 'inactive'
# Association table for many-to-many relationship between User and Tenant
user_tenant_association = db.Table(
'user_tenant_association',
db.Column('user_id', db.String(64), db.ForeignKey('user.id'), primary_key=True),
db.Column('tenant_id', db.String(64), db.ForeignKey('tenant.id'), primary_key=True),
db.Column('created_at', db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
)
class Tenant(db.Model):
"""
Tenant model representing a client/mandant in the multi-tenant system.
Each tenant has:
- Unique identifier and name
- Public API key for form submissions
- Allowed origins for CORS/handshake validation
- Status and configuration settings
Attributes:
id: Unique tenant identifier
name: Display name of the tenant
public_key: API key used by client snippets for authentication
allowed_origins: Comma-separated list of allowed origin domains
status: Current status of the tenant
config: Additional JSON configuration
created_at: Timestamp when tenant was created
updated_at: Timestamp when tenant was last updated
"""
__tablename__ = 'tenant'
id = db.Column(db.String(64), primary_key=True, unique=True, nullable=False)
name = db.Column(db.String(255), nullable=False)
public_key = db.Column(db.String(128), unique=True, nullable=False)
allowed_origins = db.Column(db.Text, nullable=False, default='*')
status = db.Column(db.Enum(TenantStatus), nullable=False, default=TenantStatus.ACTIVE)
config = db.Column(db.JSON, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at = db.Column(
db.DateTime,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False
)
# Relationships
forms = db.relationship('FormConfig', backref='tenant', lazy=True, cascade='all, delete-orphan')
submissions = db.relationship('Submission', backref='tenant', lazy=True)
users = db.relationship('User', secondary=user_tenant_association, backref='tenants', lazy=True)
def __init__(self, id: str, name: str, public_key: str, allowed_origins: str = '*',
status: TenantStatus = TenantStatus.ACTIVE, config: Optional[dict] = None):
"""
Initialize a new Tenant instance.
:param id: Unique tenant identifier
:param name: Display name of the tenant
:param public_key: API key for client authentication
:param allowed_origins: Comma-separated allowed origins
:param status: Initial tenant status
:param config: Additional configuration as JSON
"""
self.id = id
self.name = name
self.public_key = public_key
self.allowed_origins = allowed_origins
self.status = status
self.config = config or {}
def get_allowed_origins_list(self) -> list:
"""
Get allowed origins as a list.
:return: List of allowed origin strings
"""
return [origin.strip() for origin in self.allowed_origins.split(',') if origin.strip()]
def is_origin_allowed(self, origin: str) -> bool:
"""
Check if a given origin is allowed for this tenant.
:param origin: Origin to check
:return: True if origin is allowed, False otherwise
"""
allowed = self.get_allowed_origins_list()
if '*' in allowed:
return True
return origin in allowed
def is_active(self) -> bool:
"""
Check if tenant is active.
:return: True if tenant status is ACTIVE, False otherwise
"""
return self.status == TenantStatus.ACTIVE
def to_dict(self) -> dict:
"""
Convert tenant to dictionary for API responses.
:return: Dictionary representation of tenant
"""
return {
'id': self.id,
'name': self.name,
'public_key': self.public_key,
'allowed_origins': self.allowed_origins,
'status': self.status.value,
'config': self.config or {},
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
def __repr__(self) -> str:
return f'<Tenant {self.id} - {self.name}>'
class FormConfig(db.Model):
"""
Form configuration model storing dynamic form definitions.
Each form configuration has:
- Unique identifier
- Reference to tenant
- Name and description
- JSON structure defining form fields and validation
- Status and settings
Attributes:
id: Unique form identifier
tenant_id: Foreign key to tenant
name: Display name of the form
description: Optional description
form_structure: JSON defining form fields, types, and validation
settings: Additional form settings (email notifications, etc.)
is_active: Whether the form is active and accepting submissions
created_at: Timestamp when form was created
updated_at: Timestamp when form was last updated
"""
__tablename__ = 'form_config'
id = db.Column(db.String(64), primary_key=True, unique=True, nullable=False)
tenant_id = db.Column(db.String(64), db.ForeignKey('tenant.id'), nullable=False)
name = db.Column(db.String(255), nullable=False)
description = db.Column(db.Text, nullable=True)
form_structure = db.Column(db.JSON, nullable=False)
settings = db.Column(db.JSON, nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at = db.Column(
db.DateTime,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False
)
# Relationships
submissions = db.relationship('Submission', backref='form_config', lazy=True, cascade='all, delete-orphan')
def __init__(self, id: str, tenant_id: str, name: str, form_structure: dict,
description: Optional[str] = None, settings: Optional[dict] = None,
is_active: bool = True):
"""
Initialize a new FormConfig instance.
:param id: Unique form identifier
:param tenant_id: Tenant this form belongs to
:param name: Display name of the form
:param form_structure: JSON defining form fields and structure
:param description: Optional description
:param settings: Additional form settings
:param is_active: Whether form is active
"""
self.id = id
self.tenant_id = tenant_id
self.name = name
self.description = description
self.form_structure = form_structure
self.settings = settings or {}
self.is_active = is_active
def get_field_definitions(self) -> list:
"""
Get the list of field definitions from form structure.
:return: List of field definition dictionaries
"""
return self.form_structure.get('fields', [])
def get_field_names(self) -> list:
"""
Get list of field names from form structure.
:return: List of field name strings
"""
return [field.get('name') for field in self.get_field_definitions() if field.get('name')]
def to_dict(self) -> dict:
"""
Convert form config to dictionary for API responses.
:return: Dictionary representation of form config
"""
return {
'id': self.id,
'tenant_id': self.tenant_id,
'name': self.name,
'description': self.description,
'form_structure': self.form_structure,
'settings': self.settings or {},
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
def __repr__(self) -> str:
return f'<FormConfig {self.id} - {self.name}>'
class Submission(db.Model):
"""
Submission model storing form submission data.
Each submission has:
- Unique identifier
- Reference to form configuration
- JSON payload with submitted data
- IP hash for privacy-preserving identification
- Status and submission_metadata
Attributes:
id: Unique submission identifier
form_config_id: Foreign key to form configuration
tenant_id: Foreign key to tenant (denormalized for easier queries)
payload: JSON containing submitted form data
ip_hash: Hash of submitter's IP address for rate limiting/duplicate detection
user_agent: User agent string of submitter
referer: Referer header from submission request
recaptcha_score: reCAPTCHA score if validation was performed
recaptcha_passed: Whether reCAPTCHA validation passed
status: Current processing status
submission_metadata: Additional metadata (headers, etc.)
created_at: Timestamp when submission was created
"""
__tablename__ = 'submission'
id = db.Column(db.String(64), primary_key=True, unique=True, nullable=False)
form_config_id = db.Column(db.String(64), db.ForeignKey('form_config.id'), nullable=False)
tenant_id = db.Column(db.String(64), db.ForeignKey('tenant.id'), nullable=False)
payload = db.Column(db.JSON, nullable=False)
ip_hash = db.Column(db.String(64), nullable=True)
user_agent = db.Column(db.Text, nullable=True)
referer = db.Column(db.Text, nullable=True)
recaptcha_score = db.Column(db.Float, nullable=True)
recaptcha_passed = db.Column(db.Boolean, nullable=True)
status = db.Column(db.Enum(SubmissionStatus), nullable=False, default=SubmissionStatus.PENDING)
submission_metadata = db.Column(db.JSON, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
def __init__(self, id: str, form_config_id: str, tenant_id: str, payload: dict,
ip_hash: Optional[str] = None, user_agent: Optional[str] = None,
referer: Optional[str] = None, recaptcha_score: Optional[float] = None,
recaptcha_passed: Optional[bool] = None, submission_metadata: Optional[dict] = None,
status: SubmissionStatus = SubmissionStatus.PENDING):
"""
Initialize a new Submission instance.
:param id: Unique submission identifier
:param form_config_id: Form configuration this submission belongs to
:param tenant_id: Tenant this submission belongs to
:param payload: JSON containing submitted form data
:param ip_hash: Hash of submitter's IP
:param user_agent: User agent string
:param referer: Referer header
:param recaptcha_score: reCAPTCHA score
:param recaptcha_passed: Whether reCAPTCHA passed
:param submission_metadata: Additional submission_metadata
:param status: Initial submission status
"""
self.id = id
self.form_config_id = form_config_id
self.tenant_id = tenant_id
self.payload = payload
self.ip_hash = ip_hash
self.user_agent = user_agent
self.referer = referer
self.recaptcha_score = recaptcha_score
self.recaptcha_passed = recaptcha_passed
self.submission_metadata = submission_metadata or {}
self.status = status
def get_field_value(self, field_name: str) -> Any:
"""
Get the value of a specific field from the payload.
:param field_name: Name of the field to retrieve
:return: Value of the field or None if not found
"""
return self.payload.get(field_name)
def mark_as_processed(self) -> None:
"""
Mark submission as processed.
"""
self.status = SubmissionStatus.PROCESSED
db.session.commit()
def mark_as_spam(self) -> None:
"""
Mark submission as spam.
"""
self.status = SubmissionStatus.SPAM
db.session.commit()
def mark_as_failed(self) -> None:
"""
Mark submission as failed.
"""
self.status = SubmissionStatus.FAILED
db.session.commit()
def to_dict(self) -> dict:
"""
Convert submission to dictionary for API responses.
:return: Dictionary representation of submission
"""
return {
'id': self.id,
'form_config_id': self.form_config_id,
'tenant_id': self.tenant_id,
'payload': self.payload,
'ip_hash': self.ip_hash,
'user_agent': self.user_agent,
'referer': self.referer,
'recaptcha_score': self.recaptcha_score,
'recaptcha_passed': self.recaptcha_passed,
'status': self.status.value,
'submission_metadata': self.submission_metadata or {},
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self) -> str:
return f'<Submission {self.id} - {self.form_config_id}>'
class User(db.Model):
"""
User model for authentication and authorization.
Users authenticate via Zitadel OIDC in production, with a dev mode bypass.
Each user has:
- Unique identifier (from OIDC or dev mode)
- Email and name
- Role for access control
- Session management fields
Attributes:
id: Unique user identifier
email: User email address
name: Display name
role: User role for access control
sub: OIDC subject identifier (from Zitadel)
last_login: Timestamp of last successful login
created_at: Timestamp when user was created
updated_at: Timestamp when user was last updated
"""
__tablename__ = 'user'
id = db.Column(db.String(64), primary_key=True, unique=True, nullable=False)
email = db.Column(db.String(255), unique=True, nullable=False)
name = db.Column(db.String(255), nullable=False)
role = db.Column(db.Enum(UserRole), nullable=False, default=UserRole.USER)
sub = db.Column(db.String(255), unique=True, nullable=True)
last_login = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at = db.Column(
db.DateTime,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False
)
def __init__(self, id: str, email: str, name: str, role: UserRole = UserRole.USER,
sub: Optional[str] = None):
"""
Initialize a new User instance.
:param id: Unique user identifier
:param email: User email address
:param name: Display name
:param role: User role
:param sub: OIDC subject identifier
"""
self.id = id
self.email = email
self.name = name
self.role = role
self.sub = sub
def update_last_login(self) -> None:
"""
Update the last login timestamp to now.
"""
self.last_login = datetime.now(timezone.utc)
db.session.commit()
def has_role(self, role: UserRole) -> bool:
"""
Check if user has a specific role or higher privilege.
:param role: Role to check
:return: True if user has the role or a higher-privilege role
"""
role_hierarchy = {
UserRole.ADMIN: 4,
UserRole.TENANT_ADMIN: 3,
UserRole.USER: 2,
UserRole.VIEWER: 1
}
return role_hierarchy.get(self.role, 0) >= role_hierarchy.get(role, 0)
def is_admin(self) -> bool:
"""
Check if user has admin privileges.
:return: True if user is ADMIN or TENANT_ADMIN
"""
return self.role in [UserRole.ADMIN, UserRole.TENANT_ADMIN]
def to_dict(self) -> dict:
"""
Convert user to dictionary for API responses.
:return: Dictionary representation of user
"""
return {
'id': self.id,
'email': self.email,
'name': self.name,
'role': self.role.value,
'sub': self.sub,
'last_login': self.last_login.isoformat() if self.last_login else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
def __repr__(self) -> str:
return f'<User {self.id} - {self.email} ({self.role.value})>'
class AuditLog(db.Model):
"""
Audit log model for tracking administrative actions.
Each audit log entry records:
- Who performed an action
- What action was performed
- On which resource
- With what result
Attributes:
id: Unique log entry identifier
user_id: User who performed the action (nullable for system actions)
tenant_id: Tenant context (nullable for global actions)
action: Type of action performed
resource_type: Type of resource affected
resource_id: Identifier of resource affected
details: Additional details about the action
ip_address: IP address of requester
user_agent: User agent string
status: Whether action succeeded or failed
created_at: Timestamp when action was logged
"""
__tablename__ = 'audit_log'
id = db.Column(db.String(64), primary_key=True, unique=True, nullable=False)
user_id = db.Column(db.String(64), db.ForeignKey('user.id'), nullable=True)
tenant_id = db.Column(db.String(64), db.ForeignKey('tenant.id'), nullable=True)
action = db.Column(db.String(64), nullable=False)
resource_type = db.Column(db.String(64), nullable=False)
resource_id = db.Column(db.String(64), nullable=True)
details = db.Column(db.JSON, nullable=True)
ip_address = db.Column(db.String(64), nullable=True)
user_agent = db.Column(db.Text, nullable=True)
status = db.Column(db.String(16), nullable=False, default='success')
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
def __init__(self, id: str, action: str, resource_type: str,
user_id: Optional[str] = None, tenant_id: Optional[str] = None,
resource_id: Optional[str] = None, details: Optional[dict] = None,
ip_address: Optional[str] = None, user_agent: Optional[str] = None,
status: str = 'success'):
"""
Initialize a new AuditLog instance.
:param id: Unique log identifier
:param action: Type of action performed
:param resource_type: Type of resource affected
:param user_id: User who performed the action
:param tenant_id: Tenant context
:param resource_id: Resource identifier
:param details: Additional details
:param ip_address: Requester IP address
:param user_agent: Requester user agent
:param status: Action status (success/failure)
"""
self.id = id
self.user_id = user_id
self.tenant_id = tenant_id
self.action = action
self.resource_type = resource_type
self.resource_id = resource_id
self.details = details or {}
self.ip_address = ip_address
self.user_agent = user_agent
self.status = status
@classmethod
def log_action(cls, action: str, resource_type: str, user: Optional[User] = None,
tenant: Optional[Tenant] = None, resource_id: Optional[str] = None,
details: Optional[dict] = None, request_context: Optional[dict] = None,
status: str = 'success') -> 'AuditLog':
"""
Convenience method to create an audit log entry.
:param action: Type of action
:param resource_type: Type of resource
:param user: User performing the action
:param tenant: Tenant context
:param resource_id: Resource identifier
:param details: Additional details
:param request_context: Request context (ip_address, user_agent)
:param status: Action status
:return: Created audit log entry
"""
import uuid
log_entry = cls(
id=str(uuid.uuid4()),
action=action,
resource_type=resource_type,
user_id=user.id if user else None,
tenant_id=tenant.id if tenant else None,
resource_id=resource_id,
details=details,
ip_address=request_context.get('ip_address') if request_context else None,
user_agent=request_context.get('user_agent') if request_context else None,
status=status
)
db.session.add(log_entry)
db.session.commit()
return log_entry
def __repr__(self) -> str:
return f'<AuditLog {self.id} - {self.action} on {self.resource_type}:{self.resource_id}>'