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>
287 lines
12 KiB
Python
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")
|