diff --git a/.env.example b/.env.example index 537face..4fd332d 100644 --- a/.env.example +++ b/.env.example @@ -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 # ───────────────────────────────────────────────────────────────────────────── diff --git a/.gitignore b/.gitignore index 306cd4a..6fcc667 100644 --- a/.gitignore +++ b/.gitignore @@ -136,4 +136,9 @@ Thumbs.db # Project specific *.db -flask_session/ \ No newline at end of file +flask_session/ + +# Opencode files and folders +.opencode/ +.swarm/ +SWARM_PLAN.* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e795865 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 7cef62d..604bfc3 100644 --- a/README.md +++ b/README.md @@ -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 \ No newline at end of file +### Test User +| Field | Value | +|-------|-------| +| email | `bob@acme-corp.com` | +| password | `UserPass123!` | \ No newline at end of file diff --git a/config/base.py b/config/base.py index 2ac6adb..cf94022 100644 --- a/config/base.py +++ b/config/base.py @@ -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", "") diff --git a/config/development.py b/config/development.py index a622243..714a2f3 100644 --- a/config/development.py +++ b/config/development.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3b2b9b5 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..ff9c194 --- /dev/null +++ b/docker/nginx.conf @@ -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; + } +} diff --git a/flask_session/2029240f6d1128be89ddc32729463129 b/flask_session/2029240f6d1128be89ddc32729463129 deleted file mode 100644 index 60b84f8..0000000 Binary files a/flask_session/2029240f6d1128be89ddc32729463129 and /dev/null differ diff --git a/gatehouse_app/__init__.py b/gatehouse_app/__init__.py index f17c784..dd77dce 100644 --- a/gatehouse_app/__init__.py +++ b/gatehouse_app/__init__.py @@ -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 diff --git a/gatehouse_app/api/v1/auth/core.py b/gatehouse_app/api/v1/auth/core.py index 308633b..3c0edc1 100644 --- a/gatehouse_app/api/v1/auth/core.py +++ b/gatehouse_app/api/v1/auth/core.py @@ -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: diff --git a/gatehouse_app/api/v1/auth/password.py b/gatehouse_app/api/v1/auth/password.py index fac32fa..0c34a17 100644 --- a/gatehouse_app/api/v1/auth/password.py +++ b/gatehouse_app/api/v1/auth/password.py @@ -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}") diff --git a/gatehouse_app/api/v1/organizations/invites.py b/gatehouse_app/api/v1/organizations/invites.py index 4e3971e..f4ee1fd 100644 --- a/gatehouse_app/api/v1/organizations/invites.py +++ b/gatehouse_app/api/v1/organizations/invites.py @@ -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}") diff --git a/gatehouse_app/api/v1/organizations/members.py b/gatehouse_app/api/v1/organizations/members.py index c605104..3198237 100644 --- a/gatehouse_app/api/v1/organizations/members.py +++ b/gatehouse_app/api/v1/organizations/members.py @@ -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" ), ) diff --git a/gatehouse_app/services/auth_service.py b/gatehouse_app/services/auth_service.py index fdf09b7..04ec8b0 100644 --- a/gatehouse_app/services/auth_service.py +++ b/gatehouse_app/services/auth_service.py @@ -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 diff --git a/gatehouse_app/services/email_provider.py b/gatehouse_app/services/email_provider.py new file mode 100644 index 0000000..331ddd9 --- /dev/null +++ b/gatehouse_app/services/email_provider.py @@ -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() diff --git a/gatehouse_app/services/email_templates.py b/gatehouse_app/services/email_templates.py new file mode 100644 index 0000000..c4ede14 --- /dev/null +++ b/gatehouse_app/services/email_templates.py @@ -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 ( + '' + '' + '' + '' + ) + + +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''' + + + + + + {subject} + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ {logo} +

{brand_name}

+
+
+ {content} +
+
+
+ + + + +
+

© {current_year} {brand_name}. All rights reserved.

+

+ Website +  •  + Support +  •  + App +

+

+ This email was sent because you have an account with {brand_name}. +

+
+
+
+ +''' + + +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''' + + + +
+ {text} +
''' + + +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''' + + + +
+

{icon} {text}

+
''' + + +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''' + + + + + + +
{label}{value}
+ + ''' + + +# ============================================================================= +# 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''' +

Welcome to Secuird!

+

+ Hi {user_name}, +

+

+ Thank you for registering with Secuird. Please verify your email address by clicking the button below: +

+ {get_action_button(verify_link, "Verify Email Address")} +

+ This link will expire in {expiry_hours} hours. If you didn't create an account, you can safely ignore this email. +

