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:
Xingyao Wang
2026-03-17 12:54:57 +00:00
committed by GitHub
parent 8941111c4e
commit 75c823c486
11 changed files with 1110 additions and 15 deletions

View File

@@ -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

View File

@@ -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'
)

View File

@@ -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')

View 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

View File

@@ -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():

View File

@@ -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:

View File

@@ -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:

View File

@@ -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',
)