merge: testing → main (reconcile 2-week divergence)

This commit is contained in:
2026-04-28 18:36:00 -04:00
parent 499836c9e4
commit 862e4dbb31
1235 changed files with 160255 additions and 7996 deletions

View File

View File

@@ -0,0 +1,124 @@
import asyncio
import time
from typing import Any, Optional
from fastapi import APIRouter, Depends
from decnet.env import DECNET_DEVELOPER
from decnet.telemetry import traced as _traced
from decnet.web.dependencies import require_viewer, repo
from decnet.web.db.models import UserResponse
router = APIRouter()
_DEFAULT_DEPLOYMENT_LIMIT = 10
_DEFAULT_MUTATION_INTERVAL = "30m"
# Cache config_limits / config_globals reads — these change on rare admin
# writes but get polled constantly by the UI and locust.
_STATE_TTL = 5.0
_state_cache: dict[str, tuple[Optional[dict[str, Any]], float]] = {}
_state_locks: dict[str, asyncio.Lock] = {}
# Admin branch fetched repo.list_users() on every /config call — cache 5s,
# invalidate on user create/update/delete so the admin UI stays consistent.
_USERS_TTL = 5.0
_users_cache: tuple[Optional[list[dict[str, Any]]], float] = (None, 0.0)
_users_lock: Optional[asyncio.Lock] = None
def _reset_state_cache() -> None:
"""Reset cached config state — used by tests."""
global _users_cache, _users_lock
_state_cache.clear()
# Drop any locks bound to the previous event loop — reusing one from
# a dead loop deadlocks the next test.
_state_locks.clear()
_users_cache = (None, 0.0)
_users_lock = None
def invalidate_list_users_cache() -> None:
global _users_cache
_users_cache = (None, 0.0)
async def _get_list_users_cached() -> list[dict[str, Any]]:
global _users_cache, _users_lock
value, ts = _users_cache
now = time.monotonic()
if value is not None and now - ts < _USERS_TTL:
return value
if _users_lock is None:
_users_lock = asyncio.Lock()
async with _users_lock:
value, ts = _users_cache
now = time.monotonic()
if value is not None and now - ts < _USERS_TTL:
return value
value = await repo.list_users()
_users_cache = (value, time.monotonic())
return value
async def _get_state_cached(name: str) -> Optional[dict[str, Any]]:
entry = _state_cache.get(name)
now = time.monotonic()
if entry is not None and now - entry[1] < _STATE_TTL:
return entry[0]
lock = _state_locks.setdefault(name, asyncio.Lock())
async with lock:
entry = _state_cache.get(name)
now = time.monotonic()
if entry is not None and now - entry[1] < _STATE_TTL:
return entry[0]
value = await repo.get_state(name)
_state_cache[name] = (value, time.monotonic())
return value
@router.get(
"/config",
tags=["Configuration"],
responses={
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
},
)
@_traced("api.get_config")
async def api_get_config(user: dict = Depends(require_viewer)) -> dict:
limits_state = await _get_state_cached("config_limits")
globals_state = await _get_state_cached("config_globals")
deployment_limit = (
limits_state.get("deployment_limit", _DEFAULT_DEPLOYMENT_LIMIT)
if limits_state
else _DEFAULT_DEPLOYMENT_LIMIT
)
global_mutation_interval = (
globals_state.get("global_mutation_interval", _DEFAULT_MUTATION_INTERVAL)
if globals_state
else _DEFAULT_MUTATION_INTERVAL
)
base = {
"role": user["role"],
"deployment_limit": deployment_limit,
"global_mutation_interval": global_mutation_interval,
}
if user["role"] == "admin":
all_users = await _get_list_users_cached()
base["users"] = [
UserResponse(
uuid=u["uuid"],
username=u["username"],
role=u["role"],
must_change_password=u["must_change_password"],
).model_dump()
for u in all_users
]
if DECNET_DEVELOPER:
base["developer_mode"] = True
return base

View File

