feat(init): bootstrap OIDC from wizard config

init_wizard applies config['oidc'] on first init: creates the
oidc_settings row and, when admin_subject is given, binds the
bootstrap admin so OIDC-only deployments are reachable. Idempotent —
an existing row / admin binding is preserved (later admin edits via
the API survive restarts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-17 20:50:59 +01:00
parent ece2b550fc
commit f64e2a24f8

View File

@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from app.models import models from app.models import models
from app.models.role_permission import Role, Permission, RolePermission from app.models.role_permission import Role, Permission, RolePermission
from app.models.oidc_settings import OidcSettings
from app.api.deps import get_password_hash from app.api.deps import get_password_hash
logger = logging.getLogger("harborforge.init") logger = logging.getLogger("harborforge.init")
@@ -328,6 +329,49 @@ def init_deleted_user(db: Session) -> models.User | None:
return user return user
def init_oidc_settings(db: Session, oidc_cfg: dict, admin_user: models.User | None) -> None:
"""Bootstrap OIDC from the wizard config (first init only).
Creates the single oidc_settings row if absent so the deployment comes
up with OIDC configured. If admin_subject is given, binds the bootstrap
admin so it can sign in (critical in OIDC-only mode). Idempotent: an
existing row / existing admin binding is left untouched so later admin
edits via the API are not clobbered on restart."""
if not oidc_cfg:
return
existing = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
if existing is None:
db.add(OidcSettings(
id=1,
enabled=bool(oidc_cfg.get("enabled", True)),
issuer=(oidc_cfg.get("issuer") or "").strip() or None,
client_id=(oidc_cfg.get("client_id") or "").strip() or None,
client_secret=oidc_cfg.get("client_secret") or None,
redirect_uri=(oidc_cfg.get("redirect_uri") or "").strip() or None,
scopes=(oidc_cfg.get("scopes") or "").strip() or None,
post_login_redirect=(oidc_cfg.get("post_login_redirect") or "").strip() or None,
))
db.commit()
logger.info("OIDC settings bootstrapped from wizard config")
admin_subject = (oidc_cfg.get("admin_subject") or "").strip()
issuer = (oidc_cfg.get("issuer") or "").strip()
if admin_user and admin_subject and issuer and not admin_user.oidc_subject:
clash = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == admin_subject,
models.User.id != admin_user.id,
).first()
if clash:
logger.warning("Admin OIDC subject already bound to '%s'; skipping admin bind", clash.username)
else:
admin_user.oidc_issuer = issuer
admin_user.oidc_subject = admin_subject
db.commit()
logger.info("Bootstrap admin '%s' bound to OIDC subject", admin_user.username)
def run_init(db: Session) -> None: def run_init(db: Session) -> None:
"""Main initialization entry point. Reads config from shared volume.""" """Main initialization entry point. Reads config from shared volume."""
config = load_config() config = load_config()
@@ -360,4 +404,7 @@ def run_init(db: Session) -> None:
if project_cfg and admin_user: if project_cfg and admin_user:
init_default_project(db, project_cfg, admin_user.id, admin_user.username) init_default_project(db, project_cfg, admin_user.id, admin_user.username)
# OIDC bootstrap (provider config + optional bootstrap-admin binding)
init_oidc_settings(db, config.get("oidc") or {}, admin_user)
logger.info("Initialization complete") logger.info("Initialization complete")