Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions dashboard/src/lib/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,33 @@ export interface ServiceHealth {
latency_ms: number | null
}

/** Minimal public health response (no auth required). */
export interface HealthResponse {
status: string
bootstrap_complete: boolean
}

/** Full health details (requires dashboard auth). */
export interface HealthDetailsResponse extends HealthResponse {
version: string
auth_provider: string
bootstrap_complete: boolean
base_url_https?: boolean
// Enriched fields (optional — older servers won't have these)
database?: ServiceHealth
redis?: ServiceHealth
connected_agents?: number
workers?: Record<string, string>
}

/** Public health check — only status + bootstrap. */
export function getHealth() {
return request<HealthResponse>("/health", { skipAuthRedirect: true })
}

/** Authenticated health check — full operational details. */
export function getHealthDetails() {
return request<HealthDetailsResponse>("/health/details")
}

// --- Auth ---

export interface UserInfo {
Expand Down
4 changes: 2 additions & 2 deletions dashboard/src/lib/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { ApiError, requestVoid } from "./client"
export { getHealth, login, logout, getMe, setup, listKeys, createKey, deleteKey } from "./auth"
export { getHealth, getHealthDetails, login, logout, getMe, setup, listKeys, createKey, deleteKey } from "./auth"
export { listEvents } from "./events"
export { listApprovals, getApproval, submitDecision } from "./approvals"
export { listBundles, listBundleVersions, uploadBundle, deployBundle, getBundleYaml, getCurrentBundle, evaluateBundle, listDeployments } from "./bundles"
Expand All @@ -9,7 +9,7 @@ export { listChannels, createChannel, updateChannel, deleteChannel, testChannel,
export { listContracts, getContract, getContractVersion, createContract, updateContract, deleteContract, importContracts, getContractUsage } from "./contracts"
export { listCompositions, getComposition, createComposition, updateComposition, deleteComposition, previewComposition, deployComposition } from "./compositions"

export type { HealthResponse, ServiceHealth, UserInfo, SetupResponse, ApiKeyInfo, CreateKeyResponse } from "./auth"
export type { HealthResponse, HealthDetailsResponse, ServiceHealth, UserInfo, SetupResponse, ApiKeyInfo, CreateKeyResponse } from "./auth"
export type { EventResponse, EventFilters } from "./events"
export type { ApprovalResponse, ApprovalFilters } from "./approvals"
export type { BundleSummary, BundleResponse, BundleWithDeployments, DeploymentResponse, EvaluateRequest, EvaluateResponse, ContractEvaluation } from "./bundles"
Expand Down
6 changes: 3 additions & 3 deletions dashboard/src/pages/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { toast } from "sonner"
import { Settings2, Monitor, Bell, AlertTriangle, Sparkles } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { getHealth, type HealthResponse } from "@/lib/api"
import { getHealthDetails, type HealthDetailsResponse } from "@/lib/api"
import { useDashboardSSE } from "@/hooks/use-dashboard-sse"
import { SystemSection } from "./settings/system-section"
import { NotificationsSection } from "./settings/notifications-section"
Expand All @@ -16,14 +16,14 @@ export default function SettingsPage() {
const activeSection = searchParams.get("section") || "system"
const setSection = (s: string) => setSearchParams({ section: s }, { replace: true })

const [health, setHealth] = useState<HealthResponse | null>(null)
const [health, setHealth] = useState<HealthDetailsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [lastChecked, setLastChecked] = useState<Date | null>(null)
const [channelCount, setChannelCount] = useState(0)

const fetchHealth = useCallback(async () => {
try {
const data = await getHealth()
const data = await getHealthDetails()
setHealth(data)
setLastChecked(new Date())
} catch {
Expand Down
4 changes: 2 additions & 2 deletions dashboard/src/pages/settings/notifications-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { listChannels, deleteChannel, updateChannel, getHealth } from "@/lib/api"
import { listChannels, deleteChannel, updateChannel, getHealthDetails } from "@/lib/api"
import type { NotificationChannelInfo } from "@/lib/api"
import { toast } from "sonner"
import { ChannelTable } from "./notifications/channel-table"
Expand Down Expand Up @@ -49,7 +49,7 @@ export function NotificationsSection({ onChannelCountChange }: NotificationsSect
useEffect(() => { void fetchChannels() }, [fetchChannels])

useEffect(() => {
getHealth().then((h) => setBaseUrlHttps(h.base_url_https ?? null)).catch(() => {})
getHealthDetails().then((h) => setBaseUrlHttps(h.base_url_https ?? null)).catch(() => {})
}, [])

function openCreate() {
Expand Down
6 changes: 3 additions & 3 deletions dashboard/src/pages/settings/system-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
import type { HealthResponse } from "@/lib/api"
import type { HealthDetailsResponse } from "@/lib/api"

interface SystemSectionProps {
health: HealthResponse | null
health: HealthDetailsResponse | null
loading: boolean
lastChecked: Date | null
onRefresh: () => void
Expand Down Expand Up @@ -40,7 +40,7 @@ function StatusDot({ status }: { status: "ok" | "degraded" | "down" }) {
return <span className={`inline-block size-2 rounded-full ${colors[status]}`} />
}

function overallStatus(health: HealthResponse): "ok" | "degraded" | "down" {
function overallStatus(health: HealthDetailsResponse): "ok" | "degraded" | "down" {
if (health.status === "ok") return "ok"
if (health.status === "degraded") return "degraded"
return "down"
Expand Down
8 changes: 7 additions & 1 deletion src/edictum_server/auth/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,20 @@ def _extract_bearer(authorization: str) -> str:


async def require_api_key(
authorization: str = Header(alias="Authorization"),
authorization: str | None = Header(default=None, alias="Authorization"),
x_edictum_agent_id: str | None = Header(default=None, alias="X-Edictum-Agent-Id"),
db: AsyncSession = Depends(get_db),
) -> AuthContext:
"""Authenticate an agent request via API key.

Looks up the key by its 12-char prefix, then verifies with bcrypt.
Returns 401 (not 422) when the Authorization header is missing.
"""
if not authorization:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authorization header is required.",
)
raw_key = _extract_bearer(authorization)
# Key format: edk_{env}_{random} — extract env and first 8 random chars.
# This must match how generate_api_key() computes the prefix.
Expand Down
35 changes: 32 additions & 3 deletions src/edictum_server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

import sqlalchemy as sa
from fastapi import FastAPI, Request
from fastapi.exceptions import HTTPException as StarletteHTTPException
from fastapi.exceptions import HTTPException as StarletteHTTPException, RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
Expand Down Expand Up @@ -280,6 +280,18 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
settings.base_url,
)

# Warn when serving behind a proxy without trusted proxy config (H1/L1).
# Without ProxyHeadersMiddleware, Starlette uses http:// in redirects
# and rate-limiting keys on the proxy IP instead of the real client.
if settings.base_url.startswith("https://") and not settings.trusted_proxies:
logger.warning(
"EDICTUM_BASE_URL is HTTPS but EDICTUM_TRUSTED_PROXIES is not set. "
"Trailing-slash redirects will use http:// (downgrade) and rate "
"limiting will key on the proxy IP, not the real client. "
"Set EDICTUM_TRUSTED_PROXIES to your reverse proxy addresses "
"(e.g. '*' for Railway/Render, or specific CIDRs).",
)

# Validate signing key secret early — log clearly if misconfigured
try:
settings.get_signing_secret()
Expand Down Expand Up @@ -392,8 +404,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
CORSMiddleware,
allow_origins=[o.strip() for o in _settings.cors_origins.split(",") if o.strip()],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add PATCH to CORS allow_methods

Restricting CORS methods to GET/POST/PUT/DELETE/OPTIONS drops PATCH, but the app exposes PATCH routes (for example assignment rules and agent registrations) and the dashboard client calls them (dashboard/src/lib/api/agents.ts), so those browser requests will fail preflight in any cross-origin deployment even though they previously worked with wildcard methods.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning: allow_methods omits PATCH.

Two existing routes use PATCH:

  • routes/assignment_rules.py:101PATCH /{rule_id}
  • routes/agent_registrations.py:60PATCH /{agent_id}

Cross-origin callers hitting these routes will fail CORS preflight — OPTIONS won't include PATCH in Access-Control-Allow-Methods and the browser will block the request before it reaches FastAPI.

Fix:

allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],

allow_headers=["Content-Type", "X-Requested-With", "Authorization", "X-Edictum-Agent-Id"],
)

# Security response headers (HSTS, CSP, X-Frame-Options, etc.)
Expand Down Expand Up @@ -441,6 +453,23 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app.include_router(ai_usage.router)


# --- Validation error handler: strip Pydantic internals ----------------------
@app.exception_handler(RequestValidationError)
async def validation_error_handler(
_request: Request, exc: RequestValidationError
) -> JSONResponse:
"""Return 422 with sanitized error details.

Strips ``ctx`` and ``type`` fields from Pydantic errors to avoid
leaking framework internals (L4 finding).
"""
sanitized = [
{"loc": e.get("loc", []), "msg": e.get("msg", "Validation error")}
for e in exc.errors()
]
return JSONResponse(status_code=422, content={"detail": sanitized})


# --- 404 handler: redirect non-API paths to dashboard -------------------------
@app.exception_handler(404)
async def not_found_handler(
Expand Down
41 changes: 35 additions & 6 deletions src/edictum_server/routes/evaluate.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import asyncio

from fastapi import APIRouter, Depends, HTTPException

from edictum_server.auth.dependencies import (
Expand All @@ -13,6 +15,13 @@

router = APIRouter(prefix="/api/v1/bundles", tags=["evaluate"])

# Maximum time allowed for a single evaluation (seconds).
_EVALUATE_TIMEOUT_SECONDS = 5.0

# Cap concurrent evaluations so timed-out threads (which can't be killed)
# don't exhaust the default ThreadPoolExecutor.
_EVALUATE_SEMAPHORE = asyncio.Semaphore(4)


@router.post("/evaluate", response_model=EvaluateResponse)
async def evaluate(
Expand All @@ -23,14 +32,34 @@ async def evaluate(

This is a development-time endpoint for testing contracts in the dashboard.
It is never called by agents during production execution.
Evaluation runs in a thread with a timeout to prevent DoS via complex YAML.
Concurrent evaluations are capped to prevent thread pool exhaustion.
"""
try:
return evaluate_contracts(
yaml_content=body.yaml_content,
tool_name=body.tool_name,
tool_args=body.tool_args,
environment=body.environment,
principal_input=body.principal,
await asyncio.wait_for(_EVALUATE_SEMAPHORE.acquire(), timeout=1.0)
except TimeoutError:
raise HTTPException(
status_code=429,
detail="Too many concurrent evaluations. Try again shortly.",
)
try:
return await asyncio.wait_for(
asyncio.to_thread(
evaluate_contracts,
yaml_content=body.yaml_content,
tool_name=body.tool_name,
tool_args=body.tool_args,
environment=body.environment,
principal_input=body.principal,
),
timeout=_EVALUATE_TIMEOUT_SECONDS,
)
except TimeoutError:
raise HTTPException(
status_code=422,
detail="Evaluation timed out — contract bundle may be too complex.",
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
finally:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep semaphore slot until timed-out worker thread exits

The finally block always releases _EVALUATE_SEMAPHORE immediately, but asyncio.wait_for(asyncio.to_thread(...), timeout=5) only times out the awaiter and does not stop the underlying worker thread; that means expensive evaluations can keep running after a timeout while new requests are still admitted, so repeated timeout payloads can still exhaust the thread pool despite this guard.

Useful? React with 👍 / 👎.

_EVALUATE_SEMAPHORE.release()
26 changes: 25 additions & 1 deletion src/edictum_server/routes/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from sqlalchemy.ext.asyncio import AsyncSession

from edictum_server import __version__
from edictum_server.auth.dependencies import AuthContext, require_dashboard_auth
from edictum_server.config import Settings, get_settings
from edictum_server.db.engine import get_db
from edictum_server.db.models import User
Expand Down Expand Up @@ -41,11 +42,34 @@ def _get_worker_statuses(request: Request) -> dict[str, str]:

@router.get("/health")
async def health(
db: AsyncSession = Depends(get_db),
) -> JSONResponse:
"""Public health check. Returns minimal info — no auth required.

Only exposes operational status and bootstrap state (needed by
the setup wizard before any user exists).
"""
result = await db.execute(select(func.count()).select_from(User))
user_count = result.scalar() or 0

return JSONResponse(content={
"status": "ok",
"bootstrap_complete": user_count > 0,
})


@router.get("/health/details")
async def health_details(
request: Request,
settings: Settings = Depends(get_settings),
db: AsyncSession = Depends(get_db),
_auth: AuthContext = Depends(require_dashboard_auth),
) -> JSONResponse:
"""Full health check. Returns 503 when degraded."""
"""Authenticated health check with full operational details.

Returns version, infrastructure status, worker health, and connected
agent count. Requires dashboard session cookie. Returns 503 when degraded.
"""
result = await db.execute(select(func.count()).select_from(User))
user_count = result.scalar() or 0

Expand Down
32 changes: 31 additions & 1 deletion src/edictum_server/services/evaluate_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,26 @@ def _map_result(result: EvaluationResult, mode: str, yaml_hash: str) -> Evaluate
)


# Limits to prevent DoS via arbitrarily complex bundles.
_MAX_CONTRACTS = 100
_MAX_YAML_DOCUMENTS = 10


def _check_yaml_complexity(yaml_content: str) -> None:
"""Reject YAML that exceeds complexity limits before evaluation.

Raises ValueError if the content has too many YAML documents or
other structural issues that suggest a DoS attempt.
"""
# Count YAML document separators (---).
# Each '---' at the start of a line starts a new document.
doc_count = yaml_content.count("\n---") + 1
if doc_count > _MAX_YAML_DOCUMENTS:
raise ValueError(
f"YAML contains {doc_count} documents (limit: {_MAX_YAML_DOCUMENTS})."
)


def evaluate_contracts(
*,
yaml_content: str,
Expand All @@ -82,8 +102,11 @@ def evaluate_contracts(
EvaluateResponse with verdict, matched contracts, timing.

Raises:
ValueError: If the YAML is invalid or cannot be parsed as contracts.
ValueError: If the YAML is invalid, cannot be parsed, or exceeds
complexity limits (contract count, document count).
"""
_check_yaml_complexity(yaml_content)

principal = _build_principal(principal_input)
yaml_hash = hashlib.sha256(yaml_content.encode("utf-8")).hexdigest()

Expand All @@ -97,6 +120,13 @@ def evaluate_contracts(
except Exception as exc:
raise ValueError(f"Invalid contract YAML: {exc}") from exc

# Check contract count after parsing.
contract_count = len(getattr(edictum_instance, "contracts", []))
if contract_count > _MAX_CONTRACTS:
raise ValueError(
f"Bundle contains {contract_count} contracts (limit: {_MAX_CONTRACTS})."
)

result: EvaluationResult = edictum_instance.evaluate(
tool_name,
tool_args,
Expand Down
Loading
Loading