mail drin
fehlende routen drin
prod-reife erreicht.
TODOS:
Deine aktuellen offenen TODOs
1. Rate-Limiting (Spam-Schutz VOR dem reCAPTCHA)
Das reCAPTCHA hält zwar Bots ab, aber wenn jemand böswillig ein Script schreibt, das 1.000 POST-Requests pro Sekunde an /api/v1/submissions sendet, muss dein Backend jedes Mal Google kontaktieren und die Datenbank befragen. Das frisst Server-Ressourcen.
Todo: Einbinden von Flask-Limiter und Absichern der API-Route (z.B. max. 10 Submissions pro Minute pro IP-Adresse oder Tenant).
2. Billing & Feature-Enforcement (Limits pro Tenant durchsetzen)
Die Mandanten-Architektur und das config-Feld im JSON existieren bereits. Aber wir erzwingen diese Limits aktuell nicht im Backend.
Todo: Im Dashboard (templates/admin/tenants/view.html) UI-Controls einbauen, um Feature-Flags für einen Tenant festzulegen:
max_forms (z.B. 1 für Free-User, 10 für Pro-User).
allow_instant_mail (Dürfen E-Mails an den Tenant gesendet werden?).
allow_autoresponder (Darf der Autoresponder für Endkunden genutzt werden?).
Todo: Diese Limits hart im Backend (beim Erstellen von Forms und beim Mailversand) kontrollieren. (Der Mailer prüft das zwar schon halbwegs, das UI zum Pflegen fehlt aber noch komplett).
3. E-Mail Templates aufwerten (HTML statt Plain-Text)
Bisher versendet der Service E-Mails als reinen Text (plain). Für eine professionelle SaaS-Lösung sollten diese Mails ansprechend aussehen.
Todo: Im services/mailer.py den MIMEMultipart so erweitern, dass er saubere HTML-Mails aus einem Jinja2-Template (templates/emails/...) generiert, inklusive Branding/Logo deines Services.
4. Frontend-Snippet "Graceful Degradation"
Das embed.js arbeitet aktuell sehr gut. Aber was ist, wenn das Formeleon-Backend mal für 5 Minuten down ist?
Todo: Einen sauberen Timeout im fetch einbauen und dem Endkunden auf der Webseite eine schöne, fallback HTML-Meldung anzeigen ("Entschuldigung, das Formular ist momentan nicht erreichbar"), anstatt dass sich das Script lautlos in der Console mit einem CORS/Network-Error verabschiedet.
5. CI/CD Pipeline (Deployment)
Wenn der Code fertig ist, willst du ihn sicher nicht immer händisch per git pull auf deinen Produktionsserver schieben und dort docker compose up tippen.
Todo: Eine simple Pipeline (Github Actions / Gitea Actions) anlegen, die den Code testet und automatisch auf dem Zielserver ausrollt.
644 lines
24 KiB
Python
644 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)
|
|
ARCHIVED: Submission has been archived (hidden from default view)
|
|
"""
|
|
PENDING = 'pending'
|
|
PROCESSED = 'processed'
|
|
FAILED = 'failed'
|
|
SPAM = 'spam'
|
|
ARCHIVED = 'archived'
|
|
|
|
|
|
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}>'
|
|
|