mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-09-26 03:48:49 +02:00
Initial EE features (#3)
This commit is contained in:
@@ -30,8 +30,8 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
danswer/danswer-backend:${{ github.ref_name }}
|
danswer/danswer-ee-backend:${{ github.ref_name }}
|
||||||
danswer/danswer-backend:latest
|
danswer/danswer-ee-backend:latest
|
||||||
build-args: |
|
build-args: |
|
||||||
DANSWER_VERSION=${{ github.ref_name }}
|
DANSWER_VERSION=${{ github.ref_name }}
|
||||||
|
|
||||||
|
@@ -6,7 +6,7 @@ on:
|
|||||||
- '*'
|
- '*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY_IMAGE: danswer/danswer-web-server
|
REGISTRY_IMAGE: danswer/danswer-ee-web-server
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -34,8 +34,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY_IMAGE }}
|
images: ${{ env.REGISTRY_IMAGE }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=danswer/danswer-web-server:${{ github.ref_name }}
|
type=raw,value=danswer/danswer-ee-web-server:${{ github.ref_name }}
|
||||||
type=raw,value=danswer/danswer-web-server:latest
|
type=raw,value=danswer/danswer-ee-web-server:latest
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
@@ -56,6 +56,7 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
DANSWER_VERSION=${{ github.ref_name }}
|
DANSWER_VERSION=${{ github.ref_name }}
|
||||||
|
NEXT_PUBLIC_ENABLE_PAID_EE_FEATURES=true
|
||||||
# needed due to weird interactions with the builds for different platforms
|
# needed due to weird interactions with the builds for different platforms
|
||||||
no-cache: true
|
no-cache: true
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
4
.github/workflows/docker-tag-latest.yml
vendored
4
.github/workflows/docker-tag-latest.yml
vendored
@@ -25,8 +25,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Pull, Tag and Push Web Server Image
|
- name: Pull, Tag and Push Web Server Image
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create -t danswer/danswer-web-server:latest danswer/danswer-web-server:${{ github.event.inputs.version }}
|
docker buildx imagetools create -t danswer/danswer-ee-web-server:latest danswer/danswer-ee-web-server:${{ github.event.inputs.version }}
|
||||||
|
|
||||||
- name: Pull, Tag and Push API Server Image
|
- name: Pull, Tag and Push API Server Image
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools create -t danswer/danswer-backend:latest danswer/danswer-backend:${{ github.event.inputs.version }}
|
docker buildx imagetools create -t danswer/danswer-ee-backend:latest danswer/danswer-ee-backend:${{ github.event.inputs.version }}
|
||||||
|
2
.vscode/launch.template.jsonc
vendored
2
.vscode/launch.template.jsonc
vendored
@@ -51,7 +51,7 @@
|
|||||||
"PYTHONUNBUFFERED": "1"
|
"PYTHONUNBUFFERED": "1"
|
||||||
},
|
},
|
||||||
"args": [
|
"args": [
|
||||||
"danswer.main:app",
|
"ee.danswer.main:app",
|
||||||
"--reload",
|
"--reload",
|
||||||
"--port",
|
"--port",
|
||||||
"8080"
|
"8080"
|
||||||
|
@@ -152,13 +152,13 @@ python ./scripts/dev_run_background_jobs.py
|
|||||||
|
|
||||||
To run the backend API server, navigate back to `danswer/backend` and run:
|
To run the backend API server, navigate back to `danswer/backend` and run:
|
||||||
```bash
|
```bash
|
||||||
AUTH_TYPE=disabled uvicorn danswer.main:app --reload --port 8080
|
AUTH_TYPE=disabled uvicorn ee.danswer.main:app --reload --port 8080
|
||||||
```
|
```
|
||||||
_For Windows (for compatibility with both PowerShell and Command Prompt):_
|
_For Windows (for compatibility with both PowerShell and Command Prompt):_
|
||||||
```bash
|
```bash
|
||||||
powershell -Command "
|
powershell -Command "
|
||||||
$env:AUTH_TYPE='disabled'
|
$env:AUTH_TYPE='disabled'
|
||||||
uvicorn danswer.main:app --reload --port 8080
|
uvicorn ee.danswer.main:app --reload --port 8080
|
||||||
"
|
"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
8
LICENSE
8
LICENSE
@@ -1,6 +1,10 @@
|
|||||||
MIT License
|
Copyright (c) 2023 DanswerAI, Inc.
|
||||||
|
|
||||||
Copyright (c) 2023 Yuhong Sun, Chris Weaver
|
Portions of this software are licensed as follows:
|
||||||
|
|
||||||
|
* All content that resides under "ee" directories of this repository, if that directory exists, is licensed under the license defined in "backend/ee/LICENSE".
|
||||||
|
* All third party components incorporated into the Danswer Software are licensed under the original license provided by the owner of the applicable component.
|
||||||
|
* Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below.
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
@@ -42,6 +42,11 @@ RUN apt-get remove -y --allow-remove-essential perl-base xserver-common xvfb cma
|
|||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
rm /usr/local/lib/python3.11/site-packages/tornado/test/test.key
|
rm /usr/local/lib/python3.11/site-packages/tornado/test/test.key
|
||||||
|
|
||||||
|
# Enterprise Install
|
||||||
|
RUN apt-get update && apt-get install -y libxmlsec1-dev
|
||||||
|
COPY ./requirements/ee.txt /tmp/ee-requirements.txt
|
||||||
|
RUN pip install --no-cache-dir --upgrade -r /tmp/ee-requirements.txt
|
||||||
|
|
||||||
# Pre-downloading models for setups with limited egress
|
# Pre-downloading models for setups with limited egress
|
||||||
RUN python -c "from transformers import AutoTokenizer; AutoTokenizer.from_pretrained('intfloat/e5-base-v2')"
|
RUN python -c "from transformers import AutoTokenizer; AutoTokenizer.from_pretrained('intfloat/e5-base-v2')"
|
||||||
|
|
||||||
@@ -53,6 +58,11 @@ nltk.download('punkt', quiet=True);"
|
|||||||
|
|
||||||
# Set up application files
|
# Set up application files
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Enterprise Version Files
|
||||||
|
COPY ./ee /app/ee
|
||||||
|
|
||||||
|
# Set up application files
|
||||||
COPY ./danswer /app/danswer
|
COPY ./danswer /app/danswer
|
||||||
COPY ./shared_configs /app/shared_configs
|
COPY ./shared_configs /app/shared_configs
|
||||||
COPY ./alembic /app/alembic
|
COPY ./alembic /app/alembic
|
||||||
|
36
backend/ee/LICENSE
Normal file
36
backend/ee/LICENSE
Normal 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.
|
0
backend/ee/__init__.py
Normal file
0
backend/ee/__init__.py
Normal file
0
backend/ee/danswer/__init__.py
Normal file
0
backend/ee/danswer/__init__.py
Normal file
0
backend/ee/danswer/auth/__init__.py
Normal file
0
backend/ee/danswer/auth/__init__.py
Normal file
45
backend/ee/danswer/auth/users.py
Normal file
45
backend/ee/danswer/auth/users.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi import status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from danswer.configs.app_configs import AUTH_TYPE
|
||||||
|
from danswer.configs.app_configs import DISABLE_AUTH
|
||||||
|
from danswer.configs.constants import AuthType
|
||||||
|
from danswer.db.models import User
|
||||||
|
from danswer.utils.logger import setup_logger
|
||||||
|
from ee.danswer.db.saml import get_saml_account
|
||||||
|
from ee.danswer.utils.secrets import extract_hashed_cookie
|
||||||
|
|
||||||
|
logger = setup_logger()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_auth_setting() -> None:
|
||||||
|
# All the Auth flows are valid for EE version
|
||||||
|
logger.info(f"Using Auth Type: {AUTH_TYPE.value}")
|
||||||
|
|
||||||
|
|
||||||
|
async def double_check_user(
|
||||||
|
request: Request,
|
||||||
|
user: User | None,
|
||||||
|
db_session: Session,
|
||||||
|
optional: bool = DISABLE_AUTH,
|
||||||
|
) -> User | None:
|
||||||
|
if optional:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if the user has a session cookie from SAML
|
||||||
|
if AUTH_TYPE == AuthType.SAML:
|
||||||
|
saved_cookie = extract_hashed_cookie(request)
|
||||||
|
|
||||||
|
if saved_cookie:
|
||||||
|
saml_account = get_saml_account(cookie=saved_cookie, db_session=db_session)
|
||||||
|
user = saml_account.user if saml_account else None
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Access denied. User is not authenticated.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
0
backend/ee/danswer/configs/__init__.py
Normal file
0
backend/ee/danswer/configs/__init__.py
Normal file
10
backend/ee/danswer/configs/app_configs.py
Normal file
10
backend/ee/danswer/configs/app_configs.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
# Applicable for OIDC Auth
|
||||||
|
OPENID_CONFIG_URL = os.environ.get("OPENID_CONFIG_URL", "")
|
||||||
|
|
||||||
|
# Applicable for SAML Auth
|
||||||
|
SAML_CONF_DIR = (
|
||||||
|
os.environ.get("SAML_CONF_DIR")
|
||||||
|
or "/app/danswer/backend/ee/danswer/configs/saml_config"
|
||||||
|
)
|
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"strict": true,
|
||||||
|
"debug": false,
|
||||||
|
"idp": {
|
||||||
|
"entityId": "<Provide This from IDP>",
|
||||||
|
"singleSignOnService": {
|
||||||
|
"url": "<Replace this with your IDP URL> https://trial-1234567.okta.com/home/trial-1234567_danswer/somevalues/somevalues",
|
||||||
|
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
||||||
|
},
|
||||||
|
"x509cert": "<Provide this>"
|
||||||
|
},
|
||||||
|
"sp": {
|
||||||
|
"entityId": "<Provide This from IDP>",
|
||||||
|
"assertionConsumerService": {
|
||||||
|
"url": "http://127.0.0.1:3000/auth/saml/callback",
|
||||||
|
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||||
|
},
|
||||||
|
"x509cert": "<Provide this>"
|
||||||
|
}
|
||||||
|
}
|
0
backend/ee/danswer/db/__init__.py
Normal file
0
backend/ee/danswer/db/__init__.py
Normal file
26
backend/ee/danswer/db/models.py
Normal file
26
backend/ee/danswer/db/models.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime
|
||||||
|
from sqlalchemy import ForeignKey
|
||||||
|
from sqlalchemy import func
|
||||||
|
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 User
|
||||||
|
|
||||||
|
|
||||||
|
class SamlAccount(Base):
|
||||||
|
__tablename__ = "saml"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), unique=True)
|
||||||
|
encrypted_cookie: Mapped[str] = mapped_column(Text, unique=True)
|
||||||
|
expires_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship("User")
|
65
backend/ee/danswer/db/saml.py
Normal file
65
backend/ee/danswer/db/saml.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import datetime
|
||||||
|
from typing import cast
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import and_
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from danswer.configs.app_configs import SESSION_EXPIRE_TIME_SECONDS
|
||||||
|
from danswer.db.models import User
|
||||||
|
from ee.danswer.db.models import SamlAccount
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_saml_account(
|
||||||
|
user_id: UUID,
|
||||||
|
cookie: str,
|
||||||
|
db_session: Session,
|
||||||
|
expiration_offset: int = SESSION_EXPIRE_TIME_SECONDS,
|
||||||
|
) -> datetime.datetime:
|
||||||
|
expires_at = func.now() + datetime.timedelta(seconds=expiration_offset)
|
||||||
|
|
||||||
|
existing_saml_acc = (
|
||||||
|
db_session.query(SamlAccount)
|
||||||
|
.filter(SamlAccount.user_id == user_id)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_saml_acc:
|
||||||
|
existing_saml_acc.encrypted_cookie = cookie
|
||||||
|
existing_saml_acc.expires_at = cast(datetime.datetime, expires_at)
|
||||||
|
existing_saml_acc.updated_at = func.now()
|
||||||
|
saml_acc = existing_saml_acc
|
||||||
|
else:
|
||||||
|
saml_acc = SamlAccount(
|
||||||
|
user_id=user_id,
|
||||||
|
encrypted_cookie=cookie,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
db_session.add(saml_acc)
|
||||||
|
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
return saml_acc.expires_at
|
||||||
|
|
||||||
|
|
||||||
|
def get_saml_account(cookie: str, db_session: Session) -> SamlAccount | None:
|
||||||
|
stmt = (
|
||||||
|
select(SamlAccount)
|
||||||
|
.join(User, User.id == SamlAccount.user_id) # type: ignore
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
SamlAccount.encrypted_cookie == cookie,
|
||||||
|
SamlAccount.expires_at > func.now(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = db_session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def expire_saml_account(saml_account: SamlAccount, db_session: Session) -> None:
|
||||||
|
saml_account.expires_at = func.now()
|
||||||
|
db_session.commit()
|
64
backend/ee/danswer/main.py
Normal file
64
backend/ee/danswer/main.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from httpx_oauth.clients.openid import OpenID
|
||||||
|
|
||||||
|
from danswer.auth.users import auth_backend
|
||||||
|
from danswer.auth.users import fastapi_users
|
||||||
|
from danswer.configs.app_configs import APP_HOST
|
||||||
|
from danswer.configs.app_configs import APP_PORT
|
||||||
|
from danswer.configs.app_configs import AUTH_TYPE
|
||||||
|
from danswer.configs.app_configs import OAUTH_CLIENT_ID
|
||||||
|
from danswer.configs.app_configs import OAUTH_CLIENT_SECRET
|
||||||
|
from danswer.configs.app_configs import SECRET
|
||||||
|
from danswer.configs.app_configs import WEB_DOMAIN
|
||||||
|
from danswer.configs.constants import AuthType
|
||||||
|
from danswer.main import get_application
|
||||||
|
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
|
||||||
|
|
||||||
|
logger = setup_logger()
|
||||||
|
|
||||||
|
|
||||||
|
def get_ee_application() -> FastAPI:
|
||||||
|
# Anything that happens at import time is not guaranteed to be running ee-version
|
||||||
|
# Anything after the server startup will be running ee version
|
||||||
|
global_version.set_ee()
|
||||||
|
|
||||||
|
application = get_application()
|
||||||
|
|
||||||
|
if AUTH_TYPE == AuthType.OIDC:
|
||||||
|
application.include_router(
|
||||||
|
fastapi_users.get_oauth_router(
|
||||||
|
OpenID(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OPENID_CONFIG_URL),
|
||||||
|
auth_backend,
|
||||||
|
SECRET,
|
||||||
|
associate_by_email=True,
|
||||||
|
is_verified_by_default=True,
|
||||||
|
redirect_url=f"{WEB_DOMAIN}/auth/oidc/callback",
|
||||||
|
),
|
||||||
|
prefix="/auth/oidc",
|
||||||
|
tags=["auth"],
|
||||||
|
)
|
||||||
|
# need basic auth router for `logout` endpoint
|
||||||
|
application.include_router(
|
||||||
|
fastapi_users.get_auth_router(auth_backend),
|
||||||
|
prefix="/auth",
|
||||||
|
tags=["auth"],
|
||||||
|
)
|
||||||
|
|
||||||
|
elif AUTH_TYPE == AuthType.SAML:
|
||||||
|
application.include_router(saml_router)
|
||||||
|
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
app = get_ee_application()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logger.info(
|
||||||
|
f"Running Enterprise Danswer API Service on http://{APP_HOST}:{str(APP_PORT)}/"
|
||||||
|
)
|
||||||
|
uvicorn.run(app, host=APP_HOST, port=APP_PORT)
|
0
backend/ee/danswer/server/__init__.py
Normal file
0
backend/ee/danswer/server/__init__.py
Normal file
177
backend/ee/danswer/server/saml.py
Normal file
177
backend/ee/danswer/server/saml.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import contextlib
|
||||||
|
import secrets
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi import Response
|
||||||
|
from fastapi import status
|
||||||
|
from fastapi_users import exceptions
|
||||||
|
from fastapi_users.password import PasswordHelper
|
||||||
|
from onelogin.saml2.auth import OneLogin_Saml2_Auth # type: ignore
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from pydantic import EmailStr
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from danswer.auth.schemas import UserCreate
|
||||||
|
from danswer.auth.schemas import UserRole
|
||||||
|
from danswer.auth.users import get_user_manager
|
||||||
|
from danswer.configs.app_configs import SESSION_EXPIRE_TIME_SECONDS
|
||||||
|
from danswer.db.auth import get_user_count
|
||||||
|
from danswer.db.auth import get_user_db
|
||||||
|
from danswer.db.engine import get_async_session
|
||||||
|
from danswer.db.engine import get_session
|
||||||
|
from danswer.db.models import User
|
||||||
|
from danswer.utils.logger import setup_logger
|
||||||
|
from ee.danswer.configs.app_configs import SAML_CONF_DIR
|
||||||
|
from ee.danswer.db.saml import expire_saml_account
|
||||||
|
from ee.danswer.db.saml import get_saml_account
|
||||||
|
from ee.danswer.db.saml import upsert_saml_account
|
||||||
|
from ee.danswer.utils.secrets import encrypt_string
|
||||||
|
from ee.danswer.utils.secrets import extract_hashed_cookie
|
||||||
|
|
||||||
|
|
||||||
|
logger = setup_logger()
|
||||||
|
router = APIRouter(prefix="/auth/saml")
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_saml_user(email: str) -> User:
|
||||||
|
get_async_session_context = contextlib.asynccontextmanager(
|
||||||
|
get_async_session
|
||||||
|
) # type:ignore
|
||||||
|
get_user_db_context = contextlib.asynccontextmanager(get_user_db)
|
||||||
|
get_user_manager_context = contextlib.asynccontextmanager(get_user_manager)
|
||||||
|
|
||||||
|
async with get_async_session_context() as session:
|
||||||
|
async with get_user_db_context(session) as user_db:
|
||||||
|
async with get_user_manager_context(user_db) as user_manager:
|
||||||
|
try:
|
||||||
|
return await user_manager.get_by_email(email)
|
||||||
|
except exceptions.UserNotExists:
|
||||||
|
logger.info("Creating user from SAML login")
|
||||||
|
|
||||||
|
user_count = await get_user_count()
|
||||||
|
role = UserRole.ADMIN if user_count == 0 else UserRole.BASIC
|
||||||
|
|
||||||
|
fastapi_users_pw_helper = PasswordHelper()
|
||||||
|
password = fastapi_users_pw_helper.generate()
|
||||||
|
hashed_pass = fastapi_users_pw_helper.hash(password)
|
||||||
|
|
||||||
|
user: User = await user_manager.create(
|
||||||
|
UserCreate(
|
||||||
|
email=EmailStr(email),
|
||||||
|
password=hashed_pass,
|
||||||
|
is_verified=True,
|
||||||
|
role=role,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def prepare_from_fastapi_request(request: Request) -> dict[str, Any]:
|
||||||
|
form_data = await request.form()
|
||||||
|
if request.client is None:
|
||||||
|
raise ValueError("Invalid request for SAML")
|
||||||
|
|
||||||
|
rv: dict[str, Any] = {
|
||||||
|
"http_host": request.client.host,
|
||||||
|
"server_port": request.url.port,
|
||||||
|
"script_name": request.url.path,
|
||||||
|
"post_data": {},
|
||||||
|
"get_data": {},
|
||||||
|
}
|
||||||
|
if request.query_params:
|
||||||
|
rv["get_data"] = (request.query_params,)
|
||||||
|
if "SAMLResponse" in form_data:
|
||||||
|
SAMLResponse = form_data["SAMLResponse"]
|
||||||
|
rv["post_data"]["SAMLResponse"] = SAMLResponse
|
||||||
|
if "RelayState" in form_data:
|
||||||
|
RelayState = form_data["RelayState"]
|
||||||
|
rv["post_data"]["RelayState"] = RelayState
|
||||||
|
return rv
|
||||||
|
|
||||||
|
|
||||||
|
class SAMLAuthorizeResponse(BaseModel):
|
||||||
|
authorization_url: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/authorize")
|
||||||
|
async def saml_login(request: Request) -> SAMLAuthorizeResponse:
|
||||||
|
req = await prepare_from_fastapi_request(request)
|
||||||
|
auth = OneLogin_Saml2_Auth(req, custom_base_path=SAML_CONF_DIR)
|
||||||
|
callback_url = auth.login()
|
||||||
|
return SAMLAuthorizeResponse(authorization_url=callback_url)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/callback")
|
||||||
|
async def saml_login_callback(
|
||||||
|
request: Request,
|
||||||
|
db_session: Session = Depends(get_session),
|
||||||
|
) -> Response:
|
||||||
|
req = await prepare_from_fastapi_request(request)
|
||||||
|
auth = OneLogin_Saml2_Auth(req, custom_base_path=SAML_CONF_DIR)
|
||||||
|
auth.process_response()
|
||||||
|
errors = auth.get_errors()
|
||||||
|
if len(errors) != 0:
|
||||||
|
logger.error(
|
||||||
|
"Error when processing SAML Response: %s %s"
|
||||||
|
% (", ".join(errors), auth.get_last_error_reason())
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Access denied. Failed to parse SAML Response.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not auth.is_authenticated():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Access denied. User was not Authenticated.",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_email = auth.get_attribute("email")
|
||||||
|
if not user_email:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="SAML is not set up correctly, email attribute must be provided.",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_email = user_email[0]
|
||||||
|
|
||||||
|
user = await upsert_saml_user(email=user_email)
|
||||||
|
|
||||||
|
# Generate a random session cookie and Sha256 encrypt before saving
|
||||||
|
session_cookie = secrets.token_hex(16)
|
||||||
|
saved_cookie = encrypt_string(session_cookie)
|
||||||
|
|
||||||
|
upsert_saml_account(user_id=user.id, cookie=saved_cookie, db_session=db_session)
|
||||||
|
|
||||||
|
# Redirect to main Danswer search page
|
||||||
|
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
response.set_cookie(
|
||||||
|
key="session",
|
||||||
|
value=session_cookie,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
max_age=SESSION_EXPIRE_TIME_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def saml_logout(
|
||||||
|
request: Request,
|
||||||
|
db_session: Session = Depends(get_session),
|
||||||
|
) -> None:
|
||||||
|
saved_cookie = extract_hashed_cookie(request)
|
||||||
|
|
||||||
|
if saved_cookie:
|
||||||
|
saml_account = get_saml_account(cookie=saved_cookie, db_session=db_session)
|
||||||
|
if saml_account:
|
||||||
|
expire_saml_account(saml_account, db_session)
|
||||||
|
|
||||||
|
return
|
0
backend/ee/danswer/utils/__init__.py
Normal file
0
backend/ee/danswer/utils/__init__.py
Normal file
14
backend/ee/danswer/utils/secrets.py
Normal file
14
backend/ee/danswer/utils/secrets.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import hashlib
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from danswer.configs.constants import SESSION_KEY
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_string(s: str) -> str:
|
||||||
|
return hashlib.sha256(s.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_hashed_cookie(request: Request) -> str | None:
|
||||||
|
session_cookie = request.cookies.get(SESSION_KEY)
|
||||||
|
return encrypt_string(session_cookie) if session_cookie else None
|
1
backend/requirements/ee.txt
Normal file
1
backend/requirements/ee.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
python3-saml==1.15.0
|
@@ -1,14 +1,14 @@
|
|||||||
version: '3'
|
version: '3'
|
||||||
services:
|
services:
|
||||||
api_server:
|
api_server:
|
||||||
image: danswer/danswer-backend:latest
|
image: danswer/danswer-ee-backend:latest
|
||||||
build:
|
build:
|
||||||
context: ../../backend
|
context: ../../backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
command: >
|
command: >
|
||||||
/bin/sh -c "alembic upgrade head &&
|
/bin/sh -c "alembic upgrade head &&
|
||||||
echo \"Starting Danswer Api Server\" &&
|
echo \"Starting Danswer Api Server\" &&
|
||||||
uvicorn danswer.main:app --host 0.0.0.0 --port 8080"
|
uvicorn ee.danswer.main:app --host 0.0.0.0 --port 8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
- relational_db
|
- relational_db
|
||||||
- index
|
- index
|
||||||
@@ -30,6 +30,9 @@ services:
|
|||||||
- SMTP_USER=${SMTP_USER:-}
|
- SMTP_USER=${SMTP_USER:-}
|
||||||
- SMTP_PASS=${SMTP_PASS:-}
|
- SMTP_PASS=${SMTP_PASS:-}
|
||||||
- EMAIL_FROM=${EMAIL_FROM:-}
|
- EMAIL_FROM=${EMAIL_FROM:-}
|
||||||
|
- OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID:-}
|
||||||
|
- OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET:-}
|
||||||
|
- OPENID_CONFIG_URL=${OPENID_CONFIG_URL:-}
|
||||||
# Gen AI Settings
|
# Gen AI Settings
|
||||||
- GEN_AI_MODEL_PROVIDER=${GEN_AI_MODEL_PROVIDER:-}
|
- GEN_AI_MODEL_PROVIDER=${GEN_AI_MODEL_PROVIDER:-}
|
||||||
- GEN_AI_MODEL_VERSION=${GEN_AI_MODEL_VERSION:-}
|
- GEN_AI_MODEL_VERSION=${GEN_AI_MODEL_VERSION:-}
|
||||||
@@ -93,7 +96,7 @@ services:
|
|||||||
|
|
||||||
|
|
||||||
background:
|
background:
|
||||||
image: danswer/danswer-backend:latest
|
image: danswer/danswer-ee-backend:latest
|
||||||
build:
|
build:
|
||||||
context: ../../backend
|
context: ../../backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -190,7 +193,7 @@ services:
|
|||||||
|
|
||||||
|
|
||||||
web_server:
|
web_server:
|
||||||
image: danswer/danswer-web-server:latest
|
image: danswer/danswer-ee-web-server:latest
|
||||||
build:
|
build:
|
||||||
context: ../../web
|
context: ../../web
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
@@ -1,14 +1,14 @@
|
|||||||
version: '3'
|
version: '3'
|
||||||
services:
|
services:
|
||||||
api_server:
|
api_server:
|
||||||
image: danswer/danswer-backend:latest
|
image: danswer/danswer-ee-backend:latest
|
||||||
build:
|
build:
|
||||||
context: ../../backend
|
context: ../../backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
command: >
|
command: >
|
||||||
/bin/sh -c "alembic upgrade head &&
|
/bin/sh -c "alembic upgrade head &&
|
||||||
echo \"Starting Danswer Api Server\" &&
|
echo \"Starting Danswer Api Server\" &&
|
||||||
uvicorn danswer.main:app --host 0.0.0.0 --port 8080"
|
uvicorn ee.danswer.main:app --host 0.0.0.0 --port 8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
- relational_db
|
- relational_db
|
||||||
- index
|
- index
|
||||||
@@ -17,7 +17,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- AUTH_TYPE=${AUTH_TYPE:-google_oauth}
|
- AUTH_TYPE=${AUTH_TYPE:-oidc}
|
||||||
- POSTGRES_HOST=relational_db
|
- POSTGRES_HOST=relational_db
|
||||||
- VESPA_HOST=index
|
- VESPA_HOST=index
|
||||||
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
|
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
|
||||||
@@ -31,7 +31,7 @@ services:
|
|||||||
|
|
||||||
|
|
||||||
background:
|
background:
|
||||||
image: danswer/danswer-backend:latest
|
image: danswer/danswer-ee-backend:latest
|
||||||
build:
|
build:
|
||||||
context: ../../backend
|
context: ../../backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -45,7 +45,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- AUTH_TYPE=${AUTH_TYPE:-google_oauth}
|
- AUTH_TYPE=${AUTH_TYPE:-oidc}
|
||||||
- POSTGRES_HOST=relational_db
|
- POSTGRES_HOST=relational_db
|
||||||
- VESPA_HOST=index
|
- VESPA_HOST=index
|
||||||
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
|
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
|
||||||
@@ -60,7 +60,7 @@ services:
|
|||||||
|
|
||||||
|
|
||||||
web_server:
|
web_server:
|
||||||
image: danswer/danswer-web-server:latest
|
image: danswer/danswer-ee-web-server:latest
|
||||||
build:
|
build:
|
||||||
context: ../../web
|
context: ../../web
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
@@ -28,7 +28,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: api-server
|
- name: api-server
|
||||||
image: danswer/danswer-backend:latest
|
image: danswer/danswer-ee-backend:latest
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- "/bin/sh"
|
- "/bin/sh"
|
||||||
@@ -36,7 +36,7 @@ spec:
|
|||||||
- |
|
- |
|
||||||
alembic upgrade head &&
|
alembic upgrade head &&
|
||||||
echo "Starting Danswer Api Server" &&
|
echo "Starting Danswer Api Server" &&
|
||||||
uvicorn danswer.main:app --host 0.0.0.0 --port 8080
|
uvicorn ee.danswer.main:app --host 0.0.0.0 --port 8080
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
# There are some extra values since this is shared between services
|
# There are some extra values since this is shared between services
|
||||||
|
@@ -14,7 +14,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: background
|
- name: background
|
||||||
image: danswer/danswer-backend:latest
|
image: danswer/danswer-ee-backend:latest
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command: ["/usr/bin/supervisord"]
|
command: ["/usr/bin/supervisord"]
|
||||||
# There are some extra values since this is shared between services
|
# There are some extra values since this is shared between services
|
||||||
|
@@ -27,7 +27,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: web-server
|
- name: web-server
|
||||||
image: danswer/danswer-web-server:latest
|
image: danswer/danswer-ee-web-server:latest
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 3000
|
- containerPort: 3000
|
||||||
|
23
web/src/app/auth/oidc/callback/route.ts
Normal file
23
web/src/app/auth/oidc/callback/route.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { getDomain } from "@/lib/redirectSS";
|
||||||
|
import { buildUrl } from "@/lib/utilsSS";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const GET = async (request: NextRequest) => {
|
||||||
|
// Wrapper around the FastAPI endpoint /auth/oidc/callback,
|
||||||
|
// which adds back a redirect to the main app.
|
||||||
|
const url = new URL(buildUrl("/auth/oidc/callback"));
|
||||||
|
url.search = request.nextUrl.search;
|
||||||
|
|
||||||
|
const response = await fetch(url.toString());
|
||||||
|
const setCookieHeader = response.headers.get("set-cookie");
|
||||||
|
|
||||||
|
if (!setCookieHeader) {
|
||||||
|
return NextResponse.redirect(new URL("/auth/error", getDomain(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectResponse = NextResponse.redirect(
|
||||||
|
new URL("/", getDomain(request))
|
||||||
|
);
|
||||||
|
redirectResponse.headers.set("set-cookie", setCookieHeader);
|
||||||
|
return redirectResponse;
|
||||||
|
};
|
Reference in New Issue
Block a user