diff --git a/CLAUDE.md b/CLAUDE.md index 59c05b575..aa10d7b23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,7 +166,7 @@ The system uses token-based authentication with role-based access control (RBAC) 1. Token extracted from `Authorization: Bearer ` header (or `?token=` query param for `/static` routes) 2. Token hashed with SHA-256, looked up in database 3. User info and accessible partitions set on `request.state.user` and `request.state.user_partitions` -4. Bypassed for: `/docs`, `/openapi.json`, `/redoc`, `/health_check`, `/version`, `/chainlit/*` +4. Bypassed for: `/docs`, `/openapi.json`, `/redoc`, `/health_check`, `/version`, `/chainlit/*` — except in OIDC mode the three docs paths (`/docs`, `/redoc`, `/openapi.json`) are login-gated instead of public (see Middleware Behavior below) 5. If `AUTH_TOKEN` env var is not set, defaults to admin user (id=1) for all requests **Role Hierarchy** (`openrag/services/orchestrators/auth_service.py`): @@ -449,9 +449,10 @@ New table `oidc_sessions`: **Middleware Behavior**: - UI paths (`/`, `/chainlit`, `/static`) in OIDC mode without auth → 302 redirect to `/auth/login?next=...` +- Interactive docs (`/docs`, `/redoc`, `/openapi.json`): **public in token mode** (bypassed), but **login-gated in OIDC mode** — an unauthenticated browser is 302-redirected to `/auth/login`; a valid session renders them. This stops the full API surface + schema from being served anonymously in production. The set is `AuthBypassConfig.oidc_gated_paths` (default `("/docs", "/redoc", "/openapi.json")`); override it to `()` to keep docs public under OIDC. - API paths (`/v1`, `/indexer`, `/search`, etc.) without auth: - **Token mode** → `403 {"detail": "Missing token"}` (no bearer) or `403 {"detail": "Invalid token"}` (unknown bearer). The 403 status is a legacy contract the robot suite asserts (`tests/api/`). - **OIDC mode** → `401 {"detail": "Unauthenticated"}` (no usable session/bearer and the path isn't a UI redirect target). - Programmatic access: Bearer `users.token` accepted in both modes -**See Also**: Full configuration and troubleshooting guide at `docs/oidc.md`. +**See Also**: Full configuration and troubleshooting guide at `docs/content/docs/documentation/oidc.md` (quick start: `docs/content/docs/documentation/sso-quickstart.md`). diff --git a/FORWARD_PORT_LOG.md b/FORWARD_PORT_LOG.md index e30c56c74..ba0f7d5e4 100644 --- a/FORWARD_PORT_LOG.md +++ b/FORWARD_PORT_LOG.md @@ -9,19 +9,109 @@ to the cutover re-implementation queue. --- -## Forward-ported (critical) +## `main` → `refactor/hexagonal` security forward-port (2026-06-24) + +Working branch: `forward-port/main-to-hexagonal` (off `origin/refactor/hexagonal`). +Porting the 68 non-merge commits that landed on `main` after the +merge-base (`c9d53cc0`, 2026-05-21) — mostly a one-shot security-hardening audit +plus loader/OpenAI fixes, deploy hardening, deps and release chores. Each port is +one commit carrying the original subject/body + a `Forward-ported from ` +trailer. Order chosen by the user: package security code first, infra non-root + +docs last. + +### Ported + +| source (main) | subject | target location(s) | +|---|---|---| +| `c1079d7f` | Ray dashboard → localhost (compose) | `infra/compose/docker-compose.yaml` | +| `8914dfbb` | non-root Ray container (H4) | `infra/docker/ray.Dockerfile` | +| `8914dfbb`+`6860341c`+`26a70dbc`+`c0847d8f` | non-root OpenShift app image (consolidated — later commits rewrote earlier) | `infra/docker/api.Dockerfile` | +| `645128dc` | no prod bind-mount / gate --reload (N8) | `infra/compose/docker-compose.yaml`, `infra/scripts/entrypoint.sh` | +| `0e5687bc` | remove API_NUM_WORKERS footgun | `infra/scripts/entrypoint.sh`, `infra/compose/.env.example`, `infra/charts/.../values.yaml`, `docs/.../env_vars.md` | +| `319bc5cd` | drop API_NUM_WORKERS from doc env assets | `docs/assets/env_example.env`, `env_linux_gpu.env` | +| `c558dd1f` / `d69be1da` | version bump → 1.1.12 / 1.1.13 | `pyproject.toml` | +| `2af1d76f` | harden Ansible deploy (#488) | `infra/ansible/ansible.cfg`, `playbooks/openrag.yml` | +| `34dc3a2f` | Ray dashboard localhost default (C3) | `openrag/api/main.py`, `openrag/api/mcp/server.py`, `infra/cluster.yaml`, `infra/quick_start/docker-compose.yaml`, `docs/assets/compose_ollama_cpu.yaml` | +| `aa015bdd` | external Ray cluster via RAY_ADDRESS | `openrag/api/main.py`, `openrag/api/mcp/server.py`, `.env.example`, docs | +| `0515f705` | require MinIO creds (H3) | `infra/compose/milvus/milvus.yaml`, `infra/quick_start/vdb/milvus.yaml`, `.env.example` | +| `0164b829` | remove weak DB/AUTH defaults (M2/M3) | `conf/config.yaml`, compose stacks, `docs/assets/compose_ollama_cpu.yaml`, `.env.example` | +| `b8002ef0` | drop seccomp:unconfined (N11) | `infra/compose/milvus/milvus.yaml` + `.named-volumes.yaml`, `infra/quick_start/vdb/milvus.yaml` | +| `24efaa66` | Helm DB password → Secret (N7) | `infra/charts/openrag-stack/values.yaml` | +| `c8f2d47f` | default-deny NetworkPolicy (N12) | `infra/charts/.../templates/networkpolicy.yaml` (new), `values.yaml` | +| `5caec50b` | restrict metrics exposure (N9) | `infra/compose/monitoring.docker-compose.yaml` | +| `d7fc3130` | pin image tags (N10) | `infra/charts/.../values.yaml` (openrag-owned → 1.1.13), `infra/compose/monitoring.docker-compose.yaml` (third-party pins). Compose app-image pins skipped (reverted by 64c3e722). | +| `7bc46696` | require ALLOW_NO_AUTH for no-token admin bypass | `openrag/api/middleware/auth.py`, `.env.example`, `tests/unit/api/middleware/test_bypass_config.py` | +| `202433d7` | update_user mass-assignment whitelist | `openrag/core/models/user.py` (extra="ignore"), `openrag/services/orchestrators/user_service.py` (whitelist), tests | +| `edd2c7ce` | external_user_id empty→NULL (#121) | `openrag/core/models/user.py` (validator; covers update path the repo missed) | +| `229503b4` | no session token in UI file URLs (N13) | `openrag/app_front.py` | +| `97c624ef` | back-channel logout exp/jti + replay (M9) | `services/auth/oidc_client.py`, `services/orchestrators/auth_service.py` (+ replay test) | +| `2b34a0d1` | clock-skew leeway + nbf (crypto) | `services/auth/oidc_client.py` | +| `c2fde135` | logout CSRF Fetch-Metadata guard (N3) | `api/routers/auth/oidc.py` (+ tests) | +| `9a73200a` | revoke OIDC sessions on token regen (#361, #486) | `services/orchestrators/{auth_service,user_service}.py` (startup-rotation guard + revoke_by_user already present) | +| `714f2a84` | streaming finish_reason not on content chunk | `core/utils/source_filtering.py` (+ test) | +| `0bc6157e` | stop logging raw query text (#481) | `api/routers/user/search.py` (query_len) | +| `52be26f1` | non-empty RAG answer body | `openrag/prompts/templates/sys_prompt_tmpl.txt` | +| `6bc898e9` | surrounding-chunk partition scope (N6) | `services/storage/vector_store_searcher.py` (+ tests) | +| `86c9b51d` | ensure_partition_role fail-open → 404 | `api/dependencies/auth.py` (+ tests) | +| `bf4ae134` | Milvus filter scope-escape via precedence | `services/storage/milvus_store.py` `_build_filter_expr` (paren-wrap multi-part) (+ tests) | +| `f079efa5` | validate file_id / partition allowlist | `core/indexing/validators.py`, `api/dependencies/auth.py`, `partition_service.py`, `api/routers/{user/search,admin/partitions}.py` (+ tests). Defense-in-depth atop `_format_value` escaping. | +| `db92875d` | strip client llm_override endpoint/creds | `services/inference/vllm_client.py` `_resolve_overrides`, `api/schemas/user/chat.py` (+ tests) | +| `e3c7eac2` + `81bccf08` | control-token neutralizer (H8, #487) | `core/utils/text.py` `neutralize_prompt_control_tokens`, `core/prompts/chat_prompt_builder.py` (+ tests) | +| `818d5446` | stop leaking stack traces / FS paths (M7) | `api/routers/admin/indexing.py` (generic save error; admin-gated traceback) | +| `54165900` | token limit in RAG mode + bound n/best_of (M12) | `api/routers/user/chat.py`, `api/schemas/user/chat.py` (+ tests) | +| `d66cf029` | cap partitions per non-admin user (M13) | `services/orchestrators/partition_service.py`, `api/routers/admin/partitions.py`, `.env.example` (+ tests) | +| `8ecbc781` | web-search SSRF/MITM deltas (verify_ssl default True + DNS-resolution guard hook) | `services/websearch/content_fetcher.py` (+ tests). The refactor already had per-hop redirect revalidation (#383). | +| `8ea723ca` | explicit cairosvg `unsafe=False` (SSRF/XXE) | `core/indexing/parsers/image_parser.py` | +| `221f8ed8` | EML attachment fan-out cap (M8) | `core/indexing/parsers/eml_parser.py` (+ test). eml-depth already bounded by the dispatcher; docx/pptx/pdf caps N/A (docling/marker delegate). | +| `67ec4199` | authorize source-file downloads by partition | new `api/routers/user/download.py` (replaces the open `/static` mount), `api/main.py`, `chat.py`/`source_links.py` rekeyed to chunk id (+ tests) | +| `761f47a0` | validate copy-endpoint source_file_id (#477) | `api/routers/admin/indexing.py` + `docs/assets/compose_linux_gpu.yaml` | +| `74de8232` | Starlette>=0.47.2 / FastAPI>=0.116.1 (CVE-2025-54121) | `pyproject.toml` + `uv.lock` regen (starlette 0.46.2->0.47.3, fastapi->0.116.2, chainlit->2.11.1) | +| `0e6e7836` + `4d8bca01` | path-tiered rate limiting (M6) | new `api/middleware/rate_limit.py` (registered before AuthMiddleware), `limits>=3.6` dep, `.env.example`, API-test compose disable (+ tests). Refactor never had slowapi, so the 4d8bca01 swap is folded in. | +| `701fcf9e` | document Chainlit on CHAINLIT_PORT under Ray Serve | `docs/.../getting_started/usage.mdx` | +| `8849fe7d` | OIDC/SSO docs move deltas (frontmatter + links + README/CLAUDE pointers) | the file move was already done in the refactor; ported the remaining deltas | +| (final) | ruff-format the ported lines | `style:` commit, mirrors main's 355a6305 / ee86fd47 | + +### Skipped (already present / superseded on refactor) -_Security fixes, data-loss bugs, production outages re-implemented against -the new architecture. Each entry pairs the dev commit with the refactor -commit so a reviewer can audit equivalence._ +| source (main) | reason | +|---|---| +| `f9e0c394` | /chainlit path-boundary anchor already in `api/middleware/auth.py` | +| `9b82996c` | azp on multi-aud tokens already in `services/auth/oidc_client.py` | +| `64c3e722` | compose `:latest` tags already the refactor state | +| `f9b8a776` | de-flake `test_cancel_*` superseded by refactor `cca5f415` | +| `47b8cd32` | refactor already fail-fasts on missing CHAINLIT_AUTH_SECRET (stricter, #380/`b63a9825`, with a regression test). 47b8cd32 only re-adds an ALLOW_NO_AUTH-gated default-secret fallback — a loosening intentionally NOT ported. | +| `73acb1c9` | both halves already present: `sanitize_next_url` rejects CR/LF/NUL + the `/\` protocol-relative vector (#360 regression test), and the callback already verifies userinfo.sub == id_token.sub (OIDC Core §5.3.2). | +| `cdb3edc9` | test-only follow-up to M9 on main's `test_oidc_client.py` (no equivalent file); subsumed by the new replay test which carries exp. | +| `199424bf` | empty-stream 502 (#363): obviated by the refactor architecture — non-streaming uses `self._llm.chat()`/`.generate()` (materialized dicts, not a stream's first chunk), and the inference client already raises `InferenceError(status_code=502)` on invalid upstream responses. No `__anext__`/`StopAsyncIteration` path remains. | +| `70a2db36` | CustomDocLoader page accumulation (#376): obviated — the parser-shim removal deleted `CustomDocLoader`; `.doc` now goes through the docling/marker workers, which produce whole-document markdown and have no single-page-overwrite bug. | +| `63a857af` | image-URL SSRF on captioning (H2): obviated — the refactor captions extracted image *bytes* (`vlm.caption_image(image_bytes)`); a document's remote `![](http://…)` becomes an `ImageBlock(source_url=…)` with empty bytes that nothing fetches, and the URL is never forwarded to the VLM. No SSRF gadget. (`image_captioning_url` is a dormant unread knob.) The SVG sub-fix is ported separately as `8ea723ca`. | -(none yet) +### Remaining (TODO — not yet ported) -## Deferred to cutover (features) +**Batches 1 (infra core), 2 (deps), 3 (auth/OIDC), 4 (RAG/retrieval), docs, final +ruff pass: ✅ COMPLETE.** All security-relevant package code, dependencies and +docs are ported or obviated. Targeted security/infra tests, ruff and diff checks +are clean on the forward-port branch. + +**Not ported — one item, by deliberate choice:** +- `4bbefd41` **compose non-root rework** (init-perms bootstrap container + per-service + `user:` mappings). This is all-or-nothing: the per-service `user:` mappings only work + with the init-perms container that pre-creates and chowns the bind-mount dirs, and those + paths must be mapped exactly onto the refactor's `infra/compose/` layout (`../../data`, + `../../logs`, the `milvus/milvus.yaml` include, `../../extern/reranker/*`, the + named-volumes variant). It is **runtime-only** (no unit-test feedback; compose isn't + exercised in CI) and is the least security-critical item — the images already *build* + non-root via the ported Dockerfiles. Committing it on faith risks a silently broken + stack, so it is left for a follow-up that can `docker compose up` to verify init-perms + ownership and non-root writes. Target files: `infra/compose/docker-compose.yaml`, + `infra/compose/milvus/milvus.yaml`, `extern/reranker/*.yaml`, `infra/quick_start`. +- `563907ad` (doc comment trim) — cosmetic; the ported comments are already condensed and + differ from main's verbose originals, so the trim doesn't map cleanly. Skipped. + +--- + +## Forward-ported (critical) -_Non-critical changes that landed on `dev` during MODE 2. These will be -re-implemented directly in the new architecture during MODE 3 (Phases -10-12) or post-cutover. List the dev PR number / commit and the target -location in the new layout._ +_(legacy template section — superseded by the dated section above.)_ -(none yet) +(none recorded under the original Phase 5–9 process) diff --git a/README.md b/README.md index 97875e949..741c55ba2 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,7 @@ OpenRag supports two authentication modes: To enable OIDC, set `AUTH_MODE=oidc` and configure the required OIDC variables (see [`infra/compose/.env.example`](./infra/compose/.env.example) for the full list). -For comprehensive OIDC setup and configuration, see the [OIDC Authentication Guide](./docs/oidc.md). +For comprehensive OIDC setup and configuration, see the [OIDC Authentication Guide](./docs/content/docs/documentation/oidc.md) (or the [SSO Quick Start](./docs/content/docs/documentation/sso-quickstart.md) for a faster path). 3. `http://localhost:INDEXERUI_PORT` to access the indexer ui for easy document ingestion, indexing, and management diff --git a/conf/config.yaml b/conf/config.yaml index 5567f4e27..72d9adf08 100644 --- a/conf/config.yaml +++ b/conf/config.yaml @@ -74,7 +74,9 @@ rdb: host: rdb port: 5432 user: root - password: "root_password" + # No default: supply via the POSTGRES_PASSWORD env var. Shipping a known + # password would be a usable default credential on any exposed deployment. + password: "" default_file_quota: -1 # Leave unset to derive partitions_for_collection_. database: null diff --git a/docs/assets/compose_linux_gpu.yaml b/docs/assets/compose_linux_gpu.yaml index f13d68cf7..b3db5cfde 100644 --- a/docs/assets/compose_linux_gpu.yaml +++ b/docs/assets/compose_linux_gpu.yaml @@ -16,7 +16,7 @@ x-openrag: &openrag_template - ./ray_mount/logs:/app/logs ports: - ${APP_PORT:-8080}:${APP_iPORT:-8080} - - ${RAY_DASHBOARD_PORT:-8265}:8265 # Disable when in cluster mode + - 127.0.0.1:${RAY_DASHBOARD_PORT:-8265}:8265 # Localhost only: Ray dashboard/Jobs API is unauthenticated. Disable when in cluster mode networks: default: aliases: diff --git a/docs/assets/compose_ollama_cpu.yaml b/docs/assets/compose_ollama_cpu.yaml index 92d0ebead..e5a12563d 100644 --- a/docs/assets/compose_ollama_cpu.yaml +++ b/docs/assets/compose_ollama_cpu.yaml @@ -7,7 +7,8 @@ x-openrag: &openrag_template - ./ray_mount/logs:/app/logs ports: - 8090:8080 - - 8265:8265 # Disable when in cluster mode + # Localhost only: Ray dashboard/Jobs API is unauthenticated (CVE-2023-48022). Disable when in cluster mode + - 127.0.0.1:${RAY_DASHBOARD_PORT:-8265}:8265 networks: default: aliases: @@ -16,7 +17,7 @@ x-openrag: &openrag_template - .env environment: - APP_PORT=8090 - - AUTH_TOKEN=OpenRAG + - AUTH_TOKEN=${AUTH_TOKEN:?Set a strong AUTH_TOKEN in your .env} - RERANKER_ENABLED=false - MARKER_MAX_PROCESSES=1 - INDEXERUI_COMPOSE_FILE=true # Does not serve any purpose but needs to be enabled until PR is merged @@ -38,7 +39,7 @@ services: rdb: image: postgres:15 environment: - - POSTGRES_PASSWORD=root + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your .env} - POSTGRES_USER=root volumes: - ./db:/var/lib/postgresql/data @@ -72,8 +73,8 @@ services: minio: image: minio/minio:RELEASE.2023-03-20T20-16-18Z environment: - MINIO_ACCESS_KEY: minioadmin - MINIO_SECRET_KEY: minioadmin + MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?Set MINIO_ACCESS_KEY in your .env} + MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?Set MINIO_SECRET_KEY in your .env} volumes: - ./volumes/minio:/minio_data command: minio server /minio_data --console-address ":9001" @@ -91,6 +92,8 @@ services: environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 + MINIO_ACCESS_KEY_ID: ${MINIO_ACCESS_KEY:?Set MINIO_ACCESS_KEY in your .env} + MINIO_SECRET_ACCESS_KEY: ${MINIO_SECRET_KEY:?Set MINIO_SECRET_KEY in your .env} volumes: - ./volumes/milvus:/var/lib/milvus healthcheck: diff --git a/docs/assets/env_example.env b/docs/assets/env_example.env index 74b42cc50..8a6e7948e 100644 --- a/docs/assets/env_example.env +++ b/docs/assets/env_example.env @@ -10,7 +10,9 @@ VLM_MODEL= ## FastAPI App (no need to change it) # APP_PORT=8080 # this is the forwarded port -# API_NUM_WORKERS=1 # Number of uvicorn workers for the FastAPI app +# The uvicorn path runs a single worker by design (Ray provides concurrency). +# To scale the HTTP layer, use Ray Serve: ENABLE_RAY_SERVE=true with +# RAY_SERVE_NUM_REPLICAS=N (see the Ray Serve configuration section). ## To enable API HTTP authentication via HTTPBearer # AUTH_TOKEN=sk-openrag-1234 @@ -42,6 +44,11 @@ RAY_DEDUP_LOGS=0 # turns off ray log deduplication that appear across multiple p RAY_ENABLE_RECORD_ACTOR_TASK_LOGGING=1 # # to enable logs at task level in ray dashboard RAY_task_retry_delay_ms=3000 RAY_ENABLE_UV_RUN_RUNTIME_ENV=0 # critical with the newest version of UV +# Attach to an external Ray cluster instead of starting an embedded one (disables the local dashboard). +# RAY_ADDRESS=ray://X.X.X.X:10001 +# Interface the embedded Ray dashboard binds to. Defaults to 127.0.0.1 (loopback) because the +# dashboard/job API is unauthenticated (CVE-2023-48022). Set 0.0.0.0 only behind a firewall/auth proxy. +# RAY_DASHBOARD_HOST=127.0.0.1 # Indexer UI ## 1. replace X.X.X.X with localhost if launching local or with your server IP diff --git a/docs/assets/env_linux_gpu.env b/docs/assets/env_linux_gpu.env index 099114125..5bd3332f0 100644 --- a/docs/assets/env_linux_gpu.env +++ b/docs/assets/env_linux_gpu.env @@ -10,7 +10,9 @@ VLM_MODEL= ## FastAPI App (no need to change it) # APP_PORT=8080 # this is the forwarded port -# API_NUM_WORKERS=1 # Number of uvicorn workers for the FastAPI app +# The uvicorn path runs a single worker by design (Ray provides concurrency). +# To scale the HTTP layer, use Ray Serve: ENABLE_RAY_SERVE=true with +# RAY_SERVE_NUM_REPLICAS=N (see the Ray Serve configuration section). ## To enable API HTTP authentication via HTTPBearer # AUTH_TOKEN=sk-openrag-1234 @@ -38,6 +40,11 @@ RAY_DEDUP_LOGS=0 # turns off ray log deduplication that appear across multiple p RAY_ENABLE_RECORD_ACTOR_TASK_LOGGING=1 # # to enable logs at task level in ray dashboard RAY_task_retry_delay_ms=3000 RAY_ENABLE_UV_RUN_RUNTIME_ENV=0 # critical with the newest version of UV +# Attach to an external Ray cluster instead of starting an embedded one (disables the local dashboard). +# RAY_ADDRESS=ray://X.X.X.X:10001 +# Interface the embedded Ray dashboard binds to. Defaults to 127.0.0.1 (loopback) because the +# dashboard/job API is unauthenticated (CVE-2023-48022). Set 0.0.0.0 only behind a firewall/auth proxy. +# RAY_DASHBOARD_HOST=127.0.0.1 # Indexer UI ## 1. replace X.X.X.X with localhost if launching local or with your server IP diff --git a/docs/content/docs/documentation/API.mdx b/docs/content/docs/documentation/API.mdx index 1e9c74607..a3a424fb4 100644 --- a/docs/content/docs/documentation/API.mdx +++ b/docs/content/docs/documentation/API.mdx @@ -7,13 +7,13 @@ The FastAPI-powered backend provides a comprehensive document-based question ans ## 🔐 Authentication -All endpoints require authentication when **enabled** (by adding a authorization token `AUTH_TOKEN` in your **`.env`**). Include your **`AUTH_TOKEN`** in the HTTP request header: +Protected endpoints require authentication by default. Set `AUTH_TOKEN` in your `.env` and include it in the HTTP request header: ```http Authorization: Bearer YOUR_AUTH_TOKEN ``` -For OpenAI-compatible endpoints, `AUTH_TOKEN` serves as the `api_key` parameter. Use a placeholder like `'sk-1234'` when authentication is disabled (necessary for when using OpenAI client). +For OpenAI-compatible endpoints, `AUTH_TOKEN` serves as the `api_key` parameter. Local no-auth development requires the explicit `ALLOW_NO_AUTH=true` opt-in; otherwise an empty token fails closed. --- @@ -433,7 +433,7 @@ OpenAI-compatible text completion endpoint. | `websearch` | `bool` | `false` | Augments the RAG context with live web search results. When used with a partition (`openrag-{partition}`), document and web results are combined. When used without a partition (direct LLM mode), web results are the sole context. Requires `WEBSEARCH_API_TOKEN` to be configured. See [web search configuration](/openrag/documentation/env_vars/#web-search-configuration). | | `spoken_style_answer` | `bool` | `false` | Generates a succinct spoken-style conversational answer based on the retrieved documents. | | `use_map_reduce` | `bool` | `false` | Uses a map-reduce strategy to aggregate information from multiple documents. See [map-reduce configuration](/openrag/documentation/env_vars/#map--reduce-configuration). | -| `llm_override` | `object` | `null` | Routes the request to a different LLM endpoint while still using OpenRAG's RAG pipeline (retrieval, reranking, prompt construction). Accepts: `base_url` (string), `api_key` (string), `model` (string). Any field not provided falls back to the default OpenRAG LLM configuration. | +| `llm_override` | `object` | `null` | Overrides only the downstream LLM model name while still using OpenRAG's configured LLM endpoint and credentials. Accepts: `model` (string). Endpoint URL and API key are server-side configuration and cannot be changed by a client request. | Examples: @@ -498,7 +498,7 @@ curl -X 'POST' 'http://localhost:8080/v1/chat/completions' \ }' ``` -```bash title="Using a custom LLM endpoint with OpenRAG's RAG pipeline" +```bash title="Using another configured LLM model with OpenRAG's RAG pipeline" curl -X 'POST' 'http://localhost:8080/v1/chat/completions' \ -H 'accept: application/json' \ -H 'Authorization: Bearer YOUR_AUTH_TOKEN' \ @@ -514,8 +514,6 @@ curl -X 'POST' 'http://localhost:8080/v1/chat/completions' \ "stream": false, "metadata": { "llm_override": { - "base_url": "https://api.openai.com/v1", - "api_key": "sk-your-openai-key", "model": "gpt-4o" } } @@ -586,7 +584,7 @@ from openai import OpenAI, AsyncOpenAI api_base_url = "http://localhost:8080" # fastapi base url of 'openrag' base_url = f"{api_base_url}/v1" -auth_key = ... # your api authentification key AUTH_TOKEN in your .env. Is authentification is disabled, use a placeholder like 'sk-1234' +auth_key = ... # your API authentication key AUTH_TOKEN from .env client = OpenAI(api_key=auth_key, base_url=base_url) your_partition= 'my_partition' # name of your partition diff --git a/docs/content/docs/documentation/deploy_ray_cluster.md b/docs/content/docs/documentation/deploy_ray_cluster.md index c5ad01f7e..7239bba42 100644 --- a/docs/content/docs/documentation/deploy_ray_cluster.md +++ b/docs/content/docs/documentation/deploy_ray_cluster.md @@ -112,7 +112,7 @@ auth: head_start_ray_commands: - uv run ray stop - - uv run ray start --head --dashboard-host 0.0.0.0 --dashboard-port ${RAY_DASHBOARD_PORT:-8265} --node-ip-address ${HEAD_NODE_IP} --autoscaling-config=~/ray_bootstrap_config.yaml + - uv run ray start --head --dashboard-host ${RAY_DASHBOARD_HOST:-127.0.0.1} --dashboard-port ${RAY_DASHBOARD_PORT:-8265} --node-ip-address ${HEAD_NODE_IP} --autoscaling-config=~/ray_bootstrap_config.yaml worker_start_ray_commands: - uv run ray stop - uv run ray start --address ${HEAD_NODE_IP:-10.0.0.1}:6379 @@ -136,6 +136,10 @@ docker compose up -d Once running, **OpenRAG will auto-connect** to the Ray cluster using `RAY_ADDRESS` from `.env`. +:::note +When `RAY_ADDRESS` is set, the app **attaches** to the external cluster and does **not** start its own embedded Ray dashboard — the head node owns it. Keep the dashboard bound to `127.0.0.1` by default because the dashboard API is unauthenticated ([CVE-2023-48022](https://nvd.nist.gov/vuln/detail/CVE-2023-48022)). If operators need remote dashboard access, expose it only through a private network, SSH tunnel, or authenticated proxy. +::: + --- With this setup, your app is now fully distributed and ready to handle concurrent tasks across your Ray cluster. diff --git a/docs/content/docs/documentation/env_vars.md b/docs/content/docs/documentation/env_vars.md index de5c1527d..8138f1503 100644 --- a/docs/content/docs/documentation/env_vars.md +++ b/docs/content/docs/documentation/env_vars.md @@ -206,6 +206,8 @@ The PostgreSQL database is configured using the following environment variables: The main Docker Compose stack keeps the historical host-path defaults. Set these variables when you want to move state elsewhere, including Docker named volumes. +When using host paths with the non-root API image, make sure the mounted directories are writable by the container user. If that is not practical for your deployment, use the named-volume profile instead. + For an opt-in named-volume profile, copy the values from `infra/compose/.env.named-volumes.example` into your `.env`. | Variable | Default | Description | @@ -378,6 +380,8 @@ Ray is used for distributed task processing and parallel execution in the RAG pi | `RAY_POOL_SIZE` | `int` | 1 | Number of serializer actor instances (typically 1 actor per cluster node) | | `RAY_MAX_TASKS_PER_WORKER` | `int` | 8 | Maximum number of concurrent tasks (serialization tasks) per serializer actor instance | | `RAY_DASHBOARD_PORT` | `int` | 8265 | Ray Dashboard port used for monitoring. In production, [comment out this line](https://github.com/linagora/openrag/blob/ee732ea8e080dcde0107d62d12703a7525f810cd/docker-compose.yaml#L21C1-L22C1) to avoid exposing the port, as it may introduce security vulnerabilities. | +| `RAY_DASHBOARD_HOST` | `str` | `127.0.0.1` | Interface the **embedded** Ray dashboard binds to. Defaults to loopback because the Ray dashboard/job-submission API is **unauthenticated** ([CVE-2023-48022](https://nvd.nist.gov/vuln/detail/CVE-2023-48022)). Set to `0.0.0.0` only when the dashboard port is firewalled or sits behind an authenticating proxy. Ignored when `RAY_ADDRESS` is set. | +| `RAY_ADDRESS` | `str` | (unset) | When set, attach to an **external** Ray cluster at this address (e.g. `ray://HEAD_IP:10001`) instead of starting an embedded cluster in-process. In this mode the app does not start a local dashboard — the head node owns it. See [Ray Cluster deployment](/openrag/documentation/deploy_ray_cluster/). | :::danger[Attention] The following environment variables control Ray's logging behavior, task retry settings. These are not set by default and must be supplied [as suggested in the .env](/openrag/getting_started/quickstart#2-create-a-env-file) @@ -419,7 +423,19 @@ Controls the maximum number of concurrent operations for different indexer tasks | `RAY_SEMAPHORE_CONCURRENCY` | int | 100000 | Global concurrency limit for Ray semaphore operations | #### Ray Serve Configuration -Ray Serve enables deployment of the FastAPI as a scalable service. For simple deployment, without the intend to scale, one can usage the [uvicorn deployment mode](/openrag/documentation/env_vars/#ray-serve-configuration) + +Ray Serve enables deployment of the FastAPI app as a horizontally scalable service. + +By default (`ENABLE_RAY_SERVE=false`) OpenRAG runs under **uvicorn with a single worker**. This is intentional: the app initializes Ray and its named actors (`Indexer`, `Vectordb`, `TaskStateManager`, …) at import time, so a second uvicorn worker would start its **own isolated Ray cluster** with duplicate actors, fragmenting task state and vector-DB access. Concurrency within the single worker comes from the async app and from Ray itself — **not** from multiple uvicorn workers (there is intentionally no `API_NUM_WORKERS` knob). + +**To scale the HTTP layer, enable Ray Serve** — it runs `RAY_SERVE_NUM_REPLICAS` replicas inside one shared Ray cluster: + +```bash +ENABLE_RAY_SERVE=true +RAY_SERVE_NUM_REPLICAS=4 +``` + +For multi-node distributed deployments, see [Distributed Deployment in a Ray Cluster](/openrag/documentation/deploy_ray_cluster/). | Variable | Type | Default | Description | |----------|------|---------|-------------| @@ -512,10 +528,10 @@ The following environment variables configure the FastAPI server and control acc | Variable | Type | Default | Description | |----------|------|---------|-------------| | `APP_PORT` | `number` | `8000` | Port number on which the FastAPI application listens for incoming requests. | -| `AUTH_TOKEN` | `string` | `EMPTY` | An authentication token is required to access protected API endpoints. By default, this token corresponds to the API key of the created admin (see [Admin Bootstrapping](/openrag/documentation/user_auth/#2-admin-bootstrapping)). If left empty, authentication is disabled. | +| `AUTH_TOKEN` | `string` | `EMPTY` | Authentication token used to bootstrap the admin user and access protected API endpoints. If it is empty, the API fails closed unless `ALLOW_NO_AUTH=true` is explicitly set for local development. | +| `ALLOW_NO_AUTH` | `boolean` | `false` | Enables the no-auth local development bypass when `AUTH_MODE=token` and `AUTH_TOKEN` is unset. Never enable this in production. | | `SUPER_ADMIN_MODE` | `boolean` | `false` | Enables super admin privileges when set to `true`, [granting unrestricted access](/openrag/documentation/data_model/#access-control) to all operations and bypassing standard access controls. This is for debugging | | `DEFAULT_FILE_QUOTA` | `int` | `-1` | Default per-user file quota. `<0` disables quotas globally; `>=0` sets the default limit when a user has no explicit quota. | -|`API_NUM_WORKERS`|`int`|1|Number of uvicorn workers| | `PREFERRED_URL_SCHEME` | `string` | `null` | URL scheme (`http` or `https`) used when generating URLs in API responses (e.g., `task_status_url`). When running behind a reverse proxy that terminates SSL, set this to `https` to ensure generated URLs use the correct scheme. If unset, the scheme from the incoming request is used. | | `CORS_EXTRA_ORIGINS` | `string` | _(unset)_ | Semicolon-separated list of additional origins allowed by CORS (e.g. `https://app.example.com;https://other.example.com`). Extends the default list without replacing it. | diff --git a/docs/content/docs/documentation/oidc.md b/docs/content/docs/documentation/oidc.md index ca4383aae..313a1028a 100644 --- a/docs/content/docs/documentation/oidc.md +++ b/docs/content/docs/documentation/oidc.md @@ -1,4 +1,6 @@ -# OpenID Connect (OIDC) Authentication Guide +--- +title: OpenID Connect (OIDC) Authentication Guide +--- This guide walks you through configuring and using OpenRag's OIDC authentication mode. diff --git a/docs/content/docs/documentation/sso-quickstart.md b/docs/content/docs/documentation/sso-quickstart.md index 834d060cc..d23605916 100644 --- a/docs/content/docs/documentation/sso-quickstart.md +++ b/docs/content/docs/documentation/sso-quickstart.md @@ -1,4 +1,6 @@ -# SSO Quick Start (OIDC) +--- +title: SSO Quick Start (OIDC) +--- Configure OpenRag to delegate authentication to your corporate SSO (LemonLDAP::NG, Keycloak, Auth0, Azure AD, Okta…) in five steps. @@ -127,7 +129,7 @@ By default OpenRag reads the claims from the verified ID token (`OIDC_CLAIM_SOUR If your IdP models access as groups (e.g. Keycloak groups like `/openrag/project-alpha/editor`), OpenRag can map them to partition memberships automatically on every login — set `OIDC_CLAIM_GROUPS` and friends. See `docs/oidc.md` → -[Group → Partition Mapping](./oidc.md#group--partition-mapping-optional). (`is_admin` is never +[Group → Partition Mapping](/openrag/documentation/oidc/#group--partition-mapping-optional). (`is_admin` is never derived from groups.) --- @@ -136,7 +138,7 @@ derived from groups.) By default, OpenRag **does not auto-create users** on first login. Each user must exist in the database with their OIDC `sub` stored in `external_user_id`. -> **Skip this step entirely** by setting `OIDC_AUTO_PROVISION_LOGIN=true` in your `.env`. The callback then creates a non-admin user from the ID-token claims on first login and keeps `display_name` + `email` in sync with the IdP on every subsequent login. The trade-off: your IdP's user list becomes the source of truth for OpenRag accounts. See `docs/oidc.md` → [Auto-provisioning](./oidc.md#auto-provisioning-optional) for the full trust-model. +> **Skip this step entirely** by setting `OIDC_AUTO_PROVISION_LOGIN=true` in your `.env`. The callback then creates a non-admin user from the ID-token claims on first login and keeps `display_name` + `email` in sync with the IdP on every subsequent login. The trade-off: your IdP's user list becomes the source of truth for OpenRag accounts. See `docs/oidc.md` → [Auto-provisioning](/openrag/documentation/oidc/#auto-provisioning-optional) for the full trust-model. Ask the IdP admin for each user's `sub` claim value (stable identifier, NOT the username). Then create the user via the OpenRag admin API — you'll need an admin `AUTH_TOKEN` for this: @@ -174,7 +176,7 @@ docker compose logs openrag --tail 50 | grep -i OIDC Open your browser at `https://rag.mycorp.com/` → it redirects to your SSO → you log in → you come back authenticated. -If something goes wrong, see the full **[troubleshooting section in `docs/oidc.md`](./oidc.md#troubleshooting)**. Most issues fall into one of three categories: +If something goes wrong, see the full **[troubleshooting section in `docs/oidc.md`](/openrag/documentation/oidc/#troubleshooting)**. Most issues fall into one of three categories: 1. **Issuer mismatch** (Step 2 — trailing slash). 2. **Invalid redirect URI** (Step 1 — must match byte-for-byte). diff --git a/docs/content/docs/documentation/user_auth.md b/docs/content/docs/documentation/user_auth.md index 109b468a3..f0a96dc26 100644 --- a/docs/content/docs/documentation/user_auth.md +++ b/docs/content/docs/documentation/user_auth.md @@ -10,8 +10,10 @@ It covers admin behavior, user tokens, and partition-level permissions. ## **1. Authentication Activation** ### `AUTH_TOKEN` -- The presence of the environment variable **`AUTH_TOKEN`** activates authentication. -- If **`AUTH_TOKEN`** is **absent**, the middleware **bypasses all authentication checks**, allowing open access (useful for local or testing environments). +- **`AUTH_TOKEN`** is the token used to bootstrap the admin user and authenticate protected API calls. +- In **`AUTH_MODE=token`**, if **`AUTH_TOKEN`** is absent, OpenRAG fails closed by default. +- In **`AUTH_MODE=oidc`**, human login uses the OIDC session flow; **`AUTH_TOKEN`** is not the OIDC login mechanism. +- Local open mode requires **`ALLOW_NO_AUTH=true`** together with `AUTH_MODE=token`. This should never be used in production. :::danger[Attention !!!] **`SUPER_ADMIN_MODE=true`** must be activated if you want admin users to access all existing partitions, not just the admin's own partitions. @@ -105,8 +107,8 @@ Role-based restrictions are enforced via dependency guards: ## **7. Authorization Flow Summary** 1. Request arrives with optional `Authorization: Bearer `. -2. If `AUTH_TOKEN` is **unset**, authentication is skipped (open mode). -3. If set: +2. In `AUTH_MODE=token`, if `AUTH_TOKEN` is **unset**, authentication is rejected unless `ALLOW_NO_AUTH=true` is explicitly enabled for local development. +3. If a token is configured: - Middleware hashes the token. - Looks up the user by hash. - Loads their partition memberships. @@ -156,4 +158,3 @@ Role-based restrictions are enforced via dependency guards: - Configurable **`SUPER_ADMIN_MODE`** for system-wide debugging or admin override. --- - diff --git a/docs/content/docs/getting_started/usage.mdx b/docs/content/docs/getting_started/usage.mdx index eeb2c4356..8d8306736 100644 --- a/docs/content/docs/getting_started/usage.mdx +++ b/docs/content/docs/getting_started/usage.mdx @@ -15,4 +15,8 @@ By default, OpenRAG services are exposed on the following ports: | Ray Dashboard | 8265 | Ray dashboard for monitoring and managing tasks | | Indexer UI | 3042/ | Main user interface for indexing and viewing indexed documents | +:::note[Ray Serve mode] +The table above is for the default (uvicorn) deployment, where the Chainlit UI is the `/chainlit` subroute of the API. When `ENABLE_RAY_SERVE=true`, the API is served by Ray Serve on `RAY_SERVE_PORT` and the **Chainlit UI runs on its own port, `CHAINLIT_PORT`** (default `8090`), instead of the subroute. Uncomment the `${CHAINLIT_PORT}:${CHAINLIT_PORT}` mapping in `docker-compose.yaml` to expose it. See [`CHAINLIT_PORT`](/openrag/documentation/env_vars/#ray-serve-configuration). +::: + More information about the different services can be found in their respective documentation pages. \ No newline at end of file diff --git a/infra/ansible/ansible.cfg b/infra/ansible/ansible.cfg index d5db45cc6..6d99d1494 100644 --- a/infra/ansible/ansible.cfg +++ b/infra/ansible/ansible.cfg @@ -1,10 +1,10 @@ [defaults] inventory = inventory.ini -host_key_checking = False +host_key_checking = True retry_files_enabled = False stdout_callback = default gathering = smart fact_caching = memory [ssh_connection] -ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no +ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=accept-new diff --git a/infra/ansible/playbooks/openrag.yml b/infra/ansible/playbooks/openrag.yml index 71bb73c0a..d806d0bc5 100644 --- a/infra/ansible/playbooks/openrag.yml +++ b/infra/ansible/playbooks/openrag.yml @@ -104,7 +104,7 @@ dest: "{{ project_path }}/.env" owner: "{{ project_user }}" group: "{{ project_user }}" - mode: "0644" + mode: "0600" when: - not env_file.stat.exists - local_env_file.stat.exists @@ -124,14 +124,17 @@ - name: Setup Python environment block: - name: Install uv (Python package manager) - shell: curl -LsSf https://astral.sh/uv/install.sh | sh + shell: | + set -euo pipefail + curl -LsSf https://astral.sh/uv/0.5.11/install.sh | sh args: - creates: "/home/{{ project_user }}/.cargo/bin/uv" + executable: /bin/bash + creates: "/home/{{ project_user }}/.local/bin/uv" - name: Add uv to PATH in .bashrc lineinfile: path: "/home/{{ project_user }}/.bashrc" - line: 'export PATH="$HOME/.cargo/bin:$PATH"' + line: 'export PATH="$HOME/.local/bin:$PATH"' create: true become_user: "{{ project_user }}" diff --git a/infra/charts/openrag-stack/templates/networkpolicy.yaml b/infra/charts/openrag-stack/templates/networkpolicy.yaml new file mode 100644 index 000000000..abfd56e0d --- /dev/null +++ b/infra/charts/openrag-stack/templates/networkpolicy.yaml @@ -0,0 +1,33 @@ +{{- if .Values.networkPolicy.enabled }} +# Default-deny ingress baseline: applies to every pod in the namespace +# (podSelector: {}). Two allowances: +# 1. any pod in the same namespace (intra-stack traffic), and +# 2. the public HTTP ports, reachable from anywhere (Ingress controller). +# Everything else — notably the unauthenticated Ray dashboard (8265), GCS +# (6379), Ray client (10001), Postgres and Milvus — is only reachable from +# within the namespace. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "openrag-stack.fullname" . }}-default-deny + labels: + {{- include "openrag-stack.labels" . | nindent 4 }} +spec: + podSelector: {} + policyTypes: + - Ingress + ingress: + # 1. Allow all traffic originating from pods in this namespace. + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Release.Namespace }} + # 2. Allow the public HTTP ports from any source (e.g. the Ingress controller). + {{- with .Values.networkPolicy.externalPorts }} + - ports: + {{- range . }} + - port: {{ . }} + protocol: TCP + {{- end }} + {{- end }} +{{- end }} diff --git a/infra/charts/openrag-stack/templates/raycluster.yaml b/infra/charts/openrag-stack/templates/raycluster.yaml index 699b6d7ce..0d81e9e58 100644 --- a/infra/charts/openrag-stack/templates/raycluster.yaml +++ b/infra/charts/openrag-stack/templates/raycluster.yaml @@ -48,7 +48,7 @@ spec: - "ray" - "start" - "--head" - - "--dashboard-host=0.0.0.0" + - "--dashboard-host={{ .Values.ray.dashboardHost }}" - "--dashboard-agent-listen-port=52365" - "--metrics-export-port=8080" - "--block" diff --git a/infra/charts/openrag-stack/values.yaml b/infra/charts/openrag-stack/values.yaml index f7e4d4ac4..1c44a089e 100644 --- a/infra/charts/openrag-stack/values.yaml +++ b/infra/charts/openrag-stack/values.yaml @@ -1,3 +1,15 @@ +# === Network isolation === +# Default-deny ingress for all pods in the release namespace, allowing only +# intra-namespace traffic plus the public HTTP ports below. This isolates the +# unauthenticated Ray dashboard (8265), GCS (6379), Postgres, Milvus, etc. from +# outside the namespace. Disable only if you manage isolation elsewhere. +networkPolicy: + enabled: true + # Ports reachable from outside the namespace (e.g. via the Ingress controller). + externalPorts: + - 8080 # openrag API/UI + - 3000 # indexer-ui + # === Global shared persistence === persistence: enabled: true @@ -11,6 +23,7 @@ persistence: venv: { size: 10Gi } ray: + dashboardHost: "127.0.0.1" workers: count: 1 replicas: 1 @@ -20,13 +33,15 @@ ray: gpu-role: serving image: repository: ghcr.io/linagora/openrag-ray - tag: latest + # Pin to a release tag (ideally a digest) for reproducible deploys. + tag: "1.1.13" # === PostgreSQL (bitnami) === postgresql: enabled: true auth: username: &pgUser root + # Must be supplied at install time; templates fail closed if it is empty. password: &pgPass "" primary: persistence: @@ -98,6 +113,9 @@ vllm: capabilities: drop: - ALL + # NOTE: the per-model `tag: "latest"` entries below are operator-supplied + # serving images — pin each to a specific vLLM release tag (or digest) + # before production use for reproducible deploys (see vlm, pinned to v0.11.2). modelSpec: - name: "embedder" repository: "vllm/vllm-openai" @@ -208,6 +226,8 @@ reranker: servicePort: &rerankerPort 7997 image: repository: michaelf34/infinity + # Operator-supplied serving image: pin to a specific infinity release tag + # (or digest) before production use rather than tracking latest. tag: latest nodeSelector: gpu-role: serving @@ -235,7 +255,7 @@ reranker: indexerUi: enabled: true - image: "linagoraai/indexer-ui:latest" + image: "linagoraai/indexer-ui:1.1.13" imagePullPolicy: IfNotPresent replicaCount: 1 @@ -258,7 +278,8 @@ indexerUi: openrag: image: repository: linagoraai/openrag - tag: latest + # Pin to a release tag (ideally a digest) for reproducible deploys. + tag: "1.1.13" service: type: ClusterIP port: 8080 @@ -319,7 +340,8 @@ env: WITH_CHAINLIT_UI: "false" SAVE_UPLOADED_FILES: "false" - API_NUM_WORKERS: "8" + # HTTP scaling is handled by Ray Serve above (ENABLE_RAY_SERVE + + # RAY_SERVE_NUM_REPLICAS), not uvicorn workers — see entrypoint.sh. INDEXERUI_URL: "http://indexer-ui:3042" # Vector DB @@ -331,6 +353,8 @@ env: POSTGRES_HOST: "{{ .Release.Name }}-postgresql" POSTGRES_PORT: *pgPort POSTGRES_USER: *pgUser + # POSTGRES_PASSWORD is a secret — defined under env.secrets, not here in the + # (world-readable) ConfigMap. POSTGRES_AUTO_CREATE_DB: "{{ .Values.postgresProvisioning.autoCreateDatabase }}" POSTGRES_RUN_MIGRATIONS: "{{ .Values.postgresProvisioning.runMigrationsInApp }}" diff --git a/infra/cluster.yaml b/infra/cluster.yaml index 066c9c22c..17ed0d1eb 100644 --- a/infra/cluster.yaml +++ b/infra/cluster.yaml @@ -20,7 +20,8 @@ auth: head_start_ray_commands: - uv run ray stop - - uv run ray start --head --dashboard-host 0.0.0.0 --dashboard-port ${RAY_DASHBOARD_PORT:-8265} --node-ip-address ${HEAD_NODE_IP} --autoscaling-config=~/ray_bootstrap_config.yaml + # Dashboard/Jobs API is unauthenticated (CVE-2023-48022); bind to localhost by default and front with an auth proxy if remote access is needed. + - uv run ray start --head --dashboard-host ${RAY_DASHBOARD_HOST:-127.0.0.1} --dashboard-port ${RAY_DASHBOARD_PORT:-8265} --node-ip-address ${HEAD_NODE_IP} --autoscaling-config=~/ray_bootstrap_config.yaml worker_start_ray_commands: - uv run ray stop - uv run ray start --address ${HEAD_NODE_IP:-10.0.0.1}:6379 \ No newline at end of file diff --git a/infra/compose/.env.example b/infra/compose/.env.example index 5ee3429a8..3d27adc53 100644 --- a/infra/compose/.env.example +++ b/infra/compose/.env.example @@ -7,8 +7,8 @@ MODEL= # LLM_ENABLE_THINKING=false # VLM (Visual Language Model) you can set it to the same as LLM if your LLM supports images -VLM_BASE_URL= VLM_API_KEY= +VLM_BASE_URL= VLM_MODEL= # Optional: same behavior as LLM_ENABLE_THINKING for VLM chat templates. # VLM_ENABLE_THINKING=false @@ -19,10 +19,18 @@ VLM_MODEL= ## FastAPI App (no need to change it) # APP_PORT=8080 # this is the forwarded port -# API_NUM_WORKERS=1 # Number of uvicorn workers for the FastAPI app +# Local compose builds run OpenRAG as a non-root user. Override this if your +# host user is not UID 1000 and bind-mounted folders are not writable. +# APP_UID=1000 +# The uvicorn path runs a single worker by design (Ray provides concurrency). +# To scale the HTTP layer, use Ray Serve: ENABLE_RAY_SERVE=true with +# RAY_SERVE_NUM_REPLICAS=N (see the Ray Serve configuration section). ## To enable API HTTP authentication via HTTPBearer # AUTH_TOKEN=sk-openrag-1234 +# If AUTH_MODE=token and AUTH_TOKEN is unset, authentication fails closed by +# default. Local open mode requires an explicit opt-in: +# ALLOW_NO_AUTH=true # DEV ONLY — never set this in production # Optional: semicolon-separated extra allowed origins # CORS_EXTRA_ORIGINS='https://app.example.com;https://other.example.com' @@ -61,6 +69,17 @@ RERANKER_MODEL=Alibaba-NLP/gte-multilingual-reranker-base # or jinaai/jina-reran # API accepts natively. Override to restrict (e.g. to ".wav" only for vLLM deployments). # TRANSCRIBER_DIRECT_UPLOAD_SUFFIXES=.wav|.flac|.ogg|.mp3|.mp4|.m4a|.webm|.mpeg|.mpga +# Object storage (MinIO, used by Milvus) — REQUIRED, no default. +# Generate strong random values, e.g. `openssl rand -hex 16`. These are shared +# between the minio service and Milvus; both must match. +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= + +# PostgreSQL — REQUIRED, no default. The compose stacks fail to start if unset. +# Generate a strong value, e.g. `openssl rand -hex 16`. +POSTGRES_PASSWORD= +# POSTGRES_USER=root + # Prompts (templates ship inside the package at openrag/prompts/templates; # set PROMPTS_DIR only to override with a custom template directory) # PROMPTS_DIR=/path/to/custom/templates @@ -72,6 +91,14 @@ RAY_task_retry_delay_ms=3000 RAY_ENABLE_UV_RUN_RUNTIME_ENV=0 # critical with the newest version of UV # # To disable worker killing # RAY_memory_monitor_refresh_ms=0 +# Connect to an external Ray cluster instead of starting an embedded one. +# When set, the app attaches to this cluster and does NOT start a local dashboard +# (the head node owns it). See docs/documentation/deploy_ray_cluster. +# RAY_ADDRESS=ray://X.X.X.X:10001 +# Interface the embedded Ray dashboard binds to. Defaults to 127.0.0.1 (loopback) +# because the dashboard/job API is unauthenticated (CVE-2023-48022). Set to 0.0.0.0 +# only when the port is firewalled or behind an auth proxy. Ignored when RAY_ADDRESS is set. +# RAY_DASHBOARD_HOST=127.0.0.1 # Indexer UI ## 1. replace X.X.X.X with localhost if launching local or with your server IP @@ -94,6 +121,15 @@ API_BASE_URL='http://X.X.X.X:APP_PORT' # Base URL of your FastAPI backe # WEBSEARCH_TOP_K=5 # Number of web results to include (default: 5) # WEBSEARCH_LANG=fr-FR # Search language/market (default: fr-FR) +# Max partitions a non-admin user may own (-1 = unlimited; admins bypass). +# MAX_PARTITIONS_PER_USER=100 + +# Rate limiting (per-worker moving window, keyed on user id then client IP) +# RATE_LIMIT_ENABLED=true +# RATE_LIMIT_DEFAULT=300/minute # all paths except those below +# RATE_LIMIT_AUTH=20/minute # /auth/* (login/callback/logout) +# RATE_LIMIT_CHAT=60/minute # /v1/* (chat completions, tools) + # MCP SERVER # Standalone Model Context Protocol server (openrag/api/mcp/server.py). # OPENRAG_MCP_SERVER_NAME="OpenRAG MCP" diff --git a/infra/compose/docker-compose.yaml b/infra/compose/docker-compose.yaml index 1a31ebfb6..6ee47bed7 100644 --- a/infra/compose/docker-compose.yaml +++ b/infra/compose/docker-compose.yaml @@ -6,11 +6,22 @@ include: x-openrag: &openrag_template image: linagoraai/openrag:latest + # Start as root so entrypoint.sh can grant GID-0 write on the bind-mounted + # writable dirs (data/, logs/, the HF cache) — which a non-root container + # can't write when Docker auto-creates them root-owned — then it immediately + # drops to the non-root app user (UID in GID 0) via setpriv before launching. + # The image's USER stays non-root for non-compose/OpenShift runs. + user: "0:0" build: # Build context is the repo root so the Dockerfile can COPY pyproject.toml, # uv.lock, openrag/, conf/ and infra/scripts/entrypoint.sh directly. context: ../.. dockerfile: infra/docker/api.Dockerfile + args: + # Local compose bind-mounts repo-owned paths (data, logs, i8n). Build the + # image with the host user's UID by default so the non-root container can + # still write those mounts; OpenShift builds keep the Dockerfile default. + APP_UID: ${APP_UID:-1000} extra_hosts: # Let containers reach services running on the Docker host via # ``host.docker.internal`` (Linux Docker >= 20.10). Useful when an OIDC @@ -24,12 +35,18 @@ x-openrag: &openrag_template - ${DATA_VOLUME:-../../data}:/app/data - ../../i8n:/app/openrag/.chainlit/translations - ${MODEL_WEIGHTS_VOLUME:-~/.cache/huggingface}:/app/model_weights # Model weights for RAG - - ../../openrag:/app/openrag # For dev mode + # Dev only: bind-mounting the source tree over the image lets host changes + # (or a writable host path) override the running code. Uncomment for local + # development; keep it off in production. + # - ../../openrag:/app/openrag # For dev mode - /$SHARED_ENV:/ray_mount/.env # Shared environment variables - - ${LOG_VOLUME:-../../logs}:/app/logs # For dev mode + - ${LOG_VOLUME:-../../logs}:/app/logs ports: - ${APP_PORT:-8080}:${APP_iPORT:-8080} - - ${RAY_DASHBOARD_PORT:-8265}:8265 # Disable when in cluster mode + # Bind the Ray dashboard to localhost only: it has no authentication and + # its job-submission API allows arbitrary code execution on the cluster, + # so it must never be reachable from outside the host. + - 127.0.0.1:${RAY_DASHBOARD_PORT:-8265}:8265 # Disable when in cluster mode - ${CHAINLIT_PORT:-8090}:${CHAINLIT_PORT:-8090} networks: default: @@ -134,7 +151,7 @@ services: rdb: image: postgres:15 environment: - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-root_password} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your .env} - POSTGRES_USER=${POSTGRES_USER:-root} volumes: - ${DB_VOLUME:-../../db}:/var/lib/postgresql/data diff --git a/infra/compose/milvus/milvus.named-volumes.yaml b/infra/compose/milvus/milvus.named-volumes.yaml index 693361349..cf48eb555 100644 --- a/infra/compose/milvus/milvus.named-volumes.yaml +++ b/infra/compose/milvus/milvus.named-volumes.yaml @@ -18,8 +18,8 @@ services: minio: image: minio/minio:RELEASE.2024-12-18T13-15-44Z environment: - MINIO_ACCESS_KEY: minioadmin - MINIO_SECRET_KEY: minioadmin + MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?Set MINIO_ACCESS_KEY in your .env} + MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?Set MINIO_SECRET_KEY in your .env} volumes: - ${MINIO_VOLUME:-minio}:/minio_data command: minio server /minio_data --console-address ":9001" @@ -32,11 +32,14 @@ services: milvus: image: milvusdb/milvus:v2.6.11 command: ["milvus", "run", "standalone"] - security_opt: - - seccomp:unconfined + # Run under Docker's default seccomp profile (do not disable syscall + # filtering). If a specific kernel needs a wider profile, supply a vetted + # custom profile rather than seccomp:unconfined. environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 + MINIO_ACCESS_KEY_ID: ${MINIO_ACCESS_KEY:?Set MINIO_ACCESS_KEY in your .env} + MINIO_SECRET_ACCESS_KEY: ${MINIO_SECRET_KEY:?Set MINIO_SECRET_KEY in your .env} volumes: - ${MILVUS_VOLUME:-milvus}:/var/lib/milvus healthcheck: diff --git a/infra/compose/milvus/milvus.yaml b/infra/compose/milvus/milvus.yaml index 968919c42..82b9616b1 100644 --- a/infra/compose/milvus/milvus.yaml +++ b/infra/compose/milvus/milvus.yaml @@ -18,8 +18,8 @@ services: minio: image: minio/minio:RELEASE.2024-12-18T13-15-44Z environment: - MINIO_ACCESS_KEY: minioadmin - MINIO_SECRET_KEY: minioadmin + MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?Set MINIO_ACCESS_KEY in your .env} + MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?Set MINIO_SECRET_KEY in your .env} volumes: - ${MILVUS_VOLUME_DIRECTORY:-./volumes}/minio:/minio_data command: minio server /minio_data --console-address ":9001" @@ -32,11 +32,16 @@ services: milvus: image: milvusdb/milvus:v2.6.11 command: ["milvus", "run", "standalone"] - security_opt: - - seccomp:unconfined + # Run under Docker's default seccomp profile (do not disable syscall + # filtering). If a specific kernel needs a wider profile, supply a vetted + # custom profile rather than seccomp:unconfined. environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 + # Milvus must authenticate to MinIO with the same credentials; otherwise + # it falls back to the built-in minioadmin default and fails to connect. + MINIO_ACCESS_KEY_ID: ${MINIO_ACCESS_KEY:?Set MINIO_ACCESS_KEY in your .env} + MINIO_SECRET_ACCESS_KEY: ${MINIO_SECRET_KEY:?Set MINIO_SECRET_KEY in your .env} volumes: - ${MILVUS_VOLUME_DIRECTORY:-./volumes}/milvus:/var/lib/milvus healthcheck: diff --git a/infra/compose/monitoring.docker-compose.yaml b/infra/compose/monitoring.docker-compose.yaml index d415825c9..1e85a6b3b 100644 --- a/infra/compose/monitoring.docker-compose.yaml +++ b/infra/compose/monitoring.docker-compose.yaml @@ -1,9 +1,10 @@ services: prometheus: - image: prom/prometheus:latest + image: prom/prometheus:v2.54.1 container_name: openrag-prometheus ports: - - "9090:9090" + # Localhost only: the Prometheus API is unauthenticated. + - "127.0.0.1:9090:9090" volumes: - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - ./prometheus/openrag_token:/etc/prometheus/openrag_token:ro @@ -11,13 +12,14 @@ services: command: - "--config.file=/etc/prometheus/prometheus.yml" - "--storage.tsdb.retention.time=30d" - - "--web.enable-lifecycle" + # --web.enable-lifecycle is intentionally NOT set: it exposes unauthenticated + # /-/reload and /-/quit endpoints. Re-enable only behind an auth proxy. extra_hosts: - "host.docker.internal:host-gateway" restart: unless-stopped grafana: - image: grafana/grafana:latest + image: grafana/grafana:11.2.2 container_name: openrag-grafana ports: - "3000:3000" @@ -34,10 +36,12 @@ services: restart: unless-stopped node-exporter: - image: prom/node-exporter:latest + image: prom/node-exporter:v1.8.2 container_name: openrag-node-exporter ports: - - "9100:9100" + # Localhost only: Prometheus scrapes it over the compose network by name, + # so it needn't be exposed on all interfaces (it surfaces host metrics). + - "127.0.0.1:9100:9100" pid: host volumes: - /proc:/host/proc:ro @@ -54,7 +58,8 @@ services: image: utkuozdemir/nvidia_gpu_exporter:1.2.0 container_name: openrag-nvidia-gpu-exporter ports: - - "9835:9835" + # Localhost only: scraped over the compose network by name. + - "127.0.0.1:9835:9835" volumes: - /usr/lib/x86_64-linux-gnu/libnvidia-ml.so:/usr/lib/x86_64-linux-gnu/libnvidia-ml.so:ro - /usr/lib/x86_64-linux-gnu/libnvidia-ml.so.1:/usr/lib/x86_64-linux-gnu/libnvidia-ml.so.1:ro diff --git a/infra/docker/api.Dockerfile b/infra/docker/api.Dockerfile index 28d68bcc6..0e3669f95 100644 --- a/infra/docker/api.Dockerfile +++ b/infra/docker/api.Dockerfile @@ -28,6 +28,24 @@ ENV HF_HUB_CACHE=${HF_HUB_CACHE:-/app/model_weights/hub} # Set workdir for uv WORKDIR /app +# Keep uv's managed Python and cache on stable, root-owned paths outside any +# user $HOME, and put the project venv under /app so it can be made +# group-writable for the arbitrary UID OpenShift assigns (see below). HOME is a +# dedicated writable subdir (not /app) so libraries that fall back to $HOME +# never need /app itself writable. UV_FROZEN keeps `uv run` from rewriting +# uv.lock at runtime, so the project root can stay read-only. +# USER/LOGNAME are set because the arbitrary UID OpenShift assigns has no +# /etc/passwd entry: getpass.getuser() reads these env vars first and so +# resolves without a passwd lookup (the same approach used for the vllm +# service in docker-compose.yaml). This avoids making /etc/passwd writable. +ENV UV_PYTHON_INSTALL_DIR=/opt/uv/python \ + UV_CACHE_DIR=/opt/uv/cache \ + UV_PROJECT_ENVIRONMENT=/app/.venv \ + UV_FROZEN=1 \ + HOME=/app/home \ + USER=openrag \ + LOGNAME=openrag + # Install uv & setup venv COPY pyproject.toml uv.lock ./ RUN pip3 install uv && \ @@ -47,4 +65,43 @@ COPY scripts/ /app/scripts/ COPY conf/ /app/conf/ ENV PYTHONPATH=/app/openrag/ ENV APP_iPORT=${APP_iPORT:-8080} + +# --- Run as an unprivileged, OpenShift-compatible user --------------------- +# OpenShift runs containers as an arbitrary, unpredictable UID that is always a +# member of the root group (GID 0). For the app to start under that policy, +# every path it writes at runtime must be group-owned by GID 0 and +# group-writable (chmod g=u). We strip group-write from the whole tree first +# (chmod -R g-w) and then grant it back ONLY on those exact paths: the venv, +# the editable install's egg-info, $HOME, data, db, logs, the HF model cache, +# and uv's cache. So /app and /opt/uv themselves, plus the copied code/config +# and uv's managed Python (/app/openrag, /app/conf, /app/scripts, +# /opt/uv/python), stay group-readable but NOT group-writable regardless of +# their source mode — the arbitrary UID cannot create, rename, or replace +# entries in them. +# The two exceptions under the otherwise read-only /app/openrag are Chainlit's +# runtime-writable dirs: it creates ./.files at import time and writes +# ./.chainlit/config.toml + translations on startup (WORKDIR is /app/openrag), +# so both are pre-created and granted group-write below or the app crashes with +# PermissionError: '/app/openrag/.files'. +# We also bake a fixed non-root UID for plain Docker/Kubernetes, where the +# arbitrary-UID remap does not happen; APP_UID is a build arg so a compose +# build can match the host user that owns the bind-mounted volumes. The user's +# primary group is 0 so it shares the same group access on either platform. +ARG APP_UID=10001 +RUN useradd --uid ${APP_UID} --gid 0 --no-log-init --no-create-home \ + --home-dir /app/home --shell /sbin/nologin openrag \ + && mkdir -p /app/home /app/data /app/db /app/logs /app/model_weights/hub \ + /app/.venv /app/openrag.egg-info /opt/uv/cache \ + /app/openrag/.files /app/openrag/.chainlit \ + && chgrp -R 0 /app /opt/uv \ + && chmod -R g-w /app /opt/uv \ + && chmod -R g=u /app/home /app/data /app/db /app/logs /app/model_weights \ + /app/.venv /app/openrag.egg-info /opt/uv/cache \ + /app/openrag/.files /app/openrag/.chainlit +# Expose APP_UID at runtime so entrypoint.sh can drop back to this user after +# fixing bind-mount permissions (when the container is started as root, e.g. +# compose `user: "0:0"`). USER below keeps the image's default non-root. +ENV APP_UID=${APP_UID} +USER ${APP_UID} + ENTRYPOINT ../entrypoint.sh diff --git a/infra/docker/ray.Dockerfile b/infra/docker/ray.Dockerfile index 250a04c68..2029065cf 100644 --- a/infra/docker/ray.Dockerfile +++ b/infra/docker/ray.Dockerfile @@ -32,6 +32,10 @@ ENV HF_HUB_CACHE=${HF_HUB_CACHE:-/app/model_weights/hub} # Set workdir for uv WORKDIR /app +# Set HOME before installing so uv's Python and cache land under /app (owned by +# the non-root user below), not /root. +ENV HOME=/app + # Install uv & setup venv COPY pyproject.toml uv.lock ./ RUN pip3 install uv && \ @@ -54,3 +58,11 @@ COPY conf/ /app/conf/ RUN ln -s /app/.venv/bin/ray /usr/local/bin/ray ENV PYTHONPATH=/app/openrag/ + +# Run as non-root. The app writes under /app (venv, data, logs, model_weights), +# so the user owns /app. +RUN groupadd --gid 10001 app \ + && useradd --uid 10001 --gid 10001 --home-dir /app --no-create-home app \ + && mkdir -p /app/data /app/logs /app/model_weights \ + && chown -R 10001:10001 /app +USER 10001:10001 diff --git a/infra/quick_start/docker-compose.yaml b/infra/quick_start/docker-compose.yaml index 9cecd3a98..010c57db2 100644 --- a/infra/quick_start/docker-compose.yaml +++ b/infra/quick_start/docker-compose.yaml @@ -16,7 +16,7 @@ x-openrag: &openrag_template # - ./logs:/app/logs ports: - ${APP_PORT:-8080}:${APP_iPORT:-8080} - - ${RAY_DASHBOARD_PORT:-8265}:8265 # Disable when in cluster mode + - 127.0.0.1:${RAY_DASHBOARD_PORT:-8265}:8265 # Localhost only: Ray dashboard/Jobs API is unauthenticated. Disable when in cluster mode networks: default: aliases: @@ -105,7 +105,7 @@ services: rdb: image: postgres:15 environment: - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-root_password} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your .env} - POSTGRES_USER=${POSTGRES_USER:-root} volumes: - ${DB_VOLUME:-./db}:/var/lib/postgresql/data diff --git a/infra/quick_start/vdb/milvus.yaml b/infra/quick_start/vdb/milvus.yaml index 6a9f37e0c..b5743fde5 100644 --- a/infra/quick_start/vdb/milvus.yaml +++ b/infra/quick_start/vdb/milvus.yaml @@ -18,8 +18,8 @@ services: minio: image: minio/minio:RELEASE.2023-03-20T20-16-18Z environment: - MINIO_ACCESS_KEY: minioadmin - MINIO_SECRET_KEY: minioadmin + MINIO_ROOT_USER: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER in your .env} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD in your .env} volumes: - ${MILVUS_VOLUME_DIRECTORY:-./volumes}/minio:/minio_data command: minio server /minio_data --console-address ":9001" @@ -32,11 +32,13 @@ services: milvus: image: milvusdb/milvus:v2.5.4 command: ["milvus", "run", "standalone"] - security_opt: - - seccomp:unconfined + # Run under Docker's default seccomp profile (do not disable syscall filtering). environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 + # Keep Milvus's MinIO credentials in sync with the minio service above. + MINIO_ACCESS_KEY_ID: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER in your .env} + MINIO_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD in your .env} volumes: - ${MILVUS_VOLUME_DIRECTORY:-./volumes}/milvus:/var/lib/milvus healthcheck: @@ -49,4 +51,4 @@ services: # - "${VDB_PORT:-19530}:${VDB_iPORT:-19530}" depends_on: - "etcd" - - "minio" \ No newline at end of file + - "minio" diff --git a/infra/scripts/entrypoint.sh b/infra/scripts/entrypoint.sh index 5fcdf4fb5..5b7b8cdb8 100644 --- a/infra/scripts/entrypoint.sh +++ b/infra/scripts/entrypoint.sh @@ -1,13 +1,54 @@ #!/bin/bash -ENV_ARG="" + +# --- Writable-dir permission fix + privilege drop -------------------------- +# The app runs as a non-root user whose primary group is GID 0. Its writable +# dirs (/app/data, /app/logs, /app/model_weights) are bind-mounted from the +# host, so the image's group-0-writable perms (Dockerfile `chmod g=u`) don't +# apply to them — the host directory's ownership does, and Docker creates a +# missing bind source as root:root with no group-write. That makes the +# non-root app fail with "Permission denied" on upload/logging/model download. +# +# To make bind mounts "just work" with no host-side chmod: when started as +# root (compose sets `user: "0:0"` on the service), grant GID-0 write on those +# dirs — mirroring the image's own `chmod g=u` — then drop to the non-root app +# user and continue. When NOT root (OpenShift assigns an arbitrary non-root +# UID in GID 0; or a plain non-root run), skip the fix: the platform's GID-0 +# membership plus the image's group-writable paths already cover it. +APP_UID="${APP_UID:-10001}" +if [ "$(id -u)" = "0" ]; then + for d in /app/data /app/logs /app/model_weights; do + mkdir -p "$d" 2>/dev/null || true + chgrp -R 0 "$d" 2>/dev/null || true + chmod -R g+rwX "$d" 2>/dev/null || true + done + # Re-exec this script as the non-root app user (UID in GID 0) to run the app. + exec setpriv --reuid "$APP_UID" --regid 0 --clear-groups /app/entrypoint.sh "$@" +fi + +ENV_ARGS=() if [[ -n "${SHARED_ENV}" ]]; then - ENV_ARG="--env-file=${SHARED_ENV}" + ENV_ARGS+=("--env-file=${SHARED_ENV}") fi if [[ "${ENABLE_RAY_SERVE}" == "true" ]]; then echo "🔁 Starting with Ray Serve..." - uv run $ENV_ARG -m api.main + uv run --no-dev "${ENV_ARGS[@]}" -m api.main else echo "🚀 Starting with Uvicorn..." - uv run --no-dev $ENV_ARG uvicorn api.main:app --host 0.0.0.0 --port ${APP_iPORT:-8080} --reload --workers ${API_NUM_WORKERS:-1} + # This path always runs a SINGLE uvicorn worker. The app initializes Ray and + # its named actors (Indexer, Vectordb, TaskStateManager, ...) at import time, + # so each extra worker would be a separate process starting its own isolated + # Ray cluster with duplicate actors — fragmenting task state and the vector + # DB. Concurrency comes from the async app + Ray, not from uvicorn workers. + # To scale the HTTP layer horizontally, use Ray Serve (ENABLE_RAY_SERVE=true, + # RAY_SERVE_NUM_REPLICAS=N), which runs N replicas inside one Ray cluster. + if [[ -n "${API_NUM_WORKERS}" && "${API_NUM_WORKERS}" != "1" ]]; then + echo "⚠️ API_NUM_WORKERS=${API_NUM_WORKERS} is ignored: this app runs a single uvicorn worker (Ray provides concurrency). To scale, set ENABLE_RAY_SERVE=true with RAY_SERVE_NUM_REPLICAS." >&2 + fi + # --reload is dev-only (set UVICORN_RELOAD=true); it also forces a single worker. + RELOAD_ARGS=() + if [[ "${UVICORN_RELOAD}" == "true" ]]; then + RELOAD_ARGS+=("--reload") + fi + uv run --no-dev "${ENV_ARGS[@]}" uvicorn api.main:app --host 0.0.0.0 --port "${APP_iPORT:-8080}" "${RELOAD_ARGS[@]}" --workers 1 fi diff --git a/openrag/api/dependencies/auth.py b/openrag/api/dependencies/auth.py index a0523b694..fdfc4412f 100644 --- a/openrag/api/dependencies/auth.py +++ b/openrag/api/dependencies/auth.py @@ -1,5 +1,6 @@ import os +from core.indexing.validators import validate_partition_name from core.utils.exceptions import OpenRAGError from core.utils.logging import get_logger from di.providers import get_auth_service, get_config, get_job_service, get_partition_service @@ -62,6 +63,8 @@ async def ensure_partition_role( partition_service, ): """Ensure the user has at least `required_role` for the partition.""" + # Reject crafted partition names before they reach any filter expression. + validate_partition_name(partition) if SUPER_ADMIN_MODE and user.get("is_admin"): return True @@ -72,7 +75,18 @@ async def ensure_partition_role( status_code=status.HTTP_403_FORBIDDEN, detail=f"Access to partition '{partition}' forbidden", ) - return True + # Partition does not exist. The only legitimate reason a non-member may + # act on a missing partition is the create-on-write path (file upload), + # which is `editor` and which later creates the partition with the + # uploader as owner. Reading or owning a partition that does not exist + # must NOT silently succeed, or a non-member could pass an owner/viewer + # check by naming an unknown partition. + if required_role == "editor": + return True + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Partition '{partition}' not found", + ) try: auth_service.check_partition_access( @@ -155,6 +169,11 @@ async def require_partitions_viewer( if SUPER_ADMIN_MODE and user.get("is_admin"): return user if isinstance(partitions, list) and len(partitions) == 1 and partitions[0] == "all": + if not user_partitions: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="No accessible partitions", + ) return user for partition in partitions: await ensure_partition_role( diff --git a/openrag/api/dependencies/llm.py b/openrag/api/dependencies/llm.py index 9b12630c1..7d6df74cb 100644 --- a/openrag/api/dependencies/llm.py +++ b/openrag/api/dependencies/llm.py @@ -101,6 +101,11 @@ async def get_partition_name( detail=f"Access to model `{model_name}` is forbidden for the current user", ) if partition == "all" and not (is_admin and SUPER_ADMIN_MODE): + if not user_partitions: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="No accessible partitions", + ) return user_partitions return [partition] diff --git a/openrag/api/main.py b/openrag/api/main.py index 2da2858e3..b9a2e5de3 100644 --- a/openrag/api/main.py +++ b/openrag/api/main.py @@ -25,7 +25,6 @@ from contextlib import asynccontextmanager from enum import Enum from importlib.metadata import version as get_package_version -from pathlib import Path import ray import uvicorn @@ -34,6 +33,7 @@ from api.middleware import ( AuthMiddleware, InstrumentationMiddleware, + RateLimitMiddleware, RequestIdMiddleware, RequestTimeoutMiddleware, ) @@ -50,6 +50,7 @@ from api.routers.auth.oidc import router as auth_router from api.routers.user.chat import prime_max_model_tokens from api.routers.user.chat import router as openai_router +from api.routers.user.download import router as download_router from api.routers.user.extract import router as extract_router from api.routers.user.health import router as health_router from api.routers.user.search import router as search_router @@ -63,7 +64,6 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.utils import get_openapi from fastapi.responses import JSONResponse, RedirectResponse -from fastapi.staticfiles import StaticFiles # pydub 0.25.1 ships invalid-escape regex literals; the warning is upstream. warnings.filterwarnings("ignore", category=SyntaxWarning, module="pydub") @@ -75,7 +75,6 @@ logger = get_logger() settings = load_config() -DATA_DIR = Path(settings.paths.data_dir) CONTAINER_STARTUP_TIMEOUT = float( os.getenv("OPENRAG_CONTAINER_STARTUP_TIMEOUT", max(60, settings.rdb.command_timeout * 4)) ) @@ -150,7 +149,21 @@ async def lifespan(app: FastAPI): """ if not ray.is_initialized(): logger.info("Startup: initializing Ray") - ray.init(dashboard_host="0.0.0.0", ignore_reinit_error=True) + _ray_address = os.environ.get("RAY_ADDRESS") + if _ray_address: + # Connect to an external Ray cluster (e.g. a dedicated ray-head + # container). No local dashboard is started — the head node owns it. + ray.init(address=_ray_address, ignore_reinit_error=True) + else: + # Embedded mode: start a local Ray cluster inside this process. + # Bind the Ray dashboard to localhost by default; the dashboard / + # Jobs API is unauthenticated (CVE-2023-48022 "ShadowRay") so it + # must never listen on a routable interface. Operators that front it + # with an auth proxy can override via RAY_DASHBOARD_HOST. + ray.init( + dashboard_host=os.environ.get("RAY_DASHBOARD_HOST", "127.0.0.1"), + ignore_reinit_error=True, + ) logger.info("Startup: Ray is initialized") # ``ensure_worker_bootstrap`` imports ``services.workers.bootstrap`` @@ -253,6 +266,10 @@ def custom_openapi(): # and Instrumentation captures the full request duration including the # auth check. CORS is added later and ends up outside this stack so # preflights short-circuit before any instrumentation runs. +# RateLimitMiddleware is registered BEFORE AuthMiddleware so it executes AFTER +# it (registration is reverse of execution), letting it key limits on the +# authenticated user id that AuthMiddleware just put on request.state. +app.add_middleware(RateLimitMiddleware) app.add_middleware( AuthMiddleware, get_auth_service=lambda request: request.app.state.container.auth_service, @@ -282,8 +299,6 @@ def custom_openapi(): allow_headers=["*"], ) -app.mount("/static", StaticFiles(directory=DATA_DIR.resolve(), check_dir=True), name="static") - @app.get("/", include_in_schema=False) def root_redirect(): @@ -316,6 +331,8 @@ def get_config(): app.include_router(health_router, tags=[Tags.MONITORING]) app.include_router(indexer_router, prefix="/indexer", tags=[Tags.INDEXER]) app.include_router(extract_router, prefix="/extract", tags=[Tags.EXTRACT]) +# Authorized, partition-checked source-file download (served under /static). +app.include_router(download_router, tags=[Tags.EXTRACT]) app.include_router(search_router, prefix="/search", tags=[Tags.SEARCH]) app.include_router(partition_router, prefix="/partition", tags=[Tags.PARTITION]) app.include_router(model_endpoints_router, prefix="/model-endpoints", tags=[Tags.MODEL_ENDPOINTS]) @@ -345,11 +362,26 @@ def get_config(): if settings.ray.serve.enable: from ray import serve + # @serve.ingress cloudpickles `app` to ship it to replica processes. + # loguru's file sink isn't picklable (an open file handle, and with + # enqueue=True a multiprocessing.SimpleQueue that errors with "SimpleQueue + # objects should only be shared between processes through inheritance"), + # and the app graph (lifespan, exception handlers) captures the + # module-global logger by value. Strip the sinks before binding so the + # captured logger is handler-less (picklable), then restore them for this + # driver process below. Replica processes re-add their own sinks via the + # module-level get_logger() that runs on import — matching loguru's + # multiprocessing model, where handlers are per-process and never travel + # through a pickle. + logger.remove() + @serve.deployment(num_replicas=settings.ray.serve.num_replicas) @serve.ingress(app) class OpenRagAPI: """Ray Serve deployment wrapper for the FastAPI app.""" + get_logger() # restore this driver's logging now that `app` is serialized + serve.start(http_options={"host": settings.ray.serve.host, "port": settings.ray.serve.port}) if WITH_CHAINLIT_UI: from chainlit_api import app as chainlit_app diff --git a/openrag/api/mcp/server.py b/openrag/api/mcp/server.py index acd4d09f1..905185495 100644 --- a/openrag/api/mcp/server.py +++ b/openrag/api/mcp/server.py @@ -77,7 +77,18 @@ def _service(): async def _startup() -> None: global _container if not ray.is_initialized(): - ray.init(dashboard_host="0.0.0.0", ignore_reinit_error=True) + _ray_address = os.environ.get("RAY_ADDRESS") + if _ray_address: + # Attach to an external Ray cluster; the head node owns the dashboard. + ray.init(address=_ray_address, ignore_reinit_error=True) + else: + # Bind the Ray dashboard to localhost by default; it is + # unauthenticated (CVE-2023-48022). Override via RAY_DASHBOARD_HOST + # behind an auth proxy. + ray.init( + dashboard_host=os.environ.get("RAY_DASHBOARD_HOST", "127.0.0.1"), + ignore_reinit_error=True, + ) ensure_worker_bootstrap() container = ServiceContainer(config) try: @@ -108,10 +119,11 @@ class MCPAuthContextMiddleware(BaseHTTPMiddleware): """Resolve the bearer-token principal and set the MCP auth context. Mirrors the token-mode contract of ``api.middleware.AuthMiddleware``: - when ``AUTH_TOKEN`` is unset the server runs in dev mode and every call - acts as admin user 1; otherwise a valid ``Authorization: Bearer`` is - required. Allowed partitions follow ``current_user_or_admin_partitions_list`` - (``["all"]`` for admins under ``SUPER_ADMIN_MODE``). + when ``AUTH_TOKEN`` is unset and ``ALLOW_NO_AUTH=true`` the server runs in + dev mode and every call acts as admin user 1; otherwise a valid + ``Authorization: Bearer`` is required. Allowed partitions follow + ``current_user_or_admin_partitions_list`` (``["all"]`` for admins under + ``SUPER_ADMIN_MODE``). """ async def _resolve_principal(self, request: Request) -> tuple[int | None, bool, list[str] | None]: @@ -124,7 +136,12 @@ async def _resolve_principal(self, request: Request) -> tuple[int | None, bool, # In OIDC mode AUTH_TOKEN is commonly unset; without this gate the MCP # endpoint would silently treat every caller as admin (auth bypass), so # a valid bearer (users.token) is always required there. - if auth_mode == "token" and auth_token is None: + allow_no_auth = os.getenv("ALLOW_NO_AUTH", "false").strip().lower() == "true" + if auth_mode == "token" and auth_token is None and allow_no_auth: + logger.warning( + "ALLOW_NO_AUTH=true and AUTH_TOKEN is unset: MCP authentication is DISABLED — " + "every request is treated as admin user 1. Never use this in production." + ) user = await auth_service.get_user_for_request(1) else: header = request.headers.get("authorization", "") @@ -428,6 +445,7 @@ async def index_url(url: str, partition: str, file_id: str, extra_metadata: dict file_id=file_id, allowed_partitions=get_allowed_partitions(), user_id=get_user_id(), + is_admin=is_admin(), extra_metadata=extra_metadata, ) diff --git a/openrag/api/middleware/__init__.py b/openrag/api/middleware/__init__.py index a69968939..44dcb10f6 100644 --- a/openrag/api/middleware/__init__.py +++ b/openrag/api/middleware/__init__.py @@ -6,12 +6,14 @@ from api.middleware.auth import AuthMiddleware from api.middleware.instrumentation import InstrumentationMiddleware +from api.middleware.rate_limit import RateLimitMiddleware from api.middleware.request_id import REQUEST_ID_HEADER, RequestIdMiddleware from api.middleware.request_timeout import RequestTimeoutMiddleware __all__ = [ "AuthMiddleware", "InstrumentationMiddleware", + "RateLimitMiddleware", "RequestIdMiddleware", "REQUEST_ID_HEADER", "RequestTimeoutMiddleware", diff --git a/openrag/api/middleware/auth.py b/openrag/api/middleware/auth.py index b94fa748c..a5cbef119 100644 --- a/openrag/api/middleware/auth.py +++ b/openrag/api/middleware/auth.py @@ -30,6 +30,7 @@ from __future__ import annotations import os +import time from collections.abc import Callable from typing import Any from urllib.parse import quote @@ -38,6 +39,9 @@ from core.utils.logging import get_logger from fastapi import Request from fastapi.responses import JSONResponse, RedirectResponse +from limits import parse +from limits.aio.storage import MemoryStorage +from limits.aio.strategies import MovingWindowRateLimiter from starlette.middleware.base import BaseHTTPMiddleware logger = get_logger() @@ -83,6 +87,78 @@ def is_bypass_path(path: str, *, bypass_config: AuthBypassConfig | None = None) return path in cfg.bypass_paths or path == "/chainlit" or path.startswith("/chainlit/") +def _allow_no_auth() -> bool: + """Whether the no-auth dev bypass (AUTH_TOKEN unset → admin) is allowed. + + Read lazily so it can be toggled per-test, mirroring the other env reads. + """ + return os.getenv("ALLOW_NO_AUTH", "false").strip().lower() == "true" + + +def _env_flag(name: str, default: bool) -> bool: + val = os.environ.get(name) + if val is None: + return default + return val.strip().lower() in ("1", "true", "yes", "on") + + +class AuthFailureRateLimiter: + """Limit failed authentication attempts by client IP.""" + + def __init__(self) -> None: + self.enabled = _env_flag("RATE_LIMIT_ENABLED", True) + self._limiter = MovingWindowRateLimiter(MemoryStorage()) + self._limit = ( + parse(os.environ.get("RATE_LIMIT_AUTH_FAILURE", os.environ.get("RATE_LIMIT_AUTH", "20/minute"))) + if self.enabled + else None + ) + + @staticmethod + def _identity(request: Request) -> str: + client = request.client + return f"ip:{client.host}" if client else "ip:unknown" + + @staticmethod + def _safe_log_value(value: str, *, max_len: int = 200) -> str: + return value.replace("\r", "\\r").replace("\n", "\\n").replace("\x00", "\\0")[:max_len] + + async def response_if_limited(self, request: Request) -> JSONResponse | None: + if not self.enabled or self._limit is None: + return None + identity = self._identity(request) + tier = "auth-failure" + allowed = await self._limiter.test(self._limit, tier, identity) + if allowed: + return None + return await self._limited_response(request, identity) + + async def record_failure(self, request: Request) -> JSONResponse | None: + if not self.enabled or self._limit is None: + return None + identity = self._identity(request) + tier = "auth-failure" + allowed = await self._limiter.hit(self._limit, tier, identity) + if allowed: + return None + return await self._limited_response(request, identity) + + async def _limited_response(self, request: Request, identity: str) -> JSONResponse: + assert self._limit is not None + tier = "auth-failure" + stats = await self._limiter.get_window_stats(self._limit, tier, identity) + retry_after = max(1, int(stats.reset_time - time.time())) + logger.bind( + path=self._safe_log_value(str(request.url.path)), + identity=self._safe_log_value(identity), + ).warning("Auth failure rate limit exceeded") + return JSONResponse( + status_code=429, + content={"detail": "Rate limit exceeded. Please retry later.", "extra": {}}, + headers={"Retry-After": str(retry_after)}, + ) + + class AuthMiddleware(BaseHTTPMiddleware): """FastAPI middleware enforcing authentication for both token and oidc modes. @@ -102,6 +178,13 @@ def __init__( super().__init__(app) self._get_auth_service = get_auth_service self._bypass_config = bypass_config or AuthBypassConfig() + self._auth_failure_limiter = AuthFailureRateLimiter() + + async def _auth_failure(self, request: Request, *, status_code: int, detail: str) -> JSONResponse: + limited = await self._auth_failure_limiter.record_failure(request) + if limited is not None: + return limited + return JSONResponse(status_code=status_code, content={"detail": detail}) async def dispatch(self, request: Request, call_next): # Read env lazily so tests can flip AUTH_MODE per-test. @@ -110,7 +193,16 @@ async def dispatch(self, request: Request, call_next): enc_key = os.getenv("OIDC_TOKEN_ENCRYPTION_KEY") or "" # --- Dev mode: AUTH_MODE=token + AUTH_TOKEN unset → user 1 bypass. - if auth_mode == "token" and auth_token is None: + # This disables authentication entirely (every request becomes the + # admin user), so it is gated behind an explicit ALLOW_NO_AUTH=true + # opt-in. Without that flag, an unset AUTH_TOKEN does not grant + # access: requests fall through to normal Bearer auth and + # unauthenticated callers get 401. + if auth_mode == "token" and auth_token is None and _allow_no_auth(): + logger.warning( + "ALLOW_NO_AUTH=true and AUTH_TOKEN is unset: authentication is DISABLED — " + "every request is treated as admin user 1. Never use this in production." + ) auth_service = self._get_auth_service(request) user = await auth_service.get_user_for_request(1) user_partitions = await auth_service.list_user_partitions_for_request(1) @@ -121,7 +213,17 @@ async def dispatch(self, request: Request, call_next): # --- Bypass list (docs, health, /auth/* callbacks, chainlit). path = request.url.path - if is_bypass_path(path, bypass_config=self._bypass_config): + + # In oidc mode the interactive API docs are gated behind login rather + # than served anonymously: they expose the full route + schema surface, + # and an authenticated browser session still reaches them after the IdP + # redirect. In token mode they stay public — a browser has no login flow + # to bounce through — preserving the legacy bypass contract. ``oidc_gated`` + # therefore suppresses the bypass for these paths only when AUTH_MODE=oidc, + # so they fall through to the normal auth + UI-redirect path below. + oidc_gated = auth_mode == "oidc" and path in self._bypass_config.oidc_gated_paths + + if not oidc_gated and is_bypass_path(path, bypass_config=self._bypass_config): # Special case: browser HTML page-loads on /chainlit/* without an # active session can't be served usefully — Chainlit configures no # in-app auth provider when headerAuth is used, so the SPA shows @@ -138,9 +240,19 @@ async def dispatch(self, request: Request, call_next): cookie_token = request.cookies.get(SESSION_COOKIE_NAME) session_valid = False if cookie_token: - auth_service = self._get_auth_service(request) - session = await auth_service.get_oidc_session_by_token_for_request(cookie_token) - session_valid = session is not None + # Never let a session-lookup failure (e.g. a degraded boot + # where the container's pool was never opened) surface as a + # 500 on a plain page load — treat it as no session and let + # the redirect below send the browser through /auth/login. + try: + auth_service = self._get_auth_service(request) + session = await auth_service.get_oidc_session_by_token_for_request(cookie_token) + session_valid = session is not None + except Exception as e: + logger.bind(error=str(e)).warning( + "OIDC session check failed on chainlit bypass; redirecting to login" + ) + session_valid = False if not session_valid: next_path = path if request.url.query: @@ -151,6 +263,10 @@ async def dispatch(self, request: Request, call_next): ) return await call_next(request) + limited = await self._auth_failure_limiter.response_if_limited(request) + if limited is not None: + return limited + user = None session = None try: @@ -222,14 +338,15 @@ async def dispatch(self, request: Request, call_next): user = await auth_service.get_user_by_token_for_request(token) if not user and auth_mode == "token": # Legacy test contract: robot suite asserts 403 + "Invalid token". - return JSONResponse(status_code=403, content={"detail": "Invalid token"}) + return await self._auth_failure(request, status_code=403, detail="Invalid token") elif auth_mode == "token": # Token mode: no cookie + no bearer → legacy 403 "Missing token". - return JSONResponse(status_code=403, content={"detail": "Missing token"}) + return await self._auth_failure(request, status_code=403, detail="Missing token") - # --- 3) Unauthenticated: redirect UI in oidc mode, else 401 JSON. + # --- 3) Unauthenticated: redirect UI (and the oidc-gated docs) in oidc + # mode, else 401 JSON. ``oidc_gated`` is already mode-checked. if user is None: - if auth_mode == "oidc" and is_ui_path(path, bypass_config=self._bypass_config): + if oidc_gated or (auth_mode == "oidc" and is_ui_path(path, bypass_config=self._bypass_config)): next_path = path if request.url.query: next_path = f"{path}?{request.url.query}" @@ -237,7 +354,7 @@ async def dispatch(self, request: Request, call_next): url=f"/auth/login?next={quote(next_path, safe='')}", status_code=302, ) - return JSONResponse(status_code=401, content={"detail": "Unauthenticated"}) + return await self._auth_failure(request, status_code=401, detail="Unauthenticated") # --- Happy path: user resolved. request.state.user = user diff --git a/openrag/api/middleware/rate_limit.py b/openrag/api/middleware/rate_limit.py new file mode 100644 index 000000000..8574839b5 --- /dev/null +++ b/openrag/api/middleware/rate_limit.py @@ -0,0 +1,87 @@ +"""Per-identity request rate limiting, tiered by path. + +Keyed on the authenticated user, or the client IP for unauthenticated paths. +Runs after AuthMiddleware (registered before it in ``api.main`` so it executes +after auth populates ``request.state.user``). Limits are per-worker; use a Redis +storage to share them across workers. + +Env: RATE_LIMIT_ENABLED (true), RATE_LIMIT_DEFAULT (300/minute), +RATE_LIMIT_AUTH (20/minute, /auth/*), RATE_LIMIT_CHAT (60/minute, /v1/*). +""" + +import os +import time + +from core.utils.logging import get_logger +from limits import parse +from limits.aio.storage import MemoryStorage +from limits.aio.strategies import MovingWindowRateLimiter +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse + +logger = get_logger() + + +def _env_flag(name: str, default: bool) -> bool: + val = os.environ.get(name) + if val is None: + return default + return val.strip().lower() in ("1", "true", "yes", "on") + + +class RateLimitMiddleware(BaseHTTPMiddleware): + """Apply per-identity moving-window rate limits, tiered by path prefix.""" + + def __init__(self, app): + super().__init__(app) + self.enabled = _env_flag("RATE_LIMIT_ENABLED", True) + self._limiter = MovingWindowRateLimiter(MemoryStorage()) + self._default = parse(os.environ.get("RATE_LIMIT_DEFAULT", "300/minute")) + self._auth = parse(os.environ.get("RATE_LIMIT_AUTH", "20/minute")) + self._chat = parse(os.environ.get("RATE_LIMIT_CHAT", "60/minute")) + if self.enabled: + logger.info( + "Rate limiting enabled", + default=str(self._default), + auth=str(self._auth), + chat=str(self._chat), + ) + + def _limit_for(self, path: str): + if path.startswith("/auth/"): + return self._auth, "auth" + if path.startswith("/v1/"): + return self._chat, "chat" + return self._default, "default" + + @staticmethod + def _identity(request: Request) -> str: + # user is a dict set by AuthMiddleware; fall back to client IP. + user = getattr(request.state, "user", None) + user_id = user.get("id") if isinstance(user, dict) else None + if user_id is not None: + return f"user:{user_id}" + client = request.client + return f"ip:{client.host}" if client else "ip:unknown" + + async def dispatch(self, request: Request, call_next): + if not self.enabled: + return await call_next(request) + + path = request.url.path + limit, tier = self._limit_for(path) + identity = self._identity(request) + + # Key by tier so each tier has its own budget. + allowed = await self._limiter.hit(limit, tier, identity) + if not allowed: + stats = await self._limiter.get_window_stats(limit, tier, identity) + retry_after = max(1, int(stats.reset_time - time.time())) + logger.warning("Rate limit exceeded", path=path, tier=tier, identity=identity) + return JSONResponse( + status_code=429, + content={"detail": "Rate limit exceeded. Please retry later.", "extra": {}}, + headers={"Retry-After": str(retry_after)}, + ) + return await call_next(request) diff --git a/openrag/api/routers/admin/indexing.py b/openrag/api/routers/admin/indexing.py index 64b640450..ec06a41a3 100644 --- a/openrag/api/routers/admin/indexing.py +++ b/openrag/api/routers/admin/indexing.py @@ -17,6 +17,7 @@ from api.dependencies.auth import ( check_user_file_quota, + current_user, current_user_partitions, ensure_partition_role, require_partition_editor, @@ -138,10 +139,12 @@ async def add_file( try: file_path = await save_file_to_disk(file, Path(config.paths.data_dir), with_random_prefix=True) except Exception as e: + # Log the full error server-side; return a generic message so we don't + # leak filesystem paths or internals to the client. logger.exception("Failed to save file to disk.", error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=str(e), + detail="Failed to save uploaded file.", ) parsed_workspace_ids = None @@ -373,6 +376,10 @@ async def copy_file_between_partitions( auth_service=Depends(get_auth_service), partition_service=Depends(get_partition_service), ): + # source_file_id arrives as a form field (not a path param with the + # validate_file_id dependency), so validate it here against the same safe + # identifier allowlist before it reaches a Milvus filter expression. + await validate_file_id(source_file_id) # Make sure user has access to the source partition await ensure_partition_role( partition=source_partition, @@ -462,6 +469,7 @@ async def get_task_error( task_id: str, task_details=Depends(require_task_owner), service=Depends(get_indexing_service), + user=Depends(current_user), ): error = await service.get_task_error(task_id) if error is None: @@ -469,7 +477,11 @@ async def get_task_error( status_code=status.HTTP_404_NOT_FOUND, detail=f"No error found for task '{task_id}'.", ) - return {"task_id": task_id, "traceback": error.splitlines()} + # The raw traceback exposes filesystem paths and internals; only return it + # to admins. Task owners get a generic failure indicator. + if user and user.get("is_admin", False): + return {"task_id": task_id, "traceback": error.splitlines()} + return {"task_id": task_id, "traceback": ["Task failed. Contact an administrator for details."]} @router.get( diff --git a/openrag/api/routers/admin/partitions.py b/openrag/api/routers/admin/partitions.py index 728496245..7d35998db 100644 --- a/openrag/api/routers/admin/partitions.py +++ b/openrag/api/routers/admin/partitions.py @@ -9,6 +9,7 @@ via ``HTTPException``. """ +import os from typing import Literal from urllib.parse import quote @@ -17,8 +18,11 @@ require_partition_owner, require_partition_viewer, ) +from api.dependencies.files import validate_file_id from api.schemas.admin.partition_schemas import PartitionDetailResponse, UpdatePartitionRequest +from core.utils.exceptions import ConfigError from core.utils.logging import get_logger +from core.utils.partition_limits import max_partitions_for_user from di.providers import get_partition_service from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, Response, status from fastapi.responses import JSONResponse @@ -172,8 +176,8 @@ def process_file(file_dict): async def get_file( request: Request, partition: str, - file_id: str, limit: int = Query(default=2000, ge=0), + file_id: str = Depends(validate_file_id), partition_viewer=Depends(require_partition_viewer), service=Depends(get_partition_service), ): @@ -274,8 +278,26 @@ async def create_partition( status_code=status.HTTP_409_CONFLICT, detail=f"Partition '{partition}' already exists.", ) - user_id = request.state.user["id"] - await service.create_partition(partition=partition, user_id=user_id) + user = request.state.user + user_id = user["id"] + # Cap how many partitions a non-admin may own so an authenticated user can't + # exhaust storage/metadata. None bypasses the cap (admins); a negative + # MAX_PARTITIONS_PER_USER also disables it. The service raises a 403 + # (PARTITION_LIMIT_EXCEEDED) when the cap is reached. + try: + max_owned = max_partitions_for_user(user) + except ConfigError as exc: + logger.bind( + max_partitions_per_user=os.environ.get("MAX_PARTITIONS_PER_USER"), + user_id=user_id, + is_admin=bool(user.get("is_admin")), + partition=partition, + ).error("Invalid MAX_PARTITIONS_PER_USER value") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=exc.message, + ) from exc + await service.create_partition(partition=partition, user_id=user_id, max_owned=max_owned) return Response(status_code=status.HTTP_201_CREATED) @@ -534,8 +556,8 @@ async def get_related_files( ) async def get_file_ancestors( partition: str, - file_id: str, max_ancestor_depth: int | None = None, + file_id: str = Depends(validate_file_id), partition_viewer=Depends(require_partition_viewer), service=Depends(get_partition_service), ): diff --git a/openrag/api/routers/auth/oidc.py b/openrag/api/routers/auth/oidc.py index ddae2109f..770829241 100644 --- a/openrag/api/routers/auth/oidc.py +++ b/openrag/api/routers/auth/oidc.py @@ -182,12 +182,37 @@ async def backchannel_logout( # --------------------------------------------------------------------------- +def _is_csrf_safe_navigation(request: Request) -> bool: + """Reject the silent logout-CSRF vector while keeping the redirect-based UX. + + Logout must remain reachable via top-level GET navigation (the OIDC + RP-initiated logout redirects the browser to the IdP), so we can't simply + require POST. Instead we use the Fetch Metadata headers: a forged request + from ````/``