+ {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''' +

Reset Your Password

+

+ Hi {user_name}, +

+

+ We received a request to reset your password. Click the button below to create a new one: +

+ {get_action_button(reset_link, "Reset Password", WARNING_COLOR)} +

+ This link will expire in {expiry_hours} hours. +

+ {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''' +

Activate Your Account

+

+ Hi {user_name}, +

+

+ Your account has been created but is not yet activated. Click the button below to activate it: +

+ {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''' +

MFA Enrollment {urgency.title()}

+

+ Dear {user_name}, +

+ {get_alert_box(f"Important: You have {days_remaining} days to set up multi-factor authentication for your account with {org_name}.", "warning", "⏰")} +

+ To maintain access to your account, please complete the following: +

+ + + + +
+

Required MFA Methods:

+

{mfa_methods}

+

Deadline:

+

{deadline_date}

+
+ {get_action_button(setup_link, "Set Up MFA Now", PRIMARY_COLOR)} +

+ If you do not set up MFA by the deadline, your account access will be restricted. +

+

+ If you have questions, please contact your organization administrator. +

+ ''' + 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''' +

Account Access Restricted

+

+ Dear {user_name}, +

+ {get_alert_box("Your account has been suspended because you did not set up multi-factor authentication within the required timeframe.", "danger", "🚫")} +

+ To restore access to your account with {org_name}, please complete the following: +

+ + + + +
+

Required MFA Methods:

+

{mfa_methods}

+

How to Restore Access:

+
    +
  1. Log in to your account (you will see a compliance enrollment screen)
  2. +
  3. Follow the prompts to set up an authenticator app or passkey
  4. +
  5. Once MFA is configured, your access will be restored
  6. +
+
+ {get_action_button(setup_link, "Set Up MFA Now", DANGER_COLOR)} +

+ Need help? Contact your organization administrator. +

+ ''' + 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''' +

You're Invited to Join {org_name}

+

+ You've been invited by {inviter_name} to join {org_name} on Secuird. +

+ + + + +
+

Invitation Details:

+

Organization: {org_name}

+

Role: {role}

+

This invitation expires in {expiry_days} days

+
+ {get_action_button(invite_link, "Accept Invitation", SUCCESS_COLOR)} +

+ If you did not expect this invitation, you can safely ignore this email. +

+ ''' + 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''' +

Verify Your Email Address

+

+ Hi {user_name}, +

+

+ Please verify your email address by clicking the button below: +

+ {get_action_button(verify_link, "Verify Email Address")} +

+ This link will expire in {expiry_hours} hours. +

+ {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") diff --git a/gatehouse_app/services/external_auth/linking.py b/gatehouse_app/services/external_auth/linking.py index e9fc5f4..f7e8c3a 100644 --- a/gatehouse_app/services/external_auth/linking.py +++ b/gatehouse_app/services/external_auth/linking.py @@ -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, ) diff --git a/gatehouse_app/services/notification_service.py b/gatehouse_app/services/notification_service.py index 068fe8f..af84b99 100644 --- a/gatehouse_app/services/notification_service.py +++ b/gatehouse_app/services/notification_service.py @@ -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() diff --git a/gatehouse_app/services/providers/mailgun_provider.py b/gatehouse_app/services/providers/mailgun_provider.py new file mode 100644 index 0000000..62df3f8 --- /dev/null +++ b/gatehouse_app/services/providers/mailgun_provider.py @@ -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 diff --git a/gatehouse_app/services/providers/sendgrid_provider.py b/gatehouse_app/services/providers/sendgrid_provider.py new file mode 100644 index 0000000..924d2fb --- /dev/null +++ b/gatehouse_app/services/providers/sendgrid_provider.py @@ -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 diff --git a/gatehouse_app/services/providers/smtp_provider.py b/gatehouse_app/services/providers/smtp_provider.py new file mode 100644 index 0000000..85c7b8e --- /dev/null +++ b/gatehouse_app/services/providers/smtp_provider.py @@ -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 diff --git a/gatehouse_app/services/totp_service.py b/gatehouse_app/services/totp_service.py index c667e3e..e733dce 100644 --- a/gatehouse_app/services/totp_service.py +++ b/gatehouse_app/services/totp_service.py @@ -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) diff --git a/gatehouse_app/services/webauthn_service.py b/gatehouse_app/services/webauthn_service.py index 9f953d3..59df130 100644 --- a/gatehouse_app/services/webauthn_service.py +++ b/gatehouse_app/services/webauthn_service.py @@ -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')) diff --git a/migrations/versions/001_base.py b/migrations/versions/001_base.py deleted file mode 100644 index 3625854..0000000 --- a/migrations/versions/001_base.py +++ /dev/null @@ -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 ### diff --git a/migrations/versions/002_add_totp_support.py b/migrations/versions/002_add_totp_support.py deleted file mode 100644 index 3f01f31..0000000 --- a/migrations/versions/002_add_totp_support.py +++ /dev/null @@ -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') diff --git a/migrations/versions/003_create_oidc_jwks_keys_table.py b/migrations/versions/003_create_oidc_jwks_keys_table.py deleted file mode 100644 index 2ad89b0..0000000 --- a/migrations/versions/003_create_oidc_jwks_keys_table.py +++ /dev/null @@ -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') \ No newline at end of file diff --git a/migrations/versions/004_policies.py b/migrations/versions/004_policies.py deleted file mode 100644 index 476d3bd..0000000 --- a/migrations/versions/004_policies.py +++ /dev/null @@ -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 ### \ No newline at end of file diff --git a/migrations/versions/005_fix_refresh_token_access_token_id.py b/migrations/versions/005_fix_refresh_token_access_token_id.py deleted file mode 100644 index 7a183fd..0000000 --- a/migrations/versions/005_fix_refresh_token_access_token_id.py +++ /dev/null @@ -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'] - ) diff --git a/migrations/versions/006_add_departments_principals.py b/migrations/versions/006_add_departments_principals.py deleted file mode 100644 index 8022143..0000000 --- a/migrations/versions/006_add_departments_principals.py +++ /dev/null @@ -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 ### diff --git a/migrations/versions/007_add_ssh_ca_models.py b/migrations/versions/007_add_ssh_ca_models.py deleted file mode 100644 index 9749930..0000000 --- a/migrations/versions/007_add_ssh_ca_models.py +++ /dev/null @@ -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') diff --git a/migrations/versions/008_fix_authmethodtype_enum.py b/migrations/versions/008_fix_authmethodtype_enum.py deleted file mode 100644 index ccf56e9..0000000 --- a/migrations/versions/008_fix_authmethodtype_enum.py +++ /dev/null @@ -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 diff --git a/migrations/versions/009_sync_auditaction_enum.py b/migrations/versions/009_sync_auditaction_enum.py deleted file mode 100644 index 59bc210..0000000 --- a/migrations/versions/009_sync_auditaction_enum.py +++ /dev/null @@ -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 diff --git a/migrations/versions/010_password_reset_email_verify.py b/migrations/versions/010_password_reset_email_verify.py deleted file mode 100644 index efb836e..0000000 --- a/migrations/versions/010_password_reset_email_verify.py +++ /dev/null @@ -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') diff --git a/migrations/versions/011_org_invite_tokens.py b/migrations/versions/011_org_invite_tokens.py deleted file mode 100644 index 003da63..0000000 --- a/migrations/versions/011_org_invite_tokens.py +++ /dev/null @@ -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') diff --git a/migrations/versions/012_ca_nullable_org_and_cert_serial.py b/migrations/versions/012_ca_nullable_org_and_cert_serial.py deleted file mode 100644 index 8b7dd0e..0000000 --- a/migrations/versions/012_ca_nullable_org_and_cert_serial.py +++ /dev/null @@ -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, - ) diff --git a/migrations/versions/013_add_ca_type.py b/migrations/versions/013_add_ca_type.py deleted file mode 100644 index 1df4bc2..0000000 --- a/migrations/versions/013_add_ca_type.py +++ /dev/null @@ -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 diff --git a/migrations/versions/014_add_dept_cert_policy.py b/migrations/versions/014_add_dept_cert_policy.py deleted file mode 100644 index 58b6cb3..0000000 --- a/migrations/versions/014_add_dept_cert_policy.py +++ /dev/null @@ -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") diff --git a/migrations/versions/015_add_user_suspend_audit_actions.py b/migrations/versions/015_add_user_suspend_audit_actions.py deleted file mode 100644 index 06834da..0000000 --- a/migrations/versions/015_add_user_suspend_audit_actions.py +++ /dev/null @@ -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 diff --git a/migrations/versions/016_encrypt_existing_ca_keys.py b/migrations/versions/016_encrypt_existing_ca_keys.py deleted file mode 100644 index 91acdda..0000000 --- a/migrations/versions/016_encrypt_existing_ca_keys.py +++ /dev/null @@ -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." - ) diff --git a/migrations/versions/017_add_ca_serial_counter.py b/migrations/versions/017_add_ca_serial_counter.py deleted file mode 100644 index 94b85e9..0000000 --- a/migrations/versions/017_add_ca_serial_counter.py +++ /dev/null @@ -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") diff --git a/migrations/versions/018_add_ownership_and_hard_delete_audit_actions.py b/migrations/versions/018_add_ownership_and_hard_delete_audit_actions.py deleted file mode 100644 index 7cbbd32..0000000 --- a/migrations/versions/018_add_ownership_and_hard_delete_audit_actions.py +++ /dev/null @@ -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 diff --git a/migrations/versions/019_convert_auditaction_enum_to_varchar.py b/migrations/versions/019_convert_auditaction_enum_to_varchar.py deleted file mode 100644 index 1b419ac..0000000 --- a/migrations/versions/019_convert_auditaction_enum_to_varchar.py +++ /dev/null @@ -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 diff --git a/migrations/versions/020_add_zerotier_models.py b/migrations/versions/020_add_zerotier_models.py deleted file mode 100644 index bba02a4..0000000 --- a/migrations/versions/020_add_zerotier_models.py +++ /dev/null @@ -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 diff --git a/migrations/versions/020_ca_serial_timestamp_start.py b/migrations/versions/020_ca_serial_timestamp_start.py deleted file mode 100644 index 2556607..0000000 --- a/migrations/versions/020_ca_serial_timestamp_start.py +++ /dev/null @@ -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 diff --git a/migrations/versions/021_merge_heads.py b/migrations/versions/021_merge_heads.py deleted file mode 100644 index 9b1e662..0000000 --- a/migrations/versions/021_merge_heads.py +++ /dev/null @@ -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 diff --git a/migrations/versions/022_add_command_events.py b/migrations/versions/022_add_command_events.py deleted file mode 100644 index a410af7..0000000 --- a/migrations/versions/022_add_command_events.py +++ /dev/null @@ -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 diff --git a/migrations/versions/023_zerotier_drop_legacy.py b/migrations/versions/023_zerotier_drop_legacy.py deleted file mode 100644 index c1a1608..0000000 --- a/migrations/versions/023_zerotier_drop_legacy.py +++ /dev/null @@ -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}")) diff --git a/migrations/versions/024_fix_zerotier_schema.py b/migrations/versions/024_fix_zerotier_schema.py deleted file mode 100644 index 11c9aa2..0000000 --- a/migrations/versions/024_fix_zerotier_schema.py +++ /dev/null @@ -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}' - )) diff --git a/migrations/versions/025_fix_zt_timestamps.py b/migrations/versions/025_fix_zt_timestamps.py deleted file mode 100644 index 2b0029f..0000000 --- a/migrations/versions/025_fix_zt_timestamps.py +++ /dev/null @@ -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' - )) diff --git a/migrations/versions/026_schema_cleanup.py b/migrations/versions/026_schema_cleanup.py deleted file mode 100644 index 15e5ef0..0000000 --- a/migrations/versions/026_schema_cleanup.py +++ /dev/null @@ -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") diff --git a/migrations/versions/027_fix_cert_serial_uniqueness.py b/migrations/versions/027_fix_cert_serial_uniqueness.py deleted file mode 100644 index 3441b91..0000000 --- a/migrations/versions/027_fix_cert_serial_uniqueness.py +++ /dev/null @@ -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, - ) diff --git a/migrations/versions/028_org_zerotier_config.py b/migrations/versions/028_org_zerotier_config.py deleted file mode 100644 index c5aad28..0000000 --- a/migrations/versions/028_org_zerotier_config.py +++ /dev/null @@ -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") diff --git a/migrations/versions/3de11c5dc2d5_add_cert_token_to_ssh_certificates.py b/migrations/versions/3de11c5dc2d5_add_cert_token_to_ssh_certificates.py deleted file mode 100644 index 0b066ad..0000000 --- a/migrations/versions/3de11c5dc2d5_add_cert_token_to_ssh_certificates.py +++ /dev/null @@ -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 ### diff --git a/migrations/versions/6a4c4ed4a5c6_initial_migration.py b/migrations/versions/6a4c4ed4a5c6_initial_migration.py new file mode 100644 index 0000000..d325376 --- /dev/null +++ b/migrations/versions/6a4c4ed4a5c6_initial_migration.py @@ -0,0 +1,1103 @@ +"""Initial migration + +Revision ID: 6a4c4ed4a5c6 +Revises: None +Create Date: 2026-04-03 14:31:49.172415 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6a4c4ed4a5c6' +down_revision = None +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('oidc_jwks_keys', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=True), + sa.Column('kid', sa.String(length=255), nullable=False), + sa.Column('key_type', sa.String(length=50), nullable=False), + sa.Column('algorithm', sa.String(length=50), nullable=False), + sa.Column('private_key', sa.Text(), nullable=False), + sa.Column('public_key', sa.Text(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('is_primary', sa.Boolean(), 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') + ) + op.create_index(op.f('ix_oidc_jwks_keys_kid'), 'oidc_jwks_keys', ['kid'], unique=True) + 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('zt_api_token', sa.String(length=512), nullable=True), + sa.Column('zt_api_url', sa.String(length=512), nullable=True), + sa.Column('zt_api_mode', sa.String(length=32), 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', 'COMPLIANCE_SUSPENDED', 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('activated', sa.Boolean(), nullable=False), + sa.Column('activation_key', sa.String(length=128), 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_activation_key'), 'users', ['activation_key'], unique=True) + 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.String(length=100), 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', 'TOTP', 'GOOGLE', 'GITHUB', 'MICROSOFT', 'SAML', 'OIDC', 'WEBAUTHN', 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('totp_secret', sa.String(length=32), nullable=True), + sa.Column('totp_backup_codes', sa.JSON(), nullable=True), + sa.Column('totp_verified_at', sa.DateTime(), 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('cas', + sa.Column('organization_id', sa.String(length=36), nullable=True), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('ca_type', sa.Enum('user', 'host', name='catype'), nullable=False), + sa.Column('key_type', sa.Enum('ed25519', 'rsa', 'ecdsa', name='keytype'), 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('next_serial_number', sa.BigInteger(), 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('fingerprint'), + sa.UniqueConstraint('id'), + sa.UniqueConstraint('organization_id', 'name', name='uix_org_ca_name') + ) + op.create_index('idx_ca_org_active', 'cas', ['organization_id', 'is_active'], unique=False) + op.create_index(op.f('ix_cas_is_active'), 'cas', ['is_active'], unique=False) + op.create_index(op.f('ix_cas_organization_id'), 'cas', ['organization_id'], unique=False) + 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('can_sudo', 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', 'name', name='uix_org_dept_name') + ) + op.create_index(op.f('ix_departments_name'), 'departments', ['name'], unique=False) + op.create_index(op.f('ix_departments_organization_id'), 'departments', ['organization_id'], unique=False) + op.create_table('devices', + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('organization_id', sa.String(length=36), nullable=False), + sa.Column('node_id', sa.String(length=10), nullable=False), + sa.Column('device_nickname', sa.String(length=255), nullable=True), + sa.Column('hostname', sa.String(length=255), nullable=True), + sa.Column('asset_tag', sa.String(length=255), nullable=True), + sa.Column('serial_number', sa.String(length=255), nullable=True), + sa.Column('status', sa.Enum('active', 'inactive', name='device_status'), 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_devices_node_id'), 'devices', ['node_id'], unique=False) + op.create_index(op.f('ix_devices_organization_id'), 'devices', ['organization_id'], unique=False) + op.create_index(op.f('ix_devices_user_id'), 'devices', ['user_id'], unique=False) + op.create_table('email_verification_tokens', + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('token', sa.String(length=128), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('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'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id') + ) + op.create_index(op.f('ix_email_verification_tokens_token'), 'email_verification_tokens', ['token'], unique=True) + op.create_index(op.f('ix_email_verification_tokens_user_id'), 'email_verification_tokens', ['user_id'], unique=False) + 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('kill_switch_events', + sa.Column('organization_id', sa.String(length=36), nullable=False), + sa.Column('target_user_id', sa.String(length=36), nullable=False), + sa.Column('scope', sa.Enum('organization', 'selected_networks', name='kill_switch_scope'), nullable=False), + sa.Column('triggered_by_user_id', sa.String(length=36), nullable=False), + sa.Column('reason', sa.Text(), nullable=True), + sa.Column('network_ids', 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.ForeignKeyConstraint(['target_user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['triggered_by_user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id') + ) + op.create_index(op.f('ix_kill_switch_events_organization_id'), 'kill_switch_events', ['organization_id'], unique=False) + op.create_index(op.f('ix_kill_switch_events_target_user_id'), 'kill_switch_events', ['target_user_id'], unique=False) + 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('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('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('org_invite_tokens', + sa.Column('organization_id', sa.String(length=36), nullable=False), + sa.Column('invited_by_id', sa.String(length=36), nullable=True), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('role', sa.String(length=64), nullable=False), + sa.Column('token', sa.String(length=128), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('accepted_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'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id') + ) + op.create_index(op.f('ix_org_invite_tokens_email'), 'org_invite_tokens', ['email'], unique=False) + op.create_index(op.f('ix_org_invite_tokens_organization_id'), 'org_invite_tokens', ['organization_id'], unique=False) + op.create_index(op.f('ix_org_invite_tokens_token'), 'org_invite_tokens', ['token'], unique=True) + op.create_table('organization_api_keys', + sa.Column('organization_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('key_hash', sa.String(length=255), nullable=False), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + sa.Column('is_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('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') + ) + op.create_index('idx_api_key_last_used', 'organization_api_keys', ['last_used_at'], unique=False) + op.create_index('idx_org_api_key_org_active', 'organization_api_keys', ['organization_id', 'is_revoked'], unique=False) + op.create_index(op.f('ix_organization_api_keys_is_revoked'), 'organization_api_keys', ['is_revoked'], unique=False) + op.create_index(op.f('ix_organization_api_keys_key_hash'), 'organization_api_keys', ['key_hash'], unique=True) + op.create_index(op.f('ix_organization_api_keys_organization_id'), 'organization_api_keys', ['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('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) + 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('password_reset_tokens', + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('token', sa.String(length=128), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('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'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id') + ) + op.create_index(op.f('ix_password_reset_tokens_token'), 'password_reset_tokens', ['token'], unique=True) + op.create_index(op.f('ix_password_reset_tokens_user_id'), 'password_reset_tokens', ['user_id'], unique=False) + op.create_table('portal_networks', + 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('owner_user_id', sa.String(length=36), nullable=False), + sa.Column('zerotier_network_id', sa.String(length=16), nullable=False), + sa.Column('environment', sa.Enum('production', 'staging', 'development', 'lab', name='network_environment'), nullable=False), + sa.Column('request_mode', sa.Enum('open', 'approval_required', 'invite_only', name='network_request_mode'), nullable=False), + sa.Column('default_activation_lifetime_minutes', sa.Integer(), nullable=False), + sa.Column('max_activation_lifetime_minutes', sa.Integer(), 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.ForeignKeyConstraint(['owner_user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id'), + sa.UniqueConstraint('organization_id', 'zerotier_network_id', name='uix_org_zt_network_id') + ) + op.create_index(op.f('ix_portal_networks_organization_id'), 'portal_networks', ['organization_id'], unique=False) + op.create_index(op.f('ix_portal_networks_zerotier_network_id'), 'portal_networks', ['zerotier_network_id'], unique=False) + 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_name'), 'principals', ['name'], unique=False) + op.create_index(op.f('ix_principals_organization_id'), 'principals', ['organization_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('is_compliance_only', 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(['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('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') + ) + op.create_index('idx_ssh_key_user_verified', 'ssh_keys', ['user_id', 'verified'], unique=False) + op.create_index(op.f('ix_ssh_keys_fingerprint'), 'ssh_keys', ['fingerprint'], unique=True) + op.create_index(op.f('ix_ssh_keys_user_id'), 'ssh_keys', ['user_id'], unique=False) + op.create_index(op.f('ix_ssh_keys_verified'), 'ssh_keys', ['verified'], unique=False) + 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) + 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'), + sa.UniqueConstraint('id') + ) + op.create_index(op.f('ix_ca_permissions_ca_id'), 'ca_permissions', ['ca_id'], unique=False) + op.create_index(op.f('ix_ca_permissions_user_id'), 'ca_permissions', ['user_id'], unique=False) + op.create_table('department_cert_policies', + sa.Column('department_id', sa.String(length=36), nullable=False), + sa.Column('allow_user_expiry', sa.Boolean(), nullable=False), + sa.Column('default_expiry_hours', sa.Integer(), nullable=False), + sa.Column('max_expiry_hours', sa.Integer(), nullable=False), + sa.Column('allowed_extensions', sa.JSON(), nullable=False), + sa.Column('custom_extensions', sa.JSON(), 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.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id') + ) + op.create_index(op.f('ix_department_cert_policies_department_id'), 'department_cert_policies', ['department_id'], unique=True) + 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_department_id'), 'department_memberships', ['department_id'], unique=False) + op.create_index(op.f('ix_department_memberships_user_id'), 'department_memberships', ['user_id'], unique=False) + 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('department_id', 'principal_id', name='uix_dept_principal'), + sa.UniqueConstraint('id') + ) + 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) + 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=255), 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(['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) + 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_principal_id'), 'principal_memberships', ['principal_id'], unique=False) + op.create_index(op.f('ix_principal_memberships_user_id'), 'principal_memberships', ['user_id'], unique=False) + 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=True), + 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='certtype'), 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='certificatestatus'), 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('ca_id', 'serial', name='uq_ssh_certificates_ca_serial'), + sa.UniqueConstraint('id') + ) + op.create_index('idx_cert_revoked', 'ssh_certificates', ['revoked', 'revoked_at'], 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(op.f('ix_ssh_certificates_ca_id'), 'ssh_certificates', ['ca_id'], unique=False) + op.create_index(op.f('ix_ssh_certificates_revoked'), 'ssh_certificates', ['revoked'], unique=False) + op.create_index('ix_ssh_certificates_serial', 'ssh_certificates', ['serial'], 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_status'), 'ssh_certificates', ['status'], unique=False) + op.create_index(op.f('ix_ssh_certificates_user_id'), 'ssh_certificates', ['user_id'], unique=False) + op.create_table('user_network_approvals', + sa.Column('organization_id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('portal_network_id', sa.String(length=36), nullable=False), + sa.Column('granted_by_user_id', sa.String(length=36), nullable=True), + sa.Column('grant_type', sa.Enum('requested', 'assigned', name='approval_grant_type'), nullable=False), + sa.Column('state', sa.Enum('pending', 'approved', 'rejected', 'revoked', 'suspended', name='approval_state'), nullable=False), + sa.Column('justification', 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(['granted_by_user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ), + sa.ForeignKeyConstraint(['portal_network_id'], ['portal_networks.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id'), + sa.UniqueConstraint('user_id', 'portal_network_id', 'deleted_at', name='uix_user_network_approval') + ) + op.create_index(op.f('ix_user_network_approvals_organization_id'), 'user_network_approvals', ['organization_id'], unique=False) + op.create_index(op.f('ix_user_network_approvals_portal_network_id'), 'user_network_approvals', ['portal_network_id'], unique=False) + op.create_index(op.f('ix_user_network_approvals_state'), 'user_network_approvals', ['state'], unique=False) + op.create_index(op.f('ix_user_network_approvals_user_id'), 'user_network_approvals', ['user_id'], unique=False) + 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('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) + op.create_index(op.f('ix_certificate_audit_logs_action'), 'certificate_audit_logs', ['action'], unique=False) + 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_table('device_network_memberships', + sa.Column('organization_id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('device_id', sa.String(length=36), nullable=False), + sa.Column('portal_network_id', sa.String(length=36), nullable=False), + sa.Column('user_network_approval_id', sa.String(length=36), nullable=True), + sa.Column('state', sa.Enum('pending_device_registration', 'pending_request', 'pending_manager_approval', 'approved_inactive', 'joined_deauthorized', 'active_authorized', 'activation_expired', 'suspended', 'revoked', 'rejected', name='membership_state'), nullable=False), + sa.Column('join_seen', sa.Boolean(), nullable=False), + sa.Column('currently_authorized', sa.Boolean(), nullable=False), + sa.Column('approved_for_activation', 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(['device_id'], ['devices.id'], ), + sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ), + sa.ForeignKeyConstraint(['portal_network_id'], ['portal_networks.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['user_network_approval_id'], ['user_network_approvals.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('device_id', 'portal_network_id', 'deleted_at', name='uix_device_network'), + sa.UniqueConstraint('id') + ) + op.create_index(op.f('ix_device_network_memberships_device_id'), 'device_network_memberships', ['device_id'], unique=False) + op.create_index(op.f('ix_device_network_memberships_organization_id'), 'device_network_memberships', ['organization_id'], unique=False) + op.create_index(op.f('ix_device_network_memberships_portal_network_id'), 'device_network_memberships', ['portal_network_id'], unique=False) + op.create_index(op.f('ix_device_network_memberships_state'), 'device_network_memberships', ['state'], unique=False) + op.create_index(op.f('ix_device_network_memberships_user_id'), 'device_network_memberships', ['user_id'], unique=False) + op.create_index(op.f('ix_device_network_memberships_user_network_approval_id'), 'device_network_memberships', ['user_network_approval_id'], unique=False) + op.create_table('activation_sessions', + sa.Column('organization_id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('device_network_membership_id', sa.String(length=36), nullable=False), + sa.Column('authenticated_at', sa.DateTime(), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('ended_at', sa.DateTime(), nullable=True), + sa.Column('end_reason', sa.Enum('expired', 'logout', 'kill_switch', 'manual_revoke', 'approval_revoked', 'admin_action', name='activation_end_reason'), nullable=True), + sa.Column('created_by', 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(['created_by'], ['users.id'], ), + sa.ForeignKeyConstraint(['device_network_membership_id'], ['device_network_memberships.id'], ), + sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id') + ) + op.create_index(op.f('ix_activation_sessions_device_network_membership_id'), 'activation_sessions', ['device_network_membership_id'], unique=False) + op.create_index(op.f('ix_activation_sessions_organization_id'), 'activation_sessions', ['organization_id'], unique=False) + op.create_index(op.f('ix_activation_sessions_user_id'), 'activation_sessions', ['user_id'], unique=False) + op.create_table('zerotier_memberships', + sa.Column('organization_id', sa.String(length=36), nullable=False), + sa.Column('device_network_membership_id', sa.String(length=36), nullable=True), + sa.Column('zerotier_network_id', sa.String(length=16), nullable=False), + sa.Column('node_id', sa.String(length=10), nullable=False), + sa.Column('member_seen', sa.Boolean(), nullable=False), + sa.Column('authorized', sa.Boolean(), nullable=False), + sa.Column('join_seen_at', sa.DateTime(), nullable=True), + sa.Column('last_synced_at', sa.DateTime(), nullable=True), + sa.Column('raw_controller_payload', 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(['device_network_membership_id'], ['device_network_memberships.id'], ), + sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('id'), + sa.UniqueConstraint('zerotier_network_id', 'node_id', name='uix_zt_network_node') + ) + op.create_index(op.f('ix_zerotier_memberships_device_network_membership_id'), 'zerotier_memberships', ['device_network_membership_id'], unique=False) + op.create_index(op.f('ix_zerotier_memberships_node_id'), 'zerotier_memberships', ['node_id'], unique=False) + op.create_index(op.f('ix_zerotier_memberships_organization_id'), 'zerotier_memberships', ['organization_id'], unique=False) + op.create_index(op.f('ix_zerotier_memberships_zerotier_network_id'), 'zerotier_memberships', ['zerotier_network_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_zerotier_memberships_zerotier_network_id'), table_name='zerotier_memberships') + op.drop_index(op.f('ix_zerotier_memberships_organization_id'), table_name='zerotier_memberships') + op.drop_index(op.f('ix_zerotier_memberships_node_id'), table_name='zerotier_memberships') + op.drop_index(op.f('ix_zerotier_memberships_device_network_membership_id'), table_name='zerotier_memberships') + op.drop_table('zerotier_memberships') + op.drop_index(op.f('ix_activation_sessions_user_id'), table_name='activation_sessions') + op.drop_index(op.f('ix_activation_sessions_organization_id'), table_name='activation_sessions') + op.drop_index(op.f('ix_activation_sessions_device_network_membership_id'), table_name='activation_sessions') + op.drop_table('activation_sessions') + op.drop_index(op.f('ix_device_network_memberships_user_network_approval_id'), table_name='device_network_memberships') + op.drop_index(op.f('ix_device_network_memberships_user_id'), table_name='device_network_memberships') + op.drop_index(op.f('ix_device_network_memberships_state'), table_name='device_network_memberships') + op.drop_index(op.f('ix_device_network_memberships_portal_network_id'), table_name='device_network_memberships') + op.drop_index(op.f('ix_device_network_memberships_organization_id'), table_name='device_network_memberships') + op.drop_index(op.f('ix_device_network_memberships_device_id'), table_name='device_network_memberships') + op.drop_table('device_network_memberships') + 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_index(op.f('ix_certificate_audit_logs_action'), table_name='certificate_audit_logs') + 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_table('certificate_audit_logs') + op.drop_index(op.f('ix_user_network_approvals_user_id'), table_name='user_network_approvals') + op.drop_index(op.f('ix_user_network_approvals_state'), table_name='user_network_approvals') + op.drop_index(op.f('ix_user_network_approvals_portal_network_id'), table_name='user_network_approvals') + op.drop_index(op.f('ix_user_network_approvals_organization_id'), table_name='user_network_approvals') + op.drop_table('user_network_approvals') + op.drop_index(op.f('ix_ssh_certificates_user_id'), 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_ssh_key_id'), table_name='ssh_certificates') + op.drop_index('ix_ssh_certificates_serial', 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_ca_id'), 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('idx_cert_revoked', table_name='ssh_certificates') + op.drop_table('ssh_certificates') + op.drop_index(op.f('ix_principal_memberships_user_id'), table_name='principal_memberships') + op.drop_index(op.f('ix_principal_memberships_principal_id'), table_name='principal_memberships') + op.drop_table('principal_memberships') + 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_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_department_memberships_user_id'), table_name='department_memberships') + op.drop_index(op.f('ix_department_memberships_department_id'), table_name='department_memberships') + op.drop_table('department_memberships') + op.drop_index(op.f('ix_department_cert_policies_department_id'), table_name='department_cert_policies') + op.drop_table('department_cert_policies') + op.drop_index(op.f('ix_ca_permissions_user_id'), table_name='ca_permissions') + op.drop_index(op.f('ix_ca_permissions_ca_id'), table_name='ca_permissions') + op.drop_table('ca_permissions') + 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_ssh_keys_verified'), table_name='ssh_keys') + op.drop_index(op.f('ix_ssh_keys_user_id'), table_name='ssh_keys') + op.drop_index(op.f('ix_ssh_keys_fingerprint'), table_name='ssh_keys') + op.drop_index('idx_ssh_key_user_verified', table_name='ssh_keys') + op.drop_table('ssh_keys') + 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_principals_organization_id'), table_name='principals') + op.drop_index(op.f('ix_principals_name'), table_name='principals') + op.drop_table('principals') + op.drop_index(op.f('ix_portal_networks_zerotier_network_id'), table_name='portal_networks') + op.drop_index(op.f('ix_portal_networks_organization_id'), table_name='portal_networks') + op.drop_table('portal_networks') + op.drop_index(op.f('ix_password_reset_tokens_user_id'), table_name='password_reset_tokens') + op.drop_index(op.f('ix_password_reset_tokens_token'), table_name='password_reset_tokens') + op.drop_table('password_reset_tokens') + 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_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_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_organization_api_keys_organization_id'), table_name='organization_api_keys') + op.drop_index(op.f('ix_organization_api_keys_key_hash'), table_name='organization_api_keys') + op.drop_index(op.f('ix_organization_api_keys_is_revoked'), table_name='organization_api_keys') + op.drop_index('idx_org_api_key_org_active', table_name='organization_api_keys') + op.drop_index('idx_api_key_last_used', table_name='organization_api_keys') + op.drop_table('organization_api_keys') + op.drop_index(op.f('ix_org_invite_tokens_token'), table_name='org_invite_tokens') + op.drop_index(op.f('ix_org_invite_tokens_organization_id'), table_name='org_invite_tokens') + op.drop_index(op.f('ix_org_invite_tokens_email'), table_name='org_invite_tokens') + op.drop_table('org_invite_tokens') + 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_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_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') + op.drop_index(op.f('ix_kill_switch_events_target_user_id'), table_name='kill_switch_events') + op.drop_index(op.f('ix_kill_switch_events_organization_id'), table_name='kill_switch_events') + op.drop_table('kill_switch_events') + 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_email_verification_tokens_user_id'), table_name='email_verification_tokens') + op.drop_index(op.f('ix_email_verification_tokens_token'), table_name='email_verification_tokens') + op.drop_table('email_verification_tokens') + op.drop_index(op.f('ix_devices_user_id'), table_name='devices') + op.drop_index(op.f('ix_devices_organization_id'), table_name='devices') + op.drop_index(op.f('ix_devices_node_id'), table_name='devices') + op.drop_table('devices') + op.drop_index(op.f('ix_departments_organization_id'), table_name='departments') + op.drop_index(op.f('ix_departments_name'), table_name='departments') + op.drop_table('departments') + op.drop_index(op.f('ix_cas_organization_id'), table_name='cas') + op.drop_index(op.f('ix_cas_is_active'), table_name='cas') + op.drop_index('idx_ca_org_active', table_name='cas') + op.drop_table('cas') + 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_index(op.f('ix_users_activation_key'), table_name='users') + op.drop_table('users') + op.drop_index(op.f('ix_organizations_slug'), table_name='organizations') + op.drop_table('organizations') + op.drop_index(op.f('ix_oidc_jwks_keys_kid'), table_name='oidc_jwks_keys') + op.drop_table('oidc_jwks_keys') + 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 ### diff --git a/migrations/versions/add_can_sudo_to_departments.py b/migrations/versions/add_can_sudo_to_departments.py deleted file mode 100644 index ccc72e0..0000000 --- a/migrations/versions/add_can_sudo_to_departments.py +++ /dev/null @@ -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') diff --git a/migrations/versions/add_organization_api_keys_table.py b/migrations/versions/add_organization_api_keys_table.py deleted file mode 100644 index 1c62994..0000000 --- a/migrations/versions/add_organization_api_keys_table.py +++ /dev/null @@ -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') diff --git a/migrations/versions/d2fd4f159054_totp.py b/migrations/versions/d2fd4f159054_totp.py deleted file mode 100644 index 0ab474e..0000000 --- a/migrations/versions/d2fd4f159054_totp.py +++ /dev/null @@ -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 ### diff --git a/migrations/versions/d34bfb72844e_add_activation_fields_and_ca_permissions.py b/migrations/versions/d34bfb72844e_add_activation_fields_and_ca_permissions.py deleted file mode 100644 index 83d9f72..0000000 --- a/migrations/versions/d34bfb72844e_add_activation_fields_and_ca_permissions.py +++ /dev/null @@ -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') diff --git a/migrations/versions/db15faee1fb8_allow_null_ssh_key_id_for_host_certs.py b/migrations/versions/db15faee1fb8_allow_null_ssh_key_id_for_host_certs.py deleted file mode 100644 index eb33895..0000000 --- a/migrations/versions/db15faee1fb8_allow_null_ssh_key_id_for_host_certs.py +++ /dev/null @@ -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, - ) diff --git a/requirements/base.txt b/requirements/base.txt index 1f6591d..a96d6c7 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -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 diff --git a/scripts/README.md b/scripts/README.md index 683c66f..b9d2a27 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 diff --git a/scripts/configure_oauth_provider.py b/scripts/configure_oauth_provider.py index 5372bd7..77d460d 100755 --- a/scripts/configure_oauth_provider.py +++ b/scripts/configure_oauth_provider.py @@ -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: diff --git a/test_email.py b/test_email.py new file mode 100644 index 0000000..6061a5c --- /dev/null +++ b/test_email.py @@ -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 ", + ) + 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 ", + ) + 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 ", + ) + 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 ", + ) + 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 ", + ) + 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 ", + ) + 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 ", + ) + 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()