Compare commits

...

13 Commits

Author SHA1 Message Date
SubashMohan
acfa30f865 feat(permissions): update permission checks for creating personal access tokens and enhance UI with permission validation 2026-04-11 18:15:42 +05:30
SubashMohan
606ab55d73 feat(permissions): update permission identifiers for service accounts and bots management 2026-04-11 17:51:50 +05:30
SubashMohan
6223ba531b feat(permissions): update permission checks for query history access 2026-04-11 17:45:41 +05:30
SubashMohan
6f6e64ad63 feat(permissions): enhance document set management with refined error handling and permission checks 2026-04-10 10:35:29 +05:30
SubashMohan
a05f09faa2 feat(permissions): update permission checks to use ADD_AGENTS for user actions and enhance agent creation button with permission validation 2026-04-09 17:56:13 +05:30
SubashMohan
5912f632a3 feat(permissions): add READ_DOCUMENT_SETS permission and update permission checks for document sets and personas 2026-04-09 17:15:32 +05:30
SubashMohan
3e5cfa66d1 refactor(permissions): remove admin role check from effective permissions function 2026-04-09 15:25:09 +05:30
SubashMohan
b2f5eb3ec7 feat(permissions): add READ_USER_GROUPS permission and update user group access checks 2026-04-09 15:19:57 +05:30
SubashMohan
0ab2b8065d feat(permissions): enhance permission handling with effective permissions and admin checks 2026-04-09 13:16:22 +05:30
SubashMohan
4c304bf393 feat(permissions): add has_permission function and update permission checks to use MANAGE_LLMS 2026-04-09 11:56:27 +05:30
SubashMohan
cef5caa8b1 feat(icons): add SvgCreateAgent and SvgManageAgent components and update icon mapping 2026-04-08 18:16:47 +05:30
SubashMohan
f7b8650d5c feat(permissions): implement permission registry endpoint and update related models 2026-04-08 17:58:00 +05:30
SubashMohan
df532aa87d feat(user-group): implement bulk permission setting for user groups 2026-04-08 17:38:09 +05:30
40 changed files with 1256 additions and 611 deletions

View File

@@ -996,3 +996,72 @@ def set_group_permission__no_commit(
db_session.flush()
recompute_permissions_for_group__no_commit(group_id, db_session)
def set_group_permissions_bulk__no_commit(
group_id: int,
desired_permissions: set[Permission],
granted_by: UUID,
db_session: Session,
) -> list[Permission]:
"""Set the full desired permission state for a group in one pass.
Enables permissions in `desired_permissions`, disables any toggleable
permission not in the set. Non-toggleable permissions are ignored.
Calls recompute once at the end. Does NOT commit.
Returns the resulting list of enabled permissions.
"""
existing_grants = (
db_session.execute(
select(PermissionGrant)
.where(PermissionGrant.group_id == group_id)
.with_for_update()
)
.scalars()
.all()
)
grant_map: dict[Permission, PermissionGrant] = {
g.permission: g for g in existing_grants
}
# Enable desired permissions
for perm in desired_permissions:
existing = grant_map.get(perm)
if existing is not None:
if existing.is_deleted:
existing.is_deleted = False
existing.granted_by = granted_by
existing.granted_at = func.now()
else:
db_session.add(
PermissionGrant(
group_id=group_id,
permission=perm,
grant_source=GrantSource.USER,
granted_by=granted_by,
)
)
# Disable toggleable permissions not in the desired set
for perm, grant in grant_map.items():
if perm not in desired_permissions and not grant.is_deleted:
grant.is_deleted = True
db_session.flush()
recompute_permissions_for_group__no_commit(group_id, db_session)
# Return the resulting enabled set
return [
g.permission
for g in db_session.execute(
select(PermissionGrant).where(
PermissionGrant.group_id == group_id,
PermissionGrant.is_deleted.is_(False),
)
)
.scalars()
.all()
]

View File

@@ -154,7 +154,7 @@ def snapshot_from_chat_session(
@router.get("/admin/chat-sessions")
def admin_get_chat_sessions(
user_id: UUID,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.READ_QUERY_HISTORY)),
db_session: Session = Depends(get_session),
) -> ChatSessionsResponse:
# we specifically don't allow this endpoint if "anonymized" since
@@ -197,7 +197,7 @@ def get_chat_session_history(
feedback_type: QAFeedbackType | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.READ_QUERY_HISTORY)),
db_session: Session = Depends(get_session),
) -> PaginatedReturn[ChatSessionMinimal]:
ensure_query_history_is_enabled(disallowed=[QueryHistoryType.DISABLED])
@@ -235,7 +235,7 @@ def get_chat_session_history(
@router.get("/admin/chat-session-history/{chat_session_id}")
def get_chat_session_admin(
chat_session_id: UUID,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.READ_QUERY_HISTORY)),
db_session: Session = Depends(get_session),
) -> ChatSessionSnapshot:
ensure_query_history_is_enabled(disallowed=[QueryHistoryType.DISABLED])
@@ -270,7 +270,7 @@ def get_chat_session_admin(
@router.get("/admin/query-history/list")
def list_all_query_history_exports(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.READ_QUERY_HISTORY)),
db_session: Session = Depends(get_session),
) -> list[QueryHistoryExport]:
ensure_query_history_is_enabled(disallowed=[QueryHistoryType.DISABLED])
@@ -298,7 +298,7 @@ def list_all_query_history_exports(
@router.post("/admin/query-history/start-export", tags=PUBLIC_API_TAGS)
def start_query_history_export(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.READ_QUERY_HISTORY)),
db_session: Session = Depends(get_session),
start: datetime | None = None,
end: datetime | None = None,
@@ -345,7 +345,7 @@ def start_query_history_export(
@router.get("/admin/query-history/export-status", tags=PUBLIC_API_TAGS)
def get_query_history_export_status(
request_id: str,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.READ_QUERY_HISTORY)),
db_session: Session = Depends(get_session),
) -> dict[str, str]:
ensure_query_history_is_enabled(disallowed=[QueryHistoryType.DISABLED])
@@ -379,7 +379,7 @@ def get_query_history_export_status(
@router.get("/admin/query-history/download", tags=PUBLIC_API_TAGS)
def download_query_history_csv(
request_id: str,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.READ_QUERY_HISTORY)),
db_session: Session = Depends(get_session),
) -> StreamingResponse:
ensure_query_history_is_enabled(disallowed=[QueryHistoryType.DISABLED])

View File

