mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-21 01:16:45 +00:00
Compare commits
13 Commits
v3.2.0-clo
...
permission
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acfa30f865 | ||
|
|
606ab55d73 | ||
|
|
6223ba531b | ||
|
|
6f6e64ad63 | ||
|
|
a05f09faa2 | ||
|
|
5912f632a3 | ||
|
|
3e5cfa66d1 | ||
|
|
b2f5eb3ec7 | ||
|
|
0ab2b8065d | ||
|
|
4c304bf393 | ||
|
|
cef5caa8b1 | ||
|
|
f7b8650d5c | ||
|
|
df532aa87d |
@@ -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()
|
||||
]
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -132,3 +132,7 @@ class SetPermissionRequest(BaseModel):
|
||||
class SetPermissionResponse(BaseModel):
|
||||
permission: Permission
|
||||
enabled: bool
|
||||
|
||||
|
||||
class BulkSetPermissionsRequest(BaseModel):
|
||||
permissions: list[Permission]
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
27
web/lib/opal/src/icons/create-agent.tsx
Normal file
27
web/lib/opal/src/icons/create-agent.tsx
Normal 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;
|
||||
@@ -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";
|
||||
|
||||
27
web/lib/opal/src/icons/manage-agent.tsx
Normal file
27
web/lib/opal/src/icons/manage-agent.tsx
Normal 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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
75
web/src/lib/admin-sidebar-utils.ts
Normal file
75
web/src/lib/admin-sidebar-utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
36
web/src/lib/permissions.ts
Normal file
36
web/src/lib/permissions.ts
Normal 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;
|
||||
}
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -126,6 +126,7 @@ export interface User {
|
||||
password_configured?: boolean;
|
||||
tenant_info?: TenantInfo | null;
|
||||
personalization?: UserPersonalization;
|
||||
effective_permissions?: string[];
|
||||
}
|
||||
|
||||
export interface TenantInfo {
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user