mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
feat: expose_secrets param on /users/me + sandbox-scoped secrets API (#13383)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -7,7 +7,7 @@ import zipfile
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, AsyncGenerator, Sequence
|
||||
from typing import Any, AsyncGenerator, Sequence, cast
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
@@ -84,7 +84,7 @@ from openhands.app_server.utils.llm_metadata import (
|
||||
get_llm_metadata,
|
||||
should_set_litellm_extra_body,
|
||||
)
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
from openhands.integrations.service_types import SuggestedTask
|
||||
from openhands.sdk import Agent, AgentContext, LocalWorkspace
|
||||
from openhands.sdk.hooks import HookConfig
|
||||
@@ -837,7 +837,10 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
secrets = await self.user_context.get_secrets()
|
||||
|
||||
# Get all provider tokens from user authentication
|
||||
provider_tokens = await self.user_context.get_provider_tokens()
|
||||
provider_tokens = cast(
|
||||
PROVIDER_TOKEN_TYPE | None,
|
||||
await self.user_context.get_provider_tokens(),
|
||||
)
|
||||
if not provider_tokens:
|
||||
return secrets
|
||||
|
||||
|
||||
@@ -59,3 +59,20 @@ class SandboxInfo(BaseModel):
|
||||
class SandboxPage(BaseModel):
|
||||
items: list[SandboxInfo]
|
||||
next_page_id: str | None = None
|
||||
|
||||
|
||||
class SecretNameItem(BaseModel):
|
||||
"""A secret's name and optional description (value NOT included)."""
|
||||
|
||||
name: str = Field(description='The secret name/key')
|
||||
description: str | None = Field(
|
||||
default=None, description='Optional description of the secret'
|
||||
)
|
||||
|
||||
|
||||
class SecretNamesResponse(BaseModel):
|
||||
"""Response listing available secret names (no raw values)."""
|
||||
|
||||
secrets: list[SecretNameItem] = Field(
|
||||
default_factory=list, description='Available secrets'
|
||||
)
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
"""Runtime Containers router for OpenHands App Server."""
|
||||
|
||||
from typing import Annotated
|
||||
import logging
|
||||
from typing import Annotated, cast
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||||
from fastapi.security import APIKeyHeader
|
||||
|
||||
from openhands.agent_server.models import Success
|
||||
from openhands.app_server.config import depends_sandbox_service
|
||||
from openhands.app_server.sandbox.sandbox_models import SandboxInfo, SandboxPage
|
||||
from openhands.app_server.sandbox.sandbox_models import (
|
||||
SandboxInfo,
|
||||
SandboxPage,
|
||||
SecretNameItem,
|
||||
SecretNamesResponse,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_service import (
|
||||
SandboxService,
|
||||
)
|
||||
from openhands.app_server.sandbox.session_auth import validate_session_key
|
||||
from openhands.app_server.user.auth_user_context import AuthUserContext
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.user_auth.user_auth import (
|
||||
get_for_user as get_user_auth_for_user,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# We use the get_dependencies method here to signal to the OpenAPI docs that this endpoint
|
||||
# is protected. The actual protection is provided by SetAuthCookieMiddleware
|
||||
@@ -94,3 +108,104 @@ async def delete_sandbox(
|
||||
if not exists:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
||||
return Success()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sandbox-scoped secrets (authenticated via X-Session-API-Key)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _valid_sandbox_from_session_key(
|
||||
request: Request,
|
||||
sandbox_id: str,
|
||||
session_api_key: str = Depends(
|
||||
APIKeyHeader(name='X-Session-API-Key', auto_error=False)
|
||||
),
|
||||
) -> SandboxInfo:
|
||||
"""Authenticate via ``X-Session-API-Key`` and verify sandbox ownership."""
|
||||
sandbox_info = await validate_session_key(session_api_key)
|
||||
|
||||
if sandbox_info.id != sandbox_id:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
detail='Session API key does not match sandbox',
|
||||
)
|
||||
|
||||
return sandbox_info
|
||||
|
||||
|
||||
async def _get_user_context(sandbox_info: SandboxInfo) -> AuthUserContext:
|
||||
"""Build an ``AuthUserContext`` for the sandbox owner."""
|
||||
if not sandbox_info.created_by_user_id:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Sandbox has no associated user',
|
||||
)
|
||||
user_auth = await get_user_auth_for_user(sandbox_info.created_by_user_id)
|
||||
return AuthUserContext(user_auth=user_auth)
|
||||
|
||||
|
||||
@router.get('/{sandbox_id}/settings/secrets')
|
||||
async def list_secret_names(
|
||||
sandbox_info: SandboxInfo = Depends(_valid_sandbox_from_session_key),
|
||||
) -> SecretNamesResponse:
|
||||
"""List available secret names (no raw values).
|
||||
|
||||
Includes both custom secrets and provider tokens (e.g. github_token).
|
||||
"""
|
||||
user_context = await _get_user_context(sandbox_info)
|
||||
|
||||
items: list[SecretNameItem] = []
|
||||
|
||||
# Custom secrets
|
||||
secret_sources = await user_context.get_secrets()
|
||||
for name, source in secret_sources.items():
|
||||
items.append(SecretNameItem(name=name, description=source.description))
|
||||
|
||||
# Provider tokens (github_token, gitlab_token, etc.)
|
||||
provider_env_vars = cast(
|
||||
dict[str, str] | None,
|
||||
await user_context.get_provider_tokens(as_env_vars=True),
|
||||
)
|
||||
if provider_env_vars:
|
||||
for env_key in provider_env_vars:
|
||||
items.append(
|
||||
SecretNameItem(name=env_key, description=f'{env_key} provider token')
|
||||
)
|
||||
|
||||
return SecretNamesResponse(secrets=items)
|
||||
|
||||
|
||||
@router.get('/{sandbox_id}/settings/secrets/{secret_name}')
|
||||
async def get_secret_value(
|
||||
secret_name: str,
|
||||
sandbox_info: SandboxInfo = Depends(_valid_sandbox_from_session_key),
|
||||
) -> Response:
|
||||
"""Return a single secret value as plain text.
|
||||
|
||||
Called by ``LookupSecret`` inside the sandbox. Checks custom secrets
|
||||
first, then falls back to provider tokens — always resolving the
|
||||
latest token at call time.
|
||||
"""
|
||||
user_context = await _get_user_context(sandbox_info)
|
||||
|
||||
# Check custom secrets first
|
||||
secret_sources = await user_context.get_secrets()
|
||||
source = secret_sources.get(secret_name)
|
||||
if source is not None:
|
||||
value = source.get_value()
|
||||
if value is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail='Secret has no value')
|
||||
return Response(content=value, media_type='text/plain')
|
||||
|
||||
# Fall back to provider tokens (resolved fresh per request)
|
||||
provider_env_vars = cast(
|
||||
dict[str, str] | None,
|
||||
await user_context.get_provider_tokens(as_env_vars=True),
|
||||
)
|
||||
if provider_env_vars:
|
||||
token_value = provider_env_vars.get(secret_name)
|
||||
if token_value is not None:
|
||||
return Response(content=token_value, media_type='text/plain')
|
||||
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail='Secret not found')
|
||||
|
||||
66
openhands/app_server/sandbox/session_auth.py
Normal file
66
openhands/app_server/sandbox/session_auth.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Shared session-key authentication for sandbox-scoped endpoints.
|
||||
|
||||
Both the sandbox router and the user router need to validate
|
||||
``X-Session-API-Key`` headers. This module centralises that logic so
|
||||
it lives in exactly one place.
|
||||
|
||||
The ``InjectorState`` + ``ADMIN`` pattern used here is established in
|
||||
``webhook_router.py`` — the sandbox service requires an admin context to
|
||||
look up sandboxes across all users by session key, but the session key
|
||||
itself acts as the proof of access.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from openhands.app_server.config import get_global_config, get_sandbox_service
|
||||
from openhands.app_server.sandbox.sandbox_models import SandboxInfo
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import ADMIN, USER_CONTEXT_ATTR
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def validate_session_key(session_api_key: str | None) -> SandboxInfo:
|
||||
"""Validate an ``X-Session-API-Key`` and return the associated sandbox.
|
||||
|
||||
Raises:
|
||||
HTTPException(401): if the key is missing or does not map to a sandbox.
|
||||
HTTPException(401): in SAAS mode if the sandbox has no owning user.
|
||||
"""
|
||||
if not session_api_key:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
detail='X-Session-API-Key header is required',
|
||||
)
|
||||
|
||||
# The sandbox service is scoped to users. To look up a sandbox by session
|
||||
# key (which could belong to *any* user) we need an admin context. This
|
||||
# is the same pattern used in webhook_router.valid_sandbox().
|
||||
state = InjectorState()
|
||||
setattr(state, USER_CONTEXT_ATTR, ADMIN)
|
||||
|
||||
async with get_sandbox_service(state) as sandbox_service:
|
||||
sandbox_info = await sandbox_service.get_sandbox_by_session_api_key(
|
||||
session_api_key
|
||||
)
|
||||
|
||||
if sandbox_info is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED, detail='Invalid session API key'
|
||||
)
|
||||
|
||||
if not sandbox_info.created_by_user_id:
|
||||
if get_global_config().app_mode == AppMode.SAAS:
|
||||
_logger.error(
|
||||
'Sandbox had no user specified',
|
||||
extra={'sandbox_id': sandbox_info.id},
|
||||
)
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Sandbox had no user specified',
|
||||
)
|
||||
|
||||
return sandbox_info
|
||||
@@ -48,8 +48,27 @@ class AuthUserContext(UserContext):
|
||||
self._user_info = user_info
|
||||
return user_info
|
||||
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
return await self.user_auth.get_provider_tokens()
|
||||
async def get_provider_tokens(
|
||||
self, as_env_vars: bool = False
|
||||
) -> PROVIDER_TOKEN_TYPE | dict[str, str] | None:
|
||||
"""Return provider tokens.
|
||||
|
||||
Args:
|
||||
as_env_vars: When True, return a ``dict[str, str]`` mapping env
|
||||
var names (e.g. ``github_token``) to plain-text token values,
|
||||
resolving the latest value at call time. When False (default),
|
||||
return the raw ``dict[ProviderType, ProviderToken]``.
|
||||
"""
|
||||
provider_tokens = await self.user_auth.get_provider_tokens()
|
||||
if not as_env_vars:
|
||||
return provider_tokens
|
||||
results: dict[str, str] = {}
|
||||
if provider_tokens:
|
||||
for provider_type, provider_token in provider_tokens.items():
|
||||
if provider_token.token:
|
||||
env_key = ProviderHandler.get_provider_env_key(provider_type)
|
||||
results[env_key] = provider_token.token.get_secret_value()
|
||||
return results
|
||||
|
||||
async def get_provider_handler(self):
|
||||
provider_handler = self._provider_handler
|
||||
@@ -79,9 +98,9 @@ class AuthUserContext(UserContext):
|
||||
return token
|
||||
|
||||
async def get_secrets(self) -> dict[str, SecretSource]:
|
||||
results = {}
|
||||
results: dict[str, SecretSource] = {}
|
||||
|
||||
# Include custom secrets...
|
||||
# Include custom secrets
|
||||
secrets = await self.user_auth.get_secrets()
|
||||
if secrets:
|
||||
for name, custom_secret in secrets.custom_secrets.items():
|
||||
|
||||
@@ -26,7 +26,9 @@ class SpecifyUserContext(UserContext):
|
||||
) -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
async def get_provider_tokens(
|
||||
self, as_env_vars: bool = False
|
||||
) -> PROVIDER_TOKEN_TYPE | dict[str, str] | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
|
||||
|
||||
@@ -35,8 +35,16 @@ class UserContext(ABC):
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
"""Get the latest tokens for all provider types"""
|
||||
async def get_provider_tokens(
|
||||
self, as_env_vars: bool = False
|
||||
) -> PROVIDER_TOKEN_TYPE | dict[str, str] | None:
|
||||
"""Get the latest tokens for all provider types.
|
||||
|
||||
Args:
|
||||
as_env_vars: When True, return a ``dict[str, str]`` mapping env
|
||||
var names (e.g. ``github_token``) to plain-text token values.
|
||||
When False (default), return the raw provider token mapping.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
"""User router for OpenHands App Server. For the moment, this simply implements the /me endpoint."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Query, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.app_server.config import depends_user_context
|
||||
from openhands.app_server.sandbox.session_auth import validate_session_key
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.app_server.user.user_models import UserInfo
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# We use the get_dependencies method here to signal to the OpenAPI docs that this endpoint
|
||||
# is protected. The actual protection is provided by SetAuthCookieMiddleware
|
||||
router = APIRouter(prefix='/users', tags=['User'], dependencies=get_dependencies())
|
||||
@@ -18,9 +24,52 @@ user_dependency = depends_user_context()
|
||||
@router.get('/me')
|
||||
async def get_current_user(
|
||||
user_context: UserContext = user_dependency,
|
||||
expose_secrets: bool = Query(
|
||||
default=False,
|
||||
description='If true, return unmasked secret values (e.g. llm_api_key). '
|
||||
'Requires a valid X-Session-API-Key header for an active sandbox '
|
||||
'owned by the authenticated user.',
|
||||
),
|
||||
x_session_api_key: str | None = Header(default=None),
|
||||
) -> UserInfo:
|
||||
"""Get the current authenticated user."""
|
||||
user = await user_context.get_user_info()
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail='Not authenticated')
|
||||
if expose_secrets:
|
||||
await _validate_session_key_ownership(user_context, x_session_api_key)
|
||||
return JSONResponse( # type: ignore[return-value]
|
||||
content=user.model_dump(mode='json', context={'expose_secrets': True})
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def _validate_session_key_ownership(
|
||||
user_context: UserContext,
|
||||
session_api_key: str | None,
|
||||
) -> None:
|
||||
"""Verify the session key belongs to a sandbox owned by the caller.
|
||||
|
||||
Raises ``HTTPException`` if the key is missing, invalid, or belongs
|
||||
to a sandbox owned by a different user.
|
||||
"""
|
||||
sandbox_info = await validate_session_key(session_api_key)
|
||||
|
||||
# Verify the sandbox is owned by the authenticated user.
|
||||
caller_id = await user_context.get_user_id()
|
||||
if not caller_id:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Cannot determine authenticated user',
|
||||
)
|
||||
|
||||
if sandbox_info.created_by_user_id != caller_id:
|
||||
_logger.warning(
|
||||
'Session key user mismatch: sandbox owner=%s, caller=%s',
|
||||
sandbox_info.created_by_user_id,
|
||||
caller_id,
|
||||
)
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
detail='Session API key does not belong to the authenticated user',
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user