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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user