Compare commits

..

37 Commits

Author SHA1 Message Date
Wenxi Onyx
4f793ff870 Merge remote-tracking branch 'origin/main' into codex/agent-lab 2026-04-09 16:15:03 -07:00
Jamison Lahman
4a96ef13d7 chore(devtools): devcontainer allows go and rust repos (#10041) 2026-04-09 15:46:50 -07:00
Wenxi Onyx
55f570261f Merge remote-tracking branch 'origin/main' into codex/agent-lab 2026-04-09 15:07:50 -07:00
Wenxi Onyx
289a7b807e agent lab init 2026-04-09 15:07:02 -07:00
Jamison Lahman
822b0c99be chore(devtools): upgrade ods: 0.7.3->0.7.4 (#10039) 2026-04-09 14:44:56 -07:00
Jamison Lahman
bcf2851a85 chore(devtools): introduce a .devcontainer (#10035)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:27:30 -07:00
Nikolas Garza
a5a59bd8f0 feat(helm): add API and heavy worker ServiceMonitors (#10025) 2026-04-09 21:03:27 +00:00
Nikolas Garza
32d2e7985a feat(slack-bot): make agent selector searchable (#10036) 2026-04-09 20:53:47 +00:00
Bo-Onyx
c4f8d5370b fix(helm): declare metrics port on celery-worker-heavy (#10033) 2026-04-09 18:29:31 +00:00
Nikolas Garza
9e434f6a5a fix(chat): set consistent 720px content width for chat and input bar (#10032) 2026-04-09 18:06:35 +00:00
Raunak Bhagat
67dc819319 refactor: consolidate LLM provider modal routing (#10030) 2026-04-09 18:02:43 +00:00
Nikolas Garza
2d12274050 feat(chat): add deselect preferred response with smooth transitions and scroll preservation (#10026) 2026-04-09 18:00:54 +00:00
Nikolas Garza
c727ba13ee feat(nrf): add ModelSelector and multi-model support to Chrome extension (#10023) 2026-04-09 16:43:40 +00:00
Jamison Lahman
6193dd5326 chore(python): simplify internal packages/workspace (#10029) 2026-04-09 09:32:19 -07:00
Nikolas Garza
387a7d1cea fix(chat): prevent popover flash when selecting 3rd model (#10021) 2026-04-09 15:52:12 +00:00
Nikolas Garza
869578eeed fix(chat): only collapse sidebar on multi-model submit (#10020) 2026-04-09 15:41:32 +00:00
Nikolas Garza
e68648ab74 fix(chat): gate ModelSelector render on agent and provider readiness (#10017) 2026-04-09 15:41:01 +00:00
Nikolas Garza
da01002099 fix(chat): center multi-model response panels in chat view (#10006) 2026-04-09 15:40:22 +00:00
Nikolas Garza
f5d66f389c fix(input): differentiate attach file and add model icons (#10024) 2026-04-09 03:30:10 +00:00
Nikolas Garza
82d89f78c6 fix(chat): resolve model selector showing stale model on agent switch (#10022) 2026-04-09 03:06:24 +00:00
Jamison Lahman
6f49c5e32c chore: update generic LLM configuration help copy (#10011) 2026-04-09 01:08:41 +00:00
Justin Tahara
41f2bd2f19 chore(edge): Skip edge tag (#10019) 2026-04-09 00:56:51 +00:00
Jamison Lahman
bfa2f672f9 fix: /api/admin/llm/built-in/options/custom 404 (#10009) 2026-04-08 17:47:13 -07:00
Justin Tahara
a823c3ead1 chore(ods): Bump from v0.7.2 -> v0.7.3 (#10018) 2026-04-09 00:30:22 +00:00
Justin Tahara
bd7d378a9a chore(python sandbox): Bump to v0.3.3 (#10016) 2026-04-09 00:10:19 +00:00
Justin Tahara
dcec0c8ef3 feat(ods): Ad Hoc Deploys (#10014) 2026-04-08 23:54:57 +00:00
Raunak Bhagat
6456b51dcf feat: @opal/logos (#10002) 2026-04-08 16:48:11 -07:00
Bo-Onyx
7cfe27e31e feat(metrics): add pruning-specific Prometheus metrics (#9983) 2026-04-08 22:18:32 +00:00
Jamison Lahman
3c5f77f5a4 fix: fetch Custom Models provider names (#10004) 2026-04-08 14:22:42 -07:00
Jamison Lahman
ab4d1dce01 fix: Custom LLM Provider requires a Provider Name (#10003) 2026-04-08 20:33:43 +00:00
Raunak Bhagat
80c928eb58 fix: enable force-delete for last LLM provider (#9998) 2026-04-08 20:09:38 +00:00
Raunak Bhagat
77528876b1 chore: delete unused files (#10001) 2026-04-08 19:53:47 +00:00
Raunak Bhagat
3bf53495f3 refactor: foldable model list in ModelSelectionField (#9996) 2026-04-08 18:32:58 +00:00
Wenxi
e4cfcda0bf fix: initialize tracing in Slack bot service (#9993)
Co-authored-by: Adam Serafin <aserafin@match-trade.com>
2026-04-08 17:46:56 +00:00
Raunak Bhagat
475e8f6cdc refactor: remove auto-refresh from LLM provider model selection (#9995) 2026-04-08 17:45:19 +00:00
Raunak Bhagat
945272c1d2 fix: LM Studio API key field mismatch (#9991) 2026-04-08 09:52:15 -07:00
Raunak Bhagat
185b057483 fix: onboarding LLM Provider configuration fixes (#9972) 2026-04-08 08:35:36 -07:00
380 changed files with 18568 additions and 11258 deletions

65
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,65 @@
FROM ubuntu:26.04@sha256:cc925e589b7543b910fea57a240468940003fbfc0515245a495dd0ad8fe7cef1
RUN apt-get update && apt-get install -y --no-install-recommends \
acl \
curl \
fd-find \
fzf \
git \
jq \
less \
make \
neovim \
openssh-client \
python3-venv \
ripgrep \
sudo \
ca-certificates \
iptables \
ipset \
iproute2 \
dnsutils \
unzip \
wget \
zsh \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin gh \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# fd-find installs as fdfind on Debian/Ubuntu — symlink to fd
RUN ln -sf "$(which fdfind)" /usr/local/bin/fd
# Install uv (Python package manager)
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
# Create non-root dev user with passwordless sudo
RUN useradd -m -s /bin/zsh dev && \
echo "dev ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/dev && \
chmod 0440 /etc/sudoers.d/dev
ENV DEVCONTAINER=true
RUN mkdir -p /workspace && \
chown -R dev:dev /workspace
WORKDIR /workspace
# Install Claude Code
ARG CLAUDE_CODE_VERSION=latest
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
# Configure zsh — source the repo-local zshrc so shell customization
# doesn't require an image rebuild.
RUN chsh -s /bin/zsh root && \
for rc in /root/.zshrc /home/dev/.zshrc; do \
echo '[ -f /workspace/.devcontainer/zshrc ] && . /workspace/.devcontainer/zshrc' >> "$rc"; \
done && \
chown dev:dev /home/dev/.zshrc

126
.devcontainer/README.md Normal file
View File

@@ -0,0 +1,126 @@
# Onyx Dev Container
A containerized development environment for working on Onyx.
## What's included
- Ubuntu 26.04 base image
- Node.js 20, uv, Claude Code
- Docker CLI, GitHub CLI (`gh`)
- Neovim, ripgrep, fd, fzf, jq, make, wget, unzip
- Zsh as default shell (sources host `~/.zshrc` if available)
- Python venv auto-activation
- Network firewall (default-deny, whitelists npm, GitHub, Anthropic APIs, Sentry, and VS Code update servers)
## Usage
### VS Code
1. Install the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
2. Open this repo in VS Code
3. "Reopen in Container" when prompted
### CLI (`ods dev`)
The [`ods` devtools CLI](../tools/ods/README.md) provides workspace-aware wrappers
for all devcontainer operations (also available as `ods dc`):
```bash
# Start the container
ods dev up
# Open a shell
ods dev into
# Run a command
ods dev exec npm test
# Stop the container
ods dev stop
```
If you don't have `ods` installed, use the `devcontainer` CLI directly:
```bash
npm install -g @devcontainers/cli
devcontainer up --workspace-folder .
devcontainer exec --workspace-folder . zsh
```
## Restarting the container
### VS Code
Open the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) and run:
- **Dev Containers: Reopen in Container** — restarts the container without rebuilding
### CLI
```bash
# Restart the container
ods dev restart
# Pull the latest published image and recreate
ods dev rebuild
```
Or without `ods`:
```bash
devcontainer up --workspace-folder . --remove-existing-container
```
## Image
The devcontainer uses a prebuilt image published to `onyxdotapp/onyx-devcontainer`.
The tag is pinned in `devcontainer.json` — no local build is required.
To build the image locally (e.g. while iterating on the Dockerfile):
```bash
docker buildx bake devcontainer
```
The `devcontainer` target is defined in `docker-bake.hcl` at the repo root.
## User & permissions
The container runs as the `dev` user by default (`remoteUser` in devcontainer.json).
An init script (`init-dev-user.sh`) runs at container start to ensure `dev` has
read/write access to the bind-mounted workspace:
- **Standard Docker** — `dev`'s UID/GID is remapped to match the workspace owner,
so file permissions work seamlessly.
- **Rootless Docker** — The workspace appears as root-owned (UID 0) inside the
container due to user-namespace mapping. The init script grants `dev` access via
POSIX ACLs (`setfacl`), which adds a few seconds to the first container start on
large repos.
## Docker socket
The container mounts the host's Docker socket so you can run `docker` commands
from inside. `ods dev` auto-detects the socket path and sets `DOCKER_SOCK`:
| Environment | Socket path |
| ----------------------- | ------------------------------ |
| Linux (rootless Docker) | `$XDG_RUNTIME_DIR/docker.sock` |
| macOS (Docker Desktop) | `~/.docker/run/docker.sock` |
| Linux (standard Docker) | `/var/run/docker.sock` |
To override, set `DOCKER_SOCK` before running `ods dev up`. When using the
VS Code extension or `devcontainer` CLI directly (without `ods`), you must set
`DOCKER_SOCK` yourself.
## Firewall
The container starts with a default-deny firewall (`init-firewall.sh`) that only allows outbound traffic to:
- npm registry
- GitHub
- Anthropic API
- Sentry
- VS Code update servers
This requires the `NET_ADMIN` and `NET_RAW` capabilities, which are added via `runArgs` in `devcontainer.json`.

View File

@@ -0,0 +1,22 @@
{
"name": "Onyx Dev Sandbox",
"image": "onyxdotapp/onyx-devcontainer@sha256:12184169c5bcc9cca0388286d5ffe504b569bc9c37bfa631b76ee8eee2064055",
"runArgs": ["--cap-add=NET_ADMIN", "--cap-add=NET_RAW"],
"mounts": [
"source=${localEnv:DOCKER_SOCK},target=/var/run/docker.sock,type=bind",
"source=${localEnv:HOME}/.claude,target=/home/dev/.claude,type=bind",
"source=${localEnv:HOME}/.claude.json,target=/home/dev/.claude.json,type=bind",
"source=${localEnv:HOME}/.zshrc,target=/home/dev/.zshrc.host,type=bind,readonly",
"source=${localEnv:HOME}/.gitconfig,target=/home/dev/.gitconfig.host,type=bind,readonly",
"source=${localEnv:HOME}/.ssh,target=/home/dev/.ssh.host,type=bind,readonly",
"source=${localEnv:HOME}/.config/nvim,target=/home/dev/.config/nvim.host,type=bind,readonly",
"source=onyx-devcontainer-cache,target=/home/dev/.cache,type=volume",
"source=onyx-devcontainer-local,target=/home/dev/.local,type=volume"
],
"remoteUser": "dev",
"updateRemoteUserUID": false,
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
"workspaceFolder": "/workspace",
"postStartCommand": "sudo bash /workspace/.devcontainer/init-dev-user.sh && sudo bash /workspace/.devcontainer/init-firewall.sh",
"waitFor": "postStartCommand"
}

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env bash
set -euo pipefail
# Remap the dev user's UID/GID to match the workspace owner so that
# bind-mounted files are accessible without running as root.
#
# Standard Docker: Workspace is owned by the host user's UID (e.g. 1000).
# We remap dev to that UID -- fast and seamless.
#
# Rootless Docker: Workspace appears as root-owned (UID 0) inside the
# container due to user-namespace mapping. We can't remap
# dev to UID 0 (that's root), so we grant access with
# POSIX ACLs instead.
WORKSPACE=/workspace
TARGET_USER=dev
WS_UID=$(stat -c '%u' "$WORKSPACE")
WS_GID=$(stat -c '%g' "$WORKSPACE")
DEV_UID=$(id -u "$TARGET_USER")
DEV_GID=$(id -g "$TARGET_USER")
DEV_HOME=/home/"$TARGET_USER"
# Ensure directories that tools expect exist under ~dev.
# ~/.local and ~/.cache are named Docker volumes -- ensure they are owned by dev.
mkdir -p "$DEV_HOME"/.local/state "$DEV_HOME"/.local/share
chown -R "$TARGET_USER":"$TARGET_USER" "$DEV_HOME"/.local
chown -R "$TARGET_USER":"$TARGET_USER" "$DEV_HOME"/.cache
# Copy host configs mounted as *.host into their real locations.
# This gives the dev user owned copies without touching host originals.
if [ -d "$DEV_HOME/.ssh.host" ]; then
cp -a "$DEV_HOME/.ssh.host" "$DEV_HOME/.ssh"
chmod 700 "$DEV_HOME/.ssh"
chmod 600 "$DEV_HOME"/.ssh/id_* 2>/dev/null || true
chown -R "$TARGET_USER":"$TARGET_USER" "$DEV_HOME/.ssh"
fi
if [ -d "$DEV_HOME/.config/nvim.host" ]; then
mkdir -p "$DEV_HOME/.config"
cp -a "$DEV_HOME/.config/nvim.host" "$DEV_HOME/.config/nvim"
chown -R "$TARGET_USER":"$TARGET_USER" "$DEV_HOME/.config/nvim"
fi
# Already matching -- nothing to do.
if [ "$WS_UID" = "$DEV_UID" ] && [ "$WS_GID" = "$DEV_GID" ]; then
exit 0
fi
if [ "$WS_UID" != "0" ]; then
# ── Standard Docker ──────────────────────────────────────────────
# Workspace is owned by a non-root UID (the host user).
# Remap dev's UID/GID to match.
if [ "$DEV_GID" != "$WS_GID" ]; then
if ! groupmod -g "$WS_GID" "$TARGET_USER" 2>&1; then
echo "warning: failed to remap $TARGET_USER GID to $WS_GID" >&2
fi
fi
if [ "$DEV_UID" != "$WS_UID" ]; then
if ! usermod -u "$WS_UID" -g "$WS_GID" "$TARGET_USER" 2>&1; then
echo "warning: failed to remap $TARGET_USER UID to $WS_UID" >&2
fi
fi
if ! chown -R "$TARGET_USER":"$TARGET_USER" /home/"$TARGET_USER" 2>&1; then
echo "warning: failed to chown /home/$TARGET_USER" >&2
fi
else
# ── Rootless Docker ──────────────────────────────────────────────
# Workspace is root-owned inside the container. Grant dev access
# via POSIX ACLs (preserves ownership, works across the namespace
# boundary).
if command -v setfacl &>/dev/null; then
setfacl -Rm "u:${TARGET_USER}:rwX" "$WORKSPACE"
setfacl -Rdm "u:${TARGET_USER}:rwX" "$WORKSPACE" # default ACL for new files
# Git refuses to operate in repos owned by a different UID.
# Host gitconfig is mounted readonly as ~/.gitconfig.host.
# Create a real ~/.gitconfig that includes it plus container overrides.
printf '[include]\n\tpath = %s/.gitconfig.host\n[safe]\n\tdirectory = %s\n' \
"$DEV_HOME" "$WORKSPACE" > "$DEV_HOME/.gitconfig"
chown "$TARGET_USER":"$TARGET_USER" "$DEV_HOME/.gitconfig"
# If this is a worktree, the main .git dir is bind-mounted at its
# host absolute path. Grant dev access so git operations work.
GIT_COMMON_DIR=$(git -C "$WORKSPACE" rev-parse --git-common-dir 2>/dev/null || true)
if [ -n "$GIT_COMMON_DIR" ] && [ "$GIT_COMMON_DIR" != "$WORKSPACE/.git" ]; then
[ ! -d "$GIT_COMMON_DIR" ] && GIT_COMMON_DIR="$WORKSPACE/$GIT_COMMON_DIR"
if [ -d "$GIT_COMMON_DIR" ]; then
setfacl -Rm "u:${TARGET_USER}:rwX" "$GIT_COMMON_DIR"
setfacl -Rdm "u:${TARGET_USER}:rwX" "$GIT_COMMON_DIR"
git config -f "$DEV_HOME/.gitconfig" --add safe.directory "$(dirname "$GIT_COMMON_DIR")"
fi
fi
# Also fix bind-mounted dirs under ~dev that appear root-owned.
for dir in /home/"$TARGET_USER"/.claude; do
[ -d "$dir" ] && setfacl -Rm "u:${TARGET_USER}:rwX" "$dir" && setfacl -Rdm "u:${TARGET_USER}:rwX" "$dir"
done
[ -f /home/"$TARGET_USER"/.claude.json ] && \
setfacl -m "u:${TARGET_USER}:rw" /home/"$TARGET_USER"/.claude.json
else
echo "warning: setfacl not found; dev user may not have write access to workspace" >&2
echo " install the 'acl' package or set remoteUser to root" >&2
fi
fi

104
.devcontainer/init-firewall.sh Executable file
View File

@@ -0,0 +1,104 @@
#!/usr/bin/env bash
set -euo pipefail
echo "Setting up firewall..."
# Preserve docker dns resolution
DOCKER_DNS_RULES=$(iptables-save | grep -E "^-A.*-d 127.0.0.11/32" || true)
# Flush all rules
iptables -t nat -F
iptables -t nat -X
iptables -t mangle -F
iptables -t mangle -X
iptables -F
iptables -X
# Restore docker dns rules
if [ -n "$DOCKER_DNS_RULES" ]; then
echo "$DOCKER_DNS_RULES" | iptables-restore -n
fi
# Create ipset for allowed destinations
ipset create allowed-domains hash:net || true
ipset flush allowed-domains
# Fetch GitHub IP ranges (IPv4 only -- ipset hash:net and iptables are IPv4)
GITHUB_IPS=$(curl -s https://api.github.com/meta | jq -r '.api[]' 2>/dev/null | grep -v ':' || echo "")
for ip in $GITHUB_IPS; do
if ! ipset add allowed-domains "$ip" -exist 2>&1; then
echo "warning: failed to add GitHub IP $ip to allowlist" >&2
fi
done
# Resolve allowed domains
ALLOWED_DOMAINS=(
"registry.npmjs.org"
"api.anthropic.com"
"api-staging.anthropic.com"
"files.anthropic.com"
"sentry.io"
"update.code.visualstudio.com"
"pypi.org"
"files.pythonhosted.org"
"go.dev"
"storage.googleapis.com"
"static.rust-lang.org"
)
for domain in "${ALLOWED_DOMAINS[@]}"; do
IPS=$(getent ahosts "$domain" 2>/dev/null | awk '{print $1}' | grep -v ':' | sort -u || echo "")
for ip in $IPS; do
if ! ipset add allowed-domains "$ip/32" -exist 2>&1; then
echo "warning: failed to add $domain ($ip) to allowlist" >&2
fi
done
done
# Detect host network
if [[ "${DOCKER_HOST:-}" == "unix://"* ]]; then
DOCKER_GATEWAY=$(ip -4 route show | grep "^default" | awk '{print $3}')
if ! ipset add allowed-domains "$DOCKER_GATEWAY/32" -exist 2>&1; then
echo "warning: failed to add Docker gateway $DOCKER_GATEWAY to allowlist" >&2
fi
fi
# Set default policies to DROP
iptables -P FORWARD DROP
iptables -P INPUT DROP
iptables -P OUTPUT DROP
# Allow established connections
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# Allow loopback
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT
# Allow DNS
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT
# Allow outbound to allowed destinations
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
# Reject unauthorized outbound
iptables -A OUTPUT -j REJECT --reject-with icmp-host-unreachable
# Validate firewall configuration
echo "Validating firewall configuration..."
BLOCKED_SITES=("example.com" "google.com" "facebook.com")
for site in "${BLOCKED_SITES[@]}"; do
if timeout 2 ping -c 1 "$site" &>/dev/null; then
echo "Warning: $site is still reachable"
fi
done
if ! timeout 5 curl -s https://api.github.com/meta > /dev/null; then
echo "Warning: GitHub API is not accessible"
fi
echo "Firewall setup complete"

10
.devcontainer/zshrc Normal file
View File

@@ -0,0 +1,10 @@
# Devcontainer zshrc — sourced automatically for both root and dev users.
# Edit this file to customize the shell without rebuilding the image.
# Auto-activate Python venv
if [ -f /workspace/.venv/bin/activate ]; then
. /workspace/.venv/bin/activate
fi
# Source host zshrc if bind-mounted
[ -f ~/.zshrc.host ] && . ~/.zshrc.host

View File

@@ -13,7 +13,7 @@ permissions:
id-token: write # zizmor: ignore[excessive-permissions]
env:
EDGE_TAG: ${{ startsWith(github.ref_name, 'nightly-latest') }}
EDGE_TAG: ${{ startsWith(github.ref_name, 'nightly-latest') || github.ref_name == 'edge' }}
jobs:
# Determine which components to build based on the tag
@@ -156,7 +156,7 @@ jobs:
check-version-tag:
runs-on: ubuntu-slim
timeout-minutes: 10
if: ${{ !startsWith(github.ref_name, 'nightly-latest') && github.event_name != 'workflow_dispatch' }}
if: ${{ !startsWith(github.ref_name, 'nightly-latest') && github.ref_name != 'edge' && github.event_name != 'workflow_dispatch' }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6

View File

@@ -9,7 +9,6 @@ repos:
rev: d30b4298e4fb63ce8609e29acdbcf4c9018a483c
hooks:
- id: uv-sync
args: ["--locked", "--all-extras"]
- id: uv-lock
- id: uv-export
name: uv-export default.txt
@@ -18,7 +17,7 @@ repos:
"--no-emit-project",
"--no-default-groups",
"--no-hashes",
"--extra",
"--group",
"backend",
"-o",
"backend/requirements/default.txt",
@@ -31,7 +30,7 @@ repos:
"--no-emit-project",
"--no-default-groups",
"--no-hashes",
"--extra",
"--group",
"dev",
"-o",
"backend/requirements/dev.txt",
@@ -44,7 +43,7 @@ repos:
"--no-emit-project",
"--no-default-groups",
"--no-hashes",
"--extra",
"--group",
"ee",
"-o",
"backend/requirements/ee.txt",
@@ -57,7 +56,7 @@ repos:
"--no-emit-project",
"--no-default-groups",
"--no-hashes",
"--extra",
"--group",
"model_server",
"-o",
"backend/requirements/model_server.txt",

3
.vscode/launch.json vendored
View File

@@ -531,8 +531,7 @@
"request": "launch",
"runtimeExecutable": "uv",
"runtimeArgs": [
"sync",
"--all-extras"
"sync"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",

416
AGENTS.md
View File

@@ -1,361 +1,55 @@
# PROJECT KNOWLEDGE BASE
This file provides guidance to AI agents when working with code in this repository.
## KEY NOTES
- If you run into any missing python dependency errors, try running your command with `source .venv/bin/activate` \
to assume the python venv.
- To make tests work, check the `.env` file at the root of the project to find an OpenAI key.
- If using `playwright` to explore the frontend, you can usually log in with username `a@example.com` and password
`a`. The app can be accessed at `http://localhost:3000`.
- You should assume that all Onyx services are running. To verify, you can check the `backend/log` directory to
make sure we see logs coming out from the relevant service.
- To connect to the Postgres database, use: `docker exec -it onyx-relational_db-1 psql -U postgres -c "<SQL>"`
- When making calls to the backend, always go through the frontend. E.g. make a call to `http://localhost:3000/api/persona` not `http://localhost:8080/api/persona`
- Put ALL db operations under the `backend/onyx/db` / `backend/ee/onyx/db` directories. Don't run queries
outside of those directories.
## Project Overview
**Onyx** (formerly Danswer) is an open-source Gen-AI and Enterprise Search platform that connects to company documents, apps, and people. It features a modular architecture with both Community Edition (MIT licensed) and Enterprise Edition offerings.
### Background Workers (Celery)
Onyx uses Celery for asynchronous task processing with multiple specialized workers:
#### Worker Types
1. **Primary Worker** (`celery_app.py`)
- Coordinates core background tasks and system-wide operations
- Handles connector management, document sync, pruning, and periodic checks
- Runs with 4 threads concurrency
- Tasks: connector deletion, vespa sync, pruning, LLM model updates, user file sync
2. **Docfetching Worker** (`docfetching`)
- Fetches documents from external data sources (connectors)
- Spawns docprocessing tasks for each document batch
- Implements watchdog monitoring for stuck connectors
- Configurable concurrency (default from env)
3. **Docprocessing Worker** (`docprocessing`)
- Processes fetched documents through the indexing pipeline:
- Upserts documents to PostgreSQL
- Chunks documents and adds contextual information
- Embeds chunks via model server
- Writes chunks to Vespa vector database
- Updates document metadata
- Configurable concurrency (default from env)
4. **Light Worker** (`light`)
- Handles lightweight, fast operations
- Tasks: vespa operations, document permissions sync, external group sync
- Higher concurrency for quick tasks
5. **Heavy Worker** (`heavy`)
- Handles resource-intensive operations
- Primary task: document pruning operations
- Runs with 4 threads concurrency
6. **KG Processing Worker** (`kg_processing`)
- Handles Knowledge Graph processing and clustering
- Builds relationships between documents
- Runs clustering algorithms
- Configurable concurrency
7. **Monitoring Worker** (`monitoring`)
- System health monitoring and metrics collection
- Monitors Celery queues, process memory, and system status
- Single thread (monitoring doesn't need parallelism)
- Cloud-specific monitoring tasks
8. **User File Processing Worker** (`user_file_processing`)
- Processes user-uploaded files
- Handles user file indexing and project synchronization
- Configurable concurrency
9. **Beat Worker** (`beat`)
- Celery's scheduler for periodic tasks
- Uses DynamicTenantScheduler for multi-tenant support
- Schedules tasks like:
- Indexing checks (every 15 seconds)
- Connector deletion checks (every 20 seconds)
- Vespa sync checks (every 20 seconds)
- Pruning checks (every 20 seconds)
- KG processing (every 60 seconds)
- Monitoring tasks (every 5 minutes)
- Cleanup tasks (hourly)
#### Key Features
- **Thread-based Workers**: All workers use thread pools (not processes) for stability
- **Tenant Awareness**: Multi-tenant support with per-tenant task isolation. There is a
middleware layer that automatically finds the appropriate tenant ID when sending tasks
via Celery Beat.
- **Task Prioritization**: High, Medium, Low priority queues
- **Monitoring**: Built-in heartbeat and liveness checking
- **Failure Handling**: Automatic retry and failure recovery mechanisms
- **Redis Coordination**: Inter-process communication via Redis
- **PostgreSQL State**: Task state and metadata stored in PostgreSQL
#### Important Notes
**Defining Tasks**:
- Always use `@shared_task` rather than `@celery_app`
- Put tasks under `background/celery/tasks/` or `ee/background/celery/tasks`
- Never enqueue a task without an expiration. Always supply `expires=` when
sending tasks, either from the beat schedule or directly from another task. It
should never be acceptable to submit code which enqueues tasks without an
expiration, as doing so can lead to unbounded task queue growth.
**Defining APIs**:
When creating new FastAPI APIs, do NOT use the `response_model` field. Instead, just type the
function.
**Testing Updates**:
If you make any updates to a celery worker and you want to test these changes, you will need
to ask me to restart the celery worker. There is no auto-restart on code-change mechanism.
**Task Time Limits**:
Since all tasks are executed in thread pools, the time limit features of Celery are silently
disabled and won't work. Timeout logic must be implemented within the task itself.
### Code Quality
```bash
# Install and run pre-commit hooks
pre-commit install
pre-commit run --all-files
```
NOTE: Always make sure everything is strictly typed (both in Python and Typescript).
## Architecture Overview
### Technology Stack
- **Backend**: Python 3.11, FastAPI, SQLAlchemy, Alembic, Celery
- **Frontend**: Next.js 15+, React 18, TypeScript, Tailwind CSS
- **Database**: PostgreSQL with Redis caching
- **Search**: Vespa vector database
- **Auth**: OAuth2, SAML, multi-provider support
- **AI/ML**: LangChain, LiteLLM, multiple embedding models
### Directory Structure
```
backend/
├── onyx/
│ ├── auth/ # Authentication & authorization
│ ├── chat/ # Chat functionality & LLM interactions
│ ├── connectors/ # Data source connectors
│ ├── db/ # Database models & operations
│ ├── document_index/ # Vespa integration
│ ├── federated_connectors/ # External search connectors
│ ├── llm/ # LLM provider integrations
│ └── server/ # API endpoints & routers
├── ee/ # Enterprise Edition features
├── alembic/ # Database migrations
└── tests/ # Test suites
web/
├── src/app/ # Next.js app router pages
├── src/components/ # Reusable React components
└── src/lib/ # Utilities & business logic
```
## Frontend Standards
Frontend standards for the `web/` and `desktop/` projects live in `web/AGENTS.md`.
## Database & Migrations
### Running Migrations
```bash
# Standard migrations
alembic upgrade head
# Multi-tenant (Enterprise)
alembic -n schema_private upgrade head
```
### Creating Migrations
```bash
# Create migration
alembic revision -m "description"
# Multi-tenant migration
alembic -n schema_private revision -m "description"
```
Write the migration manually and place it in the file that alembic creates when running the above command.
## Testing Strategy
First, you must activate the virtual environment with `source .venv/bin/activate`.
There are 4 main types of tests within Onyx:
### Unit Tests
These should not assume any Onyx/external services are available to be called.
Interactions with the outside world should be mocked using `unittest.mock`. Generally, only
write these for complex, isolated modules e.g. `citation_processing.py`.
To run them:
```bash
pytest -xv backend/tests/unit
```
### External Dependency Unit Tests
These tests assume that all external dependencies of Onyx are available and callable (e.g. Postgres, Redis,
MinIO/S3, Vespa are running + OpenAI can be called + any request to the internet is fine + etc.).
However, the actual Onyx containers are not running and with these tests we call the function to test directly.
We can also mock components/calls at will.
The goal with these tests are to minimize mocking while giving some flexibility to mock things that are flakey,
need strictly controlled behavior, or need to have their internal behavior validated (e.g. verify a function is called
with certain args, something that would be impossible with proper integration tests).
A great example of this type of test is `backend/tests/external_dependency_unit/connectors/confluence/test_confluence_group_sync.py`.
To run them:
```bash
python -m dotenv -f .vscode/.env run -- pytest backend/tests/external_dependency_unit
```
### Integration Tests
Standard integration tests. Every test in `backend/tests/integration` runs against a real Onyx deployment. We cannot
mock anything in these tests. Prefer writing integration tests (or External Dependency Unit Tests if mocking/internal
verification is necessary) over any other type of test.
Tests are parallelized at a directory level.
When writing integration tests, make sure to check the root `conftest.py` for useful fixtures + the `backend/tests/integration/common_utils` directory for utilities. Prefer (if one exists), calling the appropriate Manager
class in the utils over directly calling the APIs with a library like `requests`. Prefer using fixtures rather than
calling the utilities directly (e.g. do NOT create admin users with
`admin_user = UserManager.create(name="admin_user")`, instead use the `admin_user` fixture).
A great example of this type of test is `backend/tests/integration/tests/streaming_endpoints/test_chat_stream.py`.
To run them:
```bash
python -m dotenv -f .vscode/.env run -- pytest backend/tests/integration
```
### Playwright (E2E) Tests
These tests are an even more complete version of the Integration Tests mentioned above. Has all services of Onyx
running, _including_ the Web Server.
Use these tests for anything that requires significant frontend <-> backend coordination.
Tests are located at `web/tests/e2e`. Tests are written in TypeScript.
To run them:
```bash
npx playwright test <TEST_NAME>
```
For shared fixtures, best practices, and detailed guidance, see `backend/tests/README.md`.
## Logs
When (1) writing integration tests or (2) doing live tests (e.g. curl / playwright) you can get access
to logs via the `backend/log/<service_name>_debug.log` file. All Onyx services (api_server, web_server, celery_X)
will be tailing their logs to this file.
## Security Considerations
- Never commit API keys or secrets to repository
- Use encrypted credential storage for connector credentials
- Follow RBAC patterns for new features
- Implement proper input validation with Pydantic models
- Use parameterized queries to prevent SQL injection
## AI/LLM Integration
- Multiple LLM providers supported via LiteLLM
- Configurable models per feature (chat, search, embeddings)
- Streaming support for real-time responses
- Token management and rate limiting
- Custom prompts and agent actions
## Creating a Plan
When creating a plan in the `plans` directory, make sure to include at least these elements:
**Issues to Address**
What the change is meant to do.
**Important Notes**
Things you come across in your research that are important to the implementation.
**Implementation strategy**
How you are going to make the changes happen. High level approach.
**Tests**
What unit (use rarely), external dependency unit, integration, and playwright tests you plan to write to
verify the correct behavior. Don't overtest. Usually, a given change only needs one type of test.
Do NOT include these: _Timeline_, _Rollback plan_
This is a minimal list - feel free to include more. Do NOT write code as part of your plan.
Keep it high level. You can reference certain files or functions though.
Before writing your plan, make sure to do research. Explore the relevant sections in the codebase.
## Error Handling
**Always raise `OnyxError` from `onyx.error_handling.exceptions` instead of `HTTPException`.
Never hardcode status codes or use `starlette.status` / `fastapi.status` constants directly.**
A global FastAPI exception handler converts `OnyxError` into a JSON response with the standard
`{"error_code": "...", "detail": "..."}` shape. This eliminates boilerplate and keeps error
handling consistent across the entire backend.
```python
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
# ✅ Good
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Session not found")
# ✅ Good — no extra message needed
raise OnyxError(OnyxErrorCode.UNAUTHENTICATED)
# ✅ Good — upstream service with dynamic status code
raise OnyxError(OnyxErrorCode.BAD_GATEWAY, detail, status_code_override=upstream_status)
# ❌ Bad — using HTTPException directly
raise HTTPException(status_code=404, detail="Session not found")
# ❌ Bad — starlette constant
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
```
Available error codes are defined in `backend/onyx/error_handling/error_codes.py`. If a new error
category is needed, add it there first — do not invent ad-hoc codes.
**Upstream service errors:** When forwarding errors from an upstream service where the HTTP
status code is dynamic (comes from the upstream response), use `status_code_override`:
```python
raise OnyxError(OnyxErrorCode.BAD_GATEWAY, detail, status_code_override=e.response.status_code)
```
## Best Practices
In addition to the other content in this file, best practices for contributing
to the codebase can be found in the "Engineering Best Practices" section of
`CONTRIBUTING.md`. Understand its contents and follow them.
# Project Knowledge Base
This file is the entrypoint for agents working in this repository. Keep it small.
## Start Here
- General development workflow and repo conventions: [CONTRIBUTING.md](./CONTRIBUTING.md)
- Frontend standards for `web/` and `desktop/`: [web/AGENTS.md](./web/AGENTS.md)
- Backend testing strategy and commands: [backend/tests/README.md](./backend/tests/README.md)
- Celery worker and task guidance: [backend/onyx/background/celery/README.md](./backend/onyx/background/celery/README.md)
- Backend API error-handling rules: [backend/onyx/error_handling/README.md](./backend/onyx/error_handling/README.md)
- Plan-writing guidance: [plans/README.md](./plans/README.md)
## Agent-Lab Docs
When working on `agent-lab` or on tasks explicitly about agent-engineering, use:
- [docs/agent/README.md](./docs/agent/README.md)
These docs are the system of record for the `agent-lab` workflow.
## Universal Notes
- For non-trivial work, create the target worktree first and keep the edit, test, and PR loop
inside that worktree. Do not prototype in one checkout and copy the patch into another unless
you are explicitly debugging the harness itself.
- Use `ods worktree create` for harness-managed worktrees. Do not use raw `git worktree add` when
you want the `agent-lab` workflow, because it will skip the manifest, env overlays, dependency
bootstrap, and lane-aware base-ref selection.
- When a change needs browser proof, use the harness journey flow instead of ad hoc screen capture:
record `before` in the target worktree before making the change, then record `after` in that
same worktree after validation. Use `ods journey compare` only when you need to recover a missed
baseline or compare two explicit revisions after the fact.
- After opening a PR, treat review feedback and failing checks as part of the same loop:
use `ods pr-review ...` for GitHub review threads and `ods pr-checks diagnose` plus `ods trace`
for failing Playwright runs.
- PR titles and commit messages should use conventional-commit style such as `fix: ...` or
`feat: ...`. Do not use `[codex]` prefixes in this repo.
- If Python dependencies appear missing, activate the root venv with `source .venv/bin/activate`.
- To make tests work, check the root `.env` file for an OpenAI key.
- If using Playwright to explore the frontend, you can usually log in with username `a@example.com`
and password `a` at `http://localhost:3000`.
- Assume Onyx services are already running unless the task indicates otherwise. Check `backend/log`
if you need to verify service activity.
- When making backend calls in local development flows, go through the frontend proxy:
`http://localhost:3000/api/...`, not `http://localhost:8080/...`.
- Put DB operations under `backend/onyx/db/` or `backend/ee/onyx/db/`. Do not add ad hoc DB access
elsewhere.
## How To Use This File
- Use this file as a map, not a manual.
- Follow the nearest authoritative doc for the subsystem you are changing.
- If a repeated rule matters enough to teach every future agent, document it near the code it
governs or encode it mechanically.

View File

@@ -117,7 +117,7 @@ If using PowerShell, the command slightly differs:
Install the required Python dependencies:
```bash
uv sync --all-extras
uv sync
```
Install Playwright for Python (headless browser required by the Web Connector):

View File

@@ -1,49 +0,0 @@
"""make user role nullable
The ``user.role`` column is no longer written or read by application
code — admin status is derived from group membership and classification
lives on ``user.account_type``. Relax the NOT NULL constraint so inserts
that omit the column (which is now the default path after the write-path
cleanup) succeed. The column itself is kept as a tombstone for rollback
safety and will be dropped in a follow-up migration once the new model
has been in production for a release cycle.
Revision ID: c8e316473aaa
Revises: 503883791c39
Create Date: 2026-04-14 14:57:29.520645
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "c8e316473aaa"
down_revision = "503883791c39"
branch_labels: str | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.alter_column(
"user",
"role",
existing_type=sa.VARCHAR(length=14),
nullable=True,
)
def downgrade() -> None:
# Backfill any NULLs written while the column was optional before we
# restore the NOT NULL constraint, otherwise the downgrade would fail
# against rows inserted after the upgrade.
op.execute("UPDATE \"user\" SET role = 'BASIC' WHERE role IS NULL")
op.alter_column(
"user",
"role",
existing_type=sa.VARCHAR(length=14),
nullable=False,
)

View File

@@ -11,14 +11,13 @@ from sqlalchemy import or_
from sqlalchemy import select
from sqlalchemy.orm import Session
from onyx.auth.permissions import has_permission
from onyx.configs.constants import MessageType
from onyx.db.enums import Permission
from onyx.db.models import ChatMessage
from onyx.db.models import ChatMessageFeedback
from onyx.db.models import ChatSession
from onyx.db.models import Persona
from onyx.db.models import User
from onyx.db.models import UserRole
def fetch_query_analytics(
@@ -339,7 +338,7 @@ def fetch_assistant_unique_users_total(
def user_can_view_assistant_stats(
db_session: Session, user: User, assistant_id: int
) -> bool:
if has_permission(user, Permission.FULL_ADMIN_PANEL_ACCESS):
if user.role == UserRole.ADMIN:
return True
# Check if the user created the persona

View File

@@ -10,9 +10,9 @@ from sqlalchemy.orm import Session
from ee.onyx.server.license.models import LicenseMetadata
from ee.onyx.server.license.models import LicensePayload
from ee.onyx.server.license.models import LicenseSource
from onyx.auth.schemas import UserRole
from onyx.cache.factory import get_cache_backend
from onyx.configs.constants import ANONYMOUS_USER_EMAIL
from onyx.db.enums import AccountType
from onyx.db.models import License
from onyx.db.models import User
from onyx.utils.logger import setup_logger
@@ -127,7 +127,7 @@ def get_used_seats(tenant_id: str | None = None) -> int:
.select_from(User)
.where(
User.is_active == True, # type: ignore # noqa: E712
User.account_type != AccountType.EXT_PERM_USER,
User.role != UserRole.EXT_PERM_USER,
User.email != ANONYMOUS_USER_EMAIL, # type: ignore
)
)

View File

@@ -1,16 +1,74 @@
from collections.abc import Sequence
from sqlalchemy import exists
from sqlalchemy import Row
from sqlalchemy import Select
from sqlalchemy import select
from sqlalchemy.orm import aliased
from sqlalchemy.orm import Session
from onyx.configs.constants import TokenRateLimitScope
from onyx.db.models import TokenRateLimit
from onyx.db.models import TokenRateLimit__UserGroup
from onyx.db.models import User
from onyx.db.models import User__UserGroup
from onyx.db.models import UserGroup
from onyx.db.models import UserRole
from onyx.server.token_rate_limits.models import TokenRateLimitArgs
def _add_user_filters(stmt: Select, user: User, get_editable: bool = True) -> Select:
if user.role == UserRole.ADMIN:
return stmt
# If anonymous user, only show global/public token_rate_limits
if user.is_anonymous:
where_clause = TokenRateLimit.scope == TokenRateLimitScope.GLOBAL
return stmt.where(where_clause)
stmt = stmt.distinct()
TRLimit_UG = aliased(TokenRateLimit__UserGroup)
User__UG = aliased(User__UserGroup)
"""
Here we select token_rate_limits by relation:
User -> User__UserGroup -> TokenRateLimit__UserGroup ->
TokenRateLimit
"""
stmt = stmt.outerjoin(TRLimit_UG).outerjoin(
User__UG,
User__UG.user_group_id == TRLimit_UG.user_group_id,
)
"""
Filter token_rate_limits by:
- if the user is in the user_group that owns the token_rate_limit
- if the user is not a global_curator, they must also have a curator relationship
to the user_group
- if editing is being done, we also filter out token_rate_limits that are owned by groups
that the user isn't a curator for
- if we are not editing, we show all token_rate_limits in the groups the user curates
"""
where_clause = User__UG.user_id == user.id
if user.role == UserRole.CURATOR and get_editable:
where_clause &= User__UG.is_curator == True # noqa: E712
if get_editable:
user_groups = select(User__UG.user_group_id).where(User__UG.user_id == user.id)
if user.role == UserRole.CURATOR:
user_groups = user_groups.where(
User__UserGroup.is_curator == True # noqa: E712
)
where_clause &= (
~exists()
.where(TRLimit_UG.rate_limit_id == TokenRateLimit.id)
.where(~TRLimit_UG.user_group_id.in_(user_groups))
.correlate(TokenRateLimit)
)
return stmt.where(where_clause)
def fetch_all_user_group_token_rate_limits_by_group(
db_session: Session,
) -> Sequence[Row[tuple[TokenRateLimit, str]]]:
@@ -49,11 +107,13 @@ def insert_user_group_token_rate_limit(
return token_limit
def fetch_user_group_token_rate_limits_for_group(
def fetch_user_group_token_rate_limits_for_user(
db_session: Session,
group_id: int,
user: User,
enabled_only: bool = False,
ordered: bool = True,
get_editable: bool = True,
) -> Sequence[TokenRateLimit]:
stmt = (
select(TokenRateLimit)
@@ -63,6 +123,7 @@ def fetch_user_group_token_rate_limits_for_group(
)
.where(TokenRateLimit__UserGroup.user_group_id == group_id)
)
stmt = _add_user_filters(stmt, user, get_editable)
if enabled_only:
stmt = stmt.where(TokenRateLimit.enabled.is_(True))

View File

@@ -2,14 +2,17 @@ from collections.abc import Sequence
from operator import and_
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import delete
from sqlalchemy import func
from sqlalchemy import Select
from sqlalchemy import select
from sqlalchemy import update
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from ee.onyx.server.user_group.models import SetCuratorRequest
from ee.onyx.server.user_group.models import UserGroupCreate
from ee.onyx.server.user_group.models import UserGroupUpdate
from onyx.configs.app_configs import DISABLE_VECTOR_DB
@@ -35,6 +38,7 @@ from onyx.db.models import User
from onyx.db.models import User__UserGroup
from onyx.db.models import UserGroup
from onyx.db.models import UserGroup__ConnectorCredentialPair
from onyx.db.models import UserRole
from onyx.db.permissions import recompute_permissions_for_group__no_commit
from onyx.db.permissions import recompute_user_permissions__no_commit
from onyx.db.users import fetch_user_by_id
@@ -130,6 +134,73 @@ def _cleanup_document_set__user_group_relationships__no_commit(
)
def validate_object_creation_for_user(
db_session: Session,
user: User,
target_group_ids: list[int] | None = None,
object_is_public: bool | None = None,
object_is_perm_sync: bool | None = None,
object_is_owned_by_user: bool = False,
object_is_new: bool = False,
) -> None:
"""
All users can create/edit permission synced objects if they don't specify a group
All admin actions are allowed.
Curators and global curators can create public objects.
Prevents other non-admins from creating/editing:
- public objects
- objects with no groups
- objects that belong to a group they don't curate
"""
if object_is_perm_sync and not target_group_ids:
return
# Admins are allowed
if user.role == UserRole.ADMIN:
return
# Allow curators and global curators to create public objects
# w/o associated groups IF the object is new/owned by them
if (
object_is_public
and user.role in [UserRole.CURATOR, UserRole.GLOBAL_CURATOR]
and (object_is_new or object_is_owned_by_user)
):
return
if object_is_public and user.role == UserRole.BASIC:
detail = "User does not have permission to create public objects"
logger.error(detail)
raise HTTPException(
status_code=400,
detail=detail,
)
if not target_group_ids:
detail = "Curators must specify 1+ groups"
logger.error(detail)
raise HTTPException(
status_code=400,
detail=detail,
)
user_curated_groups = fetch_user_groups_for_user(
db_session=db_session,
user_id=user.id,
# Global curators can curate all groups they are member of
only_curator_groups=user.role != UserRole.GLOBAL_CURATOR,
)
user_curated_group_ids = set([group.id for group in user_curated_groups])
target_group_ids_set = set(target_group_ids)
if not target_group_ids_set.issubset(user_curated_group_ids):
detail = "Curators cannot control groups they don't curate"
logger.error(detail)
raise HTTPException(
status_code=400,
detail=detail,
)
def fetch_user_group(db_session: Session, user_group_id: int) -> UserGroup | None:
stmt = select(UserGroup).where(UserGroup.id == user_group_id)
return db_session.scalar(stmt)
@@ -222,6 +293,7 @@ def fetch_user_groups(
def fetch_user_groups_for_user(
db_session: Session,
user_id: UUID,
only_curator_groups: bool = False,
eager_load_for_snapshot: bool = False,
include_default: bool = True,
) -> Sequence[UserGroup]:
@@ -231,6 +303,8 @@ def fetch_user_groups_for_user(
.join(User, User.id == User__UserGroup.user_id) # type: ignore
.where(User.id == user_id) # type: ignore
)
if only_curator_groups:
stmt = stmt.where(User__UserGroup.is_curator == True) # noqa: E712
if not include_default:
stmt = stmt.where(UserGroup.is_default == False) # noqa: E712
if eager_load_for_snapshot:
@@ -456,6 +530,167 @@ def _mark_user_group__cc_pair_relationships_outdated__no_commit(
user_group__cc_pair_relationship.is_current = False
def _validate_curator_status__no_commit(
db_session: Session,
users: list[User],
) -> None:
for user in users:
# Check if the user is a curator in any of their groups
curator_relationships = (
db_session.query(User__UserGroup)
.filter(
User__UserGroup.user_id == user.id,
User__UserGroup.is_curator == True, # noqa: E712
)
.all()
)
# if the user is a curator in any of their groups, set their role to CURATOR
# otherwise, set their role to BASIC only if they were previously a CURATOR
if curator_relationships:
user.role = UserRole.CURATOR
elif user.role == UserRole.CURATOR:
user.role = UserRole.BASIC
db_session.add(user)
def remove_curator_status__no_commit(db_session: Session, user: User) -> None:
stmt = (
update(User__UserGroup)
.where(User__UserGroup.user_id == user.id)
.values(is_curator=False)
)
db_session.execute(stmt)
_validate_curator_status__no_commit(db_session, [user])
def _validate_curator_relationship_update_requester(
db_session: Session,
user_group_id: int,
user_making_change: User,
) -> None:
"""
This function validates that the user making the change has the necessary permissions
to update the curator relationship for the target user in the given user group.
"""
# Admins can update curator relationships for any group
if user_making_change.role == UserRole.ADMIN:
return
# check if the user making the change is a curator in the group they are changing the curator relationship for
user_making_change_curator_groups = fetch_user_groups_for_user(
db_session=db_session,
user_id=user_making_change.id,
# only check if the user making the change is a curator if they are a curator
# otherwise, they are a global_curator and can update the curator relationship
# for any group they are a member of
only_curator_groups=user_making_change.role == UserRole.CURATOR,
)
requestor_curator_group_ids = [
group.id for group in user_making_change_curator_groups
]
if user_group_id not in requestor_curator_group_ids:
raise ValueError(
f"user making change {user_making_change.email} is not a curator,"
f" admin, or global_curator for group '{user_group_id}'"
)
def _validate_curator_relationship_update_request(
db_session: Session,
user_group_id: int,
target_user: User,
) -> None:
"""
This function validates that the curator_relationship_update request itself is valid.
"""
if target_user.role == UserRole.ADMIN:
raise ValueError(
f"User '{target_user.email}' is an admin and therefore has all permissions "
"of a curator. If you'd like this user to only have curator permissions, "
"you must update their role to BASIC then assign them to be CURATOR in the "
"appropriate groups."
)
elif target_user.role == UserRole.GLOBAL_CURATOR:
raise ValueError(
f"User '{target_user.email}' is a global_curator and therefore has all "
"permissions of a curator for all groups. If you'd like this user to only "
"have curator permissions for a specific group, you must update their role "
"to BASIC then assign them to be CURATOR in the appropriate groups."
)
elif target_user.role not in [UserRole.CURATOR, UserRole.BASIC]:
raise ValueError(
f"This endpoint can only be used to update the curator relationship for "
"users with the CURATOR or BASIC role. \n"
f"Target user: {target_user.email} \n"
f"Target user role: {target_user.role} \n"
)
# check if the target user is in the group they are changing the curator relationship for
requested_user_groups = fetch_user_groups_for_user(
db_session=db_session,
user_id=target_user.id,
only_curator_groups=False,
)
group_ids = [group.id for group in requested_user_groups]
if user_group_id not in group_ids:
raise ValueError(
f"target user {target_user.email} is not in group '{user_group_id}'"
)
def update_user_curator_relationship(
db_session: Session,
user_group_id: int,
set_curator_request: SetCuratorRequest,
user_making_change: User,
) -> None:
target_user = fetch_user_by_id(db_session, set_curator_request.user_id)
if not target_user:
raise ValueError(f"User with id '{set_curator_request.user_id}' not found")
_validate_curator_relationship_update_request(
db_session=db_session,
user_group_id=user_group_id,
target_user=target_user,
)
_validate_curator_relationship_update_requester(
db_session=db_session,
user_group_id=user_group_id,
user_making_change=user_making_change,
)
logger.info(
f"user_making_change={user_making_change.email if user_making_change else 'None'} is "
f"updating the curator relationship for user={target_user.email} "
f"in group={user_group_id} to is_curator={set_curator_request.is_curator}"
)
relationship_to_update = (
db_session.query(User__UserGroup)
.filter(
User__UserGroup.user_group_id == user_group_id,
User__UserGroup.user_id == set_curator_request.user_id,
)
.first()
)
if relationship_to_update:
relationship_to_update.is_curator = set_curator_request.is_curator
else:
relationship_to_update = User__UserGroup(
user_group_id=user_group_id,
user_id=set_curator_request.user_id,
is_curator=True,
)
db_session.add(relationship_to_update)
_validate_curator_status__no_commit(db_session, [target_user])
db_session.commit()
def add_users_to_user_group(
db_session: Session,
user: User,
@@ -531,6 +766,13 @@ def update_user_group(
f"User(s) not found: {', '.join(str(user_id) for user_id in missing_users)}"
)
# LEAVING THIS HERE FOR NOW FOR GIVING DIFFERENT ROLES
# ACCESS TO DIFFERENT PERMISSIONS
# if (removed_user_ids or added_user_ids) and (
# not user or user.role != UserRole.ADMIN
# ):
# raise ValueError("Only admins can add or remove users from user groups")
if removed_user_ids:
_cleanup_user__user_group_relationships__no_commit(
db_session=db_session,
@@ -561,6 +803,20 @@ def update_user_group(
if cc_pairs_updated and not DISABLE_VECTOR_DB:
db_user_group.is_up_to_date = False
removed_users = db_session.scalars(
select(User).where(User.id.in_(removed_user_ids)) # type: ignore
).unique()
# Filter out admin and global curator users before validating curator status
users_to_validate = [
user
for user in removed_users
if user.role not in [UserRole.ADMIN, UserRole.GLOBAL_CURATOR]
]
if users_to_validate:
_validate_curator_status__no_commit(db_session, users_to_validate)
# update "time_updated" to now
db_user_group.time_last_modified_by_user = func.now()
@@ -740,72 +996,3 @@ def set_group_permission__no_commit(
db_session.flush()
recompute_permissions_for_group__no_commit(group_id, db_session)
def set_group_permissions_bulk__no_commit(
group_id: int,
desired_permissions: set[Permission],
granted_by: UUID,
db_session: Session,
) -> list[Permission]:
"""Set the full desired permission state for a group in one pass.
Enables permissions in `desired_permissions`, disables any toggleable
permission not in the set. Non-toggleable permissions are ignored.
Calls recompute once at the end. Does NOT commit.
Returns the resulting list of enabled permissions.
"""
existing_grants = (
db_session.execute(
select(PermissionGrant)
.where(PermissionGrant.group_id == group_id)
.with_for_update()
)
.scalars()
.all()
)
grant_map: dict[Permission, PermissionGrant] = {
g.permission: g for g in existing_grants
}
# Enable desired permissions
for perm in desired_permissions:
existing = grant_map.get(perm)
if existing is not None:
if existing.is_deleted:
existing.is_deleted = False
existing.granted_by = granted_by
existing.granted_at = func.now()
else:
db_session.add(
PermissionGrant(
group_id=group_id,
permission=perm,
grant_source=GrantSource.USER,
granted_by=granted_by,
)
)
# Disable toggleable permissions not in the desired set
for perm, grant in grant_map.items():
if perm not in desired_permissions and not grant.is_deleted:
grant.is_deleted = True
db_session.flush()
recompute_permissions_for_group__no_commit(group_id, db_session)
# Return the resulting enabled set
return [
g.permission
for g in db_session.execute(
select(PermissionGrant).where(
PermissionGrant.group_id == group_id,
PermissionGrant.is_deleted.is_(False),
)
)
.scalars()
.all()
]

View File

@@ -1,7 +1,9 @@
from datetime import datetime
from http import HTTPStatus
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from ee.onyx.background.celery.tasks.doc_permission_syncing.tasks import (
@@ -10,16 +12,13 @@ from ee.onyx.background.celery.tasks.doc_permission_syncing.tasks import (
from ee.onyx.background.celery.tasks.external_group_syncing.tasks import (
try_creating_external_group_sync_task,
)
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_curator_or_admin_user
from onyx.background.celery.versioned_apps.client import app as client_app
from onyx.db.connector_credential_pair import (
get_connector_credential_pair_from_id_for_user,
)
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import Permission
from onyx.db.models import User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.redis.redis_connector import RedisConnector
from onyx.redis.redis_pool import get_redis_client
from onyx.server.models import StatusResponse
@@ -33,7 +32,7 @@ router = APIRouter(prefix="/manage")
@router.get("/admin/cc-pair/{cc_pair_id}/sync-permissions")
def get_cc_pair_latest_sync(
cc_pair_id: int,
user: User = Depends(require_permission(Permission.READ_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> datetime | None:
cc_pair = get_connector_credential_pair_from_id_for_user(
@@ -43,9 +42,9 @@ def get_cc_pair_latest_sync(
get_editable=False,
)
if not cc_pair:
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"CC Pair not found for current user's permissions",
raise HTTPException(
status_code=400,
detail="cc_pair not found for current user's permissions",
)
return cc_pair.last_time_perm_sync
@@ -54,7 +53,7 @@ def get_cc_pair_latest_sync(
@router.post("/admin/cc-pair/{cc_pair_id}/sync-permissions")
def sync_cc_pair(
cc_pair_id: int,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> StatusResponse[None]:
"""Triggers permissions sync on a particular cc_pair immediately"""
@@ -67,18 +66,18 @@ def sync_cc_pair(
get_editable=False,
)
if not cc_pair:
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"Connection not found for current user's permissions",
raise HTTPException(
status_code=400,
detail="Connection not found for current user's permissions",
)
r = get_redis_client()
redis_connector = RedisConnector(tenant_id, cc_pair_id)
if redis_connector.permissions.fenced:
raise OnyxError(
OnyxErrorCode.CONFLICT,
"Permissions sync task already in progress.",
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="Permissions sync task already in progress.",
)
logger.info(
@@ -91,9 +90,9 @@ def sync_cc_pair(
client_app, cc_pair_id, r, tenant_id
)
if not payload_id:
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"Permissions sync task creation failed.",
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Permissions sync task creation failed.",
)
logger.info(f"Permissions sync queued: cc_pair={cc_pair_id} id={payload_id}")
@@ -107,7 +106,7 @@ def sync_cc_pair(
@router.get("/admin/cc-pair/{cc_pair_id}/sync-groups")
def get_cc_pair_latest_group_sync(
cc_pair_id: int,
user: User = Depends(require_permission(Permission.READ_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> datetime | None:
cc_pair = get_connector_credential_pair_from_id_for_user(
@@ -117,9 +116,9 @@ def get_cc_pair_latest_group_sync(
get_editable=False,
)
if not cc_pair:
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"CC Pair not found for current user's permissions",
raise HTTPException(
status_code=400,
detail="cc_pair not found for current user's permissions",
)
return cc_pair.last_time_external_group_sync
@@ -128,7 +127,7 @@ def get_cc_pair_latest_group_sync(
@router.post("/admin/cc-pair/{cc_pair_id}/sync-groups")
def sync_cc_pair_groups(
cc_pair_id: int,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> StatusResponse[None]:
"""Triggers group sync on a particular cc_pair immediately"""
@@ -141,18 +140,18 @@ def sync_cc_pair_groups(
get_editable=False,
)
if not cc_pair:
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"Connection not found for current user's permissions",
raise HTTPException(
status_code=400,
detail="Connection not found for current user's permissions",
)
r = get_redis_client()
redis_connector = RedisConnector(tenant_id, cc_pair_id)
if redis_connector.external_group_sync.fenced:
raise OnyxError(
OnyxErrorCode.CONFLICT,
"External group sync task already in progress.",
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="External group sync task already in progress.",
)
logger.info(
@@ -165,9 +164,9 @@ def sync_cc_pair_groups(
client_app, cc_pair_id, r, tenant_id
)
if not payload_id:
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"External group sync task creation failed.",
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="External group sync task creation failed.",
)
logger.info(f"External group sync queued: cc_pair={cc_pair_id} id={payload_id}")

View File

@@ -25,7 +25,7 @@ logger = setup_logger()
def prepare_authorization_request(
connector: DocumentSource,
redirect_on_success: str | None,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
tenant_id: str | None = Depends(get_current_tenant_id),
) -> JSONResponse:
"""Used by the frontend to generate the url for the user's browser during auth request.

View File

@@ -147,7 +147,7 @@ class ConfluenceCloudOAuth:
def confluence_oauth_callback(
code: str,
state: str,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
tenant_id: str | None = Depends(get_current_tenant_id),
) -> JSONResponse:
@@ -259,7 +259,7 @@ def confluence_oauth_callback(
@router.get("/connector/confluence/accessible-resources")
def confluence_oauth_accessible_resources(
credential_id: int,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
tenant_id: str | None = Depends(get_current_tenant_id), # noqa: ARG001
) -> JSONResponse:
@@ -326,7 +326,7 @@ def confluence_oauth_finalize(
cloud_id: str,
cloud_name: str,
cloud_url: str,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
tenant_id: str | None = Depends(get_current_tenant_id), # noqa: ARG001
) -> JSONResponse:

View File

@@ -115,7 +115,7 @@ class GoogleDriveOAuth:
def handle_google_drive_oauth_callback(
code: str,
state: str,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
tenant_id: str | None = Depends(get_current_tenant_id),
) -> JSONResponse:

View File

@@ -99,7 +99,7 @@ class SlackOAuth:
def handle_slack_oauth_callback(
code: str,
state: str,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
tenant_id: str | None = Depends(get_current_tenant_id),
) -> JSONResponse:

View File

@@ -154,7 +154,7 @@ def snapshot_from_chat_session(
@router.get("/admin/chat-sessions")
def admin_get_chat_sessions(
user_id: UUID,
_: User = Depends(require_permission(Permission.READ_QUERY_HISTORY)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> ChatSessionsResponse:
# we specifically don't allow this endpoint if "anonymized" since
@@ -197,7 +197,7 @@ def get_chat_session_history(
feedback_type: QAFeedbackType | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
_: User = Depends(require_permission(Permission.READ_QUERY_HISTORY)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> PaginatedReturn[ChatSessionMinimal]:
ensure_query_history_is_enabled(disallowed=[QueryHistoryType.DISABLED])
@@ -235,7 +235,7 @@ def get_chat_session_history(
@router.get("/admin/chat-session-history/{chat_session_id}")
def get_chat_session_admin(
chat_session_id: UUID,
_: User = Depends(require_permission(Permission.READ_QUERY_HISTORY)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> ChatSessionSnapshot:
ensure_query_history_is_enabled(disallowed=[QueryHistoryType.DISABLED])
@@ -270,7 +270,7 @@ def get_chat_session_admin(
@router.get("/admin/query-history/list")
def list_all_query_history_exports(
_: User = Depends(require_permission(Permission.READ_QUERY_HISTORY)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> list[QueryHistoryExport]:
ensure_query_history_is_enabled(disallowed=[QueryHistoryType.DISABLED])
@@ -298,7 +298,7 @@ def list_all_query_history_exports(
@router.post("/admin/query-history/start-export", tags=PUBLIC_API_TAGS)
def start_query_history_export(
_: User = Depends(require_permission(Permission.READ_QUERY_HISTORY)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
start: datetime | None = None,
end: datetime | None = None,
@@ -345,7 +345,7 @@ def start_query_history_export(
@router.get("/admin/query-history/export-status", tags=PUBLIC_API_TAGS)
def get_query_history_export_status(
request_id: str,
_: User = Depends(require_permission(Permission.READ_QUERY_HISTORY)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> dict[str, str]:
ensure_query_history_is_enabled(disallowed=[QueryHistoryType.DISABLED])
@@ -379,7 +379,7 @@ def get_query_history_export_status(
@router.get("/admin/query-history/download", tags=PUBLIC_API_TAGS)
def download_query_history_csv(
request_id: str,
_: User = Depends(require_permission(Permission.READ_QUERY_HISTORY)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> StreamingResponse:
ensure_query_history_is_enabled(disallowed=[QueryHistoryType.DISABLED])

View File

@@ -59,10 +59,10 @@ from onyx.db.models import ScimToken
from onyx.db.models import ScimUserMapping
from onyx.db.models import User
from onyx.db.models import UserGroup
from onyx.db.models import UserRole
from onyx.db.permissions import recompute_permissions_for_group__no_commit
from onyx.db.permissions import recompute_user_permissions__no_commit
from onyx.db.users import assign_user_to_default_groups__no_commit
from onyx.db.users import user_is_admin
from onyx.utils.logger import setup_logger
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
@@ -494,6 +494,7 @@ def create_user(
user = User(
email=email,
hashed_password=_pw_helper.hash(_pw_helper.generate()),
role=UserRole.BASIC,
account_type=AccountType.STANDARD,
is_active=user_resource.active,
is_verified=True,
@@ -581,7 +582,7 @@ def replace_user(
# Reconcile default-group membership on reactivation
if is_reactivation:
assign_user_to_default_groups__no_commit(
db_session, user, is_admin=user_is_admin(user)
db_session, user, is_admin=(user.role == UserRole.ADMIN)
)
new_external_id = user_resource.externalId
@@ -681,7 +682,7 @@ def patch_user(
# Reconcile default-group membership on reactivation
if is_reactivation:
assign_user_to_default_groups__no_commit(
db_session, user, is_admin=user_is_admin(user)
db_session, user, is_admin=(user.role == UserRole.ADMIN)
)
# Build updated fields by merging PATCH enterprise data with current values

View File

@@ -5,9 +5,10 @@ from fastapi import Depends
from sqlalchemy.orm import Session
from ee.onyx.db.token_limit import fetch_all_user_group_token_rate_limits_by_group
from ee.onyx.db.token_limit import fetch_user_group_token_rate_limits_for_group
from ee.onyx.db.token_limit import fetch_user_group_token_rate_limits_for_user
from ee.onyx.db.token_limit import insert_user_group_token_rate_limit
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_curator_or_admin_user
from onyx.configs.constants import PUBLIC_API_TAGS
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import Permission
@@ -47,14 +48,15 @@ def get_all_group_token_limit_settings(
@router.get("/user-group/{group_id}")
def get_group_token_limit_settings(
group_id: int,
_: User = Depends(require_permission(Permission.MANAGE_USER_GROUPS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> list[TokenRateLimitDisplay]:
return [
TokenRateLimitDisplay.from_db(token_rate_limit)
for token_rate_limit in fetch_user_group_token_rate_limits_for_group(
for token_rate_limit in fetch_user_group_token_rate_limits_for_user(
db_session=db_session,
group_id=group_id,
user=user,
)
]
@@ -63,7 +65,7 @@ def get_group_token_limit_settings(
def create_group_token_limit_settings(
group_id: int,
token_limit_settings: TokenRateLimitArgs,
_: User = Depends(require_permission(Permission.MANAGE_USER_GROUPS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> TokenRateLimitDisplay:
rate_limit_display = TokenRateLimitDisplay.from_db(

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -12,26 +13,28 @@ from ee.onyx.db.user_group import fetch_user_groups_for_user
from ee.onyx.db.user_group import insert_user_group
from ee.onyx.db.user_group import prepare_user_group_for_deletion
from ee.onyx.db.user_group import rename_user_group
from ee.onyx.db.user_group import set_group_permissions_bulk__no_commit
from ee.onyx.db.user_group import set_group_permission__no_commit
from ee.onyx.db.user_group import update_user_curator_relationship
from ee.onyx.db.user_group import update_user_group
from ee.onyx.server.user_group.models import AddUsersToUserGroupRequest
from ee.onyx.server.user_group.models import BulkSetPermissionsRequest
from ee.onyx.server.user_group.models import MinimalUserGroupSnapshot
from ee.onyx.server.user_group.models import SetCuratorRequest
from ee.onyx.server.user_group.models import SetPermissionRequest
from ee.onyx.server.user_group.models import SetPermissionResponse
from ee.onyx.server.user_group.models import UpdateGroupAgentsRequest
from ee.onyx.server.user_group.models import UserGroup
from ee.onyx.server.user_group.models import UserGroupCreate
from ee.onyx.server.user_group.models import UserGroupRename
from ee.onyx.server.user_group.models import UserGroupUpdate
from onyx.auth.permissions import get_effective_permissions
from onyx.auth.permissions import NON_TOGGLEABLE_PERMISSIONS
from onyx.auth.permissions import PERMISSION_REGISTRY
from onyx.auth.permissions import PermissionRegistryEntry
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_curator_or_admin_user
from onyx.configs.app_configs import DISABLE_VECTOR_DB
from onyx.configs.constants import PUBLIC_API_TAGS
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import Permission
from onyx.db.models import User
from onyx.db.models import UserRole
from onyx.db.persona import get_persona_by_id
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
@@ -45,15 +48,24 @@ router = APIRouter(prefix="/manage", tags=PUBLIC_API_TAGS)
@router.get("/admin/user-group")
def list_user_groups(
include_default: bool = False,
_: User = Depends(require_permission(Permission.READ_USER_GROUPS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> list[UserGroup]:
user_groups = fetch_user_groups(
db_session,
only_up_to_date=False,
eager_load_for_snapshot=True,
include_default=include_default,
)
if user.role == UserRole.ADMIN:
user_groups = fetch_user_groups(
db_session,
only_up_to_date=False,
eager_load_for_snapshot=True,
include_default=include_default,
)
else:
user_groups = fetch_user_groups_for_user(
db_session=db_session,
user_id=user.id,
only_curator_groups=user.role == UserRole.CURATOR,
eager_load_for_snapshot=True,
include_default=include_default,
)
return [UserGroup.from_model(user_group) for user_group in user_groups]
@@ -63,7 +75,7 @@ def list_minimal_user_groups(
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
db_session: Session = Depends(get_session),
) -> list[MinimalUserGroupSnapshot]:
if Permission.FULL_ADMIN_PANEL_ACCESS in get_effective_permissions(user):
if user.role == UserRole.ADMIN:
user_groups = fetch_user_groups(
db_session,
only_up_to_date=False,
@@ -80,71 +92,62 @@ def list_minimal_user_groups(
]
@router.get("/admin/permissions/registry")
def get_permission_registry(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> list[PermissionRegistryEntry]:
return PERMISSION_REGISTRY
@router.get("/admin/user-group/{user_group_id}/permissions")
def get_user_group_permissions(
user_group_id: int,
_: User = Depends(require_permission(Permission.MANAGE_USER_GROUPS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> list[Permission]:
group = fetch_user_group(db_session, user_group_id)
if group is None:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "User group not found")
return [
grant.permission
for grant in group.permission_grants
if not grant.is_deleted and grant.permission not in NON_TOGGLEABLE_PERMISSIONS
grant.permission for grant in group.permission_grants if not grant.is_deleted
]
@router.put("/admin/user-group/{user_group_id}/permissions")
def set_user_group_permissions(
def set_user_group_permission(
user_group_id: int,
request: BulkSetPermissionsRequest,
user: User = Depends(require_permission(Permission.MANAGE_USER_GROUPS)),
request: SetPermissionRequest,
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> list[Permission]:
) -> SetPermissionResponse:
group = fetch_user_group(db_session, user_group_id)
if group is None:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "User group not found")
non_toggleable = [p for p in request.permissions if p in NON_TOGGLEABLE_PERMISSIONS]
if non_toggleable:
if request.permission in NON_TOGGLEABLE_PERMISSIONS:
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
f"Permissions {non_toggleable} cannot be toggled via this endpoint",
f"Permission '{request.permission}' cannot be toggled via this endpoint",
)
result = set_group_permissions_bulk__no_commit(
set_group_permission__no_commit(
group_id=user_group_id,
desired_permissions=set(request.permissions),
permission=request.permission,
enabled=request.enabled,
granted_by=user.id,
db_session=db_session,
)
db_session.commit()
return result
return SetPermissionResponse(permission=request.permission, enabled=request.enabled)
@router.post("/admin/user-group")
def create_user_group(
user_group: UserGroupCreate,
_: User = Depends(require_permission(Permission.MANAGE_USER_GROUPS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> UserGroup:
try:
db_user_group = insert_user_group(db_session, user_group)
except IntegrityError:
raise OnyxError(
OnyxErrorCode.DUPLICATE_RESOURCE,
raise HTTPException(
400,
f"User group with name '{user_group.name}' already exists. Please "
"choose a different name.",
+ "choose a different name.",
)
return UserGroup.from_model(db_user_group)
@@ -152,7 +155,7 @@ def create_user_group(
@router.patch("/admin/user-group/rename")
def rename_user_group_endpoint(
rename_request: UserGroupRename,
_: User = Depends(require_permission(Permission.MANAGE_USER_GROUPS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> UserGroup:
group = fetch_user_group(db_session, rename_request.id)
@@ -182,7 +185,7 @@ def rename_user_group_endpoint(
def patch_user_group(
user_group_id: int,
user_group_update: UserGroupUpdate,
user: User = Depends(require_permission(Permission.MANAGE_USER_GROUPS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> UserGroup:
try:
@@ -195,14 +198,14 @@ def patch_user_group(
)
)
except ValueError as e:
raise OnyxError(OnyxErrorCode.NOT_FOUND, str(e))
raise HTTPException(status_code=404, detail=str(e))
@router.post("/admin/user-group/{user_group_id}/add-users")
def add_users(
user_group_id: int,
add_users_request: AddUsersToUserGroupRequest,
user: User = Depends(require_permission(Permission.MANAGE_USER_GROUPS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> UserGroup:
try:
@@ -215,13 +218,32 @@ def add_users(
)
)
except ValueError as e:
raise OnyxError(OnyxErrorCode.NOT_FOUND, str(e))
raise HTTPException(status_code=404, detail=str(e))
@router.post("/admin/user-group/{user_group_id}/set-curator")
def set_user_curator(
user_group_id: int,
set_curator_request: SetCuratorRequest,
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> None:
try:
update_user_curator_relationship(
db_session=db_session,
user_group_id=user_group_id,
set_curator_request=set_curator_request,
user_making_change=user,
)
except ValueError as e:
logger.error(f"Error setting user curator: {e}")
raise HTTPException(status_code=404, detail=str(e))
@router.delete("/admin/user-group/{user_group_id}")
def delete_user_group(
user_group_id: int,
_: User = Depends(require_permission(Permission.MANAGE_USER_GROUPS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> None:
group = fetch_user_group(db_session, user_group_id)
@@ -230,7 +252,7 @@ def delete_user_group(
try:
prepare_user_group_for_deletion(db_session, user_group_id)
except ValueError as e:
raise OnyxError(OnyxErrorCode.NOT_FOUND, str(e))
raise HTTPException(status_code=404, detail=str(e))
if DISABLE_VECTOR_DB:
user_group = fetch_user_group(db_session, user_group_id)
@@ -242,7 +264,7 @@ def delete_user_group(
def update_group_agents(
user_group_id: int,
request: UpdateGroupAgentsRequest,
user: User = Depends(require_permission(Permission.MANAGE_USER_GROUPS)),
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> None:
for agent_id in request.added_agent_ids:

View File

@@ -17,6 +17,7 @@ class UserGroup(BaseModel):
id: int
name: str
users: list[UserInfo]
curator_ids: list[UUID]
cc_pairs: list[ConnectorCredentialPairDescriptor]
document_sets: list[DocumentSet]
personas: list[PersonaSnapshot]
@@ -36,7 +37,7 @@ class UserGroup(BaseModel):
is_active=user.is_active,
is_superuser=user.is_superuser,
is_verified=user.is_verified,
account_type=user.account_type,
role=user.role,
preferences=UserPreferences(
default_model=user.default_model,
chosen_assistants=user.chosen_assistants,
@@ -44,6 +45,11 @@ class UserGroup(BaseModel):
)
for user in user_group_model.users
],
curator_ids=[
user.user_id
for user in user_group_model.user_group_relationships
if user.is_curator and user.user_id is not None
],
cc_pairs=[
ConnectorCredentialPairDescriptor(
id=cc_pair_relationship.cc_pair.id,
@@ -108,6 +114,11 @@ class UserGroupRename(BaseModel):
name: str
class SetCuratorRequest(BaseModel):
user_id: UUID
is_curator: bool
class UpdateGroupAgentsRequest(BaseModel):
added_agent_ids: list[int]
removed_agent_ids: list[int]
@@ -121,7 +132,3 @@ class SetPermissionRequest(BaseModel):
class SetPermissionResponse(BaseModel):
permission: Permission
enabled: bool
class BulkSetPermissionsRequest(BaseModel):
permissions: list[Permission]

View File

@@ -2,11 +2,11 @@ from collections.abc import Mapping
from typing import Any
from typing import cast
from onyx.auth.schemas import UserRole
from onyx.configs.constants import ANONYMOUS_USER_EMAIL
from onyx.configs.constants import ANONYMOUS_USER_INFO_ID
from onyx.configs.constants import KV_ANONYMOUS_USER_PERSONALIZATION_KEY
from onyx.configs.constants import KV_ANONYMOUS_USER_PREFERENCES_KEY
from onyx.db.enums import AccountType
from onyx.key_value_store.store import KeyValueStore
from onyx.key_value_store.store import KvKeyNotFoundError
from onyx.server.manage.models import UserInfo
@@ -55,7 +55,7 @@ def fetch_anonymous_user_info(store: KeyValueStore) -> UserInfo:
is_active=True,
is_superuser=False,
is_verified=True,
account_type=AccountType.ANONYMOUS,
role=UserRole.LIMITED,
preferences=load_anonymous_user_preferences(store),
personalization=personalization,
is_anonymous_user=True,

View File

@@ -10,9 +10,9 @@ from pydantic import BaseModel
from onyx.auth.constants import API_KEY_LENGTH
from onyx.auth.constants import API_KEY_PREFIX
from onyx.auth.constants import DEPRECATED_API_KEY_PREFIX
from onyx.auth.schemas import UserRole
from onyx.auth.utils import get_hashed_bearer_token_from_request
from onyx.configs.app_configs import API_KEY_HASH_ROUNDS
from onyx.server.models import UserGroupInfo
from shared_configs.configs import MULTI_TENANT
@@ -21,7 +21,7 @@ class ApiKeyDescriptor(BaseModel):
api_key_display: str
api_key: str | None = None # only present on initial creation
api_key_name: str | None = None
groups: list[UserGroupInfo]
api_key_role: UserRole
user_id: uuid.UUID

View File

@@ -11,15 +11,13 @@ from collections.abc import Coroutine
from typing import Any
from fastapi import Depends
from pydantic import BaseModel
from pydantic import field_validator
from onyx.auth.users import current_user
from onyx.db.enums import Permission
from onyx.db.models import User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.utils.logger import setup_logger
from onyx.utils.variable_functionality import global_version
logger = setup_logger()
@@ -31,14 +29,14 @@ IMPLIED_PERMISSIONS: dict[str, set[str]] = {
Permission.MANAGE_AGENTS.value: {
Permission.ADD_AGENTS.value,
Permission.READ_AGENTS.value,
Permission.READ_DOCUMENT_SETS.value,
},
Permission.MANAGE_DOCUMENT_SETS.value: {
Permission.READ_DOCUMENT_SETS.value,
Permission.READ_CONNECTORS.value,
Permission.READ_USER_GROUPS.value,
},
Permission.ADD_CONNECTORS.value: {Permission.READ_CONNECTORS.value},
Permission.MANAGE_CONNECTORS.value: {
Permission.ADD_CONNECTORS.value,
Permission.READ_CONNECTORS.value,
},
Permission.MANAGE_USER_GROUPS.value: {
@@ -46,15 +44,6 @@ IMPLIED_PERMISSIONS: dict[str, set[str]] = {
Permission.READ_DOCUMENT_SETS.value,
Permission.READ_AGENTS.value,
Permission.READ_USERS.value,
Permission.READ_USER_GROUPS.value,
},
Permission.MANAGE_LLMS.value: {
Permission.READ_USER_GROUPS.value,
Permission.READ_AGENTS.value,
Permission.READ_USERS.value,
},
Permission.MANAGE_SERVICE_ACCOUNT_API_KEYS.value: {
Permission.READ_USER_GROUPS.value,
},
}
@@ -69,138 +58,9 @@ NON_TOGGLEABLE_PERMISSIONS: frozenset[Permission] = frozenset(
Permission.READ_DOCUMENT_SETS,
Permission.READ_AGENTS,
Permission.READ_USERS,
Permission.READ_USER_GROUPS,
}
)
# Permissions auto-granted to all users in Community Edition.
# In CE there is no group-permission UI, so these capabilities must be
# available without explicit grants. In EE they are controlled normally
# via group permissions.
CE_UNGATED_PERMISSIONS: frozenset[Permission] = frozenset(
{
Permission.ADD_AGENTS,
}
)
class PermissionRegistryEntry(BaseModel):
"""A UI-facing permission row served by GET /admin/permissions/registry.
The field_validator ensures non-toggleable permissions (BASIC_ACCESS,
FULL_ADMIN_PANEL_ACCESS, READ_*) can never appear in the registry.
"""
id: str
display_name: str
description: str
permissions: list[Permission]
group: int
@field_validator("permissions")
@classmethod
def must_be_toggleable(cls, v: list[Permission]) -> list[Permission]:
for p in v:
if p in NON_TOGGLEABLE_PERMISSIONS:
raise ValueError(
f"Permission '{p.value}' is not toggleable and "
"cannot be included in the permission registry"
)
return v
# Registry of toggleable permissions exposed to the admin UI.
# Single source of truth for display names, descriptions, grouping,
# and which backend tokens each UI row controls.
# The frontend fetches this via GET /admin/permissions/registry
# and only adds icon mapping locally.
PERMISSION_REGISTRY: list[PermissionRegistryEntry] = [
# Group 0 — System Configuration
PermissionRegistryEntry(
id="manage_llms",
display_name="Manage LLMs",
description="Add and update configurations for language models (LLMs).",
permissions=[Permission.MANAGE_LLMS],
group=0,
),
PermissionRegistryEntry(
id="manage_connectors_and_document_sets",
display_name="Manage Connectors & Document Sets",
description="Add and update connectors and document sets.",
permissions=[
Permission.MANAGE_CONNECTORS,
Permission.MANAGE_DOCUMENT_SETS,
],
group=0,
),
PermissionRegistryEntry(
id="manage_actions",
display_name="Manage Actions",
description="Add and update custom tools and MCP/OpenAPI actions.",
permissions=[Permission.MANAGE_ACTIONS],
group=0,
),
# Group 1 — User & Access Management
PermissionRegistryEntry(
id="manage_groups",
display_name="Manage Groups",
description="Add and update user groups.",
permissions=[Permission.MANAGE_USER_GROUPS],
group=1,
),
PermissionRegistryEntry(
id="manage_service_accounts",
display_name="Manage Service Accounts",
description="Add and update service accounts and their API keys.",
permissions=[Permission.MANAGE_SERVICE_ACCOUNT_API_KEYS],
group=1,
),
PermissionRegistryEntry(
id="manage_bots",
display_name="Manage Slack/Discord Bots",
description="Add and update Onyx integrations with Slack or Discord.",
permissions=[Permission.MANAGE_BOTS],
group=1,
),
# Group 2 — Agents
PermissionRegistryEntry(
id="create_agents",
display_name="Create Agents",
description="Create and edit the user's own agents.",
permissions=[Permission.ADD_AGENTS],
group=2,
),
PermissionRegistryEntry(
id="manage_agents",
display_name="Manage Agents",
description="View and update all public and shared agents in the organization.",
permissions=[Permission.MANAGE_AGENTS],
group=2,
),
# Group 3 — Monitoring & Tokens
PermissionRegistryEntry(
id="view_agent_analytics",
display_name="View Agent Analytics",
description="View analytics for agents the group can manage.",
permissions=[Permission.READ_AGENT_ANALYTICS],
group=3,
),
PermissionRegistryEntry(
id="view_query_history",
display_name="View Query History",
description="View query history of everyone in the organization.",
permissions=[Permission.READ_QUERY_HISTORY],
group=3,
),
PermissionRegistryEntry(
id="create_user_access_token",
display_name="Create User Access Token",
description="Add and update the user's personal access tokens.",
permissions=[Permission.CREATE_USER_API_KEYS],
group=3,
),
]
def resolve_effective_permissions(granted: set[str]) -> set[str]:
"""Expand granted permissions with their implied permissions.
@@ -223,12 +83,7 @@ def resolve_effective_permissions(granted: set[str]) -> set[str]:
def get_effective_permissions(user: User) -> set[Permission]:
"""Read granted permissions from the column and expand implied permissions.
Admin-role users always receive all permissions regardless of the JSONB
column, maintaining backward compatibility with role-based access control.
"""
"""Read granted permissions from the column and expand implied permissions."""
granted: set[Permission] = set()
for p in user.effective_permissions:
try:
@@ -237,26 +92,10 @@ def get_effective_permissions(user: User) -> set[Permission]:
logger.warning(f"Skipping unknown permission '{p}' for user {user.id}")
if Permission.FULL_ADMIN_PANEL_ACCESS in granted:
return set(Permission)
if not global_version.is_ee_version():
granted |= CE_UNGATED_PERMISSIONS
expanded = resolve_effective_permissions({p.value for p in granted})
return {Permission(p) for p in expanded}
def has_permission(user: User, permission: Permission) -> bool:
"""Check whether *user* holds *permission* (directly or via implication/admin override)."""
return permission in get_effective_permissions(user)
def _get_current_user() -> Any:
"""Lazy import to break circular dependency between permissions and users modules."""
from onyx.auth.users import current_user
return current_user
def require_permission(
required: Permission,
) -> Callable[..., Coroutine[Any, Any, User]]:
@@ -268,9 +107,7 @@ def require_permission(
...
"""
async def dependency(
user: User = Depends(_get_current_user()),
) -> User:
async def dependency(user: User = Depends(current_user)) -> User:
effective = get_effective_permissions(user)
if Permission.FULL_ADMIN_PANEL_ACCESS in effective:

View File

@@ -38,10 +38,11 @@ class UserRole(str, Enum):
class UserRead(schemas.BaseUser[uuid.UUID]):
account_type: AccountType
role: UserRole
class UserCreate(schemas.BaseUserCreate):
role: UserRole = UserRole.BASIC
account_type: AccountType = AccountType.STANDARD
tenant_id: str | None = None
# Captcha token for cloud signup protection (optional, only used when captcha is enabled)
@@ -66,8 +67,10 @@ class UserCreate(schemas.BaseUserCreate):
class UserUpdate(schemas.BaseUserUpdate):
"""Intentionally empty: keeps account_type and permissions out of the
fastapi-users PATCH endpoints."""
"""
Role updates are not allowed through the user update endpoint for security reasons
Role changes should be handled through a separate, admin-only process
"""
class AuthBackend(str, Enum):

View File

@@ -77,9 +77,9 @@ from onyx.auth.invited_users import get_invited_users
from onyx.auth.invited_users import remove_user_from_invited_users
from onyx.auth.jwt import verify_jwt_token
from onyx.auth.pat import get_hashed_pat_from_request
from onyx.auth.permissions import has_permission
from onyx.auth.schemas import AuthBackend
from onyx.auth.schemas import UserCreate
from onyx.auth.schemas import UserRole
from onyx.configs.app_configs import AUTH_BACKEND
from onyx.configs.app_configs import AUTH_COOKIE_EXPIRE_TIME_SECONDS
from onyx.configs.app_configs import AUTH_TYPE
@@ -114,12 +114,12 @@ from onyx.db.auth import get_access_token_db
from onyx.db.auth import get_default_admin_user_emails
from onyx.db.auth import get_user_count
from onyx.db.auth import get_user_db
from onyx.db.auth import SQLAlchemyUserAdminDB
from onyx.db.engine.async_sql_engine import get_async_session
from onyx.db.engine.async_sql_engine import get_async_session_context_manager
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.engine.sql_engine import get_session_with_tenant
from onyx.db.enums import AccountType
from onyx.db.enums import Permission
from onyx.db.models import AccessToken
from onyx.db.models import OAuthAccount
from onyx.db.models import Persona
@@ -158,8 +158,7 @@ REGISTER_INVITE_ONLY_CODE = "REGISTER_INVITE_ONLY"
def is_user_admin(user: User) -> bool:
return has_permission(user, Permission.FULL_ADMIN_PANEL_ACCESS)
return user.role == UserRole.ADMIN
def verify_auth_setting() -> None:
@@ -361,7 +360,7 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
)(user_email)
async with get_async_session_context_manager(tenant_id) as db_session:
if MULTI_TENANT:
tenant_user_db = SQLAlchemyUserDatabase[User, uuid.UUID](
tenant_user_db = SQLAlchemyUserAdminDB[User, uuid.UUID](
db_session, User, OAuthAccount
)
user = await tenant_user_db.get_by_email(user_email)
@@ -457,16 +456,20 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
# Single-tenant: Check invite list (skips if SAML/OIDC or no list configured)
verify_email_is_invited(user_create.email)
if MULTI_TENANT:
tenant_user_db = SQLAlchemyUserDatabase[User, uuid.UUID](
tenant_user_db = SQLAlchemyUserAdminDB[User, uuid.UUID](
db_session, User, OAuthAccount
)
self.user_db = tenant_user_db
user_count = await get_user_count()
is_admin = (
user_count == 0
or user_create.email in get_default_admin_user_emails()
)
if hasattr(user_create, "role"):
user_create.role = UserRole.BASIC
user_count = await get_user_count()
if (
user_count == 0
or user_create.email in get_default_admin_user_emails()
):
user_create.role = UserRole.ADMIN
# Check seat availability for new users (single-tenant only)
with get_session_with_current_tenant() as sync_db:
@@ -509,7 +512,7 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
# object triggers a sync lazy-load which raises MissingGreenlet
# in this async context.
user_id = user.id
self._upgrade_user_to_standard__sync(user_id, user_create, is_admin)
self._upgrade_user_to_standard__sync(user_id, user_create)
# Expire so the async session re-fetches the row updated by
# the sync session above.
self.user_db.session.expire(user)
@@ -537,7 +540,7 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
# object triggers a sync lazy-load which raises MissingGreenlet
# in this async context.
user_id = user.id
self._upgrade_user_to_standard__sync(user_id, user_create, is_admin)
self._upgrade_user_to_standard__sync(user_id, user_create)
# Expire so the async session re-fetches the row updated by
# the sync session above.
self.user_db.session.expire(user)
@@ -582,7 +585,6 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
self,
user_id: uuid.UUID,
user_create: UserCreate,
is_admin: bool,
) -> None:
"""Upgrade a non-web user to STANDARD and assign default groups atomically.
@@ -596,11 +598,12 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
user_create.password
)
sync_user.is_verified = user_create.is_verified or False
sync_user.role = user_create.role
sync_user.account_type = AccountType.STANDARD
assign_user_to_default_groups__no_commit(
sync_db,
sync_user,
is_admin=is_admin,
is_admin=(user_create.role == UserRole.ADMIN),
)
sync_db.commit()
else:
@@ -682,7 +685,7 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
# NOTE(rkuo): If this UserManager is instantiated per connection
# should we even be doing this here?
if MULTI_TENANT:
tenant_user_db = SQLAlchemyUserDatabase[User, uuid.UUID](
tenant_user_db = SQLAlchemyUserAdminDB[User, uuid.UUID](
db_session, User, OAuthAccount
)
self.user_db = tenant_user_db
@@ -775,8 +778,8 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
user = user_by_session
# If the user is inactive, check seat availability before
# upgrading — otherwise they'd become an inactive user who
# still can't log in.
# upgrading role — otherwise they'd become an inactive BASIC
# user who still can't log in.
if not user.is_active:
with get_session_with_current_tenant() as sync_db:
enforce_seat_limit(sync_db)
@@ -788,6 +791,7 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
sync_user = sync_db.query(User).filter(User.id == user.id).first() # type: ignore[arg-type]
if sync_user:
sync_user.is_verified = is_verified_by_default
sync_user.role = UserRole.BASIC
sync_user.account_type = AccountType.STANDARD
if was_inactive:
sync_user.is_active = True
@@ -931,7 +935,7 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
"email": user.email,
"onyx_cloud_user_id": str(user.id),
"tenant_id": str(tenant_id) if tenant_id else None,
"account_type": user.account_type.value,
"role": user.role.value,
"is_first_user": user_count == 1,
"source": "marketing_site_signup",
"conversion_timestamp": datetime.now(timezone.utc).isoformat(),
@@ -1519,7 +1523,7 @@ async def _get_or_create_user_from_jwt(
verify_email_is_invited(email)
verify_email_domain(email)
user_db: SQLAlchemyUserDatabase[User, uuid.UUID] = SQLAlchemyUserDatabase(
user_db: SQLAlchemyUserAdminDB[User, uuid.UUID] = SQLAlchemyUserAdminDB(
async_db_session, User, OAuthAccount
)
user_manager = UserManager(user_db)
@@ -1611,6 +1615,7 @@ def get_anonymous_user() -> User:
is_active=True,
is_verified=True,
is_superuser=False,
role=UserRole.LIMITED,
account_type=AccountType.ANONYMOUS,
use_memories=False,
enable_memory_tool=False,
@@ -1684,6 +1689,18 @@ async def current_user(
return user
async def current_curator_or_admin_user(
user: User = Depends(current_user),
) -> User:
allowed_roles = {UserRole.GLOBAL_CURATOR, UserRole.CURATOR, UserRole.ADMIN}
if user.role not in allowed_roles:
raise BasicAuthenticationError(
detail="Access denied. User is not a curator or admin.",
)
return user
async def _get_user_from_token_data(token_data: dict) -> User | None:
"""Shared logic: token data dict → User object.

View File

@@ -0,0 +1,37 @@
# Celery Development Notes
This document is the local reference for Celery worker structure and task-writing rules in Onyx.
## Worker Types
Onyx uses multiple specialized workers:
1. `primary`: coordinates core background tasks and system-wide operations.
2. `docfetching`: fetches documents from connectors and schedules downstream work.
3. `docprocessing`: runs the indexing pipeline for fetched documents.
4. `light`: handles lightweight and fast operations.
5. `heavy`: handles more resource-intensive operations.
6. `kg_processing`: runs knowledge-graph processing and clustering.
7. `monitoring`: collects health and system metrics.
8. `user_file_processing`: processes user-uploaded files.
9. `beat`: schedules periodic work.
For actual implementation details, inspect:
- `backend/onyx/background/celery/apps/`
- `backend/onyx/background/celery/configs/`
- `backend/onyx/background/celery/tasks/`
## Task Rules
- Always use `@shared_task` rather than `@celery_app`.
- Put tasks under `background/celery/tasks/` or `ee/background/celery/tasks/`.
- Never enqueue a task without `expires=`. This is a hard requirement because stale queued work can
accumulate without bound.
- Do not rely on Celery time-limit enforcement. These workers run in thread pools, so timeout logic
must be implemented inside the task itself.
## Testing Note
If you change Celery worker code and want to validate it against a running local worker, the worker
usually needs to be restarted manually. There is no general auto-restart on code change.

View File

@@ -1,3 +1,4 @@
import time
from collections.abc import Generator
from collections.abc import Iterator
from collections.abc import Sequence
@@ -30,6 +31,8 @@ from onyx.connectors.models import HierarchyNode
from onyx.connectors.models import SlimDocument
from onyx.httpx.httpx_pool import HttpxPool
from onyx.indexing.indexing_heartbeat import IndexingHeartbeatInterface
from onyx.server.metrics.pruning_metrics import inc_pruning_rate_limit_error
from onyx.server.metrics.pruning_metrics import observe_pruning_enumeration_duration
from onyx.utils.logger import setup_logger
@@ -130,6 +133,7 @@ def _extract_from_batch(
def extract_ids_from_runnable_connector(
runnable_connector: BaseConnector,
callback: IndexingHeartbeatInterface | None = None,
connector_type: str = "unknown",
) -> SlimConnectorExtractionResult:
"""
Extract document IDs and hierarchy nodes from a runnable connector.
@@ -179,21 +183,38 @@ def extract_ids_from_runnable_connector(
)
# process raw batches to extract both IDs and hierarchy nodes
for doc_list in raw_batch_generator:
if callback and callback.should_stop():
raise RuntimeError(
"extract_ids_from_runnable_connector: Stop signal detected"
)
enumeration_start = time.monotonic()
try:
for doc_list in raw_batch_generator:
if callback and callback.should_stop():
raise RuntimeError(
"extract_ids_from_runnable_connector: Stop signal detected"
)
batch_result = _extract_from_batch(doc_list)
batch_ids = batch_result.raw_id_to_parent
batch_nodes = batch_result.hierarchy_nodes
doc_batch_processing_func(batch_ids)
all_raw_id_to_parent.update(batch_ids)
all_hierarchy_nodes.extend(batch_nodes)
batch_result = _extract_from_batch(doc_list)
batch_ids = batch_result.raw_id_to_parent
batch_nodes = batch_result.hierarchy_nodes
doc_batch_processing_func(batch_ids)
all_raw_id_to_parent.update(batch_ids)
all_hierarchy_nodes.extend(batch_nodes)
if callback:
callback.progress("extract_ids_from_runnable_connector", len(batch_ids))
if callback:
callback.progress("extract_ids_from_runnable_connector", len(batch_ids))
except Exception as e:
# Best-effort rate limit detection via string matching.
# Connectors surface rate limits inconsistently — some raise HTTP 429,
# some use SDK-specific exceptions (e.g. google.api_core.exceptions.ResourceExhausted)
# that may or may not include "rate limit" or "429" in the message.
# TODO(Bo): replace with a standard ConnectorRateLimitError exception that all
# connectors raise when rate limited, making this check precise.
error_str = str(e)
if "rate limit" in error_str.lower() or "429" in error_str:
inc_pruning_rate_limit_error(connector_type)
raise
finally:
observe_pruning_enumeration_duration(
time.monotonic() - enumeration_start, connector_type
)
return SlimConnectorExtractionResult(
raw_id_to_parent=all_raw_id_to_parent,

View File

@@ -72,6 +72,7 @@ from onyx.redis.redis_hierarchy import get_source_node_id_from_cache
from onyx.redis.redis_hierarchy import HierarchyNodeCacheEntry
from onyx.redis.redis_pool import get_redis_client
from onyx.redis.redis_pool import get_redis_replica_client
from onyx.server.metrics.pruning_metrics import observe_pruning_diff_duration
from onyx.server.runtime.onyx_runtime import OnyxRuntime
from onyx.server.utils import make_short_id
from onyx.utils.logger import format_error_for_logging
@@ -570,8 +571,9 @@ def connector_pruning_generator_task(
)
# Extract docs and hierarchy nodes from the source
connector_type = cc_pair.connector.source.value
extraction_result = extract_ids_from_runnable_connector(
runnable_connector, callback
runnable_connector, callback, connector_type=connector_type
)
all_connector_doc_ids = extraction_result.raw_id_to_parent
@@ -636,40 +638,46 @@ def connector_pruning_generator_task(
commit=True,
)
# a list of docs in our local index
all_indexed_document_ids = {
doc.id
for doc in get_documents_for_connector_credential_pair(
db_session=db_session,
connector_id=connector_id,
credential_id=credential_id,
diff_start = time.monotonic()
try:
# a list of docs in our local index
all_indexed_document_ids = {
doc.id
for doc in get_documents_for_connector_credential_pair(
db_session=db_session,
connector_id=connector_id,
credential_id=credential_id,
)
}
# generate list of docs to remove (no longer in the source)
doc_ids_to_remove = list(
all_indexed_document_ids - all_connector_doc_ids.keys()
)
}
# generate list of docs to remove (no longer in the source)
doc_ids_to_remove = list(
all_indexed_document_ids - all_connector_doc_ids.keys()
)
task_logger.info(
"Pruning set collected: "
f"cc_pair={cc_pair_id} "
f"connector_source={cc_pair.connector.source} "
f"docs_to_remove={len(doc_ids_to_remove)}"
)
task_logger.info(
"Pruning set collected: "
f"cc_pair={cc_pair_id} "
f"connector_source={cc_pair.connector.source} "
f"docs_to_remove={len(doc_ids_to_remove)}"
)
task_logger.info(
f"RedisConnector.prune.generate_tasks starting. cc_pair={cc_pair_id}"
)
tasks_generated = redis_connector.prune.generate_tasks(
set(doc_ids_to_remove), self.app, db_session, None
)
if tasks_generated is None:
return None
task_logger.info(
f"RedisConnector.prune.generate_tasks starting. cc_pair={cc_pair_id}"
)
tasks_generated = redis_connector.prune.generate_tasks(
set(doc_ids_to_remove), self.app, db_session, None
)
if tasks_generated is None:
return None
task_logger.info(
f"RedisConnector.prune.generate_tasks finished. cc_pair={cc_pair_id} tasks_generated={tasks_generated}"
)
task_logger.info(
f"RedisConnector.prune.generate_tasks finished. cc_pair={cc_pair_id} tasks_generated={tasks_generated}"
)
finally:
observe_pruning_diff_duration(
time.monotonic() - diff_start, connector_type
)
redis_connector.prune.generator_complete = tasks_generated

View File

@@ -3,7 +3,6 @@ import uuid
from fastapi_users.password import PasswordHelper
from sqlalchemy import delete
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import Session
@@ -12,6 +11,7 @@ from onyx.auth.api_key import ApiKeyDescriptor
from onyx.auth.api_key import build_displayable_api_key
from onyx.auth.api_key import generate_api_key
from onyx.auth.api_key import hash_api_key
from onyx.auth.schemas import UserRole
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 UNNAMED_KEY_PLACEHOLDER
@@ -21,8 +21,8 @@ from onyx.db.models import User
from onyx.db.models import User__UserGroup
from onyx.db.models import UserGroup
from onyx.db.permissions import recompute_user_permissions__no_commit
from onyx.db.users import assign_user_to_default_groups__no_commit
from onyx.server.api_key.models import APIKeyArgs
from onyx.server.models import UserGroupInfo
from onyx.utils.logger import setup_logger
from shared_configs.contextvars import get_current_tenant_id
@@ -37,57 +37,6 @@ def is_api_key_email_address(email: str) -> bool:
return email.endswith(get_api_key_email_pattern())
def _get_user_groups(db_session: Session, user_id: uuid.UUID) -> list[UserGroupInfo]:
"""Get lightweight group info for a user."""
groups = (
db_session.scalars(
select(UserGroup)
.join(User__UserGroup, User__UserGroup.user_group_id == UserGroup.id)
.where(User__UserGroup.user_id == user_id)
)
.unique()
.all()
)
return [UserGroupInfo(id=g.id, name=g.name) for g in groups]
def _set_user_groups__no_commit(
db_session: Session,
user_id: uuid.UUID,
group_ids: list[int],
) -> None:
"""Replace all group memberships for a user with the given group_ids.
Does NOT commit."""
if group_ids:
# Validate that all requested group IDs exist
existing_ids = set(
db_session.scalars(
select(UserGroup.id).where(UserGroup.id.in_(group_ids))
).all()
)
missing = set(group_ids) - existing_ids
if missing:
raise ValueError(f"Group IDs do not exist: {sorted(missing)}")
# Remove all existing memberships
db_session.execute(
delete(User__UserGroup).where(User__UserGroup.user_id == user_id)
)
# Add new memberships
if group_ids:
insert_stmt = (
pg_insert(User__UserGroup)
.values([{"user_id": user_id, "user_group_id": gid} for gid in group_ids])
.on_conflict_do_nothing(
index_elements=[User__UserGroup.user_group_id, User__UserGroup.user_id]
)
)
db_session.execute(insert_stmt)
recompute_user_permissions__no_commit(user_id, db_session)
def fetch_api_keys(db_session: Session) -> list[ApiKeyDescriptor]:
api_keys = (
db_session.scalars(select(ApiKey).options(joinedload(ApiKey.user)))
@@ -97,10 +46,10 @@ def fetch_api_keys(db_session: Session) -> list[ApiKeyDescriptor]:
return [
ApiKeyDescriptor(
api_key_id=api_key.id,
api_key_role=api_key.user.role,
api_key_display=api_key.api_key_display,
api_key_name=api_key.name,
user_id=api_key.user_id,
groups=_get_user_groups(db_session, api_key.user_id),
)
for api_key in api_keys
]
@@ -145,6 +94,7 @@ def insert_api_key(
is_active=True,
is_superuser=False,
is_verified=True,
role=api_key_args.role,
account_type=AccountType.SERVICE_ACCOUNT,
)
db_session.add(api_key_user_row)
@@ -158,18 +108,25 @@ def insert_api_key(
)
db_session.add(api_key_row)
# Assign the service account to the specified groups
_set_user_groups__no_commit(db_session, api_key_user_id, api_key_args.group_ids)
# Assign the API key virtual user to the appropriate default group
# before commit so everything is atomic.
# Only ADMIN and BASIC roles get default group membership.
if api_key_args.role in (UserRole.ADMIN, UserRole.BASIC):
assign_user_to_default_groups__no_commit(
db_session,
api_key_user_row,
is_admin=(api_key_args.role == UserRole.ADMIN),
)
db_session.commit()
return ApiKeyDescriptor(
api_key_id=api_key_row.id,
api_key_role=api_key_user_row.role,
api_key_display=api_key_row.api_key_display,
api_key=api_key,
api_key_name=api_key_args.name,
user_id=api_key_user_id,
groups=_get_user_groups(db_session, api_key_user_id),
)
@@ -190,8 +147,31 @@ def update_api_key(
email_name = api_key_args.name or UNNAMED_KEY_PLACEHOLDER
api_key_user.email = get_api_key_fake_email(email_name, str(api_key_user.id))
# Replace all group memberships with the specified groups
_set_user_groups__no_commit(db_session, api_key_user.id, api_key_args.group_ids)
old_role = api_key_user.role
api_key_user.role = api_key_args.role
# Reconcile default-group membership when the role changes.
if old_role != api_key_args.role:
# Remove from all default groups first.
delete_stmt = delete(User__UserGroup).where(
User__UserGroup.user_id == api_key_user.id,
User__UserGroup.user_group_id.in_(
select(UserGroup.id).where(UserGroup.is_default.is_(True))
),
)
db_session.execute(delete_stmt)
# Re-assign to the correct default group (only for ADMIN/BASIC).
if api_key_args.role in (UserRole.ADMIN, UserRole.BASIC):
assign_user_to_default_groups__no_commit(
db_session,
api_key_user,
is_admin=(api_key_args.role == UserRole.ADMIN),
)
else:
# No group assigned for LIMITED, but we still need to recompute
# since we just removed the old default-group membership above.
recompute_user_permissions__no_commit(api_key_user.id, db_session)
db_session.commit()
@@ -199,8 +179,8 @@ def update_api_key(
api_key_id=existing_api_key.id,
api_key_display=existing_api_key.api_key_display,
api_key_name=api_key_args.name,
api_key_role=api_key_user.role,
user_id=existing_api_key.user_id,
groups=_get_user_groups(db_session, existing_api_key.user_id),
)
@@ -229,8 +209,8 @@ def regenerate_api_key(db_session: Session, api_key_id: int) -> ApiKeyDescriptor
api_key_display=existing_api_key.api_key_display,
api_key=new_api_key,
api_key_name=existing_api_key.name,
api_key_role=api_key_user.role,
user_id=existing_api_key.user_id,
groups=_get_user_groups(db_session, existing_api_key.user_id),
)

View File

@@ -1,9 +1,12 @@
from collections.abc import AsyncGenerator
from collections.abc import Callable
from typing import Any
from typing import Dict
from typing import TypeVar
from fastapi import Depends
from fastapi_users.models import ID
from fastapi_users.models import UP
from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase
from fastapi_users_db_sqlalchemy.access_token import SQLAlchemyAccessTokenDatabase
from sqlalchemy import func
@@ -12,13 +15,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import Session
from onyx.auth.schemas import UserRole
from onyx.configs.constants import ANONYMOUS_USER_EMAIL
from onyx.configs.constants import NO_AUTH_PLACEHOLDER_USER_EMAIL
from onyx.db.api_key import get_api_key_email_pattern
from onyx.db.engine.async_sql_engine import get_async_session
from onyx.db.engine.async_sql_engine import get_async_session_context_manager
from onyx.db.enums import AccountType
from onyx.db.enums import Permission
from onyx.db.models import AccessToken
from onyx.db.models import OAuthAccount
from onyx.db.models import User
@@ -60,14 +62,10 @@ def _add_live_user_count_where_clause(
select_stmt = select_stmt.where(User.email != NO_AUTH_PLACEHOLDER_USER_EMAIL) # type: ignore
if only_admin_users:
return select_stmt.where(
User.effective_permissions.contains(
[Permission.FULL_ADMIN_PANEL_ACCESS.value]
)
)
return select_stmt.where(User.role == UserRole.ADMIN)
return select_stmt.where(
User.account_type != AccountType.EXT_PERM_USER,
User.role != UserRole.EXT_PERM_USER,
)
@@ -97,10 +95,24 @@ async def get_user_count(only_admin_users: bool = False) -> int:
return user_count
# Need to override this because FastAPI Users doesn't give flexibility for backend field creation logic in OAuth flow
class SQLAlchemyUserAdminDB(SQLAlchemyUserDatabase[UP, ID]):
async def create(
self,
create_dict: Dict[str, Any],
) -> UP:
user_count = await get_user_count()
if user_count == 0 or create_dict["email"] in get_default_admin_user_emails():
create_dict["role"] = UserRole.ADMIN
else:
create_dict["role"] = UserRole.BASIC
return await super().create(create_dict)
async def get_user_db(
session: AsyncSession = Depends(get_async_session),
) -> AsyncGenerator[SQLAlchemyUserDatabase, None]:
yield SQLAlchemyUserDatabase(session, User, OAuthAccount)
) -> AsyncGenerator[SQLAlchemyUserAdminDB, None]:
yield SQLAlchemyUserAdminDB(session, User, OAuthAccount)
async def get_access_token_db(

View File

@@ -14,7 +14,6 @@ from sqlalchemy.orm import joinedload
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from onyx.auth.permissions import get_effective_permissions
from onyx.configs.constants import DocumentSource
from onyx.db.connector import fetch_connector_by_id
from onyx.db.credentials import fetch_credential_by_id
@@ -22,7 +21,6 @@ from onyx.db.credentials import fetch_credential_by_id_for_user
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.enums import AccessType
from onyx.db.enums import ConnectorCredentialPairStatus
from onyx.db.enums import Permission
from onyx.db.enums import ProcessingMode
from onyx.db.models import Connector
from onyx.db.models import ConnectorCredentialPair
@@ -33,6 +31,7 @@ from onyx.db.models import SearchSettings
from onyx.db.models import User
from onyx.db.models import User__UserGroup
from onyx.db.models import UserGroup__ConnectorCredentialPair
from onyx.db.models import UserRole
from onyx.server.models import StatusResponse
from onyx.utils.logger import setup_logger
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
@@ -50,27 +49,48 @@ class ConnectorType(str, Enum):
def _add_user_filters(
stmt: Select[tuple[*R]], user: User, get_editable: bool = True
) -> Select[tuple[*R]]:
effective = get_effective_permissions(user)
if Permission.MANAGE_CONNECTORS in effective:
if user.role == UserRole.ADMIN:
return stmt
# If anonymous user, only show public cc_pairs
if user.is_anonymous:
return stmt.where(ConnectorCredentialPair.access_type == AccessType.PUBLIC)
where_clause = ConnectorCredentialPair.access_type == AccessType.PUBLIC
return stmt.where(where_clause)
stmt = stmt.distinct()
UG__CCpair = aliased(UserGroup__ConnectorCredentialPair)
User__UG = aliased(User__UserGroup)
"""
Here we select cc_pairs by relation:
User -> User__UserGroup -> UserGroup__ConnectorCredentialPair ->
ConnectorCredentialPair
"""
stmt = stmt.outerjoin(UG__CCpair).outerjoin(
User__UG,
User__UG.user_group_id == UG__CCpair.user_group_id,
)
where_clause = User__UG.user_id == user.id
"""
Filter cc_pairs by:
- if the user is in the user_group that owns the cc_pair
- if the user is not a global_curator, they must also have a curator relationship
to the user_group
- if editing is being done, we also filter out cc_pairs that are owned by groups
that the user isn't a curator for
- if we are not editing, we show all cc_pairs in the groups the user is a curator
for (as well as public cc_pairs)
"""
where_clause = User__UG.user_id == user.id
if user.role == UserRole.CURATOR and get_editable:
where_clause &= User__UG.is_curator == True # noqa: E712
if get_editable:
user_groups = select(User__UG.user_group_id).where(User__UG.user_id == user.id)
if user.role == UserRole.CURATOR:
user_groups = user_groups.where(
User__UserGroup.is_curator == True # noqa: E712
)
where_clause &= (
~exists()
.where(UG__CCpair.cc_pair_id == ConnectorCredentialPair.id)
@@ -513,6 +533,7 @@ def add_credential_to_connector(
credential_id,
user,
db_session,
get_editable=False,
)
if connector is None:
@@ -595,6 +616,7 @@ def remove_credential_from_connector(
credential_id,
user,
db_session,
get_editable=False,
)
if connector is None:

View File

@@ -1,5 +1,6 @@
from typing import Any
from sqlalchemy import exists
from sqlalchemy import Select
from sqlalchemy import select
from sqlalchemy import update
@@ -7,18 +8,18 @@ from sqlalchemy.orm import Session
from sqlalchemy.sql.expression import and_
from sqlalchemy.sql.expression import or_
from onyx.auth.permissions import get_effective_permissions
from onyx.auth.schemas import UserRole
from onyx.configs.constants import DocumentSource
from onyx.connectors.google_utils.shared_constants import (
DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY,
)
from onyx.db.enums import ConnectorCredentialPairStatus
from onyx.db.enums import Permission
from onyx.db.models import ConnectorCredentialPair
from onyx.db.models import Credential
from onyx.db.models import Credential__UserGroup
from onyx.db.models import DocumentByConnectorCredentialPair
from onyx.db.models import User
from onyx.db.models import User__UserGroup
from onyx.server.documents.models import CredentialBase
from onyx.utils.logger import setup_logger
@@ -42,14 +43,16 @@ PUBLIC_CREDENTIAL_ID = 0
def _add_user_filters(
stmt: Select,
user: User,
get_editable: bool = True,
) -> Select:
"""Attaches filters to ensure the user can only access appropriate credentials."""
"""Attaches filters to the statement to ensure that the user can only
access the appropriate credentials"""
if user.is_anonymous:
raise ValueError("Anonymous users are not allowed to access credentials")
effective = get_effective_permissions(user)
if Permission.MANAGE_CONNECTORS in effective:
if user.role == UserRole.ADMIN:
# Admins can access all credentials that are public or owned by them
# or are not associated with any user
return stmt.where(
or_(
Credential.user_id == user.id,
@@ -58,9 +61,56 @@ def _add_user_filters(
Credential.source.in_(CREDENTIAL_PERMISSIONS_TO_IGNORE),
)
)
if user.role == UserRole.BASIC:
# Basic users can only access credentials that are owned by them
return stmt.where(Credential.user_id == user.id)
# All other users: only their own credentials
return stmt.where(Credential.user_id == user.id)
stmt = stmt.distinct()
"""
THIS PART IS FOR CURATORS AND GLOBAL CURATORS
Here we select cc_pairs by relation:
User -> User__UserGroup -> Credential__UserGroup -> Credential
"""
stmt = stmt.outerjoin(Credential__UserGroup).outerjoin(
User__UserGroup,
User__UserGroup.user_group_id == Credential__UserGroup.user_group_id,
)
"""
Filter Credentials by:
- if the user is in the user_group that owns the Credential
- if the user is a curator, they must also have a curator relationship
to the user_group
- if editing is being done, we also filter out Credentials that are owned by groups
that the user isn't a curator for
- if we are not editing, we show all Credentials in the groups the user is a curator
for (as well as public Credentials)
- if we are not editing, we return all Credentials directly connected to the user
"""
where_clause = User__UserGroup.user_id == user.id
if user.role == UserRole.CURATOR:
where_clause &= User__UserGroup.is_curator == True # noqa: E712
if get_editable:
user_groups = select(User__UserGroup.user_group_id).where(
User__UserGroup.user_id == user.id
)
if user.role == UserRole.CURATOR:
user_groups = user_groups.where(
User__UserGroup.is_curator == True # noqa: E712
)
where_clause &= (
~exists()
.where(Credential__UserGroup.credential_id == Credential.id)
.where(~Credential__UserGroup.user_group_id.in_(user_groups))
.correlate(Credential)
)
else:
where_clause |= Credential.curator_public == True # noqa: E712
where_clause |= Credential.user_id == user.id # noqa: E712
where_clause |= Credential.source.in_(CREDENTIAL_PERMISSIONS_TO_IGNORE)
return stmt.where(where_clause)
def _relate_credential_to_user_groups__no_commit(
@@ -82,9 +132,10 @@ def _relate_credential_to_user_groups__no_commit(
def fetch_credentials_for_user(
db_session: Session,
user: User,
get_editable: bool = True,
) -> list[Credential]:
stmt = select(Credential)
stmt = _add_user_filters(stmt, user)
stmt = _add_user_filters(stmt, user, get_editable=get_editable)
results = db_session.scalars(stmt)
return list(results.all())
@@ -93,12 +144,14 @@ def fetch_credential_by_id_for_user(
credential_id: int,
user: User,
db_session: Session,
get_editable: bool = True,
) -> Credential | None:
stmt = select(Credential).distinct()
stmt = stmt.where(Credential.id == credential_id)
stmt = _add_user_filters(
stmt=stmt,
user=user,
get_editable=get_editable,
)
result = db_session.execute(stmt)
credential = result.scalar_one_or_none()
@@ -120,9 +173,10 @@ def fetch_credentials_by_source_for_user(
db_session: Session,
user: User,
document_source: DocumentSource | None = None,
get_editable: bool = True,
) -> list[Credential]:
base_query = select(Credential).where(Credential.source == document_source)
base_query = _add_user_filters(base_query, user)
base_query = _add_user_filters(base_query, user, get_editable=get_editable)
credentials = db_session.execute(base_query).scalars().all()
return list(credentials)

View File

@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
from onyx.auth.api_key import build_displayable_api_key
from onyx.auth.api_key import generate_api_key
from onyx.auth.api_key import hash_api_key
from onyx.auth.schemas import UserRole
from onyx.configs.constants import DISCORD_SERVICE_API_KEY_NAME
from onyx.db.api_key import insert_api_key
from onyx.db.models import ApiKey
@@ -111,6 +112,7 @@ def get_or_create_discord_service_api_key(
logger.info(f"Creating Discord service API key for tenant {tenant_id}")
api_key_args = APIKeyArgs(
name=DISCORD_SERVICE_API_KEY_NAME,
role=UserRole.LIMITED, # Limited role is sufficient for chat requests
)
api_key_descriptor = insert_api_key(
db_session=db_session,

View File

@@ -4,7 +4,7 @@ from uuid import UUID
from sqlalchemy import and_
from sqlalchemy import delete
from sqlalchemy import false as sa_false
from sqlalchemy import exists
from sqlalchemy import func
from sqlalchemy import or_
from sqlalchemy import Select
@@ -13,21 +13,22 @@ from sqlalchemy.orm import aliased
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from onyx.auth.permissions import has_permission
from onyx.configs.app_configs import DISABLE_VECTOR_DB
from onyx.db.connector_credential_pair import get_cc_pair_groups_for_ids
from onyx.db.connector_credential_pair import get_connector_credential_pairs
from onyx.db.enums import AccessType
from onyx.db.enums import ConnectorCredentialPairStatus
from onyx.db.enums import Permission
from onyx.db.federated import create_federated_connector_document_set_mapping
from onyx.db.models import ConnectorCredentialPair
from onyx.db.models import Document
from onyx.db.models import DocumentByConnectorCredentialPair
from onyx.db.models import DocumentSet as DocumentSetDBModel
from onyx.db.models import DocumentSet__ConnectorCredentialPair
from onyx.db.models import DocumentSet__UserGroup
from onyx.db.models import FederatedConnector__DocumentSet
from onyx.db.models import User
from onyx.db.models import User__UserGroup
from onyx.db.models import UserRole
from onyx.server.features.document_set.models import DocumentSetCreationRequest
from onyx.server.features.document_set.models import DocumentSetUpdateRequest
from onyx.utils.logger import setup_logger
@@ -37,16 +38,54 @@ logger = setup_logger()
def _add_user_filters(stmt: Select, user: User, get_editable: bool = True) -> Select:
# MANAGE → always return all
if has_permission(user, Permission.MANAGE_DOCUMENT_SETS):
if user.role == UserRole.ADMIN:
return stmt
# READ → return all when reading, nothing when editing
if has_permission(user, Permission.READ_DOCUMENT_SETS):
if get_editable:
return stmt.where(sa_false())
return stmt
# No permission → return nothing
return stmt.where(sa_false())
stmt = stmt.distinct()
DocumentSet__UG = aliased(DocumentSet__UserGroup)
User__UG = aliased(User__UserGroup)
"""
Here we select cc_pairs by relation:
User -> User__UserGroup -> DocumentSet__UserGroup -> DocumentSet
"""
stmt = stmt.outerjoin(DocumentSet__UG).outerjoin(
User__UserGroup,
User__UserGroup.user_group_id == DocumentSet__UG.user_group_id,
)
"""
Filter DocumentSets by:
- if the user is in the user_group that owns the DocumentSet
- if the user is not a global_curator, they must also have a curator relationship
to the user_group
- if editing is being done, we also filter out DocumentSets that are owned by groups
that the user isn't a curator for
- if we are not editing, we show all DocumentSets in the groups the user is a curator
for (as well as public DocumentSets)
"""
# Anonymous users only see public DocumentSets
if user.is_anonymous:
where_clause = DocumentSetDBModel.is_public == True # noqa: E712
return stmt.where(where_clause)
where_clause = User__UserGroup.user_id == user.id
if user.role == UserRole.CURATOR and get_editable:
where_clause &= User__UserGroup.is_curator == True # noqa: E712
if get_editable:
user_groups = select(User__UG.user_group_id).where(User__UG.user_id == user.id)
if user.role == UserRole.CURATOR:
user_groups = user_groups.where(User__UG.is_curator == True) # noqa: E712
where_clause &= (
~exists()
.where(DocumentSet__UG.document_set_id == DocumentSetDBModel.id)
.where(~DocumentSet__UG.user_group_id.in_(user_groups))
.correlate(DocumentSetDBModel)
)
where_clause |= DocumentSetDBModel.user_id == user.id
else:
where_clause |= DocumentSetDBModel.is_public == True # noqa: E712
return stmt.where(where_clause)
def _delete_document_set_cc_pairs__no_commit(

View File

@@ -366,12 +366,12 @@ class Permission(str, PyEnum):
READ_DOCUMENT_SETS = "read:document_sets"
READ_AGENTS = "read:agents"
READ_USERS = "read:users"
READ_USER_GROUPS = "read:user_groups"
# Add / Manage pairs
ADD_AGENTS = "add:agents"
MANAGE_AGENTS = "manage:agents"
MANAGE_DOCUMENT_SETS = "manage:document_sets"
ADD_CONNECTORS = "add:connectors"
MANAGE_CONNECTORS = "manage:connectors"
MANAGE_LLMS = "manage:llms"
@@ -381,8 +381,8 @@ class Permission(str, PyEnum):
READ_QUERY_HISTORY = "read:query_history"
MANAGE_USER_GROUPS = "manage:user_groups"
CREATE_USER_API_KEYS = "create:user_api_keys"
MANAGE_SERVICE_ACCOUNT_API_KEYS = "manage:service_account_api_keys"
MANAGE_BOTS = "manage:bots"
CREATE_SERVICE_ACCOUNT_API_KEYS = "create:service_account_api_keys"
CREATE_SLACK_DISCORD_BOTS = "create:slack_discord_bots"
# Override — any permission check passes
FULL_ADMIN_PANEL_ACCESS = "admin"

View File

@@ -13,12 +13,10 @@ from sqlalchemy import select
from sqlalchemy.orm import aliased
from sqlalchemy.orm import Session
from onyx.auth.permissions import has_permission
from onyx.configs.constants import MessageType
from onyx.configs.constants import SearchFeedbackType
from onyx.db.chat import get_chat_message
from onyx.db.enums import AccessType
from onyx.db.enums import Permission
from onyx.db.models import ChatMessageFeedback
from onyx.db.models import ConnectorCredentialPair
from onyx.db.models import Document as DbDocument
@@ -27,6 +25,7 @@ from onyx.db.models import DocumentRetrievalFeedback
from onyx.db.models import User
from onyx.db.models import User__UserGroup
from onyx.db.models import UserGroup__ConnectorCredentialPair
from onyx.db.models import UserRole
from onyx.utils.logger import setup_logger
logger = setup_logger()
@@ -44,7 +43,7 @@ def _fetch_db_doc_by_id(doc_id: str, db_session: Session) -> DbDocument:
def _add_user_filters(stmt: Select, user: User, get_editable: bool = True) -> Select:
if has_permission(user, Permission.FULL_ADMIN_PANEL_ACCESS):
if user.role == UserRole.ADMIN:
return stmt
stmt = stmt.distinct()
@@ -72,11 +71,14 @@ def _add_user_filters(stmt: Select, user: User, get_editable: bool = True) -> Se
)
"""
Filter Documents by group membership:
- if get_editable, the document's CCPair must be owned exclusively by
groups the user belongs to (prevents mutating docs that are also
visible to groups outside the user's reach)
- otherwise, show docs in any group the user belongs to plus public docs
Filter Documents by:
- if the user is in the user_group that owns the object
- if the user is not a global_curator, they must also have a curator relationship
to the user_group
- if editing is being done, we also filter out objects that are owned by groups
that the user isn't a curator for
- if we are not editing, we show all objects in the groups the user is a curator
for (as well as public objects as well)
"""
# Anonymous users only see public documents
@@ -85,6 +87,8 @@ def _add_user_filters(stmt: Select, user: User, get_editable: bool = True) -> Se
return stmt.where(where_clause)
where_clause = User__UG.user_id == user.id
if user.role == UserRole.CURATOR and get_editable:
where_clause &= User__UG.is_curator == True # noqa: E712
if get_editable:
user_groups = select(User__UG.user_group_id).where(User__UG.user_id == user.id)
where_clause &= (

View File

@@ -302,11 +302,8 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
oauth_accounts: Mapped[list[OAuthAccount]] = relationship(
"OAuthAccount", lazy="joined", cascade="all, delete-orphan"
)
# Legacy tombstone column: no longer read or written by application code.
# Kept nullable so a pure-code rollback keeps working.
role: Mapped[UserRole | None] = mapped_column(
Enum(UserRole, native_enum=False),
nullable=True,
role: Mapped[UserRole] = mapped_column(
Enum(UserRole, native_enum=False, default=UserRole.BASIC)
)
account_type: Mapped[AccountType] = mapped_column(
Enum(AccountType, native_enum=False),

View File

@@ -9,9 +9,8 @@ from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import Session
from sqlalchemy.sql import func
from onyx.auth.permissions import has_permission
from onyx.auth.schemas import UserRole
from onyx.configs.constants import NotificationType
from onyx.db.enums import Permission
from onyx.db.models import Notification
from onyx.db.models import User
@@ -77,9 +76,7 @@ def get_notification_by_id(
if not notif:
raise ValueError(f"No notification found with id {notification_id}")
if notif.user_id != user_id and not (
notif.user_id is None
and user is not None
and has_permission(user, Permission.FULL_ADMIN_PANEL_ACCESS)
notif.user_id is None and user is not None and user.role == UserRole.ADMIN
):
raise PermissionError(
f"User {user_id} is not authorized to access notification {notification_id}"

View File

@@ -16,12 +16,12 @@ from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from onyx.access.hierarchy_access import get_user_external_group_ids
from onyx.auth.permissions import has_permission
from onyx.auth.schemas import UserRole
from onyx.configs.app_configs import CURATORS_CANNOT_VIEW_OR_EDIT_NON_OWNED_ASSISTANTS
from onyx.configs.constants import DEFAULT_PERSONA_ID
from onyx.configs.constants import NotificationType
from onyx.db.constants import SLACK_BOT_PERSONA_PREFIX
from onyx.db.document_access import get_accessible_documents_by_ids
from onyx.db.enums import Permission
from onyx.db.models import ConnectorCredentialPair
from onyx.db.models import Document
from onyx.db.models import DocumentSet
@@ -74,9 +74,7 @@ class PersonaLoadType(Enum):
def _add_user_filters(
stmt: Select[tuple[Persona]], user: User, get_editable: bool = True
) -> Select[tuple[Persona]]:
if has_permission(user, Permission.MANAGE_AGENTS):
return stmt
if not get_editable and has_permission(user, Permission.READ_AGENTS):
if user.role == UserRole.ADMIN:
return stmt
stmt = stmt.distinct()
@@ -100,7 +98,12 @@ def _add_user_filters(
"""
Filter Personas by:
- if the user is in the user_group that owns the Persona
- if we are not editing, we show all public and listed Personas
- if the user is not a global_curator, they must also have a curator relationship
to the user_group
- if editing is being done, we also filter out Personas that are owned by groups
that the user isn't a curator for
- if we are not editing, we show all Personas in the groups the user is a curator
for (as well as public Personas)
- if we are not editing, we return all Personas directly connected to the user
"""
@@ -109,9 +112,21 @@ def _add_user_filters(
where_clause = Persona.is_public == True # noqa: E712
return stmt.where(where_clause)
# If curator ownership restriction is enabled, curators can only access their own assistants
if CURATORS_CANNOT_VIEW_OR_EDIT_NON_OWNED_ASSISTANTS and user.role in [
UserRole.CURATOR,
UserRole.GLOBAL_CURATOR,
]:
where_clause = (Persona.user_id == user.id) | (Persona.user_id.is_(None))
return stmt.where(where_clause)
where_clause = User__UserGroup.user_id == user.id
if user.role == UserRole.CURATOR and get_editable:
where_clause &= User__UserGroup.is_curator == True # noqa: E712
if get_editable:
user_groups = select(User__UG.user_group_id).where(User__UG.user_id == user.id)
if user.role == UserRole.CURATOR:
user_groups = user_groups.where(User__UG.is_curator == True) # noqa: E712
where_clause &= (
~exists()
.where(Persona__UG.persona_id == Persona.id)
@@ -182,7 +197,7 @@ def _get_persona_by_name(
- Non-admin users: can only see their own personas
"""
stmt = select(Persona).where(Persona.name == persona_name)
if user and not has_permission(user, Permission.MANAGE_AGENTS):
if user and user.role != UserRole.ADMIN:
stmt = stmt.where(Persona.user_id == user.id)
result = db_session.execute(stmt).scalar_one_or_none()
return result
@@ -256,10 +271,12 @@ def create_update_persona(
try:
# Featured persona validation
if create_persona_request.is_featured:
if not has_permission(user, Permission.MANAGE_AGENTS):
raise ValueError(
"Only users with agent management permissions can make a featured persona"
)
# Curators can edit featured personas, but not make them
# TODO this will be reworked soon with RBAC permissions feature
if user.role == UserRole.CURATOR or user.role == UserRole.GLOBAL_CURATOR:
pass
elif user.role != UserRole.ADMIN:
raise ValueError("Only admins can make a featured persona")
# Convert incoming string UUIDs to UUID objects for DB operations
converted_user_file_ids = None
@@ -336,11 +353,7 @@ def update_persona_shared(
db_session=db_session, persona_id=persona_id, user=user, get_editable=True
)
if (
user
and not has_permission(user, Permission.MANAGE_AGENTS)
and persona.user_id != user.id
):
if user and user.role != UserRole.ADMIN and persona.user_id != user.id:
raise PermissionError("You don't have permission to modify this persona")
versioned_update_persona_access = fetch_versioned_implementation(
@@ -376,10 +389,7 @@ def update_persona_public_status(
persona = fetch_persona_by_id_for_user(
db_session=db_session, persona_id=persona_id, user=user, get_editable=True
)
if (
not has_permission(user, Permission.MANAGE_AGENTS)
and persona.user_id != user.id
):
if user.role != UserRole.ADMIN and persona.user_id != user.id:
raise ValueError("You don't have permission to modify this persona")
persona.is_public = is_public
@@ -1216,11 +1226,7 @@ def get_persona_by_id(
if not include_deleted:
persona_stmt = persona_stmt.where(Persona.deleted.is_(False))
if (
not user
or has_permission(user, Permission.MANAGE_AGENTS)
or (not is_for_edit and has_permission(user, Permission.READ_AGENTS))
):
if not user or user.role == UserRole.ADMIN:
result = db_session.execute(persona_stmt)
persona = result.scalar_one_or_none()
if persona is None:
@@ -1237,6 +1243,14 @@ def get_persona_by_id(
# if the user is in the .users of the persona
or_conditions |= User.id == user.id
or_conditions |= Persona.is_public == True # noqa: E712
elif user.role == UserRole.GLOBAL_CURATOR:
# global curators can edit personas for the groups they are in
or_conditions |= User__UserGroup.user_id == user.id
elif user.role == UserRole.CURATOR:
# curators can edit personas for the groups they are curators of
or_conditions |= (User__UserGroup.user_id == user.id) & (
User__UserGroup.is_curator == True # noqa: E712
)
persona_stmt = persona_stmt.where(or_conditions)
result = db_session.execute(persona_stmt)

View File

@@ -8,15 +8,19 @@ from sqlalchemy import select
from sqlalchemy import update
from sqlalchemy.orm import Session
from onyx.auth.schemas import UserRole
from onyx.db.enums import AccountType
from onyx.db.enums import DefaultAppMode
from onyx.db.enums import ThemePreference
from onyx.db.models import AccessToken
from onyx.db.models import Assistant__UserSpecificConfig
from onyx.db.models import Memory
from onyx.db.models import User
from onyx.db.models import User__UserGroup
from onyx.db.models import UserGroup
from onyx.db.permissions import recompute_user_permissions__no_commit
from onyx.db.users import assign_user_to_default_groups__no_commit
from onyx.db.users import is_limited_user
from onyx.db.users import user_is_admin
from onyx.server.manage.models import MemoryItem
from onyx.server.manage.models import UserSpecificAssistantPreference
from onyx.utils.logger import setup_logger
@@ -25,6 +29,59 @@ from onyx.utils.logger import setup_logger
logger = setup_logger()
_ROLE_TO_ACCOUNT_TYPE: dict[UserRole, AccountType] = {
UserRole.SLACK_USER: AccountType.BOT,
UserRole.EXT_PERM_USER: AccountType.EXT_PERM_USER,
}
def update_user_role(
user: User,
new_role: UserRole,
db_session: Session,
) -> None:
"""Update a user's role in the database.
Dual-writes account_type to keep it in sync with role and
reconciles default-group membership (Admin / Basic)."""
old_role = user.role
user.role = new_role
# Note: setting account_type to BOT or EXT_PERM_USER causes
# assign_user_to_default_groups__no_commit to early-return, which is
# intentional — these account types should not be in default groups.
if new_role in _ROLE_TO_ACCOUNT_TYPE:
user.account_type = _ROLE_TO_ACCOUNT_TYPE[new_role]
elif user.account_type in (AccountType.BOT, AccountType.EXT_PERM_USER):
# Upgrading from a non-web-login account type to a web role
user.account_type = AccountType.STANDARD
# Reconcile default-group membership when the role changes.
if old_role != new_role:
# Remove from all default groups first.
db_session.execute(
delete(User__UserGroup).where(
User__UserGroup.user_id == user.id,
User__UserGroup.user_group_id.in_(
select(UserGroup.id).where(UserGroup.is_default.is_(True))
),
)
)
# Re-assign to the correct default group.
# assign_user_to_default_groups__no_commit internally skips
# ANONYMOUS, BOT, and EXT_PERM_USER account types.
# Also skip limited users (no group assignment).
if not is_limited_user(user):
assign_user_to_default_groups__no_commit(
db_session,
user,
is_admin=(new_role == UserRole.ADMIN),
)
recompute_user_permissions__no_commit(user.id, db_session)
db_session.commit()
def deactivate_user(
user: User,
db_session: Session,
@@ -50,7 +107,7 @@ def activate_user(
# Also skip limited users (no group assignment).
if not is_limited_user(user):
assign_user_to_default_groups__no_commit(
db_session, user, is_admin=user_is_admin(user)
db_session, user, is_admin=(user.role == UserRole.ADMIN)
)
db_session.add(user)
db_session.commit()

View File

@@ -2,6 +2,7 @@ from collections.abc import Sequence
from typing import Any
from uuid import UUID
from fastapi import HTTPException
from fastapi_users.password import PasswordHelper
from sqlalchemy import case
from sqlalchemy import func
@@ -14,11 +15,11 @@ from sqlalchemy.sql.elements import KeyedColumnElement
from sqlalchemy.sql.expression import or_
from onyx.auth.invited_users import remove_user_from_invited_users
from onyx.auth.schemas import UserRole
from onyx.configs.constants import ANONYMOUS_USER_EMAIL
from onyx.configs.constants import DANSWER_API_KEY_DUMMY_EMAIL_DOMAIN
from onyx.configs.constants import NO_AUTH_PLACEHOLDER_USER_EMAIL
from onyx.db.enums import AccountType
from onyx.db.enums import Permission
from onyx.db.models import DocumentSet
from onyx.db.models import DocumentSet__User
from onyx.db.models import Persona
@@ -52,15 +53,82 @@ def is_limited_user(user: User) -> bool:
return False
def user_is_admin(user: User) -> bool:
"""Return True if the user holds the full admin permission.
Derived from effective_permissions, which is itself maintained from
group membership — Admin-group members carry FULL_ADMIN_PANEL_ACCESS.
def validate_user_role_update(
requested_role: UserRole,
current_account_type: AccountType,
explicit_override: bool = False,
) -> None:
"""
return Permission.FULL_ADMIN_PANEL_ACCESS.value in (
user.effective_permissions or []
)
Validate that a user role update is valid.
Assumed only admins can hit this endpoint.
raise if:
- requested role is a curator
- requested role is a slack user
- requested role is an external permissioned user
- requested role is a limited user
- current account type is BOT (slack user)
- current account type is EXT_PERM_USER
- current account type is ANONYMOUS or SERVICE_ACCOUNT
"""
if current_account_type == AccountType.BOT:
raise HTTPException(
status_code=400,
detail="To change a Slack User's role, they must first login to Onyx via the web app.",
)
if current_account_type == AccountType.EXT_PERM_USER:
raise HTTPException(
status_code=400,
detail="To change an External Permissioned User's role, they must first login to Onyx via the web app.",
)
if current_account_type in (AccountType.ANONYMOUS, AccountType.SERVICE_ACCOUNT):
raise HTTPException(
status_code=400,
detail="Cannot change the role of an anonymous or service account user.",
)
if explicit_override:
return
if requested_role == UserRole.CURATOR:
# This shouldn't happen, but just in case
raise HTTPException(
status_code=400,
detail="Curator role must be set via the User Group Menu",
)
if requested_role == UserRole.LIMITED:
# This shouldn't happen, but just in case
raise HTTPException(
status_code=400,
detail=(
"A user cannot be set to a Limited User role. "
"This role is automatically assigned to users through certain endpoints in the API."
),
)
if requested_role == UserRole.SLACK_USER:
# This shouldn't happen, but just in case
raise HTTPException(
status_code=400,
detail=(
"A user cannot be set to a Slack User role. "
"This role is automatically assigned to users who only use Onyx via Slack."
),
)
if requested_role == UserRole.EXT_PERM_USER:
# This shouldn't happen, but just in case
raise HTTPException(
status_code=400,
detail=(
"A user cannot be set to an External Permissioned User role. "
"This role is automatically assigned to users who have been "
"pulled in to the system via an external permissions system."
),
)
def get_all_users(
@@ -77,7 +145,7 @@ def get_all_users(
stmt = stmt.where(User.email != NO_AUTH_PLACEHOLDER_USER_EMAIL) # type: ignore
if not include_external:
stmt = stmt.where(User.account_type != AccountType.EXT_PERM_USER)
stmt = stmt.where(User.role != UserRole.EXT_PERM_USER)
if email_filter_string is not None:
stmt = stmt.where(User.email.ilike(f"%{email_filter_string}%")) # type: ignore
@@ -87,6 +155,7 @@ def get_all_users(
def _get_accepted_user_where_clause(
email_filter_string: str | None = None,
roles_filter: list[UserRole] = [],
include_external: bool = False,
is_active_filter: bool | None = None,
) -> list[ColumnElement[bool]]:
@@ -97,6 +166,7 @@ def _get_accepted_user_where_clause(
Parameters:
- email_filter_string: A substring to filter user emails. Only users whose emails contain this substring will be included.
- is_active_filter: When True, only active users will be included. When False, only inactive users will be included.
- roles_filter: A list of user roles to filter by. Only users with roles in this list will be included.
- include_external: If False, external permissioned users will be excluded.
Returns:
@@ -116,7 +186,7 @@ def _get_accepted_user_where_clause(
]
if not include_external:
where_clause.append(User.account_type != AccountType.EXT_PERM_USER)
where_clause.append(User.role != UserRole.EXT_PERM_USER)
if email_filter_string is not None:
personal_name_col: KeyedColumnElement[Any] = User.__table__.c.personal_name
@@ -127,6 +197,9 @@ def _get_accepted_user_where_clause(
)
)
if roles_filter:
where_clause.append(User.role.in_(roles_filter))
if is_active_filter is not None:
where_clause.append(is_active_col.is_(is_active_filter))
@@ -139,7 +212,7 @@ def get_all_accepted_users(
) -> Sequence[User]:
"""Returns all accepted users without pagination.
Uses the same filtering as the paginated endpoint but without
search or active filters."""
search, role, or active filters."""
stmt = select(User)
where_clause = _get_accepted_user_where_clause(
include_external=include_external,
@@ -154,12 +227,14 @@ def get_page_of_filtered_users(
page_num: int,
email_filter_string: str | None = None,
is_active_filter: bool | None = None,
roles_filter: list[UserRole] = [],
include_external: bool = False,
) -> Sequence[User]:
users_stmt = select(User)
where_clause = _get_accepted_user_where_clause(
email_filter_string=email_filter_string,
roles_filter=roles_filter,
include_external=include_external,
is_active_filter=is_active_filter,
)
@@ -175,10 +250,12 @@ def get_total_filtered_users_count(
db_session: Session,
email_filter_string: str | None = None,
is_active_filter: bool | None = None,
roles_filter: list[UserRole] = [],
include_external: bool = False,
) -> int:
where_clause = _get_accepted_user_where_clause(
email_filter_string=email_filter_string,
roles_filter=roles_filter,
include_external=include_external,
is_active_filter=is_active_filter,
)
@@ -189,46 +266,39 @@ def get_total_filtered_users_count(
return db_session.scalar(total_count_stmt) or 0
def get_user_counts_by_account_type_and_status(
def get_user_counts_by_role_and_status(
db_session: Session,
) -> dict[str, dict[str, int]]:
"""Returns user counts grouped by account_type and by active/inactive status.
"""Returns user counts grouped by role and by active/inactive status.
Excludes API key users, anonymous users, and no-auth placeholder users.
Uses a single query with conditional aggregation.
"""
base_where = _get_accepted_user_where_clause()
account_type_col = User.__table__.c.account_type
role_col = User.__table__.c.role
is_active_col = User.__table__.c.is_active
stmt = (
select(
account_type_col,
role_col,
func.count().label("total"),
func.sum(case((is_active_col.is_(True), 1), else_=0)).label("active"),
func.sum(case((is_active_col.is_(False), 1), else_=0)).label("inactive"),
)
.where(*base_where)
.group_by(account_type_col)
.group_by(role_col)
)
account_type_counts: dict[str, int] = {}
role_counts: dict[str, int] = {}
status_counts: dict[str, int] = {"active": 0, "inactive": 0}
for account_type_val, total, active, inactive in db_session.execute(stmt).all():
key = (
account_type_val.value
if hasattr(account_type_val, "value")
else str(account_type_val)
)
account_type_counts[key] = total
for role_val, total, active, inactive in db_session.execute(stmt).all():
key = role_val.value if hasattr(role_val, "value") else str(role_val)
role_counts[key] = total
status_counts["active"] += active or 0
status_counts["inactive"] += inactive or 0
return {
"account_type_counts": account_type_counts,
"status_counts": status_counts,
}
return {"role_counts": role_counts, "status_counts": status_counts}
def get_user_by_email(email: str, db_session: Session) -> User | None:
@@ -251,6 +321,7 @@ def _generate_slack_user(email: str) -> User:
return User(
email=email,
hashed_password=hashed_pass,
role=UserRole.SLACK_USER,
account_type=AccountType.BOT,
)
@@ -261,6 +332,7 @@ def add_slack_user_if_not_exists(db_session: Session, email: str) -> User:
if user is not None:
# If the user is an external permissioned user, we update it to a slack user
if user.account_type == AccountType.EXT_PERM_USER:
user.role = UserRole.SLACK_USER
user.account_type = AccountType.BOT
db_session.commit()
return user
@@ -297,6 +369,7 @@ def _generate_ext_permissioned_user(email: str) -> User:
return User(
email=email,
hashed_password=hashed_pass,
role=UserRole.EXT_PERM_USER,
account_type=AccountType.EXT_PERM_USER,
)

View File

@@ -0,0 +1,47 @@
# Error Handling
This directory is the local source of truth for backend API error handling.
## Primary Rule
Raise `OnyxError` from `onyx.error_handling.exceptions` instead of `HTTPException`.
The global FastAPI exception handler converts `OnyxError` into the standard JSON shape:
```json
{"error_code": "...", "detail": "..."}
```
This keeps API behavior consistent and avoids repetitive route-level boilerplate.
## Examples
```python
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
# Good
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Session not found")
# Good
raise OnyxError(OnyxErrorCode.UNAUTHENTICATED)
# Good: preserve a dynamic upstream status code
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY,
detail,
status_code_override=e.response.status_code,
)
```
Avoid:
```python
raise HTTPException(status_code=404, detail="Session not found")
```
## Notes
- Available error codes are defined in `backend/onyx/error_handling/error_codes.py`.
- If a new error category is needed, add it there first rather than inventing ad hoc strings.
- When forwarding upstream service failures with dynamic status codes, use `status_code_override`.

View File

@@ -56,7 +56,6 @@ class OnyxErrorCode(Enum):
DOCUMENT_NOT_FOUND = ("DOCUMENT_NOT_FOUND", 404)
SESSION_NOT_FOUND = ("SESSION_NOT_FOUND", 404)
USER_NOT_FOUND = ("USER_NOT_FOUND", 404)
DOCUMENT_SET_NOT_FOUND = ("DOCUMENT_SET_NOT_FOUND", 404)
# ------------------------------------------------------------------
# Conflict (409)

View File

@@ -66,7 +66,7 @@ PROVIDER_DISPLAY_NAMES: dict[str, str] = {
LlmProviderNames.LM_STUDIO: "LM Studio",
LlmProviderNames.LITELLM_PROXY: "LiteLLM Proxy",
LlmProviderNames.BIFROST: "Bifrost",
LlmProviderNames.OPENAI_COMPATIBLE: "OpenAI Compatible",
LlmProviderNames.OPENAI_COMPATIBLE: "OpenAI-Compatible",
"groq": "Groq",
"anyscale": "Anyscale",
"deepseek": "DeepSeek",
@@ -87,6 +87,44 @@ PROVIDER_DISPLAY_NAMES: dict[str, str] = {
"gemini": "Gemini",
"stability": "Stability",
"writer": "Writer",
# Custom provider display names (used in the custom provider picker)
"aiml": "AI/ML",
"assemblyai": "AssemblyAI",
"aws_polly": "AWS Polly",
"azure_ai": "Azure AI",
"chatgpt": "ChatGPT",
"cohere_chat": "Cohere Chat",
"datarobot": "DataRobot",
"deepgram": "Deepgram",
"deepinfra": "DeepInfra",
"elevenlabs": "ElevenLabs",
"fal_ai": "fal.ai",
"featherless_ai": "Featherless AI",
"fireworks_ai": "Fireworks AI",
"friendliai": "FriendliAI",
"gigachat": "GigaChat",
"github_copilot": "GitHub Copilot",
"gradient_ai": "Gradient AI",
"huggingface": "HuggingFace",
"jina_ai": "Jina AI",
"lambda_ai": "Lambda AI",
"llamagate": "LlamaGate",
"meta_llama": "Meta Llama",
"minimax": "MiniMax",
"nlp_cloud": "NLP Cloud",
"nvidia_nim": "NVIDIA NIM",
"oci": "OCI",
"ovhcloud": "OVHcloud",
"palm": "PaLM",
"publicai": "PublicAI",
"runwayml": "RunwayML",
"sambanova": "SambaNova",
"together_ai": "Together AI",
"vercel_ai_gateway": "Vercel AI Gateway",
"volcengine": "Volcengine",
"wandb": "W&B",
"watsonx": "IBM watsonx",
"zai": "ZAI",
}
# Map vendors to their brand names (used for provider_display_name generation)

View File

@@ -1,11 +1,10 @@
from collections.abc import Callable
from typing import Any
from onyx.auth.permissions import has_permission
from onyx.auth.schemas import UserRole
from onyx.configs.model_configs import GEN_AI_TEMPERATURE
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.enums import LLMModelFlowType
from onyx.db.enums import Permission
from onyx.db.llm import can_user_access_llm_provider
from onyx.db.llm import fetch_default_llm_model
from onyx.db.llm import fetch_default_vision_model
@@ -112,10 +111,7 @@ def get_llm_for_persona(
user_group_ids = fetch_user_group_ids(db_session, user)
if not can_user_access_llm_provider(
provider_model,
user_group_ids,
persona,
has_permission(user, Permission.FULL_ADMIN_PANEL_ACCESS),
provider_model, user_group_ids, persona, user.role == UserRole.ADMIN
):
logger.warning(
"User %s with persona %s cannot access provider %s. Falling back to default provider.",

View File

@@ -338,7 +338,7 @@ def get_provider_display_name(provider_name: str) -> str:
VERTEXAI_PROVIDER_NAME: "Google Vertex AI",
OPENROUTER_PROVIDER_NAME: "OpenRouter",
LITELLM_PROXY_PROVIDER_NAME: "LiteLLM Proxy",
OPENAI_COMPATIBLE_PROVIDER_NAME: "OpenAI Compatible",
OPENAI_COMPATIBLE_PROVIDER_NAME: "OpenAI-Compatible",
}
if provider_name in _ONYX_PROVIDER_DISPLAY_NAMES:

View File

@@ -90,6 +90,7 @@ from onyx.onyxbot.slack.utils import respond_in_thread_or_channel
from onyx.onyxbot.slack.utils import TenantSocketModeClient
from onyx.redis.redis_pool import get_redis_client
from onyx.server.manage.models import SlackBotTokens
from onyx.tracing.setup import setup_tracing
from onyx.utils.logger import setup_logger
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
from onyx.utils.variable_functionality import set_is_ee_based_on_env_variable
@@ -1206,6 +1207,7 @@ if __name__ == "__main__":
tenant_handler = SlackbotHandler()
set_is_ee_based_on_env_variable()
setup_tracing()
try:
# Keep the main thread alive

View File

@@ -20,7 +20,7 @@ router = APIRouter(prefix="/admin/api-key")
@router.get("")
def list_api_keys(
_: User = Depends(require_permission(Permission.MANAGE_SERVICE_ACCOUNT_API_KEYS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> list[ApiKeyDescriptor]:
return fetch_api_keys(db_session)
@@ -29,9 +29,7 @@ def list_api_keys(
@router.post("")
def create_api_key(
api_key_args: APIKeyArgs,
user: User = Depends(
require_permission(Permission.MANAGE_SERVICE_ACCOUNT_API_KEYS)
),
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> ApiKeyDescriptor:
return insert_api_key(db_session, api_key_args, user.id)
@@ -40,7 +38,7 @@ def create_api_key(
@router.post("/{api_key_id}/regenerate")
def regenerate_existing_api_key(
api_key_id: int,
_: User = Depends(require_permission(Permission.MANAGE_SERVICE_ACCOUNT_API_KEYS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> ApiKeyDescriptor:
return regenerate_api_key(db_session, api_key_id)
@@ -50,7 +48,7 @@ def regenerate_existing_api_key(
def update_existing_api_key(
api_key_id: int,
api_key_args: APIKeyArgs,
_: User = Depends(require_permission(Permission.MANAGE_SERVICE_ACCOUNT_API_KEYS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> ApiKeyDescriptor:
return update_api_key(db_session, api_key_id, api_key_args)
@@ -59,7 +57,7 @@ def update_existing_api_key(
@router.delete("/{api_key_id}")
def delete_api_key(
api_key_id: int,
_: User = Depends(require_permission(Permission.MANAGE_SERVICE_ACCOUNT_API_KEYS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> None:
remove_api_key(db_session, api_key_id)

View File

@@ -1,6 +1,8 @@
from pydantic import BaseModel
from onyx.auth.schemas import UserRole
class APIKeyArgs(BaseModel):
name: str | None = None
group_ids: list[int] = []
role: UserRole = UserRole.BASIC

View File

@@ -5,6 +5,7 @@ from fastapi.dependencies.models import Dependant
from starlette.routing import BaseRoute
from onyx.auth.users import current_chat_accessible_user
from onyx.auth.users import current_curator_or_admin_user
from onyx.auth.users import current_limited_user
from onyx.auth.users import current_user
from onyx.auth.users import current_user_from_websocket
@@ -129,6 +130,7 @@ def check_router_auth(
if (
depends_fn == current_limited_user
or depends_fn == current_user
or depends_fn == current_curator_or_admin_user
or depends_fn == current_user_with_expired_token
or depends_fn == current_chat_accessible_user
or depends_fn == current_user_from_websocket

View File

@@ -3,6 +3,7 @@ from http import HTTPStatus
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import Query
from fastapi.responses import JSONResponse
from sqlalchemy import select
@@ -10,6 +11,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_curator_or_admin_user
from onyx.background.celery.tasks.pruning.tasks import (
try_creating_prune_generator_task,
)
@@ -55,8 +57,6 @@ from onyx.db.permission_sync_attempt import (
from onyx.db.permission_sync_attempt import (
get_recent_doc_permission_sync_attempts_for_cc_pair,
)
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.redis.redis_connector import RedisConnector
from onyx.redis.redis_connector_utils import get_deletion_attempt_snapshot
from onyx.redis.redis_pool import get_redis_client
@@ -71,6 +71,7 @@ from onyx.server.documents.models import PaginatedReturn
from onyx.server.documents.models import PermissionSyncAttemptSnapshot
from onyx.server.models import StatusResponse
from onyx.utils.logger import setup_logger
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
from shared_configs.contextvars import get_current_tenant_id
logger = setup_logger()
@@ -82,17 +83,17 @@ def get_cc_pair_index_attempts(
cc_pair_id: int,
page_num: int = Query(0, ge=0),
page_size: int = Query(10, ge=1, le=1000),
user: User = Depends(require_permission(Permission.READ_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> PaginatedReturn[IndexAttemptSnapshot]:
user_has_access = verify_user_has_access_to_cc_pair(
cc_pair_id, db_session, user, get_editable=False
)
if not user_has_access:
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"CC Pair not found for current user permissions",
if user:
user_has_access = verify_user_has_access_to_cc_pair(
cc_pair_id, db_session, user, get_editable=False
)
if not user_has_access:
raise HTTPException(
status_code=400, detail="CC Pair not found for current user permissions"
)
total_count = count_index_attempts_for_cc_pair(
db_session=db_session,
@@ -118,17 +119,17 @@ def get_cc_pair_permission_sync_attempts(
cc_pair_id: int,
page_num: int = Query(0, ge=0),
page_size: int = Query(10, ge=1, le=1000),
user: User = Depends(require_permission(Permission.READ_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> PaginatedReturn[PermissionSyncAttemptSnapshot]:
user_has_access = verify_user_has_access_to_cc_pair(
cc_pair_id, db_session, user, get_editable=False
)
if not user_has_access:
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"CC Pair not found for current user permissions",
if user:
user_has_access = verify_user_has_access_to_cc_pair(
cc_pair_id, db_session, user, get_editable=False
)
if not user_has_access:
raise HTTPException(
status_code=400, detail="CC Pair not found for current user permissions"
)
# Get all permission sync attempts for this cc pair
all_attempts = get_recent_doc_permission_sync_attempts_for_cc_pair(
@@ -154,7 +155,7 @@ def get_cc_pair_permission_sync_attempts(
@router.get("/admin/cc-pair/{cc_pair_id}", tags=PUBLIC_API_TAGS)
def get_cc_pair_full_info(
cc_pair_id: int,
user: User = Depends(require_permission(Permission.READ_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> CCPairFullInfo:
tenant_id = get_current_tenant_id()
@@ -163,9 +164,8 @@ def get_cc_pair_full_info(
cc_pair_id, db_session, user, get_editable=False
)
if not cc_pair:
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
"CC Pair not found for current user permissions",
raise HTTPException(
status_code=404, detail="CC Pair not found for current user permissions"
)
editable_cc_pair = get_connector_credential_pair_from_id_for_user(
cc_pair_id, db_session, user, get_editable=True
@@ -259,7 +259,7 @@ def get_cc_pair_full_info(
def update_cc_pair_status(
cc_pair_id: int,
status_update_request: CCStatusUpdateRequest,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> JSONResponse:
"""This method returns nearly immediately. It simply sets some signals and
@@ -278,9 +278,9 @@ def update_cc_pair_status(
)
if not cc_pair:
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"Connection not found for current user's permissions",
raise HTTPException(
status_code=400,
detail="Connection not found for current user's permissions",
)
redis_connector = RedisConnector(tenant_id, cc_pair_id)
@@ -343,7 +343,7 @@ def update_cc_pair_status(
def update_cc_pair_name(
cc_pair_id: int,
new_name: str,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> StatusResponse[int]:
cc_pair = get_connector_credential_pair_from_id_for_user(
@@ -353,9 +353,8 @@ def update_cc_pair_name(
get_editable=True,
)
if not cc_pair:
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"CC Pair not found for current user's permissions",
raise HTTPException(
status_code=400, detail="CC Pair not found for current user's permissions"
)
try:
@@ -366,14 +365,14 @@ def update_cc_pair_name(
)
except IntegrityError:
db_session.rollback()
raise OnyxError(OnyxErrorCode.INVALID_INPUT, "Name must be unique")
raise HTTPException(status_code=400, detail="Name must be unique")
@router.put("/admin/cc-pair/{cc_pair_id}/property")
def update_cc_pair_property(
cc_pair_id: int,
update_request: CCPropertyUpdateRequest, # in seconds
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> StatusResponse[int]:
cc_pair = get_connector_credential_pair_from_id_for_user(
@@ -383,9 +382,8 @@ def update_cc_pair_property(
get_editable=True,
)
if not cc_pair:
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"CC Pair not found for current user's permissions",
raise HTTPException(
status_code=400, detail="CC Pair not found for current user's permissions"
)
# Can we centralize logic for updating connector properties
@@ -403,9 +401,8 @@ def update_cc_pair_property(
msg = "Pruning frequency updated successfully"
else:
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
f"Property name {update_request.name} is not valid.",
raise HTTPException(
status_code=400, detail=f"Property name {update_request.name} is not valid."
)
return StatusResponse(success=True, message=msg, data=cc_pair_id)
@@ -414,7 +411,7 @@ def update_cc_pair_property(
@router.get("/admin/cc-pair/{cc_pair_id}/last_pruned")
def get_cc_pair_last_pruned(
cc_pair_id: int,
user: User = Depends(require_permission(Permission.READ_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> datetime | None:
cc_pair = get_connector_credential_pair_from_id_for_user(
@@ -424,9 +421,9 @@ def get_cc_pair_last_pruned(
get_editable=False,
)
if not cc_pair:
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"CC Pair not found for current user's permissions",
raise HTTPException(
status_code=400,
detail="cc_pair not found for current user's permissions",
)
return cc_pair.last_pruned
@@ -435,7 +432,7 @@ def get_cc_pair_last_pruned(
@router.post("/admin/cc-pair/{cc_pair_id}/prune", tags=PUBLIC_API_TAGS)
def prune_cc_pair(
cc_pair_id: int,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> StatusResponse[list[int]]:
"""Triggers pruning on a particular cc_pair immediately"""
@@ -448,18 +445,18 @@ def prune_cc_pair(
get_editable=False,
)
if not cc_pair:
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"Connection not found for current user's permissions",
raise HTTPException(
status_code=400,
detail="Connection not found for current user's permissions",
)
r = get_redis_client()
redis_connector = RedisConnector(tenant_id, cc_pair_id)
if redis_connector.prune.fenced:
raise OnyxError(
OnyxErrorCode.CONFLICT,
"Pruning task already in progress.",
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="Pruning task already in progress.",
)
logger.info(
@@ -472,9 +469,9 @@ def prune_cc_pair(
client_app, cc_pair, db_session, r, tenant_id
)
if not payload_id:
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"Pruning task creation failed.",
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Pruning task creation failed.",
)
logger.info(f"Pruning queued: cc_pair={cc_pair.id} id={payload_id}")
@@ -488,7 +485,7 @@ def prune_cc_pair(
@router.get("/admin/cc-pair/{cc_pair_id}/get-docs-sync-status")
def get_docs_sync_status(
cc_pair_id: int,
_: User = Depends(require_permission(Permission.READ_CONNECTORS)),
_: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> list[DocumentSyncStatus]:
all_docs_for_cc_pair = get_documents_for_cc_pair(
@@ -504,7 +501,7 @@ def get_cc_pair_indexing_errors(
include_resolved: bool = Query(False),
page_num: int = Query(0, ge=0),
page_size: int = Query(10, ge=1, le=100),
_: User = Depends(require_permission(Permission.READ_CONNECTORS)),
_: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> PaginatedReturn[IndexAttemptErrorPydantic]:
"""Gives back all errors for a given CC Pair. Allows pagination based on page and page_size params.
@@ -546,7 +543,7 @@ def associate_credential_to_connector(
connector_id: int,
credential_id: int,
metadata: ConnectorCredentialPairMetadata,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
tenant_id: str = Depends(get_current_tenant_id),
) -> StatusResponse[int]:
@@ -556,6 +553,17 @@ def associate_credential_to_connector(
The intent of this endpoint is to handle connectors that actually need credentials.
"""
fetch_ee_implementation_or_noop(
"onyx.db.user_group", "validate_object_creation_for_user", None
)(
db_session=db_session,
user=user,
target_group_ids=metadata.groups,
object_is_public=metadata.access_type == AccessType.PUBLIC,
object_is_perm_sync=metadata.access_type == AccessType.SYNC,
object_is_new=True,
)
try:
validate_ccpair_for_user(
connector_id, credential_id, metadata.access_type, db_session
@@ -593,21 +601,20 @@ def associate_credential_to_connector(
delete_connector(db_session, connector_id)
db_session.commit()
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
"Connector validation error: " + str(e),
raise HTTPException(
status_code=400, detail="Connector validation error: " + str(e)
)
except IntegrityError as e:
logger.error(f"IntegrityError: {e}")
delete_connector(db_session, connector_id)
db_session.commit()
raise OnyxError(OnyxErrorCode.INVALID_INPUT, "Name must be unique")
raise HTTPException(status_code=400, detail="Name must be unique")
except Exception as e:
logger.exception(f"Unexpected error: {e}")
raise OnyxError(OnyxErrorCode.INTERNAL_ERROR, "Unexpected error")
raise HTTPException(status_code=500, detail="Unexpected error")
@router.delete(

View File

@@ -22,9 +22,9 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session
from onyx.auth.email_utils import send_email
from onyx.auth.permissions import get_effective_permissions
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_chat_accessible_user
from onyx.auth.users import current_curator_or_admin_user
from onyx.background.celery.tasks.pruning.tasks import (
try_creating_prune_generator_task,
)
@@ -118,8 +118,7 @@ from onyx.db.models import FederatedConnector
from onyx.db.models import IndexAttempt
from onyx.db.models import IndexingStatus
from onyx.db.models import User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.db.models import UserRole
from onyx.file_processing.file_types import PLAIN_TEXT_MIME_TYPE
from onyx.file_processing.file_types import WORD_PROCESSING_MIME_TYPE
from onyx.file_store.file_store import FileStore
@@ -160,6 +159,7 @@ from onyx.utils.logger import setup_logger
from onyx.utils.telemetry import mt_cloud_telemetry
from onyx.utils.threadpool_concurrency import CallableProtocol
from onyx.utils.threadpool_concurrency import run_functions_tuples_in_parallel
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
from shared_configs.contextvars import get_current_tenant_id
logger = setup_logger()
@@ -179,7 +179,7 @@ router = APIRouter(prefix="/manage", dependencies=[Depends(require_vector_db)])
@router.get("/admin/connector/gmail/app-credential")
def check_google_app_gmail_credentials_exist(
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(current_curator_or_admin_user),
) -> dict[str, str]:
try:
return {"client_id": get_google_app_cred(DocumentSource.GMAIL).web.client_id}
@@ -190,7 +190,7 @@ def check_google_app_gmail_credentials_exist(
@router.put("/admin/connector/gmail/app-credential")
def upsert_google_app_gmail_credentials(
app_credentials: GoogleAppCredentials,
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> StatusResponse:
try:
upsert_google_app_cred(app_credentials, DocumentSource.GMAIL)
@@ -204,7 +204,7 @@ def upsert_google_app_gmail_credentials(
@router.delete("/admin/connector/gmail/app-credential")
def delete_google_app_gmail_credentials(
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> StatusResponse:
try:
@@ -220,7 +220,7 @@ def delete_google_app_gmail_credentials(
@router.get("/admin/connector/google-drive/app-credential")
def check_google_app_credentials_exist(
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(current_curator_or_admin_user),
) -> dict[str, str]:
try:
return {
@@ -233,7 +233,7 @@ def check_google_app_credentials_exist(
@router.put("/admin/connector/google-drive/app-credential")
def upsert_google_app_credentials(
app_credentials: GoogleAppCredentials,
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> StatusResponse:
try:
upsert_google_app_cred(app_credentials, DocumentSource.GOOGLE_DRIVE)
@@ -247,7 +247,7 @@ def upsert_google_app_credentials(
@router.delete("/admin/connector/google-drive/app-credential")
def delete_google_app_credentials(
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> StatusResponse:
try:
@@ -263,7 +263,7 @@ def delete_google_app_credentials(
@router.get("/admin/connector/gmail/service-account-key")
def check_google_service_gmail_account_key_exist(
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(current_curator_or_admin_user),
) -> dict[str, str]:
try:
return {
@@ -280,7 +280,7 @@ def check_google_service_gmail_account_key_exist(
@router.put("/admin/connector/gmail/service-account-key")
def upsert_google_service_gmail_account_key(
service_account_key: GoogleServiceAccountKey,
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> StatusResponse:
try:
upsert_service_account_key(service_account_key, DocumentSource.GMAIL)
@@ -294,7 +294,7 @@ def upsert_google_service_gmail_account_key(
@router.delete("/admin/connector/gmail/service-account-key")
def delete_google_service_gmail_account_key(
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> StatusResponse:
try:
@@ -310,7 +310,7 @@ def delete_google_service_gmail_account_key(
@router.get("/admin/connector/google-drive/service-account-key")
def check_google_service_account_key_exist(
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(current_curator_or_admin_user),
) -> dict[str, str]:
try:
return {
@@ -327,7 +327,7 @@ def check_google_service_account_key_exist(
@router.put("/admin/connector/google-drive/service-account-key")
def upsert_google_service_account_key(
service_account_key: GoogleServiceAccountKey,
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> StatusResponse:
try:
upsert_service_account_key(service_account_key, DocumentSource.GOOGLE_DRIVE)
@@ -341,7 +341,7 @@ def upsert_google_service_account_key(
@router.delete("/admin/connector/google-drive/service-account-key")
def delete_google_service_account_key(
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> StatusResponse:
try:
@@ -358,7 +358,7 @@ def delete_google_service_account_key(
@router.put("/admin/connector/google-drive/service-account-credential")
def upsert_service_account_credential(
service_account_credential_request: GoogleServiceAccountCredentialRequest,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> ObjectCreationIdResponse:
"""Special API which allows the creation of a credential for a service account.
@@ -385,7 +385,7 @@ def upsert_service_account_credential(
@router.put("/admin/connector/gmail/service-account-credential")
def upsert_gmail_service_account_credential(
service_account_credential_request: GoogleServiceAccountCredentialRequest,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> ObjectCreationIdResponse:
"""Special API which allows the creation of a credential for a service account.
@@ -411,7 +411,7 @@ def upsert_gmail_service_account_credential(
@router.get("/admin/connector/google-drive/check-auth/{credential_id}")
def check_drive_tokens(
credential_id: int,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> AuthStatus:
db_credentials = fetch_credential_by_id_for_user(credential_id, user, db_session)
@@ -620,11 +620,11 @@ def _fetch_and_check_file_connector_cc_pair_permissions(
if has_requested_access:
return cc_pair
# Special case: users with MANAGE_CONNECTORS should be able to manage files
# Special case: global curators should be able to manage files
# for public file connectors even when they are not the creator.
if (
require_editable
and Permission.MANAGE_CONNECTORS in get_effective_permissions(user)
and user.role == UserRole.GLOBAL_CURATOR
and cc_pair.access_type == AccessType.PUBLIC
):
return cc_pair
@@ -639,7 +639,7 @@ def _fetch_and_check_file_connector_cc_pair_permissions(
def upload_files_api(
files: list[UploadFile],
unzip: bool = True,
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(current_curator_or_admin_user),
) -> FileUploadResponse:
return upload_files(files, FileOrigin.OTHER, unzip=unzip)
@@ -647,7 +647,7 @@ def upload_files_api(
@router.get("/admin/connector/{connector_id}/files", tags=PUBLIC_API_TAGS)
def list_connector_files(
connector_id: int,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> ConnectorFilesResponse:
"""List all files in a file connector."""
@@ -716,7 +716,7 @@ def update_connector_files(
connector_id: int,
files: list[UploadFile] | None = File(None),
file_ids_to_remove: str = Form("[]"),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> FileUploadResponse:
"""
@@ -929,7 +929,7 @@ def update_connector_files(
@router.get("/admin/connector", tags=PUBLIC_API_TAGS)
def get_connectors_by_credential(
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
credential: int | None = None,
) -> list[ConnectorSnapshot]:
@@ -963,7 +963,7 @@ def get_connectors_by_credential(
@router.get("/admin/connector/failed-indexing-status", tags=PUBLIC_API_TAGS)
def get_currently_failed_indexing_status(
secondary_index: bool = False,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
get_editable: bool = Query(
False, description="If true, return editable document sets"
@@ -1050,7 +1050,7 @@ def get_currently_failed_indexing_status(
@router.get("/admin/connector/status", tags=PUBLIC_API_TAGS)
def get_connector_status(
user: User = Depends(require_permission(Permission.READ_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> list[ConnectorStatus]:
# This method is only used document set and group creation/editing
@@ -1103,7 +1103,7 @@ def get_connector_status(
@router.post("/admin/connector/indexing-status", tags=PUBLIC_API_TAGS)
def get_connector_indexing_status(
request: IndexingStatusRequest,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> list[ConnectorIndexingStatusLiteResponse]:
tenant_id = get_current_tenant_id()
@@ -1175,7 +1175,7 @@ def get_connector_indexing_status(
),
]
if user and Permission.MANAGE_CONNECTORS in get_effective_permissions(user):
if user and user.role == UserRole.ADMIN:
(
editable_cc_pairs,
federated_connectors,
@@ -1549,7 +1549,7 @@ def _validate_connector_allowed(source: DocumentSource) -> None:
@router.post("/admin/connector", tags=PUBLIC_API_TAGS)
def create_connector_from_model(
connector_data: ConnectorUpdateRequest,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> ObjectCreationIdResponse:
tenant_id = get_current_tenant_id()
@@ -1557,6 +1557,16 @@ def create_connector_from_model(
try:
_validate_connector_allowed(connector_data.source)
fetch_ee_implementation_or_noop(
"onyx.db.user_group", "validate_object_creation_for_user", None
)(
db_session=db_session,
user=user,
target_group_ids=connector_data.groups,
object_is_public=connector_data.access_type == AccessType.PUBLIC,
object_is_perm_sync=connector_data.access_type == AccessType.SYNC,
object_is_new=True,
)
connector_base = connector_data.to_connector_base()
connector_response = create_connector(
db_session=db_session,
@@ -1578,11 +1588,20 @@ def create_connector_from_model(
@router.post("/admin/connector-with-mock-credential")
def create_connector_with_mock_credential(
connector_data: ConnectorUpdateRequest,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> StatusResponse:
tenant_id = get_current_tenant_id()
fetch_ee_implementation_or_noop(
"onyx.db.user_group", "validate_object_creation_for_user", None
)(
db_session=db_session,
user=user,
target_group_ids=connector_data.groups,
object_is_public=connector_data.access_type == AccessType.PUBLIC,
object_is_perm_sync=connector_data.access_type == AccessType.SYNC,
)
try:
_validate_connector_allowed(connector_data.source)
connector_response = create_connector(
@@ -1651,20 +1670,30 @@ def create_connector_with_mock_credential(
def update_connector_from_model(
connector_id: int,
connector_data: ConnectorUpdateRequest,
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> ConnectorSnapshot | StatusResponse[int]:
cc_pair = fetch_connector_credential_pair_for_connector(db_session, connector_id)
try:
_validate_connector_allowed(connector_data.source)
fetch_ee_implementation_or_noop(
"onyx.db.user_group", "validate_object_creation_for_user", None
)(
db_session=db_session,
user=user,
target_group_ids=connector_data.groups,
object_is_public=connector_data.access_type == AccessType.PUBLIC,
object_is_perm_sync=connector_data.access_type == AccessType.SYNC,
object_is_owned_by_user=cc_pair and user and cc_pair.creator_id == user.id,
)
connector_base = connector_data.to_connector_base()
except ValueError as e:
raise OnyxError(OnyxErrorCode.INVALID_INPUT, str(e))
raise HTTPException(status_code=400, detail=str(e))
updated_connector = update_connector(connector_id, connector_base, db_session)
if updated_connector is None:
raise OnyxError(
OnyxErrorCode.CONNECTOR_NOT_FOUND,
f"Connector {connector_id} does not exist",
raise HTTPException(
status_code=404, detail=f"Connector {connector_id} does not exist"
)
return ConnectorSnapshot(
@@ -1691,7 +1720,7 @@ def update_connector_from_model(
)
def delete_connector_by_id(
connector_id: int,
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> StatusResponse[int]:
try:
@@ -1707,7 +1736,7 @@ def delete_connector_by_id(
@router.post("/admin/connector/run-once", tags=PUBLIC_API_TAGS)
def connector_run_once(
run_info: RunConnectorRequest,
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> StatusResponse[int]:
"""Used to trigger indexing on a set of cc_pairs associated with a

View File

@@ -5,15 +5,18 @@ from fastapi import Depends
from fastapi import File
from fastapi import Form
from fastapi import HTTPException
from fastapi import Query
from fastapi import UploadFile
from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_curator_or_admin_user
from onyx.configs.constants import PUBLIC_API_TAGS
from onyx.connectors.factory import validate_ccpair_for_user
from onyx.db.credentials import alter_credential
from onyx.db.credentials import cleanup_gmail_credentials
from onyx.db.credentials import create_credential
from onyx.db.credentials import CREDENTIAL_PERMISSIONS_TO_IGNORE
from onyx.db.credentials import delete_credential
from onyx.db.credentials import delete_credential_for_user
from onyx.db.credentials import fetch_credential_by_id_for_user
@@ -35,6 +38,7 @@ from onyx.server.documents.private_key_types import PrivateKeyFileTypes
from onyx.server.documents.private_key_types import ProcessPrivateKeyFileProtocol
from onyx.server.models import StatusResponse
from onyx.utils.logger import setup_logger
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
logger = setup_logger()
@@ -42,18 +46,23 @@ logger = setup_logger()
router = APIRouter(prefix="/manage", tags=PUBLIC_API_TAGS)
def _ignore_credential_permissions(source: DocumentSource) -> bool:
return source in CREDENTIAL_PERMISSIONS_TO_IGNORE
"""Admin-only endpoints"""
@router.get("/admin/credential")
def list_credentials_admin(
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> list[CredentialSnapshot]:
"""Lists all public credentials"""
credentials = fetch_credentials_for_user(
db_session=db_session,
user=user,
get_editable=False,
)
return [
CredentialSnapshot.from_credential_db_model(credential)
@@ -64,13 +73,17 @@ def list_credentials_admin(
@router.get("/admin/similar-credentials/{source_type}")
def get_cc_source_full_info(
source_type: DocumentSource,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
get_editable: bool = Query(
False, description="If true, return editable credentials"
),
) -> list[CredentialSnapshot]:
credentials = fetch_credentials_by_source_for_user(
db_session=db_session,
user=user,
document_source=source_type,
get_editable=get_editable,
)
return [
@@ -82,7 +95,7 @@ def get_cc_source_full_info(
@router.delete("/admin/credential/{credential_id}")
def delete_credential_by_id_admin(
credential_id: int,
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> StatusResponse:
"""Same as the user endpoint, but can delete any credential (not just the user's own)"""
@@ -95,7 +108,7 @@ def delete_credential_by_id_admin(
@router.put("/admin/credential/swap")
def swap_credentials_for_connector(
credential_swap_req: CredentialSwapRequest,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
db_session: Session = Depends(get_session),
) -> StatusResponse:
validate_ccpair_for_user(
@@ -122,9 +135,18 @@ def swap_credentials_for_connector(
@router.post("/credential")
def create_credential_from_model(
credential_info: CredentialBase,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> ObjectCreationIdResponse:
if not _ignore_credential_permissions(credential_info.source):
fetch_ee_implementation_or_noop(
"onyx.db.user_group", "validate_object_creation_for_user", None
)(
db_session=db_session,
user=user,
target_group_ids=credential_info.groups,
object_is_public=credential_info.curator_public,
)
# Temporary fix for empty Google App credentials
if credential_info.source == DocumentSource.GMAIL:
@@ -145,7 +167,7 @@ def create_credential_with_private_key(
groups: list[int] = Form([]),
name: str | None = Form(None),
source: str = Form(...),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
uploaded_file: UploadFile = File(...),
field_key: str = Form(...),
type_definition_key: str = Form(...),
@@ -180,6 +202,16 @@ def create_credential_with_private_key(
source=DocumentSource(source),
)
if not _ignore_credential_permissions(DocumentSource(source)):
fetch_ee_implementation_or_noop(
"onyx.db.user_group", "validate_object_creation_for_user", None
)(
db_session=db_session,
user=user,
target_group_ids=groups,
object_is_public=curator_public,
)
# Temporary fix for empty Google App credentials
if DocumentSource(source) == DocumentSource.GMAIL:
cleanup_gmail_credentials(db_session=db_session)
@@ -216,6 +248,7 @@ def get_credential_by_id(
credential_id,
user,
db_session,
get_editable=False,
)
if credential is None:
raise HTTPException(
@@ -230,7 +263,7 @@ def get_credential_by_id(
def update_credential_data(
credential_id: int,
credential_update: CredentialDataUpdateRequest,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
db_session: Session = Depends(get_session),
) -> CredentialBase:
credential = alter_credential(
@@ -258,7 +291,7 @@ def update_credential_private_key(
uploaded_file: UploadFile = File(...),
field_key: str = Form(...),
type_definition_key: str = Form(...),
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
db_session: Session = Depends(get_session),
) -> CredentialBase:
try:

View File

@@ -58,7 +58,7 @@ docker buildx build --platform linux/amd64,linux/arm64 \
1. **Build and push** the new image (see above)
2. **Update the ConfigMap** in `cloud-deployment-yamls/danswer/configmap/env-configmap.yaml`:
2. **Update the ConfigMap** in in the internal repo
```yaml
SANDBOX_CONTAINER_IMAGE: "onyxdotapp/sandbox:v0.1.x"
```

View File

@@ -26,7 +26,7 @@ from onyx.db.enums import SandboxStatus
from onyx.db.models import BuildSession
from onyx.db.models import Sandbox
from onyx.db.models import User
from onyx.db.users import assign_user_to_default_groups__no_commit
from onyx.db.models import UserRole
from onyx.file_store.file_store import get_default_file_store
from onyx.server.features.build.configs import SANDBOX_BASE_PATH
from onyx.server.features.build.db.build_session import allocate_nextjs_port
@@ -108,10 +108,9 @@ def test_user(
id=uuid4(),
email=TEST_USER_EMAIL,
hashed_password="test_hashed_password", # Required NOT NULL field
role=UserRole.BASIC, # Required NOT NULL field
)
db_session.add(user)
db_session.flush()
assign_user_to_default_groups__no_commit(db_session, user, is_admin=False)
db_session.commit()
db_session.refresh(user)

View File

@@ -1,9 +1,11 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import Query
from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_curator_or_admin_user
from onyx.background.celery.versioned_apps.client import app as client_app
from onyx.configs.app_configs import DISABLE_VECTOR_DB
from onyx.configs.constants import OnyxCeleryPriority
@@ -18,13 +20,12 @@ from onyx.db.document_set import update_document_set
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import Permission
from onyx.db.models import User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.document_set.models import CheckDocSetPublicRequest
from onyx.server.features.document_set.models import CheckDocSetPublicResponse
from onyx.server.features.document_set.models import DocumentSetCreationRequest
from onyx.server.features.document_set.models import DocumentSetSummary
from onyx.server.features.document_set.models import DocumentSetUpdateRequest
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
from shared_configs.contextvars import get_current_tenant_id
@@ -34,10 +35,19 @@ router = APIRouter(prefix="/manage")
@router.post("/admin/document-set")
def create_document_set(
document_set_creation_request: DocumentSetCreationRequest,
user: User = Depends(require_permission(Permission.MANAGE_DOCUMENT_SETS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
tenant_id: str = Depends(get_current_tenant_id),
) -> int:
fetch_ee_implementation_or_noop(
"onyx.db.user_group", "validate_object_creation_for_user", None
)(
db_session=db_session,
user=user,
target_group_ids=document_set_creation_request.groups,
object_is_public=document_set_creation_request.is_public,
object_is_new=True,
)
try:
document_set_db_model, _ = insert_document_set(
document_set_creation_request=document_set_creation_request,
@@ -45,7 +55,7 @@ def create_document_set(
db_session=db_session,
)
except Exception as e:
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
raise HTTPException(status_code=400, detail=str(e))
if not DISABLE_VECTOR_DB:
client_app.send_task(
@@ -60,17 +70,27 @@ def create_document_set(
@router.patch("/admin/document-set")
def patch_document_set(
document_set_update_request: DocumentSetUpdateRequest,
user: User = Depends(require_permission(Permission.MANAGE_DOCUMENT_SETS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
tenant_id: str = Depends(get_current_tenant_id),
) -> None:
document_set = get_document_set_by_id(db_session, document_set_update_request.id)
if document_set is None:
raise OnyxError(
OnyxErrorCode.DOCUMENT_SET_NOT_FOUND,
f"Document set {document_set_update_request.id} does not exist",
raise HTTPException(
status_code=404,
detail=f"Document set {document_set_update_request.id} does not exist",
)
fetch_ee_implementation_or_noop(
"onyx.db.user_group", "validate_object_creation_for_user", None
)(
db_session=db_session,
user=user,
target_group_ids=document_set_update_request.groups,
object_is_public=document_set_update_request.is_public,
object_is_owned_by_user=user
and (document_set.user_id is None or document_set.user_id == user.id),
)
try:
update_document_set(
document_set_update_request=document_set_update_request,
@@ -78,7 +98,7 @@ def patch_document_set(
user=user,
)
except Exception as e:
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
raise HTTPException(status_code=400, detail=str(e))
if not DISABLE_VECTOR_DB:
client_app.send_task(
@@ -91,17 +111,30 @@ def patch_document_set(
@router.delete("/admin/document-set/{document_set_id}")
def delete_document_set(
document_set_id: int,
user: User = Depends(require_permission(Permission.MANAGE_DOCUMENT_SETS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
tenant_id: str = Depends(get_current_tenant_id),
) -> None:
document_set = get_document_set_by_id(db_session, document_set_id)
if document_set is None:
raise OnyxError(
OnyxErrorCode.DOCUMENT_SET_NOT_FOUND,
f"Document set {document_set_id} does not exist",
raise HTTPException(
status_code=404,
detail=f"Document set {document_set_id} does not exist",
)
# check if the user has "edit" access to the document set.
# `validate_object_creation_for_user` is poorly named, but this
# is the right function to use here
fetch_ee_implementation_or_noop(
"onyx.db.user_group", "validate_object_creation_for_user", None
)(
db_session=db_session,
user=user,
object_is_public=document_set.is_public,
object_is_owned_by_user=user
and (document_set.user_id is None or document_set.user_id == user.id),
)
try:
mark_document_set_as_to_be_deleted(
db_session=db_session,
@@ -109,7 +142,7 @@ def delete_document_set(
user=user,
)
except Exception as e:
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
raise HTTPException(status_code=400, detail=str(e))
if DISABLE_VECTOR_DB:
db_session.refresh(document_set)

View File

@@ -25,8 +25,9 @@ from pydantic import AnyUrl
from pydantic import BaseModel
from sqlalchemy.orm import Session
from onyx.auth.permissions import get_effective_permissions
from onyx.auth.permissions import require_permission
from onyx.auth.schemas import UserRole
from onyx.auth.users import current_curator_or_admin_user
from onyx.configs.app_configs import WEB_DOMAIN
from onyx.db.engine.sql_engine import get_session
from onyx.db.engine.sql_engine import get_session_with_current_tenant
@@ -58,8 +59,6 @@ from onyx.db.models import User
from onyx.db.tools import create_tool__no_commit
from onyx.db.tools import delete_tool__no_commit
from onyx.db.tools import get_tools_by_mcp_server_id
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.redis.redis_pool import get_redis_client
from onyx.server.features.mcp.models import MCPApiKeyResponse
from onyx.server.features.mcp.models import MCPAuthTemplate
@@ -352,7 +351,7 @@ class MCPOauthState(BaseModel):
async def connect_admin_oauth(
request: MCPUserOAuthConnectRequest,
db: Session = Depends(get_session),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
user: User = Depends(current_curator_or_admin_user),
) -> MCPUserOAuthConnectResponse:
"""Connect OAuth flow for admin MCP server authentication"""
return await _connect_oauth(request, db, is_admin=True, user=user)
@@ -807,16 +806,16 @@ class ServerToolsResponse(BaseModel):
def _ensure_mcp_server_owner_or_admin(server: DbMCPServer, user: User) -> None:
logger.info(
f"Ensuring MCP server owner or admin: {server.name} {user} server.owner={server.owner}"
f"Ensuring MCP server owner or admin: {server.name} {user} {user.role} server.owner={server.owner}"
)
if Permission.FULL_ADMIN_PANEL_ACCESS in get_effective_permissions(user):
if user.role == UserRole.ADMIN:
return
logger.info(f"User email: {user.email} server.owner={server.owner}")
if server.owner != user.email:
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"Curators can only modify MCP servers that they have created.",
raise HTTPException(
status_code=403,
detail="Curators can only modify MCP servers that they have created.",
)
@@ -838,8 +837,7 @@ def _db_mcp_server_to_api_mcp_server(
can_view_admin_credentials = bool(include_auth_config) and (
request_user is not None
and (
Permission.FULL_ADMIN_PANEL_ACCESS
in get_effective_permissions(request_user)
request_user.role == UserRole.ADMIN
or (request_user.email and request_user.email == db_server.owner)
)
)
@@ -1071,7 +1069,7 @@ def _get_connection_config(
def admin_list_mcp_tools_by_id(
server_id: int,
db: Session = Depends(get_session),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
user: User = Depends(current_curator_or_admin_user),
) -> MCPToolListResponse:
return _list_mcp_tools_by_id(server_id, db, True, user)
@@ -1086,7 +1084,7 @@ def get_mcp_server_tools_snapshots(
server_id: int,
source: ToolSnapshotSource = ToolSnapshotSource.DB,
db: Session = Depends(get_session),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
user: User = Depends(current_curator_or_admin_user),
) -> list[ToolSnapshot]:
"""
Get tools for an MCP server as ToolSnapshot objects.
@@ -1573,7 +1571,7 @@ def _sync_tools_for_server(
def get_mcp_server_detail(
server_id: int,
db_session: Session = Depends(get_session),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
user: User = Depends(current_curator_or_admin_user),
) -> MCPServer:
"""Return details for one MCP server if user has access"""
try:
@@ -1600,7 +1598,7 @@ def get_mcp_server_detail(
@admin_router.get("/tools")
def get_all_mcp_tools(
db: Session = Depends(get_session),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)), # noqa: ARG001
user: User = Depends(current_curator_or_admin_user), # noqa: ARG001
) -> list:
"""Get all tools associated with MCP servers, including both enabled and disabled tools"""
from sqlalchemy import select
@@ -1620,7 +1618,7 @@ def update_mcp_server_status(
server_id: int,
status: MCPServerStatus,
db: Session = Depends(get_session),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
user: User = Depends(current_curator_or_admin_user),
) -> dict[str, str]:
"""Update the status of an MCP server"""
logger.info(f"Updating MCP server {server_id} status to {status}")
@@ -1646,7 +1644,7 @@ def update_mcp_server_status(
@admin_router.get("/servers", response_model=MCPServersResponse)
def get_mcp_servers_for_admin(
db: Session = Depends(get_session),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
user: User = Depends(current_curator_or_admin_user),
) -> MCPServersResponse:
"""Get all MCP servers for admin display"""
@@ -1672,7 +1670,7 @@ def get_mcp_servers_for_admin(
def get_mcp_server_db_tools(
server_id: int,
db: Session = Depends(get_session),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
user: User = Depends(current_curator_or_admin_user),
) -> ServerToolsResponse:
"""Get existing database tools created for an MCP server"""
logger.info(f"Getting database tools for MCP server: {server_id}")
@@ -1717,7 +1715,7 @@ def get_mcp_server_db_tools(
def upsert_mcp_server(
request: MCPToolCreateRequest,
db_session: Session = Depends(get_session),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
user: User = Depends(current_curator_or_admin_user),
) -> MCPServerCreateResponse:
"""Create or update an MCP server (no tools yet)"""
@@ -1779,7 +1777,7 @@ def upsert_mcp_server(
def update_mcp_server_with_tools(
request: MCPToolUpdateRequest,
db_session: Session = Depends(get_session),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
user: User = Depends(current_curator_or_admin_user),
) -> MCPServerUpdateResponse:
"""Update an MCP server and associated tools"""
@@ -1831,7 +1829,7 @@ def update_mcp_server_with_tools(
def create_mcp_server_simple(
request: MCPServerSimpleCreateRequest,
db_session: Session = Depends(get_session),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
user: User = Depends(current_curator_or_admin_user),
) -> MCPServer:
"""Create MCP server with minimal information - auth to be configured later"""
@@ -1871,7 +1869,7 @@ def update_mcp_server_simple(
server_id: int,
request: MCPServerSimpleUpdateRequest,
db_session: Session = Depends(get_session),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
user: User = Depends(current_curator_or_admin_user),
) -> MCPServer:
"""Update MCP server basic information (name, description, URL)"""
try:
@@ -1902,7 +1900,7 @@ def update_mcp_server_simple(
def delete_mcp_server_admin(
server_id: int,
db_session: Session = Depends(get_session),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
user: User = Depends(current_curator_or_admin_user),
) -> dict:
"""Delete an MCP server and cascading related objects (tools, configs)."""
try:

View File

@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
from onyx.auth.oauth_token_manager import OAuthTokenManager
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_curator_or_admin_user
from onyx.configs.app_configs import WEB_DOMAIN
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import Permission
@@ -63,7 +64,7 @@ def _oauth_config_to_snapshot(
def create_oauth_config_endpoint(
oauth_data: OAuthConfigCreate,
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
_: User = Depends(current_curator_or_admin_user),
) -> OAuthConfigSnapshot:
"""Create a new OAuth configuration (admin only)."""
try:
@@ -85,7 +86,7 @@ def create_oauth_config_endpoint(
@admin_router.get("")
def list_oauth_configs(
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
_: User = Depends(current_curator_or_admin_user),
) -> list[OAuthConfigSnapshot]:
"""List all OAuth configurations (admin only)."""
oauth_configs = get_oauth_configs(db_session)
@@ -96,7 +97,7 @@ def list_oauth_configs(
def get_oauth_config_endpoint(
oauth_config_id: int,
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
_: User = Depends(current_curator_or_admin_user),
) -> OAuthConfigSnapshot:
"""Retrieve a single OAuth configuration (admin only)."""
oauth_config = get_oauth_config(oauth_config_id, db_session)
@@ -112,7 +113,7 @@ def update_oauth_config_endpoint(
oauth_config_id: int,
oauth_data: OAuthConfigUpdate,
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
_: User = Depends(current_curator_or_admin_user),
) -> OAuthConfigSnapshot:
"""Update an OAuth configuration (admin only)."""
try:
@@ -138,7 +139,7 @@ def update_oauth_config_endpoint(
def delete_oauth_config_endpoint(
oauth_config_id: int,
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
_: User = Depends(current_curator_or_admin_user),
) -> dict[str, str]:
"""Delete an OAuth configuration (admin only)."""
try:

View File

@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_chat_accessible_user
from onyx.auth.users import current_curator_or_admin_user
from onyx.auth.users import current_limited_user
from onyx.configs.app_configs import DISABLE_VECTOR_DB
from onyx.configs.constants import FileOrigin
@@ -134,7 +135,7 @@ class IsFeaturedRequest(BaseModel):
def patch_persona_visibility(
persona_id: int,
is_listed_request: IsListedRequest,
user: User = Depends(require_permission(Permission.MANAGE_AGENTS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> None:
update_persona_visibility(
@@ -149,7 +150,7 @@ def patch_persona_visibility(
def patch_user_persona_public_status(
persona_id: int,
is_public_request: IsPublicRequest,
user: User = Depends(require_permission(Permission.ADD_AGENTS)),
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
db_session: Session = Depends(get_session),
) -> None:
try:
@@ -168,7 +169,7 @@ def patch_user_persona_public_status(
def patch_persona_featured_status(
persona_id: int,
is_featured_request: IsFeaturedRequest,
user: User = Depends(require_permission(Permission.MANAGE_AGENTS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> None:
try:
@@ -203,7 +204,7 @@ def patch_agents_display_priorities(
@admin_router.get("", tags=PUBLIC_API_TAGS)
def list_personas_admin(
user: User = Depends(require_permission(Permission.READ_AGENTS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
include_deleted: bool = False,
get_editable: bool = Query(False, description="If true, return editable personas"),
@@ -220,7 +221,7 @@ def list_personas_admin(
def get_agents_admin_paginated(
page_num: int = Query(0, ge=0, description="Page number (0-indexed)."),
page_size: int = Query(10, ge=1, le=1000, description="Items per page."),
user: User = Depends(require_permission(Permission.READ_AGENTS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
include_deleted: bool = Query(
False, description="If true, includes deleted personas."
@@ -297,7 +298,7 @@ def upload_file(
@basic_router.post("", tags=PUBLIC_API_TAGS)
def create_persona(
persona_upsert_request: PersonaUpsertRequest,
user: User = Depends(require_permission(Permission.ADD_AGENTS)),
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
db_session: Session = Depends(get_session),
) -> PersonaSnapshot:
tenant_id = get_current_tenant_id()
@@ -327,7 +328,7 @@ def create_persona(
def update_persona(
persona_id: int,
persona_upsert_request: PersonaUpsertRequest,
user: User = Depends(require_permission(Permission.ADD_AGENTS)),
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
db_session: Session = Depends(get_session),
) -> PersonaSnapshot:
_validate_user_knowledge_enabled(persona_upsert_request, "update")
@@ -409,7 +410,7 @@ class PersonaShareRequest(BaseModel):
def share_persona(
persona_id: int,
persona_share_request: PersonaShareRequest,
user: User = Depends(require_permission(Permission.ADD_AGENTS)),
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
db_session: Session = Depends(get_session),
) -> None:
try:
@@ -433,7 +434,7 @@ def share_persona(
@basic_router.delete("/{persona_id}", tags=PUBLIC_API_TAGS)
def delete_persona(
persona_id: int,
user: User = Depends(require_permission(Permission.ADD_AGENTS)),
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
db_session: Session = Depends(get_session),
) -> None:
mark_persona_as_deleted(

View File

@@ -6,8 +6,9 @@ from fastapi import HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from onyx.auth.permissions import get_effective_permissions
from onyx.auth.permissions import require_permission
from onyx.auth.schemas import UserRole
from onyx.auth.users import current_curator_or_admin_user
from onyx.configs.constants import PUBLIC_API_TAGS
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import Permission
@@ -19,8 +20,6 @@ from onyx.db.tools import get_tool_by_id
from onyx.db.tools import get_tools
from onyx.db.tools import get_tools_by_ids
from onyx.db.tools import update_tool
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.tool.models import CustomToolCreate
from onyx.server.features.tool.models import CustomToolUpdate
from onyx.server.features.tool.models import ToolSnapshot
@@ -69,13 +68,13 @@ def _get_editable_custom_tool(tool_id: int, db_session: Session, user: User) ->
)
# Admins can always make changes; non-admins must own the tool.
if Permission.FULL_ADMIN_PANEL_ACCESS in get_effective_permissions(user):
if user.role == UserRole.ADMIN:
return tool
if tool.user_id is None or tool.user_id != user.id:
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"You can only modify actions that you created.",
raise HTTPException(
status_code=403,
detail="You can only modify actions that you created.",
)
return tool
@@ -85,7 +84,7 @@ def _get_editable_custom_tool(tool_id: int, db_session: Session, user: User) ->
def create_custom_tool(
tool_data: CustomToolCreate,
db_session: Session = Depends(get_session),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
user: User = Depends(current_curator_or_admin_user),
) -> ToolSnapshot:
_validate_tool_definition(tool_data.definition)
_validate_auth_settings(tool_data)
@@ -109,7 +108,7 @@ def update_custom_tool(
tool_id: int,
tool_data: CustomToolUpdate,
db_session: Session = Depends(get_session),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
user: User = Depends(current_curator_or_admin_user),
) -> ToolSnapshot:
existing_tool = _get_editable_custom_tool(tool_id, db_session, user)
if tool_data.definition:
@@ -133,7 +132,7 @@ def update_custom_tool(
def delete_custom_tool(
tool_id: int,
db_session: Session = Depends(get_session),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
user: User = Depends(current_curator_or_admin_user),
) -> None:
_ = _get_editable_custom_tool(tool_id, db_session, user)
try:
@@ -160,7 +159,7 @@ class ToolStatusUpdateResponse(BaseModel):
def update_tools_status(
update_data: ToolStatusUpdateRequest,
db_session: Session = Depends(get_session),
user: User = Depends(require_permission(Permission.MANAGE_ACTIONS)), # noqa: ARG001
user: User = Depends(current_curator_or_admin_user), # noqa: ARG001
) -> ToolStatusUpdateResponse:
"""Enable or disable one or more tools.
@@ -208,7 +207,7 @@ class ValidateToolResponse(BaseModel):
@admin_router.post("/custom/validate", tags=PUBLIC_API_TAGS)
def validate_tool(
tool_data: ValidateToolRequest,
_: User = Depends(require_permission(Permission.MANAGE_ACTIONS)),
_: User = Depends(current_curator_or_admin_user),
) -> ValidateToolResponse:
_validate_tool_definition(tool_data.definition)
method_specs = openapi_to_method_specs(tool_data.definition)

View File

@@ -10,6 +10,7 @@ from fastapi import Response
from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_curator_or_admin_user
from onyx.configs.constants import FederatedConnectorSource
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import Permission
@@ -64,7 +65,7 @@ def _get_federated_connector_instance(
@router.post("")
def create_federated_connector(
federated_connector_data: FederatedConnectorRequest,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> FederatedConnectorResponse:
"""Create a new federated connector"""
@@ -105,7 +106,7 @@ def create_federated_connector(
@router.get("/{id}/entities")
def get_entities(
id: int,
_: User = Depends(require_permission(Permission.READ_CONNECTORS)),
_: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> EntitySpecResponse:
"""Fetch allowed entities for the source type"""
@@ -147,7 +148,7 @@ def get_entities(
@router.get("/{id}/credentials/schema")
def get_credentials_schema(
id: int,
_: User = Depends(require_permission(Permission.READ_CONNECTORS)),
_: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> CredentialSchemaResponse:
"""Fetch credential schema for the source type"""
@@ -192,7 +193,7 @@ def get_credentials_schema(
@router.get("/sources/{source}/configuration/schema")
def get_configuration_schema_by_source(
source: FederatedConnectorSource,
_: User = Depends(require_permission(Permission.READ_CONNECTORS)),
_: User = Depends(current_curator_or_admin_user),
) -> ConfigurationSchemaResponse:
"""Fetch configuration schema for a specific source type (for setup/edit forms)"""
try:
@@ -220,7 +221,7 @@ def get_configuration_schema_by_source(
@router.get("/sources/{source}/credentials/schema")
def get_credentials_schema_by_source(
source: FederatedConnectorSource,
_: User = Depends(require_permission(Permission.READ_CONNECTORS)),
_: User = Depends(current_curator_or_admin_user),
) -> CredentialSchemaResponse:
"""Fetch credential schema for a specific source type (for setup forms)"""
try:
@@ -252,7 +253,7 @@ def get_credentials_schema_by_source(
def validate_credentials(
source: FederatedConnectorSource,
credentials: FederatedConnectorCredentials,
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(current_curator_or_admin_user),
) -> bool:
"""Validate credentials for a specific source type"""
try:
@@ -276,7 +277,7 @@ def validate_credentials(
def validate_entities(
id: int,
request: Request,
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> Response:
"""Validate specified entities for source type"""
@@ -511,7 +512,7 @@ def get_user_oauth_status(
@router.get("/{id}")
def get_federated_connector_detail(
id: int,
user: User = Depends(require_permission(Permission.READ_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> FederatedConnectorDetail:
"""Get detailed information about a specific federated connector"""
@@ -561,7 +562,7 @@ def get_federated_connector_detail(
def update_federated_connector_endpoint(
id: int,
update_request: FederatedConnectorUpdateRequest,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> FederatedConnectorDetail:
"""Update a federated connector's configuration"""
@@ -592,7 +593,7 @@ def update_federated_connector_endpoint(
@router.delete("/{id}")
def delete_federated_connector_endpoint(
id: int,
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> bool:
"""Delete a federated connector"""

View File

@@ -5,9 +5,11 @@ from typing import cast
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_curator_or_admin_user
from onyx.background.celery.versioned_apps.client import app as client_app
from onyx.configs.app_configs import GENERATIVE_MODEL_ACCESS_CHECK_FREQ
from onyx.configs.constants import DocumentSource
@@ -27,8 +29,6 @@ from onyx.db.feedback import update_document_boost_for_user
from onyx.db.feedback import update_document_hidden_for_user
from onyx.db.index_attempt import cancel_indexing_attempts_for_ccpair
from onyx.db.models import User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.file_store.file_store import get_default_file_store
from onyx.key_value_store.factory import get_kv_store
from onyx.key_value_store.interface import KvKeyNotFoundError
@@ -52,7 +52,7 @@ logger = setup_logger()
def get_most_boosted_docs(
ascending: bool,
limit: int,
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> list[BoostDoc]:
boost_docs = fetch_docs_ranked_by_boost_for_user(
@@ -77,7 +77,7 @@ def get_most_boosted_docs(
@router.post("/admin/doc-boosts")
def document_boost_update(
boost_update: BoostUpdateRequest,
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> StatusResponse:
update_document_boost_for_user(
@@ -92,7 +92,7 @@ def document_boost_update(
@router.post("/admin/doc-hidden")
def document_hidden_update(
hidden_update: HiddenUpdateRequest,
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> StatusResponse:
update_document_hidden_for_user(
@@ -106,7 +106,7 @@ def document_hidden_update(
@router.get("/admin/genai-api-key/validate")
def validate_existing_genai_api_key(
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> None:
# Only validate every so often
kv_store = get_kv_store()
@@ -125,11 +125,11 @@ def validate_existing_genai_api_key(
try:
llm = get_default_llm(timeout=10)
except ValueError:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "LLM not setup")
raise HTTPException(status_code=404, detail="LLM not setup")
error = test_llm(llm)
if error:
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, error)
raise HTTPException(status_code=400, detail=error)
# Mark check as successful
curr_time = datetime.now(tz=timezone.utc)
@@ -139,7 +139,7 @@ def validate_existing_genai_api_key(
@router.post("/admin/deletion-attempt", tags=PUBLIC_API_TAGS)
def create_deletion_attempt_for_connector_id(
connector_credential_pair_identifier: ConnectorCredentialPairIdentifier,
user: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> None:
tenant_id = get_current_tenant_id()
@@ -157,7 +157,10 @@ def create_deletion_attempt_for_connector_id(
if cc_pair is None:
error = f"Connector with ID '{connector_id}' and credential ID '{credential_id}' does not exist. Has it already been deleted?"
logger.error(error)
raise OnyxError(OnyxErrorCode.CONNECTOR_NOT_FOUND, error)
raise HTTPException(
status_code=404,
detail=error,
)
# Cancel any scheduled indexing attempts
cancel_indexing_attempts_for_ccpair(

View File

@@ -2,6 +2,8 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import status
from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
@@ -23,8 +25,6 @@ from onyx.db.discord_bot import update_guild_config
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import Permission
from onyx.db.models import User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.manage.discord_bot.models import DiscordBotConfigCreateRequest
from onyx.server.manage.discord_bot.models import DiscordBotConfigResponse
from onyx.server.manage.discord_bot.models import DiscordChannelConfigResponse
@@ -48,14 +48,14 @@ def _check_bot_config_api_access() -> None:
- When DISCORD_BOT_TOKEN env var is set (managed via env)
"""
if AUTH_TYPE == AuthType.CLOUD:
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"Discord bot configuration is managed by Onyx on Cloud.",
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Discord bot configuration is managed by Onyx on Cloud.",
)
if DISCORD_BOT_TOKEN:
raise OnyxError(
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
"Discord bot is configured via environment variables. API access disabled.",
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Discord bot is configured via environment variables. API access disabled.",
)
@@ -65,7 +65,7 @@ def _check_bot_config_api_access() -> None:
@router.get("/config", response_model=DiscordBotConfigResponse)
def get_bot_config(
_: None = Depends(_check_bot_config_api_access),
__: User = Depends(require_permission(Permission.MANAGE_BOTS)),
__: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> DiscordBotConfigResponse:
"""Get Discord bot config. Returns 403 on Cloud or if env vars set."""
@@ -83,7 +83,7 @@ def get_bot_config(
def create_bot_request(
request: DiscordBotConfigCreateRequest,
_: None = Depends(_check_bot_config_api_access),
__: User = Depends(require_permission(Permission.MANAGE_BOTS)),
__: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> DiscordBotConfigResponse:
"""Create Discord bot config. Returns 403 on Cloud or if env vars set."""
@@ -93,9 +93,9 @@ def create_bot_request(
bot_token=request.bot_token,
)
except ValueError:
raise OnyxError(
OnyxErrorCode.CONFLICT,
"Discord bot config already exists. Delete it first to create a new one.",
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Discord bot config already exists. Delete it first to create a new one.",
)
db_session.commit()
@@ -109,7 +109,7 @@ def create_bot_request(
@router.delete("/config")
def delete_bot_config_endpoint(
_: None = Depends(_check_bot_config_api_access),
__: User = Depends(require_permission(Permission.MANAGE_BOTS)),
__: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> dict:
"""Delete Discord bot config.
@@ -118,7 +118,7 @@ def delete_bot_config_endpoint(
"""
deleted = delete_discord_bot_config(db_session)
if not deleted:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Bot config not found")
raise HTTPException(status_code=404, detail="Bot config not found")
# Also delete the service API key used by the Discord bot
delete_discord_service_api_key(db_session)
@@ -132,7 +132,7 @@ def delete_bot_config_endpoint(
@router.delete("/service-api-key")
def delete_service_api_key_endpoint(
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> dict:
"""Delete the Discord service API key.
@@ -145,7 +145,7 @@ def delete_service_api_key_endpoint(
"""
deleted = delete_discord_service_api_key(db_session)
if not deleted:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Service API key not found")
raise HTTPException(status_code=404, detail="Service API key not found")
db_session.commit()
return {"deleted": True}
@@ -155,7 +155,7 @@ def delete_service_api_key_endpoint(
@router.get("/guilds", response_model=list[DiscordGuildConfigResponse])
def list_guild_configs(
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> list[DiscordGuildConfigResponse]:
"""List all guild configs (pending and registered)."""
@@ -165,7 +165,7 @@ def list_guild_configs(
@router.post("/guilds", response_model=DiscordGuildConfigCreateResponse)
def create_guild_request(
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> DiscordGuildConfigCreateResponse:
"""Create new guild config with registration key. Key shown once."""
@@ -184,13 +184,13 @@ def create_guild_request(
@router.get("/guilds/{config_id}", response_model=DiscordGuildConfigResponse)
def get_guild_config(
config_id: int,
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> DiscordGuildConfigResponse:
"""Get specific guild config."""
config = get_guild_config_by_internal_id(db_session, internal_id=config_id)
if not config:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Guild config not found")
raise HTTPException(status_code=404, detail="Guild config not found")
return DiscordGuildConfigResponse.model_validate(config)
@@ -198,13 +198,13 @@ def get_guild_config(
def update_guild_request(
config_id: int,
request: DiscordGuildConfigUpdateRequest,
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> DiscordGuildConfigResponse:
"""Update guild config."""
config = get_guild_config_by_internal_id(db_session, internal_id=config_id)
if not config:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Guild config not found")
raise HTTPException(status_code=404, detail="Guild config not found")
config = update_guild_config(
db_session,
@@ -220,7 +220,7 @@ def update_guild_request(
@router.delete("/guilds/{config_id}")
def delete_guild_request(
config_id: int,
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> dict:
"""Delete guild config (invalidates registration key).
@@ -229,7 +229,7 @@ def delete_guild_request(
"""
deleted = delete_guild_config(db_session, config_id)
if not deleted:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Guild config not found")
raise HTTPException(status_code=404, detail="Guild config not found")
# On Cloud, delete service API key when all guilds are removed
if AUTH_TYPE == AuthType.CLOUD:
@@ -249,15 +249,15 @@ def delete_guild_request(
)
def list_channel_configs(
config_id: int,
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> list[DiscordChannelConfigResponse]:
"""List whitelisted channels for a guild."""
guild_config = get_guild_config_by_internal_id(db_session, internal_id=config_id)
if not guild_config:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Guild config not found")
raise HTTPException(status_code=404, detail="Guild config not found")
if not guild_config.guild_id:
raise OnyxError(OnyxErrorCode.INVALID_INPUT, "Guild not yet registered")
raise HTTPException(status_code=400, detail="Guild not yet registered")
configs = get_channel_configs(db_session, config_id)
return [DiscordChannelConfigResponse.model_validate(c) for c in configs]
@@ -271,7 +271,7 @@ def update_channel_request(
guild_config_id: int,
channel_config_id: int,
request: DiscordChannelConfigUpdateRequest,
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> DiscordChannelConfigResponse:
"""Update channel config."""
@@ -279,7 +279,7 @@ def update_channel_request(
db_session, guild_config_id, channel_config_id
)
if not config:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Channel config not found")
raise HTTPException(status_code=404, detail="Channel config not found")
config = update_discord_channel_config(
db_session,

View File

@@ -15,8 +15,8 @@ from fastapi import Query
from pydantic import ValidationError
from sqlalchemy.orm import Session
from onyx.auth.permissions import has_permission
from onyx.auth.permissions import require_permission
from onyx.auth.schemas import UserRole
from onyx.auth.users import current_chat_accessible_user
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import LLMModelFlowType
@@ -40,6 +40,8 @@ from onyx.db.models import User
from onyx.db.persona import user_can_access_persona
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.llm.constants import PROVIDER_DISPLAY_NAMES
from onyx.llm.constants import WELL_KNOWN_PROVIDER_NAMES
from onyx.llm.factory import get_default_llm
from onyx.llm.factory import get_llm
from onyx.llm.factory import get_max_input_tokens_from_llm_provider
@@ -60,6 +62,7 @@ from onyx.server.manage.llm.models import BedrockFinalModelResponse
from onyx.server.manage.llm.models import BedrockModelsRequest
from onyx.server.manage.llm.models import BifrostFinalModelResponse
from onyx.server.manage.llm.models import BifrostModelsRequest
from onyx.server.manage.llm.models import CustomProviderOption
from onyx.server.manage.llm.models import DefaultModel
from onyx.server.manage.llm.models import LitellmFinalModelResponse
from onyx.server.manage.llm.models import LitellmModelDetails
@@ -250,9 +253,32 @@ def _validate_llm_provider_change(
)
@admin_router.get("/custom-provider-names")
def fetch_custom_provider_names(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> list[CustomProviderOption]:
"""Returns the sorted list of LiteLLM provider names that can be used
with the custom provider modal (i.e. everything that is not already
covered by a well-known provider modal)."""
import litellm
well_known = {p.value for p in WELL_KNOWN_PROVIDER_NAMES}
return sorted(
(
CustomProviderOption(
value=name,
label=PROVIDER_DISPLAY_NAMES.get(name, name.replace("_", " ").title()),
)
for name in litellm.models_by_provider.keys()
if name not in well_known
),
key=lambda o: o.label.lower(),
)
@admin_router.get("/built-in/options")
def fetch_llm_options(
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> list[WellKnownLLMProviderDescriptor]:
return fetch_available_well_known_llms()
@@ -260,7 +286,7 @@ def fetch_llm_options(
@admin_router.get("/built-in/options/{provider_name}")
def fetch_llm_provider_options(
provider_name: str,
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> WellKnownLLMProviderDescriptor:
well_known_llms = fetch_available_well_known_llms()
for well_known_llm in well_known_llms:
@@ -272,7 +298,7 @@ def fetch_llm_provider_options(
@admin_router.post("/test")
def test_llm_configuration(
test_llm_request: TestLLMRequest,
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> None:
"""Test LLM configuration settings"""
@@ -330,7 +356,7 @@ def test_llm_configuration(
@admin_router.post("/test/default")
def test_default_provider(
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> None:
try:
llm = get_default_llm()
@@ -346,7 +372,7 @@ def test_default_provider(
@admin_router.get("/provider")
def list_llm_providers(
include_image_gen: bool = Query(False),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> LLMProviderResponse[LLMProviderView]:
start_time = datetime.now(timezone.utc)
@@ -391,7 +417,7 @@ def put_llm_provider(
False,
description="True if creating a new one, False if updating an existing provider",
),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> LLMProviderView:
# validate request (e.g. if we're intending to create but the name already exists we should throw an error)
@@ -529,7 +555,7 @@ def put_llm_provider(
def delete_llm_provider(
provider_id: int,
force: bool = Query(False),
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> None:
if not force:
@@ -550,7 +576,7 @@ def delete_llm_provider(
@admin_router.post("/default")
def set_provider_as_default(
default_model_request: DefaultModel,
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> None:
update_default_provider(
@@ -563,7 +589,7 @@ def set_provider_as_default(
@admin_router.post("/default-vision")
def set_provider_as_default_vision(
default_model: DefaultModel,
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> None:
update_default_vision_provider(
@@ -575,7 +601,7 @@ def set_provider_as_default_vision(
@admin_router.get("/auto-config")
def get_auto_config(
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> dict:
"""Get the current Auto mode configuration from GitHub.
@@ -593,7 +619,7 @@ def get_auto_config(
@admin_router.get("/vision-providers")
def get_vision_capable_providers(
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> LLMProviderResponse[VisionProviderResponse]:
"""Return a list of LLM providers and their models that support image input"""
@@ -655,7 +681,7 @@ def list_llm_provider_basics(
all_providers = fetch_existing_llm_providers(db_session, [])
user_group_ids = fetch_user_group_ids(db_session, user)
can_manage_llms = has_permission(user, Permission.MANAGE_LLMS)
is_admin = user.role == UserRole.ADMIN
accessible_providers = []
@@ -667,7 +693,7 @@ def list_llm_provider_basics(
# - Excludes providers with persona restrictions (requires specific persona)
# - Excludes non-public providers with no restrictions (admin-only)
if can_user_access_llm_provider(
provider, user_group_ids, persona=None, is_admin=can_manage_llms
provider, user_group_ids, persona=None, is_admin=is_admin
):
accessible_providers.append(LLMProviderDescriptor.from_model(provider))
@@ -703,19 +729,17 @@ def get_valid_model_names_for_persona(
if not persona:
return []
can_manage_llms = has_permission(user, Permission.MANAGE_LLMS)
is_admin = user.role == UserRole.ADMIN
all_providers = fetch_existing_llm_providers(
db_session, [LLMModelFlowType.CHAT, LLMModelFlowType.VISION]
)
user_group_ids = (
set() if can_manage_llms else fetch_user_group_ids(db_session, user)
)
user_group_ids = set() if is_admin else fetch_user_group_ids(db_session, user)
valid_models = []
for llm_provider_model in all_providers:
# Check access with persona context — respects all RBAC restrictions
if can_user_access_llm_provider(
llm_provider_model, user_group_ids, persona, is_admin=can_manage_llms
llm_provider_model, user_group_ids, persona, is_admin=is_admin
):
# Collect all model names from this provider
for model_config in llm_provider_model.model_configurations:
@@ -754,20 +778,18 @@ def list_llm_providers_for_persona(
"You don't have access to this assistant",
)
can_manage_llms = has_permission(user, Permission.MANAGE_LLMS)
is_admin = user.role == UserRole.ADMIN
all_providers = fetch_existing_llm_providers(
db_session, [LLMModelFlowType.CHAT, LLMModelFlowType.VISION]
)
user_group_ids = (
set() if can_manage_llms else fetch_user_group_ids(db_session, user)
)
user_group_ids = set() if is_admin else fetch_user_group_ids(db_session, user)
llm_provider_list: list[LLMProviderDescriptor] = []
for llm_provider_model in all_providers:
# Check access with persona context — respects persona restrictions
if can_user_access_llm_provider(
llm_provider_model, user_group_ids, persona, is_admin=can_manage_llms
llm_provider_model, user_group_ids, persona, is_admin=is_admin
):
llm_provider_list.append(
LLMProviderDescriptor.from_model(llm_provider_model)
@@ -795,7 +817,7 @@ def list_llm_providers_for_persona(
if persona_default_provider:
provider = fetch_existing_llm_provider(persona_default_provider, db_session)
if provider and can_user_access_llm_provider(
provider, user_group_ids, persona, is_admin=can_manage_llms
provider, user_group_ids, persona, is_admin=is_admin
):
if persona_default_model:
# Persona specifies both provider and model — use them directly
@@ -828,7 +850,7 @@ def list_llm_providers_for_persona(
@admin_router.get("/provider-contextual-cost")
def get_provider_contextual_cost(
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> list[LLMCost]:
"""
@@ -877,7 +899,7 @@ def get_provider_contextual_cost(
@admin_router.post("/bedrock/available-models")
def get_bedrock_available_models(
request: BedrockModelsRequest,
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> list[BedrockFinalModelResponse]:
"""Fetch available Bedrock models for a specific region and credentials.
@@ -1052,7 +1074,7 @@ def _get_ollama_available_model_names(api_base: str) -> set[str]:
@admin_router.post("/ollama/available-models")
def get_ollama_available_models(
request: OllamaModelsRequest,
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> list[OllamaFinalModelResponse]:
"""Fetch the list of available models from an Ollama server."""
@@ -1176,7 +1198,7 @@ def _get_openrouter_models_response(api_base: str, api_key: str) -> dict:
@admin_router.post("/openrouter/available-models")
def get_openrouter_available_models(
request: OpenRouterModelsRequest,
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> list[OpenRouterFinalModelResponse]:
"""Fetch available models from OpenRouter `/models` endpoint.
@@ -1257,7 +1279,7 @@ def get_openrouter_available_models(
@admin_router.post("/lm-studio/available-models")
def get_lm_studio_available_models(
request: LMStudioModelsRequest,
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> list[LMStudioFinalModelResponse]:
"""Fetch available models from an LM Studio server.
@@ -1364,7 +1386,7 @@ def get_lm_studio_available_models(
@admin_router.post("/litellm/available-models")
def get_litellm_available_models(
request: LitellmModelsRequest,
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> list[LitellmFinalModelResponse]:
"""Fetch available models from Litellm proxy /v1/models endpoint."""
@@ -1497,7 +1519,7 @@ def _get_openai_compatible_models_response(
@admin_router.post("/bifrost/available-models")
def get_bifrost_available_models(
request: BifrostModelsRequest,
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> list[BifrostFinalModelResponse]:
"""Fetch available models from Bifrost gateway /v1/models endpoint."""
@@ -1587,7 +1609,7 @@ def _get_bifrost_models_response(api_base: str, api_key: str | None = None) -> d
@admin_router.post("/openai-compatible/available-models")
def get_openai_compatible_server_available_models(
request: OpenAICompatibleModelsRequest,
_: User = Depends(require_permission(Permission.MANAGE_LLMS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> list[OpenAICompatibleFinalModelResponse]:
"""Fetch available models from a generic OpenAI-compatible /v1/models endpoint."""
@@ -1652,7 +1674,7 @@ def get_openai_compatible_server_available_models(
)
for r in sorted_results
],
source_label="OpenAI Compatible",
source_label="OpenAI-Compatible",
)
return sorted_results
@@ -1671,6 +1693,6 @@ def _get_openai_compatible_server_response(
return _get_openai_compatible_models_response(
url=url,
source_name="OpenAI Compatible",
source_name="OpenAI-Compatible",
api_key=api_key,
)

View File

@@ -28,6 +28,13 @@ if TYPE_CHECKING:
T = TypeVar("T", "LLMProviderDescriptor", "LLMProviderView", "VisionProviderResponse")
class CustomProviderOption(BaseModel):
"""A provider slug + human-friendly label for the custom-provider picker."""
value: str
label: str
class TestLLMRequest(BaseModel):
# provider level
id: int | None = None

View File

@@ -10,10 +10,10 @@ from pydantic import Field
from pydantic import field_validator
from pydantic import model_validator
from onyx.auth.schemas import UserRole
from onyx.configs.app_configs import TRACK_EXTERNAL_IDP_EXPIRY
from onyx.configs.constants import AuthType
from onyx.context.search.models import SavedSearchSettings
from onyx.db.enums import AccountType
from onyx.db.enums import DefaultAppMode
from onyx.db.enums import ThemePreference
from onyx.db.memory import MAX_MEMORIES_PER_USER
@@ -124,7 +124,7 @@ class UserInfo(BaseModel):
is_active: bool
is_superuser: bool
is_verified: bool
account_type: AccountType = AccountType.STANDARD
role: UserRole
preferences: UserPreferences
personalization: UserPersonalization = Field(default_factory=UserPersonalization)
oidc_expiry: datetime | None = None
@@ -135,7 +135,6 @@ class UserInfo(BaseModel):
is_anonymous_user: bool | None = None
password_configured: bool | None = None
tenant_info: TenantInfo | None = None
effective_permissions: list[str] = Field(default_factory=list)
@classmethod
def from_model(
@@ -149,7 +148,6 @@ class UserInfo(BaseModel):
tenant_info: TenantInfo | None = None,
assistant_specific_configs: UserSpecificAssistantPreferences | None = None,
memories: list[MemoryItem] | None = None,
effective_permissions: list[str] | None = None,
) -> "UserInfo":
return cls(
id=str(user.id),
@@ -157,7 +155,7 @@ class UserInfo(BaseModel):
is_active=user.is_active,
is_superuser=user.is_superuser,
is_verified=user.is_verified,
account_type=user.account_type,
role=user.role,
password_configured=user.password_configured,
preferences=(
UserPreferences(
@@ -189,7 +187,6 @@ class UserInfo(BaseModel):
is_cloud_superuser=is_cloud_superuser,
is_anonymous_user=is_anonymous_user,
tenant_info=tenant_info,
effective_permissions=effective_permissions or [],
personalization=UserPersonalization(
name=user.personal_name or "",
role=user.personal_role or "",
@@ -205,6 +202,16 @@ class UserByEmail(BaseModel):
user_email: str
class UserRoleUpdateRequest(BaseModel):
user_email: str
new_role: UserRole
explicit_override: bool = False
class UserRoleResponse(BaseModel):
role: str
class BoostDoc(BaseModel):
document_id: str
semantic_id: str

View File

@@ -114,7 +114,7 @@ def _form_channel_config(
def create_slack_channel_config(
slack_channel_config_creation_request: SlackChannelConfigCreationRequest,
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> SlackChannelConfig:
channel_config = _form_channel_config(
db_session=db_session,
@@ -155,7 +155,7 @@ def patch_slack_channel_config(
slack_channel_config_id: int,
slack_channel_config_creation_request: SlackChannelConfigCreationRequest,
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> SlackChannelConfig:
channel_config = _form_channel_config(
db_session=db_session,
@@ -216,7 +216,7 @@ def patch_slack_channel_config(
def delete_slack_channel_config(
slack_channel_config_id: int,
db_session: Session = Depends(get_session),
user: User = Depends(require_permission(Permission.MANAGE_BOTS)),
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> None:
remove_slack_channel_config(
db_session=db_session,
@@ -228,7 +228,7 @@ def delete_slack_channel_config(
@router.get("/admin/slack-app/channel")
def list_slack_channel_configs(
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> list[SlackChannelConfig]:
slack_channel_config_models = fetch_slack_channel_configs(db_session=db_session)
return [
@@ -241,7 +241,7 @@ def list_slack_channel_configs(
def create_bot(
slack_bot_creation_request: SlackBotCreationRequest,
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> SlackBot:
tenant_id = get_current_tenant_id()
@@ -287,7 +287,7 @@ def patch_bot(
slack_bot_id: int,
slack_bot_creation_request: SlackBotCreationRequest,
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> SlackBot:
validate_bot_token(slack_bot_creation_request.bot_token)
validate_app_token(slack_bot_creation_request.app_token)
@@ -308,7 +308,7 @@ def patch_bot(
def delete_bot(
slack_bot_id: int,
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> None:
remove_slack_bot(
db_session=db_session,
@@ -320,7 +320,7 @@ def delete_bot(
def get_bot_by_id(
slack_bot_id: int,
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> SlackBot:
slack_bot_model = fetch_slack_bot(
db_session=db_session,
@@ -332,7 +332,7 @@ def get_bot_by_id(
@router.get("/admin/slack-app/bots")
def list_bots(
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> list[SlackBot]:
slack_bot_models = fetch_slack_bots(db_session=db_session)
return [
@@ -344,7 +344,7 @@ def list_bots(
def list_bot_configs(
bot_id: int,
db_session: Session = Depends(get_session),
_: User = Depends(require_permission(Permission.MANAGE_BOTS)),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> list[SlackChannelConfig]:
slack_bot_config_models = fetch_slack_channel_configs(
db_session=db_session, slack_bot_id=bot_id

View File

@@ -29,7 +29,9 @@ from onyx.auth.invited_users import remove_user_from_invited_users
from onyx.auth.invited_users import write_invited_users
from onyx.auth.permissions import get_effective_permissions
from onyx.auth.permissions import require_permission
from onyx.auth.schemas import UserRole
from onyx.auth.users import anonymous_user_enabled
from onyx.auth.users import current_curator_or_admin_user
from onyx.auth.users import enforce_seat_limit
from onyx.auth.users import optional_user
from onyx.configs.app_configs import AUTH_BACKEND
@@ -66,6 +68,7 @@ from onyx.db.user_preferences import update_user_default_app_mode
from onyx.db.user_preferences import update_user_default_model
from onyx.db.user_preferences import update_user_personalization
from onyx.db.user_preferences import update_user_pinned_assistants
from onyx.db.user_preferences import update_user_role
from onyx.db.user_preferences import update_user_shortcut_enabled
from onyx.db.user_preferences import update_user_temperature_override_enabled
from onyx.db.user_preferences import update_user_theme_preference
@@ -76,7 +79,8 @@ from onyx.db.users import get_all_users
from onyx.db.users import get_page_of_filtered_users
from onyx.db.users import get_total_filtered_users_count
from onyx.db.users import get_user_by_email
from onyx.db.users import get_user_counts_by_account_type_and_status
from onyx.db.users import get_user_counts_by_role_and_status
from onyx.db.users import validate_user_role_update
from onyx.key_value_store.factory import get_kv_store
from onyx.redis.redis_pool import get_raw_redis_client
from onyx.server.documents.models import PaginatedReturn
@@ -95,6 +99,8 @@ from onyx.server.manage.models import ThemePreferenceRequest
from onyx.server.manage.models import UserByEmail
from onyx.server.manage.models import UserInfo
from onyx.server.manage.models import UserPreferences
from onyx.server.manage.models import UserRoleResponse
from onyx.server.manage.models import UserRoleUpdateRequest
from onyx.server.manage.models import UserSpecificAssistantPreference
from onyx.server.manage.models import UserSpecificAssistantPreferences
from onyx.server.models import FullUserSnapshot
@@ -117,6 +123,48 @@ router = APIRouter()
USERS_PAGE_SIZE = 10
@router.patch("/manage/set-user-role", tags=PUBLIC_API_TAGS)
def set_user_role(
user_role_update_request: UserRoleUpdateRequest,
current_user: User = Depends(
require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)
),
db_session: Session = Depends(get_session),
) -> None:
user_to_update = get_user_by_email(
email=user_role_update_request.user_email, db_session=db_session
)
if not user_to_update:
raise HTTPException(status_code=404, detail="User not found")
current_role = user_to_update.role
requested_role = user_role_update_request.new_role
if requested_role == current_role:
return
# This will raise an exception if the role update is invalid
validate_user_role_update(
requested_role=requested_role,
current_account_type=user_to_update.account_type,
explicit_override=user_role_update_request.explicit_override,
)
if user_to_update.id == current_user.id:
raise HTTPException(
status_code=400,
detail="An admin cannot demote themselves from admin role!",
)
if requested_role == UserRole.CURATOR:
# Remove all curator db relationships before changing role
fetch_ee_implementation_or_noop(
"onyx.db.user_group",
"remove_curator_status__no_commit",
)(db_session, user_to_update)
update_user_role(user_to_update, requested_role, db_session)
class TestUpsertRequest(BaseModel):
email: str
@@ -130,17 +178,7 @@ async def test_upsert_user(
user = await fetch_ee_implementation_or_noop(
"onyx.server.saml", "upsert_saml_user", None
)(email=request.email)
return (
FullUserSnapshot.from_user_model(
user,
is_admin=(
Permission.FULL_ADMIN_PANEL_ACCESS.value
in (user.effective_permissions or [])
),
)
if user
else None
)
return FullUserSnapshot.from_user_model(user) if user else None
@router.get("/manage/users/accepted", tags=PUBLIC_API_TAGS)
@@ -148,6 +186,7 @@ def list_accepted_users(
q: str | None = Query(default=None),
page_num: int = Query(0, ge=0),
page_size: int = Query(10, ge=1, le=1000),
roles: list[UserRole] = Query(default=[]),
is_active: bool | None = Query(default=None),
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
@@ -158,12 +197,14 @@ def list_accepted_users(
page_num=page_num,
email_filter_string=q,
is_active_filter=is_active,
roles_filter=roles,
)
total_accepted_users_count = get_total_filtered_users_count(
db_session=db_session,
email_filter_string=q,
is_active_filter=is_active,
roles_filter=roles,
)
if not filtered_accepted_users:
@@ -200,10 +241,6 @@ def list_accepted_users(
for gid, gname in groups_by_user.get(user.id, [])
],
is_scim_synced=user.id in scim_synced_ids,
is_admin=(
Permission.FULL_ADMIN_PANEL_ACCESS.value
in (user.effective_permissions or [])
),
)
for user in filtered_accepted_users
],
@@ -249,10 +286,6 @@ def list_all_accepted_users(
for gid, gname in groups_by_user.get(user.id, [])
],
is_scim_synced=user.id in scim_synced_ids,
is_admin=(
Permission.FULL_ADMIN_PANEL_ACCESS.value
in (user.effective_permissions or [])
),
)
for user in users
]
@@ -263,7 +296,7 @@ def get_user_counts(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
db_session: Session = Depends(get_session),
) -> dict[str, dict[str, int]]:
return get_user_counts_by_account_type_and_status(db_session)
return get_user_counts_by_role_and_status(db_session)
@router.get("/manage/users/invited", tags=PUBLIC_API_TAGS)
@@ -289,7 +322,7 @@ def list_all_users(
slack_users_page: int | None = None,
invited_page: int | None = None,
include_api_keys: bool = False,
_: User = Depends(require_permission(Permission.READ_USERS)),
_: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> AllUsersResponse:
users = [
@@ -324,24 +357,10 @@ def list_all_users(
if accepted_page is None or invited_page is None or slack_users_page is None:
return AllUsersResponse(
accepted=[
FullUserSnapshot.from_user_model(
user,
is_admin=(
Permission.FULL_ADMIN_PANEL_ACCESS.value
in (user.effective_permissions or [])
),
)
for user in accepted_users
FullUserSnapshot.from_user_model(user) for user in accepted_users
],
slack_users=[
FullUserSnapshot.from_user_model(
user,
is_admin=(
Permission.FULL_ADMIN_PANEL_ACCESS.value
in (user.effective_permissions or [])
),
)
for user in slack_users
FullUserSnapshot.from_user_model(user) for user in slack_users
],
invited=[InvitedUserSnapshot(email=email) for email in invited_emails],
accepted_pages=1,
@@ -351,26 +370,10 @@ def list_all_users(
# Otherwise, return paginated results
return AllUsersResponse(
accepted=[
FullUserSnapshot.from_user_model(
user,
is_admin=(
Permission.FULL_ADMIN_PANEL_ACCESS.value
in (user.effective_permissions or [])
),
)
for user in accepted_users
][accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE],
slack_users=[
FullUserSnapshot.from_user_model(
user,
is_admin=(
Permission.FULL_ADMIN_PANEL_ACCESS.value
in (user.effective_permissions or [])
),
)
for user in slack_users
][
accepted=[FullUserSnapshot.from_user_model(user) for user in accepted_users][
accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE
],
slack_users=[FullUserSnapshot.from_user_model(user) for user in slack_users][
slack_users_page
* USERS_PAGE_SIZE : (slack_users_page + 1)
* USERS_PAGE_SIZE
@@ -405,7 +408,7 @@ def download_users_csv(
writer.writerow(
[
user.email,
user.account_type.value if user.account_type else "",
user.role.value if user.role else "",
"Active" if user.is_active else "Inactive",
]
)
@@ -681,6 +684,13 @@ def list_all_users_basic_info(
]
@router.get("/get-user-role", tags=PUBLIC_API_TAGS)
async def get_user_role(
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
) -> UserRoleResponse:
return UserRoleResponse(role=user.role)
def get_current_auth_token_creation_redis(
user: User, request: Request
) -> datetime | None:
@@ -847,7 +857,6 @@ def verify_user_logged_in(
invitation=tenant_invitation,
),
memories=memories,
effective_permissions=sorted(p.value for p in get_effective_permissions(user)),
)
return user_info

View File

@@ -0,0 +1,72 @@
"""Pruning-specific Prometheus metrics.
Tracks three pruning pipeline phases for connector_pruning_generator_task:
1. Document ID enumeration duration (extract_ids_from_runnable_connector)
2. Diff + dispatch duration (DB lookup, set diff, generate_tasks)
3. Rate limit errors during enumeration
All metrics are labeled by connector_type to identify which connector sources
are the most expensive to prune. cc_pair_id is intentionally excluded to avoid
unbounded cardinality.
Usage:
from onyx.server.metrics.pruning_metrics import (
observe_pruning_enumeration_duration,
observe_pruning_diff_duration,
inc_pruning_rate_limit_error,
)
"""
from prometheus_client import Counter
from prometheus_client import Histogram
from onyx.utils.logger import setup_logger
logger = setup_logger()
PRUNING_ENUMERATION_DURATION = Histogram(
"onyx_pruning_enumeration_duration_seconds",
"Duration of document ID enumeration from the source connector during pruning",
["connector_type"],
buckets=[1, 5, 15, 30, 60, 120, 300, 600, 1800, 3600],
)
PRUNING_DIFF_DURATION = Histogram(
"onyx_pruning_diff_duration_seconds",
"Duration of diff computation and subtask dispatch during pruning",
["connector_type"],
buckets=[1, 5, 15, 30, 60, 120, 300, 600, 1800, 3600],
)
PRUNING_RATE_LIMIT_ERRORS = Counter(
"onyx_pruning_rate_limit_errors_total",
"Total rate limit errors encountered during pruning document ID enumeration",
["connector_type"],
)
def observe_pruning_enumeration_duration(
duration_seconds: float, connector_type: str
) -> None:
try:
PRUNING_ENUMERATION_DURATION.labels(connector_type=connector_type).observe(
duration_seconds
)
except Exception:
logger.debug("Failed to record pruning enumeration duration", exc_info=True)
def observe_pruning_diff_duration(duration_seconds: float, connector_type: str) -> None:
try:
PRUNING_DIFF_DURATION.labels(connector_type=connector_type).observe(
duration_seconds
)
except Exception:
logger.debug("Failed to record pruning diff duration", exc_info=True)
def inc_pruning_rate_limit_error(connector_type: str) -> None:
try:
PRUNING_RATE_LIMIT_ERRORS.labels(connector_type=connector_type).inc()
except Exception:
logger.debug("Failed to record pruning rate limit error", exc_info=True)

View File

@@ -6,6 +6,7 @@ from uuid import UUID
from pydantic import BaseModel
from onyx.auth.schemas import UserRole
from onyx.db.enums import AccountType
from onyx.db.models import User
@@ -40,8 +41,8 @@ class UserGroupInfo(BaseModel):
class FullUserSnapshot(BaseModel):
id: UUID
email: str
role: UserRole
account_type: AccountType
is_admin: bool = False
is_active: bool
password_configured: bool
personal_name: str | None
@@ -56,13 +57,12 @@ class FullUserSnapshot(BaseModel):
user: User,
groups: list[UserGroupInfo] | None = None,
is_scim_synced: bool = False,
is_admin: bool = False,
) -> "FullUserSnapshot":
return cls(
id=user.id,
email=user.email,
role=user.role,
account_type=user.account_type,
is_admin=is_admin,
is_active=user.is_active,
password_configured=user.password_configured,
personal_name=user.personal_name,

View File

@@ -3,9 +3,10 @@ from datetime import timezone
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_curator_or_admin_user
from onyx.configs.constants import DEFAULT_CC_PAIR_ID
from onyx.configs.constants import DocumentSource
from onyx.configs.constants import PUBLIC_API_TAGS
@@ -17,14 +18,11 @@ from onyx.db.document import get_document
from onyx.db.document import get_documents_by_cc_pair
from onyx.db.document import get_ingestion_documents
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import Permission
from onyx.db.models import User
from onyx.db.search_settings import get_active_search_settings
from onyx.db.search_settings import get_current_search_settings
from onyx.db.search_settings import get_secondary_search_settings
from onyx.document_index.factory import get_all_document_indices
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.indexing.adapters.document_indexing_adapter import (
DocumentIndexingBatchAdapter,
)
@@ -46,7 +44,7 @@ router = APIRouter(prefix="/onyx-api", tags=PUBLIC_API_TAGS)
@router.get("/connector-docs/{cc_pair_id}")
def get_docs_by_connector_credential_pair(
cc_pair_id: int,
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> list[DocMinimalInfo]:
db_docs = get_documents_by_cc_pair(cc_pair_id=cc_pair_id, db_session=db_session)
@@ -62,7 +60,7 @@ def get_docs_by_connector_credential_pair(
@router.get("/ingestion")
def get_ingestion_docs(
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> list[DocMinimalInfo]:
db_docs = get_ingestion_documents(db_session)
@@ -79,7 +77,7 @@ def get_ingestion_docs(
@router.post("/ingestion", dependencies=[Depends(require_vector_db)])
def upsert_ingestion_doc(
doc_info: IngestionDocument,
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> IngestionResult:
tenant_id = get_current_tenant_id()
@@ -100,9 +98,8 @@ def upsert_ingestion_doc(
cc_pair_id=doc_info.cc_pair_id or DEFAULT_CC_PAIR_ID,
)
if cc_pair is None:
raise OnyxError(
OnyxErrorCode.CONNECTOR_NOT_FOUND,
"Connector-Credential Pair specified does not exist",
raise HTTPException(
status_code=400, detail="Connector-Credential Pair specified does not exist"
)
# Need to index for both the primary and secondary index if possible
@@ -182,7 +179,7 @@ def upsert_ingestion_doc(
@router.delete("/ingestion/{document_id}", dependencies=[Depends(require_vector_db)])
def delete_ingestion_doc(
document_id: str,
_: User = Depends(require_permission(Permission.MANAGE_CONNECTORS)),
_: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> None:
tenant_id = get_current_tenant_id()
@@ -190,12 +187,12 @@ def delete_ingestion_doc(
# Verify the document exists and was created via the ingestion API
document = get_document(document_id=document_id, db_session=db_session)
if document is None:
raise OnyxError(OnyxErrorCode.DOCUMENT_NOT_FOUND, "Document not found")
raise HTTPException(status_code=404, detail="Document not found")
if not document.from_ingestion_api:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Document was not created via the ingestion API",
raise HTTPException(
status_code=400,
detail="Document was not created via the ingestion API",
)
active_search_settings = get_active_search_settings(db_session)

View File

@@ -2,6 +2,7 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
@@ -11,8 +12,6 @@ from onyx.db.models import User
from onyx.db.pat import create_pat
from onyx.db.pat import list_user_pats
from onyx.db.pat import revoke_pat
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.pat.models import CreatedTokenResponse
from onyx.server.pat.models import CreateTokenRequest
from onyx.server.pat.models import TokenResponse
@@ -47,7 +46,7 @@ def list_tokens(
@router.post("")
def create_token(
request: CreateTokenRequest,
user: User = Depends(require_permission(Permission.CREATE_USER_API_KEYS)),
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
db_session: Session = Depends(get_session),
) -> CreatedTokenResponse:
"""Create new personal access token for current user."""
@@ -59,7 +58,7 @@ def create_token(
expiration_days=request.expiration_days,
)
except ValueError as e:
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
raise HTTPException(status_code=400, detail=str(e))
logger.info(f"User {user.email} created PAT '{request.name}'")
@@ -83,7 +82,9 @@ def delete_token(
"""Delete (revoke) personal access token. Only owner can revoke their own tokens."""
success = revoke_pat(db_session, token_id, user.id)
if not success:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Token not found or not owned by user")
raise HTTPException(
status_code=404, detail="Token not found or not owned by user"
)
logger.info(f"User {user.email} revoked token {token_id}")
return {"message": "Token deleted successfully"}

View File

@@ -3,6 +3,7 @@ from fastapi import Depends
from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
from onyx.auth.users import current_curator_or_admin_user
from onyx.configs.constants import DocumentSource
from onyx.context.search.models import IndexFilters
from onyx.context.search.models import SearchDoc
@@ -33,7 +34,7 @@ basic_router = APIRouter(prefix="/query")
@admin_router.post("/search", dependencies=[Depends(require_vector_db)])
def admin_search(
question: AdminSearchRequest,
user: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
user: User = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> AdminSearchResponse:
tenant_id = get_current_tenant_id()

View File

@@ -17,6 +17,7 @@ from onelogin.saml2.auth import OneLogin_Saml2_Auth # type: ignore
from pydantic import BaseModel
from onyx.auth.schemas import UserCreate
from onyx.auth.schemas import UserRole
from onyx.auth.users import auth_backend
from onyx.auth.users import fastapi_users
from onyx.auth.users import get_user_manager
@@ -24,6 +25,7 @@ from onyx.auth.users import UserManager
from onyx.configs.app_configs import REQUIRE_EMAIL_VERIFICATION
from onyx.configs.app_configs import SAML_CONF_DIR
from onyx.configs.app_configs import WEB_DOMAIN
from onyx.db.auth import get_user_count
from onyx.db.auth import get_user_db
from onyx.db.engine.async_sql_engine import get_async_session_context_manager
from onyx.db.models import User
@@ -51,9 +53,9 @@ async def upsert_saml_user(email: str) -> User:
"""
Creates or updates a user account for SAML authentication.
For new users or users with non-web-login account types:
For new users or users with non-web-login roles:
1. Generates a secure random password that meets validation criteria
2. Creates the user with verified status
2. Creates the user with appropriate role and verified status
SAML users never use this password directly as they authenticate via their
Identity Provider, but we need a valid password to satisfy system requirements.
@@ -67,13 +69,16 @@ async def upsert_saml_user(email: str) -> User:
async with get_user_manager_context(user_db) as user_manager:
try:
user = await user_manager.get_by_email(email)
# If user has a non-authenticated account type, treat as non-existent
# If user has a non-authenticated role, treat as non-existent
if not user.account_type.is_web_login():
raise exceptions.UserNotExists()
return user
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
# Generate a secure random password meeting validation requirements
# We use a secure random password since we never need to know what it is
# (SAML users authenticate via their IdP)
@@ -102,16 +107,12 @@ async def upsert_saml_user(email: str) -> User:
]
)
# Create the user with SAML-appropriate settings.
# UserManager.create triggers fastapi-users' on_after_register,
# which places the user in the Admin default group if they are
# the first user in the tenant (or their email is in
# get_default_admin_user_emails()) and the Basic default group
# otherwise — this replaces the old explicit role assignment.
# Create the user with SAML-appropriate settings
user = await user_manager.create(
UserCreate(
email=email,
password=secure_random_password, # Pass raw password, not hash
role=role,
is_verified=True, # SAML users are pre-verified by their IdP
)
)

View File

@@ -1,10 +0,0 @@
[project]
name = "onyx-backend"
version = "0.0.0"
requires-python = ">=3.11"
dependencies = [
"onyx[backend,dev,ee]",
]
[tool.uv.sources]
onyx = { workspace = true }

View File

@@ -46,11 +46,11 @@ curl -LsSf https://astral.py/uv/install.sh | sh
1. Edit `pyproject.toml`
2. Add/update/remove dependencies in the appropriate section:
- `[dependency-groups]` for dev tools
- `[project.dependencies]` for **shared** dependencies (used by both backend and model_server)
- `[project.optional-dependencies.backend]` for backend-only dependencies
- `[project.optional-dependencies.model_server]` for model_server-only dependencies (ML packages)
- `[project.optional-dependencies.ee]` for EE features
- `[dependency-groups.backend]` for backend-only dependencies
- `[dependency-groups.dev]` for dev tools
- `[dependency-groups.ee]` for EE features
- `[dependency-groups.model_server]` for model_server-only dependencies (ML packages)
3. Commit your changes - pre-commit hooks will automatically regenerate the lock file and requirements
### 3. Generating Lock File and Requirements
@@ -64,10 +64,10 @@ To manually regenerate:
```bash
uv lock
uv export --no-emit-project --no-default-groups --no-hashes --extra backend -o backend/requirements/default.txt
uv export --no-emit-project --no-default-groups --no-hashes --group backend -o backend/requirements/default.txt
uv export --no-emit-project --no-default-groups --no-hashes --group dev -o backend/requirements/dev.txt
uv export --no-emit-project --no-default-groups --no-hashes --extra ee -o backend/requirements/ee.txt
uv export --no-emit-project --no-default-groups --no-hashes --extra model_server -o backend/requirements/model_server.txt
uv export --no-emit-project --no-default-groups --no-hashes --group ee -o backend/requirements/ee.txt
uv export --no-emit-project --no-default-groups --no-hashes --group model_server -o backend/requirements/model_server.txt
```
### 4. Installing Dependencies
@@ -76,30 +76,14 @@ If enabled, all packages are installed automatically by the `uv-sync` pre-commit
branches or pulling new changes.
```bash
# For everything (most common)
uv sync --all-extras
# For development (most common) — installs shared + backend + dev + ee
uv sync
# For backend production (shared + backend dependencies)
uv sync --extra backend
# For backend development (shared + backend + dev tools)
uv sync --extra backend --extra dev
# For backend with EE (shared + backend + ee)
uv sync --extra backend --extra ee
# For backend production only (shared + backend dependencies)
uv sync --no-default-groups --group backend
# For model server (shared + model_server, NO backend deps!)
uv sync --extra model_server
```
`uv` aggressively [ignores active virtual environments](https://docs.astral.sh/uv/concepts/projects/config/#project-environment-path) and prefers the root virtual environment.
When working in workspace packages, be sure to pass `--active` when syncing the virtual environment:
```bash
cd backend/
source .venv/bin/activate
uv sync --active
uv run --active ...
uv sync --no-default-groups --group model_server
```
### 5. Upgrading Dependencies

View File

@@ -1,5 +1,5 @@
# This file was autogenerated by uv via the following command:
# uv export --no-emit-project --no-default-groups --no-hashes --extra backend -o backend/requirements/default.txt
# uv export --no-emit-project --no-default-groups --no-hashes --group backend -o backend/requirements/default.txt
agent-client-protocol==0.7.1
# via onyx
aioboto3==15.1.0
@@ -19,7 +19,6 @@ aiohttp==3.13.4
# aiobotocore
# discord-py
# litellm
# onyx
# voyageai
aioitertools==0.13.0
# via aiobotocore
@@ -28,7 +27,6 @@ aiolimiter==1.2.1
aiosignal==1.4.0
# via aiohttp
alembic==1.10.4
# via onyx
amqp==5.3.1
# via kombu
annotated-doc==0.0.4
@@ -51,13 +49,10 @@ argon2-cffi==23.1.0
argon2-cffi-bindings==25.1.0
# via argon2-cffi
asana==5.0.8
# via onyx
async-timeout==5.0.1 ; python_full_version < '3.11.3'
# via redis
asyncpg==0.30.0
# via onyx
atlassian-python-api==3.41.16
# via onyx
attrs==25.4.0
# via
# aiohttp
@@ -68,7 +63,6 @@ attrs==25.4.0
authlib==1.6.9
# via fastmcp
azure-cognitiveservices-speech==1.38.0
# via onyx
babel==2.17.0
# via courlan
backoff==2.2.1
@@ -86,7 +80,6 @@ beautifulsoup4==4.12.3
# atlassian-python-api
# markdownify
# markitdown
# onyx
# unstructured
billiard==4.2.3
# via celery
@@ -94,9 +87,7 @@ boto3==1.39.11
# via
# aiobotocore
# cohere
# onyx
boto3-stubs==1.39.11
# via onyx
botocore==1.39.11
# via
# aiobotocore
@@ -105,7 +96,6 @@ botocore==1.39.11
botocore-stubs==1.40.74
# via boto3-stubs
braintrust==0.3.9
# via onyx
brotli==1.2.0
# via onyx
bytecode==0.17.0
@@ -115,7 +105,6 @@ cachetools==6.2.2
caio==0.9.25
# via aiofile
celery==5.5.1
# via onyx
certifi==2025.11.12
# via
# asana
@@ -134,7 +123,6 @@ cffi==2.0.0
# pynacl
# zstandard
chardet==5.2.0
# via onyx
charset-normalizer==3.4.4
# via
# htmldate
@@ -146,7 +134,6 @@ charset-normalizer==3.4.4
chevron==0.14.0
# via braintrust
chonkie==1.0.10
# via onyx
claude-agent-sdk==0.1.19
# via onyx
click==8.3.1
@@ -201,15 +188,12 @@ cryptography==46.0.6
cyclopts==4.2.4
# via fastmcp
dask==2026.1.1
# via
# distributed
# onyx
# via distributed
dataclasses-json==0.6.7
# via unstructured
dateparser==1.2.2
# via htmldate
ddtrace==3.10.0
# via onyx
decorator==5.2.1
# via retry
defusedxml==0.7.1
@@ -223,7 +207,6 @@ deprecated==1.3.1
discord-py==2.4.0
# via onyx
distributed==2026.1.1
# via onyx
distro==1.9.0
# via
# openai
@@ -235,7 +218,6 @@ docstring-parser==0.17.0
docutils==0.22.3
# via rich-rst
dropbox==12.0.2
# via onyx
durationpy==0.10
# via kubernetes
email-validator==2.2.0
@@ -251,7 +233,6 @@ et-xmlfile==2.0.0
events==0.5
# via opensearch-py
exa-py==1.15.4
# via onyx
exceptiongroup==1.3.0
# via
# braintrust
@@ -262,23 +243,16 @@ fastapi==0.133.1
# fastapi-users
# onyx
fastapi-limiter==0.1.6
# via onyx
fastapi-users==15.0.4
# via
# fastapi-users-db-sqlalchemy
# onyx
# via fastapi-users-db-sqlalchemy
fastapi-users-db-sqlalchemy==7.0.0
# via onyx
fastavro==1.12.1
# via cohere
fastmcp==3.2.0
# via onyx
fastuuid==0.14.0
# via litellm
filelock==3.20.3
# via
# huggingface-hub
# onyx
# via huggingface-hub
filetype==1.2.0
# via unstructured
flatbuffers==25.9.23
@@ -298,7 +272,6 @@ gitpython==3.1.45
google-api-core==2.28.1
# via google-api-python-client
google-api-python-client==2.86.0
# via onyx
google-auth==2.48.0
# via
# google-api-core
@@ -308,11 +281,8 @@ google-auth==2.48.0
# google-genai
# kubernetes
google-auth-httplib2==0.1.0
# via
# google-api-python-client
# onyx
# via google-api-python-client
google-auth-oauthlib==1.0.0
# via onyx
google-genai==1.52.0
# via onyx
googleapis-common-protos==1.72.0
@@ -340,7 +310,6 @@ htmldate==1.9.1
httpcore==1.0.9
# via
# httpx
# onyx
# unstructured-client
httplib2==0.31.0
# via
@@ -357,21 +326,16 @@ httpx==0.28.1
# langsmith
# litellm
# mcp
# onyx
# openai
# unstructured-client
httpx-oauth==0.15.1
# via onyx
httpx-sse==0.4.3
# via
# cohere
# mcp
hubspot-api-client==11.1.0
# via onyx
huggingface-hub==0.35.3
# via
# onyx
# tokenizers
# via tokenizers
humanfriendly==10.0
# via coloredlogs
hyperframe==6.1.0
@@ -390,9 +354,7 @@ importlib-metadata==8.7.0
# litellm
# opentelemetry-api
inflection==0.5.1
# via
# onyx
# pyairtable
# via pyairtable
iniconfig==2.3.0
# via pytest
isodate==0.7.2
@@ -414,7 +376,6 @@ jinja2==3.1.6
# distributed
# litellm
jira==3.10.5
# via onyx
jiter==0.12.0
# via openai
jmespath==1.0.1
@@ -430,9 +391,7 @@ jsonpatch==1.33
jsonpointer==3.0.0
# via jsonpatch
jsonref==1.1.0
# via
# fastmcp
# onyx
# via fastmcp
jsonschema==4.25.1
# via
# litellm
@@ -450,15 +409,12 @@ kombu==5.5.4
kubernetes==31.0.0
# via onyx
langchain-core==1.2.22
# via onyx
langdetect==1.0.9
# via unstructured
langfuse==3.10.0
# via onyx
langsmith==0.3.45
# via langchain-core
lazy-imports==1.0.1
# via onyx
legacy-cgi==2.6.4 ; python_full_version >= '3.13'
# via ddtrace
litellm==1.81.6
@@ -473,7 +429,6 @@ lxml==5.3.0
# justext
# lxml-html-clean
# markitdown
# onyx
# python-docx
# python-pptx
# python3-saml
@@ -488,9 +443,7 @@ magika==0.6.3
makefun==1.16.0
# via fastapi-users
mako==1.2.4
# via
# alembic
# onyx
# via alembic
mammoth==1.11.0
# via markitdown
markdown-it-py==4.0.0
@@ -498,7 +451,6 @@ markdown-it-py==4.0.0
markdownify==1.2.2
# via markitdown
markitdown==0.1.2
# via onyx
markupsafe==3.0.3
# via
# jinja2
@@ -512,11 +464,9 @@ mcp==1.26.0
# via
# claude-agent-sdk
# fastmcp
# onyx
mdurl==0.1.2
# via markdown-it-py
mistune==3.2.0
# via onyx
more-itertools==10.8.0
# via
# jaraco-classes
@@ -525,13 +475,10 @@ more-itertools==10.8.0
mpmath==1.3.0
# via sympy
msal==1.34.0
# via
# office365-rest-python-client
# onyx
# via office365-rest-python-client
msgpack==1.1.2
# via distributed
msoffcrypto-tool==5.4.2
# via onyx
multidict==6.7.0
# via
# aiobotocore
@@ -548,7 +495,6 @@ mypy-extensions==1.0.0
# mypy
# typing-inspect
nest-asyncio==1.6.0
# via onyx
nltk==3.9.4
# via unstructured
numpy==2.4.1
@@ -563,10 +509,8 @@ oauthlib==3.2.2
# via
# atlassian-python-api
# kubernetes
# onyx
# requests-oauthlib
office365-rest-python-client==2.6.2
# via onyx
olefile==0.47
# via
# msoffcrypto-tool
@@ -582,15 +526,11 @@ openai==2.14.0
openapi-pydantic==0.5.1
# via fastmcp
openinference-instrumentation==0.1.42
# via onyx
openinference-semantic-conventions==0.1.25
# via openinference-instrumentation
openpyxl==3.0.10
# via
# markitdown
# onyx
# via markitdown
opensearch-py==3.0.0
# via onyx
opentelemetry-api==1.39.1
# via
# ddtrace
@@ -606,7 +546,6 @@ opentelemetry-exporter-otlp-proto-http==1.39.1
# via langfuse
opentelemetry-proto==1.39.1
# via
# onyx
# opentelemetry-exporter-otlp-proto-common
# opentelemetry-exporter-otlp-proto-http
opentelemetry-sdk==1.39.1
@@ -640,7 +579,6 @@ parameterized==0.9.0
partd==1.4.2
# via dask
passlib==1.7.4
# via onyx
pathable==0.4.4
# via jsonschema-path
pdfminer-six==20251107
@@ -652,9 +590,7 @@ platformdirs==4.5.0
# fastmcp
# zeep
playwright==1.55.0
# via
# onyx
# pytest-playwright
# via pytest-playwright
pluggy==1.6.0
# via pytest
ply==3.11
@@ -684,12 +620,9 @@ protobuf==6.33.5
psutil==7.1.3
# via
# distributed
# onyx
# unstructured
psycopg2-binary==2.9.9
# via onyx
puremagic==1.28
# via onyx
pwdlib==0.3.0
# via fastapi-users
py==1.11.0
@@ -697,7 +630,6 @@ py==1.11.0
py-key-value-aio==0.4.4
# via fastmcp
pyairtable==3.0.1
# via onyx
pyasn1==0.6.3
# via
# pyasn1-modules
@@ -707,7 +639,6 @@ pyasn1-modules==0.4.2
pycparser==2.23 ; implementation_name != 'PyPy'
# via cffi
pycryptodome==3.19.1
# via onyx
pydantic==2.11.7
# via
# agent-client-protocol
@@ -734,7 +665,6 @@ pydantic-settings==2.12.0
pyee==13.0.0
# via playwright
pygithub==2.5.0
# via onyx
pygments==2.20.0
# via rich
pyjwt==2.12.0
@@ -745,17 +675,13 @@ pyjwt==2.12.0
# pygithub
# simple-salesforce
pympler==1.1
# via onyx
pynacl==1.6.2
# via pygithub
pypandoc-binary==1.16.2
# via onyx
pyparsing==3.2.5
# via httplib2
pypdf==6.9.2
# via
# onyx
# unstructured-client
# via unstructured-client
pyperclip==1.11.0
# via fastmcp
pyreadline3==3.5.4 ; sys_platform == 'win32'
@@ -768,9 +694,7 @@ pytest==8.3.5
pytest-base-url==2.1.0
# via pytest-playwright
pytest-mock==3.12.0
# via onyx
pytest-playwright==0.7.0
# via onyx
python-dateutil==2.8.2
# via
# aiobotocore
@@ -781,11 +705,9 @@ python-dateutil==2.8.2
# htmldate
# hubspot-api-client
# kubernetes
# onyx
# opensearch-py
# pandas
python-docx==1.1.2
# via onyx
python-dotenv==1.1.1
# via
# braintrust
@@ -793,10 +715,8 @@ python-dotenv==1.1.1
# litellm
# magika
# mcp
# onyx
# pydantic-settings
python-gitlab==5.6.0
# via onyx
python-http-client==3.3.7
# via sendgrid
python-iso639==2025.11.16
@@ -807,19 +727,15 @@ python-multipart==0.0.22
# via
# fastapi-users
# mcp
# onyx
python-oxmsg==0.0.2
# via unstructured
python-pptx==0.6.23
# via
# markitdown
# onyx
# via markitdown
python-slugify==8.0.4
# via
# braintrust
# pytest-playwright
python3-saml==1.15.0
# via onyx
pytz==2025.2
# via
# dateparser
@@ -827,7 +743,6 @@ pytz==2025.2
# pandas
# zeep
pywikibot==9.0.0
# via onyx
pywin32==311 ; sys_platform == 'win32'
# via
# mcp
@@ -844,13 +759,9 @@ pyyaml==6.0.3
# kubernetes
# langchain-core
rapidfuzz==3.13.0
# via
# onyx
# unstructured
# via unstructured
redis==5.0.8
# via
# fastapi-limiter
# onyx
# via fastapi-limiter
referencing==0.36.2
# via
# jsonschema
@@ -881,7 +792,6 @@ requests==2.33.0
# matrix-client
# msal
# office365-rest-python-client
# onyx
# opensearch-py
# opentelemetry-exporter-otlp-proto-http
# pyairtable
@@ -907,7 +817,6 @@ requests-oauthlib==1.3.1
# google-auth-oauthlib
# jira
# kubernetes
# onyx
requests-toolbelt==1.0.0
# via
# jira
@@ -918,7 +827,6 @@ requests-toolbelt==1.0.0
retry==0.9.2
# via onyx
rfc3986==1.5.0
# via onyx
rich==14.2.0
# via
# cyclopts
@@ -938,15 +846,12 @@ s3transfer==0.13.1
secretstorage==3.5.0 ; sys_platform == 'linux'
# via keyring
sendgrid==6.12.5
# via onyx
sentry-sdk==2.14.0
# via onyx
shapely==2.0.6
# via onyx
shellingham==1.5.4
# via typer
simple-salesforce==1.12.6
# via onyx
six==1.17.0
# via
# asana
@@ -961,7 +866,6 @@ six==1.17.0
# python-dateutil
# stone
slack-sdk==3.20.2
# via onyx
smmap==5.0.2
# via gitdb
sniffio==1.3.1
@@ -976,7 +880,6 @@ sqlalchemy==2.0.15
# via
# alembic
# fastapi-users-db-sqlalchemy
# onyx
sse-starlette==3.0.3
# via mcp
sseclient-py==1.8.0
@@ -985,14 +888,11 @@ starlette==0.49.3
# via
# fastapi
# mcp
# onyx
# prometheus-fastapi-instrumentator
stone==3.3.1
# via dropbox
stripe==10.12.0
# via onyx
supervisor==4.3.0
# via onyx
sympy==1.14.0
# via onnxruntime
tblib==3.2.2
@@ -1005,11 +905,8 @@ tenacity==9.1.2
text-unidecode==1.3
# via python-slugify
tiktoken==0.7.0
# via
# litellm
# onyx
# via litellm
timeago==1.0.16
# via onyx
tld==0.13.1
# via courlan
tokenizers==0.21.4
@@ -1033,13 +930,11 @@ tqdm==4.67.1
# openai
# unstructured
trafilatura==1.12.2
# via onyx
typer==0.20.0
# via mcp
types-awscrt==0.28.4
# via botocore-stubs
types-openpyxl==3.0.4.7
# via onyx
types-requests==2.32.0.20250328
# via cohere
types-s3transfer==0.14.0
@@ -1105,11 +1000,8 @@ tzlocal==5.3.1
uncalled-for==0.2.0
# via fastmcp
unstructured==0.18.27
# via onyx
unstructured-client==0.42.6
# via
# onyx
# unstructured
# via unstructured
uritemplate==4.2.0
# via google-api-python-client
urllib3==2.6.3
@@ -1121,7 +1013,6 @@ urllib3==2.6.3
# htmldate
# hubspot-api-client
# kubernetes
# onyx
# opensearch-py
# pyairtable
# pygithub
@@ -1171,9 +1062,7 @@ xlrd==2.0.2
xlsxwriter==3.2.9
# via python-pptx
xmlsec==1.3.14
# via
# onyx
# python3-saml
# via python3-saml
xmltodict==1.0.2
# via ddtrace
yarl==1.22.0
@@ -1187,4 +1076,3 @@ zipp==3.23.0
zstandard==0.23.0
# via langsmith
zulip==0.8.2
# via onyx

View File

@@ -1,5 +1,5 @@
# This file was autogenerated by uv via the following command:
# uv export --no-emit-project --no-default-groups --no-hashes --extra dev -o backend/requirements/dev.txt
# uv export --no-emit-project --no-default-groups --no-hashes --group dev -o backend/requirements/dev.txt
agent-client-protocol==0.7.1
# via onyx
aioboto3==15.1.0
@@ -47,7 +47,6 @@ attrs==25.4.0
# jsonschema
# referencing
black==25.1.0
# via onyx
boto3==1.39.11
# via
# aiobotocore
@@ -60,7 +59,6 @@ botocore==1.39.11
brotli==1.2.0
# via onyx
celery-types==0.19.0
# via onyx
certifi==2025.11.12
# via
# httpcore
@@ -122,7 +120,6 @@ execnet==2.1.2
executing==2.2.1
# via stack-data
faker==40.1.2
# via onyx
fastapi==0.133.1
# via
# onyx
@@ -156,7 +153,6 @@ h11==0.16.0
# httpcore
# uvicorn
hatchling==1.28.0
# via onyx
hf-xet==1.2.0 ; platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'
# via huggingface-hub
httpcore==1.0.9
@@ -187,7 +183,6 @@ importlib-metadata==8.7.0
iniconfig==2.3.0
# via pytest
ipykernel==6.29.5
# via onyx
ipython==9.7.0
# via ipykernel
ipython-pygments-lexers==1.1.1
@@ -224,13 +219,11 @@ litellm==1.81.6
mako==1.2.4
# via alembic
manygo==0.2.0
# via onyx
markupsafe==3.0.3
# via
# jinja2
# mako
matplotlib==3.10.8
# via onyx
matplotlib-inline==0.2.1
# via
# ipykernel
@@ -243,12 +236,10 @@ multidict==6.7.0
# aiohttp
# yarl
mypy==1.13.0
# via onyx
mypy-extensions==1.0.0
# via
# black
# mypy
# onyx
nest-asyncio==1.6.0
# via ipykernel
nodeenv==1.9.1
@@ -263,16 +254,13 @@ oauthlib==3.2.2
# via
# kubernetes
# requests-oauthlib
onyx-devtools==0.7.2
# via onyx
onyx-devtools==0.7.4
openai==2.14.0
# via
# litellm
# onyx
openapi-generator-cli==7.17.0
# via
# onyx
# onyx-devtools
# via onyx-devtools
packaging==24.2
# via
# black
@@ -282,7 +270,6 @@ packaging==24.2
# matplotlib
# pytest
pandas-stubs==2.3.3.251201
# via onyx
parameterized==0.9.0
# via cohere
parso==0.8.5
@@ -305,7 +292,6 @@ pluggy==1.6.0
# hatchling
# pytest
pre-commit==3.2.2
# via onyx
prometheus-client==0.23.1
# via
# onyx
@@ -359,22 +345,16 @@ pyparsing==3.2.5
# via matplotlib
pytest==8.3.5
# via
# onyx
# pytest-alembic
# pytest-asyncio
# pytest-dotenv
# pytest-repeat
# pytest-xdist
pytest-alembic==0.12.1
# via onyx
pytest-asyncio==1.3.0
# via onyx
pytest-dotenv==0.5.2
# via onyx
pytest-repeat==0.9.4
# via onyx
pytest-xdist==3.8.0
# via onyx
python-dateutil==2.8.2
# via
# aiobotocore
@@ -407,9 +387,7 @@ referencing==0.36.2
regex==2025.11.3
# via tiktoken
release-tag==0.5.2
# via onyx
reorder-python-imports-black==3.14.0
# via onyx
requests==2.33.0
# via
# cohere
@@ -430,7 +408,6 @@ rpds-py==0.29.0
rsa==4.9.1
# via google-auth
ruff==0.12.0
# via onyx
s3transfer==0.13.1
# via boto3
sentry-sdk==2.14.0
@@ -484,39 +461,22 @@ traitlets==5.14.3
trove-classifiers==2025.12.1.14
# via hatchling
types-beautifulsoup4==4.12.0.3
# via onyx
types-html5lib==1.1.11.13
# via
# onyx
# types-beautifulsoup4
# via types-beautifulsoup4
types-oauthlib==3.2.0.9
# via onyx
types-passlib==1.7.7.20240106
# via onyx
types-pillow==10.2.0.20240822
# via onyx
types-psutil==7.1.3.20251125
# via onyx
types-psycopg2==2.9.21.10
# via onyx
types-python-dateutil==2.8.19.13
# via onyx
types-pytz==2023.3.1.1
# via
# onyx
# pandas-stubs
# via pandas-stubs
types-pyyaml==6.0.12.11
# via onyx
types-regex==2023.3.23.1
# via onyx
types-requests==2.32.0.20250328
# via
# cohere
# onyx
# via cohere
types-retry==0.9.9.3
# via onyx
types-setuptools==68.0.0.3
# via onyx
typing-extensions==4.15.0
# via
# aiosignal
@@ -574,4 +534,3 @@ yarl==1.22.0
zipp==3.23.0
# via importlib-metadata
zizmor==1.18.0
# via onyx

View File

@@ -1,5 +1,5 @@
# This file was autogenerated by uv via the following command:
# uv export --no-emit-project --no-default-groups --no-hashes --extra ee -o backend/requirements/ee.txt
# uv export --no-emit-project --no-default-groups --no-hashes --group ee -o backend/requirements/ee.txt
agent-client-protocol==0.7.1
# via onyx
aioboto3==15.1.0
@@ -182,7 +182,6 @@ packaging==24.2
parameterized==0.9.0
# via cohere
posthog==3.7.4
# via onyx
prometheus-client==0.23.1
# via
# onyx

View File

@@ -1,7 +1,6 @@
# This file was autogenerated by uv via the following command:
# uv export --no-emit-project --no-default-groups --no-hashes --extra model_server -o backend/requirements/model_server.txt
# uv export --no-emit-project --no-default-groups --no-hashes --group model_server -o backend/requirements/model_server.txt
accelerate==1.6.0
# via onyx
agent-client-protocol==0.7.1
# via onyx
aioboto3==15.1.0
@@ -105,7 +104,6 @@ distro==1.9.0
durationpy==0.10
# via kubernetes
einops==0.8.1
# via onyx
fastapi==0.133.1
# via
# onyx
@@ -207,7 +205,6 @@ networkx==3.5
numpy==2.4.1
# via
# accelerate
# onyx
# scikit-learn
# scipy
# transformers
@@ -363,7 +360,6 @@ s3transfer==0.13.1
safetensors==0.5.3
# via
# accelerate
# onyx
# transformers
scikit-learn==1.7.2
# via sentence-transformers
@@ -372,7 +368,6 @@ scipy==1.16.3
# scikit-learn
# sentence-transformers
sentence-transformers==4.0.2
# via onyx
sentry-sdk==2.14.0
# via onyx
setuptools==80.9.0 ; python_full_version >= '3.12'
@@ -411,7 +406,6 @@ tokenizers==0.21.4
torch==2.9.1
# via
# accelerate
# onyx
# sentence-transformers
tqdm==4.67.1
# via
@@ -420,9 +414,7 @@ tqdm==4.67.1
# sentence-transformers
# transformers
transformers==4.53.0
# via
# onyx
# sentence-transformers
# via sentence-transformers
triton==3.5.1 ; platform_machine == 'x86_64' and sys_platform == 'linux'
# via torch
types-requests==2.32.0.20250328

View File

@@ -45,6 +45,15 @@ npx playwright test <TEST_NAME>
Shared fixtures live in `backend/tests/conftest.py`. Test subdirectories can define
their own `conftest.py` for directory-scoped fixtures.
## Additional Onyx-Specific Guidance
- Activate the root venv first with `source .venv/bin/activate`.
- For many product changes in this repo, prefer integration tests or external dependency unit tests
over isolated unit tests.
- When writing integration tests, check `backend/tests/integration/common_utils/` and the root
`conftest.py` for fixtures and managers before inventing new helpers.
- Prefer existing fixtures over constructing users or entities manually inside tests.
## Running Tests Repeatedly (`pytest-repeat`)
Use `pytest-repeat` to catch flaky tests by running them multiple times:

View File

@@ -19,6 +19,7 @@ from fastapi.testclient import TestClient
from onyx.auth.users import current_user
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import Permission
from onyx.db.models import UserRole
from onyx.main import get_application
from onyx.utils.logger import setup_logger
@@ -43,6 +44,7 @@ def mock_get_session() -> Generator[MagicMock, None, None]:
def mock_current_user() -> MagicMock:
"""Mock admin user for endpoints protected by require_permission."""
mock_admin = MagicMock()
mock_admin.role = UserRole.ADMIN
mock_admin.effective_permissions = [Permission.FULL_ADMIN_PANEL_ACCESS.value]
return mock_admin

View File

@@ -9,7 +9,7 @@ from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.engine.sql_engine import SqlEngine
from onyx.db.enums import AccountType
from onyx.db.models import User
from onyx.db.users import assign_user_to_default_groups__no_commit
from onyx.db.models import UserRole
from onyx.file_store.file_store import get_default_file_store
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
from tests.external_dependency_unit.constants import TEST_TENANT_ID
@@ -56,12 +56,11 @@ def tenant_context() -> Generator[None, None, None]:
def create_test_user(
db_session: Session,
email_prefix: str,
role: UserRole = UserRole.BASIC,
account_type: AccountType = AccountType.STANDARD,
is_admin: bool = False,
) -> User:
"""Create a test user. Assigns the seeded Basic
(or Admin if is_admin=True) default group and populates
effective_permissions; skipped for BOT/EXT_PERM_USER/ANONYMOUS."""
"""Helper to create a test user with a unique email"""
# Use UUID to ensure unique email addresses
unique_email = f"{email_prefix}_{uuid4().hex[:8]}@example.com"
password_helper = PasswordHelper()
@@ -75,13 +74,10 @@ def create_test_user(
is_active=True,
is_superuser=False,
is_verified=True,
role=role,
account_type=account_type,
)
db_session.add(user)
db_session.flush()
assign_user_to_default_groups__no_commit(db_session, user, is_admin=is_admin)
db_session.commit()
db_session.refresh(user)
return user

View File

@@ -21,6 +21,7 @@ from onyx.db.models import Credential
from onyx.db.models import PublicExternalUserGroup
from onyx.db.models import User
from onyx.db.models import User__ExternalUserGroupId
from onyx.db.models import UserRole
from tests.external_dependency_unit.conftest import create_test_user
from tests.external_dependency_unit.constants import TEST_TENANT_ID
@@ -30,6 +31,7 @@ def _create_ext_perm_user(db_session: Session, name: str) -> User:
return create_test_user(
db_session,
name,
role=UserRole.EXT_PERM_USER,
account_type=AccountType.EXT_PERM_USER,
)

View File

@@ -13,6 +13,7 @@ from onyx.db.enums import AccountType
from onyx.db.enums import BuildSessionStatus
from onyx.db.models import BuildSession
from onyx.db.models import User
from onyx.db.models import UserRole
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
from tests.external_dependency_unit.constants import TEST_TENANT_ID
@@ -51,6 +52,7 @@ def test_user(db_session: Session, tenant_context: None) -> User: # noqa: ARG00
is_active=True,
is_superuser=False,
is_verified=True,
role=UserRole.EXT_PERM_USER,
account_type=AccountType.EXT_PERM_USER,
)
db_session.add(user)

View File

@@ -10,14 +10,16 @@ and are not exposed via API endpoints, so they must be tested directly.
from sqlalchemy.orm import Session
from onyx.db.enums import AccountType
from onyx.db.models import UserRole
from onyx.db.users import add_slack_user_if_not_exists
from onyx.db.users import batch_add_ext_perm_user_if_not_exists
def test_slack_user_creation_sets_account_type_bot(db_session: Session) -> None:
"""add_slack_user_if_not_exists sets account_type=BOT."""
"""add_slack_user_if_not_exists sets account_type=BOT and role=SLACK_USER."""
user = add_slack_user_if_not_exists(db_session, "slack_acct_type@test.com")
assert user.role == UserRole.SLACK_USER
assert user.account_type == AccountType.BOT
@@ -29,13 +31,14 @@ def test_ext_perm_user_creation_sets_account_type(db_session: Session) -> None:
assert len(users) == 1
user = users[0]
assert user.role == UserRole.EXT_PERM_USER
assert user.account_type == AccountType.EXT_PERM_USER
def test_ext_perm_to_slack_upgrade_updates_account_type(
def test_ext_perm_to_slack_upgrade_updates_role_and_account_type(
db_session: Session,
) -> None:
"""When an EXT_PERM_USER is upgraded via the slack path, account_type flips to BOT."""
"""When an EXT_PERM_USER is upgraded to slack, both role and account_type update."""
email = "ext_to_slack_acct_type@test.com"
# Create as ext_perm user first
@@ -44,4 +47,5 @@ def test_ext_perm_to_slack_upgrade_updates_account_type(
# Now "upgrade" via slack path
user = add_slack_user_if_not_exists(db_session, email)
assert user.role == UserRole.SLACK_USER
assert user.account_type == AccountType.BOT

View File

@@ -18,6 +18,7 @@ from onyx.db.llm import fetch_existing_llm_provider
from onyx.db.llm import remove_llm_provider
from onyx.db.llm import update_default_provider
from onyx.db.llm import upsert_llm_provider
from onyx.db.models import UserRole
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.llm.constants import LlmProviderNames
@@ -37,6 +38,7 @@ from onyx.server.manage.llm.models import TestLLMRequest as LLMTestRequest
def _create_mock_admin() -> MagicMock:
"""Create a mock admin user for testing."""
mock_admin = MagicMock()
mock_admin.role = UserRole.ADMIN
return mock_admin

View File

@@ -21,6 +21,7 @@ from sqlalchemy.orm import Session
from onyx.db.llm import fetch_existing_llm_provider
from onyx.db.llm import remove_llm_provider
from onyx.db.llm import upsert_llm_provider
from onyx.db.models import UserRole
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.llm.constants import LlmProviderNames
@@ -67,6 +68,7 @@ def _cleanup_provider(db_session: Session, name: str) -> None:
def _create_mock_admin() -> MagicMock:
"""Create a mock admin user for testing."""
mock_admin = MagicMock()
mock_admin.role = UserRole.ADMIN
return mock_admin

View File

@@ -23,6 +23,7 @@ from onyx.db.llm import fetch_llm_provider_view
from onyx.db.llm import remove_llm_provider
from onyx.db.llm import sync_auto_mode_models
from onyx.db.llm import update_default_provider
from onyx.db.models import UserRole
from onyx.llm.constants import LlmProviderNames
from onyx.llm.interfaces import LLM
from onyx.llm.well_known_providers.auto_update_models import LLMProviderRecommendation
@@ -39,6 +40,7 @@ from onyx.server.manage.llm.models import ModelConfigurationUpsertRequest
def _create_mock_admin() -> MagicMock:
"""Create a mock admin user for testing."""
mock_admin = MagicMock()
mock_admin.role = UserRole.ADMIN
return mock_admin

View File

@@ -14,7 +14,7 @@ from onyx.db.llm import remove_llm_provider
from onyx.db.llm import update_default_provider
from onyx.db.llm import upsert_llm_provider
from onyx.db.models import User
from onyx.db.users import assign_user_to_default_groups__no_commit
from onyx.db.models import UserRole
from onyx.llm.constants import LlmProviderNames
from onyx.llm.override_models import LLMOverride
from onyx.server.manage.llm.models import LLMProviderUpsertRequest
@@ -46,11 +46,10 @@ def _create_admin(db_session: Session) -> User:
is_active=True,
is_superuser=True,
is_verified=True,
role=UserRole.ADMIN,
account_type=AccountType.STANDARD,
)
db_session.add(user)
db_session.flush()
assign_user_to_default_groups__no_commit(db_session, user, is_admin=True)
db_session.commit()
db_session.refresh(user)
return user

View File

@@ -2,6 +2,7 @@ from uuid import uuid4
import requests
from onyx.db.models import UserRole
from onyx.server.api_key.models import APIKeyArgs
from tests.integration.common_utils.constants import API_SERVER_URL
from tests.integration.common_utils.constants import GENERAL_HEADERS
@@ -14,12 +15,12 @@ class APIKeyManager:
def create(
user_performing_action: DATestUser,
name: str | None = None,
group_ids: list[int] | None = None,
api_key_role: UserRole = UserRole.ADMIN,
) -> DATestAPIKey:
name = f"{name}-api-key" if name else f"test-api-key-{uuid4()}"
api_key_request = APIKeyArgs(
name=name,
group_ids=group_ids or [],
role=api_key_role,
)
api_key_response = requests.post(
f"{API_SERVER_URL}/admin/api-key",
@@ -33,7 +34,7 @@ class APIKeyManager:
api_key_display=api_key["api_key_display"],
api_key=api_key["api_key"],
api_key_name=name,
groups=api_key.get("groups", []),
api_key_role=api_key_role,
user_id=api_key["user_id"],
headers=GENERAL_HEADERS,
)
@@ -75,7 +76,10 @@ class APIKeyManager:
if key.api_key_id == api_key.api_key_id:
if verify_deleted:
raise ValueError("API Key found when it should have been deleted")
if key.api_key_name == api_key.api_key_name:
if (
key.api_key_name == api_key.api_key_name
and key.api_key_role == api_key.api_key_role
):
return
if not verify_deleted:

View File

@@ -1,5 +1,4 @@
from copy import deepcopy
from typing import Any
from urllib.parse import urlencode
from uuid import uuid4
@@ -7,10 +6,10 @@ import pytest
import requests
from requests import HTTPError
from onyx.auth.schemas import UserRole
from onyx.configs.constants import ANONYMOUS_USER_EMAIL
from onyx.configs.constants import ANONYMOUS_USER_UUID
from onyx.configs.constants import FASTAPI_USERS_AUTH_COOKIE_NAME
from onyx.db.enums import Permission
from onyx.server.documents.models import PaginatedReturn
from onyx.server.manage.models import UserInfo
from onyx.server.models import FullUserSnapshot
@@ -27,23 +26,13 @@ def build_email(name: str) -> str:
return f"{name}@example.com"
def _is_admin_from_me_response(me_json: dict[str, Any]) -> bool:
"""Determine admin-ness from the /me endpoint response.
Admin is now driven by membership in the Admin default group, which
surfaces as `FULL_ADMIN_PANEL_ACCESS` in `effective_permissions`.
"""
permissions: list[str] = me_json.get("effective_permissions", [])
return Permission.FULL_ADMIN_PANEL_ACCESS.value in permissions
class UserManager:
@staticmethod
def get_anonymous_user() -> DATestUser:
"""Get a DATestUser representing the anonymous user.
Anonymous users are real users in the database with account_type=ANONYMOUS.
They don't have login cookies requests are made with GENERAL_HEADERS.
Anonymous users are real users in the database with LIMITED role.
They don't have login cookies - requests are made with GENERAL_HEADERS.
The anonymous_user_enabled setting must be True for these requests to work.
"""
return DATestUser(
@@ -51,7 +40,7 @@ class UserManager:
email=ANONYMOUS_USER_EMAIL,
password="",
headers=GENERAL_HEADERS,
is_admin=False,
role=UserRole.LIMITED,
is_active=True,
)
@@ -85,8 +74,9 @@ class UserManager:
email=email,
password=password,
headers=deepcopy(GENERAL_HEADERS),
# `login_as_user` will refresh this from /me after login
is_admin=False,
# fill as basic for now, the `login_as_user` call will
# fill it in correctly
role=UserRole.BASIC,
is_active=True,
)
print(f"Created user {test_user.email}")
@@ -122,7 +112,7 @@ class UserManager:
test_user.headers["Cookie"] = f"fastapiusersauth={session_cookie}; "
test_user.cookies = {"fastapiusersauth": session_cookie}
# Get user info from /me endpoint
# Get user role from /me endpoint
me_response = requests.get(
url=f"{API_SERVER_URL}/me",
headers=test_user.headers,
@@ -131,7 +121,8 @@ class UserManager:
me_response.raise_for_status()
me_response_json = me_response.json()
test_user.id = me_response_json["id"]
test_user.is_admin = _is_admin_from_me_response(me_response_json)
role = UserRole(me_response_json["role"])
test_user.role = role
return test_user
@@ -145,8 +136,10 @@ class UserManager:
return response.json()
@staticmethod
def is_admin(user_to_verify: DATestUser) -> bool:
"""Check whether the user currently holds admin privileges."""
def is_role(
user_to_verify: DATestUser,
target_role: UserRole,
) -> bool:
response = requests.get(
url=f"{API_SERVER_URL}/me",
headers=user_to_verify.headers,
@@ -156,50 +149,44 @@ class UserManager:
if user_to_verify.is_active is False:
with pytest.raises(HTTPError):
response.raise_for_status()
return user_to_verify.is_admin
return user_to_verify.role == target_role
else:
response.raise_for_status()
return _is_admin_from_me_response(response.json())
role_from_response = response.json().get("role", None)
if role_from_response is None:
return user_to_verify.role == target_role
return target_role == UserRole(role_from_response)
@staticmethod
def promote_to_admin(
user_to_promote: DATestUser,
def set_role(
user_to_set: DATestUser,
target_role: UserRole,
user_performing_action: DATestUser,
explicit_override: bool = False,
) -> DATestUser:
"""Promote a user to admin by adding them to the Admin default group."""
groups_response = requests.get(
url=f"{API_SERVER_URL}/manage/admin/user-group?include_default=true",
headers=user_performing_action.headers,
)
groups_response.raise_for_status()
admin_group = next(
(
g
for g in groups_response.json()
if g.get("is_default") is True and g.get("name") == "Admin"
),
None,
)
if admin_group is None:
raise RuntimeError("Admin default group not found")
response = requests.post(
url=f"{API_SERVER_URL}/manage/admin/user-group/{admin_group['id']}/add-users",
json={"user_ids": [user_to_promote.id]},
response = requests.patch(
url=f"{API_SERVER_URL}/manage/set-user-role",
json={
"user_email": user_to_set.email,
"new_role": target_role.value,
"explicit_override": explicit_override,
},
headers=user_performing_action.headers,
)
response.raise_for_status()
return DATestUser(
id=user_to_promote.id,
email=user_to_promote.email,
password=user_to_promote.password,
headers=user_to_promote.headers,
is_admin=True,
is_active=user_to_promote.is_active,
cookies=user_to_promote.cookies,
new_user_updated_role = DATestUser(
id=user_to_set.id,
email=user_to_set.email,
password=user_to_set.password,
headers=user_to_set.headers,
role=target_role,
is_active=user_to_set.is_active,
)
return new_user_updated_role
# TODO: Add a way to check invited status
@staticmethod
@@ -238,29 +225,29 @@ class UserManager:
)
response.raise_for_status()
return DATestUser(
new_user_updated_status = DATestUser(
id=user_to_set.id,
email=user_to_set.email,
password=user_to_set.password,
headers=user_to_set.headers,
is_admin=user_to_set.is_admin,
role=user_to_set.role,
is_active=target_status,
cookies=user_to_set.cookies,
)
return new_user_updated_status
@staticmethod
def create_test_users(
user_performing_action: DATestUser,
user_name_prefix: str,
count: int,
as_admin: bool = False,
role: UserRole = UserRole.BASIC,
is_active: bool | None = None,
) -> list[DATestUser]:
users_list = []
for i in range(1, count + 1):
user = UserManager.create(name=f"{user_name_prefix}_{i}")
if as_admin:
user = UserManager.promote_to_admin(user, user_performing_action)
if role != UserRole.BASIC:
user = UserManager.set_role(user, role, user_performing_action)
if is_active is not None:
user = UserManager.set_status(user, is_active, user_performing_action)
users_list.append(user)
@@ -272,6 +259,7 @@ class UserManager:
page_num: int = 0,
page_size: int = 10,
search_query: str | None = None,
role_filter: list[UserRole] | None = None,
is_active_filter: bool | None = None,
) -> PaginatedReturn[FullUserSnapshot]:
query_params: dict[str, str | list[str] | int] = {
@@ -280,6 +268,8 @@ class UserManager:
}
if search_query:
query_params["q"] = search_query
if role_filter:
query_params["roles"] = [role.value for role in role_filter]
if is_active_filter is not None:
query_params["is_active"] = is_active_filter

View File

@@ -86,6 +86,24 @@ class UserGroupManager:
user_group.name = response.json()["name"]
return user_group
@staticmethod
def set_curator_status(
test_user_group: DATestUserGroup,
user_to_set_as_curator: DATestUser,
user_performing_action: DATestUser,
is_curator: bool = True,
) -> None:
set_curator_request = {
"user_id": user_to_set_as_curator.id,
"is_curator": is_curator,
}
response = requests.post(
f"{API_SERVER_URL}/manage/admin/user-group/{test_user_group.id}/set-curator",
json=set_curator_request,
headers=user_performing_action.headers,
)
response.raise_for_status()
@staticmethod
def get_permissions(
user_group: DATestUserGroup,
@@ -99,14 +117,15 @@ class UserGroupManager:
return response.json()
@staticmethod
def set_permissions(
def set_permission(
user_group: DATestUserGroup,
permissions: list[str],
permission: str,
enabled: bool,
user_performing_action: DATestUser,
) -> requests.Response:
response = requests.put(
f"{API_SERVER_URL}/manage/admin/user-group/{user_group.id}/permissions",
json={"permissions": permissions},
json={"permission": permission, "enabled": enabled},
headers=user_performing_action.headers,
)
return response

View File

@@ -7,6 +7,7 @@ from uuid import UUID
from pydantic import BaseModel
from pydantic import Field
from onyx.auth.schemas import UserRole
from onyx.configs.constants import MessageType
from onyx.configs.constants import QAFeedbackType
from onyx.context.search.models import SavedSearchDoc
@@ -57,7 +58,7 @@ class DATestAPIKey(BaseModel):
api_key_display: str
api_key: str | None = None # only present on initial creation
api_key_name: str | None = None
groups: list[dict] = []
api_key_role: UserRole
user_id: UUID
headers: dict
@@ -68,7 +69,7 @@ class DATestUser(BaseModel):
email: str
password: str
headers: dict
is_admin: bool
role: UserRole
is_active: bool
cookies: dict = {}

Some files were not shown because too many files have changed in this diff Show More