feat(auth): optional SEED_ADMIN_PASSWORD to pre-hash seed admin (dev helper)

Terraform gains enable_seed_password + seed_admin_password (sensitive) vars
on the customer-instance module; when enabled the password is piped via
startup-script into /opt/agnes/.env as SEED_ADMIN_PASSWORD. On first boot
app/main.py argon2-hashes it onto the seed user so the admin can log in
immediately without going through /auth/bootstrap. Never overwrites an
existing password_hash — safe against accidental reset on terraform apply.
This commit is contained in:
ZdenekSrotyr 2026-04-21 21:21:18 +02:00
parent e4f6910398
commit 96bd06ba00
5 changed files with 54 additions and 18 deletions

View file

@ -90,7 +90,10 @@ def create_app() -> FastAPI:
SCHEMA_VERSION, SCHEMA_VERSION,
) )
# Seed admin user for testing/CI (when SEED_ADMIN_EMAIL is set) # Seed admin user for testing/CI (when SEED_ADMIN_EMAIL is set).
# Optional: SEED_ADMIN_PASSWORD sets password_hash on first seed so the user
# can log in immediately without bootstrap. Only applied if the user has no
# password_hash yet — never overwrites an existing password.
seed_email = os.environ.get("SEED_ADMIN_EMAIL") seed_email = os.environ.get("SEED_ADMIN_EMAIL")
if seed_email: if seed_email:
try: try:
@ -98,10 +101,25 @@ def create_app() -> FastAPI:
from src.repositories.users import UserRepository from src.repositories.users import UserRepository
conn = get_system_db() conn = get_system_db()
repo = UserRepository(conn) repo = UserRepository(conn)
if not repo.get_by_email(seed_email): seed_password = os.environ.get("SEED_ADMIN_PASSWORD") or None
password_hash = None
if seed_password:
from argon2 import PasswordHasher
password_hash = PasswordHasher().hash(seed_password)
existing = repo.get_by_email(seed_email)
if not existing:
import uuid import uuid
repo.create(id=str(uuid.uuid4()), email=seed_email, name="Admin", role="admin") repo.create(
logger.info("Seeded admin user: %s", seed_email) id=str(uuid.uuid4()),
email=seed_email,
name="Admin",
role="admin",
password_hash=password_hash,
)
logger.info("Seeded admin user: %s (password=%s)", seed_email, "yes" if password_hash else "no")
elif password_hash and not existing.get("password_hash"):
repo.update(id=existing["id"], password_hash=password_hash, role="admin")
logger.info("Set password on existing seed admin: %s", seed_email)
conn.close() conn.close()
except Exception as e: except Exception as e:
logger.warning(f"Could not seed admin: {e}") logger.warning(f"Could not seed admin: {e}")

View file

@ -21,6 +21,8 @@ SESSION_SECRET= # python -c "import secrets; print(secrets.token_he
# ── BOOTSTRAP (first deploy only) ─────────────────── # ── BOOTSTRAP (first deploy only) ───────────────────
# SEED_ADMIN_EMAIL=admin@example.com # SEED_ADMIN_EMAIL=admin@example.com
# SEED_ADMIN_PASSWORD= # Dev helper only — sets password_hash on seed.
# # Never overwrites an existing password.
# ── EMAIL / SMTP (required for magic link auth) ───── # ── EMAIL / SMTP (required for magic link auth) ─────
# SMTP_HOST=smtp.gmail.com # SMTP_HOST=smtp.gmail.com

View file

