Add User Groups (a.k.a. RBAC) (#4)

This commit is contained in:
Chris Weaver 2023-10-09 09:45:07 -07:00
parent 92de6acc6f
commit 7503f8f37b
43 changed files with 2121 additions and 23 deletions

View File

@ -61,6 +61,7 @@ WORKDIR /app
# Enterprise Version Files
COPY ./ee /app/ee
COPY ee.supervisord.conf /etc/supervisor/conf.d/ee.supervisord.conf
# Set up application files
COPY ./danswer /app/danswer

View File

@ -151,6 +151,7 @@ def add_credential_to_connector(
connector_id: int,
credential_id: int,
cc_pair_name: str | None,
is_public: bool,
user: User,
db_session: Session,
) -> StatusResponse[int]:
@ -185,6 +186,7 @@ def add_credential_to_connector(
connector_id=connector_id,
credential_id=credential_id,
name=cc_pair_name,
is_public=is_public,
)
db_session.add(association)
db_session.commit()

View File

@ -85,6 +85,7 @@ def associate_credential_to_connector(
connector_id=connector_id,
credential_id=credential_id,
cc_pair_name=metadata.name,
is_public=metadata.is_public,
user=user,
db_session=db_session,
)

View File

@ -183,6 +183,7 @@ class ConnectorCredentialPairIdentifier(BaseModel):
class ConnectorCredentialPairMetadata(BaseModel):
name: str | None
is_public: bool
class ConnectorCredentialPairDescriptor(BaseModel):

View File

@ -0,0 +1,60 @@
[supervisord]
nodaemon=true
logfile=/dev/stdout
logfile_maxbytes=0
[program:indexing]
command=python danswer/background/update.py
stdout_logfile=/var/log/update.log
stdout_logfile_maxbytes=52428800
redirect_stderr=true
autorestart=true
[program:celery]
command=celery -A ee.danswer.background.celery worker --loglevel=INFO
stdout_logfile=/var/log/celery.log
stdout_logfile_maxbytes=52428800
redirect_stderr=true
autorestart=true
[program:file_deletion]
command=python danswer/background/file_deletion.py
stdout_logfile=/var/log/file_deletion.log
stdout_logfile_maxbytes=52428800
redirect_stderr=true
autorestart=true
[program:document_set_sync]
command=python danswer/background/document_set_sync_script.py
stdout_logfile=/var/log/document_set_sync.log
stdout_logfile_maxbytes=52428800
redirect_stderr=true
autorestart=true
[program:user_group_sync]
command=python ee/danswer/background/user_group_sync_script.py
stdout_logfile=/var/log/user_group_sync.log
stdout_logfile_maxbytes=52428800
redirect_stderr=true
autorestart=true
# Listens for slack messages and responds with answers
# for all channels that the DanswerBot has been added to.
# If not setup, this will just fail 5 times and then stop.
# More details on setup here: https://docs.danswer.dev/slack_bot_setup
[program:slack_bot_listener]
command=python danswer/bots/slack/listener.py
stdout_logfile=/var/log/slack_bot_listener.log
stdout_logfile_maxbytes=52428800
redirect_stderr=true
autorestart=true
startretries=5
startsecs=60
# pushes all logs from the above programs to stdout
[program:log-redirect-handler]
command=tail -qF /var/log/update.log /var/log/celery.log /var/log/file_deletion.log /var/log/slack_bot_listener.log /var/log/document_set_sync.log /var/log/user_group_sync.log
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
redirect_stderr=true
autorestart=true

View File

@ -0,0 +1,75 @@
from sqlalchemy.orm import Session
from danswer.access.access import _get_acl_for_user as get_acl_for_user_without_groups
from danswer.access.access import (
get_access_for_documents as get_access_for_documents_without_groups,
)
from danswer.access.models import DocumentAccess
from danswer.db.engine import get_sqlalchemy_engine
from danswer.db.models import User
from danswer.server.documents.models import ConnectorCredentialPairIdentifier
from ee.danswer.db.user_group import fetch_user_groups_for_documents
from ee.danswer.db.user_group import fetch_user_groups_for_user
def _get_access_for_documents(
document_ids: list[str],
cc_pair_to_delete: ConnectorCredentialPairIdentifier | None,
db_session: Session,
) -> dict[str, DocumentAccess]:
access_dict = get_access_for_documents_without_groups(
document_ids=document_ids,
cc_pair_to_delete=cc_pair_to_delete,
db_session=db_session,
)
user_group_info = {
document_id: group_names
for document_id, group_names in fetch_user_groups_for_documents(
db_session=db_session,
document_ids=document_ids,
cc_pair_to_delete=cc_pair_to_delete,
)
}
# overload user_ids a bit - use it for both actual User IDs + group IDs
return {
document_id: DocumentAccess(
user_ids=access.user_ids.union(user_group_info.get(document_id, [])), # type: ignore
is_public=access.is_public,
)
for document_id, access in access_dict.items()
}
def get_access_for_documents(
document_ids: list[str],
cc_pair_to_delete: ConnectorCredentialPairIdentifier | None = None,
db_session: Session | None = None,
) -> dict[str, DocumentAccess]:
if db_session is None:
with Session(get_sqlalchemy_engine()) as db_session:
return _get_access_for_documents(
document_ids, cc_pair_to_delete, db_session
)
return _get_access_for_documents(document_ids, cc_pair_to_delete, db_session)
def prefix_user_group(user_group_name: str) -> str:
"""Prefixes a user group name to eliminate collision with user IDs.
This assumes that user ids are prefixed with a different prefix."""
return f"group:{user_group_name}"
def _get_acl_for_user(user: User | None, db_session: Session) -> set[str]:
"""Returns a list of ACL entries that the user has access to. This is meant to be
used downstream to filter out documents that the user does not have access to. The
user should have access to a document if at least one entry in the document's ACL
matches one entry in the returned set.
NOTE: is imported in danswer.access.access by `fetch_versioned_implementation`
DO NOT REMOVE."""
user_groups = fetch_user_groups_for_user(db_session, user.id) if user else []
return set(
[prefix_user_group(user_group.name) for user_group in user_groups]
).union(get_acl_for_user_without_groups(user, db_session))

View File

@ -0,0 +1,7 @@
from danswer.background.celery.celery import celery_app
from ee.danswer.user_groups.sync import sync_user_groups
@celery_app.task(soft_time_limit=60 * 60 * 6) # 6 hour time limit
def sync_user_group_task(user_group_id: int) -> None:
sync_user_groups(user_group_id=user_group_id)

View File

@ -0,0 +1,54 @@
import time
from celery.result import AsyncResult
from sqlalchemy.orm import Session
from danswer.db.engine import get_sqlalchemy_engine
from danswer.utils.logger import setup_logger
from ee.danswer.background.celery.celery import sync_user_group_task
from ee.danswer.db.user_group import fetch_user_groups
logger = setup_logger()
_ExistingTaskCache: dict[int, AsyncResult] = {}
def _user_group_sync_loop() -> None:
# cleanup tasks
existing_tasks = list(_ExistingTaskCache.items())
for user_group_id, task in existing_tasks:
if task.ready():
logger.info(
f"User Group '{user_group_id}' is complete with status "
f"{task.status}. Cleaning up."
)
del _ExistingTaskCache[user_group_id]
# kick off new tasks
with Session(get_sqlalchemy_engine()) as db_session:
# check if any document sets are not synced
user_groups = fetch_user_groups(db_session=db_session, only_current=False)
for user_group in user_groups:
if not user_group.is_up_to_date:
if user_group.id in _ExistingTaskCache:
logger.info(
f"User Group '{user_group.id}' is already syncing. Skipping."
)
continue
logger.info(f"User Group {user_group.id} is not synced. Syncing now!")
task = sync_user_group_task.apply_async(
kwargs=dict(user_group_id=user_group.id),
)
_ExistingTaskCache[user_group.id] = task
if __name__ == "__main__":
while True:
start = time.monotonic()
_user_group_sync_loop()
sleep_time = 30 - (time.monotonic() - start)
if sleep_time > 0:
time.sleep(sleep_time)

View File

@ -1,14 +1,18 @@
import datetime
from uuid import UUID
from sqlalchemy import Boolean
from sqlalchemy import DateTime
from sqlalchemy import ForeignKey
from sqlalchemy import func
from sqlalchemy import String
from sqlalchemy import Text
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import relationship
from danswer.db.models import Base
from danswer.db.models import ConnectorCredentialPair
from danswer.db.models import User
@ -24,3 +28,69 @@ class SamlAccount(Base):
)
user: Mapped[User] = relationship("User")
"""Tables related to RBAC"""
class User__UserGroup(Base):
__tablename__ = "user__user_group"
user_group_id: Mapped[int] = mapped_column(
ForeignKey("user_group.id"), primary_key=True
)
user_id: Mapped[UUID] = mapped_column(ForeignKey("user.id"), primary_key=True)
class UserGroup__ConnectorCredentialPair(Base):
__tablename__ = "user_group__connector_credential_pair"
user_group_id: Mapped[int] = mapped_column(
ForeignKey("user_group.id"), primary_key=True
)
cc_pair_id: Mapped[int] = mapped_column(
ForeignKey("connector_credential_pair.id"), primary_key=True
)
# if `True`, then is part of the current state of the UserGroup
# if `False`, then is a part of the prior state of the UserGroup
# rows with `is_current=False` should be deleted when the UserGroup
# is updated and should not exist for a given UserGroup if
# `UserGroup.is_up_to_date == True`
is_current: Mapped[bool] = mapped_column(
Boolean,
default=True,
primary_key=True,
)
cc_pair: Mapped[ConnectorCredentialPair] = relationship(
"ConnectorCredentialPair",
)
class UserGroup(Base):
__tablename__ = "user_group"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String, unique=True)
# whether or not changes to the UserGroup have been propogated to Vespa
is_up_to_date: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# tell the sync job to clean up the group
is_up_for_deletion: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
users: Mapped[list[User]] = relationship(
"User",
secondary=User__UserGroup.__table__,
)
cc_pairs: Mapped[list[ConnectorCredentialPair]] = relationship(
"ConnectorCredentialPair",
secondary=UserGroup__ConnectorCredentialPair.__table__,
viewonly=True,
)
cc_pair_relationships: Mapped[
list[UserGroup__ConnectorCredentialPair]
] = relationship(
"UserGroup__ConnectorCredentialPair",
viewonly=True,
)

