Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion openrag/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,9 +327,10 @@ def get_config():
instead of assuming every admin has partition access.
"""
from api.dependencies.auth import SUPER_ADMIN_MODE
from core.utils.redaction import redact_secrets
from fastapi.encoders import jsonable_encoder

return {**jsonable_encoder(settings), "super_admin_mode": SUPER_ADMIN_MODE}
return {**redact_secrets(jsonable_encoder(settings)), "super_admin_mode": SUPER_ADMIN_MODE}


# Router mounts. Phase 10F finished moving these into
Expand Down
41 changes: 39 additions & 2 deletions openrag/api/routers/admin/model_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,23 @@
CreateModelEndpointRequest,
ModelEndpointResponse,
ModelEndpointType,
RevealApiKeyResponse,
UpdateModelEndpointRequest,
ValidateEndpointRequest,
ValidateEndpointResponse,
)
from core.config.model_endpoints import ModelEndpointRow
from core.utils.logging import get_logger
from di.providers import get_model_endpoint_service
from fastapi import APIRouter, Depends, Response, status
from fastapi import APIRouter, Depends, HTTPException, Response, status

router = APIRouter(dependencies=[Depends(require_admin)])
logger = get_logger()


def _same_endpoint_url(left: str, right: str) -> bool:
"""Compare endpoint URLs after the schema-level normalization rules."""
return left.strip().rstrip("/") == right.strip().rstrip("/")


@router.post(
Expand Down Expand Up @@ -97,17 +105,46 @@ async def set_default_model_endpoint(
return await service.get_model_endpoint(name=name, model_type=model_type)


@router.post("/{model_type}/{name}/reveal-api-key", response_model=RevealApiKeyResponse)
async def reveal_model_endpoint_api_key(
model_type: ModelEndpointType,
name: str,
service=Depends(get_model_endpoint_service),
):
"""Return the stored API key only after an explicit admin reveal action."""
endpoint = await service.get_model_endpoint(name=name, model_type=model_type)
api_key = endpoint.extra.get("api_key")
logger.bind(
model_type=model_type,
name=name,
has_api_key=isinstance(api_key, str),
).info("Model endpoint API key revealed.")
return {"api_key": api_key if isinstance(api_key, str) else None}


@router.post("/validate", response_model=ValidateEndpointResponse)
async def validate_endpoint_draft(
body: ValidateEndpointRequest,
service=Depends(get_model_endpoint_service),
):
"""Probe arbitrary endpoint values (before they are saved) for reachability
and model availability."""
api_key = body.api_key
if api_key is None and body.stored_api_key_model_type and body.stored_api_key_name:
endpoint = await service.get_model_endpoint(
name=body.stored_api_key_name,
model_type=body.stored_api_key_model_type,
)
if not _same_endpoint_url(body.endpoint, endpoint.endpoint):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Stored API key can only be reused with its saved endpoint URL.",
)
api_key = endpoint.extra.get("api_key")
return await service.validate_endpoint(
url=body.endpoint,
model_name=body.model_name,
api_key=body.api_key,
api_key=api_key,
)


Expand Down
46 changes: 46 additions & 0 deletions openrag/api/schemas/admin/model_endpoint_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from datetime import datetime
from typing import Any, Literal

from core.utils.redaction import redact_secret_mapping
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

ModelEndpointType = Literal["embedder", "reranker", "llm", "vlm"]
Expand Down Expand Up @@ -98,17 +99,55 @@ class ModelEndpointResponse(BaseModel):
batch_size: int
timeout: float
extra: dict[str, Any]
has_api_key: bool = False
is_default: bool
created_at: datetime
updated_at: datetime

@model_validator(mode="before")
@classmethod
def redact_secret_extra(cls, value: Any) -> Any:
if hasattr(value, "model_dump"):
data = value.model_dump()
elif isinstance(value, dict):
data = dict(value)
else:
data = dict(value)
extra = dict(data.get("extra") or {})
data["has_api_key"] = bool(extra.get("api_key"))
data["extra"] = redact_secret_mapping(extra)
return data


class ValidateEndpointRequest(BaseModel):
"""Request body to validate endpoint values before they are saved (draft)."""

endpoint: str
model_name: str | None = None
api_key: str | None = None
stored_api_key_model_type: ModelEndpointType | None = None
stored_api_key_name: str | None = None

@field_validator("endpoint")
@classmethod
def validate_endpoint(cls, value: str) -> str:
"""Normalize the draft endpoint URL before probing it."""
return _normalize_endpoint(value)

@field_validator("stored_api_key_name")
@classmethod
def validate_stored_api_key_name(cls, value: str | None) -> str | None:
"""Normalize the optional saved endpoint name used as credential source."""
return _normalize_name(value) if value is not None else None

@model_validator(mode="after")
def require_complete_stored_api_key_source(self) -> ValidateEndpointRequest:
"""Require both fields when draft validation reuses a stored key."""
has_type = self.stored_api_key_model_type is not None
has_name = self.stored_api_key_name is not None
if has_type != has_name:
raise ValueError("stored_api_key_model_type and stored_api_key_name must be provided together")
return self


class ValidateEndpointResponse(BaseModel):
Expand All @@ -120,10 +159,17 @@ class ValidateEndpointResponse(BaseModel):
detail: str | None = None


class RevealApiKeyResponse(BaseModel):
"""Response body for explicitly revealing a stored endpoint API key."""

api_key: str | None = None


__all__ = [
"CreateModelEndpointRequest",
"ModelEndpointResponse",
"ModelEndpointType",
"RevealApiKeyResponse",
"UpdateModelEndpointRequest",
"ValidateEndpointRequest",
"ValidateEndpointResponse",
Expand Down
154 changes: 154 additions & 0 deletions openrag/core/utils/redaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""Helpers for shaping public data without exposing secrets."""

from __future__ import annotations

from collections.abc import Mapping
from typing import Any

REDACTED_SECRET = "<redacted>"
MASK_SUFFIX = "********"
MASK_PREFIX_LENGTH = 3
MIN_PREFIX_MASK_LENGTH = 8

SECRET_FIELD_NAMES = frozenset(
{
"api_key",
"api_token",
"access_key",
"auth_token",
"chainlit_auth_secret",
"client_secret",
"hf_token",
"oidc_client_secret",
"oidc_token_encryption_key",
"password",
"private_key",
"refresh_token",
"secret",
"secret_key",
"signing_key",
"token",
"token_encryption_key",
}
)
SECRET_FIELD_SUFFIXES = frozenset(
{
"_access_key",
"_api_key",
"_auth_token",
"_password",
"_private_key",
"_refresh_token",
"_secret",
"_signing_key",
"_token",
"_token_encryption_key",
}
)


def is_secret_field(key: str) -> bool:
"""Return true only for known secret field names, not fuzzy token matches."""
normalized = key.lower()
return normalized in SECRET_FIELD_NAMES or any(normalized.endswith(suffix) for suffix in SECRET_FIELD_SUFFIXES)


def mask_secret_value(value: Any) -> str:
"""Return a public masked value with a short prefix when that is useful."""
if not isinstance(value, str) or len(value) < MIN_PREFIX_MASK_LENGTH:
return REDACTED_SECRET
return f"{value[:MASK_PREFIX_LENGTH]}{MASK_SUFFIX}"


def is_masked_secret_value(value: Any) -> bool:
"""Return true for placeholders produced by the public redaction layer."""
return (
isinstance(value, str) and len(value) == MASK_PREFIX_LENGTH + len(MASK_SUFFIX) and value.endswith(MASK_SUFFIX)
)


def is_clear_secret_value(value: Any) -> bool:
"""Return true when an update explicitly clears a stored secret."""
return value is None or value == ""


def redact_secrets(value: Any) -> Any:
"""Recursively redact values for known secret fields without mutating input."""
if isinstance(value, Mapping):
return {
key: REDACTED_SECRET if is_secret_field(str(key)) else redact_secrets(item) for key, item in value.items()
}
if isinstance(value, list):
return [redact_secrets(item) for item in value]
if isinstance(value, tuple):
return tuple(redact_secrets(item) for item in value)
return value


def redact_secret_mapping(extra: Mapping[str, Any] | None) -> dict[str, Any]:
"""Return the public model-endpoint extra shape shown in Admin UI."""
return _redact_endpoint_extra(dict(extra or {}))


def _redact_endpoint_extra(value: Any, key: str | None = None) -> Any:
if key is not None and is_secret_field(key):
return mask_secret_value(value)
if isinstance(value, Mapping):
return {entry_key: _redact_endpoint_extra(item, str(entry_key)) for entry_key, item in value.items()}
if isinstance(value, list):
return [_redact_endpoint_extra(item) for item in value]
if isinstance(value, tuple):
return tuple(_redact_endpoint_extra(item) for item in value)
return value


