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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")}):
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 )}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ?? []}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user