fix(rbac): close critical auth gaps — full RBAC audit remediation

CRITICAL — unauthenticated endpoints (CWE-306):
- agent_superset.py: 10 SQL/dashboard/dataset proxy endpoints → plugin:superset_proxy:EXECUTE
- agent_superset_explore.py: 10 database explore endpoints → plugin:superset_proxy:READ
- clean_release.py / clean_release_v2.py: router-level deny-by-default → clean_release:MANAGE
- settings.py: PUT/DELETE/test environment → admin:settings:WRITE/READ
- tasks.py: log/stats/sources/export → tasks:READ

HIGH — authorization gaps (CWE-285, CWE-613):
- require_api_key_or_jwt: add token blacklist + is_active + is_admin flag checks
- get_current_user: add is_active check
- WebSocket: add _authorize_websocket() RBAC helper, permission-gate all 6 WS endpoints
- agent_conversations: add Depends(get_current_user) to save endpoint + router-level guard
- legacy validation redirect: add validation.task:VIEW guard

MEDIUM — consistency & architecture:
- admin.py: fix permission parsing split(':',1) → rsplit(':',1)
- app.py lifespan: sync RBAC permission catalog at startup
- schemas/auth.py: add is_admin to RoleSchema (with BeforeValidator), RoleCreate, RoleUpdate
- models/auth.py: add is_admin support in create_role/update_role handlers
- permissions.ts: expand KNOWN_ACTIONS (VIEW/CREATE/EDIT/MANAGE/APPROVE/PREVIEW/LAUNCH/LAUNCH_PROD)
- permissions.ts: isAdminUser checks is_admin flag from /auth/me
- Navbar.svelte: replace exact role name check with hasPermission()
- admin/+page.svelte, admin/settings/llm/+page.svelte: add ProtectedRoute guards

TESTS:
- test_dependencies_unit.py: fix 5 tests for new is_token_blacklisted + is_admin checks
- test_api_key_auth.py: fix test_jwt_precedence for is_token_blacklisted mock
- permissions.test.ts: update non-KNOWN_ACTION test to use unknown suffix 'xyz'

VERIFIED: 230 backend tests pass, 3257 frontend tests pass, index rebuilt (7373 contracts, 3832 edges)
This commit is contained in:
2026-07-23 12:38:19 +03:00
parent e62735cc06
commit cd4b91daa5
19 changed files with 222 additions and 40 deletions

View File

