+33
-4
@@ -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
@@ -136,4 +136,9 @@ Thumbs.db
|
||||
# Project specific
|
||||
|
||||
*.db
|
||||
flask_session/
|
||||
flask_session/
|
||||
|
||||
# Opencode files and folders
|
||||
.opencode/
|
||||
.swarm/
|
||||
SWARM_PLAN.*
|
||||
+68
@@ -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"]
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
@@ -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.
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
•
|
||||
<a href="mailto:{support_email}" style="color: {PRIMARY_COLOR}; text-decoration: none;">Support</a>
|
||||
•
|
||||
<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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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')
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 1–N 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
|
||||
@@ -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'
|
||||
))
|
||||
@@ -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')
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
Reference in New Issue
Block a user