@@ -13,20 +13,21 @@ from ee.onyx.db.user_group import fetch_user_groups_for_user
from ee.onyx.db.user_group import insert_user_group
from ee.onyx.db.user_group import prepare_user_group_for_deletion
from ee.onyx.db.user_group import rename_user_group
from ee.onyx.db.user_group import set_group_permission__no_commit
from ee.onyx.db.user_group import set_group_permissions_bulk__no_commit
from ee.onyx.db.user_group import update_user_curator_relationship
from ee.onyx.db.user_group import update_user_group
from ee.onyx.server.user_group.models import AddUsersToUserGroupRequest
from ee.onyx.server.user_group.models import BulkSetPermissionsRequest
from ee.onyx.server.user_group.models import MinimalUserGroupSnapshot
from ee.onyx.server.user_group.models import SetCuratorRequest
from ee.onyx.server.user_group.models import SetPermissionRequest
from ee.onyx.server.user_group.models import SetPermissionResponse
from ee.onyx.server.user_group.models import UpdateGroupAgentsRequest
from ee.onyx.server.user_group.models import UserGroup
from ee.onyx.server.user_group.models import UserGroupCreate
from ee.onyx.server.user_group.models import UserGroupRename
from ee.onyx.server.user_group.models import UserGroupUpdate
from onyx.auth.permissions import NON_TOGGLEABLE_PERMISSIONS
from onyx.auth.permissions import PERMISSION_REGISTRY
from onyx.auth.permissions import PermissionRegistryEntry
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_curator_or_admin_user
from onyx.configs.app_configs import DISABLE_VECTOR_DB
@@ -48,24 +49,15 @@ router = APIRouter(prefix="/manage", tags=PUBLIC_API_TAGS)
@router.get("/admin/user-group")
def list_user_groups(
include_default: bool = False,
user: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.READ_USER_GROUPS)),
db_session: Session = Depends(get_session),
) -> list[UserGroup]:
if user.role == UserRole.ADMIN:
user_groups = fetch_user_groups(
db_session,
only_up_to_date=False,
eager_load_for_snapshot=True,
include_default=include_default,
)
else:
user_groups = fetch_user_groups_for_user(
db_session=db_session,
user_id=user.id,
only_curator_groups=user.role == UserRole.CURATOR,
eager_load_for_snapshot=True,
include_default=include_default,
)
user_groups = fetch_user_groups(
db_session,
only_up_to_date=False,
eager_load_for_snapshot=True,
include_default=include_default,
)
return [UserGroup.from_model(user_group) for user_group in user_groups]
@@ -92,6 +84,13 @@ def list_minimal_user_groups(
]
@router.get("/admin/permissions/registry")
def get_permission_registry(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> list[PermissionRegistryEntry]:
return PERMISSION_REGISTRY
@router.get("/admin/user-group/{user_group_id}/permissions")
def get_user_group_permissions(
user_group_id: int,
@@ -102,37 +101,39 @@ def get_user_group_permissions(
if group is None:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "User group not found")
return [
grant.permission for grant in group.permission_grants if not grant.is_deleted
grant.permission
for grant in group.permission_grants
if not grant.is_deleted and grant.permission not in NON_TOGGLEABLE_PERMISSIONS
]
@router.put("/admin/user-group/{user_group_id}/permissions")
def set_user_group_permission(
def set_user_group_permissions(
user_group_id: int,
request: SetPermissionRequest,
request: BulkSetPermissionsRequest,
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> SetPermissionResponse:
) -> list[Permission]:
group = fetch_user_group(db_session, user_group_id)
if group is None:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "User group not found")
if request.permission in NON_TOGGLEABLE_PERMISSIONS:
non_toggleable = [p for p in request.permissions if p in NON_TOGGLEABLE_PERMISSIONS]
if non_toggleable:
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
f"Permission '{request.permission}' cannot be toggled via this endpoint",
f"Permissions {non_toggleable} cannot be toggled via this endpoint",
)
set_group_permission__no_commit(
result = set_group_permissions_bulk__no_commit(
group_id=user_group_id,
permission=request.permission,
enabled=request.enabled,
desired_permissions=set(request.permissions),
granted_by=user.id,
db_session=db_session,
)
db_session.commit()
return SetPermissionResponse(permission=request.permission, enabled=request.enabled)
return result
@router.post("/admin/user-group")

View File

@@ -132,3 +132,7 @@ class SetPermissionRequest(BaseModel):
class SetPermissionResponse(BaseModel):
permission: Permission
enabled: bool
class BulkSetPermissionsRequest(BaseModel):
permissions: list[Permission]

View File

@@ -11,6 +11,8 @@ from collections.abc import Coroutine
from typing import Any
from fastapi import Depends
from pydantic import BaseModel
from pydantic import field_validator
from onyx.auth.users import current_user
from onyx.db.enums import Permission
@@ -29,14 +31,13 @@ IMPLIED_PERMISSIONS: dict[str, set[str]] = {
Permission.MANAGE_AGENTS.value: {
Permission.ADD_AGENTS.value,
Permission.READ_AGENTS.value,
Permission.READ_DOCUMENT_SETS.value,
},
Permission.MANAGE_DOCUMENT_SETS.value: {
Permission.READ_DOCUMENT_SETS.value,
Permission.READ_CONNECTORS.value,
},
Permission.ADD_CONNECTORS.value: {Permission.READ_CONNECTORS.value},
Permission.MANAGE_CONNECTORS.value: {
Permission.ADD_CONNECTORS.value,
Permission.READ_CONNECTORS.value,
},
Permission.MANAGE_USER_GROUPS.value: {
@@ -44,6 +45,11 @@ IMPLIED_PERMISSIONS: dict[str, set[str]] = {
Permission.READ_DOCUMENT_SETS.value,
Permission.READ_AGENTS.value,
Permission.READ_USERS.value,
Permission.READ_USER_GROUPS.value,
},
Permission.MANAGE_LLMS.value: {
Permission.READ_USER_GROUPS.value,
Permission.READ_AGENTS.value,
},
}
@@ -58,10 +64,129 @@ NON_TOGGLEABLE_PERMISSIONS: frozenset[Permission] = frozenset(
Permission.READ_DOCUMENT_SETS,
Permission.READ_AGENTS,
Permission.READ_USERS,
Permission.READ_USER_GROUPS,
}
)
class PermissionRegistryEntry(BaseModel):
"""A UI-facing permission row served by GET /admin/permissions/registry.
The field_validator ensures non-toggleable permissions (BASIC_ACCESS,
FULL_ADMIN_PANEL_ACCESS, READ_*) can never appear in the registry.
"""
id: str
display_name: str
description: str
permissions: list[Permission]
group: int
@field_validator("permissions")
@classmethod
def must_be_toggleable(cls, v: list[Permission]) -> list[Permission]:
for p in v:
if p in NON_TOGGLEABLE_PERMISSIONS:
raise ValueError(
f"Permission '{p.value}' is not toggleable and "
"cannot be included in the permission registry"
)
return v
# Registry of toggleable permissions exposed to the admin UI.
# Single source of truth for display names, descriptions, grouping,
# and which backend tokens each UI row controls.
# The frontend fetches this via GET /admin/permissions/registry
# and only adds icon mapping locally.
PERMISSION_REGISTRY: list[PermissionRegistryEntry] = [
# Group 0 — System Configuration
PermissionRegistryEntry(
id="manage_llms",
display_name="Manage LLMs",
description="Add and update configurations for language models (LLMs).",
permissions=[Permission.MANAGE_LLMS],
group=0,
),
PermissionRegistryEntry(
id="manage_connectors_and_document_sets",
display_name="Manage Connectors & Document Sets",
description="Add and update connectors and document sets.",
permissions=[
Permission.MANAGE_CONNECTORS,
Permission.MANAGE_DOCUMENT_SETS,
],
group=0,
),
PermissionRegistryEntry(
id="manage_actions",
display_name="Manage Actions",
description="Add and update custom tools and MCP/OpenAPI actions.",
permissions=[Permission.MANAGE_ACTIONS],
group=0,
),
# Group 1 — User & Access Management
PermissionRegistryEntry(
id="manage_groups",
display_name="Manage Groups",
description="Add and update user groups.",
permissions=[Permission.MANAGE_USER_GROUPS],
group=1,
),
PermissionRegistryEntry(
id="manage_service_accounts",
display_name="Manage Service Accounts",
description="Add and update service accounts and their API keys.",
permissions=[Permission.MANAGE_SERVICE_ACCOUNT_API_KEYS],
group=1,
),
PermissionRegistryEntry(
id="manage_bots",
display_name="Manage Slack/Discord Bots",
description="Add and update Onyx integrations with Slack or Discord.",
permissions=[Permission.MANAGE_BOTS],
group=1,
),
# Group 2 — Agents
PermissionRegistryEntry(
id="create_agents",
display_name="Create Agents",
description="Create and edit the user's own agents.",
permissions=[Permission.ADD_AGENTS],
group=2,
),
PermissionRegistryEntry(
id="manage_agents",
display_name="Manage Agents",
description="View and update all public and shared agents in the organization.",
permissions=[Permission.MANAGE_AGENTS],
group=2,
),
# Group 3 — Monitoring & Tokens
PermissionRegistryEntry(
id="view_agent_analytics",
display_name="View Agent Analytics",
description="View analytics for agents the group can manage.",
permissions=[Permission.READ_AGENT_ANALYTICS],
group=3,
),
PermissionRegistryEntry(
id="view_query_history",
display_name="View Query History",
description="View query history of everyone in the organization.",
permissions=[Permission.READ_QUERY_HISTORY],
group=3,
),
PermissionRegistryEntry(
id="create_user_access_token",
display_name="Create User Access Token",
description="Add and update the user's personal access tokens.",
permissions=[Permission.CREATE_USER_API_KEYS],
group=3,
),
]
def resolve_effective_permissions(granted: set[str]) -> set[str]:
"""Expand granted permissions with their implied permissions.
@@ -83,7 +208,12 @@ def resolve_effective_permissions(granted: set[str]) -> set[str]:
def get_effective_permissions(user: User) -> set[Permission]:
"""Read granted permissions from the column and expand implied permissions."""
"""Read granted permissions from the column and expand implied permissions.
Admin-role users always receive all permissions regardless of the JSONB
column, maintaining backward compatibility with role-based access control.
"""
granted: set[Permission] = set()
for p in user.effective_permissions:
try:
@@ -96,6 +226,11 @@ def get_effective_permissions(user: User) -> set[Permission]:
return {Permission(p) for p in expanded}
def has_permission(user: User, permission: Permission) -> bool:
"""Check whether *user* holds *permission* (directly or via implication/admin override)."""
return permission in get_effective_permissions(user)
def require_permission(
required: Permission,
) -> Callable[..., Coroutine[Any, Any, User]]:

View File

@@ -4,7 +4,6 @@ from uuid import UUID
from sqlalchemy import and_
from sqlalchemy import delete
from sqlalchemy import exists
from sqlalchemy import func
from sqlalchemy import or_
from sqlalchemy import Select
@@ -13,22 +12,21 @@ from sqlalchemy.orm import aliased
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from onyx.auth.permissions import has_permission
from onyx.configs.app_configs import DISABLE_VECTOR_DB
from onyx.db.connector_credential_pair import get_cc_pair_groups_for_ids
from onyx.db.connector_credential_pair import get_connector_credential_pairs
from onyx.db.enums import AccessType
from onyx.db.enums import ConnectorCredentialPairStatus
from onyx.db.enums import Permission
from onyx.db.federated import create_federated_connector_document_set_mapping
from onyx.db.models import ConnectorCredentialPair
from onyx.db.models import Document
from onyx.db.models import DocumentByConnectorCredentialPair
from onyx.db.models import DocumentSet as DocumentSetDBModel
from onyx.db.models import DocumentSet__ConnectorCredentialPair
from onyx.db.models import DocumentSet__UserGroup
from onyx.db.models import FederatedConnector__DocumentSet
from onyx.db.models import User
from onyx.db.models import User__UserGroup
from onyx.db.models import UserRole
from onyx.server.features.document_set.models import DocumentSetCreationRequest
from onyx.server.features.document_set.models import DocumentSetUpdateRequest
from onyx.utils.logger import setup_logger
@@ -38,54 +36,16 @@ logger = setup_logger()
def _add_user_filters(stmt: Select, user: User, get_editable: bool = True) -> Select:
if user.role == UserRole.ADMIN:
# MANAGE → always return all
if has_permission(user, Permission.MANAGE_DOCUMENT_SETS):
return stmt
stmt = stmt.distinct()
DocumentSet__UG = aliased(DocumentSet__UserGroup)
User__UG = aliased(User__UserGroup)
"""
Here we select cc_pairs by relation:
User -> User__UserGroup -> DocumentSet__UserGroup -> DocumentSet
"""
stmt = stmt.outerjoin(DocumentSet__UG).outerjoin(
User__UserGroup,
User__UserGroup.user_group_id == DocumentSet__UG.user_group_id,
)
"""
Filter DocumentSets by:
- if the user is in the user_group that owns the DocumentSet
- if the user is not a global_curator, they must also have a curator relationship
to the user_group
- if editing is being done, we also filter out DocumentSets that are owned by groups
that the user isn't a curator for
- if we are not editing, we show all DocumentSets in the groups the user is a curator
for (as well as public DocumentSets)
"""
# Anonymous users only see public DocumentSets
if user.is_anonymous:
where_clause = DocumentSetDBModel.is_public == True # noqa: E712
return stmt.where(where_clause)
where_clause = User__UserGroup.user_id == user.id
if user.role == UserRole.CURATOR and get_editable:
where_clause &= User__UserGroup.is_curator == True # noqa: E712
if get_editable:
user_groups = select(User__UG.user_group_id).where(User__UG.user_id == user.id)
if user.role == UserRole.CURATOR:
user_groups = user_groups.where(User__UG.is_curator == True) # noqa: E712
where_clause &= (
~exists()
.where(DocumentSet__UG.document_set_id == DocumentSetDBModel.id)
.where(~DocumentSet__UG.user_group_id.in_(user_groups))
.correlate(DocumentSetDBModel)
)
where_clause |= DocumentSetDBModel.user_id == user.id
else:
where_clause |= DocumentSetDBModel.is_public == True # noqa: E712
return stmt.where(where_clause)
# READ → return all when reading, nothing when editing
if has_permission(user, Permission.READ_DOCUMENT_SETS):
if get_editable:
return stmt.where(False)
return stmt
# No permission → return nothing
return stmt.where(False)
def _delete_document_set_cc_pairs__no_commit(

View File

@@ -366,12 +366,12 @@ class Permission(str, PyEnum):
READ_DOCUMENT_SETS = "read:document_sets"
READ_AGENTS = "read:agents"
READ_USERS = "read:users"
READ_USER_GROUPS = "read:user_groups"
# Add / Manage pairs
ADD_AGENTS = "add:agents"
MANAGE_AGENTS = "manage:agents"
MANAGE_DOCUMENT_SETS = "manage:document_sets"
ADD_CONNECTORS = "add:connectors"
MANAGE_CONNECTORS = "manage:connectors"
MANAGE_LLMS = "manage:llms"
@@ -381,8 +381,8 @@ class Permission(str, PyEnum):
READ_QUERY_HISTORY = "read:query_history"
MANAGE_USER_GROUPS = "manage:user_groups"
CREATE_USER_API_KEYS = "create:user_api_keys"
CREATE_SERVICE_ACCOUNT_API_KEYS = "create:service_account_api_keys"
CREATE_SLACK_DISCORD_BOTS = "create:slack_discord_bots"
MANAGE_SERVICE_ACCOUNT_API_KEYS = "manage:service_account_api_keys"
MANAGE_BOTS = "manage:bots"
# Override — any permission check passes
FULL_ADMIN_PANEL_ACCESS = "admin"

View File

@@ -16,12 +16,12 @@ from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from onyx.access.hierarchy_access import get_user_external_group_ids
from onyx.auth.schemas import UserRole
from onyx.configs.app_configs import CURATORS_CANNOT_VIEW_OR_EDIT_NON_OWNED_ASSISTANTS
from onyx.auth.permissions import has_permission
from onyx.configs.constants import DEFAULT_PERSONA_ID
from onyx.configs.constants import NotificationType
from onyx.db.constants import SLACK_BOT_PERSONA_PREFIX
from onyx.db.document_access import get_accessible_documents_by_ids
from onyx.db.enums import Permission
from onyx.db.models import ConnectorCredentialPair
from onyx.db.models import Document
from onyx.db.models import DocumentSet
@@ -74,7 +74,9 @@ class PersonaLoadType(Enum):
def _add_user_filters(
stmt: Select[tuple[Persona]], user: User, get_editable: bool = True
) -> Select[tuple[Persona]]:
if user.role == UserRole.ADMIN:
if has_permission(user, Permission.MANAGE_AGENTS):
return stmt
if not get_editable and has_permission(user, Permission.READ_AGENTS):
return stmt
stmt = stmt.distinct()
@@ -98,12 +100,7 @@ def _add_user_filters(
"""
Filter Personas by:
- if the user is in the user_group that owns the Persona
- if the user is not a global_curator, they must also have a curator relationship
to the user_group
- if editing is being done, we also filter out Personas that are owned by groups
that the user isn't a curator for
- if we are not editing, we show all Personas in the groups the user is a curator
for (as well as public Personas)
- if we are not editing, we show all public and listed Personas
- if we are not editing, we return all Personas directly connected to the user
"""
@@ -112,21 +109,9 @@ def _add_user_filters(
where_clause = Persona.is_public == True # noqa: E712
return stmt.where(where_clause)
# If curator ownership restriction is enabled, curators can only access their own assistants
if CURATORS_CANNOT_VIEW_OR_EDIT_NON_OWNED_ASSISTANTS and user.role in [
UserRole.CURATOR,
UserRole.GLOBAL_CURATOR,
]:
where_clause = (Persona.user_id == user.id) | (Persona.user_id.is_(None))
return stmt.where(where_clause)
where_clause = User__UserGroup.user_id == user.id
if user.role == UserRole.CURATOR and get_editable:
where_clause &= User__UserGroup.is_curator == True # noqa: E712
if get_editable:
user_groups = select(User__UG.user_group_id).where(User__UG.user_id == user.id)
if user.role == UserRole.CURATOR:
user_groups = user_groups.where(User__UG.is_curator == True) # noqa: E712
where_clause &= (
~exists()
.where(Persona__UG.persona_id == Persona.id)
@@ -197,7 +182,7 @@ def _get_persona_by_name(
- Non-admin users: can only see their own personas
"""
stmt = select(Persona).where(Persona.name == persona_name)
if user and user.role != UserRole.ADMIN:
if user and not has_permission(user, Permission.MANAGE_AGENTS):
stmt = stmt.where(Persona.user_id == user.id)
result = db_session.execute(stmt).scalar_one_or_none()
return result
@@ -271,12 +256,10 @@ def create_update_persona(
try:
# Featured persona validation
if create_persona_request.is_featured:
# Curators can edit featured personas, but not make them
# TODO this will be reworked soon with RBAC permissions feature
if user.role == UserRole.CURATOR or user.role == UserRole.GLOBAL_CURATOR:
pass
elif user.role != UserRole.ADMIN:
raise ValueError("Only admins can make a featured persona")
if not has_permission(user, Permission.MANAGE_AGENTS):
raise ValueError(
"Only users with agent management permissions can make a featured persona"
)
# Convert incoming string UUIDs to UUID objects for DB operations
converted_user_file_ids = None
@@ -353,7 +336,11 @@ def update_persona_shared(
db_session=db_session, persona_id=persona_id, user=user, get_editable=True
)
if user and user.role != UserRole.ADMIN and persona.user_id != user.id:
if (
user
and not has_permission(user, Permission.MANAGE_AGENTS)
and persona.user_id != user.id
):
raise PermissionError("You don't have permission to modify this persona")
versioned_update_persona_access = fetch_versioned_implementation(
@@ -389,7 +376,10 @@ def update_persona_public_status(
persona = fetch_persona_by_id_for_user(
db_session=db_session, persona_id=persona_id, user=user, get_editable=True
)
if user.role != UserRole.ADMIN and persona.user_id != user.id:
if (
not has_permission(user, Permission.MANAGE_AGENTS)
and persona.user_id != user.id
):
raise ValueError("You don't have permission to modify this persona")
persona.is_public = is_public
@@ -1226,7 +1216,11 @@ def get_persona_by_id(
if not include_deleted:
persona_stmt = persona_stmt.where(Persona.deleted.is_(False))
if not user or user.role == UserRole.ADMIN:
if (
not user
or has_permission(user, Permission.MANAGE_AGENTS)
or (not is_for_edit and has_permission(user, Permission.READ_AGENTS))
):
result = db_session.execute(persona_stmt)
persona = result.scalar_one_or_none()
if persona is None:
@@ -1243,14 +1237,6 @@ def get_persona_by_id(
# if the user is in the .users of the persona
or_conditions |= User.id == user.id
or_conditions |= Persona.is_public == True # noqa: E712
elif user.role == UserRole.GLOBAL_CURATOR:
# global curators can edit personas for the groups they are in
or_conditions |= User__UserGroup.user_id == user.id
elif user.role == UserRole.CURATOR:
# curators can edit personas for the groups they are curators of
or_conditions |= (User__UserGroup.user_id == user.id) & (
User__UserGroup.is_curator == True # noqa: E712
)
persona_stmt = persona_stmt.where(or_conditions)
result = db_session.execute(persona_stmt)

View File

@@ -56,6 +56,7 @@ class OnyxErrorCode(Enum):
DOCUMENT_NOT_FOUND = ("DOCUMENT_NOT_FOUND", 404)
SESSION_NOT_FOUND = ("SESSION_NOT_FOUND", 404)
USER_NOT_FOUND = ("USER_NOT_FOUND", 404)
DOCUMENT_SET_NOT_FOUND = ("DOCUMENT_SET_NOT_FOUND", 404)
# ------------------------------------------------------------------
# Conflict (409)

View File

@@ -20,7 +20,7 @@ router = APIRouter(prefix="/admin/api-key")
@router.get("")
def list_api_keys(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_SERVICE_ACCOUNT_API_KEYS)),
db_session: Session = Depends(get_session),
) -> list[ApiKeyDescriptor]:
return fetch_api_keys(db_session)
@@ -29,7 +29,9 @@ def list_api_keys(
@router.post("")
def create_api_key(
api_key_args: APIKeyArgs,
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
user: User = Depends(
require_permission(Permission.MANAGE_SERVICE_ACCOUNT_API_KEYS)
),
db_session: Session = Depends(get_session),
) -> ApiKeyDescriptor:
return insert_api_key(db_session, api_key_args, user.id)
@@ -38,7 +40,7 @@ def create_api_key(
@router.post("/{api_key_id}/regenerate")
def regenerate_existing_api_key(
api_key_id: int,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_SERVICE_ACCOUNT_API_KEYS)),
db_session: Session = Depends(get_session),
) -> ApiKeyDescriptor:
return regenerate_api_key(db_session, api_key_id)
@@ -48,7 +50,7 @@ def regenerate_existing_api_key(
def update_existing_api_key(
api_key_id: int,
api_key_args: APIKeyArgs,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_SERVICE_ACCOUNT_API_KEYS)),
db_session: Session = Depends(get_session),
) -> ApiKeyDescriptor:
return update_api_key(db_session, api_key_id, api_key_args)
@@ -57,7 +59,7 @@ def update_existing_api_key(
@router.delete("/{api_key_id}")
def delete_api_key(
api_key_id: int,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_SERVICE_ACCOUNT_API_KEYS)),
db_session: Session = Depends(get_session),
) -> None:
remove_api_key(db_session, api_key_id)

View File

@@ -1,11 +1,9 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import Query
from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_curator_or_admin_user
from onyx.background.celery.versioned_apps.client import app as client_app
from onyx.configs.app_configs import DISABLE_VECTOR_DB
from onyx.configs.constants import OnyxCeleryPriority
@@ -20,6 +18,8 @@ from onyx.db.document_set import update_document_set
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import Permission
from onyx.db.models import User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.document_set.models import CheckDocSetPublicRequest
from onyx.server.features.document_set.models import CheckDocSetPublicResponse
from onyx.server.features.document_set.models import DocumentSetCreationRequest
@@ -35,7 +35,7 @@ router = APIRouter(prefix="/manage")
@router.post("/admin/document-set")
def create_document_set(
document_set_creation_request: DocumentSetCreationRequest,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_DOCUMENT_SETS)),
db_session: Session = Depends(get_session),
tenant_id: str = Depends(get_current_tenant_id),
) -> int:
@@ -55,7 +55,7 @@ def create_document_set(
db_session=db_session,
)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
if not DISABLE_VECTOR_DB:
client_app.send_task(
@@ -70,15 +70,15 @@ def create_document_set(
@router.patch("/admin/document-set")
def patch_document_set(
document_set_update_request: DocumentSetUpdateRequest,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_DOCUMENT_SETS)),
db_session: Session = Depends(get_session),
tenant_id: str = Depends(get_current_tenant_id),
) -> None:
document_set = get_document_set_by_id(db_session, document_set_update_request.id)
if document_set is None:
raise HTTPException(
status_code=404,
detail=f"Document set {document_set_update_request.id} does not exist",
raise OnyxError(
OnyxErrorCode.DOCUMENT_SET_NOT_FOUND,
f"Document set {document_set_update_request.id} does not exist",
)
fetch_ee_implementation_or_noop(
@@ -98,7 +98,7 @@ def patch_document_set(
user=user,
)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
if not DISABLE_VECTOR_DB:
client_app.send_task(
@@ -111,15 +111,15 @@ def patch_document_set(
@router.delete("/admin/document-set/{document_set_id}")
def delete_document_set(
document_set_id: int,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_DOCUMENT_SETS)),
db_session: Session = Depends(get_session),
tenant_id: str = Depends(get_current_tenant_id),
) -> None:
document_set = get_document_set_by_id(db_session, document_set_id)
if document_set is None:
raise HTTPException(
status_code=404,
detail=f"Document set {document_set_id} does not exist",
raise OnyxError(
OnyxErrorCode.DOCUMENT_SET_NOT_FOUND,
f"Document set {document_set_id} does not exist",
)
# check if the user has "edit" access to the document set.
@@ -142,7 +142,7 @@ def delete_document_set(
user=user,
)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
if DISABLE_VECTOR_DB:
db_session.refresh(document_set)

View File

@@ -11,7 +11,6 @@ from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_chat_accessible_user
from onyx.auth.users import current_curator_or_admin_user
from onyx.auth.users import current_limited_user
from onyx.configs.app_configs import DISABLE_VECTOR_DB
from onyx.configs.constants import FileOrigin
@@ -135,7 +134,7 @@ class IsFeaturedRequest(BaseModel):
def patch_persona_visibility(
persona_id: int,
is_listed_request: IsListedRequest,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_AGENTS)),
db_session: Session = Depends(get_session),
) -> None:
update_persona_visibility(
@@ -150,7 +149,7 @@ def patch_persona_visibility(
def patch_user_persona_public_status(
persona_id: int,
is_public_request: IsPublicRequest,
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
user: User = Depends(require_permission(Permission.ADD_AGENTS)),
db_session: Session = Depends(get_session),
) -> None:
try:
@@ -169,7 +168,7 @@ def patch_user_persona_public_status(
def patch_persona_featured_status(
persona_id: int,
is_featured_request: IsFeaturedRequest,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_AGENTS)),
db_session: Session = Depends(get_session),
) -> None:
try:
@@ -204,7 +203,7 @@ def patch_agents_display_priorities(
@admin_router.get("", tags=PUBLIC_API_TAGS)
def list_personas_admin(
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.READ_AGENTS)),
db_session: Session = Depends(get_session),
include_deleted: bool = False,
get_editable: bool = Query(False, description="If true, return editable personas"),
@@ -221,7 +220,7 @@ def list_personas_admin(
def get_agents_admin_paginated(
page_num: int = Query(0, ge=0, description="Page number (0-indexed)."),
page_size: int = Query(10, ge=1, le=1000, description="Items per page."),
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.READ_AGENTS)),
db_session: Session = Depends(get_session),
include_deleted: bool = Query(
False, description="If true, includes deleted personas."
@@ -298,7 +297,7 @@ def upload_file(
@basic_router.post("", tags=PUBLIC_API_TAGS)
def create_persona(
persona_upsert_request: PersonaUpsertRequest,
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
user: User = Depends(require_permission(Permission.ADD_AGENTS)),
db_session: Session = Depends(get_session),
) -> PersonaSnapshot:
tenant_id = get_current_tenant_id()
@@ -328,7 +327,7 @@ def create_persona(
def update_persona(
persona_id: int,
persona_upsert_request: PersonaUpsertRequest,
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
user: User = Depends(require_permission(Permission.ADD_AGENTS)),
db_session: Session = Depends(get_session),
) -> PersonaSnapshot:
_validate_user_knowledge_enabled(persona_upsert_request, "update")
@@ -410,7 +409,7 @@ class PersonaShareRequest(BaseModel):
def share_persona(
persona_id: int,
persona_share_request: PersonaShareRequest,
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
user: User = Depends(require_permission(Permission.ADD_AGENTS)),
db_session: Session = Depends(get_session),
) -> None:
try:
@@ -434,7 +433,7 @@ def share_persona(
@basic_router.delete("/{persona_id}", tags=PUBLIC_API_TAGS)
def delete_persona(
persona_id: int,
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
user: User = Depends(require_permission(Permission.ADD_AGENTS)),
db_session: Session = Depends(get_session),
) -> None:
mark_persona_as_deleted(

View File

@@ -2,8 +2,6 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import status
from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
@@ -25,6 +23,8 @@ from onyx.db.discord_bot import update_guild_config
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import Permission
from onyx.db.models import User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.manage.discord_bot.models import DiscordBotConfigCreateRequest
from onyx.server.manage.discord_bot.models import DiscordBotConfigResponse
from onyx.server.manage.discord_bot.models import DiscordChannelConfigResponse
@@ -48,14 +48,14 @@ def _check_bot_config_api_access() -> None:
- When DISCORD_BOT_TOKEN env var is set (managed via env)
"""
if AUTH_TYPE == AuthType.CLOUD:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Discord bot configuration is managed by Onyx on Cloud.",
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"Discord bot configuration is managed by Onyx on Cloud.",
)
if DISCORD_BOT_TOKEN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Discord bot is configured via environment variables. API access disabled.",
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"Discord bot is configured via environment variables. API access disabled.",
)
@@ -65,7 +65,7 @@ def _check_bot_config_api_access() -> None:
@router.get("/config", response_model=DiscordBotConfigResponse)
def get_bot_config(
_: None = Depends(_check_bot_config_api_access),
__: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
__: User = Depends(require_permission(Permission.MANAGE_BOTS)),
db_session: Session = Depends(get_session),
) -> DiscordBotConfigResponse:
"""Get Discord bot config. Returns 403 on Cloud or if env vars set."""
@@ -83,7 +83,7 @@ def get_bot_config(
def create_bot_request(
request: DiscordBotConfigCreateRequest,
_: None = Depends(_check_bot_config_api_access),
__: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
__: User = Depends(require_permission(Permission.MANAGE_BOTS)),
db_session: Session = Depends(get_session),
) -> DiscordBotConfigResponse:
"""Create Discord bot config. Returns 403 on Cloud or if env vars set."""
@@ -93,9 +93,9 @@ def create_bot_request(
bot_token=request.bot_token,
)
except ValueError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Discord bot config already exists. Delete it first to create a new one.",
raise OnyxError(
OnyxErrorCode.CONFLICT,
"Discord bot config already exists. Delete it first to create a new one.",
)
db_session.commit()
@@ -109,7 +109,7 @@ def create_bot_request(
@router.delete("/config")
def delete_bot_config_endpoint(
_: None = Depends(_check_bot_config_api_access),
__: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
__: User = Depends(require_permission(Permission.MANAGE_BOTS)),
db_session: Session = Depends(get_session),
) -> dict:
"""Delete Discord bot config.
@@ -118,7 +118,7 @@ def delete_bot_config_endpoint(
"""
deleted = delete_discord_bot_config(db_session)
if not deleted:
raise HTTPException(status_code=404, detail="Bot config not found")
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Bot config not found")
# Also delete the service API key used by the Discord bot
delete_discord_service_api_key(db_session)
@@ -132,7 +132,7 @@ def delete_bot_config_endpoint(
@router.delete("/service-api-key")
def delete_service_api_key_endpoint(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
db_session: Session = Depends(get_session),
) -> dict:
"""Delete the Discord service API key.
@@ -145,7 +145,7 @@ def delete_service_api_key_endpoint(
"""
deleted = delete_discord_service_api_key(db_session)
if not deleted:
raise HTTPException(status_code=404, detail="Service API key not found")
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Service API key not found")
db_session.commit()
return {"deleted": True}
@@ -155,7 +155,7 @@ def delete_service_api_key_endpoint(
@router.get("/guilds", response_model=list[DiscordGuildConfigResponse])
def list_guild_configs(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
db_session: Session = Depends(get_session),
) -> list[DiscordGuildConfigResponse]:
"""List all guild configs (pending and registered)."""
@@ -165,7 +165,7 @@ def list_guild_configs(
@router.post("/guilds", response_model=DiscordGuildConfigCreateResponse)
def create_guild_request(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
db_session: Session = Depends(get_session),
) -> DiscordGuildConfigCreateResponse:
"""Create new guild config with registration key. Key shown once."""
@@ -184,13 +184,13 @@ def create_guild_request(
@router.get("/guilds/{config_id}", response_model=DiscordGuildConfigResponse)
def get_guild_config(
config_id: int,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
db_session: Session = Depends(get_session),
) -> DiscordGuildConfigResponse:
"""Get specific guild config."""
config = get_guild_config_by_internal_id(db_session, internal_id=config_id)
if not config:
raise HTTPException(status_code=404, detail="Guild config not found")
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Guild config not found")
return DiscordGuildConfigResponse.model_validate(config)
@@ -198,13 +198,13 @@ def get_guild_config(
def update_guild_request(
config_id: int,
request: DiscordGuildConfigUpdateRequest,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
db_session: Session = Depends(get_session),
) -> DiscordGuildConfigResponse:
"""Update guild config."""
config = get_guild_config_by_internal_id(db_session, internal_id=config_id)
if not config:
raise HTTPException(status_code=404, detail="Guild config not found")
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Guild config not found")
config = update_guild_config(
db_session,
@@ -220,7 +220,7 @@ def update_guild_request(
@router.delete("/guilds/{config_id}")
def delete_guild_request(
config_id: int,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
db_session: Session = Depends(get_session),
) -> dict:
"""Delete guild config (invalidates registration key).
@@ -229,7 +229,7 @@ def delete_guild_request(
"""
deleted = delete_guild_config(db_session, config_id)
if not deleted:
raise HTTPException(status_code=404, detail="Guild config not found")
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Guild config not found")
# On Cloud, delete service API key when all guilds are removed
if AUTH_TYPE == AuthType.CLOUD:
@@ -249,15 +249,15 @@ def delete_guild_request(
)
def list_channel_configs(
config_id: int,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
db_session: Session = Depends(get_session),
) -> list[DiscordChannelConfigResponse]:
"""List whitelisted channels for a guild."""
guild_config = get_guild_config_by_internal_id(db_session, internal_id=config_id)
if not guild_config:
raise HTTPException(status_code=404, detail="Guild config not found")
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Guild config not found")
if not guild_config.guild_id:
raise HTTPException(status_code=400, detail="Guild not yet registered")
raise OnyxError(OnyxErrorCode.INVALID_INPUT, "Guild not yet registered")
configs = get_channel_configs(db_session, config_id)
return [DiscordChannelConfigResponse.model_validate(c) for c in configs]
@@ -271,7 +271,7 @@ def update_channel_request(
guild_config_id: int,
channel_config_id: int,
request: DiscordChannelConfigUpdateRequest,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
db_session: Session = Depends(get_session),
) -> DiscordChannelConfigResponse:
"""Update channel config."""
@@ -279,7 +279,7 @@ def update_channel_request(
db_session, guild_config_id, channel_config_id
)
if not config:
raise HTTPException(status_code=404, detail="Channel config not found")
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Channel config not found")
config = update_discord_channel_config(
db_session,

View File

@@ -15,8 +15,8 @@ from fastapi import Query
from pydantic import ValidationError
from sqlalchemy.orm import Session
from onyx.auth.permissions import has_permission
from onyx.auth.permissions import require_permission
from onyx.auth.schemas import UserRole
from onyx.auth.users import current_chat_accessible_user
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import LLMModelFlowType
@@ -252,7 +252,7 @@ def _validate_llm_provider_change(
@admin_router.get("/built-in/options")
def fetch_llm_options(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
) -> list[WellKnownLLMProviderDescriptor]:
return fetch_available_well_known_llms()
@@ -260,7 +260,7 @@ def fetch_llm_options(
@admin_router.get("/built-in/options/{provider_name}")
def fetch_llm_provider_options(
provider_name: str,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
) -> WellKnownLLMProviderDescriptor:
well_known_llms = fetch_available_well_known_llms()
for well_known_llm in well_known_llms:
@@ -272,7 +272,7 @@ def fetch_llm_provider_options(
@admin_router.post("/test")
def test_llm_configuration(
test_llm_request: TestLLMRequest,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
db_session: Session = Depends(get_session),
) -> None:
"""Test LLM configuration settings"""
@@ -330,7 +330,7 @@ def test_llm_configuration(
@admin_router.post("/test/default")
def test_default_provider(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
) -> None:
try:
llm = get_default_llm()
@@ -346,7 +346,7 @@ def test_default_provider(
@admin_router.get("/provider")
def list_llm_providers(
include_image_gen: bool = Query(False),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
db_session: Session = Depends(get_session),
) -> LLMProviderResponse[LLMProviderView]:
start_time = datetime.now(timezone.utc)
@@ -391,7 +391,7 @@ def put_llm_provider(
False,
description="True if creating a new one, False if updating an existing provider",
),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
db_session: Session = Depends(get_session),
) -> LLMProviderView:
# validate request (e.g. if we're intending to create but the name already exists we should throw an error)
@@ -529,7 +529,7 @@ def put_llm_provider(
def delete_llm_provider(
provider_id: int,
force: bool = Query(False),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
db_session: Session = Depends(get_session),
) -> None:
if not force:
@@ -550,7 +550,7 @@ def delete_llm_provider(
@admin_router.post("/default")
def set_provider_as_default(
default_model_request: DefaultModel,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
db_session: Session = Depends(get_session),
) -> None:
update_default_provider(
@@ -563,7 +563,7 @@ def set_provider_as_default(
@admin_router.post("/default-vision")
def set_provider_as_default_vision(
default_model: DefaultModel,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
db_session: Session = Depends(get_session),
) -> None:
update_default_vision_provider(
@@ -575,7 +575,7 @@ def set_provider_as_default_vision(
@admin_router.get("/auto-config")
def get_auto_config(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
) -> dict:
"""Get the current Auto mode configuration from GitHub.
@@ -593,7 +593,7 @@ def get_auto_config(
@admin_router.get("/vision-providers")
def get_vision_capable_providers(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
db_session: Session = Depends(get_session),
) -> LLMProviderResponse[VisionProviderResponse]:
"""Return a list of LLM providers and their models that support image input"""
@@ -655,7 +655,7 @@ def list_llm_provider_basics(
all_providers = fetch_existing_llm_providers(db_session, [])
user_group_ids = fetch_user_group_ids(db_session, user)
is_admin = user.role == UserRole.ADMIN
can_manage_llms = has_permission(user, Permission.MANAGE_LLMS)
accessible_providers = []
@@ -667,7 +667,7 @@ def list_llm_provider_basics(
# - Excludes providers with persona restrictions (requires specific persona)
# - Excludes non-public providers with no restrictions (admin-only)
if can_user_access_llm_provider(
provider, user_group_ids, persona=None, is_admin=is_admin
provider, user_group_ids, persona=None, is_admin=can_manage_llms
):
accessible_providers.append(LLMProviderDescriptor.from_model(provider))
@@ -703,17 +703,19 @@ def get_valid_model_names_for_persona(
if not persona:
return []
is_admin = user.role == UserRole.ADMIN
can_manage_llms = has_permission(user, Permission.MANAGE_LLMS)
all_providers = fetch_existing_llm_providers(
db_session, [LLMModelFlowType.CHAT, LLMModelFlowType.VISION]
)
user_group_ids = set() if is_admin else fetch_user_group_ids(db_session, user)
user_group_ids = (
set() if can_manage_llms else fetch_user_group_ids(db_session, user)
)
valid_models = []
for llm_provider_model in all_providers:
# Check access with persona context — respects all RBAC restrictions
if can_user_access_llm_provider(
llm_provider_model, user_group_ids, persona, is_admin=is_admin
llm_provider_model, user_group_ids, persona, is_admin=can_manage_llms
):
# Collect all model names from this provider
for model_config in llm_provider_model.model_configurations:
@@ -752,18 +754,20 @@ def list_llm_providers_for_persona(
"You don't have access to this assistant",
)
is_admin = user.role == UserRole.ADMIN
can_manage_llms = has_permission(user, Permission.MANAGE_LLMS)
all_providers = fetch_existing_llm_providers(
db_session, [LLMModelFlowType.CHAT, LLMModelFlowType.VISION]
)
user_group_ids = set() if is_admin else fetch_user_group_ids(db_session, user)
user_group_ids = (
set() if can_manage_llms else fetch_user_group_ids(db_session, user)
)
llm_provider_list: list[LLMProviderDescriptor] = []
for llm_provider_model in all_providers:
# Check access with persona context — respects persona restrictions
if can_user_access_llm_provider(
llm_provider_model, user_group_ids, persona, is_admin=is_admin
llm_provider_model, user_group_ids, persona, is_admin=can_manage_llms
):
llm_provider_list.append(
LLMProviderDescriptor.from_model(llm_provider_model)
@@ -791,7 +795,7 @@ def list_llm_providers_for_persona(
if persona_default_provider:
provider = fetch_existing_llm_provider(persona_default_provider, db_session)
if provider and can_user_access_llm_provider(
provider, user_group_ids, persona, is_admin=is_admin
provider, user_group_ids, persona, is_admin=can_manage_llms
):
if persona_default_model:
# Persona specifies both provider and model — use them directly
@@ -824,7 +828,7 @@ def list_llm_providers_for_persona(
@admin_router.get("/provider-contextual-cost")
def get_provider_contextual_cost(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
db_session: Session = Depends(get_session),
) -> list[LLMCost]:
"""
@@ -873,7 +877,7 @@ def get_provider_contextual_cost(
@admin_router.post("/bedrock/available-models")
def get_bedrock_available_models(
request: BedrockModelsRequest,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
db_session: Session = Depends(get_session),
) -> list[BedrockFinalModelResponse]:
"""Fetch available Bedrock models for a specific region and credentials.
@@ -1048,7 +1052,7 @@ def _get_ollama_available_model_names(api_base: str) -> set[str]:
@admin_router.post("/ollama/available-models")
def get_ollama_available_models(
request: OllamaModelsRequest,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
db_session: Session = Depends(get_session),
) -> list[OllamaFinalModelResponse]:
"""Fetch the list of available models from an Ollama server."""
@@ -1172,7 +1176,7 @@ def _get_openrouter_models_response(api_base: str, api_key: str) -> dict:
@admin_router.post("/openrouter/available-models")
def get_openrouter_available_models(
request: OpenRouterModelsRequest,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
db_session: Session = Depends(get_session),
) -> list[OpenRouterFinalModelResponse]:
"""Fetch available models from OpenRouter `/models` endpoint.
@@ -1253,7 +1257,7 @@ def get_openrouter_available_models(
@admin_router.post("/lm-studio/available-models")
def get_lm_studio_available_models(
request: LMStudioModelsRequest,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
db_session: Session = Depends(get_session),
) -> list[LMStudioFinalModelResponse]:
"""Fetch available models from an LM Studio server.
@@ -1360,7 +1364,7 @@ def get_lm_studio_available_models(
@admin_router.post("/litellm/available-models")
def get_litellm_available_models(
request: LitellmModelsRequest,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
db_session: Session = Depends(get_session),
) -> list[LitellmFinalModelResponse]:
"""Fetch available models from Litellm proxy /v1/models endpoint."""
@@ -1493,7 +1497,7 @@ def _get_openai_compatible_models_response(
@admin_router.post("/bifrost/available-models")
def get_bifrost_available_models(
request: BifrostModelsRequest,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
db_session: Session = Depends(get_session),
) -> list[BifrostFinalModelResponse]:
"""Fetch available models from Bifrost gateway /v1/models endpoint."""
@@ -1583,7 +1587,7 @@ def _get_bifrost_models_response(api_base: str, api_key: str | None = None) -> d
@admin_router.post("/openai-compatible/available-models")
def get_openai_compatible_server_available_models(
request: OpenAICompatibleModelsRequest,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
db_session: Session = Depends(get_session),
) -> list[OpenAICompatibleFinalModelResponse]:
"""Fetch available models from a generic OpenAI-compatible /v1/models endpoint."""

View File

@@ -135,6 +135,7 @@ class UserInfo(BaseModel):
is_anonymous_user: bool | None = None
password_configured: bool | None = None
tenant_info: TenantInfo | None = None
effective_permissions: list[str] = Field(default_factory=list)
@classmethod
def from_model(
@@ -148,6 +149,7 @@ class UserInfo(BaseModel):
tenant_info: TenantInfo | None = None,
assistant_specific_configs: UserSpecificAssistantPreferences | None = None,
memories: list[MemoryItem] | None = None,
effective_permissions: list[str] | None = None,
) -> "UserInfo":
return cls(
id=str(user.id),
@@ -187,6 +189,7 @@ class UserInfo(BaseModel):
is_cloud_superuser=is_cloud_superuser,
is_anonymous_user=is_anonymous_user,
tenant_info=tenant_info,
effective_permissions=effective_permissions or [],
personalization=UserPersonalization(
name=user.personal_name or "",
role=user.personal_role or "",

View File

@@ -114,7 +114,7 @@ def _form_channel_config(
def create_slack_channel_config(
slack_channel_config_creation_request: SlackChannelConfigCreationRequest,
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
) -> SlackChannelConfig:
channel_config = _form_channel_config(
db_session=db_session,
@@ -155,7 +155,7 @@ def patch_slack_channel_config(
slack_channel_config_id: int,
slack_channel_config_creation_request: SlackChannelConfigCreationRequest,
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
) -> SlackChannelConfig:
channel_config = _form_channel_config(
db_session=db_session,
@@ -216,7 +216,7 @@ def patch_slack_channel_config(
def delete_slack_channel_config(
slack_channel_config_id: int,
db_session: Session = Depends(get_session),
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
user: User = Depends(require_permission(Permission.MANAGE_BOTS)),
) -> None:
remove_slack_channel_config(
db_session=db_session,
@@ -228,7 +228,7 @@ def delete_slack_channel_config(
@router.get("/admin/slack-app/channel")
def list_slack_channel_configs(
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
) -> list[SlackChannelConfig]:
slack_channel_config_models = fetch_slack_channel_configs(db_session=db_session)
return [
@@ -241,7 +241,7 @@ def list_slack_channel_configs(
def create_bot(
slack_bot_creation_request: SlackBotCreationRequest,
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
) -> SlackBot:
tenant_id = get_current_tenant_id()
@@ -287,7 +287,7 @@ def patch_bot(
slack_bot_id: int,
slack_bot_creation_request: SlackBotCreationRequest,
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
) -> SlackBot:
validate_bot_token(slack_bot_creation_request.bot_token)
validate_app_token(slack_bot_creation_request.app_token)
@@ -308,7 +308,7 @@ def patch_bot(
def delete_bot(
slack_bot_id: int,
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
) -> None:
remove_slack_bot(
db_session=db_session,
@@ -320,7 +320,7 @@ def delete_bot(
def get_bot_by_id(
slack_bot_id: int,
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
) -> SlackBot:
slack_bot_model = fetch_slack_bot(
db_session=db_session,
@@ -332,7 +332,7 @@ def get_bot_by_id(
@router.get("/admin/slack-app/bots")
def list_bots(
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
) -> list[SlackBot]:
slack_bot_models = fetch_slack_bots(db_session=db_session)
return [
@@ -344,7 +344,7 @@ def list_bots(
def list_bot_configs(
bot_id: int,
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
) -> list[SlackChannelConfig]:
slack_bot_config_models = fetch_slack_channel_configs(
db_session=db_session, slack_bot_id=bot_id

View File

@@ -857,6 +857,7 @@ def verify_user_logged_in(
invitation=tenant_invitation,
),
memories=memories,
effective_permissions=sorted(p.value for p in get_effective_permissions(user)),
)
return user_info

View File

@@ -2,7 +2,6 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
@@ -12,6 +11,8 @@ from onyx.db.models import User
from onyx.db.pat import create_pat
from onyx.db.pat import list_user_pats
from onyx.db.pat import revoke_pat
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.pat.models import CreatedTokenResponse
from onyx.server.pat.models import CreateTokenRequest
from onyx.server.pat.models import TokenResponse
@@ -46,7 +47,7 @@ def list_tokens(
@router.post("")
def create_token(
request: CreateTokenRequest,
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
user: User = Depends(require_permission(Permission.CREATE_USER_API_KEYS)),
db_session: Session = Depends(get_session),
) -> CreatedTokenResponse:
"""Create new personal access token for current user."""
@@ -58,7 +59,7 @@ def create_token(
expiration_days=request.expiration_days,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.BAD_REQUEST, str(e))
logger.info(f"User {user.email} created PAT '{request.name}'")
@@ -82,9 +83,7 @@ def delete_token(
"""Delete (revoke) personal access token. Only owner can revoke their own tokens."""
success = revoke_pat(db_session, token_id, user.id)
if not success:
raise HTTPException(
status_code=404, detail="Token not found or not owned by user"
)
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Token not found or not owned by user")
logger.info(f"User {user.email} revoked token {token_id}")
return {"message": "Token deleted successfully"}

View File

@@ -117,15 +117,14 @@ class UserGroupManager:
return response.json()
@staticmethod
def set_permission(
def set_permissions(
user_group: DATestUserGroup,
permission: str,
enabled: bool,
permissions: list[str],
user_performing_action: DATestUser,
) -> requests.Response:
response = requests.put(
f"{API_SERVER_URL}/manage/admin/user-group/{user_group.id}/permissions",
json={"permission": permission, "enabled": enabled},
json={"permissions": permissions},
headers=user_performing_action.headers,
)
return response

View File

@@ -13,7 +13,7 @@ ENTERPRISE_SKIP = pytest.mark.skipif(
@ENTERPRISE_SKIP
def test_grant_permission_via_toggle(reset: None) -> None: # noqa: ARG001
def test_grant_permission_via_bulk(reset: None) -> None: # noqa: ARG001
admin_user: DATestUser = UserManager.create(name="admin_grant")
basic_user: DATestUser = UserManager.create(name="basic_grant")
@@ -23,10 +23,11 @@ def test_grant_permission_via_toggle(reset: None) -> None: # noqa: ARG001
user_performing_action=admin_user,
)
# Grant manage:llms
resp = UserGroupManager.set_permission(group, "manage:llms", True, admin_user)
# Set desired permissions to [manage:llms]
resp = UserGroupManager.set_permissions(group, ["manage:llms"], admin_user)
resp.raise_for_status()
assert resp.json() == {"permission": "manage:llms", "enabled": True}
result = resp.json()
assert "manage:llms" in result, f"Expected manage:llms in {result}"
# Verify group permissions
group_perms = UserGroupManager.get_permissions(group, admin_user)
@@ -38,7 +39,7 @@ def test_grant_permission_via_toggle(reset: None) -> None: # noqa: ARG001
@ENTERPRISE_SKIP
def test_revoke_permission_via_toggle(reset: None) -> None: # noqa: ARG001
def test_revoke_permission_via_bulk(reset: None) -> None: # noqa: ARG001
admin_user: DATestUser = UserManager.create(name="admin_revoke")
basic_user: DATestUser = UserManager.create(name="basic_revoke")
@@ -48,13 +49,11 @@ def test_revoke_permission_via_toggle(reset: None) -> None: # noqa: ARG001
user_performing_action=admin_user,
)
# Grant then revoke
UserGroupManager.set_permission(
group, "manage:llms", True, admin_user
).raise_for_status()
UserGroupManager.set_permission(
group, "manage:llms", False, admin_user
# Grant then revoke by sending empty list
UserGroupManager.set_permissions(
group, ["manage:llms"], admin_user
).raise_for_status()
UserGroupManager.set_permissions(group, [], admin_user).raise_for_status()
# Verify removed from group
group_perms = UserGroupManager.get_permissions(group, admin_user)
@@ -68,7 +67,7 @@ def test_revoke_permission_via_toggle(reset: None) -> None: # noqa: ARG001
@ENTERPRISE_SKIP
def test_idempotent_grant(reset: None) -> None: # noqa: ARG001
def test_idempotent_bulk_set(reset: None) -> None: # noqa: ARG001
admin_user: DATestUser = UserManager.create(name="admin_idempotent_grant")
group = UserGroupManager.create(
@@ -77,12 +76,12 @@ def test_idempotent_grant(reset: None) -> None: # noqa: ARG001
user_performing_action=admin_user,
)
# Toggle ON twice
UserGroupManager.set_permission(
group, "manage:llms", True, admin_user
# Set same permissions twice
UserGroupManager.set_permissions(
group, ["manage:llms"], admin_user
).raise_for_status()
UserGroupManager.set_permission(
group, "manage:llms", True, admin_user
UserGroupManager.set_permissions(
group, ["manage:llms"], admin_user
).raise_for_status()
group_perms = UserGroupManager.get_permissions(group, admin_user)
@@ -92,22 +91,22 @@ def test_idempotent_grant(reset: None) -> None: # noqa: ARG001
@ENTERPRISE_SKIP
def test_idempotent_revoke(reset: None) -> None: # noqa: ARG001
admin_user: DATestUser = UserManager.create(name="admin_idempotent_revoke")
def test_empty_permissions_is_valid(reset: None) -> None: # noqa: ARG001
admin_user: DATestUser = UserManager.create(name="admin_empty")
group = UserGroupManager.create(
name="idempotent-revoke-group",
name="empty-perms-group",
user_ids=[admin_user.id],
user_performing_action=admin_user,
)
# Toggle OFF when never granted — should not error
resp = UserGroupManager.set_permission(group, "manage:llms", False, admin_user)
# Setting empty list should not error
resp = UserGroupManager.set_permissions(group, [], admin_user)
resp.raise_for_status()
@ENTERPRISE_SKIP
def test_cannot_toggle_basic_access(reset: None) -> None: # noqa: ARG001
def test_cannot_set_basic_access(reset: None) -> None: # noqa: ARG001
admin_user: DATestUser = UserManager.create(name="admin_basic_block")
group = UserGroupManager.create(
@@ -116,12 +115,12 @@ def test_cannot_toggle_basic_access(reset: None) -> None: # noqa: ARG001
user_performing_action=admin_user,
)
resp = UserGroupManager.set_permission(group, "basic", True, admin_user)
resp = UserGroupManager.set_permissions(group, ["basic"], admin_user)
assert resp.status_code == 400, f"Expected 400, got {resp.status_code}"
@ENTERPRISE_SKIP
def test_cannot_toggle_admin(reset: None) -> None: # noqa: ARG001
def test_cannot_set_admin(reset: None) -> None: # noqa: ARG001
admin_user: DATestUser = UserManager.create(name="admin_admin_block")
group = UserGroupManager.create(
@@ -130,7 +129,7 @@ def test_cannot_toggle_admin(reset: None) -> None: # noqa: ARG001
user_performing_action=admin_user,
)
resp = UserGroupManager.set_permission(group, "admin", True, admin_user)
resp = UserGroupManager.set_permissions(group, ["admin"], admin_user)
assert resp.status_code == 400, f"Expected 400, got {resp.status_code}"
@@ -146,11 +145,44 @@ def test_implied_permissions_expand(reset: None) -> None: # noqa: ARG001
)
# Grant manage:agents — should imply add:agents and read:agents
UserGroupManager.set_permission(
group, "manage:agents", True, admin_user
UserGroupManager.set_permissions(
group, ["manage:agents"], admin_user
).raise_for_status()
user_perms = UserManager.get_permissions(basic_user)
assert "manage:agents" in user_perms, f"Missing manage:agents: {user_perms}"
assert "add:agents" in user_perms, f"Missing implied add:agents: {user_perms}"
assert "read:agents" in user_perms, f"Missing implied read:agents: {user_perms}"
@ENTERPRISE_SKIP
def test_bulk_replaces_previous_state(reset: None) -> None: # noqa: ARG001
"""Setting a new permission list should disable ones no longer included."""
admin_user: DATestUser = UserManager.create(name="admin_replace")
group = UserGroupManager.create(
name="replace-state-group",
user_ids=[admin_user.id],
user_performing_action=admin_user,
)
# Set initial permissions
UserGroupManager.set_permissions(
group, ["manage:llms", "manage:actions"], admin_user
).raise_for_status()
# Replace with a different set
UserGroupManager.set_permissions(
group, ["manage:actions", "manage:user_groups"], admin_user
).raise_for_status()
group_perms = UserGroupManager.get_permissions(group, admin_user)
assert (
"manage:llms" not in group_perms
), f"manage:llms should be removed: {group_perms}"
assert (
"manage:actions" in group_perms
), f"manage:actions should remain: {group_perms}"
assert (
"manage:user_groups" in group_perms
), f"manage:user_groups should be added: {group_perms}"

View File

@@ -0,0 +1,27 @@
import type { IconProps } from "@opal/types";
const SvgCreateAgent = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M4.5 2.5L8 1L11.5 2.5M13.5 4.5L15 8L13.5 11.5M11.5 13.5L8 15L4.5 13.5M2.5 11.5L1 7.99999L2.5 4.5"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M5 8L8 8.00001M8 8.00001L11 8.00001M8 8.00001L8 5M8 8.00001L8 11"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgCreateAgent;

View File

@@ -55,6 +55,7 @@ export { default as SvgColumn } from "@opal/icons/column";
export { default as SvgCopy } from "@opal/icons/copy";
export { default as SvgCornerRightUpDot } from "@opal/icons/corner-right-up-dot";
export { default as SvgCpu } from "@opal/icons/cpu";
export { default as SvgCreateAgent } from "@opal/icons/create-agent";
export { default as SvgCurate } from "@opal/icons/curate";
export { default as SvgCreditCard } from "@opal/icons/credit-card";
export { default as SvgDashboard } from "@opal/icons/dashboard";
@@ -110,6 +111,7 @@ export { default as SvgLmStudio } from "@opal/icons/lm-studio";
export { default as SvgLoader } from "@opal/icons/loader";
export { default as SvgLock } from "@opal/icons/lock";
export { default as SvgLogOut } from "@opal/icons/log-out";
export { default as SvgManageAgent } from "@opal/icons/manage-agent";
export { default as SvgMaximize2 } from "@opal/icons/maximize-2";
export { default as SvgMcp } from "@opal/icons/mcp";
export { default as SvgMenu } from "@opal/icons/menu";

View File

@@ -0,0 +1,27 @@
import type { IconProps } from "@opal/types";
const SvgManageAgent = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M4.5 2.5L8 1L11.5 2.5M13.5 4.5L15 8L13.5 11.5M11.5 13.5L8 15L4.5 13.5M2.5 11.5L1 7.99999L2.5 4.5"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M6 11V8.75M6 6.75V5M6 6.75H4.75M6 6.75H7.25M10 11V9.25M10 9.25H8.75M10 9.25H11.25M10 7.25V5"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgManageAgent;

View File

@@ -1,15 +1,19 @@
"use client";
import type { Route } from "next";
import AdminSidebar from "@/sections/sidebar/AdminSidebar";
import { usePathname } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import { useSettingsContext } from "@/providers/SettingsProvider";
import { useUser } from "@/providers/UserProvider";
import { ApplicationStatus } from "@/interfaces/settings";
import { Button } from "@opal/components";
import { cn } from "@/lib/utils";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { ADMIN_ROUTES, AdminRouteEntry } from "@/lib/admin-routes";
import { hasPermission, getFirstPermittedAdminRoute } from "@/lib/permissions";
import useScreenSize from "@/hooks/useScreenSize";
import { SvgSidebar } from "@opal/icons";
import { useSidebarState } from "@/layouts/sidebar-layouts";
import { useEffect } from "react";
export interface ClientLayoutProps {
children: React.ReactNode;
@@ -56,7 +60,35 @@ export function ClientLayout({ children, enableCloud }: ClientLayoutProps) {
useSidebarState();
const { isMobile } = useScreenSize();
const pathname = usePathname();
const router = useRouter();
const settings = useSettingsContext();
const { user, permissions } = useUser();
// Enforce per-page permission: find the route that matches the current
// pathname and verify the user holds its requiredPermission.
useEffect(() => {
// Wait for user data to load before checking permissions —
// permissions default to [] while loading, which would cause a
// spurious redirect on every admin page.
if (!user) return;
const matchedRoute = Object.values(ADMIN_ROUTES).find(
(route: AdminRouteEntry) =>
route.sidebarLabel && pathname.startsWith(route.path)
);
if (
matchedRoute &&
!hasPermission(permissions, matchedRoute.requiredPermission)
) {
const fallback = getFirstPermittedAdminRoute(permissions);
// Avoid redirect loop: if the fallback is the same page, go to /app
if (pathname.startsWith(fallback)) {
router.replace("/app" as Route);
} else {
router.replace(fallback as Route);
}
}
}, [user, pathname, permissions, router]);
// Certain admin panels have their own custom sidebar.
// For those pages, we skip rendering the default `AdminSidebar` and let those individual pages render their own.

View File

@@ -36,229 +36,397 @@ import {
SvgZoomIn,
} from "@opal/icons";
export interface FeatureFlags {
vectorDbEnabled: boolean;
kgExposed: boolean;
enableCloud: boolean;
enableEnterprise: boolean;
customAnalyticsEnabled: boolean;
hasSubscription: boolean;
hooksEnabled: boolean;
opensearchEnabled: boolean;
queryHistoryEnabled: boolean;
}
export interface AdminRouteEntry {
path: string;
icon: IconFunctionComponent;
title: string;
sidebarLabel: string;
requiredPermission: string;
section: string;
requiresEnterprise: boolean;
visibleWhen: ((flags: FeatureFlags) => boolean) | null;
}
/**
* Single source of truth for every admin route: path, icon, page-header
* title, and sidebar label.
*/
export const ADMIN_ROUTES = {
INDEXING_STATUS: {
path: "/admin/indexing/status",
icon: SvgBookOpen,
title: "Existing Connectors",
sidebarLabel: "Existing Connectors",
},
ADD_CONNECTOR: {
path: "/admin/add-connector",
icon: SvgUploadCloud,
title: "Add Connector",
sidebarLabel: "Add Connector",
},
DOCUMENT_SETS: {
path: "/admin/documents/sets",
icon: SvgFiles,
title: "Document Sets",
sidebarLabel: "Document Sets",
},
DOCUMENT_EXPLORER: {
path: "/admin/documents/explorer",
icon: SvgZoomIn,
title: "Document Explorer",
sidebarLabel: "Explorer",
},
DOCUMENT_FEEDBACK: {
path: "/admin/documents/feedback",
icon: SvgThumbsUp,
title: "Document Feedback",
sidebarLabel: "Feedback",
},
AGENTS: {
path: "/admin/agents",
icon: SvgOnyxOctagon,
title: "Agents",
sidebarLabel: "Agents",
},
SLACK_BOTS: {
path: "/admin/bots",
icon: SvgSlack,
title: "Slack Integration",
sidebarLabel: "Slack Integration",
},
DISCORD_BOTS: {
path: "/admin/discord-bot",
icon: SvgDiscordMono,
title: "Discord Integration",
sidebarLabel: "Discord Integration",
},
MCP_ACTIONS: {
path: "/admin/actions/mcp",
icon: SvgMcp,
title: "MCP Actions",
sidebarLabel: "MCP Actions",
},
OPENAPI_ACTIONS: {
path: "/admin/actions/open-api",
icon: SvgActions,
title: "OpenAPI Actions",
sidebarLabel: "OpenAPI Actions",
},
STANDARD_ANSWERS: {
path: "/admin/standard-answer",
icon: SvgClipboard,
title: "Standard Answers",
sidebarLabel: "Standard Answers",
},
GROUPS: {
path: "/admin/groups",
icon: SvgUsers,
title: "Manage User Groups",
sidebarLabel: "Groups",
},
CHAT_PREFERENCES: {
path: "/admin/configuration/chat-preferences",
icon: SvgBubbleText,
title: "Chat Preferences",
sidebarLabel: "Chat Preferences",
},
// ── System Configuration (unlabeled section) ──────────────────────
LLM_MODELS: {
path: "/admin/configuration/llm",
icon: SvgCpu,
title: "Language Models",
sidebarLabel: "Language Models",
requiredPermission: "manage:llms",
section: "",
requiresEnterprise: false,
visibleWhen: null,
},
WEB_SEARCH: {
path: "/admin/configuration/web-search",
icon: SvgGlobe,
title: "Web Search",
sidebarLabel: "Web Search",
requiredPermission: "admin",
section: "",
requiresEnterprise: false,
visibleWhen: null,
},
IMAGE_GENERATION: {
path: "/admin/configuration/image-generation",
icon: SvgImage,
title: "Image Generation",
sidebarLabel: "Image Generation",
requiredPermission: "admin",
section: "",
requiresEnterprise: false,
visibleWhen: null,
},
VOICE: {
path: "/admin/configuration/voice",
icon: SvgAudio,
title: "Voice",
sidebarLabel: "Voice",
requiredPermission: "admin",
section: "",
requiresEnterprise: false,
visibleWhen: null,
},
CODE_INTERPRETER: {
path: "/admin/configuration/code-interpreter",
icon: SvgTerminal,
title: "Code Interpreter",
sidebarLabel: "Code Interpreter",
requiredPermission: "admin",
section: "",
requiresEnterprise: false,
visibleWhen: null,
},
INDEX_SETTINGS: {
path: "/admin/configuration/search",
icon: SvgSearchMenu,
title: "Index Settings",
sidebarLabel: "Index Settings",
},
DOCUMENT_PROCESSING: {
path: "/admin/configuration/document-processing",
icon: SvgFileText,
title: "Document Processing",
sidebarLabel: "Document Processing",
CHAT_PREFERENCES: {
path: "/admin/configuration/chat-preferences",
icon: SvgBubbleText,
title: "Chat Preferences",
sidebarLabel: "Chat Preferences",
requiredPermission: "admin",
section: "",
requiresEnterprise: false,
visibleWhen: null,
},
KNOWLEDGE_GRAPH: {
path: "/admin/kg",
icon: SvgNetworkGraph,
title: "Knowledge Graph",
sidebarLabel: "Knowledge Graph",
},
USERS: {
path: "/admin/users",
icon: SvgUser,
title: "Users & Requests",
sidebarLabel: "Users",
},
API_KEYS: {
path: "/admin/service-accounts",
icon: SvgUserKey,
title: "Service Accounts",
sidebarLabel: "Service Accounts",
},
TOKEN_RATE_LIMITS: {
path: "/admin/token-rate-limits",
icon: SvgProgressBars,
title: "Spending Limits",
sidebarLabel: "Spending Limits",
},
USAGE: {
path: "/admin/performance/usage",
icon: SvgActivity,
title: "Usage Statistics",
sidebarLabel: "Usage Statistics",
},
QUERY_HISTORY: {
path: "/admin/performance/query-history",
icon: SvgHistory,
title: "Query History",
sidebarLabel: "Query History",
requiredPermission: "admin",
section: "",
requiresEnterprise: false,
visibleWhen: (f: FeatureFlags) => f.vectorDbEnabled && f.kgExposed,
},
CUSTOM_ANALYTICS: {
path: "/admin/performance/custom-analytics",
icon: SvgBarChart,
title: "Custom Analytics",
sidebarLabel: "Custom Analytics",
requiredPermission: "admin",
section: "",
requiresEnterprise: true,
visibleWhen: (f: FeatureFlags) =>
!f.enableCloud && f.customAnalyticsEnabled,
},
THEME: {
path: "/admin/theme",
icon: SvgPaintBrush,
title: "Appearance & Theming",
sidebarLabel: "Appearance & Theming",
// ── Agents & Actions ──────────────────────────────────────────────
AGENTS: {
path: "/admin/agents",
icon: SvgOnyxOctagon,
title: "Agents",
sidebarLabel: "Agents",
requiredPermission: "manage:agents",
section: "Agents & Actions",
requiresEnterprise: false,
visibleWhen: null,
},
BILLING: {
path: "/admin/billing",
icon: SvgWallet,
title: "Plans & Billing",
sidebarLabel: "Plans & Billing",
MCP_ACTIONS: {
path: "/admin/actions/mcp",
icon: SvgMcp,
title: "MCP Actions",
sidebarLabel: "MCP Actions",
requiredPermission: "admin",
section: "Agents & Actions",
requiresEnterprise: false,
visibleWhen: null,
},
OPENAPI_ACTIONS: {
path: "/admin/actions/open-api",
icon: SvgActions,
title: "OpenAPI Actions",
sidebarLabel: "OpenAPI Actions",
requiredPermission: "admin",
section: "Agents & Actions",
requiresEnterprise: false,
visibleWhen: null,
},
// ── Documents & Knowledge ─────────────────────────────────────────
INDEXING_STATUS: {
path: "/admin/indexing/status",
icon: SvgBookOpen,
title: "Existing Connectors",
sidebarLabel: "Existing Connectors",
requiredPermission: "admin",
section: "Documents & Knowledge",
requiresEnterprise: false,
visibleWhen: (f: FeatureFlags) => f.vectorDbEnabled,
},
ADD_CONNECTOR: {
path: "/admin/add-connector",
icon: SvgUploadCloud,
title: "Add Connector",
sidebarLabel: "Add Connector",
requiredPermission: "admin",
section: "Documents & Knowledge",
requiresEnterprise: false,
visibleWhen: (f: FeatureFlags) => f.vectorDbEnabled,
},
DOCUMENT_SETS: {
path: "/admin/documents/sets",
icon: SvgFiles,
title: "Document Sets",
sidebarLabel: "Document Sets",
requiredPermission: "admin",
section: "Documents & Knowledge",
requiresEnterprise: false,
visibleWhen: (f: FeatureFlags) => f.vectorDbEnabled,
},
DOCUMENT_EXPLORER: {
path: "/admin/documents/explorer",
icon: SvgZoomIn,
title: "Document Explorer",
sidebarLabel: "",
requiredPermission: "admin",
section: "Documents & Knowledge",
requiresEnterprise: false,
visibleWhen: (f: FeatureFlags) => f.vectorDbEnabled,
},
DOCUMENT_FEEDBACK: {
path: "/admin/documents/feedback",
icon: SvgThumbsUp,
title: "Document Feedback",
sidebarLabel: "",
requiredPermission: "admin",
section: "Documents & Knowledge",
requiresEnterprise: false,
visibleWhen: (f: FeatureFlags) => f.vectorDbEnabled,
},
INDEX_SETTINGS: {
path: "/admin/configuration/search",
icon: SvgSearchMenu,
title: "Index Settings",
sidebarLabel: "Index Settings",
requiredPermission: "admin",
section: "Documents & Knowledge",
requiresEnterprise: false,
visibleWhen: (f: FeatureFlags) => f.vectorDbEnabled && !f.enableCloud,
},
DOCUMENT_PROCESSING: {
path: "/admin/configuration/document-processing",
icon: SvgFileText,
title: "Document Processing",
sidebarLabel: "",
requiredPermission: "admin",
section: "Documents & Knowledge",
requiresEnterprise: false,
visibleWhen: (f: FeatureFlags) => f.vectorDbEnabled,
},
INDEX_MIGRATION: {
path: "/admin/document-index-migration",
icon: SvgArrowExchange,
title: "Document Index Migration",
sidebarLabel: "Document Index Migration",
requiredPermission: "admin",
section: "Documents & Knowledge",
requiresEnterprise: false,
visibleWhen: (f: FeatureFlags) => f.vectorDbEnabled && f.opensearchEnabled,
},
// ── Integrations ──────────────────────────────────────────────────
API_KEYS: {
path: "/admin/service-accounts",
icon: SvgUserKey,
title: "Service Accounts",
sidebarLabel: "Service Accounts",
requiredPermission: "manage:service_account_api_keys",
section: "Integrations",
requiresEnterprise: false,
visibleWhen: null,
},
SLACK_BOTS: {
path: "/admin/bots",
icon: SvgSlack,
title: "Slack Integration",
sidebarLabel: "Slack Integration",
requiredPermission: "manage:bots",
section: "Integrations",
requiresEnterprise: false,
visibleWhen: null,
},
DISCORD_BOTS: {
path: "/admin/discord-bot",
icon: SvgDiscordMono,
title: "Discord Integration",
sidebarLabel: "Discord Integration",
requiredPermission: "manage:bots",
section: "Integrations",
requiresEnterprise: false,
visibleWhen: null,
},
HOOKS: {
path: "/admin/hooks",
icon: SvgShareWebhook,
title: "Hook Extensions",
sidebarLabel: "Hook Extensions",
requiredPermission: "admin",
section: "Integrations",
requiresEnterprise: false,
visibleWhen: (f: FeatureFlags) => f.hooksEnabled,
},
// ── Permissions ───────────────────────────────────────────────────
USERS: {
path: "/admin/users",
icon: SvgUser,
title: "Users & Requests",
sidebarLabel: "Users",
requiredPermission: "admin",
section: "Permissions",
requiresEnterprise: false,
visibleWhen: null,
},
GROUPS: {
path: "/admin/groups",
icon: SvgUsers,
title: "Manage User Groups",
sidebarLabel: "Groups",
requiredPermission: "admin",
section: "Permissions",
requiresEnterprise: true,
visibleWhen: null,
},
SCIM: {
path: "/admin/scim",
icon: SvgUserSync,
title: "SCIM",
sidebarLabel: "SCIM",
requiredPermission: "admin",
section: "Permissions",
requiresEnterprise: true,
visibleWhen: null,
},
// ── Organization ──────────────────────────────────────────────────
BILLING: {
path: "/admin/billing",
icon: SvgWallet,
title: "Plans & Billing",
sidebarLabel: "Plans & Billing",
requiredPermission: "admin",
section: "Organization",
requiresEnterprise: false,
visibleWhen: (f: FeatureFlags) => f.hasSubscription,
},
TOKEN_RATE_LIMITS: {
path: "/admin/token-rate-limits",
icon: SvgProgressBars,
title: "Spending Limits",
sidebarLabel: "Spending Limits",
requiredPermission: "admin",
section: "Organization",
requiresEnterprise: true,
visibleWhen: null,
},
THEME: {
path: "/admin/theme",
icon: SvgPaintBrush,
title: "Appearance & Theming",
sidebarLabel: "Appearance & Theming",
requiredPermission: "admin",
section: "Organization",
requiresEnterprise: true,
visibleWhen: null,
},
// ── Usage ─────────────────────────────────────────────────────────
USAGE: {
path: "/admin/performance/usage",
icon: SvgActivity,
title: "Usage Statistics",
sidebarLabel: "Usage Statistics",
requiredPermission: "admin",
section: "Usage",
requiresEnterprise: true,
visibleWhen: null,
},
QUERY_HISTORY: {
path: "/admin/performance/query-history",
icon: SvgHistory,
title: "Query History",
sidebarLabel: "Query History",
requiredPermission: "read:query_history",
section: "Usage",
requiresEnterprise: true,
visibleWhen: (f: FeatureFlags) => f.queryHistoryEnabled,
},
// ── Other (admin-only) ────────────────────────────────────────────
STANDARD_ANSWERS: {
path: "/admin/standard-answer",
icon: SvgClipboard,
title: "Standard Answers",
sidebarLabel: "",
requiredPermission: "admin",
section: "",
requiresEnterprise: false,
visibleWhen: null,
},
DEBUG: {
path: "/admin/debug",
icon: SvgDownload,
title: "Debug Logs",
sidebarLabel: "Debug Logs",
sidebarLabel: "",
requiredPermission: "admin",
section: "",
requiresEnterprise: false,
visibleWhen: null,
},
// Prefix-only entries used for layout matching — not rendered as sidebar
// items or page headers.
// ── Prefix-only entries (layout matching, not sidebar items) ──────
DOCUMENTS: {
path: "/admin/documents",
icon: SvgEmpty,
title: "",
sidebarLabel: "",
requiredPermission: "admin",
section: "",
requiresEnterprise: false,
visibleWhen: null,
},
PERFORMANCE: {
path: "/admin/performance",
icon: SvgEmpty,
title: "",
sidebarLabel: "",
requiredPermission: "admin",
section: "",
requiresEnterprise: false,
visibleWhen: null,
},
} as const satisfies Record<string, AdminRouteEntry>;

View File

@@ -0,0 +1,75 @@
import { IconFunctionComponent } from "@opal/types";
import { SvgArrowUpCircle } from "@opal/icons";
import {
ADMIN_ROUTES,
AdminRouteEntry,
FeatureFlags,
sidebarItem,
} from "@/lib/admin-routes";
import { hasPermission } from "@/lib/permissions";
import { CombinedSettings } from "@/interfaces/settings";
export type { FeatureFlags } from "@/lib/admin-routes";
export interface SidebarItemEntry {
section: string;
name: string;
icon: IconFunctionComponent;
link: string;
error?: boolean;
disabled?: boolean;
}
export function buildItems(
permissions: string[],
flags: FeatureFlags,
settings: CombinedSettings | null
): SidebarItemEntry[] {
const can = (perm: string) => hasPermission(permissions, perm);
const items: SidebarItemEntry[] = [];
for (const route of Object.values(ADMIN_ROUTES) as AdminRouteEntry[]) {
if (!route.sidebarLabel) continue;
if (!can(route.requiredPermission)) continue;
if (route.visibleWhen && !route.visibleWhen(flags)) continue;
const item: SidebarItemEntry = {
...sidebarItem(route),
section: route.section,
disabled: route.requiresEnterprise && !flags.enableEnterprise,
};
// Special case: INDEX_SETTINGS shows reindexing error indicator
if (route.path === ADMIN_ROUTES.INDEX_SETTINGS.path) {
item.error = settings?.settings.needs_reindexing;
}
items.push(item);
}
// Upgrade Plan — only for full admins without a subscription
if (can("admin") && !flags.hasSubscription) {
items.push({
section: "",
name: "Upgrade Plan",
icon: SvgArrowUpCircle,
link: ADMIN_ROUTES.BILLING.path,
});
}
return items;
}
/** Preserve section ordering while grouping consecutive items by section. */
export function groupBySection(items: SidebarItemEntry[]) {
const groups: { section: string; items: SidebarItemEntry[] }[] = [];
for (const item of items) {
const last = groups[groups.length - 1];
if (last && last.section === item.section) {
last.items.push(item);
} else {
groups.push({ section: item.section, items: [item] });
}
}
return groups;
}

View File

@@ -5,6 +5,7 @@ import {
getCurrentUserSS,
} from "@/lib/userSS";
import { AuthType } from "@/lib/constants";
import { hasAnyAdminPermission } from "@/lib/permissions";
/**
* Result of an authentication check.
@@ -71,13 +72,6 @@ export async function requireAuth(): Promise<AuthCheckResult> {
};
}
// Allowlist of roles that can access admin pages (all roles except BASIC)
const ADMIN_ALLOWED_ROLES = [
UserRole.ADMIN,
UserRole.CURATOR,
UserRole.GLOBAL_CURATOR,
];
/**
* Requires that the user is authenticated AND has admin role.
* If not authenticated, redirects to login.
@@ -106,8 +100,12 @@ export async function requireAdminAuth(): Promise<AuthCheckResult> {
const { user, authTypeMetadata } = authResult;
// Check if user has an allowed role
if (user && !ADMIN_ALLOWED_ROLES.includes(user.role)) {
// Check if user has admin role or any admin permission via groups
if (
user &&
user.role !== UserRole.ADMIN &&
!hasAnyAdminPermission(user.effective_permissions ?? [])
) {
return {
user,
authTypeMetadata,

View File

@@ -0,0 +1,36 @@
import { ADMIN_ROUTES } from "@/lib/admin-routes";
// Derived from ADMIN_ROUTES — no hardcoded list to maintain.
// "admin" is the full-access override token, not a regular permission.
const ADMIN_ROUTE_PERMISSIONS: Set<string> = new Set(
Object.values(ADMIN_ROUTES)
.map((r) => r.requiredPermission)
.filter((p) => p !== "admin")
);
export function hasAnyAdminPermission(permissions: string[]): boolean {
if (permissions.includes("admin")) return true;
return permissions.some((p) => ADMIN_ROUTE_PERMISSIONS.has(p));
}
export function hasPermission(
permissions: string[],
...required: string[]
): boolean {
if (permissions.includes("admin")) return true;
return required.some((r) => permissions.includes(r));
}
export function getFirstPermittedAdminRoute(permissions: string[]): string {
for (const route of Object.values(ADMIN_ROUTES)) {
if (!route.sidebarLabel) continue;
if (
permissions.includes("admin") ||
permissions.includes(route.requiredPermission)
) {
return route.path;
}
}
// Fallback — should not be reached if hasAdminAccess is checked first
return ADMIN_ROUTES.AGENTS.path;
}

View File

@@ -94,6 +94,9 @@ export const SWR_KEYS = {
// ── Groups ────────────────────────────────────────────────────────────────
adminUserGroups: "/api/manage/admin/user-group",
shareableGroups: "/api/manage/user-groups/minimal",
userGroupPermissions: (groupId: number) =>
`/api/manage/admin/user-group/${groupId}/permissions`,
permissionRegistry: "/api/manage/admin/permissions/registry",
scimToken: "/api/admin/enterprise-settings/scim/token",
// ── MCP Servers ───────────────────────────────────────────────────────────

View File

@@ -126,6 +126,7 @@ export interface User {
password_configured?: boolean;
tenant_info?: TenantInfo | null;
personalization?: UserPersonalization;
effective_permissions?: string[];
}
export interface TenantInfo {

View File

@@ -15,6 +15,7 @@ import {
UserRole,
ThemePreference,
} from "@/lib/types";
import { hasAnyAdminPermission } from "@/lib/permissions";
import { usePostHog } from "posthog-js/react";
import { SettingsContext } from "@/providers/SettingsProvider";
import { useTokenRefresh } from "@/hooks/useTokenRefresh";
@@ -26,10 +27,14 @@ import {
import { updateUserPersonalization as persistPersonalization } from "@/lib/userSettings";
import { useTheme } from "next-themes";
const EMPTY_PERMISSIONS: string[] = [];
interface UserContextType {
user: User | null;
isAdmin: boolean;
isCurator: boolean;
hasAdminAccess: boolean;
permissions: string[];
refreshUser: () => Promise<void>;
isCloudSuperuser: boolean;
authTypeMetadata: AuthTypeMetadata;
@@ -523,6 +528,10 @@ export function UserProvider({ children }: { children: React.ReactNode }) {
isCurator:
upToDateUser?.role === UserRole.CURATOR ||
upToDateUser?.role === UserRole.GLOBAL_CURATOR,
hasAdminAccess: hasAnyAdminPermission(
upToDateUser?.effective_permissions ?? EMPTY_PERMISSIONS
),
permissions: upToDateUser?.effective_permissions ?? EMPTY_PERMISSIONS,
isCloudSuperuser: upToDateUser?.is_cloud_superuser ?? false,
}}
>

View File

@@ -3,6 +3,7 @@
import { useMemo, useState, useRef, useEffect } from "react";
import AgentCard from "@/sections/cards/AgentCard";
import { useUser } from "@/providers/UserProvider";
import { hasPermission } from "@/lib/permissions";
import { checkUserOwnsAgent as checkUserOwnsAgent } from "@/lib/agents";
import { useAgents } from "@/hooks/useAgents";
import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces";
@@ -66,7 +67,8 @@ export default function AgentsNavigationPage() {
const { agents } = useAgents();
const [creatorFilterOpen, setCreatorFilterOpen] = useState(false);
const [actionsFilterOpen, setActionsFilterOpen] = useState(false);
const { user } = useUser();
const { user, permissions } = useUser();
const canCreateAgent = hasPermission(permissions, "add:agents");
const [searchQuery, setSearchQuery] = useState("");
const [activeTab, setActiveTab] = useState<"all" | "your">("all");
const [selectedCreatorIds, setSelectedCreatorIds] = useState<Set<string>>(
@@ -427,9 +429,15 @@ export default function AgentsNavigationPage() {
description="Customize AI behavior and knowledge for you and your team's use cases."
rightChildren={
<Button
href="/app/agents/create"
href={canCreateAgent ? "/app/agents/create" : undefined}
icon={SvgPlus}
aria-label="AgentsPage/new-agent-button"
disabled={!canCreateAgent}
tooltip={
!canCreateAgent
? "You don't have permission to create agents. Contact your admin to request access."
: undefined
}
>
New Agent
</Button>

View File

@@ -12,6 +12,7 @@ import {
SvgKey,
SvgLock,
SvgMinusCircle,
SvgPlusCircle,
SvgTrash,
SvgUnplug,
} from "@opal/icons";
@@ -35,7 +36,6 @@ import useSWR from "swr";
import { SWR_KEYS } from "@/lib/swr-keys";
import { errorHandlingFetcher } from "@/lib/fetcher";
import useFilter from "@/hooks/useFilter";
import CreateButton from "@/refresh-components/buttons/CreateButton";
import { Button } from "@opal/components";
import useFederatedOAuthStatus from "@/hooks/useFederatedOAuthStatus";
import useCCPairs from "@/hooks/useCCPairs";
@@ -63,6 +63,7 @@ import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidE
import { useSettingsContext } from "@/providers/SettingsProvider";
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
import { useCloudSubscription } from "@/hooks/useCloudSubscription";
import { hasPermission } from "@/lib/permissions";
interface PAT {
id: number;
@@ -1040,7 +1041,7 @@ function ChatPreferencesSettings() {
}
function AccountsAccessSettings() {
const { user, authTypeMetadata } = useUser();
const { user, authTypeMetadata, permissions } = useUser();
const authType = useAuthType();
const [showPasswordModal, setShowPasswordModal] = useState(false);
@@ -1067,18 +1068,18 @@ function AccountsAccessSettings() {
const [tokenToDelete, setTokenToDelete] = useState<PAT | null>(null);
const canCreateTokens = useCloudSubscription();
const canCreatePAT = hasPermission(permissions, "create:user_api_keys");
const showPasswordSection = Boolean(user?.password_configured);
const showTokensSection = authType !== null;
// Fetch PATs with SWR
// Fetch PATs with SWR — always fetch when auth is available
const {
data: pats = [],
mutate,
error,
isLoading,
} = useSWR<PAT[]>(
showTokensSection ? SWR_KEYS.userPats : null,
authType !== null ? SWR_KEYS.userPats : null,
errorHandlingFetcher,
{
revalidateOnFocus: true,
@@ -1087,6 +1088,10 @@ function AccountsAccessSettings() {
}
);
// Hide the section entirely if user has no permission AND no existing tokens
const showTokensSection =
authType !== null && (canCreatePAT || pats.length > 0);
// Use filter hook for searching tokens
const {
query,
@@ -1410,15 +1415,19 @@ function AccountsAccessSettings() {
variant="internal"
/>
)}
<CreateButton
<Button
onClick={() => setShowCreateModal(true)}
secondary={false}
internal
transient={showCreateModal}
rightIcon
prominence="secondary"
rightIcon={SvgPlusCircle}
disabled={!canCreatePAT}
tooltip={
!canCreatePAT
? "You don't have permission to create access tokens"
: undefined
}
>
New Access Token
</CreateButton>
</Button>
</Section>
<Section gap={0.25}>

View File

@@ -19,9 +19,11 @@ import {
updateAgentGroupSharing,
updateDocSetGroupSharing,
saveTokenLimits,
saveGroupPermissions,
} from "./svc";
import { memberTableColumns, PAGE_SIZE } from "./shared";
import SharedGroupResources from "@/refresh-pages/admin/GroupsPage/SharedGroupResources";
import GroupPermissionsSection from "./GroupPermissionsSection";
import TokenLimitSection from "./TokenLimitSection";
import type { TokenLimit } from "./TokenLimitSection";
@@ -34,6 +36,9 @@ function CreateGroupPage() {
const [selectedCcPairIds, setSelectedCcPairIds] = useState<number[]>([]);
const [selectedDocSetIds, setSelectedDocSetIds] = useState<number[]>([]);
const [selectedAgentIds, setSelectedAgentIds] = useState<number[]>([]);
const [enabledPermissions, setEnabledPermissions] = useState<Set<string>>(
new Set()
);
const [tokenLimits, setTokenLimits] = useState<TokenLimit[]>([
{ tokenBudget: null, periodHours: null },
]);
@@ -54,6 +59,7 @@ function CreateGroupPage() {
selectedUserIds,
selectedCcPairIds
);
await saveGroupPermissions(groupId, enabledPermissions);
await updateAgentGroupSharing(groupId, [], selectedAgentIds);
await updateDocSetGroupSharing(groupId, [], selectedDocSetIds);
await saveTokenLimits(groupId, tokenLimits, []);
@@ -153,6 +159,11 @@ function CreateGroupPage() {
/>
</Section>
)}
<GroupPermissionsSection
enabledPermissions={enabledPermissions}
onPermissionsChange={setEnabledPermissions}
/>
<SharedGroupResources
selectedCcPairIds={selectedCcPairIds}
onCcPairIdsChange={setSelectedCcPairIds}

View File

@@ -30,9 +30,11 @@ import {
updateAgentGroupSharing,
updateDocSetGroupSharing,
saveTokenLimits,
saveGroupPermissions,
} from "./svc";
import { SWR_KEYS } from "@/lib/swr-keys";
import SharedGroupResources from "@/refresh-pages/admin/GroupsPage/SharedGroupResources";
import GroupPermissionsSection from "./GroupPermissionsSection";
import TokenLimitSection from "./TokenLimitSection";
import type { TokenLimit } from "./TokenLimitSection";
@@ -75,6 +77,11 @@ function EditGroupPage({ groupId }: EditGroupPageProps) {
TokenRateLimitDisplay[]
>(SWR_KEYS.userGroupTokenRateLimit(groupId), errorHandlingFetcher);
// Fetch permissions for this group
const { data: groupPermissions, isLoading: permissionsLoading } = useSWR<
string[]
>(SWR_KEYS.userGroupPermissions(groupId), errorHandlingFetcher);
// Form state
const [groupName, setGroupName] = useState("");
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
@@ -87,6 +94,9 @@ function EditGroupPage({ groupId }: EditGroupPageProps) {
const [tokenLimits, setTokenLimits] = useState<TokenLimit[]>([
{ tokenBudget: null, periodHours: null },
]);
const [enabledPermissions, setEnabledPermissions] = useState<Set<string>>(
new Set()
);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [initialized, setInitialized] = useState(false);
@@ -101,7 +111,11 @@ function EditGroupPage({ groupId }: EditGroupPageProps) {
error: candidatesError,
} = useGroupMemberCandidates();
const isLoading = groupLoading || candidatesLoading || tokenLimitsLoading;
const isLoading =
groupLoading ||
candidatesLoading ||
tokenLimitsLoading ||
permissionsLoading;
const error = groupError ?? candidatesError;
// Pre-populate form when group data loads
@@ -132,6 +146,13 @@ function EditGroupPage({ groupId }: EditGroupPageProps) {
}
}, [tokenRateLimits]);
// Pre-populate permissions when fetched
useEffect(() => {
if (groupPermissions) {
setEnabledPermissions(new Set(groupPermissions));
}
}, [groupPermissions]);
const memberRows = useMemo(() => {
const selected = new Set(selectedUserIds);
return allRows.filter((r) => selected.has(r.id ?? r.email));
@@ -233,6 +254,9 @@ function EditGroupPage({ groupId }: EditGroupPageProps) {
selectedDocSetIds
);
// Save permissions (bulk desired-state)
await saveGroupPermissions(groupId, enabledPermissions);
// Save token rate limits (create/update/delete)
await saveTokenLimits(groupId, tokenLimits, tokenRateLimits ?? []);
@@ -242,6 +266,7 @@ function EditGroupPage({ groupId }: EditGroupPageProps) {
mutate(SWR_KEYS.adminUserGroups);
mutate(SWR_KEYS.userGroupTokenRateLimit(groupId));
mutate(SWR_KEYS.userGroupPermissions(groupId));
toast.success(`Group "${trimmed}" updated`);
router.push("/admin/groups");
} catch (e) {
@@ -431,6 +456,11 @@ function EditGroupPage({ groupId }: EditGroupPageProps) {
)}
</Section>
<GroupPermissionsSection
enabledPermissions={enabledPermissions}
onPermissionsChange={setEnabledPermissions}
/>
<SharedGroupResources
selectedCcPairIds={selectedCcPairIds}
onCcPairIdsChange={setSelectedCcPairIds}

View File

@@ -0,0 +1,133 @@
"use client";
import { Fragment } from "react";
import useSWR from "swr";
import { ContentAction } from "@opal/layouts";
import {
SvgSettings,
SvgPlug,
SvgActions,
SvgUsers,
SvgUserKey,
SvgSlack,
SvgPlusCircle,
SvgUserManage,
SvgBarChart,
SvgHistory,
SvgKey,
SvgShield,
SvgCpu,
SvgFiles,
SvgCreateAgent,
SvgManageAgent,
} from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
import Card from "@/refresh-components/cards/Card";
import Switch from "@/refresh-components/inputs/Switch";
import Separator from "@/refresh-components/Separator";
import SimpleCollapsible from "@/refresh-components/SimpleCollapsible";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { SWR_KEYS } from "@/lib/swr-keys";
import type { PermissionRegistryEntry } from "@/refresh-pages/admin/GroupsPage/interfaces";
// ---------------------------------------------------------------------------
// Icon mapping — the only permission metadata maintained in the frontend.
// The `id` keys must match the backend PERMISSION_REGISTRY entries.
// ---------------------------------------------------------------------------
const ICON_MAP: Record<string, IconFunctionComponent> = {
manage_llms: SvgCpu,
manage_connectors_and_document_sets: SvgFiles,
manage_actions: SvgActions,
manage_groups: SvgUsers,
manage_service_accounts: SvgUserKey,
manage_bots: SvgSlack,
create_agents: SvgCreateAgent,
manage_agents: SvgManageAgent,
view_agent_analytics: SvgBarChart,
view_query_history: SvgHistory,
create_user_access_token: SvgKey,
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface GroupPermissionsSectionProps {
enabledPermissions: Set<string>;
onPermissionsChange: (permissions: Set<string>) => void;
}
function GroupPermissionsSection({
enabledPermissions,
onPermissionsChange,
}: GroupPermissionsSectionProps) {
const { data: registry, isLoading } = useSWR<PermissionRegistryEntry[]>(
SWR_KEYS.permissionRegistry,
errorHandlingFetcher
);
function isRowEnabled(entry: PermissionRegistryEntry): boolean {
return entry.permissions.every((p) => enabledPermissions.has(p));
}
function handleToggle(entry: PermissionRegistryEntry, checked: boolean) {
const next = new Set(enabledPermissions);
for (const perm of entry.permissions) {
if (checked) {
next.add(perm);
} else {
next.delete(perm);
}
}
onPermissionsChange(next);
}
return (
<SimpleCollapsible>
<SimpleCollapsible.Header
title="Group Permissions"
description="Set access and permissions for members of this group."
/>
<SimpleCollapsible.Content>
{isLoading || !registry ? (
<SimpleLoader />
) : (
<Card>
{registry.map((entry, index) => {
const prevGroup =
index > 0 ? registry[index - 1]!.group : entry.group;
const icon = ICON_MAP[entry.id] ?? SvgShield;
return (
<Fragment key={entry.id}>
{index > 0 && entry.group !== prevGroup && (
<Separator noPadding />
)}
<ContentAction
icon={icon}
title={entry.display_name}
description={entry.description}
sizePreset="main-ui"
variant="section"
paddingVariant="md"
rightChildren={
<Switch
checked={isRowEnabled(entry)}
onCheckedChange={(checked) =>
handleToggle(entry, checked)
}
/>
}
/>
</Fragment>
);
})}
</Card>
)}
</SimpleCollapsible.Content>
</SimpleCollapsible>
);
}
export default GroupPermissionsSection;

View File

@@ -20,3 +20,12 @@ export interface TokenRateLimitDisplay {
token_budget: number;
period_hours: number;
}
/** Mirrors backend PermissionRegistryEntry from onyx.auth.permissions. */
export interface PermissionRegistryEntry {
id: string;
display_name: string;
description: string;
permissions: string[];
group: number;
}

View File

@@ -281,6 +281,27 @@ async function saveTokenLimits(
}
}
// ---------------------------------------------------------------------------
// Group permissions — bulk set desired permissions in a single request
// ---------------------------------------------------------------------------
async function saveGroupPermissions(
groupId: number,
enabledPermissions: Set<string>
): Promise<void> {
const res = await fetch(`${USER_GROUP_URL}/${groupId}/permissions`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ permissions: Array.from(enabledPermissions) }),
});
if (!res.ok) {
const detail = await res.json().catch(() => null);
throw new Error(
detail?.detail ?? `Failed to update permissions: ${res.statusText}`
);
}
}
export {
renameGroup,
createGroup,
@@ -289,4 +310,5 @@ export {
updateAgentGroupSharing,
updateDocSetGroupSharing,
saveTokenLimits,
saveGroupPermissions,
};

View File

@@ -16,182 +16,25 @@ import { useSidebarFolded } from "@/layouts/sidebar-layouts";
import { useIsKGExposed } from "@/app/admin/kg/utils";
import { useCustomAnalyticsEnabled } from "@/lib/hooks/useCustomAnalyticsEnabled";
import { useUser } from "@/providers/UserProvider";
import { UserRole } from "@/lib/types";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { CombinedSettings } from "@/interfaces/settings";
import { SidebarTab } from "@opal/components";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import Separator from "@/refresh-components/Separator";
import Spacer from "@/refresh-components/Spacer";
import { SvgArrowUpCircle, SvgSearch, SvgX } from "@opal/icons";
import { SvgSearch, SvgX } from "@opal/icons";
import {
useBillingInformation,
useLicense,
hasActiveSubscription,
} from "@/lib/billing";
import { ADMIN_ROUTES, sidebarItem } from "@/lib/admin-routes";
import useFilter from "@/hooks/useFilter";
import { IconFunctionComponent } from "@opal/types";
import AccountPopover from "@/sections/sidebar/AccountPopover";
const SECTIONS = {
UNLABELED: "",
AGENTS_AND_ACTIONS: "Agents & Actions",
DOCUMENTS_AND_KNOWLEDGE: "Documents & Knowledge",
INTEGRATIONS: "Integrations",
PERMISSIONS: "Permissions",
ORGANIZATION: "Organization",
USAGE: "Usage",
} as const;
interface SidebarItemEntry {
section: string;
name: string;
icon: IconFunctionComponent;
link: string;
error?: boolean;
disabled?: boolean;
}
function buildItems(
isCurator: boolean,
enableCloud: boolean,
enableEnterprise: boolean,
settings: CombinedSettings | null,
kgExposed: boolean,
customAnalyticsEnabled: boolean,
hasSubscription: boolean,
hooksEnabled: boolean
): SidebarItemEntry[] {
const vectorDbEnabled = settings?.settings.vector_db_enabled !== false;
const items: SidebarItemEntry[] = [];
const add = (section: string, route: Parameters<typeof sidebarItem>[0]) => {
items.push({ ...sidebarItem(route), section });
};
const addDisabled = (
section: string,
route: Parameters<typeof sidebarItem>[0],
isDisabled: boolean
) => {
items.push({ ...sidebarItem(route), section, disabled: isDisabled });
};
// 1. No header — core configuration (admin only)
if (!isCurator) {
add(SECTIONS.UNLABELED, ADMIN_ROUTES.LLM_MODELS);
add(SECTIONS.UNLABELED, ADMIN_ROUTES.WEB_SEARCH);
add(SECTIONS.UNLABELED, ADMIN_ROUTES.IMAGE_GENERATION);
add(SECTIONS.UNLABELED, ADMIN_ROUTES.VOICE);
add(SECTIONS.UNLABELED, ADMIN_ROUTES.CODE_INTERPRETER);
add(SECTIONS.UNLABELED, ADMIN_ROUTES.CHAT_PREFERENCES);
if (vectorDbEnabled && kgExposed) {
add(SECTIONS.UNLABELED, ADMIN_ROUTES.KNOWLEDGE_GRAPH);
}
if (!enableCloud && customAnalyticsEnabled) {
addDisabled(
SECTIONS.UNLABELED,
ADMIN_ROUTES.CUSTOM_ANALYTICS,
!enableEnterprise
);
}
}
// 2. Agents & Actions
add(SECTIONS.AGENTS_AND_ACTIONS, ADMIN_ROUTES.AGENTS);
add(SECTIONS.AGENTS_AND_ACTIONS, ADMIN_ROUTES.MCP_ACTIONS);
add(SECTIONS.AGENTS_AND_ACTIONS, ADMIN_ROUTES.OPENAPI_ACTIONS);
// 3. Documents & Knowledge
if (vectorDbEnabled) {
add(SECTIONS.DOCUMENTS_AND_KNOWLEDGE, ADMIN_ROUTES.INDEXING_STATUS);
add(SECTIONS.DOCUMENTS_AND_KNOWLEDGE, ADMIN_ROUTES.ADD_CONNECTOR);
add(SECTIONS.DOCUMENTS_AND_KNOWLEDGE, ADMIN_ROUTES.DOCUMENT_SETS);
if (!isCurator && !enableCloud) {
items.push({
...sidebarItem(ADMIN_ROUTES.INDEX_SETTINGS),
section: SECTIONS.DOCUMENTS_AND_KNOWLEDGE,
error: settings?.settings.needs_reindexing,
});
}
if (!isCurator && settings?.settings.opensearch_indexing_enabled) {
add(SECTIONS.DOCUMENTS_AND_KNOWLEDGE, ADMIN_ROUTES.INDEX_MIGRATION);
}
}
// 4. Integrations (admin only)
if (!isCurator) {
add(SECTIONS.INTEGRATIONS, ADMIN_ROUTES.API_KEYS);
add(SECTIONS.INTEGRATIONS, ADMIN_ROUTES.SLACK_BOTS);
add(SECTIONS.INTEGRATIONS, ADMIN_ROUTES.DISCORD_BOTS);
if (hooksEnabled) {
add(SECTIONS.INTEGRATIONS, ADMIN_ROUTES.HOOKS);
}
}
// 5. Permissions
if (!isCurator) {
add(SECTIONS.PERMISSIONS, ADMIN_ROUTES.USERS);
addDisabled(SECTIONS.PERMISSIONS, ADMIN_ROUTES.GROUPS, !enableEnterprise);
addDisabled(SECTIONS.PERMISSIONS, ADMIN_ROUTES.SCIM, !enableEnterprise);
} else if (enableEnterprise) {
add(SECTIONS.PERMISSIONS, ADMIN_ROUTES.GROUPS);
}
// 6. Organization (admin only)
if (!isCurator) {
if (hasSubscription) {
add(SECTIONS.ORGANIZATION, ADMIN_ROUTES.BILLING);
}
addDisabled(
SECTIONS.ORGANIZATION,
ADMIN_ROUTES.TOKEN_RATE_LIMITS,
!enableEnterprise
);
addDisabled(SECTIONS.ORGANIZATION, ADMIN_ROUTES.THEME, !enableEnterprise);
}
// 7. Usage (admin only)
if (!isCurator) {
addDisabled(SECTIONS.USAGE, ADMIN_ROUTES.USAGE, !enableEnterprise);
if (settings?.settings.query_history_type !== "disabled") {
addDisabled(
SECTIONS.USAGE,
ADMIN_ROUTES.QUERY_HISTORY,
!enableEnterprise
);
}
}
// 8. Upgrade Plan (admin only, no subscription)
if (!isCurator && !hasSubscription) {
items.push({
section: SECTIONS.UNLABELED,
name: "Upgrade Plan",
icon: SvgArrowUpCircle,
link: ADMIN_ROUTES.BILLING.path,
});
}
return items;
}
/** Preserve section ordering while grouping consecutive items by section. */
function groupBySection(items: SidebarItemEntry[]) {
const groups: { section: string; items: SidebarItemEntry[] }[] = [];
for (const item of items) {
const last = groups[groups.length - 1];
if (last && last.section === item.section) {
last.items.push(item);
} else {
groups.push({ section: item.section, items: [item] });
}
}
return groups;
}
import {
buildItems,
groupBySection,
type FeatureFlags,
type SidebarItemEntry,
} from "@/lib/admin-sidebar-utils";
interface AdminSidebarProps {
enableCloudSS: boolean;
@@ -221,14 +64,12 @@ function AdminSidebarInner({
const { kgExposed } = useIsKGExposed();
const pathname = usePathname();
const { customAnalyticsEnabled } = useCustomAnalyticsEnabled();
const { user } = useUser();
const { permissions } = useUser();
const settings = useSettingsContext();
const enableEnterprise = usePaidEnterpriseFeaturesEnabled();
const { data: billingData, isLoading: billingLoading } =
useBillingInformation();
const { data: licenseData, isLoading: licenseLoading } = useLicense();
const isCurator =
user?.role === UserRole.CURATOR || user?.role === UserRole.GLOBAL_CURATOR;
// Default to true while loading to avoid flashing "Upgrade Plan"
const hasSubscriptionOrLicense =
billingLoading || licenseLoading
@@ -237,19 +78,21 @@ function AdminSidebarInner({
(billingData && hasActiveSubscription(billingData)) ||
licenseData?.has_license
);
const hooksEnabled =
enableEnterprise && (settings?.settings.hooks_enabled ?? false);
const allItems = buildItems(
isCurator,
enableCloudSS,
enableEnterprise,
settings,
const flags: FeatureFlags = {
vectorDbEnabled: settings?.settings.vector_db_enabled !== false,
kgExposed,
enableCloud: enableCloudSS,
enableEnterprise,
customAnalyticsEnabled,
hasSubscriptionOrLicense,
hooksEnabled
);
hasSubscription: hasSubscriptionOrLicense,
hooksEnabled:
enableEnterprise && (settings?.settings.hooks_enabled ?? false),
opensearchEnabled: settings?.settings.opensearch_indexing_enabled ?? false,
queryHistoryEnabled: settings?.settings.query_history_type !== "disabled",
};
const allItems = buildItems(permissions, flags, settings);
const itemExtractor = useCallback((item: SidebarItemEntry) => item.name, []);

View File

@@ -55,6 +55,7 @@ import { showErrorNotification, handleMoveOperation } from "./sidebarUtils";
import { SidebarTab } from "@opal/components";
import { ChatSession } from "@/app/app/interfaces";
import { useUser } from "@/providers/UserProvider";
import { getFirstPermittedAdminRoute } from "@/lib/permissions";
import useAppFocus from "@/hooks/useAppFocus";
import { useCreateModal } from "@/refresh-components/contexts/ModalContext";
import { useModalContext } from "@/components/context/ModalContext";
@@ -479,7 +480,7 @@ const MemoizedAppSidebarInner = memo(function AppSidebarInner() {
]
);
const { isAdmin, isCurator, user } = useUser();
const { isAdmin, isCurator, hasAdminAccess, permissions, user } = useUser();
const activeSidebarTab = useAppFocus();
const createProjectModal = useCreateModal();
const defaultAppMode =
@@ -584,13 +585,13 @@ const MemoizedAppSidebarInner = memo(function AppSidebarInner() {
const settingsButton = useMemo(
() => (
<div>
{(isAdmin || isCurator) && (
{hasAdminAccess && (
<SidebarTab
href={isCurator ? "/admin/agents" : "/admin/configuration/llm"}
href={getFirstPermittedAdminRoute(permissions)}
icon={SvgSettings}
folded={folded}
>
{isAdmin ? "Admin Panel" : "Curator Panel"}
Admin Panel
</SidebarTab>
)}
<AccountPopover
@@ -601,7 +602,13 @@ const MemoizedAppSidebarInner = memo(function AppSidebarInner() {
/>
</div>
),
[folded, isAdmin, isCurator, handleShowBuildIntro, isOnyxCraftEnabled]
[
folded,
hasAdminAccess,
permissions,
handleShowBuildIntro,
isOnyxCraftEnabled,
]
);
return (