@@ -0,0 +1,144 @@
import uuid as _uuid
from fastapi import APIRouter, Depends, HTTPException
from decnet.telemetry import traced as _traced
from decnet.web.auth import ahash_password
from decnet.web.dependencies import require_admin, invalidate_user_cache, repo
from decnet.web.router.config.api_get_config import invalidate_list_users_cache
from decnet.web.db.models import (
CreateUserRequest,
MessageResponse,
ResetUserPasswordRequest,
UpdateUserRoleRequest,
UserResponse,
)
router = APIRouter()
@router.post(
"/config/users",
tags=["Configuration"],
response_model=UserResponse,
responses={
400: {"description": "Bad Request (e.g. malformed JSON)"},
401: {"description": "Could not validate credentials"},
403: {"description": "Admin access required"},
409: {"description": "Username already exists"},
422: {"description": "Validation error"},
},
)
@_traced("api.create_user")
async def api_create_user(
req: CreateUserRequest,
admin: dict = Depends(require_admin),
) -> UserResponse:
existing = await repo.get_user_by_username(req.username)
if existing:
raise HTTPException(status_code=409, detail="Username already exists")
user_uuid = str(_uuid.uuid4())
await repo.create_user({
"uuid": user_uuid,
"username": req.username,
"password_hash": await ahash_password(req.password),
"role": req.role,
"must_change_password": True, # nosec B105 — not a password
})
invalidate_list_users_cache()
return UserResponse(
uuid=user_uuid,
username=req.username,
role=req.role,
must_change_password=True,
)
@router.delete(
"/config/users/{user_uuid}",
tags=["Configuration"],
response_model=MessageResponse,
responses={
401: {"description": "Could not validate credentials"},
403: {"description": "Admin access required / cannot delete self"},
404: {"description": "User not found"},
},
)
@_traced("api.delete_user")
async def api_delete_user(
user_uuid: str,
admin: dict = Depends(require_admin),
) -> dict[str, str]:
if user_uuid == admin["uuid"]:
raise HTTPException(status_code=403, detail="Cannot delete your own account")
deleted = await repo.delete_user(user_uuid)
if not deleted:
raise HTTPException(status_code=404, detail="User not found")
invalidate_user_cache(user_uuid)
invalidate_list_users_cache()
return {"message": "User deleted"}
@router.put(
"/config/users/{user_uuid}/role",
tags=["Configuration"],
response_model=MessageResponse,
responses={
400: {"description": "Bad Request (e.g. malformed JSON)"},
401: {"description": "Could not validate credentials"},
403: {"description": "Admin access required / cannot change own role"},
404: {"description": "User not found"},
422: {"description": "Validation error"},
},
)
@_traced("api.update_user_role")
async def api_update_user_role(
user_uuid: str,
req: UpdateUserRoleRequest,
admin: dict = Depends(require_admin),
) -> dict[str, str]:
if user_uuid == admin["uuid"]:
raise HTTPException(status_code=403, detail="Cannot change your own role")
target = await repo.get_user_by_uuid(user_uuid)
if not target:
raise HTTPException(status_code=404, detail="User not found")
await repo.update_user_role(user_uuid, req.role)
invalidate_user_cache(user_uuid)
invalidate_list_users_cache()
return {"message": "User role updated"}
@router.put(
"/config/users/{user_uuid}/reset-password",
tags=["Configuration"],
response_model=MessageResponse,
responses={
400: {"description": "Bad Request (e.g. malformed JSON)"},
401: {"description": "Could not validate credentials"},
403: {"description": "Admin access required"},
404: {"description": "User not found"},
422: {"description": "Validation error"},
},
)
@_traced("api.reset_user_password")
async def api_reset_user_password(
user_uuid: str,
req: ResetUserPasswordRequest,
admin: dict = Depends(require_admin),
) -> dict[str, str]:
target = await repo.get_user_by_uuid(user_uuid)
if not target:
raise HTTPException(status_code=404, detail="User not found")
await repo.update_user_password(
user_uuid,
await ahash_password(req.new_password),
must_change_password=True,
)
invalidate_user_cache(user_uuid)
invalidate_list_users_cache()
return {"message": "Password reset successfully"}

View File

@@ -0,0 +1,29 @@
from fastapi import APIRouter, Depends, HTTPException
from decnet.env import DECNET_DEVELOPER
from decnet.telemetry import traced as _traced
from decnet.web.db.models import PurgeResponse
from decnet.web.dependencies import require_admin, repo
router = APIRouter()
@router.delete(
"/config/reinit",
tags=["Configuration"],
response_model=PurgeResponse,
responses={
401: {"description": "Could not validate credentials"},
403: {"description": "Admin access required or developer mode not enabled"},
},
)
@_traced("api.reinit")
async def api_reinit(admin: dict = Depends(require_admin)) -> dict:
if not DECNET_DEVELOPER:
raise HTTPException(status_code=403, detail="Developer mode is not enabled")
counts = await repo.purge_logs_and_bounties()
return {
"message": "Data purged",
"deleted": counts,
}

View File

@@ -0,0 +1,50 @@
from fastapi import APIRouter, Depends
from decnet.telemetry import traced as _traced
from decnet.web.dependencies import require_admin, repo
from decnet.web.db.models import DeploymentLimitRequest, GlobalMutationIntervalRequest, MessageResponse
router = APIRouter()
@router.put(
"/config/deployment-limit",
tags=["Configuration"],
response_model=MessageResponse,
responses={
400: {"description": "Bad Request (e.g. malformed JSON)"},
401: {"description": "Could not validate credentials"},
403: {"description": "Admin access required"},
422: {"description": "Validation error"},
},
)
@_traced("api.update_deployment_limit")
async def api_update_deployment_limit(
req: DeploymentLimitRequest,
admin: dict = Depends(require_admin),
) -> dict[str, str]:
await repo.set_state("config_limits", {"deployment_limit": req.deployment_limit})
return {"message": "Deployment limit updated"}
@router.put(
"/config/global-mutation-interval",
tags=["Configuration"],
response_model=MessageResponse,
responses={
400: {"description": "Bad Request (e.g. malformed JSON)"},
401: {"description": "Could not validate credentials"},
403: {"description": "Admin access required"},
422: {"description": "Validation error"},
},
)
@_traced("api.update_global_mutation_interval")
async def api_update_global_mutation_interval(
req: GlobalMutationIntervalRequest,
admin: dict = Depends(require_admin),
) -> dict[str, str]:
await repo.set_state(
"config_globals",
{"global_mutation_interval": req.global_mutation_interval},
)
return {"message": "Global mutation interval updated"}