30 Commits

Author SHA1 Message Date
h z
b8e413a7ea Merge pull request 'feat(users): PATCH /users/{id}/bind-agent to backfill agents row' (#20) from feat/user-bind-agent into main 2026-05-22 18:58:25 +00:00
hanghang zhang
d41d12aecd feat(users): PATCH /users/{id}/bind-agent to backfill agents row
Companion endpoint for the cli's upcoming `hf user bind-agent` subcommand.
Lets admin retroactively bind an existing user to (agent_id,
claw_identifier) when that user was created before `hf user create`
supported the binding flags (i.e. all of zhi/lyn/mirror/sherlock/orion/
nav on prod today — agents table has 0 rows even though their user rows
exist).

Schema:
  PATCH /users/{identifier}/bind-agent
  body: {agent_id: str, claw_identifier: str}  // both required
  perm: account.create (admin auto)            // same as POST /users

Behaviour:
  * idempotent: re-bind to the same (agent_id, claw_identifier) → 200
    no-op, no extra row
  * 409 if user is already bound to a different pair
  * 409 if requested agent_id is already in use by another user
  * creates the agents row inline; subsequent /schedule-types/agent/
    {agent_id}/assign etc. then work normally

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:58:06 +01:00
h z
a1049492e1 Merge pull request 'fix(schedule-type): accept X-API-Key for CRUD' (#19) from feat/schedule-type-apikey-auth into main 2026-05-22 18:36:20 +00:00
hanghang zhang
8f3c69032f fix(schedule-type): accept X-API-Key for CRUD
The /schedule-types/ router was the last surface still gated on
get_current_user (JWT-only). The companion special-slot router
(PR #18) used get_current_user_or_apikey, so the admin flow was:

  * create a schedule_type → DB direct insert (cli can't reach it)
  * add special slot via API → works

Swaps all 5 CRUD endpoints (list / create / patch / delete /
assign-agent) to get_current_user_or_apikey so the same hzhang
admin api_key that works for special-slot creation now works for
schedule_type creation too. /schedule-types/agent/me already uses
X-Agent-ID headers (not user auth), so no change there.

Existing JWT callers are unaffected — get_current_user_or_apikey
tries api_key first then falls back to JWT.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:35:56 +01:00
h z
d870646e28 Merge pull request 'feat(calendar): maintenance window + schedule_type special slots' (#18) from feat/maintenance-window-and-special-slots into main 2026-05-22 18:19:06 +00:00
hanghang zhang
2cbf6445eb feat(calendar): maintenance window + schedule_type special slots
## What this adds

1. **Maintenance window on ScheduleType**
   - New columns: maintenance_from / maintenance_to (UTC hours, 0-23)
   - Invariant: window is exactly 1 hour (validated in pydantic;
     maintenance_to must equal (maintenance_from + 1) % 24)
   - Default applied via additive migration: 8:00-9:00 UTC for existing
     rows so deployments don't crash on first boot

2. **ScheduleTypeSpecialSlot** — admin-managed slot template
   - New table schedule_type_special_slots
   - Admin (schedule_type.manage) CRUD via
     /schedule-types/{id}/special-slots
   - Fields: name, description, minute_in_window (0-59 inside the
     parent maintenance window), estimated_duration, priority,
     event_data (JSON merged into materialised slot), is_active
   - Unique constraint (schedule_type_id, name) — name is the stable
     human-readable identifier per cohort

3. **Per-agent materialisation**
   - New service app/services/special_slot_materialiser.py
   - GET /calendar/sync calls materialise_special_slots_for_claw
     (idempotent, one row per agent per template per date)
   - GET /calendar/day calls materialise_special_slots_for_user
   - Materialised rows are slot_type=system, event_type=system_event,
     is_admin_locked=true, special_slot_id pointing back to template
   - Plugin's runSync picks them up like any other due slot via the
     normal real-slots query path

4. **Admin-locked enforcement**
   - New TimeSlot columns: is_admin_locked, special_slot_id (FK to
     schedule_type_special_slots, ON DELETE SET NULL)
   - PATCH /calendar/slots/{id}: refuses any edit on admin-locked
     slots (423)
   - POST /calendar/slots/{id}/cancel: refuses cancel on admin-locked
     (423)
   - PATCH /calendar/slots/{id}/agent-update: admin-locked accept only
     ongoing/paused/finished/aborted statuses (423 on other transitions)

5. **Maintenance-window guard on slot creation**
   - POST /calendar/slots: rejects slot_type=system outright (only
     materialiser may create system slots) and rejects any non-system
     slot whose [scheduled_at, +duration] intersects the calling
     user's schedule_type maintenance window (422). Handles 23->0 wrap

6. **Schema response**
   - TimeSlotResponse / CalendarSlotItem now include is_admin_locked
     and special_slot_id so clients can render the lock indicator and
     trace back to the template

## Migration

Additive only — no destructive changes. Lives in _migrate_schema()
in app/main.py; the new schedule_type_special_slots table is created
by Base.metadata.create_all() on first boot.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:18:42 +01:00
h z
4675ab7201 Merge pull request 'feature/oidc-login' (#17) from feature/oidc-login into main
Reviewed-on: #17
2026-05-17 21:27:39 +00:00
a9d075bc19 fix(security): block OIDC-binding privilege escalation
The oidc-binding PUT/DELETE endpoints allowed any account.create holder
(non-admin role 'account-manager') to bind an attacker-controlled OIDC
identity to the admin account (or unbind admin, reopening the OIDC-only
bootstrap window) — full admin takeover.

Non-admin callers may now only manage bindings of non-privileged
accounts: requests targeting an is_admin user, the built-in
acc-mgr/deleted-user, or any holder of account.create / user.reset-apikey
are rejected with 403. Global admins remain unrestricted, so the
intended "account-manager binds normal users" capability is preserved.

Found by post-feature security audit. Verified locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:07:43 +01:00
9429e37542 feat(auth): OIDC-only admin-role bootstrap auto-connect
In OIDC-only mode, before any admin is linked, an IdP user whose token
carries the configured admin role (default "admin"; OIDC_ADMIN_ROLE /
oidc_settings.admin_role) auto-connects to the unbound hf admin on
first OIDC sign-in, then the window self-closes once any admin is
bound. Roles are scanned across userinfo + the (unverified) access
token: realm_access.roles, resource_access.*.roles, roles/role/groups.
Adds admin_role to settings model/env/effective/API and to the wizard
bootstrap config. Replaces the manual admin-subject approach.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:05:39 +01:00
0229fbb54c feat(init): bootstrap OIDC from wizard config
init_wizard applies config['oidc'] on first init: creates the
oidc_settings row and, when admin_subject is given, binds the
bootstrap admin so OIDC-only deployments are reachable. Idempotent —
an existing row / admin binding is preserved (later admin edits via
the API survive restarts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:59 +01:00
ffce4298c8 docs: OIDC feature test plan / test points
Test points for OIDC login, user binding, HARBORFORGE_OIDC_ONLY mode,
and the admin OIDC settings page, with local verification status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:40:26 +01:00
a115e380cb feat(auth): admin-configurable OIDC provider (oidc_settings)
Persist OIDC config in a single-row oidc_settings table; non-empty DB
fields override the OIDC_* env vars (env = bootstrap default). The
Authlib client is rebuilt when config changes.

- GET/PUT /auth/oidc/settings — admin only, via JWT OR API key. The
  API-key path is the recovery channel when OIDC-only mode is on and
  OIDC is misconfigured (avoids total lockout).
- client_secret is write-only: never returned (has_client_secret bool),
  preserved when the field is left blank on update.
- /auth/config, login/link/callback now use the effective (DB|env)
  config so enabling OIDC needs no redeploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:29:15 +01:00
94155614f5 feat(auth): OIDC login + identity binding + HARBORFORGE_OIDC_ONLY
- Generic OIDC (Authlib discovery) Authorization Code flow; backend
  issues the existing HS256 JWT on success. Unbound identities are
  rejected (no auto-provisioning).
- User.oidc_issuer/oidc_subject (unique together) + startup migration.
- PUT/DELETE /users/{id}/oidc-binding (admin or account-manager;
  JWT or API key; 409 on conflict). Self-link /auth/oidc/link
  (non-OIDC_ONLY only). Public GET /auth/config.
- HARBORFORGE_OIDC_ONLY: /auth/token rejected, create/update ignore
  password (passwordless users; API keys + OIDC still work).
- Dockerfile ARG/ENV HARBORFORGE_OIDC_ONLY; authlib+itsdangerous deps;
  SessionMiddleware for OIDC state. Fixed _user_response to expose
  the new binding fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:22:04 +01:00
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
30 changed files with 677 additions and 2284 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_ROOT_PASSWORD=harborforge_root
MYSQL_DATABASE=harborforge MYSQL_DATABASE=harborforge
MYSQL_USER=harborforge MYSQL_USER=harborforge
MYSQL_PASSWORD=harborforge_pass MYSQL_PASSWORD=harborforge_pass
# Full DSN used by the backend container. Default points to a service
# named "mysql" on the same docker network. Override if your DB is elsewhere.
DATABASE_URL=mysql+pymysql://harborforge:harborforge_pass@mysql:3306/harborforge
# --- Application ---------------------------------------------------------- # Application
# Must be 32+ chars and not a placeholder; use: openssl rand -hex 32
SECRET_KEY=change-me-use-openssl-rand-hex-32 SECRET_KEY=change-me-use-openssl-rand-hex-32
LOG_LEVEL=INFO LOG_LEVEL=INFO
# When true: password login is disabled, all sign-in goes through OIDC,
# user creation ignores any password (passwordless users that can only
# authenticate via OIDC binding or API keys). Frontend hides password UI.
HARBORFORGE_OIDC_ONLY=false
# --- Discord wakeup (optional; previously in wizard config) ---------------
# Used by /agents/{id}/wakeup to spin a private Discord channel + DM.
HARBORFORGE_DISCORD_GUILD_ID=
HARBORFORGE_DISCORD_BOT_TOKEN=
# --- OIDC issuer / client_id / client_secret / redirect_uri ---------------
# NOT env vars in v0.4.0+. Configure via:
# docker exec hf-backend hf-cli config oidc \
# --issuer https://login.example.com/realms/foo \
# --client-id harborforge --client-secret <s> \
# --redirect-uri https://hf-api.example.com/auth/oidc/callback \
# --post-login-redirect https://hf.example.com/oidc/callback \
# --enabled true

View File

@@ -42,12 +42,6 @@ COPY requirements.txt ./
COPY entrypoint.sh . COPY entrypoint.sh .
RUN chmod +x entrypoint.sh RUN chmod +x entrypoint.sh
# Install hf-cli as a /usr/local/bin shim that re-enters the app package
# (so `docker exec hf-backend hf-cli admin create-user ...` works). The
# CLI reads the same DATABASE_URL / SECRET_KEY env as the backend.
RUN printf '#!/bin/sh\nexec python -m app.cli "$@"\n' > /usr/local/bin/hf-cli && \
chmod +x /usr/local/bin/hf-cli
# OIDC-only mode: when "true", password login is rejected, user creation # OIDC-only mode: when "true", password login is rejected, user creation
# ignores passwords (passwordless users that sign in via a bound OIDC # ignores passwords (passwordless users that sign in via a bound OIDC
# identity / API keys). Overridable at runtime via the same env var. # identity / API keys). Overridable at runtime via the same env var.

View File

@@ -59,43 +59,22 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De
return user return user
def _lookup_api_key(db: Session, key: str) -> models.User | None:
"""Resolve an API key string to a User; mark last_used_at on hit."""
if not key:
return None
key_obj = db.query(APIKey).filter(APIKey.key == key, APIKey.is_active == True).first() # noqa: E712
if not key_obj:
return None
key_obj.last_used_at = datetime.utcnow()
db.commit()
return db.query(models.User).filter(models.User.id == key_obj.user_id).first()
async def get_current_user_or_apikey( async def get_current_user_or_apikey(
token: str = Depends(oauth2_scheme), token: str = Depends(oauth2_scheme),
api_key: str = Depends(apikey_header), api_key: str = Depends(apikey_header),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""Authenticate via JWT token (Authorization: Bearer <jwt>) OR API key """Authenticate via JWT token OR API key."""
(X-API-Key: <key>, OR — as a convenience for CLI clients that only know
Bearer — Authorization: Bearer <api-key>; falls back when JWT decode fails).
"""
# Native X-API-Key header
if api_key: if api_key:
user = _lookup_api_key(db, 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: if user:
return user return user
# Bearer header — try JWT first, then API key on decode failure
if token: if token:
try:
return await get_current_user(token=token, db=db) return await get_current_user(token=token, db=db)
except HTTPException:
user = _lookup_api_key(db, token)
if user:
return user
raise
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")

View File

@@ -11,7 +11,7 @@ from app.core.config import get_db, settings
from app.models import models from app.models import models
from app.models.role_permission import Permission, Role, RolePermission from app.models.role_permission import Permission, Role, RolePermission
from app.schemas import schemas from app.schemas import schemas
from app.api.deps import Token, verify_password, create_access_token, get_current_user, 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 = APIRouter(prefix="/auth", tags=["Auth"])
@@ -80,7 +80,7 @@ class PermissionIntrospectionResponse(BaseModel):
@router.get("/me/permissions", response_model=PermissionIntrospectionResponse) @router.get("/me/permissions", response_model=PermissionIntrospectionResponse)
async def get_my_permissions( async def get_my_permissions(
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Return the current user's effective permissions for CLI help introspection.""" """Return the current user's effective permissions for CLI help introspection."""

View File

@@ -52,7 +52,6 @@ from app.schemas.calendar import (
) )
from app.services.agent_heartbeat import get_pending_slots_for_agent from app.services.agent_heartbeat import get_pending_slots_for_agent
from app.services.agent_status import ( from app.services.agent_status import (
AgentStatusError,
record_heartbeat, record_heartbeat,
transition_to_busy, transition_to_busy,
transition_to_idle, transition_to_idle,
@@ -206,14 +205,12 @@ def create_slot(
st.maintenance_from, st.maintenance_from,
st.maintenance_to, st.maintenance_to,
): ):
mf_h, mf_m = divmod(st.maintenance_from, 60)
mt_h, mt_m = divmod(st.maintenance_to, 60)
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail=( detail=(
f"slot at {payload.scheduled_at} duration {payload.estimated_duration}min " f"slot at {payload.scheduled_at} duration {payload.estimated_duration}min "
f"intersects the maintenance window " f"intersects the maintenance window "
f"{mf_h:02d}:{mf_m:02d}-{mt_h:02d}:{mt_m:02d} UTC of " f"{st.maintenance_from:02d}:00-{st.maintenance_to:02d}:00 UTC of "
f"schedule_type '{st.name}' — that window is admin-reserved" f"schedule_type '{st.name}' — that window is admin-reserved"
), ),
) )
@@ -344,21 +341,22 @@ def _require_agent(db: Session, agent_id: str, claw_identifier: str) -> Agent:
def _scheduled_inside_window( def _scheduled_inside_window(
scheduled_at, scheduled_at,
estimated_duration_minutes: int, estimated_duration_minutes: int,
window_from_min: int, window_from_hour: int,
window_to_min: int, window_to_hour: int,
) -> bool: ) -> bool:
"""True if [scheduled_at, scheduled_at+duration] intersects [from, to). """True if [scheduled_at, scheduled_at+duration] intersects [from:00, to:00].
Window bounds are minutes-since-UTC-midnight (0-1439). Handles the Handles the 23→0 wrap case (window straddles UTC midnight).
case where the window crosses UTC midnight (e.g. 23:30→01:00).
""" """
start_min = scheduled_at.hour * 60 + scheduled_at.minute start_min = scheduled_at.hour * 60 + scheduled_at.minute
end_min = start_min + max(estimated_duration_minutes, 1) end_min = start_min + max(estimated_duration_minutes, 1)
if window_to_min > window_from_min: win_start_min = window_from_hour * 60
win_end_min = window_to_hour * 60
if win_end_min > win_start_min:
# normal same-day window # normal same-day window
return start_min < window_to_min and end_min > window_from_min return start_min < win_end_min and end_min > win_start_min
# wrap-around: window = [from..1440) [0..to) # wrap-around: window = [from..24:00) [00:00..to)
return (start_min < 1440 and end_min > window_from_min) or end_min > window_to_min return (start_min < 24 * 60 and end_min > win_start_min) or end_min > win_end_min
# Admin-locked special slots accept only these agent-driven status # Admin-locked special slots accept only these agent-driven status
@@ -562,29 +560,6 @@ def agent_update_virtual_slot(
return TimeSlotEditResponse(slot=_slot_to_response(slot), warnings=[]) 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( @router.post(
"/agent/status", "/agent/status",
summary="Update agent runtime status from plugin", summary="Update agent runtime status from plugin",
@@ -595,13 +570,6 @@ def update_agent_status(
): ):
agent = _require_agent(db, payload.agent_id, payload.claw_identifier) agent = _require_agent(db, payload.agent_id, payload.claw_identifier)
target = (payload.status or '').lower().strip() 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: if target == AgentStatus.IDLE.value:
transition_to_idle(db, agent) transition_to_idle(db, agent)
elif target == AgentStatus.BUSY.value: elif target == AgentStatus.BUSY.value:
@@ -615,11 +583,6 @@ def update_agent_status(
transition_to_exhausted(db, agent, reason=reason, recovery_at=payload.recovery_at) transition_to_exhausted(db, agent, reason=reason, recovery_at=payload.recovery_at)
else: else:
raise HTTPException(status_code=400, detail="Unsupported agent status") 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))
db.commit() db.commit()
return {"ok": True, "agent_id": agent.agent_id, "status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status)} return {"ok": True, "agent_id": agent.agent_id, "status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status)}

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

@@ -1,11 +1,8 @@
"""OIDC (OpenID Connect) login + admin-configurable provider settings. """OIDC (OpenID Connect) login + admin-configurable provider settings.
Provider config (issuer / client_id / client_secret / redirect_uri / The OIDC provider can be configured at runtime from the admin UI
scopes / post_login_redirect / admin_role / enabled) lives entirely in (persisted in the oidc_settings table). A stored row's non-empty fields
the `oidc_settings` DB table (single row, id=1) and is set via either override the OIDC_* env vars; env values act as bootstrap defaults.
the admin UI or `docker exec hf-backend hf-cli config oidc ...`.
HARBORFORGE_OIDC_ONLY is the only OIDC-related env var (deploy-time
policy: when true, password login is disabled).
Sign-in policy: an OIDC identity must already be bound to an hf user Sign-in policy: an OIDC identity must already be bound to an hf user
(see PUT /users/{id}/oidc-binding). Unbound identities are rejected. (see PUT /users/{id}/oidc-binding). Unbound identities are rejected.
@@ -54,20 +51,27 @@ class EffectiveOidc:
def get_effective_oidc(db: Session) -> EffectiveOidc: def get_effective_oidc(db: Session) -> EffectiveOidc:
"""DB row is the only source of truth — no env fallback. If the row is
absent OIDC is treated as unconfigured (login attempts will 503)."""
row = db.query(OidcSettings).filter(OidcSettings.id == 1).first() row = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
def pick(db_val, env_val):
return db_val if (db_val is not None and db_val != "") else env_val
if row is None: if row is None:
return EffectiveOidc(False, "", "", "", "", "", "", "admin") return EffectiveOidc(
settings.OIDC_ENABLED, settings.OIDC_ISSUER, settings.OIDC_CLIENT_ID,
settings.OIDC_CLIENT_SECRET, settings.OIDC_REDIRECT_URI,
settings.OIDC_SCOPES, settings.OIDC_POST_LOGIN_REDIRECT,
settings.OIDC_ADMIN_ROLE,
)
return EffectiveOidc( return EffectiveOidc(
bool(row.enabled), bool(row.enabled),
row.issuer or "", pick(row.issuer, settings.OIDC_ISSUER),
row.client_id or "", pick(row.client_id, settings.OIDC_CLIENT_ID),
row.client_secret or "", pick(row.client_secret, settings.OIDC_CLIENT_SECRET),
row.redirect_uri or "", pick(row.redirect_uri, settings.OIDC_REDIRECT_URI),
row.scopes or "", pick(row.scopes, settings.OIDC_SCOPES),
row.post_login_redirect or "", pick(row.post_login_redirect, settings.OIDC_POST_LOGIN_REDIRECT),
getattr(row, "admin_role", None) or "admin", pick(getattr(row, "admin_role", None), settings.OIDC_ADMIN_ROLE),
) )
@@ -301,17 +305,17 @@ def get_oidc_settings(db: Session = Depends(get_db), _: models.User = Depends(_r
row = db.query(OidcSettings).filter(OidcSettings.id == 1).first() row = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
cfg = get_effective_oidc(db) cfg = get_effective_oidc(db)
return OidcSettingsOut( return OidcSettingsOut(
enabled=bool(row.enabled) if row else False, enabled=bool(row.enabled) if row else bool(settings.OIDC_ENABLED),
issuer=(row.issuer if row else None) or None, issuer=(row.issuer if row else None) or settings.OIDC_ISSUER or None,
client_id=(row.client_id if row else None) or None, client_id=(row.client_id if row else None) or settings.OIDC_CLIENT_ID or None,
has_client_secret=bool(row.client_secret if row else None), has_client_secret=bool((row.client_secret if row else None) or settings.OIDC_CLIENT_SECRET),
redirect_uri=(row.redirect_uri if row else None) or None, redirect_uri=(row.redirect_uri if row else None) or settings.OIDC_REDIRECT_URI or None,
scopes=(row.scopes if row else None) or None, scopes=(row.scopes if row else None) or settings.OIDC_SCOPES or None,
post_login_redirect=(row.post_login_redirect if row else None) or None, post_login_redirect=(row.post_login_redirect if row else None) or settings.OIDC_POST_LOGIN_REDIRECT or None,
admin_role=cfg.admin_role, admin_role=cfg.admin_role,
oidc_only=bool(settings.HARBORFORGE_OIDC_ONLY), oidc_only=bool(settings.HARBORFORGE_OIDC_ONLY),
effective_enabled=cfg.configured, effective_enabled=cfg.configured,
source="db", source="db" if row else "env",
) )

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) @router.post("", response_model=schemas.ProjectResponse, status_code=status.HTTP_201_CREATED)
def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
# Project creation is gated by the `project.create` global permission # Check if user is admin
# (admin auto-grants by virtue of is_admin). Any role granted that perm
# via the Role Editor can create projects.
if not current_user.is_admin: if not current_user.is_admin:
from app.models.role_permission import Permission, RolePermission raise HTTPException(status_code=403, detail="Only admins can create projects")
has = (
db.query(Permission.id)
.join(RolePermission, RolePermission.permission_id == Permission.id)
.filter(
RolePermission.role_id == current_user.role_id,
Permission.name == "project.create",
)
.first()
if current_user.role_id
else None
)
if not has:
raise HTTPException(
status_code=403,
detail="Permission denied: project.create required",
)
# Auto-fill owner_name from owner_id # Auto-fill owner_name from owner_id
user = db.query(models.User).filter(models.User.id == project.owner_id).first() user = db.query(models.User).filter(models.User.id == project.owner_id).first()
if not user: if not user:

View File

@@ -57,18 +57,6 @@ def _require_schedule_manage(db: Session, user: User) -> User:
return 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 # Schedule Type CRUD
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -83,7 +71,7 @@ def list_schedule_types(
current_user: User = Depends(get_current_user_or_apikey), current_user: User = Depends(get_current_user_or_apikey),
): ):
_require_schedule_read(db, 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( @router.post(
@@ -108,13 +96,11 @@ def create_schedule_type(
work_to=payload.work_to, work_to=payload.work_to,
entertainment_from=payload.entertainment_from, entertainment_from=payload.entertainment_from,
entertainment_to=payload.entertainment_to, entertainment_to=payload.entertainment_to,
maintenance_from=payload.maintenance_from,
maintenance_to=payload.maintenance_to,
) )
db.add(st) db.add(st)
db.commit() db.commit()
db.refresh(st) db.refresh(st)
return _attach_derived(st) return st
@router.patch( @router.patch(
@@ -134,23 +120,12 @@ def update_schedule_type(
if not st: if not st:
raise HTTPException(404, "Schedule type not found") raise HTTPException(404, "Schedule type not found")
update_fields = payload.model_dump(exclude_unset=True) for field, value in payload.model_dump(exclude_unset=True).items():
for field, value in update_fields.items():
setattr(st, field, value) 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.commit()
db.refresh(st) db.refresh(st)
return _attach_derived(st) return st
@router.delete( @router.delete(
@@ -206,8 +181,7 @@ def get_my_schedule_type(
if not agent.schedule_type_id: if not agent.schedule_type_id:
return None return None
st = db.query(ScheduleType).filter(ScheduleType.id == agent.schedule_type_id).first() return db.query(ScheduleType).filter(ScheduleType.id == agent.schedule_type_id).first()
return _attach_derived(st) if st else None
@router.put( @router.put(

View File

@@ -75,17 +75,15 @@ def _fetch_schedule_type(db: Session, schedule_type_id: int) -> ScheduleType:
def _validate_fits_window( def _validate_fits_window(
minute_in_window: int, minute_in_window: int,
estimated_duration: int, estimated_duration: int,
maintenance_duration_minutes: int,
) -> None: ) -> None:
"""Reject special slots that wouldn't fit inside the parent's maintenance window.""" """Reject special slots that wouldn't fit inside the 1-hour window."""
if minute_in_window + estimated_duration > maintenance_duration_minutes: if minute_in_window + estimated_duration > 60:
raise HTTPException( raise HTTPException(
422, 422,
( (
f"special slot does not fit in maintenance window: " f"special slot does not fit in maintenance window: "
f"minute_in_window={minute_in_window} + " f"minute_in_window={minute_in_window} + "
f"estimated_duration={estimated_duration} > " f"estimated_duration={estimated_duration} > 60"
f"maintenance window {maintenance_duration_minutes}min"
), ),
) )
@@ -129,8 +127,8 @@ def create_special_slot(
current_user: User = Depends(get_current_user_or_apikey), current_user: User = Depends(get_current_user_or_apikey),
): ):
_require_schedule_manage(db, current_user) _require_schedule_manage(db, current_user)
st = _fetch_schedule_type(db, schedule_type_id) _fetch_schedule_type(db, schedule_type_id)
_validate_fits_window(payload.minute_in_window, payload.estimated_duration, st.compute_maintenance_duration()) _validate_fits_window(payload.minute_in_window, payload.estimated_duration)
dup = ( dup = (
db.query(ScheduleTypeSpecialSlot) db.query(ScheduleTypeSpecialSlot)
@@ -190,8 +188,7 @@ def update_special_slot(
update_fields = payload.model_dump(exclude_unset=True) update_fields = payload.model_dump(exclude_unset=True)
next_min = update_fields.get("minute_in_window", slot.minute_in_window) next_min = update_fields.get("minute_in_window", slot.minute_in_window)
next_dur = update_fields.get("estimated_duration", slot.estimated_duration) 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)
_validate_fits_window(next_min, next_dur, parent.compute_maintenance_duration())
for field, value in update_fields.items(): for field, value in update_fields.items():
setattr(slot, field, value) setattr(slot, field, value)

View File

@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash
from app.core.config import get_db, settings from app.core.config import get_db, settings
from app.init_bootstrap import DELETED_USER_USERNAME from app.init_wizard import DELETED_USER_USERNAME
from app.models import models from app.models import models
from app.models.agent import Agent from app.models.agent import Agent
from app.models.role_permission import Permission, Role, RolePermission from app.models.role_permission import Permission, Role, RolePermission
@@ -39,11 +39,7 @@ def _user_response(user: models.User) -> dict:
return data return data
def require_admin(current_user: models.User = Depends(get_current_user_or_apikey)): def require_admin(current_user: models.User = Depends(get_current_user)):
# Accept either OAuth2 JWT or X-API-Key (incl. Bearer-as-apikey fallback)
# so CLI clients using their provisioned api-key can hit admin-gated user
# routes (list / get / update / patch). The admin gate still reads
# User.is_admin — only the auth carrier broadens.
if not current_user.is_admin: if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin required") raise HTTPException(status_code=403, detail="Admin required")
return current_user return current_user
@@ -72,29 +68,11 @@ def require_account_creator(
raise HTTPException(status_code=403, detail="Account creation permission required") raise HTTPException(status_code=403, detail="Account creation permission required")
def _resolve_user_role(db: Session, role_id: int | None, *, is_agent: bool = False) -> Role: def _resolve_user_role(db: Session, role_id: int | None) -> Role:
"""Resolve target role for user creation.
Default policy when caller didn't pin role_id:
- is_agent (i.e. payload had agent_id/claw_identifier) → general-agent
- human user → guest
general-agent ≈ guest + user.reset-self-apikey so agents can rotate
their own API key without admin intervention. Created in
init_bootstrap.py on every startup; falls back to guest if absent
(e.g. very old DB that hasn't been re-seeded yet).
"""
if role_id is None: if role_id is None:
default_name = "general-agent" if is_agent else "guest"
role = db.query(Role).filter(Role.name == default_name).first()
if not role and is_agent:
# general-agent missing from this DB → fall back to guest, log warn
role = db.query(Role).filter(Role.name == "guest").first() role = db.query(Role).filter(Role.name == "guest").first()
if not role: if not role:
raise HTTPException( raise HTTPException(status_code=500, detail="Default guest role is missing")
status_code=500,
detail=f"Default role '{default_name}' is missing (DB not seeded)",
)
return role return role
role = db.query(Role).filter(Role.id == role_id).first() role = db.query(Role).filter(Role.id == role_id).first()
@@ -134,7 +112,7 @@ def create_user(
if existing_agent: if existing_agent:
raise HTTPException(status_code=400, detail="agent_id already in use") raise HTTPException(status_code=400, detail="agent_id already in use")
assigned_role = _resolve_user_role(db, user.role_id, is_agent=has_agent_id) assigned_role = _resolve_user_role(db, user.role_id)
# In OIDC-only mode, ignore any supplied password: the user is created # In OIDC-only mode, ignore any supplied password: the user is created
# passwordless (cannot password-login) and is expected to sign in via a # passwordless (cannot password-login) and is expected to sign in via a
# bound OIDC identity. API keys still work for such users. # bound OIDC identity. API keys still work for such users.
@@ -413,7 +391,7 @@ def delete_user(
if not deleted_user: if not deleted_user:
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail="Built-in deleted-user account not found. 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) _reassign_user_references(db, user.id, deleted_user.id)

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). import os
import json
OIDC issuer/client_id/etc. live in the `oidc_settings` DB table set
via `hf-cli config oidc ...`. The OIDC_ONLY flag remains env-driven
because it's a deploy-time policy, not a per-tenant runtime config.
"""
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from typing import Optional
def _resolve_db_url(env_url: str) -> str:
"""Read DB config from wizard config volume if available, else use env."""
config_dir = os.getenv("CONFIG_DIR", "/config")
config_file = os.getenv("CONFIG_FILE", "harborforge.json")
config_path = os.path.join(config_dir, config_file)
if os.path.exists(config_path):
try:
with open(config_path, "r") as f:
cfg = json.load(f)
db_cfg = cfg.get("database")
if db_cfg:
host = db_cfg.get("host", "mysql")
port = db_cfg.get("port", 3306)
user = db_cfg.get("user", "harborforge")
password = db_cfg.get("password", "harborforge_pass")
database = db_cfg.get("database", "harborforge")
return f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}"
except Exception:
pass
return env_url
class Settings(BaseSettings): class Settings(BaseSettings):
@@ -17,9 +38,19 @@ class Settings(BaseSettings):
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# --- OIDC (generic, OpenID Connect discovery) ---
OIDC_ENABLED: bool = False
OIDC_ISSUER: str = "" # e.g. https://idp.example.com (we use {issuer}/.well-known/openid-configuration)
OIDC_CLIENT_ID: str = ""
OIDC_CLIENT_SECRET: str = ""
OIDC_REDIRECT_URI: str = "" # backend callback, e.g. https://hf-api.example.com/auth/oidc/callback
OIDC_SCOPES: str = "openid email profile"
OIDC_POST_LOGIN_REDIRECT: str = "" # frontend URL to return to (token in fragment). Falls back to "/"
OIDC_ADMIN_ROLE: str = "admin" # OIDC role name that bootstraps the unbound hf admin (OIDC-only)
# When true: no password login at all. Password login endpoint rejects, # When true: no password login at all. Password login endpoint rejects,
# user creation ignores any password (passwordless users that only sign # user creation ignores any password (passwordless user that can only use
# in via a bound OIDC identity / API keys), frontend hides password UI. # API keys / OIDC), and the frontend hides all password UI.
HARBORFORGE_OIDC_ONLY: bool = False HARBORFORGE_OIDC_ONLY: bool = False
class Config: class Config:
@@ -44,7 +75,9 @@ if settings.SECRET_KEY in _WEAK_SECRETS or len(settings.SECRET_KEY) < 32:
"Refusing to start with a default/short key." "Refusing to start with a default/short key."
) )
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) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base() Base = declarative_base()

View File

@@ -1,286 +0,0 @@
"""
HarborForge unconditional startup seeds — runs every time backend boots.
Seeds default permissions, default roles, the `acc-mgr` built-in (account
provisioning agent), and the `deleted-user` foreign-key sink. Idempotent;
existing rows are left alone.
Wizard/.json config bootstrap has been removed entirely as of v0.4.0.
First-deploy admin user, OIDC settings, and discord webhook config all
moved to operator-driven flows:
docker exec hf-backend hf-cli admin create-user --email ... --password ...
docker exec hf-backend hf-cli config oidc --issuer ... --client-id ...
Builtin accounts created here:
- acc-mgr (account-manager role) — cannot log in, used by the
account-creation API as a system principal
- deleted-user — FK sink so user delete doesn't cascade
The bootstrap admin user is NOT created here — that's CLI-driven so
operators pick the email/password themselves.
"""
import logging
from sqlalchemy.orm import Session
from app.models import models
from app.models.role_permission import Role, Permission, RolePermission
logger = logging.getLogger("harborforge.bootstrap")
# ---------------------------------------------------------------------------
# Permissions catalog (canonical; new perms get added on every release)
# ---------------------------------------------------------------------------
DEFAULT_PERMISSIONS = [
# Project permissions
("project.read", "View project", "project"),
("project.write", "Edit project", "project"),
("project.create", "Create a project", "project"),
("project.delete", "Delete project", "project"),
("project.manage_members", "Manage project members", "project"),
# Knowledge base permissions
("knowledge-base.read", "View knowledge bases", "knowledge-base"),
("knowledge-base.create", "Create a knowledge base", "knowledge-base"),
("knowledge-base.update", "Edit a knowledge base and its structure", "knowledge-base"),
("knowledge-base.delete", "Delete a knowledge base", "knowledge-base"),
# Task/Milestone permissions
("task.create", "Create tasks", "task"),
("task.read", "View tasks", "task"),
("task.write", "Edit tasks", "task"),
("task.delete", "Delete tasks", "task"),
("milestone.create", "Create milestones", "milestone"),
("milestone.read", "View milestones", "milestone"),
("milestone.write", "Edit milestones", "milestone"),
("milestone.delete", "Delete milestones", "milestone"),
# Milestone actions
("milestone.freeze", "Freeze milestone scope", "milestone"),
("milestone.start", "Start milestone execution", "milestone"),
("milestone.close", "Close / abort milestone", "milestone"),
# Task actions
("task.close", "Close / cancel a task", "task"),
("task.reopen_closed", "Reopen a closed task", "task"),
("task.reopen_completed", "Reopen a completed task", "task"),
# Proposal actions (permission names kept as propose.* for DB compat)
("propose.accept", "Accept a proposal into a milestone", "propose"),
("propose.reject", "Reject a proposal", "propose"),
("propose.reopen", "Reopen a rejected proposal", "propose"),
# Role/Permission management
("role.manage", "Manage roles and permissions", "admin"),
("account.create", "Create HarborForge accounts", "account"),
# User management
("user.manage", "Manage users", "admin"),
# API key management
("user.reset-self-apikey", "Reset own API key", "user"),
("user.reset-apikey", "Reset any user's API key", "admin"),
# Monitor
("monitor.read", "View monitor", "monitor"),
("monitor.manage", "Manage monitor", "monitor"),
# Calendar
("calendar.read", "View calendar slots and plans", "calendar"),
("calendar.write", "Create and edit calendar slots and plans", "calendar"),
("calendar.manage", "Manage calendar settings and workload policies", "calendar"),
# Webhook
("webhook.manage", "Manage webhooks", "admin"),
# Project member management (used by DELETE /projects/{id}/members/{user_id})
("member.remove", "Remove a project member", "project"),
# Schedule type (calendar templates) — read covers list+detail, manage covers
# create/edit/delete on schedule_types AND their special slots.
("schedule_type.read", "View schedule types and special slots", "calendar"),
("schedule_type.manage", "Create / edit / delete schedule types and slots", "calendar"),
]
def init_default_permissions(db: Session) -> list[Permission]:
"""Insert any missing perms from DEFAULT_PERMISSIONS. Returns all rows."""
created = []
for name, description, category in DEFAULT_PERMISSIONS:
existing = db.query(Permission).filter(Permission.name == name).first()
if not existing:
perm = Permission(name=name, description=description, category=category)
db.add(perm)
created.append(perm)
logger.info("Created permission '%s'", name)
if created:
db.commit()
return db.query(Permission).all()
# ---------------------------------------------------------------------------
# Default roles + permission set per role
# ---------------------------------------------------------------------------
_MGR_PERMISSIONS = {
"project.read", "project.write", "project.create", "project.manage_members",
"knowledge-base.read", "knowledge-base.create", "knowledge-base.update", "knowledge-base.delete",
"task.create", "task.read", "task.write", "task.delete",
"milestone.create", "milestone.read", "milestone.write", "milestone.delete",
"milestone.freeze", "milestone.start", "milestone.close",
"task.close", "task.reopen_closed", "task.reopen_completed",
"propose.accept", "propose.reject", "propose.reopen",
"monitor.read",
"calendar.read", "calendar.write", "calendar.manage",
"user.reset-self-apikey",
}
_DEV_PERMISSIONS = {
"project.read",
"knowledge-base.read", "knowledge-base.update",
"task.create", "task.read", "task.write",
"milestone.read",
"task.close", "task.reopen_closed", "task.reopen_completed",
"monitor.read",
"calendar.read", "calendar.write",
"user.reset-self-apikey",
}
_ACCOUNT_MANAGER_PERMISSIONS = {
"account.create",
"user.reset-apikey",
}
# Default role for agents (assigned automatically by POST /users when
# the create-user payload carries agent_id/claw_identifier — see
# app/api/routers/users.py:_resolve_user_role). Guest-tier reads +
# self-service API-key rotation so agents can manage their own creds
# without admin intervention.
_GENERAL_AGENT_PERMISSIONS = {
"project.read",
"knowledge-base.read",
"task.read",
"milestone.read",
"monitor.read",
"calendar.read",
"user.reset-self-apikey",
}
_DEFAULT_ROLES = [
("admin", "Administrator - full access to all features", None), # None ⇒ all perms
("account-manager", "Account manager - can only create accounts", _ACCOUNT_MANAGER_PERMISSIONS),
("mgr", "Manager - project & milestone management", _MGR_PERMISSIONS),
("dev", "Developer - task execution & daily work", _DEV_PERMISSIONS),
("general-agent", "General agent - read-only + self API key rotation", _GENERAL_AGENT_PERMISSIONS),
("guest", "Guest - read-only access", None), # special: *.read only
]
def _ensure_role(db: Session, name: str, description: str, is_global: bool = True) -> Role:
role = db.query(Role).filter(Role.name == name).first()
if not role:
role = Role(name=name, description=description, is_global=is_global)
db.add(role)
db.commit()
db.refresh(role)
logger.info("Created role '%s' (id=%d)", name, role.id)
return role
def _sync_role_permissions(db: Session, role: Role, target_perm_names: set[str] | None) -> None:
"""Additive: grants missing perms, never revokes manually-granted ones.
``target_perm_names is None`` means **all** perms (admin)."""
all_perms = db.query(Permission).all()
perm_by_name = {p.name: p for p in all_perms}
if target_perm_names is None:
wanted_ids = {p.id for p in all_perms}
else:
wanted_ids = {perm_by_name[n].id for n in target_perm_names if n in perm_by_name}
existing_ids = {rp.permission_id for rp in role.permissions}
added = 0
for pid in wanted_ids - existing_ids:
db.add(RolePermission(role_id=role.id, permission_id=pid))
added += 1
if added:
db.commit()
logger.info("Assigned %d new permissions to role '%s'", added, role.name)
def init_default_roles(db: Session) -> None:
"""Create default roles (admin/account-manager/mgr/dev/guest) + permissions."""
all_perms = db.query(Permission).all()
read_perm_names = {p.name for p in all_perms if p.name.endswith(".read")}
for name, description, perm_set in _DEFAULT_ROLES:
role = _ensure_role(db, name, description)
if name == "guest":
_sync_role_permissions(db, role, read_perm_names)
else:
_sync_role_permissions(db, role, perm_set)
logger.info("Default roles ready (admin / account-manager / mgr / dev / guest)")
# ---------------------------------------------------------------------------
# Built-in user accounts (system principals, cannot log in)
# ---------------------------------------------------------------------------
DELETED_USER_USERNAME = "deleted-user"
def init_acc_mgr_user(db: Session) -> models.User | None:
"""The account-manager system principal. Holds the `account-manager`
role so the account-creation API can attribute new users to it. No
password, no OIDC binding — cannot log in."""
username = "acc-mgr"
existing = db.query(models.User).filter(models.User.username == username).first()
if existing:
return existing
acc_mgr_role = db.query(Role).filter(Role.name == "account-manager").first()
if not acc_mgr_role:
logger.warning("account-manager role not found, skipping acc-mgr user creation")
return None
user = models.User(
username=username,
email="acc-mgr@harborforge.internal",
full_name="Account Manager",
hashed_password=None,
is_admin=False,
is_active=True,
role_id=acc_mgr_role.id,
)
db.add(user)
db.commit()
db.refresh(user)
logger.info("Created acc-mgr user (id=%d) with account-manager role", user.id)
return user
def init_deleted_user(db: Session) -> models.User | None:
"""FK sink for deleted users — when a real user is deleted, all FK
references reassign here instead of cascading."""
existing = db.query(models.User).filter(
models.User.username == DELETED_USER_USERNAME
).first()
if existing:
return existing
user = models.User(
username=DELETED_USER_USERNAME,
email="deleted-user@harborforge.internal",
full_name="Deleted User",
hashed_password=None,
is_admin=False,
is_active=False,
role_id=None,
)
db.add(user)
db.commit()
db.refresh(user)
logger.info("Created deleted-user (id=%d)", user.id)
return user
# ---------------------------------------------------------------------------
# Top-level bootstrap entry point — called from main.py startup
# ---------------------------------------------------------------------------
def run_bootstrap(db: Session) -> None:
"""Idempotent startup seed. Safe to call on every boot.
Does NOT create the admin user — that's CLI-driven (see hf-cli admin
create-user) so operators pick credentials.
"""
init_default_permissions(db)
init_default_roles(db)
init_acc_mgr_user(db)
init_deleted_user(db)
logger.info("Bootstrap seeds complete")

411
app/init_wizard.py Normal file
View File

@@ -0,0 +1,411 @@
"""
HarborForge initialization from AbstractWizard config volume.
Reads config from shared volume (written by AbstractWizard).
On startup, creates admin user and default project if not exists.
"""
import os
import json
import logging
from sqlalchemy.orm import Session
from app.models import models
from app.models.role_permission import Role, Permission, RolePermission
from app.models.oidc_settings import OidcSettings
from app.api.deps import get_password_hash
logger = logging.getLogger("harborforge.init")
CONFIG_DIR = os.getenv("CONFIG_DIR", "/config")
CONFIG_FILE = os.getenv("CONFIG_FILE", "harborforge.json")
def load_config() -> dict | None:
"""Load initialization config from shared volume."""
config_path = os.path.join(CONFIG_DIR, CONFIG_FILE)
if not os.path.exists(config_path):
logger.info("No config file at %s, skipping initialization", config_path)
return None
try:
with open(config_path, "r") as f:
return json.load(f)
except Exception as e:
logger.warning("Failed to read config %s: %s", config_path, e)
return None
def get_db_url(config: dict) -> str | None:
"""Build DATABASE_URL from wizard config, or fall back to env."""
db_cfg = config.get("database")
if not db_cfg:
return os.getenv("DATABASE_URL")
host = db_cfg.get("host", "mysql")
port = db_cfg.get("port", 3306)
user = db_cfg.get("user", "harborforge")
password = db_cfg.get("password", "harborforge_pass")
database = db_cfg.get("database", "harborforge")
return f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}"
def init_admin_user(db: Session, admin_cfg: dict) -> models.User | None:
"""Create admin user if not exists."""
username = admin_cfg.get("username", "admin")
existing = db.query(models.User).filter(models.User.username == username).first()
if existing:
logger.info("Admin user '%s' already exists (id=%d), skipping", username, existing.id)
return existing
password = admin_cfg.get("password", "changeme")
user = models.User(
username=username,
email=admin_cfg.get("email", f"{username}@harborforge.local"),
full_name=admin_cfg.get("full_name", "Admin"),
hashed_password=get_password_hash(password),
is_admin=True,
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
logger.info("Created admin user '%s' (id=%d)", username, user.id)
return user
def init_default_project(db: Session, project_cfg: dict, owner_id: int, owner_name: str = "") -> None:
"""Create default project if configured and not exists."""
name = project_cfg.get("name")
if not name:
return
existing = db.query(models.Project).filter(models.Project.name == name).first()
if existing:
logger.info("Project '%s' already exists (id=%d), skipping", name, existing.id)
return
project = models.Project(
name=name,
description=project_cfg.get("description", ""),
owner_name=project_cfg.get("owner") or owner_name or "",
owner_id=owner_id,
)
db.add(project)
db.commit()
db.refresh(project)
logger.info("Created default project '%s' (id=%d)", name, project.id)
# Default permissions that will be created if not exist
DEFAULT_PERMISSIONS = [
# Project permissions
("project.read", "View project", "project"),
("project.write", "Edit project", "project"),
("project.delete", "Delete project", "project"),
("project.manage_members", "Manage project members", "project"),
# Task/Milestone permissions
("task.create", "Create tasks", "task"),
("task.read", "View tasks", "task"),
("task.write", "Edit tasks", "task"),
("task.delete", "Delete tasks", "task"),
("milestone.create", "Create milestones", "milestone"),
("milestone.read", "View milestones", "milestone"),
("milestone.write", "Edit milestones", "milestone"),
("milestone.delete", "Delete milestones", "milestone"),
# Milestone actions
("milestone.freeze", "Freeze milestone scope", "milestone"),
("milestone.start", "Start milestone execution", "milestone"),
("milestone.close", "Close / abort milestone", "milestone"),
# Task actions
("task.close", "Close / cancel a task", "task"),
("task.reopen_closed", "Reopen a closed task", "task"),
("task.reopen_completed", "Reopen a completed task", "task"),
# Proposal actions (permission names kept as propose.* for DB compat)
("propose.accept", "Accept a proposal into a milestone", "propose"),
("propose.reject", "Reject a proposal", "propose"),
("propose.reopen", "Reopen a rejected proposal", "propose"),
# Role/Permission management
("role.manage", "Manage roles and permissions", "admin"),
("account.create", "Create HarborForge accounts", "account"),
# User management
("user.manage", "Manage users", "admin"),
# API key management
("user.reset-self-apikey", "Reset own API key", "user"),
("user.reset-apikey", "Reset any user's API key", "admin"),
# Monitor
("monitor.read", "View monitor", "monitor"),
("monitor.manage", "Manage monitor", "monitor"),
# Calendar
("calendar.read", "View calendar slots and plans", "calendar"),
("calendar.write", "Create and edit calendar slots and plans", "calendar"),
("calendar.manage", "Manage calendar settings and workload policies", "calendar"),
# Webhook
("webhook.manage", "Manage webhooks", "admin"),
]
def init_default_permissions(db: Session) -> list[Permission]:
"""Create default permissions if they don't exist. Returns all permissions."""
created = []
for name, description, category in DEFAULT_PERMISSIONS:
existing = db.query(Permission).filter(Permission.name == name).first()
if not existing:
perm = Permission(name=name, description=description, category=category)
db.add(perm)
created.append(perm)
logger.info("Created permission '%s'", name)
if created:
db.commit()
# Return all permissions
return db.query(Permission).all()
# ---------------------------------------------------------------------------
# Default role → permission mapping
# ---------------------------------------------------------------------------
# mgr: project management + all milestone/task/proposal actions
_MGR_PERMISSIONS = {
"project.read", "project.write", "project.manage_members",
"task.create", "task.read", "task.write", "task.delete",
"milestone.create", "milestone.read", "milestone.write", "milestone.delete",
"milestone.freeze", "milestone.start", "milestone.close",
"task.close", "task.reopen_closed", "task.reopen_completed",
"propose.accept", "propose.reject", "propose.reopen",
"monitor.read",
"calendar.read", "calendar.write", "calendar.manage",
"user.reset-self-apikey",
}
# dev: day-to-day development work — no freeze/start/close milestone, no accept/reject proposal
_DEV_PERMISSIONS = {
"project.read",
"task.create", "task.read", "task.write",
"milestone.read",
"task.close", "task.reopen_closed", "task.reopen_completed",
"monitor.read",
"calendar.read", "calendar.write",
"user.reset-self-apikey",
}
_ACCOUNT_MANAGER_PERMISSIONS = {
"account.create",
"user.reset-apikey",
}
# Role definitions: (name, description, permission_set)
_DEFAULT_ROLES = [
("admin", "Administrator - full access to all features", None), # None ⇒ all perms
("account-manager", "Account manager - can only create accounts", _ACCOUNT_MANAGER_PERMISSIONS),
("mgr", "Manager - project & milestone management", _MGR_PERMISSIONS),
("dev", "Developer - task execution & daily work", _DEV_PERMISSIONS),
("guest", "Guest - read-only access", None), # special: *.read only
]
def _ensure_role(db: Session, name: str, description: str, is_global: bool = True) -> Role:
"""Get or create a role by name."""
role = db.query(Role).filter(Role.name == name).first()
if not role:
role = Role(name=name, description=description, is_global=is_global)
db.add(role)
db.commit()
db.refresh(role)
logger.info("Created role '%s' (id=%d)", name, role.id)
return role
def _sync_role_permissions(db: Session, role: Role, target_perm_names: set[str] | None) -> None:
"""Ensure *role* has exactly the permissions in *target_perm_names*.
* ``None`` means **all** permissions (admin).
* The special sentinel ``"__read_only__"`` is handled by the caller passing
just the ``*.read`` names.
Only adds missing permissions; never removes manually-granted ones (additive).
"""
all_perms = db.query(Permission).all()
perm_by_name = {p.name: p for p in all_perms}
if target_perm_names is None:
wanted_ids = {p.id for p in all_perms}
else:
wanted_ids = {perm_by_name[n].id for n in target_perm_names if n in perm_by_name}
existing_ids = {rp.permission_id for rp in role.permissions}
added = 0
for pid in wanted_ids - existing_ids:
db.add(RolePermission(role_id=role.id, permission_id=pid))
added += 1
if added:
db.commit()
logger.info("Assigned %d new permissions to role '%s'", added, role.name)
def init_admin_role(db: Session, admin_user: models.User) -> None:
"""Create default roles (admin / mgr / dev / guest) with preset permissions."""
all_perms = db.query(Permission).all()
read_perm_names = {p.name for p in all_perms if p.name.endswith(".read")}
for name, description, perm_set in _DEFAULT_ROLES:
role = _ensure_role(db, name, description)
if name == "guest":
_sync_role_permissions(db, role, read_perm_names)
else:
_sync_role_permissions(db, role, perm_set)
logger.info("Default roles setup complete (admin, mgr, dev, guest)")
def init_acc_mgr_user(db: Session) -> models.User | None:
"""Create the built-in acc-mgr user if not exists.
This user:
- Has role 'account-manager' (can only create accounts)
- Cannot log in (no password, hashed_password=None)
- Cannot be deleted (enforced in delete endpoint)
- Is created automatically after wizard initialization
"""
username = "acc-mgr"
existing = db.query(models.User).filter(models.User.username == username).first()
if existing:
logger.info("acc-mgr user already exists (id=%d), skipping", existing.id)
return existing
# Find account-manager role
acc_mgr_role = db.query(Role).filter(Role.name == "account-manager").first()
if not acc_mgr_role:
logger.warning("account-manager role not found, skipping acc-mgr user creation")
return None
user = models.User(
username=username,
email="acc-mgr@harborforge.internal",
full_name="Account Manager",
hashed_password=None, # Cannot log in — no password
is_admin=False,
is_active=True,
role_id=acc_mgr_role.id,
)
db.add(user)
db.commit()
db.refresh(user)
logger.info("Created acc-mgr user (id=%d) with account-manager role", user.id)
return user
DELETED_USER_USERNAME = "deleted-user"
def init_deleted_user(db: Session) -> models.User | None:
"""Create the built-in deleted-user if not exists.
This user serves as a foreign key sink: when a real user is deleted,
all references are reassigned here instead of cascading deletes.
It has no role (no permissions) and cannot log in.
"""
existing = db.query(models.User).filter(
models.User.username == DELETED_USER_USERNAME
).first()
if existing:
logger.info("deleted-user already exists (id=%d), skipping", existing.id)
return existing
user = models.User(
username=DELETED_USER_USERNAME,
email="deleted-user@harborforge.internal",
full_name="Deleted User",
hashed_password=None,
is_admin=False,
is_active=False,
role_id=None,
)
db.add(user)
db.commit()
db.refresh(user)
logger.info("Created deleted-user (id=%d)", user.id)
return user
def init_oidc_settings(db: Session, oidc_cfg: dict, admin_user: models.User | None) -> None:
"""Bootstrap OIDC from the wizard config (first init only).
Creates the single oidc_settings row if absent so the deployment comes
up with OIDC configured. If admin_subject is given, binds the bootstrap
admin so it can sign in (critical in OIDC-only mode). Idempotent: an
existing row / existing admin binding is left untouched so later admin
edits via the API are not clobbered on restart."""
if not oidc_cfg:
return
existing = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
if existing is None:
db.add(OidcSettings(
id=1,
enabled=bool(oidc_cfg.get("enabled", True)),
issuer=(oidc_cfg.get("issuer") or "").strip() or None,
client_id=(oidc_cfg.get("client_id") or "").strip() or None,
client_secret=oidc_cfg.get("client_secret") or None,
redirect_uri=(oidc_cfg.get("redirect_uri") or "").strip() or None,
scopes=(oidc_cfg.get("scopes") or "").strip() or None,
post_login_redirect=(oidc_cfg.get("post_login_redirect") or "").strip() or None,
admin_role=(oidc_cfg.get("admin_role") or "").strip() or None,
))
db.commit()
logger.info("OIDC settings bootstrapped from wizard config")
admin_subject = (oidc_cfg.get("admin_subject") or "").strip()
issuer = (oidc_cfg.get("issuer") or "").strip()
if admin_user and admin_subject and issuer and not admin_user.oidc_subject:
clash = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == admin_subject,
models.User.id != admin_user.id,
).first()
if clash:
logger.warning("Admin OIDC subject already bound to '%s'; skipping admin bind", clash.username)
else:
admin_user.oidc_issuer = issuer
admin_user.oidc_subject = admin_subject
db.commit()
logger.info("Bootstrap admin '%s' bound to OIDC subject", admin_user.username)
def run_init(db: Session) -> None:
"""Main initialization entry point. Reads config from shared volume."""
config = load_config()
if not config:
return
logger.info("Running HarborForge initialization from wizard config")
# Initialize default permissions and admin role (always run)
all_perms = init_default_permissions(db)
logger.info("Default permissions initialized: %d total", len(all_perms))
# Admin user
admin_cfg = config.get("admin")
admin_user = None
if admin_cfg:
admin_user = init_admin_user(db, admin_cfg)
# Create admin role and assign to admin user
if admin_user:
init_admin_role(db, admin_user)
# Built-in acc-mgr user (after roles are created)
init_acc_mgr_user(db)
# Built-in deleted-user (foreign key sink for deleted accounts)
init_deleted_user(db)
# Default project
project_cfg = config.get("default_project")
if project_cfg and admin_user:
init_default_project(db, project_cfg, admin_user.id, admin_user.username)
# OIDC bootstrap (provider config + optional bootstrap-admin binding)
init_oidc_settings(db, config.get("oidc") or {}, admin_user)
logger.info("Initialization complete")

View File

@@ -42,22 +42,24 @@ def version():
@app.get("/config/status", tags=["System"]) @app.get("/config/status", tags=["System"])
def config_status(): def config_status():
"""Has the deployment been bootstrapped (admin user exists)? """Check if HarborForge has been initialized (reads from config volume).
Frontend uses this instead of contacting the wizard directly."""
Frontend hits this on mount to decide whether to show login or a import os, json
"no admin yet, run hf-cli admin create-user" placeholder. With the config_dir = os.getenv("CONFIG_DIR", "/config")
wizard removed in v0.4.0 the only deploy-time bootstrap step is the config_file = os.getenv("CONFIG_FILE", "harborforge.json")
operator running `docker exec hf-backend hf-cli admin create-user ...` config_path = os.path.join(config_dir, config_file)
once; this endpoint just reports whether that has happened. if not os.path.exists(config_path):
""" return {"initialized": False}
from app.core.config import SessionLocal
from app.models import models
db = SessionLocal()
try: try:
admin_count = db.query(models.User).filter(models.User.is_admin == True).count() # noqa: E712 with open(config_path, "r") as f:
return {"initialized": admin_count > 0} cfg = json.load(f)
finally: return {
db.close() "initialized": cfg.get("initialized", False),
"backend_url": cfg.get("backend_url"),
"discord": cfg.get("discord") or {},
}
except Exception:
return {"initialized": False}
# Register routers # Register routers
from app.api.routers.auth import router as auth_router from app.api.routers.auth import router as auth_router
@@ -79,7 +81,6 @@ from app.api.routers.schedule_type import router as schedule_type_router
from app.api.routers.schedule_type_special_slot import router as schedule_type_special_slot_router from app.api.routers.schedule_type_special_slot import router as schedule_type_special_slot_router
from app.api.routers.calendar import router as calendar_router from app.api.routers.calendar import router as calendar_router
from app.api.routers.oidc import router as oidc_router from app.api.routers.oidc import router as oidc_router
from app.api.routers.knowledge import router as knowledge_router
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(oidc_router) app.include_router(oidc_router)
@@ -100,7 +101,6 @@ app.include_router(essentials_router)
app.include_router(schedule_type_router) app.include_router(schedule_type_router)
app.include_router(schedule_type_special_slot_router) app.include_router(schedule_type_special_slot_router)
app.include_router(calendar_router) app.include_router(calendar_router)
app.include_router(knowledge_router)
# Auto schema migration for lightweight deployments # Auto schema migration for lightweight deployments
@@ -400,9 +400,9 @@ def _migrate_schema():
db.execute(text("ALTER TABLE agents ADD COLUMN schedule_type_id INTEGER NULL")) db.execute(text("ALTER TABLE agents ADD COLUMN schedule_type_id INTEGER NULL"))
# --- schedule_types: add maintenance_from / maintenance_to --- # --- schedule_types: add maintenance_from / maintenance_to ---
# Default 8:009:00 UTC for existing rows; the maintenance # Default 8:009:00 UTC for existing rows; the 1-hour-window
# duration invariant (1-180min) is enforced at the schema # invariant is enforced at the schema level for any NEW rows by
# level for any NEW rows by ScheduleTypeCreate validator. # the pydantic ScheduleTypeCreate validator.
if _has_table(db, "schedule_types"): if _has_table(db, "schedule_types"):
if not _has_column(db, "schedule_types", "maintenance_from"): if not _has_column(db, "schedule_types", "maintenance_from"):
db.execute(text( db.execute(text(
@@ -413,29 +413,6 @@ def _migrate_schema():
"ALTER TABLE schedule_types ADD COLUMN maintenance_to INT NOT NULL DEFAULT 9" "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 --- # --- time_slots: admin-locked + special_slot pointer ---
if _has_table(db, "time_slots"): if _has_table(db, "time_slots"):
if not _has_column(db, "time_slots", "is_admin_locked"): if not _has_column(db, "time_slots", "is_admin_locked"):
@@ -490,17 +467,15 @@ def _sync_default_user_roles(db):
@app.on_event("startup") @app.on_event("startup")
def startup(): def startup():
from app.core.config import Base, engine, SessionLocal from app.core.config import Base, engine, SessionLocal
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar, minimum_workload, schedule_type, schedule_type_special_slot, oidc_settings, 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, schedule_type_special_slot, oidc_settings
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_migrate_schema() _migrate_schema()
# Idempotent startup seed: permissions, default roles, built-in # Initialize from AbstractWizard (admin user, default project, etc.)
# accounts (acc-mgr, deleted-user). The admin user + OIDC config are from app.init_wizard import run_init
# NOT created here — they're operator-driven via hf-cli.
from app.init_bootstrap import run_bootstrap
db = SessionLocal() db = SessionLocal()
try: try:
run_bootstrap(db) run_init(db)
_sync_default_user_roles(db) _sync_default_user_roles(db)
finally: finally:
db.close() db.close()

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,17 +1,9 @@
"""ScheduleType model — defines work/entertainment/maintenance time periods. """ScheduleType model — defines work/entertainment/maintenance time periods.
Each ScheduleType defines the daily work, entertainment, and maintenance Each ScheduleType defines the daily work, entertainment, and maintenance
windows for agents who reference this type. All bounds are stored as windows. Agents reference a schedule_type to know when they should be
**minutes-since-UTC-midnight** (0-1439 inclusive) so half-hour and other working, when they can engage in entertainment, and when the system
sub-hour boundaries are exact. requires them to surrender control for admin-scheduled special slots.
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.
""" """
from sqlalchemy import Column, Integer, String, DateTime from sqlalchemy import Column, Integer, String, DateTime
@@ -34,26 +26,52 @@ class ScheduleType(Base):
comment="Human-readable schedule type name (e.g., 'standard', 'night-shift')", comment="Human-readable schedule type name (e.g., 'standard', 'night-shift')",
) )
# Minutes since UTC midnight, 0-1439 inclusive. work_from = Column(
work_from = Column(Integer, nullable=False, comment="Work period start (minutes since UTC midnight)") Integer,
work_to = Column(Integer, nullable=False, comment="Work period end (minutes since UTC midnight)") nullable=False,
comment="Work period start hour (0-23, UTC)",
)
entertainment_from = Column(Integer, nullable=False, comment="Entertainment start (minutes since UTC midnight)") work_to = Column(
entertainment_to = Column(Integer, nullable=False, comment="Entertainment end (minutes since UTC midnight)") Integer,
nullable=False,
comment="Work period end hour (0-23, UTC)",
)
# Maintenance window — admin-owned, variable length (1-180 min). entertainment_from = Column(
# Default 8:009:00 UTC = 480540 minutes for existing rows. 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)",
)
# -----------------------------------------------------------------
# Maintenance window — every agent on this schedule_type must
# surrender work/entertainment slots during this hour. Admin-created
# special slots tied to this schedule_type can only be scheduled
# inside this window. The window is always exactly 1 hour.
#
# Default (when columns are added via additive migration to existing
# rows) is 8:009:00 UTC so deployments stay sane until an operator
# picks proper hours per schedule_type.
# -----------------------------------------------------------------
maintenance_from = Column( maintenance_from = Column(
Integer, Integer,
nullable=False, nullable=False,
server_default="480", server_default="8",
comment="Maintenance start (minutes since UTC midnight, default 480 = 8:00 UTC).", comment="Maintenance window start hour (0-23, UTC). Window is exactly 1h.",
) )
maintenance_to = Column( maintenance_to = Column(
Integer, Integer,
nullable=False, nullable=False,
server_default="540", server_default="9",
comment="Maintenance end (minutes since UTC midnight, default 540 = 9:00 UTC). Duration ((to-from) mod 1440) must be in [1, 180].", comment="Maintenance window end hour (0-23, UTC). Must equal (maintenance_from + 1) % 24.",
) )
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -65,19 +83,3 @@ class ScheduleType(Base):
back_populates="schedule_type", back_populates="schedule_type",
cascade="all, delete-orphan", 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,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,44 +1,27 @@
"""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, model_validator
from typing import Optional from typing import Optional
_MAX_MIN = 1440 # 24 * 60 — exclusive upper bound def _validate_maintenance_window(maintenance_from: int, maintenance_to: int) -> None:
"""Maintenance window must be exactly 1 hour (handles 23→0 wrap)."""
expected_to = (maintenance_from + 1) % 24
def _maintenance_duration(maint_from: int, maint_to: int) -> int: if maintenance_to != expected_to:
"""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( raise ValueError(
f"maintenance window duration must be in [1, 180] minutes; " f"maintenance window must be exactly 1 hour: "
f"got {dur} (from={maint_from}, to={maint_to})" f"expected maintenance_to={expected_to}, got {maintenance_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): class ScheduleTypeCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=64) 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_from: int = Field(..., ge=0, le=23)
work_to: int = Field(..., ge=0, lt=_MAX_MIN) work_to: int = Field(..., ge=0, le=23)
entertainment_from: int = Field(..., ge=0, lt=_MAX_MIN) entertainment_from: int = Field(..., ge=0, le=23)
entertainment_to: int = Field(..., ge=0, lt=_MAX_MIN) entertainment_to: int = Field(..., ge=0, le=23)
maintenance_from: int = Field(480, ge=0, lt=_MAX_MIN, description="Maintenance start (default 480 = 8:00 UTC)") maintenance_from: int = Field(8, ge=0, le=23, description="Maintenance window start hour UTC (default 8)")
maintenance_to: int = Field(540, ge=0, lt=_MAX_MIN, description="Maintenance end; (to-from) mod 1440 in [1,180]") maintenance_to: int = Field(9, ge=0, le=23, description="Maintenance window end hour UTC; must equal (maintenance_from+1) % 24")
@model_validator(mode="after") @model_validator(mode="after")
def _check_maintenance(self): def _check_maintenance(self):
@@ -48,12 +31,12 @@ class ScheduleTypeCreate(BaseModel):
class ScheduleTypeUpdate(BaseModel): class ScheduleTypeUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=64) name: Optional[str] = Field(None, min_length=1, max_length=64)
work_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN) work_from: Optional[int] = Field(None, ge=0, le=23)
work_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN) work_to: Optional[int] = Field(None, ge=0, le=23)
entertainment_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN) entertainment_from: Optional[int] = Field(None, ge=0, le=23)
entertainment_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN) entertainment_to: Optional[int] = Field(None, ge=0, le=23)
maintenance_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN) maintenance_from: Optional[int] = Field(None, ge=0, le=23)
maintenance_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN) maintenance_to: Optional[int] = Field(None, ge=0, le=23)
@model_validator(mode="after") @model_validator(mode="after")
def _check_maintenance(self): def _check_maintenance(self):
@@ -73,7 +56,6 @@ class ScheduleTypeResponse(BaseModel):
entertainment_to: int entertainment_to: int
maintenance_from: int maintenance_from: int
maintenance_to: int maintenance_to: int
maintenance_duration_minutes: Optional[int] = None # derived; populated by router
class Config: class Config:
from_attributes = True from_attributes = True

View File

@@ -9,8 +9,8 @@ from pydantic import BaseModel, Field
class SpecialSlotCreate(BaseModel): class SpecialSlotCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=64) name: str = Field(..., min_length=1, max_length=64)
description: Optional[str] = Field(None, max_length=512) 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") minute_in_window: int = Field(0, ge=0, le=59, description="Minute offset (0-59) 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)") estimated_duration: int = Field(15, ge=1, le=60, description="Duration in minutes; must fit inside the 1-hour maintenance window")
priority: int = Field(50, ge=0, le=99) 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") event_data: Optional[dict[str, Any]] = Field(None, description="JSON payload merged into every materialised slot's event_data")
is_active: bool = True is_active: bool = True
@@ -19,8 +19,8 @@ class SpecialSlotCreate(BaseModel):
class SpecialSlotUpdate(BaseModel): class SpecialSlotUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=64) name: Optional[str] = Field(None, min_length=1, max_length=64)
description: Optional[str] = Field(None, max_length=512) description: Optional[str] = Field(None, max_length=512)
minute_in_window: Optional[int] = Field(None, ge=0, le=179) minute_in_window: Optional[int] = Field(None, ge=0, le=59)
estimated_duration: Optional[int] = Field(None, ge=1, le=180) estimated_duration: Optional[int] = Field(None, ge=1, le=60)
priority: Optional[int] = Field(None, ge=0, le=99) priority: Optional[int] = Field(None, ge=0, le=99)
event_data: Optional[dict[str, Any]] = None event_data: Optional[dict[str, Any]] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None

View File

@@ -1,25 +1,17 @@
from __future__ import annotations from __future__ import annotations
import os
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
import requests import requests
from fastapi import HTTPException from fastapi import HTTPException
from app.services.harborforge_config import get_discord_wakeup_config
DISCORD_API_BASE = "https://discord.com/api/v10" DISCORD_API_BASE = "https://discord.com/api/v10"
WAKEUP_CATEGORY_NAME = "HarborForge Wakeup" WAKEUP_CATEGORY_NAME = "HarborForge Wakeup"
def _discord_config() -> dict[str, str | None]:
"""Discord wakeup is configured via env vars (previously read from the
AbstractWizard config file). Returns guild_id+bot_token or Nones."""
return {
"guild_id": os.getenv("HARBORFORGE_DISCORD_GUILD_ID") or None,
"bot_token": os.getenv("HARBORFORGE_DISCORD_BOT_TOKEN") or None,
}
def _headers(bot_token: str) -> dict[str, str]: def _headers(bot_token: str) -> dict[str, str]:
return { return {
"Authorization": f"Bot {bot_token}", "Authorization": f"Bot {bot_token}",
@@ -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]: 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") guild_id = cfg.get("guild_id")
bot_token = cfg.get("bot_token") bot_token = cfg.get("bot_token")
if not guild_id or not bot_token: if not guild_id or not bot_token:

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

@@ -145,11 +145,11 @@ def _build_time_slot_from_template(
schedule_type: ScheduleType, schedule_type: ScheduleType,
template: ScheduleTypeSpecialSlot, template: ScheduleTypeSpecialSlot,
) -> TimeSlot: ) -> TimeSlot:
# schedule_type.maintenance_from is minutes-since-UTC-midnight; the scheduled_at = time_type(
# template's minute_in_window is an offset inside that window. Combined hour=schedule_type.maintenance_from,
# offset must fit in [0, 1440) and produce a wall-clock time_type. minute=template.minute_in_window,
total_min = (schedule_type.maintenance_from + template.minute_in_window) % 1440 second=0,
scheduled_at = time_type(hour=total_min // 60, minute=total_min % 60, second=0) )
# Merge admin-supplied event_data with bookkeeping pointers so the # Merge admin-supplied event_data with bookkeeping pointers so the
# agent (and ARD) can identify the template at wake time. # agent (and ARD) can identify the template at wake time.
merged_event_data = dict(template.event_data or {}) merged_event_data = dict(template.event_data or {})

View File

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

View File

@@ -15,7 +15,7 @@ from fastapi.testclient import TestClient
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Patch the production engine/SessionLocal BEFORE importing app so that # Patch the production engine/SessionLocal BEFORE importing app so that
# startup events (Base.metadata.create_all, init_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. # 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