View File

@ -0,0 +1,298 @@
from collections.abc import Sequence
from operator import and_
from uuid import UUID
from sqlalchemy import func
from sqlalchemy import select
from sqlalchemy.orm import Session
from danswer.db.models import ConnectorCredentialPair
from danswer.db.models import Document
from danswer.db.models import DocumentByConnectorCredentialPair
from danswer.db.models import User
from danswer.server.documents.models import ConnectorCredentialPairIdentifier
from ee.danswer.db.models import User__UserGroup
from ee.danswer.db.models import UserGroup
from ee.danswer.db.models import UserGroup__ConnectorCredentialPair
from ee.danswer.server.user_group.models import UserGroupCreate
from ee.danswer.server.user_group.models import UserGroupUpdate
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)
def fetch_user_groups(
db_session: Session, only_current: bool = True
) -> Sequence[UserGroup]:
stmt = select(UserGroup)
if only_current:
stmt = stmt.where(UserGroup.is_up_to_date == True) # noqa: E712
return db_session.scalars(stmt).all()
def fetch_user_groups_for_user(
db_session: Session, user_id: UUID
) -> Sequence[UserGroup]:
stmt = (
select(UserGroup)
.join(User__UserGroup, User__UserGroup.user_group_id == UserGroup.id)
.join(User, User.id == User__UserGroup.user_id) # type: ignore
.where(User.id == user_id) # type: ignore
)
return db_session.scalars(stmt).all()
def fetch_documents_for_user_group(
db_session: Session, user_group_id: int
) -> Sequence[Document]:
stmt = (
select(Document)
.join(
DocumentByConnectorCredentialPair,
Document.id == DocumentByConnectorCredentialPair.id,
)
.join(
ConnectorCredentialPair,
and_(
DocumentByConnectorCredentialPair.connector_id
== ConnectorCredentialPair.connector_id,
DocumentByConnectorCredentialPair.credential_id
== ConnectorCredentialPair.credential_id,
),
)
.join(
UserGroup__ConnectorCredentialPair,
UserGroup__ConnectorCredentialPair.cc_pair_id == ConnectorCredentialPair.id,
)
.join(
UserGroup,
UserGroup__ConnectorCredentialPair.user_group_id == UserGroup.id,
)
.where(UserGroup.id == user_group_id)
)
return db_session.scalars(stmt).all()
def fetch_user_groups_for_documents(
db_session: Session,
document_ids: list[str],
cc_pair_to_delete: ConnectorCredentialPairIdentifier | None = None,
) -> Sequence[tuple[int, list[str]]]:
stmt = (
select(Document.id, func.array_agg(UserGroup.name))
.join(
UserGroup__ConnectorCredentialPair,
UserGroup.id == UserGroup__ConnectorCredentialPair.user_group_id,
)
.join(
ConnectorCredentialPair,
ConnectorCredentialPair.id == UserGroup__ConnectorCredentialPair.cc_pair_id,
)
.join(
DocumentByConnectorCredentialPair,
and_(
DocumentByConnectorCredentialPair.connector_id
== ConnectorCredentialPair.connector_id,
DocumentByConnectorCredentialPair.credential_id
== ConnectorCredentialPair.credential_id,
),
)
.join(Document, Document.id == DocumentByConnectorCredentialPair.id)
.where(Document.id.in_(document_ids))
.where(UserGroup__ConnectorCredentialPair.is_current == True) # noqa: E712
.group_by(Document.id)
)
# pretend that the specified cc pair doesn't exist
if cc_pair_to_delete is not None:
stmt = stmt.where(
and_(
ConnectorCredentialPair.connector_id != cc_pair_to_delete.connector_id,
ConnectorCredentialPair.credential_id
!= cc_pair_to_delete.credential_id,
)
)
return db_session.execute(stmt).all() # type: ignore
def _check_user_group_is_modifiable(user_group: UserGroup) -> None:
if not user_group.is_up_to_date:
raise ValueError(
"Specified user group is currently syncing. Wait until the current "
"sync has finished before editing."
)
def _add_user__user_group_relationships__no_commit(
db_session: Session, user_group_id: int, user_ids: list[UUID]
) -> list[User__UserGroup]:
"""NOTE: does not commit the transaction."""
relationships = [
User__UserGroup(user_id=user_id, user_group_id=user_group_id)
for user_id in user_ids
]
db_session.add_all(relationships)
return relationships
def _add_user_group__cc_pair_relationships__no_commit(
db_session: Session, user_group_id: int, cc_pair_ids: list[int]
) -> list[UserGroup__ConnectorCredentialPair]:
"""NOTE: does not commit the transaction."""
relationships = [
UserGroup__ConnectorCredentialPair(
user_group_id=user_group_id, cc_pair_id=cc_pair_id
)
for cc_pair_id in cc_pair_ids
]
db_session.add_all(relationships)
return relationships
def insert_user_group(db_session: Session, user_group: UserGroupCreate) -> UserGroup:
db_user_group = UserGroup(name=user_group.name)
db_session.add(db_user_group)
db_session.flush() # give the group an ID
_add_user__user_group_relationships__no_commit(
db_session=db_session,
user_group_id=db_user_group.id,
user_ids=user_group.user_ids,
)
_add_user_group__cc_pair_relationships__no_commit(
db_session=db_session,
user_group_id=db_user_group.id,
cc_pair_ids=user_group.cc_pair_ids,
)
db_session.commit()
return db_user_group
def _cleanup_user__user_group_relationships__no_commit(
db_session: Session, user_group_id: int
) -> None:
"""NOTE: does not commit the transaction."""
user__user_group_relationships = db_session.scalars(
select(User__UserGroup).where(User__UserGroup.user_group_id == user_group_id)
).all()
for user__user_group_relationship in user__user_group_relationships:
db_session.delete(user__user_group_relationship)
def _mark_user_group__cc_pair_relationships_outdated__no_commit(
db_session: Session, user_group_id: int
) -> None:
"""NOTE: does not commit the transaction."""
user_group__cc_pair_relationships = db_session.scalars(
select(UserGroup__ConnectorCredentialPair).where(
UserGroup__ConnectorCredentialPair.user_group_id == user_group_id
)
)
for user_group__cc_pair_relationship in user_group__cc_pair_relationships:
user_group__cc_pair_relationship.is_current = False
def update_user_group(
db_session: Session, user_group_id: int, user_group: UserGroupUpdate
) -> UserGroup:
stmt = select(UserGroup).where(UserGroup.id == user_group_id)
db_user_group = db_session.scalar(stmt)
if db_user_group is None:
raise ValueError(f"UserGroup with id '{user_group_id}' not found")
_check_user_group_is_modifiable(db_user_group)
existing_cc_pairs = db_user_group.cc_pairs
cc_pairs_updated = set([cc_pair.id for cc_pair in existing_cc_pairs]) != set(
user_group.cc_pair_ids
)
users_updated = set([user.id for user in db_user_group.users]) != set(
user_group.user_ids
)
if users_updated:
_cleanup_user__user_group_relationships__no_commit(
db_session=db_session, user_group_id=user_group_id
)
_add_user__user_group_relationships__no_commit(
db_session=db_session,
user_group_id=user_group_id,
user_ids=user_group.user_ids,
)
if cc_pairs_updated:
_mark_user_group__cc_pair_relationships_outdated__no_commit(
db_session=db_session, user_group_id=user_group_id
)
_add_user_group__cc_pair_relationships__no_commit(
db_session=db_session,
user_group_id=db_user_group.id,
cc_pair_ids=user_group.cc_pair_ids,
)
# only needs to sync with Vespa if the cc_pairs have been updated
if cc_pairs_updated:
db_user_group.is_up_to_date = False
db_session.commit()
return db_user_group
def prepare_user_group_for_deletion(db_session: Session, user_group_id: int) -> None:
stmt = select(UserGroup).where(UserGroup.id == user_group_id)
db_user_group = db_session.scalar(stmt)
if db_user_group is None:
raise ValueError(f"UserGroup with id '{user_group_id}' not found")
_check_user_group_is_modifiable(db_user_group)
_cleanup_user__user_group_relationships__no_commit(
db_session=db_session, user_group_id=user_group_id
)
_mark_user_group__cc_pair_relationships_outdated__no_commit(
db_session=db_session, user_group_id=user_group_id
)
db_user_group.is_up_to_date = False
db_user_group.is_up_for_deletion = True
db_session.commit()
def _cleanup_user_group__cc_pair_relationships__no_commit(
db_session: Session, user_group_id: int, outdated_only: bool
) -> None:
"""NOTE: does not commit the transaction."""
stmt = select(UserGroup__ConnectorCredentialPair).where(
UserGroup__ConnectorCredentialPair.user_group_id == user_group_id
)
if outdated_only:
stmt = stmt.where(
UserGroup__ConnectorCredentialPair.is_current == False # noqa: E712
)
user_group__cc_pair_relationships = db_session.scalars(stmt)
for user_group__cc_pair_relationship in user_group__cc_pair_relationships:
db_session.delete(user_group__cc_pair_relationship)
def mark_user_group_as_synced(db_session: Session, user_group: UserGroup) -> None:
# cleanup outdated relationships
_cleanup_user_group__cc_pair_relationships__no_commit(
db_session=db_session, user_group_id=user_group.id, outdated_only=True
)
user_group.is_up_to_date = True
db_session.commit()
def delete_user_group(db_session: Session, user_group: UserGroup) -> None:
_cleanup_user__user_group_relationships__no_commit(
db_session=db_session, user_group_id=user_group.id
)
_cleanup_user_group__cc_pair_relationships__no_commit(
db_session=db_session,
user_group_id=user_group.id,
outdated_only=False,
)
db_session.delete(user_group)
db_session.commit()

