Compare commits
9 Commits
feat/get-a
...
feat/knowl
| Author | SHA1 | Date | |
|---|---|---|---|
| 9feff8e008 | |||
| 88779d2db0 | |||
| cacb1d2652 | |||
| d2b83ad58d | |||
| 01f6b562e1 | |||
| 595391b41b | |||
| 54feb9686c | |||
| 5ea2cdfc9e | |||
| 422b2fa7b7 |
29
.env.example
29
.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_ROOT_PASSWORD=harborforge_root
|
||||||
MYSQL_DATABASE=harborforge
|
MYSQL_DATABASE=harborforge
|
||||||
MYSQL_USER=harborforge
|
MYSQL_USER=harborforge
|
||||||
MYSQL_PASSWORD=harborforge_pass
|
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
|
SECRET_KEY=change-me-use-openssl-rand-hex-32
|
||||||
LOG_LEVEL=INFO
|
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 <s> \
|
||||||
|
# --redirect-uri https://hf-api.example.com/auth/oidc/callback \
|
||||||
|
# --post-login-redirect https://hf.example.com/oidc/callback \
|
||||||
|
# --enabled true
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ COPY requirements.txt ./
|
|||||||
COPY entrypoint.sh .
|
COPY entrypoint.sh .
|
||||||
RUN chmod +x 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
|
# OIDC-only mode: when "true", password login is rejected, user creation
|
||||||
# ignores passwords (passwordless users that sign in via a bound OIDC
|
# ignores passwords (passwordless users that sign in via a bound OIDC
|
||||||
# identity / API keys). Overridable at runtime via the same env var.
|
# identity / API keys). Overridable at runtime via the same env var.
|
||||||
|
|||||||
@@ -59,22 +59,43 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_api_key(db: Session, key: str) -> models.User | None:
|
||||||
|
"""Resolve an API key string to a User; mark last_used_at on hit."""
|
||||||
|
if not key:
|
||||||
|
return None
|
||||||
|
key_obj = db.query(APIKey).filter(APIKey.key == key, APIKey.is_active == True).first() # noqa: E712
|
||||||
|
if not key_obj:
|
||||||
|
return None
|
||||||
|
key_obj.last_used_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
return db.query(models.User).filter(models.User.id == key_obj.user_id).first()
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user_or_apikey(
|
async def get_current_user_or_apikey(
|
||||||
token: str = Depends(oauth2_scheme),
|
token: str = Depends(oauth2_scheme),
|
||||||
api_key: str = Depends(apikey_header),
|
api_key: str = Depends(apikey_header),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Authenticate via JWT token OR API key."""
|
"""Authenticate via JWT token (Authorization: Bearer <jwt>) OR API key
|
||||||
|
(X-API-Key: <key>, OR — as a convenience for CLI clients that only know
|
||||||
|
Bearer — Authorization: Bearer <api-key>; falls back when JWT decode fails).
|
||||||
|
"""
|
||||||
|
# Native X-API-Key header
|
||||||
if api_key:
|
if api_key:
|
||||||
key_obj = db.query(APIKey).filter(APIKey.key == api_key, APIKey.is_active == True).first()
|
user = _lookup_api_key(db, api_key)
|
||||||
if key_obj:
|
|
||||||
key_obj.last_used_at = datetime.utcnow()
|
|
||||||
db.commit()
|
|
||||||
user = db.query(models.User).filter(models.User.id == key_obj.user_id).first()
|
|
||||||
if user:
|
if user:
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
# Bearer header — try JWT first, then API key on decode failure
|
||||||
if token:
|
if token:
|
||||||
|
try:
|
||||||
return await get_current_user(token=token, db=db)
|
return await get_current_user(token=token, db=db)
|
||||||
|
except HTTPException:
|
||||||
|
user = _lookup_api_key(db, token)
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
raise
|
||||||
|
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from app.core.config import get_db, settings
|
|||||||
from app.models import models
|
from app.models import models
|
||||||
from app.models.role_permission import Permission, Role, RolePermission
|
from app.models.role_permission import Permission, Role, RolePermission
|
||||||
from app.schemas import schemas
|
from app.schemas import schemas
|
||||||
from app.api.deps import Token, verify_password, create_access_token, get_current_user
|
from app.api.deps import Token, verify_password, create_access_token, get_current_user, get_current_user_or_apikey
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["Auth"])
|
router = APIRouter(prefix="/auth", tags=["Auth"])
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ class PermissionIntrospectionResponse(BaseModel):
|
|||||||
|
|
||||||
@router.get("/me/permissions", response_model=PermissionIntrospectionResponse)
|
@router.get("/me/permissions", response_model=PermissionIntrospectionResponse)
|
||||||
async def get_my_permissions(
|
async def get_my_permissions(
|
||||||
current_user: models.User = Depends(get_current_user),
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Return the current user's effective permissions for CLI help introspection."""
|
"""Return the current user's effective permissions for CLI help introspection."""
|
||||||
|
|||||||
735
app/api/routers/knowledge.py
Normal file
735
app/api/routers/knowledge.py
Normal file
@@ -0,0 +1,735 @@
|
|||||||
|
"""Knowledge Base router with global-permission RBAC.
|
||||||
|
|
||||||
|
Permissions (global, granted via the Role Editor; admins auto-pass):
|
||||||
|
knowledge-base.create create a knowledge base
|
||||||
|
knowledge-base.read read any knowledge base / topic / category / fact
|
||||||
|
knowledge-base.update edit a KB and its topic/category/fact structure,
|
||||||
|
and link/unlink knowledge bases to projects
|
||||||
|
knowledge-base.delete delete a knowledge base
|
||||||
|
|
||||||
|
There is no per-KB membership model (unlike projects) — access is purely by
|
||||||
|
the four global permissions above.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import get_db
|
||||||
|
from app.models import models
|
||||||
|
from app.models import knowledge as kb_models
|
||||||
|
from app.schemas import knowledge as kb_schemas
|
||||||
|
from app.api.deps import get_current_user_or_apikey
|
||||||
|
|
||||||
|
router = APIRouter(tags=["KnowledgeBase"])
|
||||||
|
|
||||||
|
PERM_CREATE = "knowledge-base.create"
|
||||||
|
PERM_READ = "knowledge-base.read"
|
||||||
|
PERM_UPDATE = "knowledge-base.update"
|
||||||
|
PERM_DELETE = "knowledge-base.delete"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Permission helper (global perms only)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _require_perm(db: Session, user: models.User, perm_name: str) -> None:
|
||||||
|
if user.is_admin:
|
||||||
|
return
|
||||||
|
from app.models.role_permission import Permission, RolePermission
|
||||||
|
has = (
|
||||||
|
db.query(Permission.id)
|
||||||
|
.join(RolePermission, RolePermission.permission_id == Permission.id)
|
||||||
|
.filter(
|
||||||
|
RolePermission.role_id == user.role_id,
|
||||||
|
Permission.name == perm_name,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
if user.role_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if not has:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Permission denied: {perm_name} required",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Knowledge-base code generation (same rules as project_code)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
WORD_SEGMENT_RE = re.compile(r"[A-Za-z]+")
|
||||||
|
CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+")
|
||||||
|
|
||||||
|
|
||||||
|
def _split_words(name: str):
|
||||||
|
segments = WORD_SEGMENT_RE.findall(name or "")
|
||||||
|
words = []
|
||||||
|
for seg in segments:
|
||||||
|
for part in CAMEL_RE.findall(seg):
|
||||||
|
if part.isupper() and len(part) > 1:
|
||||||
|
words.extend(list(part))
|
||||||
|
else:
|
||||||
|
words.append(part)
|
||||||
|
return words
|
||||||
|
|
||||||
|
|
||||||
|
def _code_exists(db: Session, code: str) -> bool:
|
||||||
|
return (
|
||||||
|
db.query(kb_models.KnowledgeBase)
|
||||||
|
.filter(kb_models.KnowledgeBase.knowledge_base_code == code)
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_counter(db: Session, prefix: str, width: int) -> str:
|
||||||
|
if width <= 0:
|
||||||
|
return ""
|
||||||
|
counter = (
|
||||||
|
db.query(kb_models.KnowledgeBaseCodeCounter)
|
||||||
|
.filter(kb_models.KnowledgeBaseCodeCounter.prefix == prefix)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not counter:
|
||||||
|
counter = kb_models.KnowledgeBaseCodeCounter(prefix=prefix, next_value=0)
|
||||||
|
db.add(counter)
|
||||||
|
db.flush()
|
||||||
|
value = counter.next_value
|
||||||
|
counter.next_value += 1
|
||||||
|
db.flush()
|
||||||
|
return format(value, "x").upper().zfill(width)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_with_counter(db: Session, prefix: str, width: int) -> str:
|
||||||
|
while True:
|
||||||
|
suffix = _next_counter(db, prefix, width)
|
||||||
|
code = (prefix + suffix).upper()
|
||||||
|
if not _code_exists(db, code):
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_kb_code(db: Session, title: str) -> str:
|
||||||
|
words = _split_words(title)
|
||||||
|
if not words:
|
||||||
|
return _generate_with_counter(db, "UN", 4)
|
||||||
|
|
||||||
|
if len(words) == 1:
|
||||||
|
letters = "".join(c for c in words[0] if c.isalpha()).upper()
|
||||||
|
if not letters:
|
||||||
|
return _generate_with_counter(db, "UN", 4)
|
||||||
|
if len(letters) >= 6:
|
||||||
|
code = letters[:6]
|
||||||
|
if _code_exists(db, code):
|
||||||
|
return _generate_with_counter(db, letters[:2], 4)
|
||||||
|
return code
|
||||||
|
prefix = letters
|
||||||
|
return _generate_with_counter(db, prefix, 6 - len(prefix))
|
||||||
|
|
||||||
|
total_letters = sum(len(w) for w in words)
|
||||||
|
if len(words) > 6:
|
||||||
|
code = "".join(w[0] for w in words[:6]).upper()
|
||||||
|
if _code_exists(db, code):
|
||||||
|
return _generate_with_counter(db, code[:2], 4)
|
||||||
|
return code
|
||||||
|
|
||||||
|
if total_letters < 6:
|
||||||
|
prefix = "".join(words).upper()
|
||||||
|
return _generate_with_counter(db, prefix, 6 - len(prefix))
|
||||||
|
|
||||||
|
if total_letters == 6:
|
||||||
|
code = "".join(words).upper()
|
||||||
|
if _code_exists(db, code):
|
||||||
|
return _generate_with_counter(db, code[:2], 4)
|
||||||
|
return code
|
||||||
|
|
||||||
|
# total_letters > 6: initials, then fill from a counter on collision
|
||||||
|
code = "".join(w[0] for w in words).upper()[:6]
|
||||||
|
if not _code_exists(db, code):
|
||||||
|
return code
|
||||||
|
return _generate_with_counter(db, code[:2], 4)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Resolvers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _resolve_kb(db: Session, identifier: str) -> kb_models.KnowledgeBase:
|
||||||
|
kb = None
|
||||||
|
try:
|
||||||
|
kb = db.query(kb_models.KnowledgeBase).filter(kb_models.KnowledgeBase.id == int(identifier)).first()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
kb = (
|
||||||
|
db.query(kb_models.KnowledgeBase)
|
||||||
|
.filter(kb_models.KnowledgeBase.knowledge_base_code == str(identifier))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not kb:
|
||||||
|
raise HTTPException(status_code=404, detail="Knowledge base not found")
|
||||||
|
return kb
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_project(db: Session, identifier: str) -> models.Project:
|
||||||
|
project = None
|
||||||
|
try:
|
||||||
|
project = db.query(models.Project).filter(models.Project.id == int(identifier)).first()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
project = db.query(models.Project).filter(models.Project.project_code == str(identifier)).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
def _get_topic(db: Session, topic_id: int) -> kb_models.KnowledgeTopic:
|
||||||
|
topic = db.query(kb_models.KnowledgeTopic).filter(kb_models.KnowledgeTopic.id == topic_id).first()
|
||||||
|
if not topic:
|
||||||
|
raise HTTPException(status_code=404, detail="Topic not found")
|
||||||
|
return topic
|
||||||
|
|
||||||
|
|
||||||
|
def _get_category(db: Session, category_id: int) -> kb_models.KnowledgeCategory:
|
||||||
|
cat = db.query(kb_models.KnowledgeCategory).filter(kb_models.KnowledgeCategory.id == category_id).first()
|
||||||
|
if not cat:
|
||||||
|
raise HTTPException(status_code=404, detail="Category not found")
|
||||||
|
return cat
|
||||||
|
|
||||||
|
|
||||||
|
def _descendant_category_ids(db: Session, category_id: int) -> List[int]:
|
||||||
|
"""Return [category_id, ...all nested descendants] (deepest last)."""
|
||||||
|
collected = [category_id]
|
||||||
|
frontier = [category_id]
|
||||||
|
while frontier:
|
||||||
|
children = (
|
||||||
|
db.query(kb_models.KnowledgeCategory.id)
|
||||||
|
.filter(kb_models.KnowledgeCategory.parent.in_(frontier))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
child_ids = [c.id for c in children]
|
||||||
|
if not child_ids:
|
||||||
|
break
|
||||||
|
collected.extend(child_ids)
|
||||||
|
frontier = child_ids
|
||||||
|
return collected
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Knowledge Base CRUD
|
||||||
|
# ===========================================================================
|
||||||
|
@router.post("/knowledge-bases", response_model=kb_schemas.KnowledgeBaseResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_knowledge_base(
|
||||||
|
payload: kb_schemas.KnowledgeBaseCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_CREATE)
|
||||||
|
kb = kb_models.KnowledgeBase(
|
||||||
|
title=payload.title,
|
||||||
|
description=payload.description,
|
||||||
|
created_by=current_user.id,
|
||||||
|
knowledge_base_code=_generate_kb_code(db, payload.title),
|
||||||
|
)
|
||||||
|
db.add(kb)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(kb)
|
||||||
|
return kb
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/knowledge-bases", response_model=List[kb_schemas.KnowledgeBaseResponse])
|
||||||
|
def list_knowledge_bases(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
project: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_READ)
|
||||||
|
q = db.query(kb_models.KnowledgeBase)
|
||||||
|
if project is not None:
|
||||||
|
proj = _resolve_project(db, project)
|
||||||
|
linked_ids = [
|
||||||
|
row.knowledge_base_id
|
||||||
|
for row in db.query(kb_models.ProjectKnowledgeBase.knowledge_base_id)
|
||||||
|
.filter(kb_models.ProjectKnowledgeBase.project_id == proj.id)
|
||||||
|
.all()
|
||||||
|
]
|
||||||
|
if not linked_ids:
|
||||||
|
return []
|
||||||
|
q = q.filter(kb_models.KnowledgeBase.id.in_(linked_ids))
|
||||||
|
return q.order_by(kb_models.KnowledgeBase.id).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/knowledge-bases/{kb_id}", response_model=kb_schemas.KnowledgeBaseResponse)
|
||||||
|
def get_knowledge_base(
|
||||||
|
kb_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_READ)
|
||||||
|
return _resolve_kb(db, kb_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/knowledge-bases/{kb_id}", response_model=kb_schemas.KnowledgeBaseResponse)
|
||||||
|
def update_knowledge_base(
|
||||||
|
kb_id: str,
|
||||||
|
payload: kb_schemas.KnowledgeBaseUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_UPDATE)
|
||||||
|
kb = _resolve_kb(db, kb_id)
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
for field, value in data.items():
|
||||||
|
setattr(kb, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(kb)
|
||||||
|
return kb
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/knowledge-bases/{kb_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_knowledge_base(
|
||||||
|
kb_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_DELETE)
|
||||||
|
kb = _resolve_kb(db, kb_id)
|
||||||
|
|
||||||
|
topic_ids = [
|
||||||
|
t.id
|
||||||
|
for t in db.query(kb_models.KnowledgeTopic.id)
|
||||||
|
.filter(kb_models.KnowledgeTopic.knowledge_base_id == kb.id)
|
||||||
|
.all()
|
||||||
|
]
|
||||||
|
if topic_ids:
|
||||||
|
db.query(kb_models.KnowledgeFact).filter(kb_models.KnowledgeFact.topic_id.in_(topic_ids)).delete(synchronize_session=False)
|
||||||
|
db.query(kb_models.KnowledgeCategory).filter(kb_models.KnowledgeCategory.topic_id.in_(topic_ids)).delete(synchronize_session=False)
|
||||||
|
db.query(kb_models.KnowledgeTopic).filter(kb_models.KnowledgeTopic.id.in_(topic_ids)).delete(synchronize_session=False)
|
||||||
|
db.query(kb_models.ProjectKnowledgeBase).filter(kb_models.ProjectKnowledgeBase.knowledge_base_id == kb.id).delete(synchronize_session=False)
|
||||||
|
db.delete(kb)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Tree (read-only aggregate)
|
||||||
|
# ===========================================================================
|
||||||
|
@router.get("/knowledge-bases/{kb_id}/tree", response_model=kb_schemas.KnowledgeBaseTree)
|
||||||
|
def get_knowledge_base_tree(
|
||||||
|
kb_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_READ)
|
||||||
|
kb = _resolve_kb(db, kb_id)
|
||||||
|
|
||||||
|
topics = db.query(kb_models.KnowledgeTopic).filter(kb_models.KnowledgeTopic.knowledge_base_id == kb.id).all()
|
||||||
|
topic_ids = [t.id for t in topics]
|
||||||
|
cats = (
|
||||||
|
db.query(kb_models.KnowledgeCategory).filter(kb_models.KnowledgeCategory.topic_id.in_(topic_ids)).all()
|
||||||
|
if topic_ids else []
|
||||||
|
)
|
||||||
|
facts = (
|
||||||
|
db.query(kb_models.KnowledgeFact).filter(kb_models.KnowledgeFact.topic_id.in_(topic_ids)).all()
|
||||||
|
if topic_ids else []
|
||||||
|
)
|
||||||
|
|
||||||
|
# Index facts by (topic_id, category_id) and categories by (topic_id, parent)
|
||||||
|
facts_by_cat: dict = {}
|
||||||
|
facts_topic_direct: dict = {}
|
||||||
|
for f in facts:
|
||||||
|
fr = kb_schemas.KnowledgeFactResponse.model_validate(f)
|
||||||
|
if f.category_id is None:
|
||||||
|
facts_topic_direct.setdefault(f.topic_id, []).append(fr)
|
||||||
|
else:
|
||||||
|
facts_by_cat.setdefault(f.category_id, []).append(fr)
|
||||||
|
|
||||||
|
cats_by_parent: dict = {}
|
||||||
|
for c in cats:
|
||||||
|
cats_by_parent.setdefault((c.topic_id, c.parent), []).append(c)
|
||||||
|
|
||||||
|
def build_category(cat) -> kb_schemas.CategoryTreeNode:
|
||||||
|
children = cats_by_parent.get((cat.topic_id, cat.id), [])
|
||||||
|
return kb_schemas.CategoryTreeNode(
|
||||||
|
id=cat.id,
|
||||||
|
name=cat.name,
|
||||||
|
parent=cat.parent,
|
||||||
|
topic_id=cat.topic_id,
|
||||||
|
description=cat.description,
|
||||||
|
categories=[build_category(ch) for ch in children],
|
||||||
|
facts=facts_by_cat.get(cat.id, []),
|
||||||
|
)
|
||||||
|
|
||||||
|
topic_nodes = []
|
||||||
|
for t in topics:
|
||||||
|
top_level_cats = cats_by_parent.get((t.id, None), [])
|
||||||
|
topic_nodes.append(
|
||||||
|
kb_schemas.TopicTreeNode(
|
||||||
|
id=t.id,
|
||||||
|
topic=t.topic,
|
||||||
|
knowledge_base_id=t.knowledge_base_id,
|
||||||
|
description=t.description,
|
||||||
|
categories=[build_category(c) for c in top_level_cats],
|
||||||
|
facts=facts_topic_direct.get(t.id, []),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return kb_schemas.KnowledgeBaseTree(
|
||||||
|
id=kb.id,
|
||||||
|
knowledge_base_code=kb.knowledge_base_code,
|
||||||
|
title=kb.title,
|
||||||
|
description=kb.description,
|
||||||
|
topics=topic_nodes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Topics
|
||||||
|
# ===========================================================================
|
||||||
|
@router.get("/knowledge-bases/{kb_id}/topics", response_model=List[kb_schemas.KnowledgeTopicResponse])
|
||||||
|
def list_topics(
|
||||||
|
kb_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_READ)
|
||||||
|
kb = _resolve_kb(db, kb_id)
|
||||||
|
return db.query(kb_models.KnowledgeTopic).filter(kb_models.KnowledgeTopic.knowledge_base_id == kb.id).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/knowledge-bases/{kb_id}/topics", response_model=kb_schemas.KnowledgeTopicResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_topic(
|
||||||
|
kb_id: str,
|
||||||
|
payload: kb_schemas.KnowledgeTopicCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_UPDATE)
|
||||||
|
kb = _resolve_kb(db, kb_id)
|
||||||
|
existing = (
|
||||||
|
db.query(kb_models.KnowledgeTopic)
|
||||||
|
.filter(
|
||||||
|
kb_models.KnowledgeTopic.knowledge_base_id == kb.id,
|
||||||
|
kb_models.KnowledgeTopic.topic == payload.topic,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="A topic with this name already exists in this knowledge base")
|
||||||
|
topic = kb_models.KnowledgeTopic(
|
||||||
|
topic=payload.topic,
|
||||||
|
description=payload.description,
|
||||||
|
knowledge_base_id=kb.id,
|
||||||
|
created_by=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(topic)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(topic)
|
||||||
|
return topic
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/knowledge-topics/{topic_id}", response_model=kb_schemas.KnowledgeTopicResponse)
|
||||||
|
def get_topic(
|
||||||
|
topic_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_READ)
|
||||||
|
return _get_topic(db, topic_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/knowledge-topics/{topic_id}", response_model=kb_schemas.KnowledgeTopicResponse)
|
||||||
|
def update_topic(
|
||||||
|
topic_id: int,
|
||||||
|
payload: kb_schemas.KnowledgeTopicUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_UPDATE)
|
||||||
|
topic = _get_topic(db, topic_id)
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
if "topic" in data and data["topic"] and data["topic"] != topic.topic:
|
||||||
|
clash = (
|
||||||
|
db.query(kb_models.KnowledgeTopic)
|
||||||
|
.filter(
|
||||||
|
kb_models.KnowledgeTopic.knowledge_base_id == topic.knowledge_base_id,
|
||||||
|
kb_models.KnowledgeTopic.topic == data["topic"],
|
||||||
|
kb_models.KnowledgeTopic.id != topic.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if clash:
|
||||||
|
raise HTTPException(status_code=400, detail="A topic with this name already exists in this knowledge base")
|
||||||
|
for field, value in data.items():
|
||||||
|
setattr(topic, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(topic)
|
||||||
|
return topic
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/knowledge-topics/{topic_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_topic(
|
||||||
|
topic_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_UPDATE)
|
||||||
|
topic = _get_topic(db, topic_id)
|
||||||
|
db.query(kb_models.KnowledgeFact).filter(kb_models.KnowledgeFact.topic_id == topic.id).delete(synchronize_session=False)
|
||||||
|
db.query(kb_models.KnowledgeCategory).filter(kb_models.KnowledgeCategory.topic_id == topic.id).delete(synchronize_session=False)
|
||||||
|
db.delete(topic)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Categories
|
||||||
|
# ===========================================================================
|
||||||
|
def _check_category_unique(db: Session, topic_id: int, parent: Optional[int], name: str, exclude_id: Optional[int] = None):
|
||||||
|
q = db.query(kb_models.KnowledgeCategory).filter(
|
||||||
|
kb_models.KnowledgeCategory.topic_id == topic_id,
|
||||||
|
kb_models.KnowledgeCategory.name == name,
|
||||||
|
)
|
||||||
|
if parent is None:
|
||||||
|
q = q.filter(kb_models.KnowledgeCategory.parent.is_(None))
|
||||||
|
else:
|
||||||
|
q = q.filter(kb_models.KnowledgeCategory.parent == parent)
|
||||||
|
if exclude_id is not None:
|
||||||
|
q = q.filter(kb_models.KnowledgeCategory.id != exclude_id)
|
||||||
|
if q.first():
|
||||||
|
raise HTTPException(status_code=400, detail="A category with this name already exists under the same parent")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/knowledge-topics/{topic_id}/categories", response_model=List[kb_schemas.KnowledgeCategoryResponse])
|
||||||
|
def list_categories(
|
||||||
|
topic_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_READ)
|
||||||
|
_get_topic(db, topic_id)
|
||||||
|
return db.query(kb_models.KnowledgeCategory).filter(kb_models.KnowledgeCategory.topic_id == topic_id).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/knowledge-categories", response_model=kb_schemas.KnowledgeCategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_category(
|
||||||
|
payload: kb_schemas.KnowledgeCategoryCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_UPDATE)
|
||||||
|
_get_topic(db, payload.topic_id)
|
||||||
|
if payload.parent is not None:
|
||||||
|
parent_cat = _get_category(db, payload.parent)
|
||||||
|
if parent_cat.topic_id != payload.topic_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Parent category belongs to a different topic")
|
||||||
|
_check_category_unique(db, payload.topic_id, payload.parent, payload.name)
|
||||||
|
cat = kb_models.KnowledgeCategory(
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
parent=payload.parent,
|
||||||
|
topic_id=payload.topic_id,
|
||||||
|
created_by=current_user.id,
|
||||||
|
last_updated_by=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(cat)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cat)
|
||||||
|
return cat
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/knowledge-categories/{category_id}", response_model=kb_schemas.KnowledgeCategoryResponse)
|
||||||
|
def get_category(
|
||||||
|
category_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_READ)
|
||||||
|
return _get_category(db, category_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/knowledge-categories/{category_id}", response_model=kb_schemas.KnowledgeCategoryResponse)
|
||||||
|
def update_category(
|
||||||
|
category_id: int,
|
||||||
|
payload: kb_schemas.KnowledgeCategoryUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_UPDATE)
|
||||||
|
cat = _get_category(db, category_id)
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
new_parent = data.get("parent", cat.parent) if "parent" in data else cat.parent
|
||||||
|
if "parent" in data and data["parent"] is not None:
|
||||||
|
if data["parent"] == cat.id:
|
||||||
|
raise HTTPException(status_code=400, detail="A category cannot be its own parent")
|
||||||
|
parent_cat = _get_category(db, data["parent"])
|
||||||
|
if parent_cat.topic_id != cat.topic_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Parent category belongs to a different topic")
|
||||||
|
# Prevent cycles: new parent must not be a descendant of this category
|
||||||
|
if data["parent"] in _descendant_category_ids(db, cat.id):
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot move a category under one of its own descendants")
|
||||||
|
|
||||||
|
new_name = data.get("name", cat.name)
|
||||||
|
if ("name" in data and data["name"] != cat.name) or ("parent" in data and new_parent != cat.parent):
|
||||||
|
_check_category_unique(db, cat.topic_id, new_parent, new_name, exclude_id=cat.id)
|
||||||
|
|
||||||
|
for field, value in data.items():
|
||||||
|
setattr(cat, field, value)
|
||||||
|
cat.last_updated_by = current_user.id
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cat)
|
||||||
|
return cat
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/knowledge-categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_category(
|
||||||
|
category_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_UPDATE)
|
||||||
|
cat = _get_category(db, category_id)
|
||||||
|
ids = _descendant_category_ids(db, cat.id)
|
||||||
|
db.query(kb_models.KnowledgeFact).filter(kb_models.KnowledgeFact.category_id.in_(ids)).delete(synchronize_session=False)
|
||||||
|
db.query(kb_models.KnowledgeCategory).filter(kb_models.KnowledgeCategory.id.in_(ids)).delete(synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Facts
|
||||||
|
# ===========================================================================
|
||||||
|
@router.post("/knowledge-facts", response_model=kb_schemas.KnowledgeFactResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_fact(
|
||||||
|
payload: kb_schemas.KnowledgeFactCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_UPDATE)
|
||||||
|
_get_topic(db, payload.topic_id)
|
||||||
|
if payload.category_id is not None:
|
||||||
|
cat = _get_category(db, payload.category_id)
|
||||||
|
if cat.topic_id != payload.topic_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Category belongs to a different topic")
|
||||||
|
fact = kb_models.KnowledgeFact(
|
||||||
|
fact=payload.fact,
|
||||||
|
topic_id=payload.topic_id,
|
||||||
|
category_id=payload.category_id,
|
||||||
|
)
|
||||||
|
db.add(fact)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(fact)
|
||||||
|
return fact
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/knowledge-facts/{fact_id}", response_model=kb_schemas.KnowledgeFactResponse)
|
||||||
|
def get_fact(
|
||||||
|
fact_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_READ)
|
||||||
|
fact = db.query(kb_models.KnowledgeFact).filter(kb_models.KnowledgeFact.id == fact_id).first()
|
||||||
|
if not fact:
|
||||||
|
raise HTTPException(status_code=404, detail="Fact not found")
|
||||||
|
return fact
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/knowledge-facts/{fact_id}", response_model=kb_schemas.KnowledgeFactResponse)
|
||||||
|
def update_fact(
|
||||||
|
fact_id: int,
|
||||||
|
payload: kb_schemas.KnowledgeFactUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_UPDATE)
|
||||||
|
fact = db.query(kb_models.KnowledgeFact).filter(kb_models.KnowledgeFact.id == fact_id).first()
|
||||||
|
if not fact:
|
||||||
|
raise HTTPException(status_code=404, detail="Fact not found")
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
if "category_id" in data and data["category_id"] is not None:
|
||||||
|
cat = _get_category(db, data["category_id"])
|
||||||
|
if cat.topic_id != fact.topic_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Category belongs to a different topic")
|
||||||
|
for field, value in data.items():
|
||||||
|
setattr(fact, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(fact)
|
||||||
|
return fact
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/knowledge-facts/{fact_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_fact(
|
||||||
|
fact_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_UPDATE)
|
||||||
|
fact = db.query(kb_models.KnowledgeFact).filter(kb_models.KnowledgeFact.id == fact_id).first()
|
||||||
|
if not fact:
|
||||||
|
raise HTTPException(status_code=404, detail="Fact not found")
|
||||||
|
db.delete(fact)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Project <-> KnowledgeBase links
|
||||||
|
# ===========================================================================
|
||||||
|
@router.get("/projects/{project_id}/knowledge-bases", response_model=List[kb_schemas.KnowledgeBaseResponse])
|
||||||
|
def list_project_knowledge_bases(
|
||||||
|
project_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_READ)
|
||||||
|
project = _resolve_project(db, project_id)
|
||||||
|
linked_ids = [
|
||||||
|
row.knowledge_base_id
|
||||||
|
for row in db.query(kb_models.ProjectKnowledgeBase.knowledge_base_id)
|
||||||
|
.filter(kb_models.ProjectKnowledgeBase.project_id == project.id)
|
||||||
|
.all()
|
||||||
|
]
|
||||||
|
if not linked_ids:
|
||||||
|
return []
|
||||||
|
return db.query(kb_models.KnowledgeBase).filter(kb_models.KnowledgeBase.id.in_(linked_ids)).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projects/{project_id}/knowledge-bases", response_model=kb_schemas.KnowledgeBaseResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def link_knowledge_base_to_project(
|
||||||
|
project_id: str,
|
||||||
|
payload: kb_schemas.ProjectKnowledgeBaseLink,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_UPDATE)
|
||||||
|
project = _resolve_project(db, project_id)
|
||||||
|
kb = _resolve_kb(db, payload.knowledge_base)
|
||||||
|
existing = (
|
||||||
|
db.query(kb_models.ProjectKnowledgeBase)
|
||||||
|
.filter(
|
||||||
|
kb_models.ProjectKnowledgeBase.project_id == project.id,
|
||||||
|
kb_models.ProjectKnowledgeBase.knowledge_base_id == kb.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
db.add(kb_models.ProjectKnowledgeBase(project_id=project.id, knowledge_base_id=kb.id))
|
||||||
|
db.commit()
|
||||||
|
return kb
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/projects/{project_id}/knowledge-bases/{kb_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def unlink_knowledge_base_from_project(
|
||||||
|
project_id: str,
|
||||||
|
kb_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||||
|
):
|
||||||
|
_require_perm(db, current_user, PERM_UPDATE)
|
||||||
|
project = _resolve_project(db, project_id)
|
||||||
|
kb = _resolve_kb(db, kb_id)
|
||||||
|
db.query(kb_models.ProjectKnowledgeBase).filter(
|
||||||
|
kb_models.ProjectKnowledgeBase.project_id == project.id,
|
||||||
|
kb_models.ProjectKnowledgeBase.knowledge_base_id == kb.id,
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
"""OIDC (OpenID Connect) login + admin-configurable provider settings.
|
"""OIDC (OpenID Connect) login + admin-configurable provider settings.
|
||||||
|
|
||||||
The OIDC provider can be configured at runtime from the admin UI
|
Provider config (issuer / client_id / client_secret / redirect_uri /
|
||||||
(persisted in the oidc_settings table). A stored row's non-empty fields
|
scopes / post_login_redirect / admin_role / enabled) lives entirely in
|
||||||
override the OIDC_* env vars; env values act as bootstrap defaults.
|
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
|
Sign-in policy: an OIDC identity must already be bound to an hf user
|
||||||
(see PUT /users/{id}/oidc-binding). Unbound identities are rejected.
|
(see PUT /users/{id}/oidc-binding). Unbound identities are rejected.
|
||||||
@@ -51,27 +54,20 @@ class EffectiveOidc:
|
|||||||
|
|
||||||
|
|
||||||
def get_effective_oidc(db: Session) -> 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()
|
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:
|
if row is None:
|
||||||
return EffectiveOidc(
|
return EffectiveOidc(False, "", "", "", "", "", "", "admin")
|
||||||
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(
|
return EffectiveOidc(
|
||||||
bool(row.enabled),
|
bool(row.enabled),
|
||||||
pick(row.issuer, settings.OIDC_ISSUER),
|
row.issuer or "",
|
||||||
pick(row.client_id, settings.OIDC_CLIENT_ID),
|
row.client_id or "",
|
||||||
pick(row.client_secret, settings.OIDC_CLIENT_SECRET),
|
row.client_secret or "",
|
||||||
pick(row.redirect_uri, settings.OIDC_REDIRECT_URI),
|
row.redirect_uri or "",
|
||||||
pick(row.scopes, settings.OIDC_SCOPES),
|
row.scopes or "",
|
||||||
pick(row.post_login_redirect, settings.OIDC_POST_LOGIN_REDIRECT),
|
row.post_login_redirect or "",
|
||||||
pick(getattr(row, "admin_role", None), settings.OIDC_ADMIN_ROLE),
|
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()
|
row = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
|
||||||
cfg = get_effective_oidc(db)
|
cfg = get_effective_oidc(db)
|
||||||
return OidcSettingsOut(
|
return OidcSettingsOut(
|
||||||
enabled=bool(row.enabled) if row else bool(settings.OIDC_ENABLED),
|
enabled=bool(row.enabled) if row else False,
|
||||||
issuer=(row.issuer if row else None) or settings.OIDC_ISSUER or None,
|
issuer=(row.issuer if row else None) or None,
|
||||||
client_id=(row.client_id if row else None) or settings.OIDC_CLIENT_ID or None,
|
client_id=(row.client_id if row else None) or None,
|
||||||
has_client_secret=bool((row.client_secret if row else None) or settings.OIDC_CLIENT_SECRET),
|
has_client_secret=bool(row.client_secret if row else None),
|
||||||
redirect_uri=(row.redirect_uri if row else None) or settings.OIDC_REDIRECT_URI or None,
|
redirect_uri=(row.redirect_uri if row else None) or None,
|
||||||
scopes=(row.scopes if row else None) or settings.OIDC_SCOPES or None,
|
scopes=(row.scopes if row else None) or None,
|
||||||
post_login_redirect=(row.post_login_redirect if row else None) or settings.OIDC_POST_LOGIN_REDIRECT or None,
|
post_login_redirect=(row.post_login_redirect if row else None) or None,
|
||||||
admin_role=cfg.admin_role,
|
admin_role=cfg.admin_role,
|
||||||
oidc_only=bool(settings.HARBORFORGE_OIDC_ONLY),
|
oidc_only=bool(settings.HARBORFORGE_OIDC_ONLY),
|
||||||
effective_enabled=cfg.configured,
|
effective_enabled=cfg.configured,
|
||||||
source="db" if row else "env",
|
source="db",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -153,9 +153,27 @@ def _generate_project_code(db, name: str) -> str:
|
|||||||
|
|
||||||
@router.post("", response_model=schemas.ProjectResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=schemas.ProjectResponse, status_code=status.HTTP_201_CREATED)
|
||||||
def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
# Check if user is admin
|
# Project creation is gated by the `project.create` global permission
|
||||||
|
# (admin auto-grants by virtue of is_admin). Any role granted that perm
|
||||||
|
# via the Role Editor can create projects.
|
||||||
if not current_user.is_admin:
|
if not current_user.is_admin:
|
||||||
raise HTTPException(status_code=403, detail="Only admins can create projects")
|
from app.models.role_permission import Permission, RolePermission
|
||||||
|
has = (
|
||||||
|
db.query(Permission.id)
|
||||||
|
.join(RolePermission, RolePermission.permission_id == Permission.id)
|
||||||
|
.filter(
|
||||||
|
RolePermission.role_id == current_user.role_id,
|
||||||
|
Permission.name == "project.create",
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
if current_user.role_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if not has:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Permission denied: project.create required",
|
||||||
|
)
|
||||||
# Auto-fill owner_name from owner_id
|
# Auto-fill owner_name from owner_id
|
||||||
user = db.query(models.User).filter(models.User.id == project.owner_id).first()
|
user = db.query(models.User).filter(models.User.id == project.owner_id).first()
|
||||||
if not user:
|
if not user:
|
||||||
|
|||||||
@@ -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.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash
|
||||||
from app.core.config import get_db, settings
|
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 import models
|
||||||
from app.models.agent import Agent
|
from app.models.agent import Agent
|
||||||
from app.models.role_permission import Permission, Role, RolePermission
|
from app.models.role_permission import Permission, Role, RolePermission
|
||||||
@@ -39,7 +39,11 @@ def _user_response(user: models.User) -> dict:
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
def require_admin(current_user: models.User = Depends(get_current_user)):
|
def require_admin(current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||||
|
# Accept either OAuth2 JWT or X-API-Key (incl. Bearer-as-apikey fallback)
|
||||||
|
# so CLI clients using their provisioned api-key can hit admin-gated user
|
||||||
|
# routes (list / get / update / patch). The admin gate still reads
|
||||||
|
# User.is_admin — only the auth carrier broadens.
|
||||||
if not current_user.is_admin:
|
if not current_user.is_admin:
|
||||||
raise HTTPException(status_code=403, detail="Admin required")
|
raise HTTPException(status_code=403, detail="Admin required")
|
||||||
return current_user
|
return current_user
|
||||||
@@ -68,11 +72,29 @@ def require_account_creator(
|
|||||||
raise HTTPException(status_code=403, detail="Account creation permission required")
|
raise HTTPException(status_code=403, detail="Account creation permission required")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_user_role(db: Session, role_id: int | None) -> Role:
|
def _resolve_user_role(db: Session, role_id: int | None, *, is_agent: bool = False) -> Role:
|
||||||
|
"""Resolve target role for user creation.
|
||||||
|
|
||||||
|
Default policy when caller didn't pin role_id:
|
||||||
|
- is_agent (i.e. payload had agent_id/claw_identifier) → general-agent
|
||||||
|
- human user → guest
|
||||||
|
|
||||||
|
general-agent ≈ guest + user.reset-self-apikey so agents can rotate
|
||||||
|
their own API key without admin intervention. Created in
|
||||||
|
init_bootstrap.py on every startup; falls back to guest if absent
|
||||||
|
(e.g. very old DB that hasn't been re-seeded yet).
|
||||||
|
"""
|
||||||
if role_id is None:
|
if role_id is None:
|
||||||
|
default_name = "general-agent" if is_agent else "guest"
|
||||||
|
role = db.query(Role).filter(Role.name == default_name).first()
|
||||||
|
if not role and is_agent:
|
||||||
|
# general-agent missing from this DB → fall back to guest, log warn
|
||||||
role = db.query(Role).filter(Role.name == "guest").first()
|
role = db.query(Role).filter(Role.name == "guest").first()
|
||||||
if not role:
|
if not role:
|
||||||
raise HTTPException(status_code=500, detail="Default guest role is missing")
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Default role '{default_name}' is missing (DB not seeded)",
|
||||||
|
)
|
||||||
return role
|
return role
|
||||||
|
|
||||||
role = db.query(Role).filter(Role.id == role_id).first()
|
role = db.query(Role).filter(Role.id == role_id).first()
|
||||||
@@ -112,7 +134,7 @@ def create_user(
|
|||||||
if existing_agent:
|
if existing_agent:
|
||||||
raise HTTPException(status_code=400, detail="agent_id already in use")
|
raise HTTPException(status_code=400, detail="agent_id already in use")
|
||||||
|
|
||||||
assigned_role = _resolve_user_role(db, user.role_id)
|
assigned_role = _resolve_user_role(db, user.role_id, is_agent=has_agent_id)
|
||||||
# In OIDC-only mode, ignore any supplied password: the user is created
|
# In OIDC-only mode, ignore any supplied password: the user is created
|
||||||
# passwordless (cannot password-login) and is expected to sign in via a
|
# passwordless (cannot password-login) and is expected to sign in via a
|
||||||
# bound OIDC identity. API keys still work for such users.
|
# bound OIDC identity. API keys still work for such users.
|
||||||
@@ -391,7 +413,7 @@ def delete_user(
|
|||||||
if not deleted_user:
|
if not deleted_user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
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)
|
_reassign_user_references(db, user.id, deleted_user.id)
|
||||||
|
|||||||
10
app/cli/__init__.py
Normal file
10
app/cli/__init__.py
Normal file
@@ -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
|
||||||
|
"""
|
||||||
68
app/cli/__main__.py
Normal file
68
app/cli/__main__.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""hf-cli entry point. Dispatches to subject-specific modules."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def _load_all_models() -> None:
|
||||||
|
"""Import every model module so SQLAlchemy's declarative registry
|
||||||
|
resolves cross-table relationships (e.g. User.role, User.agent).
|
||||||
|
|
||||||
|
main.py's startup() does the same thing for the web server; the CLI
|
||||||
|
skips startup() but still queries User → would otherwise hit
|
||||||
|
`KeyError: 'Agent'` when SA tries to resolve relationship targets.
|
||||||
|
Keep this list in sync with main.py's startup import list.
|
||||||
|
"""
|
||||||
|
from app.models import ( # noqa: F401
|
||||||
|
models, webhook, apikey, activity, milestone, notification, worklog,
|
||||||
|
monitor, role_permission, task, support, meeting, proposal, propose,
|
||||||
|
essential, agent, calendar, minimum_workload, schedule_type,
|
||||||
|
schedule_type_special_slot, oidc_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_load_all_models()
|
||||||
|
|
||||||
|
|
||||||
|
USAGE = """Usage:
|
||||||
|
hf-cli admin create-user --email <e> [--username <u>] [--full-name <n>]
|
||||||
|
[--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 <url>] [--client-id <id>] [--client-secret <s>]
|
||||||
|
[--redirect-uri <url>] [--post-login-redirect <url>]
|
||||||
|
[--scopes "openid email profile"] [--admin-role <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())
|
||||||
269
app/cli/admin.py
Normal file
269
app/cli/admin.py
Normal file
@@ -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)
|
||||||
108
app/cli/config.py
Normal file
108
app/cli/config.py
Normal file
@@ -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)
|
||||||
@@ -1,34 +1,13 @@
|
|||||||
import os
|
"""Backend runtime settings — env-only (no wizard / no config volume).
|
||||||
import json
|
|
||||||
|
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 import create_engine
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from pydantic_settings import BaseSettings
|
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):
|
class Settings(BaseSettings):
|
||||||
@@ -38,19 +17,9 @@ class Settings(BaseSettings):
|
|||||||
ALGORITHM: str = "HS256"
|
ALGORITHM: str = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
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,
|
# When true: no password login at all. Password login endpoint rejects,
|
||||||
# user creation ignores any password (passwordless user that can only use
|
# user creation ignores any password (passwordless users that only sign
|
||||||
# API keys / OIDC), and the frontend hides all password UI.
|
# in via a bound OIDC identity / API keys), frontend hides password UI.
|
||||||
HARBORFORGE_OIDC_ONLY: bool = False
|
HARBORFORGE_OIDC_ONLY: bool = False
|
||||||
|
|
||||||
class Config:
|
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."
|
"Refusing to start with a default/short key."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Resolve DB URL: wizard config volume > env > default
|
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
||||||
_db_url = _resolve_db_url(settings.DATABASE_URL)
|
|
||||||
engine = create_engine(_db_url, pool_pre_ping=True)
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|||||||
286
app/init_bootstrap.py
Normal file
286
app/init_bootstrap.py
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
"""
|
||||||
|
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")
|
||||||
@@ -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")
|
|
||||||
44
app/main.py
44
app/main.py
@@ -42,24 +42,22 @@ def version():
|
|||||||
|
|
||||||
@app.get("/config/status", tags=["System"])
|
@app.get("/config/status", tags=["System"])
|
||||||
def config_status():
|
def config_status():
|
||||||
"""Check if HarborForge has been initialized (reads from config volume).
|
"""Has the deployment been bootstrapped (admin user exists)?
|
||||||
Frontend uses this instead of contacting the wizard directly."""
|
|
||||||
import os, json
|
Frontend hits this on mount to decide whether to show login or a
|
||||||
config_dir = os.getenv("CONFIG_DIR", "/config")
|
"no admin yet, run hf-cli admin create-user" placeholder. With the
|
||||||
config_file = os.getenv("CONFIG_FILE", "harborforge.json")
|
wizard removed in v0.4.0 the only deploy-time bootstrap step is the
|
||||||
config_path = os.path.join(config_dir, config_file)
|
operator running `docker exec hf-backend hf-cli admin create-user ...`
|
||||||
if not os.path.exists(config_path):
|
once; this endpoint just reports whether that has happened.
|
||||||
return {"initialized": False}
|
"""
|
||||||
|
from app.core.config import SessionLocal
|
||||||
|
from app.models import models
|
||||||
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
with open(config_path, "r") as f:
|
admin_count = db.query(models.User).filter(models.User.is_admin == True).count() # noqa: E712
|
||||||
cfg = json.load(f)
|
return {"initialized": admin_count > 0}
|
||||||
return {
|
finally:
|
||||||
"initialized": cfg.get("initialized", False),
|
db.close()
|
||||||
"backend_url": cfg.get("backend_url"),
|
|
||||||
"discord": cfg.get("discord") or {},
|
|
||||||
}
|
|
||||||
except Exception:
|
|
||||||
return {"initialized": False}
|
|
||||||
|
|
||||||
# Register routers
|
# Register routers
|
||||||
from app.api.routers.auth import router as auth_router
|
from app.api.routers.auth import router as auth_router
|
||||||
@@ -81,6 +79,7 @@ from app.api.routers.schedule_type import router as schedule_type_router
|
|||||||
from app.api.routers.schedule_type_special_slot import router as schedule_type_special_slot_router
|
from app.api.routers.schedule_type_special_slot import router as schedule_type_special_slot_router
|
||||||
from app.api.routers.calendar import router as calendar_router
|
from app.api.routers.calendar import router as calendar_router
|
||||||
from app.api.routers.oidc import router as oidc_router
|
from app.api.routers.oidc import router as oidc_router
|
||||||
|
from app.api.routers.knowledge import router as knowledge_router
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(oidc_router)
|
app.include_router(oidc_router)
|
||||||
@@ -101,6 +100,7 @@ app.include_router(essentials_router)
|
|||||||
app.include_router(schedule_type_router)
|
app.include_router(schedule_type_router)
|
||||||
app.include_router(schedule_type_special_slot_router)
|
app.include_router(schedule_type_special_slot_router)
|
||||||
app.include_router(calendar_router)
|
app.include_router(calendar_router)
|
||||||
|
app.include_router(knowledge_router)
|
||||||
|
|
||||||
|
|
||||||
# Auto schema migration for lightweight deployments
|
# Auto schema migration for lightweight deployments
|
||||||
@@ -490,15 +490,17 @@ def _sync_default_user_roles(db):
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def startup():
|
def startup():
|
||||||
from app.core.config import Base, engine, SessionLocal
|
from app.core.config import Base, engine, SessionLocal
|
||||||
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar, minimum_workload, schedule_type, schedule_type_special_slot, oidc_settings
|
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar, minimum_workload, schedule_type, schedule_type_special_slot, oidc_settings, knowledge
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
_migrate_schema()
|
_migrate_schema()
|
||||||
|
|
||||||
# Initialize from AbstractWizard (admin user, default project, etc.)
|
# Idempotent startup seed: permissions, default roles, built-in
|
||||||
from app.init_wizard import run_init
|
# 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()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
run_init(db)
|
run_bootstrap(db)
|
||||||
_sync_default_user_roles(db)
|
_sync_default_user_roles(db)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
101
app/models/knowledge.py
Normal file
101
app/models/knowledge.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""Knowledge Base models.
|
||||||
|
|
||||||
|
Mirrors the Project feature's shape (human-friendly *code*, creator FK,
|
||||||
|
created/updated timestamps). Hierarchy is:
|
||||||
|
|
||||||
|
knowledge_base
|
||||||
|
└─ knowledge_topic (unique per (topic, knowledge_base_id))
|
||||||
|
├─ knowledge_fact (category_id NULL → fact lives on the topic)
|
||||||
|
└─ knowledge_category (parent NULL → top-level category in topic)
|
||||||
|
├─ knowledge_fact
|
||||||
|
└─ knowledge_category (parent → nested)
|
||||||
|
|
||||||
|
`project_knowledge_base` is the M2M link between projects and knowledge bases.
|
||||||
|
|
||||||
|
Relationships are intentionally kept minimal (no ORM cascade on the
|
||||||
|
self-referential category tree); deletion ordering is handled explicitly in
|
||||||
|
the router to stay clear of FK-ordering surprises under MySQL.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.core.config import Base
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBase(Base):
|
||||||
|
__tablename__ = "knowledge_bases"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
knowledge_base_code = Column(String(16), unique=True, index=True, nullable=True)
|
||||||
|
title = Column(String(200), nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
last_updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
creator = relationship("User", foreign_keys=[created_by])
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeTopic(Base):
|
||||||
|
__tablename__ = "knowledge_topics"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("topic", "knowledge_base_id", name="uq_knowledge_topic_kb"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
topic = Column(String(200), nullable=False)
|
||||||
|
knowledge_base_id = Column(Integer, ForeignKey("knowledge_bases.id"), nullable=False, index=True)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
last_updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
creator = relationship("User", foreign_keys=[created_by])
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeCategory(Base):
|
||||||
|
__tablename__ = "knowledge_categories"
|
||||||
|
__table_args__ = (
|
||||||
|
# NOTE: MySQL treats NULLs as distinct in a UNIQUE index, so this only
|
||||||
|
# enforces uniqueness for non-NULL `parent`. Top-level categories
|
||||||
|
# (parent IS NULL) are de-duped in the router (application-level check).
|
||||||
|
UniqueConstraint("topic_id", "parent", "name", name="uq_knowledge_category_triple"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String(200), nullable=False)
|
||||||
|
parent = Column(Integer, ForeignKey("knowledge_categories.id"), nullable=True, index=True)
|
||||||
|
topic_id = Column(Integer, ForeignKey("knowledge_topics.id"), nullable=False, index=True)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
last_updated_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeFact(Base):
|
||||||
|
__tablename__ = "knowledge_facts"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
category_id = Column(Integer, ForeignKey("knowledge_categories.id"), nullable=True, index=True)
|
||||||
|
topic_id = Column(Integer, ForeignKey("knowledge_topics.id"), nullable=False, index=True)
|
||||||
|
fact = Column(Text, nullable=False)
|
||||||
|
last_updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectKnowledgeBase(Base):
|
||||||
|
__tablename__ = "project_knowledge_bases"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("project_id", "knowledge_base_id", name="uq_project_knowledge_base"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
|
||||||
|
knowledge_base_id = Column(Integer, ForeignKey("knowledge_bases.id"), nullable=False, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBaseCodeCounter(Base):
|
||||||
|
__tablename__ = "knowledge_base_code_counters"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
prefix = Column(String(16), unique=True, index=True, nullable=False)
|
||||||
|
next_value = Column(Integer, default=0)
|
||||||
166
app/schemas/knowledge.py
Normal file
166
app/schemas/knowledge.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"""Pydantic schemas for the Knowledge Base feature."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Knowledge Base
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
class KnowledgeBaseBase(BaseModel):
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBaseCreate(KnowledgeBaseBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBaseUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBaseResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
knowledge_base_code: Optional[str] = None
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
created_by: int
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
last_updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Topic
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
class KnowledgeTopicBase(BaseModel):
|
||||||
|
topic: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeTopicCreate(KnowledgeTopicBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeTopicUpdate(BaseModel):
|
||||||
|
topic: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeTopicResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
topic: str
|
||||||
|
knowledge_base_id: int
|
||||||
|
description: Optional[str] = None
|
||||||
|
created_by: int
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
last_updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Category
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
class KnowledgeCategoryBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeCategoryCreate(KnowledgeCategoryBase):
|
||||||
|
topic_id: int
|
||||||
|
parent: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeCategoryUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
parent: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeCategoryResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
parent: Optional[int] = None
|
||||||
|
topic_id: int
|
||||||
|
description: Optional[str] = None
|
||||||
|
created_by: Optional[int] = None
|
||||||
|
last_updated_by: Optional[int] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Fact
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
class KnowledgeFactBase(BaseModel):
|
||||||
|
fact: str
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeFactCreate(KnowledgeFactBase):
|
||||||
|
topic_id: int
|
||||||
|
category_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeFactUpdate(BaseModel):
|
||||||
|
fact: Optional[str] = None
|
||||||
|
category_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeFactResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
category_id: Optional[int] = None
|
||||||
|
topic_id: int
|
||||||
|
fact: str
|
||||||
|
last_updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Project <-> KnowledgeBase link
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
class ProjectKnowledgeBaseLink(BaseModel):
|
||||||
|
# Accept either a numeric id or a knowledge_base_code (mirrors how
|
||||||
|
# projects are referenced elsewhere).
|
||||||
|
knowledge_base: str
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Nested tree (read-only aggregate)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
class CategoryTreeNode(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
parent: Optional[int] = None
|
||||||
|
topic_id: int
|
||||||
|
description: Optional[str] = None
|
||||||
|
categories: List["CategoryTreeNode"] = []
|
||||||
|
facts: List[KnowledgeFactResponse] = []
|
||||||
|
|
||||||
|
|
||||||
|
class TopicTreeNode(BaseModel):
|
||||||
|
id: int
|
||||||
|
topic: str
|
||||||
|
knowledge_base_id: int
|
||||||
|
description: Optional[str] = None
|
||||||
|
categories: List[CategoryTreeNode] = []
|
||||||
|
facts: List[KnowledgeFactResponse] = []
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBaseTree(BaseModel):
|
||||||
|
id: int
|
||||||
|
knowledge_base_code: Optional[str] = None
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
topics: List[TopicTreeNode] = []
|
||||||
|
|
||||||
|
|
||||||
|
CategoryTreeNode.model_rebuild()
|
||||||
@@ -1,17 +1,25 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from app.services.harborforge_config import get_discord_wakeup_config
|
|
||||||
|
|
||||||
DISCORD_API_BASE = "https://discord.com/api/v10"
|
DISCORD_API_BASE = "https://discord.com/api/v10"
|
||||||
WAKEUP_CATEGORY_NAME = "HarborForge Wakeup"
|
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]:
|
def _headers(bot_token: str) -> dict[str, str]:
|
||||||
return {
|
return {
|
||||||
"Authorization": f"Bot {bot_token}",
|
"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]:
|
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")
|
guild_id = cfg.get("guild_id")
|
||||||
bot_token = cfg.get("bot_token")
|
bot_token = cfg.get("bot_token")
|
||||||
if not guild_id or not bot_token:
|
if not guild_id or not bot_token:
|
||||||
|
|||||||
@@ -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"),
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,5 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Wait for wizard config before starting uvicorn
|
# HarborForge backend entrypoint. All config comes from env vars (DATABASE_URL,
|
||||||
CONFIG_DIR="${CONFIG_DIR:-/config}"
|
# SECRET_KEY, HARBORFORGE_OIDC_ONLY, etc.). First-deploy admin user + OIDC
|
||||||
CONFIG_FILE="${CONFIG_FILE:-harborforge.json}"
|
# issuer config are operator-driven via `docker exec hf-backend hf-cli ...`.
|
||||||
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
|
|
||||||
|
|
||||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
|
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from fastapi.testclient import TestClient
|
|||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Patch the production engine/SessionLocal BEFORE importing app so that
|
# 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.
|
# in-memory SQLite database instead of trying to connect to MySQL.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
147
tests/test_knowledge_base.py
Normal file
147
tests/test_knowledge_base.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"""Knowledge Base API tests — CRUD, hierarchy, uniqueness, tree, links, RBAC."""
|
||||||
|
from tests.conftest import auth_header
|
||||||
|
|
||||||
|
|
||||||
|
def _create_kb(client, token, title="Infra Runbook", description="ops notes"):
|
||||||
|
r = client.post(
|
||||||
|
"/knowledge-bases",
|
||||||
|
json={"title": title, "description": description},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
class TestKnowledgeBaseCRUD:
|
||||||
|
def test_create_generates_code(self, client, seed):
|
||||||
|
kb = _create_kb(client, seed["admin_token"], title="Infra Runbook")
|
||||||
|
assert kb["title"] == "Infra Runbook"
|
||||||
|
assert kb["knowledge_base_code"] # auto-generated, non-empty
|
||||||
|
assert kb["created_by"] == seed["admin_user"].id
|
||||||
|
|
||||||
|
def test_create_requires_permission(self, client, seed):
|
||||||
|
# dev role has no knowledge-base.create
|
||||||
|
r = client.post(
|
||||||
|
"/knowledge-bases",
|
||||||
|
json={"title": "Nope"},
|
||||||
|
headers=auth_header(seed["dev_token"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
def test_get_by_id_and_code(self, client, seed):
|
||||||
|
kb = _create_kb(client, seed["admin_token"])
|
||||||
|
by_id = client.get(f"/knowledge-bases/{kb['id']}", headers=auth_header(seed["admin_token"]))
|
||||||
|
by_code = client.get(f"/knowledge-bases/{kb['knowledge_base_code']}", headers=auth_header(seed["admin_token"]))
|
||||||
|
assert by_id.status_code == 200 and by_code.status_code == 200
|
||||||
|
assert by_id.json()["id"] == by_code.json()["id"] == kb["id"]
|
||||||
|
|
||||||
|
def test_update_and_list(self, client, seed):
|
||||||
|
kb = _create_kb(client, seed["admin_token"])
|
||||||
|
r = client.patch(
|
||||||
|
f"/knowledge-bases/{kb['id']}",
|
||||||
|
json={"description": "updated"},
|
||||||
|
headers=auth_header(seed["admin_token"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200 and r.json()["description"] == "updated"
|
||||||
|
lst = client.get("/knowledge-bases", headers=auth_header(seed["admin_token"]))
|
||||||
|
assert lst.status_code == 200 and any(k["id"] == kb["id"] for k in lst.json())
|
||||||
|
|
||||||
|
def test_delete_cascades(self, client, seed):
|
||||||
|
token = seed["admin_token"]
|
||||||
|
kb = _create_kb(client, token)
|
||||||
|
topic = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "Net"}, headers=auth_header(token)).json()
|
||||||
|
client.post("/knowledge-facts", json={"topic_id": topic["id"], "fact": "x"}, headers=auth_header(token))
|
||||||
|
r = client.delete(f"/knowledge-bases/{kb['id']}", headers=auth_header(token))
|
||||||
|
assert r.status_code == 204
|
||||||
|
assert client.get(f"/knowledge-bases/{kb['id']}", headers=auth_header(token)).status_code == 404
|
||||||
|
assert client.get(f"/knowledge-topics/{topic['id']}", headers=auth_header(token)).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestHierarchy:
|
||||||
|
def test_topic_unique_per_kb(self, client, seed):
|
||||||
|
token = seed["admin_token"]
|
||||||
|
kb = _create_kb(client, token)
|
||||||
|
r1 = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "Routing"}, headers=auth_header(token))
|
||||||
|
r2 = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "Routing"}, headers=auth_header(token))
|
||||||
|
assert r1.status_code == 201 and r2.status_code == 400
|
||||||
|
|
||||||
|
def test_category_triple_unique_and_nesting(self, client, seed):
|
||||||
|
token = seed["admin_token"]
|
||||||
|
kb = _create_kb(client, token)
|
||||||
|
topic = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "T"}, headers=auth_header(token)).json()
|
||||||
|
# top-level category
|
||||||
|
c1 = client.post("/knowledge-categories", json={"topic_id": topic["id"], "name": "DNS"}, headers=auth_header(token))
|
||||||
|
assert c1.status_code == 201
|
||||||
|
# duplicate top-level (parent NULL) rejected at app level
|
||||||
|
dup = client.post("/knowledge-categories", json={"topic_id": topic["id"], "name": "DNS"}, headers=auth_header(token))
|
||||||
|
assert dup.status_code == 400
|
||||||
|
# nested category with same name under different parent is allowed
|
||||||
|
child = client.post(
|
||||||
|
"/knowledge-categories",
|
||||||
|
json={"topic_id": topic["id"], "name": "DNS", "parent": c1.json()["id"]},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert child.status_code == 201
|
||||||
|
|
||||||
|
def test_no_cycle_on_reparent(self, client, seed):
|
||||||
|
token = seed["admin_token"]
|
||||||
|
kb = _create_kb(client, token)
|
||||||
|
topic = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "T"}, headers=auth_header(token)).json()
|
||||||
|
a = client.post("/knowledge-categories", json={"topic_id": topic["id"], "name": "A"}, headers=auth_header(token)).json()
|
||||||
|
b = client.post("/knowledge-categories", json={"topic_id": topic["id"], "name": "B", "parent": a["id"]}, headers=auth_header(token)).json()
|
||||||
|
# try to move A under its descendant B -> rejected
|
||||||
|
r = client.patch(f"/knowledge-categories/{a['id']}", json={"parent": b["id"]}, headers=auth_header(token))
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
def test_tree_shape(self, client, seed):
|
||||||
|
token = seed["admin_token"]
|
||||||
|
kb = _create_kb(client, token)
|
||||||
|
topic = client.post(f"/knowledge-bases/{kb['id']}/topics", json={"topic": "T"}, headers=auth_header(token)).json()
|
||||||
|
cat = client.post("/knowledge-categories", json={"topic_id": topic["id"], "name": "C"}, headers=auth_header(token)).json()
|
||||||
|
# fact directly on topic
|
||||||
|
client.post("/knowledge-facts", json={"topic_id": topic["id"], "fact": "topic-fact"}, headers=auth_header(token))
|
||||||
|
# fact under category
|
||||||
|
client.post("/knowledge-facts", json={"topic_id": topic["id"], "category_id": cat["id"], "fact": "cat-fact"}, headers=auth_header(token))
|
||||||
|
|
||||||
|
tree = client.get(f"/knowledge-bases/{kb['id']}/tree", headers=auth_header(token)).json()
|
||||||
|
assert len(tree["topics"]) == 1
|
||||||
|
t = tree["topics"][0]
|
||||||
|
assert [f["fact"] for f in t["facts"]] == ["topic-fact"]
|
||||||
|
assert len(t["categories"]) == 1
|
||||||
|
assert [f["fact"] for f in t["categories"][0]["facts"]] == ["cat-fact"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestProjectLinks:
|
||||||
|
def test_link_unlink(self, client, seed):
|
||||||
|
token = seed["admin_token"]
|
||||||
|
kb = _create_kb(client, token)
|
||||||
|
# link by code
|
||||||
|
r = client.post(
|
||||||
|
"/projects/TPRJ/knowledge-bases",
|
||||||
|
json={"knowledge_base": kb["knowledge_base_code"]},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
linked = client.get("/projects/TPRJ/knowledge-bases", headers=auth_header(token)).json()
|
||||||
|
assert any(k["id"] == kb["id"] for k in linked)
|
||||||
|
# filter list by project
|
||||||
|
filtered = client.get(f"/knowledge-bases?project=TPRJ", headers=auth_header(token)).json()
|
||||||
|
assert any(k["id"] == kb["id"] for k in filtered)
|
||||||
|
# unlink
|
||||||
|
r = client.delete(f"/projects/TPRJ/knowledge-bases/{kb['id']}", headers=auth_header(token))
|
||||||
|
assert r.status_code == 204
|
||||||
|
linked = client.get("/projects/TPRJ/knowledge-bases", headers=auth_header(token)).json()
|
||||||
|
assert not any(k["id"] == kb["id"] for k in linked)
|
||||||
|
|
||||||
|
def test_link_is_idempotent(self, client, seed):
|
||||||
|
token = seed["admin_token"]
|
||||||
|
kb = _create_kb(client, token)
|
||||||
|
for _ in range(2):
|
||||||
|
r = client.post(
|
||||||
|
"/projects/TPRJ/knowledge-bases",
|
||||||
|
json={"knowledge_base": str(kb["id"])},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
linked = client.get("/projects/TPRJ/knowledge-bases", headers=auth_header(token)).json()
|
||||||
|
assert sum(1 for k in linked if k["id"] == kb["id"]) == 1
|
||||||
Reference in New Issue
Block a user