feat(backend)!: kill AbstractWizard, env-driven config + hf-cli
Drops the AbstractWizard config-volume bootstrap entirely. All deploy-time
config now comes from docker env vars (.env). First-deploy admin user + OIDC
provider config are operator-driven via `docker exec hf_backend hf-cli ...`.
Backend changes:
- entrypoint.sh: drop config-wait loop, just exec uvicorn
- app/core/config.py: drop _resolve_db_url + OIDC_* env vars (DB only now);
keep HARBORFORGE_OIDC_ONLY (deploy-time policy)
- app/init_wizard.py → app/init_bootstrap.py: drop load_config / admin / OIDC /
default-project bootstrap; keep idempotent startup seed (permissions,
default roles, acc-mgr + deleted-user builtins)
- app/main.py: /config/status now returns {initialized: <admin exists>};
startup() imports init_bootstrap.run_bootstrap
- app/api/routers/oidc.py: get_effective_oidc reads DB only (no env fallback)
- app/services/harborforge_config.py: removed (replaced by direct env reads)
- app/services/discord_wakeup.py: HF_DISCORD_GUILD_ID / HF_DISCORD_BOT_TOKEN env
- app/api/routers/users.py + tests/conftest.py: rename init_wizard refs
New hf-cli surface (app/cli/, invoked via /usr/local/bin/hf-cli shim):
hf-cli admin create-user --email <e> [--username <u>] [--password <p>]
[--oidc-issuer <url> --oidc-subject <sub>]
hf-cli admin list
hf-cli admin set-role --username <u> --role <admin|mgr|dev|guest|account-manager>
hf-cli admin reset-password --username <u> --password <p>
hf-cli admin bind-oidc --username <u> --oidc-issuer <url> --oidc-subject <sub>
hf-cli config oidc [--issuer/...] [--client-id/...] [--client-secret/...]
[--redirect-uri/...] [--enabled true|false] [--show-secret]
Bootstrap migration on existing deployments: existing admin / OIDC settings
in the DB are preserved across the cutover; only the wizard config-volume
+ wizard sidecar services need to be removed from compose. Restart picks
up the new entrypoint + skips the config wait.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
"""OIDC (OpenID Connect) login + admin-configurable provider settings.
|
||||
|
||||
The OIDC provider can be configured at runtime from the admin UI
|
||||
(persisted in the oidc_settings table). A stored row's non-empty fields
|
||||
override the OIDC_* env vars; env values act as bootstrap defaults.
|
||||
Provider config (issuer / client_id / client_secret / redirect_uri /
|
||||
scopes / post_login_redirect / admin_role / enabled) lives entirely in
|
||||
the `oidc_settings` DB table (single row, id=1) and is set via either
|
||||
the admin UI or `docker exec hf-backend hf-cli config oidc ...`.
|
||||
HARBORFORGE_OIDC_ONLY is the only OIDC-related env var (deploy-time
|
||||
policy: when true, password login is disabled).
|
||||
|
||||
Sign-in policy: an OIDC identity must already be bound to an hf user
|
||||
(see PUT /users/{id}/oidc-binding). Unbound identities are rejected.
|
||||
@@ -51,27 +54,20 @@ class EffectiveOidc:
|
||||
|
||||
|
||||
def get_effective_oidc(db: Session) -> EffectiveOidc:
|
||||
"""DB row is the only source of truth — no env fallback. If the row is
|
||||
absent OIDC is treated as unconfigured (login attempts will 503)."""
|
||||
row = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
|
||||
|
||||
def pick(db_val, env_val):
|
||||
return db_val if (db_val is not None and db_val != "") else env_val
|
||||
|
||||
if row is None:
|
||||
return EffectiveOidc(
|
||||
settings.OIDC_ENABLED, settings.OIDC_ISSUER, settings.OIDC_CLIENT_ID,
|
||||
settings.OIDC_CLIENT_SECRET, settings.OIDC_REDIRECT_URI,
|
||||
settings.OIDC_SCOPES, settings.OIDC_POST_LOGIN_REDIRECT,
|
||||
settings.OIDC_ADMIN_ROLE,
|
||||
)
|
||||
return EffectiveOidc(False, "", "", "", "", "", "", "admin")
|
||||
return EffectiveOidc(
|
||||
bool(row.enabled),
|
||||
pick(row.issuer, settings.OIDC_ISSUER),
|
||||
pick(row.client_id, settings.OIDC_CLIENT_ID),
|
||||
pick(row.client_secret, settings.OIDC_CLIENT_SECRET),
|
||||
pick(row.redirect_uri, settings.OIDC_REDIRECT_URI),
|
||||
pick(row.scopes, settings.OIDC_SCOPES),
|
||||
pick(row.post_login_redirect, settings.OIDC_POST_LOGIN_REDIRECT),
|
||||
pick(getattr(row, "admin_role", None), settings.OIDC_ADMIN_ROLE),
|
||||
row.issuer or "",
|
||||
row.client_id or "",
|
||||
row.client_secret or "",
|
||||
row.redirect_uri or "",
|
||||
row.scopes or "",
|
||||
row.post_login_redirect or "",
|
||||
getattr(row, "admin_role", None) or "admin",
|
||||
)
|
||||
|
||||
|
||||
@@ -305,17 +301,17 @@ def get_oidc_settings(db: Session = Depends(get_db), _: models.User = Depends(_r
|
||||
row = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
|
||||
cfg = get_effective_oidc(db)
|
||||
return OidcSettingsOut(
|
||||
enabled=bool(row.enabled) if row else bool(settings.OIDC_ENABLED),
|
||||
issuer=(row.issuer if row else None) or settings.OIDC_ISSUER or None,
|
||||
client_id=(row.client_id if row else None) or settings.OIDC_CLIENT_ID or None,
|
||||
has_client_secret=bool((row.client_secret if row else None) or settings.OIDC_CLIENT_SECRET),
|
||||
redirect_uri=(row.redirect_uri if row else None) or settings.OIDC_REDIRECT_URI or None,
|
||||
scopes=(row.scopes if row else None) or settings.OIDC_SCOPES or None,
|
||||
post_login_redirect=(row.post_login_redirect if row else None) or settings.OIDC_POST_LOGIN_REDIRECT or None,
|
||||
enabled=bool(row.enabled) if row else False,
|
||||
issuer=(row.issuer if row else None) or None,
|
||||
client_id=(row.client_id if row else None) or None,
|
||||
has_client_secret=bool(row.client_secret if row else None),
|
||||
redirect_uri=(row.redirect_uri if row else None) or None,
|
||||
scopes=(row.scopes if row else None) or None,
|
||||
post_login_redirect=(row.post_login_redirect if row else None) or None,
|
||||
admin_role=cfg.admin_role,
|
||||
oidc_only=bool(settings.HARBORFORGE_OIDC_ONLY),
|
||||
effective_enabled=cfg.configured,
|
||||
source="db" if row else "env",
|
||||
source="db",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash
|
||||
from app.core.config import get_db, settings
|
||||
from app.init_wizard import DELETED_USER_USERNAME
|
||||
from app.init_bootstrap import DELETED_USER_USERNAME
|
||||
from app.models import models
|
||||
from app.models.agent import Agent
|
||||
from app.models.role_permission import Permission, Role, RolePermission
|
||||
@@ -391,7 +391,7 @@ def delete_user(
|
||||
if not deleted_user:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Built-in deleted-user account not found. Run init_wizard first.",
|
||||
detail="Built-in deleted-user account not found. Backend startup failed to seed it; restart the container.",
|
||||
)
|
||||
|
||||
_reassign_user_references(db, user.id, deleted_user.id)
|
||||
|
||||
Reference in New Issue
Block a user