View File

@ -17,6 +17,7 @@ from danswer.utils.logger import setup_logger
from danswer.utils.variable_functionality import global_version
from ee.danswer.configs.app_configs import OPENID_CONFIG_URL
from ee.danswer.server.saml import router as saml_router
from ee.danswer.server.user_group.api import router as user_group_router
logger = setup_logger()
@ -51,6 +52,9 @@ def get_ee_application() -> FastAPI:
elif AUTH_TYPE == AuthType.SAML:
application.include_router(saml_router)
# RBAC / group access control
application.include_router(user_group_router)
return application

View File

@ -0,0 +1,71 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
import danswer.db.models as db_models
from danswer.auth.users import current_admin_user
from danswer.db.engine import get_session
from ee.danswer.db.user_group import fetch_user_groups
from ee.danswer.db.user_group import insert_user_group
from ee.danswer.db.user_group import prepare_user_group_for_deletion
from ee.danswer.db.user_group import update_user_group
from ee.danswer.server.user_group.models import UserGroup
from ee.danswer.server.user_group.models import UserGroupCreate
from ee.danswer.server.user_group.models import UserGroupUpdate
router = APIRouter(prefix="/manage")
@router.get("/admin/user-group")
def list_user_groups(
_: db_models.User = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> list[UserGroup]:
user_groups = fetch_user_groups(db_session, only_current=False)
return [UserGroup.from_model(user_group) for user_group in user_groups]
@router.post("/admin/user-group")
def create_user_group(
user_group: UserGroupCreate,
_: db_models.User = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> UserGroup:
try:
db_user_group = insert_user_group(db_session, user_group)
except IntegrityError:
raise HTTPException(
400,
f"User group with name '{user_group.name}' already exists. Please "
+ "choose a different name.",
)
return UserGroup.from_model(db_user_group)
@router.patch("/admin/user-group/{user_group_id}")
def patch_user_group(
user_group_id: int,
user_group: UserGroupUpdate,
_: db_models.User = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> UserGroup:
try:
return UserGroup.from_model(
update_user_group(db_session, user_group_id, user_group)
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.delete("/admin/user-group/{user_group_id}")
def delete_user_group(
user_group_id: int,
_: db_models.User = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> None:
try:
prepare_user_group_for_deletion(db_session, user_group_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))

View File

@ -0,0 +1,65 @@
from uuid import UUID
from pydantic import BaseModel
from danswer.server.documents.models import (
ConnectorCredentialPairDescriptor,
ConnectorSnapshot,
CredentialSnapshot,
)
from danswer.server.manage.models import UserInfo
from ee.danswer.db.models import UserGroup as UserGroupModel
class UserGroup(BaseModel):
id: int
name: str
users: list[UserInfo]
cc_pairs: list[ConnectorCredentialPairDescriptor]
is_up_to_date: bool
is_up_for_deletion: bool
@classmethod
def from_model(cls, document_set_model: UserGroupModel) -> "UserGroup":
return cls(
id=document_set_model.id,
name=document_set_model.name,
users=[
UserInfo(
id=str(user.id),
email=user.email,
is_active=user.is_active,
is_superuser=user.is_superuser,
is_verified=user.is_verified,
role=user.role,
)
for user in document_set_model.users
],
cc_pairs=[
ConnectorCredentialPairDescriptor(
id=cc_pair_relationship.cc_pair.id,
name=cc_pair_relationship.cc_pair.name,
connector=ConnectorSnapshot.from_connector_db_model(
cc_pair_relationship.cc_pair.connector
),
credential=CredentialSnapshot.from_credential_db_model(
cc_pair_relationship.cc_pair.credential
),
)
for cc_pair_relationship in document_set_model.cc_pair_relationships
if cc_pair_relationship.is_current
],
is_up_to_date=document_set_model.is_up_to_date,
is_up_for_deletion=document_set_model.is_up_for_deletion,
)
class UserGroupCreate(BaseModel):
name: str
user_ids: list[UUID]
cc_pair_ids: list[int]
class UserGroupUpdate(BaseModel):
user_ids: list[UUID]
cc_pair_ids: list[int]

View File

@ -0,0 +1,70 @@
from sqlalchemy.orm import Session
from danswer.document_index.factory import get_default_document_index
from danswer.document_index.interfaces import DocumentIndex
from danswer.document_index.interfaces import UpdateRequest
from danswer.db.document import prepare_to_modify_documents
from danswer.db.engine import get_sqlalchemy_engine
from danswer.utils.batching import batch_generator
from danswer.utils.logger import setup_logger
from ee.danswer.access.access import get_access_for_documents
from ee.danswer.db.user_group import delete_user_group
from ee.danswer.db.user_group import fetch_documents_for_user_group
from ee.danswer.db.user_group import fetch_user_group
from ee.danswer.db.user_group import mark_user_group_as_synced
logger = setup_logger()
_SYNC_BATCH_SIZE = 1000
def _sync_user_group_batch(
document_ids: list[str], document_index: DocumentIndex
) -> None:
logger.debug(f"Syncing document sets for: {document_ids}")
# begin a transaction, release lock at the end
with Session(get_sqlalchemy_engine()) as db_session:
# acquires a lock on the documents so that no other process can modify them
prepare_to_modify_documents(db_session=db_session, document_ids=document_ids)
# get current state of document sets for these documents
document_id_to_access = get_access_for_documents(
document_ids=document_ids, db_session=db_session
)
# update Vespa
document_index.update(
update_requests=[
UpdateRequest(
document_ids=[document_id],
access=document_id_to_access[document_id],
)
for document_id in document_ids
]
)
def sync_user_groups(user_group_id: int) -> None:
"""Sync the status of Postgres for the specified user group"""
document_index = get_default_document_index()
with Session(get_sqlalchemy_engine()) as db_session:
user_group = fetch_user_group(
db_session=db_session, user_group_id=user_group_id
)
if user_group is None:
raise ValueError(f"User group '{user_group_id}' does not exist")
documents_to_update = fetch_documents_for_user_group(
db_session=db_session,
user_group_id=user_group_id,
)
for document_batch in batch_generator(documents_to_update, _SYNC_BATCH_SIZE):
_sync_user_group_batch(
document_ids=[document.id for document in document_batch],
document_index=document_index,
)
if user_group.is_up_for_deletion:
delete_user_group(db_session=db_session, user_group=user_group)
else:
mark_user_group_as_synced(db_session=db_session, user_group=user_group)

View File

@ -100,7 +100,7 @@ services:
build:
context: ../../backend
dockerfile: Dockerfile
command: /usr/bin/supervisord
command: /usr/bin/supervisord -c /etc/supervisor/conf.d/ee.supervisord.conf
depends_on:
- relational_db
- index
@ -203,6 +203,7 @@ services:
- NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS:-}
- NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS:-}
- NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT:-}
- NEXT_PUBLIC_EE_ENABLED=${NEXT_PUBLIC_EE_ENABLED:-true}
depends_on:
- api_server
restart: always

View File

@ -35,7 +35,7 @@ services:
build:
context: ../../backend
dockerfile: Dockerfile
command: /usr/bin/supervisord
command: /usr/bin/supervisord -c /etc/supervisor/conf.d/ee.supervisord.conf
depends_on:
- relational_db
- index
@ -70,6 +70,7 @@ services:
- NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS:-}
- NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS:-}
- NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT:-}
- NEXT_PUBLIC_EE_ENABLED=${NEXT_PUBLIC_EE_ENABLED:-true}
depends_on:
- api_server
restart: always

