17 Commits

Author SHA1 Message Date
90b494f097 Merge security/critical-auth-fixes into main
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:55:59 +01:00
e7d3cbe07b docs: README accuracy pass + Security section
Document the auth/RBAC/SSRF hardening in this branch: mandatory strong
SECRET_KEY (server refuses weak/default), admin-only + masked /api-keys,
admin-only /webhooks with SSRF guard, project role hierarchy, and auth
added to previously-open endpoints. Fixed stale Issues→tasks model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:50:25 +01:00
51fb8ca073 fix(security): close critical auth/SSRF/RBAC holes
Verified locally end-to-end (before: exploitable, after: blocked).

- config: refuse to start on weak/default/short SECRET_KEY (was
  trivially forgeable JWT -> full admin)
- deps: add reusable require_admin dependency (JWT or API key)
- api-keys: require admin to mint/list/revoke; mask key on list
  (was unauthenticated -> instant admin API key)
- webhooks: whole router now admin-only (was fully unauthenticated
  CRUD + readable logs)
- webhook delivery: validate URL scheme + reject hosts resolving to
  private/loopback/link-local/reserved IPs; disable redirects
  (was a readable SSRF primitive)
- rbac: implement a real project-role hierarchy in check_project_role
  (was a no-op: any member, even guest, passed admin/mgr gates)
- misc: auth on delete_milestone (+ensure_can_edit_milestone),
  worklog create/delete (force caller user_id, owner-only delete),
  /activity and /export/tasks (were unauthenticated data exposure)
- tasks: auth + ensure_can_edit_task on assign_task and batch_assign

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:53:14 +01:00
h z
1cb924451b Merge pull request 'zhi-2026-04-18' (#16) from zhi-2026-04-18 into main
Reviewed-on: #16
2026-05-01 07:24:35 +00:00
h z
c011e334a0 Merge branch 'main' into zhi-2026-04-18 2026-05-01 07:24:28 +00:00
zhi
d52861fd9c feat: schedule type system for work/entertainment periods
- New model: ScheduleType (name, work_from/to, entertainment_from/to)
- Agent.schedule_type_id FK to schedule_types
- CRUD API: GET/POST/PATCH/DELETE /schedule-types/
- Agent assignment: PUT /schedule-types/agent/{agent_id}/assign
- Agent self-query: GET /schedule-types/agent/me
- Permissions: schedule_type.read, schedule_type.manage
- Migration: adds schedule_type_id column to agents table

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 09:20:51 +00:00
zhi
3aa6dd2d6e feat: add /calendar/sync endpoint for multi-agent schedule sync
Returns today's slots for all agents on a claw instance, keyed by
agent_id. Used by HF Plugin to maintain a local schedule cache
instead of per-agent heartbeat.

Also records heartbeat for all agents on the instance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 09:30:57 +00:00
c3199d0cd0 fix: Essential model uses created_by_id not user_id
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 23:17:32 +01:00
d3f72962c0 fix: correct ActivityLog import name in user deletion
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 23:15:45 +01:00
4643a73c60 feat: add deleted-user builtin and safe user deletion
- Add deleted-user as a built-in account (no permissions, cannot log in)
  created during init_wizard, protected from deletion like acc-mgr
- On user delete, reassign all foreign key references to deleted-user
  then delete the original user, instead of failing on IntegrityError
- API keys, notifications, and project memberships are deleted outright
  since they're meaningless without the real user

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 23:08:19 +01:00
h z
eae947d9b6 Merge pull request 'feat(Dockerfile): multi-stage build to reduce image size from 852MB to ~200MB' (#15) from multi-stage into main
Reviewed-on: #15
2026-04-16 21:23:04 +00:00
h z
a2f626557e Merge branch 'main' into multi-stage 2026-04-16 21:22:54 +00:00
h z
c5827db872 Merge pull request 'dev-2026-03-29' (#14) from dev-2026-03-29 into main
Reviewed-on: #14
2026-04-16 21:22:03 +00:00
7326cadfec feat: grant user.reset-apikey permission to account-manager role
Allows acc-mgr to reset user API keys, enabling automated
provisioning workflows via the CLI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:19:13 +00:00
1b10c97099 feat: allow API key auth for reset-apikey endpoint
Change dependency from get_current_user (OAuth2 only) to
get_current_user_or_apikey, enabling account-manager API key
to reset user API keys for provisioning workflows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:17:13 +00:00
8434a5d226 feat(Dockerfile): multi-stage build to reduce image size from 852MB to ~200MB
Stage 1 (builder): install build deps and pre-download wheels
Stage 2 (runtime): copy only installed packages + runtime deps, no build tools
2026-04-15 01:27:44 +00:00
h z
a2ab541b73 Merge pull request 'HarborForge.Backend: dev-2026-03-29 -> main' (#13) from dev-2026-03-29 into main
Reviewed-on: #13
2026-04-05 22:08:14 +00:00
41 changed files with 368 additions and 3853 deletions

View File

@@ -1,34 +1,11 @@
# HarborForge Backend Environment Variables (v0.4.0+ — wizard removed)
# HarborForge Environment Variables
# --- Database (used by both the mysql container and the backend) -----------
# Database
MYSQL_ROOT_PASSWORD=harborforge_root
MYSQL_DATABASE=harborforge
MYSQL_USER=harborforge
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 ----------------------------------------------------------
# Must be 32+ chars and not a placeholder; use: openssl rand -hex 32
# Application
SECRET_KEY=change-me-use-openssl-rand-hex-32
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

View File

@@ -42,17 +42,5 @@ COPY requirements.txt ./
COPY 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
# ignores passwords (passwordless users that sign in via a bound OIDC
# identity / API keys). Overridable at runtime via the same env var.
ARG HARBORFORGE_OIDC_ONLY=false
ENV HARBORFORGE_OIDC_ONLY=${HARBORFORGE_OIDC_ONLY}
EXPOSE 8000
ENTRYPOINT ["./entrypoint.sh"]

View File

@@ -1,6 +1,4 @@
"""Shared auth dependencies."""
import hashlib
import logging
from datetime import datetime, timedelta
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, APIKeyHeader
@@ -13,8 +11,6 @@ from app.core.config import get_db, settings
from app.models import models
from app.models.apikey import APIKey
logger = logging.getLogger("harborforge.deps")
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token", auto_error=False)
apikey_header = APIKeyHeader(name="X-API-Key", auto_error=False)
@@ -63,69 +59,22 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De
return user
def hash_api_key(raw: str) -> str:
"""SHA-256 of a raw API key. Keys are high-entropy random tokens, so a
fast hash (not bcrypt) is appropriate and allows O(1) lookup by hash."""
return hashlib.sha256(raw.encode()).hexdigest()
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_hash == hash_api_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(
token: str = Depends(oauth2_scheme),
api_key: str = Depends(apikey_header),
db: Session = Depends(get_db)
):
"""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).
Bearer tokens are tried in order: local HS256 JWT → external Tessera
(OIDC) RS256 access token → API key. The Tessera path is purely additive
and never affects local-JWT/API-key callers.
"""
# Native X-API-Key header
"""Authenticate via JWT token OR API key."""
if api_key:
user = _lookup_api_key(db, api_key)
if user:
return user
# Bearer header — local JWT first, then Tessera, then API key.
key_obj = db.query(APIKey).filter(APIKey.key == api_key, APIKey.is_active == True).first()
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:
return user
if token:
try:
return await get_current_user(token=token, db=db)
except HTTPException:
pass
# External Tessera (OIDC) RS256 access token.
try:
from app.api.tessera import authenticate_tessera
return authenticate_tessera(db, token)
except HTTPException:
pass
except Exception: # JWKS fetch / unexpected verifier error → don't 500
logger.warning("Tessera token verification error", exc_info=True)
# Bearer-carried API key (CLI convenience).
user = _lookup_api_key(db, token)
if user:
return user
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
return await get_current_user(token=token, db=db)
raise HTTPException(status_code=401, detail="Not authenticated")

View File

@@ -11,15 +11,13 @@ from app.core.config import get_db, settings
from app.models import models
from app.models.role_permission import Permission, Role, RolePermission
from app.schemas import schemas
from app.api.deps import Token, verify_password, create_access_token, get_current_user, get_current_user_or_apikey
from app.api.deps import Token, verify_password, create_access_token, get_current_user
router = APIRouter(prefix="/auth", tags=["Auth"])
@router.post("/token", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
if settings.HARBORFORGE_OIDC_ONLY:
raise HTTPException(status_code=403, detail="Password login is disabled (OIDC only)")
user = db.query(models.User).filter(models.User.username == form_data.username).first()
if not user or not verify_password(form_data.password, user.hashed_password or ""):
raise HTTPException(status_code=401, detail="Incorrect username or password",
@@ -80,7 +78,7 @@ class PermissionIntrospectionResponse(BaseModel):
@router.get("/me/permissions", response_model=PermissionIntrospectionResponse)
async def get_my_permissions(
current_user: models.User = Depends(get_current_user_or_apikey),
current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Return the current user's effective permissions for CLI help introspection."""

View File

@@ -21,11 +21,6 @@ from app.core.config import get_db
from app.models.calendar import SchedulePlan, SlotStatus, SlotType, TimeSlot
from app.models.models import User
from app.models.agent import Agent, AgentStatus, ExhaustReason
from app.models.schedule_type import ScheduleType
from app.services.special_slot_materialiser import (
materialise_special_slots_for_claw,
materialise_special_slots_for_user,
)
from app.schemas.calendar import (
AgentHeartbeatResponse,
AgentStatusUpdateRequest,
@@ -52,7 +47,6 @@ from app.schemas.calendar import (
)
from app.services.agent_heartbeat import get_pending_slots_for_agent
from app.services.agent_status import (
AgentStatusError,
record_heartbeat,
transition_to_busy,
transition_to_idle,
@@ -149,8 +143,6 @@ def _slot_to_response(slot: TimeSlot) -> TimeSlotResponse:
priority=slot.priority,
status=slot.status.value if hasattr(slot.status, "value") else str(slot.status),
plan_id=slot.plan_id,
is_admin_locked=bool(getattr(slot, "is_admin_locked", False)),
special_slot_id=getattr(slot, "special_slot_id", None),
created_at=slot.created_at,
updated_at=slot.updated_at,
)
@@ -176,48 +168,6 @@ def create_slot(
"""
target_date = payload.date or date_type.today()
# --- Maintenance-window guard ---
# Non-`system` slots may not be placed inside the schedule_type's
# 1-hour maintenance window. The window is admin-territory, reserved
# for materialised special slots from `schedule_type_special_slots`.
# `system` slot_type is itself reserved server-side (the materialiser
# is the only legitimate caller) — refuse it here outright so the
# public API cannot manufacture a fake admin-locked slot.
if payload.slot_type == SlotType.SYSTEM:
raise HTTPException(
status_code=422,
detail=(
"slot_type='system' is reserved for schedule_type special slots "
"and cannot be created via this endpoint"
),
)
_agent_for_user = (
db.query(Agent).filter(Agent.user_id == current_user.id).first()
)
if _agent_for_user and _agent_for_user.schedule_type_id:
st = (
db.query(ScheduleType)
.filter(ScheduleType.id == _agent_for_user.schedule_type_id)
.first()
)
if st and _scheduled_inside_window(
payload.scheduled_at,
payload.estimated_duration,
st.maintenance_from,
st.maintenance_to,
):
mf_h, mf_m = divmod(st.maintenance_from, 60)
mt_h, mt_m = divmod(st.maintenance_to, 60)
raise HTTPException(
status_code=422,
detail=(
f"slot at {payload.scheduled_at} duration {payload.estimated_duration}min "
f"intersects the maintenance window "
f"{mf_h:02d}:{mf_m:02d}-{mt_h:02d}:{mt_m:02d} UTC of "
f"schedule_type '{st.name}' — that window is admin-reserved"
),
)
# --- Overlap check (hard reject) ---
conflicts = check_overlap_for_create(
db,
@@ -286,8 +236,6 @@ def _real_slot_to_item(slot: TimeSlot) -> CalendarSlotItem:
priority=slot.priority,
status=slot.status.value if hasattr(slot.status, "value") else str(slot.status),
plan_id=slot.plan_id,
is_admin_locked=bool(getattr(slot, "is_admin_locked", False)),
special_slot_id=getattr(slot, "special_slot_id", None),
created_at=slot.created_at,
updated_at=slot.updated_at,
)
@@ -341,47 +289,7 @@ def _require_agent(db: Session, agent_id: str, claw_identifier: str) -> Agent:
return agent
def _scheduled_inside_window(
scheduled_at,
estimated_duration_minutes: int,
window_from_min: int,
window_to_min: int,
) -> bool:
"""True if [scheduled_at, scheduled_at+duration] intersects [from, to).
Window bounds are minutes-since-UTC-midnight (0-1439). Handles the
case where the window crosses UTC midnight (e.g. 23:30→01:00).
"""
start_min = scheduled_at.hour * 60 + scheduled_at.minute
end_min = start_min + max(estimated_duration_minutes, 1)
if window_to_min > window_from_min:
# normal same-day window
return start_min < window_to_min and end_min > window_from_min
# wrap-around: window = [from..1440) [0..to)
return (start_min < 1440 and end_min > window_from_min) or end_min > window_to_min
# Admin-locked special slots accept only these agent-driven status
# transitions; movement / cancellation / arbitrary status edits are
# rejected because the schedule_type owner is the source of truth.
_ADMIN_LOCKED_ALLOWED_STATUSES = {
SlotStatusEnum.ONGOING,
SlotStatusEnum.PAUSED,
SlotStatusEnum.FINISHED,
SlotStatusEnum.ABORTED,
}
def _apply_agent_slot_update(slot: TimeSlot, payload: SlotAgentUpdate) -> None:
if getattr(slot, "is_admin_locked", False):
if payload.status not in _ADMIN_LOCKED_ALLOWED_STATUSES:
raise HTTPException(
status_code=423,
detail=(
f"slot {slot.id} is admin-locked (special slot); only "
f"ongoing/paused/finished/aborted are allowed via agent-update"
),
)
slot.status = payload.status.value
if payload.started_at is not None:
slot.started_at = payload.started_at
@@ -469,12 +377,6 @@ def sync_schedules(
"""
today = date_type.today()
# Materialise today's special slots for every agent on this claw
# before reading. This is idempotent — re-runs against an already-
# materialised (agent, date, template) are no-ops. Plugin's runSync
# picks them up like any other slot via the normal real_slots query.
materialise_special_slots_for_claw(db, x_claw_identifier, today, commit=True)
# Find all agents on this claw instance
agents = (
db.query(Agent)
@@ -562,29 +464,6 @@ def agent_update_virtual_slot(
return TimeSlotEditResponse(slot=_slot_to_response(slot), warnings=[])
@router.get(
"/agent/status",
summary="Read an agent's current runtime status (no side effects)",
)
def get_agent_status(
agent_id: str = Query(..., description="Target agent_id"),
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
db: Session = Depends(get_db),
):
"""Return `{agent_id, status}` so callers (Fabric.OpenclawPlugin's
triage on-call gate, etc.) can decide whether the agent is currently
eligible without flipping their state.
No-op for unknown agents — returns 404 with `{detail: 'Agent not
found'}` so the caller can decide whether to fail-open or fail-closed.
"""
agent = _require_agent(db, agent_id, x_claw_identifier)
return {
"agent_id": agent.agent_id,
"status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status),
}
@router.post(
"/agent/status",
summary="Update agent runtime status from plugin",
@@ -595,31 +474,19 @@ def update_agent_status(
):
agent = _require_agent(db, payload.agent_id, payload.claw_identifier)
target = (payload.status or '').lower().strip()
# Idempotent same-state transition: a 'busy → busy' request is a
# no-op rather than a 500. Lets plugin status gates / cli `--set`
# be safe to fire-and-forget without first reading current state.
current = agent.status.value if hasattr(agent.status, 'value') else str(agent.status)
if current == target:
return {"ok": True, "agent_id": agent.agent_id, "status": current, "no_change": True}
try:
if target == AgentStatus.IDLE.value:
transition_to_idle(db, agent)
elif target == AgentStatus.BUSY.value:
transition_to_busy(db, agent, slot_type=SlotType.WORK)
elif target == AgentStatus.ON_CALL.value:
transition_to_busy(db, agent, slot_type=SlotType.ON_CALL)
elif target == AgentStatus.OFFLINE.value:
transition_to_offline(db, agent)
elif target == AgentStatus.EXHAUSTED.value:
reason = ExhaustReason.BILLING if payload.exhaust_reason == 'billing' else ExhaustReason.RATE_LIMIT
transition_to_exhausted(db, agent, reason=reason, recovery_at=payload.recovery_at)
else:
raise HTTPException(status_code=400, detail="Unsupported agent status")
except AgentStatusError as e:
# State-machine violation (e.g. busy → busy via wrong precondition)
# → 409 with the rejected transition explained, instead of a 500.
db.rollback()
raise HTTPException(status_code=409, detail=str(e))
if target == AgentStatus.IDLE.value:
transition_to_idle(db, agent)
elif target == AgentStatus.BUSY.value:
transition_to_busy(db, agent, slot_type=SlotType.WORK)
elif target == AgentStatus.ON_CALL.value:
transition_to_busy(db, agent, slot_type=SlotType.ON_CALL)
elif target == AgentStatus.OFFLINE.value:
transition_to_offline(db, agent)
elif target == AgentStatus.EXHAUSTED.value:
reason = ExhaustReason.BILLING if payload.exhaust_reason == 'billing' else ExhaustReason.RATE_LIMIT
transition_to_exhausted(db, agent, reason=reason, recovery_at=payload.recovery_at)
else:
raise HTTPException(status_code=400, detail="Unsupported agent status")
db.commit()
return {"ok": True, "agent_id": agent.agent_id, "status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status)}
@@ -647,10 +514,6 @@ def get_calendar_day(
"""
target_date = date or date_type.today()
# Materialise today's special slots for this user before reading,
# so the day-view returns them alongside any user-created slots.
materialise_special_slots_for_user(db, current_user.id, target_date, commit=True)
# 1. Fetch real slots for the day
real_slots = (
db.query(TimeSlot)
@@ -726,20 +589,6 @@ def edit_real_slot(
if slot is None:
raise HTTPException(status_code=404, detail="Slot not found")
# --- Admin-locked guard ---
# Special slots materialised from a schedule_type template are
# admin-owned; agents may complete/abort/pause/resume via the
# plugin-facing agent-update endpoint but cannot edit time/type/
# duration/event-data via this user-facing edit endpoint.
if getattr(slot, "is_admin_locked", False):
raise HTTPException(
status_code=423,
detail=(
f"slot {slot.id} is admin-locked (materialised from a special "
f"slot template); only the schedule_type owner can edit it"
),
)
# --- Past-slot guard ---
try:
guard_edit_real_slot(db, slot)
@@ -907,16 +756,6 @@ def cancel_real_slot(
if slot is None:
raise HTTPException(status_code=404, detail="Slot not found")
# --- Admin-locked guard ---
if getattr(slot, "is_admin_locked", False):
raise HTTPException(
status_code=423,
detail=(
f"slot {slot.id} is admin-locked (materialised from a special "
f"slot template); only the schedule_type owner can cancel it"
),
)
# --- Past-slot guard ---
try:
guard_cancel_real_slot(db, slot)

View File

@@ -32,12 +32,8 @@ def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
check_project_role(db, current_user.id, task.project_id, min_role="viewer")
# Always attribute the comment to the authenticated caller — never trust
# a client-supplied author_id (prevents author spoofing).
data = comment.model_dump()
data.pop("author_id", None)
db_comment = models.Comment(**data, author_id=current_user.id)
db_comment = models.Comment(**comment.model_dump())
db.add(db_comment)
db.commit()
db.refresh(db_comment)

View File

@@ -1,735 +0,0 @@
"""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

View File

@@ -12,7 +12,7 @@ from sqlalchemy import func as sqlfunc
from pydantic import BaseModel
from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey, require_admin, hash_api_key
from app.api.deps import get_current_user_or_apikey, require_admin
from app.api.rbac import check_project_role, ensure_can_edit_milestone
from app.models import models
from app.models.apikey import APIKey
@@ -49,8 +49,7 @@ class APIKeyCreate(BaseModel):
class APIKeyResponse(BaseModel):
id: int
key: str | None = None # full secret — only populated on create/reset
key_prefix: str | None = None # masked display for listings
key: str
name: str
user_id: int
is_active: bool
@@ -67,16 +66,11 @@ def create_api_key(data: APIKeyCreate, db: Session = Depends(get_db),
if not user:
raise HTTPException(status_code=404, detail="User not found")
key = secrets.token_hex(32)
db_key = APIKey(key_hash=hash_api_key(key), key_prefix=key[:8], name=data.name, user_id=data.user_id)
db_key = APIKey(key=key, name=data.name, user_id=data.user_id)
db.add(db_key)
db.commit()
db.refresh(db_key)
# Return the raw key once (it is never stored or shown again).
return {
"id": db_key.id, "key": key, "key_prefix": db_key.key_prefix,
"name": db_key.name, "user_id": db_key.user_id, "is_active": db_key.is_active,
"created_at": db_key.created_at, "last_used_at": db_key.last_used_at,
}
return db_key
@router.get("/api-keys", response_model=List[APIKeyResponse], tags=["API Keys"])
@@ -86,14 +80,11 @@ def list_api_keys(user_id: int = None, db: Session = Depends(get_db),
if user_id:
query = query.filter(APIKey.user_id == user_id)
keys = query.all()
# Never expose the secret on listing — the raw key isn't stored. Show only
# the masked prefix.
return [{
"id": k.id, "key": None,
"key_prefix": (k.key_prefix + "") if k.key_prefix else None,
"name": k.name, "user_id": k.user_id, "is_active": k.is_active,
"created_at": k.created_at, "last_used_at": k.last_used_at,
} for k in keys]
# Never expose the full secret on listing; show only a masked prefix.
for k in keys:
if k.key and len(k.key) > 8:
k.key = k.key[:6] + "" + k.key[-2:]
return keys
@router.delete("/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["API Keys"])
@@ -141,10 +132,7 @@ def list_activity(entity_type: str = None, entity_id: int = None, user_id: int =
def create_milestone(ms: schemas.MilestoneCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
import json
project = db.query(models.Project).filter(models.Project.id == ms.project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="mgr")
project_code = project.project_code if project.project_code else f"P{ms.project_id}"
project_code = project.project_code if project and project.project_code else f"P{ms.project_id}"
max_ms = db.query(MilestoneModel).filter(MilestoneModel.project_id == ms.project_id).order_by(MilestoneModel.id.desc()).first()
next_num = (max_ms.id + 1) if max_ms else 1
@@ -500,15 +488,14 @@ def create_milestone_task(project_code: str, milestone_id: str, task_data: dict,
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="dev")
ms = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
if ms.status and hasattr(ms.status, "value") and ms.status.value == "undergoing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is undergoing")
milestone_code = ms.milestone_code or f"m{ms.id}"
max_task = db.query(Task).filter(Task.milestone_id == ms.id).order_by(Task.id.desc()).first()
next_num = (max_task.id + 1) if max_task else 1
@@ -635,7 +622,6 @@ def create_support(project_code: str, milestone_id: str, support_data: dict, db:
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="dev")
ms = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first()
if not ms:
@@ -782,15 +768,14 @@ def create_meeting(project_code: str, milestone_id: str, meeting_data: dict, db:
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="dev")
ms = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == milestone_id, MilestoneModel.project_id == project.id).first()
if not ms:
raise HTTPException(status_code=404, detail="Milestone not found")
if ms.status and hasattr(ms.status, "value") and ms.status.value == "undergoing":
raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is undergoing")
milestone_code = ms.milestone_code or f"m{ms.id}"
max_meeting = db.query(Meeting).filter(Meeting.milestone_id == ms.id).order_by(Meeting.id.desc()).first()
next_num = (max_meeting.id + 1) if max_meeting else 1

View File

@@ -1,346 +0,0 @@
"""OIDC (OpenID Connect) login + admin-configurable provider settings.
Provider config (issuer / client_id / client_secret / redirect_uri /
scopes / post_login_redirect / admin_role / enabled) lives entirely in
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
(see PUT /users/{id}/oidc-binding). Unbound identities are rejected.
"""
import logging
from datetime import timedelta
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.config import get_db, settings
from app.models import models
from app.models.oidc_settings import OidcSettings
from app.api.deps import create_access_token, get_current_user, get_current_user_or_apikey
router = APIRouter(prefix="/auth", tags=["Auth"])
logger = logging.getLogger("harborforge.oidc")
# ---- effective config (DB row overrides env) ------------------------------
class EffectiveOidc:
def __init__(self, enabled, issuer, client_id, client_secret,
redirect_uri, scopes, post_login_redirect, admin_role):
self.enabled = enabled
self.issuer = issuer
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.scopes = scopes or "openid email profile"
self.post_login_redirect = post_login_redirect
self.admin_role = (admin_role or "admin").strip() or "admin"
@property
def configured(self) -> bool:
return bool(self.enabled and self.issuer and self.client_id)
def fingerprint(self) -> str:
return "|".join([
str(self.enabled), self.issuer or "", self.client_id or "",
self.client_secret or "", self.redirect_uri or "", self.scopes or "",
])
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()
if row is None:
return EffectiveOidc(False, "", "", "", "", "", "", "admin")
return EffectiveOidc(
bool(row.enabled),
row.issuer or "",
row.client_id or "",
row.client_secret or "",
row.redirect_uri or "",
row.scopes or "",
row.post_login_redirect or "",
getattr(row, "admin_role", None) or "admin",
)
# Authlib client cache, rebuilt when the effective config changes.
_oauth = None
_oauth_fp = None
def _client(cfg: EffectiveOidc):
global _oauth, _oauth_fp
if not cfg.configured:
raise HTTPException(status_code=503, detail="OIDC is not configured")
fp = cfg.fingerprint()
if _oauth is None or _oauth_fp != fp:
from authlib.integrations.starlette_client import OAuth
oauth = OAuth()
oauth.register(
name="oidc",
server_metadata_url=cfg.issuer.rstrip("/") + "/.well-known/openid-configuration",
client_id=cfg.client_id,
client_secret=cfg.client_secret,
client_kwargs={"scope": cfg.scopes},
)
_oauth, _oauth_fp = oauth, fp
return _oauth.oidc
def _invalidate_client():
global _oauth, _oauth_fp
_oauth = None
_oauth_fp = None
def _collect_roles(claims: dict, token: dict) -> set[str]:
"""Roles from common OIDC claim shapes, across the ID-token/userinfo
claims and the (unverified) access token — Keycloak puts realm/client
roles in the access token by default."""
pools = [claims if isinstance(claims, dict) else {}]
at = token.get("access_token")
if at:
try:
from jose import jwt as _jwt
pools.append(_jwt.get_unverified_claims(at))
except Exception:
pass
roles: set[str] = set()
for p in pools:
if not isinstance(p, dict):
continue
ra = p.get("realm_access")
if isinstance(ra, dict):
roles.update(ra.get("roles") or [])
res = p.get("resource_access")
if isinstance(res, dict):
for v in res.values():
if isinstance(v, dict):
roles.update(v.get("roles") or [])
for key in ("roles", "role", "groups"):
val = p.get(key)
if isinstance(val, str):
roles.add(val)
elif isinstance(val, (list, tuple)):
roles.update(str(x) for x in val)
return {str(r).strip().lstrip("/").lower() for r in roles if r}
def _frontend(cfg: EffectiveOidc, qs: dict | None = None, fragment: str | None = None) -> str:
base = cfg.post_login_redirect or "/"
url = base
if qs:
url += ("&" if "?" in base else "?") + urlencode(qs)
if fragment:
url += "#" + fragment
return url
# ---- public auth config ---------------------------------------------------
@router.get("/config")
def auth_config(db: Session = Depends(get_db)):
cfg = get_effective_oidc(db)
return {
"oidc_enabled": cfg.configured,
"oidc_only": bool(settings.HARBORFORGE_OIDC_ONLY),
"password_login": not bool(settings.HARBORFORGE_OIDC_ONLY),
"oidc_login_url": "/auth/oidc/login",
}
# ---- sign-in / link flows -------------------------------------------------
@router.get("/oidc/login")
async def oidc_login(request: Request, db: Session = Depends(get_db)):
cfg = get_effective_oidc(db)
oidc = _client(cfg)
request.session.pop("hf_oidc_uid", None)
request.session["hf_oidc_mode"] = "login"
return await oidc.authorize_redirect(request, cfg.redirect_uri)
@router.get("/oidc/link")
async def oidc_link(request: Request, db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)):
if settings.HARBORFORGE_OIDC_ONLY:
raise HTTPException(status_code=403, detail="Self-service linking is disabled in OIDC-only mode")
cfg = get_effective_oidc(db)
oidc = _client(cfg)
request.session["hf_oidc_mode"] = "link"
request.session["hf_oidc_uid"] = current_user.id
return await oidc.authorize_redirect(request, cfg.redirect_uri)
@router.get("/oidc/callback")
async def oidc_callback(request: Request, db: Session = Depends(get_db)):
cfg = get_effective_oidc(db)
oidc = _client(cfg)
mode = request.session.pop("hf_oidc_mode", "login")
link_uid = request.session.pop("hf_oidc_uid", None)
try:
token = await oidc.authorize_access_token(request)
except Exception:
return RedirectResponse(_frontend(cfg, {"oidc_error": "exchange_failed"}))
claims = token.get("userinfo") or {}
if not claims:
try:
claims = await oidc.userinfo(token=token)
except Exception:
claims = {}
subject = claims.get("sub")
issuer = claims.get("iss") or cfg.issuer
if not subject:
return RedirectResponse(_frontend(cfg, {"oidc_error": "no_subject"}))
if mode == "link":
if settings.HARBORFORGE_OIDC_ONLY or link_uid is None:
return RedirectResponse(_frontend(cfg, {"oidc_error": "link_not_allowed"}))
user = db.query(models.User).filter(models.User.id == link_uid).first()
if not user:
return RedirectResponse(_frontend(cfg, {"oidc_error": "user_gone"}))
clash = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == subject,
models.User.id != user.id,
).first()
if clash:
return RedirectResponse(_frontend(cfg, {"oidc_error": "already_bound"}))
user.oidc_issuer = issuer
user.oidc_subject = subject
db.commit()
return RedirectResponse(_frontend(cfg, {"oidc_linked": "1"}))
user = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == subject,
).first()
# OIDC-only bootstrap: before any admin is linked, an IdP user whose
# token carries the configured admin role auto-connects to the unbound
# hf admin. Self-closes once any admin is bound.
if user is None and settings.HARBORFORGE_OIDC_ONLY:
any_admin_bound = db.query(models.User).filter(
models.User.is_admin == True, # noqa: E712
models.User.oidc_subject.isnot(None),
).first()
if not any_admin_bound and cfg.admin_role.lower() in _collect_roles(claims, token):
taken = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == subject,
).first()
if taken is None:
boot = db.query(models.User).filter(
models.User.is_admin == True, # noqa: E712
models.User.is_active == True, # noqa: E712
models.User.oidc_subject.is_(None),
).order_by(models.User.id).first()
if boot is not None:
boot.oidc_issuer = issuer
boot.oidc_subject = subject
db.commit()
logger.info("OIDC bootstrap: auto-connected admin '%s' via admin role", boot.username)
user = boot
if not user or not user.is_active or user.username in ("acc-mgr", "deleted-user"):
return RedirectResponse(_frontend(cfg, {"oidc_error": "not_linked"}))
access_token = create_access_token(
data={"sub": str(user.id)},
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
)
return RedirectResponse(_frontend(cfg, fragment=urlencode({"token": access_token})))
# ---- admin: OIDC provider settings ----------------------------------------
def _require_admin_any(current_user: models.User = Depends(get_current_user_or_apikey)) -> models.User:
"""Admin via JWT OR API key. The API-key path is the recovery channel
when OIDC-only mode is on and OIDC is not yet/incorrectly configured."""
if not getattr(current_user, "is_admin", False):
raise HTTPException(status_code=403, detail="Admin privileges required")
return current_user
class OidcSettingsIn(BaseModel):
enabled: bool | None = None
issuer: str | None = None
client_id: str | None = None
client_secret: str | None = None # blank/omitted = keep existing
redirect_uri: str | None = None
scopes: str | None = None
post_login_redirect: str | None = None
admin_role: str | None = None
class OidcSettingsOut(BaseModel):
enabled: bool
issuer: str | None
client_id: str | None
has_client_secret: bool
redirect_uri: str | None
scopes: str | None
post_login_redirect: str | None
admin_role: str
oidc_only: bool # read-only (deploy env)
effective_enabled: bool # provider actually usable
source: str # "db" or "env"
@router.get("/oidc/settings", response_model=OidcSettingsOut)
def get_oidc_settings(db: Session = Depends(get_db), _: models.User = Depends(_require_admin_any)):
row = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
cfg = get_effective_oidc(db)
return OidcSettingsOut(
enabled=bool(row.enabled) if row else False,
issuer=(row.issuer if row else None) or None,
client_id=(row.client_id if row else None) or None,
has_client_secret=bool(row.client_secret if row else None),
redirect_uri=(row.redirect_uri if row else None) or None,
scopes=(row.scopes if row else None) or None,
post_login_redirect=(row.post_login_redirect if row else None) or None,
admin_role=cfg.admin_role,
oidc_only=bool(settings.HARBORFORGE_OIDC_ONLY),
effective_enabled=cfg.configured,
source="db",
)
@router.put("/oidc/settings", response_model=OidcSettingsOut)
def update_oidc_settings(payload: OidcSettingsIn, db: Session = Depends(get_db),
_: models.User = Depends(_require_admin_any)):
row = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
if row is None:
row = OidcSettings(id=1, enabled=False)
db.add(row)
if payload.enabled is not None:
row.enabled = payload.enabled
if payload.issuer is not None:
row.issuer = payload.issuer.strip() or None
if payload.client_id is not None:
row.client_id = payload.client_id.strip() or None
# client_secret: only overwrite when a non-empty value is supplied
if payload.client_secret:
row.client_secret = payload.client_secret
if payload.redirect_uri is not None:
row.redirect_uri = payload.redirect_uri.strip() or None
if payload.scopes is not None:
row.scopes = payload.scopes.strip() or None
if payload.post_login_redirect is not None:
row.post_login_redirect = payload.post_login_redirect.strip() or None
if payload.admin_role is not None:
row.admin_role = payload.admin_role.strip() or None
db.commit()
_invalidate_client()
return get_oidc_settings(db=db, _=_)

View File

@@ -153,27 +153,9 @@ def _generate_project_code(db, name: str) -> str:
@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)):
# 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.
# Check if user is admin
if not current_user.is_admin:
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",
)
raise HTTPException(status_code=403, detail="Only admins can create projects")
# Auto-fill owner_name from owner_id
user = db.query(models.User).filter(models.User.id == project.owner_id).first()
if not user:

View File

@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
from typing import List
from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey
from app.api.deps import get_current_user
from app.models.models import User
from app.models.agent import Agent
from app.models.schedule_type import ScheduleType
@@ -57,18 +57,6 @@ def _require_schedule_manage(db: Session, user: User) -> User:
return user
def _attach_derived(st: ScheduleType) -> ScheduleType:
"""Attach derived fields (maintenance_duration_minutes) so the
pydantic ScheduleTypeResponse picks them up via from_attributes.
Pydantic with from_attributes reads attributes off the ORM object;
setting a transient attr here avoids having to convert through dict.
"""
if st is not None:
st.maintenance_duration_minutes = st.compute_maintenance_duration()
return st
# ---------------------------------------------------------------------------
# Schedule Type CRUD
# ---------------------------------------------------------------------------
@@ -80,10 +68,10 @@ def _attach_derived(st: ScheduleType) -> ScheduleType:
)
def list_schedule_types(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
current_user: User = Depends(get_current_user),
):
_require_schedule_read(db, current_user)
return [_attach_derived(st) for st in db.query(ScheduleType).all()]
return db.query(ScheduleType).all()
@router.post(
@@ -94,7 +82,7 @@ def list_schedule_types(
def create_schedule_type(
payload: ScheduleTypeCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
current_user: User = Depends(get_current_user),
):
_require_schedule_manage(db, current_user)
@@ -108,13 +96,11 @@ def create_schedule_type(
work_to=payload.work_to,
entertainment_from=payload.entertainment_from,
entertainment_to=payload.entertainment_to,
maintenance_from=payload.maintenance_from,
maintenance_to=payload.maintenance_to,
)
db.add(st)
db.commit()
db.refresh(st)
return _attach_derived(st)
return st
@router.patch(
@@ -126,7 +112,7 @@ def update_schedule_type(
schedule_type_id: int,
payload: ScheduleTypeUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
current_user: User = Depends(get_current_user),
):
_require_schedule_manage(db, current_user)
@@ -134,23 +120,12 @@ def update_schedule_type(
if not st:
raise HTTPException(404, "Schedule type not found")
update_fields = payload.model_dump(exclude_unset=True)
for field, value in update_fields.items():
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(st, field, value)
# Re-validate maintenance after merge (partial updates can put the row
# into an invalid window combo that the pydantic schema couldn't catch
# because it only saw one field).
from app.schemas.schedule_type import _validate_maintenance_window
try:
_validate_maintenance_window(st.maintenance_from, st.maintenance_to)
except ValueError as e:
db.rollback()
raise HTTPException(422, str(e))
db.commit()
db.refresh(st)
return _attach_derived(st)
return st
@router.delete(
@@ -160,7 +135,7 @@ def update_schedule_type(
def delete_schedule_type(
schedule_type_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
current_user: User = Depends(get_current_user),
):
_require_schedule_manage(db, current_user)
@@ -206,8 +181,7 @@ def get_my_schedule_type(
if not agent.schedule_type_id:
return None
st = db.query(ScheduleType).filter(ScheduleType.id == agent.schedule_type_id).first()
return _attach_derived(st) if st else None
return db.query(ScheduleType).filter(ScheduleType.id == agent.schedule_type_id).first()
@router.put(
@@ -218,7 +192,7 @@ def assign_schedule_type(
agent_id: str,
payload: AgentScheduleTypeAssign,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
current_user: User = Depends(get_current_user),
):
_require_schedule_manage(db, current_user)

View File

@@ -1,226 +0,0 @@
"""Special-slot CRUD for a ScheduleType (admin-only).
A "special slot" is a recurring slot template tied to a ScheduleType.
The system materialises one `time_slots` row per agent on that
schedule_type per date, scheduled inside the schedule_type's
maintenance window. Materialised rows are `is_admin_locked=true` —
agents can complete / abort / pause / resume them but cannot move
or cancel them.
All endpoints require `schedule_type.manage` (admin auto-grants).
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey
from app.models.models import User
from app.models.role_permission import Permission, RolePermission
from app.models.schedule_type import ScheduleType
from app.models.schedule_type_special_slot import ScheduleTypeSpecialSlot
from app.schemas.schedule_type_special_slot import (
SpecialSlotCreate,
SpecialSlotUpdate,
SpecialSlotResponse,
)
router = APIRouter(prefix="/schedule-types", tags=["ScheduleTypes"])
# ---------------------------------------------------------------------------
# Permission helpers — mirror schedule_type.py's local helpers so this router
# doesn't have to depend on internal symbols of the other router.
# ---------------------------------------------------------------------------
def _has_permission(db: Session, user: User, permission_name: str) -> bool:
if user.is_admin:
return True
if not user.role_id:
return False
return (
db.query(RolePermission)
.join(Permission)
.filter(
RolePermission.role_id == user.role_id,
Permission.name == permission_name,
)
.first()
is not None
)
def _require_schedule_manage(db: Session, user: User) -> User:
if not _has_permission(db, user, "schedule_type.manage"):
raise HTTPException(403, "Permission denied: schedule_type.manage")
return user
def _require_schedule_read(db: Session, user: User) -> User:
if not _has_permission(db, user, "schedule_type.read"):
raise HTTPException(403, "Permission denied: schedule_type.read")
return user
def _fetch_schedule_type(db: Session, schedule_type_id: int) -> ScheduleType:
st = db.query(ScheduleType).filter(ScheduleType.id == schedule_type_id).first()
if not st:
raise HTTPException(404, f"ScheduleType {schedule_type_id} not found")
return st
def _validate_fits_window(
minute_in_window: int,
estimated_duration: int,
maintenance_duration_minutes: int,
) -> None:
"""Reject special slots that wouldn't fit inside the parent's maintenance window."""
if minute_in_window + estimated_duration > maintenance_duration_minutes:
raise HTTPException(
422,
(
f"special slot does not fit in maintenance window: "
f"minute_in_window={minute_in_window} + "
f"estimated_duration={estimated_duration} > "
f"maintenance window {maintenance_duration_minutes}min"
),
)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get(
"/{schedule_type_id}/special-slots",
response_model=List[SpecialSlotResponse],
summary="List special slots for a schedule type",
)
def list_special_slots(
schedule_type_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_read(db, current_user)
_fetch_schedule_type(db, schedule_type_id)
return (
db.query(ScheduleTypeSpecialSlot)
.filter(ScheduleTypeSpecialSlot.schedule_type_id == schedule_type_id)
.order_by(
ScheduleTypeSpecialSlot.minute_in_window.asc(),
ScheduleTypeSpecialSlot.id.asc(),
)
.all()
)
@router.post(
"/{schedule_type_id}/special-slots",
response_model=SpecialSlotResponse,
summary="Create a special slot for a schedule type (admin)",
)
def create_special_slot(
schedule_type_id: int,
payload: SpecialSlotCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_manage(db, current_user)
st = _fetch_schedule_type(db, schedule_type_id)
_validate_fits_window(payload.minute_in_window, payload.estimated_duration, st.compute_maintenance_duration())
dup = (
db.query(ScheduleTypeSpecialSlot)
.filter(
ScheduleTypeSpecialSlot.schedule_type_id == schedule_type_id,
ScheduleTypeSpecialSlot.name == payload.name,
)
.first()
)
if dup:
raise HTTPException(
409,
f"special slot '{payload.name}' already exists for schedule_type {schedule_type_id}",
)
slot = ScheduleTypeSpecialSlot(
schedule_type_id=schedule_type_id,
name=payload.name,
description=payload.description,
minute_in_window=payload.minute_in_window,
estimated_duration=payload.estimated_duration,
priority=payload.priority,
event_data=payload.event_data,
is_active=payload.is_active,
created_by_user_id=current_user.id,
)
db.add(slot)
db.commit()
db.refresh(slot)
return slot
@router.patch(
"/{schedule_type_id}/special-slots/{slot_id}",
response_model=SpecialSlotResponse,
summary="Update a special slot (admin)",
)
def update_special_slot(
schedule_type_id: int,
slot_id: int,
payload: SpecialSlotUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_manage(db, current_user)
slot = (
db.query(ScheduleTypeSpecialSlot)
.filter(
ScheduleTypeSpecialSlot.id == slot_id,
ScheduleTypeSpecialSlot.schedule_type_id == schedule_type_id,
)
.first()
)
if not slot:
raise HTTPException(404, "Special slot not found")
update_fields = payload.model_dump(exclude_unset=True)
next_min = update_fields.get("minute_in_window", slot.minute_in_window)
next_dur = update_fields.get("estimated_duration", slot.estimated_duration)
parent = _fetch_schedule_type(db, schedule_type_id)
_validate_fits_window(next_min, next_dur, parent.compute_maintenance_duration())
for field, value in update_fields.items():
setattr(slot, field, value)
db.commit()
db.refresh(slot)
return slot
@router.delete(
"/{schedule_type_id}/special-slots/{slot_id}",
summary="Delete a special slot (admin)",
)
def delete_special_slot(
schedule_type_id: int,
slot_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_manage(db, current_user)
slot = (
db.query(ScheduleTypeSpecialSlot)
.filter(
ScheduleTypeSpecialSlot.id == slot_id,
ScheduleTypeSpecialSlot.schedule_type_id == schedule_type_id,
)
.first()
)
if not slot:
raise HTTPException(404, "Special slot not found")
db.delete(slot)
db.commit()
return {"ok": True, "deleted": slot_id}

View File

@@ -7,9 +7,9 @@ from pydantic import BaseModel
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash, hash_api_key
from app.core.config import get_db, settings
from app.init_bootstrap import DELETED_USER_USERNAME
from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash
from app.core.config import get_db
from app.init_wizard import DELETED_USER_USERNAME
from app.models import models
from app.models.agent import Agent
from app.models.role_permission import Permission, Role, RolePermission
@@ -32,18 +32,12 @@ def _user_response(user: models.User) -> dict:
"role_name": user.role_name,
"agent_id": user.agent.agent_id if user.agent else None,
"discord_user_id": user.discord_user_id,
"oidc_issuer": user.oidc_issuer,
"oidc_subject": user.oidc_subject,
"created_at": user.created_at,
}
return data
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.
def require_admin(current_user: models.User = Depends(get_current_user)):
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin required")
return current_user
@@ -72,29 +66,11 @@ def require_account_creator(
raise HTTPException(status_code=403, detail="Account creation permission required")
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).
"""
def _resolve_user_role(db: Session, role_id: int | None) -> Role:
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:
raise HTTPException(
status_code=500,
detail=f"Default role '{default_name}' is missing (DB not seeded)",
)
raise HTTPException(status_code=500, detail="Default guest role is missing")
return role
role = db.query(Role).filter(Role.id == role_id).first()
@@ -134,14 +110,8 @@ def create_user(
if existing_agent:
raise HTTPException(status_code=400, detail="agent_id already in use")
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
# passwordless (cannot password-login) and is expected to sign in via a
# bound OIDC identity. API keys still work for such users.
if settings.HARBORFORGE_OIDC_ONLY:
hashed_password = None
else:
hashed_password = get_password_hash(user.password) if user.password else None
assigned_role = _resolve_user_role(db, user.role_id)
hashed_password = get_password_hash(user.password) if user.password else None
db_user = models.User(
username=user.username,
email=user.email,
@@ -221,7 +191,7 @@ def update_user(
if payload.full_name is not None:
user.full_name = payload.full_name
if payload.password is not None and payload.password.strip() and not settings.HARBORFORGE_OIDC_ONLY:
if payload.password is not None and payload.password.strip():
user.hashed_password = get_password_hash(payload.password)
if payload.role_id is not None:
@@ -243,71 +213,6 @@ def update_user(
return _user_response(user)
@router.patch("/{identifier}/bind-agent", response_model=schemas.UserResponse)
def bind_agent(
identifier: str,
payload: schemas.UserBindAgentRequest,
db: Session = Depends(get_db),
_: models.User = Depends(require_account_creator),
):
"""Bind an existing user to (agent_id, claw_identifier).
Backfill path for users that were created via `hf user create` before
the cli supported `--agent-id` / `--claw-identifier` flags. Creates
the `agents` row that should have been written at user-create time.
Idempotent: if the user is already bound to the same
(agent_id, claw_identifier), returns the user unchanged (200, no-op).
Rejects (409) if:
- the user is bound to a DIFFERENT (agent_id, claw_identifier)
- the requested agent_id is already in use by another user
Permission: account.create (admin auto-grants) — same gate as
POST /users so the surface stays symmetric.
"""
user = _find_user_by_id_or_username(db, identifier)
if not user:
raise HTTPException(status_code=404, detail="User not found")
existing_agent_for_user = db.query(Agent).filter(Agent.user_id == user.id).first()
if existing_agent_for_user:
if (
existing_agent_for_user.agent_id == payload.agent_id
and existing_agent_for_user.claw_identifier == payload.claw_identifier
):
# idempotent re-bind
return _user_response(user)
raise HTTPException(
status_code=409,
detail=(
f"User '{user.username}' is already bound to agent "
f"'{existing_agent_for_user.agent_id}' on claw "
f"'{existing_agent_for_user.claw_identifier}'"
),
)
existing_for_agent_id = (
db.query(Agent).filter(Agent.agent_id == payload.agent_id).first()
)
if existing_for_agent_id:
raise HTTPException(
status_code=409,
detail=f"agent_id '{payload.agent_id}' already in use by another user",
)
db.add(
Agent(
user_id=user.id,
agent_id=payload.agent_id,
claw_identifier=payload.claw_identifier,
)
)
db.commit()
db.refresh(user)
return _user_response(user)
_BUILTIN_USERNAMES = {"acc-mgr", DELETED_USER_USERNAME}
@@ -413,7 +318,7 @@ def delete_user(
if not deleted_user:
raise HTTPException(
status_code=500,
detail="Built-in deleted-user account not found. Backend startup failed to seed it; restart the container.",
detail="Built-in deleted-user account not found. Run init_wizard first.",
)
_reassign_user_references(db, user.id, deleted_user.id)
@@ -464,10 +369,9 @@ def reset_user_apikey(
existing_key.is_active = False
db.flush()
# Create new key (store only the hash + a display prefix)
# Create new key
new_key = APIKey(
key_hash=hash_api_key(new_key_value),
key_prefix=new_key_value[:8],
key=new_key_value,
name=f"{target_user.username}-key",
user_id=target_user.id,
is_active=True,
@@ -510,92 +414,3 @@ def list_user_worklogs(
if current_user.id != user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Forbidden")
return db.query(WorkLog).filter(WorkLog.user_id == user.id).order_by(WorkLog.logged_date.desc()).limit(limit).all()
# ---- OIDC identity binding ------------------------------------------------
class OidcBindingRequest(BaseModel):
issuer: str
subject: str
class OidcBindingResponse(BaseModel):
user_id: int
username: str
oidc_issuer: str | None = None
oidc_subject: str | None = None
def _assert_can_manage_oidc_binding(db: Session, caller: models.User, target: models.User) -> None:
"""Global admins may (un)bind anyone. Non-admin account managers may
only operate on non-privileged accounts — never on an admin or another
privileged account — otherwise binding an attacker-controlled OIDC
identity to an admin would be a privilege-escalation primitive."""
if getattr(caller, "is_admin", False):
return
privileged = (
getattr(target, "is_admin", False)
or target.username in ("acc-mgr", "deleted-user")
or _has_global_permission(db, target, "account.create")
or _has_global_permission(db, target, "user.reset-apikey")
)
if privileged:
raise HTTPException(
status_code=403,
detail="Only a global admin may manage the OIDC binding of a privileged account",
)
@router.put("/{identifier}/oidc-binding", response_model=OidcBindingResponse)
def bind_user_oidc(
identifier: str,
payload: OidcBindingRequest,
db: Session = Depends(get_db),
caller: models.User = Depends(require_account_creator),
):
"""Bind an hf user to an external OIDC identity (issuer + subject).
Admin or account-manager (JWT or API key). Account managers may not
target privileged/admin accounts. One OIDC identity maps to at most
one user."""
issuer = (payload.issuer or "").strip()
subject = (payload.subject or "").strip()
if not issuer or not subject:
raise HTTPException(status_code=400, detail="issuer and subject are required")
user = _find_user_by_id_or_username(db, identifier)
if not user:
raise HTTPException(status_code=404, detail="User not found")
_assert_can_manage_oidc_binding(db, caller, user)
clash = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == subject,
models.User.id != user.id,
).first()
if clash:
raise HTTPException(status_code=409, detail=f"OIDC identity already bound to user '{clash.username}'")
user.oidc_issuer = issuer
user.oidc_subject = subject
db.commit()
db.refresh(user)
return OidcBindingResponse(user_id=user.id, username=user.username,
oidc_issuer=user.oidc_issuer, oidc_subject=user.oidc_subject)
@router.delete("/{identifier}/oidc-binding", response_model=OidcBindingResponse)
def unbind_user_oidc(
identifier: str,
db: Session = Depends(get_db),
caller: models.User = Depends(require_account_creator),
):
"""Remove a user's OIDC binding. Admin or account-manager; account
managers may not target privileged/admin accounts."""
user = _find_user_by_id_or_username(db, identifier)
if not user:
raise HTTPException(status_code=404, detail="User not found")
_assert_can_manage_oidc_binding(db, caller, user)
user.oidc_issuer = None
user.oidc_subject = None
db.commit()
db.refresh(user)
return OidcBindingResponse(user_id=user.id, username=user.username,
oidc_issuer=None, oidc_subject=None)

View File

@@ -1,231 +0,0 @@
"""Tessera (external OIDC, Keycloak-compatible) access-token verification.
Accepts RS256 access tokens issued by the configured Tessera realm as API
bearer tokens. This is ADDITIVE to the existing local HS256 JWT and API-key
auth — see app/api/deps.get_current_user_or_apikey.
Verification:
* fetch + cache the realm JWKS ({issuer}/protocol/openid-connect/certs),
* select the JWK by the token header `kid`,
* verify the RS256 signature, `iss == TESSERA_ISSUER`,
`aud` contains TESSERA_AUDIENCE, and require `exp`/`iat`.
Verified claims are mapped to / provision an hf models.User, mirroring the
OIDC login callback provisioning (app/api/routers/oidc.py).
"""
import logging
import threading
import time
import requests
from fastapi import HTTPException
from jose import jwt
from jose.exceptions import JWTError
from sqlalchemy.orm import Session
from app.core.config import settings
from app.models import models
from app.models.role_permission import Role
logger = logging.getLogger("harborforge.tessera")
# JWKS cache: refetched when a token's kid is unknown, and at most once per
# _JWKS_TTL otherwise (so rotated/revoked keys are picked up within the TTL).
_JWKS_TTL = 3600
_jwks_lock = threading.Lock()
_jwks: dict | None = None
_jwks_fetched_at: float = 0.0
def _jwks_url() -> str:
return settings.TESSERA_ISSUER.rstrip("/") + "/protocol/openid-connect/certs"
def _fetch_jwks() -> dict:
resp = requests.get(_jwks_url(), timeout=5)
resp.raise_for_status()
return resp.json()
def _get_jwks(force: bool = False) -> dict:
global _jwks, _jwks_fetched_at
with _jwks_lock:
now = time.time()
if force or _jwks is None or (now - _jwks_fetched_at) > _JWKS_TTL:
_jwks = _fetch_jwks()
_jwks_fetched_at = now
return _jwks
def _key_for_kid(kid: str | None) -> dict | None:
keys = (_get_jwks() or {}).get("keys") or []
for k in keys:
if k.get("kid") == kid:
return k
# Unknown kid → keys may have rotated; force a refresh once and retry.
keys = (_get_jwks(force=True) or {}).get("keys") or []
for k in keys:
if k.get("kid") == kid:
return k
return None
def verify_tessera_token(token: str) -> dict:
"""Verify a Tessera RS256 access token and return its decoded claims.
Raises HTTPException(401) on any failure (so callers can fall through to
the next auth method without leaking which check failed).
"""
if not token:
raise HTTPException(status_code=401, detail="No token")
try:
header = jwt.get_unverified_header(token)
except JWTError as exc:
raise HTTPException(status_code=401, detail="Malformed token") from exc
if header.get("alg") != "RS256":
raise HTTPException(status_code=401, detail="Unexpected token algorithm")
key = _key_for_kid(header.get("kid"))
if key is None:
raise HTTPException(status_code=401, detail="Unknown signing key")
try:
claims = jwt.decode(
token,
key,
algorithms=["RS256"],
issuer=settings.TESSERA_ISSUER,
audience=settings.TESSERA_AUDIENCE,
options={"require_exp": True, "require_iat": True},
)
except JWTError as exc:
raise HTTPException(status_code=401, detail="Invalid Tessera token") from exc
if not claims.get("sub"):
raise HTTPException(status_code=401, detail="Token missing subject")
return claims
def _collect_roles(claims: dict) -> set[str]:
"""Tessera/Keycloak roles: realm_access.roles + resource_access.<client>.roles.
Mirrors app/api/routers/oidc._collect_roles (normalised lower-case, no
leading slash).
"""
roles: set[str] = set()
ra = claims.get("realm_access")
if isinstance(ra, dict):
roles.update(ra.get("roles") or [])
res = claims.get("resource_access")
if isinstance(res, dict):
for v in res.values():
if isinstance(v, dict):
roles.update(v.get("roles") or [])
return {str(r).strip().lstrip("/").lower() for r in roles if r}
# Token roles (lower-case) mapped to an hf global role name, highest first.
# The first match wins. Token "admin" → hf admin (sets is_admin); others map
# onto the existing global role hierarchy.
_ROLE_PRIORITY = ["admin", "mgr", "dev", "member", "viewer", "guest"]
def _resolve_global_role(db: Session, token_roles: set[str]) -> Role | None:
for name in _ROLE_PRIORITY:
if name in token_roles:
role = db.query(Role).filter(
Role.is_global == True, # noqa: E712
Role.name == name,
).first()
if role:
return role
# No recognised role → fall back to guest (least privilege).
return db.query(Role).filter(
Role.is_global == True, # noqa: E712
Role.name == "guest",
).first()
def _unique_username(db: Session, base: str) -> str:
base = (base or "tessera-user").strip() or "tessera-user"
candidate = base
n = 1
while db.query(models.User).filter(models.User.username == candidate).first():
n += 1
candidate = f"{base}-{n}"
return candidate
def resolve_or_provision_user(db: Session, claims: dict) -> models.User:
"""Resolve the hf User for a verified Tessera token, auto-provisioning one
if no match exists. Mirrors the OIDC callback binding/provisioning."""
issuer = claims.get("iss") or settings.TESSERA_ISSUER
subject = claims.get("sub")
email = (claims.get("email") or "").strip() or None
username = (claims.get("preferred_username") or "").strip() or None
token_roles = _collect_roles(claims)
is_admin = "admin" in token_roles
# 1) by (issuer, subject)
user = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == subject,
).first()
# 2) by email — bind this Tessera identity onto the existing account.
if user is None and email:
user = db.query(models.User).filter(models.User.email == email).first()
if user is not None:
if user.oidc_subject and user.oidc_subject != subject:
# Email belongs to a user already bound to a different identity.
raise HTTPException(status_code=401, detail="Account already bound to another identity")
user.oidc_issuer = issuer
user.oidc_subject = subject
db.commit()
# 3) auto-provision
if user is None:
role = _resolve_global_role(db, token_roles)
if role is None:
raise HTTPException(status_code=500, detail="No global role available (DB not seeded)")
uname = _unique_username(db, username or (email.split("@")[0] if email else None) or f"tessera-{subject[:8]}")
# Email is NOT NULL + unique; synthesise a stable placeholder if absent.
eff_email = email or f"{subject}@tessera.local"
if db.query(models.User).filter(models.User.email == eff_email).first():
eff_email = f"{subject}@tessera.local"
user = models.User(
username=uname,
email=eff_email,
full_name=(claims.get("name") or username or uname),
hashed_password=None,
oidc_issuer=issuer,
oidc_subject=subject,
is_active=True,
is_admin=is_admin,
role_id=role.id,
)
db.add(user)
db.commit()
db.refresh(user)
logger.info("Tessera: provisioned user '%s' (admin=%s) for subject %s", user.username, is_admin, subject)
return user
if not user.is_active or user.username in ("acc-mgr", "deleted-user"):
raise HTTPException(status_code=401, detail="User is not permitted to sign in")
# Keep admin status in sync with the token's realm/client roles on each
# request so role changes in Tessera take effect without re-provisioning.
if bool(user.is_admin) != is_admin:
user.is_admin = is_admin
db.commit()
db.refresh(user)
return user
def authenticate_tessera(db: Session, token: str) -> models.User:
"""Verify a Tessera bearer token and return the resolved hf User."""
claims = verify_tessera_token(token)
return resolve_or_provision_user(db, claims)

View File

@@ -1,10 +0,0 @@
"""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
"""

View File

@@ -1,68 +0,0 @@
"""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())

View File

@@ -1,269 +0,0 @@
"""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)

View File

@@ -1,108 +0,0 @@
"""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)

View File

@@ -1,13 +1,34 @@
"""Backend runtime settings — env-only (no wizard / no config volume).
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.
"""
import os
import json
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
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):
@@ -17,22 +38,6 @@ class Settings(BaseSettings):
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# When true: no password login at all. Password login endpoint rejects,
# user creation ignores any password (passwordless users that only sign
# in via a bound OIDC identity / API keys), frontend hides password UI.
HARBORFORGE_OIDC_ONLY: bool = False
# Mark the OIDC state/session cookie Secure (HTTPS-only). Defaults to True
# for production; set SESSION_COOKIE_SECURE=false for plain-HTTP local dev.
SESSION_COOKIE_SECURE: bool = True
# External OIDC provider ("Tessera", Keycloak-compatible) whose RS256
# access tokens are accepted as API bearer tokens (additive to local
# HS256 JWT + API keys). Tokens are verified against the issuer's JWKS;
# `iss` must equal TESSERA_ISSUER and `aud` must contain TESSERA_AUDIENCE.
TESSERA_ISSUER: str = "https://login.hangman-lab.top/realms/Hangman-Lab"
TESSERA_AUDIENCE: str = "harbor-forge"
class Config:
env_file = ".env"
@@ -55,7 +60,9 @@ if settings.SECRET_KEY in _WEAK_SECRETS or len(settings.SECRET_KEY) < 32:
"Refusing to start with a default/short key."
)
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
# Resolve DB URL: wizard config volume > env > default
_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)
Base = declarative_base()

View File

@@ -1,49 +1,105 @@
"""
HarborForge unconditional startup seeds runs every time backend boots.
HarborForge initialization from AbstractWizard config volume.
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.
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.api.deps import get_password_hash
logger = logging.getLogger("harborforge.bootstrap")
logger = logging.getLogger("harborforge.init")
CONFIG_DIR = os.getenv("CONFIG_DIR", "/config")
CONFIG_FILE = os.getenv("CONFIG_FILE", "harborforge.json")
# ---------------------------------------------------------------------------
# Permissions catalog (canonical; new perms get added on every release)
# ---------------------------------------------------------------------------
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.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"),
@@ -82,17 +138,11 @@ DEFAULT_PERMISSIONS = [
("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."""
"""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()
@@ -101,17 +151,21 @@ def init_default_permissions(db: Session) -> list[Permission]:
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 roles + permission set per role
# Default role permission mapping
# ---------------------------------------------------------------------------
# mgr: project management + all milestone/task/proposal actions
_MGR_PERMISSIONS = {
"project.read", "project.write", "project.create", "project.manage_members",
"knowledge-base.read", "knowledge-base.create", "knowledge-base.update", "knowledge-base.delete",
"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",
@@ -122,9 +176,9 @@ _MGR_PERMISSIONS = {
"user.reset-self-apikey",
}
# dev: day-to-day development work — no freeze/start/close milestone, no accept/reject proposal
_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",
@@ -138,32 +192,18 @@ _ACCOUNT_MANAGER_PERMISSIONS = {
"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",
}
# Role definitions: (name, description, permission_set)
_DEFAULT_ROLES = [
("admin", "Administrator - full access to all features", None), # None ⇒ all perms
("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
("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)
@@ -175,8 +215,13 @@ def _ensure_role(db: Session, name: str, description: str, is_global: bool = Tru
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)."""
"""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}
@@ -190,40 +235,45 @@ def _sync_role_permissions(db: Session, role: Role, target_perm_names: set[str]
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."""
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 ready (admin / account-manager / mgr / dev / guest)")
# ---------------------------------------------------------------------------
# Built-in user accounts (system principals, cannot log in)
# ---------------------------------------------------------------------------
DELETED_USER_USERNAME = "deleted-user"
logger.info("Default roles setup complete (admin, mgr, dev, guest)")
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."""
"""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")
@@ -233,7 +283,7 @@ def init_acc_mgr_user(db: Session) -> models.User | None:
username=username,
email="acc-mgr@harborforge.internal",
full_name="Account Manager",
hashed_password=None,
hashed_password=None, # Cannot log in — no password
is_admin=False,
is_active=True,
role_id=acc_mgr_role.id,
@@ -245,13 +295,21 @@ def init_acc_mgr_user(db: Session) -> models.User | None:
return user
DELETED_USER_USERNAME = "deleted-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."""
"""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(
@@ -270,17 +328,36 @@ def init_deleted_user(db: Session) -> models.User | None:
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.
def run_init(db: Session) -> None:
"""Main initialization entry point. Reads config from shared volume."""
config = load_config()
if not config:
return
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)
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)
logger.info("Bootstrap seeds complete")
# 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)
logger.info("Initialization complete")

View File

@@ -1,9 +1,6 @@
"""HarborForge API — Agent/人类协同任务管理平台"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
from app.core.config import settings
app = FastAPI(
title="HarborForge API",
@@ -20,17 +17,6 @@ app.add_middleware(
allow_headers=["*"],
)
# Short-lived signed session cookie — only used to carry the OIDC
# state/nonce between /auth/oidc/login and the callback.
app.add_middleware(
SessionMiddleware,
secret_key=settings.SECRET_KEY,
session_cookie="hf_oidc",
same_site="lax",
https_only=settings.SESSION_COOKIE_SECURE,
max_age=600,
)
# Health & version (kept at top level)
@app.get("/health", tags=["System"])
def health_check():
@@ -42,22 +28,24 @@ def version():
@app.get("/config/status", tags=["System"])
def config_status():
"""Has the deployment been bootstrapped (admin user exists)?
Frontend hits this on mount to decide whether to show login or a
"no admin yet, run hf-cli admin create-user" placeholder. With the
wizard removed in v0.4.0 the only deploy-time bootstrap step is the
operator running `docker exec hf-backend hf-cli admin create-user ...`
once; this endpoint just reports whether that has happened.
"""
from app.core.config import SessionLocal
from app.models import models
db = SessionLocal()
"""Check if HarborForge has been initialized (reads from config volume).
Frontend uses this instead of contacting the wizard directly."""
import os, json
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 not os.path.exists(config_path):
return {"initialized": False}
try:
admin_count = db.query(models.User).filter(models.User.is_admin == True).count() # noqa: E712
return {"initialized": admin_count > 0}
finally:
db.close()
with open(config_path, "r") as f:
cfg = json.load(f)
return {
"initialized": cfg.get("initialized", False),
"backend_url": cfg.get("backend_url"),
"discord": cfg.get("discord") or {},
}
except Exception:
return {"initialized": False}
# Register routers
from app.api.routers.auth import router as auth_router
@@ -76,13 +64,9 @@ from app.api.routers.milestone_actions import router as milestone_actions_router
from app.api.routers.meetings import router as meetings_router
from app.api.routers.essentials import router as essentials_router
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.calendar import router as calendar_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(oidc_router)
app.include_router(tasks_router)
app.include_router(projects_router)
app.include_router(users_router)
@@ -98,9 +82,7 @@ app.include_router(milestone_actions_router)
app.include_router(meetings_router)
app.include_router(essentials_router)
app.include_router(schedule_type_router)
app.include_router(schedule_type_special_slot_router)
app.include_router(calendar_router)
app.include_router(knowledge_router)
# Auto schema migration for lightweight deployments
@@ -295,18 +277,6 @@ def _migrate_schema():
if _has_table(db, "users") and not _has_column(db, "users", "discord_user_id"):
db.execute(text("ALTER TABLE users ADD COLUMN discord_user_id VARCHAR(32) NULL"))
# --- users OIDC binding (issuer + subject), unique together ---
if _has_table(db, "users") and not _has_column(db, "users", "oidc_issuer"):
db.execute(text("ALTER TABLE users ADD COLUMN oidc_issuer VARCHAR(255) NULL"))
if _has_table(db, "users") and not _has_column(db, "users", "oidc_subject"):
db.execute(text("ALTER TABLE users ADD COLUMN oidc_subject VARCHAR(255) NULL"))
if _has_table(db, "users") and _has_column(db, "users", "oidc_subject"):
_ensure_unique_index(db, "users", "uq_users_oidc_identity", "oidc_issuer, oidc_subject")
# --- oidc_settings.admin_role (added after the table shipped) ---
if _has_table(db, "oidc_settings") and not _has_column(db, "oidc_settings", "admin_role"):
db.execute(text("ALTER TABLE oidc_settings ADD COLUMN admin_role VARCHAR(128) NULL"))
# --- monitored_servers.api_key for heartbeat v2 ---
if _has_table(db, "monitored_servers") and not _has_column(db, "monitored_servers", "api_key"):
db.execute(text("ALTER TABLE monitored_servers ADD COLUMN api_key VARCHAR(64) NULL"))
@@ -399,76 +369,6 @@ def _migrate_schema():
if _has_table(db, "agents") and not _has_column(db, "agents", "schedule_type_id"):
db.execute(text("ALTER TABLE agents ADD COLUMN schedule_type_id INTEGER NULL"))
# --- schedule_types: add maintenance_from / maintenance_to ---
# Default 8:009:00 UTC for existing rows; the maintenance
# duration invariant (1-180min) is enforced at the schema
# level for any NEW rows by ScheduleTypeCreate validator.
if _has_table(db, "schedule_types"):
if not _has_column(db, "schedule_types", "maintenance_from"):
db.execute(text(
"ALTER TABLE schedule_types ADD COLUMN maintenance_from INT NOT NULL DEFAULT 8"
))
if not _has_column(db, "schedule_types", "maintenance_to"):
db.execute(text(
"ALTER TABLE schedule_types ADD COLUMN maintenance_to INT NOT NULL DEFAULT 9"
))
# --- minutes-since-midnight migration (PR #21+) ---
# The 6 schedule_type window columns used to hold *hours*
# (0-23). PR #21 changed semantics to *minutes since UTC
# midnight* (0-1439). Detect the legacy regime by checking
# if ANY row has all 6 values ≤ 23 — if so, multiply each
# by 60 to convert. Idempotent: post-conversion values are
# all ≥ 0 and usually well above 23, so guard never fires
# twice.
row = db.execute(text(
"SELECT MAX(GREATEST(work_from, work_to, entertainment_from, entertainment_to, maintenance_from, maintenance_to)) AS m "
"FROM schedule_types"
)).fetchone()
if row is not None and row.m is not None and row.m <= 23:
db.execute(text(
"UPDATE schedule_types SET "
" work_from = work_from * 60, "
" work_to = work_to * 60, "
" entertainment_from = entertainment_from * 60, "
" entertainment_to = entertainment_to * 60, "
" maintenance_from = maintenance_from * 60, "
" maintenance_to = maintenance_to * 60"
))
# --- time_slots: admin-locked + special_slot pointer ---
if _has_table(db, "time_slots"):
if not _has_column(db, "time_slots", "is_admin_locked"):
db.execute(text(
"ALTER TABLE time_slots ADD COLUMN is_admin_locked TINYINT(1) NOT NULL DEFAULT 0"
))
if not _has_column(db, "time_slots", "special_slot_id"):
db.execute(text(
"ALTER TABLE time_slots ADD COLUMN special_slot_id INTEGER NULL"
))
# Index for the materialiser's idempotency lookup
db.execute(text(
"CREATE INDEX idx_time_slots_special_slot_id ON time_slots (special_slot_id)"
))
# --- api_keys: migrate legacy plaintext `key` -> hashed `key_hash` ---
# Only runs on deployments that still have the old plaintext column;
# fresh installs get key_hash/key_prefix directly from create_all.
if _has_table(db, "api_keys") and _has_column(db, "api_keys", "key"):
if not _has_column(db, "api_keys", "key_hash"):
db.execute(text("ALTER TABLE api_keys ADD COLUMN key_hash VARCHAR(64) NULL"))
if not _has_column(db, "api_keys", "key_prefix"):
db.execute(text("ALTER TABLE api_keys ADD COLUMN key_prefix VARCHAR(16) NULL"))
db.execute(text("ALTER TABLE api_keys MODIFY COLUMN `key` VARCHAR(64) NULL"))
db.execute(text("UPDATE api_keys SET key_hash = SHA2(`key`, 256), key_prefix = LEFT(`key`, 8) WHERE key_hash IS NULL AND `key` IS NOT NULL"))
db.execute(text("UPDATE api_keys SET `key` = NULL WHERE `key` IS NOT NULL"))
_ensure_unique_index(db, "api_keys", "idx_api_keys_key_hash", "key_hash")
# --- schedule_type_special_slots: create-table is handled by
# Base.metadata.create_all on first boot; no migration needed here
# because there is no legacy table to evolve. Future schema bumps
# to that table go in this block.
db.commit()
except Exception as e:
db.rollback()
@@ -503,17 +403,15 @@ def _sync_default_user_roles(db):
@app.on_event("startup")
def startup():
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, knowledge
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
Base.metadata.create_all(bind=engine)
_migrate_schema()
# Idempotent startup seed: permissions, default roles, built-in
# 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
# Initialize from AbstractWizard (admin user, default project, etc.)
from app.init_wizard import run_init
db = SessionLocal()
try:
run_bootstrap(db)
run_init(db)
_sync_default_user_roles(db)
finally:
db.close()

View File

@@ -7,10 +7,7 @@ class APIKey(Base):
__tablename__ = "api_keys"
id = Column(Integer, primary_key=True, index=True)
# The raw key is never stored — only its SHA-256 hash. `key_prefix` holds
# the first few chars for human-readable display/masking in listings.
key_hash = Column(String(64), unique=True, nullable=False, index=True)
key_prefix = Column(String(16), nullable=True)
key = Column(String(64), unique=True, nullable=False, index=True)
name = Column(String(100), nullable=False) # e.g. "agent-zhi", "agent-lyn"
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
is_active = Column(Boolean, default=True)

View File

@@ -178,37 +178,11 @@ class TimeSlot(Base):
comment="Source plan if materialized from a SchedulePlan; set NULL on edit/cancel",
)
# -----------------------------------------------------------------
# Admin-locked slots are materialised from a ScheduleTypeSpecialSlot
# template. The agent can complete / abort / pause / resume them but
# cannot edit their time, type, duration, or cancel them outright —
# the slot exists because admin decided every agent on the parent
# schedule_type should run it. See `_apply_agent_slot_update` for
# the enforcement.
# -----------------------------------------------------------------
is_admin_locked = Column(
Boolean,
nullable=False,
server_default="0",
comment="True for slots materialised from a schedule_type special slot template.",
)
# Pointer back to the template that materialised this slot. NULL for
# all user-created or plan-generated slots. Lets us cascade updates
# and surface 'why is this on my calendar' to the agent.
special_slot_id = Column(
Integer,
ForeignKey("schedule_type_special_slots.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# relationship ----------------------------------------------------------
plan = relationship("SchedulePlan", back_populates="materialized_slots")
special_slot = relationship("ScheduleTypeSpecialSlot")
# ---------------------------------------------------------------------------

View File

@@ -1,101 +0,0 @@
"""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)

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean, JSON, Time, UniqueConstraint
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean, JSON, Time
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.config import Base
@@ -66,9 +66,6 @@ class Project(Base):
class User(Base):
__tablename__ = "users"
__table_args__ = (
UniqueConstraint("oidc_issuer", "oidc_subject", name="uq_users_oidc_identity"),
)
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, nullable=False)
@@ -76,10 +73,6 @@ class User(Base):
hashed_password = Column(String(255), nullable=True)
full_name = Column(String(100), nullable=True)
discord_user_id = Column(String(32), nullable=True)
# OIDC binding: an hf user is linked to at most one external OIDC identity
# (issuer + subject). Unique together so one IdP identity maps to one user.
oidc_issuer = Column(String(255), nullable=True)
oidc_subject = Column(String(255), nullable=True)
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)

View File

@@ -1,25 +0,0 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.sql import func
from app.core.config import Base
class OidcSettings(Base):
"""Single-row (id=1) runtime OIDC configuration.
When a row exists its non-empty fields override the OIDC_* env vars,
so the provider can be configured from the admin UI without a redeploy.
"""
__tablename__ = "oidc_settings"
id = Column(Integer, primary_key=True, default=1)
enabled = Column(Boolean, default=False, nullable=False)
issuer = Column(String(255), nullable=True)
client_id = Column(String(255), nullable=True)
client_secret = Column(String(512), nullable=True)
redirect_uri = Column(String(512), nullable=True)
scopes = Column(String(255), nullable=True)
post_login_redirect = Column(String(512), nullable=True)
# OIDC role name that, in OIDC-only mode, auto-connects an unbound
# hf admin on first login (bootstrap). Default "admin".
admin_role = Column(String(128), nullable=True)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -1,27 +1,17 @@
"""ScheduleType model — defines work/entertainment/maintenance time periods.
"""ScheduleType model — defines work/entertainment time periods.
Each ScheduleType defines the daily work, entertainment, and maintenance
windows for agents who reference this type. All bounds are stored as
**minutes-since-UTC-midnight** (0-1439 inclusive) so half-hour and other
sub-hour boundaries are exact.
Maintenance window length is variable (1-180 minutes) and admin-owned;
agent slots cannot intersect it (see `app/api/routers/calendar.py`).
Historical note: pre-PR #21 the columns held *hours* (0-23) and the
maintenance window was hard-fixed at exactly 1 hour. The additive
migration in `_migrate_schema()` multiplies legacy values by 60 so
existing rows convert transparently.
Each ScheduleType defines the daily work and entertainment windows.
Agents reference a schedule_type to know when they should be working
vs when they can engage in entertainment activities.
"""
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.config import Base
class ScheduleType(Base):
"""Work/entertainment/maintenance period definition."""
"""Work/entertainment period definition."""
__tablename__ = "schedule_types"
@@ -34,50 +24,29 @@ class ScheduleType(Base):
comment="Human-readable schedule type name (e.g., 'standard', 'night-shift')",
)
# Minutes since UTC midnight, 0-1439 inclusive.
work_from = Column(Integer, nullable=False, comment="Work period start (minutes since UTC midnight)")
work_to = Column(Integer, nullable=False, comment="Work period end (minutes since UTC midnight)")
entertainment_from = Column(Integer, nullable=False, comment="Entertainment start (minutes since UTC midnight)")
entertainment_to = Column(Integer, nullable=False, comment="Entertainment end (minutes since UTC midnight)")
# Maintenance window — admin-owned, variable length (1-180 min).
# Default 8:009:00 UTC = 480540 minutes for existing rows.
maintenance_from = Column(
work_from = Column(
Integer,
nullable=False,
server_default="480",
comment="Maintenance start (minutes since UTC midnight, default 480 = 8:00 UTC).",
comment="Work period start hour (0-23, UTC)",
)
maintenance_to = Column(
work_to = Column(
Integer,
nullable=False,
server_default="540",
comment="Maintenance end (minutes since UTC midnight, default 540 = 9:00 UTC). Duration ((to-from) mod 1440) must be in [1, 180].",
comment="Work period end hour (0-23, UTC)",
)
entertainment_from = Column(
Integer,
nullable=False,
comment="Entertainment period start hour (0-23, UTC)",
)
entertainment_to = Column(
Integer,
nullable=False,
comment="Entertainment period end hour (0-23, UTC)",
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# relationship ---------------------------------------------------
special_slots = relationship(
"ScheduleTypeSpecialSlot",
back_populates="schedule_type",
cascade="all, delete-orphan",
)
# ---------------------------------------------------------------
# Convenience methods used by the API layer + materialiser.
# ---------------------------------------------------------------
def compute_maintenance_duration(self) -> int:
"""Maintenance window length in minutes (handles 23→0 wrap)."""
return (self.maintenance_to - self.maintenance_from) % 1440 or 1440
def window_contains(self, start_min: int, end_min: int, win_from: int, win_to: int) -> bool:
"""True if [start_min, end_min) intersects [win_from, win_to) (handles wrap)."""
# Normalise into [0, 1440) — same logic as the helper in calendar.py.
if win_to > win_from:
return start_min < win_to and end_min > win_from
# wrap window crosses midnight: [win_from..1440) [0..win_to)
return start_min < win_to or end_min > win_from

View File

@@ -1,116 +0,0 @@
"""ScheduleTypeSpecialSlot — admin-managed slot template tied to a ScheduleType.
A "special slot" is a recurring slot template that the system materializes
into every matching agent's `time_slots` row each day. It exists for tasks
that admin wants to enforce across an entire schedule type cohort, e.g.:
* `plan-schedule` — daily planning slot all agents on this type must run
* `secret-rotation-window` — security maintenance
* `policy-update` — read updated agent policies
Rules:
* Only admins (`schedule_type.manage` permission) may create / edit /
delete special slots.
* The slot's `minute_in_window` offset must place it inside the parent
schedule_type's maintenance window (`maintenance_from..maintenance_from+59`).
* Materialised `time_slots` rows from a special slot carry
`is_admin_locked=true` so the agent-side `PATCH .../agent-update`
refuses status/time edits other than complete/abort/pause/resume.
* Materialisation produces one `time_slots` row per agent using this
schedule_type per date, with `slot_type=system`, `event_type=system_event`,
`event_data={"special_slot_id": <id>, "special_slot_name": "<name>",
"source": "schedule_type_special_slot", ...admin-supplied...}`.
"""
from sqlalchemy import Column, Integer, String, ForeignKey, JSON, DateTime, Boolean, UniqueConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.config import Base
class ScheduleTypeSpecialSlot(Base):
"""Admin-managed daily slot template attached to a ScheduleType."""
__tablename__ = "schedule_type_special_slots"
__table_args__ = (
# One slot template per (schedule_type, name) so admin can use the
# `name` field as a stable, human-readable identifier for the cohort.
UniqueConstraint("schedule_type_id", "name", name="uq_special_slot_type_name"),
)
id = Column(Integer, primary_key=True, index=True)
schedule_type_id = Column(
Integer,
ForeignKey("schedule_types.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name = Column(
String(64),
nullable=False,
comment="Short identifier, e.g. 'plan-schedule', 'secret-rotation'",
)
description = Column(
String(512),
nullable=True,
comment="Human-readable note on what this slot is for",
)
minute_in_window = Column(
Integer,
nullable=False,
server_default="0",
comment=(
"Minute offset (0-59) inside the schedule_type maintenance window. "
"The materialised time_slot's scheduled_at becomes "
"maintenance_from:minute_in_window:00 UTC."
),
)
estimated_duration = Column(
Integer,
nullable=False,
server_default="15",
comment="Duration in minutes. Must fit inside the maintenance window.",
)
priority = Column(
Integer,
nullable=False,
server_default="50",
comment="Wake priority — higher value wakes first if multiple slots are due.",
)
event_data = Column(
JSON,
nullable=True,
comment=(
"Admin-supplied JSON payload that gets merged into every "
"materialised slot's event_data. Use this to pass a workflow "
"tag, suggested_workload, or any other context the agent "
"should see in its wakeup message."
),
)
is_active = Column(
Boolean,
nullable=False,
server_default="1",
comment="Soft-disable without deleting; inactive templates are skipped during materialisation.",
)
created_by_user_id = Column(
Integer,
ForeignKey("users.id"),
nullable=False,
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# relationship ---------------------------------------------------
schedule_type = relationship("ScheduleType", back_populates="special_slots")

View File

@@ -144,8 +144,6 @@ class TimeSlotResponse(BaseModel):
priority: int
status: str
plan_id: Optional[int] = None
is_admin_locked: bool = False
special_slot_id: Optional[int] = None
created_at: Optional[dt_datetime] = None
updated_at: Optional[dt_datetime] = None
@@ -228,8 +226,6 @@ class CalendarSlotItem(BaseModel):
priority: int
status: str
plan_id: Optional[int] = None
is_admin_locked: bool = False
special_slot_id: Optional[int] = None
created_at: Optional[dt_datetime] = None
updated_at: Optional[dt_datetime] = None

View File

@@ -1,166 +0,0 @@
"""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()

View File

@@ -1,67 +1,23 @@
"""Schemas for ScheduleType CRUD.
"""Schemas for ScheduleType CRUD."""
All `*_from` / `*_to` values are **minutes since UTC midnight** (0-1439).
A maintenance window of variable length is allowed (1-180 minutes,
handles 23→0 wrap).
"""
from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, Field
from typing import Optional
_MAX_MIN = 1440 # 24 * 60 — exclusive upper bound
def _maintenance_duration(maint_from: int, maint_to: int) -> int:
"""Maintenance window length in minutes; treats from==to as 24h (invalid)."""
return (maint_to - maint_from) % _MAX_MIN or _MAX_MIN
def _validate_maintenance_window(maint_from: int, maint_to: int) -> None:
dur = _maintenance_duration(maint_from, maint_to)
if dur < 1 or dur > 180:
raise ValueError(
f"maintenance window duration must be in [1, 180] minutes; "
f"got {dur} (from={maint_from}, to={maint_to})"
)
def _validate_minute_field(name: str, value: int) -> None:
if value < 0 or value >= _MAX_MIN:
raise ValueError(f"{name} must be in [0, {_MAX_MIN}); got {value}")
class ScheduleTypeCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=64)
work_from: int = Field(..., ge=0, lt=_MAX_MIN, description="Work start (minutes since UTC midnight, 0-1439)")
work_to: int = Field(..., ge=0, lt=_MAX_MIN)
entertainment_from: int = Field(..., ge=0, lt=_MAX_MIN)
entertainment_to: int = Field(..., ge=0, lt=_MAX_MIN)
maintenance_from: int = Field(480, ge=0, lt=_MAX_MIN, description="Maintenance start (default 480 = 8:00 UTC)")
maintenance_to: int = Field(540, ge=0, lt=_MAX_MIN, description="Maintenance end; (to-from) mod 1440 in [1,180]")
@model_validator(mode="after")
def _check_maintenance(self):
_validate_maintenance_window(self.maintenance_from, self.maintenance_to)
return self
work_from: int = Field(..., ge=0, le=23)
work_to: int = Field(..., ge=0, le=23)
entertainment_from: int = Field(..., ge=0, le=23)
entertainment_to: int = Field(..., ge=0, le=23)
class ScheduleTypeUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=64)
work_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
work_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
entertainment_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
entertainment_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
maintenance_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
maintenance_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
@model_validator(mode="after")
def _check_maintenance(self):
# Only validate when both fields are present together; partial-
# update validation against the merged row happens at apply time.
if self.maintenance_from is not None and self.maintenance_to is not None:
_validate_maintenance_window(self.maintenance_from, self.maintenance_to)
return self
work_from: Optional[int] = Field(None, ge=0, le=23)
work_to: Optional[int] = Field(None, ge=0, le=23)
entertainment_from: Optional[int] = Field(None, ge=0, le=23)
entertainment_to: Optional[int] = Field(None, ge=0, le=23)
class ScheduleTypeResponse(BaseModel):
@@ -71,9 +27,6 @@ class ScheduleTypeResponse(BaseModel):
work_to: int
entertainment_from: int
entertainment_to: int
maintenance_from: int
maintenance_to: int
maintenance_duration_minutes: Optional[int] = None # derived; populated by router
class Config:
from_attributes = True

View File

@@ -1,43 +0,0 @@
"""Schemas for ScheduleTypeSpecialSlot CRUD (admin-only)."""
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, Field
class SpecialSlotCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=64)
description: Optional[str] = Field(None, max_length=512)
minute_in_window: int = Field(0, ge=0, le=179, description="Minute offset (0-179) inside the schedule_type maintenance window")
estimated_duration: int = Field(15, ge=1, le=180, description="Duration in minutes; must fit inside the maintenance window (1-180min)")
priority: int = Field(50, ge=0, le=99)
event_data: Optional[dict[str, Any]] = Field(None, description="JSON payload merged into every materialised slot's event_data")
is_active: bool = True
class SpecialSlotUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=64)
description: Optional[str] = Field(None, max_length=512)
minute_in_window: Optional[int] = Field(None, ge=0, le=179)
estimated_duration: Optional[int] = Field(None, ge=1, le=180)
priority: Optional[int] = Field(None, ge=0, le=99)
event_data: Optional[dict[str, Any]] = None
is_active: Optional[bool] = None
class SpecialSlotResponse(BaseModel):
id: int
schedule_type_id: int
name: str
description: Optional[str]
minute_in_window: int
estimated_duration: int
priority: int
event_data: Optional[dict[str, Any]]
is_active: bool
created_by_user_id: int
created_at: datetime
class Config:
from_attributes = True

View File

@@ -1,4 +1,4 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, time
from enum import Enum
@@ -105,9 +105,7 @@ class CommentBase(BaseModel):
class CommentCreate(CommentBase):
task_id: int
# author_id is NOT accepted from the client — the comment is always
# attributed to the authenticated caller (server-side) to prevent
# author spoofing.
author_id: int
class CommentUpdate(BaseModel):
@@ -188,19 +186,6 @@ class UserUpdate(BaseModel):
discord_user_id: Optional[str] = None
class UserBindAgentRequest(BaseModel):
"""Request body for PATCH /users/{identifier}/bind-agent.
Binds an existing user to (agent_id, claw_identifier) by inserting a
row in the `agents` table. Both fields required (mirrors the
create-time invariant in UserCreate). Idempotent: re-binding the same
user to the same (agent_id, claw_identifier) returns the existing
Agent row instead of 409.
"""
agent_id: str = Field(..., min_length=1, max_length=128)
claw_identifier: str = Field(..., min_length=1, max_length=128)
class UserResponse(UserBase):
id: int
is_active: bool
@@ -209,10 +194,8 @@ class UserResponse(UserBase):
role_name: Optional[str] = None
agent_id: Optional[str] = None
discord_user_id: Optional[str] = None
oidc_issuer: Optional[str] = None
oidc_subject: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True

View File

@@ -1,25 +1,17 @@
from __future__ import annotations
import os
from datetime import datetime, timezone
from typing import Any
import requests
from fastapi import HTTPException
from app.services.harborforge_config import get_discord_wakeup_config
DISCORD_API_BASE = "https://discord.com/api/v10"
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]:
return {
"Authorization": f"Bot {bot_token}",
@@ -42,7 +34,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]:
cfg = _discord_config()
cfg = get_discord_wakeup_config()
guild_id = cfg.get("guild_id")
bot_token = cfg.get("bot_token")
if not guild_id or not bot_token:

View File

@@ -0,0 +1,26 @@
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"),
}

View File

@@ -1,175 +0,0 @@
"""Materialise schedule_type special slots into per-agent time_slots rows.
A ScheduleTypeSpecialSlot is a template — it lives on the schedule_type.
For an agent on that schedule_type to actually be woken, the system must
emit a row in `time_slots` with `slot_type=system`, `is_admin_locked=true`,
`special_slot_id=<template_id>` for the agent's `user_id` on the target
date. This module is the single materialisation point.
Called from:
* GET /calendar/day — before returning slots, materialise today's special
slots for the calling user.
* GET /calendar/sync — before returning per-claw schedules, materialise
today's special slots for every agent on this claw whose schedule_type
has any active special slot template.
Idempotent: re-running on the same (agent, date, special_slot_template)
is a no-op — uniqueness is enforced via SELECT-then-insert. We do not add
a DB-level unique constraint because the time_slots table is already
indexed by (user_id, date) and an extra composite index is overkill for
the low cardinality of (agents × special-slot-templates) per day.
"""
from __future__ import annotations
from datetime import date as date_type, time as time_type
from typing import Iterable
from sqlalchemy.orm import Session
from app.models.agent import Agent
from app.models.calendar import TimeSlot, SlotType, SlotStatus, EventType
from app.models.schedule_type import ScheduleType
from app.models.schedule_type_special_slot import ScheduleTypeSpecialSlot
def materialise_special_slots_for_user(
db: Session,
user_id: int,
target_date: date_type,
commit: bool = True,
) -> list[TimeSlot]:
"""Materialise today's special slots for one agent (identified by user_id).
Returns the list of newly created rows (may be empty if all already exist
or the agent has no schedule_type / no active templates).
"""
agent = db.query(Agent).filter(Agent.user_id == user_id).first()
if not agent or not agent.schedule_type_id:
return []
return _materialise_for_agent(db, agent, target_date, commit=commit)
def materialise_special_slots_for_claw(
db: Session,
claw_identifier: str,
target_date: date_type,
commit: bool = True,
) -> list[TimeSlot]:
"""Materialise today's special slots for every agent on a claw instance.
Used by the multi-agent `/calendar/sync` endpoint so plugin-driven
`runSync` cycles see the special slots without each agent having to
hit `/calendar/day` first.
"""
agents = (
db.query(Agent)
.filter(
Agent.claw_identifier == claw_identifier,
Agent.schedule_type_id.isnot(None),
)
.all()
)
created: list[TimeSlot] = []
for agent in agents:
created.extend(_materialise_for_agent(db, agent, target_date, commit=False))
if commit and created:
db.commit()
return created
def _materialise_for_agent(
db: Session,
agent: Agent,
target_date: date_type,
commit: bool,
) -> list[TimeSlot]:
st: ScheduleType | None = (
db.query(ScheduleType).filter(ScheduleType.id == agent.schedule_type_id).first()
)
if not st:
return []
templates: Iterable[ScheduleTypeSpecialSlot] = (
db.query(ScheduleTypeSpecialSlot)
.filter(
ScheduleTypeSpecialSlot.schedule_type_id == st.id,
ScheduleTypeSpecialSlot.is_active.is_(True),
)
.all()
)
created: list[TimeSlot] = []
for tpl in templates:
if _already_materialised(db, agent.user_id, target_date, tpl.id):
continue
slot = _build_time_slot_from_template(
user_id=agent.user_id,
target_date=target_date,
schedule_type=st,
template=tpl,
)
db.add(slot)
created.append(slot)
if commit and created:
db.commit()
for slot in created:
db.refresh(slot)
return created
def _already_materialised(
db: Session,
user_id: int,
target_date: date_type,
template_id: int,
) -> bool:
return (
db.query(TimeSlot.id)
.filter(
TimeSlot.user_id == user_id,
TimeSlot.date == target_date,
TimeSlot.special_slot_id == template_id,
)
.first()
is not None
)
def _build_time_slot_from_template(
*,
user_id: int,
target_date: date_type,
schedule_type: ScheduleType,
template: ScheduleTypeSpecialSlot,
) -> TimeSlot:
# schedule_type.maintenance_from is minutes-since-UTC-midnight; the
# template's minute_in_window is an offset inside that window. Combined
# offset must fit in [0, 1440) and produce a wall-clock time_type.
total_min = (schedule_type.maintenance_from + template.minute_in_window) % 1440
scheduled_at = time_type(hour=total_min // 60, minute=total_min % 60, second=0)
# Merge admin-supplied event_data with bookkeeping pointers so the
# agent (and ARD) can identify the template at wake time.
merged_event_data = dict(template.event_data or {})
merged_event_data.setdefault("source", "schedule_type_special_slot")
merged_event_data["special_slot_id"] = template.id
merged_event_data["special_slot_name"] = template.name
merged_event_data["schedule_type_id"] = schedule_type.id
merged_event_data["schedule_type_name"] = schedule_type.name
return TimeSlot(
user_id=user_id,
date=target_date,
slot_type=SlotType.SYSTEM,
estimated_duration=template.estimated_duration,
scheduled_at=scheduled_at,
attended=False,
event_type=EventType.SYSTEM_EVENT,
event_data=merged_event_data,
priority=template.priority,
status=SlotStatus.NOT_STARTED,
is_admin_locked=True,
special_slot_id=template.id,
)

View File

@@ -1,99 +0,0 @@
# OIDC 接入 — 测试点描述
覆盖范围OIDC 登录、hf 用户与 OIDC 身份绑定、`HARBORFORGE_OIDC_ONLY`
模式、管理员 OIDC 配置页面。涉及仓库分支 `feature/oidc-login`
`HarborForge.Backend` + `HarborForge.Frontend`)。
“本地状态” 列:✅ 已用真实 Keycloak 在本地栈端到端验证;🟡 已用接口/构建
验证但未走真实 IdP UI⬜ 待测。
## 0. 测试环境
- 后端 `http://127.0.0.1:18000`、前端 `http://127.0.0.1:13000`(本地验证栈)
- IdPKeycloak 容器realm `hf`confidential client `hf-client`
- IdP 测试用户:`tester` / `Test123!`emailVerified=true
- 关键约束:**issuer URL 必须浏览器与后端容器都能用同一地址访问**
(否则 token `iss` 校验失败)。本地用宿主机 IP 统一两端。
- 配置项(运行时 env 或 DBDB 覆盖 env
`OIDC_ENABLED / OIDC_ISSUER / OIDC_CLIENT_ID / OIDC_CLIENT_SECRET /
OIDC_REDIRECT_URI / OIDC_SCOPES / OIDC_POST_LOGIN_REDIRECT`
部署级 `HARBORFORGE_OIDC_ONLY`Docker ARG/ENV前后端均有
## 1. 管理员 OIDC 配置(页面 + API
| # | 测试点 | 步骤 | 预期 | 本地 |
|---|--------|------|------|------|
|1.1|读取初始配置|`GET /auth/oidc/settings`admin|返回 `source=env``has_client_secret=false``effective_enabled` 反映 env|✅|
|1.2|保存配置|`PUT /auth/oidc/settings` 填 issuer/client/secret/redirect/scopes/post_login|200`source=db``effective_enabled=true`|✅|
|1.3|client_secret 只写不回显|保存后再 `GET`|响应无明文 secret`has_client_secret=true`|✅|
|1.4|secret 留空保留原值|`PUT` 不带 `client_secret`|原 secret 不变,登录仍可用|🟡|
|1.5|配置即时生效|保存后 `GET /auth/config`|`oidc_enabled` 立即变化,无需重启|✅|
|1.6|页面仅 admin 可见|非 admin 访问 `/settings/oidc`|被重定向到 `/`侧栏无入口API 返回 401/403|🟡|
|1.7|页面展示 Callback URL|打开 OIDC 设置页|醒目显示需在 IdP 注册的 redirect/callback URL、当前状态与配置来源|🟡|
## 2. OIDC 登录流程(授权码)
| # | 测试点 | 步骤 | 预期 | 本地 |
|---|--------|------|------|------|
|2.1|发起登录|`GET /auth/oidc/login`|302 跳转到 IdP authorize 端点state/nonce 入会话 cookie|✅|
|2.2|IdP 登录成功回跳|在 IdP 输入 `tester/Test123!`|302 回 `…/auth/oidc/callback?code=…&state=…`|✅|
|2.3|回调换码并签发|后端处理 callback|校验 ID token(JWKS)→定位绑定用户→签发 hf JWT→302 到前端 `post_login#token=…`|✅|
|2.4|登录态可用|用返回 token 调 `GET /auth/me`|返回被绑定的 hf 用户|✅|
|2.5|前端按钮|登录页点 “Sign in with SSO”|全页跳转到后端 `/auth/oidc/login`|🟡|
|2.6|未配置 OIDC|`OIDC` 关闭时访问 `/auth/oidc/login`|503 “OIDC is not configured”|🟡|
|2.7|token 交换失败|code 失效/被篡改|回前端 `?oidc_error=exchange_failed`,登录页提示|⬜|
## 3. hf 用户 ↔ OIDC 身份绑定
| # | 测试点 | 步骤 | 预期 | 本地 |
|---|--------|------|------|------|
|3.1|管理员绑定|`PUT /users/{id}/oidc-binding` {issuer,subject}admin 或 acc-manager|200用户响应含 `oidc_issuer/oidc_subject`|✅|
|3.2|未绑定身份拒绝登录|解绑后用该 IdP 账号登录|回 `?oidc_error=not_linked`**不签发 token**(不自动开号)|✅|
|3.3|身份唯一性|把同一 (issuer,subject) 绑到另一用户|409 冲突|✅|
|3.4|解绑|`DELETE /users/{id}/oidc-binding`|200绑定清空|✅|
|3.5|绑定鉴权|无凭据 / 普通用户调用绑定 API|401 / 403|✅|
|3.6|API key 通道|admin 的 API key 调用绑定/配置 API|200支持 JWT 或 API key|✅|
|3.7|自助绑定(非 ONLY|登录用户点侧栏 “Link OIDC account” 走一次 OIDC|回 `?oidc_linked=1`,当前账号绑定成功|🟡|
|3.8|自助绑定冲突|自助绑定到已被占用的身份|`?oidc_error=already_bound`|⬜|
|3.9|OIDC_ONLY 下禁自助|ONLY 模式访问 `/auth/oidc/link`|403仅管理员 API 可绑)|🟡|
## 4. HARBORFORGE_OIDC_ONLY 模式
| # | 测试点 | 步骤 | 预期 | 本地 |
|---|--------|------|------|------|
|4.1|配置反映|ONLY=true 时 `GET /auth/config`|`oidc_only=true``password_login=false`|✅|
|4.2|禁用密码登录|`POST /auth/token`|403 “Password login is disabled (OIDC only)”|✅|
|4.3|建用户忽略密码|`POST /users` 带 password|201但 DB `hashed_password=NULL`(无密码用户)|✅|
|4.4|改用户忽略密码|`PATCH /users/{id}` 带 password|密码不被设置|🟡|
|4.5|无密码用户仍可用|对该用户绑定 OIDC、生成 API key|绑定 200、`reset-apikey` 200可经 OIDC 登录|✅|
|4.6|前端隐藏密码 UI|ONLY 模式打开登录页 / 用户管理页|无用户名密码框;用户管理无密码/重置密码组件|🟡|
|4.7|防锁死恢复|ONLY 模式且 OIDC 配错|管理员 API key 仍可调 `GET/PUT /auth/oidc/settings` 修复|✅|
## 5. 回归(不破坏既有功能)
| # | 测试点 | 步骤 | 预期 | 本地 |
|---|--------|------|------|------|
|5.1|默认模式密码登录|未开 ONLY 时 `POST /auth/token`|正常 200 签发 token|✅|
|5.2|用户响应新增字段|`GET /users` `/users/{id}` `/auth/me`|含 `oidc_issuer/oidc_subject`(未绑定为 null|✅|
|5.3|启动迁移幂等|新旧库重复启动后端|`users.oidc_*` 列与 `oidc_settings` 表存在,无报错|✅|
|5.4|前端构建|`npm run build`Docker 镜像)|TS 编译通过|✅|
|5.5|后端导入|镜像内 `import app.main`|无导入错误OIDC 路由注册|✅|
|5.6|镜像参数|前后端 Dockerfile|含 `ARG/ENV HARBORFORGE_OIDC_ONLY`|✅|
## 6. 安全检查点
- ID token 经 IdP JWKS 校验Authlib discovery + nonce签发的是 hf 原有
HS256 JWT依赖强 `SECRET_KEY`(弱 key 后端拒绝启动)。
- `client_secret` 持久化在 DB接口永不回显。
- 绑定/配置接口强制 admin或 account-manager 绑定),支持 API key 作为
OIDC_ONLY 下的恢复通道。
- 未绑定 OIDC 身份一律拒绝,不自动开号(防越权开户)。
- `redirect_uri` 必须在 IdP 精确注册;后端回跳前端地址来自服务端配置
`post_login_redirect`),不接受客户端传入,避免开放重定向。
## 7. 已知限制 / 待补
- 真实浏览器交互点按钮、IdP 同意页、SameSite cookie 行为)需人工过一遍
(本地已用 curl 无头跑通授权码全流程,逻辑等价)。
- 多 IdP / 多 issuer、token 刷新、登出联动SLO未实现超出本次范围。
- 自助绑定相关 UI3.7/3.8)建议人工在浏览器复核。

View File

@@ -1,5 +1,19 @@
#!/bin/sh
# HarborForge backend entrypoint. All config comes from env vars (DATABASE_URL,
# SECRET_KEY, HARBORFORGE_OIDC_ONLY, etc.). First-deploy admin user + OIDC
# issuer config are operator-driven via `docker exec hf-backend hf-cli ...`.
# Wait for wizard config before starting uvicorn
CONFIG_DIR="${CONFIG_DIR:-/config}"
CONFIG_FILE="${CONFIG_FILE:-harborforge.json}"
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

View File

@@ -12,5 +12,3 @@ alembic==1.13.1
python-dotenv==1.0.0
httpx==0.27.0
requests==2.31.0
authlib==1.3.2
itsdangerous==2.2.0

View File

@@ -15,7 +15,7 @@ from fastapi.testclient import TestClient
# ---------------------------------------------------------------------------
# Patch the production engine/SessionLocal BEFORE importing app so that
# startup events (Base.metadata.create_all, init_bootstrap, etc.) use the
# startup events (Base.metadata.create_all, init_wizard, etc.) use the
# in-memory SQLite database instead of trying to connect to MySQL.
# ---------------------------------------------------------------------------

View File

@@ -1,147 +0,0 @@
"""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