def preserve_existing_secrets(existing: Mapping[str, Any] | None, incoming: Mapping[str, Any]) -> dict[str, Any]:
"""Keep stored secrets when an update payload omits or echoes a redacted value."""
return _preserve_existing_secrets(existing or {}, incoming)


def _preserve_existing_secrets(existing: Mapping[str, Any], incoming: Mapping[str, Any]) -> dict[str, Any]:
merged = dict(incoming)
for key, value in existing.items():
incoming_value = merged.get(key)
if is_secret_field(str(key)):
if key in merged and is_clear_secret_value(incoming_value):
merged.pop(key, None)
continue
if key not in merged or incoming_value == REDACTED_SECRET or is_masked_secret_value(incoming_value):
merged[key] = value
continue
if isinstance(value, Mapping) and isinstance(incoming_value, Mapping):
merged[key] = _preserve_existing_secrets(value, incoming_value)
elif isinstance(value, list) and isinstance(incoming_value, list):
merged[key] = _preserve_existing_secret_lists(value, incoming_value)
for key, value in list(merged.items()):
if is_secret_field(str(key)) and is_clear_secret_value(value):
merged.pop(key, None)
return merged


def _preserve_existing_secret_lists(existing: list[Any], incoming: list[Any]) -> list[Any]:
merged = list(incoming)
for index, existing_item in enumerate(existing):
if index >= len(merged):
break
incoming_item = merged[index]
if isinstance(existing_item, Mapping) and isinstance(incoming_item, Mapping):
merged[index] = _preserve_existing_secrets(existing_item, incoming_item)
Comment thread
hedhoud marked this conversation as resolved.
Resolved
elif isinstance(existing_item, list) and isinstance(incoming_item, list):
merged[index] = _preserve_existing_secret_lists(existing_item, incoming_item)
return merged


__all__ = [
"REDACTED_SECRET",
"SECRET_FIELD_NAMES",
"is_masked_secret_value",
"is_secret_field",
"is_clear_secret_value",
"mask_secret_value",
"preserve_existing_secrets",
"redact_secret_mapping",
"redact_secrets",
]
18 changes: 17 additions & 1 deletion openrag/services/orchestrators/model_endpoint_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@
import os
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from urllib.parse import urlsplit

from core.config.model_endpoints import ModelEndpointConfig, ModelEndpointRow
from core.utils.exceptions import NotFoundError, ValidationError
from core.utils.logging import get_logger
from core.utils.redaction import preserve_existing_secrets

if TYPE_CHECKING:
from core.config.root import Settings
Expand Down Expand Up @@ -245,6 +247,9 @@ async def update_model_endpoint(self, name: str, model_type: str, **fields: obje
# default by promoting another endpoint, never by leaving the type with none.
promote_to_default = bool(fields.pop("is_default", None))

if isinstance(fields.get("extra"), dict):
fields["extra"] = preserve_existing_secrets(existing.extra, fields["extra"]) # type: ignore[arg-type]

if fields:
updated = await self._repo.update(name, model_type, **fields)
else:
Expand Down Expand Up @@ -336,10 +341,21 @@ async def validate_endpoint(
"models_served": None,
"detail": None,
}
try:
parsed = urlsplit(url)
except ValueError:
result["detail"] = "Endpoint URL must be an absolute HTTP(S) URL."
return result
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
result["detail"] = "Endpoint URL must be an absolute HTTP(S) URL."
return result
if parsed.username or parsed.password:
result["detail"] = "Endpoint URL must not include credentials."
return result
models_url = url.rstrip("/") + "/models"
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
try:
async with httpx.AsyncClient(timeout=5.0, headers=headers) as client:
async with httpx.AsyncClient(timeout=5.0, headers=headers, follow_redirects=False) as client:
resp = await client.get(models_url)
result["reachable"] = True
if resp.status_code == 200:
Expand Down
4 changes: 3 additions & 1 deletion tests/integration/api/test_model_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ def test_model_endpoint_crud_validate_and_default_selection(api_client):
},
)
assert create_endpoint.status_code == 201, create_endpoint.text
assert create_endpoint.json()["extra"]["api_key"] == "ci-test-key"
assert "ci-test-key" not in create_endpoint.text
assert create_endpoint.json()["has_api_key"] is True
assert create_endpoint.json()["extra"]["api_key"] == "ci-********"

validate_missing = api_client.post(f"/model-endpoints/llm/{endpoint_name}/validate")
_assert_success(validate_missing, context="validate missing model")
Expand Down
Loading
Loading