diff --git a/app/init_wizard.py b/app/init_wizard.py index d49594d..c8e5d38 100644 --- a/app/init_wizard.py +++ b/app/init_wizard.py @@ -11,6 +11,7 @@ from sqlalchemy.orm import Session from app.models import models from app.models.role_permission import Role, Permission, RolePermission +from app.models.oidc_settings import OidcSettings from app.api.deps import get_password_hash logger = logging.getLogger("harborforge.init") @@ -328,6 +329,49 @@ def init_deleted_user(db: Session) -> models.User | None: 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: """Main initialization entry point. Reads config from shared volume.""" config = load_config() @@ -360,4 +404,7 @@ def run_init(db: Session) -> None: if project_cfg and admin_user: 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")