Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Anonymous users #3357

Merged
merged 6 commits into from
Dec 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading