Skip to content

Commit

Permalink
Merge pull request #3357 from onyx-dot-app/anonymous_users
Browse files Browse the repository at this point in the history
Anonymous users
  • Loading branch information
pablonyx authored Dec 21, 2024
2 parents 7933f65 + 9f6eebc commit ab61f9b
Show file tree
Hide file tree
Showing 42 changed files with 578 additions and 325 deletions.
7 changes: 5 additions & 2 deletions backend/onyx/auth/noauth_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,16 @@ def load_no_auth_user_preferences(store: KeyValueStore) -> UserPreferences:
)


def fetch_no_auth_user(store: KeyValueStore) -> UserInfo:
def fetch_no_auth_user(
store: KeyValueStore, *, anonymous_user_enabled: bool | None = None
) -> UserInfo:
return UserInfo(
id=NO_AUTH_USER_ID,
email=NO_AUTH_USER_EMAIL,
is_active=True,
is_superuser=False,
is_verified=True,
role=UserRole.ADMIN,
role=UserRole.BASIC if anonymous_user_enabled else UserRole.ADMIN,
preferences=load_no_auth_user_preferences(store),
is_anonymous_user=anonymous_user_enabled,
)
72 changes: 53 additions & 19 deletions backend/onyx/auth/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
from onyx.configs.constants import DANSWER_API_KEY_DUMMY_EMAIL_DOMAIN
from onyx.configs.constants import DANSWER_API_KEY_PREFIX
from onyx.configs.constants import MilestoneRecordType
from onyx.configs.constants import OnyxRedisLocks
from onyx.configs.constants import PASSWORD_SPECIAL_CHARS
from onyx.configs.constants import UNNAMED_KEY_PLACEHOLDER
from onyx.db.api_key import fetch_user_for_api_key
Expand All @@ -84,7 +85,7 @@
from onyx.db.models import OAuthAccount
from onyx.db.models import User
from onyx.db.users import get_user_by_email
from onyx.server.utils import BasicAuthenticationError
from onyx.redis.redis_pool import get_redis_client
from onyx.utils.logger import setup_logger
from onyx.utils.telemetry import create_milestone_and_report
from onyx.utils.telemetry import optional_telemetry
Expand All @@ -98,6 +99,11 @@
logger = setup_logger()


class BasicAuthenticationError(HTTPException):
def __init__(self, detail: str):
super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail)


def is_user_admin(user: User | None) -> bool:
if AUTH_TYPE == AuthType.DISABLED:
return True
Expand Down Expand Up @@ -138,6 +144,20 @@ def user_needs_to_be_verified() -> bool:
return False


def anonymous_user_enabled() -> bool:
if MULTI_TENANT:
return False

redis_client = get_redis_client(tenant_id=None)
value = redis_client.get(OnyxRedisLocks.ANONYMOUS_USER_ENABLED)

if value is None:
return False

assert isinstance(value, bytes)
return int(value.decode("utf-8")) == 1


def verify_email_is_invited(email: str) -> None:
whitelist = get_invited_users()
if not whitelist:
Expand Down Expand Up @@ -690,30 +710,36 @@ async def double_check_user(
user: User | None,
optional: bool = DISABLE_AUTH,
include_expired: bool = False,
allow_anonymous_access: bool = False,
) -> User | None:
if optional:
return None
return user

if user is None:
raise BasicAuthenticationError(
detail="Access denied. User is not authenticated.",
)
if user is not None:
# If user attempted to authenticate, verify them, do not default
# to anonymous access if it fails.
if user_needs_to_be_verified() and not user.is_verified:
raise BasicAuthenticationError(
detail="Access denied. User is not verified.",
)

if user_needs_to_be_verified() and not user.is_verified:
raise BasicAuthenticationError(
detail="Access denied. User is not verified.",
)
if (
user.oidc_expiry
and user.oidc_expiry < datetime.now(timezone.utc)
and not include_expired
):
raise BasicAuthenticationError(
detail="Access denied. User's OIDC token has expired.",
)