View File

@ -16,7 +16,7 @@ spec:
- name: background
image: danswer/danswer-ee-backend:latest
imagePullPolicy: IfNotPresent
command: ["/usr/bin/supervisord"]
command: ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/ee.supervisord.conf"]
# There are some extra values since this is shared between services
# There are no conflicts though, extra env variables are simply ignored
envFrom:

View File

@ -36,3 +36,5 @@ spec:
envFrom:
- configMapRef:
name: env-configmap
args:
- "NEXT_PUBLIC_EE_ENABLED=true"

View File

@ -53,6 +53,8 @@ ENV NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PRED
ARG NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS
ENV NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS}
ARG NEXT_PUBLIC_EE_ENABLED
ENV NEXT_PUBLIC_EE_ENABLED=${NEXT_PUBLIC_EE_ENABLED}
ARG NEXT_PUBLIC_DISABLE_LOGOUT
ENV NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT}
@ -101,6 +103,8 @@ ENV NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PRED
ARG NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS
ENV NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS}
ARG NEXT_PUBLIC_EE_ENABLED
ENV NEXT_PUBLIC_EE_ENABLED=${NEXT_PUBLIC_EE_ENABLED}
ARG NEXT_PUBLIC_DISABLE_LOGOUT
ENV NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT}

View File

