feat(auth): OIDC-only admin-role bootstrap auto-connect
In OIDC-only mode, before any admin is linked, an IdP user whose token carries the configured admin role (default "admin"; OIDC_ADMIN_ROLE / oidc_settings.admin_role) auto-connects to the unbound hf admin on first OIDC sign-in, then the window self-closes once any admin is bound. Roles are scanned across userinfo + the (unverified) access token: realm_access.roles, resource_access.*.roles, roles/role/groups. Adds admin_role to settings model/env/effective/API and to the wizard bootstrap config. Replaces the manual admin-subject approach. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user