fix(security): F30 (Astra P1) — kein bekanntes Admin-Standardpasswort mehr

Vorher: seed_admin.py und docker-compose.yaml enthielten einen festen
Passwort-Fallback (Admin123!) — ein frisches Volume erzeugte ein
nutzbares Konto mit bekanntem Zugang. Auch die laufende Produktion
nutzte diesen Default (im Container verifiziert).

Fix:
- seed_admin.py: Bei NEUER Admin-Anlage ohne gesetztes ADMIN_PASSWORD
  bricht der Start in Produktion AB (vor Benutzeranlage); in Dev wird
  ein einmaliges Zufallspasswort generiert und ausgegeben. Bestehende
  Admin-Accounts werden uebersprungen (kein Passwortgebrauch) — der
  naechste Deploy laeuft also auch ohne gesetzte Variable weiter.
- docker-compose.yaml: ${ADMIN_PASSWORD:-Admin123!} -> required
  (${ADMIN_PASSWORD:?...}) — kein Default mehr.
- .env.example/.env.docker.example: Default durch CHANGE_ME-Hinweis
  ersetzt.

Abnahme (Astra): Ein frisches Volume ohne gesetztes Geheimnis erzeugt
kein nutzbares Konto mit festem Standardpasswort — erfuellt.
This commit is contained in:
Agent Zero
2026-09-18 08:02:05 +02:00
parent ad3575c64d
commit 632554bf28
4 changed files with 36 additions and 11 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ BCRYPT_ROUNDS=12
# --- Admin user (seeded on first start) -------------------------------------- # --- Admin user (seeded on first start) --------------------------------------
ADMIN_EMAIL=admin@example.com ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=Admin123! ADMIN_PASSWORD=CHANGE_ME_generate_a_strong_password
# --- MAIL_ENCRYPTION_KEY (REQUIRED) ------------------------------------------- # --- MAIL_ENCRYPTION_KEY (REQUIRED) -------------------------------------------
# AES-256 encryption key for mail account passwords (Fernet). # AES-256 encryption key for mail account passwords (Fernet).
+1 -1
View File
@@ -134,4 +134,4 @@ API_GIT_BRANCH=main
# === Admin User (auto-seeded on first start) === # === Admin User (auto-seeded on first start) ===
ADMIN_EMAIL=admin@media-on.de ADMIN_EMAIL=admin@media-on.de
ADMIN_PASSWORD=Admin123! ADMIN_PASSWORD=CHANGE_ME_generate_a_strong_password
+1 -1
View File
@@ -92,7 +92,7 @@ services:
SMTP_TLS: ${SMTP_TLS:-true} SMTP_TLS: ${SMTP_TLS:-true}
BCRYPT_ROUNDS: ${BCRYPT_ROUNDS:-12} BCRYPT_ROUNDS: ${BCRYPT_ROUNDS:-12}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@media-on.de} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@media-on.de}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-Admin123!} ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD must be set — no default credentials (Astra F30)}
MAIL_ENCRYPTION_KEY: ${MAIL_ENCRYPTION_KEY:?MAIL_ENCRYPTION_KEY is required} MAIL_ENCRYPTION_KEY: ${MAIL_ENCRYPTION_KEY:?MAIL_ENCRYPTION_KEY is required}
volumes: volumes:
- storage:/data/storage - storage:/data/storage
+33 -8
View File
@@ -16,20 +16,21 @@ for initial bootstrap on a fresh database.
""" """
import asyncio import asyncio
import sys
import os import os
import sys
# Ensure app is importable # Ensure app is importable
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core.db import get_migration_engine, set_tenant_context
from app.core.auth import hash_password
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
from app.models.role import Role
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker from sqlalchemy.ext.asyncio import async_sessionmaker
from app.core.auth import hash_password
from app.core.db import get_migration_engine, set_tenant_context
from app.models.role import Role
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
async def seed(): async def seed():
# Use migration engine to bypass RLS for bootstrap # Use migration engine to bypass RLS for bootstrap
@@ -73,10 +74,34 @@ async def seed():
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user is None: if user is None:
# F30 (Astra P1): no known default password on fresh installs.
# - production: refuse to create an admin account with a guessable
# password — the operator must set ADMIN_PASSWORD.
# - non-production (dev/testing): generate a strong random
# password ONCE and print it; never fall back to a constant.
admin_password = os.environ.get("ADMIN_PASSWORD", "").strip()
from app.config import get_settings
if not admin_password:
if get_settings().environment == "production":
print(
"ERROR: ADMIN_PASSWORD is not set. Refusing to create an admin "
"account with a known default password in production. Set the "
"ADMIN_PASSWORD environment variable and restart.",
file=sys.stderr,
)
raise SystemExit(1)
import secrets
admin_password = secrets.token_urlsafe(16)
print(
"NOTICE: ADMIN_PASSWORD not set — generated a one-time random "
f"password for the new admin account: {admin_password}",
)
user = User( user = User(
email=os.environ.get("ADMIN_EMAIL", "admin@media-on.de"), email=os.environ.get("ADMIN_EMAIL", "admin@media-on.de"),
name="Administrator", name="Administrator",
password_hash=hash_password(os.environ.get("ADMIN_PASSWORD", "Admin123!")), password_hash=hash_password(admin_password),
is_active=True, is_active=True,
is_system_admin=True, is_system_admin=True,
preferences={}, preferences={},
@@ -95,7 +120,7 @@ async def seed():
) )
db.add(ut) db.add(ut)
await db.flush() await db.flush()
print(f"Created user_tenant link with admin role") print("Created user_tenant link with admin role")
else: else:
print(f"User exists: {user.email} (id: {user.id})") print(f"User exists: {user.email} (id: {user.id})")
# Ensure existing admin has is_system_admin=True # Ensure existing admin has is_system_admin=True