Merge pull request #19 from CoryHawkless/email-uplift

Email uplift
This commit is contained in:
2026-04-05 14:18:06 +09:30
committed by GitHub
64 changed files with 2590 additions and 3476 deletions
+33 -4
View File
@@ -2,8 +2,24 @@ FLASK_APP=manage.py
FLASK_ENV=development
FLASK_DEBUG=1
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/gatehouse_dev
# ═════════════════════════════════════════════════════════════════════════════
# Docker / Production
# ═════════════════════════════════════════════════════════════════════════════
COMPOSE_PROJECT_NAME=authy2
FLASK_ENV=production
POSTGRES_USER=authy2
POSTGRES_PASSWORD=changeme-in-production
POSTGRES_DB=authy2
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
SQLALCHEMY_DATABASE_URI=${DATABASE_URL}
REDIS_URL=redis://redis:6379/0
SESSION_REDIS_URL=redis://redis:6379/0
RATELIMIT_STORAGE_URL=redis://redis:6379/1
HTTP_PORT=80
HTTPS_PORT=443
API_PORT=5000
# Database (overridden by Docker values above)
SQLALCHEMY_ECHO=False
SQLALCHEMY_LOG_LEVEL=WARNING
@@ -15,7 +31,7 @@ CA_ENCRYPTION_KEY=change-me-in-production
BCRYPT_LOG_ROUNDS=12
# Session cookies
SESSION_COOKIE_SECURE=False
SESSION_COOKIE_SECURE=True
SESSION_COOKIE_SAMESITE=Lax
# Only needed when sharing cookies across subdomains (e.g. api.example.com + ui.example.com)
# SESSION_COOKIE_DOMAIN=example.com
@@ -61,7 +77,7 @@ OIDC_BASE_URL=http://localhost:5000
# WebAuthn
# ─────────────────────────────────────────────────────────────────────────────
WEBAUTHN_RP_ID=localhost
WEBAUTHN_RP_NAME=Gatehouse
WEBAUTHN_RP_NAME=Secuird
WEBAUTHN_ORIGIN=http://localhost:8080
# ─────────────────────────────────────────────────────────────────────────────
@@ -81,6 +97,19 @@ SMTP_USERNAME=
SMTP_PASSWORD=
FROM_ADDRESS=noreply@gatehouse.local
# Email Provider (smtp, mailgun, sendgrid)
# Note: SMTP is the default. Set to "mailgun" or "sendgrid" to use those providers
EMAIL_PROVIDER=smtp
# Mailgun Configuration (used when EMAIL_PROVIDER=mailgun)
# MAILGUN_API_KEY=your-mailgun-api-key
# MAILGUN_DOMAIN=mg.yourdomain.com
# MAILGUN_API_URL=https://api.mailgun.net/v3
# SendGrid Configuration (used when EMAIL_PROVIDER=sendgrid)
# SENDGRID_API_KEY=SG.your-sendgrid-api-key
# SENDGRID_FROM_EMAIL=noreply@yourdomain.com
# ─────────────────────────────────────────────────────────────────────────────
# Logging
# ─────────────────────────────────────────────────────────────────────────────
+6 -1
View File
@@ -136,4 +136,9 @@ Thumbs.db
# Project specific
*.db
flask_session/
flask_session/
# Opencode files and folders
.opencode/
.swarm/
SWARM_PLAN.*
+68
View File
@@ -0,0 +1,68 @@
# Multi-stage build for Gatehouse Auth API
# Build stage
FROM python:3.11-slim as builder
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Create virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Copy requirements files
WORKDIR /app
COPY requirements/base.txt requirements/base.txt
COPY requirements/production.txt requirements/production.txt
# Install dependencies
RUN pip install --no-cache-dir --upgrade pip wheel && \
pip install --no-cache-dir -r requirements/production.txt
# Production stage
FROM python:3.11-slim
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
curl \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd --gid 1000 appgroup && \
useradd --uid 1000 --gid appgroup --shell /bin/bash --create-home appuser
# Copy virtual environment from builder
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Copy application code
WORKDIR /app
COPY --chown=appuser:appgroup . .
# Create log and session directories
RUN mkdir -p /app/logs /app/flask_session && chown -R appuser:appgroup /app/logs /app/flask_session
# Switch to non-root user
USER appuser
# Expose port
EXPOSE 5000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:5000/api/health || exit 1
# Run gunicorn with gevent workers
CMD ["gunicorn", "--bind", "0.0.0.0:5000", \
"--workers", "4", \
"--worker-class", "gevent", \
"--worker-connections", "1000", \
"--timeout", "120", \
"--access-logfile", "-", \
"--error-logfile", "-", \
"--log-level", "info", \
"wsgi:application"]
+102 -14
View File
@@ -64,7 +64,7 @@ python scripts/init_db.py
6. **Seed sample data** (optional):
```bash
python scripts/seed_data.py
python -m scripts.seed_data
```
7. **Run the application**:
@@ -77,6 +77,71 @@ python wsgi.py
The API will be available at `http://localhost:5000`
## Docker Deployment
### Prerequisites
- Docker 20.10+
- Docker Compose 2.0+
### Quick Start
1. **Start all services**:
```bash
docker-compose up -d
```
2. **Initialize the database** (run migrations):
```bash
docker-compose exec api python manage.py db upgrade
```
3. **Seed sample data** (optional):
```bash
docker-compose exec api python scripts/seed_data.py
```
4. **Verify health**:
```bash
curl http://localhost:5000/api/health
```
### Useful Commands
```bash
# View logs
docker-compose logs -f api
# Run migrations
docker-compose exec api python manage.py db upgrade
# Open shell in container
docker-compose exec api /bin/bash
# Rebuild after changes
docker-compose up -d --build
# Stop all services
docker-compose down
```
### Environment Variables
Copy `.env.example` to `.env` and configure:
- `POSTGRES_USER` / `POSTGRES_PASSWORD` - Database credentials
- `SECRET_KEY` - Flask secret key (required in production)
- `ENCRYPTION_KEY` - Data encryption key
- `CA_ENCRYPTION_KEY` - CA private key encryption
- `CORS_ORIGINS` - Allowed CORS origins (comma-separated)
### Production Considerations
- Use a strong `SECRET_KEY` (256-bit random)
- Enable HTTPS via nginx (configure SSL certificates)
- Set `BCRYPT_LOG_ROUNDS=13` for stronger password hashing
- Use Redis persistence (`--appendonly yes`)
- Configure log aggregation as needed
## API Endpoints
### Authentication
@@ -197,22 +262,45 @@ python manage.py db upgrade
## running seed
python -m scripts.seed_data
## Development Commands
## Running flask in dev
### Run Flask in Development
```bash
FLASK_ENV=development flask run --debug --port 8888
```
### Seed Sample Data
```bash
python -m scripts.seed_data
# Or with Docker:
docker-compose exec api python scripts/seed_data.py
```
### Database Migration
```bash
# Apply migrations
flask db upgrade
# With Docker:
docker-compose exec api python manage.py db upgrade
```
### SQLite Browser (Development)
```bash
sqlite_web instance/db_file.db --port 9999 --host 0.0.0.0
```
# Test creds
## OIDC Client
client_id: acme-portal-001
client_secret: acme_secret_portal_2024
## Test Credentials
## User
email: bob@acme-corp.com
password: UserPass123!
### OIDC Client
| Field | Value |
|-------|-------|
| client_id | `acme-portal-001` |
| client_secret | `acme_secret_portal_2024` |
## Sqlite editor
sqlite_web instance/db_file.db --port 9999 --host 0.0.0.0
### Test User
| Field | Value |
|-------|-------|
| email | `bob@acme-corp.com` |
| password | `UserPass123!` |
+13 -1
View File
@@ -123,7 +123,7 @@ class BaseConfig:
# WebAuthn Configuration
WEBAUTHN_RP_ID = os.getenv("WEBAUTHN_RP_ID", "localhost")
WEBAUTHN_RP_NAME = os.getenv("WEBAUTHN_RP_NAME", "Gatehouse")
WEBAUTHN_RP_NAME = os.getenv("WEBAUTHN_RP_NAME", "Secuird")
WEBAUTHN_ORIGIN = os.getenv("WEBAUTHN_ORIGIN", "https://ui.webauthn.local")
# Frontend URL (for OAuth callback redirects)
@@ -140,3 +140,15 @@ class BaseConfig:
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
FROM_ADDRESS = os.getenv("FROM_ADDRESS", "noreply@gatehouse.local")
# Email Provider Configuration
EMAIL_PROVIDER = os.getenv("EMAIL_PROVIDER", "smtp").lower() # smtp, mailgun, sendgrid
# Mailgun Configuration (used when EMAIL_PROVIDER=mailgun)
MAILGUN_API_KEY = os.getenv("MAILGUN_API_KEY", "")
MAILGUN_DOMAIN = os.getenv("MAILGUN_DOMAIN", "")
MAILGUN_API_URL = os.getenv("MAILGUN_API_URL", "https://api.mailgun.net/v3")
# SendGrid Configuration (used when EMAIL_PROVIDER=sendgrid)
SENDGRID_API_KEY = os.getenv("SENDGRID_API_KEY", "")
SENDGRID_FROM_EMAIL = os.getenv("SENDGRID_FROM_EMAIL", "")
+10 -2
View File
@@ -20,13 +20,13 @@ class DevelopmentConfig(BaseConfig):
# Reduced bcrypt rounds for faster dev cycles
BCRYPT_LOG_ROUNDS = 4
# Gatehouse React UI URL — OIDC authorize redirects here instead of showing raw HTML
# Secuird React UI URL — OIDC authorize redirects here instead of showing raw HTML
OIDC_UI_URL = os.getenv("OIDC_UI_URL", "http://localhost:8080")
# Add localhost:8080 (React UI) to CORS allowed origins for OIDC bridge endpoints
CORS_ORIGINS = os.getenv(
"CORS_ORIGINS",
"http://localhost:8080,http://localhost:3000,http://localhost:5173,https://ui.webauthn.local"
"http://192.168.50.124:8080,http://localhost:8080,http://localhost:3000,http://localhost:5173,https://ui.webauthn.local"
).split(",")
# ── Email / SMTP ──────────────────────────────────────────────────────────
@@ -40,3 +40,11 @@ class DevelopmentConfig(BaseConfig):
SMTP_USE_TLS = os.getenv("SMTP_USE_TLS", "").lower() == "true" if os.getenv("SMTP_USE_TLS") else int(os.getenv("SMTP_PORT", "1025")) not in (25, 1025)
FROM_ADDRESS = os.getenv("FROM_ADDRESS", "noreply@gatehouse.local")
EMAIL_FROM = FROM_ADDRESS # alias
# Email Provider Configuration
EMAIL_PROVIDER = os.getenv("EMAIL_PROVIDER", "smtp").lower()
MAILGUN_API_KEY = os.getenv("MAILGUN_API_KEY") or None
MAILGUN_DOMAIN = os.getenv("MAILGUN_DOMAIN") or None
MAILGUN_API_URL = os.getenv("MAILGUN_API_URL", "https://api.mailgun.net/v3")
SENDGRID_API_KEY = os.getenv("SENDGRID_API_KEY") or None
SENDGRID_FROM_EMAIL = os.getenv("SENDGRID_FROM_EMAIL") or None
+93
View File
@@ -0,0 +1,93 @@
version: '3.8'
services:
api:
build:
context: .
dockerfile: Dockerfile
env_file:
- .env
environment:
- FLASK_ENV=production
- CORS_ORIGINS=http://192.168.50.124:8080,http://localhost:8080,http://localhost:5173
- DATABASE_URL=postgresql://${POSTGRES_USER:-gatehouse}:${POSTGRES_PASSWORD:-gatehouse}@db:5432/${POSTGRES_DB:-gatehouse}
- SQLALCHEMY_DATABASE_URI=postgresql://${POSTGRES_USER:-gatehouse}:${POSTGRES_PASSWORD:-gatehouse}@db:5432/${POSTGRES_DB:-gatehouse}
- REDIS_URL=redis://redis:6379/0
- SESSION_REDIS_URL=redis://redis:6379/0
- RATELIMIT_STORAGE_URL=redis://redis:6379/1
ports:
- "${API_PORT:-5000}:5000"
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- authy2-network
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=${POSTGRES_USER:-gatehouse}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-gatehouse}
- POSTGRES_DB=${POSTGRES_DB:-gatehouse}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- authy2-network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-gatehouse} -d ${POSTGRES_DB:-gatehouse}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
ports:
- "5432:5432"
redis:
image: redis:7-alpine
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
networks:
- authy2-network
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
nginx:
image: nginx:1.27-alpine
volumes:
- ./docker/nginx.conf:/etc/nginx/nginx.conf:ro
ports:
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
depends_on:
- api
networks:
- authy2-network
restart: unless-stopped
healthcheck:
test: ["CMD", "nginx", "-t"]
interval: 30s
timeout: 10s
retries: 3
networks:
authy2-network:
driver: bridge
volumes:
postgres_data:
redis_data:
+97
View File
@@ -0,0 +1,97 @@
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types text/plain text/css text/xml application/json application/javascript
application/xml application/xml+rss text/javascript application/x-javascript;
upstream api {
server api:5000;
}
server {
listen 80;
server_name localhost;
# Health check endpoint
location /health {
proxy_pass http://api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
# API routes
location /api/ {
proxy_pass http://api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
# Increase buffer for larger responses
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 16k;
proxy_busy_buffers_size 24k;
}
# Catch-all proxy (for any other routes)
location / {
proxy_pass http://api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}
}
Binary file not shown.
+7 -1
View File
@@ -191,6 +191,8 @@ def setup_logging(app):
root_logger.setLevel(log_level)
if app.config.get("LOG_TO_STDOUT"):
# Clear existing handlers on root logger to avoid duplicates
root_logger.handlers.clear()
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(formatter)
stream_handler.setLevel(log_level)
@@ -207,7 +209,8 @@ def setup_logging(app):
child_logger.propagate = True
child_logger.setLevel(log_level)
# Configure Flask app logger
# Configure Flask app logger - clear handlers so it only propagates to root
app.logger.handlers.clear()
app.logger.setLevel(log_level)
# Configure SQLAlchemy logging level (also set at module level before DB init)
@@ -217,6 +220,9 @@ def setup_logging(app):
logging.getLogger('sqlalchemy.dialects').setLevel(sqlalchemy_log_level)
logging.getLogger('sqlalchemy.pool').setLevel(sqlalchemy_log_level)
# Suppress watchdog debug logging
logging.getLogger('watchdog.observers.inotify_buffer').setLevel(logging.INFO)
app.logger.info("Application startup")
# Test debug log after logging is configured
+3 -3
View File
@@ -32,12 +32,12 @@ def register():
verify_token = EmailVerificationToken.generate(user_id=user.id)
app_url = current_app.config.get("APP_URL", "http://localhost:8080")
verify_link = f"{app_url}/verify-email?token={verify_token.token}"
subject = "Verify your Gatehouse email address"
subject = "Verify your Secuird email address"
body = (
f"Hi {user.full_name or user.email},\n\n"
f"Welcome to Gatehouse! Please verify your email address by clicking the link below (valid for 24 hours):\n"
f"Welcome to Secuird! Please verify your email address by clicking the link below (valid for 24 hours):\n"
f"{verify_link}\n\n"
f"Gatehouse Security Team"
f"Secuird Security Team"
)
NotificationService._send_email_async(to_address=user.email, subject=subject, body=body)
except Exception as exc:
+8 -8
View File
@@ -29,14 +29,14 @@ def forgot_password():
reset_link = f"{app_url}/reset-password?token={reset_token.token}"
NotificationService._send_email_async(
to_address=user.email,
subject="Reset your Gatehouse password",
subject="Reset your Secuird password",
body=(
f"Hi {user.full_name or user.email},\n\n"
f"You requested a password reset for your Gatehouse account.\n\n"
f"You requested a password reset for your Secuird account.\n\n"
f"Click the link below to reset your password (valid for 2 hours):\n"
f"{reset_link}\n\n"
f"If you did not request this, you can safely ignore this email.\n\n"
f"Gatehouse Security Team"
f"Secuird Security Team"
),
)
_logger.info(f"Password reset token generated for user {user.id}")
@@ -131,12 +131,12 @@ def resend_verification():
verify_link = f"{app_url}/verify-email?token={verify_token.token}"
NotificationService._send_email_async(
to_address=user.email,
subject="Verify your Gatehouse email address",
subject="Verify your Secuird email address",
body=(
f"Hi {user.full_name or user.email},\n\n"
f"Please verify your email address by clicking the link below (valid for 24 hours):\n"
f"{verify_link}\n\n"
f"Gatehouse Security Team"
f"Secuird Security Team"
),
)
_logger.info(f"Verification email sent for user {user.id}")
@@ -202,13 +202,13 @@ def resend_activation():
activate_link = f"{app_url}/activate?code={code}"
NotificationService._send_email_async(
to_address=user.email,
subject="Activate your Gatehouse account",
subject="Activate your Secuird account",
body=(
f"Hi {user.full_name or user.email},\n\n"
f"Please activate your Gatehouse account by clicking the link below:\n"
f"Please activate your Secuird account by clicking the link below:\n"
f"{activate_link}\n\n"
f"If you did not create an account, you can safely ignore this email.\n\n"
f"Gatehouse Security Team"
f"Secuird Security Team"
),
)
_logger.info(f"Activation email re-sent to {user.id}")
@@ -39,12 +39,12 @@ def create_org_invite(org_id):
NotificationService._send_email_async(
to_address=email,
subject=f"You're invited to join {org.name} on Gatehouse",
subject=f"You're invited to join {org.name} on Secuird",
body=(
f"You've been invited to join {org.name} on Gatehouse.\n\n"
f"You've been invited to join {org.name} on Secuird.\n\n"
f"Click the link below to accept the invitation (valid for 7 days):\n"
f"{invite_link}\n\n"
f"Gatehouse Security Team"
f"Secuird Security Team"
),
)
logging.getLogger(__name__).info(f"[INVITE] Email queued for {email}")
@@ -167,9 +167,9 @@ def send_mfa_reminder(org_id, user_id):
body=(
f"Hi {user.full_name or user.email},\n\n"
"Your organization administrator has asked you to set up "
"multi-factor authentication (MFA) on your Gatehouse account.\n\n"
"multi-factor authentication (MFA) on your Secuird account.\n\n"
"Please log in and configure MFA as soon as possible.\n\n"
"Gatehouse Security Team"
"Secuird Security Team"
),
)
+1 -1
View File
@@ -294,7 +294,7 @@ class AuthService:
provisioning_uri = TOTPService.generate_provisioning_uri(
user_email=user.email,
secret=secret,
issuer="Gatehouse",
issuer="Secuird",
)
# Generate QR code data URI
+85
View File
@@ -0,0 +1,85 @@
"""Email provider interfaces and factory."""
import logging
import os
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
@dataclass
class EmailMessage:
"""Email message data structure."""
to: str
subject: str
body: str
html_body: Optional[str] = None
from_address: Optional[str] = None
class EmailProvider(ABC):
"""Abstract base class for email providers."""
@abstractmethod
def send(self, message: EmailMessage) -> bool:
"""
Send an email message.
Args:
message: EmailMessage instance containing email details
Returns:
bool: True if email was sent successfully, False otherwise
"""
pass
class NoOpEmailProvider(EmailProvider):
"""No-op email provider that logs and returns False."""
def send(self, message: EmailMessage) -> bool:
"""Log that emails are disabled and return False."""
logger.info(f"Email disabled - would send to={message.to} subject={message.subject}")
return False
class EmailProviderFactory:
"""Factory for creating email provider instances."""
@staticmethod
def get_provider() -> EmailProvider:
"""
Create an email provider based on EMAIL_PROVIDER config.
Returns:
EmailProvider: An instance of the appropriate email provider
"""
provider_name = os.getenv("EMAIL_PROVIDER", "smtp").lower()
if provider_name == "smtp":
try:
from gatehouse_app.services.providers.smtp_provider import SmtpEmailProvider
return SmtpEmailProvider()
except ImportError:
logger.warning("SMTP provider not implemented, using no-op provider")
return NoOpEmailProvider()
if provider_name == "mailgun":
try:
from gatehouse_app.services.providers.mailgun_provider import MailgunEmailProvider
return MailgunEmailProvider()
except ImportError:
logger.warning("Mailgun provider not implemented, using no-op provider")
return NoOpEmailProvider()
if provider_name == "sendgrid":
try:
from gatehouse_app.services.providers.sendgrid_provider import SendGridEmailProvider
return SendGridEmailProvider()
except ImportError:
logger.warning("SendGrid provider not implemented, using no-op provider")
return NoOpEmailProvider()
logger.error(f"Invalid EMAIL_PROVIDER value: {provider_name}, defaulting to no-op provider")
return NoOpEmailProvider()
+498
View File
@@ -0,0 +1,498 @@
"""HTML Email Templates for Secuird.
This module provides beautifully designed HTML email templates with
Secuird branding, responsive design, and consistent styling.
"""
import os
from typing import Optional
from flask import current_app
PRIMARY_COLOR = "#36b9a6"
PRIMARY_DARK = "#2d9a89"
TEXT_COLOR = "#1e293b"
MUTED_COLOR = "#64748b"
BORDER_COLOR = "#e2e8f0"
BACKGROUND_COLOR = "#f8fafc"
WHITE = "#ffffff"
DANGER_COLOR = "#dc2626"
WARNING_COLOR = "#f59e0b"
SUCCESS_COLOR = "#16a34a"
def get_logo_url() -> str:
"""Get the email logo URL from config or use default inline SVG."""
return current_app.config.get("EMAIL_BRAND_LOGO_URL", "")
def get_brand_name() -> str:
"""Get the brand name from config."""
return current_app.config.get("EMAIL_BRAND_NAME", "Secuird")
def get_support_email() -> str:
"""Get the support email from config."""
return current_app.config.get("EMAIL_SUPPORT_EMAIL", "support@secuird.tech")
def get_website_url() -> str:
"""Get the website URL from config."""
return current_app.config.get("EMAIL_WEBSITE_URL", "https://secuird.tech")
def get_app_url() -> str:
"""Get the app URL from config."""
return current_app.config.get("APP_URL", "https://secuird.tech")
def get_inline_logo() -> str:
"""Returns an inline SVG logo as a data URI for email embedding."""
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" '
'width="40" height="40"><rect width="24" height="24" rx="4" fill="#36b9a6"/>'
'<path d="M4 4h3v16H4V4z" fill="#ffffff"/><path d="M17 4h3v16h-3V4z" fill="#ffffff"/>'
'<path d="M7 4h10v3H7V4z" fill="#ffffff" opacity="0.7"/>'
'<circle cx="12" cy="14" r="2" fill="#ffffff" opacity="0.5"/></svg>'
)
def get_base_html(
content: str,
subject: str,
preheader: Optional[str] = None,
) -> str:
"""Generate the base HTML email template.
Args:
content: The main content HTML
subject: Email subject (used for title and header)
preheader: Preview text shown in email clients
Returns:
Complete HTML email string
"""
logo = get_inline_logo()
brand_name = get_brand_name()
support_email = get_support_email()
website_url = get_website_url()
app_url = get_app_url()
current_year = __import__("datetime").datetime.now().year
return f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{subject}</title>
<!--[if mso]>
<style type="text/css">
table {{ border-collapse: collapse; }}
.button {{ padding: 12px 24px !important; }}
</style>
<![endif]-->
</head>
<body style="margin: 0; padding: 0; background-color: {BACKGROUND_COLOR}; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: {BACKGROUND_COLOR};">
<tr>
<td align="center" style="padding: 40px 20px;">
<!-- Email Container -->
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 600px; background-color: {WHITE}; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);">
<!-- Header -->
<tr>
<td style="background: linear-gradient(135deg, {PRIMARY_COLOR}, {PRIMARY_DARK}); border-radius: 12px 12px 0 0; padding: 32px; text-align: center;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td style="text-align: center;">
{logo}
<h1 style="margin: 16px 0 0 0; color: {WHITE}; font-size: 24px; font-weight: 600;">{brand_name}</h1>
</td>
</tr>
</table>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 40px 32px;">
{content}
</td>
</tr>
<!-- Divider -->
<tr>
<td style="padding: 0 32px;">
<hr style="border: none; border-top: 1px solid {BORDER_COLOR}; margin: 0;">
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 24px 32px; background-color: {BACKGROUND_COLOR}; border-radius: 0 0 12px 12px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td style="text-align: center; color: {MUTED_COLOR}; font-size: 13px;">
<p style="margin: 0 0 8px 0;">© {current_year} {brand_name}. All rights reserved.</p>
<p style="margin: 0;">
<a href="{website_url}" style="color: {PRIMARY_COLOR}; text-decoration: none;">Website</a>
&nbsp;&bull;&nbsp;
<a href="mailto:{support_email}" style="color: {PRIMARY_COLOR}; text-decoration: none;">Support</a>
&nbsp;&bull;&nbsp;
<a href="{app_url}" style="color: {PRIMARY_COLOR}; text-decoration: none;">App</a>
</p>
<p style="margin: 12px 0 0 0; font-size: 11px; color: #94a3b8;">
This email was sent because you have an account with {brand_name}.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>'''
def get_action_button(link: str, text: str, color: str = PRIMARY_COLOR) -> str:
"""Generate an HTML action button.
Args:
link: The URL the button links to
text: Button text
color: Button background color
Returns:
HTML button string
"""
return f'''<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 24px 0;">
<tr>
<td style="border-radius: 8px; background: linear-gradient(135deg, {color}, {color});">
<a href="{link}" style="display: inline-block; padding: 14px 32px; color: {WHITE}; text-decoration: none; font-weight: 600; font-size: 15px; border-radius: 8px;">{text}</a>
</td>
</tr>
</table>'''
def get_alert_box(text: str, alert_type: str = "info", icon: str = "") -> str:
"""Generate an alert/highlight box.
Args:
text: Alert text
alert_type: Type of alert (info, warning, danger, success)
icon: Optional icon emoji or HTML
Returns:
HTML alert box string
"""
colors = {
"info": (PRIMARY_COLOR, "#e0f2f1"),
"warning": (WARNING_COLOR, "#fef3c7"),
"danger": (DANGER_COLOR, "#fee2e2"),
"success": (SUCCESS_COLOR, "#dcfce7"),
}
border_color, bg_color = colors.get(alert_type, colors["info"])
return f'''<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin: 20px 0;">
<tr>
<td style="background-color: {bg_color}; border-left: 4px solid {border_color}; padding: 16px 20px; border-radius: 0 8px 8px 0;">
<p style="margin: 0; color: {TEXT_COLOR}; font-size: 14px;">{icon} {text}</p>
</td>
</tr>
</table>'''
def get_detail_row(label: str, value: str) -> str:
"""Generate a detail row for email content.
Args:
label: Field label
value: Field value
Returns:
HTML row string
"""
return f'''<tr>
<td style="padding: 8px 0; border-bottom: 1px solid {BORDER_COLOR};">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td width="40%" style="color: {MUTED_COLOR}; font-size: 13px;">{label}</td>
<td width="60%" style="color: {TEXT_COLOR}; font-size: 14px; font-weight: 500;">{value}</td>
</tr>
</table>
</td>
</tr>'''
# =============================================================================
# EMAIL TEMPLATES
# =============================================================================
def build_email_verification_html(
user_name: str,
verify_link: str,
expiry_hours: int = 24,
) -> str:
"""Build email verification email (welcome email).
Args:
user_name: Recipient's name or email
verify_link: Verification link URL
expiry_hours: Hours until link expires
Returns:
HTML email string
"""
content = f'''
<h2 style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 20px; font-weight: 600;">Welcome to Secuird!</h2>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
Hi <strong>{user_name}</strong>,
</p>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
Thank you for registering with Secuird. Please verify your email address by clicking the button below:
</p>
{get_action_button(verify_link, "Verify Email Address")}
<p style="margin: 20px 0; color: {MUTED_COLOR}; font-size: 13px;">
This link will expire in <strong>{expiry_hours} hours</strong>. If you didn't create an account, you can safely ignore this email.
</p>
{get_alert_box("For security reasons, please don't forward this email to anyone.", "warning", "⚠️")}
'''
return get_base_html(content, "Verify your Secuird email address", "Please verify your email address to activate your account")
def build_password_reset_html(
user_name: str,
reset_link: str,
expiry_hours: int = 2,
) -> str:
"""Build password reset email.
Args:
user_name: Recipient's name or email
reset_link: Password reset link URL
expiry_hours: Hours until link expires
Returns:
HTML email string
"""
content = f'''
<h2 style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 20px; font-weight: 600;">Reset Your Password</h2>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
Hi <strong>{user_name}</strong>,
</p>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
We received a request to reset your password. Click the button below to create a new one:
</p>
{get_action_button(reset_link, "Reset Password", WARNING_COLOR)}
<p style="margin: 20px 0; color: {MUTED_COLOR}; font-size: 13px;">
This link will expire in <strong>{expiry_hours} hours</strong>.
</p>
{get_alert_box("If you didn't request a password reset, your account is secure. You can safely ignore this email.", "info", "🔒")}
'''
return get_base_html(content, "Reset your Secuird password", "Click the button to reset your password")
def build_account_activation_html(
user_name: str,
activation_link: str,
) -> str:
"""Build account activation email.
Args:
user_name: Recipient's name or email
activation_link: Account activation link URL
Returns:
HTML email string
"""
content = f'''
<h2 style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 20px; font-weight: 600;">Activate Your Account</h2>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
Hi <strong>{user_name}</strong>,
</p>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
Your account has been created but is not yet activated. Click the button below to activate it:
</p>
{get_action_button(activation_link, "Activate Account", SUCCESS_COLOR)}
{get_alert_box("If you didn't create an account, you can safely ignore this email.", "warning", "⚠️")}
'''
return get_base_html(content, "Activate your Secuird account", "Activate your account to get started")
def build_mfa_deadline_reminder_html(
user_name: str,
org_name: str,
days_remaining: int,
deadline_date: str,
mfa_methods: str,
setup_link: str,
) -> str:
"""Build MFA deadline reminder email.
Args:
user_name: Recipient's name or email
org_name: Organization name
days_remaining: Days until MFA deadline
deadline_date: Formatted deadline date
mfa_methods: Required MFA methods
setup_link: Link to set up MFA
Returns:
HTML email string
"""
urgency = "immediate action" if days_remaining <= 3 else "attention required"
content = f'''
<h2 style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 20px; font-weight: 600;">MFA Enrollment {urgency.title()}</h2>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
Dear <strong>{user_name}</strong>,
</p>
{get_alert_box(f"<strong>Important:</strong> You have <strong>{days_remaining} days</strong> to set up multi-factor authentication for your account with {org_name}.", "warning", "")}
<p style="margin: 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
To maintain access to your account, please complete the following:
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin: 20px 0; background-color: {BACKGROUND_COLOR}; border-radius: 8px;">
<tr>
<td style="padding: 20px;">
<h3 style="margin: 0 0 12px 0; color: {TEXT_COLOR}; font-size: 14px; font-weight: 600;">Required MFA Methods:</h3>
<p style="margin: 0; color: {MUTED_COLOR}; font-size: 14px;">{mfa_methods}</p>
<h3 style="margin: 16px 0 12px 0; color: {TEXT_COLOR}; font-size: 14px; font-weight: 600;">Deadline:</h3>
<p style="margin: 0; color: {DANGER_COLOR}; font-size: 14px; font-weight: 600;">{deadline_date}</p>
</td>
</tr>
</table>
{get_action_button(setup_link, "Set Up MFA Now", PRIMARY_COLOR)}
<p style="margin: 20px 0; color: {MUTED_COLOR}; font-size: 13px;">
If you do not set up MFA by the deadline, your account access will be restricted.
</p>
<p style="margin: 0; color: {MUTED_COLOR}; font-size: 13px;">
If you have questions, please contact your organization administrator.
</p>
'''
subject = f"Action Required: MFA enrollment deadline in {days_remaining} days"
return get_base_html(content, subject, f"MFA enrollment required for {org_name} - {days_remaining} days remaining")
def build_mfa_suspension_html(
user_name: str,
org_name: str,
mfa_methods: str,
setup_link: str,
) -> str:
"""Build MFA suspension notification email.
Args:
user_name: Recipient's name or email
org_name: Organization name
mfa_methods: Required MFA methods
setup_link: Link to set up MFA
Returns:
HTML email string
"""
content = f'''
<h2 style="margin: 0 0 20px 0; color: {DANGER_COLOR}; font-size: 20px; font-weight: 600;">Account Access Restricted</h2>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
Dear <strong>{user_name}</strong>,
</p>
{get_alert_box("<strong>Your account has been suspended</strong> because you did not set up multi-factor authentication within the required timeframe.", "danger", "🚫")}
<p style="margin: 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
To restore access to your account with <strong>{org_name}</strong>, please complete the following:
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin: 20px 0; background-color: {BACKGROUND_COLOR}; border-radius: 8px;">
<tr>
<td style="padding: 20px;">
<h3 style="margin: 0 0 12px 0; color: {TEXT_COLOR}; font-size: 14px; font-weight: 600;">Required MFA Methods:</h3>
<p style="margin: 0; color: {MUTED_COLOR}; font-size: 14px;">{mfa_methods}</p>
<h3 style="margin: 16px 0 12px 0; color: {TEXT_COLOR}; font-size: 14px; font-weight: 600;">How to Restore Access:</h3>
<ol style="margin: 0; padding-left: 20px; color: {TEXT_COLOR}; font-size: 14px; line-height: 1.8;">
<li>Log in to your account (you will see a compliance enrollment screen)</li>
<li>Follow the prompts to set up an authenticator app or passkey</li>
<li>Once MFA is configured, your access will be restored</li>
</ol>
</td>
</tr>
</table>
{get_action_button(setup_link, "Set Up MFA Now", DANGER_COLOR)}
<p style="margin: 20px 0; color: {MUTED_COLOR}; font-size: 13px;">
Need help? Contact your organization administrator.
</p>
'''
return get_base_html(content, "Account Access Restricted - MFA Enrollment Required", "Your account has been suspended due to missing MFA")
def build_org_invite_html(
inviter_name: str,
org_name: str,
invite_link: str,
role: str,
expiry_days: int = 7,
) -> str:
"""Build organization invite email.
Args:
inviter_name: Name of person who sent the invite
org_name: Organization name
invite_link: Invitation acceptance link
role: Role the invitee will have
expiry_days: Days until invite expires
Returns:
HTML email string
"""
content = f'''
<h2 style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 20px; font-weight: 600;">You're Invited to Join {org_name}</h2>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
You've been invited by <strong>{inviter_name}</strong> to join <strong>{org_name}</strong> on Secuird.
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin: 20px 0; background-color: {BACKGROUND_COLOR}; border-radius: 8px;">
<tr>
<td style="padding: 20px;">
<h3 style="margin: 0 0 12px 0; color: {TEXT_COLOR}; font-size: 14px; font-weight: 600;">Invitation Details:</h3>
<p style="margin: 0; color: {TEXT_COLOR}; font-size: 14px;"><strong>Organization:</strong> {org_name}</p>
<p style="margin: 8px 0 0 0; color: {TEXT_COLOR}; font-size: 14px;"><strong>Role:</strong> {role}</p>
<p style="margin: 8px 0 0 0; color: {MUTED_COLOR}; font-size: 13px;">This invitation expires in {expiry_days} days</p>
</td>
</tr>
</table>
{get_action_button(invite_link, "Accept Invitation", SUCCESS_COLOR)}
<p style="margin: 20px 0; color: {MUTED_COLOR}; font-size: 13px;">
If you did not expect this invitation, you can safely ignore this email.
</p>
'''
return get_base_html(content, f"You're invited to join {org_name} on Secuird", f"You've been invited to join {org_name}")
def build_email_verification_resend_html(
user_name: str,
verify_link: str,
expiry_hours: int = 24,
) -> str:
"""Build email verification resend email.
Args:
user_name: Recipient's name or email
verify_link: Verification link URL
expiry_hours: Hours until link expires
Returns:
HTML email string
"""
content = f'''
<h2 style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 20px; font-weight: 600;">Verify Your Email Address</h2>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
Hi <strong>{user_name}</strong>,
</p>
<p style="margin: 0 0 20px 0; color: {TEXT_COLOR}; font-size: 15px; line-height: 1.6;">
Please verify your email address by clicking the button below:
</p>
{get_action_button(verify_link, "Verify Email Address")}
<p style="margin: 20px 0; color: {MUTED_COLOR}; font-size: 13px;">
This link will expire in <strong>{expiry_hours} hours</strong>.
</p>
{get_alert_box("If you didn't request this, you can safely ignore this email.", "info", "🔒")}
'''
return get_base_html(content, "Verify your Secuird email address", "Please verify your email address")
@@ -136,7 +136,7 @@ def complete_link_flow(
).first()
if conflicting:
raise ExternalAuthError(
f"This {provider_type_str} account is already linked to a different Gatehouse user.",
f"This {provider_type_str} account is already linked to a different Secuird user.",
"PROVIDER_ALREADY_LINKED",
409,
)
@@ -246,10 +246,10 @@ def authenticate_with_provider(
provider_user_id=user_info["provider_user_id"],
email=user_info["email"],
failure_reason="account_not_found",
error_message="No Gatehouse account matches this external account",
error_message="No Secuird account matches this external account",
)
raise ExternalAuthError(
"No Gatehouse account matches this external account. Please register first.",
"No Secuird account matches this external account. Please register first.",
"ACCOUNT_NOT_FOUND",
400,
)
+18 -49
View File
@@ -24,6 +24,7 @@ from gatehouse_app.models.security.mfa_policy_compliance import MfaPolicyComplia
from gatehouse_app.models.security.organization_security_policy import OrganizationSecurityPolicy
from gatehouse_app.models.user.user import User
from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.services.email_provider import EmailMessage, EmailProviderFactory
from gatehouse_app.utils.constants import AuditAction
logger = logging.getLogger(__name__)
@@ -207,7 +208,7 @@ If you do not set up MFA by the deadline, your account access will be restricted
If you have any questions, please contact your organization administrator.
Best regards,
Gatehouse Security Team
Secuird Security Team
"""
return body
@@ -266,7 +267,7 @@ As a result, your account has been placed in a suspended state.
Contact your organization administrator if you have questions.
Best regards,
Gatehouse Security Team
Secuird Security Team
"""
return body
@@ -280,12 +281,9 @@ Gatehouse Security Team
"""Send an email on a daemon thread so the calling request returns immediately.
If EMAIL_ENABLED is False, logs instead of sending.
All SMTP exceptions are caught and logged this method never raises.
All email provider exceptions are caught and logged this method never raises.
The Flask app context is pushed inside the thread so current_app works correctly.
"""
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from flask import current_app
app = current_app._get_current_object() # capture real app before leaving request context
@@ -295,58 +293,29 @@ Gatehouse Security Team
email_enabled = app.config.get(NotificationService.EMAIL_ENABLED_KEY, False)
if not email_enabled:
logger.info(
f"[EMAIL DISABLED] Would have sent to: {to_address} | Subject: {subject}\n"
f"Body: {body[:500]}"
f"[EMAIL DISABLED] Would have sent to: {to_address} | Subject: {subject}"
)
return
smtp_host = app.config.get(NotificationService.SMTP_HOST_KEY, "")
smtp_port_raw = app.config.get(NotificationService.SMTP_PORT_KEY, 587)
smtp_username = app.config.get(NotificationService.SMTP_USERNAME_KEY)
smtp_password = app.config.get(NotificationService.SMTP_PASSWORD_KEY)
from_address = app.config.get(NotificationService.FROM_ADDRESS_KEY, "")
missing = [k for k, v in [("SMTP_HOST", smtp_host), ("FROM_ADDRESS", from_address)] if not v]
if missing:
logger.error(
f"[EMAIL] Cannot send — missing config: {', '.join(missing)}. "
f"Would have sent to: {to_address} | Subject: {subject}"
)
return
try:
smtp_port = int(smtp_port_raw)
except (TypeError, ValueError):
logger.error(f"[EMAIL] Invalid SMTP_PORT value: {smtp_port_raw!r}")
return
smtp_use_tls = app.config.get(
NotificationService.SMTP_USE_TLS_KEY,
smtp_port not in (25, 1025),
# Build email message
message = EmailMessage(
to=to_address,
subject=subject,
body=body,
html_body=html_body,
from_address=from_address,
)
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = from_address
msg["To"] = to_address
msg.attach(MIMEText(body, "plain"))
if html_body:
msg.attach(MIMEText(html_body, "html"))
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.ehlo()
if smtp_use_tls:
server.starttls()
server.ehlo()
if smtp_username and smtp_password:
server.login(smtp_username, smtp_password)
server.send_message(msg)
# Get provider and send
provider = EmailProviderFactory.get_provider()
success = provider.send(message)
if success:
logger.info(f"[EMAIL] Sent to {to_address} | Subject: {subject}")
except Exception as e:
logger.error(f"[EMAIL] Failed to send to {to_address}: {e}")
else:
logger.error(f"[EMAIL] Failed to send to {to_address}")
threading.Thread(target=_send, daemon=True).start()
@@ -0,0 +1,83 @@
"""Mailgun email provider implementation."""
import logging
import requests
from flask import current_app
from gatehouse_app.services.email_provider import EmailMessage, EmailProvider
logger = logging.getLogger(__name__)
class MailgunEmailProvider(EmailProvider):
"""Mailgun API-based email provider implementation."""
# Configuration keys
MAILGUN_API_KEY = "MAILGUN_API_KEY"
MAILGUN_DOMAIN = "MAILGUN_DOMAIN"
MAILGUN_API_URL = "MAILGUN_API_URL"
FROM_ADDRESS = "FROM_ADDRESS"
DEFAULT_API_URL = "https://api.mailgun.net/v3"
def send(self, message: EmailMessage) -> bool:
"""Send an email via Mailgun API.
Args:
message: EmailMessage instance containing email details
Returns:
bool: True if email was sent successfully, False otherwise
"""
api_key = current_app.config.get(self.MAILGUN_API_KEY)
domain = current_app.config.get(self.MAILGUN_DOMAIN)
api_url = current_app.config.get(self.MAILGUN_API_URL, self.DEFAULT_API_URL)
default_from = current_app.config.get(self.FROM_ADDRESS)
missing = [k for k, v in [("MAILGUN_API_KEY", api_key), ("MAILGUN_DOMAIN", domain)] if not v]
if missing:
logger.error(
f"[MAILGUN] Cannot send — missing config: {', '.join(missing)}. "
f"Would have sent to: {message.to} | Subject: {message.subject}"
)
return False
from_address = message.from_address or default_from
if not from_address:
logger.error(
f"[MAILGUN] Cannot send — missing FROM_ADDRESS. "
f"Would have sent to: {message.to} | Subject: {message.subject}"
)
return False
url = f"{api_url}/{domain}/messages"
data = {
"to": message.to,
"subject": message.subject,
"text": message.body,
"from": from_address,
}
if message.html_body:
data["html"] = message.html_body
try:
response = requests.post(
url,
auth=("api", api_key),
data=data,
)
if response.status_code == 200:
logger.info(f"[MAILGUN] Sent to {message.to} | Subject: {message.subject}")
return True
else:
logger.error(
f"[MAILGUN] Failed to send to {message.to}: from {from_address}"
f"status={response.status_code} body={response.text}"
)
return False
except Exception as e:
logger.error(f"[MAILGUN] Exception while sending to {message.to}: {e}")
return False
@@ -0,0 +1,94 @@
"""SendGrid email provider implementation."""
import logging
import requests
from flask import current_app
from gatehouse_app.services.email_provider import EmailMessage, EmailProvider
logger = logging.getLogger(__name__)
class SendGridEmailProvider(EmailProvider):
"""SendGrid API-based email provider implementation."""
# Configuration keys
SENDGRID_API_KEY = "SENDGRID_API_KEY"
SENDGRID_FROM_EMAIL = "SENDGRID_FROM_EMAIL"
FROM_ADDRESS = "FROM_ADDRESS"
API_URL = "https://api.sendgrid.com/v3/mail/send"
def send(self, message: EmailMessage) -> bool:
"""Send an email via SendGrid API.
Args:
message: EmailMessage instance containing email details
Returns:
bool: True if email was sent successfully, False otherwise
"""
api_key = current_app.config.get(self.SENDGRID_API_KEY)
default_from = current_app.config.get(self.SENDGRID_FROM_EMAIL)
fallback_from = current_app.config.get(self.FROM_ADDRESS)
if not api_key:
logger.error(
f"[SENDGRID] Cannot send — missing SENDGRID_API_KEY config. "
f"Would have sent to: {message.to} | Subject: {message.subject}"
)
return False
from_address = message.from_address or default_from or fallback_from
if not from_address:
logger.error(
f"[SENDGRID] Cannot send — missing from address (SENDGRID_FROM_EMAIL or FROM_ADDRESS). "
f"Would have sent to: {message.to} | Subject: {message.subject}"
)
return False
payload = {
"personalizations": [
{
"to": [{"email": message.to}]
}
],
"from": {"email": from_address},
"subject": message.subject,
"content": [
{
"type": "text/plain",
"value": message.body
}
]
}
if message.html_body:
payload["content"].append({
"type": "text/html",
"value": message.html_body
})
try:
response = requests.post(
self.API_URL,
json=payload,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
)
if response.status_code == 202:
logger.info(f"[SENDGRID] Sent to {message.to} | Subject: {message.subject}")
return True
else:
logger.error(
f"[SENDGRID] Failed to send to {message.to}: "
f"status={response.status_code} body={response.text}"
)
return False
except Exception as e:
logger.error(f"[SENDGRID] Exception while sending to {message.to}: {e}")
return False
@@ -0,0 +1,92 @@
"""SMTP email provider implementation."""
import logging
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from flask import current_app
from gatehouse_app.services.email_provider import EmailMessage, EmailProvider
logger = logging.getLogger(__name__)
class SmtpEmailProvider(EmailProvider):
"""SMTP-based email provider implementation."""
# Configuration keys
EMAIL_ENABLED_KEY = "EMAIL_ENABLED"
SMTP_HOST_KEY = "SMTP_HOST"
SMTP_PORT_KEY = "SMTP_PORT"
SMTP_USERNAME_KEY = "SMTP_USERNAME"
SMTP_PASSWORD_KEY = "SMTP_PASSWORD"
SMTP_USE_TLS_KEY = "SMTP_USE_TLS"
FROM_ADDRESS_KEY = "FROM_ADDRESS"
def send(self, message: EmailMessage) -> bool:
"""Send an email via SMTP.
Args:
message: EmailMessage instance containing email details
Returns:
bool: True if email was sent successfully, False otherwise
"""
email_enabled = current_app.config.get(self.EMAIL_ENABLED_KEY, False)
if not email_enabled:
logger.info(
f"[EMAIL DISABLED] Would have sent to: {message.to} | "
f"Subject: {message.subject}"
)
return False
smtp_host = current_app.config.get(self.SMTP_HOST_KEY, "")
from_address = message.from_address or current_app.config.get(self.FROM_ADDRESS_KEY, "")
missing = [k for k, v in [("SMTP_HOST", smtp_host), ("FROM_ADDRESS", from_address)] if not v]
if missing:
logger.error(
f"[EMAIL] Cannot send — missing config: {', '.join(missing)}. "
f"Would have sent to: {message.to} | Subject: {message.subject}"
)
return False
smtp_port_raw = current_app.config.get(self.SMTP_PORT_KEY, 587)
try:
smtp_port = int(smtp_port_raw)
except (TypeError, ValueError):
logger.error(f"[EMAIL] Invalid SMTP_PORT value: {smtp_port_raw!r}")
return False
smtp_username = current_app.config.get(self.SMTP_USERNAME_KEY)
smtp_password = current_app.config.get(self.SMTP_PASSWORD_KEY)
smtp_use_tls = current_app.config.get(
self.SMTP_USE_TLS_KEY,
smtp_port not in (25, 1025),
)
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = message.subject
msg["From"] = from_address
msg["To"] = message.to
msg.attach(MIMEText(message.body, "plain"))
if message.html_body:
msg.attach(MIMEText(message.html_body, "html"))
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.ehlo()
if smtp_use_tls:
server.starttls()
server.ehlo()
if smtp_username and smtp_password:
server.login(smtp_username, smtp_password)
server.send_message(msg)
logger.info(f"[EMAIL] Sent to {message.to} | Subject: {message.subject}")
return True
except Exception as e:
logger.error(f"[EMAIL] Failed to send to {message.to}: {e}")
return False
+3 -3
View File
@@ -75,14 +75,14 @@ class TOTPService:
return secret
@staticmethod
def generate_provisioning_uri(user_email: str, secret: str, issuer: str = "Gatehouse") -> str:
def generate_provisioning_uri(user_email: str, secret: str, issuer: str = "Secuird") -> str:
"""
Generate provisioning URI for QR code.
Args:
user_email: User's email address
secret: TOTP secret (base32 encoded)
issuer: Issuer name (default: "Gatehouse")
issuer: Issuer name (default: "Secuird")
Returns:
otpauth:// URI for QR code generation
@@ -90,7 +90,7 @@ class TOTPService:
Example:
>>> uri = TOTPService.generate_provisioning_uri("user@example.com", "JBSWY3DPEHPK3PXP")
>>> print(uri)
otpauth://totp/Gatehouse:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Gatehouse
otpauth://totp/Secuird:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Secuird
"""
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(name=user_email, issuer_name=issuer)
+1 -1
View File
@@ -167,7 +167,7 @@ class WebAuthnService:
# Get RP configuration
rp_id = current_app.config.get('WEBAUTHN_RP_ID', 'localhost')
rp_name = current_app.config.get('WEBAUTHN_RP_NAME', 'Gatehouse')
rp_name = current_app.config.get('WEBAUTHN_RP_NAME', 'Secuird')
# Generate user ID (Base64URL encoded)
user_id = cls._base64url_encode(user.id.encode('utf-8'))
-357
View File
@@ -1,357 +0,0 @@
"""empty message
Revision ID: 0abed208e728
Revises: None
Create Date: 2026-01-11 16:07:05.491356
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('organizations',
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('slug', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('logo_url', sa.String(length=512), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('settings', sa.JSON(), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
)
op.create_index(op.f('ix_organizations_slug'), 'organizations', ['slug'], unique=True)
op.create_table('users',
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('email_verified', sa.Boolean(), nullable=False),
sa.Column('full_name', sa.String(length=255), nullable=True),
sa.Column('avatar_url', sa.String(length=512), nullable=True),
sa.Column('status', sa.Enum('ACTIVE', 'INACTIVE', 'SUSPENDED', 'PENDING', name='userstatus'), nullable=False),
sa.Column('last_login_at', sa.DateTime(), nullable=True),
sa.Column('last_login_ip', sa.String(length=45), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_status'), 'users', ['status'], unique=False)
op.create_table('audit_logs',
sa.Column('user_id', sa.String(length=36), nullable=True),
sa.Column('action', sa.Enum('USER_LOGIN', 'USER_LOGOUT', 'USER_REGISTER', 'USER_UPDATE', 'USER_DELETE', 'PASSWORD_CHANGE', 'PASSWORD_RESET', 'ORG_CREATE', 'ORG_UPDATE', 'ORG_DELETE', 'ORG_MEMBER_ADD', 'ORG_MEMBER_REMOVE', 'ORG_MEMBER_ROLE_CHANGE', 'SESSION_CREATE', 'SESSION_REVOKE', 'AUTH_METHOD_ADD', 'AUTH_METHOD_REMOVE', name='auditaction'), nullable=False),
sa.Column('resource_type', sa.String(length=50), nullable=True),
sa.Column('resource_id', sa.String(length=36), nullable=True),
sa.Column('organization_id', sa.String(length=36), nullable=True),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('user_agent', sa.Text(), nullable=True),
sa.Column('request_id', sa.String(length=36), nullable=True),
sa.Column('extra_data', sa.JSON(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('success', sa.Boolean(), nullable=False),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
)
op.create_index('idx_audit_org', 'audit_logs', ['organization_id', 'created_at'], unique=False)
op.create_index('idx_audit_resource', 'audit_logs', ['resource_type', 'resource_id'], unique=False)
op.create_index('idx_audit_user_action', 'audit_logs', ['user_id', 'action'], unique=False)
op.create_index(op.f('ix_audit_logs_action'), 'audit_logs', ['action'], unique=False)
op.create_index(op.f('ix_audit_logs_organization_id'), 'audit_logs', ['organization_id'], unique=False)
op.create_index(op.f('ix_audit_logs_request_id'), 'audit_logs', ['request_id'], unique=False)
op.create_index(op.f('ix_audit_logs_resource_id'), 'audit_logs', ['resource_id'], unique=False)
op.create_index(op.f('ix_audit_logs_resource_type'), 'audit_logs', ['resource_type'], unique=False)
op.create_index(op.f('ix_audit_logs_user_id'), 'audit_logs', ['user_id'], unique=False)
op.create_table('authentication_methods',
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('method_type', sa.Enum('PASSWORD', 'GOOGLE', 'GITHUB', 'MICROSOFT', 'SAML', 'OIDC', name='authmethodtype'), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=True),
sa.Column('provider_user_id', sa.String(length=255), nullable=True),
sa.Column('provider_data', sa.JSON(), nullable=True),
sa.Column('is_primary', sa.Boolean(), nullable=False),
sa.Column('verified', sa.Boolean(), nullable=False),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('user_id', 'method_type', 'provider_user_id', name='uix_user_method_provider')
)
op.create_index('idx_user_method', 'authentication_methods', ['user_id', 'method_type'], unique=False)
op.create_index(op.f('ix_authentication_methods_method_type'), 'authentication_methods', ['method_type'], unique=False)
op.create_index(op.f('ix_authentication_methods_user_id'), 'authentication_methods', ['user_id'], unique=False)
op.create_table('oidc_clients',
sa.Column('organization_id', sa.String(length=36), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('client_id', sa.String(length=255), nullable=False),
sa.Column('client_secret_hash', sa.String(length=255), nullable=False),
sa.Column('redirect_uris', sa.JSON(), nullable=False),
sa.Column('grant_types', sa.JSON(), nullable=False),
sa.Column('response_types', sa.JSON(), nullable=False),
sa.Column('scopes', sa.JSON(), nullable=False),
sa.Column('logo_uri', sa.String(length=512), nullable=True),
sa.Column('client_uri', sa.String(length=512), nullable=True),
sa.Column('policy_uri', sa.String(length=512), nullable=True),
sa.Column('tos_uri', sa.String(length=512), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('is_confidential', sa.Boolean(), nullable=False),
sa.Column('require_pkce', sa.Boolean(), nullable=False),
sa.Column('access_token_lifetime', sa.Integer(), nullable=False),
sa.Column('refresh_token_lifetime', sa.Integer(), nullable=False),
sa.Column('id_token_lifetime', sa.Integer(), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
)
op.create_index(op.f('ix_oidc_clients_client_id'), 'oidc_clients', ['client_id'], unique=True)
op.create_index(op.f('ix_oidc_clients_organization_id'), 'oidc_clients', ['organization_id'], unique=False)
op.create_table('organization_members',
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('organization_id', sa.String(length=36), nullable=False),
sa.Column('role', sa.Enum('OWNER', 'ADMIN', 'MEMBER', 'GUEST', name='organizationrole'), nullable=False),
sa.Column('invited_by_id', sa.String(length=36), nullable=True),
sa.Column('invited_at', sa.DateTime(), nullable=True),
sa.Column('joined_at', sa.DateTime(), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['invited_by_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('user_id', 'organization_id', name='uix_user_org')
)
op.create_index(op.f('ix_organization_members_organization_id'), 'organization_members', ['organization_id'], unique=False)
op.create_index(op.f('ix_organization_members_user_id'), 'organization_members', ['user_id'], unique=False)
op.create_table('sessions',
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('token', sa.String(length=255), nullable=False),
sa.Column('status', sa.Enum('ACTIVE', 'EXPIRED', 'REVOKED', name='sessionstatus'), nullable=False),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('user_agent', sa.Text(), nullable=True),
sa.Column('device_info', sa.JSON(), nullable=True),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('last_activity_at', sa.DateTime(), nullable=False),
sa.Column('revoked_at', sa.DateTime(), nullable=True),
sa.Column('revoked_reason', sa.String(length=255), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
)
op.create_index(op.f('ix_sessions_token'), 'sessions', ['token'], unique=True)
op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'], unique=False)
op.create_table('oidc_audit_logs',
sa.Column('event_type', sa.String(length=100), nullable=False),
sa.Column('client_id', sa.String(length=255), nullable=True),
sa.Column('user_id', sa.String(length=36), nullable=True),
sa.Column('success', sa.Boolean(), nullable=False),
sa.Column('error_code', sa.String(length=100), nullable=True),
sa.Column('error_description', sa.Text(), nullable=True),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('user_agent', sa.Text(), nullable=True),
sa.Column('request_id', sa.String(length=36), nullable=True),
sa.Column('event_metadata', sa.JSON(), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['client_id'], ['oidc_clients.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
)
op.create_index(op.f('ix_oidc_audit_logs_client_id'), 'oidc_audit_logs', ['client_id'], unique=False)
op.create_index(op.f('ix_oidc_audit_logs_event_type'), 'oidc_audit_logs', ['event_type'], unique=False)
op.create_index(op.f('ix_oidc_audit_logs_ip_address'), 'oidc_audit_logs', ['ip_address'], unique=False)
op.create_index(op.f('ix_oidc_audit_logs_request_id'), 'oidc_audit_logs', ['request_id'], unique=False)
op.create_index(op.f('ix_oidc_audit_logs_success'), 'oidc_audit_logs', ['success'], unique=False)
op.create_index(op.f('ix_oidc_audit_logs_user_id'), 'oidc_audit_logs', ['user_id'], unique=False)
op.create_table('oidc_authorization_codes',
sa.Column('client_id', sa.String(length=255), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('code_hash', sa.String(length=255), nullable=False),
sa.Column('redirect_uri', sa.String(length=512), nullable=False),
sa.Column('scope', sa.JSON(), nullable=True),
sa.Column('nonce', sa.String(length=255), nullable=True),
sa.Column('code_verifier', sa.String(length=255), nullable=True),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('used_at', sa.DateTime(), nullable=True),
sa.Column('is_used', sa.Boolean(), nullable=False),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('user_agent', sa.Text(), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['client_id'], ['oidc_clients.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
)
op.create_index(op.f('ix_oidc_authorization_codes_client_id'), 'oidc_authorization_codes', ['client_id'], unique=False)
op.create_index(op.f('ix_oidc_authorization_codes_expires_at'), 'oidc_authorization_codes', ['expires_at'], unique=False)
op.create_index(op.f('ix_oidc_authorization_codes_user_id'), 'oidc_authorization_codes', ['user_id'], unique=False)
op.create_table('oidc_refresh_tokens',
sa.Column('client_id', sa.String(length=255), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('token_hash', sa.String(length=255), nullable=False),
sa.Column('access_token_id', sa.String(length=36), nullable=True),
sa.Column('scope', sa.JSON(), nullable=True),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('revoked_at', sa.DateTime(), nullable=True),
sa.Column('revoked_reason', sa.String(length=255), nullable=True),
sa.Column('previous_token_hash', sa.String(length=255), nullable=True),
sa.Column('rotation_count', sa.Integer(), nullable=False),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('user_agent', sa.Text(), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['access_token_id'], ['sessions.id'], ),
sa.ForeignKeyConstraint(['client_id'], ['oidc_clients.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
)
op.create_index(op.f('ix_oidc_refresh_tokens_access_token_id'), 'oidc_refresh_tokens', ['access_token_id'], unique=False)
op.create_index(op.f('ix_oidc_refresh_tokens_client_id'), 'oidc_refresh_tokens', ['client_id'], unique=False)
op.create_index(op.f('ix_oidc_refresh_tokens_expires_at'), 'oidc_refresh_tokens', ['expires_at'], unique=False)
op.create_index(op.f('ix_oidc_refresh_tokens_token_hash'), 'oidc_refresh_tokens', ['token_hash'], unique=True)
op.create_index(op.f('ix_oidc_refresh_tokens_user_id'), 'oidc_refresh_tokens', ['user_id'], unique=False)
op.create_table('oidc_sessions',
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('client_id', sa.String(length=255), nullable=False),
sa.Column('state', sa.String(length=255), nullable=False),
sa.Column('nonce', sa.String(length=255), nullable=True),
sa.Column('redirect_uri', sa.String(length=512), nullable=False),
sa.Column('scope', sa.JSON(), nullable=True),
sa.Column('code_challenge', sa.String(length=255), nullable=True),
sa.Column('code_challenge_method', sa.String(length=10), nullable=True),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('authenticated_at', sa.DateTime(), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['client_id'], ['oidc_clients.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
)
op.create_index(op.f('ix_oidc_sessions_client_id'), 'oidc_sessions', ['client_id'], unique=False)
op.create_index(op.f('ix_oidc_sessions_expires_at'), 'oidc_sessions', ['expires_at'], unique=False)
op.create_index(op.f('ix_oidc_sessions_state'), 'oidc_sessions', ['state'], unique=False)
op.create_index(op.f('ix_oidc_sessions_user_id'), 'oidc_sessions', ['user_id'], unique=False)
op.create_table('oidc_token_metadata',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('client_id', sa.String(length=255), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('token_type', sa.String(length=50), nullable=False),
sa.Column('token_jti', sa.String(length=255), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('revoked_at', sa.DateTime(), nullable=True),
sa.Column('revoked_reason', sa.String(length=255), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['client_id'], ['oidc_clients.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_oidc_token_metadata_client_id'), 'oidc_token_metadata', ['client_id'], unique=False)
op.create_index(op.f('ix_oidc_token_metadata_expires_at'), 'oidc_token_metadata', ['expires_at'], unique=False)
op.create_index(op.f('ix_oidc_token_metadata_token_jti'), 'oidc_token_metadata', ['token_jti'], unique=False)
op.create_index(op.f('ix_oidc_token_metadata_user_id'), 'oidc_token_metadata', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_oidc_token_metadata_user_id'), table_name='oidc_token_metadata')
op.drop_index(op.f('ix_oidc_token_metadata_token_jti'), table_name='oidc_token_metadata')
op.drop_index(op.f('ix_oidc_token_metadata_expires_at'), table_name='oidc_token_metadata')
op.drop_index(op.f('ix_oidc_token_metadata_client_id'), table_name='oidc_token_metadata')
op.drop_table('oidc_token_metadata')
op.drop_index(op.f('ix_oidc_sessions_user_id'), table_name='oidc_sessions')
op.drop_index(op.f('ix_oidc_sessions_state'), table_name='oidc_sessions')
op.drop_index(op.f('ix_oidc_sessions_expires_at'), table_name='oidc_sessions')
op.drop_index(op.f('ix_oidc_sessions_client_id'), table_name='oidc_sessions')
op.drop_table('oidc_sessions')
op.drop_index(op.f('ix_oidc_refresh_tokens_user_id'), table_name='oidc_refresh_tokens')
op.drop_index(op.f('ix_oidc_refresh_tokens_token_hash'), table_name='oidc_refresh_tokens')
op.drop_index(op.f('ix_oidc_refresh_tokens_expires_at'), table_name='oidc_refresh_tokens')
op.drop_index(op.f('ix_oidc_refresh_tokens_client_id'), table_name='oidc_refresh_tokens')
op.drop_index(op.f('ix_oidc_refresh_tokens_access_token_id'), table_name='oidc_refresh_tokens')
op.drop_table('oidc_refresh_tokens')
op.drop_index(op.f('ix_oidc_authorization_codes_user_id'), table_name='oidc_authorization_codes')
op.drop_index(op.f('ix_oidc_authorization_codes_expires_at'), table_name='oidc_authorization_codes')
op.drop_index(op.f('ix_oidc_authorization_codes_client_id'), table_name='oidc_authorization_codes')
op.drop_table('oidc_authorization_codes')
op.drop_index(op.f('ix_oidc_audit_logs_user_id'), table_name='oidc_audit_logs')
op.drop_index(op.f('ix_oidc_audit_logs_success'), table_name='oidc_audit_logs')
op.drop_index(op.f('ix_oidc_audit_logs_request_id'), table_name='oidc_audit_logs')
op.drop_index(op.f('ix_oidc_audit_logs_ip_address'), table_name='oidc_audit_logs')
op.drop_index(op.f('ix_oidc_audit_logs_event_type'), table_name='oidc_audit_logs')
op.drop_index(op.f('ix_oidc_audit_logs_client_id'), table_name='oidc_audit_logs')
op.drop_table('oidc_audit_logs')
op.drop_index(op.f('ix_sessions_user_id'), table_name='sessions')
op.drop_index(op.f('ix_sessions_token'), table_name='sessions')
op.drop_table('sessions')
op.drop_index(op.f('ix_organization_members_user_id'), table_name='organization_members')
op.drop_index(op.f('ix_organization_members_organization_id'), table_name='organization_members')
op.drop_table('organization_members')
op.drop_index(op.f('ix_oidc_clients_organization_id'), table_name='oidc_clients')
op.drop_index(op.f('ix_oidc_clients_client_id'), table_name='oidc_clients')
op.drop_table('oidc_clients')
op.drop_index(op.f('ix_authentication_methods_user_id'), table_name='authentication_methods')
op.drop_index(op.f('ix_authentication_methods_method_type'), table_name='authentication_methods')
op.drop_index('idx_user_method', table_name='authentication_methods')
op.drop_table('authentication_methods')
op.drop_index(op.f('ix_audit_logs_user_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_resource_type'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_resource_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_request_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_organization_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_action'), table_name='audit_logs')
op.drop_index('idx_audit_user_action', table_name='audit_logs')
op.drop_index('idx_audit_resource', table_name='audit_logs')
op.drop_index('idx_audit_org', table_name='audit_logs')
op.drop_table('audit_logs')
op.drop_index(op.f('ix_users_status'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_organizations_slug'), table_name='organizations')
op.drop_table('organizations')
# ### end Alembic commands ###
@@ -1,53 +0,0 @@
"""Database migration: Add TOTP support to authentication_methods table.
Revision ID: 002
Revises: 0abed208e728
Create Date: 2026-01-11 00:00:00
This migration adds TOTP (Time-based One-Time Password) support to the
authentication_methods table by adding three new columns:
- totp_secret: Stores the TOTP secret key
- totp_backup_codes: Stores backup codes for account recovery
- totp_verified_at: Tracks when TOTP was verified
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# Revision identifiers
revision = '002'
down_revision = '001'
branch_labels = None
depends_on = None
def upgrade():
"""Add TOTP columns to authentication_methods table."""
# Add TOTP secret column
op.add_column(
'authentication_methods',
sa.Column('totp_secret', sa.String(32), nullable=True)
)
# Add TOTP backup codes column (JSON type for PostgreSQL)
op.add_column(
'authentication_methods',
sa.Column('totp_backup_codes', postgresql.JSON, nullable=True)
)
# Add TOTP verified at column
op.add_column(
'authentication_methods',
sa.Column('totp_verified_at', sa.DateTime, nullable=True)
)
def downgrade():
"""Remove TOTP columns from authentication_methods table."""
# Remove TOTP columns in reverse order of addition
op.drop_column('authentication_methods', 'totp_verified_at')
op.drop_column('authentication_methods', 'totp_backup_codes')
op.drop_column('authentication_methods', 'totp_secret')
@@ -1,50 +0,0 @@
"""Database migration: Create oidc_jwks_keys table.
Revision ID: 002
Revises: 001
Create Date: 2024-01-01 00:00:00
This migration creates the oidc_jwks_keys table for persisting OIDC signing keys.
"""
from alembic import op
import sqlalchemy as sa
# Revision identifiers
revision = '003'
down_revision = '002'
branch_labels = None
depends_on = None
def upgrade():
"""Create oidc_jwks_keys table."""
op.create_table(
'oidc_jwks_keys',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('created_at', sa.DateTime, nullable=False),
sa.Column('updated_at', sa.DateTime, nullable=False),
sa.Column('expires_at', sa.DateTime, nullable=True),
sa.Column('deleted_at', sa.DateTime, nullable=True),
sa.Column('kid', sa.String(255), nullable=False),
sa.Column('key_type', sa.String(50), nullable=False),
sa.Column('private_key', sa.Text, nullable=False),
sa.Column('public_key', sa.Text, nullable=False),
sa.Column('algorithm', sa.String(50), nullable=False),
sa.Column('is_active', sa.Boolean, default=True, nullable=False),
sa.Column('is_primary', sa.Boolean, default=False, nullable=False),
)
# Create unique index on kid
op.create_index('ix_oidc_jwks_keys_kid', 'oidc_jwks_keys', ['kid'], unique=True)
# Create index on is_active for filtering active keys
op.create_index('ix_oidc_jwks_keys_is_active', 'oidc_jwks_keys', ['is_active'])
def downgrade():
"""Drop oidc_jwks_keys table."""
op.drop_index('ix_oidc_jwks_keys_is_active', table_name='oidc_jwks_keys')
op.drop_index('ix_oidc_jwks_keys_kid', table_name='oidc_jwks_keys')
op.drop_table('oidc_jwks_keys')
-122
View File
@@ -1,122 +0,0 @@
"""empty message
Revision ID: 5d99e6d4cdc6
Revises: 003
Create Date: 2026-01-16 15:31:36.288933
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '004'
down_revision = '003'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('mfa_policy_compliance',
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('organization_id', sa.String(length=36), nullable=False),
sa.Column('status', sa.Enum('NOT_APPLICABLE', 'PENDING', 'IN_GRACE', 'COMPLIANT', 'PAST_DUE', 'SUSPENDED', name='mfacompliancestatus'), nullable=False),
sa.Column('policy_version', sa.Integer(), nullable=False),
sa.Column('applied_at', sa.DateTime(), nullable=True),
sa.Column('deadline_at', sa.DateTime(), nullable=True),
sa.Column('compliant_at', sa.DateTime(), nullable=True),
sa.Column('suspended_at', sa.DateTime(), nullable=True),
sa.Column('last_notified_at', sa.DateTime(), nullable=True),
sa.Column('notification_count', sa.Integer(), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('user_id', 'organization_id', name='uix_user_org_compliance')
)
op.create_index(op.f('ix_mfa_policy_compliance_organization_id'), 'mfa_policy_compliance', ['organization_id'], unique=False)
op.create_index(op.f('ix_mfa_policy_compliance_user_id'), 'mfa_policy_compliance', ['user_id'], unique=False)
op.create_table('organization_security_policies',
sa.Column('organization_id', sa.String(length=36), nullable=False),
sa.Column('mfa_policy_mode', sa.Enum('DISABLED', 'OPTIONAL', 'REQUIRE_TOTP', 'REQUIRE_WEBAUTHN', 'REQUIRE_TOTP_OR_WEBAUTHN', name='mfapolicymode'), nullable=False),
sa.Column('mfa_grace_period_days', sa.Integer(), nullable=False),
sa.Column('notify_days_before', sa.Integer(), nullable=False),
sa.Column('policy_version', sa.Integer(), nullable=False),
sa.Column('updated_by_user_id', sa.String(length=36), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.ForeignKeyConstraint(['updated_by_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
)
op.create_index(op.f('ix_organization_security_policies_organization_id'), 'organization_security_policies', ['organization_id'], unique=True)
op.create_table('user_security_policies',
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('organization_id', sa.String(length=36), nullable=False),
sa.Column('mfa_override_mode', sa.Enum('INHERIT', 'REQUIRED', 'EXEMPT', name='mfarequirementoverride'), nullable=False),
sa.Column('force_totp', sa.Boolean(), nullable=False),
sa.Column('force_webauthn', sa.Boolean(), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('user_id', 'organization_id', name='uix_user_org_policy')
)
op.create_index(op.f('ix_user_security_policies_organization_id'), 'user_security_policies', ['organization_id'], unique=False)
op.create_index(op.f('ix_user_security_policies_user_id'), 'user_security_policies', ['user_id'], unique=False)
# Use batch operations for SQLite-compatible column type changes
with op.batch_alter_table('audit_logs', schema=None) as batch_op:
batch_op.alter_column('action',
existing_type=sa.VARCHAR(length=22),
type_=sa.Enum('USER_LOGIN', 'USER_LOGOUT', 'USER_REGISTER', 'USER_UPDATE', 'USER_DELETE', 'PASSWORD_CHANGE', 'PASSWORD_RESET', 'ORG_CREATE', 'ORG_UPDATE', 'ORG_DELETE', 'ORG_MEMBER_ADD', 'ORG_MEMBER_REMOVE', 'ORG_MEMBER_ROLE_CHANGE', 'SESSION_CREATE', 'SESSION_REVOKE', 'AUTH_METHOD_ADD', 'AUTH_METHOD_REMOVE', 'TOTP_ENROLL_INITIATED', 'TOTP_ENROLL_COMPLETED', 'TOTP_VERIFY_SUCCESS', 'TOTP_VERIFY_FAILED', 'TOTP_DISABLED', 'TOTP_BACKUP_CODE_USED', 'TOTP_BACKUP_CODES_REGENERATED', 'WEBAUTHN_REGISTER_INITIATED', 'WEBAUTHN_REGISTER_COMPLETED', 'WEBAUTHN_REGISTER_FAILED', 'WEBAUTHN_LOGIN_INITIATED', 'WEBAUTHN_LOGIN_SUCCESS', 'WEBAUTHN_LOGIN_FAILED', 'WEBAUTHN_CREDENTIAL_DELETED', 'WEBAUTHN_CREDENTIAL_RENAMED', 'ORG_SECURITY_POLICY_UPDATE', 'USER_SECURITY_POLICY_OVERRIDE_UPDATE', 'MFA_POLICY_USER_SUSPENDED', 'MFA_POLICY_USER_COMPLIANT', name='auditaction'),
existing_nullable=False)
op.drop_index(op.f('ix_oidc_jwks_keys_is_active'), table_name='oidc_jwks_keys')
op.add_column('sessions', sa.Column('is_compliance_only', sa.Boolean(), nullable=False))
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.alter_column('status',
existing_type=sa.VARCHAR(length=9),
type_=sa.Enum('ACTIVE', 'INACTIVE', 'SUSPENDED', 'PENDING', 'COMPLIANCE_SUSPENDED', name='userstatus'),
existing_nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.alter_column('status',
existing_type=sa.Enum('ACTIVE', 'INACTIVE', 'SUSPENDED', 'PENDING', 'COMPLIANCE_SUSPENDED', name='userstatus'),
type_=sa.VARCHAR(length=9),
existing_nullable=False)
op.drop_column('sessions', 'is_compliance_only')
op.create_index(op.f('ix_oidc_jwks_keys_is_active'), 'oidc_jwks_keys', ['is_active'], unique=False)
with op.batch_alter_table('audit_logs', schema=None) as batch_op:
batch_op.alter_column('action',
existing_type=sa.Enum('USER_LOGIN', 'USER_LOGOUT', 'USER_REGISTER', 'USER_UPDATE', 'USER_DELETE', 'PASSWORD_CHANGE', 'PASSWORD_RESET', 'ORG_CREATE', 'ORG_UPDATE', 'ORG_DELETE', 'ORG_MEMBER_ADD', 'ORG_MEMBER_REMOVE', 'ORG_MEMBER_ROLE_CHANGE', 'SESSION_CREATE', 'SESSION_REVOKE', 'AUTH_METHOD_ADD', 'AUTH_METHOD_REMOVE', 'TOTP_ENROLL_INITIATED', 'TOTP_ENROLL_COMPLETED', 'TOTP_VERIFY_SUCCESS', 'TOTP_VERIFY_FAILED', 'TOTP_DISABLED', 'TOTP_BACKUP_CODE_USED', 'TOTP_BACKUP_CODES_REGENERATED', 'WEBAUTHN_REGISTER_INITIATED', 'WEBAUTHN_REGISTER_COMPLETED', 'WEBAUTHN_REGISTER_FAILED', 'WEBAUTHN_LOGIN_INITIATED', 'WEBAUTHN_LOGIN_SUCCESS', 'WEBAUTHN_LOGIN_FAILED', 'WEBAUTHN_CREDENTIAL_DELETED', 'WEBAUTHN_CREDENTIAL_RENAMED', 'ORG_SECURITY_POLICY_UPDATE', 'USER_SECURITY_POLICY_OVERRIDE_UPDATE', 'MFA_POLICY_USER_SUSPENDED', 'MFA_POLICY_USER_COMPLIANT', name='auditaction'),
type_=sa.VARCHAR(length=22),
existing_nullable=False)
op.drop_index(op.f('ix_user_security_policies_user_id'), table_name='user_security_policies')
op.drop_index(op.f('ix_user_security_policies_organization_id'), table_name='user_security_policies')
op.drop_table('user_security_policies')
op.drop_index(op.f('ix_organization_security_policies_organization_id'), table_name='organization_security_policies')
op.drop_table('organization_security_policies')
op.drop_index(op.f('ix_mfa_policy_compliance_user_id'), table_name='mfa_policy_compliance')
op.drop_index(op.f('ix_mfa_policy_compliance_organization_id'), table_name='mfa_policy_compliance')
op.drop_table('mfa_policy_compliance')
# ### end Alembic commands ###
@@ -1,72 +0,0 @@
"""Fix oidc_refresh_tokens.access_token_id — widen column and drop wrong FK
The access_token_id column was VARCHAR(36) with a foreign key to sessions.id.
In practice the code stores JWT JTI strings (43+ chars) in this column, not
session UUIDs, so the FK constraint was wrong and the column was too narrow.
This migration:
1. Drops the foreign key constraint to sessions.id (IF EXISTS may have been
applied manually already via raw SQL)
2. Widens the column to VARCHAR(255)
Revision ID: 005
Revises: d2fd4f159054
Create Date: 2026-02-25
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.engine.reflection import Inspector
# revision identifiers, used by Alembic.
revision = '005'
down_revision = 'd2fd4f159054'
branch_labels = None
depends_on = None
def _fk_exists(conn, table_name, constraint_name):
"""Check whether a named FK constraint exists on a table."""
insp = Inspector.from_engine(conn)
fks = insp.get_foreign_keys(table_name)
return any(fk.get('name') == constraint_name for fk in fks)
def upgrade():
conn = op.get_bind()
# Drop the incorrect FK to sessions.id only if it still exists
# (may have been removed manually before this migration was written)
if _fk_exists(conn, 'oidc_refresh_tokens', 'oidc_refresh_tokens_access_token_id_fkey'):
op.drop_constraint(
'oidc_refresh_tokens_access_token_id_fkey',
'oidc_refresh_tokens',
type_='foreignkey'
)
# Widen the column to hold JWT JTI strings (43+ chars)
op.alter_column(
'oidc_refresh_tokens',
'access_token_id',
existing_type=sa.String(length=36),
type_=sa.String(length=255),
existing_nullable=True
)
def downgrade():
op.alter_column(
'oidc_refresh_tokens',
'access_token_id',
existing_type=sa.String(length=255),
type_=sa.String(length=36),
existing_nullable=True
)
# Re-add the FK constraint to sessions.id
op.create_foreign_key(
'oidc_refresh_tokens_access_token_id_fkey',
'oidc_refresh_tokens',
'sessions',
['access_token_id'],
['id']
)
@@ -1,127 +0,0 @@
"""Add Department and Principal models for SSH CA management.
Revision ID: 006
Revises: 005
Create Date: 2026-02-27 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '006'
down_revision = '005'
branch_labels = None
depends_on = None
def upgrade():
# ### Department table ###
op.create_table('departments',
sa.Column('organization_id', sa.String(length=36), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('organization_id', 'name', name='uix_org_dept_name')
)
op.create_index(op.f('ix_departments_organization_id'), 'departments', ['organization_id'], unique=False)
op.create_index(op.f('ix_departments_name'), 'departments', ['name'], unique=False)
# ### DepartmentMembership table ###
op.create_table('department_memberships',
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('department_id', sa.String(length=36), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['department_id'], ['departments.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('user_id', 'department_id', name='uix_user_dept')
)
op.create_index(op.f('ix_department_memberships_user_id'), 'department_memberships', ['user_id'], unique=False)
op.create_index(op.f('ix_department_memberships_department_id'), 'department_memberships', ['department_id'], unique=False)
# ### Principal table ###
op.create_table('principals',
sa.Column('organization_id', sa.String(length=36), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('organization_id', 'name', name='uix_org_principal_name')
)
op.create_index(op.f('ix_principals_organization_id'), 'principals', ['organization_id'], unique=False)
op.create_index(op.f('ix_principals_name'), 'principals', ['name'], unique=False)
# ### PrincipalMembership table ###
op.create_table('principal_memberships',
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('principal_id', sa.String(length=36), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['principal_id'], ['principals.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('user_id', 'principal_id', name='uix_user_principal')
)
op.create_index(op.f('ix_principal_memberships_user_id'), 'principal_memberships', ['user_id'], unique=False)
op.create_index(op.f('ix_principal_memberships_principal_id'), 'principal_memberships', ['principal_id'], unique=False)
# ### DepartmentPrincipal table ###
op.create_table('department_principals',
sa.Column('department_id', sa.String(length=36), nullable=False),
sa.Column('principal_id', sa.String(length=36), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['department_id'], ['departments.id'], ),
sa.ForeignKeyConstraint(['principal_id'], ['principals.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('department_id', 'principal_id', name='uix_dept_principal')
)
op.create_index(op.f('ix_department_principals_department_id'), 'department_principals', ['department_id'], unique=False)
op.create_index(op.f('ix_department_principals_principal_id'), 'department_principals', ['principal_id'], unique=False)
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_department_principals_principal_id'), table_name='department_principals')
op.drop_index(op.f('ix_department_principals_department_id'), table_name='department_principals')
op.drop_table('department_principals')
op.drop_index(op.f('ix_principal_memberships_principal_id'), table_name='principal_memberships')
op.drop_index(op.f('ix_principal_memberships_user_id'), table_name='principal_memberships')
op.drop_table('principal_memberships')
op.drop_index(op.f('ix_principals_name'), table_name='principals')
op.drop_index(op.f('ix_principals_organization_id'), table_name='principals')
op.drop_table('principals')
op.drop_index(op.f('ix_department_memberships_department_id'), table_name='department_memberships')
op.drop_index(op.f('ix_department_memberships_user_id'), table_name='department_memberships')
op.drop_table('department_memberships')
op.drop_index(op.f('ix_departments_name'), table_name='departments')
op.drop_index(op.f('ix_departments_organization_id'), table_name='departments')
op.drop_table('departments')
# ### end Alembic commands ###
@@ -1,173 +0,0 @@
"""Add SSH CA models: SSHKey, SSHCertificate, CA, CertificateAuditLog.
Revision ID: 007
Revises: 006
Create Date: 2026-02-27 11:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '007'
down_revision = '006'
branch_labels = None
depends_on = None
def upgrade():
# ### CA table ###
op.create_table('cas',
sa.Column('organization_id', sa.String(length=36), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('key_type', sa.Enum('ed25519', 'rsa', 'ecdsa', name='ca_key_type_enum'), nullable=False),
sa.Column('private_key', sa.Text(), nullable=False),
sa.Column('public_key', sa.Text(), nullable=False),
sa.Column('fingerprint', sa.String(length=255), nullable=False),
sa.Column('crl_enabled', sa.Boolean(), nullable=False),
sa.Column('crl_endpoint', sa.String(length=512), nullable=True),
sa.Column('default_cert_validity_hours', sa.Integer(), nullable=False),
sa.Column('max_cert_validity_hours', sa.Integer(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('rotated_at', sa.DateTime(), nullable=True),
sa.Column('rotation_reason', sa.String(length=255), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('fingerprint'),
sa.UniqueConstraint('organization_id', 'name', name='uix_org_ca_name')
)
op.create_index(op.f('ix_cas_organization_id'), 'cas', ['organization_id'], unique=False)
op.create_index('idx_ca_org_active', 'cas', ['organization_id', 'is_active'], unique=False)
# ### SSHKey table ###
op.create_table('ssh_keys',
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('payload', sa.Text(), nullable=False),
sa.Column('fingerprint', sa.String(length=255), nullable=False),
sa.Column('description', sa.String(length=255), nullable=True),
sa.Column('verified', sa.Boolean(), nullable=False),
sa.Column('verified_at', sa.DateTime(), nullable=True),
sa.Column('verify_text', sa.String(length=255), nullable=True),
sa.Column('verify_text_created_at', sa.DateTime(), nullable=True),
sa.Column('key_type', sa.String(length=50), nullable=True),
sa.Column('key_bits', sa.Integer(), nullable=True),
sa.Column('key_comment', sa.String(length=255), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('payload'),
sa.UniqueConstraint('fingerprint')
)
op.create_index(op.f('ix_ssh_keys_user_id'), 'ssh_keys', ['user_id'], unique=False)
op.create_index(op.f('ix_ssh_keys_fingerprint'), 'ssh_keys', ['fingerprint'], unique=False)
op.create_index(op.f('ix_ssh_keys_verified'), 'ssh_keys', ['verified'], unique=False)
op.create_index('idx_ssh_key_user_verified', 'ssh_keys', ['user_id', 'verified'], unique=False)
# ### SSHCertificate table ###
op.create_table('ssh_certificates',
sa.Column('ca_id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('ssh_key_id', sa.String(length=36), nullable=False),
sa.Column('certificate', sa.Text(), nullable=False),
sa.Column('serial', sa.String(length=255), nullable=False),
sa.Column('key_id', sa.String(length=255), nullable=False),
sa.Column('cert_type', sa.Enum('user', 'host', name='ssh_cert_type_enum'), nullable=False),
sa.Column('principals', sa.JSON(), nullable=False),
sa.Column('valid_after', sa.DateTime(), nullable=False),
sa.Column('valid_before', sa.DateTime(), nullable=False),
sa.Column('revoked', sa.Boolean(), nullable=False),
sa.Column('revoked_at', sa.DateTime(), nullable=True),
sa.Column('revoke_reason', sa.String(length=255), nullable=True),
sa.Column('status', sa.Enum('requested', 'issued', 'revoked', 'expired', 'superseded', name='ssh_cert_status_enum'), nullable=False),
sa.Column('request_ip', sa.String(length=45), nullable=True),
sa.Column('request_user_agent', sa.String(length=512), nullable=True),
sa.Column('critical_options', sa.JSON(), nullable=True),
sa.Column('extensions', sa.JSON(), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['ca_id'], ['cas.id'], ),
sa.ForeignKeyConstraint(['ssh_key_id'], ['ssh_keys.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('serial')
)
op.create_index(op.f('ix_ssh_certificates_ca_id'), 'ssh_certificates', ['ca_id'], unique=False)
op.create_index(op.f('ix_ssh_certificates_user_id'), 'ssh_certificates', ['user_id'], unique=False)
op.create_index(op.f('ix_ssh_certificates_ssh_key_id'), 'ssh_certificates', ['ssh_key_id'], unique=False)
op.create_index(op.f('ix_ssh_certificates_serial'), 'ssh_certificates', ['serial'], unique=False)
op.create_index(op.f('ix_ssh_certificates_revoked'), 'ssh_certificates', ['revoked'], unique=False)
op.create_index(op.f('ix_ssh_certificates_status'), 'ssh_certificates', ['status'], unique=False)
op.create_index('idx_cert_user_status', 'ssh_certificates', ['user_id', 'status'], unique=False)
op.create_index('idx_cert_validity', 'ssh_certificates', ['valid_after', 'valid_before'], unique=False)
op.create_index('idx_cert_revoked', 'ssh_certificates', ['revoked', 'revoked_at'], unique=False)
# ### CertificateAuditLog table ###
op.create_table('certificate_audit_logs',
sa.Column('certificate_id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=True),
sa.Column('action', sa.String(length=50), nullable=False),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('user_agent', sa.String(length=512), nullable=True),
sa.Column('request_id', sa.String(length=36), nullable=True),
sa.Column('message', sa.Text(), nullable=True),
sa.Column('extra_data', sa.JSON(), nullable=True),
sa.Column('success', sa.Boolean(), nullable=False),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['certificate_id'], ['ssh_certificates.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
)
op.create_index(op.f('ix_certificate_audit_logs_certificate_id'), 'certificate_audit_logs', ['certificate_id'], unique=False)
op.create_index(op.f('ix_certificate_audit_logs_user_id'), 'certificate_audit_logs', ['user_id'], unique=False)
op.create_index(op.f('ix_certificate_audit_logs_action'), 'certificate_audit_logs', ['action'], unique=False)
op.create_index('idx_cert_audit_cert_action', 'certificate_audit_logs', ['certificate_id', 'action'], unique=False)
op.create_index('idx_cert_audit_user', 'certificate_audit_logs', ['user_id', 'created_at'], unique=False)
def downgrade():
op.drop_index('idx_cert_audit_user', table_name='certificate_audit_logs')
op.drop_index('idx_cert_audit_cert_action', table_name='certificate_audit_logs')
op.drop_index(op.f('ix_certificate_audit_logs_action'), table_name='certificate_audit_logs')
op.drop_index(op.f('ix_certificate_audit_logs_user_id'), table_name='certificate_audit_logs')
op.drop_index(op.f('ix_certificate_audit_logs_certificate_id'), table_name='certificate_audit_logs')
op.drop_table('certificate_audit_logs')
op.drop_index('idx_cert_revoked', table_name='ssh_certificates')
op.drop_index('idx_cert_validity', table_name='ssh_certificates')
op.drop_index('idx_cert_user_status', table_name='ssh_certificates')
op.drop_index(op.f('ix_ssh_certificates_status'), table_name='ssh_certificates')
op.drop_index(op.f('ix_ssh_certificates_revoked'), table_name='ssh_certificates')
op.drop_index(op.f('ix_ssh_certificates_serial'), table_name='ssh_certificates')
op.drop_index(op.f('ix_ssh_certificates_ssh_key_id'), table_name='ssh_certificates')
op.drop_index(op.f('ix_ssh_certificates_user_id'), table_name='ssh_certificates')
op.drop_index(op.f('ix_ssh_certificates_ca_id'), table_name='ssh_certificates')
op.drop_table('ssh_certificates')
op.drop_index('idx_ssh_key_user_verified', table_name='ssh_keys')
op.drop_index(op.f('ix_ssh_keys_verified'), table_name='ssh_keys')
op.drop_index(op.f('ix_ssh_keys_fingerprint'), table_name='ssh_keys')
op.drop_index(op.f('ix_ssh_keys_user_id'), table_name='ssh_keys')
op.drop_table('ssh_keys')
op.drop_index('idx_ca_org_active', table_name='cas')
op.drop_index(op.f('ix_cas_organization_id'), table_name='cas')
op.drop_table('cas')
@@ -1,53 +0,0 @@
"""Add TOTP and WEBAUTHN to authmethodtype enum.
Revision ID: 008
Revises: 007
Create Date: 2026-02-27 15:00:00.000000
The original migration (001_base) created authmethodtype with only:
PASSWORD, GOOGLE, GITHUB, MICROSOFT, SAML, OIDC
This migration adds the missing TOTP and WEBAUTHN values so
has_totp_enabled() and has_webauthn_enabled() queries work correctly.
"""
from alembic import op
import sqlalchemy as sa
revision = '008'
down_revision = '007'
branch_labels = None
depends_on = None
def upgrade():
# Add TOTP to the enum (idempotent approach using DO block)
op.execute("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'TOTP'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'authmethodtype')
) THEN
ALTER TYPE authmethodtype ADD VALUE 'TOTP';
END IF;
END$$;
""")
op.execute("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'WEBAUTHN'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'authmethodtype')
) THEN
ALTER TYPE authmethodtype ADD VALUE 'WEBAUTHN';
END IF;
END$$;
""")
def downgrade():
# PostgreSQL does not support removing enum values; downgrade is a no-op.
pass
@@ -1,61 +0,0 @@
"""Sync auditaction enum with all AuditAction Python enum values.
Revision ID: 009
Revises: 008
Create Date: 2026-02-27 15:20:00.000000
The auditaction DB enum was only created with the initial 17 values from 001_base.py.
All TOTP, WebAuthn, OAuth, SSH, CA, Principal, and Department audit actions were added
to the Python enum but never synced to the DB type.
"""
from alembic import op
revision = '009'
down_revision = '008'
branch_labels = None
depends_on = None
MISSING_VALUES = [
'TOTP_ENROLL_INITIATED', 'TOTP_ENROLL_COMPLETED', 'TOTP_VERIFY_SUCCESS',
'TOTP_VERIFY_FAILED', 'TOTP_DISABLED', 'TOTP_BACKUP_CODE_USED',
'TOTP_BACKUP_CODES_REGENERATED', 'WEBAUTHN_REGISTER_INITIATED',
'WEBAUTHN_REGISTER_COMPLETED', 'WEBAUTHN_REGISTER_FAILED',
'WEBAUTHN_LOGIN_INITIATED', 'WEBAUTHN_LOGIN_SUCCESS', 'WEBAUTHN_LOGIN_FAILED',
'WEBAUTHN_CREDENTIAL_DELETED', 'WEBAUTHN_CREDENTIAL_RENAMED',
'ORG_SECURITY_POLICY_UPDATE', 'USER_SECURITY_POLICY_OVERRIDE_UPDATE',
'MFA_POLICY_USER_SUSPENDED', 'MFA_POLICY_USER_COMPLIANT',
'EXTERNAL_AUTH_LINK_INITIATED', 'EXTERNAL_AUTH_LINK_COMPLETED',
'EXTERNAL_AUTH_LINK_FAILED', 'EXTERNAL_AUTH_UNLINK', 'EXTERNAL_AUTH_LOGIN',
'EXTERNAL_AUTH_LOGIN_FAILED', 'EXTERNAL_AUTH_TOKEN_REFRESH',
'EXTERNAL_AUTH_CONFIG_CREATE', 'EXTERNAL_AUTH_CONFIG_UPDATE',
'EXTERNAL_AUTH_CONFIG_DELETE', 'SSH_KEY_ADDED', 'SSH_KEY_VERIFIED',
'SSH_KEY_DELETED', 'SSH_KEY_VALIDATION_FAILED', 'SSH_CERT_REQUESTED',
'SSH_CERT_ISSUED', 'SSH_CERT_FAILED', 'SSH_CERT_REVOKED', 'SSH_CERT_EXPIRED',
'CA_CREATED', 'CA_UPDATED', 'CA_DELETED', 'CA_KEY_ROTATED',
'PRINCIPAL_CREATED', 'PRINCIPAL_UPDATED', 'PRINCIPAL_DELETED',
'PRINCIPAL_MEMBER_ADDED', 'PRINCIPAL_MEMBER_REMOVED',
'DEPARTMENT_CREATED', 'DEPARTMENT_UPDATED', 'DEPARTMENT_DELETED',
'DEPARTMENT_MEMBER_ADDED', 'DEPARTMENT_MEMBER_REMOVED',
]
def upgrade():
for val in MISSING_VALUES:
op.execute(f"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = '{val}'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'auditaction')
) THEN
ALTER TYPE auditaction ADD VALUE '{val}';
END IF;
END$$;
""")
def downgrade():
# PostgreSQL does not support removing enum values; downgrade is a no-op.
pass
@@ -1,50 +0,0 @@
"""add password reset and email verification token tables
Revision ID: 010_password_reset_email_verify
Revises: 009_sync_auditaction_enum
Create Date: 2025-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '010_password_reset_email_verify'
down_revision = '009'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'password_reset_tokens',
sa.Column('id', sa.String(36), primary_key=True, nullable=False),
sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('token', sa.String(128), nullable=False, unique=True),
sa.Column('expires_at', sa.DateTime, nullable=False),
sa.Column('used_at', sa.DateTime, nullable=True),
sa.Column('created_at', sa.DateTime, nullable=False),
sa.Column('updated_at', sa.DateTime, nullable=False),
sa.Column('deleted_at', sa.DateTime, nullable=True),
)
op.create_index('ix_password_reset_tokens_user_id', 'password_reset_tokens', ['user_id'])
op.create_index('ix_password_reset_tokens_token', 'password_reset_tokens', ['token'])
op.create_table(
'email_verification_tokens',
sa.Column('id', sa.String(36), primary_key=True, nullable=False),
sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('token', sa.String(128), nullable=False, unique=True),
sa.Column('expires_at', sa.DateTime, nullable=False),
sa.Column('used_at', sa.DateTime, nullable=True),
sa.Column('created_at', sa.DateTime, nullable=False),
sa.Column('updated_at', sa.DateTime, nullable=False),
sa.Column('deleted_at', sa.DateTime, nullable=True),
)
op.create_index('ix_email_verification_tokens_user_id', 'email_verification_tokens', ['user_id'])
op.create_index('ix_email_verification_tokens_token', 'email_verification_tokens', ['token'])
def downgrade():
op.drop_table('email_verification_tokens')
op.drop_table('password_reset_tokens')
@@ -1,38 +0,0 @@
"""add org_invite_tokens table
Revision ID: 011_org_invite_tokens
Revises: 010_password_reset_email_verify
Create Date: 2025-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = '011_org_invite_tokens'
down_revision = '010_password_reset_email_verify'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'org_invite_tokens',
sa.Column('id', sa.String(36), primary_key=True, nullable=False),
sa.Column('organization_id', sa.String(36), sa.ForeignKey('organizations.id', ondelete='CASCADE'), nullable=False),
sa.Column('invited_by_id', sa.String(36), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True),
sa.Column('email', sa.String(255), nullable=False),
sa.Column('role', sa.String(64), nullable=False, server_default='member'),
sa.Column('token', sa.String(128), nullable=False, unique=True),
sa.Column('expires_at', sa.DateTime, nullable=False),
sa.Column('accepted_at', sa.DateTime, nullable=True),
sa.Column('created_at', sa.DateTime, nullable=False),
sa.Column('updated_at', sa.DateTime, nullable=False),
sa.Column('deleted_at', sa.DateTime, nullable=True),
)
op.create_index('ix_org_invite_tokens_organization_id', 'org_invite_tokens', ['organization_id'])
op.create_index('ix_org_invite_tokens_email', 'org_invite_tokens', ['email'])
op.create_index('ix_org_invite_tokens_token', 'org_invite_tokens', ['token'])
def downgrade():
op.drop_table('org_invite_tokens')
@@ -1,33 +0,0 @@
"""Make CA.organization_id nullable (system CA) and add cert_id to sign response
Revision ID: 012_ca_nullable_org_and_cert_serial
Revises: 011_org_invite_tokens
Create Date: 2025-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = '012_ca_nullable_org'
down_revision = '011_org_invite_tokens'
branch_labels = None
depends_on = None
def upgrade():
# Allow CA records without an org (e.g. the global system-config CA)
with op.batch_alter_table('cas', schema=None) as batch_op:
batch_op.alter_column(
'organization_id',
existing_type=sa.String(36),
nullable=True,
)
def downgrade():
with op.batch_alter_table('cas', schema=None) as batch_op:
batch_op.alter_column(
'organization_id',
existing_type=sa.String(36),
nullable=False,
)
-42
View File
@@ -1,42 +0,0 @@
"""Add ca_type column to cas table (user/host).
Revision ID: 013
Revises: d34bfb72844e
Create Date: 2026-02-28 23:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '013'
down_revision = 'd34bfb72844e'
branch_labels = None
depends_on = None
def upgrade():
# Create the enum type first (PostgreSQL requires this)
ca_type_enum = sa.Enum('user', 'host', name='ca_type_enum')
ca_type_enum.create(op.get_bind(), checkfirst=True)
# Add ca_type column with a default of 'user' so existing CAs stay valid
op.add_column(
'cas',
sa.Column(
'ca_type',
ca_type_enum,
nullable=False,
server_default='user',
),
)
def downgrade():
op.drop_column('cas', 'ca_type')
# Drop the enum type (PostgreSQL only; SQLite ignores)
try:
op.execute("DROP TYPE IF EXISTS ca_type_enum")
except Exception:
pass
@@ -1,44 +0,0 @@
"""add_department_cert_policies
Adds the department_cert_policies table which stores per-department
SSH certificate issuance rules:
- whether users may choose their own expiry
- default and maximum expiry durations
- allowed SSH certificate extensions
"""
from alembic import op
import sqlalchemy as sa
revision = "014_add_dept_cert_policy"
down_revision = "013"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"department_cert_policies",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("department_id", sa.String(36), sa.ForeignKey("departments.id"), nullable=False, unique=True),
# Whether users are allowed to specify their own expiry (up to max)
sa.Column("allow_user_expiry", sa.Boolean(), nullable=False, server_default="0"),
# Default validity in hours (used when user doesn't specify, or not allowed to)
sa.Column("default_expiry_hours", sa.Integer(), nullable=False, server_default="1"),
# Hard cap on validity; admin cannot be exceeded
sa.Column("max_expiry_hours", sa.Integer(), nullable=False, server_default="24"),
# JSON list of extension names that are enabled for this department
# e.g. ["permit-pty", "permit-agent-forwarding"]
sa.Column("allowed_extensions", sa.JSON(), nullable=False, server_default='["permit-pty","permit-agent-forwarding","permit-X11-forwarding","permit-port-forwarding","permit-user-rc"]'),
# Admin-defined custom extension names beyond the standard five
sa.Column("custom_extensions", sa.JSON(), nullable=False, server_default="[]"),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
)
op.create_index("idx_dept_cert_policy_dept", "department_cert_policies", ["department_id"])
def downgrade():
op.drop_index("idx_dept_cert_policy_dept", "department_cert_policies")
op.drop_table("department_cert_policies")
@@ -1,37 +0,0 @@
"""Add USER_SUSPEND and USER_UNSUSPEND to auditaction enum.
Revision ID: 015_add_user_suspend_audit_actions
Revises: 014_add_dept_cert_policy
Create Date: 2026-03-02
USER_SUSPEND and USER_UNSUSPEND were added to the Python AuditAction enum
but were never synced to the PostgreSQL auditaction type, causing a
DataError (invalid enum value) whenever an admin suspends or unsuspends a user.
"""
from alembic import op
revision = "015_user_suspend_audit"
down_revision = "014_add_dept_cert_policy"
branch_labels = None
depends_on = None
def upgrade():
for val in ("USER_SUSPEND", "USER_UNSUSPEND"):
op.execute(f"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = '{val}'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'auditaction')
) THEN
ALTER TYPE auditaction ADD VALUE '{val}';
END IF;
END$$;
""")
def downgrade():
# PostgreSQL does not support removing enum values; downgrade is a no-op.
pass
@@ -1,168 +0,0 @@
"""Encrypt existing plaintext CA private keys at rest.
Revision ID: 016_encrypt_existing_ca_keys
Revises: 015_add_user_suspend_audit_actions
Create Date: 2026-03-02
All CA private keys created before this migration were stored as plaintext PEM
strings in the ``cas.private_key`` column. This migration detects those rows
(by checking for the absence of the ``$fernet$`` prefix that encrypted values
carry) and re-encrypts them with the key derived from ``CA_ENCRYPTION_KEY``.
The migration is safe to re-run: already-encrypted rows are left untouched.
Prerequisites
-------------
``CA_ENCRYPTION_KEY`` must be set in the environment before running this
migration. The same value must be configured for the running application.
To roll back to plaintext (downgrade):
The ``downgrade()`` function decrypts all rows back to plaintext PEM. This is
provided only for emergency rollback and should not be used in production once
the system has been running with encrypted keys.
"""
import os
import base64
import hashlib
import logging
from alembic import op
import sqlalchemy as sa
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# Alembic revision identifiers
revision = "016_encrypt_ca_keys"
down_revision = "015_user_suspend_audit"
branch_labels = None
depends_on = None
_FERNET_PREFIX = "$fernet$"
def _get_fernet():
"""Build a Fernet instance from CA_ENCRYPTION_KEY env var."""
from cryptography.fernet import Fernet
raw_key = os.environ.get("CA_ENCRYPTION_KEY")
if not raw_key:
raise RuntimeError(
"CA_ENCRYPTION_KEY environment variable is not set. "
"Set it before running this migration."
)
key_bytes = base64.urlsafe_b64encode(hashlib.sha256(raw_key.encode()).digest())
return Fernet(key_bytes)
def upgrade():
"""Encrypt plaintext CA private keys."""
bind = op.get_bind()
session = Session(bind=bind)
try:
fernet = _get_fernet()
except RuntimeError as exc:
raise RuntimeError(str(exc)) from exc
# Fetch all non-deleted CA rows
rows = session.execute(
sa.text("SELECT id, private_key FROM cas WHERE deleted_at IS NULL")
).fetchall()
encrypted_count = 0
skipped_count = 0
for row in rows:
ca_id, private_key = row[0], row[1]
if not private_key:
logger.warning(f"CA {ca_id} has empty private_key — skipping")
skipped_count += 1
continue
if private_key.startswith(_FERNET_PREFIX):
# Already encrypted
skipped_count += 1
continue
# Encrypt
try:
token = fernet.encrypt(private_key.encode()).decode()
encrypted_value = f"{_FERNET_PREFIX}{token}"
session.execute(
sa.text("UPDATE cas SET private_key = :pk WHERE id = :id"),
{"pk": encrypted_value, "id": ca_id},
)
encrypted_count += 1
logger.info(f"Encrypted private key for CA {ca_id}")
except Exception as exc:
session.rollback()
raise RuntimeError(
f"Failed to encrypt private key for CA {ca_id}: {exc}"
) from exc
session.commit()
logger.info(
f"CA key encryption migration complete: "
f"{encrypted_count} encrypted, {skipped_count} skipped"
)
print(
f" [016_encrypt_ca_keys] {encrypted_count} CA private key(s) encrypted, "
f"{skipped_count} already encrypted or empty."
)
def downgrade():
"""Decrypt CA private keys back to plaintext (emergency rollback only)."""
bind = op.get_bind()
session = Session(bind=bind)
try:
fernet = _get_fernet()
except RuntimeError as exc:
raise RuntimeError(str(exc)) from exc
rows = session.execute(
sa.text("SELECT id, private_key FROM cas WHERE deleted_at IS NULL")
).fetchall()
decrypted_count = 0
skipped_count = 0
for row in rows:
ca_id, private_key = row[0], row[1]
if not private_key or not private_key.startswith(_FERNET_PREFIX):
skipped_count += 1
continue
token = private_key[len(_FERNET_PREFIX):]
try:
from cryptography.fernet import InvalidToken
try:
plaintext = fernet.decrypt(token.encode()).decode()
except InvalidToken as exc:
raise RuntimeError(
f"Downgrade failed: cannot decrypt CA {ca_id} — wrong key or corrupted data."
) from exc
session.execute(
sa.text("UPDATE cas SET private_key = :pk WHERE id = :id"),
{"pk": plaintext, "id": ca_id},
)
decrypted_count += 1
logger.warning(f"Decrypted (plaintext restore) private key for CA {ca_id}")
except RuntimeError:
session.rollback()
raise
session.commit()
logger.warning(
f"CA key decryption (downgrade) complete: "
f"{decrypted_count} decrypted, {skipped_count} skipped"
)
print(
f" [016_encrypt_ca_keys] DOWNGRADE: {decrypted_count} CA private key(s) "
f"decrypted to plaintext. WARNING: keys are now unencrypted at rest."
)
@@ -1,37 +0,0 @@
"""Add monotonic serial counter to CAs table.
Each CA now owns a `next_serial_number` (BigInteger) that is atomically
incremented every time a certificate is signed. This guarantees:
- Serials are unique per CA
- Serials are monotonically increasing (auditable, no gaps by accident)
- The value embedded in the OpenSSH certificate matches what is stored
in the `ssh_certificates.serial` column
Revision ID: 017_add_ca_serial_counter
Revises: 016_encrypt_ca_keys
Create Date: 2026-03-02
"""
from alembic import op
import sqlalchemy as sa
revision = "017_add_ca_serial_counter"
down_revision = "016_encrypt_ca_keys"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("cas", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"next_serial_number",
sa.BigInteger(),
nullable=False,
server_default="1",
)
)
def downgrade():
with op.batch_alter_table("cas", schema=None) as batch_op:
batch_op.drop_column("next_serial_number")
@@ -1,52 +0,0 @@
"""Add ORG_OWNERSHIP_TRANSFERRED and USER_HARD_DELETE to auditaction enum.
Revision ID: 018_audit_enum_values
Revises: 017_add_ca_serial_counter
Create Date: 2026-03-02
ORG_OWNERSHIP_TRANSFERRED and USER_HARD_DELETE were added to the Python
AuditAction enum but were never synced to the PostgreSQL auditaction type,
causing a DataError (invalid enum value) when transferring org ownership
or hard-deleting a user.
"""
from alembic import op
revision = "018_audit_enum_values"
down_revision = "017_add_ca_serial_counter"
branch_labels = None
depends_on = None
def upgrade():
# ALTER TYPE ... ADD VALUE cannot run inside a transaction block in PostgreSQL.
# Alembic has already opened a transaction on the connection by the time our
# upgrade() runs, so we must:
# 1. Roll back that open transaction on the raw psycopg2 connection.
# 2. Switch to autocommit so the ALTER TYPE runs outside any transaction.
# 3. Restore the previous state afterwards.
conn = op.get_bind()
# SQLAlchemy 2.x: conn.connection is a _ConnectionFairy; .driver_connection is psycopg2
fairy = conn.connection
raw = getattr(fairy, "driver_connection", None) or getattr(fairy, "dbapi_connection", fairy)
# Roll back the open transaction so psycopg2 allows us to change autocommit.
raw.rollback()
old_autocommit = raw.autocommit
raw.autocommit = True
try:
with raw.cursor() as cur:
for val in ("ORG_OWNERSHIP_TRANSFERRED", "USER_HARD_DELETE"):
cur.execute(
"SELECT 1 FROM pg_enum "
"WHERE enumlabel = %s "
"AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'auditaction')",
(val,),
)
if not cur.fetchone():
cur.execute(f"ALTER TYPE auditaction ADD VALUE '{val}'")
finally:
raw.autocommit = old_autocommit
def downgrade():
# PostgreSQL does not support removing enum values; downgrade is a no-op.
pass
@@ -1,143 +0,0 @@
"""Convert audit_logs.action from auditaction enum to VARCHAR(100).
Revision ID: 019_audit_varchar
Revises: 018_audit_enum_values, db15faee1fb8
Create Date: 2026-03-04
WHY
---
The PostgreSQL `auditaction` ENUM type must be explicitly altered every time a
new AuditAction is added to the Python enum, otherwise the INSERT fails with:
psycopg2.errors.InvalidTextRepresentation:
invalid input value for enum auditaction: "admin.mfa.remove"
The Python enum was refactored from UPPER_SNAKE_CASE to lower.dot.case string
values, but only the UPPER_SNAKE_CASE values exist in the DB type. Rather
than add every new value forever, we convert the column to VARCHAR(100) which
accepts any string the Python layer already validates the value via the Enum.
DATA MIGRATION
--------------
All existing rows store UPPER_SNAKE_CASE values. We map each one to the
corresponding new lower.dot.case string so historical audit logs remain
queryable with the current enum.
"""
from alembic import op
import sqlalchemy as sa
revision = "019_audit_varchar"
down_revision = ("018_audit_enum_values", "db15faee1fb8")
branch_labels = None
depends_on = None
# Map every UPPER_SNAKE_CASE DB value → its new lower.dot.case Python value.
VALUE_MAP = {
"USER_LOGIN": "user.login",
"USER_LOGOUT": "user.logout",
"USER_REGISTER": "user.register",
"USER_UPDATE": "user.update",
"USER_DELETE": "user.delete",
"USER_HARD_DELETE": "user.hard_delete",
"USER_SUSPEND": "user.suspend",
"USER_UNSUSPEND": "user.unsuspend",
"PASSWORD_CHANGE": "user.password_change",
"PASSWORD_RESET": "user.password_reset",
"ORG_CREATE": "org.create",
"ORG_UPDATE": "org.update",
"ORG_DELETE": "org.delete",
"ORG_MEMBER_ADD": "org.member.add",
"ORG_MEMBER_REMOVE": "org.member.remove",
"ORG_MEMBER_ROLE_CHANGE": "org.member.role_change",
"ORG_OWNERSHIP_TRANSFERRED": "org.ownership.transferred",
"SESSION_CREATE": "session.create",
"SESSION_REVOKE": "session.revoke",
"AUTH_METHOD_ADD": "auth.method.add",
"AUTH_METHOD_REMOVE": "auth.method.remove",
"TOTP_ENROLL_INITIATED": "totp.enroll.initiated",
"TOTP_ENROLL_COMPLETED": "totp.enroll.completed",
"TOTP_VERIFY_SUCCESS": "totp.verify.success",
"TOTP_VERIFY_FAILED": "totp.verify.failed",
"TOTP_DISABLED": "totp.disabled",
"TOTP_BACKUP_CODE_USED": "totp.backup_code.used",
"TOTP_BACKUP_CODES_REGENERATED": "totp.backup_codes.regenerated",
"WEBAUTHN_REGISTER_INITIATED": "webauthn.register.initiated",
"WEBAUTHN_REGISTER_COMPLETED": "webauthn.register.completed",
"WEBAUTHN_REGISTER_FAILED": "webauthn.register.failed",
"WEBAUTHN_LOGIN_INITIATED": "webauthn.login.initiated",
"WEBAUTHN_LOGIN_SUCCESS": "webauthn.login.success",
"WEBAUTHN_LOGIN_FAILED": "webauthn.login.failed",
"WEBAUTHN_CREDENTIAL_DELETED": "webauthn.credential.deleted",
"WEBAUTHN_CREDENTIAL_RENAMED": "webauthn.credential.renamed",
"ORG_SECURITY_POLICY_UPDATE": "org.security_policy.update",
"USER_SECURITY_POLICY_OVERRIDE_UPDATE":"user.security_policy.override_update",
"MFA_POLICY_USER_SUSPENDED": "mfa.policy.user_suspended",
"MFA_POLICY_USER_COMPLIANT": "mfa.policy.user_compliant",
"EXTERNAL_AUTH_LINK_INITIATED": "external_auth.link.initiated",
"EXTERNAL_AUTH_LINK_COMPLETED": "external_auth.link.completed",
"EXTERNAL_AUTH_LINK_FAILED": "external_auth.link.failed",
"EXTERNAL_AUTH_UNLINK": "external_auth.unlink",
"EXTERNAL_AUTH_LOGIN": "external_auth.login",
"EXTERNAL_AUTH_LOGIN_FAILED": "external_auth.login.failed",
"EXTERNAL_AUTH_TOKEN_REFRESH": "external_auth.token_refresh",
"EXTERNAL_AUTH_CONFIG_CREATE": "external_auth.config.create",
"EXTERNAL_AUTH_CONFIG_UPDATE": "external_auth.config.update",
"EXTERNAL_AUTH_CONFIG_DELETE": "external_auth.config.delete",
"SSH_KEY_ADDED": "ssh.key.added",
"SSH_KEY_VERIFIED": "ssh.key.verified",
"SSH_KEY_DELETED": "ssh.key.deleted",
"SSH_KEY_VALIDATION_FAILED": "ssh.key.validation.failed",
"SSH_CERT_REQUESTED": "ssh.cert.requested",
"SSH_CERT_ISSUED": "ssh.cert.issued",
"SSH_CERT_FAILED": "ssh.cert.failed",
"SSH_CERT_REVOKED": "ssh.cert.revoked",
"SSH_CERT_EXPIRED": "ssh.cert.expired",
"CA_CREATED": "ca.created",
"CA_UPDATED": "ca.updated",
"CA_DELETED": "ca.deleted",
"CA_KEY_ROTATED": "ca.key.rotated",
"PRINCIPAL_CREATED": "principal.created",
"PRINCIPAL_UPDATED": "principal.updated",
"PRINCIPAL_DELETED": "principal.deleted",
"PRINCIPAL_MEMBER_ADDED": "principal.member.added",
"PRINCIPAL_MEMBER_REMOVED": "principal.member.removed",
"DEPARTMENT_CREATED": "department.created",
"DEPARTMENT_UPDATED": "department.updated",
"DEPARTMENT_DELETED": "department.deleted",
"DEPARTMENT_MEMBER_ADDED": "department.member.added",
"DEPARTMENT_MEMBER_REMOVED": "department.member.removed",
}
def upgrade():
conn = op.get_bind()
# 1. Add a temporary VARCHAR column
op.add_column("audit_logs", sa.Column("action_new", sa.String(100), nullable=True))
# 2. Populate it: map old UPPER_SNAKE_CASE to new lower.dot.case
for old_val, new_val in VALUE_MAP.items():
conn.execute(
sa.text("UPDATE audit_logs SET action_new = :new WHERE action::text = :old"),
{"new": new_val, "old": old_val},
)
# 3. Any unmapped rows (shouldn't exist, but be safe): copy as-is
conn.execute(sa.text("UPDATE audit_logs SET action_new = action::text WHERE action_new IS NULL"))
# 4. Drop the old enum column, rename the new one
op.drop_column("audit_logs", "action")
op.alter_column("audit_logs", "action_new", new_column_name="action", nullable=False)
# 5. Recreate the index (was on the old column)
op.create_index("ix_audit_logs_action", "audit_logs", ["action"])
op.create_index("idx_audit_user_action", "audit_logs", ["user_id", "action"])
# 6. Drop the now-unused auditaction enum type
op.execute("DROP TYPE IF EXISTS auditaction")
def downgrade():
# Converting VARCHAR back to a custom enum is complex and lossy for new
# values — provide a no-op downgrade. Run a previous backup to revert.
pass
@@ -1,26 +0,0 @@
"""Add ZeroTier / Portal Network models.
Revision ID: 020_zerotier
Revises: 019_audit_varchar
Create Date: 2026-03-19
SUPERSEDED by 023_zerotier_drop_legacy which creates all ZeroTier tables
idempotently (with IF NOT EXISTS / if_not_exists=True). This migration is
kept as a no-op to preserve the Alembic revision chain for databases that
already have '020_zerotier' stamped (e.g. dev environments).
"""
revision = "020_zerotier"
down_revision = "019_audit_varchar"
branch_labels = None
depends_on = None
def upgrade():
# No-op — 023_zerotier_drop_legacy handles everything idempotently.
pass
def downgrade():
# No-op — 023_zerotier_drop_legacy handles rollback.
pass
@@ -1,76 +0,0 @@
"""Seed CA serial counters with a timestamp-based starting value.
Revision ID: 020_ca_serial_timestamp_start
Revises: 019_audit_varchar, d34bfb72844e
Create Date: 2026-03-06
WHY
---
``next_serial_number`` was originally seeded at ``1`` for every CA
(``server_default="1"`` in migration 017). Because the
``ix_ssh_certificates_serial`` index enforces a globally-unique constraint on
the serial column, any two CAs issuing their first certificate would both try
to insert serial ``1``, causing a UniqueViolation.
FIX new CAs
-------------
The CA model's Python-side ``default`` is now ``_serial_start()``, which
returns ``int(time.time() * 1000)`` (Unix milliseconds) at row-creation time.
CAs created after this migration will start their serial counter at the
millisecond they were first inserted, so serials are globally unique across
CAs and still monotonically increasing within each CA.
FIX existing CAs
-------------------
This migration performs a data migration: any CA whose ``next_serial_number``
is still ``<= 2`` (i.e. has issued at most one certificate since the original
``1``-based default) is given a new timestamp-based starting value.
CAs that have already issued many certificates keep their current counter
unchanged their serials are already beyond the low collision-prone range.
NOTE: the ``server_default`` on the column is intentionally NOT changed here
because SQLAlchemy uses the Python-side ``default=_serial_start`` callable for
new rows; the ``server_default`` is only a database-level fallback that is
never hit when rows are inserted via the ORM.
"""
import time
from alembic import op
import sqlalchemy as sa
revision = "020_ca_serial_timestamp_start"
down_revision = ("3de11c5dc2d5", "d34bfb72844e")
branch_labels = None
depends_on = None
def _now_ms() -> int:
return int(time.time() * 1000)
def upgrade():
conn = op.get_bind()
# Update ALL CAs to a timestamp-based starting serial — not just those
# stuck at 1. Any CA with a serial below the current ms timestamp is in
# the low collision-prone range (serials 1N where N is tiny). Resetting
# every CA to a fresh ms timestamp is safe: the counter only moves forward
# from here, and no existing certificate serial is changed.
rows = conn.execute(
sa.text("SELECT id FROM cas")
).fetchall()
for (ca_id,) in rows:
new_start = _now_ms()
conn.execute(
sa.text(
"UPDATE cas SET next_serial_number = :val WHERE id = :id"
),
{"val": new_start, "id": ca_id},
)
def downgrade():
# There is no safe downgrade for a data migration that assigns new serial
# starting points — resetting to 1 would recreate the collision risk.
pass
-22
View File
@@ -1,22 +0,0 @@
"""Merge 020_ca_serial_timestamp_start and 002_add_can_sudo_to_departments into a single head.
Revision ID: 021_merge_heads
Revises: 020_ca_serial_timestamp_start, 002_add_can_sudo_to_departments
Create Date: 2026-03-09
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = '021_merge_heads'
down_revision = ('020_ca_serial_timestamp_start', '002_add_can_sudo_to_departments')
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass
@@ -1,29 +0,0 @@
"""Merge zerotier + CA/sudo/api-key branches.
Revision ID: 022_add_command_events
Revises: 020_zerotier, 021_merge_heads
Create Date: 2026-03-09
Pure merge-point for 020_zerotier and 021_merge_heads.
Revision ID kept as-is for compatibility with production databases that
already have '022_add_command_events' stamped in alembic_version.
"""
from alembic import op
# ---------------------------------------------------------------------------
# revision identifiers
# ---------------------------------------------------------------------------
revision = "022_add_command_events"
down_revision = ("020_zerotier", "021_merge_heads")
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass
@@ -1,393 +0,0 @@
"""Apply ZeroTier tables and drop legacy SSH-session tables.
Revision ID: 023_apply_zerotier_drop_legacy_ssh_tables
Revises: 022_add_command_events
Create Date: 2026-03-22
CONTEXT
-------
Migration 020_zerotier was never applied to the production database the
alembic_version stamp jumped directly from a pre-zerotier revision to
022_add_command_events. This migration catches the DB up by:
1. Creating all ZeroTier / Portal Network tables (idempotent every
create_table uses if_not_exists=True so it is safe to run on a DB
that already has some of these tables).
2. Dropping the legacy SSH-session tables that no longer have
corresponding ORM models:
- command_events (dropped first has FKs to servers + host_sessions)
- sudo_events (dropped first has FK to host_sessions)
- host_sessions (dropped second referenced by the two above)
- servers (dropped last)
All drops use IF EXISTS so the migration is also safe on a fresh DB
that ran 020_zerotier correctly (those tables would already be absent).
PROD SAFETY
-----------
- All create_table calls use if_not_exists=True.
- All drop_table calls use IF EXISTS via op.execute() for tables that may
or may not be present.
- No data migration; no destructive schema change on tables that still
have ORM models.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.engine.reflection import Inspector
# ---------------------------------------------------------------------------
revision = "023_zerotier_drop_legacy"
down_revision = "022_add_command_events"
branch_labels = None
depends_on = None
# ---------------------------------------------------------------------------
def _table_exists(conn, table: str) -> bool:
return Inspector.from_engine(conn).has_table(table)
def _index_exists(conn, table: str, index: str) -> bool:
insp = Inspector.from_engine(conn)
return any(i["name"] == index for i in insp.get_indexes(table)) if _table_exists(conn, table) else False
def _type_exists(conn, type_name: str) -> bool:
result = conn.execute(
sa.text("SELECT 1 FROM pg_type WHERE typname = :t"),
{"t": type_name},
).scalar()
return bool(result)
def _pg_enum(name: str) -> sa.Text:
"""Return a plain Text column type for use inside create_table.
We rely on the enum type already existing in PostgreSQL (created above via
'CREATE TYPE ... IF NOT EXISTS'). Using sa.String avoids SQLAlchemy's
automatic 'CREATE TYPE' emission inside create_table, which would fail if
the type already exists. A cast via server_default / CHECK constraint is
not required PostgreSQL accepts varchar literals for enum columns when
inserted from SQLAlchemy's ORM layer, which uses the Python Enum type map.
"""
return sa.String(40)
# ---------------------------------------------------------------------------
# upgrade
# ---------------------------------------------------------------------------
def upgrade():
conn = op.get_bind()
dialect = conn.dialect.name
# ── 1. Enum types (PostgreSQL only, idempotent) ───────────────────────────
if dialect == "postgresql":
enum_defs = {
"network_environment": ["production", "staging", "development", "lab"],
"network_request_mode": ["open", "approval_required", "invite_only"],
"approval_grant_type": ["requested", "assigned"],
"approval_state": ["pending", "approved", "rejected", "revoked", "suspended"],
"membership_state": [
"pending_device_registration", "pending_request",
"pending_manager_approval", "approved_inactive",
"joined_deauthorized", "active_authorized",
"activation_expired", "suspended", "revoked", "rejected",
],
"activation_end_reason": [
"expired", "logout", "kill_switch",
"manual_revoke", "approval_revoked", "admin_action",
],
"kill_switch_scope": ["organization", "global", "selected_networks"],
"device_status": ["active", "inactive"],
}
for type_name, values in enum_defs.items():
if not _type_exists(conn, type_name):
quoted = ", ".join(f"'{v}'" for v in values)
conn.execute(sa.text(f"CREATE TYPE {type_name} AS ENUM ({quoted})"))
# ── 2. portal_networks ────────────────────────────────────────────────────
op.create_table(
"portal_networks",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("owner_user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
sa.Column("zerotier_network_id", sa.String(16), nullable=False),
sa.Column("environment", sa.String(40), nullable=False),
sa.Column("request_mode", sa.String(40), nullable=False),
sa.Column("default_activation_lifetime_minutes", sa.Integer, nullable=False, server_default="480"),
sa.Column("max_activation_lifetime_minutes", sa.Integer, nullable=True),
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
if_not_exists=True,
)
if not _index_exists(conn, "portal_networks", "ix_portal_networks_organization_id"):
op.create_index("ix_portal_networks_organization_id", "portal_networks", ["organization_id"])
if not _index_exists(conn, "portal_networks", "ix_portal_networks_zerotier_network_id"):
op.create_index("ix_portal_networks_zerotier_network_id", "portal_networks", ["zerotier_network_id"])
if not _index_exists(conn, "portal_networks", "ix_portal_networks_org_zt"):
op.create_index(
"ix_portal_networks_org_zt", "portal_networks",
["organization_id", "zerotier_network_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
# ── 3. devices ────────────────────────────────────────────────────────────
op.create_table(
"devices",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False),
sa.Column("node_id", sa.String(10), nullable=False),
sa.Column("device_nickname", sa.String(255), nullable=True),
sa.Column("hostname", sa.String(255), nullable=True),
sa.Column("asset_tag", sa.String(255), nullable=True),
sa.Column("serial_number", sa.String(255), nullable=True),
sa.Column("status", sa.String(40), nullable=False, server_default="active"),
if_not_exists=True,
)
if not _index_exists(conn, "devices", "ix_devices_user_id"):
op.create_index("ix_devices_user_id", "devices", ["user_id"])
if not _index_exists(conn, "devices", "ix_devices_organization_id"):
op.create_index("ix_devices_organization_id", "devices", ["organization_id"])
if not _index_exists(conn, "devices", "ix_devices_node_id_active") and dialect == "postgresql":
op.create_index(
"ix_devices_node_id_active", "devices", ["node_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
elif not _index_exists(conn, "devices", "ix_devices_node_id") and dialect != "postgresql":
op.create_index("ix_devices_node_id", "devices", ["node_id"])
# ── 4. user_network_approvals ─────────────────────────────────────────────
op.create_table(
"user_network_approvals",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False),
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
sa.Column("portal_network_id", sa.String(36), sa.ForeignKey("portal_networks.id"), nullable=False),
sa.Column("granted_by_user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=True),
sa.Column("grant_type", sa.String(40), nullable=False, server_default="requested"),
sa.Column("state", sa.String(40), nullable=False, server_default="pending"),
sa.Column("justification", sa.Text, nullable=True),
if_not_exists=True,
)
if not _index_exists(conn, "user_network_approvals", "ix_user_network_approvals_organization_id"):
op.create_index("ix_user_network_approvals_organization_id", "user_network_approvals", ["organization_id"])
if not _index_exists(conn, "user_network_approvals", "ix_user_network_approvals_user_id"):
op.create_index("ix_user_network_approvals_user_id", "user_network_approvals", ["user_id"])
if not _index_exists(conn, "user_network_approvals", "ix_user_network_approvals_portal_network_id"):
op.create_index("ix_user_network_approvals_portal_network_id", "user_network_approvals", ["portal_network_id"])
if not _index_exists(conn, "user_network_approvals", "ix_user_network_approvals_state"):
op.create_index("ix_user_network_approvals_state", "user_network_approvals", ["state"])
if not _index_exists(conn, "user_network_approvals", "ix_user_network_approvals_user_network"):
op.create_index(
"ix_user_network_approvals_user_network", "user_network_approvals",
["user_id", "portal_network_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
# ── 5. device_network_memberships ─────────────────────────────────────────
op.create_table(
"device_network_memberships",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False),
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
sa.Column("device_id", sa.String(36), sa.ForeignKey("devices.id"), nullable=False),
sa.Column("portal_network_id", sa.String(36), sa.ForeignKey("portal_networks.id"), nullable=False),
sa.Column("user_network_approval_id", sa.String(36), sa.ForeignKey("user_network_approvals.id"), nullable=True),
sa.Column("state", sa.String(40), nullable=False, server_default="pending_device_registration"),
sa.Column("join_seen", sa.Boolean, nullable=False, server_default="false"),
sa.Column("currently_authorized", sa.Boolean, nullable=False, server_default="false"),
sa.Column("approved_for_activation", sa.Boolean, nullable=False, server_default="true"),
if_not_exists=True,
)
if not _index_exists(conn, "device_network_memberships", "ix_device_network_memberships_organization_id"):
op.create_index("ix_device_network_memberships_organization_id", "device_network_memberships", ["organization_id"])
if not _index_exists(conn, "device_network_memberships", "ix_device_network_memberships_user_id"):
op.create_index("ix_device_network_memberships_user_id", "device_network_memberships", ["user_id"])
if not _index_exists(conn, "device_network_memberships", "ix_device_network_memberships_device_id"):
op.create_index("ix_device_network_memberships_device_id", "device_network_memberships", ["device_id"])
if not _index_exists(conn, "device_network_memberships", "ix_device_network_memberships_portal_network_id"):
op.create_index("ix_device_network_memberships_portal_network_id", "device_network_memberships", ["portal_network_id"])
if not _index_exists(conn, "device_network_memberships", "ix_device_network_memberships_state"):
op.create_index("ix_device_network_memberships_state", "device_network_memberships", ["state"])
if not _index_exists(conn, "device_network_memberships", "ix_device_network_memberships_user_network_approval_id"):
op.create_index("ix_device_network_memberships_user_network_approval_id", "device_network_memberships", ["user_network_approval_id"])
if not _index_exists(conn, "device_network_memberships", "ix_device_network_memberships_device_network"):
op.create_index(
"ix_device_network_memberships_device_network", "device_network_memberships",
["device_id", "portal_network_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
# ── 6. activation_sessions ────────────────────────────────────────────────
op.create_table(
"activation_sessions",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False),
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
sa.Column("device_network_membership_id", sa.String(36), sa.ForeignKey("device_network_memberships.id"), nullable=False),
sa.Column("authenticated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("end_reason", sa.String(40), nullable=True),
sa.Column("created_by", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
if_not_exists=True,
)
if not _index_exists(conn, "activation_sessions", "ix_activation_sessions_organization_id"):
op.create_index("ix_activation_sessions_organization_id", "activation_sessions", ["organization_id"])
if not _index_exists(conn, "activation_sessions", "ix_activation_sessions_user_id"):
op.create_index("ix_activation_sessions_user_id", "activation_sessions", ["user_id"])
if not _index_exists(conn, "activation_sessions", "ix_activation_sessions_device_network_membership_id"):
op.create_index("ix_activation_sessions_device_network_membership_id", "activation_sessions", ["device_network_membership_id"])
# ── 7. zerotier_memberships ───────────────────────────────────────────────
op.create_table(
"zerotier_memberships",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False),
sa.Column("device_network_membership_id", sa.String(36), sa.ForeignKey("device_network_memberships.id"), nullable=True),
sa.Column("zerotier_network_id", sa.String(16), nullable=False),
sa.Column("node_id", sa.String(10), nullable=False),
sa.Column("member_seen", sa.Boolean, nullable=False, server_default="false"),
sa.Column("authorized", sa.Boolean, nullable=False, server_default="false"),
sa.Column("join_seen_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_synced_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("raw_controller_payload", sa.JSON, nullable=True),
if_not_exists=True,
)
if not _index_exists(conn, "zerotier_memberships", "ix_zerotier_memberships_organization_id"):
op.create_index("ix_zerotier_memberships_organization_id", "zerotier_memberships", ["organization_id"])
if not _index_exists(conn, "zerotier_memberships", "ix_zerotier_memberships_device_network_membership_id"):
op.create_index("ix_zerotier_memberships_device_network_membership_id", "zerotier_memberships", ["device_network_membership_id"])
if not _index_exists(conn, "zerotier_memberships", "ix_zerotier_memberships_zerotier_network_id"):
op.create_index("ix_zerotier_memberships_zerotier_network_id", "zerotier_memberships", ["zerotier_network_id"])
if not _index_exists(conn, "zerotier_memberships", "ix_zerotier_memberships_node_id"):
op.create_index("ix_zerotier_memberships_node_id", "zerotier_memberships", ["node_id"])
if not _index_exists(conn, "zerotier_memberships", "ix_zerotier_memberships_network_node"):
op.create_index(
"ix_zerotier_memberships_network_node", "zerotier_memberships",
["zerotier_network_id", "node_id"],
unique=True,
)
# ── 8. kill_switch_events ────────────────────────────────────────────────
op.create_table(
"kill_switch_events",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False),
sa.Column("target_user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
sa.Column("scope", sa.String(40), nullable=False, server_default="organization"),
sa.Column("triggered_by_user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
sa.Column("reason", sa.Text, nullable=True),
sa.Column("network_ids", sa.JSON, nullable=True),
if_not_exists=True,
)
if not _index_exists(conn, "kill_switch_events", "ix_kill_switch_events_organization_id"):
op.create_index("ix_kill_switch_events_organization_id", "kill_switch_events", ["organization_id"])
if not _index_exists(conn, "kill_switch_events", "ix_kill_switch_events_target_user_id"):
op.create_index("ix_kill_switch_events_target_user_id", "kill_switch_events", ["target_user_id"])
# ── 9. Drop legacy SSH-session tables (IF EXISTS — safe on fresh DBs) ─────
#
# Order matters due to FK constraints:
# command_events → servers, host_sessions
# sudo_events → host_sessions
# host_sessions → (nothing that still exists)
# servers → (nothing that still exists)
conn.execute(sa.text("DROP TABLE IF EXISTS command_events CASCADE"))
conn.execute(sa.text("DROP TABLE IF EXISTS sudo_events CASCADE"))
conn.execute(sa.text("DROP TABLE IF EXISTS host_sessions CASCADE"))
conn.execute(sa.text("DROP TABLE IF EXISTS servers CASCADE"))
# ---------------------------------------------------------------------------
# downgrade
# ---------------------------------------------------------------------------
def downgrade():
conn = op.get_bind()
dialect = conn.dialect.name
# Re-create the legacy tables (minimal — enough for FK integrity)
op.create_table(
"servers",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False),
sa.Column("hostname", sa.String(255), nullable=False),
sa.Column("display_name", sa.String(255), nullable=True),
sa.Column("ip_address", sa.String(64), nullable=True),
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
if_not_exists=True,
)
op.create_table(
"host_sessions",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.Column("organization_id", sa.String(36), sa.ForeignKey("organizations.id"), nullable=False),
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
sa.Column("server_id", sa.String(36), sa.ForeignKey("servers.id"), nullable=False),
if_not_exists=True,
)
# Drop ZeroTier tables
op.drop_table("kill_switch_events", if_exists=True)
op.drop_table("zerotier_memberships", if_exists=True)
op.drop_table("activation_sessions", if_exists=True)
op.drop_table("device_network_memberships", if_exists=True)
op.drop_table("user_network_approvals", if_exists=True)
op.drop_table("devices", if_exists=True)
op.drop_table("portal_networks", if_exists=True)
# Drop ZeroTier enum types
if dialect == "postgresql":
for t in [
"kill_switch_scope", "device_status", "activation_end_reason",
"membership_state", "approval_state", "approval_grant_type",
"network_request_mode", "network_environment",
]:
conn.execute(sa.text(f"DROP TYPE IF EXISTS {t}"))
@@ -1,291 +0,0 @@
"""Fix ZeroTier table schema: enum types, unique constraints, indexes, drop cert_token.
Revision ID: 024_fix_zerotier_schema
Revises: 023_zerotier_drop_legacy
Create Date: 2026-03-22
Addresses all `db check` differences after 023:
- Cast VARCHAR(40) enum columns to their proper PostgreSQL enum types
(guarded skipped if columns are already native enum, e.g. on a fresh DB
where 020_zerotier created them correctly)
- Replace partial unique indexes with named UniqueConstraints
- Fix devices.node_id partial index -> plain index
- Add UniqueConstraint on `id` for all new ZeroTier tables (BaseModel.unique=True)
- Drop orphan cert_token column and its index from ssh_certificates
"""
from alembic import op
import sqlalchemy as sa
revision = "024_fix_zerotier_schema"
down_revision = "023_zerotier_drop_legacy"
branch_labels = None
depends_on = None
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _col_data_type(conn, table: str, column: str) -> str | None:
"""Return the PostgreSQL data_type string for a column, or None."""
row = conn.execute(sa.text(
"SELECT data_type FROM information_schema.columns "
"WHERE table_name = :t AND column_name = :c"
), {"t": table, "c": column}).first()
return row[0] if row else None
def _column_exists(conn, table: str, column: str) -> bool:
return _col_data_type(conn, table, column) is not None
def _index_exists(conn, table: str, index: str) -> bool:
from sqlalchemy.engine.reflection import Inspector
insp = Inspector.from_engine(conn)
return any(i["name"] == index for i in insp.get_indexes(table))
def _constraint_exists(conn, constraint: str) -> bool:
row = conn.execute(sa.text(
"SELECT 1 FROM information_schema.table_constraints "
"WHERE constraint_name = :c"
), {"c": constraint}).first()
return row is not None
def upgrade():
conn = op.get_bind()
# -------------------------------------------------------------------------
# 1. Cast VARCHAR(40) enum columns to proper PostgreSQL enum types.
# GUARDED: On a fresh DB, 020_zerotier already created these as native
# enum types. We only cast if the column is currently 'character varying'.
# -------------------------------------------------------------------------
enum_casts = [
("portal_networks", "environment", "network_environment", None),
("portal_networks", "request_mode", "network_request_mode", None),
("devices", "status", "device_status", "'active'::device_status"),
("device_network_memberships", "state", "membership_state", "'pending_device_registration'::membership_state"),
("user_network_approvals", "grant_type", "approval_grant_type", "'requested'::approval_grant_type"),
("user_network_approvals", "state", "approval_state", "'pending'::approval_state"),
("activation_sessions", "end_reason", "activation_end_reason", None),
("kill_switch_events", "scope", "kill_switch_scope", "'organization'::kill_switch_scope"),
]
for table, col, enum_type, new_default in enum_casts:
dtype = _col_data_type(conn, table, col)
if dtype == "character varying":
conn.execute(sa.text(f'ALTER TABLE "{table}" ALTER COLUMN "{col}" DROP DEFAULT'))
conn.execute(sa.text(
f'ALTER TABLE "{table}" ALTER COLUMN "{col}" TYPE {enum_type} '
f'USING "{col}"::text::{enum_type}'
))
if new_default:
conn.execute(sa.text(
f'ALTER TABLE "{table}" ALTER COLUMN "{col}" SET DEFAULT {new_default}'
))
elif dtype == "USER-DEFINED" and new_default:
# Already native enum (fresh DB path). Ensure server_default is set
# if 020 used `default=` (Python-side) instead of `server_default=`.
# This is harmless — SET DEFAULT is idempotent.
conn.execute(sa.text(
f'ALTER TABLE "{table}" ALTER COLUMN "{col}" SET DEFAULT {new_default}'
))
# -------------------------------------------------------------------------
# 2. portal_networks: drop partial unique index, add named UniqueConstraint
# -------------------------------------------------------------------------
if _index_exists(conn, "portal_networks", "ix_portal_networks_org_zt"):
op.drop_index("ix_portal_networks_org_zt", table_name="portal_networks")
if not _constraint_exists(conn, "uix_org_zt_network_id"):
op.create_unique_constraint(
"uix_org_zt_network_id",
"portal_networks",
["organization_id", "zerotier_network_id"],
)
# -------------------------------------------------------------------------
# 3. device_network_memberships: drop partial unique index, add named UC
# -------------------------------------------------------------------------
if _index_exists(conn, "device_network_memberships", "ix_device_network_memberships_device_network"):
op.drop_index("ix_device_network_memberships_device_network", table_name="device_network_memberships")
if not _constraint_exists(conn, "uix_device_network"):
op.create_unique_constraint(
"uix_device_network",
"device_network_memberships",
["device_id", "portal_network_id", "deleted_at"],
)
# -------------------------------------------------------------------------
# 4. user_network_approvals: drop partial unique index, add named UC
# -------------------------------------------------------------------------
if _index_exists(conn, "user_network_approvals", "ix_user_network_approvals_user_network"):
op.drop_index("ix_user_network_approvals_user_network", table_name="user_network_approvals")
if not _constraint_exists(conn, "uix_user_network_approval"):
op.create_unique_constraint(
"uix_user_network_approval",
"user_network_approvals",
["user_id", "portal_network_id", "deleted_at"],
)
# -------------------------------------------------------------------------
# 5. zerotier_memberships: drop index, add named UniqueConstraint
# -------------------------------------------------------------------------
if _index_exists(conn, "zerotier_memberships", "ix_zerotier_memberships_network_node"):
op.drop_index("ix_zerotier_memberships_network_node", table_name="zerotier_memberships")
if not _constraint_exists(conn, "uix_zt_network_node"):
op.create_unique_constraint(
"uix_zt_network_node",
"zerotier_memberships",
["zerotier_network_id", "node_id"],
)
# -------------------------------------------------------------------------
# 6. devices.node_id: drop partial unique index, add plain non-unique index
# -------------------------------------------------------------------------
if _index_exists(conn, "devices", "ix_devices_node_id_active"):
op.drop_index("ix_devices_node_id_active", table_name="devices")
if not _index_exists(conn, "devices", "ix_devices_node_id"):
op.create_index("ix_devices_node_id", "devices", ["node_id"])
# -------------------------------------------------------------------------
# 7. Add UniqueConstraint on `id` for all ZeroTier tables
# BaseModel defines id with unique=True → separate _id_key constraint.
# -------------------------------------------------------------------------
zt_tables = [
"portal_networks",
"devices",
"device_network_memberships",
"user_network_approvals",
"activation_sessions",
"zerotier_memberships",
"kill_switch_events",
]
for tbl in zt_tables:
cname = f"{tbl}_id_key"
if not _constraint_exists(conn, cname):
op.create_unique_constraint(cname, tbl, ["id"])
# -------------------------------------------------------------------------
# 8. Drop orphan cert_token column and its index from ssh_certificates.
# cert_token was created by 3de11c5dc2d5 but the SSHCertificate model
# never uses it. Guarded in case a future revision removes it first.
# -------------------------------------------------------------------------
if _index_exists(conn, "ssh_certificates", "ix_ssh_certificates_cert_token"):
op.drop_index("ix_ssh_certificates_cert_token", table_name="ssh_certificates")
if _column_exists(conn, "ssh_certificates", "cert_token"):
op.drop_column("ssh_certificates", "cert_token")
def downgrade():
conn = op.get_bind()
# Restore cert_token if it was dropped
if not _column_exists(conn, "ssh_certificates", "cert_token"):
op.add_column(
"ssh_certificates",
sa.Column("cert_token", sa.String(64), nullable=True),
)
if not _index_exists(conn, "ssh_certificates", "ix_ssh_certificates_cert_token"):
op.create_index(
"ix_ssh_certificates_cert_token",
"ssh_certificates",
["cert_token"],
unique=True,
)
# Drop id unique constraints on ZeroTier tables
zt_tables = [
"portal_networks",
"devices",
"device_network_memberships",
"user_network_approvals",
"activation_sessions",
"zerotier_memberships",
"kill_switch_events",
]
for tbl in zt_tables:
cname = f"{tbl}_id_key"
if _constraint_exists(conn, cname):
op.drop_constraint(cname, tbl, type_="unique")
# Restore devices node_id index
if _index_exists(conn, "devices", "ix_devices_node_id"):
op.drop_index("ix_devices_node_id", table_name="devices")
if not _index_exists(conn, "devices", "ix_devices_node_id_active"):
op.create_index(
"ix_devices_node_id_active",
"devices",
["node_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
# Restore zerotier_memberships index
if _constraint_exists(conn, "uix_zt_network_node"):
op.drop_constraint("uix_zt_network_node", "zerotier_memberships", type_="unique")
if not _index_exists(conn, "zerotier_memberships", "ix_zerotier_memberships_network_node"):
op.create_index(
"ix_zerotier_memberships_network_node",
"zerotier_memberships",
["zerotier_network_id", "node_id"],
unique=True,
)
# Restore user_network_approvals partial unique index
if _constraint_exists(conn, "uix_user_network_approval"):
op.drop_constraint("uix_user_network_approval", "user_network_approvals", type_="unique")
if not _index_exists(conn, "user_network_approvals", "ix_user_network_approvals_user_network"):
op.create_index(
"ix_user_network_approvals_user_network",
"user_network_approvals",
["user_id", "portal_network_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
# Restore device_network_memberships partial unique index
if _constraint_exists(conn, "uix_device_network"):
op.drop_constraint("uix_device_network", "device_network_memberships", type_="unique")
if not _index_exists(conn, "device_network_memberships", "ix_device_network_memberships_device_network"):
op.create_index(
"ix_device_network_memberships_device_network",
"device_network_memberships",
["device_id", "portal_network_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
# Restore portal_networks partial unique index
if _constraint_exists(conn, "uix_org_zt_network_id"):
op.drop_constraint("uix_org_zt_network_id", "portal_networks", type_="unique")
if not _index_exists(conn, "portal_networks", "ix_portal_networks_org_zt"):
op.create_index(
"ix_portal_networks_org_zt",
"portal_networks",
["organization_id", "zerotier_network_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
# Cast enum columns back to VARCHAR(40) — only if currently native enum
enum_casts = [
("portal_networks", "environment", "'development'::character varying"),
("portal_networks", "request_mode", "'approval_required'::character varying"),
("devices", "status", "'active'::character varying"),
("device_network_memberships", "state", "'pending_device_registration'::character varying"),
("user_network_approvals", "grant_type", "'requested'::character varying"),
("user_network_approvals", "state", "'pending'::character varying"),
("activation_sessions", "end_reason", None),
("kill_switch_events", "scope", "'organization'::character varying"),
]
for table, col, old_default in enum_casts:
conn.execute(sa.text(f'ALTER TABLE "{table}" ALTER COLUMN "{col}" DROP DEFAULT'))
conn.execute(sa.text(
f'ALTER TABLE "{table}" ALTER COLUMN "{col}" TYPE VARCHAR(40) '
f'USING "{col}"::text'
))
if old_default:
conn.execute(sa.text(
f'ALTER TABLE "{table}" ALTER COLUMN "{col}" SET DEFAULT {old_default}'
))
@@ -1,101 +0,0 @@
"""Convert ZeroTier table timestamp columns from TIMESTAMPTZ to TIMESTAMP.
Revision ID: 025_fix_zt_timestamps
Revises: 024_fix_zerotier_schema
Create Date: 2026-03-22
Migration 020_zerotier (and 023's fallback create_table) defined ZeroTier tables
with sa.DateTime(timezone=True), producing TIMESTAMP WITH TIME ZONE columns.
The rest of the codebase uses plain DateTime (timezone-naive TIMESTAMP WITHOUT
TIME ZONE). This migration aligns all ZeroTier table timestamp columns with the
existing codebase convention.
GUARDED: Each ALTER is only executed if the column is currently
TIMESTAMP WITH TIME ZONE. On a DB that has already been converted (e.g. dev),
the migration is a harmless no-op.
"""
from alembic import op
import sqlalchemy as sa
revision = "025_fix_zt_timestamps"
down_revision = "024_fix_zerotier_schema"
branch_labels = None
depends_on = None
# All ZeroTier tables that inherit BaseModel's created_at/updated_at/deleted_at
_ZT_BASE_TABLES = [
"portal_networks",
"devices",
"device_network_memberships",
"user_network_approvals",
"kill_switch_events",
"activation_sessions",
"zerotier_memberships",
]
# Additional datetime columns specific to individual models
_EXTRA_COLS = {
"activation_sessions": ["authenticated_at", "expires_at", "ended_at"],
"zerotier_memberships": ["join_seen_at", "last_synced_at"],
}
def _col_is_timestamptz(conn, table: str, column: str) -> bool:
"""Return True if the column is TIMESTAMP WITH TIME ZONE."""
row = conn.execute(sa.text(
"SELECT data_type FROM information_schema.columns "
"WHERE table_name = :t AND column_name = :c"
), {"t": table, "c": column}).first()
return row is not None and row[0] == "timestamp with time zone"
def _col_is_timestamp(conn, table: str, column: str) -> bool:
"""Return True if the column is TIMESTAMP WITHOUT TIME ZONE."""
row = conn.execute(sa.text(
"SELECT data_type FROM information_schema.columns "
"WHERE table_name = :t AND column_name = :c"
), {"t": table, "c": column}).first()
return row is not None and row[0] == "timestamp without time zone"
def upgrade():
conn = op.get_bind()
for tbl in _ZT_BASE_TABLES:
for col in ("created_at", "updated_at", "deleted_at"):
if _col_is_timestamptz(conn, tbl, col):
conn.execute(sa.text(
f'ALTER TABLE "{tbl}" ALTER COLUMN "{col}" '
f'TYPE TIMESTAMP WITHOUT TIME ZONE '
f'USING "{col}" AT TIME ZONE \'UTC\''
))
for col in _EXTRA_COLS.get(tbl, []):
if _col_is_timestamptz(conn, tbl, col):
conn.execute(sa.text(
f'ALTER TABLE "{tbl}" ALTER COLUMN "{col}" '
f'TYPE TIMESTAMP WITHOUT TIME ZONE '
f'USING CASE WHEN "{col}" IS NULL THEN NULL '
f'ELSE "{col}" AT TIME ZONE \'UTC\' END'
))
def downgrade():
conn = op.get_bind()
for tbl in _ZT_BASE_TABLES:
for col in ("created_at", "updated_at", "deleted_at"):
if _col_is_timestamp(conn, tbl, col):
conn.execute(sa.text(
f'ALTER TABLE "{tbl}" ALTER COLUMN "{col}" '
f'TYPE TIMESTAMP WITH TIME ZONE '
f'USING "{col}" AT TIME ZONE \'UTC\''
))
for col in _EXTRA_COLS.get(tbl, []):
if _col_is_timestamp(conn, tbl, col):
conn.execute(sa.text(
f'ALTER TABLE "{tbl}" ALTER COLUMN "{col}" '
f'TYPE TIMESTAMP WITH TIME ZONE '
f'USING CASE WHEN "{col}" IS NULL THEN NULL '
f'ELSE "{col}" AT TIME ZONE \'UTC\' END'
))
-216
View File
@@ -1,216 +0,0 @@
"""Schema cleanup: id UniqueConstraints, organization_api_keys index/timestamp fixes.
Revision ID: 026_schema_cleanup
Revises: 025_fix_zt_timestamps
Create Date: 2026-03-23
Addresses all `db check` differences after 025 on a database upgraded from
production (021_merge_heads):
1. Add UniqueConstraint on `id` for all pre-existing tables that inherit
BaseModel (which declares id with unique=True). The ZeroTier tables
already got these in 024_fix_zerotier_schema; this covers the rest.
2. organization_api_keys fix schema drift vs. the current model:
- TIMESTAMPTZ TIMESTAMP WITHOUT TIME ZONE (align with rest of codebase)
- Drop legacy unique constraint 'organization_api_keys_key_hash_key'
and replace with named index 'ix_organization_api_keys_key_hash'
- Drop extra index 'idx_org_api_key_org_id' (superseded by
'ix_organization_api_keys_organization_id')
- Add 'ix_organization_api_keys_organization_id' and
'ix_organization_api_keys_is_revoked' named indexes expected by model
3. Drop 'idx_dept_can_sudo' index from departments created by an old
migration but not declared in the current Department model.
All operations are guarded so the migration is safe to re-run.
"""
from alembic import op
import sqlalchemy as sa
revision = "026_schema_cleanup"
down_revision = "025_fix_zt_timestamps"
branch_labels = None
depends_on = None
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _constraint_exists(conn, name: str) -> bool:
row = conn.execute(sa.text(
"SELECT 1 FROM information_schema.table_constraints "
"WHERE constraint_name = :n"
), {"n": name}).first()
return row is not None
def _index_exists(conn, table: str, index: str) -> bool:
row = conn.execute(sa.text(
"SELECT 1 FROM pg_indexes "
"WHERE tablename = :t AND indexname = :i"
), {"t": table, "i": index}).first()
return row is not None
def _col_is_timestamptz(conn, table: str, column: str) -> bool:
row = conn.execute(sa.text(
"SELECT data_type FROM information_schema.columns "
"WHERE table_name = :t AND column_name = :c"
), {"t": table, "c": column}).first()
return row is not None and row[0] == "timestamp with time zone"
# ---------------------------------------------------------------------------
# Tables that inherit BaseModel and need an id UniqueConstraint.
# ZeroTier tables were handled in 024; all others are listed here.
# ---------------------------------------------------------------------------
_LEGACY_TABLES = [
"application_provider_configs",
"audit_logs",
"authentication_methods",
"ca_permissions",
"cas",
"certificate_audit_logs",
"department_cert_policies",
"department_memberships",
"department_principals",
"departments",
"email_verification_tokens",
"external_provider_configs",
"mfa_policy_compliance",
"oauth_states",
"oidc_audit_logs",
"oidc_authorization_codes",
"oidc_clients",
"oidc_refresh_tokens",
"oidc_sessions",
# oidc_token_metadata intentionally excluded: its id column overrides
# BaseModel without unique=True (JTI is the PK but not separately unique)
"org_invite_tokens",
"organization_api_keys",
"organization_members",
"organization_provider_overrides",
"organization_security_policies",
"organizations",
"password_reset_tokens",
"principal_memberships",
"principals",
"sessions",
"ssh_certificates",
"ssh_keys",
"user_security_policies",
"users",
]
def upgrade():
conn = op.get_bind()
# ── 1. Add id UniqueConstraint to all legacy BaseModel tables ─────────
for tbl in _LEGACY_TABLES:
cname = f"{tbl}_id_key"
if not _constraint_exists(conn, cname):
op.create_unique_constraint(cname, tbl, ["id"])
# Drop the wrongly-added constraint on oidc_token_metadata if present
# (its id column overrides BaseModel without unique=True)
if _constraint_exists(conn, "oidc_token_metadata_id_key"):
op.drop_constraint("oidc_token_metadata_id_key", "oidc_token_metadata", type_="unique")
# ── 2. organization_api_keys: timestamp columns TIMESTAMPTZ → TIMESTAMP
for col in ("created_at", "updated_at", "deleted_at", "last_used_at", "revoked_at"):
if _col_is_timestamptz(conn, "organization_api_keys", col):
conn.execute(sa.text(
f'ALTER TABLE organization_api_keys ALTER COLUMN "{col}" '
f'TYPE TIMESTAMP WITHOUT TIME ZONE '
f'USING CASE WHEN "{col}" IS NULL THEN NULL '
f'ELSE "{col}" AT TIME ZONE \'UTC\' END'
))
# ── 3. organization_api_keys: replace legacy unique constraint + indexes
# Drop the anonymous unique constraint on key_hash (created by
# sa.UniqueConstraint('key_hash') in the original migration)
if _constraint_exists(conn, "organization_api_keys_key_hash_key"):
op.drop_constraint(
"organization_api_keys_key_hash_key",
"organization_api_keys",
type_="unique",
)
# Add named unique index for key_hash expected by the model
if not _index_exists(conn, "organization_api_keys", "ix_organization_api_keys_key_hash"):
op.create_index(
"ix_organization_api_keys_key_hash",
"organization_api_keys",
["key_hash"],
unique=True,
)
# Drop the legacy plain org-id index (superseded by the named one below)
if _index_exists(conn, "organization_api_keys", "idx_org_api_key_org_id"):
op.drop_index("idx_org_api_key_org_id", table_name="organization_api_keys")
# Add named org-id index expected by the model
if not _index_exists(conn, "organization_api_keys", "ix_organization_api_keys_organization_id"):
op.create_index(
"ix_organization_api_keys_organization_id",
"organization_api_keys",
["organization_id"],
)
# Add named is_revoked index expected by the model
if not _index_exists(conn, "organization_api_keys", "ix_organization_api_keys_is_revoked"):
op.create_index(
"ix_organization_api_keys_is_revoked",
"organization_api_keys",
["is_revoked"],
)
# ── 4. Drop orphan idx_dept_can_sudo from departments ─────────────────
if _index_exists(conn, "departments", "idx_dept_can_sudo"):
op.drop_index("idx_dept_can_sudo", table_name="departments")
# NOTE: ix_ssh_certificates_serial uniqueness is handled in
# 027_fix_cert_serial_uniqueness (composite unique per CA).
def downgrade():
conn = op.get_bind()
# Restore idx_dept_can_sudo
if not _index_exists(conn, "departments", "idx_dept_can_sudo"):
op.create_index("idx_dept_can_sudo", "departments", ["organization_id", "can_sudo"])
# Restore organization_api_keys indexes
if _index_exists(conn, "organization_api_keys", "ix_organization_api_keys_is_revoked"):
op.drop_index("ix_organization_api_keys_is_revoked", table_name="organization_api_keys")
if _index_exists(conn, "organization_api_keys", "ix_organization_api_keys_organization_id"):
op.drop_index("ix_organization_api_keys_organization_id", table_name="organization_api_keys")
if _index_exists(conn, "organization_api_keys", "ix_organization_api_keys_key_hash"):
op.drop_index("ix_organization_api_keys_key_hash", table_name="organization_api_keys")
if not _constraint_exists(conn, "organization_api_keys_key_hash_key"):
op.create_unique_constraint(
"organization_api_keys_key_hash_key",
"organization_api_keys",
["key_hash"],
)
if not _index_exists(conn, "organization_api_keys", "idx_org_api_key_org_id"):
op.create_index("idx_org_api_key_org_id", "organization_api_keys", ["organization_id"])
# Restore TIMESTAMPTZ on organization_api_keys
for col in ("created_at", "updated_at", "deleted_at", "last_used_at", "revoked_at"):
conn.execute(sa.text(
f'ALTER TABLE organization_api_keys ALTER COLUMN "{col}" '
f'TYPE TIMESTAMP WITH TIME ZONE '
f'USING CASE WHEN "{col}" IS NULL THEN NULL '
f'ELSE "{col}" AT TIME ZONE \'UTC\' END'
))
# Drop id UniqueConstraints from legacy tables
for tbl in reversed(_LEGACY_TABLES):
cname = f"{tbl}_id_key"
if _constraint_exists(conn, cname):
op.drop_constraint(cname, tbl, type_="unique")
@@ -1,105 +0,0 @@
"""Fix ssh_certificates serial uniqueness: per-CA not global.
Revision ID: 027_fix_cert_serial_uniqueness
Revises: 026_schema_cleanup
Create Date: 2026-03-23
The SSHCertificate model uses a per-CA monotonic serial counter, meaning
serial numbers are only unique within a single CA not across the whole
table. The original migration created a global unique index on `serial`
alone, which is incorrect and was blocking enforcement (duplicate serial=1
rows exist in production where two different CAs both issued their first
certificate).
This migration:
1. Drops the old non-unique index ix_ssh_certificates_serial (which was
never enforcing uniqueness just an index).
2. Drops the stale unique constraint ssh_certificates_serial_key if it
somehow exists.
3. Creates a proper composite unique constraint uq_ssh_certificates_ca_serial
on (ca_id, serial), reflecting the real invariant: a serial is unique
within one CA.
All operations are guarded (IF EXISTS / try/except) so this is safe to
re-run on any DB state.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.engine.reflection import Inspector
# ---------------------------------------------------------------------------
# revision identifiers
# ---------------------------------------------------------------------------
revision = "027_fix_cert_serial_uniqueness"
down_revision = "026_schema_cleanup"
branch_labels = None
depends_on = None
def _index_exists(conn, table: str, index: str) -> bool:
insp = Inspector.from_engine(conn)
return any(i["name"] == index for i in insp.get_indexes(table))
def _constraint_exists(conn, table: str, constraint: str) -> bool:
insp = Inspector.from_engine(conn)
for uc in insp.get_unique_constraints(table):
if uc["name"] == constraint:
return True
return False
def upgrade():
conn = op.get_bind()
# 1. Drop the old global non-unique index on serial (if present)
if _index_exists(conn, "ssh_certificates", "ix_ssh_certificates_serial"):
op.drop_index("ix_ssh_certificates_serial", table_name="ssh_certificates")
# 2. Drop any stale global unique constraint on serial alone (defensive)
if _constraint_exists(conn, "ssh_certificates", "ssh_certificates_serial_key"):
op.drop_constraint(
"ssh_certificates_serial_key",
"ssh_certificates",
type_="unique",
)
# 3. Add composite unique constraint: serial is unique per CA
if not _constraint_exists(conn, "ssh_certificates", "uq_ssh_certificates_ca_serial"):
op.create_unique_constraint(
"uq_ssh_certificates_ca_serial",
"ssh_certificates",
["ca_id", "serial"],
)
# 4. Re-create a plain non-unique index on serial for fast lookups
if not _index_exists(conn, "ssh_certificates", "ix_ssh_certificates_serial"):
op.create_index(
"ix_ssh_certificates_serial",
"ssh_certificates",
["serial"],
unique=False,
)
def downgrade():
conn = op.get_bind()
# Remove the composite constraint
if _constraint_exists(conn, "ssh_certificates", "uq_ssh_certificates_ca_serial"):
op.drop_constraint(
"uq_ssh_certificates_ca_serial",
"ssh_certificates",
type_="unique",
)
# Restore the old non-unique index (best effort — data may have duplicates)
if not _index_exists(conn, "ssh_certificates", "ix_ssh_certificates_serial"):
op.create_index(
"ix_ssh_certificates_serial",
"ssh_certificates",
["serial"],
unique=False,
)
@@ -1,69 +0,0 @@
"""Add per-org ZeroTier credentials to organizations table.
Revision ID: 028_org_zerotier_config
Revises: 026_schema_cleanup
Create Date: 2026-03-25
Adds three nullable columns to `organizations`:
- zt_api_token VARCHAR(512) API token (Central) or authtoken.secret (controller)
- zt_api_url VARCHAR(512) base URL of the controller / Central API
- zt_api_mode VARCHAR(32) "central" | "controller"
When these are NULL the server-level ZEROTIER_API_* env vars are used instead,
so existing deployments are fully backwards-compatible with no data migration needed.
"""
from alembic import op
import sqlalchemy as sa
revision = "028_org_zerotier_config"
down_revision = "027_fix_cert_serial_uniqueness"
branch_labels = None
depends_on = None
def _col_exists(conn, table: str, column: str) -> bool:
row = conn.execute(
sa.text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = :t AND column_name = :c"
),
{"t": table, "c": column},
).first()
return row is not None
def upgrade():
conn = op.get_bind()
if not _col_exists(conn, "organizations", "zt_api_token"):
op.add_column(
"organizations",
sa.Column("zt_api_token", sa.String(512), nullable=True),
)
if not _col_exists(conn, "organizations", "zt_api_url"):
op.add_column(
"organizations",
sa.Column("zt_api_url", sa.String(512), nullable=True),
)
if not _col_exists(conn, "organizations", "zt_api_mode"):
op.add_column(
"organizations",
sa.Column("zt_api_mode", sa.String(32), nullable=True),
)
def downgrade():
conn = op.get_bind()
if _col_exists(conn, "organizations", "zt_api_mode"):
op.drop_column("organizations", "zt_api_mode")
if _col_exists(conn, "organizations", "zt_api_url"):
op.drop_column("organizations", "zt_api_url")
if _col_exists(conn, "organizations", "zt_api_token"):
op.drop_column("organizations", "zt_api_token")
@@ -1,30 +0,0 @@
"""add_cert_token_to_ssh_certificates
Revision ID: 3de11c5dc2d5
Revises: 019_audit_varchar
Create Date: 2026-03-06 16:04:33.561099
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3de11c5dc2d5'
down_revision = '019_audit_varchar'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('ssh_certificates', sa.Column('cert_token', sa.String(length=64), nullable=True))
op.create_index(op.f('ix_ssh_certificates_cert_token'), 'ssh_certificates', ['cert_token'], unique=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_ssh_certificates_cert_token'), table_name='ssh_certificates')
op.drop_column('ssh_certificates', 'cert_token')
# ### end Alembic commands ###
File diff suppressed because it is too large Load Diff
@@ -1,34 +0,0 @@
"""Add can_sudo column to departments table.
Revision ID: 002_add_can_sudo_to_departments
Revises: 001_add_org_api_keys
Create Date: 2026-03-07 23:40:30.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '002_add_can_sudo_to_departments'
down_revision = '001_add_org_api_keys'
branch_labels = None
depends_on = None
def upgrade():
# Add can_sudo column to departments table
op.add_column('departments',
sa.Column('can_sudo', sa.Boolean(), nullable=False, server_default='false'))
# Create index for performance
op.create_index('idx_dept_can_sudo', 'departments',
['organization_id', 'can_sudo'])
def downgrade():
# Drop index
op.drop_index('idx_dept_can_sudo', table_name='departments')
# Drop column
op.drop_column('departments', 'can_sudo')
@@ -1,56 +0,0 @@
"""Add organization_api_keys table for API key management.
Revision ID: 001_add_org_api_keys
Revises: 3de11c5dc2d5
Create Date: 2026-03-07 23:40:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '001_add_org_api_keys'
down_revision = '3de11c5dc2d5'
branch_labels = None
depends_on = None
def upgrade():
# Create organization_api_keys table
op.create_table(
'organization_api_keys',
sa.Column('id', sa.String(36), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('organization_id', sa.String(36), nullable=False),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('key_hash', sa.String(255), nullable=False),
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('is_revoked', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('revoke_reason', sa.String(255), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('key_hash'),
)
# Create indexes for performance
op.create_index('idx_org_api_key_org_active', 'organization_api_keys',
['organization_id', 'is_revoked'])
op.create_index('idx_api_key_last_used', 'organization_api_keys',
['last_used_at'])
op.create_index('idx_org_api_key_org_id', 'organization_api_keys',
['organization_id'])
def downgrade():
# Drop indexes
op.drop_index('idx_org_api_key_org_id', table_name='organization_api_keys')
op.drop_index('idx_api_key_last_used', table_name='organization_api_keys')
op.drop_index('idx_org_api_key_org_active', table_name='organization_api_keys')
# Drop table
op.drop_table('organization_api_keys')
-124
View File
@@ -1,124 +0,0 @@
"""totp
Revision ID: d2fd4f159054
Revises: 004
Create Date: 2026-02-23 13:21:54.136904
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd2fd4f159054'
down_revision = '004'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('application_provider_configs',
sa.Column('provider_type', sa.String(length=50), nullable=False),
sa.Column('client_id', sa.String(length=255), nullable=False),
sa.Column('client_secret_encrypted', sa.String(length=512), nullable=True),
sa.Column('is_enabled', sa.Boolean(), nullable=False),
sa.Column('default_redirect_url', sa.String(length=2048), nullable=True),
sa.Column('additional_config', sa.JSON(), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
)
op.create_index(op.f('ix_application_provider_configs_provider_type'), 'application_provider_configs', ['provider_type'], unique=True)
op.create_table('external_provider_configs',
sa.Column('organization_id', sa.String(length=36), nullable=False),
sa.Column('provider_type', sa.String(length=50), nullable=False),
sa.Column('client_id', sa.String(length=255), nullable=False),
sa.Column('client_secret_encrypted', sa.String(length=512), nullable=True),
sa.Column('auth_url', sa.String(length=2048), nullable=False),
sa.Column('token_url', sa.String(length=2048), nullable=False),
sa.Column('userinfo_url', sa.String(length=2048), nullable=True),
sa.Column('jwks_url', sa.String(length=2048), nullable=True),
sa.Column('scopes', sa.JSON(), nullable=False),
sa.Column('redirect_uris', sa.JSON(), nullable=False),
sa.Column('settings', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('organization_id', 'provider_type', name='uix_org_provider_type')
)
op.create_index('idx_provider_config_org', 'external_provider_configs', ['organization_id', 'provider_type'], unique=False)
op.create_index(op.f('ix_external_provider_configs_organization_id'), 'external_provider_configs', ['organization_id'], unique=False)
op.create_index(op.f('ix_external_provider_configs_provider_type'), 'external_provider_configs', ['provider_type'], unique=False)
op.create_table('oauth_states',
sa.Column('state', sa.String(length=64), nullable=False),
sa.Column('flow_type', sa.String(length=50), nullable=False),
sa.Column('provider_type', sa.String(length=50), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=True),
sa.Column('organization_id', sa.String(length=36), nullable=True),
sa.Column('nonce', sa.String(length=128), nullable=True),
sa.Column('code_verifier', sa.String(length=128), nullable=True),
sa.Column('code_challenge', sa.String(length=128), nullable=True),
sa.Column('redirect_uri', sa.String(length=2048), nullable=True),
sa.Column('return_url', sa.String(length=2048), nullable=True),
sa.Column('extra_data', sa.JSON(), nullable=True),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('used', sa.Boolean(), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
)
op.create_index(op.f('ix_oauth_states_expires_at'), 'oauth_states', ['expires_at'], unique=False)
op.create_index(op.f('ix_oauth_states_organization_id'), 'oauth_states', ['organization_id'], unique=False)
op.create_index(op.f('ix_oauth_states_state'), 'oauth_states', ['state'], unique=True)
op.create_table('organization_provider_overrides',
sa.Column('organization_id', sa.String(length=36), nullable=False),
sa.Column('provider_type', sa.String(length=50), nullable=False),
sa.Column('client_id', sa.String(length=255), nullable=True),
sa.Column('client_secret_encrypted', sa.String(length=512), nullable=True),
sa.Column('is_enabled', sa.Boolean(), nullable=False),
sa.Column('redirect_url_override', sa.String(length=2048), nullable=True),
sa.Column('additional_config', sa.JSON(), nullable=True),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id'),
sa.UniqueConstraint('organization_id', 'provider_type', name='uix_org_provider_override_type')
)
op.create_index(op.f('ix_organization_provider_overrides_organization_id'), 'organization_provider_overrides', ['organization_id'], unique=False)
op.create_index(op.f('ix_organization_provider_overrides_provider_type'), 'organization_provider_overrides', ['provider_type'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_organization_provider_overrides_provider_type'), table_name='organization_provider_overrides')
op.drop_index(op.f('ix_organization_provider_overrides_organization_id'), table_name='organization_provider_overrides')
op.drop_table('organization_provider_overrides')
op.drop_index(op.f('ix_oauth_states_state'), table_name='oauth_states')
op.drop_index(op.f('ix_oauth_states_organization_id'), table_name='oauth_states')
op.drop_index(op.f('ix_oauth_states_expires_at'), table_name='oauth_states')
op.drop_table('oauth_states')
op.drop_index(op.f('ix_external_provider_configs_provider_type'), table_name='external_provider_configs')
op.drop_index(op.f('ix_external_provider_configs_organization_id'), table_name='external_provider_configs')
op.drop_index('idx_provider_config_org', table_name='external_provider_configs')
op.drop_table('external_provider_configs')
op.drop_index(op.f('ix_application_provider_configs_provider_type'), table_name='application_provider_configs')
op.drop_table('application_provider_configs')
# ### end Alembic commands ###
@@ -1,50 +0,0 @@
"""add_activation_fields_and_ca_permissions
Revision ID: d34bfb72844e
Revises: 012_ca_nullable_org
Create Date: 2026-02-28 18:06:47.328552
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd34bfb72844e'
down_revision = '012_ca_nullable_org'
branch_labels = None
depends_on = None
def upgrade():
# Create ca_permissions table
op.create_table(
'ca_permissions',
sa.Column('ca_id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('permission', sa.String(length=50), nullable=False),
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['ca_id'], ['cas.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('ca_id', 'user_id', name='uix_ca_permission'),
)
op.create_index('ix_ca_permissions_ca_id', 'ca_permissions', ['ca_id'], unique=False)
op.create_index('ix_ca_permissions_user_id', 'ca_permissions', ['user_id'], unique=False)
# Add activation columns to users
op.add_column('users', sa.Column('activated', sa.Boolean(), nullable=False,
server_default=sa.text('true')))
op.add_column('users', sa.Column('activation_key', sa.String(length=128), nullable=True))
op.create_index('ix_users_activation_key', 'users', ['activation_key'], unique=True)
def downgrade():
op.drop_index('ix_users_activation_key', table_name='users')
op.drop_column('users', 'activation_key')
op.drop_column('users', 'activated')
op.drop_index('ix_ca_permissions_user_id', table_name='ca_permissions')
op.drop_index('ix_ca_permissions_ca_id', table_name='ca_permissions')
op.drop_table('ca_permissions')
@@ -1,42 +0,0 @@
"""allow_null_ssh_key_id_for_host_certs
Make ssh_certificates.ssh_key_id nullable so that host certificates issued
against a raw server host public key (i.e. not a pre-registered SSHKey record)
can be persisted in the database.
Revision ID: db15faee1fb8
Revises: 018_audit_enum_values
Create Date: 2026-03-03 16:55:54.030674
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'db15faee1fb8'
down_revision = '018_audit_enum_values'
branch_labels = None
depends_on = None
def upgrade():
op.alter_column(
'ssh_certificates',
'ssh_key_id',
existing_type=sa.VARCHAR(length=36),
nullable=True,
)
def downgrade():
# Null out any rows introduced by host-cert issuance before restoring NOT NULL
op.execute(
"UPDATE ssh_certificates SET ssh_key_id = '00000000-0000-0000-0000-000000000000' "
"WHERE ssh_key_id IS NULL"
)
op.alter_column(
'ssh_certificates',
'ssh_key_id',
existing_type=sa.VARCHAR(length=36),
nullable=False,
)
+3
View File
@@ -49,5 +49,8 @@ Flask-Limiter==3.5.0
python-json-logger==2.0.7
qrcode[pil]
# HTTP requests
requests>=2.31.0
# SSH CA Certificate signing
sshkey-tools==0.11.3
+2 -2
View File
@@ -1,6 +1,6 @@
# Gatehouse Scripts
# Secuird Scripts
This directory contains utility scripts for managing and configuring Gatehouse.
This directory contains utility scripts for managing and configuring Secuird.
## OAuth Provider Configuration Script
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
OAuth Provider Configuration Script for Gatehouse
OAuth Provider Configuration Script for Secuird
This script allows administrators to configure OAuth providers at the application level
using the new ApplicationProviderConfig architecture.
@@ -457,7 +457,7 @@ def delete_provider(args):
def main():
"""Main entry point for the script."""
parser = argparse.ArgumentParser(
description="Configure OAuth providers for Gatehouse authentication",
description="Configure OAuth providers for Secuird authentication",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Test script to verify email delivery with HTML templates."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from dotenv import load_dotenv
load_dotenv(".env")
from gatehouse_app import create_app
from gatehouse_app.services.email_provider import EmailProviderFactory, EmailMessage
from gatehouse_app.services import email_templates
def test_html_email():
app = create_app()
print("Testing HTML Email Templates...")
print(f"EMAIL_PROVIDER: {app.config.get('EMAIL_PROVIDER')}")
print(f"MAILGUN_DOMAIN: {app.config.get('MAILGUN_DOMAIN')}")
with app.app_context():
provider = EmailProviderFactory.get_provider()
print(f"Provider class: {provider.__class__.__name__}")
# Test 1: Email Verification
print("\n--- Test 1: Email Verification ---")
html_body = email_templates.build_email_verification_html(
user_name="Cory",
verify_link="https://secuird.tech/verify-email?token=test123",
expiry_hours=24,
)
message = EmailMessage(
to="cory@hawkvelt.id.au",
subject="Verify your Secuird email address",
body="Plain text version: Please verify your email by clicking the link.",
html_body=html_body,
from_address="Secuird <noreply@secuird.tech>",
)
success = provider.send(message)
print(f"Result: {'✅ SUCCESS' if success else '❌ FAILED'}")
# Test 2: Password Reset
print("\n--- Test 2: Password Reset ---")
html_body = email_templates.build_password_reset_html(
user_name="Cory",
reset_link="https://secuird.tech/reset-password?token=test456",
expiry_hours=2,
)
message = EmailMessage(
to="cory@hawkvelt.id.au",
subject="Reset your Secuird password",
body="Plain text version: Reset your password by clicking the link.",
html_body=html_body,
from_address="Secuird <noreply@secuird.tech>",
)
success = provider.send(message)
print(f"Result: {'✅ SUCCESS' if success else '❌ FAILED'}")
# Test 3: MFA Deadline Reminder
print("\n--- Test 3: MFA Deadline Reminder ---")
html_body = email_templates.build_mfa_deadline_reminder_html(
user_name="Cory",
org_name="Acme Corp",
days_remaining=5,
deadline_date="2026-04-09 23:59 UTC",
mfa_methods="Authenticator app (TOTP) or Passkey (WebAuthn)",
setup_link="https://secuird.tech/settings/security",
)
message = EmailMessage(
to="cory@hawkvelt.id.au",
subject="Action Required: MFA enrollment deadline in 5 days",
body="Plain text version: MFA enrollment required.",
html_body=html_body,
from_address="Secuird <noreply@secuird.tech>",
)
success = provider.send(message)
print(f"Result: {'✅ SUCCESS' if success else '❌ FAILED'}")
# Test 4: MFA Suspension
print("\n--- Test 4: MFA Suspension ---")
html_body = email_templates.build_mfa_suspension_html(
user_name="Cory",
org_name="Acme Corp",
mfa_methods="Authenticator app (TOTP) or Passkey (WebAuthn)",
setup_link="https://secuird.tech/settings/security",
)
message = EmailMessage(
to="cory@hawkvelt.id.au",
subject="Account Access Restricted - MFA Enrollment Required",
body="Plain text version: Your account has been suspended.",
html_body=html_body,
from_address="Secuird <noreply@secuird.tech>",
)
success = provider.send(message)
print(f"Result: {'✅ SUCCESS' if success else '❌ FAILED'}")
# Test 5: Organization Invite
print("\n--- Test 5: Organization Invite ---")
html_body = email_templates.build_org_invite_html(
inviter_name="Admin User",
org_name="Acme Corporation",
invite_link="https://secuird.tech/invite?token=test789",
role="Member",
expiry_days=7,
)
message = EmailMessage(
to="cory@hawkvelt.id.au",
subject="You're invited to join Acme Corporation on Secuird",
body="Plain text version: You've been invited to join.",
html_body=html_body,
from_address="Secuird <noreply@secuird.tech>",
)
success = provider.send(message)
print(f"Result: {'✅ SUCCESS' if success else '❌ FAILED'}")
# Test 6: Account Activation
print("\n--- Test 6: Account Activation ---")
html_body = email_templates.build_account_activation_html(
user_name="Cory",
activation_link="https://secuird.tech/activate?code=testabc",
)
message = EmailMessage(
to="cory@hawkvelt.id.au",
subject="Activate your Secuird account",
body="Plain text version: Activate your account.",
html_body=html_body,
from_address="Secuird <noreply@secuird.tech>",
)
success = provider.send(message)
print(f"Result: {'✅ SUCCESS' if success else '❌ FAILED'}")
# Test 7: Email Verification Resend
print("\n--- Test 7: Email Verification Resend ---")
html_body = email_templates.build_email_verification_resend_html(
user_name="Cory",
verify_link="https://secuird.tech/verify-email?token=testxyz",
expiry_hours=24,
)
message = EmailMessage(
to="cory@hawkvelt.id.au",
subject="Verify your Secuird email address",
body="Plain text version: Please verify your email.",
html_body=html_body,
from_address="Secuird <noreply@secuird.tech>",
)
success = provider.send(message)
print(f"Result: {'✅ SUCCESS' if success else '❌ FAILED'}")
print("\n" + "=" * 50)
print("All 7 email templates sent!")
print("=" * 50)
if __name__ == "__main__":
test_html_email()