Verified locally end-to-end (before: exploitable, after: blocked). - config: refuse to start on weak/default/short SECRET_KEY (was trivially forgeable JWT -> full admin) - deps: add reusable require_admin dependency (JWT or API key) - api-keys: require admin to mint/list/revoke; mask key on list (was unauthenticated -> instant admin API key) - webhooks: whole router now admin-only (was fully unauthenticated CRUD + readable logs) - webhook delivery: validate URL scheme + reject hosts resolving to private/loopback/link-local/reserved IPs; disable redirects (was a readable SSRF primitive) - rbac: implement a real project-role hierarchy in check_project_role (was a no-op: any member, even guest, passed admin/mgr gates) - misc: auth on delete_milestone (+ensure_can_edit_milestone), worklog create/delete (force caller user_id, owner-only delete), /activity and /export/tasks (were unauthenticated data exposure) - tasks: auth + ensure_can_edit_task on assign_task and batch_assign Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
81 lines
3.2 KiB
Python
81 lines
3.2 KiB
Python
"""Webhooks router."""
|
|
import json
|
|
from typing import List
|
|
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
|
|
|
|
# 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)
|
|
def create_webhook(wh: WebhookCreate, db: Session = Depends(get_db)):
|
|
db_wh = Webhook(**wh.model_dump())
|
|
db.add(db_wh)
|
|
db.commit()
|
|
db.refresh(db_wh)
|
|
return db_wh
|
|
|
|
|
|
@router.get("", response_model=List[WebhookResponse])
|
|
def list_webhooks(project_id: int = None, db: Session = Depends(get_db)):
|
|
query = db.query(Webhook)
|
|
if project_id is not None:
|
|
query = query.filter(Webhook.project_id == project_id)
|
|
return query.all()
|
|
|
|
|
|
@router.get("/{webhook_id}", response_model=WebhookResponse)
|
|
def get_webhook(webhook_id: int, db: Session = Depends(get_db)):
|
|
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
|
|
if not wh:
|
|
raise HTTPException(status_code=404, detail="Webhook not found")
|
|
return wh
|
|
|
|
|
|
@router.patch("/{webhook_id}", response_model=WebhookResponse)
|
|
def update_webhook(webhook_id: int, wh_update: WebhookUpdate, db: Session = Depends(get_db)):
|
|
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
|
|
if not wh:
|
|
raise HTTPException(status_code=404, detail="Webhook not found")
|
|
for field, value in wh_update.model_dump(exclude_unset=True).items():
|
|
setattr(wh, field, value)
|
|
db.commit()
|
|
db.refresh(wh)
|
|
return wh
|
|
|
|
|
|
@router.delete("/{webhook_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def delete_webhook(webhook_id: int, db: Session = Depends(get_db)):
|
|
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
|
|
if not wh:
|
|
raise HTTPException(status_code=404, detail="Webhook not found")
|
|
db.delete(wh)
|
|
db.commit()
|
|
return None
|
|
|
|
|
|
@router.get("/{webhook_id}/logs", response_model=List[WebhookLogResponse])
|
|
def list_webhook_logs(webhook_id: int, limit: int = 50, db: Session = Depends(get_db)):
|
|
return db.query(WebhookLog).filter(
|
|
WebhookLog.webhook_id == webhook_id
|
|
).order_by(WebhookLog.created_at.desc()).limit(limit).all()
|
|
|
|
|
|
@router.post("/{webhook_id}/retry/{log_id}")
|
|
def retry_webhook(webhook_id: int, log_id: int, bg: BackgroundTasks, db: Session = Depends(get_db)):
|
|
log_entry = db.query(WebhookLog).filter(WebhookLog.id == log_id, WebhookLog.webhook_id == webhook_id).first()
|
|
if not log_entry:
|
|
raise HTTPException(status_code=404, detail="Webhook log not found")
|
|
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
|
|
if not wh:
|
|
raise HTTPException(status_code=404, detail="Webhook not found")
|
|
bg.add_task(fire_webhooks_sync, log_entry.event, json.loads(log_entry.payload), wh.project_id, db)
|
|
return {"status": "retry_queued", "log_id": log_id}
|