12 Commits

Author SHA1 Message Date
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
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
34 changed files with 301 additions and 2686 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,17 +42,5 @@ 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
# ignores passwords (passwordless users that sign in via a bound OIDC
# identity / API keys). Overridable at runtime via the same env var.
ARG HARBORFORGE_OIDC_ONLY=false
ENV HARBORFORGE_OIDC_ONLY=${HARBORFORGE_OIDC_ONLY}
EXPOSE 8000 EXPOSE 8000
ENTRYPOINT ["./entrypoint.sh"] ENTRYPOINT ["./entrypoint.sh"]

View File

@@ -59,43 +59,22 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De
return user return user
def _lookup_api_key(db: Session, key: str) -> models.User | None:
"""Resolve an API key string to a User; mark last_used_at on hit."""
if not key:
return None
key_obj = db.query(APIKey).filter(APIKey.key == key, APIKey.is_active == True).first() # noqa: E712
if not key_obj:
return None
key_obj.last_used_at = datetime.utcnow()
db.commit()
return db.query(models.User).filter(models.User.id == key_obj.user_id).first()
async def get_current_user_or_apikey( async def get_current_user_or_apikey(
token: str = Depends(oauth2_scheme), token: str = Depends(oauth2_scheme),
api_key: str = Depends(apikey_header), api_key: str = Depends(apikey_header),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""Authenticate via JWT token (Authorization: Bearer <jwt>) OR API key """Authenticate via JWT token OR API key."""
(X-API-Key: <key>, OR — as a convenience for CLI clients that only know
Bearer — Authorization: Bearer <api-key>; falls back when JWT decode fails).
"""
# Native X-API-Key header
if api_key: if api_key:
user = _lookup_api_key(db, api_key) key_obj = db.query(APIKey).filter(APIKey.key == api_key, APIKey.is_active == True).first()
if 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,15 +11,13 @@ 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"])
@router.post("/token", response_model=Token) @router.post("/token", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
if settings.HARBORFORGE_OIDC_ONLY:
raise HTTPException(status_code=403, detail="Password login is disabled (OIDC only)")
user = db.query(models.User).filter(models.User.username == form_data.username).first() user = db.query(models.User).filter(models.User.username == form_data.username).first()
if not user or not verify_password(form_data.password, user.hashed_password or ""): if not user or not verify_password(form_data.password, user.hashed_password or ""):
raise HTTPException(status_code=401, detail="Incorrect username or password", raise HTTPException(status_code=401, detail="Incorrect username or password",
@@ -80,7 +78,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

@@ -21,11 +21,6 @@ from app.core.config import get_db
from app.models.calendar import SchedulePlan, SlotStatus, SlotType, TimeSlot from app.models.calendar import SchedulePlan, SlotStatus, SlotType, TimeSlot
from app.models.models import User from app.models.models import User
from app.models.agent import Agent, AgentStatus, ExhaustReason from app.models.agent import Agent, AgentStatus, ExhaustReason
from app.models.schedule_type import ScheduleType
from app.services.special_slot_materialiser import (
materialise_special_slots_for_claw,
materialise_special_slots_for_user,
)
from app.schemas.calendar import ( from app.schemas.calendar import (
AgentHeartbeatResponse, AgentHeartbeatResponse,
AgentStatusUpdateRequest, AgentStatusUpdateRequest,
@@ -52,7 +47,6 @@ from app.schemas.calendar import (
) )
from app.services.agent_heartbeat import get_pending_slots_for_agent from app.services.agent_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,
@@ -149,8 +143,6 @@ def _slot_to_response(slot: TimeSlot) -> TimeSlotResponse:
priority=slot.priority, priority=slot.priority,
status=slot.status.value if hasattr(slot.status, "value") else str(slot.status), status=slot.status.value if hasattr(slot.status, "value") else str(slot.status),
plan_id=slot.plan_id, plan_id=slot.plan_id,
is_admin_locked=bool(getattr(slot, "is_admin_locked", False)),
special_slot_id=getattr(slot, "special_slot_id", None),
created_at=slot.created_at, created_at=slot.created_at,
updated_at=slot.updated_at, updated_at=slot.updated_at,
) )
@@ -176,48 +168,6 @@ def create_slot(
""" """
target_date = payload.date or date_type.today() target_date = payload.date or date_type.today()
# --- Maintenance-window guard ---
# Non-`system` slots may not be placed inside the schedule_type's
# 1-hour maintenance window. The window is admin-territory, reserved
# for materialised special slots from `schedule_type_special_slots`.
# `system` slot_type is itself reserved server-side (the materialiser
# is the only legitimate caller) — refuse it here outright so the
# public API cannot manufacture a fake admin-locked slot.
if payload.slot_type == SlotType.SYSTEM:
raise HTTPException(
status_code=422,
detail=(
"slot_type='system' is reserved for schedule_type special slots "
"and cannot be created via this endpoint"
),
)
_agent_for_user = (
db.query(Agent).filter(Agent.user_id == current_user.id).first()
)
if _agent_for_user and _agent_for_user.schedule_type_id:
st = (
db.query(ScheduleType)
.filter(ScheduleType.id == _agent_for_user.schedule_type_id)
.first()
)
if st and _scheduled_inside_window(
payload.scheduled_at,
payload.estimated_duration,
st.maintenance_from,
st.maintenance_to,
):
mf_h, mf_m = divmod(st.maintenance_from, 60)
mt_h, mt_m = divmod(st.maintenance_to, 60)
raise HTTPException(
status_code=422,
detail=(
f"slot at {payload.scheduled_at} duration {payload.estimated_duration}min "
f"intersects the maintenance window "
f"{mf_h:02d}:{mf_m:02d}-{mt_h:02d}:{mt_m:02d} UTC of "
f"schedule_type '{st.name}' — that window is admin-reserved"
),
)
# --- Overlap check (hard reject) --- # --- Overlap check (hard reject) ---
conflicts = check_overlap_for_create( conflicts = check_overlap_for_create(
db, db,
@@ -286,8 +236,6 @@ def _real_slot_to_item(slot: TimeSlot) -> CalendarSlotItem:
priority=slot.priority, priority=slot.priority,
status=slot.status.value if hasattr(slot.status, "value") else str(slot.status), status=slot.status.value if hasattr(slot.status, "value") else str(slot.status),
plan_id=slot.plan_id, plan_id=slot.plan_id,
is_admin_locked=bool(getattr(slot, "is_admin_locked", False)),
special_slot_id=getattr(slot, "special_slot_id", None),
created_at=slot.created_at, created_at=slot.created_at,
updated_at=slot.updated_at, updated_at=slot.updated_at,
) )
@@ -341,47 +289,7 @@ def _require_agent(db: Session, agent_id: str, claw_identifier: str) -> Agent:
return agent return agent
def _scheduled_inside_window(
scheduled_at,
estimated_duration_minutes: int,
window_from_min: int,
window_to_min: int,
) -> bool:
"""True if [scheduled_at, scheduled_at+duration] intersects [from, to).
Window bounds are minutes-since-UTC-midnight (0-1439). Handles the
case where the window crosses UTC midnight (e.g. 23:30→01:00).
"""
start_min = scheduled_at.hour * 60 + scheduled_at.minute
end_min = start_min + max(estimated_duration_minutes, 1)
if window_to_min > window_from_min:
# normal same-day window
return start_min < window_to_min and end_min > window_from_min
# wrap-around: window = [from..1440) [0..to)
return (start_min < 1440 and end_min > window_from_min) or end_min > window_to_min
# Admin-locked special slots accept only these agent-driven status
# transitions; movement / cancellation / arbitrary status edits are
# rejected because the schedule_type owner is the source of truth.
_ADMIN_LOCKED_ALLOWED_STATUSES = {
SlotStatusEnum.ONGOING,
SlotStatusEnum.PAUSED,
SlotStatusEnum.FINISHED,
SlotStatusEnum.ABORTED,
}
def _apply_agent_slot_update(slot: TimeSlot, payload: SlotAgentUpdate) -> None: def _apply_agent_slot_update(slot: TimeSlot, payload: SlotAgentUpdate) -> None:
if getattr(slot, "is_admin_locked", False):
if payload.status not in _ADMIN_LOCKED_ALLOWED_STATUSES:
raise HTTPException(
status_code=423,
detail=(
f"slot {slot.id} is admin-locked (special slot); only "
f"ongoing/paused/finished/aborted are allowed via agent-update"
),
)
slot.status = payload.status.value slot.status = payload.status.value
if payload.started_at is not None: if payload.started_at is not None:
slot.started_at = payload.started_at slot.started_at = payload.started_at
@@ -453,68 +361,6 @@ def agent_heartbeat(
) )
@router.get(
"/sync",
summary="Sync today's schedules for all agents on a claw instance",
)
def sync_schedules(
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
db: Session = Depends(get_db),
):
"""Return today's slots for all agents belonging to the given claw instance.
Used by the HF OpenClaw plugin to maintain a local schedule cache.
Returns a dict of { agent_id: [slots] } for all agents with matching
claw_identifier.
"""
today = date_type.today()
# Materialise today's special slots for every agent on this claw
# before reading. This is idempotent — re-runs against an already-
# materialised (agent, date, template) are no-ops. Plugin's runSync
# picks them up like any other slot via the normal real_slots query.
materialise_special_slots_for_claw(db, x_claw_identifier, today, commit=True)
# Find all agents on this claw instance
agents = (
db.query(Agent)
.filter(Agent.claw_identifier == x_claw_identifier)
.all()
)
schedules: dict[str, list[dict]] = {}
for agent in agents:
# Get real slots for today
real_slots = (
db.query(TimeSlot)
.filter(
TimeSlot.user_id == agent.user_id,
TimeSlot.date == today,
TimeSlot.status.notin_(list(_INACTIVE_STATUSES)),
)
.all()
)
items = [_real_slot_to_item(s).model_dump(mode="json") for s in real_slots]
# Get virtual plan slots
virtual_slots = get_virtual_slots_for_date(db, agent.user_id, today)
for vs in virtual_slots:
items.append(_virtual_slot_to_item(vs).model_dump(mode="json"))
schedules[agent.agent_id] = items
# Record heartbeat for liveness
for agent in agents:
record_heartbeat(db, agent)
db.commit()
return {
"schedules": schedules,
"date": today.isoformat(),
"agent_count": len(agents),
}
@router.patch( @router.patch(
"/slots/{slot_id}/agent-update", "/slots/{slot_id}/agent-update",
response_model=TimeSlotEditResponse, response_model=TimeSlotEditResponse,
@@ -562,29 +408,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 +418,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)}
@@ -647,10 +458,6 @@ def get_calendar_day(
""" """
target_date = date or date_type.today() target_date = date or date_type.today()
# Materialise today's special slots for this user before reading,
# so the day-view returns them alongside any user-created slots.
materialise_special_slots_for_user(db, current_user.id, target_date, commit=True)
# 1. Fetch real slots for the day # 1. Fetch real slots for the day
real_slots = ( real_slots = (
db.query(TimeSlot) db.query(TimeSlot)
@@ -726,20 +533,6 @@ def edit_real_slot(
if slot is None: if slot is None:
raise HTTPException(status_code=404, detail="Slot not found") raise HTTPException(status_code=404, detail="Slot not found")
# --- Admin-locked guard ---
# Special slots materialised from a schedule_type template are
# admin-owned; agents may complete/abort/pause/resume via the
# plugin-facing agent-update endpoint but cannot edit time/type/
# duration/event-data via this user-facing edit endpoint.
if getattr(slot, "is_admin_locked", False):
raise HTTPException(
status_code=423,
detail=(
f"slot {slot.id} is admin-locked (materialised from a special "
f"slot template); only the schedule_type owner can edit it"
),
)
# --- Past-slot guard --- # --- Past-slot guard ---
try: try:
guard_edit_real_slot(db, slot) guard_edit_real_slot(db, slot)
@@ -907,16 +700,6 @@ def cancel_real_slot(
if slot is None: if slot is None:
raise HTTPException(status_code=404, detail="Slot not found") raise HTTPException(status_code=404, detail="Slot not found")
# --- Admin-locked guard ---
if getattr(slot, "is_admin_locked", False):
raise HTTPException(
status_code=423,
detail=(
f"slot {slot.id} is admin-locked (materialised from a special "
f"slot template); only the schedule_type owner can cancel it"
),
)
# --- Past-slot guard --- # --- Past-slot guard ---
try: try:
guard_cancel_real_slot(db, slot) guard_cancel_real_slot(db, slot)

View File

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

View File

@@ -153,27 +153,9 @@ def _generate_project_code(db, name: str) -> str:
@router.post("", response_model=schemas.ProjectResponse, status_code=status.HTTP_201_CREATED) @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

@@ -1,235 +0,0 @@
"""ScheduleType API router.
CRUD for schedule types (work/entertainment time periods)
and agent schedule type assignment.
"""
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.orm import Session
from typing import List
from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey
from app.models.models import User
from app.models.agent import Agent
from app.models.schedule_type import ScheduleType
from app.models.role_permission import Permission, RolePermission
from app.schemas.schedule_type import (
ScheduleTypeCreate,
ScheduleTypeUpdate,
ScheduleTypeResponse,
AgentScheduleTypeAssign,
)
router = APIRouter(prefix="/schedule-types", tags=["ScheduleTypes"])
# ---------------------------------------------------------------------------
# Permission helpers
# ---------------------------------------------------------------------------
def _has_permission(db: Session, user: User, permission_name: str) -> bool:
if user.is_admin:
return True
if not user.role_id:
return False
return (
db.query(RolePermission)
.join(Permission)
.filter(
RolePermission.role_id == user.role_id,
Permission.name == permission_name,
)
.first()
is not None
)
def _require_schedule_read(db: Session, user: User) -> User:
if not _has_permission(db, user, "schedule_type.read"):
raise HTTPException(403, "Permission denied: schedule_type.read")
return user
def _require_schedule_manage(db: Session, user: User) -> User:
if not _has_permission(db, user, "schedule_type.manage"):
raise HTTPException(403, "Permission denied: schedule_type.manage")
return user
def _attach_derived(st: ScheduleType) -> ScheduleType:
"""Attach derived fields (maintenance_duration_minutes) so the
pydantic ScheduleTypeResponse picks them up via from_attributes.
Pydantic with from_attributes reads attributes off the ORM object;
setting a transient attr here avoids having to convert through dict.
"""
if st is not None:
st.maintenance_duration_minutes = st.compute_maintenance_duration()
return st
# ---------------------------------------------------------------------------
# Schedule Type CRUD
# ---------------------------------------------------------------------------
@router.get(
"/",
response_model=List[ScheduleTypeResponse],
summary="List all schedule types",
)
def list_schedule_types(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_read(db, current_user)
return [_attach_derived(st) for st in db.query(ScheduleType).all()]
@router.post(
"/",
response_model=ScheduleTypeResponse,
summary="Create a schedule type",
)
def create_schedule_type(
payload: ScheduleTypeCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_manage(db, current_user)
existing = db.query(ScheduleType).filter(ScheduleType.name == payload.name).first()
if existing:
raise HTTPException(409, f"Schedule type '{payload.name}' already exists")
st = ScheduleType(
name=payload.name,
work_from=payload.work_from,
work_to=payload.work_to,
entertainment_from=payload.entertainment_from,
entertainment_to=payload.entertainment_to,
maintenance_from=payload.maintenance_from,
maintenance_to=payload.maintenance_to,
)
db.add(st)
db.commit()
db.refresh(st)
return _attach_derived(st)
@router.patch(
"/{schedule_type_id}",
response_model=ScheduleTypeResponse,
summary="Update a schedule type",
)
def update_schedule_type(
schedule_type_id: int,
payload: ScheduleTypeUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_manage(db, current_user)
st = db.query(ScheduleType).filter(ScheduleType.id == schedule_type_id).first()
if not st:
raise HTTPException(404, "Schedule type not found")
update_fields = payload.model_dump(exclude_unset=True)
for field, value in update_fields.items():
setattr(st, field, value)
# Re-validate maintenance after merge (partial updates can put the row
# into an invalid window combo that the pydantic schema couldn't catch
# because it only saw one field).
from app.schemas.schedule_type import _validate_maintenance_window
try:
_validate_maintenance_window(st.maintenance_from, st.maintenance_to)
except ValueError as e:
db.rollback()
raise HTTPException(422, str(e))
db.commit()
db.refresh(st)
return _attach_derived(st)
@router.delete(
"/{schedule_type_id}",
summary="Delete a schedule type",
)
def delete_schedule_type(
schedule_type_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_manage(db, current_user)
st = db.query(ScheduleType).filter(ScheduleType.id == schedule_type_id).first()
if not st:
raise HTTPException(404, "Schedule type not found")
# Check if any agents are using this schedule type
agents_using = db.query(Agent).filter(Agent.schedule_type_id == schedule_type_id).count()
if agents_using > 0:
raise HTTPException(
409,
f"Cannot delete: {agents_using} agent(s) are assigned to this schedule type",
)
db.delete(st)
db.commit()
return {"ok": True, "deleted": schedule_type_id}
# ---------------------------------------------------------------------------
# Agent schedule type assignment (agent-facing, uses X-Agent-ID header)
# ---------------------------------------------------------------------------
@router.get(
"/agent/me",
response_model=ScheduleTypeResponse | None,
summary="Get my schedule type",
)
def get_my_schedule_type(
x_agent_id: str = Header(..., alias="X-Agent-ID"),
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
db: Session = Depends(get_db),
):
agent = (
db.query(Agent)
.filter(Agent.agent_id == x_agent_id, Agent.claw_identifier == x_claw_identifier)
.first()
)
if not agent:
raise HTTPException(404, "Agent not found")
if not agent.schedule_type_id:
return None
st = db.query(ScheduleType).filter(ScheduleType.id == agent.schedule_type_id).first()
return _attach_derived(st) if st else None
@router.put(
"/agent/{agent_id}/assign",
summary="Assign a schedule type to an agent",
)
def assign_schedule_type(
agent_id: str,
payload: AgentScheduleTypeAssign,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_or_apikey),
):
_require_schedule_manage(db, current_user)
agent = db.query(Agent).filter(Agent.agent_id == agent_id).first()
if not agent:
raise HTTPException(404, f"Agent '{agent_id}' not found")
st = db.query(ScheduleType).filter(ScheduleType.name == payload.schedule_type_name).first()
if not st:
raise HTTPException(404, f"Schedule type '{payload.schedule_type_name}' not found")
agent.schedule_type_id = st.id
db.commit()
return {"ok": True, "agent_id": agent_id, "schedule_type": st.name}

View File

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

View File

@@ -8,8 +8,8 @@ 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 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
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
@@ -32,18 +32,12 @@ def _user_response(user: models.User) -> dict:
"role_name": user.role_name, "role_name": user.role_name,
"agent_id": user.agent.agent_id if user.agent else None, "agent_id": user.agent.agent_id if user.agent else None,
"discord_user_id": user.discord_user_id, "discord_user_id": user.discord_user_id,
"oidc_issuer": user.oidc_issuer,
"oidc_subject": user.oidc_subject,
"created_at": user.created_at, "created_at": user.created_at,
} }
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 +66,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,14 +110,8 @@ 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 hashed_password = get_password_hash(user.password) if user.password else None
# passwordless (cannot password-login) and is expected to sign in via a
# bound OIDC identity. API keys still work for such users.
if settings.HARBORFORGE_OIDC_ONLY:
hashed_password = None
else:
hashed_password = get_password_hash(user.password) if user.password else None
db_user = models.User( db_user = models.User(
username=user.username, username=user.username,
email=user.email, email=user.email,
@@ -221,7 +191,7 @@ def update_user(
if payload.full_name is not None: if payload.full_name is not None:
user.full_name = payload.full_name user.full_name = payload.full_name
if payload.password is not None and payload.password.strip() and not settings.HARBORFORGE_OIDC_ONLY: if payload.password is not None and payload.password.strip():
user.hashed_password = get_password_hash(payload.password) user.hashed_password = get_password_hash(payload.password)
if payload.role_id is not None: if payload.role_id is not None:
@@ -243,71 +213,6 @@ def update_user(
return _user_response(user) return _user_response(user)
@router.patch("/{identifier}/bind-agent", response_model=schemas.UserResponse)
def bind_agent(
identifier: str,
payload: schemas.UserBindAgentRequest,
db: Session = Depends(get_db),
_: models.User = Depends(require_account_creator),
):
"""Bind an existing user to (agent_id, claw_identifier).
Backfill path for users that were created via `hf user create` before
the cli supported `--agent-id` / `--claw-identifier` flags. Creates
the `agents` row that should have been written at user-create time.
Idempotent: if the user is already bound to the same
(agent_id, claw_identifier), returns the user unchanged (200, no-op).
Rejects (409) if:
- the user is bound to a DIFFERENT (agent_id, claw_identifier)
- the requested agent_id is already in use by another user
Permission: account.create (admin auto-grants) — same gate as
POST /users so the surface stays symmetric.
"""
user = _find_user_by_id_or_username(db, identifier)
if not user:
raise HTTPException(status_code=404, detail="User not found")
existing_agent_for_user = db.query(Agent).filter(Agent.user_id == user.id).first()
if existing_agent_for_user:
if (
existing_agent_for_user.agent_id == payload.agent_id
and existing_agent_for_user.claw_identifier == payload.claw_identifier
):
# idempotent re-bind
return _user_response(user)
raise HTTPException(
status_code=409,
detail=(
f"User '{user.username}' is already bound to agent "
f"'{existing_agent_for_user.agent_id}' on claw "
f"'{existing_agent_for_user.claw_identifier}'"
),
)
existing_for_agent_id = (
db.query(Agent).filter(Agent.agent_id == payload.agent_id).first()
)
if existing_for_agent_id:
raise HTTPException(
status_code=409,
detail=f"agent_id '{payload.agent_id}' already in use by another user",
)
db.add(
Agent(
user_id=user.id,
agent_id=payload.agent_id,
claw_identifier=payload.claw_identifier,
)
)
db.commit()
db.refresh(user)
return _user_response(user)
_BUILTIN_USERNAMES = {"acc-mgr", DELETED_USER_USERNAME} _BUILTIN_USERNAMES = {"acc-mgr", DELETED_USER_USERNAME}
@@ -413,7 +318,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)
@@ -509,92 +414,3 @@ def list_user_worklogs(
if current_user.id != user.id and not current_user.is_admin: if current_user.id != user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Forbidden") raise HTTPException(status_code=403, detail="Forbidden")
return db.query(WorkLog).filter(WorkLog.user_id == user.id).order_by(WorkLog.logged_date.desc()).limit(limit).all() return db.query(WorkLog).filter(WorkLog.user_id == user.id).order_by(WorkLog.logged_date.desc()).limit(limit).all()
# ---- OIDC identity binding ------------------------------------------------
class OidcBindingRequest(BaseModel):
issuer: str
subject: str
class OidcBindingResponse(BaseModel):
user_id: int
username: str
oidc_issuer: str | None = None
oidc_subject: str | None = None
def _assert_can_manage_oidc_binding(db: Session, caller: models.User, target: models.User) -> None:
"""Global admins may (un)bind anyone. Non-admin account managers may
only operate on non-privileged accounts — never on an admin or another
privileged account — otherwise binding an attacker-controlled OIDC
identity to an admin would be a privilege-escalation primitive."""
if getattr(caller, "is_admin", False):
return
privileged = (
getattr(target, "is_admin", False)
or target.username in ("acc-mgr", "deleted-user")
or _has_global_permission(db, target, "account.create")
or _has_global_permission(db, target, "user.reset-apikey")
)
if privileged:
raise HTTPException(
status_code=403,
detail="Only a global admin may manage the OIDC binding of a privileged account",
)
@router.put("/{identifier}/oidc-binding", response_model=OidcBindingResponse)
def bind_user_oidc(
identifier: str,
payload: OidcBindingRequest,
db: Session = Depends(get_db),
caller: models.User = Depends(require_account_creator),
):
"""Bind an hf user to an external OIDC identity (issuer + subject).
Admin or account-manager (JWT or API key). Account managers may not
target privileged/admin accounts. One OIDC identity maps to at most
one user."""
issuer = (payload.issuer or "").strip()
subject = (payload.subject or "").strip()
if not issuer or not subject:
raise HTTPException(status_code=400, detail="issuer and subject are required")
user = _find_user_by_id_or_username(db, identifier)
if not user:
raise HTTPException(status_code=404, detail="User not found")
_assert_can_manage_oidc_binding(db, caller, user)
clash = db.query(models.User).filter(
models.User.oidc_issuer == issuer,
models.User.oidc_subject == subject,
models.User.id != user.id,
).first()
if clash:
raise HTTPException(status_code=409, detail=f"OIDC identity already bound to user '{clash.username}'")
user.oidc_issuer = issuer
user.oidc_subject = subject
db.commit()
db.refresh(user)
return OidcBindingResponse(user_id=user.id, username=user.username,
oidc_issuer=user.oidc_issuer, oidc_subject=user.oidc_subject)
@router.delete("/{identifier}/oidc-binding", response_model=OidcBindingResponse)
def unbind_user_oidc(
identifier: str,
db: Session = Depends(get_db),
caller: models.User = Depends(require_account_creator),
):
"""Remove a user's OIDC binding. Admin or account-manager; account
managers may not target privileged/admin accounts."""
user = _find_user_by_id_or_username(db, identifier)
if not user:
raise HTTPException(status_code=404, detail="User not found")
_assert_can_manage_oidc_binding(db, caller, user)
user.oidc_issuer = None
user.oidc_subject = None
db.commit()
db.refresh(user)
return OidcBindingResponse(user_id=user.id, username=user.username,
oidc_issuer=None, oidc_subject=None)

View File

@@ -1,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,11 +38,6 @@ 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,
# user creation ignores any password (passwordless users that only sign
# in via a bound OIDC identity / API keys), frontend hides password UI.
HARBORFORGE_OIDC_ONLY: bool = False
class Config: class Config:
env_file = ".env" env_file = ".env"
@@ -44,7 +60,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,42 +1,103 @@
""" """
HarborForge unconditional startup seeds runs every time backend boots. HarborForge initialization from AbstractWizard config volume.
Seeds default permissions, default roles, the `acc-mgr` built-in (account Reads config from shared volume (written by AbstractWizard).
provisioning agent), and the `deleted-user` foreign-key sink. Idempotent; On startup, creates admin user and default project if not exists.
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 os
import json
import logging import logging
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import models from app.models import models
from app.models.role_permission import Role, Permission, RolePermission from app.models.role_permission import Role, Permission, RolePermission
from app.api.deps import get_password_hash
logger = logging.getLogger("harborforge.bootstrap") logger = logging.getLogger("harborforge.init")
CONFIG_DIR = os.getenv("CONFIG_DIR", "/config")
CONFIG_FILE = os.getenv("CONFIG_FILE", "harborforge.json")
# --------------------------------------------------------------------------- def load_config() -> dict | None:
# Permissions catalog (canonical; new perms get added on every release) """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 = [ DEFAULT_PERMISSIONS = [
# Project permissions # Project permissions
("project.read", "View project", "project"), ("project.read", "View project", "project"),
("project.write", "Edit project", "project"), ("project.write", "Edit project", "project"),
("project.create", "Create a project", "project"),
("project.delete", "Delete project", "project"), ("project.delete", "Delete project", "project"),
("project.manage_members", "Manage project members", "project"), ("project.manage_members", "Manage project members", "project"),
# Task/Milestone permissions # Task/Milestone permissions
@@ -77,17 +138,11 @@ DEFAULT_PERMISSIONS = [
("calendar.manage", "Manage calendar settings and workload policies", "calendar"), ("calendar.manage", "Manage calendar settings and workload policies", "calendar"),
# Webhook # Webhook
("webhook.manage", "Manage webhooks", "admin"), ("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]: def init_default_permissions(db: Session) -> list[Permission]:
"""Insert any missing perms from DEFAULT_PERMISSIONS. Returns all rows.""" """Create default permissions if they don't exist. Returns all permissions."""
created = [] created = []
for name, description, category in DEFAULT_PERMISSIONS: for name, description, category in DEFAULT_PERMISSIONS:
existing = db.query(Permission).filter(Permission.name == name).first() existing = db.query(Permission).filter(Permission.name == name).first()
@@ -96,16 +151,21 @@ def init_default_permissions(db: Session) -> list[Permission]:
db.add(perm) db.add(perm)
created.append(perm) created.append(perm)
logger.info("Created permission '%s'", name) logger.info("Created permission '%s'", name)
if created: if created:
db.commit() db.commit()
# Return all permissions
return db.query(Permission).all() return db.query(Permission).all()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Default roles + permission set per role # Default role permission mapping
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# mgr: project management + all milestone/task/proposal actions
_MGR_PERMISSIONS = { _MGR_PERMISSIONS = {
"project.read", "project.write", "project.create", "project.manage_members", "project.read", "project.write", "project.manage_members",
"task.create", "task.read", "task.write", "task.delete", "task.create", "task.read", "task.write", "task.delete",
"milestone.create", "milestone.read", "milestone.write", "milestone.delete", "milestone.create", "milestone.read", "milestone.write", "milestone.delete",
"milestone.freeze", "milestone.start", "milestone.close", "milestone.freeze", "milestone.start", "milestone.close",
@@ -116,6 +176,7 @@ _MGR_PERMISSIONS = {
"user.reset-self-apikey", "user.reset-self-apikey",
} }
# dev: day-to-day development work — no freeze/start/close milestone, no accept/reject proposal
_DEV_PERMISSIONS = { _DEV_PERMISSIONS = {
"project.read", "project.read",
"task.create", "task.read", "task.write", "task.create", "task.read", "task.write",
@@ -131,31 +192,18 @@ _ACCOUNT_MANAGER_PERMISSIONS = {
"user.reset-apikey", "user.reset-apikey",
} }
# Default role for agents (assigned automatically by POST /users when # Role definitions: (name, description, permission_set)
# 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 = [ _DEFAULT_ROLES = [
("admin", "Administrator - full access to all features", None), # None ⇒ all perms ("admin", "Administrator - full access to all features", None), # None ⇒ all perms
("account-manager", "Account manager - can only create accounts", _ACCOUNT_MANAGER_PERMISSIONS), ("account-manager", "Account manager - can only create accounts", _ACCOUNT_MANAGER_PERMISSIONS),
("mgr", "Manager - project & milestone management", _MGR_PERMISSIONS), ("mgr", "Manager - project & milestone management", _MGR_PERMISSIONS),
("dev", "Developer - task execution & daily work", _DEV_PERMISSIONS), ("dev", "Developer - task execution & daily work", _DEV_PERMISSIONS),
("general-agent", "General agent - read-only + self API key rotation", _GENERAL_AGENT_PERMISSIONS), ("guest", "Guest - read-only access", None), # special: *.read only
("guest", "Guest - read-only access", None), # special: *.read only
] ]
def _ensure_role(db: Session, name: str, description: str, is_global: bool = True) -> Role: 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() role = db.query(Role).filter(Role.name == name).first()
if not role: if not role:
role = Role(name=name, description=description, is_global=is_global) role = Role(name=name, description=description, is_global=is_global)
@@ -167,8 +215,13 @@ def _ensure_role(db: Session, name: str, description: str, is_global: bool = Tru
def _sync_role_permissions(db: Session, role: Role, target_perm_names: set[str] | None) -> None: def _sync_role_permissions(db: Session, role: Role, target_perm_names: set[str] | None) -> None:
"""Additive: grants missing perms, never revokes manually-granted ones. """Ensure *role* has exactly the permissions in *target_perm_names*.
``target_perm_names is None`` means **all** perms (admin)."""
* ``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() all_perms = db.query(Permission).all()
perm_by_name = {p.name: p for p in all_perms} perm_by_name = {p.name: p for p in all_perms}
@@ -182,40 +235,45 @@ def _sync_role_permissions(db: Session, role: Role, target_perm_names: set[str]
for pid in wanted_ids - existing_ids: for pid in wanted_ids - existing_ids:
db.add(RolePermission(role_id=role.id, permission_id=pid)) db.add(RolePermission(role_id=role.id, permission_id=pid))
added += 1 added += 1
if added: if added:
db.commit() db.commit()
logger.info("Assigned %d new permissions to role '%s'", added, role.name) logger.info("Assigned %d new permissions to role '%s'", added, role.name)
def init_default_roles(db: Session) -> None: def init_admin_role(db: Session, admin_user: models.User) -> None:
"""Create default roles (admin/account-manager/mgr/dev/guest) + permissions.""" """Create default roles (admin / mgr / dev / guest) with preset permissions."""
all_perms = db.query(Permission).all() all_perms = db.query(Permission).all()
read_perm_names = {p.name for p in all_perms if p.name.endswith(".read")} read_perm_names = {p.name for p in all_perms if p.name.endswith(".read")}
for name, description, perm_set in _DEFAULT_ROLES: for name, description, perm_set in _DEFAULT_ROLES:
role = _ensure_role(db, name, description) role = _ensure_role(db, name, description)
if name == "guest": if name == "guest":
_sync_role_permissions(db, role, read_perm_names) _sync_role_permissions(db, role, read_perm_names)
else: else:
_sync_role_permissions(db, role, perm_set) _sync_role_permissions(db, role, perm_set)
logger.info("Default roles ready (admin / account-manager / mgr / dev / guest)")
logger.info("Default roles setup complete (admin, 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: def init_acc_mgr_user(db: Session) -> models.User | None:
"""The account-manager system principal. Holds the `account-manager` """Create the built-in acc-mgr user if not exists.
role so the account-creation API can attribute new users to it. No
password, no OIDC binding cannot log in.""" 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" username = "acc-mgr"
existing = db.query(models.User).filter(models.User.username == username).first() existing = db.query(models.User).filter(models.User.username == username).first()
if existing: if existing:
logger.info("acc-mgr user already exists (id=%d), skipping", existing.id)
return existing return existing
# Find account-manager role
acc_mgr_role = db.query(Role).filter(Role.name == "account-manager").first() acc_mgr_role = db.query(Role).filter(Role.name == "account-manager").first()
if not acc_mgr_role: if not acc_mgr_role:
logger.warning("account-manager role not found, skipping acc-mgr user creation") logger.warning("account-manager role not found, skipping acc-mgr user creation")
@@ -225,7 +283,7 @@ def init_acc_mgr_user(db: Session) -> models.User | None:
username=username, username=username,
email="acc-mgr@harborforge.internal", email="acc-mgr@harborforge.internal",
full_name="Account Manager", full_name="Account Manager",
hashed_password=None, hashed_password=None, # Cannot log in — no password
is_admin=False, is_admin=False,
is_active=True, is_active=True,
role_id=acc_mgr_role.id, role_id=acc_mgr_role.id,
@@ -237,13 +295,21 @@ def init_acc_mgr_user(db: Session) -> models.User | None:
return user return user
DELETED_USER_USERNAME = "deleted-user"
def init_deleted_user(db: Session) -> models.User | None: def init_deleted_user(db: Session) -> models.User | None:
"""FK sink for deleted users — when a real user is deleted, all FK """Create the built-in deleted-user if not exists.
references reassign here instead of cascading."""
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( existing = db.query(models.User).filter(
models.User.username == DELETED_USER_USERNAME models.User.username == DELETED_USER_USERNAME
).first() ).first()
if existing: if existing:
logger.info("deleted-user already exists (id=%d), skipping", existing.id)
return existing return existing
user = models.User( user = models.User(
@@ -262,17 +328,36 @@ def init_deleted_user(db: Session) -> models.User | None:
return user return user
# --------------------------------------------------------------------------- def run_init(db: Session) -> None:
# Top-level bootstrap entry point — called from main.py startup """Main initialization entry point. Reads config from shared volume."""
# --------------------------------------------------------------------------- config = load_config()
def run_bootstrap(db: Session) -> None: if not config:
"""Idempotent startup seed. Safe to call on every boot. return
Does NOT create the admin user that's CLI-driven (see hf-cli admin logger.info("Running HarborForge initialization from wizard config")
create-user) so operators pick credentials.
""" # Initialize default permissions and admin role (always run)
init_default_permissions(db) all_perms = init_default_permissions(db)
init_default_roles(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) init_acc_mgr_user(db)
# Built-in deleted-user (foreign key sink for deleted accounts)
init_deleted_user(db) init_deleted_user(db)
logger.info("Bootstrap seeds complete")
# Default project
project_cfg = config.get("default_project")
if project_cfg and admin_user:
init_default_project(db, project_cfg, admin_user.id, admin_user.username)
logger.info("Initialization complete")

View File

@@ -1,9 +1,6 @@
"""HarborForge API — Agent/人类协同任务管理平台""" """HarborForge API — Agent/人类协同任务管理平台"""
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
from app.core.config import settings
app = FastAPI( app = FastAPI(
title="HarborForge API", title="HarborForge API",
@@ -20,17 +17,6 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# Short-lived signed session cookie — only used to carry the OIDC
# state/nonce between /auth/oidc/login and the callback.
app.add_middleware(
SessionMiddleware,
secret_key=settings.SECRET_KEY,
session_cookie="hf_oidc",
same_site="lax",
https_only=False,
max_age=600,
)
# Health & version (kept at top level) # Health & version (kept at top level)
@app.get("/health", tags=["System"]) @app.get("/health", tags=["System"])
def health_check(): def health_check():
@@ -42,22 +28,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
@@ -75,13 +63,9 @@ from app.api.routers.proposes import router as proposes_router # legacy compat
from app.api.routers.milestone_actions import router as milestone_actions_router from app.api.routers.milestone_actions import router as milestone_actions_router
from app.api.routers.meetings import router as meetings_router from app.api.routers.meetings import router as meetings_router
from app.api.routers.essentials import router as essentials_router from app.api.routers.essentials import router as essentials_router
from app.api.routers.schedule_type import router as schedule_type_router
from app.api.routers.schedule_type_special_slot import router as schedule_type_special_slot_router
from app.api.routers.calendar import router as calendar_router from app.api.routers.calendar import router as calendar_router
from app.api.routers.oidc import router as oidc_router
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(oidc_router)
app.include_router(tasks_router) app.include_router(tasks_router)
app.include_router(projects_router) app.include_router(projects_router)
app.include_router(users_router) app.include_router(users_router)
@@ -96,8 +80,6 @@ app.include_router(proposes_router) # legacy compat
app.include_router(milestone_actions_router) app.include_router(milestone_actions_router)
app.include_router(meetings_router) app.include_router(meetings_router)
app.include_router(essentials_router) app.include_router(essentials_router)
app.include_router(schedule_type_router)
app.include_router(schedule_type_special_slot_router)
app.include_router(calendar_router) app.include_router(calendar_router)
@@ -293,18 +275,6 @@ def _migrate_schema():
if _has_table(db, "users") and not _has_column(db, "users", "discord_user_id"): if _has_table(db, "users") and not _has_column(db, "users", "discord_user_id"):
db.execute(text("ALTER TABLE users ADD COLUMN discord_user_id VARCHAR(32) NULL")) db.execute(text("ALTER TABLE users ADD COLUMN discord_user_id VARCHAR(32) NULL"))
# --- users OIDC binding (issuer + subject), unique together ---
if _has_table(db, "users") and not _has_column(db, "users", "oidc_issuer"):
db.execute(text("ALTER TABLE users ADD COLUMN oidc_issuer VARCHAR(255) NULL"))
if _has_table(db, "users") and not _has_column(db, "users", "oidc_subject"):
db.execute(text("ALTER TABLE users ADD COLUMN oidc_subject VARCHAR(255) NULL"))
if _has_table(db, "users") and _has_column(db, "users", "oidc_subject"):
_ensure_unique_index(db, "users", "uq_users_oidc_identity", "oidc_issuer, oidc_subject")
# --- oidc_settings.admin_role (added after the table shipped) ---
if _has_table(db, "oidc_settings") and not _has_column(db, "oidc_settings", "admin_role"):
db.execute(text("ALTER TABLE oidc_settings ADD COLUMN admin_role VARCHAR(128) NULL"))
# --- monitored_servers.api_key for heartbeat v2 --- # --- monitored_servers.api_key for heartbeat v2 ---
if _has_table(db, "monitored_servers") and not _has_column(db, "monitored_servers", "api_key"): if _has_table(db, "monitored_servers") and not _has_column(db, "monitored_servers", "api_key"):
db.execute(text("ALTER TABLE monitored_servers ADD COLUMN api_key VARCHAR(64) NULL")) db.execute(text("ALTER TABLE monitored_servers ADD COLUMN api_key VARCHAR(64) NULL"))
@@ -393,67 +363,6 @@ def _migrate_schema():
if _has_table(db, "time_slots") and not _has_column(db, "time_slots", "wakeup_sent_at"): if _has_table(db, "time_slots") and not _has_column(db, "time_slots", "wakeup_sent_at"):
db.execute(text("ALTER TABLE time_slots ADD COLUMN wakeup_sent_at DATETIME NULL")) db.execute(text("ALTER TABLE time_slots ADD COLUMN wakeup_sent_at DATETIME NULL"))
# --- agents: add schedule_type_id FK ---
if _has_table(db, "agents") and not _has_column(db, "agents", "schedule_type_id"):
db.execute(text("ALTER TABLE agents ADD COLUMN schedule_type_id INTEGER NULL"))
# --- schedule_types: add maintenance_from / maintenance_to ---
# Default 8:009:00 UTC for existing rows; the maintenance
# duration invariant (1-180min) is enforced at the schema
# level for any NEW rows by ScheduleTypeCreate validator.
if _has_table(db, "schedule_types"):
if not _has_column(db, "schedule_types", "maintenance_from"):
db.execute(text(
"ALTER TABLE schedule_types ADD COLUMN maintenance_from INT NOT NULL DEFAULT 8"
))
if not _has_column(db, "schedule_types", "maintenance_to"):
db.execute(text(
"ALTER TABLE schedule_types ADD COLUMN maintenance_to INT NOT NULL DEFAULT 9"
))
# --- minutes-since-midnight migration (PR #21+) ---
# The 6 schedule_type window columns used to hold *hours*
# (0-23). PR #21 changed semantics to *minutes since UTC
# midnight* (0-1439). Detect the legacy regime by checking
# if ANY row has all 6 values ≤ 23 — if so, multiply each
# by 60 to convert. Idempotent: post-conversion values are
# all ≥ 0 and usually well above 23, so guard never fires
# twice.
row = db.execute(text(
"SELECT MAX(GREATEST(work_from, work_to, entertainment_from, entertainment_to, maintenance_from, maintenance_to)) AS m "
"FROM schedule_types"
)).fetchone()
if row is not None and row.m is not None and row.m <= 23:
db.execute(text(
"UPDATE schedule_types SET "
" work_from = work_from * 60, "
" work_to = work_to * 60, "
" entertainment_from = entertainment_from * 60, "
" entertainment_to = entertainment_to * 60, "
" maintenance_from = maintenance_from * 60, "
" maintenance_to = maintenance_to * 60"
))
# --- time_slots: admin-locked + special_slot pointer ---
if _has_table(db, "time_slots"):
if not _has_column(db, "time_slots", "is_admin_locked"):
db.execute(text(
"ALTER TABLE time_slots ADD COLUMN is_admin_locked TINYINT(1) NOT NULL DEFAULT 0"
))
if not _has_column(db, "time_slots", "special_slot_id"):
db.execute(text(
"ALTER TABLE time_slots ADD COLUMN special_slot_id INTEGER NULL"
))
# Index for the materialiser's idempotency lookup
db.execute(text(
"CREATE INDEX idx_time_slots_special_slot_id ON time_slots (special_slot_id)"
))
# --- schedule_type_special_slots: create-table is handled by
# Base.metadata.create_all on first boot; no migration needed here
# because there is no legacy table to evolve. Future schema bumps
# to that table go in this block.
db.commit() db.commit()
except Exception as e: except Exception as e:
db.rollback() db.rollback()
@@ -488,17 +397,15 @@ def _sync_default_user_roles(db):
@app.on_event("startup") @app.on_event("startup")
def startup(): def startup():
from app.core.config import Base, engine, SessionLocal from app.core.config import Base, engine, SessionLocal
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar, minimum_workload, schedule_type, schedule_type_special_slot, oidc_settings from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar, minimum_workload
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

@@ -131,15 +131,6 @@ class Agent(Base):
comment="rate_limit | billing — why the agent is exhausted", comment="rate_limit | billing — why the agent is exhausted",
) )
# -- schedule type ------------------------------------------------------
schedule_type_id = Column(
Integer,
ForeignKey("schedule_types.id"),
nullable=True,
comment="FK to schedule_types — defines work/entertainment periods",
)
# -- timestamps --------------------------------------------------------- # -- timestamps ---------------------------------------------------------
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -147,4 +138,3 @@ class Agent(Base):
# -- relationships ------------------------------------------------------ # -- relationships ------------------------------------------------------
user = relationship("User", back_populates="agent", uselist=False) user = relationship("User", back_populates="agent", uselist=False)
schedule_type = relationship("ScheduleType", lazy="joined")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
from pydantic import BaseModel, Field from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
from datetime import datetime, time from datetime import datetime, time
from enum import Enum from enum import Enum
@@ -186,19 +186,6 @@ class UserUpdate(BaseModel):
discord_user_id: Optional[str] = None discord_user_id: Optional[str] = None
class UserBindAgentRequest(BaseModel):
"""Request body for PATCH /users/{identifier}/bind-agent.
Binds an existing user to (agent_id, claw_identifier) by inserting a
row in the `agents` table. Both fields required (mirrors the
create-time invariant in UserCreate). Idempotent: re-binding the same
user to the same (agent_id, claw_identifier) returns the existing
Agent row instead of 409.
"""
agent_id: str = Field(..., min_length=1, max_length=128)
claw_identifier: str = Field(..., min_length=1, max_length=128)
class UserResponse(UserBase): class UserResponse(UserBase):
id: int id: int
is_active: bool is_active: bool
@@ -207,8 +194,6 @@ class UserResponse(UserBase):
role_name: Optional[str] = None role_name: Optional[str] = None
agent_id: Optional[str] = None agent_id: Optional[str] = None
discord_user_id: Optional[str] = None discord_user_id: Optional[str] = None
oidc_issuer: Optional[str] = None
oidc_subject: Optional[str] = None
created_at: datetime created_at: datetime
class Config: class Config:

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

View File

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

View File

@@ -1,5 +1,19 @@
#!/bin/sh #!/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

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

View File

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