@ -209,18 +209,19 @@ resource "google_compute_instance" "vm" {
} }
metadata_startup_script = templatefile("${path.module}/startup-script.sh.tpl", { metadata_startup_script = templatefile("${path.module}/startup-script.sh.tpl", {
customer_name = var.customer_name customer_name = var.customer_name
image_repo = var.image_repo image_repo = var.image_repo
image_tag = each.value.image_tag image_tag = each.value.image_tag
upgrade_mode = each.value.upgrade_mode upgrade_mode = each.value.upgrade_mode
tls_mode = each.value.tls_mode tls_mode = each.value.tls_mode
domain = each.value.domain domain = each.value.domain
acme_email = var.acme_email != "" ? var.acme_email : var.seed_admin_email acme_email = var.acme_email != "" ? var.acme_email : var.seed_admin_email
data_source = var.data_source data_source = var.data_source
keboola_stack_url = var.keboola_stack_url keboola_stack_url = var.keboola_stack_url
seed_admin_email = var.seed_admin_email seed_admin_email = var.seed_admin_email
role = each.value.role seed_admin_password = var.enable_seed_password ? var.seed_admin_password : ""
compose_ref = var.compose_ref role = each.value.role
compose_ref = var.compose_ref
}) })
service_account { service_account {
@ -289,8 +290,8 @@ resource "google_monitoring_alert_policy" "health_failure" {
conditions { conditions {
display_name = "Uptime check failed > 5 min" display_name = "Uptime check failed > 5 min"
condition_threshold { condition_threshold {
filter = "metric.type=\"monitoring.googleapis.com/uptime_check/check_passed\" AND metric.labels.check_id=\"${google_monitoring_uptime_check_config.health[each.key].uptime_check_id}\" AND resource.type=\"uptime_url\"" filter = "metric.type=\"monitoring.googleapis.com/uptime_check/check_passed\" AND metric.labels.check_id=\"${google_monitoring_uptime_check_config.health[each.key].uptime_check_id}\" AND resource.type=\"uptime_url\""
duration = "300s" duration = "300s"
# ALIGN_FRACTION_TRUE yields fraction of checks that returned true. # ALIGN_FRACTION_TRUE yields fraction of checks that returned true.
# If the fraction stays < 1 (i.e. any probe failed) for 5 min alert. # If the fraction stays < 1 (i.e. any probe failed) for 5 min alert.
comparison = "COMPARISON_LT" comparison = "COMPARISON_LT"

View file

@ -15,6 +15,7 @@ ACME_EMAIL="${acme_email}"
DATA_SOURCE="${data_source}" DATA_SOURCE="${data_source}"
KEBOOLA_STACK_URL="${keboola_stack_url}" KEBOOLA_STACK_URL="${keboola_stack_url}"
SEED_ADMIN_EMAIL="${seed_admin_email}" SEED_ADMIN_EMAIL="${seed_admin_email}"
SEED_ADMIN_PASSWORD="${seed_admin_password}"
ROLE="${role}" ROLE="${role}"
COMPOSE_REF="${compose_ref}" COMPOSE_REF="${compose_ref}"
@ -81,6 +82,7 @@ DATA_SOURCE=$DATA_SOURCE
KEBOOLA_STORAGE_TOKEN=$KEBOOLA_TOKEN KEBOOLA_STORAGE_TOKEN=$KEBOOLA_TOKEN
KEBOOLA_STACK_URL=$KEBOOLA_STACK_URL KEBOOLA_STACK_URL=$KEBOOLA_STACK_URL
SEED_ADMIN_EMAIL=$SEED_ADMIN_EMAIL SEED_ADMIN_EMAIL=$SEED_ADMIN_EMAIL
SEED_ADMIN_PASSWORD=$SEED_ADMIN_PASSWORD
LOG_LEVEL=info LOG_LEVEL=info
DOMAIN=$DOMAIN DOMAIN=$DOMAIN
AGNES_TAG=$IMAGE_TAG AGNES_TAG=$IMAGE_TAG

View file

@ -53,6 +53,19 @@ variable "seed_admin_email" {
type = string type = string
} }
variable "enable_seed_password" {
description = "Pokud true, seed admin user dostane hned password_hash ze seed_admin_password (dev helper). Ponech false v prod — admin si heslo nastaví přes /auth/bootstrap nebo Google OAuth."
type = bool
default = false
}
variable "seed_admin_password" {
description = "Plain-text heslo pro seed admina. Použije se jen když enable_seed_password=true. POZOR: ukládá se do Terraform state."
type = string
default = ""
sensitive = true
}
variable "data_source" { variable "data_source" {
description = "Typ data source — keboola | bigquery | csv" description = "Typ data source — keboola | bigquery | csv"
type = string type = string