@ -9,17 +9,31 @@ const nextConfig = {
output: "standalone",
swcMinify: true,
rewrites: async () => {
const eeRedirects =
process.env.NEXT_PUBLIC_EE_ENABLED === "true"
? [
{
source: "/admin/groups",
destination: "/ee/admin/groups",
},
{
source: "/admin/groups/:path*",
destination: "/ee/admin/groups/:path*",
},
]
: [];
// In production, something else (nginx in the one box setup) should take
// care of this rewrite. TODO (chris): better support setups where
// web_server and api_server are on different machines.
if (process.env.NODE_ENV === "production") return [];
if (process.env.NODE_ENV === "production") return eeRedirects;
return [
{
source: "/api/:path*",
destination: "http://127.0.0.1:8080/:path*", // Proxy to Backend
},
];
].concat(eeRedirects);
},
redirects: async () => {
// In production, something else (nginx in the one box setup) should take

View File

@ -17,7 +17,7 @@ import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
import { getDocsProcessedPerMinute } from "@/lib/indexAttempt";
import Link from "next/link";
import { isCurrentlyDeleting } from "@/lib/documentDeletion";
import { FiEdit } from "react-icons/fi";
import { FiCheck, FiEdit, FiXCircle } from "react-icons/fi";
const NUM_IN_PAGE = 20;
@ -86,6 +86,7 @@ export function CCPairIndexingStatusTable({
<TableRow>
<TableHeaderCell>Connector</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Is Public</TableHeaderCell>
<TableHeaderCell>Last Indexed</TableHeaderCell>
<TableHeaderCell>Docs Indexed</TableHeaderCell>
</TableRow>
@ -116,6 +117,13 @@ export function CCPairIndexingStatusTable({
ccPairsIndexingStatus={ccPairsIndexingStatus}
/>
</TableCell>
<TableCell>
{ccPairsIndexingStatus.public_doc ? (
<FiCheck className="my-auto text-emerald-600" size="18" />
) : (
<FiXCircle className="my-auto text-red-600" />
)}
</TableCell>
<TableCell>
{timeAgo(ccPairsIndexingStatus?.last_success) || "-"}
</TableCell>

36
web/src/app/ee/LICENSE Normal file
View File

@ -0,0 +1,36 @@
The DanswerAI Enterprise license (the “Enterprise License”)
Copyright (c) 2023 DanswerAI, Inc.
With regard to the Danswer Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the DanswerAI Subscription Terms of Service, available
at https://danswer.ai/terms (the “Enterprise Terms”), or other
agreement governing the use of the Software, as agreed by you and DanswerAI,
and otherwise have a valid Danswer Enterprise license for the
correct number of user seats. Subject to the foregoing sentence, you are free to
modify this Software and publish patches to the Software. You agree that DanswerAI
and/or its licensors (as applicable) retain all right, title and interest in and
to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid Danswer Enterprise license for the correct
number of user seats. Notwithstanding the foregoing, you may copy and modify
the Software for development and testing purposes, without requiring a
subscription. You agree that DanswerAI and/or its licensors (as applicable) retain
all right, title and interest in and to all such modifications. You are not
granted any other rights beyond what is expressly stated herein. Subject to the
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
and/or sell the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the Danswer Software, those
components are licensed under the original license provided by the owner of the
applicable component.

View File

@ -0,0 +1,64 @@
import { ConnectorIndexingStatus } from "@/lib/types";
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
interface ConnectorEditorProps {
selectedCCPairIds: number[];
setSetCCPairIds: (ccPairId: number[]) => void;
allCCPairs: ConnectorIndexingStatus<any, any>[];
}
export const ConnectorEditor = ({
selectedCCPairIds,
setSetCCPairIds,
allCCPairs,
}: ConnectorEditorProps) => {
return (
<div className="mb-3 flex gap-2 flex-wrap">
{allCCPairs
// remove public docs, since they don't make sense as part of a group
.filter((ccPair) => !ccPair.public_doc)
.map((ccPair) => {
const ind = selectedCCPairIds.indexOf(ccPair.cc_pair_id);
let isSelected = ind !== -1;
return (
<div
key={`${ccPair.connector.id}-${ccPair.credential.id}`}
className={
`
px-3
py-1
rounded-lg
border
border-gray-700
w-fit
flex
cursor-pointer ` +
(isSelected ? " bg-gray-600" : " hover:bg-gray-700")
}
onClick={() => {
if (isSelected) {
setSetCCPairIds(
selectedCCPairIds.filter(
(ccPairId) => ccPairId !== ccPair.cc_pair_id
)
);
} else {
setSetCCPairIds([...selectedCCPairIds, ccPair.cc_pair_id]);
}
}}
>
<div className="my-auto">
<ConnectorTitle
connector={ccPair.connector}
ccPairId={ccPair.cc_pair_id}
ccPairName={ccPair.name}
isLink={false}
showMetadata={false}
/>
</div>
</div>
);
})}
</div>
);
};

View File

@ -0,0 +1,94 @@
import { User } from "@/lib/types";
import { useState } from "react";
import { FiPlus, FiX } from "react-icons/fi";
import { SearchMultiSelectDropdown } from "@/components/Dropdown";
import { UsersIcon } from "@/components/icons/icons";
import { Button } from "@/components/Button";
interface UserEditorProps {
selectedUserIds: string[];
setSelectedUserIds: (userIds: string[]) => void;
allUsers: User[];
existingUsers: User[];
onSubmit?: (users: User[]) => void;
}
export const UserEditor = ({
selectedUserIds,
setSelectedUserIds,
allUsers,
existingUsers,
onSubmit,
}: UserEditorProps) => {
const selectedUsers = allUsers.filter((user) =>
selectedUserIds.includes(user.id)
);
return (
<>
<div className="mb-2 flex flex-wrap gap-x-2">
{selectedUsers.length > 0 &&
selectedUsers.map((selectedUser) => (
<div
key={selectedUser.id}
onClick={() => {
setSelectedUserIds(
selectedUserIds.filter((userId) => userId !== selectedUser.id)
);
}}
className={`
flex
rounded-lg
px-2
py-1
border
border-gray-700
hover:bg-gray-900
cursor-pointer`}
>
{selectedUser.email} <FiX className="ml-1 my-auto" />
</div>
))}
</div>
<div className="flex">
<SearchMultiSelectDropdown
options={allUsers
.filter(
(user) =>
!selectedUserIds.includes(user.id) &&
!existingUsers.map((user) => user.id).includes(user.id)
)
.map((user) => {
return {
name: user.email,
value: user.id,
};
})}
onSelect={(option) => {
setSelectedUserIds([
...Array.from(new Set([...selectedUserIds, option.value])),
]);
}}
itemComponent={({ option }) => (
<div className="flex px-4 py-2.5 hover:bg-gray-800 cursor-pointer">
<UsersIcon className="mr-2 my-auto" />
{option.name}
<div className="ml-auto my-auto">
<FiPlus />
</div>
</div>
)}
/>
{onSubmit && (
<Button
className="ml-3 flex-nowrap w-32"
onClick={() => onSubmit(selectedUsers)}
>
Add Users
</Button>
)}
</div>
</>
);
};

View File

@ -0,0 +1,145 @@
import { ArrayHelpers, FieldArray, Form, Formik } from "formik";
import * as Yup from "yup";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { ConnectorIndexingStatus, DocumentSet, User } from "@/lib/types";
import { TextFormField } from "@/components/admin/connectors/Field";
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
import { createUserGroup } from "./lib";
import { UserGroup } from "./types";
import { UserEditor } from "./UserEditor";
import { ConnectorEditor } from "./ConnectorEditor";
interface UserGroupCreationFormProps {
onClose: () => void;
setPopup: (popupSpec: PopupSpec | null) => void;
users: User[];
ccPairs: ConnectorIndexingStatus<any, any>[];
existingUserGroup?: UserGroup;
}
export const UserGroupCreationForm = ({
onClose,
setPopup,
users,
ccPairs,
existingUserGroup,
}: UserGroupCreationFormProps) => {
const isUpdate = existingUserGroup !== undefined;
return (
<div>
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-30"
onClick={onClose}
>
<div
className="bg-gray-800 rounded-lg border border-gray-700 shadow-lg relative w-1/2 text-sm"
onClick={(event) => event.stopPropagation()}
>
<Formik
initialValues={{
name: existingUserGroup ? existingUserGroup.name : "",
user_ids: [] as string[],
cc_pair_ids: [] as number[],
}}
validationSchema={Yup.object().shape({
name: Yup.string().required("Please enter a name for the group"),
user_ids: Yup.array().of(Yup.string().required()),
cc_pair_ids: Yup.array().of(Yup.number().required()),
})}
onSubmit={async (values, formikHelpers) => {
formikHelpers.setSubmitting(true);
let response;
response = await createUserGroup(values);
formikHelpers.setSubmitting(false);
if (response.ok) {
setPopup({
message: isUpdate
? "Successfully updated user group!"
: "Successfully created user group!",
type: "success",
});
onClose();
} else {
const responseJson = await response.json();
const errorMsg = responseJson.detail || responseJson.message;
setPopup({
message: isUpdate
? `Error updating user group - ${errorMsg}`
: `Error creating user group - ${errorMsg}`,
type: "error",
});
}
}}
>
{({ isSubmitting, values, setFieldValue }) => (
<Form>
<h2 className="text-xl font-bold mb-3 border-b border-gray-600 pt-4 pb-3 bg-gray-700 px-6">
{isUpdate ? "Update a User Group" : "Create a new User Group"}
</h2>
<div className="p-4">
<TextFormField
name="name"
label="Name:"
placeholder="A name for the User Group"
disabled={isUpdate}
autoCompleteDisabled={true}
/>
<div className="border-t border-gray-600 py-2" />
<h2 className="mb-1 font-medium">
Select which connectors this group has access to:
</h2>
<p className="mb-3 text-xs">
All documents indexed by the selected connectors will be
visible to users in this group.
</p>
<ConnectorEditor
allCCPairs={ccPairs}
selectedCCPairIds={values.cc_pair_ids}
setSetCCPairIds={(ccPairsIds) =>
setFieldValue("cc_pair_ids", ccPairsIds)
}
/>
<div className="border-t border-gray-600 py-2" />
<h2 className="mb-1 font-medium">
Select which Users should be a part of this Group.
</h2>
<p className="mb-3 text-xs">
All selected users will be able to search through all
documents indexed by the selected connectors.
</p>
<div className="mb-3 gap-2">
<UserEditor
selectedUserIds={values.user_ids}
setSelectedUserIds={(userIds) =>
setFieldValue("user_ids", userIds)
}
allUsers={users}
existingUsers={[]}
/>
</div>
<div className="flex">
<button
type="submit"
disabled={isSubmitting}
className={
"bg-slate-500 hover:bg-slate-700 text-white " +
"font-bold py-2 px-4 rounded focus:outline-none " +
"focus:shadow-outline w-full max-w-sm mx-auto"
}
>
{isUpdate ? "Update!" : "Create!"}
</button>
</div>
</div>
</Form>
)}
</Formik>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,161 @@
"use client";
import { UserGroup } from "./types";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { LoadingAnimation } from "@/components/Loading";
import { BasicTable } from "@/components/admin/connectors/BasicTable";
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
import { TrashIcon } from "@/components/icons/icons";
import { deleteUserGroup } from "./lib";
import { useRouter } from "next/navigation";
import { FiUser } from "react-icons/fi";
import { User } from "@/lib/types";
const MAX_USERS_TO_DISPLAY = 6;
const SimpleUserDisplay = ({ user }: { user: User }) => {
return (
<div className="flex my-0.5 text-gray-200">
<FiUser className="mr-2 my-auto" /> {user.email}
</div>
);
};
interface UserGroupsTableProps {
userGroups: UserGroup[];
setPopup: (popupSpec: PopupSpec | null) => void;
refresh: () => void;
}
export const UserGroupsTable = ({
userGroups,
setPopup,
refresh,
}: UserGroupsTableProps) => {
const router = useRouter();
// sort by name for consistent ordering
userGroups.sort((a, b) => {
if (a.name < b.name) {
return -1;
} else if (a.name > b.name) {
return 1;
} else {
return 0;
}
});
return (
<div>
<BasicTable
columns={[
{
header: "Name",
key: "name",
},
{
header: "Connectors",
key: "ccPairs",
},
{
header: "Users",
key: "users",
},
{
header: "Status",
key: "status",
},
{
header: "Delete",
key: "delete",
},
]}
data={userGroups
.filter((userGroup) => !userGroup.is_up_for_deletion)
.map((userGroup) => {
return {
id: userGroup.id,
name: userGroup.name,
ccPairs: (
<div>
{userGroup.cc_pairs.map((ccPairDescriptor, ind) => {
return (
<div
className={
ind !== userGroup.cc_pairs.length - 1 ? "mb-3" : ""
}
key={ccPairDescriptor.id}
>
<ConnectorTitle
connector={ccPairDescriptor.connector}
ccPairId={ccPairDescriptor.id}
ccPairName={ccPairDescriptor.name}
showMetadata={false}
/>
</div>
);
})}
</div>
),
users: (
<div>
{userGroup.users.length <= MAX_USERS_TO_DISPLAY ? (
userGroup.users.map((user) => {
return <SimpleUserDisplay key={user.id} user={user} />;
})
) : (
<div>
{userGroup.users
.slice(0, MAX_USERS_TO_DISPLAY)
.map((user) => {
return (
<SimpleUserDisplay key={user.id} user={user} />
);
})}
<div className="text-gray-300">
+ {userGroup.users.length - MAX_USERS_TO_DISPLAY} more
</div>
</div>
)}
</div>
),
status: userGroup.is_up_to_date ? (
<div className="text-emerald-600">Up to date!</div>
) : (
<div className="text-gray-300 w-10">
<LoadingAnimation text="Syncing" />
</div>
),
delete: (
<div
className="cursor-pointer"
onClick={async (event) => {
event.stopPropagation();
const response = await deleteUserGroup(userGroup.id);
if (response.ok) {
setPopup({
message: `User Group "${userGroup.name}" deleted`,
type: "success",
});
} else {
const errorMsg = (await response.json()).detail;
setPopup({
message: `Failed to delete User Group - ${errorMsg}`,
type: "error",
});
}
refresh();
}}
>
<TrashIcon />
</div>
),
};
})}
onSelect={(data) => {
router.push(`/admin/groups/${data.id}`);
}}
/>
</div>
);
};

View File

@ -0,0 +1,154 @@
import { Button } from "@/components/Button";
import { SearchMultiSelectDropdown } from "@/components/Dropdown";
import { Modal } from "@/components/Modal";
import { UsersIcon } from "@/components/icons/icons";
import { useState } from "react";
import { FiPlus, FiX } from "react-icons/fi";
import { updateUserGroup } from "./lib";
import { UserGroup } from "../types";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { Connector, ConnectorIndexingStatus } from "@/lib/types";
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
interface AddConnectorFormProps {
ccPairs: ConnectorIndexingStatus<any, any>[];
userGroup: UserGroup;
onClose: () => void;
setPopup: (popupSpec: PopupSpec) => void;
}
export const AddConnectorForm: React.FC<AddConnectorFormProps> = ({
ccPairs,
userGroup,
onClose,
setPopup,
}) => {
const [selectedCCPairIds, setSelectedCCPairIds] = useState<number[]>([]);
const selectedCCPairs = ccPairs.filter((ccPair) =>
selectedCCPairIds.includes(ccPair.cc_pair_id)
);
return (
<Modal title="Add New Connector" onOutsideClick={() => onClose()}>
<div className="px-6 pt-4 pb-12">
<div className="mb-2 flex flex-wrap gap-x-2">
{selectedCCPairs.length > 0 &&
selectedCCPairs.map((ccPair) => (
<div
key={ccPair.cc_pair_id}
onClick={() => {
setSelectedCCPairIds(
selectedCCPairIds.filter(
(ccPairId) => ccPairId !== ccPair.cc_pair_id
)
);
}}
className={`
flex
rounded-lg
px-2
py-1
my-1
border
border-gray-700
hover:bg-gray-900
cursor-pointer`}
>
<ConnectorTitle
ccPairId={ccPair.cc_pair_id}
ccPairName={ccPair.name}
connector={ccPair.connector}
isLink={false}
showMetadata={false}
/>
<FiX className="ml-1 my-auto" />
</div>
))}
</div>
<div className="flex">
<SearchMultiSelectDropdown
options={ccPairs
.filter(
(ccPair) =>
!selectedCCPairIds.includes(ccPair.cc_pair_id) &&
!userGroup.cc_pairs
.map((userGroupCCPair) => userGroupCCPair.id)
.includes(ccPair.cc_pair_id)
)
// remove public docs, since they don't make sense as part of a group
.filter((ccPair) => !ccPair.public_doc)
.map((ccPair) => {
return {
name: ccPair.name?.toString() || "",
value: ccPair.cc_pair_id?.toString(),
metadata: {
ccPairId: ccPair.cc_pair_id,
connector: ccPair.connector,
},
};
})}
onSelect={(option) => {
setSelectedCCPairIds([
...Array.from(
new Set([...selectedCCPairIds, parseInt(option.value)])
),
]);
}}
itemComponent={({ option }) => (
<div className="flex px-4 py-2.5 hover:bg-gray-800 cursor-pointer">
<div className="my-auto">
<ConnectorTitle
ccPairId={option?.metadata?.ccPairId as number}
ccPairName={option.name}
connector={option?.metadata?.connector as Connector<any>}
isLink={false}
showMetadata={false}
/>
</div>
<div className="ml-auto my-auto">
<FiPlus />
</div>
</div>
)}
/>
<Button
className="ml-3 flex-nowrap w-48"
onClick={async () => {
const newCCPairIds = [
...Array.from(
new Set(
userGroup.cc_pairs
.map((ccPair) => ccPair.id)
.concat(selectedCCPairIds)
)
),
];
const response = await updateUserGroup(userGroup.id, {
user_ids: userGroup.users.map((user) => user.id),
cc_pair_ids: newCCPairIds,
});
if (response.ok) {
setPopup({
message: "Successfully added users to group",
type: "success",
});
onClose();
} else {
const responseJson = await response.json();
const errorMsg = responseJson.detail || responseJson.message;
setPopup({
message: `Failed to add users to group - ${errorMsg}`,
type: "error",
});
onClose();
}
}}
>
Add Connectors
</Button>
</div>
</div>
</Modal>
);
};

View File

@ -0,0 +1,66 @@
import { Modal } from "@/components/Modal";
import { updateUserGroup } from "./lib";
import { UserGroup } from "../types";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { User } from "@/lib/types";
import { UserEditor } from "../UserEditor";
import { useState } from "react";
interface AddMemberFormProps {
users: User[];
userGroup: UserGroup;
onClose: () => void;
setPopup: (popupSpec: PopupSpec) => void;
}
export const AddMemberForm: React.FC<AddMemberFormProps> = ({
users,
userGroup,
onClose,
setPopup,
}) => {
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
return (
<Modal title="Add New User" onOutsideClick={() => onClose()}>
<div className="px-6 pt-4 pb-12">
<UserEditor
selectedUserIds={selectedUserIds}
setSelectedUserIds={setSelectedUserIds}
allUsers={users}
existingUsers={userGroup.users}
onSubmit={async (selectedUsers) => {
const newUserIds = [
...Array.from(
new Set(
userGroup.users
.map((user) => user.id)
.concat(selectedUsers.map((user) => user.id))
)
),
];
const response = await updateUserGroup(userGroup.id, {
user_ids: newUserIds,
cc_pair_ids: userGroup.cc_pairs.map((ccPair) => ccPair.id),
});
if (response.ok) {
setPopup({
message: "Successfully added users to group",
type: "success",
});
onClose();
} else {
const responseJson = await response.json();
const errorMsg = responseJson.detail || responseJson.message;
setPopup({
message: `Failed to add users to group - ${errorMsg}`,
type: "error",
});
onClose();
}
}}
/>
</div>
</Modal>
);
};

View File

@ -0,0 +1,225 @@
"use client";
import { usePopup } from "@/components/admin/connectors/Popup";
import { useState } from "react";
import { UserGroup } from "../types";
import { BasicTable } from "@/components/admin/connectors/BasicTable";
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
import { Button } from "@/components/Button";
import { AddMemberForm } from "./AddMemberForm";
import { TrashIcon } from "@/components/icons/icons";
import { updateUserGroup } from "./lib";
import { LoadingAnimation } from "@/components/Loading";
import { ConnectorIndexingStatus, User } from "@/lib/types";
import { AddConnectorForm } from "./AddConnectorForm";
interface GroupDisplayProps {
users: User[];
ccPairs: ConnectorIndexingStatus<any, any>[];
userGroup: UserGroup;
refreshUserGroup: () => void;
}
export const GroupDisplay = ({
users,
ccPairs,
userGroup,
refreshUserGroup,
}: GroupDisplayProps) => {
const { popup, setPopup } = usePopup();
const [addMemberFormVisible, setAddMemberFormVisible] = useState(false);
const [addConnectorFormVisible, setAddConnectorFormVisible] = useState(false);
return (
<div>
{popup}
<div className="text-sm mb-3 flex">
<b className="mr-1">Status:</b>{" "}
{userGroup.is_up_to_date ? (
<div className="text-emerald-600">Up to date</div>
) : (
<div className="text-gray-300">
<LoadingAnimation text="Syncing" />
</div>
)}
</div>
<div className="flex w-full">
<h2 className="text-xl font-bold">Users</h2>
</div>
<div className="mt-2">
{userGroup.users.length > 0 ? (
<BasicTable
columns={[
{
header: "Email",
key: "email",
},
{
header: "Remove User",
key: "remove",
alignment: "right",
},
]}
data={userGroup.users.map((user) => {
return {
email: <div>{user.email}</div>,
remove: (
<div className="flex">
<div
className="cursor-pointer ml-auto mr-1"
onClick={async () => {
const response = await updateUserGroup(userGroup.id, {
user_ids: userGroup.users
.filter(
(userGroupUser) => userGroupUser.id !== user.id
)
.map((userGroupUser) => userGroupUser.id),
cc_pair_ids: userGroup.cc_pairs.map(
(ccPair) => ccPair.id
),
});
if (response.ok) {
setPopup({
message: "Successfully removed user from group",
type: "success",
});
} else {
const responseJson = await response.json();
const errorMsg =
responseJson.detail || responseJson.message;
setPopup({
message: `Error removing user from group - ${errorMsg}`,
type: "error",
});
}
refreshUserGroup();
}}
>
<TrashIcon />
</div>
</div>
),
};
})}
/>
) : (
<div className="text-sm">No users in this group...</div>
)}
</div>
<Button
className="mt-3"
onClick={() => setAddMemberFormVisible(true)}
disabled={!userGroup.is_up_to_date}
>
Add Users
</Button>
{addMemberFormVisible && (
<AddMemberForm
users={users}
userGroup={userGroup}
onClose={() => {
setAddMemberFormVisible(false);
refreshUserGroup();
}}
setPopup={setPopup}
/>
)}
<h2 className="text-xl font-bold mt-4">Connectors</h2>
<div className="mt-2">
{userGroup.cc_pairs.length > 0 ? (
<BasicTable
columns={[
{
header: "Connector",
key: "connector_name",
},
{
header: "Remove Connector",
key: "delete",
alignment: "right",
},
]}
data={userGroup.cc_pairs.map((ccPair) => {
return {
connector_name:
(
<ConnectorTitle
connector={ccPair.connector}
ccPairId={ccPair.id}
ccPairName={ccPair.name}
/>
) || "",
delete: (
<div className="flex">
<div
className="cursor-pointer ml-auto mr-1"
onClick={async () => {
const response = await updateUserGroup(userGroup.id, {
user_ids: userGroup.users.map(
(userGroupUser) => userGroupUser.id
),
cc_pair_ids: userGroup.cc_pairs
.filter(
(userGroupCCPair) =>
userGroupCCPair.id != ccPair.id
)
.map((ccPair) => ccPair.id),
});
if (response.ok) {
setPopup({
message:
"Successfully removed connector from group",
type: "success",
});
} else {
const responseJson = await response.json();
const errorMsg =
responseJson.detail || responseJson.message;
setPopup({
message: `Error removing connector from group - ${errorMsg}`,
type: "error",
});
}
refreshUserGroup();
}}
>
<TrashIcon />
</div>
</div>
),
};
})}
/>
) : (
<div className="text-sm">No connectors in this group...</div>
)}
</div>
<Button
className="mt-3"
onClick={() => setAddConnectorFormVisible(true)}
disabled={!userGroup.is_up_to_date}
>
Add Connectors
</Button>
{addConnectorFormVisible && (
<AddConnectorForm
ccPairs={ccPairs}
userGroup={userGroup}
onClose={() => {
setAddConnectorFormVisible(false);
refreshUserGroup();
}}
setPopup={setPopup}
/>
)}
</div>
);
};

View File

@ -0,0 +1,12 @@
import { useUserGroups } from "../hooks";
export const useSpecificUserGroup = (groupId: string) => {
const { data, isLoading, error, refreshUserGroups } = useUserGroups();
const userGroup = data?.find((group) => group.id.toString() === groupId);
return {
userGroup,
isLoading,
error,
refreshUserGroup: refreshUserGroups,
};
};

View File

@ -0,0 +1,15 @@
import { UserGroupUpdate } from "../types";
export const updateUserGroup = async (
groupId: number,
userGroup: UserGroupUpdate
) => {
const url = `/api/manage/admin/user-group/${groupId}`;
return await fetch(url, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(userGroup),
});
};

View File

@ -0,0 +1,81 @@
"use client";
import { GroupsIcon } from "@/components/icons/icons";
import { GroupDisplay } from "./GroupDisplay";
import { FiAlertCircle, FiChevronLeft } from "react-icons/fi";
import { useSpecificUserGroup } from "./hook";
import { ThreeDotsLoader } from "@/components/Loading";
import { useConnectorCredentialIndexingStatus, useUsers } from "@/lib/hooks";
import { useRouter } from "next/navigation";
const Page = ({ params }: { params: { groupId: string } }) => {
const router = useRouter();
const {
userGroup,
isLoading: userGroupIsLoading,
error: userGroupError,
refreshUserGroup,
} = useSpecificUserGroup(params.groupId);
const {
data: users,
isLoading: userIsLoading,
error: usersError,
} = useUsers();
const {
data: ccPairs,
isLoading: isCCPairsLoading,
error: ccPairsError,
} = useConnectorCredentialIndexingStatus();
if (userGroupIsLoading || userIsLoading || isCCPairsLoading) {
return (
<div className="h-full">
<div className="my-auto">
<ThreeDotsLoader />
</div>
</div>
);
}
if (!userGroup || userGroupError) {
return <div>Error loading user group</div>;
}
if (!users || usersError) {
return <div>Error loading users</div>;
}
if (!ccPairs || ccPairsError) {
return <div>Error loading connectors</div>;
}
return (
<div className="mx-auto container">
<div
className="my-auto flex mb-1 hover:bg-gray-700 w-fit pr-2 cursor-pointer rounded-lg"
onClick={() => router.back()}
>
<FiChevronLeft className="mr-1 my-auto" />
Back
</div>
<div className="border-solid border-gray-600 border-b pb-2 mb-4 flex">
<GroupsIcon size={32} />
<h1 className="text-3xl font-bold pl-2">
{userGroup ? userGroup.name : <FiAlertCircle />}
</h1>
</div>
{userGroup ? (
<GroupDisplay
users={users}
ccPairs={ccPairs}
userGroup={userGroup}
refreshUserGroup={refreshUserGroup}
/>
) : (
<div>Unable to fetch User Group :(</div>
)}
</div>
);
};
export default Page;

View File

@ -0,0 +1,14 @@
import useSWR, { mutate } from "swr";
import { UserGroup } from "./types";
import { errorHandlingFetcher } from "@/lib/fetcher";
const USER_GROUP_URL = "/api/manage/admin/user-group";
export const useUserGroups = () => {
const swrResponse = useSWR<UserGroup[]>(USER_GROUP_URL, errorHandlingFetcher);
return {
...swrResponse,
refreshUserGroups: () => mutate(USER_GROUP_URL),
};
};

View File

@ -0,0 +1,17 @@
import { UserGroupCreation } from "./types";
export const createUserGroup = async (userGroup: UserGroupCreation) => {
return fetch("/api/manage/admin/user-group", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(userGroup),
});
};
export const deleteUserGroup = async (userGroupId: number) => {
return fetch(`/api/manage/admin/user-group/${userGroupId}`, {
method: "DELETE",
});
};

View File

@ -0,0 +1,86 @@
"use client";
import { GroupsIcon } from "@/components/icons/icons";
import { UserGroupsTable } from "./UserGroupsTable";
import { UserGroupCreationForm } from "./UserGroupCreationForm";
import { usePopup } from "@/components/admin/connectors/Popup";
import { useState } from "react";
import { Button } from "@/components/Button";
import { ThreeDotsLoader } from "@/components/Loading";
import { useConnectorCredentialIndexingStatus, useUsers } from "@/lib/hooks";
import { useUserGroups } from "./hooks";
const Main = () => {
const { popup, setPopup } = usePopup();
const [showForm, setShowForm] = useState(false);
const { data, isLoading, error, refreshUserGroups } = useUserGroups();
const {
data: ccPairs,
isLoading: isCCPairsLoading,
error: ccPairsError,
} = useConnectorCredentialIndexingStatus();
const {
data: users,
isLoading: userIsLoading,
error: usersError,
} = useUsers();
if (isLoading || isCCPairsLoading || userIsLoading) {
return <ThreeDotsLoader />;
}
if (error || !data) {
return <div className="text-red-600">Error loading users</div>;
}
if (ccPairsError || !ccPairs) {
return <div className="text-red-600">Error loading connectors</div>;
}
if (usersError || !users) {
return <div className="text-red-600">Error loading users</div>;
}
return (
<>
{popup}
<div className="my-3">
<Button onClick={() => setShowForm(true)}>Create New User Group</Button>
</div>
<UserGroupsTable
userGroups={data}
setPopup={setPopup}
refresh={refreshUserGroups}
/>
{showForm && (
<UserGroupCreationForm
onClose={() => {
refreshUserGroups();
setShowForm(false);
}}
setPopup={setPopup}
users={users}
ccPairs={ccPairs}
/>
)}
</>
);
};
const Page = () => {
return (
<div className="mx-auto container">
<div className="border-solid border-gray-600 border-b pb-2 mb-4 flex">
<GroupsIcon size={32} />
<h1 className="text-3xl font-bold pl-2">Manage Users Groups</h1>
</div>
<Main />
</div>
);
};
export default Page;

View File

@ -0,0 +1,21 @@
import { CCPairDescriptor, User } from "@/lib/types";
export interface UserGroupUpdate {
user_ids: string[];
cc_pair_ids: number[];
}
export interface UserGroup {
id: number;
name: string;
users: User[];
cc_pairs: CCPairDescriptor<any, any>[];
is_up_to_date: boolean;
is_up_for_deletion: boolean;
}
export interface UserGroupCreation {
name: string;
user_ids: string[];
cc_pair_ids: number[];
}

View File

@ -0,0 +1,11 @@
/* Duplicate of `/app/admin/layout.tsx */
import { Layout } from "@/components/admin/Layout";
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return await Layout({ children });
}

19
web/src/app/ee/layout.tsx Normal file
View File

@ -0,0 +1,19 @@
import { EE_ENABLED } from "@/lib/constants";
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
if (!EE_ENABLED) {
return (
<div className="flex h-screen">
<div className="mx-auto my-auto text-lg font-bold text-red-500">
This funcitonality is only available in the Enterprise Edition :(
</div>
</div>
);
}
return children;
}

View File

@ -8,6 +8,7 @@ import {
ZoomInIcon,
RobotIcon,
ConnectorIcon,
GroupsIcon,
} from "@/components/icons/icons";
import { User } from "@/lib/types";
import {
@ -15,6 +16,7 @@ import {
getAuthTypeMetadataSS,
getCurrentUserSS,
} from "@/lib/userSS";
import { EE_ENABLED } from "@/lib/constants";
import { redirect } from "next/navigation";
import { FiCpu, FiPackage, FiSettings, FiSlack, FiTool } from "react-icons/fi";
@ -179,6 +181,19 @@ export async function Layout({ children }: { children: React.ReactNode }) {
),
link: "/admin/users",
},
...(EE_ENABLED
? [
{
name: (
<div className="flex">
<GroupsIcon size={18} />
<div className="ml-1">Groups</div>
</div>
),
link: "/admin/groups",
},
]
: []),
],
},
{

View File

@ -10,10 +10,11 @@ import {
} from "@/lib/types";
import { deleteConnectorIfExistsAndIsUnlinked } from "@/lib/connector";
import { FormBodyBuilder, RequireAtLeastOne } from "./types";
import { TextFormField } from "./Field";
import { BooleanFormField, TextFormField } from "./Field";
import { createCredential, linkCredential } from "@/lib/credential";
import { useSWRConfig } from "swr";
import { Button } from "@tremor/react";
import { EE_ENABLED } from "@/lib/constants";
const BASE_CONNECTOR_URL = "/api/manage/admin/connector";
@ -74,6 +75,7 @@ interface BaseProps<T extends Yup.AnyObject> {
// If specified, then we will create an empty credential and associate
// the connector with it. If credentialId is specified, then this will be ignored
shouldCreateEmptyCredentialForConnector?: boolean;
showNonPublicOption?: boolean;
}
type ConnectorFormProps<T extends Yup.AnyObject> = RequireAtLeastOne<
@ -95,26 +97,44 @@ export function ConnectorForm<T extends Yup.AnyObject>({
pruneFreq,
onSubmit,
shouldCreateEmptyCredentialForConnector,
// only show this option for EE, since groups are not supported in CE
showNonPublicOption = EE_ENABLED,
}: ConnectorFormProps<T>): JSX.Element {
const { mutate } = useSWRConfig();
const { popup, setPopup } = usePopup();
const shouldHaveNameInput = credentialId !== undefined && !ccPairNameBuilder;
const ccPairNameInitialValue = shouldHaveNameInput
? { cc_pair_name: "" }
: {};
const publicOptionInitialValue = showNonPublicOption
? { is_public: false }
: {};
let finalValidationSchema =
validationSchema as Yup.ObjectSchema<Yup.AnyObject>;
if (shouldHaveNameInput) {
finalValidationSchema = finalValidationSchema.concat(CCPairNameHaver);
}
if (showNonPublicOption) {
finalValidationSchema = finalValidationSchema.concat(
Yup.object().shape({
is_public: Yup.boolean(),
})
);
}
return (
<>
{popup}
<Formik
initialValues={
shouldHaveNameInput
? { cc_pair_name: "", ...initialValues }
: initialValues
}
validationSchema={
shouldHaveNameInput
? validationSchema.concat(CCPairNameHaver)
: validationSchema
}
initialValues={{
...publicOptionInitialValue,
...ccPairNameInitialValue,
...initialValues,
}}
validationSchema={finalValidationSchema}
onSubmit={async (values, formikHelpers) => {
formikHelpers.setSubmitting(true);
const connectorName = nameBuilder(values);
@ -185,7 +205,8 @@ export function ConnectorForm<T extends Yup.AnyObject>({
const linkCredentialResponse = await linkCredential(
response.id,
credentialIdToLinkTo,
ccPairName
ccPairName as string,
values.is_public
);
if (!linkCredentialResponse.ok) {
const linkCredentialErrorMsg =
@ -222,6 +243,22 @@ export function ConnectorForm<T extends Yup.AnyObject>({
)}
{formBody && formBody}
{formBodyBuilder && formBodyBuilder(values)}
{showNonPublicOption && (
<>
<div className="border-t border-gray-600 py-2" />
<BooleanFormField
name="is_public"
label="Documents are Public?"
subtext={
"If set, then documents indexed by this connector will be " +
"visible to all users. If turned off, then only users who explicitly " +
"have been given access to the documents (e.g. through a User Group) " +
"will have access"
}
/>
<div className="border-t border-gray-600 py-2" />
</>
)}
<div className="flex">
<Button
type="submit"

View File

@ -1,5 +1,4 @@
import { ConnectorIndexingStatus, Credential } from "@/lib/types";
import { BasicTable } from "@/components/admin/connectors/BasicTable";
import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup";
import { useState } from "react";
import { LinkBreakIcon, LinkIcon } from "@/components/icons/icons";
@ -14,6 +13,7 @@ import {
TableBody,
TableCell,
} from "@tremor/react";
import { FiCheck, FiXCircle } from "react-icons/fi";
interface StatusRowProps<ConnectorConfigType, ConnectorCredentialType> {
connectorIndexingStatus: ConnectorIndexingStatus<
@ -141,6 +141,10 @@ export function ConnectorsTable<ConnectorConfigType, ConnectorCredentialType>({
header: "Status",
key: "status",
},
{
header: "Is Public",
key: "is_public",
},
];
if (connectorIncludesCredential) {
columns.push({
@ -165,6 +169,7 @@ export function ConnectorsTable<ConnectorConfigType, ConnectorCredentialType>({
<TableHeaderCell key={header}>{header}</TableHeaderCell>
))}
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Is Public</TableHeaderCell>
{connectorIncludesCredential && (
<TableHeaderCell>Credential</TableHeaderCell>
)}
@ -217,6 +222,13 @@ export function ConnectorsTable<ConnectorConfigType, ConnectorCredentialType>({
onUpdate={onUpdate}
/>
</TableCell>
<TableCell>
{connectorIndexingStatus.public_doc ? (
<FiCheck className="my-auto text-success" size="18" />
) : (
<FiXCircle className="my-auto text-error" />
)}
</TableCell>
{connectorIncludesCredential && (
<TableCell>{credentialDisplay}</TableCell>
)}

View File

@ -11,7 +11,6 @@ import {
Brain,
X,
Question,
Users,
Gear,
ArrowSquareOut,
} from "@phosphor-icons/react";
@ -36,6 +35,7 @@ import {
FiCpu,
FiInfo,
FiUploadCloud,
FiUser,
FiUsers,
} from "react-icons/fi";
import { SiBookstack } from "react-icons/si";
@ -94,7 +94,7 @@ export const UsersIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <Users size={size} className={className} />;
return <FiUser size={size} className={className} />;
};
export const GroupsIcon = ({

View File

@ -31,7 +31,8 @@ export async function deleteCredential<T>(credentialId: number) {
export function linkCredential(
connectorId: number,
credentialId: number,
name?: string
name?: string,
isPublic?: boolean
) {
return fetch(
`/api/manage/connector/${connectorId}/credential/${credentialId}`,
@ -40,7 +41,10 @@ export function linkCredential(
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name: name || null }),
body: JSON.stringify({
name: name || null,
is_public: isPublic !== undefined ? isPublic : true, // default to public
}),
}
);
}