Compare commits

...

25 Commits

Author SHA1 Message Date
SubashMohan
751e3fb043 feat(permissions): update user access requirement for listing all users to 'READ_USERS' permission 2026-04-13 17:26:58 +05:30
SubashMohan
3c0f7d9587 feat(permissions): update user permissions to require 'FULL_ADMIN_PANEL_ACCESS' for document management endpoints 2026-04-13 17:20:22 +05:30
SubashMohan
7b48722798 feat(permissions): update ingestion API to require 'manage:connectors' permission for access 2026-04-13 17:15:24 +05:30
SubashMohan
ebbd444c92 feat(permissions): update user permissions for document management and error handling 2026-04-13 17:08:16 +05:30
SubashMohan
c18be46ee0 feat(permissions): update user permissions to require 'manage:connectors' for OAuth callback endpoints 2026-04-13 16:57:43 +05:30
SubashMohan
4c0e58eab7 refactor(token-limit): rename function to fetch_user_group_token_rate_limits_for_group and update related API usage 2026-04-13 16:37:01 +05:30
SubashMohan
ac3bbdbac9 feat(permissions): update user group permissions to require 'manage:user_groups' 2026-04-13 16:32:27 +05:30
SubashMohan
ca4299db92 refactor(user-group): remove unused curator-related functions and properties
refactor(api): replace HTTPException with OnyxError for better error handling
refactor(users): simplify role update logic by removing curator status handling
2026-04-13 16:06:28 +05:30
SubashMohan
ec1cc44703 feat(permissions): update permission checks for user actions and replace HTTPException with OnyxError 2026-04-13 15:33:55 +05:30
SubashMohan
84bf5060dd feat(admin-routes): update required permissions for admin routes to more specific actions 2026-04-13 15:24:52 +05:30
SubashMohan
f6e2c32331 feat(connector): enhance error handling by replacing HTTPException with OnyxError for invalid input and connector not found scenarios 2026-04-13 14:27:07 +05:30
SubashMohan
3d333d2d76 Refactor permission checks and remove redundant code across multiple modules 2026-04-13 14:02:52 +05:30
SubashMohan
acfa30f865 feat(permissions): update permission checks for creating personal access tokens and enhance UI with permission validation 2026-04-11 18:15:42 +05:30
SubashMohan
606ab55d73 feat(permissions): update permission identifiers for service accounts and bots management 2026-04-11 17:51:50 +05:30
SubashMohan
6223ba531b feat(permissions): update permission checks for query history access 2026-04-11 17:45:41 +05:30
SubashMohan
6f6e64ad63 feat(permissions): enhance document set management with refined error handling and permission checks 2026-04-10 10:35:29 +05:30
SubashMohan
a05f09faa2 feat(permissions): update permission checks to use ADD_AGENTS for user actions and enhance agent creation button with permission validation 2026-04-09 17:56:13 +05:30
SubashMohan
5912f632a3 feat(permissions): add READ_DOCUMENT_SETS permission and update permission checks for document sets and personas 2026-04-09 17:15:32 +05:30
SubashMohan
3e5cfa66d1 refactor(permissions): remove admin role check from effective permissions function 2026-04-09 15:25:09 +05:30
SubashMohan
b2f5eb3ec7 feat(permissions): add READ_USER_GROUPS permission and update user group access checks 2026-04-09 15:19:57 +05:30
SubashMohan
0ab2b8065d feat(permissions): enhance permission handling with effective permissions and admin checks 2026-04-09 13:16:22 +05:30
SubashMohan
4c304bf393 feat(permissions): add has_permission function and update permission checks to use MANAGE_LLMS 2026-04-09 11:56:27 +05:30
SubashMohan
cef5caa8b1 feat(icons): add SvgCreateAgent and SvgManageAgent components and update icon mapping 2026-04-08 18:16:47 +05:30
SubashMohan
f7b8650d5c feat(permissions): implement permission registry endpoint and update related models 2026-04-08 17:58:00 +05:30
SubashMohan
df532aa87d feat(user-group): implement bulk permission setting for user groups 2026-04-08 17:38:09 +05:30
59 changed files with 1514 additions and 1402 deletions

View File

