1 Commits

11 changed files with 134 additions and 431 deletions

View File

@@ -1,46 +1,25 @@
# Stage 1: build dependencies
FROM python:3.11-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y \
build-essential \
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 --prefix=/install -r requirements.txt
# Stage 2: slim runtime
FROM python:3.11-slim FROM python:3.11-slim
WORKDIR /app WORKDIR /app
# Install runtime dependencies only (no build tools) # Install system dependencies
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
default-libmysqlclient-dev \ build-essential \
curl \ curl \
default-libmysqlclient-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy installed packages from builder # Install Python dependencies
COPY --from=builder /install /usr/local COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code # Copy application code
COPY app/ ./app/ COPY . .
COPY requirements.txt ./
# Make entrypoint
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh RUN chmod +x entrypoint.sh
# Expose port
EXPOSE 8000 EXPOSE 8000
# Wait for wizard config, then start uvicorn
ENTRYPOINT ["./entrypoint.sh"] ENTRYPOINT ["./entrypoint.sh"]

232
README.md
View File

@@ -1,163 +1,100 @@
# HarborForge Backend # HarborForge Backend
The core REST API for HarborForge — an Agent/人类协同任务管理平台 (Agent/Human collaborative task-management platform). Agent/人类协同任务管理平台 - FastAPI 后端
Part of the [HarborForge](../README.md) platform. ## API Endpoints (38)
- **Role:** core REST API — users, projects, tasks, milestones, proposals, RBAC, webhooks, worklogs, notifications, monitor telemetry. ### Auth
- **Stack:** Python 3.11 · FastAPI · SQLAlchemy · MySQL - `POST /auth/token` - 登录获取 JWT token
- **Port:** `8000` - `GET /auth/me` - 获取当前用户信息
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
## Run / Build > 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`
### Docker
```bash > Issues 和 Search 列表接口返回分页格式:
docker build -t harborforge-backend . > Issues 支持排序参数: (created_at/priority/title/due_date/status), (asc/desc)
docker run -p 8000:8000 \ > Issues 支持额外过滤:,
-e SECRET_KEY="$(openssl rand -hex 32)" \ - `POST /issues` - 创建 issue支持 resolution 决议案类型)
-v /path/to/config:/config \ - `GET /issues` - 列表(分页、排序、按 assignee/tag 过滤)(支持按 project/status/type 过滤)
harborforge-backend - `GET /issues/{id}` - 详情
``` - `PATCH /issues/{id}` - 更新
- `DELETE /issues/{id}` - 删除
- `POST /issues/{id}/transition` - 状态变更(触发 webhook
- `GET /search/issues?q=keyword` - 搜索
### Local (uvicorn) ### Comments
- `POST /comments` - 创建评论
- `GET /issues/{id}/comments` - 列表
- `PATCH /comments/{id}` - 更新
- `DELETE /comments/{id}` - 删除
```bash ### Projects
pip install -r requirements.txt - `POST /projects` - 创建
export SECRET_KEY="$(openssl rand -hex 32)" - `GET /projects` - 列表
uvicorn app.main:app --host 0.0.0.0 --port 8000 - `GET /projects/{id}` - 详情
``` - `PATCH /projects/{id}` - 更新
- `DELETE /projects/{id}` - 删除
On startup the app creates/migrates the schema, runs AbstractWizard ### Project Members
initialization (admin user, default project, default roles), and starts a - `POST /projects/{id}/members` - 添加成员
background monitor-polling thread. - `GET /projects/{id}/members` - 列表
- `DELETE /projects/{id}/members/{user_id}` - 移除
## Configuration ### Users
- `POST /users` - 注册
- `GET /users` - 列表
- `GET /users/{id}` - 详情
- `PATCH /users/{id}` - 更新
Environment variables (also loadable from a `.env` file): ### Webhooks
- `POST /webhooks` - 创建
- `GET /webhooks` - 列表
- `GET /webhooks/{id}` - 详情
- `PATCH /webhooks/{id}` - 更新
- `DELETE /webhooks/{id}` - 删除
- `GET /webhooks/{id}/logs` - 投递日志
| Variable | Default | Description | ### System
|----------|---------|-------------| - `GET /health` - 健康检查
| `SECRET_KEY` | *(none — must be set)* | JWT signing key (HS256). The server **refuses to start** with a weak/default/short value. | - `GET /version` - 版本信息
| `DATABASE_URL` | `mysql+pymysql://harborforge:harborforge_pass@mysql:3306/harborforge` | Fallback DB URL when the wizard config volume is absent. | - `GET /dashboard/stats` - 统计面板
| `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`. |
Database resolution order: **wizard config volume** (`$CONFIG_DIR/$CONFIG_FILE``database` block) → `DATABASE_URL` env → built-in default. ### 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` - 里程碑完成进度
## Security ### Notifications
- `GET /notifications` - 列表(支持 user_id, unread_only 过滤)
- `GET /notifications/count` - 未读通知计数
- `POST /notifications/{id}/read` - 标记已读
- `POST /notifications/read-all` - 全部标记已读
The current code enforces the following security posture. These are ### Issue Assignment
operational requirements, not optional hardening. - `POST /issues/{id}/assign` - 指派 issue自动发送通知
### Mandatory strong `SECRET_KEY` ### Webhook Retry
- `POST /webhooks/{id}/retry/{log_id}` - 重试失败的 webhook 投递
`app/core/config.py` validates `SECRET_KEY` at import time and **raises and ### Time Tracking (Work Logs)
refuses to start** if the value is empty, shorter than 32 characters, or a - `POST /worklogs` - 记录工时
known default/placeholder (e.g. `change-me-in-production`, `secret`, - `GET /issues/{id}/worklogs` - 某 issue 的工时记录
`changeme`). Operators **must** provide a strong random key: - `GET /issues/{id}/worklogs/summary` - 某 issue 工时汇总
- `GET /users/{id}/worklogs` - 某用户的工时记录
- `DELETE /worklogs/{id}` - 删除工时记录
- `GET /projects/{id}/worklogs/summary` - 项目工时汇总(按用户分组)
```bash ### Export
openssl rand -hex 32 - `GET /export/issues` - 导出 issues CSV
``` - `GET /issues/overdue` - 逾期未完成的 issue
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 ## CLI
@@ -165,9 +102,18 @@ 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. See [HarborForge.Cli](../HarborForge.Cli/README.md) for installation and usage.
## Tech Stack ## 技术栈
- Python 3.11 + FastAPI - Python 3.11 + FastAPI
- SQLAlchemy + MySQL (auto schema create/migrate on startup) - SQLAlchemy + MySQL
- JWT (python-jose, HS256) + bcrypt password hashing - JWT (python-jose)
- Docker - Docker
## Issue Types
| Type | 用途 |
|------|------|
| task | 普通任务 |
| story | 用户故事 |
| test | 测试用例 |
| resolution | 决议案Agent 僵局提交)|

