Compare commits
24 Commits
feat/knowl
...
4675ab7201
| Author | SHA1 | Date | |
|---|---|---|---|
| 4675ab7201 | |||
| a9d075bc19 | |||
| 9429e37542 | |||
| 0229fbb54c | |||
| ffce4298c8 | |||
| a115e380cb | |||
| 94155614f5 | |||
| 90b494f097 | |||
| e7d3cbe07b | |||
| 51fb8ca073 | |||
| 1cb924451b | |||
| c011e334a0 | |||
| d52861fd9c | |||
| 3aa6dd2d6e | |||
| c3199d0cd0 | |||
| d3f72962c0 | |||
| 4643a73c60 | |||
| eae947d9b6 | |||
| a2f626557e | |||
| c5827db872 | |||
| 7326cadfec | |||
| 1b10c97099 | |||
| 8434a5d226 | |||
| a2ab541b73 |
43
Dockerfile
43
Dockerfile
@@ -1,25 +1,52 @@
|
||||
FROM python:3.11-slim
|
||||
# Stage 1: build dependencies
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
curl \
|
||||
default-libmysqlclient-dev \
|
||||
pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Pre-download wheels to avoid recompiling bcrypt from source
|
||||
RUN pip install --no-cache-dir --prefix=/install \
|
||||
'bcrypt==4.0.1' \
|
||||
'cffi>=2.0' \
|
||||
'pycparser>=2.0'
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||
|
||||
# Stage 2: slim runtime
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies only (no build tools)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
default-libmysqlclient-dev \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy installed packages from builder
|
||||
COPY --from=builder /install /usr/local
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
COPY app/ ./app/
|
||||
COPY requirements.txt ./
|
||||
|
||||
# Make entrypoint
|
||||
COPY entrypoint.sh .
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
# 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}
|
||||
|
||||
# Wait for wizard config, then start uvicorn
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
|
||||
232
README.md
232
README.md
@@ -1,100 +1,163 @@
|
||||
# HarborForge Backend
|
||||
|
||||
Agent/人类协同任务管理平台 - FastAPI 后端
|
||||
The core REST API for HarborForge — an Agent/人类协同任务管理平台 (Agent/Human collaborative task-management platform).
|
||||
|
||||
## API Endpoints (38)
|
||||
Part of the [HarborForge](../README.md) platform.
|
||||
|
||||
### Auth
|
||||
- `POST /auth/token` - 登录获取 JWT token
|
||||
- `GET /auth/me` - 获取当前用户信息
|
||||
- **Role:** core REST API — users, projects, tasks, milestones, proposals, RBAC, webhooks, worklogs, notifications, monitor telemetry.
|
||||
- **Stack:** Python 3.11 · FastAPI · SQLAlchemy · MySQL
|
||||
- **Port:** `8000`
|
||||
|
||||
### Issues
|
||||
The service reads its database configuration from the AbstractWizard config volume (falling back to env/defaults) and authenticates requests with JWT (HS256) signed by `SECRET_KEY`.
|
||||
|
||||
> Issues 和 Search 列表接口返回分页格式:`{items, total, page, page_size, total_pages}`
|
||||
> Issues 支持排序参数:`sort_by` (created_at/priority/title/due_date/status), `sort_order` (asc/desc)
|
||||
> Issues 支持额外过滤:`assignee_id`, `tag`
|
||||
## Run / Build
|
||||
|
||||
### Docker
|
||||
|
||||
> Issues 和 Search 列表接口返回分页格式:
|
||||
> Issues 支持排序参数: (created_at/priority/title/due_date/status), (asc/desc)
|
||||
> Issues 支持额外过滤:,
|
||||
- `POST /issues` - 创建 issue(支持 resolution 决议案类型)
|
||||
- `GET /issues` - 列表(分页、排序、按 assignee/tag 过滤)(支持按 project/status/type 过滤)
|
||||
- `GET /issues/{id}` - 详情
|
||||
- `PATCH /issues/{id}` - 更新
|
||||
- `DELETE /issues/{id}` - 删除
|
||||
- `POST /issues/{id}/transition` - 状态变更(触发 webhook)
|
||||
- `GET /search/issues?q=keyword` - 搜索
|
||||
```bash
|
||||
docker build -t harborforge-backend .
|
||||
docker run -p 8000:8000 \
|
||||
-e SECRET_KEY="$(openssl rand -hex 32)" \
|
||||
-v /path/to/config:/config \
|
||||
harborforge-backend
|
||||
```
|
||||
|
||||
### Comments
|
||||
- `POST /comments` - 创建评论
|
||||
- `GET /issues/{id}/comments` - 列表
|
||||
- `PATCH /comments/{id}` - 更新
|
||||
- `DELETE /comments/{id}` - 删除
|
||||
### Local (uvicorn)
|
||||
|
||||
### Projects
|
||||
- `POST /projects` - 创建
|
||||
- `GET /projects` - 列表
|
||||
- `GET /projects/{id}` - 详情
|
||||
- `PATCH /projects/{id}` - 更新
|
||||
- `DELETE /projects/{id}` - 删除
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
export SECRET_KEY="$(openssl rand -hex 32)"
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### Project Members
|
||||
- `POST /projects/{id}/members` - 添加成员
|
||||
- `GET /projects/{id}/members` - 列表
|
||||
- `DELETE /projects/{id}/members/{user_id}` - 移除
|
||||
On startup the app creates/migrates the schema, runs AbstractWizard
|
||||
initialization (admin user, default project, default roles), and starts a
|
||||
background monitor-polling thread.
|
||||
|
||||
### Users
|
||||
- `POST /users` - 注册
|
||||
- `GET /users` - 列表
|
||||
- `GET /users/{id}` - 详情
|
||||
- `PATCH /users/{id}` - 更新
|
||||
## Configuration
|
||||
|
||||
### Webhooks
|
||||
- `POST /webhooks` - 创建
|
||||
- `GET /webhooks` - 列表
|
||||
- `GET /webhooks/{id}` - 详情
|
||||
- `PATCH /webhooks/{id}` - 更新
|
||||
- `DELETE /webhooks/{id}` - 删除
|
||||
- `GET /webhooks/{id}/logs` - 投递日志
|
||||
Environment variables (also loadable from a `.env` file):
|
||||
|
||||
### System
|
||||
- `GET /health` - 健康检查
|
||||
- `GET /version` - 版本信息
|
||||
- `GET /dashboard/stats` - 统计面板
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SECRET_KEY` | *(none — must be set)* | JWT signing key (HS256). The server **refuses to start** with a weak/default/short value. |
|
||||
| `DATABASE_URL` | `mysql+pymysql://harborforge:harborforge_pass@mysql:3306/harborforge` | Fallback DB URL when the wizard config volume is absent. |
|
||||
| `ALGORITHM` | `HS256` | JWT algorithm. |
|
||||
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `30` | Access-token lifetime. |
|
||||
| `LOG_LEVEL` | `INFO` | Log level. |
|
||||
| `CONFIG_DIR` | `/config` | AbstractWizard config volume directory. |
|
||||
| `CONFIG_FILE` | `harborforge.json` | Config file name within `CONFIG_DIR`. |
|
||||
|
||||
### Milestones
|
||||
- `POST /milestones` - 创建里程碑
|
||||
- `GET /milestones` - 列表(支持按 project/status 过滤)
|
||||
- `GET /milestones/{id}` - 详情
|
||||
- `PATCH /milestones/{id}` - 更新
|
||||
- `DELETE /milestones/{id}` - 删除
|
||||
- `GET /milestones/{id}/issues` - 里程碑下的 issue 列表
|
||||
- `GET /milestones/{id}/progress` - 里程碑完成进度
|
||||
Database resolution order: **wizard config volume** (`$CONFIG_DIR/$CONFIG_FILE` → `database` block) → `DATABASE_URL` env → built-in default.
|
||||
|
||||
### Notifications
|
||||
- `GET /notifications` - 列表(支持 user_id, unread_only 过滤)
|
||||
- `GET /notifications/count` - 未读通知计数
|
||||
- `POST /notifications/{id}/read` - 标记已读
|
||||
- `POST /notifications/read-all` - 全部标记已读
|
||||
## Security
|
||||
|
||||
### Issue Assignment
|
||||
- `POST /issues/{id}/assign` - 指派 issue(自动发送通知)
|
||||
The current code enforces the following security posture. These are
|
||||
operational requirements, not optional hardening.
|
||||
|
||||
### Webhook Retry
|
||||
- `POST /webhooks/{id}/retry/{log_id}` - 重试失败的 webhook 投递
|
||||
### Mandatory strong `SECRET_KEY`
|
||||
|
||||
### Time Tracking (Work Logs)
|
||||
- `POST /worklogs` - 记录工时
|
||||
- `GET /issues/{id}/worklogs` - 某 issue 的工时记录
|
||||
- `GET /issues/{id}/worklogs/summary` - 某 issue 工时汇总
|
||||
- `GET /users/{id}/worklogs` - 某用户的工时记录
|
||||
- `DELETE /worklogs/{id}` - 删除工时记录
|
||||
- `GET /projects/{id}/worklogs/summary` - 项目工时汇总(按用户分组)
|
||||
`app/core/config.py` validates `SECRET_KEY` at import time and **raises and
|
||||
refuses to start** if the value is empty, shorter than 32 characters, or a
|
||||
known default/placeholder (e.g. `change-me-in-production`, `secret`,
|
||||
`changeme`). Operators **must** provide a strong random key:
|
||||
|
||||
### Export
|
||||
- `GET /export/issues` - 导出 issues CSV
|
||||
- `GET /issues/overdue` - 逾期未完成的 issue
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
A weak signing key allows JWT forgery and full authentication bypass, so this
|
||||
check is intentionally fatal.
|
||||
|
||||
### API-key management is admin-only and masked
|
||||
|
||||
The `/api-keys` endpoints (`POST`, `GET`, `DELETE /api-keys/{id}`) all require
|
||||
a global admin (`require_admin`). Listing **never returns the full secret** —
|
||||
keys are masked to a short prefix/suffix (e.g. `abc123…9f`). The full key is
|
||||
only returned once, on creation.
|
||||
|
||||
### Webhooks router is admin-only with SSRF protection
|
||||
|
||||
The entire `/webhooks` router is mounted with `dependencies=[Depends(require_admin)]`,
|
||||
so every webhook endpoint (create/list/get/update/delete/logs/retry) requires a
|
||||
global admin. Webhook delivery (`app/services/webhook.py`) validates the target
|
||||
URL before sending:
|
||||
|
||||
- Only `http`/`https` schemes are allowed.
|
||||
- The host is DNS-resolved and **every** resolved address is rejected if it is
|
||||
private, loopback, link-local, multicast, reserved, or unspecified
|
||||
(SSRF protection).
|
||||
- HTTP redirects are disabled (`follow_redirects=False`).
|
||||
|
||||
### Project role hierarchy enforcement
|
||||
|
||||
`check_project_role` in `app/api/rbac.py` enforces a real, ordered role
|
||||
hierarchy rather than a flat membership check:
|
||||
|
||||
```
|
||||
guest(0) < viewer(1) < member(2) < dev(3) < mgr(4) < admin(5)
|
||||
```
|
||||
|
||||
A caller below the required rank is denied with `403`, and any unknown role on
|
||||
either side is denied by default. Global admins bypass project-level checks.
|
||||
|
||||
### Authentication on previously open endpoints
|
||||
|
||||
The following endpoints now require an authenticated caller
|
||||
(JWT bearer token **or** `X-API-Key`) and enforce ownership/permission:
|
||||
|
||||
- `DELETE /milestones/{id}` — requires milestone-edit permission.
|
||||
- `POST /worklogs` — worklogs are always attributed to the caller; only admins
|
||||
may log time for another user.
|
||||
- `DELETE /worklogs/{id}` — caller-scoped; non-admins cannot delete another
|
||||
user's worklog.
|
||||
- `POST /tasks/{task_code}/assign` and `POST /tasks/batch/assign`.
|
||||
- `GET /activity`.
|
||||
- `GET /export/tasks`.
|
||||
|
||||
## Authentication
|
||||
|
||||
- `POST /auth/token` — OAuth2 password grant; returns a JWT bearer token.
|
||||
- Authenticated requests send `Authorization: Bearer <token>` **or**
|
||||
`X-API-Key: <key>` (API keys map to a user and are created by admins).
|
||||
- `GET /auth/me` — current user.
|
||||
- `GET /auth/me/permissions`, `GET /auth/me/apikey-permissions` — permission introspection.
|
||||
|
||||
## Key API Areas
|
||||
|
||||
| Area | Prefix / Routes | Notes |
|
||||
|------|-----------------|-------|
|
||||
| Auth | `/auth/*` | token, current user, permission introspection |
|
||||
| Users | `/users` | registration, list/detail/update (list & mutate are admin-only) |
|
||||
| Projects | `/projects` | CRUD, members (`/projects/{id}/members`), worklog summary |
|
||||
| Project members | `/projects/{id}/members` | add/list/remove with role |
|
||||
| Milestones | `/projects/{id}/milestones`, `/milestones/{id}` | CRUD, items, progress |
|
||||
| Milestone actions | preflight / freeze / start / close | lifecycle transitions |
|
||||
| Tasks | `/tasks` | CRUD, transition, take, assign, batch transition/assign, tags, search |
|
||||
| Comments | `/comments`, `/tasks/{id}/comments` | CRUD |
|
||||
| Proposals | `/projects/{code}/proposals` | propose / accept / reject / reopen (legacy `/proposes`) |
|
||||
| Essentials | proposal essentials | feature/improvement/refactor items |
|
||||
| Meetings | `/meetings` | create/list/detail/update/delete/attend |
|
||||
| Roles & RBAC | `/roles` | roles, permissions, role-permission assignment |
|
||||
| Webhooks | `/webhooks` | **admin-only**; CRUD, logs, retry (SSRF-guarded delivery) |
|
||||
| API keys | `/api-keys` | **admin-only**; create/list (masked)/revoke |
|
||||
| Worklogs | `/worklogs`, `/tasks/{id}/worklogs`, `/users/{id}/worklogs` | time tracking & summaries |
|
||||
| Notifications | `/notifications` | list, unread count, mark read / read-all |
|
||||
| Activity | `/activity` | activity log (authenticated) |
|
||||
| Export | `/export/tasks` | CSV export (authenticated) |
|
||||
| Calendar | `/calendar` | scheduling / time slots |
|
||||
| Monitor | `/monitor` | public overview, admin providers/servers, heartbeat telemetry |
|
||||
| Dashboard | `/dashboard/stats` | aggregate statistics |
|
||||
| System | `/health`, `/version`, `/config/status` | health, version, init status |
|
||||
|
||||
## Task Types
|
||||
|
||||
| Type | 用途 |
|
||||
|------|------|
|
||||
| issue | 普通任务 |
|
||||
| story | 用户故事 |
|
||||
| test | 测试用例 |
|
||||
| resolution | 决议案(Agent 僵局提交)|
|
||||
|
||||
## CLI
|
||||
|
||||
@@ -102,18 +165,9 @@ The legacy Python CLI (`cli.py`) has been retired. Use the Go-based `hf` CLI ins
|
||||
|
||||
See [HarborForge.Cli](../HarborForge.Cli/README.md) for installation and usage.
|
||||
|
||||
## 技术栈
|
||||
## Tech Stack
|
||||
|
||||
- Python 3.11 + FastAPI
|
||||
- SQLAlchemy + MySQL
|
||||
- JWT (python-jose)
|
||||
- SQLAlchemy + MySQL (auto schema create/migrate on startup)
|
||||
- JWT (python-jose, HS256) + bcrypt password hashing
|
||||
- Docker
|
||||
|
||||
## Issue Types
|
||||
|
||||
| Type | 用途 |
|
||||
|------|------|
|
||||
| task | 普通任务 |
|
||||
| story | 用户故事 |
|
||||
| test | 测试用例 |
|
||||
| resolution | 决议案(Agent 僵局提交)|
|
||||
|
||||
@@ -76,3 +76,10 @@ async def get_current_user_or_apikey(
|
||||
if token:
|
||||
return await get_current_user(token=token, db=db)
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
|
||||
def require_admin(current_user: models.User = Depends(get_current_user_or_apikey)) -> models.User:
|
||||
"""Dependency: caller must be a global admin (JWT or API key)."""
|
||||
if not getattr(current_user, "is_admin", False):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required")
|
||||
return current_user
|
||||
|
||||
@@ -81,12 +81,28 @@ def check_project_role(db: Session, user_id: int, project_id: int, min_role: str
|
||||
detail="Role not found"
|
||||
)
|
||||
|
||||
# Legacy compatibility: most current routes use non-hierarchical names like dev/mgr.
|
||||
# For now, any valid membership passes those broad checks; strict edit rules are handled
|
||||
# by the explicit can_edit_* helpers below.
|
||||
if min_role in {"dev", "mgr", "viewer", "member", "guest", "admin"}:
|
||||
return True
|
||||
|
||||
# Enforce a real role hierarchy. Higher rank == more privilege.
|
||||
_RANK = {
|
||||
"guest": 0,
|
||||
"viewer": 1,
|
||||
"member": 2,
|
||||
"dev": 3,
|
||||
"mgr": 4,
|
||||
"admin": 5,
|
||||
}
|
||||
role_rank = _RANK.get((role.name or "").lower())
|
||||
required_rank = _RANK.get((min_role or "member").lower())
|
||||
if role_rank is None or required_rank is None:
|
||||
# Unknown role on either side -> deny by default.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Insufficient project role (have '{role.name}', need '{min_role}')",
|
||||
)
|
||||
if role_rank < required_rank:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Insufficient project role (have '{role.name}', need '{min_role}')",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ router = APIRouter(prefix="/auth", tags=["Auth"])
|
||||
|
||||
@router.post("/token", response_model=Token)
|
||||
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
if settings.HARBORFORGE_OIDC_ONLY:
|
||||
raise HTTPException(status_code=403, detail="Password login is disabled (OIDC only)")
|
||||
user = db.query(models.User).filter(models.User.username == form_data.username).first()
|
||||
if not user or not verify_password(form_data.password, user.hashed_password or ""):
|
||||
raise HTTPException(status_code=401, detail="Incorrect username or password",
|
||||
|
||||
@@ -361,6 +361,62 @@ def agent_heartbeat(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sync",
|
||||
summary="Sync today's schedules for all agents on a claw instance",
|
||||
)
|
||||
def sync_schedules(
|
||||
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Return today's slots for all agents belonging to the given claw instance.
|
||||
|
||||
Used by the HF OpenClaw plugin to maintain a local schedule cache.
|
||||
Returns a dict of { agent_id: [slots] } for all agents with matching
|
||||
claw_identifier.
|
||||
"""
|
||||
today = date_type.today()
|
||||
|
||||
# Find all agents on this claw instance
|
||||
agents = (
|
||||
db.query(Agent)
|
||||
.filter(Agent.claw_identifier == x_claw_identifier)
|
||||
.all()
|
||||
)
|
||||
|
||||
schedules: dict[str, list[dict]] = {}
|
||||
for agent in agents:
|
||||
# Get real slots for today
|
||||
real_slots = (
|
||||
db.query(TimeSlot)
|
||||
.filter(
|
||||
TimeSlot.user_id == agent.user_id,
|
||||
TimeSlot.date == today,
|
||||
TimeSlot.status.notin_(list(_INACTIVE_STATUSES)),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
items = [_real_slot_to_item(s).model_dump(mode="json") for s in real_slots]
|
||||
|
||||
# Get virtual plan slots
|
||||
virtual_slots = get_virtual_slots_for_date(db, agent.user_id, today)
|
||||
for vs in virtual_slots:
|
||||
items.append(_virtual_slot_to_item(vs).model_dump(mode="json"))
|
||||
|
||||
schedules[agent.agent_id] = items
|
||||
|
||||
# Record heartbeat for liveness
|
||||
for agent in agents:
|
||||
record_heartbeat(db, agent)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"schedules": schedules,
|
||||
"date": today.isoformat(),
|
||||
"agent_count": len(agents),
|
||||
}
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/slots/{slot_id}/agent-update",
|
||||
response_model=TimeSlotEditResponse,
|
||||
|
||||
@@ -12,7 +12,7 @@ from sqlalchemy import func as sqlfunc
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import get_db
|
||||
from app.api.deps import get_current_user_or_apikey
|
||||
from app.api.deps import get_current_user_or_apikey, require_admin
|
||||
from app.api.rbac import check_project_role, ensure_can_edit_milestone
|
||||
from app.models import models
|
||||
from app.models.apikey import APIKey
|
||||
@@ -60,7 +60,8 @@ class APIKeyResponse(BaseModel):
|
||||
|
||||
|
||||
@router.post("/api-keys", response_model=APIKeyResponse, status_code=status.HTTP_201_CREATED, tags=["API Keys"])
|
||||
def create_api_key(data: APIKeyCreate, db: Session = Depends(get_db)):
|
||||
def create_api_key(data: APIKeyCreate, db: Session = Depends(get_db),
|
||||
_: models.User = Depends(require_admin)):
|
||||
user = db.query(models.User).filter(models.User.id == data.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
@@ -73,15 +74,22 @@ def create_api_key(data: APIKeyCreate, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/api-keys", response_model=List[APIKeyResponse], tags=["API Keys"])
|
||||
def list_api_keys(user_id: int = None, db: Session = Depends(get_db)):
|
||||
def list_api_keys(user_id: int = None, db: Session = Depends(get_db),
|
||||
_: models.User = Depends(require_admin)):
|
||||
query = db.query(APIKey)
|
||||
if user_id:
|
||||
query = query.filter(APIKey.user_id == user_id)
|
||||
return query.all()
|
||||
keys = query.all()
|
||||
# Never expose the full secret on listing; show only a masked prefix.
|
||||
for k in keys:
|
||||
if k.key and len(k.key) > 8:
|
||||
k.key = k.key[:6] + "…" + k.key[-2:]
|
||||
return keys
|
||||
|
||||
|
||||
@router.delete("/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["API Keys"])
|
||||
def revoke_api_key(key_id: int, db: Session = Depends(get_db)):
|
||||
def revoke_api_key(key_id: int, db: Session = Depends(get_db),
|
||||
_: models.User = Depends(require_admin)):
|
||||
key_obj = db.query(APIKey).filter(APIKey.id == key_id).first()
|
||||
if not key_obj:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
@@ -106,7 +114,8 @@ class ActivityLogResponse(BaseModel):
|
||||
|
||||
@router.get("/activity", response_model=List[ActivityLogResponse], tags=["Activity"])
|
||||
def list_activity(entity_type: str = None, entity_id: int = None, user_id: int = None,
|
||||
limit: int = 50, db: Session = Depends(get_db)):
|
||||
limit: int = 50, db: Session = Depends(get_db),
|
||||
_: models.User = Depends(get_current_user_or_apikey)):
|
||||
query = db.query(ActivityLog)
|
||||
if entity_type:
|
||||
query = query.filter(ActivityLog.entity_type == entity_type)
|
||||
@@ -199,8 +208,10 @@ def update_milestone(milestone_id: str, ms_update: schemas.MilestoneUpdate, db:
|
||||
|
||||
|
||||
@router.delete("/milestones/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Milestones"])
|
||||
def delete_milestone(milestone_id: str, db: Session = Depends(get_db)):
|
||||
def delete_milestone(milestone_id: str, db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
ms = _resolve_milestone(db, milestone_id)
|
||||
ensure_can_edit_milestone(db, current_user.id, ms)
|
||||
db.delete(ms)
|
||||
db.commit()
|
||||
return None
|
||||
@@ -322,16 +333,18 @@ class WorkLogResponse(BaseModel):
|
||||
|
||||
|
||||
@router.post("/worklogs", response_model=WorkLogResponse, status_code=status.HTTP_201_CREATED, tags=["Time Tracking"])
|
||||
def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db)):
|
||||
def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
task = db.query(Task).filter(Task.id == wl.task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
user = db.query(models.User).filter(models.User.id == wl.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if wl.hours <= 0:
|
||||
raise HTTPException(status_code=400, detail="Hours must be positive")
|
||||
db_wl = WorkLog(**wl.model_dump())
|
||||
data = wl.model_dump()
|
||||
# Worklogs are always attributed to the caller (non-admins cannot log time for others).
|
||||
if not current_user.is_admin or not data.get("user_id"):
|
||||
data["user_id"] = current_user.id
|
||||
db_wl = WorkLog(**data)
|
||||
db.add(db_wl)
|
||||
db.commit()
|
||||
db.refresh(db_wl)
|
||||
@@ -370,10 +383,13 @@ def task_worklog_summary(task_id: str, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.delete("/worklogs/{worklog_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Time Tracking"])
|
||||
def delete_worklog(worklog_id: int, db: Session = Depends(get_db)):
|
||||
def delete_worklog(worklog_id: int, db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
wl = db.query(WorkLog).filter(WorkLog.id == worklog_id).first()
|
||||
if not wl:
|
||||
raise HTTPException(status_code=404, detail="Work log not found")
|
||||
if not current_user.is_admin and wl.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot delete another user's worklog")
|
||||
db.delete(wl)
|
||||
db.commit()
|
||||
return None
|
||||
@@ -382,7 +398,8 @@ def delete_worklog(worklog_id: int, db: Session = Depends(get_db)):
|
||||
# ============ Export ============
|
||||
|
||||
@router.get("/export/tasks", tags=["Export"])
|
||||
def export_tasks_csv(project_id: int = None, db: Session = Depends(get_db)):
|
||||
def export_tasks_csv(project_id: int = None, db: Session = Depends(get_db),
|
||||
_: models.User = Depends(get_current_user_or_apikey)):
|
||||
query = db.query(Task)
|
||||
if project_id:
|
||||
query = query.filter(Task.project_id == project_id)
|
||||
|
||||
350
app/api/routers/oidc.py
Normal file
350
app/api/routers/oidc.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""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, _=_)
|
||||
209
app/api/routers/schedule_type.py
Normal file
209
app/api/routers/schedule_type.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""ScheduleType API router.
|
||||
|
||||
CRUD for schedule types (work/entertainment time periods)
|
||||
and agent schedule type assignment.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from app.core.config import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.models import User
|
||||
from app.models.agent import Agent
|
||||
from app.models.schedule_type import ScheduleType
|
||||
from app.models.role_permission import Permission, RolePermission
|
||||
from app.schemas.schedule_type import (
|
||||
ScheduleTypeCreate,
|
||||
ScheduleTypeUpdate,
|
||||
ScheduleTypeResponse,
|
||||
AgentScheduleTypeAssign,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/schedule-types", tags=["ScheduleTypes"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Permission helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _has_permission(db: Session, user: User, permission_name: str) -> bool:
|
||||
if user.is_admin:
|
||||
return True
|
||||
if not user.role_id:
|
||||
return False
|
||||
return (
|
||||
db.query(RolePermission)
|
||||
.join(Permission)
|
||||
.filter(
|
||||
RolePermission.role_id == user.role_id,
|
||||
Permission.name == permission_name,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _require_schedule_read(db: Session, user: User) -> User:
|
||||
if not _has_permission(db, user, "schedule_type.read"):
|
||||
raise HTTPException(403, "Permission denied: schedule_type.read")
|
||||
return user
|
||||
|
||||
|
||||
def _require_schedule_manage(db: Session, user: User) -> User:
|
||||
if not _has_permission(db, user, "schedule_type.manage"):
|
||||
raise HTTPException(403, "Permission denied: schedule_type.manage")
|
||||
return user
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schedule Type CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=List[ScheduleTypeResponse],
|
||||
summary="List all schedule types",
|
||||
)
|
||||
def list_schedule_types(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_require_schedule_read(db, current_user)
|
||||
return db.query(ScheduleType).all()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=ScheduleTypeResponse,
|
||||
summary="Create a schedule type",
|
||||
)
|
||||
def create_schedule_type(
|
||||
payload: ScheduleTypeCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_require_schedule_manage(db, current_user)
|
||||
|
||||
existing = db.query(ScheduleType).filter(ScheduleType.name == payload.name).first()
|
||||
if existing:
|
||||
raise HTTPException(409, f"Schedule type '{payload.name}' already exists")
|
||||
|
||||
st = ScheduleType(
|
||||
name=payload.name,
|
||||
work_from=payload.work_from,
|
||||
work_to=payload.work_to,
|
||||
entertainment_from=payload.entertainment_from,
|
||||
entertainment_to=payload.entertainment_to,
|
||||
)
|
||||
db.add(st)
|
||||
db.commit()
|
||||
db.refresh(st)
|
||||
return st
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{schedule_type_id}",
|
||||
response_model=ScheduleTypeResponse,
|
||||
summary="Update a schedule type",
|
||||
)
|
||||
def update_schedule_type(
|
||||
schedule_type_id: int,
|
||||
payload: ScheduleTypeUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_require_schedule_manage(db, current_user)
|
||||
|
||||
st = db.query(ScheduleType).filter(ScheduleType.id == schedule_type_id).first()
|
||||
if not st:
|
||||
raise HTTPException(404, "Schedule type not found")
|
||||
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(st, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(st)
|
||||
return st
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{schedule_type_id}",
|
||||
summary="Delete a schedule type",
|
||||
)
|
||||
def delete_schedule_type(
|
||||
schedule_type_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_require_schedule_manage(db, current_user)
|
||||
|
||||
st = db.query(ScheduleType).filter(ScheduleType.id == schedule_type_id).first()
|
||||
if not st:
|
||||
raise HTTPException(404, "Schedule type not found")
|
||||
|
||||
# Check if any agents are using this schedule type
|
||||
agents_using = db.query(Agent).filter(Agent.schedule_type_id == schedule_type_id).count()
|
||||
if agents_using > 0:
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"Cannot delete: {agents_using} agent(s) are assigned to this schedule type",
|
||||
)
|
||||
|
||||
db.delete(st)
|
||||
db.commit()
|
||||
return {"ok": True, "deleted": schedule_type_id}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent schedule type assignment (agent-facing, uses X-Agent-ID header)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get(
|
||||
"/agent/me",
|
||||
response_model=ScheduleTypeResponse | None,
|
||||
summary="Get my schedule type",
|
||||
)
|
||||
def get_my_schedule_type(
|
||||
x_agent_id: str = Header(..., alias="X-Agent-ID"),
|
||||
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
agent = (
|
||||
db.query(Agent)
|
||||
.filter(Agent.agent_id == x_agent_id, Agent.claw_identifier == x_claw_identifier)
|
||||
.first()
|
||||
)
|
||||
if not agent:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
|
||||
if not agent.schedule_type_id:
|
||||
return None
|
||||
|
||||
return db.query(ScheduleType).filter(ScheduleType.id == agent.schedule_type_id).first()
|
||||
|
||||
|
||||
@router.put(
|
||||
"/agent/{agent_id}/assign",
|
||||
summary="Assign a schedule type to an agent",
|
||||
)
|
||||
def assign_schedule_type(
|
||||
agent_id: str,
|
||||
payload: AgentScheduleTypeAssign,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
_require_schedule_manage(db, current_user)
|
||||
|
||||
agent = db.query(Agent).filter(Agent.agent_id == agent_id).first()
|
||||
if not agent:
|
||||
raise HTTPException(404, f"Agent '{agent_id}' not found")
|
||||
|
||||
st = db.query(ScheduleType).filter(ScheduleType.name == payload.schedule_type_name).first()
|
||||
if not st:
|
||||
raise HTTPException(404, f"Schedule type '{payload.schedule_type_name}' not found")
|
||||
|
||||
agent.schedule_type_id = st.id
|
||||
db.commit()
|
||||
return {"ok": True, "agent_id": agent_id, "schedule_type": st.name}
|
||||
@@ -576,8 +576,10 @@ def take_task(
|
||||
# ---- Assignment ----
|
||||
|
||||
@router.post("/tasks/{task_code}/assign")
|
||||
def assign_task(task_code: str, assignee_id: int, db: Session = Depends(get_db)):
|
||||
def assign_task(task_code: str, assignee_id: int, db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
task = _resolve_task(db, task_code)
|
||||
ensure_can_edit_task(db, current_user.id, task)
|
||||
user = db.query(models.User).filter(models.User.id == assignee_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
@@ -765,7 +767,8 @@ def batch_transition(
|
||||
|
||||
|
||||
@router.post("/tasks/batch/assign")
|
||||
def batch_assign(data: BatchAssign, db: Session = Depends(get_db)):
|
||||
def batch_assign(data: BatchAssign, db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey)):
|
||||
user = db.query(models.User).filter(models.User.id == data.assignee_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Assignee not found")
|
||||
@@ -773,6 +776,7 @@ def batch_assign(data: BatchAssign, db: Session = Depends(get_db)):
|
||||
for task_code in data.task_codes:
|
||||
task = db.query(Task).filter(Task.task_code == task_code).first()
|
||||
if task:
|
||||
ensure_can_edit_task(db, current_user.id, task)
|
||||
task.assignee_id = data.assignee_id
|
||||
updated.append(task.task_code)
|
||||
db.commit()
|
||||
|
||||
@@ -8,7 +8,8 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash
|
||||
from app.core.config import get_db
|
||||
from app.core.config import get_db, settings
|
||||
from app.init_wizard import DELETED_USER_USERNAME
|
||||
from app.models import models
|
||||
from app.models.agent import Agent
|
||||
from app.models.role_permission import Permission, Role, RolePermission
|
||||
@@ -31,6 +32,8 @@ def _user_response(user: models.User) -> dict:
|
||||
"role_name": user.role_name,
|
||||
"agent_id": user.agent.agent_id if user.agent else None,
|
||||
"discord_user_id": user.discord_user_id,
|
||||
"oidc_issuer": user.oidc_issuer,
|
||||
"oidc_subject": user.oidc_subject,
|
||||
"created_at": user.created_at,
|
||||
}
|
||||
return data
|
||||
@@ -110,6 +113,12 @@ def create_user(
|
||||
raise HTTPException(status_code=400, detail="agent_id already in use")
|
||||
|
||||
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
|
||||
db_user = models.User(
|
||||
username=user.username,
|
||||
@@ -190,7 +199,7 @@ def update_user(
|
||||
if payload.full_name is not None:
|
||||
user.full_name = payload.full_name
|
||||
|
||||
if payload.password is not None and payload.password.strip():
|
||||
if payload.password is not None and payload.password.strip() and not settings.HARBORFORGE_OIDC_ONLY:
|
||||
user.hashed_password = get_password_hash(payload.password)
|
||||
|
||||
if payload.role_id is not None:
|
||||
@@ -212,6 +221,86 @@ def update_user(
|
||||
return _user_response(user)
|
||||
|
||||
|
||||
_BUILTIN_USERNAMES = {"acc-mgr", DELETED_USER_USERNAME}
|
||||
|
||||
|
||||
def _reassign_user_references(db: Session, old_id: int, new_id: int) -> None:
|
||||
"""Reassign all foreign key references from old_id to new_id, then delete
|
||||
records that would be meaningless under deleted-user (api_keys, notifications,
|
||||
project memberships)."""
|
||||
from app.models.apikey import APIKey
|
||||
from app.models.notification import Notification
|
||||
from app.models.activity import ActivityLog as Activity
|
||||
from app.models.worklog import WorkLog as WorkLogModel
|
||||
from app.models.meeting import Meeting, MeetingParticipant
|
||||
from app.models.task import Task
|
||||
from app.models.support import Support
|
||||
from app.models.proposal import Proposal
|
||||
from app.models.milestone import Milestone
|
||||
from app.models.calendar import TimeSlot, SchedulePlan
|
||||
from app.models.minimum_workload import MinimumWorkload
|
||||
from app.models.essential import Essential
|
||||
|
||||
# Delete records that are meaningless without the real user
|
||||
db.query(APIKey).filter(APIKey.user_id == old_id).delete()
|
||||
db.query(Notification).filter(Notification.user_id == old_id).delete()
|
||||
db.query(models.ProjectMember).filter(models.ProjectMember.user_id == old_id).delete()
|
||||
|
||||
# Reassign ownership/authorship references
|
||||
db.query(models.Project).filter(models.Project.owner_id == old_id).update(
|
||||
{"owner_id": new_id})
|
||||
db.query(models.Comment).filter(models.Comment.author_id == old_id).update(
|
||||
{"author_id": new_id})
|
||||
db.query(Activity).filter(Activity.user_id == old_id).update(
|
||||
{"user_id": new_id})
|
||||
db.query(WorkLogModel).filter(WorkLogModel.user_id == old_id).update(
|
||||
{"user_id": new_id})
|
||||
|
||||
# Tasks
|
||||
db.query(Task).filter(Task.reporter_id == old_id).update(
|
||||
{"reporter_id": new_id})
|
||||
db.query(Task).filter(Task.assignee_id == old_id).update(
|
||||
{"assignee_id": new_id})
|
||||
db.query(Task).filter(Task.created_by_id == old_id).update(
|
||||
{"created_by_id": new_id})
|
||||
|
||||
# Meetings
|
||||
db.query(Meeting).filter(Meeting.reporter_id == old_id).update(
|
||||
{"reporter_id": new_id})
|
||||
db.query(MeetingParticipant).filter(MeetingParticipant.user_id == old_id).update(
|
||||
{"user_id": new_id})
|
||||
|
||||
# Support
|
||||
db.query(Support).filter(Support.reporter_id == old_id).update(
|
||||
{"reporter_id": new_id})
|
||||
db.query(Support).filter(Support.assignee_id == old_id).update(
|
||||
{"assignee_id": new_id})
|
||||
|
||||
# Proposals
|
||||
db.query(Proposal).filter(Proposal.created_by_id == old_id).update(
|
||||
{"created_by_id": new_id})
|
||||
|
||||
# Milestones
|
||||
db.query(Milestone).filter(Milestone.created_by_id == old_id).update(
|
||||
{"created_by_id": new_id})
|
||||
|
||||
# Calendar
|
||||
db.query(TimeSlot).filter(TimeSlot.user_id == old_id).update(
|
||||
{"user_id": new_id})
|
||||
db.query(SchedulePlan).filter(SchedulePlan.user_id == old_id).update(
|
||||
{"user_id": new_id})
|
||||
|
||||
# Minimum workload / Essential
|
||||
db.query(MinimumWorkload).filter(MinimumWorkload.user_id == old_id).update(
|
||||
{"user_id": new_id})
|
||||
db.query(Essential).filter(Essential.created_by_id == old_id).update(
|
||||
{"created_by_id": new_id})
|
||||
|
||||
# Agent profile
|
||||
db.query(Agent).filter(Agent.user_id == old_id).update(
|
||||
{"user_id": new_id})
|
||||
|
||||
|
||||
@router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_user(
|
||||
identifier: str,
|
||||
@@ -223,17 +312,26 @@ def delete_user(
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if current_user.id == user.id:
|
||||
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
||||
# Protect built-in accounts from deletion
|
||||
if user.is_admin:
|
||||
raise HTTPException(status_code=400, detail="Admin accounts cannot be deleted")
|
||||
if user.username == "acc-mgr":
|
||||
raise HTTPException(status_code=400, detail="The acc-mgr account is a built-in account and cannot be deleted")
|
||||
try:
|
||||
if user.username in _BUILTIN_USERNAMES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"The {user.username} account is a built-in account and cannot be deleted",
|
||||
)
|
||||
|
||||
deleted_user = db.query(models.User).filter(
|
||||
models.User.username == DELETED_USER_USERNAME
|
||||
).first()
|
||||
if not deleted_user:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Built-in deleted-user account not found. Run init_wizard first.",
|
||||
)
|
||||
|
||||
_reassign_user_references(db, user.id, deleted_user.id)
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=400, detail="User has related records. Deactivate the account instead.")
|
||||
return None
|
||||
|
||||
|
||||
@@ -241,7 +339,7 @@ def delete_user(
|
||||
def reset_user_apikey(
|
||||
identifier: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
"""Reset (regenerate) a user's API key.
|
||||
|
||||
@@ -249,6 +347,8 @@ def reset_user_apikey(
|
||||
- user.reset-apikey: can reset any user's API key
|
||||
- user.reset-self-apikey: can reset only own API key
|
||||
- admin: can reset any user's API key
|
||||
|
||||
Accepts both OAuth2 Bearer token and X-API-Key authentication.
|
||||
"""
|
||||
import secrets
|
||||
from app.models.apikey import APIKey
|
||||
@@ -322,3 +422,92 @@ def list_user_worklogs(
|
||||
if current_user.id != user.id and not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
return db.query(WorkLog).filter(WorkLog.user_id == user.id).order_by(WorkLog.logged_date.desc()).limit(limit).all()
|
||||
|
||||
|
||||
# ---- OIDC identity binding ------------------------------------------------
|
||||
|
||||
class OidcBindingRequest(BaseModel):
|
||||
issuer: str
|
||||
subject: str
|
||||
|
||||
|
||||
class OidcBindingResponse(BaseModel):
|
||||
user_id: int
|
||||
username: str
|
||||
oidc_issuer: str | None = None
|
||||
oidc_subject: str | None = None
|
||||
|
||||
|
||||
def _assert_can_manage_oidc_binding(db: Session, caller: models.User, target: models.User) -> None:
|
||||
"""Global admins may (un)bind anyone. Non-admin account managers may
|
||||
only operate on non-privileged accounts — never on an admin or another
|
||||
privileged account — otherwise binding an attacker-controlled OIDC
|
||||
identity to an admin would be a privilege-escalation primitive."""
|
||||
if getattr(caller, "is_admin", False):
|
||||
return
|
||||
privileged = (
|
||||
getattr(target, "is_admin", False)
|
||||
or target.username in ("acc-mgr", "deleted-user")
|
||||
or _has_global_permission(db, target, "account.create")
|
||||
or _has_global_permission(db, target, "user.reset-apikey")
|
||||
)
|
||||
if privileged:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only a global admin may manage the OIDC binding of a privileged account",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{identifier}/oidc-binding", response_model=OidcBindingResponse)
|
||||
def bind_user_oidc(
|
||||
identifier: str,
|
||||
payload: OidcBindingRequest,
|
||||
db: Session = Depends(get_db),
|
||||
caller: models.User = Depends(require_account_creator),
|
||||
):
|
||||
"""Bind an hf user to an external OIDC identity (issuer + subject).
|
||||
|
||||
Admin or account-manager (JWT or API key). Account managers may not
|
||||
target privileged/admin accounts. One OIDC identity maps to at most
|
||||
one user."""
|
||||
issuer = (payload.issuer or "").strip()
|
||||
subject = (payload.subject or "").strip()
|
||||
if not issuer or not subject:
|
||||
raise HTTPException(status_code=400, detail="issuer and subject are required")
|
||||
user = _find_user_by_id_or_username(db, identifier)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
_assert_can_manage_oidc_binding(db, caller, user)
|
||||
clash = db.query(models.User).filter(
|
||||
models.User.oidc_issuer == issuer,
|
||||
models.User.oidc_subject == subject,
|
||||
models.User.id != user.id,
|
||||
).first()
|
||||
if clash:
|
||||
raise HTTPException(status_code=409, detail=f"OIDC identity already bound to user '{clash.username}'")
|
||||
user.oidc_issuer = issuer
|
||||
user.oidc_subject = subject
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return OidcBindingResponse(user_id=user.id, username=user.username,
|
||||
oidc_issuer=user.oidc_issuer, oidc_subject=user.oidc_subject)
|
||||
|
||||
|
||||
@router.delete("/{identifier}/oidc-binding", response_model=OidcBindingResponse)
|
||||
def unbind_user_oidc(
|
||||
identifier: str,
|
||||
db: Session = Depends(get_db),
|
||||
caller: models.User = Depends(require_account_creator),
|
||||
):
|
||||
"""Remove a user's OIDC binding. Admin or account-manager; account
|
||||
managers may not target privileged/admin accounts."""
|
||||
user = _find_user_by_id_or_username(db, identifier)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
_assert_can_manage_oidc_binding(db, caller, user)
|
||||
user.oidc_issuer = None
|
||||
user.oidc_subject = None
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return OidcBindingResponse(user_id=user.id, username=user.username,
|
||||
oidc_issuer=None, oidc_subject=None)
|
||||
|
||||
@@ -5,11 +5,13 @@ from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_db
|
||||
from app.api.deps import require_admin
|
||||
from app.models.webhook import Webhook, WebhookLog
|
||||
from app.schemas.webhook import WebhookCreate, WebhookUpdate, WebhookResponse, WebhookLogResponse
|
||||
from app.services.webhook import fire_webhooks_sync
|
||||
|
||||
router = APIRouter(prefix="/webhooks", tags=["Webhooks"])
|
||||
# Webhook management is admin-only (registration, inspection, retry, logs).
|
||||
router = APIRouter(prefix="/webhooks", tags=["Webhooks"], dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
@router.post("", response_model=WebhookResponse, status_code=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -38,12 +38,43 @@ class Settings(BaseSettings):
|
||||
ALGORITHM: str = "HS256"
|
||||
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:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
# Fail fast on a weak/default JWT signing key (prevents token forgery).
|
||||
_WEAK_SECRETS = {
|
||||
"change-me-in-production",
|
||||
"change_me_in_production",
|
||||
"change-me-use-openssl-rand-hex-32",
|
||||
"secret",
|
||||
"changeme",
|
||||
"",
|
||||
}
|
||||
if settings.SECRET_KEY in _WEAK_SECRETS or len(settings.SECRET_KEY) < 32:
|
||||
raise RuntimeError(
|
||||
"Insecure SECRET_KEY: set a strong random value "
|
||||
"(e.g. `openssl rand -hex 32`) via the SECRET_KEY env var. "
|
||||
"Refusing to start with a default/short key."
|
||||
)
|
||||
|
||||
# Resolve DB URL: wizard config volume > env > default
|
||||
_db_url = _resolve_db_url(settings.DATABASE_URL)
|
||||
engine = create_engine(_db_url, pool_pre_ping=True)
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import models
|
||||
from app.models.role_permission import Role, Permission, RolePermission
|
||||
from app.models.oidc_settings import OidcSettings
|
||||
from app.api.deps import get_password_hash
|
||||
|
||||
logger = logging.getLogger("harborforge.init")
|
||||
@@ -189,6 +190,7 @@ _DEV_PERMISSIONS = {
|
||||
|
||||
_ACCOUNT_MANAGER_PERMISSIONS = {
|
||||
"account.create",
|
||||
"user.reset-apikey",
|
||||
}
|
||||
|
||||
# Role definitions: (name, description, permission_set)
|
||||
@@ -294,6 +296,83 @@ def init_acc_mgr_user(db: Session) -> models.User | None:
|
||||
return user
|
||||
|
||||
|
||||
DELETED_USER_USERNAME = "deleted-user"
|
||||
|
||||
|
||||
def init_deleted_user(db: Session) -> models.User | None:
|
||||
"""Create the built-in deleted-user if not exists.
|
||||
|
||||
This user serves as a foreign key sink: when a real user is deleted,
|
||||
all references are reassigned here instead of cascading deletes.
|
||||
It has no role (no permissions) and cannot log in.
|
||||
"""
|
||||
existing = db.query(models.User).filter(
|
||||
models.User.username == DELETED_USER_USERNAME
|
||||
).first()
|
||||
if existing:
|
||||
logger.info("deleted-user already exists (id=%d), skipping", existing.id)
|
||||
return existing
|
||||
|
||||
user = models.User(
|
||||
username=DELETED_USER_USERNAME,
|
||||
email="deleted-user@harborforge.internal",
|
||||
full_name="Deleted User",
|
||||
hashed_password=None,
|
||||
is_admin=False,
|
||||
is_active=False,
|
||||
role_id=None,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
logger.info("Created deleted-user (id=%d)", user.id)
|
||||
return user
|
||||
|
||||
|
||||
def init_oidc_settings(db: Session, oidc_cfg: dict, admin_user: models.User | None) -> None:
|
||||
"""Bootstrap OIDC from the wizard config (first init only).
|
||||
|
||||
Creates the single oidc_settings row if absent so the deployment comes
|
||||
up with OIDC configured. If admin_subject is given, binds the bootstrap
|
||||
admin so it can sign in (critical in OIDC-only mode). Idempotent: an
|
||||
existing row / existing admin binding is left untouched so later admin
|
||||
edits via the API are not clobbered on restart."""
|
||||
if not oidc_cfg:
|
||||
return
|
||||
|
||||
existing = db.query(OidcSettings).filter(OidcSettings.id == 1).first()
|
||||
if existing is None:
|
||||
db.add(OidcSettings(
|
||||
id=1,
|
||||
enabled=bool(oidc_cfg.get("enabled", True)),
|
||||
issuer=(oidc_cfg.get("issuer") or "").strip() or None,
|
||||
client_id=(oidc_cfg.get("client_id") or "").strip() or None,
|
||||
client_secret=oidc_cfg.get("client_secret") or None,
|
||||
redirect_uri=(oidc_cfg.get("redirect_uri") or "").strip() or None,
|
||||
scopes=(oidc_cfg.get("scopes") or "").strip() or None,
|
||||
post_login_redirect=(oidc_cfg.get("post_login_redirect") or "").strip() or None,
|
||||
admin_role=(oidc_cfg.get("admin_role") or "").strip() or None,
|
||||
))
|
||||
db.commit()
|
||||
logger.info("OIDC settings bootstrapped from wizard config")
|
||||
|
||||
admin_subject = (oidc_cfg.get("admin_subject") or "").strip()
|
||||
issuer = (oidc_cfg.get("issuer") or "").strip()
|
||||
if admin_user and admin_subject and issuer and not admin_user.oidc_subject:
|
||||
clash = db.query(models.User).filter(
|
||||
models.User.oidc_issuer == issuer,
|
||||
models.User.oidc_subject == admin_subject,
|
||||
models.User.id != admin_user.id,
|
||||
).first()
|
||||
if clash:
|
||||
logger.warning("Admin OIDC subject already bound to '%s'; skipping admin bind", clash.username)
|
||||
else:
|
||||
admin_user.oidc_issuer = issuer
|
||||
admin_user.oidc_subject = admin_subject
|
||||
db.commit()
|
||||
logger.info("Bootstrap admin '%s' bound to OIDC subject", admin_user.username)
|
||||
|
||||
|
||||
def run_init(db: Session) -> None:
|
||||
"""Main initialization entry point. Reads config from shared volume."""
|
||||
config = load_config()
|
||||
@@ -318,9 +397,15 @@ def run_init(db: Session) -> None:
|
||||
# Built-in acc-mgr user (after roles are created)
|
||||
init_acc_mgr_user(db)
|
||||
|
||||
# Built-in deleted-user (foreign key sink for deleted accounts)
|
||||
init_deleted_user(db)
|
||||
|
||||
# Default project
|
||||
project_cfg = config.get("default_project")
|
||||
if project_cfg and admin_user:
|
||||
init_default_project(db, project_cfg, admin_user.id, admin_user.username)
|
||||
|
||||
# OIDC bootstrap (provider config + optional bootstrap-admin binding)
|
||||
init_oidc_settings(db, config.get("oidc") or {}, admin_user)
|
||||
|
||||
logger.info("Initialization complete")
|
||||
|
||||
36
app/main.py
36
app/main.py
@@ -1,6 +1,9 @@
|
||||
"""HarborForge API — Agent/人类协同任务管理平台"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
app = FastAPI(
|
||||
title="HarborForge API",
|
||||
@@ -17,6 +20,17 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Short-lived signed session cookie — only used to carry the OIDC
|
||||
# state/nonce between /auth/oidc/login and the callback.
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.SECRET_KEY,
|
||||
session_cookie="hf_oidc",
|
||||
same_site="lax",
|
||||
https_only=False,
|
||||
max_age=600,
|
||||
)
|
||||
|
||||
# Health & version (kept at top level)
|
||||
@app.get("/health", tags=["System"])
|
||||
def health_check():
|
||||
@@ -63,9 +77,12 @@ from app.api.routers.proposes import router as proposes_router # legacy compat
|
||||
from app.api.routers.milestone_actions import router as milestone_actions_router
|
||||
from app.api.routers.meetings import router as meetings_router
|
||||
from app.api.routers.essentials import router as essentials_router
|
||||
from app.api.routers.schedule_type import router as schedule_type_router
|
||||
from app.api.routers.calendar import router as calendar_router
|
||||
from app.api.routers.oidc import router as oidc_router
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(oidc_router)
|
||||
app.include_router(tasks_router)
|
||||
app.include_router(projects_router)
|
||||
app.include_router(users_router)
|
||||
@@ -80,6 +97,7 @@ app.include_router(proposes_router) # legacy compat
|
||||
app.include_router(milestone_actions_router)
|
||||
app.include_router(meetings_router)
|
||||
app.include_router(essentials_router)
|
||||
app.include_router(schedule_type_router)
|
||||
app.include_router(calendar_router)
|
||||
|
||||
|
||||
@@ -275,6 +293,18 @@ def _migrate_schema():
|
||||
if _has_table(db, "users") and not _has_column(db, "users", "discord_user_id"):
|
||||
db.execute(text("ALTER TABLE users ADD COLUMN discord_user_id VARCHAR(32) NULL"))
|
||||
|
||||
# --- users OIDC binding (issuer + subject), unique together ---
|
||||
if _has_table(db, "users") and not _has_column(db, "users", "oidc_issuer"):
|
||||
db.execute(text("ALTER TABLE users ADD COLUMN oidc_issuer VARCHAR(255) NULL"))
|
||||
if _has_table(db, "users") and not _has_column(db, "users", "oidc_subject"):
|
||||
db.execute(text("ALTER TABLE users ADD COLUMN oidc_subject VARCHAR(255) NULL"))
|
||||
if _has_table(db, "users") and _has_column(db, "users", "oidc_subject"):
|
||||
_ensure_unique_index(db, "users", "uq_users_oidc_identity", "oidc_issuer, oidc_subject")
|
||||
|
||||
# --- oidc_settings.admin_role (added after the table shipped) ---
|
||||
if _has_table(db, "oidc_settings") and not _has_column(db, "oidc_settings", "admin_role"):
|
||||
db.execute(text("ALTER TABLE oidc_settings ADD COLUMN admin_role VARCHAR(128) NULL"))
|
||||
|
||||
# --- monitored_servers.api_key for heartbeat v2 ---
|
||||
if _has_table(db, "monitored_servers") and not _has_column(db, "monitored_servers", "api_key"):
|
||||
db.execute(text("ALTER TABLE monitored_servers ADD COLUMN api_key VARCHAR(64) NULL"))
|
||||
@@ -363,6 +393,10 @@ def _migrate_schema():
|
||||
if _has_table(db, "time_slots") and not _has_column(db, "time_slots", "wakeup_sent_at"):
|
||||
db.execute(text("ALTER TABLE time_slots ADD COLUMN wakeup_sent_at DATETIME NULL"))
|
||||
|
||||
# --- agents: add schedule_type_id FK ---
|
||||
if _has_table(db, "agents") and not _has_column(db, "agents", "schedule_type_id"):
|
||||
db.execute(text("ALTER TABLE agents ADD COLUMN schedule_type_id INTEGER NULL"))
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
@@ -397,7 +431,7 @@ def _sync_default_user_roles(db):
|
||||
@app.on_event("startup")
|
||||
def startup():
|
||||
from app.core.config import Base, engine, SessionLocal
|
||||
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar, minimum_workload
|
||||
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, oidc_settings
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_migrate_schema()
|
||||
|
||||
|
||||
@@ -131,6 +131,15 @@ class Agent(Base):
|
||||
comment="rate_limit | billing — why the agent is exhausted",
|
||||
)
|
||||
|
||||
# -- schedule type ------------------------------------------------------
|
||||
|
||||
schedule_type_id = Column(
|
||||
Integer,
|
||||
ForeignKey("schedule_types.id"),
|
||||
nullable=True,
|
||||
comment="FK to schedule_types — defines work/entertainment periods",
|
||||
)
|
||||
|
||||
# -- timestamps ---------------------------------------------------------
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -138,3 +147,4 @@ class Agent(Base):
|
||||
# -- relationships ------------------------------------------------------
|
||||
|
||||
user = relationship("User", back_populates="agent", uselist=False)
|
||||
schedule_type = relationship("ScheduleType", lazy="joined")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean, JSON, Time
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean, JSON, Time, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.config import Base
|
||||
@@ -66,6 +66,9 @@ class Project(Base):
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("oidc_issuer", "oidc_subject", name="uq_users_oidc_identity"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, nullable=False)
|
||||
@@ -73,6 +76,10 @@ class User(Base):
|
||||
hashed_password = Column(String(255), nullable=True)
|
||||
full_name = Column(String(100), nullable=True)
|
||||
discord_user_id = Column(String(32), nullable=True)
|
||||
# OIDC binding: an hf user is linked to at most one external OIDC identity
|
||||
# (issuer + subject). Unique together so one IdP identity maps to one user.
|
||||
oidc_issuer = Column(String(255), nullable=True)
|
||||
oidc_subject = Column(String(255), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_admin = Column(Boolean, default=False)
|
||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)
|
||||
|
||||
25
app/models/oidc_settings.py
Normal file
25
app/models/oidc_settings.py
Normal file
@@ -0,0 +1,25 @@
|
||||
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())
|
||||
52
app/models/schedule_type.py
Normal file
52
app/models/schedule_type.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""ScheduleType model — defines work/entertainment time periods.
|
||||
|
||||
Each ScheduleType defines the daily work and entertainment windows.
|
||||
Agents reference a schedule_type to know when they should be working
|
||||
vs when they can engage in entertainment activities.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.config import Base
|
||||
|
||||
|
||||
class ScheduleType(Base):
|
||||
"""Work/entertainment period definition."""
|
||||
|
||||
__tablename__ = "schedule_types"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
name = Column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
comment="Human-readable schedule type name (e.g., 'standard', 'night-shift')",
|
||||
)
|
||||
|
||||
work_from = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
comment="Work period start hour (0-23, UTC)",
|
||||
)
|
||||
|
||||
work_to = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
comment="Work period end hour (0-23, UTC)",
|
||||
)
|
||||
|
||||
entertainment_from = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
comment="Entertainment period start hour (0-23, UTC)",
|
||||
)
|
||||
|
||||
entertainment_to = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
comment="Entertainment period end hour (0-23, UTC)",
|
||||
)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
36
app/schemas/schedule_type.py
Normal file
36
app/schemas/schedule_type.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Schemas for ScheduleType CRUD."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class ScheduleTypeCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=64)
|
||||
work_from: int = Field(..., ge=0, le=23)
|
||||
work_to: int = Field(..., ge=0, le=23)
|
||||
entertainment_from: int = Field(..., ge=0, le=23)
|
||||
entertainment_to: int = Field(..., ge=0, le=23)
|
||||
|
||||
|
||||
class ScheduleTypeUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=64)
|
||||
work_from: Optional[int] = Field(None, ge=0, le=23)
|
||||
work_to: Optional[int] = Field(None, ge=0, le=23)
|
||||
entertainment_from: Optional[int] = Field(None, ge=0, le=23)
|
||||
entertainment_to: Optional[int] = Field(None, ge=0, le=23)
|
||||
|
||||
|
||||
class ScheduleTypeResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
work_from: int
|
||||
work_to: int
|
||||
entertainment_from: int
|
||||
entertainment_to: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AgentScheduleTypeAssign(BaseModel):
|
||||
schedule_type_name: str = Field(..., description="Name of the schedule type to assign")
|
||||
@@ -194,6 +194,8 @@ class UserResponse(UserBase):
|
||||
role_name: Optional[str] = None
|
||||
agent_id: Optional[str] = None
|
||||
discord_user_id: Optional[str] = None
|
||||
oidc_issuer: Optional[str] = None
|
||||
oidc_subject: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -2,12 +2,41 @@ import json
|
||||
import hmac
|
||||
import hashlib
|
||||
import logging
|
||||
import socket
|
||||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.webhook import Webhook, WebhookLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _validate_webhook_url(url: str) -> None:
|
||||
"""Raise ValueError if the URL would target a non-public address (SSRF guard)."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError(f"unsupported scheme: {parsed.scheme!r}")
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
raise ValueError("missing host")
|
||||
# Resolve every address the host maps to and reject non-global ranges.
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, parsed.port or (443 if parsed.scheme == "https" else 80))
|
||||
except socket.gaierror as e:
|
||||
raise ValueError(f"DNS resolution failed: {e}")
|
||||
for info in infos:
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
if (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_multicast
|
||||
or ip.is_reserved
|
||||
or ip.is_unspecified
|
||||
):
|
||||
raise ValueError(f"host resolves to non-public address {ip}")
|
||||
|
||||
|
||||
def fire_webhooks_sync(event: str, payload: dict, project_id: int, db: Session):
|
||||
"""Find matching webhooks and send payloads (sync version)."""
|
||||
import httpx
|
||||
@@ -35,6 +64,8 @@ def fire_webhooks_sync(event: str, payload: dict, project_id: int, db: Session):
|
||||
payload=payload_json,
|
||||
)
|
||||
try:
|
||||
_validate_webhook_url(wh.url)
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if wh.secret:
|
||||
sig = hmac.new(
|
||||
@@ -42,7 +73,7 @@ def fire_webhooks_sync(event: str, payload: dict, project_id: int, db: Session):
|
||||
).hexdigest()
|
||||
headers["X-Webhook-Signature"] = sig
|
||||
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
with httpx.Client(timeout=10.0, follow_redirects=False) as client:
|
||||
resp = client.post(wh.url, content=payload_json, headers=headers)
|
||||
log.response_status = resp.status_code
|
||||
log.response_body = resp.text[:1000]
|
||||
|
||||
99
docs/oidc-test-plan.md
Normal file
99
docs/oidc-test-plan.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# 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,3 +12,5 @@ alembic==1.13.1
|
||||
python-dotenv==1.0.0
|
||||
httpx==0.27.0
|
||||
requests==2.31.0
|
||||
authlib==1.3.2
|
||||
itsdangerous==2.2.0
|
||||
|
||||
Reference in New Issue
Block a user