@@ -161,6 +161,28 @@ async def lifespan(app: FastAPI):
except Exception as _e:
logger.explore("Failed to clean up stuck general tasks", error=str(_e))
logger.reason("Synchronizing RBAC permission catalog at startup")
try:
from src.services.rbac_permission_catalog import (
discover_declared_permissions,
sync_permission_catalog,
)
from src.core.database import SessionLocal as _AuthDb
from src.dependencies import get_plugin_loader
_auth_db = _AuthDb()
_plugin_loader = get_plugin_loader()
_declared = discover_declared_permissions(plugin_loader=_plugin_loader)
_inserted = sync_permission_catalog(db=_auth_db, declared_permissions=_declared)
_auth_db.close()
if _inserted > 0:
logger.reason(
f"Synchronized {_inserted} new RBAC permissions at startup",
payload={"inserted": _inserted},
)
except Exception as _e:
logger.explore("Failed to sync RBAC permission catalog at startup", error=str(_e))
logger.reason("Initializing AsyncJobRunner")
get_async_job_runner() # Initialize singleton with running event loop BEFORE scheduler starts
logger.reason("Starting scheduler")
@@ -633,6 +655,57 @@ async def _authenticate_websocket(websocket: WebSocket, endpoint_name: str) -> b
# #endregion App.AppModule.AuthenticateWebsocket
# #region App.AppModule.AuthorizeWebsocket [C:3] [TYPE Function] [SEMANTICS websocket,auth,rbac]
# @ingroup Module
# @BRIEF Extract the authenticated principal from a WebSocket token and check RBAC permissions.
# @PRE _authenticate_websocket has already validated the token.
# @POST Returns True if the authenticated user has the required permission; False otherwise.
# @RELATION DEPENDS_ON -> [Auth.Jwt.DecodeToken]
# @RELATION DEPENDS_ON -> [Core.Repository.AuthRepository]
def _authorize_websocket(websocket: WebSocket, resource: str, action: str) -> bool:
"""Check if the WebSocket-authenticated user has the required RBAC permission."""
ws_token = websocket.query_params.get("token", "")
try:
from .core.auth.jwt import decode_token
from .core.database import SessionLocal
from .models.auth import User, Role
payload = decode_token(ws_token)
username = payload.get("sub")
if not isinstance(username, str) or not username:
return False
db = SessionLocal()
try:
user = db.query(User).filter(User.username == username).first()
if not user:
return False
if not getattr(user, "is_active", True):
return False
# Admin bypass via is_admin flag
for role in user.roles:
if getattr(role, "is_admin", False):
return True
for perm in role.permissions:
if perm.resource == resource and perm.action == action:
return True
return False
finally:
db.close()
except Exception:
logger.explore(
"WebSocket authorization failed",
payload={"resource": resource, "action": action},
error="Token decode or DB lookup failed",
)
return False
# #endregion App.AppModule.AuthorizeWebsocket
# #region App.AppModule.SetWebsocketTraceId [C:2] [TYPE Function] [SEMANTICS websocket,trace,context]
# @ingroup Module
# @BRIEF Apply a valid UUID4 x-trace-id query parameter to the current WebSocket context.
@@ -715,6 +788,10 @@ async def websocket_endpoint(websocket: WebSocket, task_id: str, source: str = N
await websocket.close(code=4001, reason="Authentication required")
return
if not _authorize_websocket(websocket, "tasks", "READ"):
await websocket.close(code=4003, reason="Insufficient permissions")
return
await websocket.accept()
source_filter = source.lower() if source else None
level_filter = level.upper() if level else None
@@ -916,6 +993,10 @@ async def task_events_websocket(websocket: WebSocket):
await websocket.close(code=4001, reason="Authentication required")
return
if not _authorize_websocket(websocket, "tasks", "READ"):
await websocket.close(code=4003, reason="Insufficient permissions")
return
await websocket.accept()
logger.reason("Accepted global task events WebSocket connection")
@@ -977,6 +1058,11 @@ async def app_logs_websocket(
if not await _authenticate_websocket(websocket, "ws/app-logs"):
await websocket.close(code=4001, reason="Authentication required")
return
if not _authorize_websocket(websocket, "admin:settings", "READ"):
await websocket.close(code=4003, reason="Insufficient permissions")
return
await websocket.accept()
handler = get_app_log_handler()
level_hierarchy = {"DEBUG": 0, "INFO": 1, "WARNING": 2, "ERROR": 3}
@@ -1055,6 +1141,10 @@ async def maintenance_events_websocket(websocket: WebSocket):
await websocket.close(code=4001, reason="Authentication required")
return
if not _authorize_websocket(websocket, "maintenance", "READ"):
await websocket.close(code=4003, reason="Insufficient permissions")
return
await websocket.accept()
logger.reason("Accepted maintenance events WebSocket connection")
@@ -1102,6 +1192,10 @@ async def dataset_websocket_endpoint(websocket: WebSocket, env_id: str):
await websocket.close(code=4001, reason="Authentication required")
return
if not _authorize_websocket(websocket, "plugin:migration", "READ"):
await websocket.close(code=4003, reason="Insufficient permissions")
return
await websocket.accept()
logger.reason("Accepted dataset event WebSocket", payload={"env_id": env_id})
task_manager = get_task_manager()
@@ -1145,6 +1239,11 @@ async def translate_run_websocket(websocket: WebSocket, run_id: str):
if not await _authenticate_websocket(websocket, "ws/translate/run"):
await websocket.close(code=4001, reason="Authentication required")
return
if not _authorize_websocket(websocket, "translate.run", "VIEW"):
await websocket.close(code=4003, reason="Insufficient permissions")
return
await websocket.accept()
logger.reason("Accepted translate run WebSocket", payload={"run_id": run_id})
try: