Compare commits
17 Commits
feat/get-a
...
90b494f097
| Author | SHA1 | Date | |
|---|---|---|---|
| 90b494f097 | |||
| e7d3cbe07b | |||
| 51fb8ca073 | |||
| 1cb924451b | |||
| c011e334a0 | |||
| d52861fd9c | |||
| 3aa6dd2d6e | |||
| c3199d0cd0 | |||
| d3f72962c0 | |||
| 4643a73c60 | |||
| eae947d9b6 | |||
| a2f626557e | |||
| c5827db872 | |||
| 7326cadfec | |||
| 1b10c97099 | |||
| 8434a5d226 | |||
| a2ab541b73 |
@@ -42,11 +42,5 @@ COPY requirements.txt ./
|
|||||||
COPY entrypoint.sh .
|
COPY entrypoint.sh .
|
||||||
RUN chmod +x entrypoint.sh
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
# 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"]
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ 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",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -469,12 +377,6 @@ def sync_schedules(
|
|||||||
"""
|
"""
|
||||||
today = date_type.today()
|
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
|
# Find all agents on this claw instance
|
||||||
agents = (
|
agents = (
|
||||||
db.query(Agent)
|
db.query(Agent)
|
||||||
@@ -562,29 +464,6 @@ def agent_update_virtual_slot(
|
|||||||
return TimeSlotEditResponse(slot=_slot_to_response(slot), warnings=[])
|
return TimeSlotEditResponse(slot=_slot_to_response(slot), warnings=[])
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/agent/status",
|
|
||||||
summary="Read an agent's current runtime status (no side effects)",
|
|
||||||
)
|
|
||||||
def get_agent_status(
|
|
||||||
agent_id: str = Query(..., description="Target agent_id"),
|
|
||||||
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Return `{agent_id, status}` so callers (Fabric.OpenclawPlugin's
|
|
||||||
triage on-call gate, etc.) can decide whether the agent is currently
|
|
||||||
eligible without flipping their state.
|
|
||||||
|
|
||||||
No-op for unknown agents — returns 404 with `{detail: 'Agent not
|
|
||||||
found'}` so the caller can decide whether to fail-open or fail-closed.
|
|
||||||
"""
|
|
||||||
agent = _require_agent(db, agent_id, x_claw_identifier)
|
|
||||||
return {
|
|
||||||
"agent_id": agent.agent_id,
|
|
||||||
"status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/agent/status",
|
"/agent/status",
|
||||||
summary="Update agent runtime status from plugin",
|
summary="Update agent runtime status from plugin",
|
||||||
@@ -595,13 +474,6 @@ def update_agent_status(
|
|||||||
):
|
):
|
||||||
agent = _require_agent(db, payload.agent_id, payload.claw_identifier)
|
agent = _require_agent(db, payload.agent_id, payload.claw_identifier)
|
||||||
target = (payload.status or '').lower().strip()
|
target = (payload.status or '').lower().strip()
|
||||||
# Idempotent same-state transition: a 'busy → busy' request is a
|
|
||||||
# no-op rather than a 500. Lets plugin status gates / cli `--set`
|
|
||||||
# be safe to fire-and-forget without first reading current state.
|
|
||||||
current = agent.status.value if hasattr(agent.status, 'value') else str(agent.status)
|
|
||||||
if current == target:
|
|
||||||
return {"ok": True, "agent_id": agent.agent_id, "status": current, "no_change": True}
|
|
||||||
try:
|
|
||||||
if target == AgentStatus.IDLE.value:
|
if target == AgentStatus.IDLE.value:
|
||||||
transition_to_idle(db, agent)
|
transition_to_idle(db, agent)
|
||||||
elif target == AgentStatus.BUSY.value:
|
elif target == AgentStatus.BUSY.value:
|
||||||
@@ -615,11 +487,6 @@ def update_agent_status(
|
|||||||
transition_to_exhausted(db, agent, reason=reason, recovery_at=payload.recovery_at)
|
transition_to_exhausted(db, agent, reason=reason, recovery_at=payload.recovery_at)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="Unsupported agent status")
|
raise HTTPException(status_code=400, detail="Unsupported agent status")
|
||||||
except AgentStatusError as e:
|
|
||||||
# State-machine violation (e.g. busy → busy via wrong precondition)
|
|
||||||
# → 409 with the rejected transition explained, instead of a 500.
|
|
||||||
db.rollback()
|
|
||||||
raise HTTPException(status_code=409, detail=str(e))
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "agent_id": agent.agent_id, "status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status)}
|
return {"ok": True, "agent_id": agent.agent_id, "status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status)}
|
||||||
|
|
||||||
@@ -647,10 +514,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 +589,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 +756,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)
|
||||||
|
|||||||
@@ -1,350 +0,0 @@
|
|||||||
"""OIDC (OpenID Connect) login + admin-configurable provider settings.
|
|
||||||
|
|
||||||
The OIDC provider can be configured at runtime from the admin UI
|
|
||||||
(persisted in the oidc_settings table). A stored row's non-empty fields
|
|
||||||
override the OIDC_* env vars; env values act as bootstrap defaults.
|
|
||||||
|
|
||||||
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:
|
|
||||||
row = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
|
|
||||||
|
|
||||||
def pick(db_val, env_val):
|
|
||||||
return db_val if (db_val is not None and db_val != "") else env_val
|
|
||||||
|
|
||||||
if row is None:
|
|
||||||
return EffectiveOidc(
|
|
||||||
settings.OIDC_ENABLED, settings.OIDC_ISSUER, settings.OIDC_CLIENT_ID,
|
|
||||||
settings.OIDC_CLIENT_SECRET, settings.OIDC_REDIRECT_URI,
|
|
||||||
settings.OIDC_SCOPES, settings.OIDC_POST_LOGIN_REDIRECT,
|
|
||||||
settings.OIDC_ADMIN_ROLE,
|
|
||||||
)
|
|
||||||
return EffectiveOidc(
|
|
||||||
bool(row.enabled),
|
|
||||||
pick(row.issuer, settings.OIDC_ISSUER),
|
|
||||||
pick(row.client_id, settings.OIDC_CLIENT_ID),
|
|
||||||
pick(row.client_secret, settings.OIDC_CLIENT_SECRET),
|
|
||||||
pick(row.redirect_uri, settings.OIDC_REDIRECT_URI),
|
|
||||||
pick(row.scopes, settings.OIDC_SCOPES),
|
|
||||||
pick(row.post_login_redirect, settings.OIDC_POST_LOGIN_REDIRECT),
|
|
||||||
pick(getattr(row, "admin_role", None), settings.OIDC_ADMIN_ROLE),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# 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 bool(settings.OIDC_ENABLED),
|
|
||||||
issuer=(row.issuer if row else None) or settings.OIDC_ISSUER or None,
|
|
||||||
client_id=(row.client_id if row else None) or settings.OIDC_CLIENT_ID or None,
|
|
||||||
has_client_secret=bool((row.client_secret if row else None) or settings.OIDC_CLIENT_SECRET),
|
|
||||||
redirect_uri=(row.redirect_uri if row else None) or settings.OIDC_REDIRECT_URI or None,
|
|
||||||
scopes=(row.scopes if row else None) or settings.OIDC_SCOPES or None,
|
|
||||||
post_login_redirect=(row.post_login_redirect if row else None) or settings.OIDC_POST_LOGIN_REDIRECT or None,
|
|
||||||
admin_role=cfg.admin_role,
|
|
||||||
oidc_only=bool(settings.HARBORFORGE_OIDC_ONLY),
|
|
||||||
effective_enabled=cfg.configured,
|
|
||||||
source="db" if row else "env",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@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, _=_)
|
|
||||||
@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
|
|||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from app.core.config import get_db
|
from app.core.config import get_db
|
||||||
from app.api.deps import get_current_user_or_apikey
|
from app.api.deps import get_current_user
|
||||||
from app.models.models import User
|
from app.models.models import User
|
||||||
from app.models.agent import Agent
|
from app.models.agent import Agent
|
||||||
from app.models.schedule_type import ScheduleType
|
from app.models.schedule_type import ScheduleType
|
||||||
@@ -57,18 +57,6 @@ def _require_schedule_manage(db: Session, user: User) -> User:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def _attach_derived(st: ScheduleType) -> ScheduleType:
|
|
||||||
"""Attach derived fields (maintenance_duration_minutes) so the
|
|
||||||
pydantic ScheduleTypeResponse picks them up via from_attributes.
|
|
||||||
|
|
||||||
Pydantic with from_attributes reads attributes off the ORM object;
|
|
||||||
setting a transient attr here avoids having to convert through dict.
|
|
||||||
"""
|
|
||||||
if st is not None:
|
|
||||||
st.maintenance_duration_minutes = st.compute_maintenance_duration()
|
|
||||||
return st
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Schedule Type CRUD
|
# Schedule Type CRUD
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -80,10 +68,10 @@ def _attach_derived(st: ScheduleType) -> ScheduleType:
|
|||||||
)
|
)
|
||||||
def list_schedule_types(
|
def list_schedule_types(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user_or_apikey),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
_require_schedule_read(db, current_user)
|
_require_schedule_read(db, current_user)
|
||||||
return [_attach_derived(st) for st in db.query(ScheduleType).all()]
|
return db.query(ScheduleType).all()
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -94,7 +82,7 @@ def list_schedule_types(
|
|||||||
def create_schedule_type(
|
def create_schedule_type(
|
||||||
payload: ScheduleTypeCreate,
|
payload: ScheduleTypeCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user_or_apikey),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
_require_schedule_manage(db, current_user)
|
_require_schedule_manage(db, current_user)
|
||||||
|
|
||||||
@@ -108,13 +96,11 @@ def create_schedule_type(
|
|||||||
work_to=payload.work_to,
|
work_to=payload.work_to,
|
||||||
entertainment_from=payload.entertainment_from,
|
entertainment_from=payload.entertainment_from,
|
||||||
entertainment_to=payload.entertainment_to,
|
entertainment_to=payload.entertainment_to,
|
||||||
maintenance_from=payload.maintenance_from,
|
|
||||||
maintenance_to=payload.maintenance_to,
|
|
||||||
)
|
)
|
||||||
db.add(st)
|
db.add(st)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(st)
|
db.refresh(st)
|
||||||
return _attach_derived(st)
|
return st
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
@@ -126,7 +112,7 @@ def update_schedule_type(
|
|||||||
schedule_type_id: int,
|
schedule_type_id: int,
|
||||||
payload: ScheduleTypeUpdate,
|
payload: ScheduleTypeUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user_or_apikey),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
_require_schedule_manage(db, current_user)
|
_require_schedule_manage(db, current_user)
|
||||||
|
|
||||||
@@ -134,23 +120,12 @@ def update_schedule_type(
|
|||||||
if not st:
|
if not st:
|
||||||
raise HTTPException(404, "Schedule type not found")
|
raise HTTPException(404, "Schedule type not found")
|
||||||
|
|
||||||
update_fields = payload.model_dump(exclude_unset=True)
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
for field, value in update_fields.items():
|
|
||||||
setattr(st, field, value)
|
setattr(st, field, value)
|
||||||
|
|
||||||
# Re-validate maintenance after merge (partial updates can put the row
|
|
||||||
# into an invalid window combo that the pydantic schema couldn't catch
|
|
||||||
# because it only saw one field).
|
|
||||||
from app.schemas.schedule_type import _validate_maintenance_window
|
|
||||||
try:
|
|
||||||
_validate_maintenance_window(st.maintenance_from, st.maintenance_to)
|
|
||||||
except ValueError as e:
|
|
||||||
db.rollback()
|
|
||||||
raise HTTPException(422, str(e))
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(st)
|
db.refresh(st)
|
||||||
return _attach_derived(st)
|
return st
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
@@ -160,7 +135,7 @@ def update_schedule_type(
|
|||||||
def delete_schedule_type(
|
def delete_schedule_type(
|
||||||
schedule_type_id: int,
|
schedule_type_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user_or_apikey),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
_require_schedule_manage(db, current_user)
|
_require_schedule_manage(db, current_user)
|
||||||
|
|
||||||
@@ -206,8 +181,7 @@ def get_my_schedule_type(
|
|||||||
if not agent.schedule_type_id:
|
if not agent.schedule_type_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
st = db.query(ScheduleType).filter(ScheduleType.id == agent.schedule_type_id).first()
|
return db.query(ScheduleType).filter(ScheduleType.id == agent.schedule_type_id).first()
|
||||||
return _attach_derived(st) if st else None
|
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
@@ -218,7 +192,7 @@ def assign_schedule_type(
|
|||||||
agent_id: str,
|
agent_id: str,
|
||||||
payload: AgentScheduleTypeAssign,
|
payload: AgentScheduleTypeAssign,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user_or_apikey),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
_require_schedule_manage(db, current_user)
|
_require_schedule_manage(db, current_user)
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
|
||||||
@@ -8,7 +8,7 @@ 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_wizard 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
|
||||||
@@ -32,8 +32,6 @@ 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
|
||||||
@@ -113,12 +111,6 @@ def create_user(
|
|||||||
raise HTTPException(status_code=400, detail="agent_id already in use")
|
raise HTTPException(status_code=400, detail="agent_id already in use")
|
||||||
|
|
||||||
assigned_role = _resolve_user_role(db, user.role_id)
|
assigned_role = _resolve_user_role(db, user.role_id)
|
||||||
# In OIDC-only mode, ignore any supplied password: the user is created
|
|
||||||
# passwordless (cannot password-login) and is expected to sign in via a
|
|
||||||
# bound OIDC identity. API keys still work for such users.
|
|
||||||
if settings.HARBORFORGE_OIDC_ONLY:
|
|
||||||
hashed_password = None
|
|
||||||
else:
|
|
||||||
hashed_password = get_password_hash(user.password) if user.password else None
|
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,
|
||||||
@@ -199,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:
|
||||||
@@ -221,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}
|
||||||
|
|
||||||
|
|
||||||
@@ -487,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)
|
|
||||||
|
|||||||
@@ -38,21 +38,6 @@ class Settings(BaseSettings):
|
|||||||
ALGORITHM: str = "HS256"
|
ALGORITHM: str = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
|
|
||||||
# --- OIDC (generic, OpenID Connect discovery) ---
|
|
||||||
OIDC_ENABLED: bool = False
|
|
||||||
OIDC_ISSUER: str = "" # e.g. https://idp.example.com (we use {issuer}/.well-known/openid-configuration)
|
|
||||||
OIDC_CLIENT_ID: str = ""
|
|
||||||
OIDC_CLIENT_SECRET: str = ""
|
|
||||||
OIDC_REDIRECT_URI: str = "" # backend callback, e.g. https://hf-api.example.com/auth/oidc/callback
|
|
||||||
OIDC_SCOPES: str = "openid email profile"
|
|
||||||
OIDC_POST_LOGIN_REDIRECT: str = "" # frontend URL to return to (token in fragment). Falls back to "/"
|
|
||||||
OIDC_ADMIN_ROLE: str = "admin" # OIDC role name that bootstraps the unbound hf admin (OIDC-only)
|
|
||||||
|
|
||||||
# When true: no password login at all. Password login endpoint rejects,
|
|
||||||
# user creation ignores any password (passwordless user that can only use
|
|
||||||
# API keys / OIDC), and the frontend hides all password UI.
|
|
||||||
HARBORFORGE_OIDC_ONLY: bool = False
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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.models.oidc_settings import OidcSettings
|
|
||||||
from app.api.deps import get_password_hash
|
from app.api.deps import get_password_hash
|
||||||
|
|
||||||
logger = logging.getLogger("harborforge.init")
|
logger = logging.getLogger("harborforge.init")
|
||||||
@@ -329,50 +328,6 @@ def init_deleted_user(db: Session) -> models.User | None:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def init_oidc_settings(db: Session, oidc_cfg: dict, admin_user: models.User | None) -> None:
|
|
||||||
"""Bootstrap OIDC from the wizard config (first init only).
|
|
||||||
|
|
||||||
Creates the single oidc_settings row if absent so the deployment comes
|
|
||||||
up with OIDC configured. If admin_subject is given, binds the bootstrap
|
|
||||||
admin so it can sign in (critical in OIDC-only mode). Idempotent: an
|
|
||||||
existing row / existing admin binding is left untouched so later admin
|
|
||||||
edits via the API are not clobbered on restart."""
|
|
||||||
if not oidc_cfg:
|
|
||||||
return
|
|
||||||
|
|
||||||
existing = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
|
|
||||||
if existing is None:
|
|
||||||
db.add(OidcSettings(
|
|
||||||
id=1,
|
|
||||||
enabled=bool(oidc_cfg.get("enabled", True)),
|
|
||||||
issuer=(oidc_cfg.get("issuer") or "").strip() or None,
|
|
||||||
client_id=(oidc_cfg.get("client_id") or "").strip() or None,
|
|
||||||
client_secret=oidc_cfg.get("client_secret") or None,
|
|
||||||
redirect_uri=(oidc_cfg.get("redirect_uri") or "").strip() or None,
|
|
||||||
scopes=(oidc_cfg.get("scopes") or "").strip() or None,
|
|
||||||
post_login_redirect=(oidc_cfg.get("post_login_redirect") or "").strip() or None,
|
|
||||||
admin_role=(oidc_cfg.get("admin_role") or "").strip() or None,
|
|
||||||
))
|
|
||||||
db.commit()
|
|
||||||
logger.info("OIDC settings bootstrapped from wizard config")
|
|
||||||
|
|
||||||
admin_subject = (oidc_cfg.get("admin_subject") or "").strip()
|
|
||||||
issuer = (oidc_cfg.get("issuer") or "").strip()
|
|
||||||
if admin_user and admin_subject and issuer and not admin_user.oidc_subject:
|
|
||||||
clash = db.query(models.User).filter(
|
|
||||||
models.User.oidc_issuer == issuer,
|
|
||||||
models.User.oidc_subject == admin_subject,
|
|
||||||
models.User.id != admin_user.id,
|
|
||||||
).first()
|
|
||||||
if clash:
|
|
||||||
logger.warning("Admin OIDC subject already bound to '%s'; skipping admin bind", clash.username)
|
|
||||||
else:
|
|
||||||
admin_user.oidc_issuer = issuer
|
|
||||||
admin_user.oidc_subject = admin_subject
|
|
||||||
db.commit()
|
|
||||||
logger.info("Bootstrap admin '%s' bound to OIDC subject", admin_user.username)
|
|
||||||
|
|
||||||
|
|
||||||
def run_init(db: Session) -> None:
|
def run_init(db: Session) -> None:
|
||||||
"""Main initialization entry point. Reads config from shared volume."""
|
"""Main initialization entry point. Reads config from shared volume."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@@ -405,7 +360,4 @@ def run_init(db: Session) -> None:
|
|||||||
if project_cfg and admin_user:
|
if project_cfg and admin_user:
|
||||||
init_default_project(db, project_cfg, admin_user.id, admin_user.username)
|
init_default_project(db, project_cfg, admin_user.id, admin_user.username)
|
||||||
|
|
||||||
# OIDC bootstrap (provider config + optional bootstrap-admin binding)
|
|
||||||
init_oidc_settings(db, config.get("oidc") or {}, admin_user)
|
|
||||||
|
|
||||||
logger.info("Initialization complete")
|
logger.info("Initialization complete")
|
||||||
|
|||||||
89
app/main.py
89
app/main.py
@@ -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():
|
||||||
@@ -78,12 +64,9 @@ from app.api.routers.milestone_actions import router as milestone_actions_router
|
|||||||
from app.api.routers.meetings import router as meetings_router
|
from app.api.routers.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 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)
|
||||||
@@ -99,7 +82,6 @@ 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_router)
|
||||||
app.include_router(schedule_type_special_slot_router)
|
|
||||||
app.include_router(calendar_router)
|
app.include_router(calendar_router)
|
||||||
|
|
||||||
|
|
||||||
@@ -295,18 +277,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"))
|
||||||
@@ -399,63 +369,6 @@ def _migrate_schema():
|
|||||||
if _has_table(db, "agents") and not _has_column(db, "agents", "schedule_type_id"):
|
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"))
|
db.execute(text("ALTER TABLE agents ADD COLUMN schedule_type_id INTEGER NULL"))
|
||||||
|
|
||||||
# --- schedule_types: add maintenance_from / maintenance_to ---
|
|
||||||
# Default 8:00–9: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()
|
||||||
@@ -490,7 +403,7 @@ def _sync_default_user_roles(db):
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def startup():
|
def startup():
|
||||||
from app.core.config import Base, engine, SessionLocal
|
from app.core.config import Base, engine, SessionLocal
|
||||||
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar, minimum_workload, schedule_type, schedule_type_special_slot, oidc_settings
|
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar, minimum_workload, schedule_type
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
_migrate_schema()
|
_migrate_schema()
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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())
|
|
||||||
@@ -1,27 +1,17 @@
|
|||||||
"""ScheduleType model — defines work/entertainment/maintenance time periods.
|
"""ScheduleType model — defines work/entertainment time periods.
|
||||||
|
|
||||||
Each ScheduleType defines the daily work, entertainment, and maintenance
|
Each ScheduleType defines the daily work and entertainment windows.
|
||||||
windows for agents who reference this type. All bounds are stored as
|
Agents reference a schedule_type to know when they should be working
|
||||||
**minutes-since-UTC-midnight** (0-1439 inclusive) so half-hour and other
|
vs when they can engage in entertainment activities.
|
||||||
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 import Column, Integer, String, DateTime
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
class ScheduleType(Base):
|
class ScheduleType(Base):
|
||||||
"""Work/entertainment/maintenance period definition."""
|
"""Work/entertainment period definition."""
|
||||||
|
|
||||||
__tablename__ = "schedule_types"
|
__tablename__ = "schedule_types"
|
||||||
|
|
||||||
@@ -34,50 +24,29 @@ class ScheduleType(Base):
|
|||||||
comment="Human-readable schedule type name (e.g., 'standard', 'night-shift')",
|
comment="Human-readable schedule type name (e.g., 'standard', 'night-shift')",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Minutes since UTC midnight, 0-1439 inclusive.
|
work_from = Column(
|
||||||
work_from = Column(Integer, nullable=False, comment="Work period start (minutes since UTC midnight)")
|
|
||||||
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:00–9:00 UTC = 480–540 minutes for existing rows.
|
|
||||||
maintenance_from = Column(
|
|
||||||
Integer,
|
Integer,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
server_default="480",
|
comment="Work period start hour (0-23, UTC)",
|
||||||
comment="Maintenance start (minutes since UTC midnight, default 480 = 8:00 UTC).",
|
|
||||||
)
|
)
|
||||||
maintenance_to = Column(
|
|
||||||
|
work_to = Column(
|
||||||
Integer,
|
Integer,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
server_default="540",
|
comment="Work period end hour (0-23, UTC)",
|
||||||
comment="Maintenance end (minutes since UTC midnight, default 540 = 9:00 UTC). Duration ((to-from) mod 1440) must be in [1, 180].",
|
)
|
||||||
|
|
||||||
|
entertainment_from = Column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
comment="Entertainment period start hour (0-23, UTC)",
|
||||||
|
)
|
||||||
|
|
||||||
|
entertainment_to = Column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
comment="Entertainment period end hour (0-23, UTC)",
|
||||||
)
|
)
|
||||||
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
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 ---------------------------------------------------
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -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")
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,67 +1,23 @@
|
|||||||
"""Schemas for ScheduleType CRUD.
|
"""Schemas for ScheduleType CRUD."""
|
||||||
|
|
||||||
All `*_from` / `*_to` values are **minutes since UTC midnight** (0-1439).
|
from pydantic import BaseModel, Field
|
||||||
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
|
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):
|
class ScheduleTypeCreate(BaseModel):
|
||||||
name: str = Field(..., min_length=1, max_length=64)
|
name: str = Field(..., min_length=1, max_length=64)
|
||||||
work_from: int = Field(..., ge=0, lt=_MAX_MIN, description="Work start (minutes since UTC midnight, 0-1439)")
|
work_from: int = Field(..., ge=0, le=23)
|
||||||
work_to: int = Field(..., ge=0, lt=_MAX_MIN)
|
work_to: int = Field(..., ge=0, le=23)
|
||||||
entertainment_from: int = Field(..., ge=0, lt=_MAX_MIN)
|
entertainment_from: int = Field(..., ge=0, le=23)
|
||||||
entertainment_to: int = Field(..., ge=0, lt=_MAX_MIN)
|
entertainment_to: int = Field(..., ge=0, le=23)
|
||||||
maintenance_from: int = Field(480, ge=0, lt=_MAX_MIN, description="Maintenance start (default 480 = 8:00 UTC)")
|
|
||||||
maintenance_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):
|
class ScheduleTypeUpdate(BaseModel):
|
||||||
name: Optional[str] = Field(None, min_length=1, max_length=64)
|
name: Optional[str] = Field(None, min_length=1, max_length=64)
|
||||||
work_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
|
work_from: Optional[int] = Field(None, ge=0, le=23)
|
||||||
work_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
|
work_to: Optional[int] = Field(None, ge=0, le=23)
|
||||||
entertainment_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
|
entertainment_from: Optional[int] = Field(None, ge=0, le=23)
|
||||||
entertainment_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
|
entertainment_to: Optional[int] = Field(None, ge=0, le=23)
|
||||||
maintenance_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
|
|
||||||
maintenance_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):
|
class ScheduleTypeResponse(BaseModel):
|
||||||
@@ -71,9 +27,6 @@ class ScheduleTypeResponse(BaseModel):
|
|||||||
work_to: int
|
work_to: int
|
||||||
entertainment_from: int
|
entertainment_from: int
|
||||||
entertainment_to: int
|
entertainment_to: int
|
||||||
maintenance_from: int
|
|
||||||
maintenance_to: int
|
|
||||||
maintenance_duration_minutes: Optional[int] = None # derived; populated by router
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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`(本地验证栈)
|
|
||||||
- IdP:Keycloak 容器,realm `hf`,confidential client `hf-client`
|
|
||||||
- IdP 测试用户:`tester` / `Test123!`(emailVerified=true)
|
|
||||||
- 关键约束:**issuer URL 必须浏览器与后端容器都能用同一地址访问**
|
|
||||||
(否则 token `iss` 校验失败)。本地用宿主机 IP 统一两端。
|
|
||||||
- 配置项(运行时 env 或 DB,DB 覆盖 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)未实现,超出本次范围。
|
|
||||||
- 自助绑定相关 UI(3.7/3.8)建议人工在浏览器复核。
|
|
||||||
@@ -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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user