Files
HarborForge.Backend/app/init_bootstrap.py
hzhang 9feff8e008 feat(knowledge-base): KnowledgeBase feature — models, CRUD API, RBAC
New entities mirroring the Project shape:
- knowledge_bases (human code, title, description, created_by, timestamps)
- knowledge_topics (UNIQUE(topic, knowledge_base_id))
- knowledge_categories (self-referential parent; UNIQUE(topic_id, parent, name),
  with an app-level check for the NULL-parent case MySQL can't enforce)
- knowledge_facts (category_id NULL → fact lives directly on the topic)
- project_knowledge_bases (M2M project ↔ knowledge base)

Adds full CRUD for KB/topic/category/fact, a nested /tree aggregate,
project link/unlink/list, KB-code generation (same algorithm as project
codes), and category cycle-prevention. Four global permissions
(knowledge-base.create/read/update/delete) seeded in init_bootstrap and
granted to admin/mgr/dev/general-agent/guest as appropriate. New tables
auto-create via Base.metadata.create_all; router wired in main.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:03:14 +01:00

287 lines
12 KiB
Python

"""
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.create", "Create a project", "project"),
("project.delete", "Delete project", "project"),
("project.manage_members", "Manage project members", "project"),
# Knowledge base permissions
("knowledge-base.read", "View knowledge bases", "knowledge-base"),
("knowledge-base.create", "Create a knowledge base", "knowledge-base"),
("knowledge-base.update", "Edit a knowledge base and its structure", "knowledge-base"),
("knowledge-base.delete", "Delete a knowledge base", "knowledge-base"),
# 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.create", "project.manage_members",
"knowledge-base.read", "knowledge-base.create", "knowledge-base.update", "knowledge-base.delete",
"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",
"knowledge-base.read", "knowledge-base.update",
"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",
"knowledge-base.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")