if (
user.oidc_expiry
and user.oidc_expiry < datetime.now(timezone.utc)
and not include_expired
):
raise BasicAuthenticationError(
detail="Access denied. User's OIDC token has expired.",
)
return user

return user
if allow_anonymous_access:
return None

raise BasicAuthenticationError(
detail="Access denied. User is not authenticated.",
)


async def current_user_with_expired_token(
Expand All @@ -728,6 +754,14 @@ async def current_limited_user(
return await double_check_user(user)


async def current_chat_accesssible_user(
user: User | None = Depends(optional_user),
) -> User | None:
return await double_check_user(
user, allow_anonymous_access=anonymous_user_enabled()
)


async def current_user(
user: User | None = Depends(optional_user),
) -> User | None:
Expand Down
1 change: 1 addition & 0 deletions backend/onyx/configs/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ class OnyxRedisLocks:

SLACK_BOT_LOCK = "da_lock:slack_bot"
SLACK_BOT_HEARTBEAT_PREFIX = "da_heartbeat:slack_bot"
ANONYMOUS_USER_ENABLED = "anonymous_user_enabled"


class OnyxRedisSignals:
Expand Down
2 changes: 2 additions & 0 deletions backend/onyx/server/auth_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from starlette.routing import BaseRoute

from onyx.auth.users import current_admin_user
from onyx.auth.users import current_chat_accesssible_user
from onyx.auth.users import current_curator_or_admin_user
from onyx.auth.users import current_limited_user
from onyx.auth.users import current_user
Expand Down Expand Up @@ -109,6 +110,7 @@ def check_router_auth(
or depends_fn == current_curator_or_admin_user
or depends_fn == api_key_dep
or depends_fn == current_user_with_expired_token
or depends_fn == current_chat_accesssible_user
or depends_fn == control_plane_dep
or depends_fn == current_cloud_superuser
):
Expand Down
3 changes: 2 additions & 1 deletion backend/onyx/server/documents/connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from sqlalchemy.orm import Session

from onyx.auth.users import current_admin_user
from onyx.auth.users import current_chat_accesssible_user
from onyx.auth.users import current_curator_or_admin_user
from onyx.auth.users import current_user
from onyx.background.celery.celery_utils import get_deletion_attempt_snapshot
Expand Down Expand Up @@ -1055,7 +1056,7 @@ class BasicCCPairInfo(BaseModel):

@router.get("/connector-status")
def get_basic_connector_indexing_status(
_: User = Depends(current_user),
_: User = Depends(current_chat_accesssible_user),
db_session: Session = Depends(get_session),
) -> list[BasicCCPairInfo]:
cc_pairs = get_connector_credential_pairs(db_session)
Expand Down
3 changes: 2 additions & 1 deletion backend/onyx/server/features/persona/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from sqlalchemy.orm import Session

from onyx.auth.users import current_admin_user
from onyx.auth.users import current_chat_accesssible_user
from onyx.auth.users import current_curator_or_admin_user
from onyx.auth.users import current_limited_user
from onyx.auth.users import current_user
Expand Down Expand Up @@ -323,7 +324,7 @@ def get_image_generation_tool(

@basic_router.get("")
def list_personas(
user: User | None = Depends(current_user),
user: User | None = Depends(current_chat_accesssible_user),
db_session: Session = Depends(get_session),
include_deleted: bool = False,
persona_ids: list[int] = Query(None),
Expand Down
5 changes: 4 additions & 1 deletion backend/onyx/server/manage/get_state.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from fastapi import APIRouter

from onyx import __version__
from onyx.auth.users import anonymous_user_enabled
from onyx.auth.users import user_needs_to_be_verified
from onyx.configs.app_configs import AUTH_TYPE
from onyx.server.manage.models import AuthTypeResponse
Expand All @@ -18,7 +19,9 @@ def healthcheck() -> StatusResponse:
@router.get("/auth/type")
def get_auth_type() -> AuthTypeResponse:
return AuthTypeResponse(
auth_type=AUTH_TYPE, requires_verification=user_needs_to_be_verified()
auth_type=AUTH_TYPE,
requires_verification=user_needs_to_be_verified(),
anonymous_user_enabled=anonymous_user_enabled(),
)


Expand Down
4 changes: 2 additions & 2 deletions backend/onyx/server/manage/llm/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from sqlalchemy.orm import Session

from onyx.auth.users import current_admin_user
from onyx.auth.users import current_user
from onyx.auth.users import current_chat_accesssible_user
from onyx.db.engine import get_session
from onyx.db.llm import fetch_existing_llm_providers
from onyx.db.llm import fetch_provider
Expand Down Expand Up @@ -190,7 +190,7 @@ def set_provider_as_default(

@basic_router.get("/provider")
def list_llm_provider_basics(
user: User | None = Depends(current_user),
user: User | None = Depends(current_chat_accesssible_user),
db_session: Session = Depends(get_session),
) -> list[LLMProviderDescriptor]:
return [
Expand Down
4 changes: 4 additions & 0 deletions backend/onyx/server/manage/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class AuthTypeResponse(BaseModel):
# specifies whether the current auth setup requires
# users to have verified emails
requires_verification: bool
anonymous_user_enabled: bool | None = None


class UserPreferences(BaseModel):
Expand All @@ -61,6 +62,7 @@ class UserInfo(BaseModel):
current_token_expiry_length: int | None = None
is_cloud_superuser: bool = False
organization_name: str | None = None
is_anonymous_user: bool | None = None

@classmethod
def from_model(
Expand All @@ -70,6 +72,7 @@ def from_model(
expiry_length: int | None = None,
is_cloud_superuser: bool = False,
organization_name: str | None = None,
is_anonymous_user: bool | None = None,
) -> "UserInfo":
return cls(
id=str(user.id),
Expand All @@ -96,6 +99,7 @@ def from_model(
current_token_created_at=current_token_created_at,
current_token_expiry_length=expiry_length,
is_cloud_superuser=is_cloud_superuser,
is_anonymous_user=is_anonymous_user,
)


Expand Down
5 changes: 4 additions & 1 deletion backend/onyx/server/manage/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from onyx.auth.noauth_user import set_no_auth_user_preferences
from onyx.auth.schemas import UserRole
from onyx.auth.schemas import UserStatus
from onyx.auth.users import anonymous_user_enabled
from onyx.auth.users import current_admin_user
from onyx.auth.users import current_curator_or_admin_user
from onyx.auth.users import current_user
Expand Down Expand Up @@ -522,13 +523,15 @@ def verify_user_logged_in(
# NOTE: this does not use `current_user` / `current_admin_user` because we don't want
# to enforce user verification here - the frontend always wants to get the info about
# the current user regardless of if they are currently verified

if user is None:
# if auth type is disabled, return a dummy user with preferences from
# the key-value store
if AUTH_TYPE == AuthType.DISABLED:
store = get_kv_store()
return fetch_no_auth_user(store)
if anonymous_user_enabled():
store = get_kv_store()
return fetch_no_auth_user(store, anonymous_user_enabled=True)

raise BasicAuthenticationError(detail="User Not Authenticated")
if user.oidc_expiry and user.oidc_expiry < datetime.now(timezone.utc):
Expand Down
7 changes: 4 additions & 3 deletions backend/onyx/server/query_and_chat/chat_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from pydantic import BaseModel
from sqlalchemy.orm import Session

from onyx.auth.users import current_chat_accesssible_user
from onyx.auth.users import current_limited_user
from onyx.auth.users import current_user
from onyx.chat.chat_utils import create_chat_chain
Expand Down Expand Up @@ -145,7 +146,7 @@ def update_chat_session_model(
def get_chat_session(
session_id: UUID,
is_shared: bool = False,
user: User | None = Depends(current_user),
user: User | None = Depends(current_chat_accesssible_user),
db_session: Session = Depends(get_session),
) -> ChatSessionDetailResponse:
user_id = user.id if user is not None else None
Expand Down Expand Up @@ -197,7 +198,7 @@ def get_chat_session(
@router.post("/create-chat-session")
def create_new_chat_session(
chat_session_creation_request: ChatSessionCreationRequest,
user: User | None = Depends(current_user),
user: User | None = Depends(current_chat_accesssible_user),
db_session: Session = Depends(get_session),
) -> CreateChatSessionID:
user_id = user.id if user is not None else None
Expand Down Expand Up @@ -330,7 +331,7 @@ def is_connected_sync() -> bool:
def handle_new_chat_message(
chat_message_req: CreateChatMessageRequest,
request: Request,
user: User | None = Depends(current_limited_user),
user: User | None = Depends(current_chat_accesssible_user),
_rate_limit_check: None = Depends(check_token_rate_limits),
is_connected_func: Callable[[], bool] = Depends(is_connected),
tenant_id: str = Depends(get_current_tenant_id),
Expand Down
4 changes: 2 additions & 2 deletions backend/onyx/server/query_and_chat/token_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from sqlalchemy import select
from sqlalchemy.orm import Session

from onyx.auth.users import current_user
from onyx.auth.users import current_chat_accesssible_user
from onyx.db.engine import get_session_context_manager
from onyx.db.engine import get_session_with_tenant
from onyx.db.models import ChatMessage
Expand All @@ -31,7 +31,7 @@


def check_token_rate_limits(
user: User | None = Depends(current_user),
user: User | None = Depends(current_chat_accesssible_user),
) -> None:
# short circuit if no rate limits are set up
# NOTE: result of `any_rate_limit_exists` is cached, so this call is fast 99% of the time
Expand Down
1 change: 1 addition & 0 deletions backend/onyx/server/settings/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class Settings(BaseModel):
maximum_chat_retention_days: int | None = None
gpu_enabled: bool | None = None
product_gating: GatingType = GatingType.NONE
anonymous_user_enabled: bool | None = None


class UserSettings(Settings):
Expand Down
35 changes: 26 additions & 9 deletions backend/onyx/server/settings/store.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,38 @@
from typing import cast

from onyx.configs.constants import KV_SETTINGS_KEY
from onyx.configs.constants import OnyxRedisLocks
from onyx.key_value_store.factory import get_kv_store
from onyx.key_value_store.interface import KvKeyNotFoundError
from onyx.redis.redis_pool import get_redis_client
from onyx.server.settings.models import Settings
from shared_configs.configs import MULTI_TENANT


def load_settings() -> Settings:
dynamic_config_store = get_kv_store()
try:
settings = Settings(**cast(dict, dynamic_config_store.load(KV_SETTINGS_KEY)))
except KvKeyNotFoundError:
settings = Settings()
dynamic_config_store.store(KV_SETTINGS_KEY, settings.model_dump())
if MULTI_TENANT:
# If multi-tenant, anonymous user is always false
anonymous_user_enabled = False
else:
redis_client = get_redis_client(tenant_id=None)
value = redis_client.get(OnyxRedisLocks.ANONYMOUS_USER_ENABLED)
if value is not None:
assert isinstance(value, bytes)
anonymous_user_enabled = int(value.decode("utf-8")) == 1
else:
# Default to False
anonymous_user_enabled = False
# Optionally store the default back to Redis
redis_client.set(OnyxRedisLocks.ANONYMOUS_USER_ENABLED, "0")

settings = Settings(anonymous_user_enabled=anonymous_user_enabled)
return settings


def store_settings(settings: Settings) -> None:
if not MULTI_TENANT and settings.anonymous_user_enabled is not None:
# Only non-multi-tenant scenario can set the anonymous user enabled flag
redis_client = get_redis_client(tenant_id=None)
redis_client.set(
OnyxRedisLocks.ANONYMOUS_USER_ENABLED,
"1" if settings.anonymous_user_enabled else "0",
)

get_kv_store().store(KV_SETTINGS_KEY, settings.model_dump())
Loading

0 comments on commit ab61f9b

Please sign in to comment.