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:
256
app/init_bootstrap.py
Normal file
256
app/init_bootstrap.py
Normal file
@@ -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")
|
||||
Reference in New Issue
Block a user