@@ -1,74 +1,16 @@
from collections.abc import Sequence
from sqlalchemy import exists
from sqlalchemy import Row
from sqlalchemy import Select
from sqlalchemy import select
from sqlalchemy.orm import aliased
from sqlalchemy.orm import Session
from onyx.configs.constants import TokenRateLimitScope
from onyx.db.models import TokenRateLimit
from onyx.db.models import TokenRateLimit__UserGroup
from onyx.db.models import User
from onyx.db.models import User__UserGroup
from onyx.db.models import UserGroup
from onyx.db.models import UserRole
from onyx.server.token_rate_limits.models import TokenRateLimitArgs
def _add_user_filters(stmt: Select, user: User, get_editable: bool = True) -> Select:
if user.role == UserRole.ADMIN:
return stmt
# If anonymous user, only show global/public token_rate_limits
if user.is_anonymous:
where_clause = TokenRateLimit.scope == TokenRateLimitScope.GLOBAL
return stmt.where(where_clause)
stmt = stmt.distinct()
TRLimit_UG = aliased(TokenRateLimit__UserGroup)
User__UG = aliased(User__UserGroup)
"""
Here we select token_rate_limits by relation:
User -> User__UserGroup -> TokenRateLimit__UserGroup ->
TokenRateLimit
"""
stmt = stmt.outerjoin(TRLimit_UG).outerjoin(
User__UG,
User__UG.user_group_id == TRLimit_UG.user_group_id,
)
"""
Filter token_rate_limits by:
- if the user is in the user_group that owns the token_rate_limit
- 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 token_rate_limits that are owned by groups
that the user isn't a curator for
- if we are not editing, we show all token_rate_limits in the groups the user curates
"""
where_clause = User__UG.user_id == user.id
if user.role == UserRole.CURATOR and get_editable:
where_clause &= User__UG.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__UserGroup.is_curator == True # noqa: E712
)
where_clause &= (
~exists()
.where(TRLimit_UG.rate_limit_id == TokenRateLimit.id)
.where(~TRLimit_UG.user_group_id.in_(user_groups))
.correlate(TokenRateLimit)
)
return stmt.where(where_clause)
def fetch_all_user_group_token_rate_limits_by_group(
db_session: Session,
) -> Sequence[Row[tuple[TokenRateLimit, str]]]:
@@ -107,13 +49,11 @@ def insert_user_group_token_rate_limit(
return token_limit
def fetch_user_group_token_rate_limits_for_user(
def fetch_user_group_token_rate_limits_for_group(
db_session: Session,
group_id: int,
user: User,
enabled_only: bool = False,
ordered: bool = True,
get_editable: bool = True,
) -> Sequence[TokenRateLimit]:
stmt = (
select(TokenRateLimit)
@@ -123,7 +63,6 @@ def fetch_user_group_token_rate_limits_for_user(
)
.where(TokenRateLimit__UserGroup.user_group_id == group_id)
)
stmt = _add_user_filters(stmt, user, get_editable)
if enabled_only:
stmt = stmt.where(TokenRateLimit.enabled.is_(True))

View File

@@ -2,17 +2,14 @@ from collections.abc import Sequence
from operator import and_
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import delete
from sqlalchemy import func
from sqlalchemy import Select
from sqlalchemy import select
from sqlalchemy import update
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from ee.onyx.server.user_group.models import SetCuratorRequest
from ee.onyx.server.user_group.models import UserGroupCreate
from ee.onyx.server.user_group.models import UserGroupUpdate
from onyx.configs.app_configs import DISABLE_VECTOR_DB
@@ -38,7 +35,6 @@ from onyx.db.models import User
from onyx.db.models import User__UserGroup
from onyx.db.models import UserGroup
from onyx.db.models import UserGroup__ConnectorCredentialPair
from onyx.db.models import UserRole
from onyx.db.permissions import recompute_permissions_for_group__no_commit
from onyx.db.permissions import recompute_user_permissions__no_commit
from onyx.db.users import fetch_user_by_id
@@ -134,73 +130,6 @@ def _cleanup_document_set__user_group_relationships__no_commit(
)
def validate_object_creation_for_user(
db_session: Session,
user: User,
target_group_ids: list[int] | None = None,
object_is_public: bool | None = None,
object_is_perm_sync: bool | None = None,
object_is_owned_by_user: bool = False,
object_is_new: bool = False,
) -> None:
"""
All users can create/edit permission synced objects if they don't specify a group
All admin actions are allowed.
Curators and global curators can create public objects.
Prevents other non-admins from creating/editing:
- public objects
- objects with no groups
- objects that belong to a group they don't curate
"""
if object_is_perm_sync and not target_group_ids:
return
# Admins are allowed
if user.role == UserRole.ADMIN:
return
# Allow curators and global curators to create public objects
# w/o associated groups IF the object is new/owned by them
if (
object_is_public
and user.role in [UserRole.CURATOR, UserRole.GLOBAL_CURATOR]
and (object_is_new or object_is_owned_by_user)
):
return
if object_is_public and user.role == UserRole.BASIC:
detail = "User does not have permission to create public objects"
logger.error(detail)
raise HTTPException(
status_code=400,
detail=detail,
)
if not target_group_ids:
detail = "Curators must specify 1+ groups"
logger.error(detail)
raise HTTPException(
status_code=400,
detail=detail,
)
user_curated_groups = fetch_user_groups_for_user(
db_session=db_session,
user_id=user.id,
# Global curators can curate all groups they are member of
only_curator_groups=user.role != UserRole.GLOBAL_CURATOR,
)
user_curated_group_ids = set([group.id for group in user_curated_groups])
target_group_ids_set = set(target_group_ids)
if not target_group_ids_set.issubset(user_curated_group_ids):
detail = "Curators cannot control groups they don't curate"
logger.error(detail)
raise HTTPException(
status_code=400,
detail=detail,
)
def fetch_user_group(db_session: Session, user_group_id: int) -> UserGroup | None:
stmt = select(UserGroup).where(UserGroup.id == user_group_id)
return db_session.scalar(stmt)
@@ -530,167 +459,6 @@ def _mark_user_group__cc_pair_relationships_outdated__no_commit(
user_group__cc_pair_relationship.is_current = False
def _validate_curator_status__no_commit(
db_session: Session,
users: list[User],
) -> None:
for user in users:
# Check if the user is a curator in any of their groups
curator_relationships = (
db_session.query(User__UserGroup)
.filter(
User__UserGroup.user_id == user.id,
User__UserGroup.is_curator == True, # noqa: E712
)
.all()
)
# if the user is a curator in any of their groups, set their role to CURATOR
# otherwise, set their role to BASIC only if they were previously a CURATOR
if curator_relationships:
user.role = UserRole.CURATOR
elif user.role == UserRole.CURATOR:
user.role = UserRole.BASIC
db_session.add(user)
def remove_curator_status__no_commit(db_session: Session, user: User) -> None:
stmt = (
update(User__UserGroup)
.where(User__UserGroup.user_id == user.id)
.values(is_curator=False)
)
db_session.execute(stmt)
_validate_curator_status__no_commit(db_session, [user])
def _validate_curator_relationship_update_requester(
db_session: Session,
user_group_id: int,
user_making_change: User,
) -> None:
"""
This function validates that the user making the change has the necessary permissions
to update the curator relationship for the target user in the given user group.
"""
# Admins can update curator relationships for any group
if user_making_change.role == UserRole.ADMIN:
return
# check if the user making the change is a curator in the group they are changing the curator relationship for
user_making_change_curator_groups = fetch_user_groups_for_user(
db_session=db_session,
user_id=user_making_change.id,
# only check if the user making the change is a curator if they are a curator
# otherwise, they are a global_curator and can update the curator relationship
# for any group they are a member of
only_curator_groups=user_making_change.role == UserRole.CURATOR,
)
requestor_curator_group_ids = [
group.id for group in user_making_change_curator_groups
]
if user_group_id not in requestor_curator_group_ids:
raise ValueError(
f"user making change {user_making_change.email} is not a curator,"
f" admin, or global_curator for group '{user_group_id}'"
)
def _validate_curator_relationship_update_request(
db_session: Session,
user_group_id: int,
target_user: User,
) -> None:
"""
This function validates that the curator_relationship_update request itself is valid.
"""
if target_user.role == UserRole.ADMIN:
raise ValueError(
f"User '{target_user.email}' is an admin and therefore has all permissions "
"of a curator. If you'd like this user to only have curator permissions, "
"you must update their role to BASIC then assign them to be CURATOR in the "
"appropriate groups."
)
elif target_user.role == UserRole.GLOBAL_CURATOR:
raise ValueError(
f"User '{target_user.email}' is a global_curator and therefore has all "
"permissions of a curator for all groups. If you'd like this user to only "
"have curator permissions for a specific group, you must update their role "
"to BASIC then assign them to be CURATOR in the appropriate groups."
)
elif target_user.role not in [UserRole.CURATOR, UserRole.BASIC]:
raise ValueError(
f"This endpoint can only be used to update the curator relationship for "
"users with the CURATOR or BASIC role. \n"
f"Target user: {target_user.email} \n"
f"Target user role: {target_user.role} \n"
)
# check if the target user is in the group they are changing the curator relationship for
requested_user_groups = fetch_user_groups_for_user(
db_session=db_session,
user_id=target_user.id,
only_curator_groups=False,
)
group_ids = [group.id for group in requested_user_groups]
if user_group_id not in group_ids:
raise ValueError(
f"target user {target_user.email} is not in group '{user_group_id}'"
)
def update_user_curator_relationship(
db_session: Session,
user_group_id: int,
set_curator_request: SetCuratorRequest,
user_making_change: User,
) -> None:
target_user = fetch_user_by_id(db_session, set_curator_request.user_id)
if not target_user:
raise ValueError(f"User with id '{set_curator_request.user_id}' not found")
_validate_curator_relationship_update_request(
db_session=db_session,
user_group_id=user_group_id,
target_user=target_user,
)
_validate_curator_relationship_update_requester(
db_session=db_session,
user_group_id=user_group_id,
user_making_change=user_making_change,
)
logger.info(
f"user_making_change={user_making_change.email if user_making_change else 'None'} is "
f"updating the curator relationship for user={target_user.email} "
f"in group={user_group_id} to is_curator={set_curator_request.is_curator}"
)
relationship_to_update = (
db_session.query(User__UserGroup)
.filter(
User__UserGroup.user_group_id == user_group_id,
User__UserGroup.user_id == set_curator_request.user_id,
)
.first()
)
if relationship_to_update:
relationship_to_update.is_curator = set_curator_request.is_curator
else:
relationship_to_update = User__UserGroup(
user_group_id=user_group_id,
user_id=set_curator_request.user_id,
is_curator=True,
)
db_session.add(relationship_to_update)
_validate_curator_status__no_commit(db_session, [target_user])
db_session.commit()
def add_users_to_user_group(
db_session: Session,
user: User,
@@ -766,13 +534,6 @@ def update_user_group(
f"User(s) not found: {', '.join(str(user_id) for user_id in missing_users)}"
)
# LEAVING THIS HERE FOR NOW FOR GIVING DIFFERENT ROLES
# ACCESS TO DIFFERENT PERMISSIONS
# if (removed_user_ids or added_user_ids) and (
# not user or user.role != UserRole.ADMIN
# ):
# raise ValueError("Only admins can add or remove users from user groups")
if removed_user_ids:
_cleanup_user__user_group_relationships__no_commit(
db_session=db_session,
@@ -803,20 +564,6 @@ def update_user_group(
if cc_pairs_updated and not DISABLE_VECTOR_DB:
db_user_group.is_up_to_date = False
removed_users = db_session.scalars(
select(User).where(User.id.in_(removed_user_ids)) # type: ignore
).unique()
# Filter out admin and global curator users before validating curator status
users_to_validate = [
user
for user in removed_users
if user.role not in [UserRole.ADMIN, UserRole.GLOBAL_CURATOR]
]
if users_to_validate:
_validate_curator_status__no_commit(db_session, users_to_validate)
# update "time_updated" to now
db_user_group.time_last_modified_by_user = func.now()
@@ -996,3 +743,72 @@ def set_group_permission__no_commit(
db_session.flush()
recompute_permissions_for_group__no_commit(group_id, db_session)
def set_group_permissions_bulk__no_commit(
group_id: int,
desired_permissions: set[Permission],
granted_by: UUID,
db_session: Session,
) -> list[Permission]:
"""Set the full desired permission state for a group in one pass.
Enables permissions in `desired_permissions`, disables any toggleable
permission not in the set. Non-toggleable permissions are ignored.
Calls recompute once at the end. Does NOT commit.
Returns the resulting list of enabled permissions.
"""
existing_grants = (
db_session.execute(
select(PermissionGrant)
.where(PermissionGrant.group_id == group_id)
.with_for_update()
)
.scalars()
.all()
)
grant_map: dict[Permission, PermissionGrant] = {
g.permission: g for g in existing_grants
}
# Enable desired permissions
for perm in desired_permissions:
existing = grant_map.get(perm)
if existing is not None:
if existing.is_deleted:
existing.is_deleted = False
existing.granted_by = granted_by
existing.granted_at = func.now()
else:
db_session.add(
PermissionGrant(
group_id=group_id,
permission=perm,
grant_source=GrantSource.USER,
granted_by=granted_by,
)
)
# Disable toggleable permissions not in the desired set
for perm, grant in grant_map.items():
if perm not in desired_permissions and not grant.is_deleted:
grant.is_deleted = True
db_session.flush()
recompute_permissions_for_group__no_commit(group_id, db_session)
# Return the resulting enabled set
return [
g.permission
for g in db_session.execute(
select(PermissionGrant).where(
PermissionGrant.group_id == group_id,
PermissionGrant.is_deleted.is_(False),
)
)
.scalars()
.all()
]

View File

@@ -1,9 +1,7 @@
from datetime import datetime
from http import HTTPStatus
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from ee.onyx.background.celery.tasks.doc_permission_syncing.tasks import (
@@ -12,13 +10,16 @@ from ee.onyx.background.celery.tasks.doc_permission_syncing.tasks import (
from ee.onyx.background.celery.tasks.external_group_syncing.tasks import (
try_creating_external_group_sync_task,
)
from onyx.auth.users import current_curator_or_admin_user
from onyx.auth.permissions import require_permission
from onyx.background.celery.versioned_apps.client import app as client_app
from onyx.db.connector_credential_pair import (
get_connector_credential_pair_from_id_for_user,
)
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.redis.redis_connector import RedisConnector
from onyx.redis.redis_pool import get_redis_client
from onyx.server.models import StatusResponse
@@ -32,7 +33,7 @@ router = APIRouter(prefix="/manage")
@router.get("/admin/cc-pair/{cc_pair_id}/sync-permissions")
def get_cc_pair_latest_sync(
cc_pair_id: int,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.READ_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> datetime | None:
cc_pair = get_connector_credential_pair_from_id_for_user(
@@ -42,9 +43,9 @@ def get_cc_pair_latest_sync(
get_editable=False,
)
if not cc_pair:
raise HTTPException(
status_code=400,
detail="cc_pair not found for current user's permissions",
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"CC Pair not found for current user's permissions",
)
return cc_pair.last_time_perm_sync
@@ -53,7 +54,7 @@ def get_cc_pair_latest_sync(
@router.post("/admin/cc-pair/{cc_pair_id}/sync-permissions")
def sync_cc_pair(
cc_pair_id: int,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> StatusResponse[None]:
"""Triggers permissions sync on a particular cc_pair immediately"""
@@ -66,18 +67,18 @@ def sync_cc_pair(
get_editable=False,
)
if not cc_pair:
raise HTTPException(
status_code=400,
detail="Connection not found for current user's permissions",
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"Connection not found for current user's permissions",
)
r = get_redis_client()
redis_connector = RedisConnector(tenant_id, cc_pair_id)
if redis_connector.permissions.fenced:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="Permissions sync task already in progress.",
raise OnyxError(
OnyxErrorCode.CONFLICT,
"Permissions sync task already in progress.",
)
logger.info(
@@ -90,9 +91,9 @@ def sync_cc_pair(
client_app, cc_pair_id, r, tenant_id
)
if not payload_id:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Permissions sync task creation failed.",
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"Permissions sync task creation failed.",
)
logger.info(f"Permissions sync queued: cc_pair={cc_pair_id} id={payload_id}")
@@ -106,7 +107,7 @@ def sync_cc_pair(
@router.get("/admin/cc-pair/{cc_pair_id}/sync-groups")
def get_cc_pair_latest_group_sync(
cc_pair_id: int,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.READ_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> datetime | None:
cc_pair = get_connector_credential_pair_from_id_for_user(
@@ -116,9 +117,9 @@ def get_cc_pair_latest_group_sync(
get_editable=False,
)
if not cc_pair:
raise HTTPException(
status_code=400,
detail="cc_pair not found for current user's permissions",
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"CC Pair not found for current user's permissions",
)
return cc_pair.last_time_external_group_sync
@@ -127,7 +128,7 @@ def get_cc_pair_latest_group_sync(
@router.post("/admin/cc-pair/{cc_pair_id}/sync-groups")
def sync_cc_pair_groups(
cc_pair_id: int,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> StatusResponse[None]:
"""Triggers group sync on a particular cc_pair immediately"""
@@ -140,18 +141,18 @@ def sync_cc_pair_groups(
get_editable=False,
)
if not cc_pair:
raise HTTPException(
status_code=400,
detail="Connection not found for current user's permissions",
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"Connection not found for current user's permissions",
)
r = get_redis_client()
redis_connector = RedisConnector(tenant_id, cc_pair_id)
if redis_connector.external_group_sync.fenced:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="External group sync task already in progress.",
raise OnyxError(
OnyxErrorCode.CONFLICT,
"External group sync task already in progress.",
)
logger.info(
@@ -164,9 +165,9 @@ def sync_cc_pair_groups(
client_app, cc_pair_id, r, tenant_id
)
if not payload_id:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="External group sync task creation failed.",
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"External group sync task creation failed.",
)
logger.info(f"External group sync queued: cc_pair={cc_pair_id} id={payload_id}")

View File

@@ -25,7 +25,7 @@ logger = setup_logger()
def prepare_authorization_request(
connector: DocumentSource,
redirect_on_success: str | None,
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
tenant_id: str | None = Depends(get_current_tenant_id),
) -> JSONResponse:
"""Used by the frontend to generate the url for the user's browser during auth request.

View File

@@ -147,7 +147,7 @@ class ConfluenceCloudOAuth:
def confluence_oauth_callback(
code: str,
state: str,
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
tenant_id: str | None = Depends(get_current_tenant_id),
) -> JSONResponse:
@@ -259,7 +259,7 @@ def confluence_oauth_callback(
@router.get("/connector/confluence/accessible-resources")
def confluence_oauth_accessible_resources(
credential_id: int,
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
tenant_id: str | None = Depends(get_current_tenant_id), # noqa: ARG001
) -> JSONResponse:
@@ -326,7 +326,7 @@ def confluence_oauth_finalize(
cloud_id: str,
cloud_name: str,
cloud_url: str,
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
tenant_id: str | None = Depends(get_current_tenant_id), # noqa: ARG001
) -> JSONResponse:

View File

@@ -115,7 +115,7 @@ class GoogleDriveOAuth:
def handle_google_drive_oauth_callback(
code: str,
state: str,
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
tenant_id: str | None = Depends(get_current_tenant_id),
) -> JSONResponse:

View File

@@ -99,7 +99,7 @@ class SlackOAuth:
def handle_slack_oauth_callback(
code: str,
state: str,
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
tenant_id: str | None = Depends(get_current_tenant_id),
) -> JSONResponse:

View File

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

View File

@@ -5,10 +5,9 @@ from fastapi import Depends
from sqlalchemy.orm import Session
from ee.onyx.db.token_limit import fetch_all_user_group_token_rate_limits_by_group
from ee.onyx.db.token_limit import fetch_user_group_token_rate_limits_for_user
from ee.onyx.db.token_limit import fetch_user_group_token_rate_limits_for_group
from ee.onyx.db.token_limit import insert_user_group_token_rate_limit
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_curator_or_admin_user
from onyx.configs.constants import PUBLIC_API_TAGS
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import Permission
@@ -48,15 +47,14 @@ def get_all_group_token_limit_settings(
@router.get("/user-group/{group_id}")
def get_group_token_limit_settings(
group_id: int,
user: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_USER_GROUPS)),
db_session: Session = Depends(get_session),
) -> list[TokenRateLimitDisplay]:
return [
TokenRateLimitDisplay.from_db(token_rate_limit)
for token_rate_limit in fetch_user_group_token_rate_limits_for_user(
for token_rate_limit in fetch_user_group_token_rate_limits_for_group(
db_session=db_session,
group_id=group_id,
user=user,
)
]
@@ -65,7 +63,7 @@ def get_group_token_limit_settings(
def create_group_token_limit_settings(
group_id: int,
token_limit_settings: TokenRateLimitArgs,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_USER_GROUPS)),
db_session: Session = Depends(get_session),
) -> TokenRateLimitDisplay:
rate_limit_display = TokenRateLimitDisplay.from_db(

View File

@@ -1,6 +1,5 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -13,28 +12,26 @@ 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 update_user_curator_relationship
from ee.onyx.db.user_group import set_group_permissions_bulk__no_commit
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 get_effective_permissions
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
from onyx.configs.constants import PUBLIC_API_TAGS
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import Permission
from onyx.db.models import User
from onyx.db.models import UserRole
from onyx.db.persona import get_persona_by_id
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
@@ -48,24 +45,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]
@@ -75,7 +63,7 @@ def list_minimal_user_groups(
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
db_session: Session = Depends(get_session),
) -> list[MinimalUserGroupSnapshot]:
if user.role == UserRole.ADMIN:
if Permission.FULL_ADMIN_PANEL_ACCESS in get_effective_permissions(user):
user_groups = fetch_user_groups(
db_session,
only_up_to_date=False,
@@ -92,62 +80,71 @@ 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,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_USER_GROUPS)),
db_session: Session = Depends(get_session),
) -> list[Permission]:
group = fetch_user_group(db_session, user_group_id)
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,
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
request: BulkSetPermissionsRequest,
user: User = Depends(require_permission(Permission.MANAGE_USER_GROUPS)),
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")
def create_user_group(
user_group: UserGroupCreate,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_USER_GROUPS)),
db_session: Session = Depends(get_session),
) -> UserGroup:
try:
db_user_group = insert_user_group(db_session, user_group)
except IntegrityError:
raise HTTPException(
400,
raise OnyxError(
OnyxErrorCode.DUPLICATE_RESOURCE,
f"User group with name '{user_group.name}' already exists. Please "
+ "choose a different name.",
"choose a different name.",
)
return UserGroup.from_model(db_user_group)
@@ -155,7 +152,7 @@ def create_user_group(
@router.patch("/admin/user-group/rename")
def rename_user_group_endpoint(
rename_request: UserGroupRename,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_USER_GROUPS)),
db_session: Session = Depends(get_session),
) -> UserGroup:
group = fetch_user_group(db_session, rename_request.id)
@@ -185,7 +182,7 @@ def rename_user_group_endpoint(
def patch_user_group(
user_group_id: int,
user_group_update: UserGroupUpdate,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_USER_GROUPS)),
db_session: Session = Depends(get_session),
) -> UserGroup:
try:
@@ -198,14 +195,14 @@ def patch_user_group(
)
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
raise OnyxError(OnyxErrorCode.NOT_FOUND, str(e))
@router.post("/admin/user-group/{user_group_id}/add-users")
def add_users(
user_group_id: int,
add_users_request: AddUsersToUserGroupRequest,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_USER_GROUPS)),
db_session: Session = Depends(get_session),
) -> UserGroup:
try:
@@ -218,32 +215,13 @@ def add_users(
)
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/admin/user-group/{user_group_id}/set-curator")
def set_user_curator(
user_group_id: int,
set_curator_request: SetCuratorRequest,
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> None:
try:
update_user_curator_relationship(
db_session=db_session,
user_group_id=user_group_id,
set_curator_request=set_curator_request,
user_making_change=user,
)
except ValueError as e:
logger.error(f"Error setting user curator: {e}")
raise HTTPException(status_code=404, detail=str(e))
raise OnyxError(OnyxErrorCode.NOT_FOUND, str(e))
@router.delete("/admin/user-group/{user_group_id}")
def delete_user_group(
user_group_id: int,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_USER_GROUPS)),
db_session: Session = Depends(get_session),
) -> None:
group = fetch_user_group(db_session, user_group_id)
@@ -252,7 +230,7 @@ def delete_user_group(
try:
prepare_user_group_for_deletion(db_session, user_group_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
raise OnyxError(OnyxErrorCode.NOT_FOUND, str(e))
if DISABLE_VECTOR_DB:
user_group = fetch_user_group(db_session, user_group_id)
@@ -264,7 +242,7 @@ def delete_user_group(
def update_group_agents(
user_group_id: int,
request: UpdateGroupAgentsRequest,
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
user: User = Depends(require_permission(Permission.MANAGE_USER_GROUPS)),
db_session: Session = Depends(get_session),
) -> None:
for agent_id in request.added_agent_ids:

View File

@@ -17,7 +17,6 @@ class UserGroup(BaseModel):
id: int
name: str
users: list[UserInfo]
curator_ids: list[UUID]
cc_pairs: list[ConnectorCredentialPairDescriptor]
document_sets: list[DocumentSet]
personas: list[PersonaSnapshot]
@@ -45,11 +44,6 @@ class UserGroup(BaseModel):
)
for user in user_group_model.users
],
curator_ids=[
user.user_id
for user in user_group_model.user_group_relationships
if user.is_curator and user.user_id is not None
],
cc_pairs=[
ConnectorCredentialPairDescriptor(
id=cc_pair_relationship.cc_pair.id,
@@ -114,11 +108,6 @@ class UserGroupRename(BaseModel):
name: str
class SetCuratorRequest(BaseModel):
user_id: UUID
is_curator: bool
class UpdateGroupAgentsRequest(BaseModel):
added_agent_ids: list[int]
removed_agent_ids: list[int]
@@ -132,3 +121,7 @@ class SetPermissionRequest(BaseModel):
class SetPermissionResponse(BaseModel):
permission: Permission
enabled: bool
class BulkSetPermissionsRequest(BaseModel):
permissions: list[Permission]

View File

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

View File

@@ -14,6 +14,7 @@ from sqlalchemy.orm import joinedload
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from onyx.auth.permissions import get_effective_permissions
from onyx.configs.constants import DocumentSource
from onyx.db.connector import fetch_connector_by_id
from onyx.db.credentials import fetch_credential_by_id
@@ -21,6 +22,7 @@ from onyx.db.credentials import fetch_credential_by_id_for_user
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.enums import AccessType
from onyx.db.enums import ConnectorCredentialPairStatus
from onyx.db.enums import Permission
from onyx.db.enums import ProcessingMode
from onyx.db.models import Connector
from onyx.db.models import ConnectorCredentialPair
@@ -31,7 +33,6 @@ from onyx.db.models import SearchSettings
from onyx.db.models import User
from onyx.db.models import User__UserGroup
from onyx.db.models import UserGroup__ConnectorCredentialPair
from onyx.db.models import UserRole
from onyx.server.models import StatusResponse
from onyx.utils.logger import setup_logger
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
@@ -49,48 +50,27 @@ class ConnectorType(str, Enum):
def _add_user_filters(
stmt: Select[tuple[*R]], user: User, get_editable: bool = True
) -> Select[tuple[*R]]:
if user.role == UserRole.ADMIN:
effective = get_effective_permissions(user)
if Permission.MANAGE_CONNECTORS in effective:
return stmt
# If anonymous user, only show public cc_pairs
if user.is_anonymous:
where_clause = ConnectorCredentialPair.access_type == AccessType.PUBLIC
return stmt.where(where_clause)
return stmt.where(ConnectorCredentialPair.access_type == AccessType.PUBLIC)
stmt = stmt.distinct()
UG__CCpair = aliased(UserGroup__ConnectorCredentialPair)
User__UG = aliased(User__UserGroup)
"""
Here we select cc_pairs by relation:
User -> User__UserGroup -> UserGroup__ConnectorCredentialPair ->
ConnectorCredentialPair
"""
stmt = stmt.outerjoin(UG__CCpair).outerjoin(
User__UG,
User__UG.user_group_id == UG__CCpair.user_group_id,
)
"""
Filter cc_pairs by:
- if the user is in the user_group that owns the cc_pair
- 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 cc_pairs that are owned by groups
that the user isn't a curator for
- if we are not editing, we show all cc_pairs in the groups the user is a curator
for (as well as public cc_pairs)
"""
where_clause = User__UG.user_id == user.id
if user.role == UserRole.CURATOR and get_editable:
where_clause &= User__UG.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__UserGroup.is_curator == True # noqa: E712
)
where_clause &= (
~exists()
.where(UG__CCpair.cc_pair_id == ConnectorCredentialPair.id)

View File

@@ -1,6 +1,5 @@
from typing import Any
from sqlalchemy import exists
from sqlalchemy import Select
from sqlalchemy import select
from sqlalchemy import update
@@ -8,18 +7,18 @@ from sqlalchemy.orm import Session
from sqlalchemy.sql.expression import and_
from sqlalchemy.sql.expression import or_
from onyx.auth.schemas import UserRole
from onyx.auth.permissions import get_effective_permissions
from onyx.configs.constants import DocumentSource
from onyx.connectors.google_utils.shared_constants import (
DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY,
)
from onyx.db.enums import ConnectorCredentialPairStatus
from onyx.db.enums import Permission
from onyx.db.models import ConnectorCredentialPair
from onyx.db.models import Credential
from onyx.db.models import Credential__UserGroup
from onyx.db.models import DocumentByConnectorCredentialPair
from onyx.db.models import User
from onyx.db.models import User__UserGroup
from onyx.server.documents.models import CredentialBase
from onyx.utils.logger import setup_logger
@@ -43,16 +42,14 @@ PUBLIC_CREDENTIAL_ID = 0
def _add_user_filters(
stmt: Select,
user: User,
get_editable: bool = True,
) -> Select:
"""Attaches filters to the statement to ensure that the user can only
access the appropriate credentials"""
"""Attaches filters to ensure the user can only access appropriate credentials."""
if user.is_anonymous:
raise ValueError("Anonymous users are not allowed to access credentials")
if user.role == UserRole.ADMIN:
# Admins can access all credentials that are public or owned by them
# or are not associated with any user
effective = get_effective_permissions(user)
if Permission.MANAGE_CONNECTORS in effective:
return stmt.where(
or_(
Credential.user_id == user.id,
@@ -61,56 +58,9 @@ def _add_user_filters(
Credential.source.in_(CREDENTIAL_PERMISSIONS_TO_IGNORE),
)
)
if user.role == UserRole.BASIC:
# Basic users can only access credentials that are owned by them
return stmt.where(Credential.user_id == user.id)
stmt = stmt.distinct()
"""
THIS PART IS FOR CURATORS AND GLOBAL CURATORS
Here we select cc_pairs by relation:
User -> User__UserGroup -> Credential__UserGroup -> Credential
"""
stmt = stmt.outerjoin(Credential__UserGroup).outerjoin(
User__UserGroup,
User__UserGroup.user_group_id == Credential__UserGroup.user_group_id,
)
"""
Filter Credentials by:
- if the user is in the user_group that owns the Credential
- if the user is a curator, they must also have a curator relationship
to the user_group
- if editing is being done, we also filter out Credentials that are owned by groups
that the user isn't a curator for
- if we are not editing, we show all Credentials in the groups the user is a curator
for (as well as public Credentials)
- if we are not editing, we return all Credentials directly connected to the user
"""
where_clause = User__UserGroup.user_id == user.id
if user.role == UserRole.CURATOR:
where_clause &= User__UserGroup.is_curator == True # noqa: E712
if get_editable:
user_groups = select(User__UserGroup.user_group_id).where(
User__UserGroup.user_id == user.id
)
if user.role == UserRole.CURATOR:
user_groups = user_groups.where(
User__UserGroup.is_curator == True # noqa: E712
)
where_clause &= (
~exists()
.where(Credential__UserGroup.credential_id == Credential.id)
.where(~Credential__UserGroup.user_group_id.in_(user_groups))
.correlate(Credential)
)
else:
where_clause |= Credential.curator_public == True # noqa: E712
where_clause |= Credential.user_id == user.id # noqa: E712
where_clause |= Credential.source.in_(CREDENTIAL_PERMISSIONS_TO_IGNORE)
return stmt.where(where_clause)
# All other users: only their own credentials
return stmt.where(Credential.user_id == user.id)
def _relate_credential_to_user_groups__no_commit(
@@ -132,10 +82,9 @@ def _relate_credential_to_user_groups__no_commit(
def fetch_credentials_for_user(
db_session: Session,
user: User,
get_editable: bool = True,
) -> list[Credential]:
stmt = select(Credential)
stmt = _add_user_filters(stmt, user, get_editable=get_editable)
stmt = _add_user_filters(stmt, user)
results = db_session.scalars(stmt)
return list(results.all())
@@ -144,14 +93,12 @@ def fetch_credential_by_id_for_user(
credential_id: int,
user: User,
db_session: Session,
get_editable: bool = True,
) -> Credential | None:
stmt = select(Credential).distinct()
stmt = stmt.where(Credential.id == credential_id)
stmt = _add_user_filters(
stmt=stmt,
user=user,
get_editable=get_editable,
)
result = db_session.execute(stmt)
credential = result.scalar_one_or_none()
@@ -173,10 +120,9 @@ def fetch_credentials_by_source_for_user(
db_session: Session,
user: User,
document_source: DocumentSource | None = None,
get_editable: bool = True,
) -> list[Credential]:
base_query = select(Credential).where(Credential.source == document_source)
base_query = _add_user_filters(base_query, user, get_editable=get_editable)
base_query = _add_user_filters(base_query, user)
credentials = db_session.execute(base_query).scalars().all()
return list(credentials)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,6 @@ from http import HTTPStatus
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import Query
from fastapi.responses import JSONResponse
from sqlalchemy import select
@@ -11,7 +10,6 @@ from sqlalchemy.exc import IntegrityError
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.tasks.pruning.tasks import (
try_creating_prune_generator_task,
)
@@ -57,6 +55,8 @@ from onyx.db.permission_sync_attempt import (
from onyx.db.permission_sync_attempt import (
get_recent_doc_permission_sync_attempts_for_cc_pair,
)
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.redis.redis_connector import RedisConnector
from onyx.redis.redis_connector_utils import get_deletion_attempt_snapshot
from onyx.redis.redis_pool import get_redis_client
@@ -71,7 +71,6 @@ from onyx.server.documents.models import PaginatedReturn
from onyx.server.documents.models import PermissionSyncAttemptSnapshot
from onyx.server.models import StatusResponse
from onyx.utils.logger import setup_logger
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
from shared_configs.contextvars import get_current_tenant_id
logger = setup_logger()
@@ -83,17 +82,17 @@ def get_cc_pair_index_attempts(
cc_pair_id: int,
page_num: int = Query(0, ge=0),
page_size: int = Query(10, ge=1, le=1000),
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.READ_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> PaginatedReturn[IndexAttemptSnapshot]:
if user:
user_has_access = verify_user_has_access_to_cc_pair(
cc_pair_id, db_session, user, get_editable=False
user_has_access = verify_user_has_access_to_cc_pair(
cc_pair_id, db_session, user, get_editable=False
)
if not user_has_access:
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"CC Pair not found for current user permissions",
)
if not user_has_access:
raise HTTPException(
status_code=400, detail="CC Pair not found for current user permissions"
)
total_count = count_index_attempts_for_cc_pair(
db_session=db_session,
@@ -119,17 +118,17 @@ def get_cc_pair_permission_sync_attempts(
cc_pair_id: int,
page_num: int = Query(0, ge=0),
page_size: int = Query(10, ge=1, le=1000),
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.READ_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> PaginatedReturn[PermissionSyncAttemptSnapshot]:
if user:
user_has_access = verify_user_has_access_to_cc_pair(
cc_pair_id, db_session, user, get_editable=False
user_has_access = verify_user_has_access_to_cc_pair(
cc_pair_id, db_session, user, get_editable=False
)
if not user_has_access:
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"CC Pair not found for current user permissions",
)
if not user_has_access:
raise HTTPException(
status_code=400, detail="CC Pair not found for current user permissions"
)
# Get all permission sync attempts for this cc pair
all_attempts = get_recent_doc_permission_sync_attempts_for_cc_pair(
@@ -155,7 +154,7 @@ def get_cc_pair_permission_sync_attempts(
@router.get("/admin/cc-pair/{cc_pair_id}", tags=PUBLIC_API_TAGS)
def get_cc_pair_full_info(
cc_pair_id: int,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.READ_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> CCPairFullInfo:
tenant_id = get_current_tenant_id()
@@ -164,8 +163,9 @@ def get_cc_pair_full_info(
cc_pair_id, db_session, user, get_editable=False
)
if not cc_pair:
raise HTTPException(
status_code=404, detail="CC Pair not found for current user permissions"
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
"CC Pair not found for current user permissions",
)
editable_cc_pair = get_connector_credential_pair_from_id_for_user(
cc_pair_id, db_session, user, get_editable=True
@@ -259,7 +259,7 @@ def get_cc_pair_full_info(
def update_cc_pair_status(
cc_pair_id: int,
status_update_request: CCStatusUpdateRequest,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> JSONResponse:
"""This method returns nearly immediately. It simply sets some signals and
@@ -278,9 +278,9 @@ def update_cc_pair_status(
)
if not cc_pair:
raise HTTPException(
status_code=400,
detail="Connection not found for current user's permissions",
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"Connection not found for current user's permissions",
)
redis_connector = RedisConnector(tenant_id, cc_pair_id)
@@ -343,7 +343,7 @@ def update_cc_pair_status(
def update_cc_pair_name(
cc_pair_id: int,
new_name: str,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> StatusResponse[int]:
cc_pair = get_connector_credential_pair_from_id_for_user(
@@ -353,8 +353,9 @@ def update_cc_pair_name(
get_editable=True,
)
if not cc_pair:
raise HTTPException(
status_code=400, detail="CC Pair not found for current user's permissions"
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"CC Pair not found for current user's permissions",
)
try:
@@ -365,14 +366,14 @@ def update_cc_pair_name(
)
except IntegrityError:
db_session.rollback()
raise HTTPException(status_code=400, detail="Name must be unique")
raise OnyxError(OnyxErrorCode.INVALID_INPUT, "Name must be unique")
@router.put("/admin/cc-pair/{cc_pair_id}/property")
def update_cc_pair_property(
cc_pair_id: int,
update_request: CCPropertyUpdateRequest, # in seconds
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> StatusResponse[int]:
cc_pair = get_connector_credential_pair_from_id_for_user(
@@ -382,8 +383,9 @@ def update_cc_pair_property(
get_editable=True,
)
if not cc_pair:
raise HTTPException(
status_code=400, detail="CC Pair not found for current user's permissions"
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"CC Pair not found for current user's permissions",
)
# Can we centralize logic for updating connector properties
@@ -401,8 +403,9 @@ def update_cc_pair_property(
msg = "Pruning frequency updated successfully"
else:
raise HTTPException(
status_code=400, detail=f"Property name {update_request.name} is not valid."
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
f"Property name {update_request.name} is not valid.",
)
return StatusResponse(success=True, message=msg, data=cc_pair_id)
@@ -411,7 +414,7 @@ def update_cc_pair_property(
@router.get("/admin/cc-pair/{cc_pair_id}/last_pruned")
def get_cc_pair_last_pruned(
cc_pair_id: int,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.READ_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> datetime | None:
cc_pair = get_connector_credential_pair_from_id_for_user(
@@ -421,9 +424,9 @@ def get_cc_pair_last_pruned(
get_editable=False,
)
if not cc_pair:
raise HTTPException(
status_code=400,
detail="cc_pair not found for current user's permissions",
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"CC Pair not found for current user's permissions",
)
return cc_pair.last_pruned
@@ -432,7 +435,7 @@ def get_cc_pair_last_pruned(
@router.post("/admin/cc-pair/{cc_pair_id}/prune", tags=PUBLIC_API_TAGS)
def prune_cc_pair(
cc_pair_id: int,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> StatusResponse[list[int]]:
"""Triggers pruning on a particular cc_pair immediately"""
@@ -445,18 +448,18 @@ def prune_cc_pair(
get_editable=False,
)
if not cc_pair:
raise HTTPException(
status_code=400,
detail="Connection not found for current user's permissions",
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"Connection not found for current user's permissions",
)
r = get_redis_client()
redis_connector = RedisConnector(tenant_id, cc_pair_id)
if redis_connector.prune.fenced:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="Pruning task already in progress.",
raise OnyxError(
OnyxErrorCode.CONFLICT,
"Pruning task already in progress.",
)
logger.info(
@@ -469,9 +472,9 @@ def prune_cc_pair(
client_app, cc_pair, db_session, r, tenant_id
)
if not payload_id:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Pruning task creation failed.",
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"Pruning task creation failed.",
)
logger.info(f"Pruning queued: cc_pair={cc_pair.id} id={payload_id}")
@@ -485,7 +488,7 @@ def prune_cc_pair(
@router.get("/admin/cc-pair/{cc_pair_id}/get-docs-sync-status")
def get_docs_sync_status(
cc_pair_id: int,
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.READ_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> list[DocumentSyncStatus]:
all_docs_for_cc_pair = get_documents_for_cc_pair(
@@ -501,7 +504,7 @@ def get_cc_pair_indexing_errors(
include_resolved: bool = Query(False),
page_num: int = Query(0, ge=0),
page_size: int = Query(10, ge=1, le=100),
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.READ_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> PaginatedReturn[IndexAttemptErrorPydantic]:
"""Gives back all errors for a given CC Pair. Allows pagination based on page and page_size params.
@@ -543,7 +546,7 @@ def associate_credential_to_connector(
connector_id: int,
credential_id: int,
metadata: ConnectorCredentialPairMetadata,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
tenant_id: str = Depends(get_current_tenant_id),
) -> StatusResponse[int]:
@@ -553,17 +556,6 @@ def associate_credential_to_connector(
The intent of this endpoint is to handle connectors that actually need credentials.
"""
fetch_ee_implementation_or_noop(
"onyx.db.user_group", "validate_object_creation_for_user", None
)(
db_session=db_session,
user=user,
target_group_ids=metadata.groups,
object_is_public=metadata.access_type == AccessType.PUBLIC,
object_is_perm_sync=metadata.access_type == AccessType.SYNC,
object_is_new=True,
)
try:
validate_ccpair_for_user(
connector_id, credential_id, metadata.access_type, db_session
@@ -601,20 +593,21 @@ def associate_credential_to_connector(
delete_connector(db_session, connector_id)
db_session.commit()
raise HTTPException(
status_code=400, detail="Connector validation error: " + str(e)
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
"Connector validation error: " + str(e),
)
except IntegrityError as e:
logger.error(f"IntegrityError: {e}")
delete_connector(db_session, connector_id)
db_session.commit()
raise HTTPException(status_code=400, detail="Name must be unique")
raise OnyxError(OnyxErrorCode.INVALID_INPUT, "Name must be unique")
except Exception as e:
logger.exception(f"Unexpected error: {e}")
raise HTTPException(status_code=500, detail="Unexpected error")
raise OnyxError(OnyxErrorCode.INTERNAL_ERROR, "Unexpected error")
@router.delete(

View File

@@ -22,9 +22,9 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session
from onyx.auth.email_utils import send_email
from onyx.auth.permissions import get_effective_permissions
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.background.celery.tasks.pruning.tasks import (
try_creating_prune_generator_task,
)
@@ -118,7 +118,8 @@ from onyx.db.models import FederatedConnector
from onyx.db.models import IndexAttempt
from onyx.db.models import IndexingStatus
from onyx.db.models import User
from onyx.db.models import UserRole
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.file_processing.file_types import PLAIN_TEXT_MIME_TYPE
from onyx.file_processing.file_types import WORD_PROCESSING_MIME_TYPE
from onyx.file_store.file_store import FileStore
@@ -159,7 +160,6 @@ from onyx.utils.logger import setup_logger
from onyx.utils.telemetry import mt_cloud_telemetry
from onyx.utils.threadpool_concurrency import CallableProtocol
from onyx.utils.threadpool_concurrency import run_functions_tuples_in_parallel
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
from shared_configs.contextvars import get_current_tenant_id
logger = setup_logger()
@@ -179,7 +179,7 @@ router = APIRouter(prefix="/manage", dependencies=[Depends(require_vector_db)])
@router.get("/admin/connector/gmail/app-credential")
def check_google_app_gmail_credentials_exist(
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
) -> dict[str, str]:
try:
return {"client_id": get_google_app_cred(DocumentSource.GMAIL).web.client_id}
@@ -190,7 +190,7 @@ def check_google_app_gmail_credentials_exist(
@router.put("/admin/connector/gmail/app-credential")
def upsert_google_app_gmail_credentials(
app_credentials: GoogleAppCredentials,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
) -> StatusResponse:
try:
upsert_google_app_cred(app_credentials, DocumentSource.GMAIL)
@@ -204,7 +204,7 @@ def upsert_google_app_gmail_credentials(
@router.delete("/admin/connector/gmail/app-credential")
def delete_google_app_gmail_credentials(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> StatusResponse:
try:
@@ -220,7 +220,7 @@ def delete_google_app_gmail_credentials(
@router.get("/admin/connector/google-drive/app-credential")
def check_google_app_credentials_exist(
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
) -> dict[str, str]:
try:
return {
@@ -233,7 +233,7 @@ def check_google_app_credentials_exist(
@router.put("/admin/connector/google-drive/app-credential")
def upsert_google_app_credentials(
app_credentials: GoogleAppCredentials,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
) -> StatusResponse:
try:
upsert_google_app_cred(app_credentials, DocumentSource.GOOGLE_DRIVE)
@@ -247,7 +247,7 @@ def upsert_google_app_credentials(
@router.delete("/admin/connector/google-drive/app-credential")
def delete_google_app_credentials(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> StatusResponse:
try:
@@ -263,7 +263,7 @@ def delete_google_app_credentials(
@router.get("/admin/connector/gmail/service-account-key")
def check_google_service_gmail_account_key_exist(
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
) -> dict[str, str]:
try:
return {
@@ -280,7 +280,7 @@ def check_google_service_gmail_account_key_exist(
@router.put("/admin/connector/gmail/service-account-key")
def upsert_google_service_gmail_account_key(
service_account_key: GoogleServiceAccountKey,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
) -> StatusResponse:
try:
upsert_service_account_key(service_account_key, DocumentSource.GMAIL)
@@ -294,7 +294,7 @@ def upsert_google_service_gmail_account_key(
@router.delete("/admin/connector/gmail/service-account-key")
def delete_google_service_gmail_account_key(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> StatusResponse:
try:
@@ -310,7 +310,7 @@ def delete_google_service_gmail_account_key(
@router.get("/admin/connector/google-drive/service-account-key")
def check_google_service_account_key_exist(
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
) -> dict[str, str]:
try:
return {
@@ -327,7 +327,7 @@ def check_google_service_account_key_exist(
@router.put("/admin/connector/google-drive/service-account-key")
def upsert_google_service_account_key(
service_account_key: GoogleServiceAccountKey,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
) -> StatusResponse:
try:
upsert_service_account_key(service_account_key, DocumentSource.GOOGLE_DRIVE)
@@ -341,7 +341,7 @@ def upsert_google_service_account_key(
@router.delete("/admin/connector/google-drive/service-account-key")
def delete_google_service_account_key(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> StatusResponse:
try:
@@ -358,7 +358,7 @@ def delete_google_service_account_key(
@router.put("/admin/connector/google-drive/service-account-credential")
def upsert_service_account_credential(
service_account_credential_request: GoogleServiceAccountCredentialRequest,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> ObjectCreationIdResponse:
"""Special API which allows the creation of a credential for a service account.
@@ -385,7 +385,7 @@ def upsert_service_account_credential(
@router.put("/admin/connector/gmail/service-account-credential")
def upsert_gmail_service_account_credential(
service_account_credential_request: GoogleServiceAccountCredentialRequest,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> ObjectCreationIdResponse:
"""Special API which allows the creation of a credential for a service account.
@@ -411,7 +411,7 @@ def upsert_gmail_service_account_credential(
@router.get("/admin/connector/google-drive/check-auth/{credential_id}")
def check_drive_tokens(
credential_id: int,
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> AuthStatus:
db_credentials = fetch_credential_by_id_for_user(credential_id, user, db_session)
@@ -620,11 +620,11 @@ def _fetch_and_check_file_connector_cc_pair_permissions(
if has_requested_access:
return cc_pair
# Special case: global curators should be able to manage files
# Special case: users with MANAGE_CONNECTORS should be able to manage files
# for public file connectors even when they are not the creator.
if (
require_editable
and user.role == UserRole.GLOBAL_CURATOR
and Permission.MANAGE_CONNECTORS in get_effective_permissions(user)
and cc_pair.access_type == AccessType.PUBLIC
):
return cc_pair
@@ -639,7 +639,7 @@ def _fetch_and_check_file_connector_cc_pair_permissions(
def upload_files_api(
files: list[UploadFile],
unzip: bool = True,
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
) -> FileUploadResponse:
return upload_files(files, FileOrigin.OTHER, unzip=unzip)
@@ -647,7 +647,7 @@ def upload_files_api(
@router.get("/admin/connector/{connector_id}/files", tags=PUBLIC_API_TAGS)
def list_connector_files(
connector_id: int,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> ConnectorFilesResponse:
"""List all files in a file connector."""
@@ -716,7 +716,7 @@ def update_connector_files(
connector_id: int,
files: list[UploadFile] | None = File(None),
file_ids_to_remove: str = Form("[]"),
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> FileUploadResponse:
"""
@@ -929,7 +929,7 @@ def update_connector_files(
@router.get("/admin/connector", tags=PUBLIC_API_TAGS)
def get_connectors_by_credential(
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
credential: int | None = None,
) -> list[ConnectorSnapshot]:
@@ -963,7 +963,7 @@ def get_connectors_by_credential(
@router.get("/admin/connector/failed-indexing-status", tags=PUBLIC_API_TAGS)
def get_currently_failed_indexing_status(
secondary_index: bool = False,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
get_editable: bool = Query(
False, description="If true, return editable document sets"
@@ -1050,7 +1050,7 @@ def get_currently_failed_indexing_status(
@router.get("/admin/connector/status", tags=PUBLIC_API_TAGS)
def get_connector_status(
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> list[ConnectorStatus]:
# This method is only used document set and group creation/editing
@@ -1103,7 +1103,7 @@ def get_connector_status(
@router.post("/admin/connector/indexing-status", tags=PUBLIC_API_TAGS)
def get_connector_indexing_status(
request: IndexingStatusRequest,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> list[ConnectorIndexingStatusLiteResponse]:
tenant_id = get_current_tenant_id()
@@ -1175,7 +1175,7 @@ def get_connector_indexing_status(
),
]
if user and user.role == UserRole.ADMIN:
if user and Permission.MANAGE_CONNECTORS in get_effective_permissions(user):
(
editable_cc_pairs,
federated_connectors,
@@ -1549,7 +1549,7 @@ def _validate_connector_allowed(source: DocumentSource) -> None:
@router.post("/admin/connector", tags=PUBLIC_API_TAGS)
def create_connector_from_model(
connector_data: ConnectorUpdateRequest,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> ObjectCreationIdResponse:
tenant_id = get_current_tenant_id()
@@ -1557,16 +1557,6 @@ def create_connector_from_model(
try:
_validate_connector_allowed(connector_data.source)
fetch_ee_implementation_or_noop(
"onyx.db.user_group", "validate_object_creation_for_user", None
)(
db_session=db_session,
user=user,
target_group_ids=connector_data.groups,
object_is_public=connector_data.access_type == AccessType.PUBLIC,
object_is_perm_sync=connector_data.access_type == AccessType.SYNC,
object_is_new=True,
)
connector_base = connector_data.to_connector_base()
connector_response = create_connector(
db_session=db_session,
@@ -1588,20 +1578,11 @@ def create_connector_from_model(
@router.post("/admin/connector-with-mock-credential")
def create_connector_with_mock_credential(
connector_data: ConnectorUpdateRequest,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> StatusResponse:
tenant_id = get_current_tenant_id()
fetch_ee_implementation_or_noop(
"onyx.db.user_group", "validate_object_creation_for_user", None
)(
db_session=db_session,
user=user,
target_group_ids=connector_data.groups,
object_is_public=connector_data.access_type == AccessType.PUBLIC,
object_is_perm_sync=connector_data.access_type == AccessType.SYNC,
)
try:
_validate_connector_allowed(connector_data.source)
connector_response = create_connector(
@@ -1670,30 +1651,20 @@ def create_connector_with_mock_credential(
def update_connector_from_model(
connector_id: int,
connector_data: ConnectorUpdateRequest,
user: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> ConnectorSnapshot | StatusResponse[int]:
cc_pair = fetch_connector_credential_pair_for_connector(db_session, connector_id)
try:
_validate_connector_allowed(connector_data.source)
fetch_ee_implementation_or_noop(
"onyx.db.user_group", "validate_object_creation_for_user", None
)(
db_session=db_session,
user=user,
target_group_ids=connector_data.groups,
object_is_public=connector_data.access_type == AccessType.PUBLIC,
object_is_perm_sync=connector_data.access_type == AccessType.SYNC,
object_is_owned_by_user=cc_pair and user and cc_pair.creator_id == user.id,
)
connector_base = connector_data.to_connector_base()
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.INVALID_INPUT, str(e))
updated_connector = update_connector(connector_id, connector_base, db_session)
if updated_connector is None:
raise HTTPException(
status_code=404, detail=f"Connector {connector_id} does not exist"
raise OnyxError(
OnyxErrorCode.CONNECTOR_NOT_FOUND,
f"Connector {connector_id} does not exist",
)
return ConnectorSnapshot(
@@ -1720,7 +1691,7 @@ def update_connector_from_model(
)
def delete_connector_by_id(
connector_id: int,
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> StatusResponse[int]:
try:
@@ -1736,7 +1707,7 @@ def delete_connector_by_id(
@router.post("/admin/connector/run-once", tags=PUBLIC_API_TAGS)
def connector_run_once(
run_info: RunConnectorRequest,
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> StatusResponse[int]:
"""Used to trigger indexing on a set of cc_pairs associated with a

View File

@@ -5,18 +5,15 @@ from fastapi import Depends
from fastapi import File
from fastapi import Form
from fastapi import HTTPException
from fastapi import Query
from fastapi import UploadFile
from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_curator_or_admin_user
from onyx.configs.constants import PUBLIC_API_TAGS
from onyx.connectors.factory import validate_ccpair_for_user
from onyx.db.credentials import alter_credential
from onyx.db.credentials import cleanup_gmail_credentials
from onyx.db.credentials import create_credential
from onyx.db.credentials import CREDENTIAL_PERMISSIONS_TO_IGNORE
from onyx.db.credentials import delete_credential
from onyx.db.credentials import delete_credential_for_user
from onyx.db.credentials import fetch_credential_by_id_for_user
@@ -38,7 +35,6 @@ from onyx.server.documents.private_key_types import PrivateKeyFileTypes
from onyx.server.documents.private_key_types import ProcessPrivateKeyFileProtocol
from onyx.server.models import StatusResponse
from onyx.utils.logger import setup_logger
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
logger = setup_logger()
@@ -46,23 +42,18 @@ logger = setup_logger()
router = APIRouter(prefix="/manage", tags=PUBLIC_API_TAGS)
def _ignore_credential_permissions(source: DocumentSource) -> bool:
return source in CREDENTIAL_PERMISSIONS_TO_IGNORE
"""Admin-only endpoints"""
@router.get("/admin/credential")
def list_credentials_admin(
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> list[CredentialSnapshot]:
"""Lists all public credentials"""
credentials = fetch_credentials_for_user(
db_session=db_session,
user=user,
get_editable=False,
)
return [
CredentialSnapshot.from_credential_db_model(credential)
@@ -73,17 +64,13 @@ def list_credentials_admin(
@router.get("/admin/similar-credentials/{source_type}")
def get_cc_source_full_info(
source_type: DocumentSource,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
get_editable: bool = Query(
False, description="If true, return editable credentials"
),
) -> list[CredentialSnapshot]:
credentials = fetch_credentials_by_source_for_user(
db_session=db_session,
user=user,
document_source=source_type,
get_editable=get_editable,
)
return [
@@ -95,7 +82,7 @@ def get_cc_source_full_info(
@router.delete("/admin/credential/{credential_id}")
def delete_credential_by_id_admin(
credential_id: int,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> StatusResponse:
"""Same as the user endpoint, but can delete any credential (not just the user's own)"""
@@ -108,7 +95,7 @@ def delete_credential_by_id_admin(
@router.put("/admin/credential/swap")
def swap_credentials_for_connector(
credential_swap_req: CredentialSwapRequest,
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> StatusResponse:
validate_ccpair_for_user(
@@ -135,18 +122,9 @@ def swap_credentials_for_connector(
@router.post("/credential")
def create_credential_from_model(
credential_info: CredentialBase,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> ObjectCreationIdResponse:
if not _ignore_credential_permissions(credential_info.source):
fetch_ee_implementation_or_noop(
"onyx.db.user_group", "validate_object_creation_for_user", None
)(
db_session=db_session,
user=user,
target_group_ids=credential_info.groups,
object_is_public=credential_info.curator_public,
)
# Temporary fix for empty Google App credentials
if credential_info.source == DocumentSource.GMAIL:
@@ -167,7 +145,7 @@ def create_credential_with_private_key(
groups: list[int] = Form([]),
name: str | None = Form(None),
source: str = Form(...),
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
uploaded_file: UploadFile = File(...),
field_key: str = Form(...),
type_definition_key: str = Form(...),
@@ -202,16 +180,6 @@ def create_credential_with_private_key(
source=DocumentSource(source),
)
if not _ignore_credential_permissions(DocumentSource(source)):
fetch_ee_implementation_or_noop(
"onyx.db.user_group", "validate_object_creation_for_user", None
)(
db_session=db_session,
user=user,
target_group_ids=groups,
object_is_public=curator_public,
)
# Temporary fix for empty Google App credentials
if DocumentSource(source) == DocumentSource.GMAIL:
cleanup_gmail_credentials(db_session=db_session)
@@ -248,7 +216,6 @@ def get_credential_by_id(
credential_id,
user,
db_session,
get_editable=False,
)
if credential is None:
raise HTTPException(
@@ -263,7 +230,7 @@ def get_credential_by_id(
def update_credential_data(
credential_id: int,
credential_update: CredentialDataUpdateRequest,
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> CredentialBase:
credential = alter_credential(
@@ -291,7 +258,7 @@ def update_credential_private_key(
uploaded_file: UploadFile = File(...),
field_key: str = Form(...),
type_definition_key: str = Form(...),
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> CredentialBase:
try:

View File

@@ -1,11 +1,9 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import Query
from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_curator_or_admin_user
from onyx.background.celery.versioned_apps.client import app as client_app
from onyx.configs.app_configs import DISABLE_VECTOR_DB
from onyx.configs.constants import OnyxCeleryPriority
@@ -20,12 +18,13 @@ 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
from onyx.server.features.document_set.models import DocumentSetSummary
from onyx.server.features.document_set.models import DocumentSetUpdateRequest
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
from shared_configs.contextvars import get_current_tenant_id
@@ -35,19 +34,10 @@ 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:
fetch_ee_implementation_or_noop(
"onyx.db.user_group", "validate_object_creation_for_user", None
)(
db_session=db_session,
user=user,
target_group_ids=document_set_creation_request.groups,
object_is_public=document_set_creation_request.is_public,
object_is_new=True,
)
try:
document_set_db_model, _ = insert_document_set(
document_set_creation_request=document_set_creation_request,
@@ -55,7 +45,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,27 +60,17 @@ 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(
"onyx.db.user_group", "validate_object_creation_for_user", None
)(
db_session=db_session,
user=user,
target_group_ids=document_set_update_request.groups,
object_is_public=document_set_update_request.is_public,
object_is_owned_by_user=user
and (document_set.user_id is None or document_set.user_id == user.id),
)
try:
update_document_set(
document_set_update_request=document_set_update_request,
@@ -98,7 +78,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,30 +91,17 @@ 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.
# `validate_object_creation_for_user` is poorly named, but this
# is the right function to use here
fetch_ee_implementation_or_noop(
"onyx.db.user_group", "validate_object_creation_for_user", None
)(
db_session=db_session,
user=user,
object_is_public=document_set.is_public,
object_is_owned_by_user=user
and (document_set.user_id is None or document_set.user_id == user.id),
)
try:
mark_document_set_as_to_be_deleted(
db_session=db_session,
@@ -142,7 +109,7 @@ def delete_document_set(
user=user,
)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
if DISABLE_VECTOR_DB:
db_session.refresh(document_set)

View File

@@ -25,9 +25,8 @@ from pydantic import AnyUrl
from pydantic import BaseModel
from sqlalchemy.orm import Session
from onyx.auth.permissions import get_effective_permissions
from onyx.auth.permissions import require_permission
from onyx.auth.schemas import UserRole
from onyx.auth.users import current_curator_or_admin_user
from onyx.configs.app_configs import WEB_DOMAIN
from onyx.db.engine.sql_engine import get_session
from onyx.db.engine.sql_engine import get_session_with_current_tenant
@@ -59,6 +58,8 @@ from onyx.db.models import User
from onyx.db.tools import create_tool__no_commit
from onyx.db.tools import delete_tool__no_commit
from onyx.db.tools import get_tools_by_mcp_server_id
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.redis.redis_pool import get_redis_client
from onyx.server.features.mcp.models import MCPApiKeyResponse
from onyx.server.features.mcp.models import MCPAuthTemplate
@@ -351,7 +352,7 @@ class MCPOauthState(BaseModel):
async def connect_admin_oauth(
request: MCPUserOAuthConnectRequest,
db: Session = Depends(get_session),
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
) -> MCPUserOAuthConnectResponse:
"""Connect OAuth flow for admin MCP server authentication"""
return await _connect_oauth(request, db, is_admin=True, user=user)
@@ -806,16 +807,16 @@ class ServerToolsResponse(BaseModel):
def _ensure_mcp_server_owner_or_admin(server: DbMCPServer, user: User) -> None:
logger.info(
f"Ensuring MCP server owner or admin: {server.name} {user} {user.role} server.owner={server.owner}"
f"Ensuring MCP server owner or admin: {server.name} {user} server.owner={server.owner}"
)
if user.role == UserRole.ADMIN:
if Permission.FULL_ADMIN_PANEL_ACCESS in get_effective_permissions(user):
return
logger.info(f"User email: {user.email} server.owner={server.owner}")
if server.owner != user.email:
raise HTTPException(
status_code=403,
detail="Curators can only modify MCP servers that they have created.",
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"Curators can only modify MCP servers that they have created.",
)
@@ -837,7 +838,8 @@ def _db_mcp_server_to_api_mcp_server(
can_view_admin_credentials = bool(include_auth_config) and (
request_user is not None
and (
request_user.role == UserRole.ADMIN
Permission.FULL_ADMIN_PANEL_ACCESS
in get_effective_permissions(request_user)
or (request_user.email and request_user.email == db_server.owner)
)
)
@@ -1069,7 +1071,7 @@ def _get_connection_config(
def admin_list_mcp_tools_by_id(
server_id: int,
db: Session = Depends(get_session),
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
) -> MCPToolListResponse:
return _list_mcp_tools_by_id(server_id, db, True, user)
@@ -1084,7 +1086,7 @@ def get_mcp_server_tools_snapshots(
server_id: int,
source: ToolSnapshotSource = ToolSnapshotSource.DB,
db: Session = Depends(get_session),
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
) -> list[ToolSnapshot]:
"""
Get tools for an MCP server as ToolSnapshot objects.
@@ -1571,7 +1573,7 @@ def _sync_tools_for_server(
def get_mcp_server_detail(
server_id: int,
db_session: Session = Depends(get_session),
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
) -> MCPServer:
"""Return details for one MCP server if user has access"""
try:
@@ -1598,7 +1600,7 @@ def get_mcp_server_detail(
@admin_router.get("/tools")
def get_all_mcp_tools(
db: Session = Depends(get_session),
user: User = Depends(current_curator_or_admin_user), # noqa: ARG001
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)), # noqa: ARG001
) -> list:
"""Get all tools associated with MCP servers, including both enabled and disabled tools"""
from sqlalchemy import select
@@ -1618,7 +1620,7 @@ def update_mcp_server_status(
server_id: int,
status: MCPServerStatus,
db: Session = Depends(get_session),
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
) -> dict[str, str]:
"""Update the status of an MCP server"""
logger.info(f"Updating MCP server {server_id} status to {status}")
@@ -1644,7 +1646,7 @@ def update_mcp_server_status(
@admin_router.get("/servers", response_model=MCPServersResponse)
def get_mcp_servers_for_admin(
db: Session = Depends(get_session),
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
) -> MCPServersResponse:
"""Get all MCP servers for admin display"""
@@ -1670,7 +1672,7 @@ def get_mcp_servers_for_admin(
def get_mcp_server_db_tools(
server_id: int,
db: Session = Depends(get_session),
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
) -> ServerToolsResponse:
"""Get existing database tools created for an MCP server"""
logger.info(f"Getting database tools for MCP server: {server_id}")
@@ -1715,7 +1717,7 @@ def get_mcp_server_db_tools(
def upsert_mcp_server(
request: MCPToolCreateRequest,
db_session: Session = Depends(get_session),
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
) -> MCPServerCreateResponse:
"""Create or update an MCP server (no tools yet)"""
@@ -1777,7 +1779,7 @@ def upsert_mcp_server(
def update_mcp_server_with_tools(
request: MCPToolUpdateRequest,
db_session: Session = Depends(get_session),
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
) -> MCPServerUpdateResponse:
"""Update an MCP server and associated tools"""
@@ -1829,7 +1831,7 @@ def update_mcp_server_with_tools(
def create_mcp_server_simple(
request: MCPServerSimpleCreateRequest,
db_session: Session = Depends(get_session),
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
) -> MCPServer:
"""Create MCP server with minimal information - auth to be configured later"""
@@ -1869,7 +1871,7 @@ def update_mcp_server_simple(
server_id: int,
request: MCPServerSimpleUpdateRequest,
db_session: Session = Depends(get_session),
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
) -> MCPServer:
"""Update MCP server basic information (name, description, URL)"""
try:
@@ -1900,7 +1902,7 @@ def update_mcp_server_simple(
def delete_mcp_server_admin(
server_id: int,
db_session: Session = Depends(get_session),
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
) -> dict:
"""Delete an MCP server and cascading related objects (tools, configs)."""
try:

View File

@@ -7,7 +7,6 @@ from sqlalchemy.orm import Session
from onyx.auth.oauth_token_manager import OAuthTokenManager
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_curator_or_admin_user
from onyx.configs.app_configs import WEB_DOMAIN
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import Permission
@@ -64,7 +63,7 @@ def _oauth_config_to_snapshot(
def create_oauth_config_endpoint(
oauth_data: OAuthConfigCreate,
db_session: Session = Depends(get_session),
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
) -> OAuthConfigSnapshot:
"""Create a new OAuth configuration (admin only)."""
try:
@@ -86,7 +85,7 @@ def create_oauth_config_endpoint(
@admin_router.get("")
def list_oauth_configs(
db_session: Session = Depends(get_session),
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
) -> list[OAuthConfigSnapshot]:
"""List all OAuth configurations (admin only)."""
oauth_configs = get_oauth_configs(db_session)
@@ -97,7 +96,7 @@ def list_oauth_configs(
def get_oauth_config_endpoint(
oauth_config_id: int,
db_session: Session = Depends(get_session),
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
) -> OAuthConfigSnapshot:
"""Retrieve a single OAuth configuration (admin only)."""
oauth_config = get_oauth_config(oauth_config_id, db_session)
@@ -113,7 +112,7 @@ def update_oauth_config_endpoint(
oauth_config_id: int,
oauth_data: OAuthConfigUpdate,
db_session: Session = Depends(get_session),
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
) -> OAuthConfigSnapshot:
"""Update an OAuth configuration (admin only)."""
try:
@@ -139,7 +138,7 @@ def update_oauth_config_endpoint(
def delete_oauth_config_endpoint(
oauth_config_id: int,
db_session: Session = Depends(get_session),
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
) -> dict[str, str]:
"""Delete an OAuth configuration (admin only)."""
try:

View File

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

View File

@@ -6,9 +6,8 @@ from fastapi import HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from onyx.auth.permissions import get_effective_permissions
from onyx.auth.permissions import require_permission
from onyx.auth.schemas import UserRole
from onyx.auth.users import current_curator_or_admin_user
from onyx.configs.constants import PUBLIC_API_TAGS
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import Permission
@@ -20,6 +19,8 @@ from onyx.db.tools import get_tool_by_id
from onyx.db.tools import get_tools
from onyx.db.tools import get_tools_by_ids
from onyx.db.tools import update_tool
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.tool.models import CustomToolCreate
from onyx.server.features.tool.models import CustomToolUpdate
from onyx.server.features.tool.models import ToolSnapshot
@@ -68,13 +69,13 @@ def _get_editable_custom_tool(tool_id: int, db_session: Session, user: User) ->
)
# Admins can always make changes; non-admins must own the tool.
if user.role == UserRole.ADMIN:
if Permission.FULL_ADMIN_PANEL_ACCESS in get_effective_permissions(user):
return tool
if tool.user_id is None or tool.user_id != user.id:
raise HTTPException(
status_code=403,
detail="You can only modify actions that you created.",
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"You can only modify actions that you created.",
)
return tool
@@ -84,7 +85,7 @@ def _get_editable_custom_tool(tool_id: int, db_session: Session, user: User) ->
def create_custom_tool(
tool_data: CustomToolCreate,
db_session: Session = Depends(get_session),
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
) -> ToolSnapshot:
_validate_tool_definition(tool_data.definition)
_validate_auth_settings(tool_data)
@@ -108,7 +109,7 @@ def update_custom_tool(
tool_id: int,
tool_data: CustomToolUpdate,
db_session: Session = Depends(get_session),
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
) -> ToolSnapshot:
existing_tool = _get_editable_custom_tool(tool_id, db_session, user)
if tool_data.definition:
@@ -132,7 +133,7 @@ def update_custom_tool(
def delete_custom_tool(
tool_id: int,
db_session: Session = Depends(get_session),
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
) -> None:
_ = _get_editable_custom_tool(tool_id, db_session, user)
try:
@@ -159,7 +160,7 @@ class ToolStatusUpdateResponse(BaseModel):
def update_tools_status(
update_data: ToolStatusUpdateRequest,
db_session: Session = Depends(get_session),
user: User = Depends(current_curator_or_admin_user), # noqa: ARG001
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)), # noqa: ARG001
) -> ToolStatusUpdateResponse:
"""Enable or disable one or more tools.
@@ -207,7 +208,7 @@ class ValidateToolResponse(BaseModel):
@admin_router.post("/custom/validate", tags=PUBLIC_API_TAGS)
def validate_tool(
tool_data: ValidateToolRequest,
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
) -> ValidateToolResponse:
_validate_tool_definition(tool_data.definition)
method_specs = openapi_to_method_specs(tool_data.definition)

View File

@@ -10,7 +10,6 @@ from fastapi import Response
from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_curator_or_admin_user
from onyx.configs.constants import FederatedConnectorSource
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import Permission
@@ -65,7 +64,7 @@ def _get_federated_connector_instance(
@router.post("")
def create_federated_connector(
federated_connector_data: FederatedConnectorRequest,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> FederatedConnectorResponse:
"""Create a new federated connector"""
@@ -106,7 +105,7 @@ def create_federated_connector(
@router.get("/{id}/entities")
def get_entities(
id: int,
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.READ_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> EntitySpecResponse:
"""Fetch allowed entities for the source type"""
@@ -148,7 +147,7 @@ def get_entities(
@router.get("/{id}/credentials/schema")
def get_credentials_schema(
id: int,
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.READ_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> CredentialSchemaResponse:
"""Fetch credential schema for the source type"""
@@ -193,7 +192,7 @@ def get_credentials_schema(
@router.get("/sources/{source}/configuration/schema")
def get_configuration_schema_by_source(
source: FederatedConnectorSource,
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.READ_CONNECTORS)),
) -> ConfigurationSchemaResponse:
"""Fetch configuration schema for a specific source type (for setup/edit forms)"""
try:
@@ -221,7 +220,7 @@ def get_configuration_schema_by_source(
@router.get("/sources/{source}/credentials/schema")
def get_credentials_schema_by_source(
source: FederatedConnectorSource,
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.READ_CONNECTORS)),
) -> CredentialSchemaResponse:
"""Fetch credential schema for a specific source type (for setup forms)"""
try:
@@ -253,7 +252,7 @@ def get_credentials_schema_by_source(
def validate_credentials(
source: FederatedConnectorSource,
credentials: FederatedConnectorCredentials,
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
) -> bool:
"""Validate credentials for a specific source type"""
try:
@@ -277,7 +276,7 @@ def validate_credentials(
def validate_entities(
id: int,
request: Request,
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> Response:
"""Validate specified entities for source type"""
@@ -512,7 +511,7 @@ def get_user_oauth_status(
@router.get("/{id}")
def get_federated_connector_detail(
id: int,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.READ_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> FederatedConnectorDetail:
"""Get detailed information about a specific federated connector"""
@@ -562,7 +561,7 @@ def get_federated_connector_detail(
def update_federated_connector_endpoint(
id: int,
update_request: FederatedConnectorUpdateRequest,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> FederatedConnectorDetail:
"""Update a federated connector's configuration"""
@@ -593,7 +592,7 @@ def update_federated_connector_endpoint(
@router.delete("/{id}")
def delete_federated_connector_endpoint(
id: int,
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> bool:
"""Delete a federated connector"""

View File

@@ -5,11 +5,9 @@ from typing import cast
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
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 GENERATIVE_MODEL_ACCESS_CHECK_FREQ
from onyx.configs.constants import DocumentSource
@@ -29,6 +27,8 @@ from onyx.db.feedback import update_document_boost_for_user
from onyx.db.feedback import update_document_hidden_for_user
from onyx.db.index_attempt import cancel_indexing_attempts_for_ccpair
from onyx.db.models import User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.file_store.file_store import get_default_file_store
from onyx.key_value_store.factory import get_kv_store
from onyx.key_value_store.interface import KvKeyNotFoundError
@@ -52,7 +52,7 @@ logger = setup_logger()
def get_most_boosted_docs(
ascending: bool,
limit: int,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> list[BoostDoc]:
boost_docs = fetch_docs_ranked_by_boost_for_user(
@@ -77,7 +77,7 @@ def get_most_boosted_docs(
@router.post("/admin/doc-boosts")
def document_boost_update(
boost_update: BoostUpdateRequest,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> StatusResponse:
update_document_boost_for_user(
@@ -92,7 +92,7 @@ def document_boost_update(
@router.post("/admin/doc-hidden")
def document_hidden_update(
hidden_update: HiddenUpdateRequest,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> StatusResponse:
update_document_hidden_for_user(
@@ -106,7 +106,7 @@ def document_hidden_update(
@router.get("/admin/genai-api-key/validate")
def validate_existing_genai_api_key(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
) -> None:
# Only validate every so often
kv_store = get_kv_store()
@@ -125,11 +125,11 @@ def validate_existing_genai_api_key(
try:
llm = get_default_llm(timeout=10)
except ValueError:
raise HTTPException(status_code=404, detail="LLM not setup")
raise OnyxError(OnyxErrorCode.NOT_FOUND, "LLM not setup")
error = test_llm(llm)
if error:
raise HTTPException(status_code=400, detail=error)
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, error)
# Mark check as successful
curr_time = datetime.now(tz=timezone.utc)
@@ -139,7 +139,7 @@ def validate_existing_genai_api_key(
@router.post("/admin/deletion-attempt", tags=PUBLIC_API_TAGS)
def create_deletion_attempt_for_connector_id(
connector_credential_pair_identifier: ConnectorCredentialPairIdentifier,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> None:
tenant_id = get_current_tenant_id()
@@ -157,10 +157,7 @@ def create_deletion_attempt_for_connector_id(
if cc_pair is None:
error = f"Connector with ID '{connector_id}' and credential ID '{credential_id}' does not exist. Has it already been deleted?"
logger.error(error)
raise HTTPException(
status_code=404,
detail=error,
)
raise OnyxError(OnyxErrorCode.CONNECTOR_NOT_FOUND, error)
# Cancel any scheduled indexing attempts
cancel_indexing_attempts_for_ccpair(

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,6 @@ from onyx.auth.permissions import get_effective_permissions
from onyx.auth.permissions import require_permission
from onyx.auth.schemas import UserRole
from onyx.auth.users import anonymous_user_enabled
from onyx.auth.users import current_curator_or_admin_user
from onyx.auth.users import enforce_seat_limit
from onyx.auth.users import optional_user
from onyx.configs.app_configs import AUTH_BACKEND
@@ -155,13 +154,6 @@ def set_user_role(
detail="An admin cannot demote themselves from admin role!",
)
if requested_role == UserRole.CURATOR:
# Remove all curator db relationships before changing role
fetch_ee_implementation_or_noop(
"onyx.db.user_group",
"remove_curator_status__no_commit",
)(db_session, user_to_update)
update_user_role(user_to_update, requested_role, db_session)
@@ -322,7 +314,7 @@ def list_all_users(
slack_users_page: int | None = None,
invited_page: int | None = None,
include_api_keys: bool = False,
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.READ_USERS)),
db_session: Session = Depends(get_session),
) -> AllUsersResponse:
users = [
@@ -857,6 +849,7 @@ def verify_user_logged_in(
invitation=tenant_invitation,
),
memories=memories,
effective_permissions=sorted(p.value for p in get_effective_permissions(user)),
)
return user_info

View File

@@ -3,10 +3,9 @@ from datetime import timezone
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from onyx.auth.users import current_curator_or_admin_user
from onyx.auth.permissions import require_permission
from onyx.configs.constants import DEFAULT_CC_PAIR_ID
from onyx.configs.constants import DocumentSource
from onyx.configs.constants import PUBLIC_API_TAGS
@@ -18,11 +17,14 @@ from onyx.db.document import get_document
from onyx.db.document import get_documents_by_cc_pair
from onyx.db.document import get_ingestion_documents
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import Permission
from onyx.db.models import User
from onyx.db.search_settings import get_active_search_settings
from onyx.db.search_settings import get_current_search_settings
from onyx.db.search_settings import get_secondary_search_settings
from onyx.document_index.factory import get_all_document_indices
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.indexing.adapters.document_indexing_adapter import (
DocumentIndexingBatchAdapter,
)
@@ -44,7 +46,7 @@ router = APIRouter(prefix="/onyx-api", tags=PUBLIC_API_TAGS)
@router.get("/connector-docs/{cc_pair_id}")
def get_docs_by_connector_credential_pair(
cc_pair_id: int,
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> list[DocMinimalInfo]:
db_docs = get_documents_by_cc_pair(cc_pair_id=cc_pair_id, db_session=db_session)
@@ -60,7 +62,7 @@ def get_docs_by_connector_credential_pair(
@router.get("/ingestion")
def get_ingestion_docs(
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> list[DocMinimalInfo]:
db_docs = get_ingestion_documents(db_session)
@@ -77,7 +79,7 @@ def get_ingestion_docs(
@router.post("/ingestion", dependencies=[Depends(require_vector_db)])
def upsert_ingestion_doc(
doc_info: IngestionDocument,
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> IngestionResult:
tenant_id = get_current_tenant_id()
@@ -98,8 +100,9 @@ def upsert_ingestion_doc(
cc_pair_id=doc_info.cc_pair_id or DEFAULT_CC_PAIR_ID,
)
if cc_pair is None:
raise HTTPException(
status_code=400, detail="Connector-Credential Pair specified does not exist"
raise OnyxError(
OnyxErrorCode.CONNECTOR_NOT_FOUND,
"Connector-Credential Pair specified does not exist",
)
# Need to index for both the primary and secondary index if possible
@@ -179,7 +182,7 @@ def upsert_ingestion_doc(
@router.delete("/ingestion/{document_id}", dependencies=[Depends(require_vector_db)])
def delete_ingestion_doc(
document_id: str,
_: User = Depends(current_curator_or_admin_user),
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
db_session: Session = Depends(get_session),
) -> None:
tenant_id = get_current_tenant_id()
@@ -187,12 +190,12 @@ def delete_ingestion_doc(
# Verify the document exists and was created via the ingestion API
document = get_document(document_id=document_id, db_session=db_session)
if document is None:
raise HTTPException(status_code=404, detail="Document not found")
raise OnyxError(OnyxErrorCode.DOCUMENT_NOT_FOUND, "Document not found")
if not document.from_ingestion_api:
raise HTTPException(
status_code=400,
detail="Document was not created via the ingestion API",
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Document was not created via the ingestion API",
)
active_search_settings = get_active_search_settings(db_session)

View File

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

View File

@@ -3,7 +3,6 @@ from fastapi import Depends
from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_curator_or_admin_user
from onyx.configs.constants import DocumentSource
from onyx.context.search.models import IndexFilters
from onyx.context.search.models import SearchDoc
@@ -34,7 +33,7 @@ basic_router = APIRouter(prefix="/query")
@admin_router.post("/search", dependencies=[Depends(require_vector_db)])
def admin_search(
question: AdminSearchRequest,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> AdminSearchResponse:
tenant_id = get_current_tenant_id()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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