""" 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"), # Project member management (used by DELETE /projects/{id}/members/{user_id}) ("member.remove", "Remove a project member", "project"), # Schedule type (calendar templates) — read covers list+detail, manage covers # create/edit/delete on schedule_types AND their special slots. ("schedule_type.read", "View schedule types and special slots", "calendar"), ("schedule_type.manage", "Create / edit / delete schedule types and slots", "calendar"), ] 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 role for agents (assigned automatically by POST /users when # the create-user payload carries agent_id/claw_identifier — see # app/api/routers/users.py:_resolve_user_role). Guest-tier reads + # self-service API-key rotation so agents can manage their own creds # without admin intervention. _GENERAL_AGENT_PERMISSIONS = { "project.read", "task.read", "milestone.read", "monitor.read", "calendar.read", "user.reset-self-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), ("general-agent", "General agent - read-only + self API key rotation", _GENERAL_AGENT_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")