Source code for tooluniverse.credentials

"""Request-scoped credentials for multi-tenant ToolUniverse applications.

Environment variables remain the backwards-compatible default for local and single-user use.
Hosted applications can activate :func:`credential_context` around one request so tools resolve
only that request's credentials.  The values are immutable, are never copied to ``os.environ``,
and are restored automatically when the context exits.
"""

from __future__ import annotations

import contextvars
import os
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from types import MappingProxyType
from typing import Iterator, Mapping, Optional


CredentialMapping = Mapping[str, str]

_CREDENTIAL_NAME_SUFFIXES = (
    "KEY",
    "TOKEN",
    "SECRET",
    "PASSWORD",
    "USERNAME",
    "EMAIL",
    "JWT",
)

# ``None`` means there is no request scope and tools may use their normal environment fallback.
# An active empty mapping is intentionally different: it masks all process-level credentials.
_request_credentials: contextvars.ContextVar[Optional[CredentialMapping]] = (
    contextvars.ContextVar("tooluniverse_request_credentials", default=None)
)


def _validated_copy(credentials: CredentialMapping) -> CredentialMapping:
    if not isinstance(credentials, Mapping):
        raise TypeError(
            "credentials must be a mapping of string names to string values"
        )

    copied = {}
    for name, value in credentials.items():
        if not isinstance(name, str) or not name:
            raise TypeError("credential names must be non-empty strings")
        if not isinstance(value, str):
            raise TypeError(f"credential {name!r} must have a string value")
        copied[name] = value
    return MappingProxyType(copied)


[docs] @contextmanager def credential_context(credentials: CredentialMapping) -> Iterator[CredentialMapping]: """Activate an isolated credential mapping for the current execution context. Contexts may be nested and are safe across asyncio tasks. Use :class:`ContextThreadPoolExecutor` when work crosses into a thread pool. """ scoped = _validated_copy(credentials) token = _request_credentials.set(scoped) try: yield scoped finally: _request_credentials.reset(token)
[docs] def current_credentials() -> Optional[CredentialMapping]: """Return the immutable active mapping, or ``None`` outside a request scope.""" return _request_credentials.get()
def has_credential_context() -> bool: """Return whether request-scoped credentials (including an empty scope) are active.""" return _request_credentials.get() is not None def is_credential_name(name: str) -> bool: """Distinguish provider credentials from process-level infrastructure config.""" return isinstance(name, str) and name.endswith(_CREDENTIAL_NAME_SUFFIXES)
[docs] def get_credential(name: str, fallback: Optional[str] = None) -> Optional[str]: """Resolve a credential without exposing it as a tool argument. Outside a request credential context, ``fallback`` is used when supplied; otherwise the environment variable named by ``name`` is read. Inside an active context, a missing name returns ``None`` rather than falling back to a process-wide secret. This fail-closed behavior prevents one tenant from inheriting another deployment credential. """ if not isinstance(name, str) or not name: raise TypeError("credential name must be a non-empty string") scoped = _request_credentials.get() if scoped is None: return fallback if fallback is not None else os.environ.get(name) return scoped.get(name)
class ContextThreadPoolExecutor(ThreadPoolExecutor): """ThreadPoolExecutor that propagates the submitting task's context variables.""" def submit(self, fn, /, *args, **kwargs): context = contextvars.copy_context() return super().submit(context.run, fn, *args, **kwargs)