fix(rbac): single admin authority, gate privileged surfaces, enforce session on dual-auth

- is_admin flag is the sole admin authority; remove role-name fallbacks (dependencies, reports, tasks, agent lifecycle, security badge, frontend)
- normalize (resource, ACTION) across REST/MCP/catalog; strict admin role/permission parsing (400 on unknown)
- gate LLM provider CRUD/status/test on admin:settings; require auth on agent llm-status; quarantine listing requires scenario:automation MANAGE
- enforce logical-session revocation/idle in require_api_key_or_jwt
- last-admin lockout guards for user/role CRUD; block non-admin from granting is_admin
- frontend: flag-only admin, route permission map from the nav registry, write-control gating, 403 dedupe
- tests: orthogonal edge coverage (flag-only admin, lockout boundaries, session states, normalization)
This commit is contained in:
2026-09-22 21:14:35 +03:00
parent 5a5ac4dd7c
commit f0d7b79523
36 changed files with 1256 additions and 160 deletions

View File

@@ -15,6 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ...core.auth.repository import AuthRepository
from ...core.auth.permission_utils import is_admin_user
from ...core.auth.security import get_password_hash
from ...core.database import get_auth_db
from ...core.logger import belief_scope, logger
@@ -44,6 +45,59 @@ router = APIRouter(prefix="/api/admin", tags=["admin"])
# #endregion Api.Admin.Router
# #region Api.Admin.Helpers [C:3] [TYPE Block] [SEMANTICS admin,rbac,guard,lockout]
# @ingroup Api
# @BRIEF Shared fail-closed guards: last-admin lockout and strict role/permission resolution.
# @INVARIANT The system never loses its final active administrator through user or role CRUD.
def _active_admin_user_count(db: Session) -> int:
"""Count distinct active users holding at least one is_admin role."""
return (
db.query(User)
.join(User.roles)
.filter(Role.is_admin.is_(True), User.is_active.is_(True))
.distinct()
.count()
)
def _resolve_roles(db: Session, role_names: list[str]) -> list[Role]:
"""Resolve role names to Role rows; an unknown name is a 400, never silently dropped.
Duplicate names are de-duplicated (order-preserving) so a client cannot trigger a
duplicate-association error by repeating the same role twice.
"""
repo = AuthRepository(db)
resolved: list[Role] = []
seen: set[str] = set()
for role_name in role_names:
if role_name in seen:
continue
seen.add(role_name)
role = repo.get_role_by_name(role_name)
if role is None:
raise HTTPException(status_code=400, detail=f"Unknown role: {role_name}")
resolved.append(role)
return resolved
def _resolve_permissions(db: Session, values: list[str]) -> list:
"""Resolve permission ids or 'resource:ACTION' strings; unknown values are a 400."""
repo = AuthRepository(db)
resolved = []
for value in values:
perm = repo.get_permission_by_id(value)
if perm is None and ":" in value:
resource, action = value.rsplit(":", 1)
perm = repo.get_permission_by_resource_action(
resource.strip(), action.strip().upper()
)
if perm is None:
raise HTTPException(status_code=400, detail=f"Unknown permission: {value}")
resolved.append(perm)
return resolved
# #endregion Api.Admin.Helpers
# #region Api.Admin.ListUsers [C:3] [TYPE Function]
# @ingroup Api
# @BRIEF Lists all registered users.
@@ -87,10 +141,8 @@ async def create_user(
is_active=user_in.is_active,
)
for role_name in user_in.roles:
role = repo.get_role_by_name(role_name)
if role:
new_user.roles.append(role)
for role in _resolve_roles(db, user_in.roles):
new_user.roles.append(role)
db.add(new_user)
db.commit()
@@ -120,19 +172,38 @@ async def update_user(
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Snapshot admin authority before mutation for the last-admin lockout guard.
was_admin = is_admin_user(user)
was_active = bool(user.is_active)
admin_count_before = _active_admin_user_count(db)
new_roles = None
if user_in.roles is not None:
new_roles = _resolve_roles(db, user_in.roles)
if user_in.email is not None:
user.email = user_in.email
if user_in.is_active is not None:
user.is_active = user_in.is_active
if user_in.password is not None:
user.password_hash = get_password_hash(user_in.password)
if new_roles is not None:
user.roles = new_roles
if user_in.roles is not None:
user.roles = []
for role_name in user_in.roles:
role = repo.get_role_by_name(role_name)
if role:
user.roles.append(role)
# Last-admin lockout: the final active administrator can be neither deactivated
# nor stripped of every is_admin role.
will_be_active = bool(user.is_active)
will_be_admin = is_admin_user(user)
if (
was_admin
and was_active
and not (will_be_active and will_be_admin)
and admin_count_before <= 1
):
db.rollback()
raise HTTPException(
status_code=409,
detail="Cannot deactivate or demote the last active administrator — this would lock everyone out.",
)
db.commit()
db.refresh(user)
@@ -166,6 +237,15 @@ async def delete_user(
raise HTTPException(status_code=404, detail="User not found")
logger.reflect("Found user to delete", payload={"username": user.username})
if (
is_admin_user(user)
and bool(user.is_active)
and _active_admin_user_count(db) <= 1
):
raise HTTPException(
status_code=409,
detail="Cannot delete the last active administrator — this would lock everyone out.",
)
db.delete(user)
db.commit()
logger.reflect("Successfully deleted user", payload={"user_id": user_id})
@@ -201,25 +281,19 @@ async def list_roles(
async def create_role(
role_in: RoleCreate,
db: Session = Depends(get_auth_db),
_=Depends(has_permission("admin:roles", "WRITE")),
current_user=Depends(has_permission("admin:roles", "WRITE")),
):
with belief_scope("api.admin.create_role"):
if role_in.is_admin and not is_admin_user(current_user):
raise HTTPException(
status_code=403,
detail="Only an administrator may grant the admin flag.",
)
if db.query(Role).filter(Role.name == role_in.name).first():
raise HTTPException(status_code=400, detail="Role already exists")
new_role = Role(name=role_in.name, description=role_in.description, is_admin=role_in.is_admin)
repo = AuthRepository(db)
for perm_id_or_str in role_in.permissions:
perm = repo.get_permission_by_id(perm_id_or_str)
if not perm and ":" in perm_id_or_str:
# rsplit splits on the LAST colon — correctly handles resources
# like "plugin:migration" with action "EXECUTE".
res, act = perm_id_or_str.rsplit(":", 1)
perm = repo.get_permission_by_resource_action(res, act)
if perm:
new_role.permissions.append(perm)
new_role.permissions = _resolve_permissions(db, role_in.permissions)
db.add(new_role)
db.commit()
@@ -242,7 +316,7 @@ async def update_role(
role_id: str,
role_in: RoleUpdate,
db: Session = Depends(get_auth_db),
_=Depends(has_permission("admin:roles", "WRITE")),
current_user=Depends(has_permission("admin:roles", "WRITE")),
):
with belief_scope("api.admin.update_role"):
repo = AuthRepository(db)
@@ -255,6 +329,11 @@ async def update_role(
if role_in.description is not None:
role.description = role_in.description
if role_in.is_admin is not None and role_in.is_admin != role.is_admin:
if role_in.is_admin is True and not is_admin_user(current_user):
raise HTTPException(
status_code=403,
detail="Only an administrator may grant the admin flag.",
)
# Last-admin-role guard: never remove the admin flag from the final
# is_admin role, otherwise every admin user would lose access.
if role_in.is_admin is False and role.is_admin is True:
@@ -267,17 +346,7 @@ async def update_role(
role.is_admin = role_in.is_admin
if role_in.permissions is not None:
role.permissions = []
for perm_id_or_str in role_in.permissions:
perm = repo.get_permission_by_id(perm_id_or_str)
if not perm and ":" in perm_id_or_str:
# rsplit splits on the LAST colon — correctly handles resources
# like "plugin:migration" with action "EXECUTE".
res, act = perm_id_or_str.rsplit(":", 1)
perm = repo.get_permission_by_resource_action(res, act)
if perm:
role.permissions.append(perm)
role.permissions = _resolve_permissions(db, role_in.permissions)
db.commit()
db.refresh(role)
@@ -306,6 +375,16 @@ async def delete_role(
if not role:
raise HTTPException(status_code=404, detail="Role not found")
# Deleting the final is_admin role would strip administrative authority from
# every admin user (roles carry the authority; users hold no flag of their own).
if role.is_admin:
admin_role_count = db.query(Role).filter(Role.is_admin.is_(True)).count()
if admin_role_count <= 1:
raise HTTPException(
status_code=409,
detail="Cannot delete the last admin role — this would lock everyone out.",
)
db.delete(role)
db.commit()
return None

View File

@@ -11,6 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status as http_sta
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.auth.permission_utils import is_admin_user
from ...dependencies import get_agent_service_user, get_current_user
from ...models.auth import User
from ...schemas.agent_lifecycle import (
@@ -72,10 +73,7 @@ async def read_events(
Regular users see only their own events. Admin users may query by user_id.
"""
is_admin = any(
getattr(role, "is_admin", False) or role.name == "Admin"
for role in current_user.roles
)
is_admin = is_admin_user(current_user)
# Non-admin users cannot query other users' events
if user_id and not is_admin:

View File

@@ -3,7 +3,9 @@
# @BRIEF Agent LLM provider health status endpoint — used by frontend for provider availability indicator.
# @RATIONALE Frontend performs health check at mount and auto-retries every 30s if provider unavailable.
# @RELATION DEPENDS_ON -> [src.core.utils.llm_health]
from fastapi import APIRouter, Query
from fastapi import APIRouter, Depends, Query
from ...dependencies import get_current_user
router = APIRouter(prefix="/api/agent", tags=["agent-status"])
@@ -20,7 +22,10 @@ router = APIRouter(prefix="/api/agent", tags=["agent-status"])
# module uses only openai+httpx (no gradio, no langchain), so it works in
# both backend and agent containers.
@router.get("/llm-status")
async def get_llm_status(force: bool = Query(False)):
async def get_llm_status(
force: bool = Query(False),
_=Depends(get_current_user),
):
"""Get cached LLM provider health status. Probes provider if cache expired."""
from src.core.utils.llm_health import _check_llm_provider_health, _llm_status
status = await _check_llm_provider_health(force=force)

View File

@@ -413,7 +413,7 @@ def list_notifications(limit: int = 100, db=_DB, current_user=_READ):
# @INVARIANT Recovery requires scenario:automation MANAGE and a matching quarantine version; an unknown or stale
# release changes no schedule and no counter.
@router.get("/quarantine")
def list_quarantines(_user=_USER):
def list_quarantines(_perm=_MANAGE):
return get_poisoned_store().list_quarantines()

View File

@@ -15,7 +15,7 @@ from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.logger import logger
from ...core.utils.endpoint_locality import EndpointNotLocalError
from ...dependencies import get_current_user as get_current_active_user
from ...dependencies import has_permission
from ...plugins.llm_analysis.models import LLMProviderConfig, LLMProviderType
from ...schemas.auth import User
from ...services.llm_provider import LLMProviderService, is_masked_or_placeholder, mask_api_key
@@ -87,7 +87,7 @@ def _is_valid_runtime_api_key(value: str | None) -> bool:
# @RELATION DEPENDS_ON -> [Plugin.Models.LLMProviderConfig]
@router.get("/providers", response_model=list[LLMProviderConfig])
async def get_providers(
current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db)
current_user: User = Depends(has_permission("admin:settings", "READ")), db: Session = Depends(get_db)
):
"""
Get all LLM provider configurations.
@@ -121,7 +121,7 @@ async def get_providers(
@router.post("/providers/fetch-models")
async def fetch_models(
payload: FetchModelsRequest,
current_user: User = Depends(get_current_active_user),
current_user: User = Depends(has_permission("admin:settings", "WRITE")),
db: Session = Depends(get_db),
):
from ...plugins.llm_analysis.models import LLMProviderType
@@ -200,7 +200,7 @@ async def fetch_models(
# @RELATION CALLS -> [Api.Llm.IsValidRuntimeApiKey]
@router.get("/status")
async def get_llm_status(
current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db)
current_user: User = Depends(has_permission("admin:settings", "READ")), db: Session = Depends(get_db)
):
service = LLMProviderService(db)
providers = service.get_all_providers()
@@ -276,7 +276,7 @@ async def get_llm_status(
)
async def create_provider(
config: LLMProviderConfig,
current_user: User = Depends(get_current_active_user),
current_user: User = Depends(has_permission("admin:settings", "WRITE")),
db: Session = Depends(get_db),
):
"""
@@ -306,7 +306,7 @@ async def create_provider(
async def update_provider(
provider_id: str,
config: LLMProviderConfig,
current_user: User = Depends(get_current_active_user),
current_user: User = Depends(has_permission("admin:settings", "WRITE")),
db: Session = Depends(get_db),
):
"""
@@ -339,7 +339,7 @@ async def update_provider(
@router.delete("/providers/{provider_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_provider(
provider_id: str,
current_user: User = Depends(get_current_active_user),
current_user: User = Depends(has_permission("admin:settings", "WRITE")),
db: Session = Depends(get_db),
):
"""
@@ -365,7 +365,7 @@ async def delete_provider(
@router.post("/providers/{provider_id}/test")
async def test_connection(
provider_id: str,
current_user: User = Depends(get_current_active_user),
current_user: User = Depends(has_permission("admin:settings", "WRITE")),
db: Session = Depends(get_db),
):
logger.reason(
@@ -421,7 +421,7 @@ async def test_connection(
# @RELATION DEPENDS_ON -> [Plugin.Models.LLMProviderConfig]
@router.post("/providers/test")
async def test_provider_config(
config: LLMProviderConfig, current_user: User = Depends(get_current_active_user)
config: LLMProviderConfig, current_user: User = Depends(has_permission("admin:settings", "WRITE"))
):
"""
Test connection with a provided configuration.
@@ -477,7 +477,7 @@ class ProbeMaxImagesResponse(BaseModel):
@router.post("/providers/{provider_id}/probe-max-images", response_model=ProbeMaxImagesResponse)
async def probe_max_images(
provider_id: str,
current_user: User = Depends(get_current_active_user),
current_user: User = Depends(has_permission("admin:settings", "WRITE")),
db: Session = Depends(get_db),
):
"""

View File

@@ -12,6 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from ...core.config_manager import ConfigManager
from ...core.auth.permission_utils import is_admin_user
from ...core.logger import belief_scope
from ...core.task_manager import Task, TaskManager, TaskStatus
from ...core.task_manager.models import LogFilter, LogStats
@@ -456,10 +457,7 @@ async def resume_task(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
)
is_admin = any(
getattr(role, "is_admin", False) or role.name == "Admin"
for role in current_user.roles
)
is_admin = is_admin_user(current_user)
task_owner_id = getattr(task, "user_id", None)
if (
isinstance(task_owner_id, str)

View File

@@ -303,12 +303,11 @@ def ensure_initial_admin_user() -> None:
)
db = SessionLocal()
try:
admin_role = db.query(Role).filter(Role.name == "Admin").first()
if not admin_role:
admin_role = Role(name="Admin", description="System Administrator", is_admin=True)
db.add(admin_role)
db.commit()
db.refresh(admin_role)
# Ensure the Admin role carries is_admin=True (shared idempotent helper) so the
# bootstrap administrator actually receives administrative authority.
from .core.auth.repository import AuthRepository
admin_role = AuthRepository(db).ensure_admin_role()
existing_user = db.query(User).filter(User.username == username).first()
if existing_user:
logger.reflect(

View File

@@ -12,20 +12,46 @@
from __future__ import annotations
# #region Core.Auth.PermissionUtils.NormalizePermissionPair [C:1] [TYPE Function] [SEMANTICS auth,rbac,normalize]
# @ingroup Core
# @BRIEF Canonical (resource, ACTION) normalization shared by every RBAC comparison site.
# @POST resource is stripped; action is stripped and upper-cased so REST, MCP and catalog
# comparisons cannot drift on case/whitespace.
def normalize_permission_pair(resource, action) -> tuple[str, str]:
return (
str(resource or "").strip(),
str(action or "").strip().upper(),
)
# #endregion Core.Auth.PermissionUtils.NormalizePermissionPair
# #region Core.Auth.PermissionUtils.IsAdminUser [C:1] [TYPE Function] [SEMANTICS auth,rbac,admin]
# @ingroup Core
# @BRIEF The single administrative predicate: a user is admin only via the Role.is_admin flag.
# @POST Pure; None/missing roles evaluate to False. Role NAME is never consulted — the flag is
# the sole source of truth (mirrors dependencies.has_permission and app.py WebSocket auth).
def is_admin_user(user) -> bool:
return any(
getattr(role, "is_admin", False)
for role in (getattr(user, "roles", None) or [])
)
# #endregion Core.Auth.PermissionUtils.IsAdminUser
# #region Core.Auth.PermissionUtils.UserHasPermission [C:2] [TYPE Function] [SEMANTICS auth,rbac,user,has-permission]
# @ingroup Core
# @BRIEF True when the authenticated user (or any role) holds resource:action or is admin.
# @POST Pure predicate; never raises. Missing roles/permissions evaluate to False.
# Comparison uses normalize_permission_pair (case/whitespace insensitive).
def user_has_permission(current_user, resource: str, action: str) -> bool:
roles = getattr(current_user, "roles", None) or []
for role in roles:
if getattr(role, "is_admin", False):
return True
if is_admin_user(current_user):
return True
wanted = normalize_permission_pair(resource, action)
for role in getattr(current_user, "roles", None) or []:
for perm in getattr(role, "permissions", None) or []:
if (
getattr(perm, "resource", None) == resource
and getattr(perm, "action", None) == action
):
if normalize_permission_pair(
getattr(perm, "resource", None), getattr(perm, "action", None)
) == wanted:
return True
return False
# #endregion Core.Auth.PermissionUtils.UserHasPermission

View File

@@ -26,6 +26,7 @@ from jose import JWTError
from .core.async_job_runner import AsyncJobRunner
from .core.auth.jwt import decode_token, is_token_blacklisted
from .core.auth.permission_utils import is_admin_user, normalize_permission_pair
from .core.auth.repository import AuthRepository
from .core.config_manager import ConfigManager
from .core.database import get_auth_db, get_db, init_db
@@ -446,18 +447,12 @@ def require_api_key_or_jwt(
)
# Check JWT permission (Admin has full access via is_admin flag)
has_perm = any(
getattr(role, "is_admin", False)
has_perm = is_admin_user(user) or any(
normalize_permission_pair(perm.resource, perm.action)
== normalize_permission_pair(required_jwt_resource, required_jwt_action)
for role in user.roles
for perm in role.permissions
)
if not has_perm:
for role in user.roles:
for perm in role.permissions:
if perm.resource == required_jwt_resource and perm.action == required_jwt_action:
has_perm = True
break
if has_perm:
break
if not has_perm:
raise HTTPException(
@@ -465,6 +460,11 @@ def require_api_key_or_jwt(
detail=f"Permission denied for {required_jwt_resource}:{required_jwt_action}",
)
# Enforce logical-session state (revocation + idle timeout) exactly as
# get_current_user does. Without this, a revoked session's replacement JWT
# would still authorize dual-auth (API-key-or-JWT) routes.
_enforce_session_policy(auth_db, payload)
# @NOTE [LOG-004] (see docs/adr/ADR-0017-agent-centric-logging.md)
# Не логируем "user resolved" на каждый запрос — это низкоуровневый шум.
# Высокоуровневый framing даёт "Handle API request".
@@ -864,14 +864,15 @@ def touch_session_activity(db, payload: dict) -> None:
# @POST Returns True if user has permission.
def has_permission(resource: str, action: str):
def permission_checker(current_user: User = Depends(get_current_user)):
# Union of all permissions across all roles
# Union of all permissions across all roles (normalized comparison)
wanted = normalize_permission_pair(resource, action)
for role in current_user.roles:
for perm in role.permissions:
if perm.resource == resource and perm.action == action:
if normalize_permission_pair(perm.resource, perm.action) == wanted:
return current_user
# is_admin is the single source of truth for the administrative bypass.
if any(getattr(role, "is_admin", False) for role in current_user.roles):
if is_admin_user(current_user):
return current_user
from .core.auth.logger import log_security_event
@@ -892,4 +893,46 @@ def has_permission(resource: str, action: str):
# #endregion Dependencies.AppDependencies.HasPermission
# #region Dependencies.AppDependencies.HasAnyPermission [C:3] [TYPE Function]
# @ingroup Module
# @RELATION CALLS -> Core.Repository.AuthRepository
# @BRIEF Dependency checking that the current user holds ANY of the given (resource, action) grants.
# Lets a shared endpoint accept a narrow least-privilege permission alongside its legacy gate
# (e.g. GET /api/dashboards accepts dashboards:READ or plugin:migration:READ).
# @PRE User is authenticated.
# @POST Returns the current user if any pair matches (or the user is admin); otherwise 403.
def has_any_permission(*pairs: tuple[str, str]):
def permission_checker(current_user: User = Depends(get_current_user)):
wanted = {
normalize_permission_pair(resource, action)
for resource, action in pairs
}
for role in current_user.roles:
for perm in role.permissions:
if normalize_permission_pair(perm.resource, perm.action) in wanted:
return current_user
# is_admin is the single source of truth for the administrative bypass.
if is_admin_user(current_user):
return current_user
from .core.auth.logger import log_security_event
log_security_event(
"PERMISSION_DENIED",
str(getattr(current_user, "username", "unknown")),
{"any_of": sorted(f"{resource}:{action}" for resource, action in wanted)},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Permission denied",
)
return permission_checker
# #endregion Dependencies.AppDependencies.HasAnyPermission
# #endregion Dependencies.AppDependencies

View File

@@ -24,6 +24,7 @@
from datetime import UTC, datetime
from ...core.logger import belief_scope
from ...core.auth.permission_utils import is_admin_user
from ...core.task_manager import TaskManager
from ...models.report import (
ReportCollection,
@@ -57,10 +58,7 @@ def _filter_tasks_by_rbac(tasks: list[Task], current_user) -> list[Task]:
- analyst → own tasks + system tasks (user_id=None)
- viewer → own tasks only
"""
is_admin = any(
getattr(role, "is_admin", False) or role.name == "Admin"
for role in current_user.roles
)
is_admin = is_admin_user(current_user)
if is_admin:
return tasks
is_analyst = any(

View File

@@ -19,6 +19,7 @@
from typing import Any
from ..core.logger import belief_scope, logger
from ..core.auth.permission_utils import is_admin_user
from ..models.auth import User
from ..schemas.profile import ProfilePermissionState, ProfileSecuritySummary
from .profile_utils import sanitize_text
@@ -56,7 +57,7 @@ class SecurityBadgeService:
role_names_set.add(normalized_role_name)
role_names = sorted(role_names_set)
is_admin = any(str(role_name).lower() == "admin" for role_name in role_names)
is_admin = is_admin_user(current_user)
user_permission_pairs = self._collect_user_permission_pairs(current_user)
declared_permission_pairs: set[tuple[str, str]] = set()

View File

@@ -408,7 +408,7 @@ class TestCreateRole:
"permissions": ["users:read"],
})
assert resp.status_code == 201
mock_repo.get_permission_by_resource_action.assert_called_once_with("users", "read")
mock_repo.get_permission_by_resource_action.assert_called_once_with("users", "READ")
mock_session.add.assert_called_once()
@@ -543,7 +543,7 @@ class TestUpdateRole:
"permissions": ["users:write"],
})
assert resp.status_code == 200
mock_repo.get_permission_by_resource_action.assert_called_once_with("users", "write")
mock_repo.get_permission_by_resource_action.assert_called_once_with("users", "WRITE")
class TestDeleteRole:
@@ -553,7 +553,9 @@ class TestDeleteRole:
"""Happy path: role deleted returns 204."""
mock_session = MagicMock()
mock_repo = MagicMock()
mock_repo.get_role_by_id.return_value = MagicMock()
existing_role = MagicMock()
existing_role.is_admin = False
mock_repo.get_role_by_id.return_value = existing_role
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
from src.core.database import get_auth_db
@@ -563,6 +565,22 @@ class TestDeleteRole:
mock_session.delete.assert_called_once()
mock_session.commit.assert_called_once()
def test_delete_last_admin_role_blocked(self):
"""Deleting the final is_admin role returns 409 (lockout guard)."""
mock_session = MagicMock()
mock_repo = MagicMock()
existing_role = MagicMock()
existing_role.is_admin = True
mock_repo.get_role_by_id.return_value = existing_role
mock_session.query.return_value.filter.return_value.count.return_value = 1
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
from src.core.database import get_auth_db
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.delete("/api/admin/roles/role-1")
assert resp.status_code == 409
mock_session.delete.assert_not_called()
def test_delete_role_not_found(self):
"""Non-existent role returns 404."""
mock_session = MagicMock()
@@ -676,4 +694,313 @@ class TestCreateAdMapping:
"role_id": "role-1",
})
assert resp.status_code == 422
# #region Test.Api.AdminLockoutGuards [C:3] [TYPE Test] [SEMANTICS test,admin,rbac,lockout,escalation]
class TestAdminLockoutGuards:
"""Last-admin lockout, admin-flag escalation guard, and strict role/permission resolution."""
def _admin_user_mock(self):
role = MagicMock()
role.id = "role-admin"
role.is_admin = True
user = MagicMock()
user.id = "u1"
user.username = "admin"
user.email = "admin@example.com"
user.is_active = True
user.roles = [role]
return user
def test_update_last_admin_demotion_blocked(self):
"""Demoting the last active admin (role replacement) returns 409 without commit."""
from src.core.database import get_auth_db
mock_session = MagicMock()
mock_session.query.return_value.join.return_value.filter.return_value.distinct.return_value.count.return_value = 1
existing = self._admin_user_mock()
editor = MagicMock()
editor.id = "role-editor"
editor.is_admin = False
mock_repo = MagicMock()
mock_repo.get_user_by_id.return_value = existing
mock_repo.get_role_by_name.return_value = editor
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.put("/api/admin/users/u1", json={"roles": ["Editor"]})
assert resp.status_code == 409
mock_session.commit.assert_not_called()
mock_session.rollback.assert_called_once()
def test_update_last_admin_deactivation_blocked(self):
"""Deactivating the last active admin returns 409 without commit."""
from src.core.database import get_auth_db
mock_session = MagicMock()
mock_session.query.return_value.join.return_value.filter.return_value.distinct.return_value.count.return_value = 1
existing = self._admin_user_mock()
mock_repo = MagicMock()
mock_repo.get_user_by_id.return_value = existing
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.put("/api/admin/users/u1", json={"is_active": False})
assert resp.status_code == 409
mock_session.commit.assert_not_called()
def test_delete_last_admin_user_blocked(self):
"""Deleting the last active admin returns 409 and never deletes."""
from src.core.database import get_auth_db
mock_session = MagicMock()
mock_session.query.return_value.join.return_value.filter.return_value.distinct.return_value.count.return_value = 1
existing = self._admin_user_mock()
mock_repo = MagicMock()
mock_repo.get_user_by_id.return_value = existing
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.delete("/api/admin/users/u1")
assert resp.status_code == 409
mock_session.delete.assert_not_called()
def test_create_role_admin_flag_requires_admin_caller(self):
"""A caller holding admin:roles WRITE but not the is_admin flag cannot mint an admin role."""
from datetime import datetime
from src.dependencies import get_current_user
from src.schemas.auth import PermissionSchema, RoleSchema, User
caller = User(
id="u-role-editor",
username="role.editor",
email="editor@example.com",
auth_source="LOCAL",
is_active=True,
created_at=datetime.now(),
roles=[
RoleSchema(
id="r-role-editor",
name="RoleEditor",
description="May edit roles",
is_admin=False,
permissions=[PermissionSchema(id="p-roles-write", resource="admin:roles", action="WRITE")],
)
],
)
client = _make_client({get_current_user: lambda: caller})
resp = client.post("/api/admin/roles", json={"name": "GhostAdmin", "is_admin": True})
assert resp.status_code == 403
def test_create_user_unknown_role_rejected(self):
"""An unknown role name is a 400, never silently dropped."""
from src.core.database import get_auth_db
mock_session = MagicMock()
mock_repo = MagicMock()
mock_repo.get_user_by_username.return_value = None
mock_repo.get_role_by_name.return_value = None
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.post("/api/admin/users", json={
"username": "ghost",
"email": "ghost@example.com",
"password": "StrongPass1",
"roles": ["Ghost"],
})
assert resp.status_code == 400
assert "Unknown role: Ghost" in resp.text
def test_create_role_unknown_permission_rejected(self):
"""An unknown permission string is a 400, never silently dropped."""
from src.core.database import get_auth_db
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = None
mock_repo = MagicMock()
mock_repo.get_permission_by_id.return_value = None
mock_repo.get_permission_by_resource_action.return_value = None
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.post("/api/admin/roles", json={
"name": "Broken",
"permissions": ["ghost:READ"],
})
assert resp.status_code == 400
assert "Unknown permission: ghost:READ" in resp.text
# ── Orthogonal edge cases ──
def _non_admin_caller(self):
from datetime import datetime
from src.schemas.auth import PermissionSchema, RoleSchema, User
return User(
id="u-role-editor",
username="role.editor",
email="editor@example.com",
auth_source="LOCAL",
is_active=True,
created_at=datetime.now(),
roles=[
RoleSchema(
id="r-role-editor",
name="RoleEditor",
description="May edit roles",
is_admin=False,
permissions=[PermissionSchema(id="p-roles-write", resource="admin:roles", action="WRITE")],
)
],
)
def test_demote_one_of_two_admins_allowed(self):
"""Boundary: with two active admins, demoting one is NOT a lockout and succeeds."""
from datetime import datetime
from src.core.database import get_auth_db
mock_session = MagicMock()
mock_session.query.return_value.join.return_value.filter.return_value.distinct.return_value.count.return_value = 2
existing = self._admin_user_mock()
existing.auth_source = "LOCAL"
existing.created_at = datetime.now()
editor = MagicMock()
editor.id = "role-editor"
editor.name = "Editor"
editor.description = "Editor role"
editor.is_admin = False
editor.permissions = []
mock_repo = MagicMock()
mock_repo.get_user_by_id.return_value = existing
mock_repo.get_role_by_name.return_value = editor
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.put("/api/admin/users/u1", json={"roles": ["Editor"]})
assert resp.status_code == 200
mock_session.commit.assert_called_once()
def test_update_role_last_admin_flag_removal_blocked(self):
"""Removing is_admin from the only admin role returns 409 (role-level lockout guard)."""
from src.core.database import get_auth_db
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.count.return_value = 1
role = MagicMock()
role.id = "role-admin"
role.name = "Admin"
role.description = ""
role.is_admin = True
role.permissions = []
mock_repo = MagicMock()
mock_repo.get_role_by_id.return_value = role
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.put("/api/admin/roles/role-admin", json={"is_admin": False})
assert resp.status_code == 409
mock_session.commit.assert_not_called()
def test_update_role_non_admin_cannot_grant_admin_flag(self):
"""A caller with admin:roles WRITE but no admin flag cannot promote a role to admin."""
from src.core.database import get_auth_db
from src.dependencies import get_current_user
mock_session = MagicMock()
role = MagicMock()
role.id = "role-1"
role.name = "Editor"
role.description = ""
role.is_admin = False
role.permissions = []
mock_repo = MagicMock()
mock_repo.get_role_by_id.return_value = role
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
client = _make_client({
get_auth_db: lambda: mock_session,
get_current_user: lambda: self._non_admin_caller(),
})
resp = client.put("/api/admin/roles/role-1", json={"is_admin": True})
assert resp.status_code == 403
def test_create_role_non_admin_flag_allowed_for_non_admin_caller(self):
"""The escalation guard must not over-block: creating a NON-admin role is allowed."""
from src.core.database import get_auth_db
from src.dependencies import get_current_user
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = None
mock_session.refresh.side_effect = lambda obj: setattr(obj, "id", "new-role-id")
mock_repo = MagicMock()
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
client = _make_client({
get_auth_db: lambda: mock_session,
get_current_user: lambda: self._non_admin_caller(),
})
resp = client.post("/api/admin/roles", json={"name": "Editor", "is_admin": False})
assert resp.status_code == 201
def test_create_role_unknown_permission_without_colon_rejected(self):
"""A non-'resource:action' unknown permission token is also rejected (400)."""
from src.core.database import get_auth_db
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = None
mock_repo = MagicMock()
mock_repo.get_permission_by_id.return_value = None
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.post("/api/admin/roles", json={"name": "Broken", "permissions": ["ghost"]})
assert resp.status_code == 400
assert "Unknown permission: ghost" in resp.text
def test_create_user_duplicate_role_names_deduped(self):
"""Repeating a role name must not create a duplicate association."""
from datetime import datetime
from src.core.database import get_auth_db
mock_session = MagicMock()
mock_session.refresh.side_effect = lambda obj: (
setattr(obj, "id", "new-user-id"),
setattr(obj, "created_at", datetime.now()),
)
role = MagicMock()
role.id = "role-editor"
role.name = "Editor"
role.description = "Editor role"
role.is_admin = False
role.permissions = []
mock_repo = MagicMock()
mock_repo.get_user_by_username.return_value = None
mock_repo.get_role_by_name.return_value = role
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.post("/api/admin/users", json={
"username": "dup",
"email": "dup@example.com",
"password": "StrongPass1",
"roles": ["Editor", "Editor"],
})
assert resp.status_code == 201
added_user = mock_session.add.call_args[0][0]
assert len(added_user.roles) == 1
# #endregion Test.Api.AdminLockoutGuards
# #endregion Test.Api.Admin

View File

@@ -23,12 +23,22 @@ if _src not in sys.path:
sys.path.insert(0, _src)
def _make_client() -> TestClient:
"""Build a TestClient with the agent status router."""
def _make_client(authenticated: bool = True) -> TestClient:
"""Build a TestClient with the agent status router.
The endpoint requires an authenticated user (RBAC-02); tests override
get_current_user unless the test is specifically asserting the 401 path.
"""
from src.api.routes.agent_status import router
app = FastAPI()
app.include_router(router)
if authenticated:
from src.dependencies import get_current_user
app.dependency_overrides[get_current_user] = lambda: MagicMock(
username="tester", id="user-1"
)
return TestClient(app)
@@ -84,12 +94,11 @@ class TestGetLlmStatus:
@patch("src.core.utils.llm_health._check_llm_provider_health", AsyncMock(return_value="ok"))
@patch("src.core.utils.llm_health._llm_status", {"status": "ok", "last_error": ""})
def test_no_auth_required(self):
"""Status endpoint does not require authentication."""
client = _make_client()
# No auth token — should still work since no Depends on get_current_user
def test_auth_required(self):
"""Status endpoint requires authentication (RBAC-02: no anonymous provider probe)."""
client = _make_client(authenticated=False)
resp = client.get("/api/agent/llm-status")
assert resp.status_code == 200
assert resp.status_code == 401
def test_check_health_called(self):
"""_check_llm_provider_health is called on each request."""

View File

@@ -489,4 +489,102 @@ class TestProbeMaxImages:
resp = client.post(f"{P}/providers/prov-1/probe-max-images")
assert resp.status_code == 200
assert resp.json()["max_images"] == 0
# #region Test.Api.LlmPermissionGate [C:3] [TYPE Test] [SEMANTICS test,llm,rbac,denied]
class TestLlmPermissionGate:
"""LLM provider surface is gated on admin:settings — authentication alone is not enough (RBAC-01)."""
def _non_admin_client(self) -> TestClient:
from datetime import datetime
from src.dependencies import get_current_user
from src.schemas.auth import User
non_admin = User(
id="user-1", username="regular", email="u@x.com", auth_source="LOCAL",
created_at=datetime.now(), roles=[],
)
return _make_client({get_current_user: lambda: non_admin})
def test_get_providers_denied_without_read_permission(self):
client = self._non_admin_client()
resp = client.get(f"{P}/providers")
assert resp.status_code == 403
def test_get_status_denied_without_read_permission(self):
client = self._non_admin_client()
resp = client.get(f"{P}/status")
assert resp.status_code == 403
def test_delete_provider_denied_without_write_permission(self):
client = self._non_admin_client()
resp = client.delete(f"{P}/providers/prov-1")
assert resp.status_code == 403
def test_test_connection_denied_without_write_permission(self):
client = self._non_admin_client()
resp = client.post(f"{P}/providers/prov-1/test")
assert resp.status_code == 403
# ── Orthogonal edge cases ──
def _user_client(self, roles) -> TestClient:
from datetime import datetime
from src.dependencies import get_current_user
from src.schemas.auth import User
user = User(
id="user-rbac", username="rbac", email="rbac@x.com", auth_source="LOCAL",
created_at=datetime.now(), roles=roles,
)
return _make_client({get_current_user: lambda: user})
def test_admin_flag_bypass_without_explicit_permission(self):
"""Boundary: is_admin=true bypasses the explicit admin:settings permission requirement."""
from src.schemas.auth import RoleSchema
client = self._user_client([
RoleSchema(id="r", name="Admin", description="", is_admin=True, permissions=[]),
])
mock_db = MagicMock()
mock_svc = MagicMock()
mock_svc.get_all_providers.return_value = []
from src.core.database import get_db
with patch("src.api.routes.llm.LLMProviderService", return_value=mock_svc):
resp = client.get(f"{P}/providers", )
assert resp.status_code == 200
def test_read_only_permission_allows_get_but_not_delete(self):
"""Boundary: admin:settings READ grants reads but the same user cannot mutate."""
from src.schemas.auth import PermissionSchema, RoleSchema
read_only = RoleSchema(
id="r", name="SettingsReader", description="", is_admin=False,
permissions=[PermissionSchema(id="p", resource="admin:settings", action="READ")],
)
client = self._user_client([read_only])
mock_db = MagicMock()
mock_svc = MagicMock()
mock_svc.get_all_providers.return_value = []
from src.core.database import get_db
with patch("src.api.routes.llm.LLMProviderService", return_value=mock_svc):
get_resp = client.get(f"{P}/providers")
assert get_resp.status_code == 200
assert client.delete(f"{P}/providers/prov-1").status_code == 403
def test_unrelated_permission_denied(self):
"""Boundary: holding an unrelated plugin permission does not open the LLM surface."""
from src.schemas.auth import PermissionSchema, RoleSchema
other = RoleSchema(
id="r", name="StorageWriter", description="", is_admin=False,
permissions=[PermissionSchema(id="p", resource="plugin:storage", action="WRITE")],
)
client = self._user_client([other])
assert client.get(f"{P}/providers").status_code == 403
# #endregion Test.Api.LlmPermissionGate
# #endregion Test.Api.Llm

View File

@@ -11,7 +11,7 @@
# @TEST_FIXTURE: _START_BODY -> INLINE_JSON
# @TEST_FIXTURE: _SETTINGS_BODY -> INLINE_JSON
# @TEST_INVARIANT Api.Routes.MaintenanceRoutesModule."RBAC enforced per FR-015" -> VERIFIED_BY: [test_list_events_denied_403, test_list_dashboard_banners_denied_403, test_list_event_dashboards_denied_403, test_preview_dashboards_denied_403, test_get_settings_denied_403, test_put_settings_denied_403, test_start_denied_403, test_end_denied_403, test_end_all_denied_403, test_unauthenticated_401]
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import create_engine
@@ -109,11 +109,22 @@ def _sqlite_session():
# #region Test.Maintenance.Routes.Rbac.JwtClient [C:2] [TYPE Function]
# @BRIEF Real-JWT harness for the require_api_key_or_jwt mutations (start/end/end-all).
def _jwt_client(*, is_admin: bool) -> tuple[TestClient, str]:
def _jwt_client(
*,
is_admin: bool,
session_id: str | None = None,
revoked: bool = False,
create_activity: bool = True,
activity_age_minutes: int = 0,
) -> tuple[TestClient, str]:
"""Fresh app exercising the FULL real _auth_dependency JWT chain:
create_access_token -> decode_token -> AuthRepository lookup on a seeded user ->
unpatched is_token_blacklisted against the real (empty) token_blacklist table ->
roles/permissions loop. is_admin=False seeds a user with NO roles -> real 403.
When session_id is provided, a SessionActivity row is seeded so the logical-session
enforcement (revocation/idle) path of require_api_key_or_jwt is exercised;
create_activity=False simulates a purged/missing session row, and activity_age_minutes
backdates last_activity_at for the idle-expiry path.
"""
session = _sqlite_session()
user = User(
@@ -127,8 +138,30 @@ def _jwt_client(*, is_admin: bool) -> tuple[TestClient, str]:
role.is_admin = True
user.roles.append(role)
session.add(user)
session.flush()
if session_id:
from datetime import UTC, datetime, timedelta
from src.models.auth import SessionActivity
now = datetime.now(UTC)
if create_activity:
session.add(
SessionActivity(
sid=session_id,
user_id=user.id,
issued_at=now,
expires_at=now + timedelta(minutes=30),
last_activity_at=now - timedelta(minutes=activity_age_minutes),
is_revoked=revoked,
)
)
session.commit()
token = create_access_token(data={"sub": _JWT_USERNAME})
token = (
create_access_token(data={"sub": _JWT_USERNAME}, session_id=session_id)
if session_id
else create_access_token(data={"sub": _JWT_USERNAME})
)
factory = session.bind
session.close()
@@ -301,6 +334,75 @@ class TestJwtMutationRbac:
assert resp.json() == {"task_id": _CONTROL_TASK_ID, "status": "pending"}
# #endregion Test.Maintenance.Routes.Rbac.ControlAdminJwtWrite
# #region Test.Maintenance.Routes.Rbac.RevokedSession401 [C:3] [TYPE Function]
# @BRIEF RBAC-04: a revoked logical session (logout) makes a still-valid replacement JWT
# fail with 401 SESSION_REVOKED on the dual-auth mutation path — not just 403/permission.
def test_end_all_revoked_session_401(self) -> None:
client, token = _jwt_client(is_admin=True, session_id="sid-revoked", revoked=True)
resp = client.post(
"/api/maintenance/end-all",
json={},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 401
assert "SESSION_REVOKED" in resp.json()["detail"]
# #endregion Test.Maintenance.Routes.Rbac.RevokedSession401
# #region Test.Maintenance.Routes.Rbac.ActiveSessionControl [C:2] [TYPE Function]
# @BRIEF Positive control: an active logical session with the admin role still dispatches (202).
def test_end_all_active_session_not_401(self) -> None:
client, token = _jwt_client(is_admin=True, session_id="sid-active", revoked=False)
resp = client.post(
"/api/maintenance/end-all",
json={},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 202
# #endregion Test.Maintenance.Routes.Rbac.ActiveSessionControl
# #region Test.Maintenance.Routes.Rbac.MissingSessionRow401 [C:2] [TYPE Function]
# @BRIEF Edge: a JWT with a sid whose SessionActivity row was purged -> 401 SESSION_MISSING.
def test_end_all_missing_session_row_401(self) -> None:
client, token = _jwt_client(
is_admin=True, session_id="sid-purged", create_activity=False
)
resp = client.post(
"/api/maintenance/end-all",
json={},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 401
assert "SESSION_MISSING" in resp.json()["detail"]
# #endregion Test.Maintenance.Routes.Rbac.MissingSessionRow401
# #region Test.Maintenance.Routes.Rbac.IdleExpired401 [C:2] [TYPE Function]
# @BRIEF Edge: an unrevoked session idle beyond the configured timeout -> 401 SESSION_IDLE_EXPIRED.
def test_end_all_idle_expired_session_401(self) -> None:
with patch("src.dependencies.get_session_idle_timeout_minutes", return_value=30):
client, token = _jwt_client(
is_admin=True, session_id="sid-idle", activity_age_minutes=120
)
resp = client.post(
"/api/maintenance/end-all",
json={},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 401
assert "SESSION_IDLE_EXPIRED" in resp.json()["detail"]
# #endregion Test.Maintenance.Routes.Rbac.IdleExpired401
# #region Test.Maintenance.Routes.Rbac.LegacyTokenWithoutSid [C:2] [TYPE Function]
# @BRIEF Edge: a legacy token with no sid claim skips session enforcement (expiry-only) -> 202.
def test_end_all_legacy_token_without_sid_ok(self) -> None:
client, token = _jwt_client(is_admin=True)
resp = client.post(
"/api/maintenance/end-all",
json={},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 202
# #endregion Test.Maintenance.Routes.Rbac.LegacyTokenWithoutSid
class TestUnauthenticatedRbac:
"""No credentials at all -> 401 on every endpoint (scheme auto-error for session-user

View File

@@ -820,6 +820,14 @@ class TestScenarioAutomationQuarantineRelease:
)
assert resp.status_code == 403
def test_listing_requires_manage_scope(self, monkeypatch, tmp_path):
"""RBAC-05: quarantine listing is operator-only (MANAGE), not merely authenticated."""
monkeypatch.setenv("SCENARIO_POISONED_STORE_PATH", str(tmp_path / "poisoned.jsonl"))
user = _make_user_with_permissions([("scenario:automation", "TRIGGER")])
with _client_for(user) as client:
resp = client.get("/api/scenario-automation/quarantine")
assert resp.status_code == 403
def test_operator_release_roundtrip(self, dashboard_testing_client, monkeypatch, tmp_path):
client = dashboard_testing_client
store_path = tmp_path / "poisoned" / "failures.jsonl"

View File

@@ -1,6 +1,7 @@
# #region Test.Core.PermissionUtils [C:3] [TYPE Module] [SEMANTICS test,auth,rbac,permission,predicate]
# @BRIEF Verify user_has_permission — the pure user->permission predicate used by
# load_testing/lineage conditional PROD checks.
# load_testing/lineage conditional PROD checks — plus the shared normalization helper and the
# is_admin predicate (flag is the sole administrative authority).
# @RELATION BINDS_TO -> [Core.Auth.PermissionUtils]
# @TEST_EDGE: missing_roles_attribute -> Missing roles attribute evaluates to False
# @TEST_EDGE: admin_short_circuit -> Admin role grants regardless of permissions
@@ -9,6 +10,7 @@
# @TEST_EDGE: action_mismatch -> Right resource with wrong action denies
# @TEST_EDGE: missing_permissions -> Role without permissions denies
# @TEST_EDGE: missing_perm_fields -> Permission without resource/action fields denies
# @TEST_EDGE: normalization -> action case/whitespace and resource whitespace are ignored
# @TEST_INVARIANT: pure_predicate_never_raises -> VERIFIED_BY: test_missing_roles_attribute_denies,
# test_missing_permissions_attribute_denies, test_missing_perm_fields_deny
from types import SimpleNamespace
@@ -117,4 +119,76 @@ class TestUserHasPermissionDeny:
_perm("chart", "read"),
]))
assert user_has_permission(user, "dashboard", "read") is False
# ═══════════════════════════════════════════════════════════════════
# Normalization + is_admin predicate (RBAC-03 / RBAC-09 edge coverage)
# ═══════════════════════════════════════════════════════════════════
class TestNormalizePermissionPair:
"""normalize_permission_pair is the single comparison boundary for REST/MCP/catalog."""
def test_strips_and_uppercases(self):
from src.core.auth.permission_utils import normalize_permission_pair
assert normalize_permission_pair(" scenario ", "run_prod") == ("scenario", "RUN_PROD")
def test_none_becomes_empty(self):
from src.core.auth.permission_utils import normalize_permission_pair
assert normalize_permission_pair(None, None) == ("", "")
def test_non_string_is_coerced(self):
from src.core.auth.permission_utils import normalize_permission_pair
assert normalize_permission_pair(123, 456) == ("123", "456")
def test_resource_with_colons_is_preserved(self):
from src.core.auth.permission_utils import normalize_permission_pair
assert normalize_permission_pair("a:b:c", " view ") == ("a:b:c", "VIEW")
class TestIsAdminUser:
"""The is_admin flag is the sole administrative authority; role NAME is never consulted."""
def test_flag_is_the_only_authority(self):
from src.core.auth.permission_utils import is_admin_user
assert is_admin_user(_user(_role(is_admin=True))) is True
assert is_admin_user(_user(_role(is_admin=False, permissions=[]))) is False
def test_role_named_admin_without_flag_is_not_admin(self):
from src.core.auth.permission_utils import is_admin_user
named = SimpleNamespace(name="Admin", is_admin=False, permissions=[])
assert is_admin_user(_user(named)) is False
def test_role_without_flag_attribute_is_not_admin(self):
from src.core.auth.permission_utils import is_admin_user
assert is_admin_user(_user(SimpleNamespace(name="Admin", permissions=[]))) is False
def test_roles_none_or_missing_is_not_admin(self):
from src.core.auth.permission_utils import is_admin_user
assert is_admin_user(SimpleNamespace(roles=None)) is False
assert is_admin_user(object()) is False
def test_numeric_flag_follows_truthiness(self):
from src.core.auth.permission_utils import is_admin_user
assert is_admin_user(_user(_role(is_admin=1))) is True
assert is_admin_user(_user(_role(is_admin=0))) is False
class TestUserHasPermissionNormalization:
"""Permission comparison is case/whitespace insensitive; empty requirement is not a grant."""
def test_action_comparison_is_case_insensitive(self):
user = _user(_role(permissions=[_perm("scenario", "RUN_PROD")]))
assert user_has_permission(user, "scenario", "run_prod") is True
def test_resource_whitespace_is_ignored(self):
user = _user(_role(permissions=[SimpleNamespace(resource="scenario", action="RUN")]))
assert user_has_permission(user, " scenario ", "run") is True
def test_role_named_admin_without_flag_does_not_grant(self):
named = SimpleNamespace(name="Admin", is_admin=False, permissions=[])
assert user_has_permission(_user(named), "tasks", "READ") is False
def test_empty_requirement_is_not_a_grant(self):
user = _user(_role(permissions=[_perm("tasks", "READ")]))
assert user_has_permission(user, "", "") is False
# #endregion Test.Core.PermissionUtils

View File

@@ -418,6 +418,38 @@ class TestRbacFiltering:
assert {item.task_id for item in result.items} == {"own", "other", "system"}
def test_admin_name_without_flag_is_not_admin(self):
"""RBAC-03: a role named 'Admin' without is_admin=true must NOT grant admin visibility."""
from src.models.report import ReportQuery
tm = MagicMock()
tm.get_all_tasks.return_value = self._task_set()
service = _make_service(task_manager=tm)
result = service.list_reports(ReportQuery(), current_user=self._user("user-1", "Admin", False))
assert {item.task_id for item in result.items} == {"own"}
def test_admin_flag_on_non_first_role_grants_all(self):
"""Edge: admin authority is the union of roles, not the first role's name."""
from src.models.report import ReportQuery
tm = MagicMock()
tm.get_all_tasks.return_value = self._task_set()
service = _make_service(task_manager=tm)
user = MagicMock()
user.id = "user-1"
viewer = MagicMock()
viewer.name = "viewer"
viewer.is_admin = False
super_user = MagicMock()
super_user.name = "Other"
super_user.is_admin = True
user.roles = [viewer, super_user]
result = service.list_reports(ReportQuery(), current_user=user)
assert {item.task_id for item in result.items} == {"own", "other", "system"}
def test_summary_uses_same_rbac_filter(self):
tm = MagicMock()
tm.get_all_tasks.return_value = self._task_set()

View File

@@ -22,9 +22,10 @@ def _make_user(**kwargs):
return user
# #endregion _make_user
# #region _make_role [C:1] [TYPE Function]
def _make_role(name, permissions=None):
def _make_role(name, permissions=None, is_admin=False):
role = MagicMock()
role.name = name
role.is_admin = is_admin
role.permissions = permissions or []
return role
# #endregion _make_role
@@ -119,7 +120,7 @@ class TestBuildSecuritySummary:
def test_admin_role_detected(self):
from src.services.security_badge_service import SecurityBadgeService
svc = SecurityBadgeService()
role = _make_role("Admin")
role = _make_role("Admin", is_admin=True)
user = _make_user(roles=[role], auth_source="database")
summary = svc.build_security_summary(user)
assert "Admin" in summary.roles
@@ -157,7 +158,7 @@ class TestBuildSecuritySummary:
def test_is_admin_allows_all(self):
from src.services.security_badge_service import SecurityBadgeService
perms = [_make_permission("dashboard", "read")]
role = _make_role("Admin", perms)
role = _make_role("Admin", perms, is_admin=True)
user = _make_user(roles=[role])
with patch("src.services.security_badge_service.discover_declared_permissions",
return_value={("dashboard", "READ"), ("dataset", "WRITE")}):

View File

@@ -81,6 +81,8 @@ async function buildApiError(response: Response): Promise<ApiError> {
// @INVARIANT API_EXEMPT_ENDPOINTS endpoints never trigger session expiry handling.
let _sessionExpiredHandler: (() => void) | null = null;
let _sessionExpiryInProgress = false;
/** Last dispatched error toast, used to deduplicate a burst of identical concurrent failures. */
let _lastErrorToast: { message: string; at: number } | null = null;
const API_EXEMPT_ENDPOINTS = new Set(['/auth/login', '/auth/login/adfs']);
@@ -124,9 +126,15 @@ function notifyApiError(error: ApiError): void {
// During session expiry flow, suppress 401 toasts (user is being redirected to login).
// Other error types (500, network) are still shown — they may be useful even during logout.
if (_sessionExpiryInProgress && error?.status === 401) return;
if (error?.status === 401) { notifications.error(`401 Unauthorized: ${error.message}`); return; }
if (error?.status >= 500) { notifications.error(`Server error (${error.status}): ${error.message}`); return; }
notifications.error(error.message);
let message: string;
if (error?.status === 401) message = `401 Unauthorized: ${error.message}`;
else if (error?.status === 403) message = `Access denied: ${error.message}`;
else if (error?.status >= 500) message = `Server error (${error.status}): ${error.message}`;
else message = error.message;
// Deduplicate identical concurrent failures (a single navigation can fan out many calls).
if (_lastErrorToast && _lastErrorToast.message === message && Date.now() - _lastErrorToast.at < 3000) return;
_lastErrorToast = { message, at: Date.now() };
notifications.error(message);
}
// #endregion Api.ApiModule.NotifyApiError
@@ -870,7 +878,7 @@ export const api = {
// @LAYER API
// @RELATION DEPENDS_ON -> [Api.ApiModule.FetchApi]
// @DATA_CONTRACT params -> { env_id, page?, page_size?, page_context?, apply_profile_default?, override_show_all?, search?, filters?: { title?, git_status?, test_status?, changed_on?, actor? } }
// @DATA_CONTRACT response -> { dashboards: { id, title, slug, last_modified, owners, git_status, last_task }[], total: number, page: number, page_size: number, total_pages: number, effective_profile_filter?: { applied: boolean, override_show_all: boolean, username?: string, match_logic?: string } }
// @DATA_CONTRACT response -> { dashboards: { id, title, slug, last_modified, owners, tags, git_status, last_task }[], total: number, page: number, page_size: number, total_pages: number, effective_profile_filter?: { applied: boolean, override_show_all: boolean, username?: string, match_logic?: string } }
getDashboards: <T = unknown>(envId: string, options: DashboardListParams = {}) => {
const params = new URLSearchParams({ env_id: envId });
if (options.search) params.append('search', options.search);

View File

@@ -235,6 +235,34 @@ describe('ApiModule — fetch wrappers (global fetch mock)', () => {
expect(notifications.error).toHaveBeenCalledWith('Server error (503): Service unavailable');
});
it('notifyApiError dispatches Access denied toast for 403', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 403,
json: () => Promise.resolve({ detail: 'Permission denied for admin:roles:WRITE' }),
} as Response);
const { notifications } = await import('$lib/toasts.svelte.js');
notifications.error.mockClear();
const { api } = await import('$lib/api.js');
await expect(api.fetchApi('/forbidden')).rejects.toMatchObject({ status: 403 });
expect(notifications.error).toHaveBeenCalledWith('Access denied: Permission denied for admin:roles:WRITE');
});
it('deduplicates a burst of identical error toasts', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 403,
json: () => Promise.resolve({ detail: 'Permission denied for scenario:run' }),
} as Response);
const { notifications } = await import('$lib/toasts.svelte.js');
notifications.error.mockClear();
const { api } = await import('$lib/api.js');
await Promise.allSettled([api.fetchApi('/a'), api.fetchApi('/b')]);
expect(notifications.error).toHaveBeenCalledTimes(1);
});
it('fetchApi passes signal to native fetch', async () => {
const abortController = new AbortController();
vi.mocked(fetch).mockResolvedValue({

View File

@@ -18,6 +18,7 @@ import {
interface User {
roles?: Array<{
name?: string;
is_admin?: boolean;
permissions?: Array<string | { resource?: string; action?: string }>;
}>;
}
@@ -37,11 +38,16 @@ describe("auth.permissions", () => {
});
});
it("detects admin role case-insensitively", () => {
const user: User = {
it("detects admin via the is_admin flag and ignores the role name", () => {
const flagged: User = {
roles: [{ name: "SuperUser", is_admin: true }],
};
expect(isAdminUser(flagged)).toBe(true);
const nameOnly: User = {
roles: [{ name: "ADMIN" }],
};
expect(isAdminUser(user)).toBe(true);
expect(isAdminUser(nameOnly)).toBe(false);
});
it("denies when user is absent and permission is required", () => {
@@ -102,7 +108,7 @@ describe("auth.permissions", () => {
it("always grants for admin role regardless of explicit permissions", () => {
const adminUser: User = {
roles: [{ name: "Admin", permissions: [] }],
roles: [{ name: "Admin", is_admin: true, permissions: [] }],
};
expect(hasPermission(adminUser, "admin:users", "READ")).toBe(true);
@@ -130,6 +136,17 @@ describe("auth.permissions", () => {
});
});
it("recognizes extended actions such as RUN_PROD as an action suffix", () => {
expect(normalizePermissionRequirement("scenario:RUN_PROD")).toEqual({
resource: "scenario",
action: "RUN_PROD",
});
expect(normalizePermissionRequirement("scenario:triage")).toEqual({
resource: "scenario",
action: "TRIAGE",
});
});
it("returns false for isAdminUser with null", () => {
expect(isAdminUser(null)).toBe(false);
});
@@ -345,6 +362,49 @@ describe("auth.permissions", () => {
};
expect(hasPermission(user, "tasks", "WRITE")).toBe(false);
});
// ── Orthogonal edge cases: normalization boundaries and permissive-string hardening ──
it("trims whitespace around a resource:action requirement", () => {
expect(normalizePermissionRequirement(" scenario : triage ")).toEqual({
resource: "scenario",
action: "TRIAGE",
});
});
it("does not let a bare-resource string permission satisfy WRITE", () => {
const user: User = { roles: [{ name: "Reader", permissions: ["datasets"] }] };
expect(hasPermission(user, "datasets", "READ")).toBe(true);
expect(hasPermission(user, "datasets", "WRITE")).toBe(false);
expect(hasPermission(user, "datasets", "EXECUTE")).toBe(false);
});
it("grants an empty requirement even without a user", () => {
expect(hasPermission(null, "")).toBe(true);
});
it("isAdminUser is false when roles is not an array", () => {
expect(
isAdminUser({ roles: { name: "Admin", is_admin: true } } as unknown as User),
).toBe(false);
});
it("isAdminUser follows boolean truthiness of the flag", () => {
expect(
isAdminUser({ roles: [{ name: "X", is_admin: 1 as unknown as boolean }] }),
).toBe(true);
expect(
isAdminUser({ roles: [{ name: "Admin", is_admin: 0 as unknown as boolean }] }),
).toBe(false);
});
it("matches permission object resources case/whitespace-insensitively", () => {
const user: User = {
roles: [{ name: "Runner", permissions: [{ resource: " scenario ", action: "run_prod" }] }],
};
expect(hasPermission(user, "scenario", "RUN_PROD")).toBe(true);
expect(hasPermission(user, "scenario", "RUN")).toBe(false);
});
});
// #endregion Tests.Permissions.PermissionsTestModule

View File

@@ -29,7 +29,7 @@ interface NormalizedPermission {
action: string;
}
const KNOWN_ACTIONS = new Set(["READ", "WRITE", "EXECUTE", "DELETE", "VIEW", "CREATE", "EDIT", "MANAGE", "APPROVE", "PREVIEW", "LAUNCH", "LAUNCH_PROD"]);
const KNOWN_ACTIONS = new Set(["READ", "WRITE", "EXECUTE", "DELETE", "VIEW", "CREATE", "EDIT", "MANAGE", "APPROVE", "PREVIEW", "LAUNCH", "LAUNCH_PROD", "RUN", "RUN_PROD", "PROD", "TRIGGER", "TRIAGE", "REFRESH", "RESTORE", "ARCHIVE", "VALIDATE"]);
function normalizeAction(action: string, fallback = "READ"): string {
const normalized = String(action || "").trim().toUpperCase();
@@ -53,7 +53,7 @@ export function normalizePermissionRequirement(permission: string, defaultAction
if (parts.length > 1) {
const tail = normalizeAction(parts[parts.length - 1], fallbackAction);
if (KNOWN_ACTIONS.has(tail)) {
const resource = parts.slice(0, -1).join(":");
const resource = parts.slice(0, -1).join(":").trim();
return { resource, action: tail };
}
}
@@ -65,12 +65,11 @@ export function normalizePermissionRequirement(permission: string, defaultAction
// #region Auth.Permissions.IsAdminUserFunction [TYPE Function]
// @BRIEF: Determine whether user has Admin role.
// @PRE: user can be null or partially populated.
// @POST: Returns true when at least one role has is_admin=true or name=="Admin" (case-insensitive).
// @POST: Returns true when at least one role has is_admin=true. Role NAME is never consulted —
// the flag is the single source of truth mirrored from the backend (dependencies.has_permission).
export function isAdminUser(user: User | null | undefined): boolean {
const roles = Array.isArray(user?.roles) ? user.roles : [];
return roles.some(
(role) => Boolean(role?.is_admin) || String(role?.name || "").trim().toLowerCase() === "admin",
);
return roles.some((role) => Boolean(role?.is_admin));
}
// #endregion Auth.Permissions.IsAdminUserFunction
@@ -94,10 +93,10 @@ export function hasPermission(user: User | null | undefined, requirement: string
const permissions = Array.isArray(role?.permissions) ? role.permissions : [];
for (const permission of permissions) {
if (typeof permission === "string") {
const normalized = normalizePermissionRequirement(
permission,
requiredAction,
);
// A stored string permission is either "resource:ACTION" or a bare resource.
// A bare resource means READ (never the requested action) — otherwise a
// read-granted string would silently satisfy any WRITE/EXECUTE requirement.
const normalized = normalizePermissionRequirement(permission, "READ");
if (
normalized.resource === resource &&
normalized.action === requiredAction

View File

@@ -24,14 +24,17 @@
import { hasPermission } from "$lib/auth/permissions.js";
import { log } from "$lib/cot-logger";
const { requiredPermission = null, fallbackPath = "/profile", children } = $props<{
const { requiredPermission = null, requiredAction = "READ", fallbackPath = "/profile", children } = $props<{
requiredPermission?: string | null;
requiredAction?: string;
fallbackPath?: string;
children?: import("svelte").Snippet;
}>();
let hasRouteAccess = $state(false);
let isCheckingAccess = $state(true);
// Seed with the initial requirement so the onMount verification is not duplicated by the effect.
let lastPermissionKey = $state(`${requiredPermission ?? ""}::${requiredAction}`);
// #region Auth.ProtectedRoute.VerifySessionAndAccess [C:3] [TYPE Function] [SEMANTICS auth,session,permission]
// @ingroup Components
@@ -52,6 +55,8 @@
return;
}
// Hydrate the identity when unknown; a cached identity is refreshed on window
// focus (refreshIdentity) so server-side role changes surface without a hard reload.
let currentUser = $auth.user;
if (!currentUser) {
auth.setLoading(true);
@@ -78,9 +83,9 @@
return;
}
if (requiredPermission && !hasPermission(currentUser, requiredPermission, "READ")) {
if (requiredPermission && !hasPermission(currentUser, requiredPermission, requiredAction)) {
log("ProtectedRoute.verifySessionAndAccess", "REFLECT", "Permission denied, redirecting to fallback",
{ requiredPermission, fallbackPath });
{ requiredPermission, requiredAction, fallbackPath });
hasRouteAccess = false;
await goto(fallbackPath);
return;
@@ -96,8 +101,32 @@
}
// #endregion Auth.ProtectedRoute.VerifySessionAndAccess
// Refresh the cached identity from the server when the tab regains focus so role changes
// are reflected without a hard reload; a transient failure keeps the last known identity.
async function refreshIdentity(): Promise<void> {
if (!$auth.token || !$auth.user) return;
try {
const user = await fetchApi("/auth/me");
if (user) auth.setUser(user);
} catch {
// Backend remains authoritative on each API call; keep the cached identity.
}
}
onMount(() => {
void verifySessionAndAccess();
const onFocus = () => { void refreshIdentity(); };
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
});
// Re-verify when the required permission for the current route changes (client navigation
// within the same layout does not remount this component).
$effect(() => {
const key = `${requiredPermission ?? ""}::${requiredAction}`;
if (key === lastPermissionKey) return;
lastPermissionKey = key;
void verifySessionAndAccess();
});
</script>

View File

@@ -12,7 +12,7 @@ import { describe, it, expect } from "vitest";
import { buildSidebarSections, getTogglableFeatureNodes } from "../sidebarNavigation";
import manifest from "../projectSections.json";
const adminUser = { roles: [{ name: "Admin", permissions: [] }] };
const adminUser = { roles: [{ name: "Admin", is_admin: true, permissions: [] }] };
// #region Tests.ProjectSections.Guard.NavFeatureIdsFromBuiltSections [TYPE Function]
// @BRIEF: Collect feature ids surfaced by buildSidebarSections for an admin with all flags enabled.

View File

@@ -5,7 +5,7 @@
// @RELATION DEPENDS_ON -> [EXT:frontend:SidebarNavigation]
import { describe, it, expect } from "vitest";
import { buildSidebarSections } from "../sidebarNavigation";
import { buildSidebarSections, permissionForPath } from "../sidebarNavigation";
interface I18nNavRecord {
[key: string]: string | undefined;
@@ -17,6 +17,7 @@ interface I18nState {
interface UserRole {
name?: string;
is_admin?: boolean;
permissions?: Array<string | { resource?: string; action?: string }>;
}
@@ -78,6 +79,7 @@ describe("sidebarNavigation", () => {
const user = makeUser([
{
name: "Admin",
is_admin: true,
permissions: [],
},
]);
@@ -95,7 +97,7 @@ describe("sidebarNavigation", () => {
});
it("places Health Center first in the Testing category", () => {
const user = makeUser([{ name: "Admin", permissions: [] }]);
const user = makeUser([{ name: "Admin", is_admin: true, permissions: [] }]);
const categories = buildSidebarSections(i18nState, user).flatMap((section) => section.categories);
const dashboards = categories.find((category) => category.id === "dashboards");
const testing = categories.find((category) => category.id === "dashboard_testing");
@@ -119,16 +121,15 @@ describe("sidebarNavigation", () => {
const categories = buildSidebarSections(i18nState, user).flatMap((section) => section.categories);
const categoryIds = categories.map((category) => category.id);
// migration requires plugin:migration → visible
// git/tools have no requiredPermission → always visible
// translation requires translate.job → hidden
// storage requires plugin:storage → hidden
// migration/dashboards/datasets require plugin:migration → visible
// Health Center subitem also requires plugin:migration → dashboard_testing visible
// git requires plugin:git, translation requires translate.job → hidden
// tools stays visible via the ungated debug/backups subitems
// admin requires admin:* → hidden
expect(categoryIds).toEqual([
"dashboards",
"datasets",
"migration",
"git",
"dashboard_testing",
"reports",
"tools",
@@ -153,6 +154,7 @@ describe("sidebarNavigation", () => {
const user = makeUser([
{
name: "Admin",
is_admin: true,
permissions: [],
},
]);
@@ -170,7 +172,7 @@ describe("sidebarNavigation", () => {
]);
});
it("shows git and tools for users without plugin permissions", () => {
it("hides git from users without the plugin:git permission, tools stays via ungated subitems", () => {
const user = makeUser([
{
name: "Basic",
@@ -181,18 +183,57 @@ describe("sidebarNavigation", () => {
const categories = buildSidebarSections(i18nState, user).flatMap((section) => section.categories);
const categoryIds = categories.map((category) => category.id);
// Profile is now in the sidebar footer, not a nav category
// Git and Tools have no requiredPermission → always visible
expect(categoryIds).toContain("git");
// Profile is now in the sidebar footer, not a nav category.
// git requires plugin:git EXECUTE → hidden; tools keeps ungated debug/backups subitems.
expect(categoryIds).not.toContain("git");
expect(categoryIds).toContain("tools");
});
// ── FE-02/FE-10: route-level permission resolution mirrors the sidebar registry ──
describe("permissionForPath", () => {
it("resolves the longest matching nav permission", () => {
expect(permissionForPath("/admin/settings/llm")).toEqual({ resource: "admin:settings", action: "READ" });
expect(permissionForPath("/admin/users")).toEqual({ resource: "admin:users", action: "READ" });
expect(permissionForPath("/dashboard-testing/automation")).toEqual({ resource: "scenario:automation", action: "READ" });
expect(permissionForPath("/git")).toEqual({ resource: "plugin:git", action: "EXECUTE" });
expect(permissionForPath("/tools/storage")).toEqual({ resource: "plugin:storage", action: "READ" });
});
it("matches nested paths under a nav base", () => {
expect(permissionForPath("/admin/settings/llm/extra")).toEqual({ resource: "admin:settings", action: "READ" });
});
it("returns null for ungated routes", () => {
expect(permissionForPath("/profile")).toBeNull();
expect(permissionForPath("/tools/debug")).toBeNull();
expect(permissionForPath("/agent")).toBeNull();
});
it("respects path-segment boundaries (no /admin prefix over-match)", () => {
expect(permissionForPath("/administrator")).toBeNull();
expect(permissionForPath("/admin")).toBeNull();
});
it("normalizes trailing slashes, query strings and empty paths", () => {
expect(permissionForPath("/git/")).toEqual({ resource: "plugin:git", action: "EXECUTE" });
expect(permissionForPath("/admin/users?tab=1")).toEqual({ resource: "admin:users", action: "READ" });
expect(permissionForPath("")).toBeNull();
});
it("still matches automation sub-paths", () => {
expect(permissionForPath("/dashboard-testing/automation/rules")).toEqual({
resource: "scenario:automation",
action: "READ",
});
});
});
// ── P0-2 fix: semantic design token compliance ──
describe("design token compliance", () => {
const rawColorRE = /\b(from|to|text|ring)-(blue|red|green|yellow|amber|orange|purple|pink|indigo|gray|slate|zinc|neutral|stone|sky|cyan|teal|emerald|lime|violet|fuchsia|rose)-\d{2,3}\b/;
it("all category tone values use semantic tokens, not raw Tailwind colors", () => {
const user = makeUser([{ name: "Admin", permissions: [] }]);
const user = makeUser([{ name: "Admin", is_admin: true, permissions: [] }]);
const sections = buildSidebarSections(i18nState, user);
for (const section of sections) {
@@ -206,7 +247,7 @@ describe("sidebarNavigation", () => {
});
it("all categories have valid tone strings", () => {
const user = makeUser([{ name: "Admin", permissions: [] }]);
const user = makeUser([{ name: "Admin", is_admin: true, permissions: [] }]);
const sections = buildSidebarSections(i18nState, user);
const categories = sections.flatMap((s) => s.categories);
@@ -225,7 +266,7 @@ describe("sidebarNavigation", () => {
// ── Fix 2: the dashboard-testing category makes the approval loop reachable ──
describe("dashboard testing category (Fix 2)", () => {
it("is shown to an admin with Health Center and four RBAC-gated subitems", () => {
const user = makeUser([{ name: "Admin", permissions: [] }]);
const user = makeUser([{ name: "Admin", is_admin: true, permissions: [] }]);
const categories = buildSidebarSections(i18nState, user).flatMap((s) => s.categories);
const testing = categories.find((c) => c.id === "dashboard_testing");
@@ -241,7 +282,7 @@ describe("sidebarNavigation", () => {
});
it("lives in the operations section (no new top-level section id)", () => {
const user = makeUser([{ name: "Admin", permissions: [] }]);
const user = makeUser([{ name: "Admin", is_admin: true, permissions: [] }]);
const sections = buildSidebarSections(i18nState, user);
expect(sections.map((s) => s.id)).toEqual(["resources", "operations", "system"]);
const operations = sections.find((s) => s.id === "operations");

View File

@@ -158,9 +158,11 @@ const SIDEBAR_SECTIONS: SectionDef[] = [
icon: "activity",
tone: "from-category-git-from to-category-git-to text-category-git-text ring-category-git-ring",
path: "/git",
requiredPermission: "plugin:git",
requiredAction: "EXECUTE",
requiredFeature: "git_integration",
subItems: [
{ labelKey: "git_repo_status", labelFallback: "Repository Status", path: "/git", requiredFeature: "git_integration" },
{ labelKey: "git_repo_status", labelFallback: "Repository Status", path: "/git", requiredPermission: "plugin:git", requiredAction: "EXECUTE", requiredFeature: "git_integration" },
],
},
{
@@ -193,7 +195,10 @@ const SIDEBAR_SECTIONS: SectionDef[] = [
{ labelKey: "dt_scenarios", path: "/dashboard-testing/scenarios", requiredPermission: "dashboard:testing", requiredAction: "READ" },
{ labelKey: "dt_runs", path: "/dashboard-testing/runs", requiredPermission: "scenario", requiredAction: "RUN" },
{ labelKey: "dt_analytics", path: "/dashboard-testing/analytics", requiredPermission: "scenario:result", requiredAction: "VIEW" },
{ labelKey: "dt_automation", path: "/dashboard-testing/automation", requiredPermission: "scenario:automation", requiredAction: "TRIGGER" },
// The automation page is readable by any scenario:automation READ holder (list/read
// endpoints); TRIGGER/PROD gate only the trigger controls inside the page. The nav must
// mirror the page-open gate, not the strongest action.
{ labelKey: "dt_automation", path: "/dashboard-testing/automation", requiredPermission: "scenario:automation", requiredAction: "READ" },
],
},
{
@@ -223,9 +228,9 @@ const SIDEBAR_SECTIONS: SectionDef[] = [
tone: "from-category-tools-from to-category-tools-to text-category-tools-text ring-category-tools-ring",
path: "/tools/mapper",
subItems: [
{ labelKey: "tools_mapper", path: "/tools/mapper", requiredFeature: "dataset_mapper" },
{ labelKey: "tools_mapper", path: "/tools/mapper", requiredPermission: "plugin:mapper", requiredAction: "EXECUTE", requiredFeature: "dataset_mapper" },
{ labelKey: "tools_debug", path: "/tools/debug", requiredFeature: "debug" },
{ labelKey: "tools_storage", path: "/tools/storage", requiredFeature: "storage_manager" },
{ labelKey: "tools_storage", path: "/tools/storage", requiredPermission: "plugin:storage", requiredAction: "READ", requiredFeature: "storage_manager" },
{ labelKey: "tools_backups", path: "/tools/backups", requiredFeature: "backup" },
],
},
@@ -401,4 +406,42 @@ export function getTogglableFeatureNodes(): TogglableFeatureNode[] {
}
// #endregion Layout.SidebarNavigation.GetTogglableFeatureNodesFunction
// #region Layout.SidebarNavigation.PermissionForPathFunction [C:3] [TYPE Function]
// @BRIEF Resolve the RBAC permission required by the current route path, from the same registry
// that builds the sidebar (single source: nav and route guard cannot drift).
// @PRE pathname is a SvelteKit route path (may include query-less path only).
// @POST Returns the longest matching { resource, action } with a requiredPermission, or null when
// the route is not permission-gated (unknown/ungated routes are not blocked here — the
// backend remains authoritative).
export interface RoutePermission {
resource: string;
action: string;
}
export function permissionForPath(pathname: string): RoutePermission | null {
const path = String(pathname || '').split('?')[0].replace(/\/+$/, '') || '/';
const candidates: Array<{ base: string; permission: RoutePermission }> = [];
for (const section of SIDEBAR_SECTIONS) {
for (const category of section.categories) {
const add = (base: string, resource?: string, action?: string): void => {
if (resource) candidates.push({ base, permission: { resource, action: action || 'READ' } });
};
add(category.path, category.requiredPermission, category.requiredAction);
for (const sub of category.subItems) {
add(sub.path, sub.requiredPermission, sub.requiredAction);
}
}
}
// Longest base first so /admin/settings/llm wins over /admin/settings.
candidates.sort((a, b) => b.base.length - a.base.length);
for (const candidate of candidates) {
const base = candidate.base.replace(/\/+$/, '') || '/';
if (path === base || path.startsWith(`${base}/`)) {
return candidate.permission;
}
}
return null;
}
// #endregion Layout.SidebarNavigation.PermissionForPathFunction
// #endregion Layout.SidebarNavigation.SidebarNavigationModule

View File

@@ -38,6 +38,7 @@
import { sidebarStore } from '$lib/stores/sidebar.svelte.js';
import { taskDrawerStore } from '$lib/stores/taskDrawer.svelte.js';
import { resetTraceId, log } from '$lib/cot-logger';
import { permissionForPath } from '$lib/components/layout/sidebarNavigation';
let { children } = $props();
// Reset trace_id on SPA navigation for clean per-page traces
@@ -69,6 +70,8 @@
let isAgentPage = $derived(page.url.pathname === '/agent');
let isExpanded = $derived(sidebarStore.value?.isExpanded ?? true);
let isProductionContext = $derived($isProductionContextStore);
/** Route-level permission derived from the same registry as the sidebar (single source). */
let routePermission = $derived(permissionForPath(page.url.pathname));
/** When task drawer is open on lg+, shell reserves right padding so content is not covered. */
let taskDrawerOpen = $derived(Boolean(taskDrawerStore.value?.isOpen));
</script>
@@ -89,7 +92,10 @@
{@render children?.()}
</div>
{:else}
<ProtectedRoute>
<ProtectedRoute
requiredPermission={routePermission?.resource ?? null}
requiredAction={routePermission?.action ?? 'READ'}
>
<SessionTimeoutGuard />
<!-- Sidebar -->

View File

@@ -99,8 +99,9 @@ vi.mock('$lib/auth/store.svelte.js', () => {
};
});
const mockHasPermission = vi.hoisted(() => vi.fn(() => true));
vi.mock('$lib/auth/permissions.js', () => ({
hasPermission: () => true,
hasPermission: mockHasPermission,
}));
// ── Admin service mock (hoisted) ──────────────────────────────────
@@ -143,6 +144,7 @@ describe('Admin Roles Page', () => {
beforeEach(() => {
vi.clearAllMocks();
mockHasPermission.mockReturnValue(true);
mockGetRoles.mockResolvedValue(mockRoles);
mockGetPermissions.mockResolvedValue(mockPermissions);
});
@@ -226,6 +228,25 @@ describe('Admin Roles Page', () => {
});
});
// #endregion AdminRolesPageTest.Describe.TestSaveSendsIsAdmin
// #region AdminRolesPageTest.Describe.TestReadOnlyDisablesWrites [C:2] [TYPE Test]
// @BRIEF FE-04: a read-only admin (no admin:roles WRITE) sees the page but write controls are disabled.
it('disables write controls when the user lacks admin:roles WRITE', async () => {
// READ still allowed (page renders); WRITE denied -> controls disabled.
mockHasPermission.mockImplementation((_user: unknown, _resource: string, action: string) => action !== 'WRITE');
render(AdminRolesPage);
await waitFor(() => {
expect(screen.getByText('Admin')).toBeTruthy();
});
const createButton = screen.getByRole('button', { name: 'Create Role' }) as HTMLButtonElement;
expect(createButton.disabled).toBe(true);
for (const edit of screen.getAllByText('Edit')) {
expect((edit.closest('button') as HTMLButtonElement).disabled).toBe(true);
}
});
// #endregion AdminRolesPageTest.Describe.TestReadOnlyDisablesWrites
});
// #endregion AdminRolesPageTest.Describe
// #endregion Tests.AdminRoles.AdminRolesPageTest

View File

@@ -22,8 +22,13 @@
import ProtectedRoute from '$lib/components/auth/ProtectedRoute.svelte';
import { adminService } from '../../../services/adminService';
import { log } from '$lib/cot-logger';
import { auth } from '$lib/auth/store.svelte.js';
import { hasPermission } from '$lib/auth/permissions.js';
// [/SECTION: IMPORTS]
/** Write capability mirrors the backend has_permission("admin:roles", "WRITE") gate. */
const canWrite = $derived(hasPermission($auth.user, 'admin:roles', 'WRITE'));
let roles = $state([]);
let permissions = $state([]);
let loading = $state(true);
@@ -74,6 +79,7 @@
* @post showModal is true, roleForm is reset.
*/
function openCreateModal() {
if (!canWrite) return;
log("AdminRolesPage", "REASON", "Opening create modal");
isEditing = false;
currentRoleId = null;
@@ -90,6 +96,7 @@
* @post showModal is true, roleForm is populated.
*/
function openEditModal(role) {
if (!canWrite) return;
log("AdminRolesPage", "REASON", "Opening edit modal", { roleId: role.id });
isEditing = true;
currentRoleId = role.id;
@@ -111,6 +118,7 @@
* @post Role is saved, modal closed, data reloaded.
*/
async function handleSaveRole() {
if (!canWrite) return;
log("AdminRolesPage", "REASON", "Saving role");
try {
if (isEditing) {
@@ -136,6 +144,7 @@
* @post Confirmation dialog shown, deletes on confirm.
*/
function promptDeleteRole(role) {
if (!canWrite) return;
deleteRoleTarget = role;
showDeleteRoleConfirm = true;
}
@@ -149,6 +158,7 @@
* @post Role is deleted, data reloaded.
*/
async function onConfirmDeleteRole() {
if (!canWrite) return;
const role = deleteRoleTarget;
deleteRoleTarget = null;
log("AdminRolesPage", "REASON", "Deleting role", { roleId: role.id });
@@ -174,6 +184,7 @@
<Button
variant="primary"
onclick={openCreateModal}
disabled={!canWrite}
>
{$t.admin.roles.create}
</Button>
@@ -217,8 +228,8 @@
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Button onclick={() => openEditModal(role)} variant="ghost" size="sm" class="text-primary mr-3">{$t.common.edit}</Button>
<Button onclick={() => promptDeleteRole(role)} variant="ghost" size="sm" class="text-destructive">{$t.common.delete}</Button>
<Button onclick={() => openEditModal(role)} variant="ghost" size="sm" class="text-primary mr-3" disabled={!canWrite}>{$t.common.edit}</Button>
<Button onclick={() => promptDeleteRole(role)} variant="ghost" size="sm" class="text-destructive" disabled={!canWrite}>{$t.common.delete}</Button>
</td>
</tr>
{/each}

View File

@@ -22,8 +22,13 @@
import ProtectedRoute from '$lib/components/auth/ProtectedRoute.svelte';
import { adminService } from '../../../services/adminService';
import { log } from '$lib/cot-logger';
import { auth } from '$lib/auth/store.svelte.js';
import { hasPermission } from '$lib/auth/permissions.js';
// [/SECTION: IMPORTS]
/** Write capability mirrors the backend has_permission("admin:settings", "WRITE") gate. */
const canWrite = $derived(hasPermission($auth.user, 'admin:settings', 'WRITE'));
let mappings = [];
let roles = [];
let loading = true;
@@ -93,6 +98,7 @@
* @relation CALLS -> [EXT:method:adminService.createADGroupMapping]
*/
async function handleCreateMapping() {
if (!canWrite) return;
log("AdminSettingsPage", "REASON", "Creating AD mapping");
// Guard Clause (@PRE)
@@ -156,6 +162,7 @@
* @relation CALLS -> [EXT:method:adminService.updateLoggingConfig]
*/
async function saveLoggingConfig() {
if (!canWrite) return;
log("AdminSettingsPage", "REASON", "Saving logging config");
loggingConfigSaving = true;
loggingConfigSaved = false;
@@ -188,7 +195,7 @@
<div class="container mx-auto p-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{$t.admin.settings.title}</h1>
<Button onclick={() => showCreateModal = true}>
<Button onclick={() => showCreateModal = true} disabled={!canWrite}>
{$t.admin.settings.add_mapping}
</Button>
</div>
@@ -295,7 +302,7 @@
<div class="flex items-center gap-3 pt-2">
<Button
onclick={saveLoggingConfig}
disabled={loggingConfigSaving}
disabled={!canWrite || loggingConfigSaving}
>
{loggingConfigSaving ? ($t.settings?.saving ) : ($t.admin?.settings?.save_configuration )}
</Button>

View File

@@ -20,6 +20,11 @@
import { notifications } from '$lib/toasts.svelte.js';
import { requestApi } from '../../../../lib/api';
import { log } from '$lib/cot-logger';
import { auth } from '$lib/auth/store.svelte.js';
import { hasPermission } from '$lib/auth/permissions.js';
/** Write capability mirrors the backend has_permission("admin:settings", "WRITE") gate. */
const canWrite = $derived(hasPermission($auth.user, 'admin:settings', 'WRITE'));
let providers = [];
let loading = true;
@@ -72,6 +77,7 @@
}
async function saveSettings() {
if (!canWrite) return;
savingPrompts = true;
try {
const current = await requestApi('/settings/consolidated');
@@ -249,7 +255,7 @@
<div class="mt-4 flex justify-end">
<Button
disabled={savingPrompts}
disabled={!canWrite || savingPrompts}
onclick={saveSettings}
>
{savingPrompts ? '...' : ($t.settings?.save_llm_prompts )}

View File

@@ -26,8 +26,13 @@
import ProtectedRoute from '$lib/components/auth/ProtectedRoute.svelte';
import { adminService } from '../../../services/adminService';
import { log } from '$lib/cot-logger';
import { auth } from '$lib/auth/store.svelte.js';
import { hasPermission } from '$lib/auth/permissions.js';
// [/SECTION: IMPORTS]
/** Write capability mirrors the backend has_permission("admin:users", "WRITE") gate. */
const canWrite = $derived(hasPermission($auth.user, 'admin:users', 'WRITE'));
let users = $state([]);
let roles = $state([]);
let loading = $state(true);
@@ -79,6 +84,7 @@
* @post showModal is true, isEditing is false, userForm is reset.
*/
function openCreateModal() {
if (!canWrite) return;
isEditing = false;
currentUserId = null;
userForm = { username: '', email: '', password: '', roles: [], is_active: true };
@@ -95,6 +101,7 @@
* @param {Object} user - The user object to edit.
*/
function openEditModal(user) {
if (!canWrite) return;
isEditing = true;
currentUserId = user.id;
userForm = {
@@ -119,6 +126,7 @@
* @relation CALLS -> [EXT:method:adminService.updateUser]
*/
async function handleSaveUser() {
if (!canWrite) return;
log("AdminUsersPage", "REASON", "Saving user");
try {
if (isEditing) {
@@ -147,7 +155,7 @@
* @param {Object} user - The user to delete.
*/
function promptDeleteUser(user) {
if (deletingUserId) return;
if (!canWrite || deletingUserId) return;
deleteUserTarget = user;
showDeleteUserConfirm = true;
}
@@ -163,6 +171,7 @@
* @relation CALLS -> [EXT:method:adminService.deleteUser]
*/
async function onConfirmDeleteUser() {
if (!canWrite) return;
const user = deleteUserTarget;
deleteUserTarget = null;
if (deletingUserId) return;
@@ -192,6 +201,7 @@
<Button
variant="primary"
onclick={openCreateModal}
disabled={!canWrite}
>
{$t.admin.users.create}
</Button>
@@ -245,11 +255,11 @@
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Button onclick={() => openEditModal(user)} variant="ghost" size="sm" class="text-primary mr-3" disabled={deletingUserId === user.id}>{$t.common.edit}</Button>
<Button onclick={() => openEditModal(user)} variant="ghost" size="sm" class="text-primary mr-3" disabled={!canWrite || deletingUserId === user.id}>{$t.common.edit}</Button>
<Button
onclick={() => promptDeleteUser(user)}
variant="ghost" size="sm" class="text-destructive"
disabled={deletingUserId === user.id}
disabled={!canWrite || deletingUserId === user.id}
>
{deletingUserId === user.id ? ($t.common.deleting ) : $t.common.delete}
</Button>

View File

@@ -17,6 +17,11 @@
import VisualDagCanvas from "$lib/components/scenario-editor/VisualDagCanvas.svelte";
import AgentActionPanel from "$lib/components/scenario-editor/AgentActionPanel.svelte";
import { ScenarioEditorModel } from "$lib/models/ScenarioEditorModel.svelte";
import { auth } from "$lib/auth/store.svelte.js";
import { hasPermission } from "$lib/auth/permissions.js";
/** Write capability mirrors the backend has_permission("scenario", "EDIT") gate. */
const canEdit = $derived(hasPermission($auth.user, "scenario", "EDIT"));
const model = new ScenarioEditorModel();
const scenarioId = $derived(page.params.id);
@@ -36,11 +41,13 @@
});
function updateAssertion(value: { logical_step_id: string; comparison: "exact" | "absolute" | "relative" | "range" | "row_set"; baseline_ref: string; threshold?: number }): void {
if (!canEdit) return;
pendingOps = [...pendingOps.filter((op) => !(op.op === "set_assertion" && op.logical_step_id === value.logical_step_id)), { op: "set_assertion", ...value }];
void model.applyOps(pendingOps);
}
function updateDependencies(next: { source: string; target: string }[]): void {
if (!canEdit) return;
const added = next.find((edge) => !dependencies.some((current) => current.source === edge.source && current.target === edge.target));
if (!added) return;
pendingOps = [...pendingOps, { op: "set_dependency", logical_step_id: added.source, target_logical_step_id: added.target, action: "add" }];
@@ -48,6 +55,7 @@
}
async function saveDirectDraft(): Promise<void> {
if (!canEdit) return;
await model.saveDraft();
if (model.savedRevision) {
pendingOps = [];
@@ -112,7 +120,7 @@
<section class="rounded-lg border border-warning bg-warning-light p-4">
<h2 class="font-semibold text-text">{dt.editor_ready_title}</h2>
<p class="mt-1 break-all text-xs text-text-muted">Digest {model.draft.digest.slice(0, 12)}…</p>
<button class="mt-3 rounded bg-primary px-3 py-2 text-sm text-white" disabled={model.loading} onclick={() => void saveDirectDraft()}>{dt.editor_save}</button>
<button class="mt-3 rounded bg-primary px-3 py-2 text-sm text-white" disabled={model.loading || !canEdit} onclick={() => void saveDirectDraft()}>{dt.editor_save}</button>
</section>
{/if}
{#if model.savedRevision}

View File

@@ -20,6 +20,12 @@
import TerminalReasonBanner from "$lib/components/scenario-run/TerminalReasonBanner.svelte";
import { resolveTerminalReason, terminalCodeFor } from "$lib/components/scenario-run/terminal-reasons";
import type { ScenarioStepRun } from "$lib/types/scenario-run";
import { auth } from "$lib/auth/store.svelte.js";
import { hasPermission } from "$lib/auth/permissions.js";
/** Capabilities mirror the backend gates: cancel/checkpoint = scenario RUN, approval = RUN_PROD. */
const canRun = $derived(hasPermission($auth.user, "scenario", "RUN"));
const canRunProd = $derived(hasPermission($auth.user, "scenario", "RUN_PROD"));
const model = new RunMonitorModel();
const runId = $derived(page.params.runId);
@@ -82,7 +88,7 @@
</div>
{/if}
{#if ["queued", "running", "waiting_human", "pending_approval"].includes(model.run.status)}
<Button variant="destructive" disabled={model.busy} onclick={() => model.cancel(runId)}>{dt.monitor_cancel}</Button>
<Button variant="destructive" disabled={model.busy || !canRun} onclick={() => model.cancel(runId)}>{dt.monitor_cancel}</Button>
{/if}
<div class="grid gap-6 lg:grid-cols-[1fr_340px]">
<section class="space-y-4">
@@ -95,12 +101,12 @@
{/if}
</section>
<aside class="space-y-4">
{#if model.run.status === "pending_approval"}
{#if model.run.status === "pending_approval" && canRunProd}
<ApprovalDecisionPanel
busy={model.busy}
ondecide={(decision, comment) => void model.decideApproval(runId, decision, comment)}
/>
{:else if model.state === "waiting_human"}
{:else if model.state === "waiting_human" && canRun}
<HumanCheckpointPanel
busy={model.busy}
evidenceRefs={waitingStep?.artifact_refs ?? []}

View File

@@ -10,8 +10,13 @@ import { page } from "$app/state";
import { api } from "$lib/api";
import DetailPage from "../+page.svelte";
// The route gates human-checkpoint controls on scenario RUN; default to granted.
const mockHasPermission = vi.hoisted(() => vi.fn(() => true));
vi.mock("$lib/auth/permissions.js", () => ({ hasPermission: mockHasPermission }));
beforeEach(() => {
page.params = { id: "scenario-1", runId: "run-1" };
mockHasPermission.mockReturnValue(true);
});
afterEach(() => {
@@ -64,6 +69,16 @@ describe("RunMonitor route (run.ux)", () => {
expect(body.disposition).toBe("false_positive");
});
it("hides the human checkpoint panel when the user lacks scenario RUN", async () => {
mockHasPermission.mockImplementation((_user: unknown, _resource: string, action: string) => action !== "RUN");
vi.spyOn(api, "fetchApi").mockResolvedValue(waitingRun);
render(DetailPage);
// The run loads (header shows the real status) but the RUN-gated checkpoint controls are absent.
await screen.findAllByText(/waiting_human/);
expect(screen.queryByText("Требуется решение аналитика")).toBeNull();
});
it("renders the final result with counts and provenance (T012)", async () => {
vi.spyOn(api, "fetchApi").mockResolvedValueOnce(terminalRun).mockResolvedValueOnce(result);
render(DetailPage);