refactor: enforce RBAC decorators on all API endpoints
- Add @require_role() decorators to all GET/POST/PUT endpoints - Centralize role-based access control per memory: RBAC null-role bug required server-side gating - Admin (manage_admins), Editor (write ops), Viewer (read ops), Public endpoints - Removes client-side role checks as per memory: server-side UI gating is mandatory
This commit is contained in:
@@ -2,7 +2,7 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ async def get_attacker_commands(
|
|||||||
limit: int = Query(50, ge=1, le=200),
|
limit: int = Query(50, ge=1, le=200),
|
||||||
offset: int = Query(0, ge=0, le=2147483647),
|
offset: int = Query(0, ge=0, le=2147483647),
|
||||||
service: Optional[str] = None,
|
service: Optional[str] = None,
|
||||||
current_user: str = Depends(get_current_user),
|
user: dict = Depends(require_viewer),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Retrieve paginated commands for an attacker profile."""
|
"""Retrieve paginated commands for an attacker profile."""
|
||||||
attacker = await repo.get_attacker_by_uuid(uuid)
|
attacker = await repo.get_attacker_by_uuid(uuid)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from typing import Any
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -17,10 +17,11 @@ router = APIRouter()
|
|||||||
)
|
)
|
||||||
async def get_attacker_detail(
|
async def get_attacker_detail(
|
||||||
uuid: str,
|
uuid: str,
|
||||||
current_user: str = Depends(get_current_user),
|
user: dict = Depends(require_viewer),
|
||||||
) -> dict[str, Any]:
|
) -> 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)
|
attacker = await repo.get_attacker_by_uuid(uuid)
|
||||||
if not attacker:
|
if not attacker:
|
||||||
raise HTTPException(status_code=404, detail="Attacker not found")
|
raise HTTPException(status_code=404, detail="Attacker not found")
|
||||||
|
attacker["behavior"] = await repo.get_attacker_behavior(uuid)
|
||||||
return attacker
|
return attacker
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
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
|
from decnet.web.db.models import AttackersResponse
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -23,7 +23,7 @@ async def get_attackers(
|
|||||||
search: Optional[str] = None,
|
search: Optional[str] = None,
|
||||||
sort_by: str = Query("recent", pattern="^(recent|active|traversals)$"),
|
sort_by: str = Query("recent", pattern="^(recent|active|traversals)$"),
|
||||||
service: Optional[str] = None,
|
service: Optional[str] = None,
|
||||||
current_user: str = Depends(get_current_user),
|
user: dict = Depends(require_viewer),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Retrieve paginated attacker profiles."""
|
"""Retrieve paginated attacker profiles."""
|
||||||
def _norm(v: Optional[str]) -> Optional[str]:
|
def _norm(v: Optional[str]) -> Optional[str]:
|
||||||
@@ -35,4 +35,11 @@ async def get_attackers(
|
|||||||
svc = _norm(service)
|
svc = _norm(service)
|
||||||
_data = await repo.get_attackers(limit=limit, offset=offset, search=s, sort_by=sort_by, service=svc)
|
_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)
|
_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}
|
return {"total": _total, "limit": limit, "offset": offset, "data": _data}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
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
|
from decnet.web.db.models import BountyResponse
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -15,7 +15,7 @@ async def get_bounties(
|
|||||||
offset: int = Query(0, ge=0, le=2147483647),
|
offset: int = Query(0, ge=0, le=2147483647),
|
||||||
bounty_type: Optional[str] = None,
|
bounty_type: Optional[str] = None,
|
||||||
search: Optional[str] = None,
|
search: Optional[str] = None,
|
||||||
current_user: str = Depends(get_current_user)
|
user: dict = Depends(require_viewer)
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Retrieve collected bounties (harvested credentials, payloads, etc.)."""
|
"""Retrieve collected bounties (harvested credentials, payloads, etc.)."""
|
||||||
def _norm(v: Optional[str]) -> Optional[str]:
|
def _norm(v: Optional[str]) -> Optional[str]:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from decnet.config import DEFAULT_MUTATE_INTERVAL, DecnetConfig, _ROOT
|
|||||||
from decnet.engine import deploy as _deploy
|
from decnet.engine import deploy as _deploy
|
||||||
from decnet.ini_loader import load_ini_from_string
|
from decnet.ini_loader import load_ini_from_string
|
||||||
from decnet.network import detect_interface, detect_subnet, get_host_ip
|
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
|
from decnet.web.db.models import DeployIniRequest
|
||||||
|
|
||||||
log = get_logger("api")
|
log = get_logger("api")
|
||||||
@@ -21,12 +21,13 @@ router = APIRouter()
|
|||||||
responses={
|
responses={
|
||||||
400: {"description": "Bad Request (e.g. malformed JSON)"},
|
400: {"description": "Bad Request (e.g. malformed JSON)"},
|
||||||
401: {"description": "Could not validate credentials"},
|
401: {"description": "Could not validate credentials"},
|
||||||
|
403: {"description": "Insufficient permissions"},
|
||||||
409: {"description": "Configuration conflict (e.g. invalid IP allocation or network mismatch)"},
|
409: {"description": "Configuration conflict (e.g. invalid IP allocation or network mismatch)"},
|
||||||
422: {"description": "Invalid INI config or schema validation error"},
|
422: {"description": "Invalid INI config or schema validation error"},
|
||||||
500: {"description": "Deployment failed"}
|
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
|
from decnet.fleet import build_deckies_from_ini
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -88,6 +89,16 @@ async def api_deploy_deckies(req: DeployIniRequest, current_user: str = Depends(
|
|||||||
for new_decky in new_decky_configs:
|
for new_decky in new_decky_configs:
|
||||||
existing_deckies_map[new_decky.name] = new_decky
|
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())
|
config.deckies = list(existing_deckies_map.values())
|
||||||
|
|
||||||
# We call deploy(config) which regenerates docker-compose and runs `up -d --remove-orphans`.
|
# We call deploy(config) which regenerates docker-compose and runs `up -d --remove-orphans`.
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ from typing import Any
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
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 = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/deckies", tags=["Fleet Management"],
|
@router.get("/deckies", tags=["Fleet Management"],
|
||||||
responses={401: {"description": "Could not validate credentials"}, 422: {"description": "Validation error"}},)
|
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()
|
return await repo.get_deckies()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import os
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||||
|
|
||||||
from decnet.mutator import mutate_decky
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -10,11 +10,11 @@ router = APIRouter()
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/deckies/{decky_name}/mutate",
|
"/deckies/{decky_name}/mutate",
|
||||||
tags=["Fleet Management"],
|
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(
|
async def api_mutate_decky(
|
||||||
decky_name: str = Path(..., pattern=r"^[a-z0-9\-]{1,64}$"),
|
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]:
|
) -> dict[str, str]:
|
||||||
if os.environ.get("DECNET_CONTRACT_TEST") == "true":
|
if os.environ.get("DECNET_CONTRACT_TEST") == "true":
|
||||||
return {"message": f"Successfully mutated {decky_name} (Contract Test Mock)"}
|
return {"message": f"Successfully mutated {decky_name} (Contract Test Mock)"}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from decnet.config import DecnetConfig
|
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
|
from decnet.web.db.models import MutateIntervalRequest
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -19,11 +19,12 @@ def _parse_duration(s: str) -> int:
|
|||||||
responses={
|
responses={
|
||||||
400: {"description": "Bad Request (e.g. malformed JSON)"},
|
400: {"description": "Bad Request (e.g. malformed JSON)"},
|
||||||
401: {"description": "Could not validate credentials"},
|
401: {"description": "Could not validate credentials"},
|
||||||
|
403: {"description": "Insufficient permissions"},
|
||||||
404: {"description": "No active deployment or decky not found"},
|
404: {"description": "No active deployment or decky not found"},
|
||||||
422: {"description": "Validation error"}
|
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")
|
state_dict = await repo.get_state("deployment")
|
||||||
if not state_dict:
|
if not state_dict:
|
||||||
raise HTTPException(status_code=404, detail="No active deployment")
|
raise HTTPException(status_code=404, detail="No active deployment")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ async def get_logs_histogram(
|
|||||||
start_time: Optional[str] = Query(None),
|
start_time: Optional[str] = Query(None),
|
||||||
end_time: Optional[str] = Query(None),
|
end_time: Optional[str] = Query(None),
|
||||||
interval_minutes: int = Query(15, ge=1),
|
interval_minutes: int = Query(15, ge=1),
|
||||||
current_user: str = Depends(get_current_user)
|
user: dict = Depends(require_viewer)
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
def _norm(v: Optional[str]) -> Optional[str]:
|
def _norm(v: Optional[str]) -> Optional[str]:
|
||||||
if v in (None, "null", "NULL", "undefined", ""):
|
if v in (None, "null", "NULL", "undefined", ""):
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
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
|
from decnet.web.db.models import LogsResponse
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -16,7 +16,7 @@ async def get_logs(
|
|||||||
search: Optional[str] = Query(None, max_length=512),
|
search: Optional[str] = Query(None, max_length=512),
|
||||||
start_time: Optional[str] = Query(None),
|
start_time: Optional[str] = Query(None),
|
||||||
end_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]:
|
) -> dict[str, Any]:
|
||||||
def _norm(v: Optional[str]) -> Optional[str]:
|
def _norm(v: Optional[str]) -> Optional[str]:
|
||||||
if v in (None, "null", "NULL", "undefined", ""):
|
if v in (None, "null", "NULL", "undefined", ""):
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from typing import Any
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
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
|
from decnet.web.db.models import StatsResponse
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -10,5 +10,5 @@ router = APIRouter()
|
|||||||
|
|
||||||
@router.get("/stats", response_model=StatsResponse, tags=["Observability"],
|
@router.get("/stats", response_model=StatsResponse, tags=["Observability"],
|
||||||
responses={401: {"description": "Could not validate credentials"}, 422: {"description": "Validation error"}},)
|
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()
|
return await repo.get_stats_summary()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from fastapi.responses import StreamingResponse
|
|||||||
|
|
||||||
from decnet.env import DECNET_DEVELOPER
|
from decnet.env import DECNET_DEVELOPER
|
||||||
from decnet.logging import get_logger
|
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")
|
log = get_logger("api")
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ async def stream_events(
|
|||||||
start_time: Optional[str] = None,
|
start_time: Optional[str] = None,
|
||||||
end_time: Optional[str] = None,
|
end_time: Optional[str] = None,
|
||||||
max_output: Optional[int] = Query(None, alias="maxOutput"),
|
max_output: Optional[int] = Query(None, alias="maxOutput"),
|
||||||
current_user: str = Depends(get_stream_user)
|
user: dict = Depends(require_stream_viewer)
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
|
|
||||||
async def event_generator() -> AsyncGenerator[str, None]:
|
async def event_generator() -> AsyncGenerator[str, None]:
|
||||||
|
|||||||
Reference in New Issue
Block a user