32 Commits

Author SHA1 Message Date
h z
6fd37ab46d Merge pull request 'feat(schedule_type): minute-precision windows + variable maintenance length' (#21) from feat/schedule-type-minutes into main 2026-05-22 19:19:21 +00:00
hanghang zhang
a0b3380654 feat(schedule_type): minute-precision windows + variable maintenance length
Lifts the two hard restrictions in PR #18:
  * window bounds were `int hour` (0-23) → now `int minutes-since-UTC-midnight` (0-1439)
  * maintenance window was exactly 1 hour → now any duration in [1, 180] minutes
    ((maint_to - maint_from) mod 1440)

## Schema migration (additive)

`_migrate_schema()` detects legacy "hours" rows (any row where MAX of the
6 window columns is ≤ 23) and multiplies each column by 60 to convert
to minutes. Idempotent — post-conversion values are well above 23 so
the guard never fires twice.

## Touched surfaces

- `models/schedule_type.py` — column comments updated; new
  `compute_maintenance_duration()` helper (returns 1-1440 min, treats
  from==to as 1440 which is then rejected by validator)
- `schemas/schedule_type.py` — `*_from`/`*_to` upper bound 23 → 1440;
  `_validate_maintenance_window` accepts 1-180min duration; response
  includes derived `maintenance_duration_minutes`
- `schemas/schedule_type_special_slot.py` — `minute_in_window` max
  59→179; `estimated_duration` max 60→180
- `routers/schedule_type.py` — PATCH re-validates merged maintenance
  pair (partial updates can put the row into an invalid combo the
  pydantic single-field validator can't catch); `_attach_derived`
  populates the new response field
- `routers/schedule_type_special_slot.py` — `_validate_fits_window`
  now takes the parent's maintenance duration instead of hard-coded 60
- `routers/calendar.py` — `_scheduled_inside_window` arg renamed
  hour→min; the maintenance-window guard error message formats
  HH:MM not HH:00
- `services/special_slot_materialiser.py` — materialised
  `scheduled_at` derived from `(maint_from_min + tpl.minute_in_window)`
  with hour/minute split

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 20:18:21 +01:00
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
24 changed files with 608 additions and 1036 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

@@ -1,5 +1,4 @@
"""Shared auth dependencies.""" """Shared auth dependencies."""
import hashlib
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, APIKeyHeader from fastapi.security import OAuth2PasswordBearer, APIKeyHeader
@@ -60,49 +59,22 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De
return user return user
def hash_api_key(raw: str) -> str:
"""SHA-256 of a raw API key. Keys are high-entropy random tokens, so a
fast hash (not bcrypt) is appropriate and allows O(1) lookup by hash."""
return hashlib.sha256(raw.encode()).hexdigest()
def _lookup_api_key(db: Session, key: str) -> models.User | None:
"""Resolve an API key string to a User; mark last_used_at on hit."""
if not key:
return None
key_obj = db.query(APIKey).filter(APIKey.key_hash == hash_api_key(key), APIKey.is_active == True).first() # noqa: E712
if not key_obj:
return None
key_obj.last_used_at = datetime.utcnow()
db.commit()
return db.query(models.User).filter(models.User.id == key_obj.user_id).first()
async def get_current_user_or_apikey( 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 user: if key_obj:
return user key_obj.last_used_at = datetime.utcnow()
db.commit()
# Bearer header — try JWT first, then API key on decode failure user = db.query(models.User).filter(models.User.id == key_obj.user_id).first()
if token:
try:
return await get_current_user(token=token, db=db)
except HTTPException:
user = _lookup_api_key(db, token)
if user: if user:
return user return user
raise if token:
return await get_current_user(token=token, db=db)
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,
@@ -562,29 +561,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,31 +571,19 @@ 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 if target == AgentStatus.IDLE.value:
# no-op rather than a 500. Lets plugin status gates / cli `--set` transition_to_idle(db, agent)
# be safe to fire-and-forget without first reading current state. elif target == AgentStatus.BUSY.value:
current = agent.status.value if hasattr(agent.status, 'value') else str(agent.status) transition_to_busy(db, agent, slot_type=SlotType.WORK)
if current == target: elif target == AgentStatus.ON_CALL.value:
return {"ok": True, "agent_id": agent.agent_id, "status": current, "no_change": True} transition_to_busy(db, agent, slot_type=SlotType.ON_CALL)
try: elif target == AgentStatus.OFFLINE.value:
if target == AgentStatus.IDLE.value: transition_to_offline(db, agent)
transition_to_idle(db, agent) elif target == AgentStatus.EXHAUSTED.value:
elif target == AgentStatus.BUSY.value: reason = ExhaustReason.BILLING if payload.exhaust_reason == 'billing' else ExhaustReason.RATE_LIMIT
transition_to_busy(db, agent, slot_type=SlotType.WORK) transition_to_exhausted(db, agent, reason=reason, recovery_at=payload.recovery_at)
elif target == AgentStatus.ON_CALL.value: else:
transition_to_busy(db, agent, slot_type=SlotType.ON_CALL) raise HTTPException(status_code=400, detail="Unsupported agent status")
elif target == AgentStatus.OFFLINE.value:
transition_to_offline(db, agent)
elif target == AgentStatus.EXHAUSTED.value:
reason = ExhaustReason.BILLING if payload.exhaust_reason == 'billing' else ExhaustReason.RATE_LIMIT
transition_to_exhausted(db, agent, reason=reason, recovery_at=payload.recovery_at)
else:
raise HTTPException(status_code=400, detail="Unsupported agent status")
except AgentStatusError as e:
# State-machine violation (e.g. busy → busy via wrong precondition)
# → 409 with the rejected transition explained, instead of a 500.
db.rollback()
raise HTTPException(status_code=409, detail=str(e))
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

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

View File

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

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

@@ -7,9 +7,9 @@ from pydantic import BaseModel
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash, hash_api_key from app.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 == "guest").first()
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()
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)
@@ -464,10 +442,9 @@ def reset_user_apikey(
existing_key.is_active = False existing_key.is_active = False
db.flush() db.flush()
# Create new key (store only the hash + a display prefix) # Create new key
new_key = APIKey( new_key = APIKey(
key_hash=hash_api_key(new_key_value), key=new_key_value,
key_prefix=new_key_value[:8],
name=f"{target_user.username}-key", name=f"{target_user.username}-key",
user_id=target_user.id, user_id=target_user.id,
is_active=True, is_active=True,

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,14 +38,20 @@ class Settings(BaseSettings):
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# When true: no password login at all. Password login endpoint rejects, # --- OIDC (generic, OpenID Connect discovery) ---
# user creation ignores any password (passwordless users that only sign OIDC_ENABLED: bool = False
# in via a bound OIDC identity / API keys), frontend hides password UI. OIDC_ISSUER: str = "" # e.g. https://idp.example.com (we use {issuer}/.well-known/openid-configuration)
HARBORFORGE_OIDC_ONLY: bool = False 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)
# Mark the OIDC state/session cookie Secure (HTTPS-only). Defaults to True # When true: no password login at all. Password login endpoint rejects,
# for production; set SESSION_COOKIE_SECURE=false for plain-HTTP local dev. # user creation ignores any password (passwordless user that can only use
SESSION_COOKIE_SECURE: bool = True # API keys / OIDC), and the frontend hides all password UI.
HARBORFORGE_OIDC_ONLY: bool = False
class Config: class Config:
env_file = ".env" env_file = ".env"
@@ -48,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,278 +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"),
# 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",
"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",
"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",
"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

@@ -27,7 +27,7 @@ app.add_middleware(
secret_key=settings.SECRET_KEY, secret_key=settings.SECRET_KEY,
session_cookie="hf_oidc", session_cookie="hf_oidc",
same_site="lax", same_site="lax",
https_only=settings.SESSION_COOKIE_SECURE, https_only=False,
max_age=600, max_age=600,
) )
@@ -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
@@ -449,19 +451,6 @@ def _migrate_schema():
"CREATE INDEX idx_time_slots_special_slot_id ON time_slots (special_slot_id)" "CREATE INDEX idx_time_slots_special_slot_id ON time_slots (special_slot_id)"
)) ))
# --- api_keys: migrate legacy plaintext `key` -> hashed `key_hash` ---
# Only runs on deployments that still have the old plaintext column;
# fresh installs get key_hash/key_prefix directly from create_all.
if _has_table(db, "api_keys") and _has_column(db, "api_keys", "key"):
if not _has_column(db, "api_keys", "key_hash"):
db.execute(text("ALTER TABLE api_keys ADD COLUMN key_hash VARCHAR(64) NULL"))
if not _has_column(db, "api_keys", "key_prefix"):
db.execute(text("ALTER TABLE api_keys ADD COLUMN key_prefix VARCHAR(16) NULL"))
db.execute(text("ALTER TABLE api_keys MODIFY COLUMN `key` VARCHAR(64) NULL"))
db.execute(text("UPDATE api_keys SET key_hash = SHA2(`key`, 256), key_prefix = LEFT(`key`, 8) WHERE key_hash IS NULL AND `key` IS NOT NULL"))
db.execute(text("UPDATE api_keys SET `key` = NULL WHERE `key` IS NOT NULL"))
_ensure_unique_index(db, "api_keys", "idx_api_keys_key_hash", "key_hash")
# --- schedule_type_special_slots: create-table is handled by # --- schedule_type_special_slots: create-table is handled by
# Base.metadata.create_all on first boot; no migration needed here # Base.metadata.create_all on first boot; no migration needed here
# because there is no legacy table to evolve. Future schema bumps # because there is no legacy table to evolve. Future schema bumps
@@ -505,13 +494,11 @@ def startup():
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

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

View File

@@ -105,9 +105,7 @@ class CommentBase(BaseModel):
class CommentCreate(CommentBase): class CommentCreate(CommentBase):
task_id: int task_id: int
# author_id is NOT accepted from the client — the comment is always author_id: int
# attributed to the authenticated caller (server-side) to prevent
# author spoofing.
class CommentUpdate(BaseModel): class CommentUpdate(BaseModel):

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

@@ -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.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------