diff --git a/app/api/routers/oidc.py b/app/api/routers/oidc.py index adf50e7..52a7706 100644 --- a/app/api/routers/oidc.py +++ b/app/api/routers/oidc.py @@ -7,6 +7,7 @@ override the OIDC_* env vars; env values act as bootstrap defaults. Sign-in policy: an OIDC identity must already be bound to an hf user (see PUT /users/{id}/oidc-binding). Unbound identities are rejected. """ +import logging from datetime import timedelta from urllib.parse import urlencode @@ -21,13 +22,14 @@ from app.models.oidc_settings import OidcSettings from app.api.deps import create_access_token, get_current_user, get_current_user_or_apikey router = APIRouter(prefix="/auth", tags=["Auth"]) +logger = logging.getLogger("harborforge.oidc") # ---- effective config (DB row overrides env) ------------------------------ class EffectiveOidc: def __init__(self, enabled, issuer, client_id, client_secret, - redirect_uri, scopes, post_login_redirect): + redirect_uri, scopes, post_login_redirect, admin_role): self.enabled = enabled self.issuer = issuer self.client_id = client_id @@ -35,6 +37,7 @@ class EffectiveOidc: self.redirect_uri = redirect_uri self.scopes = scopes or "openid email profile" self.post_login_redirect = post_login_redirect + self.admin_role = (admin_role or "admin").strip() or "admin" @property def configured(self) -> bool: @@ -58,6 +61,7 @@ def get_effective_oidc(db: Session) -> 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( bool(row.enabled), @@ -67,6 +71,7 @@ def get_effective_oidc(db: Session) -> EffectiveOidc: 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), ) @@ -100,6 +105,39 @@ def _invalidate_client(): _oauth_fp = None +def _collect_roles(claims: dict, token: dict) -> set[str]: + """Roles from common OIDC claim shapes, across the ID-token/userinfo + claims and the (unverified) access token — Keycloak puts realm/client + roles in the access token by default.""" + pools = [claims if isinstance(claims, dict) else {}] + at = token.get("access_token") + if at: + try: + from jose import jwt as _jwt + pools.append(_jwt.get_unverified_claims(at)) + except Exception: + pass + roles: set[str] = set() + for p in pools: + if not isinstance(p, dict): + continue + ra = p.get("realm_access") + if isinstance(ra, dict): + roles.update(ra.get("roles") or []) + res = p.get("resource_access") + if isinstance(res, dict): + for v in res.values(): + if isinstance(v, dict): + roles.update(v.get("roles") or []) + for key in ("roles", "role", "groups"): + val = p.get(key) + if isinstance(val, str): + roles.add(val) + elif isinstance(val, (list, tuple)): + roles.update(str(x) for x in val) + return {str(r).strip().lstrip("/").lower() for r in roles if r} + + def _frontend(cfg: EffectiveOidc, qs: dict | None = None, fragment: str | None = None) -> str: base = cfg.post_login_redirect or "/" url = base @@ -190,6 +228,33 @@ async def oidc_callback(request: Request, db: Session = Depends(get_db)): models.User.oidc_issuer == issuer, models.User.oidc_subject == subject, ).first() + + # OIDC-only bootstrap: before any admin is linked, an IdP user whose + # token carries the configured admin role auto-connects to the unbound + # hf admin. Self-closes once any admin is bound. + if user is None and settings.HARBORFORGE_OIDC_ONLY: + any_admin_bound = db.query(models.User).filter( + models.User.is_admin == True, # noqa: E712 + models.User.oidc_subject.isnot(None), + ).first() + if not any_admin_bound and cfg.admin_role.lower() in _collect_roles(claims, token): + taken = db.query(models.User).filter( + models.User.oidc_issuer == issuer, + models.User.oidc_subject == subject, + ).first() + if taken is None: + boot = db.query(models.User).filter( + models.User.is_admin == True, # noqa: E712 + models.User.is_active == True, # noqa: E712 + models.User.oidc_subject.is_(None), + ).order_by(models.User.id).first() + if boot is not None: + boot.oidc_issuer = issuer + boot.oidc_subject = subject + db.commit() + logger.info("OIDC bootstrap: auto-connected admin '%s' via admin role", boot.username) + user = boot + if not user or not user.is_active or user.username in ("acc-mgr", "deleted-user"): return RedirectResponse(_frontend(cfg, {"oidc_error": "not_linked"})) @@ -218,6 +283,7 @@ class OidcSettingsIn(BaseModel): redirect_uri: str | None = None scopes: str | None = None post_login_redirect: str | None = None + admin_role: str | None = None class OidcSettingsOut(BaseModel): @@ -228,6 +294,7 @@ class OidcSettingsOut(BaseModel): redirect_uri: str | None scopes: str | None post_login_redirect: str | None + admin_role: str oidc_only: bool # read-only (deploy env) effective_enabled: bool # provider actually usable source: str # "db" or "env" @@ -245,6 +312,7 @@ def get_oidc_settings(db: Session = Depends(get_db), _: models.User = Depends(_r 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, + admin_role=cfg.admin_role, oidc_only=bool(settings.HARBORFORGE_OIDC_ONLY), effective_enabled=cfg.configured, source="db" if row else "env", @@ -274,6 +342,8 @@ def update_oidc_settings(payload: OidcSettingsIn, db: Session = Depends(get_db), row.scopes = payload.scopes.strip() or None if payload.post_login_redirect is not None: row.post_login_redirect = payload.post_login_redirect.strip() or None + if payload.admin_role is not None: + row.admin_role = payload.admin_role.strip() or None db.commit() _invalidate_client() diff --git a/app/core/config.py b/app/core/config.py index 27d1c1d..18e0271 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -46,6 +46,7 @@ class Settings(BaseSettings): OIDC_REDIRECT_URI: str = "" # backend callback, e.g. https://hf-api.example.com/auth/oidc/callback OIDC_SCOPES: str = "openid email profile" OIDC_POST_LOGIN_REDIRECT: str = "" # frontend URL to return to (token in fragment). Falls back to "/" + OIDC_ADMIN_ROLE: str = "admin" # OIDC role name that bootstraps the unbound hf admin (OIDC-only) # When true: no password login at all. Password login endpoint rejects, # user creation ignores any password (passwordless user that can only use diff --git a/app/init_wizard.py b/app/init_wizard.py index c8e5d38..4ac7ad7 100644 --- a/app/init_wizard.py +++ b/app/init_wizard.py @@ -351,6 +351,7 @@ def init_oidc_settings(db: Session, oidc_cfg: dict, admin_user: models.User | No 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, + admin_role=(oidc_cfg.get("admin_role") or "").strip() or None, )) db.commit() logger.info("OIDC settings bootstrapped from wizard config") diff --git a/app/main.py b/app/main.py index d44e375..45d9a7a 100644 --- a/app/main.py +++ b/app/main.py @@ -301,6 +301,10 @@ def _migrate_schema(): if _has_table(db, "users") and _has_column(db, "users", "oidc_subject"): _ensure_unique_index(db, "users", "uq_users_oidc_identity", "oidc_issuer, oidc_subject") + # --- oidc_settings.admin_role (added after the table shipped) --- + if _has_table(db, "oidc_settings") and not _has_column(db, "oidc_settings", "admin_role"): + db.execute(text("ALTER TABLE oidc_settings ADD COLUMN admin_role VARCHAR(128) NULL")) + # --- monitored_servers.api_key for heartbeat v2 --- if _has_table(db, "monitored_servers") and not _has_column(db, "monitored_servers", "api_key"): db.execute(text("ALTER TABLE monitored_servers ADD COLUMN api_key VARCHAR(64) NULL")) diff --git a/app/models/oidc_settings.py b/app/models/oidc_settings.py index 4c8694e..6edc60d 100644 --- a/app/models/oidc_settings.py +++ b/app/models/oidc_settings.py @@ -19,4 +19,7 @@ class OidcSettings(Base): redirect_uri = Column(String(512), nullable=True) scopes = Column(String(255), nullable=True) post_login_redirect = Column(String(512), nullable=True) + # OIDC role name that, in OIDC-only mode, auto-connects an unbound + # hf admin on first login (bootstrap). Default "admin". + admin_role = Column(String(128), nullable=True) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())