diff --git a/.env.example b/.env.example index 8ea3db5..e09f6b8 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,34 @@ -# HarborForge Environment Variables +# HarborForge Backend Environment Variables (v0.4.0+ — wizard removed) -# Database +# --- Database (used by both the mysql container and the backend) ----------- MYSQL_ROOT_PASSWORD=harborforge_root MYSQL_DATABASE=harborforge MYSQL_USER=harborforge MYSQL_PASSWORD=harborforge_pass +# Full DSN used by the backend container. Default points to a service +# named "mysql" on the same docker network. Override if your DB is elsewhere. +DATABASE_URL=mysql+pymysql://harborforge:harborforge_pass@mysql:3306/harborforge -# Application +# --- Application ---------------------------------------------------------- +# Must be 32+ chars and not a placeholder; use: openssl rand -hex 32 SECRET_KEY=change-me-use-openssl-rand-hex-32 LOG_LEVEL=INFO + +# When true: password login is disabled, all sign-in goes through OIDC, +# user creation ignores any password (passwordless users that can only +# authenticate via OIDC binding or API keys). Frontend hides password UI. +HARBORFORGE_OIDC_ONLY=false + +# --- Discord wakeup (optional; previously in wizard config) --------------- +# Used by /agents/{id}/wakeup to spin a private Discord channel + DM. +HARBORFORGE_DISCORD_GUILD_ID= +HARBORFORGE_DISCORD_BOT_TOKEN= + +# --- OIDC issuer / client_id / client_secret / redirect_uri --------------- +# NOT env vars in v0.4.0+. Configure via: +# docker exec hf-backend hf-cli config oidc \ +# --issuer https://login.example.com/realms/foo \ +# --client-id harborforge --client-secret \ +# --redirect-uri https://hf-api.example.com/auth/oidc/callback \ +# --post-login-redirect https://hf.example.com/oidc/callback \ +# --enabled true diff --git a/Dockerfile b/Dockerfile index ddb411f..d4d4fee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,6 +42,12 @@ COPY requirements.txt ./ COPY entrypoint.sh . RUN chmod +x entrypoint.sh +# Install hf-cli as a /usr/local/bin shim that re-enters the app package +# (so `docker exec hf-backend hf-cli admin create-user ...` works). The +# CLI reads the same DATABASE_URL / SECRET_KEY env as the backend. +RUN printf '#!/bin/sh\nexec python -m app.cli "$@"\n' > /usr/local/bin/hf-cli && \ + chmod +x /usr/local/bin/hf-cli + # OIDC-only mode: when "true", password login is rejected, user creation # ignores passwords (passwordless users that sign in via a bound OIDC # identity / API keys). Overridable at runtime via the same env var. diff --git a/app/api/routers/oidc.py b/app/api/routers/oidc.py index 52a7706..9d97ae2 100644 --- a/app/api/routers/oidc.py +++ b/app/api/routers/oidc.py @@ -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", ) diff --git a/app/api/routers/users.py b/app/api/routers/users.py index 0dfd908..d62b203 100644 --- a/app/api/routers/users.py +++ b/app/api/routers/users.py @@ -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) diff --git a/app/cli/__init__.py b/app/cli/__init__.py new file mode 100644 index 0000000..6f98570 --- /dev/null +++ b/app/cli/__init__.py @@ -0,0 +1,10 @@ +"""hf-cli — operator commands run inside the backend container. + +Subjects: + admin — bootstrap / manage the initial admin user + config — runtime config (OIDC, etc.) + +Invoked via the shim at /usr/local/bin/hf-cli (Dockerfile-installed): + docker exec hf-backend hf-cli admin create-user --email me@example.com --password '...' + docker exec hf-backend hf-cli config oidc --issuer ... --client-id ... --enabled true +""" diff --git a/app/cli/__main__.py b/app/cli/__main__.py new file mode 100644 index 0000000..d4dd5dd --- /dev/null +++ b/app/cli/__main__.py @@ -0,0 +1,48 @@ +"""hf-cli entry point. Dispatches to subject-specific modules.""" +import sys + + +USAGE = """Usage: + hf-cli admin create-user --email [--username ] [--full-name ] + [--password

] [--oidc-issuer --oidc-subject ] + hf-cli admin list + hf-cli admin set-role --username --role + hf-cli admin reset-password --username --password

