diff --git a/decnet/web/router/attackers/api_get_attacker_commands.py b/decnet/web/router/attackers/api_get_attacker_commands.py index bb7875a..8653d95 100644 --- a/decnet/web/router/attackers/api_get_attacker_commands.py +++ b/decnet/web/router/attackers/api_get_attacker_commands.py @@ -2,7 +2,7 @@ from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException, Query -from decnet.web.dependencies import get_current_user, repo +from decnet.web.dependencies import require_viewer, repo router = APIRouter() @@ -20,7 +20,7 @@ async def get_attacker_commands( limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0, le=2147483647), service: Optional[str] = None, - current_user: str = Depends(get_current_user), + user: dict = Depends(require_viewer), ) -> dict[str, Any]: """Retrieve paginated commands for an attacker profile.""" attacker = await repo.get_attacker_by_uuid(uuid) diff --git a/decnet/web/router/attackers/api_get_attacker_detail.py b/decnet/web/router/attackers/api_get_attacker_detail.py index 42bad76..4d23537 100644 --- a/decnet/web/router/attackers/api_get_attacker_detail.py +++ b/decnet/web/router/attackers/api_get_attacker_detail.py @@ -2,7 +2,7 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException -from decnet.web.dependencies import get_current_user, repo +from decnet.web.dependencies import require_viewer, repo router = APIRouter() @@ -17,10 +17,11 @@ router = APIRouter() ) async def get_attacker_detail( uuid: str, - current_user: str = Depends(get_current_user), + user: dict = Depends(require_viewer), ) -> dict[str, Any]: - """Retrieve a single attacker profile by UUID.""" + """Retrieve a single attacker profile by UUID (with behavior block).""" attacker = await repo.get_attacker_by_uuid(uuid) if not attacker: raise HTTPException(status_code=404, detail="Attacker not found") + attacker["behavior"] = await repo.get_attacker_behavior(uuid) return attacker diff --git a/decnet/web/router/attackers/api_get_attackers.py b/decnet/web/router/attackers/api_get_attackers.py index 0b33994..8961266 100644 --- a/decnet/web/router/attackers/api_get_attackers.py +++ b/decnet/web/router/attackers/api_get_attackers.py @@ -2,7 +2,7 @@ from typing import Any, Optional from fastapi import APIRouter, Depends, Query -from decnet.web.dependencies import get_current_user, repo +from decnet.web.dependencies import require_viewer, repo from decnet.web.db.models import AttackersResponse router = APIRouter() @@ -23,7 +23,7 @@ async def get_attackers( search: Optional[str] = None, sort_by: str = Query("recent", pattern="^(recent|active|traversals)$"), service: Optional[str] = None, - current_user: str = Depends(get_current_user), + user: dict = Depends(require_viewer), ) -> dict[str, Any]: """Retrieve paginated attacker profiles.""" def _norm(v: Optional[str]) -> Optional[str]: @@ -35,4 +35,11 @@ async def get_attackers( svc = _norm(service) _data = await repo.get_attackers(limit=limit, offset=offset, search=s, sort_by=sort_by, service=svc) _total = await repo.get_total_attackers(search=s, service=svc) + + # Bulk-join behavior rows for the IPs in this page to avoid N+1 queries. + _ips = {row["ip"] for row in _data if row.get("ip")} + _behaviors = await repo.get_behaviors_for_ips(_ips) if _ips else {} + for row in _data: + row["behavior"] = _behaviors.get(row.get("ip")) + return {"total": _total, "limit": limit, "offset": offset, "data": _data} diff --git a/decnet/web/router/bounty/api_get_bounties.py b/decnet/web/router/bounty/api_get_bounties.py index 5ff7fd2..30da3b8 100644 --- a/decnet/web/router/bounty/api_get_bounties.py +++ b/decnet/web/router/bounty/api_get_bounties.py @@ -2,7 +2,7 @@ from typing import Any, Optional from fastapi import APIRouter, Depends, Query -from decnet.web.dependencies import get_current_user, repo +from decnet.web.dependencies import require_viewer, repo from decnet.web.db.models import BountyResponse router = APIRouter() @@ -15,7 +15,7 @@ async def get_bounties( offset: int = Query(0, ge=0, le=2147483647), bounty_type: Optional[str] = None, search: Optional[str] = None, - current_user: str = Depends(get_current_user) + user: dict = Depends(require_viewer) ) -> dict[str, Any]: """Retrieve collected bounties (harvested credentials, payloads, etc.).""" def _norm(v: Optional[str]) -> Optional[str]: diff --git a/decnet/web/router/fleet/api_deploy_deckies.py b/decnet/web/router/fleet/api_deploy_deckies.py index be49fdb..c799fc7 100644 --- a/decnet/web/router/fleet/api_deploy_deckies.py +++ b/decnet/web/router/fleet/api_deploy_deckies.py @@ -7,7 +7,7 @@ from decnet.config import DEFAULT_MUTATE_INTERVAL, DecnetConfig, _ROOT from decnet.engine import deploy as _deploy from decnet.ini_loader import load_ini_from_string from decnet.network import detect_interface, detect_subnet, get_host_ip -from decnet.web.dependencies import get_current_user, repo +from decnet.web.dependencies import require_admin, repo from decnet.web.db.models import DeployIniRequest log = get_logger("api") @@ -21,12 +21,13 @@ router = APIRouter() responses={ 400: {"description": "Bad Request (e.g. malformed JSON)"}, 401: {"description": "Could not validate credentials"}, + 403: {"description": "Insufficient permissions"}, 409: {"description": "Configuration conflict (e.g. invalid IP allocation or network mismatch)"}, 422: {"description": "Invalid INI config or schema validation error"}, 500: {"description": "Deployment failed"} } ) -async def api_deploy_deckies(req: DeployIniRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]: +async def api_deploy_deckies(req: DeployIniRequest, admin: dict = Depends(require_admin)) -> dict[str, str]: from decnet.fleet import build_deckies_from_ini try: @@ -88,6 +89,16 @@ async def api_deploy_deckies(req: DeployIniRequest, current_user: str = Depends( for new_decky in new_decky_configs: existing_deckies_map[new_decky.name] = new_decky + # Enforce deployment limit + limits_state = await repo.get_state("config_limits") + deployment_limit = limits_state.get("deployment_limit", 10) if limits_state else 10 + if len(existing_deckies_map) > deployment_limit: + raise HTTPException( + status_code=409, + detail=f"Deployment would result in {len(existing_deckies_map)} deckies, " + f"exceeding the configured limit of {deployment_limit}", + ) + config.deckies = list(existing_deckies_map.values()) # We call deploy(config) which regenerates docker-compose and runs `up -d --remove-orphans`. diff --git a/decnet/web/router/fleet/api_get_deckies.py b/decnet/web/router/fleet/api_get_deckies.py index 7353373..c520ae8 100644 --- a/decnet/web/router/fleet/api_get_deckies.py +++ b/decnet/web/router/fleet/api_get_deckies.py @@ -2,12 +2,12 @@ from typing import Any from fastapi import APIRouter, Depends -from decnet.web.dependencies import get_current_user, repo +from decnet.web.dependencies import require_viewer, repo router = APIRouter() @router.get("/deckies", tags=["Fleet Management"], responses={401: {"description": "Could not validate credentials"}, 422: {"description": "Validation error"}},) -async def get_deckies(current_user: str = Depends(get_current_user)) -> list[dict[str, Any]]: +async def get_deckies(user: dict = Depends(require_viewer)) -> list[dict[str, Any]]: return await repo.get_deckies() diff --git a/decnet/web/router/fleet/api_mutate_decky.py b/decnet/web/router/fleet/api_mutate_decky.py index e3facc6..b98fa7b 100644 --- a/decnet/web/router/fleet/api_mutate_decky.py +++ b/decnet/web/router/fleet/api_mutate_decky.py @@ -2,7 +2,7 @@ import os from fastapi import APIRouter, Depends, HTTPException, Path from decnet.mutator import mutate_decky -from decnet.web.dependencies import get_current_user, repo +from decnet.web.dependencies import require_admin, repo router = APIRouter() @@ -10,11 +10,11 @@ router = APIRouter() @router.post( "/deckies/{decky_name}/mutate", tags=["Fleet Management"], - responses={401: {"description": "Could not validate credentials"}, 404: {"description": "Decky not found"}} + responses={401: {"description": "Could not validate credentials"}, 403: {"description": "Insufficient permissions"}, 404: {"description": "Decky not found"}} ) async def api_mutate_decky( decky_name: str = Path(..., pattern=r"^[a-z0-9\-]{1,64}$"), - current_user: str = Depends(get_current_user), + admin: dict = Depends(require_admin), ) -> dict[str, str]: if os.environ.get("DECNET_CONTRACT_TEST") == "true": return {"message": f"Successfully mutated {decky_name} (Contract Test Mock)"} diff --git a/decnet/web/router/fleet/api_mutate_interval.py b/decnet/web/router/fleet/api_mutate_interval.py index f437340..f8c5202 100644 --- a/decnet/web/router/fleet/api_mutate_interval.py +++ b/decnet/web/router/fleet/api_mutate_interval.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException from decnet.config import DecnetConfig -from decnet.web.dependencies import get_current_user, repo +from decnet.web.dependencies import require_admin, repo from decnet.web.db.models import MutateIntervalRequest router = APIRouter() @@ -19,11 +19,12 @@ def _parse_duration(s: str) -> int: responses={ 400: {"description": "Bad Request (e.g. malformed JSON)"}, 401: {"description": "Could not validate credentials"}, + 403: {"description": "Insufficient permissions"}, 404: {"description": "No active deployment or decky not found"}, 422: {"description": "Validation error"} }, ) -async def api_update_mutate_interval(decky_name: str, req: MutateIntervalRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]: +async def api_update_mutate_interval(decky_name: str, req: MutateIntervalRequest, admin: dict = Depends(require_admin)) -> dict[str, str]: state_dict = await repo.get_state("deployment") if not state_dict: raise HTTPException(status_code=404, detail="No active deployment") diff --git a/decnet/web/router/logs/api_get_histogram.py b/decnet/web/router/logs/api_get_histogram.py index 6e6d877..2fe9775 100644 --- a/decnet/web/router/logs/api_get_histogram.py +++ b/decnet/web/router/logs/api_get_histogram.py @@ -2,7 +2,7 @@ from typing import Any, Optional from fastapi import APIRouter, Depends, Query -from decnet.web.dependencies import get_current_user, repo +from decnet.web.dependencies import require_viewer, repo router = APIRouter() @@ -14,7 +14,7 @@ async def get_logs_histogram( start_time: Optional[str] = Query(None), end_time: Optional[str] = Query(None), interval_minutes: int = Query(15, ge=1), - current_user: str = Depends(get_current_user) + user: dict = Depends(require_viewer) ) -> list[dict[str, Any]]: def _norm(v: Optional[str]) -> Optional[str]: if v in (None, "null", "NULL", "undefined", ""): diff --git a/decnet/web/router/logs/api_get_logs.py b/decnet/web/router/logs/api_get_logs.py index 2324c8c..74fec9f 100644 --- a/decnet/web/router/logs/api_get_logs.py +++ b/decnet/web/router/logs/api_get_logs.py @@ -2,7 +2,7 @@ from typing import Any, Optional from fastapi import APIRouter, Depends, Query -from decnet.web.dependencies import get_current_user, repo +from decnet.web.dependencies import require_viewer, repo from decnet.web.db.models import LogsResponse router = APIRouter() @@ -16,7 +16,7 @@ async def get_logs( search: Optional[str] = Query(None, max_length=512), start_time: Optional[str] = Query(None), end_time: Optional[str] = Query(None), - current_user: str = Depends(get_current_user) + user: dict = Depends(require_viewer) ) -> dict[str, Any]: def _norm(v: Optional[str]) -> Optional[str]: if v in (None, "null", "NULL", "undefined", ""): diff --git a/decnet/web/router/stats/api_get_stats.py b/decnet/web/router/stats/api_get_stats.py index f72d8ad..caf1c6f 100644 --- a/decnet/web/router/stats/api_get_stats.py +++ b/decnet/web/router/stats/api_get_stats.py @@ -2,7 +2,7 @@ from typing import Any from fastapi import APIRouter, Depends -from decnet.web.dependencies import get_current_user, repo +from decnet.web.dependencies import require_viewer, repo from decnet.web.db.models import StatsResponse router = APIRouter() @@ -10,5 +10,5 @@ router = APIRouter() @router.get("/stats", response_model=StatsResponse, tags=["Observability"], responses={401: {"description": "Could not validate credentials"}, 422: {"description": "Validation error"}},) -async def get_stats(current_user: str = Depends(get_current_user)) -> dict[str, Any]: +async def get_stats(user: dict = Depends(require_viewer)) -> dict[str, Any]: return await repo.get_stats_summary() diff --git a/decnet/web/router/stream/api_stream_events.py b/decnet/web/router/stream/api_stream_events.py index 8bd56e6..01f3e20 100644 --- a/decnet/web/router/stream/api_stream_events.py +++ b/decnet/web/router/stream/api_stream_events.py @@ -7,7 +7,7 @@ from fastapi.responses import StreamingResponse from decnet.env import DECNET_DEVELOPER from decnet.logging import get_logger -from decnet.web.dependencies import get_stream_user, repo +from decnet.web.dependencies import require_stream_viewer, repo log = get_logger("api") @@ -31,7 +31,7 @@ async def stream_events( start_time: Optional[str] = None, end_time: Optional[str] = None, max_output: Optional[int] = Query(None, alias="maxOutput"), - current_user: str = Depends(get_stream_user) + user: dict = Depends(require_stream_viewer) ) -> StreamingResponse: async def event_generator() -> AsyncGenerator[str, None]: