Initial scaffold: FastAPI backend + Vue 3 frontend + Docker setup
Backend: config/db/security/logging core, SQLAlchemy models (Server, Credential, UpdateJob, UpdateLog, AuditLog, User), services (winrm, ssh, cau, audit, job_runner), REST API (auth, servers, updates, audit), Socket.io WebSocket layer. Frontend: Vue 3 + TS + Pinia + Tailwind, Views (Dashboard, Servers, Updates, Audit, Login), axios + socket.io-client, nginx prod config.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Core package: config, database, security, logging, exceptions."""
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Application configuration via pydantic-settings.
|
||||
|
||||
All values are read from environment variables / .env file.
|
||||
See .env.example for the full list.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Central application settings."""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
# Core
|
||||
app_env: str = "development"
|
||||
app_name: str = "Insight Updater"
|
||||
secret_key: str = "dev-secret-change-me-32-chars-min"
|
||||
encryption_key: str = ""
|
||||
log_level: str = "INFO"
|
||||
log_format: str = "json"
|
||||
|
||||
# JWT
|
||||
jwt_algorithm: str = "RS256"
|
||||
jwt_private_key_path: str = "keys/private.pem"
|
||||
jwt_public_key_path: str = "keys/public.pem"
|
||||
jwt_access_token_expire_minutes: int = 30
|
||||
jwt_refresh_token_expire_days: int = 7
|
||||
|
||||
# Database / Redis
|
||||
database_url: str = "sqlite+aiosqlite:///./data/app.db"
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# WinRM
|
||||
winrm_transport: str = "ntlm"
|
||||
winrm_cert_validation: str = "ignore"
|
||||
winrm_operation_timeout: int = 60
|
||||
winrm_read_timeout: int = 120
|
||||
winrm_kerberos_delegation: bool = True
|
||||
|
||||
# SSH
|
||||
ssh_timeout: int = 30
|
||||
|
||||
# LDAP (stub)
|
||||
ldap_enabled: bool = False
|
||||
ldap_uri: str = ""
|
||||
ldap_bind_dn: str = ""
|
||||
ldap_bind_password: str = ""
|
||||
ldap_user_search_base: str = ""
|
||||
ldap_user_filter: str = "(sAMAccountName={username})"
|
||||
|
||||
# CORS
|
||||
cors_origins: str = "http://localhost:3000,http://localhost:8000"
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.app_env.lower() == "production"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Database setup: async engine, session factory, declarative base."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.log_level.upper() == "DEBUG",
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
|
||||
async_session_factory = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base for all ORM models."""
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""FastAPI dependency yielding an async DB session."""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Create all tables (scaffold mode; Alembic migrations come later)."""
|
||||
# Import models so they register on the metadata
|
||||
from app import models # noqa: F401
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Custom application exceptions, mapped to HTTP responses in main.py."""
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
"""Base class for application errors."""
|
||||
|
||||
status_code: int = 500
|
||||
detail: str = "Internal server error"
|
||||
|
||||
def __init__(self, detail: str | None = None) -> None:
|
||||
super().__init__(detail or self.detail)
|
||||
if detail:
|
||||
self.detail = detail
|
||||
|
||||
|
||||
class NotFoundError(AppError):
|
||||
status_code = 404
|
||||
detail = "Resource not found"
|
||||
|
||||
|
||||
class ConflictError(AppError):
|
||||
status_code = 409
|
||||
detail = "Resource conflict"
|
||||
|
||||
|
||||
class UnauthorizedError(AppError):
|
||||
status_code = 401
|
||||
detail = "Authentication required"
|
||||
|
||||
|
||||
class ForbiddenError(AppError):
|
||||
status_code = 403
|
||||
detail = "Permission denied"
|
||||
|
||||
|
||||
class InvalidTokenError(UnauthorizedError):
|
||||
detail = "Token is invalid or expired"
|
||||
|
||||
|
||||
class CredentialDecryptionError(AppError):
|
||||
status_code = 500
|
||||
detail = "Stored credential cannot be decrypted"
|
||||
|
||||
|
||||
class ConnectionTestError(AppError):
|
||||
status_code = 502
|
||||
detail = "Connection test failed"
|
||||
|
||||
|
||||
class WinRMError(AppError):
|
||||
status_code = 502
|
||||
detail = "WinRM operation failed"
|
||||
|
||||
|
||||
class SSHError(AppError):
|
||||
status_code = 502
|
||||
detail = "SSH operation failed"
|
||||
|
||||
|
||||
class CAUError(AppError):
|
||||
status_code = 502
|
||||
detail = "Cluster-Aware Updating operation failed"
|
||||
|
||||
|
||||
class JobNotCancellableError(ConflictError):
|
||||
detail = "Job cannot be cancelled in its current state"
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Structured logging setup with structlog."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import structlog
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
level = getattr(logging, settings.log_level.upper(), logging.INFO)
|
||||
|
||||
logging.basicConfig(
|
||||
format="%(message)s",
|
||||
stream=sys.stdout,
|
||||
level=level,
|
||||
)
|
||||
|
||||
processors: list = [
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
]
|
||||
|
||||
if settings.log_format == "json":
|
||||
processors.append(structlog.processors.JSONRenderer())
|
||||
else:
|
||||
processors.append(structlog.dev.ConsoleRenderer())
|
||||
|
||||
structlog.configure(
|
||||
processors=processors,
|
||||
wrapper_class=structlog.make_filtering_bound_logger(level),
|
||||
logger_factory=structlog.PrintLoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||
return structlog.get_logger(name) # type: ignore[no-any-return]
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Security helpers: Fernet credential encryption, JWT issue/verify, password hashing."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import bcrypt
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.exceptions import CredentialDecryptionError, InvalidTokenError
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fernet encryption for stored credentials
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_fernet: Fernet | None = None
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
global _fernet
|
||||
if _fernet is None:
|
||||
key = settings.encryption_key
|
||||
if not key:
|
||||
# Dev fallback: derive a valid fernet key from SECRET_KEY
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
key = base64.urlsafe_b64encode(
|
||||
hashlib.sha256(settings.secret_key.encode()).digest()
|
||||
).decode()
|
||||
_fernet = Fernet(key.encode() if isinstance(key, str) else key)
|
||||
return _fernet
|
||||
|
||||
|
||||
def encrypt(plaintext: str) -> str:
|
||||
"""Encrypt a secret for at-rest storage."""
|
||||
return _get_fernet().encrypt(plaintext.encode()).decode()
|
||||
|
||||
|
||||
def decrypt(token: str) -> str:
|
||||
"""Decrypt a stored secret. Raises CredentialDecryptionError on failure."""
|
||||
try:
|
||||
return _get_fernet().decrypt(token.encode()).decode()
|
||||
except InvalidToken as exc:
|
||||
raise CredentialDecryptionError("Stored credential cannot be decrypted") from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Password hashing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
# bcrypt hard limit: 72 bytes
|
||||
return bcrypt.hashpw(password.encode()[:72], bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(plain.encode()[:72], hashed.encode())
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JWT (RS256 with key files, HS256 fallback for dev without keys)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _read_key(path: str) -> str | None:
|
||||
p = Path(path)
|
||||
return p.read_text() if p.exists() else None
|
||||
|
||||
|
||||
def create_access_token(subject: str, extra_claims: dict[str, Any] | None = None) -> str:
|
||||
expire = datetime.now(UTC) + timedelta(minutes=settings.jwt_access_token_expire_minutes)
|
||||
claims: dict[str, Any] = {"sub": subject, "exp": expire, "type": "access"}
|
||||
if extra_claims:
|
||||
claims.update(extra_claims)
|
||||
|
||||
private_key = _read_key(settings.jwt_private_key_path)
|
||||
if private_key and settings.jwt_algorithm == "RS256":
|
||||
return jwt.encode(claims, private_key, algorithm="RS256")
|
||||
# Dev fallback when no key pair exists yet
|
||||
return jwt.encode(claims, settings.secret_key, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
"""Decode and validate a JWT. Raises InvalidTokenError on failure."""
|
||||
try:
|
||||
public_key = _read_key(settings.jwt_public_key_path)
|
||||
if public_key and settings.jwt_algorithm == "RS256":
|
||||
return jwt.decode(token, public_key, algorithms=["RS256"]) # type: ignore[no-any-return]
|
||||
return jwt.decode(token, settings.secret_key, algorithms=["HS256"]) # type: ignore[no-any-return]
|
||||
except JWTError as exc:
|
||||
raise InvalidTokenError("Token is invalid or expired") from exc
|
||||
Reference in New Issue
Block a user