+ hf-cli admin bind-oidc --username --oidc-issuer --oidc-subject + + hf-cli config oidc [--issuer ] [--client-id ] [--client-secret ] + [--redirect-uri ] [--post-login-redirect ] + [--scopes "openid email profile"] [--admin-role ] + [--enabled true|false] [--show-secret] + +Reads DATABASE_URL + SECRET_KEY from the same env as the backend. Run +inside the backend container: `docker exec hf-backend hf-cli ...`. +""" + + +def main() -> int: + args = sys.argv[1:] + if len(args) < 1: + sys.stderr.write(USAGE) + return 1 + + subject = args[0] + rest = args[1:] + + if subject == "admin": + from app.cli import admin + return admin.dispatch(rest) + if subject == "config": + from app.cli import config + return config.dispatch(rest) + if subject in ("-h", "--help", "help"): + sys.stdout.write(USAGE) + return 0 + + sys.stderr.write(f"unknown subject: {subject}\n\n") + sys.stderr.write(USAGE) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/app/cli/admin.py b/app/cli/admin.py new file mode 100644 index 0000000..e4f5357 --- /dev/null +++ b/app/cli/admin.py @@ -0,0 +1,269 @@ +"""hf-cli admin … — bootstrap and manage the deployment's admin user.""" +import argparse +import json +import sys + +from sqlalchemy.exc import IntegrityError + +from app.api.deps import get_password_hash +from app.core.config import SessionLocal, settings +from app.models import models +from app.models.role_permission import Role + + +def _open_db(): + return SessionLocal() + + +def _emit(payload: dict) -> None: + sys.stdout.write(json.dumps(payload, indent=2) + "\n") + + +# --------------------------------------------------------------------------- +# create-user +# --------------------------------------------------------------------------- +def _cmd_create_user(argv: list[str]) -> int: + p = argparse.ArgumentParser(prog="hf-cli admin create-user") + p.add_argument("--email", required=True) + p.add_argument("--username", default=None, + help="Defaults to email's local-part if omitted.") + p.add_argument("--full-name", default="Admin") + p.add_argument("--password", default=None, + help="Required when HARBORFORGE_OIDC_ONLY=false. Ignored " + "when OIDC_ONLY=true (use --oidc-issuer/--oidc-subject).") + p.add_argument("--oidc-issuer", default=None, + help="Bind the new admin to this OIDC issuer at creation. " + "Required in OIDC_ONLY mode for the bootstrap admin.") + p.add_argument("--oidc-subject", default=None, + help="OIDC subject claim (sub) to bind the new admin to.") + args = p.parse_args(argv) + + username = args.username or args.email.split("@", 1)[0] + oidc_only = bool(settings.HARBORFORGE_OIDC_ONLY) + + if oidc_only: + if not (args.oidc_issuer and args.oidc_subject): + sys.stderr.write( + "HARBORFORGE_OIDC_ONLY=true: must pass --oidc-issuer and " + "--oidc-subject so the new admin can sign in.\n" + ) + return 2 + hashed_password = None + else: + if not args.password: + sys.stderr.write("--password is required when OIDC_ONLY is false.\n") + return 2 + hashed_password = get_password_hash(args.password) + + if (args.oidc_issuer and not args.oidc_subject) or (args.oidc_subject and not args.oidc_issuer): + sys.stderr.write("--oidc-issuer and --oidc-subject must be passed together.\n") + return 2 + + db = _open_db() + try: + existing = db.query(models.User).filter(models.User.username == username).first() + if existing: + sys.stderr.write(f"user '{username}' already exists (id={existing.id})\n") + return 3 + + admin_role = db.query(Role).filter(Role.name == "admin").first() + if not admin_role: + sys.stderr.write( + "admin role not found — backend startup seed should create it. " + "Restart the container then retry.\n" + ) + return 4 + + user = models.User( + username=username, + email=args.email, + full_name=args.full_name, + hashed_password=hashed_password, + is_admin=True, + is_active=True, + role_id=admin_role.id, + oidc_issuer=(args.oidc_issuer or None), + oidc_subject=(args.oidc_subject or None), + ) + db.add(user) + try: + db.commit() + except IntegrityError as e: + db.rollback() + sys.stderr.write(f"DB integrity error: {e.orig}\n") + return 5 + db.refresh(user) + + _emit({ + "ok": True, + "created": True, + "user": { + "id": user.id, + "username": user.username, + "email": user.email, + "full_name": user.full_name, + "is_admin": user.is_admin, + "role_id": user.role_id, + "oidc_issuer": user.oidc_issuer, + "oidc_subject": user.oidc_subject, + "has_password": user.hashed_password is not None, + }, + }) + return 0 + finally: + db.close() + + +# --------------------------------------------------------------------------- +# list +# --------------------------------------------------------------------------- +def _cmd_list(_argv: list[str]) -> int: + db = _open_db() + try: + admins = ( + db.query(models.User) + .filter(models.User.is_admin == True) # noqa: E712 + .order_by(models.User.id.asc()) + .all() + ) + _emit({ + "ok": True, + "count": len(admins), + "admins": [ + { + "id": u.id, + "username": u.username, + "email": u.email, + "is_active": u.is_active, + "oidc_bound": bool(u.oidc_issuer and u.oidc_subject), + "has_password": u.hashed_password is not None, + } + for u in admins + ], + }) + return 0 + finally: + db.close() + + +# --------------------------------------------------------------------------- +# set-role +# --------------------------------------------------------------------------- +def _cmd_set_role(argv: list[str]) -> int: + p = argparse.ArgumentParser(prog="hf-cli admin set-role") + p.add_argument("--username", required=True) + p.add_argument("--role", required=True) + args = p.parse_args(argv) + + db = _open_db() + try: + user = db.query(models.User).filter(models.User.username == args.username).first() + if not user: + sys.stderr.write(f"user '{args.username}' not found\n") + return 3 + role = db.query(Role).filter(Role.name == args.role).first() + if not role: + sys.stderr.write(f"role '{args.role}' not found\n") + return 4 + user.role_id = role.id + user.is_admin = (args.role == "admin") + db.commit() + _emit({ + "ok": True, + "user": {"id": user.id, "username": user.username, "role": role.name, "is_admin": user.is_admin}, + }) + return 0 + finally: + db.close() + + +# --------------------------------------------------------------------------- +# reset-password +# --------------------------------------------------------------------------- +def _cmd_reset_password(argv: list[str]) -> int: + p = argparse.ArgumentParser(prog="hf-cli admin reset-password") + p.add_argument("--username", required=True) + p.add_argument("--password", required=True) + args = p.parse_args(argv) + + if settings.HARBORFORGE_OIDC_ONLY: + sys.stderr.write("HARBORFORGE_OIDC_ONLY=true: password login is disabled.\n") + return 2 + + db = _open_db() + try: + user = db.query(models.User).filter(models.User.username == args.username).first() + if not user: + sys.stderr.write(f"user '{args.username}' not found\n") + return 3 + user.hashed_password = get_password_hash(args.password) + db.commit() + _emit({"ok": True, "user": {"id": user.id, "username": user.username, "password_reset": True}}) + return 0 + finally: + db.close() + + +# --------------------------------------------------------------------------- +# bind-oidc — attach an OIDC identity to an existing admin +# --------------------------------------------------------------------------- +def _cmd_bind_oidc(argv: list[str]) -> int: + p = argparse.ArgumentParser(prog="hf-cli admin bind-oidc") + p.add_argument("--username", required=True) + p.add_argument("--oidc-issuer", required=True) + p.add_argument("--oidc-subject", required=True) + args = p.parse_args(argv) + + db = _open_db() + try: + user = db.query(models.User).filter(models.User.username == args.username).first() + if not user: + sys.stderr.write(f"user '{args.username}' not found\n") + return 3 + clash = db.query(models.User).filter( + models.User.oidc_issuer == args.oidc_issuer, + models.User.oidc_subject == args.oidc_subject, + models.User.id != user.id, + ).first() + if clash: + sys.stderr.write(f"OIDC subject already bound to '{clash.username}' (id={clash.id})\n") + return 4 + user.oidc_issuer = args.oidc_issuer + user.oidc_subject = args.oidc_subject + db.commit() + _emit({ + "ok": True, + "user": { + "id": user.id, + "username": user.username, + "oidc_issuer": user.oidc_issuer, + "oidc_subject": user.oidc_subject, + }, + }) + return 0 + finally: + db.close() + + +# --------------------------------------------------------------------------- +# dispatcher +# --------------------------------------------------------------------------- +ACTIONS = { + "create-user": _cmd_create_user, + "list": _cmd_list, + "set-role": _cmd_set_role, + "reset-password": _cmd_reset_password, + "bind-oidc": _cmd_bind_oidc, +} + + +def dispatch(argv: list[str]) -> int: + if not argv: + sys.stderr.write("admin: missing action; one of: " + ", ".join(ACTIONS) + "\n") + return 1 + action, rest = argv[0], argv[1:] + fn = ACTIONS.get(action) + if not fn: + sys.stderr.write(f"admin: unknown action '{action}'; valid: {', '.join(ACTIONS)}\n") + return 1 + return fn(rest) diff --git a/app/cli/config.py b/app/cli/config.py new file mode 100644 index 0000000..db444d5 --- /dev/null +++ b/app/cli/config.py @@ -0,0 +1,108 @@ +"""hf-cli config … — runtime configuration stored in DB. + +Currently only the OIDC provider config has a CLI surface (it used to +live in the AbstractWizard config). Mirrors dialectic-cli's +`config oidc` shape: only the flags you pass are mutated, the rest stays +unchanged. Prints the post-update row with client_secret masked unless +--show-secret is given. +""" +import argparse +import json +import sys + +from app.core.config import SessionLocal +from app.models.oidc_settings import OidcSettings + + +def _emit(payload: dict) -> None: + sys.stdout.write(json.dumps(payload, indent=2) + "\n") + + +def _bool(v: str) -> bool: + return v.lower() in ("1", "true", "yes", "on") + + +def _cmd_oidc(argv: list[str]) -> int: + p = argparse.ArgumentParser(prog="hf-cli config oidc") + p.add_argument("--issuer", default=None) + p.add_argument("--client-id", default=None) + p.add_argument("--client-secret", default=None) + p.add_argument("--redirect-uri", default=None) + p.add_argument("--post-login-redirect", default=None) + p.add_argument("--scopes", default=None, + help='Default: "openid email profile"') + p.add_argument("--admin-role", default=None, + help="OIDC role name that bootstraps an unbound hf admin " + "on first OIDC-only login. Default: admin.") + p.add_argument("--enabled", default=None, + help="true|false. Without this flag the row's existing " + "value is preserved.") + p.add_argument("--show-secret", action="store_true", + help="Reveal client_secret in the output (local audit " + "only — never paste into chat).") + args = p.parse_args(argv) + + db = SessionLocal() + try: + row = db.query(OidcSettings).filter(OidcSettings.id == 1).first() + if row is None: + row = OidcSettings(id=1, enabled=False) + db.add(row) + + if args.issuer is not None: + row.issuer = args.issuer.strip() or None + if args.client_id is not None: + row.client_id = args.client_id.strip() or None + if args.client_secret is not None: + row.client_secret = args.client_secret or None + if args.redirect_uri is not None: + row.redirect_uri = args.redirect_uri.strip() or None + if args.post_login_redirect is not None: + row.post_login_redirect = args.post_login_redirect.strip() or None + if args.scopes is not None: + row.scopes = args.scopes.strip() or None + if args.admin_role is not None: + row.admin_role = args.admin_role.strip() or None + if args.enabled is not None: + row.enabled = _bool(args.enabled) + + db.commit() + db.refresh(row) + + out: dict = { + "enabled": bool(row.enabled), + "issuer": row.issuer, + "client_id": row.client_id, + "redirect_uri": row.redirect_uri, + "post_login_redirect": row.post_login_redirect, + "scopes": row.scopes, + "admin_role": row.admin_role, + } + if args.show_secret: + out["client_secret"] = row.client_secret + elif row.client_secret: + out["client_secret"] = "***set***" + else: + out["client_secret"] = None + + _emit({"ok": True, "config": out}) + return 0 + finally: + db.close() + + +ACTIONS = { + "oidc": _cmd_oidc, +} + + +def dispatch(argv: list[str]) -> int: + if not argv: + sys.stderr.write("config: missing action; one of: " + ", ".join(ACTIONS) + "\n") + return 1 + action, rest = argv[0], argv[1:] + fn = ACTIONS.get(action) + if not fn: + sys.stderr.write(f"config: unknown action '{action}'; valid: {', '.join(ACTIONS)}\n") + return 1 + return fn(rest) diff --git a/app/core/config.py b/app/core/config.py index 18e0271..5b57fbc 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,34 +1,13 @@ -import os -import json +"""Backend runtime settings — env-only (no wizard / no config volume). + +OIDC issuer/client_id/etc. live in the `oidc_settings` DB table set +via `hf-cli config oidc ...`. The OIDC_ONLY flag remains env-driven +because it's a deploy-time policy, not a per-tenant runtime config. +""" from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from pydantic_settings import BaseSettings -from typing import Optional - - -def _resolve_db_url(env_url: str) -> str: - """Read DB config from wizard config volume if available, else use env.""" - config_dir = os.getenv("CONFIG_DIR", "/config") - config_file = os.getenv("CONFIG_FILE", "harborforge.json") - config_path = os.path.join(config_dir, config_file) - - if os.path.exists(config_path): - try: - with open(config_path, "r") as f: - cfg = json.load(f) - db_cfg = cfg.get("database") - if db_cfg: - host = db_cfg.get("host", "mysql") - port = db_cfg.get("port", 3306) - user = db_cfg.get("user", "harborforge") - password = db_cfg.get("password", "harborforge_pass") - database = db_cfg.get("database", "harborforge") - return f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}" - except Exception: - pass - - return env_url class Settings(BaseSettings): @@ -38,19 +17,9 @@ class Settings(BaseSettings): ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 - # --- OIDC (generic, OpenID Connect discovery) --- - OIDC_ENABLED: bool = False - OIDC_ISSUER: str = "" # e.g. https://idp.example.com (we use {issuer}/.well-known/openid-configuration) - OIDC_CLIENT_ID: str = "" - OIDC_CLIENT_SECRET: str = "" - 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 - # API keys / OIDC), and the frontend hides all password UI. + # user creation ignores any password (passwordless users that only sign + # in via a bound OIDC identity / API keys), frontend hides password UI. HARBORFORGE_OIDC_ONLY: bool = False class Config: @@ -75,9 +44,7 @@ if settings.SECRET_KEY in _WEAK_SECRETS or len(settings.SECRET_KEY) < 32: "Refusing to start with a default/short key." ) -# Resolve DB URL: wizard config volume > env > default -_db_url = _resolve_db_url(settings.DATABASE_URL) -engine = create_engine(_db_url, pool_pre_ping=True) +engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() diff --git a/app/init_bootstrap.py b/app/init_bootstrap.py new file mode 100644 index 0000000..ac9a371 --- /dev/null +++ b/app/init_bootstrap.py @@ -0,0 +1,256 @@ +""" +HarborForge unconditional startup seeds — runs every time backend boots. + +Seeds default permissions, default roles, the `acc-mgr` built-in (account +provisioning agent), and the `deleted-user` foreign-key sink. Idempotent; +existing rows are left alone. + +Wizard/.json config bootstrap has been removed entirely as of v0.4.0. +First-deploy admin user, OIDC settings, and discord webhook config all +moved to operator-driven flows: + + docker exec hf-backend hf-cli admin create-user --email ... --password ... + docker exec hf-backend hf-cli config oidc --issuer ... --client-id ... + +Builtin accounts created here: + - acc-mgr (account-manager role) — cannot log in, used by the + account-creation API as a system principal + - deleted-user — FK sink so user delete doesn't cascade + +The bootstrap admin user is NOT created here — that's CLI-driven so +operators pick the email/password themselves. +""" +import logging +from sqlalchemy.orm import Session + +from app.models import models +from app.models.role_permission import Role, Permission, RolePermission + +logger = logging.getLogger("harborforge.bootstrap") + + +# --------------------------------------------------------------------------- +# Permissions catalog (canonical; new perms get added on every release) +# --------------------------------------------------------------------------- +DEFAULT_PERMISSIONS = [ + # Project permissions + ("project.read", "View project", "project"), + ("project.write", "Edit project", "project"), + ("project.delete", "Delete project", "project"), + ("project.manage_members", "Manage project members", "project"), + # Task/Milestone permissions + ("task.create", "Create tasks", "task"), + ("task.read", "View tasks", "task"), + ("task.write", "Edit tasks", "task"), + ("task.delete", "Delete tasks", "task"), + ("milestone.create", "Create milestones", "milestone"), + ("milestone.read", "View milestones", "milestone"), + ("milestone.write", "Edit milestones", "milestone"), + ("milestone.delete", "Delete milestones", "milestone"), + # Milestone actions + ("milestone.freeze", "Freeze milestone scope", "milestone"), + ("milestone.start", "Start milestone execution", "milestone"), + ("milestone.close", "Close / abort milestone", "milestone"), + # Task actions + ("task.close", "Close / cancel a task", "task"), + ("task.reopen_closed", "Reopen a closed task", "task"), + ("task.reopen_completed", "Reopen a completed task", "task"), + # Proposal actions (permission names kept as propose.* for DB compat) + ("propose.accept", "Accept a proposal into a milestone", "propose"), + ("propose.reject", "Reject a proposal", "propose"), + ("propose.reopen", "Reopen a rejected proposal", "propose"), + # Role/Permission management + ("role.manage", "Manage roles and permissions", "admin"), + ("account.create", "Create HarborForge accounts", "account"), + # User management + ("user.manage", "Manage users", "admin"), + # API key management + ("user.reset-self-apikey", "Reset own API key", "user"), + ("user.reset-apikey", "Reset any user's API key", "admin"), + # Monitor + ("monitor.read", "View monitor", "monitor"), + ("monitor.manage", "Manage monitor", "monitor"), + # Calendar + ("calendar.read", "View calendar slots and plans", "calendar"), + ("calendar.write", "Create and edit calendar slots and plans", "calendar"), + ("calendar.manage", "Manage calendar settings and workload policies", "calendar"), + # Webhook + ("webhook.manage", "Manage webhooks", "admin"), +] + + +def init_default_permissions(db: Session) -> list[Permission]: + """Insert any missing perms from DEFAULT_PERMISSIONS. Returns all rows.""" + created = [] + for name, description, category in DEFAULT_PERMISSIONS: + existing = db.query(Permission).filter(Permission.name == name).first() + if not existing: + perm = Permission(name=name, description=description, category=category) + db.add(perm) + created.append(perm) + logger.info("Created permission '%s'", name) + if created: + db.commit() + return db.query(Permission).all() + + +# --------------------------------------------------------------------------- +# Default roles + permission set per role +# --------------------------------------------------------------------------- +_MGR_PERMISSIONS = { + "project.read", "project.write", "project.manage_members", + "task.create", "task.read", "task.write", "task.delete", + "milestone.create", "milestone.read", "milestone.write", "milestone.delete", + "milestone.freeze", "milestone.start", "milestone.close", + "task.close", "task.reopen_closed", "task.reopen_completed", + "propose.accept", "propose.reject", "propose.reopen", + "monitor.read", + "calendar.read", "calendar.write", "calendar.manage", + "user.reset-self-apikey", +} + +_DEV_PERMISSIONS = { + "project.read", + "task.create", "task.read", "task.write", + "milestone.read", + "task.close", "task.reopen_closed", "task.reopen_completed", + "monitor.read", + "calendar.read", "calendar.write", + "user.reset-self-apikey", +} + +_ACCOUNT_MANAGER_PERMISSIONS = { + "account.create", + "user.reset-apikey", +} + +_DEFAULT_ROLES = [ + ("admin", "Administrator - full access to all features", None), # None ⇒ all perms + ("account-manager", "Account manager - can only create accounts", _ACCOUNT_MANAGER_PERMISSIONS), + ("mgr", "Manager - project & milestone management", _MGR_PERMISSIONS), + ("dev", "Developer - task execution & daily work", _DEV_PERMISSIONS), + ("guest", "Guest - read-only access", None), # special: *.read only +] + + +def _ensure_role(db: Session, name: str, description: str, is_global: bool = True) -> Role: + role = db.query(Role).filter(Role.name == name).first() + if not role: + role = Role(name=name, description=description, is_global=is_global) + db.add(role) + db.commit() + db.refresh(role) + logger.info("Created role '%s' (id=%d)", name, role.id) + return role + + +def _sync_role_permissions(db: Session, role: Role, target_perm_names: set[str] | None) -> None: + """Additive: grants missing perms, never revokes manually-granted ones. + ``target_perm_names is None`` means **all** perms (admin).""" + all_perms = db.query(Permission).all() + perm_by_name = {p.name: p for p in all_perms} + + if target_perm_names is None: + wanted_ids = {p.id for p in all_perms} + else: + wanted_ids = {perm_by_name[n].id for n in target_perm_names if n in perm_by_name} + + existing_ids = {rp.permission_id for rp in role.permissions} + added = 0 + for pid in wanted_ids - existing_ids: + db.add(RolePermission(role_id=role.id, permission_id=pid)) + added += 1 + if added: + db.commit() + logger.info("Assigned %d new permissions to role '%s'", added, role.name) + + +def init_default_roles(db: Session) -> None: + """Create default roles (admin/account-manager/mgr/dev/guest) + permissions.""" + all_perms = db.query(Permission).all() + read_perm_names = {p.name for p in all_perms if p.name.endswith(".read")} + + for name, description, perm_set in _DEFAULT_ROLES: + role = _ensure_role(db, name, description) + if name == "guest": + _sync_role_permissions(db, role, read_perm_names) + else: + _sync_role_permissions(db, role, perm_set) + logger.info("Default roles ready (admin / account-manager / mgr / dev / guest)") + + +# --------------------------------------------------------------------------- +# Built-in user accounts (system principals, cannot log in) +# --------------------------------------------------------------------------- +DELETED_USER_USERNAME = "deleted-user" + + +def init_acc_mgr_user(db: Session) -> models.User | None: + """The account-manager system principal. Holds the `account-manager` + role so the account-creation API can attribute new users to it. No + password, no OIDC binding — cannot log in.""" + username = "acc-mgr" + existing = db.query(models.User).filter(models.User.username == username).first() + if existing: + return existing + + acc_mgr_role = db.query(Role).filter(Role.name == "account-manager").first() + if not acc_mgr_role: + logger.warning("account-manager role not found, skipping acc-mgr user creation") + return None + + user = models.User( + username=username, + email="acc-mgr@harborforge.internal", + full_name="Account Manager", + hashed_password=None, + is_admin=False, + is_active=True, + role_id=acc_mgr_role.id, + ) + db.add(user) + db.commit() + db.refresh(user) + logger.info("Created acc-mgr user (id=%d) with account-manager role", user.id) + return user + + +def init_deleted_user(db: Session) -> models.User | None: + """FK sink for deleted users — when a real user is deleted, all FK + references reassign here instead of cascading.""" + existing = db.query(models.User).filter( + models.User.username == DELETED_USER_USERNAME + ).first() + if existing: + return existing + + user = models.User( + username=DELETED_USER_USERNAME, + email="deleted-user@harborforge.internal", + full_name="Deleted User", + hashed_password=None, + is_admin=False, + is_active=False, + role_id=None, + ) + db.add(user) + db.commit() + db.refresh(user) + logger.info("Created deleted-user (id=%d)", user.id) + return user + + +# --------------------------------------------------------------------------- +# Top-level bootstrap entry point — called from main.py startup +# --------------------------------------------------------------------------- +def run_bootstrap(db: Session) -> None: + """Idempotent startup seed. Safe to call on every boot. + + Does NOT create the admin user — that's CLI-driven (see hf-cli admin + create-user) so operators pick credentials. + """ + init_default_permissions(db) + init_default_roles(db) + init_acc_mgr_user(db) + init_deleted_user(db) + logger.info("Bootstrap seeds complete") diff --git a/app/init_wizard.py b/app/init_wizard.py deleted file mode 100644 index 4ac7ad7..0000000 --- a/app/init_wizard.py +++ /dev/null @@ -1,411 +0,0 @@ -""" -HarborForge initialization from AbstractWizard config volume. - -Reads config from shared volume (written by AbstractWizard). -On startup, creates admin user and default project if not exists. -""" -import os -import json -import logging -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") - -CONFIG_DIR = os.getenv("CONFIG_DIR", "/config") -CONFIG_FILE = os.getenv("CONFIG_FILE", "harborforge.json") - - -def load_config() -> dict | None: - """Load initialization config from shared volume.""" - config_path = os.path.join(CONFIG_DIR, CONFIG_FILE) - if not os.path.exists(config_path): - logger.info("No config file at %s, skipping initialization", config_path) - return None - try: - with open(config_path, "r") as f: - return json.load(f) - except Exception as e: - logger.warning("Failed to read config %s: %s", config_path, e) - return None - - -def get_db_url(config: dict) -> str | None: - """Build DATABASE_URL from wizard config, or fall back to env.""" - db_cfg = config.get("database") - if not db_cfg: - return os.getenv("DATABASE_URL") - - host = db_cfg.get("host", "mysql") - port = db_cfg.get("port", 3306) - user = db_cfg.get("user", "harborforge") - password = db_cfg.get("password", "harborforge_pass") - database = db_cfg.get("database", "harborforge") - return f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}" - - -def init_admin_user(db: Session, admin_cfg: dict) -> models.User | None: - """Create admin user if not exists.""" - username = admin_cfg.get("username", "admin") - existing = db.query(models.User).filter(models.User.username == username).first() - if existing: - logger.info("Admin user '%s' already exists (id=%d), skipping", username, existing.id) - return existing - - password = admin_cfg.get("password", "changeme") - user = models.User( - username=username, - email=admin_cfg.get("email", f"{username}@harborforge.local"), - full_name=admin_cfg.get("full_name", "Admin"), - hashed_password=get_password_hash(password), - is_admin=True, - is_active=True, - ) - db.add(user) - db.commit() - db.refresh(user) - logger.info("Created admin user '%s' (id=%d)", username, user.id) - return user - - -def init_default_project(db: Session, project_cfg: dict, owner_id: int, owner_name: str = "") -> None: - """Create default project if configured and not exists.""" - name = project_cfg.get("name") - if not name: - return - existing = db.query(models.Project).filter(models.Project.name == name).first() - if existing: - logger.info("Project '%s' already exists (id=%d), skipping", name, existing.id) - return - - project = models.Project( - name=name, - description=project_cfg.get("description", ""), - owner_name=project_cfg.get("owner") or owner_name or "", - owner_id=owner_id, - ) - db.add(project) - db.commit() - db.refresh(project) - logger.info("Created default project '%s' (id=%d)", name, project.id) - - -# Default permissions that will be created if not exist -DEFAULT_PERMISSIONS = [ - # Project permissions - ("project.read", "View project", "project"), - ("project.write", "Edit project", "project"), - ("project.delete", "Delete project", "project"), - ("project.manage_members", "Manage project members", "project"), - # Task/Milestone permissions - ("task.create", "Create tasks", "task"), - ("task.read", "View tasks", "task"), - ("task.write", "Edit tasks", "task"), - ("task.delete", "Delete tasks", "task"), - ("milestone.create", "Create milestones", "milestone"), - ("milestone.read", "View milestones", "milestone"), - ("milestone.write", "Edit milestones", "milestone"), - ("milestone.delete", "Delete milestones", "milestone"), - # Milestone actions - ("milestone.freeze", "Freeze milestone scope", "milestone"), - ("milestone.start", "Start milestone execution", "milestone"), - ("milestone.close", "Close / abort milestone", "milestone"), - # Task actions - ("task.close", "Close / cancel a task", "task"), - ("task.reopen_closed", "Reopen a closed task", "task"), - ("task.reopen_completed", "Reopen a completed task", "task"), - # Proposal actions (permission names kept as propose.* for DB compat) - ("propose.accept", "Accept a proposal into a milestone", "propose"), - ("propose.reject", "Reject a proposal", "propose"), - ("propose.reopen", "Reopen a rejected proposal", "propose"), - # Role/Permission management - ("role.manage", "Manage roles and permissions", "admin"), - ("account.create", "Create HarborForge accounts", "account"), - # User management - ("user.manage", "Manage users", "admin"), - # API key management - ("user.reset-self-apikey", "Reset own API key", "user"), - ("user.reset-apikey", "Reset any user's API key", "admin"), - # Monitor - ("monitor.read", "View monitor", "monitor"), - ("monitor.manage", "Manage monitor", "monitor"), - # Calendar - ("calendar.read", "View calendar slots and plans", "calendar"), - ("calendar.write", "Create and edit calendar slots and plans", "calendar"), - ("calendar.manage", "Manage calendar settings and workload policies", "calendar"), - # Webhook - ("webhook.manage", "Manage webhooks", "admin"), -] - - -def init_default_permissions(db: Session) -> list[Permission]: - """Create default permissions if they don't exist. Returns all permissions.""" - created = [] - for name, description, category in DEFAULT_PERMISSIONS: - existing = db.query(Permission).filter(Permission.name == name).first() - if not existing: - perm = Permission(name=name, description=description, category=category) - db.add(perm) - created.append(perm) - logger.info("Created permission '%s'", name) - - if created: - db.commit() - - # Return all permissions - return db.query(Permission).all() - - -# --------------------------------------------------------------------------- -# Default role → permission mapping -# --------------------------------------------------------------------------- - -# mgr: project management + all milestone/task/proposal actions -_MGR_PERMISSIONS = { - "project.read", "project.write", "project.manage_members", - "task.create", "task.read", "task.write", "task.delete", - "milestone.create", "milestone.read", "milestone.write", "milestone.delete", - "milestone.freeze", "milestone.start", "milestone.close", - "task.close", "task.reopen_closed", "task.reopen_completed", - "propose.accept", "propose.reject", "propose.reopen", - "monitor.read", - "calendar.read", "calendar.write", "calendar.manage", - "user.reset-self-apikey", -} - -# dev: day-to-day development work — no freeze/start/close milestone, no accept/reject proposal -_DEV_PERMISSIONS = { - "project.read", - "task.create", "task.read", "task.write", - "milestone.read", - "task.close", "task.reopen_closed", "task.reopen_completed", - "monitor.read", - "calendar.read", "calendar.write", - "user.reset-self-apikey", -} - -_ACCOUNT_MANAGER_PERMISSIONS = { - "account.create", - "user.reset-apikey", -} - -# Role definitions: (name, description, permission_set) -_DEFAULT_ROLES = [ - ("admin", "Administrator - full access to all features", None), # None ⇒ all perms - ("account-manager", "Account manager - can only create accounts", _ACCOUNT_MANAGER_PERMISSIONS), - ("mgr", "Manager - project & milestone management", _MGR_PERMISSIONS), - ("dev", "Developer - task execution & daily work", _DEV_PERMISSIONS), - ("guest", "Guest - read-only access", None), # special: *.read only -] - - -def _ensure_role(db: Session, name: str, description: str, is_global: bool = True) -> Role: - """Get or create a role by name.""" - role = db.query(Role).filter(Role.name == name).first() - if not role: - role = Role(name=name, description=description, is_global=is_global) - db.add(role) - db.commit() - db.refresh(role) - logger.info("Created role '%s' (id=%d)", name, role.id) - return role - - -def _sync_role_permissions(db: Session, role: Role, target_perm_names: set[str] | None) -> None: - """Ensure *role* has exactly the permissions in *target_perm_names*. - - * ``None`` means **all** permissions (admin). - * The special sentinel ``"__read_only__"`` is handled by the caller passing - just the ``*.read`` names. - Only adds missing permissions; never removes manually-granted ones (additive). - """ - all_perms = db.query(Permission).all() - perm_by_name = {p.name: p for p in all_perms} - - if target_perm_names is None: - wanted_ids = {p.id for p in all_perms} - else: - wanted_ids = {perm_by_name[n].id for n in target_perm_names if n in perm_by_name} - - existing_ids = {rp.permission_id for rp in role.permissions} - added = 0 - for pid in wanted_ids - existing_ids: - db.add(RolePermission(role_id=role.id, permission_id=pid)) - added += 1 - - if added: - db.commit() - logger.info("Assigned %d new permissions to role '%s'", added, role.name) - - -def init_admin_role(db: Session, admin_user: models.User) -> None: - """Create default roles (admin / mgr / dev / guest) with preset permissions.""" - - all_perms = db.query(Permission).all() - read_perm_names = {p.name for p in all_perms if p.name.endswith(".read")} - - for name, description, perm_set in _DEFAULT_ROLES: - role = _ensure_role(db, name, description) - - if name == "guest": - _sync_role_permissions(db, role, read_perm_names) - else: - _sync_role_permissions(db, role, perm_set) - - logger.info("Default roles setup complete (admin, mgr, dev, guest)") - - -def init_acc_mgr_user(db: Session) -> models.User | None: - """Create the built-in acc-mgr user if not exists. - - This user: - - Has role 'account-manager' (can only create accounts) - - Cannot log in (no password, hashed_password=None) - - Cannot be deleted (enforced in delete endpoint) - - Is created automatically after wizard initialization - """ - username = "acc-mgr" - existing = db.query(models.User).filter(models.User.username == username).first() - if existing: - logger.info("acc-mgr user already exists (id=%d), skipping", existing.id) - return existing - - # Find account-manager role - acc_mgr_role = db.query(Role).filter(Role.name == "account-manager").first() - if not acc_mgr_role: - logger.warning("account-manager role not found, skipping acc-mgr user creation") - return None - - user = models.User( - username=username, - email="acc-mgr@harborforge.internal", - full_name="Account Manager", - hashed_password=None, # Cannot log in — no password - is_admin=False, - is_active=True, - role_id=acc_mgr_role.id, - ) - db.add(user) - db.commit() - db.refresh(user) - logger.info("Created acc-mgr user (id=%d) with account-manager role", user.id) - return user - - -DELETED_USER_USERNAME = "deleted-user" - - -def init_deleted_user(db: Session) -> models.User | None: - """Create the built-in deleted-user if not exists. - - This user serves as a foreign key sink: when a real user is deleted, - all references are reassigned here instead of cascading deletes. - It has no role (no permissions) and cannot log in. - """ - existing = db.query(models.User).filter( - models.User.username == DELETED_USER_USERNAME - ).first() - if existing: - logger.info("deleted-user already exists (id=%d), skipping", existing.id) - return existing - - user = models.User( - username=DELETED_USER_USERNAME, - email="deleted-user@harborforge.internal", - full_name="Deleted User", - hashed_password=None, - is_admin=False, - is_active=False, - role_id=None, - ) - db.add(user) - db.commit() - db.refresh(user) - logger.info("Created deleted-user (id=%d)", user.id) - 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, - admin_role=(oidc_cfg.get("admin_role") 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() - if not config: - return - - logger.info("Running HarborForge initialization from wizard config") - - # Initialize default permissions and admin role (always run) - all_perms = init_default_permissions(db) - logger.info("Default permissions initialized: %d total", len(all_perms)) - - # Admin user - admin_cfg = config.get("admin") - admin_user = None - if admin_cfg: - admin_user = init_admin_user(db, admin_cfg) - # Create admin role and assign to admin user - if admin_user: - init_admin_role(db, admin_user) - - # Built-in acc-mgr user (after roles are created) - init_acc_mgr_user(db) - - # Built-in deleted-user (foreign key sink for deleted accounts) - init_deleted_user(db) - - # Default project - project_cfg = config.get("default_project") - 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") diff --git a/app/main.py b/app/main.py index d85a282..86173ae 100644 --- a/app/main.py +++ b/app/main.py @@ -42,24 +42,22 @@ def version(): @app.get("/config/status", tags=["System"]) def config_status(): - """Check if HarborForge has been initialized (reads from config volume). - Frontend uses this instead of contacting the wizard directly.""" - import os, json - config_dir = os.getenv("CONFIG_DIR", "/config") - config_file = os.getenv("CONFIG_FILE", "harborforge.json") - config_path = os.path.join(config_dir, config_file) - if not os.path.exists(config_path): - return {"initialized": False} + """Has the deployment been bootstrapped (admin user exists)? + + Frontend hits this on mount to decide whether to show login or a + "no admin yet, run hf-cli admin create-user" placeholder. With the + wizard removed in v0.4.0 the only deploy-time bootstrap step is the + operator running `docker exec hf-backend hf-cli admin create-user ...` + once; this endpoint just reports whether that has happened. + """ + from app.core.config import SessionLocal + from app.models import models + db = SessionLocal() try: - with open(config_path, "r") as f: - cfg = json.load(f) - return { - "initialized": cfg.get("initialized", False), - "backend_url": cfg.get("backend_url"), - "discord": cfg.get("discord") or {}, - } - except Exception: - return {"initialized": False} + admin_count = db.query(models.User).filter(models.User.is_admin == True).count() # noqa: E712 + return {"initialized": admin_count > 0} + finally: + db.close() # Register routers from app.api.routers.auth import router as auth_router @@ -494,11 +492,13 @@ def startup(): Base.metadata.create_all(bind=engine) _migrate_schema() - # Initialize from AbstractWizard (admin user, default project, etc.) - from app.init_wizard import run_init + # Idempotent startup seed: permissions, default roles, built-in + # accounts (acc-mgr, deleted-user). The admin user + OIDC config are + # NOT created here — they're operator-driven via hf-cli. + from app.init_bootstrap import run_bootstrap db = SessionLocal() try: - run_init(db) + run_bootstrap(db) _sync_default_user_roles(db) finally: db.close() diff --git a/app/services/discord_wakeup.py b/app/services/discord_wakeup.py index 7503c7a..6cc0c34 100644 --- a/app/services/discord_wakeup.py +++ b/app/services/discord_wakeup.py @@ -1,17 +1,25 @@ from __future__ import annotations +import os from datetime import datetime, timezone from typing import Any import requests from fastapi import HTTPException -from app.services.harborforge_config import get_discord_wakeup_config - DISCORD_API_BASE = "https://discord.com/api/v10" WAKEUP_CATEGORY_NAME = "HarborForge Wakeup" +def _discord_config() -> dict[str, str | None]: + """Discord wakeup is configured via env vars (previously read from the + AbstractWizard config file). Returns guild_id+bot_token or Nones.""" + return { + "guild_id": os.getenv("HARBORFORGE_DISCORD_GUILD_ID") or None, + "bot_token": os.getenv("HARBORFORGE_DISCORD_BOT_TOKEN") or None, + } + + def _headers(bot_token: str) -> dict[str, str]: return { "Authorization": f"Bot {bot_token}", @@ -34,7 +42,7 @@ def _ensure_category(guild_id: str, bot_token: str) -> str | None: def create_private_wakeup_channel(discord_user_id: str, title: str, message: str) -> dict[str, Any]: - cfg = get_discord_wakeup_config() + cfg = _discord_config() guild_id = cfg.get("guild_id") bot_token = cfg.get("bot_token") if not guild_id or not bot_token: diff --git a/app/services/harborforge_config.py b/app/services/harborforge_config.py deleted file mode 100644 index 42e84d7..0000000 --- a/app/services/harborforge_config.py +++ /dev/null @@ -1,26 +0,0 @@ -import json -import os -from typing import Any - -CONFIG_DIR = os.getenv("CONFIG_DIR", "/config") -CONFIG_FILE = os.getenv("CONFIG_FILE", "harborforge.json") - - -def load_runtime_config() -> dict[str, Any]: - config_path = os.path.join(CONFIG_DIR, CONFIG_FILE) - if not os.path.exists(config_path): - return {} - try: - with open(config_path, "r") as f: - return json.load(f) - except Exception: - return {} - - -def get_discord_wakeup_config() -> dict[str, str | None]: - cfg = load_runtime_config() - discord_cfg = cfg.get("discord") or {} - return { - "guild_id": discord_cfg.get("guild_id"), - "bot_token": discord_cfg.get("bot_token"), - } diff --git a/entrypoint.sh b/entrypoint.sh index 0a318fb..7c87163 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,19 +1,5 @@ #!/bin/sh -# Wait for wizard config before starting uvicorn -CONFIG_DIR="${CONFIG_DIR:-/config}" -CONFIG_FILE="${CONFIG_FILE:-harborforge.json}" -CONFIG_PATH="$CONFIG_DIR/$CONFIG_FILE" - -echo "HarborForge Backend - waiting for config..." -echo " Config path: $CONFIG_PATH" - -while true; do - if [ -f "$CONFIG_PATH" ]; then - echo " Config found! Starting backend..." - break - fi - echo " Config not ready, waiting 5s... (run setup wizard via SSH tunnel)" - sleep 5 -done - +# HarborForge backend entrypoint. All config comes from env vars (DATABASE_URL, +# SECRET_KEY, HARBORFORGE_OIDC_ONLY, etc.). First-deploy admin user + OIDC +# issuer config are operator-driven via `docker exec hf-backend hf-cli ...`. exec uvicorn app.main:app --host 0.0.0.0 --port 8000 diff --git a/tests/conftest.py b/tests/conftest.py index 5cb9573..04c24ec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,7 +15,7 @@ from fastapi.testclient import TestClient # --------------------------------------------------------------------------- # Patch the production engine/SessionLocal BEFORE importing app so that -# startup events (Base.metadata.create_all, init_wizard, etc.) use the +# startup events (Base.metadata.create_all, init_bootstrap, etc.) use the # in-memory SQLite database instead of trying to connect to MySQL. # ---------------------------------------------------------------------------