mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-21 17:36:44 +00:00
Compare commits
25 Commits
v3.2.6
...
permission
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
751e3fb043 | ||
|
|
3c0f7d9587 | ||
|
|
7b48722798 | ||
|
|
ebbd444c92 | ||
|
|
c18be46ee0 | ||
|
|
4c0e58eab7 | ||
|
|
ac3bbdbac9 | ||
|
|
ca4299db92 | ||
|
|
ec1cc44703 | ||
|
|
84bf5060dd | ||
|
|
f6e2c32331 | ||
|
|
3d333d2d76 | ||
|
|
acfa30f865 | ||
|
|
606ab55d73 | ||
|
|
6223ba531b | ||
|
|
6f6e64ad63 | ||
|
|
a05f09faa2 | ||
|
|
5912f632a3 | ||
|
|
3e5cfa66d1 | ||
|
|
b2f5eb3ec7 | ||
|
|
0ab2b8065d | ||
|
|
4c304bf393 | ||
|
|
cef5caa8b1 | ||
|
|
f7b8650d5c | ||
|
|
df532aa87d |
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ from uuid import UUID
|
||||
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import exists
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy import Select
|
||||
@@ -13,22 +12,21 @@ from sqlalchemy.orm import aliased
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.permissions import has_permission
|
||||
from onyx.configs.app_configs import DISABLE_VECTOR_DB
|
||||
from onyx.db.connector_credential_pair import get_cc_pair_groups_for_ids
|
||||
from onyx.db.connector_credential_pair import get_connector_credential_pairs
|
||||
from onyx.db.enums import AccessType
|
||||
from onyx.db.enums import ConnectorCredentialPairStatus
|
||||
from onyx.db.enums import Permission
|
||||
from onyx.db.federated import create_federated_connector_document_set_mapping
|
||||
from onyx.db.models import ConnectorCredentialPair
|
||||
from onyx.db.models import Document
|
||||
from onyx.db.models import DocumentByConnectorCredentialPair
|
||||
from onyx.db.models import DocumentSet as DocumentSetDBModel
|
||||
from onyx.db.models import DocumentSet__ConnectorCredentialPair
|
||||
from onyx.db.models import DocumentSet__UserGroup
|
||||
from onyx.db.models import FederatedConnector__DocumentSet
|
||||
from onyx.db.models import User
|
||||
from onyx.db.models import User__UserGroup
|
||||
from onyx.db.models import UserRole
|
||||
from onyx.server.features.document_set.models import DocumentSetCreationRequest
|
||||
from onyx.server.features.document_set.models import DocumentSetUpdateRequest
|
||||
from onyx.utils.logger import setup_logger
|
||||
@@ -38,54 +36,16 @@ logger = setup_logger()
|
||||
|
||||
|
||||
def _add_user_filters(stmt: Select, user: User, get_editable: bool = True) -> Select:
|
||||
if user.role == UserRole.ADMIN:
|
||||
# MANAGE → always return all
|
||||
if has_permission(user, Permission.MANAGE_DOCUMENT_SETS):
|
||||
return stmt
|
||||
|
||||
stmt = stmt.distinct()
|
||||
DocumentSet__UG = aliased(DocumentSet__UserGroup)
|
||||
User__UG = aliased(User__UserGroup)
|
||||
"""
|
||||
Here we select cc_pairs by relation:
|
||||
User -> User__UserGroup -> DocumentSet__UserGroup -> DocumentSet
|
||||
"""
|
||||
stmt = stmt.outerjoin(DocumentSet__UG).outerjoin(
|
||||
User__UserGroup,
|
||||
User__UserGroup.user_group_id == DocumentSet__UG.user_group_id,
|
||||
)
|
||||
"""
|
||||
Filter DocumentSets by:
|
||||
- if the user is in the user_group that owns the DocumentSet
|
||||
- if the user is not a global_curator, they must also have a curator relationship
|
||||
to the user_group
|
||||
- if editing is being done, we also filter out DocumentSets that are owned by groups
|
||||
that the user isn't a curator for
|
||||
- if we are not editing, we show all DocumentSets in the groups the user is a curator
|
||||
for (as well as public DocumentSets)
|
||||
"""
|
||||
|
||||
# Anonymous users only see public DocumentSets
|
||||
if user.is_anonymous:
|
||||
where_clause = DocumentSetDBModel.is_public == True # noqa: E712
|
||||
return stmt.where(where_clause)
|
||||
|
||||
where_clause = User__UserGroup.user_id == user.id
|
||||
if user.role == UserRole.CURATOR and get_editable:
|
||||
where_clause &= User__UserGroup.is_curator == True # noqa: E712
|
||||
if get_editable:
|
||||
user_groups = select(User__UG.user_group_id).where(User__UG.user_id == user.id)
|
||||
if user.role == UserRole.CURATOR:
|
||||
user_groups = user_groups.where(User__UG.is_curator == True) # noqa: E712
|
||||
where_clause &= (
|
||||
~exists()
|
||||
.where(DocumentSet__UG.document_set_id == DocumentSetDBModel.id)
|
||||
.where(~DocumentSet__UG.user_group_id.in_(user_groups))
|
||||
.correlate(DocumentSetDBModel)
|
||||
)
|
||||
where_clause |= DocumentSetDBModel.user_id == user.id
|
||||
else:
|
||||
where_clause |= DocumentSetDBModel.is_public == True # noqa: E712
|
||||
|
||||
return stmt.where(where_clause)
|
||||
# READ → return all when reading, nothing when editing
|
||||
if has_permission(user, Permission.READ_DOCUMENT_SETS):
|
||||
if get_editable:
|
||||
return stmt.where(False)
|
||||
return stmt
|
||||
# No permission → return nothing
|
||||
return stmt.where(False)
|
||||
|
||||
|
||||
def _delete_document_set_cc_pairs__no_commit(
|
||||
|
||||
@@ -366,12 +366,12 @@ class Permission(str, PyEnum):
|
||||
READ_DOCUMENT_SETS = "read:document_sets"
|
||||
READ_AGENTS = "read:agents"
|
||||
READ_USERS = "read:users"
|
||||
READ_USER_GROUPS = "read:user_groups"
|
||||
|
||||
# Add / Manage pairs
|
||||
ADD_AGENTS = "add:agents"
|
||||
MANAGE_AGENTS = "manage:agents"
|
||||
MANAGE_DOCUMENT_SETS = "manage:document_sets"
|
||||
ADD_CONNECTORS = "add:connectors"
|
||||
MANAGE_CONNECTORS = "manage:connectors"
|
||||
MANAGE_LLMS = "manage:llms"
|
||||
|
||||
@@ -381,8 +381,8 @@ class Permission(str, PyEnum):
|
||||
READ_QUERY_HISTORY = "read:query_history"
|
||||
MANAGE_USER_GROUPS = "manage:user_groups"
|
||||
CREATE_USER_API_KEYS = "create:user_api_keys"
|
||||
CREATE_SERVICE_ACCOUNT_API_KEYS = "create:service_account_api_keys"
|
||||
CREATE_SLACK_DISCORD_BOTS = "create:slack_discord_bots"
|
||||
MANAGE_SERVICE_ACCOUNT_API_KEYS = "manage:service_account_api_keys"
|
||||
MANAGE_BOTS = "manage:bots"
|
||||
|
||||
# Override — any permission check passes
|
||||
FULL_ADMIN_PANEL_ACCESS = "admin"
|
||||
|
||||
@@ -16,12 +16,12 @@ from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.access.hierarchy_access import get_user_external_group_ids
|
||||
from onyx.auth.schemas import UserRole
|
||||
from onyx.configs.app_configs import CURATORS_CANNOT_VIEW_OR_EDIT_NON_OWNED_ASSISTANTS
|
||||
from onyx.auth.permissions import has_permission
|
||||
from onyx.configs.constants import DEFAULT_PERSONA_ID
|
||||
from onyx.configs.constants import NotificationType
|
||||
from onyx.db.constants import SLACK_BOT_PERSONA_PREFIX
|
||||
from onyx.db.document_access import get_accessible_documents_by_ids
|
||||
from onyx.db.enums import Permission
|
||||
from onyx.db.models import ConnectorCredentialPair
|
||||
from onyx.db.models import Document
|
||||
from onyx.db.models import DocumentSet
|
||||
@@ -74,7 +74,9 @@ class PersonaLoadType(Enum):
|
||||
def _add_user_filters(
|
||||
stmt: Select[tuple[Persona]], user: User, get_editable: bool = True
|
||||
) -> Select[tuple[Persona]]:
|
||||
if user.role == UserRole.ADMIN:
|
||||
if has_permission(user, Permission.MANAGE_AGENTS):
|
||||
return stmt
|
||||
if not get_editable and has_permission(user, Permission.READ_AGENTS):
|
||||
return stmt
|
||||
|
||||
stmt = stmt.distinct()
|
||||
@@ -98,12 +100,7 @@ def _add_user_filters(
|
||||
"""
|
||||
Filter Personas by:
|
||||
- if the user is in the user_group that owns the Persona
|
||||
- if the user is not a global_curator, they must also have a curator relationship
|
||||
to the user_group
|
||||
- if editing is being done, we also filter out Personas that are owned by groups
|
||||
that the user isn't a curator for
|
||||
- if we are not editing, we show all Personas in the groups the user is a curator
|
||||
for (as well as public Personas)
|
||||
- if we are not editing, we show all public and listed Personas
|
||||
- if we are not editing, we return all Personas directly connected to the user
|
||||
"""
|
||||
|
||||
@@ -112,21 +109,9 @@ def _add_user_filters(
|
||||
where_clause = Persona.is_public == True # noqa: E712
|
||||
return stmt.where(where_clause)
|
||||
|
||||
# If curator ownership restriction is enabled, curators can only access their own assistants
|
||||
if CURATORS_CANNOT_VIEW_OR_EDIT_NON_OWNED_ASSISTANTS and user.role in [
|
||||
UserRole.CURATOR,
|
||||
UserRole.GLOBAL_CURATOR,
|
||||
]:
|
||||
where_clause = (Persona.user_id == user.id) | (Persona.user_id.is_(None))
|
||||
return stmt.where(where_clause)
|
||||
|
||||
where_clause = User__UserGroup.user_id == user.id
|
||||
if user.role == UserRole.CURATOR and get_editable:
|
||||
where_clause &= User__UserGroup.is_curator == True # noqa: E712
|
||||
if get_editable:
|
||||
user_groups = select(User__UG.user_group_id).where(User__UG.user_id == user.id)
|
||||
if user.role == UserRole.CURATOR:
|
||||
user_groups = user_groups.where(User__UG.is_curator == True) # noqa: E712
|
||||
where_clause &= (
|
||||
~exists()
|
||||
.where(Persona__UG.persona_id == Persona.id)
|
||||
@@ -197,7 +182,7 @@ def _get_persona_by_name(
|
||||
- Non-admin users: can only see their own personas
|
||||
"""
|
||||
stmt = select(Persona).where(Persona.name == persona_name)
|
||||
if user and user.role != UserRole.ADMIN:
|
||||
if user and not has_permission(user, Permission.MANAGE_AGENTS):
|
||||
stmt = stmt.where(Persona.user_id == user.id)
|
||||
result = db_session.execute(stmt).scalar_one_or_none()
|
||||
return result
|
||||
@@ -271,12 +256,10 @@ def create_update_persona(
|
||||
try:
|
||||
# Featured persona validation
|
||||
if create_persona_request.is_featured:
|
||||
# Curators can edit featured personas, but not make them
|
||||
# TODO this will be reworked soon with RBAC permissions feature
|
||||
if user.role == UserRole.CURATOR or user.role == UserRole.GLOBAL_CURATOR:
|
||||
pass
|
||||
elif user.role != UserRole.ADMIN:
|
||||
raise ValueError("Only admins can make a featured persona")
|
||||
if not has_permission(user, Permission.MANAGE_AGENTS):
|
||||
raise ValueError(
|
||||
"Only users with agent management permissions can make a featured persona"
|
||||
)
|
||||
|
||||
# Convert incoming string UUIDs to UUID objects for DB operations
|
||||
converted_user_file_ids = None
|
||||
@@ -353,7 +336,11 @@ def update_persona_shared(
|
||||
db_session=db_session, persona_id=persona_id, user=user, get_editable=True
|
||||
)
|
||||
|
||||
if user and user.role != UserRole.ADMIN and persona.user_id != user.id:
|
||||
if (
|
||||
user
|
||||
and not has_permission(user, Permission.MANAGE_AGENTS)
|
||||
and persona.user_id != user.id
|
||||
):
|
||||
raise PermissionError("You don't have permission to modify this persona")
|
||||
|
||||
versioned_update_persona_access = fetch_versioned_implementation(
|
||||
@@ -389,7 +376,10 @@ def update_persona_public_status(
|
||||
persona = fetch_persona_by_id_for_user(
|
||||
db_session=db_session, persona_id=persona_id, user=user, get_editable=True
|
||||
)
|
||||
if user.role != UserRole.ADMIN and persona.user_id != user.id:
|
||||
if (
|
||||
not has_permission(user, Permission.MANAGE_AGENTS)
|
||||
and persona.user_id != user.id
|
||||
):
|
||||
raise ValueError("You don't have permission to modify this persona")
|
||||
|
||||
persona.is_public = is_public
|
||||
@@ -1226,7 +1216,11 @@ def get_persona_by_id(
|
||||
if not include_deleted:
|
||||
persona_stmt = persona_stmt.where(Persona.deleted.is_(False))
|
||||
|
||||
if not user or user.role == UserRole.ADMIN:
|
||||
if (
|
||||
not user
|
||||
or has_permission(user, Permission.MANAGE_AGENTS)
|
||||
or (not is_for_edit and has_permission(user, Permission.READ_AGENTS))
|
||||
):
|
||||
result = db_session.execute(persona_stmt)
|
||||
persona = result.scalar_one_or_none()
|
||||
if persona is None:
|
||||
@@ -1243,14 +1237,6 @@ def get_persona_by_id(
|
||||
# if the user is in the .users of the persona
|
||||
or_conditions |= User.id == user.id
|
||||
or_conditions |= Persona.is_public == True # noqa: E712
|
||||
elif user.role == UserRole.GLOBAL_CURATOR:
|
||||
# global curators can edit personas for the groups they are in
|
||||
or_conditions |= User__UserGroup.user_id == user.id
|
||||
elif user.role == UserRole.CURATOR:
|
||||
# curators can edit personas for the groups they are curators of
|
||||
or_conditions |= (User__UserGroup.user_id == user.id) & (
|
||||
User__UserGroup.is_curator == True # noqa: E712
|
||||
)
|
||||
|
||||
persona_stmt = persona_stmt.where(or_conditions)
|
||||
result = db_session.execute(persona_stmt)
|
||||
|
||||
@@ -56,6 +56,7 @@ class OnyxErrorCode(Enum):
|
||||
DOCUMENT_NOT_FOUND = ("DOCUMENT_NOT_FOUND", 404)
|
||||
SESSION_NOT_FOUND = ("SESSION_NOT_FOUND", 404)
|
||||
USER_NOT_FOUND = ("USER_NOT_FOUND", 404)
|
||||
DOCUMENT_SET_NOT_FOUND = ("DOCUMENT_SET_NOT_FOUND", 404)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Conflict (409)
|
||||
|
||||
@@ -20,7 +20,7 @@ router = APIRouter(prefix="/admin/api-key")
|
||||
|
||||
@router.get("")
|
||||
def list_api_keys(
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_SERVICE_ACCOUNT_API_KEYS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[ApiKeyDescriptor]:
|
||||
return fetch_api_keys(db_session)
|
||||
@@ -29,7 +29,9 @@ def list_api_keys(
|
||||
@router.post("")
|
||||
def create_api_key(
|
||||
api_key_args: APIKeyArgs,
|
||||
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
user: User = Depends(
|
||||
require_permission(Permission.MANAGE_SERVICE_ACCOUNT_API_KEYS)
|
||||
),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ApiKeyDescriptor:
|
||||
return insert_api_key(db_session, api_key_args, user.id)
|
||||
@@ -38,7 +40,7 @@ def create_api_key(
|
||||
@router.post("/{api_key_id}/regenerate")
|
||||
def regenerate_existing_api_key(
|
||||
api_key_id: int,
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_SERVICE_ACCOUNT_API_KEYS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ApiKeyDescriptor:
|
||||
return regenerate_api_key(db_session, api_key_id)
|
||||
@@ -48,7 +50,7 @@ def regenerate_existing_api_key(
|
||||
def update_existing_api_key(
|
||||
api_key_id: int,
|
||||
api_key_args: APIKeyArgs,
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_SERVICE_ACCOUNT_API_KEYS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> ApiKeyDescriptor:
|
||||
return update_api_key(db_session, api_key_id, api_key_args)
|
||||
@@ -57,7 +59,7 @@ def update_existing_api_key(
|
||||
@router.delete("/{api_key_id}")
|
||||
def delete_api_key(
|
||||
api_key_id: int,
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_SERVICE_ACCOUNT_API_KEYS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
remove_api_key(db_session, api_key_id)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from fastapi import status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.permissions import require_permission
|
||||
@@ -25,6 +23,8 @@ from onyx.db.discord_bot import update_guild_config
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.enums import Permission
|
||||
from onyx.db.models import User
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.server.manage.discord_bot.models import DiscordBotConfigCreateRequest
|
||||
from onyx.server.manage.discord_bot.models import DiscordBotConfigResponse
|
||||
from onyx.server.manage.discord_bot.models import DiscordChannelConfigResponse
|
||||
@@ -48,14 +48,14 @@ def _check_bot_config_api_access() -> None:
|
||||
- When DISCORD_BOT_TOKEN env var is set (managed via env)
|
||||
"""
|
||||
if AUTH_TYPE == AuthType.CLOUD:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Discord bot configuration is managed by Onyx on Cloud.",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
|
||||
"Discord bot configuration is managed by Onyx on Cloud.",
|
||||
)
|
||||
if DISCORD_BOT_TOKEN:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Discord bot is configured via environment variables. API access disabled.",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
|
||||
"Discord bot is configured via environment variables. API access disabled.",
|
||||
)
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ def _check_bot_config_api_access() -> None:
|
||||
@router.get("/config", response_model=DiscordBotConfigResponse)
|
||||
def get_bot_config(
|
||||
_: None = Depends(_check_bot_config_api_access),
|
||||
__: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
__: User = Depends(require_permission(Permission.MANAGE_BOTS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> DiscordBotConfigResponse:
|
||||
"""Get Discord bot config. Returns 403 on Cloud or if env vars set."""
|
||||
@@ -83,7 +83,7 @@ def get_bot_config(
|
||||
def create_bot_request(
|
||||
request: DiscordBotConfigCreateRequest,
|
||||
_: None = Depends(_check_bot_config_api_access),
|
||||
__: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
__: User = Depends(require_permission(Permission.MANAGE_BOTS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> DiscordBotConfigResponse:
|
||||
"""Create Discord bot config. Returns 403 on Cloud or if env vars set."""
|
||||
@@ -93,9 +93,9 @@ def create_bot_request(
|
||||
bot_token=request.bot_token,
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Discord bot config already exists. Delete it first to create a new one.",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.CONFLICT,
|
||||
"Discord bot config already exists. Delete it first to create a new one.",
|
||||
)
|
||||
|
||||
db_session.commit()
|
||||
@@ -109,7 +109,7 @@ def create_bot_request(
|
||||
@router.delete("/config")
|
||||
def delete_bot_config_endpoint(
|
||||
_: None = Depends(_check_bot_config_api_access),
|
||||
__: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
__: User = Depends(require_permission(Permission.MANAGE_BOTS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Delete Discord bot config.
|
||||
@@ -118,7 +118,7 @@ def delete_bot_config_endpoint(
|
||||
"""
|
||||
deleted = delete_discord_bot_config(db_session)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Bot config not found")
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Bot config not found")
|
||||
|
||||
# Also delete the service API key used by the Discord bot
|
||||
delete_discord_service_api_key(db_session)
|
||||
@@ -132,7 +132,7 @@ def delete_bot_config_endpoint(
|
||||
|
||||
@router.delete("/service-api-key")
|
||||
def delete_service_api_key_endpoint(
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Delete the Discord service API key.
|
||||
@@ -145,7 +145,7 @@ def delete_service_api_key_endpoint(
|
||||
"""
|
||||
deleted = delete_discord_service_api_key(db_session)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Service API key not found")
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Service API key not found")
|
||||
db_session.commit()
|
||||
return {"deleted": True}
|
||||
|
||||
@@ -155,7 +155,7 @@ def delete_service_api_key_endpoint(
|
||||
|
||||
@router.get("/guilds", response_model=list[DiscordGuildConfigResponse])
|
||||
def list_guild_configs(
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[DiscordGuildConfigResponse]:
|
||||
"""List all guild configs (pending and registered)."""
|
||||
@@ -165,7 +165,7 @@ def list_guild_configs(
|
||||
|
||||
@router.post("/guilds", response_model=DiscordGuildConfigCreateResponse)
|
||||
def create_guild_request(
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> DiscordGuildConfigCreateResponse:
|
||||
"""Create new guild config with registration key. Key shown once."""
|
||||
@@ -184,13 +184,13 @@ def create_guild_request(
|
||||
@router.get("/guilds/{config_id}", response_model=DiscordGuildConfigResponse)
|
||||
def get_guild_config(
|
||||
config_id: int,
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> DiscordGuildConfigResponse:
|
||||
"""Get specific guild config."""
|
||||
config = get_guild_config_by_internal_id(db_session, internal_id=config_id)
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="Guild config not found")
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Guild config not found")
|
||||
return DiscordGuildConfigResponse.model_validate(config)
|
||||
|
||||
|
||||
@@ -198,13 +198,13 @@ def get_guild_config(
|
||||
def update_guild_request(
|
||||
config_id: int,
|
||||
request: DiscordGuildConfigUpdateRequest,
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> DiscordGuildConfigResponse:
|
||||
"""Update guild config."""
|
||||
config = get_guild_config_by_internal_id(db_session, internal_id=config_id)
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="Guild config not found")
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Guild config not found")
|
||||
|
||||
config = update_guild_config(
|
||||
db_session,
|
||||
@@ -220,7 +220,7 @@ def update_guild_request(
|
||||
@router.delete("/guilds/{config_id}")
|
||||
def delete_guild_request(
|
||||
config_id: int,
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Delete guild config (invalidates registration key).
|
||||
@@ -229,7 +229,7 @@ def delete_guild_request(
|
||||
"""
|
||||
deleted = delete_guild_config(db_session, config_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Guild config not found")
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Guild config not found")
|
||||
|
||||
# On Cloud, delete service API key when all guilds are removed
|
||||
if AUTH_TYPE == AuthType.CLOUD:
|
||||
@@ -249,15 +249,15 @@ def delete_guild_request(
|
||||
)
|
||||
def list_channel_configs(
|
||||
config_id: int,
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[DiscordChannelConfigResponse]:
|
||||
"""List whitelisted channels for a guild."""
|
||||
guild_config = get_guild_config_by_internal_id(db_session, internal_id=config_id)
|
||||
if not guild_config:
|
||||
raise HTTPException(status_code=404, detail="Guild config not found")
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Guild config not found")
|
||||
if not guild_config.guild_id:
|
||||
raise HTTPException(status_code=400, detail="Guild not yet registered")
|
||||
raise OnyxError(OnyxErrorCode.INVALID_INPUT, "Guild not yet registered")
|
||||
|
||||
configs = get_channel_configs(db_session, config_id)
|
||||
return [DiscordChannelConfigResponse.model_validate(c) for c in configs]
|
||||
@@ -271,7 +271,7 @@ def update_channel_request(
|
||||
guild_config_id: int,
|
||||
channel_config_id: int,
|
||||
request: DiscordChannelConfigUpdateRequest,
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> DiscordChannelConfigResponse:
|
||||
"""Update channel config."""
|
||||
@@ -279,7 +279,7 @@ def update_channel_request(
|
||||
db_session, guild_config_id, channel_config_id
|
||||
)
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="Channel config not found")
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Channel config not found")
|
||||
|
||||
config = update_discord_channel_config(
|
||||
db_session,
|
||||
|
||||
@@ -15,8 +15,8 @@ from fastapi import Query
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.permissions import has_permission
|
||||
from onyx.auth.permissions import require_permission
|
||||
from onyx.auth.schemas import UserRole
|
||||
from onyx.auth.users import current_chat_accessible_user
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.enums import LLMModelFlowType
|
||||
@@ -252,7 +252,7 @@ def _validate_llm_provider_change(
|
||||
|
||||
@admin_router.get("/built-in/options")
|
||||
def fetch_llm_options(
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
|
||||
) -> list[WellKnownLLMProviderDescriptor]:
|
||||
return fetch_available_well_known_llms()
|
||||
|
||||
@@ -260,7 +260,7 @@ def fetch_llm_options(
|
||||
@admin_router.get("/built-in/options/{provider_name}")
|
||||
def fetch_llm_provider_options(
|
||||
provider_name: str,
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
|
||||
) -> WellKnownLLMProviderDescriptor:
|
||||
well_known_llms = fetch_available_well_known_llms()
|
||||
for well_known_llm in well_known_llms:
|
||||
@@ -272,7 +272,7 @@ def fetch_llm_provider_options(
|
||||
@admin_router.post("/test")
|
||||
def test_llm_configuration(
|
||||
test_llm_request: TestLLMRequest,
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
"""Test LLM configuration settings"""
|
||||
@@ -330,7 +330,7 @@ def test_llm_configuration(
|
||||
|
||||
@admin_router.post("/test/default")
|
||||
def test_default_provider(
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
|
||||
) -> None:
|
||||
try:
|
||||
llm = get_default_llm()
|
||||
@@ -346,7 +346,7 @@ def test_default_provider(
|
||||
@admin_router.get("/provider")
|
||||
def list_llm_providers(
|
||||
include_image_gen: bool = Query(False),
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> LLMProviderResponse[LLMProviderView]:
|
||||
start_time = datetime.now(timezone.utc)
|
||||
@@ -391,7 +391,7 @@ def put_llm_provider(
|
||||
False,
|
||||
description="True if creating a new one, False if updating an existing provider",
|
||||
),
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> LLMProviderView:
|
||||
# validate request (e.g. if we're intending to create but the name already exists we should throw an error)
|
||||
@@ -529,7 +529,7 @@ def put_llm_provider(
|
||||
def delete_llm_provider(
|
||||
provider_id: int,
|
||||
force: bool = Query(False),
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
if not force:
|
||||
@@ -550,7 +550,7 @@ def delete_llm_provider(
|
||||
@admin_router.post("/default")
|
||||
def set_provider_as_default(
|
||||
default_model_request: DefaultModel,
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
update_default_provider(
|
||||
@@ -563,7 +563,7 @@ def set_provider_as_default(
|
||||
@admin_router.post("/default-vision")
|
||||
def set_provider_as_default_vision(
|
||||
default_model: DefaultModel,
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
update_default_vision_provider(
|
||||
@@ -575,7 +575,7 @@ def set_provider_as_default_vision(
|
||||
|
||||
@admin_router.get("/auto-config")
|
||||
def get_auto_config(
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
|
||||
) -> dict:
|
||||
"""Get the current Auto mode configuration from GitHub.
|
||||
|
||||
@@ -593,7 +593,7 @@ def get_auto_config(
|
||||
|
||||
@admin_router.get("/vision-providers")
|
||||
def get_vision_capable_providers(
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> LLMProviderResponse[VisionProviderResponse]:
|
||||
"""Return a list of LLM providers and their models that support image input"""
|
||||
@@ -655,7 +655,7 @@ def list_llm_provider_basics(
|
||||
|
||||
all_providers = fetch_existing_llm_providers(db_session, [])
|
||||
user_group_ids = fetch_user_group_ids(db_session, user)
|
||||
is_admin = user.role == UserRole.ADMIN
|
||||
can_manage_llms = has_permission(user, Permission.MANAGE_LLMS)
|
||||
|
||||
accessible_providers = []
|
||||
|
||||
@@ -667,7 +667,7 @@ def list_llm_provider_basics(
|
||||
# - Excludes providers with persona restrictions (requires specific persona)
|
||||
# - Excludes non-public providers with no restrictions (admin-only)
|
||||
if can_user_access_llm_provider(
|
||||
provider, user_group_ids, persona=None, is_admin=is_admin
|
||||
provider, user_group_ids, persona=None, is_admin=can_manage_llms
|
||||
):
|
||||
accessible_providers.append(LLMProviderDescriptor.from_model(provider))
|
||||
|
||||
@@ -703,17 +703,19 @@ def get_valid_model_names_for_persona(
|
||||
if not persona:
|
||||
return []
|
||||
|
||||
is_admin = user.role == UserRole.ADMIN
|
||||
can_manage_llms = has_permission(user, Permission.MANAGE_LLMS)
|
||||
all_providers = fetch_existing_llm_providers(
|
||||
db_session, [LLMModelFlowType.CHAT, LLMModelFlowType.VISION]
|
||||
)
|
||||
user_group_ids = set() if is_admin else fetch_user_group_ids(db_session, user)
|
||||
user_group_ids = (
|
||||
set() if can_manage_llms else fetch_user_group_ids(db_session, user)
|
||||
)
|
||||
|
||||
valid_models = []
|
||||
for llm_provider_model in all_providers:
|
||||
# Check access with persona context — respects all RBAC restrictions
|
||||
if can_user_access_llm_provider(
|
||||
llm_provider_model, user_group_ids, persona, is_admin=is_admin
|
||||
llm_provider_model, user_group_ids, persona, is_admin=can_manage_llms
|
||||
):
|
||||
# Collect all model names from this provider
|
||||
for model_config in llm_provider_model.model_configurations:
|
||||
@@ -752,18 +754,20 @@ def list_llm_providers_for_persona(
|
||||
"You don't have access to this assistant",
|
||||
)
|
||||
|
||||
is_admin = user.role == UserRole.ADMIN
|
||||
can_manage_llms = has_permission(user, Permission.MANAGE_LLMS)
|
||||
all_providers = fetch_existing_llm_providers(
|
||||
db_session, [LLMModelFlowType.CHAT, LLMModelFlowType.VISION]
|
||||
)
|
||||
user_group_ids = set() if is_admin else fetch_user_group_ids(db_session, user)
|
||||
user_group_ids = (
|
||||
set() if can_manage_llms else fetch_user_group_ids(db_session, user)
|
||||
)
|
||||
|
||||
llm_provider_list: list[LLMProviderDescriptor] = []
|
||||
|
||||
for llm_provider_model in all_providers:
|
||||
# Check access with persona context — respects persona restrictions
|
||||
if can_user_access_llm_provider(
|
||||
llm_provider_model, user_group_ids, persona, is_admin=is_admin
|
||||
llm_provider_model, user_group_ids, persona, is_admin=can_manage_llms
|
||||
):
|
||||
llm_provider_list.append(
|
||||
LLMProviderDescriptor.from_model(llm_provider_model)
|
||||
@@ -791,7 +795,7 @@ def list_llm_providers_for_persona(
|
||||
if persona_default_provider:
|
||||
provider = fetch_existing_llm_provider(persona_default_provider, db_session)
|
||||
if provider and can_user_access_llm_provider(
|
||||
provider, user_group_ids, persona, is_admin=is_admin
|
||||
provider, user_group_ids, persona, is_admin=can_manage_llms
|
||||
):
|
||||
if persona_default_model:
|
||||
# Persona specifies both provider and model — use them directly
|
||||
@@ -824,7 +828,7 @@ def list_llm_providers_for_persona(
|
||||
|
||||
@admin_router.get("/provider-contextual-cost")
|
||||
def get_provider_contextual_cost(
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[LLMCost]:
|
||||
"""
|
||||
@@ -873,7 +877,7 @@ def get_provider_contextual_cost(
|
||||
@admin_router.post("/bedrock/available-models")
|
||||
def get_bedrock_available_models(
|
||||
request: BedrockModelsRequest,
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[BedrockFinalModelResponse]:
|
||||
"""Fetch available Bedrock models for a specific region and credentials.
|
||||
@@ -1048,7 +1052,7 @@ def _get_ollama_available_model_names(api_base: str) -> set[str]:
|
||||
@admin_router.post("/ollama/available-models")
|
||||
def get_ollama_available_models(
|
||||
request: OllamaModelsRequest,
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[OllamaFinalModelResponse]:
|
||||
"""Fetch the list of available models from an Ollama server."""
|
||||
@@ -1172,7 +1176,7 @@ def _get_openrouter_models_response(api_base: str, api_key: str) -> dict:
|
||||
@admin_router.post("/openrouter/available-models")
|
||||
def get_openrouter_available_models(
|
||||
request: OpenRouterModelsRequest,
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[OpenRouterFinalModelResponse]:
|
||||
"""Fetch available models from OpenRouter `/models` endpoint.
|
||||
@@ -1253,7 +1257,7 @@ def get_openrouter_available_models(
|
||||
@admin_router.post("/lm-studio/available-models")
|
||||
def get_lm_studio_available_models(
|
||||
request: LMStudioModelsRequest,
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[LMStudioFinalModelResponse]:
|
||||
"""Fetch available models from an LM Studio server.
|
||||
@@ -1360,7 +1364,7 @@ def get_lm_studio_available_models(
|
||||
@admin_router.post("/litellm/available-models")
|
||||
def get_litellm_available_models(
|
||||
request: LitellmModelsRequest,
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[LitellmFinalModelResponse]:
|
||||
"""Fetch available models from Litellm proxy /v1/models endpoint."""
|
||||
@@ -1493,7 +1497,7 @@ def _get_openai_compatible_models_response(
|
||||
@admin_router.post("/bifrost/available-models")
|
||||
def get_bifrost_available_models(
|
||||
request: BifrostModelsRequest,
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[BifrostFinalModelResponse]:
|
||||
"""Fetch available models from Bifrost gateway /v1/models endpoint."""
|
||||
@@ -1583,7 +1587,7 @@ def _get_bifrost_models_response(api_base: str, api_key: str | None = None) -> d
|
||||
@admin_router.post("/openai-compatible/available-models")
|
||||
def get_openai_compatible_server_available_models(
|
||||
request: OpenAICompatibleModelsRequest,
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[OpenAICompatibleFinalModelResponse]:
|
||||
"""Fetch available models from a generic OpenAI-compatible /v1/models endpoint."""
|
||||
|
||||
@@ -135,6 +135,7 @@ class UserInfo(BaseModel):
|
||||
is_anonymous_user: bool | None = None
|
||||
password_configured: bool | None = None
|
||||
tenant_info: TenantInfo | None = None
|
||||
effective_permissions: list[str] = Field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_model(
|
||||
@@ -148,6 +149,7 @@ class UserInfo(BaseModel):
|
||||
tenant_info: TenantInfo | None = None,
|
||||
assistant_specific_configs: UserSpecificAssistantPreferences | None = None,
|
||||
memories: list[MemoryItem] | None = None,
|
||||
effective_permissions: list[str] | None = None,
|
||||
) -> "UserInfo":
|
||||
return cls(
|
||||
id=str(user.id),
|
||||
@@ -187,6 +189,7 @@ class UserInfo(BaseModel):
|
||||
is_cloud_superuser=is_cloud_superuser,
|
||||
is_anonymous_user=is_anonymous_user,
|
||||
tenant_info=tenant_info,
|
||||
effective_permissions=effective_permissions or [],
|
||||
personalization=UserPersonalization(
|
||||
name=user.personal_name or "",
|
||||
role=user.personal_role or "",
|
||||
|
||||
@@ -114,7 +114,7 @@ def _form_channel_config(
|
||||
def create_slack_channel_config(
|
||||
slack_channel_config_creation_request: SlackChannelConfigCreationRequest,
|
||||
db_session: Session = Depends(get_session),
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
|
||||
) -> SlackChannelConfig:
|
||||
channel_config = _form_channel_config(
|
||||
db_session=db_session,
|
||||
@@ -155,7 +155,7 @@ def patch_slack_channel_config(
|
||||
slack_channel_config_id: int,
|
||||
slack_channel_config_creation_request: SlackChannelConfigCreationRequest,
|
||||
db_session: Session = Depends(get_session),
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
|
||||
) -> SlackChannelConfig:
|
||||
channel_config = _form_channel_config(
|
||||
db_session=db_session,
|
||||
@@ -216,7 +216,7 @@ def patch_slack_channel_config(
|
||||
def delete_slack_channel_config(
|
||||
slack_channel_config_id: int,
|
||||
db_session: Session = Depends(get_session),
|
||||
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
user: User = Depends(require_permission(Permission.MANAGE_BOTS)),
|
||||
) -> None:
|
||||
remove_slack_channel_config(
|
||||
db_session=db_session,
|
||||
@@ -228,7 +228,7 @@ def delete_slack_channel_config(
|
||||
@router.get("/admin/slack-app/channel")
|
||||
def list_slack_channel_configs(
|
||||
db_session: Session = Depends(get_session),
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
|
||||
) -> list[SlackChannelConfig]:
|
||||
slack_channel_config_models = fetch_slack_channel_configs(db_session=db_session)
|
||||
return [
|
||||
@@ -241,7 +241,7 @@ def list_slack_channel_configs(
|
||||
def create_bot(
|
||||
slack_bot_creation_request: SlackBotCreationRequest,
|
||||
db_session: Session = Depends(get_session),
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
|
||||
) -> SlackBot:
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
@@ -287,7 +287,7 @@ def patch_bot(
|
||||
slack_bot_id: int,
|
||||
slack_bot_creation_request: SlackBotCreationRequest,
|
||||
db_session: Session = Depends(get_session),
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
|
||||
) -> SlackBot:
|
||||
validate_bot_token(slack_bot_creation_request.bot_token)
|
||||
validate_app_token(slack_bot_creation_request.app_token)
|
||||
@@ -308,7 +308,7 @@ def patch_bot(
|
||||
def delete_bot(
|
||||
slack_bot_id: int,
|
||||
db_session: Session = Depends(get_session),
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
|
||||
) -> None:
|
||||
remove_slack_bot(
|
||||
db_session=db_session,
|
||||
@@ -320,7 +320,7 @@ def delete_bot(
|
||||
def get_bot_by_id(
|
||||
slack_bot_id: int,
|
||||
db_session: Session = Depends(get_session),
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
|
||||
) -> SlackBot:
|
||||
slack_bot_model = fetch_slack_bot(
|
||||
db_session=db_session,
|
||||
@@ -332,7 +332,7 @@ def get_bot_by_id(
|
||||
@router.get("/admin/slack-app/bots")
|
||||
def list_bots(
|
||||
db_session: Session = Depends(get_session),
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
|
||||
) -> list[SlackBot]:
|
||||
slack_bot_models = fetch_slack_bots(db_session=db_session)
|
||||
return [
|
||||
@@ -344,7 +344,7 @@ def list_bots(
|
||||
def list_bot_configs(
|
||||
bot_id: int,
|
||||
db_session: Session = Depends(get_session),
|
||||
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
|
||||
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
|
||||
) -> list[SlackChannelConfig]:
|
||||
slack_bot_config_models = fetch_slack_channel_configs(
|
||||
db_session=db_session, slack_bot_id=bot_id
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -117,15 +117,14 @@ class UserGroupManager:
|
||||
return response.json()
|
||||
|
||||
@staticmethod
|
||||
def set_permission(
|
||||
def set_permissions(
|
||||
user_group: DATestUserGroup,
|
||||
permission: str,
|
||||
enabled: bool,
|
||||
permissions: list[str],
|
||||
user_performing_action: DATestUser,
|
||||
) -> requests.Response:
|
||||
response = requests.put(
|
||||
f"{API_SERVER_URL}/manage/admin/user-group/{user_group.id}/permissions",
|
||||
json={"permission": permission, "enabled": enabled},
|
||||
json={"permissions": permissions},
|
||||
headers=user_performing_action.headers,
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -13,7 +13,7 @@ ENTERPRISE_SKIP = pytest.mark.skipif(
|
||||
|
||||
|
||||
@ENTERPRISE_SKIP
|
||||
def test_grant_permission_via_toggle(reset: None) -> None: # noqa: ARG001
|
||||
def test_grant_permission_via_bulk(reset: None) -> None: # noqa: ARG001
|
||||
admin_user: DATestUser = UserManager.create(name="admin_grant")
|
||||
basic_user: DATestUser = UserManager.create(name="basic_grant")
|
||||
|
||||
@@ -23,10 +23,11 @@ def test_grant_permission_via_toggle(reset: None) -> None: # noqa: ARG001
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
# Grant manage:llms
|
||||
resp = UserGroupManager.set_permission(group, "manage:llms", True, admin_user)
|
||||
# Set desired permissions to [manage:llms]
|
||||
resp = UserGroupManager.set_permissions(group, ["manage:llms"], admin_user)
|
||||
resp.raise_for_status()
|
||||
assert resp.json() == {"permission": "manage:llms", "enabled": True}
|
||||
result = resp.json()
|
||||
assert "manage:llms" in result, f"Expected manage:llms in {result}"
|
||||
|
||||
# Verify group permissions
|
||||
group_perms = UserGroupManager.get_permissions(group, admin_user)
|
||||
@@ -38,7 +39,7 @@ def test_grant_permission_via_toggle(reset: None) -> None: # noqa: ARG001
|
||||
|
||||
|
||||
@ENTERPRISE_SKIP
|
||||
def test_revoke_permission_via_toggle(reset: None) -> None: # noqa: ARG001
|
||||
def test_revoke_permission_via_bulk(reset: None) -> None: # noqa: ARG001
|
||||
admin_user: DATestUser = UserManager.create(name="admin_revoke")
|
||||
basic_user: DATestUser = UserManager.create(name="basic_revoke")
|
||||
|
||||
@@ -48,13 +49,11 @@ def test_revoke_permission_via_toggle(reset: None) -> None: # noqa: ARG001
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
# Grant then revoke
|
||||
UserGroupManager.set_permission(
|
||||
group, "manage:llms", True, admin_user
|
||||
).raise_for_status()
|
||||
UserGroupManager.set_permission(
|
||||
group, "manage:llms", False, admin_user
|
||||
# Grant then revoke by sending empty list
|
||||
UserGroupManager.set_permissions(
|
||||
group, ["manage:llms"], admin_user
|
||||
).raise_for_status()
|
||||
UserGroupManager.set_permissions(group, [], admin_user).raise_for_status()
|
||||
|
||||
# Verify removed from group
|
||||
group_perms = UserGroupManager.get_permissions(group, admin_user)
|
||||
@@ -68,7 +67,7 @@ def test_revoke_permission_via_toggle(reset: None) -> None: # noqa: ARG001
|
||||
|
||||
|
||||
@ENTERPRISE_SKIP
|
||||
def test_idempotent_grant(reset: None) -> None: # noqa: ARG001
|
||||
def test_idempotent_bulk_set(reset: None) -> None: # noqa: ARG001
|
||||
admin_user: DATestUser = UserManager.create(name="admin_idempotent_grant")
|
||||
|
||||
group = UserGroupManager.create(
|
||||
@@ -77,12 +76,12 @@ def test_idempotent_grant(reset: None) -> None: # noqa: ARG001
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
# Toggle ON twice
|
||||
UserGroupManager.set_permission(
|
||||
group, "manage:llms", True, admin_user
|
||||
# Set same permissions twice
|
||||
UserGroupManager.set_permissions(
|
||||
group, ["manage:llms"], admin_user
|
||||
).raise_for_status()
|
||||
UserGroupManager.set_permission(
|
||||
group, "manage:llms", True, admin_user
|
||||
UserGroupManager.set_permissions(
|
||||
group, ["manage:llms"], admin_user
|
||||
).raise_for_status()
|
||||
|
||||
group_perms = UserGroupManager.get_permissions(group, admin_user)
|
||||
@@ -92,22 +91,22 @@ def test_idempotent_grant(reset: None) -> None: # noqa: ARG001
|
||||
|
||||
|
||||
@ENTERPRISE_SKIP
|
||||
def test_idempotent_revoke(reset: None) -> None: # noqa: ARG001
|
||||
admin_user: DATestUser = UserManager.create(name="admin_idempotent_revoke")
|
||||
def test_empty_permissions_is_valid(reset: None) -> None: # noqa: ARG001
|
||||
admin_user: DATestUser = UserManager.create(name="admin_empty")
|
||||
|
||||
group = UserGroupManager.create(
|
||||
name="idempotent-revoke-group",
|
||||
name="empty-perms-group",
|
||||
user_ids=[admin_user.id],
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
# Toggle OFF when never granted — should not error
|
||||
resp = UserGroupManager.set_permission(group, "manage:llms", False, admin_user)
|
||||
# Setting empty list should not error
|
||||
resp = UserGroupManager.set_permissions(group, [], admin_user)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
@ENTERPRISE_SKIP
|
||||
def test_cannot_toggle_basic_access(reset: None) -> None: # noqa: ARG001
|
||||
def test_cannot_set_basic_access(reset: None) -> None: # noqa: ARG001
|
||||
admin_user: DATestUser = UserManager.create(name="admin_basic_block")
|
||||
|
||||
group = UserGroupManager.create(
|
||||
@@ -116,12 +115,12 @@ def test_cannot_toggle_basic_access(reset: None) -> None: # noqa: ARG001
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
resp = UserGroupManager.set_permission(group, "basic", True, admin_user)
|
||||
resp = UserGroupManager.set_permissions(group, ["basic"], admin_user)
|
||||
assert resp.status_code == 400, f"Expected 400, got {resp.status_code}"
|
||||
|
||||
|
||||
@ENTERPRISE_SKIP
|
||||
def test_cannot_toggle_admin(reset: None) -> None: # noqa: ARG001
|
||||
def test_cannot_set_admin(reset: None) -> None: # noqa: ARG001
|
||||
admin_user: DATestUser = UserManager.create(name="admin_admin_block")
|
||||
|
||||
group = UserGroupManager.create(
|
||||
@@ -130,7 +129,7 @@ def test_cannot_toggle_admin(reset: None) -> None: # noqa: ARG001
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
resp = UserGroupManager.set_permission(group, "admin", True, admin_user)
|
||||
resp = UserGroupManager.set_permissions(group, ["admin"], admin_user)
|
||||
assert resp.status_code == 400, f"Expected 400, got {resp.status_code}"
|
||||
|
||||
|
||||
@@ -146,11 +145,44 @@ def test_implied_permissions_expand(reset: None) -> None: # noqa: ARG001
|
||||
)
|
||||
|
||||
# Grant manage:agents — should imply add:agents and read:agents
|
||||
UserGroupManager.set_permission(
|
||||
group, "manage:agents", True, admin_user
|
||||
UserGroupManager.set_permissions(
|
||||
group, ["manage:agents"], admin_user
|
||||
).raise_for_status()
|
||||
|
||||
user_perms = UserManager.get_permissions(basic_user)
|
||||
assert "manage:agents" in user_perms, f"Missing manage:agents: {user_perms}"
|
||||
assert "add:agents" in user_perms, f"Missing implied add:agents: {user_perms}"
|
||||
assert "read:agents" in user_perms, f"Missing implied read:agents: {user_perms}"
|
||||
|
||||
|
||||
@ENTERPRISE_SKIP
|
||||
def test_bulk_replaces_previous_state(reset: None) -> None: # noqa: ARG001
|
||||
"""Setting a new permission list should disable ones no longer included."""
|
||||
admin_user: DATestUser = UserManager.create(name="admin_replace")
|
||||
|
||||
group = UserGroupManager.create(
|
||||
name="replace-state-group",
|
||||
user_ids=[admin_user.id],
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
# Set initial permissions
|
||||
UserGroupManager.set_permissions(
|
||||
group, ["manage:llms", "manage:actions"], admin_user
|
||||
).raise_for_status()
|
||||
|
||||
# Replace with a different set
|
||||
UserGroupManager.set_permissions(
|
||||
group, ["manage:actions", "manage:user_groups"], admin_user
|
||||
).raise_for_status()
|
||||
|
||||
group_perms = UserGroupManager.get_permissions(group, admin_user)
|
||||
assert (
|
||||
"manage:llms" not in group_perms
|
||||
), f"manage:llms should be removed: {group_perms}"
|
||||
assert (
|
||||
"manage:actions" in group_perms
|
||||
), f"manage:actions should remain: {group_perms}"
|
||||
assert (
|
||||
"manage:user_groups" in group_perms
|
||||
), f"manage:user_groups should be added: {group_perms}"
|
||||
|
||||
27
web/lib/opal/src/icons/create-agent.tsx
Normal file
27
web/lib/opal/src/icons/create-agent.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgCreateAgent = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M4.5 2.5L8 1L11.5 2.5M13.5 4.5L15 8L13.5 11.5M11.5 13.5L8 15L4.5 13.5M2.5 11.5L1 7.99999L2.5 4.5"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5 8L8 8.00001M8 8.00001L11 8.00001M8 8.00001L8 5M8 8.00001L8 11"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgCreateAgent;
|
||||
@@ -55,6 +55,7 @@ export { default as SvgColumn } from "@opal/icons/column";
|
||||
export { default as SvgCopy } from "@opal/icons/copy";
|
||||
export { default as SvgCornerRightUpDot } from "@opal/icons/corner-right-up-dot";
|
||||
export { default as SvgCpu } from "@opal/icons/cpu";
|
||||
export { default as SvgCreateAgent } from "@opal/icons/create-agent";
|
||||
export { default as SvgCurate } from "@opal/icons/curate";
|
||||
export { default as SvgCreditCard } from "@opal/icons/credit-card";
|
||||
export { default as SvgDashboard } from "@opal/icons/dashboard";
|
||||
@@ -110,6 +111,7 @@ export { default as SvgLmStudio } from "@opal/icons/lm-studio";
|
||||
export { default as SvgLoader } from "@opal/icons/loader";
|
||||
export { default as SvgLock } from "@opal/icons/lock";
|
||||
export { default as SvgLogOut } from "@opal/icons/log-out";
|
||||
export { default as SvgManageAgent } from "@opal/icons/manage-agent";
|
||||
export { default as SvgMaximize2 } from "@opal/icons/maximize-2";
|
||||
export { default as SvgMcp } from "@opal/icons/mcp";
|
||||
export { default as SvgMenu } from "@opal/icons/menu";
|
||||
|
||||
27
web/lib/opal/src/icons/manage-agent.tsx
Normal file
27
web/lib/opal/src/icons/manage-agent.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgManageAgent = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M4.5 2.5L8 1L11.5 2.5M13.5 4.5L15 8L13.5 11.5M11.5 13.5L8 15L4.5 13.5M2.5 11.5L1 7.99999L2.5 4.5"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6 11V8.75M6 6.75V5M6 6.75H4.75M6 6.75H7.25M10 11V9.25M10 9.25H8.75M10 9.25H11.25M10 7.25V5"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgManageAgent;
|
||||
@@ -1,15 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import type { Route } from "next";
|
||||
import AdminSidebar from "@/sections/sidebar/AdminSidebar";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import { ApplicationStatus } from "@/interfaces/settings";
|
||||
import { Button } from "@opal/components";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ADMIN_ROUTES } from "@/lib/admin-routes";
|
||||
import { ADMIN_ROUTES, AdminRouteEntry } from "@/lib/admin-routes";
|
||||
import { hasPermission, getFirstPermittedAdminRoute } from "@/lib/permissions";
|
||||
import useScreenSize from "@/hooks/useScreenSize";
|
||||
import { SvgSidebar } from "@opal/icons";
|
||||
import { useSidebarState } from "@/layouts/sidebar-layouts";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export interface ClientLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -56,7 +60,35 @@ export function ClientLayout({ children, enableCloud }: ClientLayoutProps) {
|
||||
useSidebarState();
|
||||
const { isMobile } = useScreenSize();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const settings = useSettingsContext();
|
||||
const { user, permissions } = useUser();
|
||||
|
||||
// Enforce per-page permission: find the route that matches the current
|
||||
// pathname and verify the user holds its requiredPermission.
|
||||
useEffect(() => {
|
||||
// Wait for user data to load before checking permissions —
|
||||
// permissions default to [] while loading, which would cause a
|
||||
// spurious redirect on every admin page.
|
||||
if (!user) return;
|
||||
|
||||
const matchedRoute = Object.values(ADMIN_ROUTES).find(
|
||||
(route: AdminRouteEntry) =>
|
||||
route.sidebarLabel && pathname.startsWith(route.path)
|
||||
);
|
||||
if (
|
||||
matchedRoute &&
|
||||
!hasPermission(permissions, matchedRoute.requiredPermission)
|
||||
) {
|
||||
const fallback = getFirstPermittedAdminRoute(permissions);
|
||||
// Avoid redirect loop: if the fallback is the same page, go to /app
|
||||
if (pathname.startsWith(fallback)) {
|
||||
router.replace("/app" as Route);
|
||||
} else {
|
||||
router.replace(fallback as Route);
|
||||
}
|
||||
}
|
||||
}, [user, pathname, permissions, router]);
|
||||
|
||||
// Certain admin panels have their own custom sidebar.
|
||||
// For those pages, we skip rendering the default `AdminSidebar` and let those individual pages render their own.
|
||||
|
||||
@@ -36,229 +36,397 @@ import {
|
||||
SvgZoomIn,
|
||||
} from "@opal/icons";
|
||||
|
||||
export interface FeatureFlags {
|
||||
vectorDbEnabled: boolean;
|
||||
kgExposed: boolean;
|
||||
enableCloud: boolean;
|
||||
enableEnterprise: boolean;
|
||||
customAnalyticsEnabled: boolean;
|
||||
hasSubscription: boolean;
|
||||
hooksEnabled: boolean;
|
||||
opensearchEnabled: boolean;
|
||||
queryHistoryEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface AdminRouteEntry {
|
||||
path: string;
|
||||
icon: IconFunctionComponent;
|
||||
title: string;
|
||||
sidebarLabel: string;
|
||||
requiredPermission: string;
|
||||
section: string;
|
||||
requiresEnterprise: boolean;
|
||||
visibleWhen: ((flags: FeatureFlags) => boolean) | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for every admin route: path, icon, page-header
|
||||
* title, and sidebar label.
|
||||
*/
|
||||
export const ADMIN_ROUTES = {
|
||||
INDEXING_STATUS: {
|
||||
path: "/admin/indexing/status",
|
||||
icon: SvgBookOpen,
|
||||
title: "Existing Connectors",
|
||||
sidebarLabel: "Existing Connectors",
|
||||
},
|
||||
ADD_CONNECTOR: {
|
||||
path: "/admin/add-connector",
|
||||
icon: SvgUploadCloud,
|
||||
title: "Add Connector",
|
||||
sidebarLabel: "Add Connector",
|
||||
},
|
||||
DOCUMENT_SETS: {
|
||||
path: "/admin/documents/sets",
|
||||
icon: SvgFiles,
|
||||
title: "Document Sets",
|
||||
sidebarLabel: "Document Sets",
|
||||
},
|
||||
DOCUMENT_EXPLORER: {
|
||||
path: "/admin/documents/explorer",
|
||||
icon: SvgZoomIn,
|
||||
title: "Document Explorer",
|
||||
sidebarLabel: "Explorer",
|
||||
},
|
||||
DOCUMENT_FEEDBACK: {
|
||||
path: "/admin/documents/feedback",
|
||||
icon: SvgThumbsUp,
|
||||
title: "Document Feedback",
|
||||
sidebarLabel: "Feedback",
|
||||
},
|
||||
AGENTS: {
|
||||
path: "/admin/agents",
|
||||
icon: SvgOnyxOctagon,
|
||||
title: "Agents",
|
||||
sidebarLabel: "Agents",
|
||||
},
|
||||
SLACK_BOTS: {
|
||||
path: "/admin/bots",
|
||||
icon: SvgSlack,
|
||||
title: "Slack Integration",
|
||||
sidebarLabel: "Slack Integration",
|
||||
},
|
||||
DISCORD_BOTS: {
|
||||
path: "/admin/discord-bot",
|
||||
icon: SvgDiscordMono,
|
||||
title: "Discord Integration",
|
||||
sidebarLabel: "Discord Integration",
|
||||
},
|
||||
MCP_ACTIONS: {
|
||||
path: "/admin/actions/mcp",
|
||||
icon: SvgMcp,
|
||||
title: "MCP Actions",
|
||||
sidebarLabel: "MCP Actions",
|
||||
},
|
||||
OPENAPI_ACTIONS: {
|
||||
path: "/admin/actions/open-api",
|
||||
icon: SvgActions,
|
||||
title: "OpenAPI Actions",
|
||||
sidebarLabel: "OpenAPI Actions",
|
||||
},
|
||||
STANDARD_ANSWERS: {
|
||||
path: "/admin/standard-answer",
|
||||
icon: SvgClipboard,
|
||||
title: "Standard Answers",
|
||||
sidebarLabel: "Standard Answers",
|
||||
},
|
||||
GROUPS: {
|
||||
path: "/admin/groups",
|
||||
icon: SvgUsers,
|
||||
title: "Manage User Groups",
|
||||
sidebarLabel: "Groups",
|
||||
},
|
||||
CHAT_PREFERENCES: {
|
||||
path: "/admin/configuration/chat-preferences",
|
||||
icon: SvgBubbleText,
|
||||
title: "Chat Preferences",
|
||||
sidebarLabel: "Chat Preferences",
|
||||
},
|
||||
// ── System Configuration (unlabeled section) ──────────────────────
|
||||
LLM_MODELS: {
|
||||
path: "/admin/configuration/llm",
|
||||
icon: SvgCpu,
|
||||
title: "Language Models",
|
||||
sidebarLabel: "Language Models",
|
||||
requiredPermission: "manage:llms",
|
||||
section: "",
|
||||
requiresEnterprise: false,
|
||||
visibleWhen: null,
|
||||
},
|
||||
WEB_SEARCH: {
|
||||
path: "/admin/configuration/web-search",
|
||||
icon: SvgGlobe,
|
||||
title: "Web Search",
|
||||
sidebarLabel: "Web Search",
|
||||
requiredPermission: "admin",
|
||||
section: "",
|
||||
requiresEnterprise: false,
|
||||
visibleWhen: null,
|
||||
},
|
||||
IMAGE_GENERATION: {
|
||||
path: "/admin/configuration/image-generation",
|
||||
icon: SvgImage,
|
||||
title: "Image Generation",
|
||||
sidebarLabel: "Image Generation",
|
||||
requiredPermission: "admin",
|
||||
section: "",
|
||||
requiresEnterprise: false,
|
||||
visibleWhen: null,
|
||||
},
|
||||
VOICE: {
|
||||
path: "/admin/configuration/voice",
|
||||
icon: SvgAudio,
|
||||
title: "Voice",
|
||||
sidebarLabel: "Voice",
|
||||
requiredPermission: "admin",
|
||||
section: "",
|
||||
requiresEnterprise: false,
|
||||
visibleWhen: null,
|
||||
},
|
||||
CODE_INTERPRETER: {
|
||||
path: "/admin/configuration/code-interpreter",
|
||||
icon: SvgTerminal,
|
||||
title: "Code Interpreter",
|
||||
sidebarLabel: "Code Interpreter",
|
||||
requiredPermission: "admin",
|
||||
section: "",
|
||||
requiresEnterprise: false,
|
||||
visibleWhen: null,
|
||||
},
|
||||
INDEX_SETTINGS: {
|
||||
path: "/admin/configuration/search",
|
||||
icon: SvgSearchMenu,
|
||||
title: "Index Settings",
|
||||
sidebarLabel: "Index Settings",
|
||||
},
|
||||
DOCUMENT_PROCESSING: {
|
||||
path: "/admin/configuration/document-processing",
|
||||
icon: SvgFileText,
|
||||
title: "Document Processing",
|
||||
sidebarLabel: "Document Processing",
|
||||
CHAT_PREFERENCES: {
|
||||
path: "/admin/configuration/chat-preferences",
|
||||
icon: SvgBubbleText,
|
||||
title: "Chat Preferences",
|
||||
sidebarLabel: "Chat Preferences",
|
||||
requiredPermission: "admin",
|
||||
section: "",
|
||||
requiresEnterprise: false,
|
||||
visibleWhen: null,
|
||||
},
|
||||
KNOWLEDGE_GRAPH: {
|
||||
path: "/admin/kg",
|
||||
icon: SvgNetworkGraph,
|
||||
title: "Knowledge Graph",
|
||||
sidebarLabel: "Knowledge Graph",
|
||||
},
|
||||
USERS: {
|
||||
path: "/admin/users",
|
||||
icon: SvgUser,
|
||||
title: "Users & Requests",
|
||||
sidebarLabel: "Users",
|
||||
},
|
||||
API_KEYS: {
|
||||
path: "/admin/service-accounts",
|
||||
icon: SvgUserKey,
|
||||
title: "Service Accounts",
|
||||
sidebarLabel: "Service Accounts",
|
||||
},
|
||||
TOKEN_RATE_LIMITS: {
|
||||
path: "/admin/token-rate-limits",
|
||||
icon: SvgProgressBars,
|
||||
title: "Spending Limits",
|
||||
sidebarLabel: "Spending Limits",
|
||||
},
|
||||
USAGE: {
|
||||
path: "/admin/performance/usage",
|
||||
icon: SvgActivity,
|
||||
title: "Usage Statistics",
|
||||
sidebarLabel: "Usage Statistics",
|
||||
},
|
||||
QUERY_HISTORY: {
|
||||
path: "/admin/performance/query-history",
|
||||
icon: SvgHistory,
|
||||
title: "Query History",
|
||||
sidebarLabel: "Query History",
|
||||
requiredPermission: "admin",
|
||||
section: "",
|
||||
requiresEnterprise: false,
|
||||
visibleWhen: (f: FeatureFlags) => f.vectorDbEnabled && f.kgExposed,
|
||||
},
|
||||
CUSTOM_ANALYTICS: {
|
||||
path: "/admin/performance/custom-analytics",
|
||||
icon: SvgBarChart,
|
||||
title: "Custom Analytics",
|
||||
sidebarLabel: "Custom Analytics",
|
||||
requiredPermission: "admin",
|
||||
section: "",
|
||||
requiresEnterprise: true,
|
||||
visibleWhen: (f: FeatureFlags) =>
|
||||
!f.enableCloud && f.customAnalyticsEnabled,
|
||||
},
|
||||
THEME: {
|
||||
path: "/admin/theme",
|
||||
icon: SvgPaintBrush,
|
||||
title: "Appearance & Theming",
|
||||
sidebarLabel: "Appearance & Theming",
|
||||
|
||||
// ── Agents & Actions ──────────────────────────────────────────────
|
||||
AGENTS: {
|
||||
path: "/admin/agents",
|
||||
icon: SvgOnyxOctagon,
|
||||
title: "Agents",
|
||||
sidebarLabel: "Agents",
|
||||
requiredPermission: "manage:agents",
|
||||
section: "Agents & Actions",
|
||||
requiresEnterprise: false,
|
||||
visibleWhen: null,
|
||||
},
|
||||
BILLING: {
|
||||
path: "/admin/billing",
|
||||
icon: SvgWallet,
|
||||
title: "Plans & Billing",
|
||||
sidebarLabel: "Plans & Billing",
|
||||
MCP_ACTIONS: {
|
||||
path: "/admin/actions/mcp",
|
||||
icon: SvgMcp,
|
||||
title: "MCP Actions",
|
||||
sidebarLabel: "MCP Actions",
|
||||
requiredPermission: "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>;
|
||||
|
||||
|
||||
75
web/src/lib/admin-sidebar-utils.ts
Normal file
75
web/src/lib/admin-sidebar-utils.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { IconFunctionComponent } from "@opal/types";
|
||||
import { SvgArrowUpCircle } from "@opal/icons";
|
||||
import {
|
||||
ADMIN_ROUTES,
|
||||
AdminRouteEntry,
|
||||
FeatureFlags,
|
||||
sidebarItem,
|
||||
} from "@/lib/admin-routes";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { CombinedSettings } from "@/interfaces/settings";
|
||||
|
||||
export type { FeatureFlags } from "@/lib/admin-routes";
|
||||
|
||||
export interface SidebarItemEntry {
|
||||
section: string;
|
||||
name: string;
|
||||
icon: IconFunctionComponent;
|
||||
link: string;
|
||||
error?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function buildItems(
|
||||
permissions: string[],
|
||||
flags: FeatureFlags,
|
||||
settings: CombinedSettings | null
|
||||
): SidebarItemEntry[] {
|
||||
const can = (perm: string) => hasPermission(permissions, perm);
|
||||
const items: SidebarItemEntry[] = [];
|
||||
|
||||
for (const route of Object.values(ADMIN_ROUTES) as AdminRouteEntry[]) {
|
||||
if (!route.sidebarLabel) continue;
|
||||
if (!can(route.requiredPermission)) continue;
|
||||
if (route.visibleWhen && !route.visibleWhen(flags)) continue;
|
||||
|
||||
const item: SidebarItemEntry = {
|
||||
...sidebarItem(route),
|
||||
section: route.section,
|
||||
disabled: route.requiresEnterprise && !flags.enableEnterprise,
|
||||
};
|
||||
|
||||
// Special case: INDEX_SETTINGS shows reindexing error indicator
|
||||
if (route.path === ADMIN_ROUTES.INDEX_SETTINGS.path) {
|
||||
item.error = settings?.settings.needs_reindexing;
|
||||
}
|
||||
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
// Upgrade Plan — only for full admins without a subscription
|
||||
if (can("admin") && !flags.hasSubscription) {
|
||||
items.push({
|
||||
section: "",
|
||||
name: "Upgrade Plan",
|
||||
icon: SvgArrowUpCircle,
|
||||
link: ADMIN_ROUTES.BILLING.path,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Preserve section ordering while grouping consecutive items by section. */
|
||||
export function groupBySection(items: SidebarItemEntry[]) {
|
||||
const groups: { section: string; items: SidebarItemEntry[] }[] = [];
|
||||
for (const item of items) {
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.section === item.section) {
|
||||
last.items.push(item);
|
||||
} else {
|
||||
groups.push({ section: item.section, items: [item] });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getCurrentUserSS,
|
||||
} from "@/lib/userSS";
|
||||
import { AuthType } from "@/lib/constants";
|
||||
import { hasAnyAdminPermission } from "@/lib/permissions";
|
||||
|
||||
/**
|
||||
* Result of an authentication check.
|
||||
@@ -71,13 +72,6 @@ export async function requireAuth(): Promise<AuthCheckResult> {
|
||||
};
|
||||
}
|
||||
|
||||
// Allowlist of roles that can access admin pages (all roles except BASIC)
|
||||
const ADMIN_ALLOWED_ROLES = [
|
||||
UserRole.ADMIN,
|
||||
UserRole.CURATOR,
|
||||
UserRole.GLOBAL_CURATOR,
|
||||
];
|
||||
|
||||
/**
|
||||
* Requires that the user is authenticated AND has admin role.
|
||||
* If not authenticated, redirects to login.
|
||||
@@ -106,8 +100,12 @@ export async function requireAdminAuth(): Promise<AuthCheckResult> {
|
||||
|
||||
const { user, authTypeMetadata } = authResult;
|
||||
|
||||
// Check if user has an allowed role
|
||||
if (user && !ADMIN_ALLOWED_ROLES.includes(user.role)) {
|
||||
// Check if user has admin role or any admin permission via groups
|
||||
if (
|
||||
user &&
|
||||
user.role !== UserRole.ADMIN &&
|
||||
!hasAnyAdminPermission(user.effective_permissions ?? [])
|
||||
) {
|
||||
return {
|
||||
user,
|
||||
authTypeMetadata,
|
||||
|
||||
36
web/src/lib/permissions.ts
Normal file
36
web/src/lib/permissions.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ADMIN_ROUTES } from "@/lib/admin-routes";
|
||||
|
||||
// Derived from ADMIN_ROUTES — no hardcoded list to maintain.
|
||||
// "admin" is the full-access override token, not a regular permission.
|
||||
const ADMIN_ROUTE_PERMISSIONS: Set<string> = new Set(
|
||||
Object.values(ADMIN_ROUTES)
|
||||
.map((r) => r.requiredPermission)
|
||||
.filter((p) => p !== "admin")
|
||||
);
|
||||
|
||||
export function hasAnyAdminPermission(permissions: string[]): boolean {
|
||||
if (permissions.includes("admin")) return true;
|
||||
return permissions.some((p) => ADMIN_ROUTE_PERMISSIONS.has(p));
|
||||
}
|
||||
|
||||
export function hasPermission(
|
||||
permissions: string[],
|
||||
...required: string[]
|
||||
): boolean {
|
||||
if (permissions.includes("admin")) return true;
|
||||
return required.some((r) => permissions.includes(r));
|
||||
}
|
||||
|
||||
export function getFirstPermittedAdminRoute(permissions: string[]): string {
|
||||
for (const route of Object.values(ADMIN_ROUTES)) {
|
||||
if (!route.sidebarLabel) continue;
|
||||
if (
|
||||
permissions.includes("admin") ||
|
||||
permissions.includes(route.requiredPermission)
|
||||
) {
|
||||
return route.path;
|
||||
}
|
||||
}
|
||||
// Fallback — should not be reached if hasAdminAccess is checked first
|
||||
return ADMIN_ROUTES.AGENTS.path;
|
||||
}
|
||||
@@ -94,6 +94,9 @@ export const SWR_KEYS = {
|
||||
// ── Groups ────────────────────────────────────────────────────────────────
|
||||
adminUserGroups: "/api/manage/admin/user-group",
|
||||
shareableGroups: "/api/manage/user-groups/minimal",
|
||||
userGroupPermissions: (groupId: number) =>
|
||||
`/api/manage/admin/user-group/${groupId}/permissions`,
|
||||
permissionRegistry: "/api/manage/admin/permissions/registry",
|
||||
scimToken: "/api/admin/enterprise-settings/scim/token",
|
||||
|
||||
// ── MCP Servers ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -126,6 +126,7 @@ export interface User {
|
||||
password_configured?: boolean;
|
||||
tenant_info?: TenantInfo | null;
|
||||
personalization?: UserPersonalization;
|
||||
effective_permissions?: string[];
|
||||
}
|
||||
|
||||
export interface TenantInfo {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
UserRole,
|
||||
ThemePreference,
|
||||
} from "@/lib/types";
|
||||
import { hasAnyAdminPermission } from "@/lib/permissions";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { SettingsContext } from "@/providers/SettingsProvider";
|
||||
import { useTokenRefresh } from "@/hooks/useTokenRefresh";
|
||||
@@ -26,10 +27,14 @@ import {
|
||||
import { updateUserPersonalization as persistPersonalization } from "@/lib/userSettings";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
const EMPTY_PERMISSIONS: string[] = [];
|
||||
|
||||
interface UserContextType {
|
||||
user: User | null;
|
||||
isAdmin: boolean;
|
||||
isCurator: boolean;
|
||||
hasAdminAccess: boolean;
|
||||
permissions: string[];
|
||||
refreshUser: () => Promise<void>;
|
||||
isCloudSuperuser: boolean;
|
||||
authTypeMetadata: AuthTypeMetadata;
|
||||
@@ -523,6 +528,10 @@ export function UserProvider({ children }: { children: React.ReactNode }) {
|
||||
isCurator:
|
||||
upToDateUser?.role === UserRole.CURATOR ||
|
||||
upToDateUser?.role === UserRole.GLOBAL_CURATOR,
|
||||
hasAdminAccess: hasAnyAdminPermission(
|
||||
upToDateUser?.effective_permissions ?? EMPTY_PERMISSIONS
|
||||
),
|
||||
permissions: upToDateUser?.effective_permissions ?? EMPTY_PERMISSIONS,
|
||||
isCloudSuperuser: upToDateUser?.is_cloud_superuser ?? false,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMemo, useState, useRef, useEffect } from "react";
|
||||
import AgentCard from "@/sections/cards/AgentCard";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { checkUserOwnsAgent as checkUserOwnsAgent } from "@/lib/agents";
|
||||
import { useAgents } from "@/hooks/useAgents";
|
||||
import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces";
|
||||
@@ -66,7 +67,8 @@ export default function AgentsNavigationPage() {
|
||||
const { agents } = useAgents();
|
||||
const [creatorFilterOpen, setCreatorFilterOpen] = useState(false);
|
||||
const [actionsFilterOpen, setActionsFilterOpen] = useState(false);
|
||||
const { user } = useUser();
|
||||
const { user, permissions } = useUser();
|
||||
const canCreateAgent = hasPermission(permissions, "add:agents");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"all" | "your">("all");
|
||||
const [selectedCreatorIds, setSelectedCreatorIds] = useState<Set<string>>(
|
||||
@@ -427,9 +429,15 @@ export default function AgentsNavigationPage() {
|
||||
description="Customize AI behavior and knowledge for you and your team's use cases."
|
||||
rightChildren={
|
||||
<Button
|
||||
href="/app/agents/create"
|
||||
href={canCreateAgent ? "/app/agents/create" : undefined}
|
||||
icon={SvgPlus}
|
||||
aria-label="AgentsPage/new-agent-button"
|
||||
disabled={!canCreateAgent}
|
||||
tooltip={
|
||||
!canCreateAgent
|
||||
? "You don't have permission to create agents. Contact your admin to request access."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
New Agent
|
||||
</Button>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SvgKey,
|
||||
SvgLock,
|
||||
SvgMinusCircle,
|
||||
SvgPlusCircle,
|
||||
SvgTrash,
|
||||
SvgUnplug,
|
||||
} from "@opal/icons";
|
||||
@@ -35,7 +36,6 @@ import useSWR from "swr";
|
||||
import { SWR_KEYS } from "@/lib/swr-keys";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import useFilter from "@/hooks/useFilter";
|
||||
import CreateButton from "@/refresh-components/buttons/CreateButton";
|
||||
import { Button } from "@opal/components";
|
||||
import useFederatedOAuthStatus from "@/hooks/useFederatedOAuthStatus";
|
||||
import useCCPairs from "@/hooks/useCCPairs";
|
||||
@@ -63,6 +63,7 @@ import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidE
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
|
||||
import { useCloudSubscription } from "@/hooks/useCloudSubscription";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
|
||||
interface PAT {
|
||||
id: number;
|
||||
@@ -1040,7 +1041,7 @@ function ChatPreferencesSettings() {
|
||||
}
|
||||
|
||||
function AccountsAccessSettings() {
|
||||
const { user, authTypeMetadata } = useUser();
|
||||
const { user, authTypeMetadata, permissions } = useUser();
|
||||
const authType = useAuthType();
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
|
||||
@@ -1067,18 +1068,18 @@ function AccountsAccessSettings() {
|
||||
const [tokenToDelete, setTokenToDelete] = useState<PAT | null>(null);
|
||||
|
||||
const canCreateTokens = useCloudSubscription();
|
||||
const canCreatePAT = hasPermission(permissions, "create:user_api_keys");
|
||||
|
||||
const showPasswordSection = Boolean(user?.password_configured);
|
||||
const showTokensSection = authType !== null;
|
||||
|
||||
// Fetch PATs with SWR
|
||||
// Fetch PATs with SWR — always fetch when auth is available
|
||||
const {
|
||||
data: pats = [],
|
||||
mutate,
|
||||
error,
|
||||
isLoading,
|
||||
} = useSWR<PAT[]>(
|
||||
showTokensSection ? SWR_KEYS.userPats : null,
|
||||
authType !== null ? SWR_KEYS.userPats : null,
|
||||
errorHandlingFetcher,
|
||||
{
|
||||
revalidateOnFocus: true,
|
||||
@@ -1087,6 +1088,10 @@ function AccountsAccessSettings() {
|
||||
}
|
||||
);
|
||||
|
||||
// Hide the section entirely if user has no permission AND no existing tokens
|
||||
const showTokensSection =
|
||||
authType !== null && (canCreatePAT || pats.length > 0);
|
||||
|
||||
// Use filter hook for searching tokens
|
||||
const {
|
||||
query,
|
||||
@@ -1410,15 +1415,19 @@ function AccountsAccessSettings() {
|
||||
variant="internal"
|
||||
/>
|
||||
)}
|
||||
<CreateButton
|
||||
<Button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
secondary={false}
|
||||
internal
|
||||
transient={showCreateModal}
|
||||
rightIcon
|
||||
prominence="secondary"
|
||||
rightIcon={SvgPlusCircle}
|
||||
disabled={!canCreatePAT}
|
||||
tooltip={
|
||||
!canCreatePAT
|
||||
? "You don't have permission to create access tokens"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
New Access Token
|
||||
</CreateButton>
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Section gap={0.25}>
|
||||
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
updateAgentGroupSharing,
|
||||
updateDocSetGroupSharing,
|
||||
saveTokenLimits,
|
||||
saveGroupPermissions,
|
||||
} from "./svc";
|
||||
import { memberTableColumns, PAGE_SIZE } from "./shared";
|
||||
import SharedGroupResources from "@/refresh-pages/admin/GroupsPage/SharedGroupResources";
|
||||
import GroupPermissionsSection from "./GroupPermissionsSection";
|
||||
import TokenLimitSection from "./TokenLimitSection";
|
||||
import type { TokenLimit } from "./TokenLimitSection";
|
||||
|
||||
@@ -34,6 +36,9 @@ function CreateGroupPage() {
|
||||
const [selectedCcPairIds, setSelectedCcPairIds] = useState<number[]>([]);
|
||||
const [selectedDocSetIds, setSelectedDocSetIds] = useState<number[]>([]);
|
||||
const [selectedAgentIds, setSelectedAgentIds] = useState<number[]>([]);
|
||||
const [enabledPermissions, setEnabledPermissions] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [tokenLimits, setTokenLimits] = useState<TokenLimit[]>([
|
||||
{ tokenBudget: null, periodHours: null },
|
||||
]);
|
||||
@@ -54,6 +59,7 @@ function CreateGroupPage() {
|
||||
selectedUserIds,
|
||||
selectedCcPairIds
|
||||
);
|
||||
await saveGroupPermissions(groupId, enabledPermissions);
|
||||
await updateAgentGroupSharing(groupId, [], selectedAgentIds);
|
||||
await updateDocSetGroupSharing(groupId, [], selectedDocSetIds);
|
||||
await saveTokenLimits(groupId, tokenLimits, []);
|
||||
@@ -153,6 +159,11 @@ function CreateGroupPage() {
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
<GroupPermissionsSection
|
||||
enabledPermissions={enabledPermissions}
|
||||
onPermissionsChange={setEnabledPermissions}
|
||||
/>
|
||||
|
||||
<SharedGroupResources
|
||||
selectedCcPairIds={selectedCcPairIds}
|
||||
onCcPairIdsChange={setSelectedCcPairIds}
|
||||
|
||||
@@ -30,9 +30,11 @@ import {
|
||||
updateAgentGroupSharing,
|
||||
updateDocSetGroupSharing,
|
||||
saveTokenLimits,
|
||||
saveGroupPermissions,
|
||||
} from "./svc";
|
||||
import { SWR_KEYS } from "@/lib/swr-keys";
|
||||
import SharedGroupResources from "@/refresh-pages/admin/GroupsPage/SharedGroupResources";
|
||||
import GroupPermissionsSection from "./GroupPermissionsSection";
|
||||
import TokenLimitSection from "./TokenLimitSection";
|
||||
import type { TokenLimit } from "./TokenLimitSection";
|
||||
|
||||
@@ -75,6 +77,11 @@ function EditGroupPage({ groupId }: EditGroupPageProps) {
|
||||
TokenRateLimitDisplay[]
|
||||
>(SWR_KEYS.userGroupTokenRateLimit(groupId), errorHandlingFetcher);
|
||||
|
||||
// Fetch permissions for this group
|
||||
const { data: groupPermissions, isLoading: permissionsLoading } = useSWR<
|
||||
string[]
|
||||
>(SWR_KEYS.userGroupPermissions(groupId), errorHandlingFetcher);
|
||||
|
||||
// Form state
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
||||
@@ -87,6 +94,9 @@ function EditGroupPage({ groupId }: EditGroupPageProps) {
|
||||
const [tokenLimits, setTokenLimits] = useState<TokenLimit[]>([
|
||||
{ tokenBudget: null, periodHours: null },
|
||||
]);
|
||||
const [enabledPermissions, setEnabledPermissions] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
@@ -101,7 +111,11 @@ function EditGroupPage({ groupId }: EditGroupPageProps) {
|
||||
error: candidatesError,
|
||||
} = useGroupMemberCandidates();
|
||||
|
||||
const isLoading = groupLoading || candidatesLoading || tokenLimitsLoading;
|
||||
const isLoading =
|
||||
groupLoading ||
|
||||
candidatesLoading ||
|
||||
tokenLimitsLoading ||
|
||||
permissionsLoading;
|
||||
const error = groupError ?? candidatesError;
|
||||
|
||||
// Pre-populate form when group data loads
|
||||
@@ -132,6 +146,13 @@ function EditGroupPage({ groupId }: EditGroupPageProps) {
|
||||
}
|
||||
}, [tokenRateLimits]);
|
||||
|
||||
// Pre-populate permissions when fetched
|
||||
useEffect(() => {
|
||||
if (groupPermissions) {
|
||||
setEnabledPermissions(new Set(groupPermissions));
|
||||
}
|
||||
}, [groupPermissions]);
|
||||
|
||||
const memberRows = useMemo(() => {
|
||||
const selected = new Set(selectedUserIds);
|
||||
return allRows.filter((r) => selected.has(r.id ?? r.email));
|
||||
@@ -233,6 +254,9 @@ function EditGroupPage({ groupId }: EditGroupPageProps) {
|
||||
selectedDocSetIds
|
||||
);
|
||||
|
||||
// Save permissions (bulk desired-state)
|
||||
await saveGroupPermissions(groupId, enabledPermissions);
|
||||
|
||||
// Save token rate limits (create/update/delete)
|
||||
await saveTokenLimits(groupId, tokenLimits, tokenRateLimits ?? []);
|
||||
|
||||
@@ -242,6 +266,7 @@ function EditGroupPage({ groupId }: EditGroupPageProps) {
|
||||
|
||||
mutate(SWR_KEYS.adminUserGroups);
|
||||
mutate(SWR_KEYS.userGroupTokenRateLimit(groupId));
|
||||
mutate(SWR_KEYS.userGroupPermissions(groupId));
|
||||
toast.success(`Group "${trimmed}" updated`);
|
||||
router.push("/admin/groups");
|
||||
} catch (e) {
|
||||
@@ -431,6 +456,11 @@ function EditGroupPage({ groupId }: EditGroupPageProps) {
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<GroupPermissionsSection
|
||||
enabledPermissions={enabledPermissions}
|
||||
onPermissionsChange={setEnabledPermissions}
|
||||
/>
|
||||
|
||||
<SharedGroupResources
|
||||
selectedCcPairIds={selectedCcPairIds}
|
||||
onCcPairIdsChange={setSelectedCcPairIds}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
import useSWR from "swr";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import {
|
||||
SvgSettings,
|
||||
SvgPlug,
|
||||
SvgActions,
|
||||
SvgUsers,
|
||||
SvgUserKey,
|
||||
SvgSlack,
|
||||
SvgPlusCircle,
|
||||
SvgUserManage,
|
||||
SvgBarChart,
|
||||
SvgHistory,
|
||||
SvgKey,
|
||||
SvgShield,
|
||||
SvgCpu,
|
||||
SvgFiles,
|
||||
SvgCreateAgent,
|
||||
SvgManageAgent,
|
||||
} from "@opal/icons";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import Switch from "@/refresh-components/inputs/Switch";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import SimpleCollapsible from "@/refresh-components/SimpleCollapsible";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { SWR_KEYS } from "@/lib/swr-keys";
|
||||
import type { PermissionRegistryEntry } from "@/refresh-pages/admin/GroupsPage/interfaces";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Icon mapping — the only permission metadata maintained in the frontend.
|
||||
// The `id` keys must match the backend PERMISSION_REGISTRY entries.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ICON_MAP: Record<string, IconFunctionComponent> = {
|
||||
manage_llms: SvgCpu,
|
||||
manage_connectors_and_document_sets: SvgFiles,
|
||||
manage_actions: SvgActions,
|
||||
manage_groups: SvgUsers,
|
||||
manage_service_accounts: SvgUserKey,
|
||||
manage_bots: SvgSlack,
|
||||
create_agents: SvgCreateAgent,
|
||||
manage_agents: SvgManageAgent,
|
||||
view_agent_analytics: SvgBarChart,
|
||||
view_query_history: SvgHistory,
|
||||
create_user_access_token: SvgKey,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GroupPermissionsSectionProps {
|
||||
enabledPermissions: Set<string>;
|
||||
onPermissionsChange: (permissions: Set<string>) => void;
|
||||
}
|
||||
|
||||
function GroupPermissionsSection({
|
||||
enabledPermissions,
|
||||
onPermissionsChange,
|
||||
}: GroupPermissionsSectionProps) {
|
||||
const { data: registry, isLoading } = useSWR<PermissionRegistryEntry[]>(
|
||||
SWR_KEYS.permissionRegistry,
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
function isRowEnabled(entry: PermissionRegistryEntry): boolean {
|
||||
return entry.permissions.every((p) => enabledPermissions.has(p));
|
||||
}
|
||||
|
||||
function handleToggle(entry: PermissionRegistryEntry, checked: boolean) {
|
||||
const next = new Set(enabledPermissions);
|
||||
for (const perm of entry.permissions) {
|
||||
if (checked) {
|
||||
next.add(perm);
|
||||
} else {
|
||||
next.delete(perm);
|
||||
}
|
||||
}
|
||||
onPermissionsChange(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleCollapsible>
|
||||
<SimpleCollapsible.Header
|
||||
title="Group Permissions"
|
||||
description="Set access and permissions for members of this group."
|
||||
/>
|
||||
<SimpleCollapsible.Content>
|
||||
{isLoading || !registry ? (
|
||||
<SimpleLoader />
|
||||
) : (
|
||||
<Card>
|
||||
{registry.map((entry, index) => {
|
||||
const prevGroup =
|
||||
index > 0 ? registry[index - 1]!.group : entry.group;
|
||||
const icon = ICON_MAP[entry.id] ?? SvgShield;
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
{index > 0 && entry.group !== prevGroup && (
|
||||
<Separator noPadding />
|
||||
)}
|
||||
<ContentAction
|
||||
icon={icon}
|
||||
title={entry.display_name}
|
||||
description={entry.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
paddingVariant="md"
|
||||
rightChildren={
|
||||
<Switch
|
||||
checked={isRowEnabled(entry)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggle(entry, checked)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
)}
|
||||
</SimpleCollapsible.Content>
|
||||
</SimpleCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupPermissionsSection;
|
||||
@@ -20,3 +20,12 @@ export interface TokenRateLimitDisplay {
|
||||
token_budget: number;
|
||||
period_hours: number;
|
||||
}
|
||||
|
||||
/** Mirrors backend PermissionRegistryEntry from onyx.auth.permissions. */
|
||||
export interface PermissionRegistryEntry {
|
||||
id: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
permissions: string[];
|
||||
group: number;
|
||||
}
|
||||
|
||||
@@ -281,6 +281,27 @@ async function saveTokenLimits(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group permissions — bulk set desired permissions in a single request
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function saveGroupPermissions(
|
||||
groupId: number,
|
||||
enabledPermissions: Set<string>
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${USER_GROUP_URL}/${groupId}/permissions`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ permissions: Array.from(enabledPermissions) }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().catch(() => null);
|
||||
throw new Error(
|
||||
detail?.detail ?? `Failed to update permissions: ${res.statusText}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
renameGroup,
|
||||
createGroup,
|
||||
@@ -289,4 +310,5 @@ export {
|
||||
updateAgentGroupSharing,
|
||||
updateDocSetGroupSharing,
|
||||
saveTokenLimits,
|
||||
saveGroupPermissions,
|
||||
};
|
||||
|
||||
@@ -16,182 +16,25 @@ import { useSidebarFolded } from "@/layouts/sidebar-layouts";
|
||||
import { useIsKGExposed } from "@/app/admin/kg/utils";
|
||||
import { useCustomAnalyticsEnabled } from "@/lib/hooks/useCustomAnalyticsEnabled";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import { UserRole } from "@/lib/types";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import { CombinedSettings } from "@/interfaces/settings";
|
||||
import { SidebarTab } from "@opal/components";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import Spacer from "@/refresh-components/Spacer";
|
||||
import { SvgArrowUpCircle, SvgSearch, SvgX } from "@opal/icons";
|
||||
import { SvgSearch, SvgX } from "@opal/icons";
|
||||
import {
|
||||
useBillingInformation,
|
||||
useLicense,
|
||||
hasActiveSubscription,
|
||||
} from "@/lib/billing";
|
||||
import { ADMIN_ROUTES, sidebarItem } from "@/lib/admin-routes";
|
||||
import useFilter from "@/hooks/useFilter";
|
||||
import { IconFunctionComponent } from "@opal/types";
|
||||
import AccountPopover from "@/sections/sidebar/AccountPopover";
|
||||
|
||||
const SECTIONS = {
|
||||
UNLABELED: "",
|
||||
AGENTS_AND_ACTIONS: "Agents & Actions",
|
||||
DOCUMENTS_AND_KNOWLEDGE: "Documents & Knowledge",
|
||||
INTEGRATIONS: "Integrations",
|
||||
PERMISSIONS: "Permissions",
|
||||
ORGANIZATION: "Organization",
|
||||
USAGE: "Usage",
|
||||
} as const;
|
||||
|
||||
interface SidebarItemEntry {
|
||||
section: string;
|
||||
name: string;
|
||||
icon: IconFunctionComponent;
|
||||
link: string;
|
||||
error?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function buildItems(
|
||||
isCurator: boolean,
|
||||
enableCloud: boolean,
|
||||
enableEnterprise: boolean,
|
||||
settings: CombinedSettings | null,
|
||||
kgExposed: boolean,
|
||||
customAnalyticsEnabled: boolean,
|
||||
hasSubscription: boolean,
|
||||
hooksEnabled: boolean
|
||||
): SidebarItemEntry[] {
|
||||
const vectorDbEnabled = settings?.settings.vector_db_enabled !== false;
|
||||
const items: SidebarItemEntry[] = [];
|
||||
|
||||
const add = (section: string, route: Parameters<typeof sidebarItem>[0]) => {
|
||||
items.push({ ...sidebarItem(route), section });
|
||||
};
|
||||
|
||||
const addDisabled = (
|
||||
section: string,
|
||||
route: Parameters<typeof sidebarItem>[0],
|
||||
isDisabled: boolean
|
||||
) => {
|
||||
items.push({ ...sidebarItem(route), section, disabled: isDisabled });
|
||||
};
|
||||
|
||||
// 1. No header — core configuration (admin only)
|
||||
if (!isCurator) {
|
||||
add(SECTIONS.UNLABELED, ADMIN_ROUTES.LLM_MODELS);
|
||||
add(SECTIONS.UNLABELED, ADMIN_ROUTES.WEB_SEARCH);
|
||||
add(SECTIONS.UNLABELED, ADMIN_ROUTES.IMAGE_GENERATION);
|
||||
add(SECTIONS.UNLABELED, ADMIN_ROUTES.VOICE);
|
||||
add(SECTIONS.UNLABELED, ADMIN_ROUTES.CODE_INTERPRETER);
|
||||
add(SECTIONS.UNLABELED, ADMIN_ROUTES.CHAT_PREFERENCES);
|
||||
|
||||
if (vectorDbEnabled && kgExposed) {
|
||||
add(SECTIONS.UNLABELED, ADMIN_ROUTES.KNOWLEDGE_GRAPH);
|
||||
}
|
||||
|
||||
if (!enableCloud && customAnalyticsEnabled) {
|
||||
addDisabled(
|
||||
SECTIONS.UNLABELED,
|
||||
ADMIN_ROUTES.CUSTOM_ANALYTICS,
|
||||
!enableEnterprise
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Agents & Actions
|
||||
add(SECTIONS.AGENTS_AND_ACTIONS, ADMIN_ROUTES.AGENTS);
|
||||
add(SECTIONS.AGENTS_AND_ACTIONS, ADMIN_ROUTES.MCP_ACTIONS);
|
||||
add(SECTIONS.AGENTS_AND_ACTIONS, ADMIN_ROUTES.OPENAPI_ACTIONS);
|
||||
|
||||
// 3. Documents & Knowledge
|
||||
if (vectorDbEnabled) {
|
||||
add(SECTIONS.DOCUMENTS_AND_KNOWLEDGE, ADMIN_ROUTES.INDEXING_STATUS);
|
||||
add(SECTIONS.DOCUMENTS_AND_KNOWLEDGE, ADMIN_ROUTES.ADD_CONNECTOR);
|
||||
add(SECTIONS.DOCUMENTS_AND_KNOWLEDGE, ADMIN_ROUTES.DOCUMENT_SETS);
|
||||
if (!isCurator && !enableCloud) {
|
||||
items.push({
|
||||
...sidebarItem(ADMIN_ROUTES.INDEX_SETTINGS),
|
||||
section: SECTIONS.DOCUMENTS_AND_KNOWLEDGE,
|
||||
error: settings?.settings.needs_reindexing,
|
||||
});
|
||||
}
|
||||
if (!isCurator && settings?.settings.opensearch_indexing_enabled) {
|
||||
add(SECTIONS.DOCUMENTS_AND_KNOWLEDGE, ADMIN_ROUTES.INDEX_MIGRATION);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Integrations (admin only)
|
||||
if (!isCurator) {
|
||||
add(SECTIONS.INTEGRATIONS, ADMIN_ROUTES.API_KEYS);
|
||||
add(SECTIONS.INTEGRATIONS, ADMIN_ROUTES.SLACK_BOTS);
|
||||
add(SECTIONS.INTEGRATIONS, ADMIN_ROUTES.DISCORD_BOTS);
|
||||
if (hooksEnabled) {
|
||||
add(SECTIONS.INTEGRATIONS, ADMIN_ROUTES.HOOKS);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Permissions
|
||||
if (!isCurator) {
|
||||
add(SECTIONS.PERMISSIONS, ADMIN_ROUTES.USERS);
|
||||
addDisabled(SECTIONS.PERMISSIONS, ADMIN_ROUTES.GROUPS, !enableEnterprise);
|
||||
addDisabled(SECTIONS.PERMISSIONS, ADMIN_ROUTES.SCIM, !enableEnterprise);
|
||||
} else if (enableEnterprise) {
|
||||
add(SECTIONS.PERMISSIONS, ADMIN_ROUTES.GROUPS);
|
||||
}
|
||||
|
||||
// 6. Organization (admin only)
|
||||
if (!isCurator) {
|
||||
if (hasSubscription) {
|
||||
add(SECTIONS.ORGANIZATION, ADMIN_ROUTES.BILLING);
|
||||
}
|
||||
addDisabled(
|
||||
SECTIONS.ORGANIZATION,
|
||||
ADMIN_ROUTES.TOKEN_RATE_LIMITS,
|
||||
!enableEnterprise
|
||||
);
|
||||
addDisabled(SECTIONS.ORGANIZATION, ADMIN_ROUTES.THEME, !enableEnterprise);
|
||||
}
|
||||
|
||||
// 7. Usage (admin only)
|
||||
if (!isCurator) {
|
||||
addDisabled(SECTIONS.USAGE, ADMIN_ROUTES.USAGE, !enableEnterprise);
|
||||
if (settings?.settings.query_history_type !== "disabled") {
|
||||
addDisabled(
|
||||
SECTIONS.USAGE,
|
||||
ADMIN_ROUTES.QUERY_HISTORY,
|
||||
!enableEnterprise
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Upgrade Plan (admin only, no subscription)
|
||||
if (!isCurator && !hasSubscription) {
|
||||
items.push({
|
||||
section: SECTIONS.UNLABELED,
|
||||
name: "Upgrade Plan",
|
||||
icon: SvgArrowUpCircle,
|
||||
link: ADMIN_ROUTES.BILLING.path,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Preserve section ordering while grouping consecutive items by section. */
|
||||
function groupBySection(items: SidebarItemEntry[]) {
|
||||
const groups: { section: string; items: SidebarItemEntry[] }[] = [];
|
||||
for (const item of items) {
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.section === item.section) {
|
||||
last.items.push(item);
|
||||
} else {
|
||||
groups.push({ section: item.section, items: [item] });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
import {
|
||||
buildItems,
|
||||
groupBySection,
|
||||
type FeatureFlags,
|
||||
type SidebarItemEntry,
|
||||
} from "@/lib/admin-sidebar-utils";
|
||||
|
||||
interface AdminSidebarProps {
|
||||
enableCloudSS: boolean;
|
||||
@@ -221,14 +64,12 @@ function AdminSidebarInner({
|
||||
const { kgExposed } = useIsKGExposed();
|
||||
const pathname = usePathname();
|
||||
const { customAnalyticsEnabled } = useCustomAnalyticsEnabled();
|
||||
const { user } = useUser();
|
||||
const { permissions } = useUser();
|
||||
const settings = useSettingsContext();
|
||||
const enableEnterprise = usePaidEnterpriseFeaturesEnabled();
|
||||
const { data: billingData, isLoading: billingLoading } =
|
||||
useBillingInformation();
|
||||
const { data: licenseData, isLoading: licenseLoading } = useLicense();
|
||||
const isCurator =
|
||||
user?.role === UserRole.CURATOR || user?.role === UserRole.GLOBAL_CURATOR;
|
||||
// Default to true while loading to avoid flashing "Upgrade Plan"
|
||||
const hasSubscriptionOrLicense =
|
||||
billingLoading || licenseLoading
|
||||
@@ -237,19 +78,21 @@ function AdminSidebarInner({
|
||||
(billingData && hasActiveSubscription(billingData)) ||
|
||||
licenseData?.has_license
|
||||
);
|
||||
const hooksEnabled =
|
||||
enableEnterprise && (settings?.settings.hooks_enabled ?? false);
|
||||
|
||||
const allItems = buildItems(
|
||||
isCurator,
|
||||
enableCloudSS,
|
||||
enableEnterprise,
|
||||
settings,
|
||||
const flags: FeatureFlags = {
|
||||
vectorDbEnabled: settings?.settings.vector_db_enabled !== false,
|
||||
kgExposed,
|
||||
enableCloud: enableCloudSS,
|
||||
enableEnterprise,
|
||||
customAnalyticsEnabled,
|
||||
hasSubscriptionOrLicense,
|
||||
hooksEnabled
|
||||
);
|
||||
hasSubscription: hasSubscriptionOrLicense,
|
||||
hooksEnabled:
|
||||
enableEnterprise && (settings?.settings.hooks_enabled ?? false),
|
||||
opensearchEnabled: settings?.settings.opensearch_indexing_enabled ?? false,
|
||||
queryHistoryEnabled: settings?.settings.query_history_type !== "disabled",
|
||||
};
|
||||
|
||||
const allItems = buildItems(permissions, flags, settings);
|
||||
|
||||
const itemExtractor = useCallback((item: SidebarItemEntry) => item.name, []);
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ import { showErrorNotification, handleMoveOperation } from "./sidebarUtils";
|
||||
import { SidebarTab } from "@opal/components";
|
||||
import { ChatSession } from "@/app/app/interfaces";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import { getFirstPermittedAdminRoute } from "@/lib/permissions";
|
||||
import useAppFocus from "@/hooks/useAppFocus";
|
||||
import { useCreateModal } from "@/refresh-components/contexts/ModalContext";
|
||||
import { useModalContext } from "@/components/context/ModalContext";
|
||||
@@ -479,7 +480,7 @@ const MemoizedAppSidebarInner = memo(function AppSidebarInner() {
|
||||
]
|
||||
);
|
||||
|
||||
const { isAdmin, isCurator, user } = useUser();
|
||||
const { isAdmin, isCurator, hasAdminAccess, permissions, user } = useUser();
|
||||
const activeSidebarTab = useAppFocus();
|
||||
const createProjectModal = useCreateModal();
|
||||
const defaultAppMode =
|
||||
@@ -584,13 +585,13 @@ const MemoizedAppSidebarInner = memo(function AppSidebarInner() {
|
||||
const settingsButton = useMemo(
|
||||
() => (
|
||||
<div>
|
||||
{(isAdmin || isCurator) && (
|
||||
{hasAdminAccess && (
|
||||
<SidebarTab
|
||||
href={isCurator ? "/admin/agents" : "/admin/configuration/llm"}
|
||||
href={getFirstPermittedAdminRoute(permissions)}
|
||||
icon={SvgSettings}
|
||||
folded={folded}
|
||||
>
|
||||
{isAdmin ? "Admin Panel" : "Curator Panel"}
|
||||
Admin Panel
|
||||
</SidebarTab>
|
||||
)}
|
||||
<AccountPopover
|
||||
@@ -601,7 +602,13 @@ const MemoizedAppSidebarInner = memo(function AppSidebarInner() {
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[folded, isAdmin, isCurator, handleShowBuildIntro, isOnyxCraftEnabled]
|
||||
[
|
||||
folded,
|
||||
hasAdminAccess,
|
||||
permissions,
|
||||
handleShowBuildIntro,
|
||||
isOnyxCraftEnabled,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user