View File

@@ -76,10 +76,3 @@ async def get_current_user_or_apikey(
if token: if token:
return await get_current_user(token=token, db=db) return await get_current_user(token=token, db=db)
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
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

View File

@@ -81,28 +81,12 @@ def check_project_role(db: Session, user_id: int, project_id: int, min_role: str
detail="Role not found" detail="Role not found"
) )
# Enforce a real role hierarchy. Higher rank == more privilege. # Legacy compatibility: most current routes use non-hierarchical names like dev/mgr.
_RANK = { # For now, any valid membership passes those broad checks; strict edit rules are handled
"guest": 0, # by the explicit can_edit_* helpers below.
"viewer": 1, if min_role in {"dev", "mgr", "viewer", "member", "guest", "admin"}:
"member": 2, return True
"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 return True

View File

@@ -12,7 +12,7 @@ from sqlalchemy import func as sqlfunc
from pydantic import BaseModel from pydantic import BaseModel
from app.core.config import get_db from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey, require_admin from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role, ensure_can_edit_milestone from app.api.rbac import check_project_role, ensure_can_edit_milestone
from app.models import models from app.models import models
from app.models.apikey import APIKey from app.models.apikey import APIKey
@@ -60,8 +60,7 @@ class APIKeyResponse(BaseModel):
@router.post("/api-keys", response_model=APIKeyResponse, status_code=status.HTTP_201_CREATED, tags=["API Keys"]) @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() user = db.query(models.User).filter(models.User.id == data.user_id).first()
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
@@ -74,22 +73,15 @@ def create_api_key(data: APIKeyCreate, db: Session = Depends(get_db),
@router.get("/api-keys", response_model=List[APIKeyResponse], tags=["API Keys"]) @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) query = db.query(APIKey)
if user_id: if user_id:
query = query.filter(APIKey.user_id == user_id) query = query.filter(APIKey.user_id == user_id)
keys = query.all() return 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"]) @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() key_obj = db.query(APIKey).filter(APIKey.id == key_id).first()
if not key_obj: if not key_obj:
raise HTTPException(status_code=404, detail="API key not found") raise HTTPException(status_code=404, detail="API key not found")
@@ -114,8 +106,7 @@ class ActivityLogResponse(BaseModel):
@router.get("/activity", response_model=List[ActivityLogResponse], tags=["Activity"]) @router.get("/activity", response_model=List[ActivityLogResponse], tags=["Activity"])
def list_activity(entity_type: str = None, entity_id: int = None, user_id: int = None, 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) query = db.query(ActivityLog)
if entity_type: if entity_type:
query = query.filter(ActivityLog.entity_type == entity_type) query = query.filter(ActivityLog.entity_type == entity_type)
@@ -208,10 +199,8 @@ 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"]) @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) ms = _resolve_milestone(db, milestone_id)
ensure_can_edit_milestone(db, current_user.id, ms)
db.delete(ms) db.delete(ms)
db.commit() db.commit()
return None return None
@@ -333,18 +322,16 @@ class WorkLogResponse(BaseModel):
@router.post("/worklogs", response_model=WorkLogResponse, status_code=status.HTTP_201_CREATED, tags=["Time Tracking"]) @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() task = db.query(Task).filter(Task.id == wl.task_id).first()
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") 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: if wl.hours <= 0:
raise HTTPException(status_code=400, detail="Hours must be positive") raise HTTPException(status_code=400, detail="Hours must be positive")
data = wl.model_dump() db_wl = WorkLog(**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.add(db_wl)
db.commit() db.commit()
db.refresh(db_wl) db.refresh(db_wl)
@@ -383,13 +370,10 @@ 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"]) @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() wl = db.query(WorkLog).filter(WorkLog.id == worklog_id).first()
if not wl: if not wl:
raise HTTPException(status_code=404, detail="Work log not found") 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.delete(wl)
db.commit() db.commit()
return None return None
@@ -398,8 +382,7 @@ def delete_worklog(worklog_id: int, db: Session = Depends(get_db),
# ============ Export ============ # ============ Export ============
@router.get("/export/tasks", tags=["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) query = db.query(Task)
if project_id: if project_id:
query = query.filter(Task.project_id == project_id) query = query.filter(Task.project_id == project_id)

View File

@@ -576,10 +576,8 @@ def take_task(
# ---- Assignment ---- # ---- Assignment ----
@router.post("/tasks/{task_code}/assign") @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) 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() user = db.query(models.User).filter(models.User.id == assignee_id).first()
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
@@ -767,8 +765,7 @@ def batch_transition(
@router.post("/tasks/batch/assign") @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() user = db.query(models.User).filter(models.User.id == data.assignee_id).first()
if not user: if not user:
raise HTTPException(status_code=404, detail="Assignee not found") raise HTTPException(status_code=404, detail="Assignee not found")
@@ -776,7 +773,6 @@ def batch_assign(data: BatchAssign, db: Session = Depends(get_db),
for task_code in data.task_codes: for task_code in data.task_codes:
task = db.query(Task).filter(Task.task_code == task_code).first() task = db.query(Task).filter(Task.task_code == task_code).first()
if task: if task:
ensure_can_edit_task(db, current_user.id, task)
task.assignee_id = data.assignee_id task.assignee_id = data.assignee_id
updated.append(task.task_code) updated.append(task.task_code)
db.commit() db.commit()

View File

@@ -9,7 +9,6 @@ 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 from app.core.config import get_db
from app.init_wizard import DELETED_USER_USERNAME
from app.models import models from app.models import models
from app.models.agent import Agent from app.models.agent import Agent
from app.models.role_permission import Permission, Role, RolePermission from app.models.role_permission import Permission, Role, RolePermission
@@ -213,86 +212,6 @@ def update_user(
return _user_response(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) @router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user( def delete_user(
identifier: str, identifier: str,
@@ -304,26 +223,17 @@ def delete_user(
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
if current_user.id == user.id: if current_user.id == user.id:
raise HTTPException(status_code=400, detail="You cannot delete your own account") raise HTTPException(status_code=400, detail="You cannot delete your own account")
# Protect built-in accounts from deletion
if user.is_admin: if user.is_admin:
raise HTTPException(status_code=400, detail="Admin accounts cannot be deleted") raise HTTPException(status_code=400, detail="Admin accounts cannot be deleted")
if user.username in _BUILTIN_USERNAMES: if user.username == "acc-mgr":
raise HTTPException( raise HTTPException(status_code=400, detail="The acc-mgr account is a built-in account and cannot be deleted")
status_code=400, try:
detail=f"The {user.username} account is a built-in account and cannot be deleted", db.delete(user)
) db.commit()
except IntegrityError:
deleted_user = db.query(models.User).filter( db.rollback()
models.User.username == DELETED_USER_USERNAME raise HTTPException(status_code=400, detail="User has related records. Deactivate the account instead.")
).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()
return None return None
@@ -331,7 +241,7 @@ def delete_user(
def reset_user_apikey( def reset_user_apikey(
identifier: str, identifier: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user),
): ):
"""Reset (regenerate) a user's API key. """Reset (regenerate) a user's API key.
@@ -339,8 +249,6 @@ def reset_user_apikey(
- user.reset-apikey: can reset any user's API key - user.reset-apikey: can reset any user's API key
- user.reset-self-apikey: can reset only own API key - user.reset-self-apikey: can reset only own API key
- admin: can reset any user's API key - admin: can reset any user's API key
Accepts both OAuth2 Bearer token and X-API-Key authentication.
""" """
import secrets import secrets
from app.models.apikey import APIKey from app.models.apikey import APIKey

View File

@@ -5,13 +5,11 @@ from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import get_db from app.core.config import get_db
from app.api.deps import require_admin
from app.models.webhook import Webhook, WebhookLog from app.models.webhook import Webhook, WebhookLog
from app.schemas.webhook import WebhookCreate, WebhookUpdate, WebhookResponse, WebhookLogResponse from app.schemas.webhook import WebhookCreate, WebhookUpdate, WebhookResponse, WebhookLogResponse
from app.services.webhook import fire_webhooks_sync from app.services.webhook import fire_webhooks_sync
# Webhook management is admin-only (registration, inspection, retry, logs). router = APIRouter(prefix="/webhooks", tags=["Webhooks"])
router = APIRouter(prefix="/webhooks", tags=["Webhooks"], dependencies=[Depends(require_admin)])
@router.post("", response_model=WebhookResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=WebhookResponse, status_code=status.HTTP_201_CREATED)

View File

@@ -44,22 +44,6 @@ class Settings(BaseSettings):
settings = Settings() 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 # Resolve DB URL: wizard config volume > env > default
_db_url = _resolve_db_url(settings.DATABASE_URL) _db_url = _resolve_db_url(settings.DATABASE_URL)
engine = create_engine(_db_url, pool_pre_ping=True) engine = create_engine(_db_url, pool_pre_ping=True)

View File

@@ -189,7 +189,6 @@ _DEV_PERMISSIONS = {
_ACCOUNT_MANAGER_PERMISSIONS = { _ACCOUNT_MANAGER_PERMISSIONS = {
"account.create", "account.create",
"user.reset-apikey",
} }
# Role definitions: (name, description, permission_set) # Role definitions: (name, description, permission_set)
@@ -295,39 +294,6 @@ def init_acc_mgr_user(db: Session) -> models.User | None:
return user return user
DELETED_USER_USERNAME = "deleted-user"
def init_deleted_user(db: Session) -> models.User | None:
"""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 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()
@@ -352,9 +318,6 @@ def run_init(db: Session) -> None:
# Built-in acc-mgr user (after roles are created) # Built-in acc-mgr user (after roles are created)
init_acc_mgr_user(db) init_acc_mgr_user(db)
# Built-in deleted-user (foreign key sink for deleted accounts)
init_deleted_user(db)
# Default project # Default project
project_cfg = config.get("default_project") project_cfg = config.get("default_project")
if project_cfg and admin_user: if project_cfg and admin_user:

View File

@@ -2,41 +2,12 @@ import json
import hmac import hmac
import hashlib import hashlib
import logging import logging
import socket
import ipaddress
from urllib.parse import urlparse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.webhook import Webhook, WebhookLog from app.models.webhook import Webhook, WebhookLog
logger = logging.getLogger(__name__) 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): def fire_webhooks_sync(event: str, payload: dict, project_id: int, db: Session):
"""Find matching webhooks and send payloads (sync version).""" """Find matching webhooks and send payloads (sync version)."""
import httpx import httpx
@@ -64,8 +35,6 @@ def fire_webhooks_sync(event: str, payload: dict, project_id: int, db: Session):
payload=payload_json, payload=payload_json,
) )
try: try:
_validate_webhook_url(wh.url)
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
if wh.secret: if wh.secret:
sig = hmac.new( sig = hmac.new(
@@ -73,7 +42,7 @@ def fire_webhooks_sync(event: str, payload: dict, project_id: int, db: Session):
).hexdigest() ).hexdigest()
headers["X-Webhook-Signature"] = sig headers["X-Webhook-Signature"] = sig
with httpx.Client(timeout=10.0, follow_redirects=False) as client: with httpx.Client(timeout=10.0) as client:
resp = client.post(wh.url, content=payload_json, headers=headers) resp = client.post(wh.url, content=payload_json, headers=headers)
log.response_status = resp.status_code log.response_status = resp.status_code
log.response_body = resp.text[:1000] log.response_body = resp.text[:1000]