Fix: Make INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD required env vars with validation
This commit is contained in:
@@ -1,8 +1,11 @@
|
|||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_db_url(url: str) -> str:
|
def _resolve_db_url(url: str) -> str:
|
||||||
"""Convert relative sqlite path to absolute path for Docker or local dev."""
|
"""Convert relative sqlite path to absolute path for Docker or local dev."""
|
||||||
@@ -28,8 +31,8 @@ class Settings(BaseSettings):
|
|||||||
HOST: str = "0.0.0.0"
|
HOST: str = "0.0.0.0"
|
||||||
PORT: int = 8000
|
PORT: int = 8000
|
||||||
LOG_LEVEL: str = "INFO"
|
LOG_LEVEL: str = "INFO"
|
||||||
INITIAL_ADMIN_USERNAME: str = "admin" # Auto-created admin user
|
INITIAL_ADMIN_USERNAME: str # Required: admin user to auto-create
|
||||||
INITIAL_ADMIN_PASSWORD: str = "admin123"
|
INITIAL_ADMIN_PASSWORD: str # Required: password for auto-created admin
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def resolved_database_url(self) -> str:
|
def resolved_database_url(self) -> str:
|
||||||
@@ -43,5 +46,17 @@ class Settings(BaseSettings):
|
|||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
# Validate required secrets at startup
|
# Validate required secrets at startup
|
||||||
|
_missing = []
|
||||||
if not settings.JWT_SECRET_KEY or settings.JWT_SECRET_KEY == "change-me-super-secret-key-min32chars!!":
|
if not settings.JWT_SECRET_KEY or settings.JWT_SECRET_KEY == "change-me-super-secret-key-min32chars!!":
|
||||||
raise ValueError("JWT_SECRET_KEY must be set in environment variables")
|
_missing.append("JWT_SECRET_KEY")
|
||||||
|
if not settings.INITIAL_ADMIN_USERNAME:
|
||||||
|
_missing.append("INITIAL_ADMIN_USERNAME")
|
||||||
|
if not settings.INITIAL_ADMIN_PASSWORD:
|
||||||
|
_missing.append("INITIAL_ADMIN_PASSWORD")
|
||||||
|
|
||||||
|
if _missing:
|
||||||
|
raise ValueError(f"Required environment variables not set: {', '.join(_missing)}")
|
||||||
|
|
||||||
|
# Log initial admin credentials (password masked)
|
||||||
|
logger.info(f"Initial admin username: {settings.INITIAL_ADMIN_USERNAME}")
|
||||||
|
logger.info(f"Initial admin password: {'*' * len(settings.INITIAL_ADMIN_PASSWORD)}")
|
||||||
|
|||||||
@@ -9,33 +9,24 @@ from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
# Async engine for aiosqlite
|
|
||||||
DATABASE_URL = settings.resolved_database_url
|
|
||||||
|
|
||||||
# Sync engine for migrations and initial setup
|
|
||||||
SYNC_DATABASE_URL = DATABASE_URL.replace("sqlite+aiosqlite:///", "sqlite:///")
|
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Create data directory at module load time (before any engine connection)
|
# Async session factory - uses DATABASE_URL from settings
|
||||||
DATA_DIR = Path("/app/data")
|
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Async session factory - URL uses absolute path to /app/data
|
|
||||||
async_engine = create_async_engine(
|
async_engine = create_async_engine(
|
||||||
f"sqlite+aiosqlite:///{DATA_DIR}/claudia_docs.db",
|
settings.resolved_database_url,
|
||||||
echo=False,
|
echo=False,
|
||||||
connect_args={"check_same_thread": False}
|
connect_args={"check_same_thread": False}
|
||||||
)
|
)
|
||||||
AsyncSessionLocal = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
AsyncSessionLocal = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
# Sync engine for migrations
|
# Sync engine for migrations (same database)
|
||||||
|
_sync_db_path = settings.resolved_database_url.replace("sqlite+aiosqlite:///", "sqlite:///")
|
||||||
sync_engine = create_engine(
|
sync_engine = create_engine(
|
||||||
f"sqlite:///{DATA_DIR}/claudia_docs.db",
|
_sync_db_path,
|
||||||
echo=False,
|
echo=False,
|
||||||
connect_args={"check_same_thread": False}
|
connect_args={"check_same_thread": False}
|
||||||
)
|
)
|
||||||
@@ -62,7 +53,9 @@ async def get_db_simple():
|
|||||||
|
|
||||||
async def init_db():
|
async def init_db():
|
||||||
"""Initialize database with all tables, views, FTS5, and triggers."""
|
"""Initialize database with all tables, views, FTS5, and triggers."""
|
||||||
# Data directory already created at module load time
|
# Ensure data directory exists
|
||||||
|
db_path = settings.resolved_database_url.replace("sqlite+aiosqlite:///", "")
|
||||||
|
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
async with async_engine.begin() as conn:
|
async with async_engine.begin() as conn:
|
||||||
# Create all tables via SQL (not ORM) to handle SQLite-specific features
|
# Create all tables via SQL (not ORM) to handle SQLite-specific features
|
||||||
@@ -75,39 +68,56 @@ async def init_db():
|
|||||||
async def _create_initial_admin():
|
async def _create_initial_admin():
|
||||||
"""Create initial admin user from environment variables if it doesn't exist."""
|
"""Create initial admin user from environment variables if it doesn't exist."""
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
import logging
|
||||||
|
|
||||||
if not settings.INITIAL_ADMIN_USERNAME or not settings.INITIAL_ADMIN_PASSWORD:
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
username = settings.INITIAL_ADMIN_USERNAME
|
||||||
|
password = settings.INITIAL_ADMIN_PASSWORD
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
logger.warning(
|
||||||
|
"_create_initial_admin skipped: INITIAL_ADMIN_USERNAME or "
|
||||||
|
"INITIAL_ADMIN_PASSWORD not set"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
logger.info(f"_create_initial_admin: creating admin '{username}'")
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
# Check if username already exists (any role)
|
# Check if username already exists (any role)
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
text("SELECT id, role FROM agents WHERE username = :username"),
|
text("SELECT id, role FROM agents WHERE username = :username"),
|
||||||
{"username": settings.INITIAL_ADMIN_USERNAME}
|
{"username": username}
|
||||||
)
|
)
|
||||||
existing = result.fetchone()
|
existing = result.fetchone()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
return # Username already taken, skip creation
|
logger.info(
|
||||||
|
f"_create_initial_admin: username '{username}' already exists "
|
||||||
|
f"(role={existing.role}), skipping creation"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# Create admin user with bcrypt hash
|
# Create admin user with bcrypt hash
|
||||||
password_hash = bcrypt.hashpw(
|
password_hash = bcrypt.hashpw(
|
||||||
settings.INITIAL_ADMIN_PASSWORD.encode("utf-8"),
|
password.encode("utf-8"),
|
||||||
bcrypt.gensalt()
|
bcrypt.gensalt()
|
||||||
).decode("utf-8")
|
).decode("utf-8")
|
||||||
|
|
||||||
await session.execute(
|
await session.execute(
|
||||||
text("""
|
text("""
|
||||||
INSERT INTO agents (id, username, password_hash, role, created_at, updated_at)
|
INSERT INTO agents (id, username, password_hash, role, is_deleted, created_at, updated_at)
|
||||||
VALUES (:id, :username, :password_hash, 'admin', datetime('now'), datetime('now'))
|
VALUES (:id, :username, :password_hash, 'admin', 0, datetime('now'), datetime('now'))
|
||||||
"""),
|
"""),
|
||||||
{
|
{
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"username": settings.INITIAL_ADMIN_USERNAME,
|
"username": username,
|
||||||
"password_hash": password_hash
|
"password_hash": password_hash
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
logger.info(f"_create_initial_admin: admin '{username}' created successfully")
|
||||||
|
|
||||||
|
|
||||||
def _create_schema(sync_conn):
|
def _create_schema(sync_conn):
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user