mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-21 17:36:44 +00:00
Compare commits
37 Commits
new-permis
...
codex/agen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f793ff870 | ||
|
|
4a96ef13d7 | ||
|
|
55f570261f | ||
|
|
289a7b807e | ||
|
|
822b0c99be | ||
|
|
bcf2851a85 | ||
|
|
a5a59bd8f0 | ||
|
|
32d2e7985a | ||
|
|
c4f8d5370b | ||
|
|
9e434f6a5a | ||
|
|
67dc819319 | ||
|
|
2d12274050 | ||
|
|
c727ba13ee | ||
|
|
6193dd5326 | ||
|
|
387a7d1cea | ||
|
|
869578eeed | ||
|
|
e68648ab74 | ||
|
|
da01002099 | ||
|
|
f5d66f389c | ||
|
|
82d89f78c6 | ||
|
|
6f49c5e32c | ||
|
|
41f2bd2f19 | ||
|
|
bfa2f672f9 | ||
|
|
a823c3ead1 | ||
|
|
bd7d378a9a | ||
|
|
dcec0c8ef3 | ||
|
|
6456b51dcf | ||
|
|
7cfe27e31e | ||
|
|
3c5f77f5a4 | ||
|
|
ab4d1dce01 | ||
|
|
80c928eb58 | ||
|
|
77528876b1 | ||
|
|
3bf53495f3 | ||
|
|
e4cfcda0bf | ||
|
|
475e8f6cdc | ||
|
|
945272c1d2 | ||
|
|
185b057483 |
65
.devcontainer/Dockerfile
Normal file
65
.devcontainer/Dockerfile
Normal 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
126
.devcontainer/README.md
Normal 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`.
|
||||
22
.devcontainer/devcontainer.json
Normal file
22
.devcontainer/devcontainer.json
Normal 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"
|
||||
}
|
||||
105
.devcontainer/init-dev-user.sh
Normal file
105
.devcontainer/init-dev-user.sh
Normal 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
104
.devcontainer/init-firewall.sh
Executable 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
10
.devcontainer/zshrc
Normal 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
|
||||
4
.github/workflows/deployment.yml
vendored
4
.github/workflows/deployment.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
3
.vscode/launch.json
vendored
@@ -531,8 +531,7 @@
|
||||
"request": "launch",
|
||||
"runtimeExecutable": "uv",
|
||||
"runtimeArgs": [
|
||||
"sync",
|
||||
"--all-extras"
|
||||
"sync"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
|
||||
416
AGENTS.md
416
AGENTS.md
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
37
backend/onyx/background/celery/README.md
Normal file
37
backend/onyx/background/celery/README.md
Normal 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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 &= (
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
47
backend/onyx/error_handling/README.md
Normal file
47
backend/onyx/error_handling/README.md
Normal 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`.
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
72
backend/onyx/server/metrics/pruning_metrics.py
Normal file
72
backend/onyx/server/metrics/pruning_metrics.py
Normal 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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user