feat(proxy): add overwrite_user_with_key_hash to stamp outgoing user param with key hash (#34417)

* feat(proxy): add overwrite_user_with_key_hash to stamp outgoing user param with key hash

Adds a litellm_settings flag that forces the outgoing user param to the
authenticated key's hashed token before the request is forwarded to the
provider. The value overrides any caller-supplied user, so providers see
a stable, tamper-proof identifier they can rate-limit or ban on, and the
hash matches user_api_key_hash in spend logs for easy mapping back to
the key owner. Off by default

* fix(proxy): hash non-sk credentials before stamping user param

UserAPIKeyAuth only hashes sk-prefixed keys and JWTs; custom-auth
credentials stay raw on api_key, so stamping them directly would forward
auth material to the provider. Pass through the two known hashed forms
(sha256 hex, hashed-jwt-*) and hash anything else

* refactor(proxy): stamp only standard virtual keys, skip jwt and custom auth

A hashed JWT rotates on every token re-issue so it is useless as a
stable ban id, and custom-auth credentials arrive raw on api_key.
Instead of hashing whatever we hold, the stamp now applies only when
api_key is the sha256 hex digest of a standard virtual key; other auth
methods are explicitly out of scope until the stamped identifier is
configurable

* fix(proxy): gate user stamping on server-set virtual key provenance

Shape alone cannot distinguish a key hash from a raw custom-auth
credential that happens to be 64 hex chars. Adds via_virtual_key, a
server-only marker on UserAPIKeyAuth following the
mcp_admitted_user_subject pattern: stripped from all validated input so
handlers and claims cannot forge it, set by post-construction assignment
only at the DB virtual-key auth return. Stamping now requires the marker
and the hash shape

* test(proxy): prove db auth path sets via_virtual_key marker

The stamping unit tests set the marker manually, so deleting the
assignment in _user_api_key_auth_builder would pass every existing test;
this exercises the real builder path with a mocked identity store and
fails if the marker is not set

* fix(proxy): stamp master-key requests with the master key alias

Master-key auth substitutes LITELLM_PROXY_MASTER_KEY_ALIAS for api_key
so the key and its hash never propagate; that made master-key traffic
bypass stamping and pass the caller-supplied user through. The master
path now sets via_virtual_key and the stamp gate accepts the alias
alongside the sha256 shape, so admin traffic gets the same tamper-proof
id that spend logs already record for it

* fix(proxy): restore via_virtual_key marker on key-cache hits

Cached PROXY_ADMIN auth objects early-return before the marked DB and
master-key returns, and cache serialization drops the exclude=True
marker, so cached admin traffic bypassed stamping. Key-cache entries are
written only after the proxy validated a virtual key or the master key,
so the cache-hit boundary restores the marker; the UI-login JWT fallback
constructs its token from a decrypted blob, not this cache, and stays
unmarked
This commit is contained in:
ryan-crabbe-berri 2026-07-23 16:38:01 -07:00 • committed by GitHub
parent e906a7e796
commit 7aaaa055b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 489 additions and 2 deletions

View file

@ -211,6 +211,9 @@ filter_invalid_headers: Optional[bool] = False
add_user_information_to_llm_headers: Optional[bool] = (
None # adds user_id, team_id, token hash (params from StandardLoggingMetadata) to request headers
)
overwrite_user_with_key_hash: bool = (
False # force the outgoing `user` param to the hashed api key, so providers see a stable, tamper-proof id
)
store_audit_logs = False # Enterprise feature, allow users to see audit logs
skip_system_message_in_guardrail: bool = False
skip_tool_message_in_guardrail: bool = False

View file

@ -2616,6 +2616,17 @@ class UserAPIKeyAuth(LiteLLM_VerificationTokenView): # the expected response ob
# key off. Server-only and stripped from validated input for the same reason as the marker
# above: a forged entry would let a caller pick which team's rpm bucket it is charged against.
mcp_source_team_rpm_limits: dict[str, dict[str, int]] | None = Field(default=None, exclude=True)
via_virtual_key: bool = Field(
default=False,
exclude=True,
description=(
"Server-only marker set exclusively by the DB virtual-key and master-key auth paths via "
"post-construction assignment. Stripped from validated input so custom auth handlers, JWT "
"claims, or key metadata cannot forge it. Gates overwrite_user_with_key_hash stamping: only "
"a credential the proxy itself validated as a key may be forwarded as the provider-facing "
"user id."
),
)
budget_reservation: Optional[Dict[str, Any]] = Field(default=None, exclude=True)
budget_throttle_pct: Optional[float] = Field(default=None, exclude=True)
user: Optional[Any] = None # Expanded user object when expand=user is used
@ -2641,6 +2652,7 @@ class UserAPIKeyAuth(LiteLLM_VerificationTokenView): # the expected response ob
# kwargs, model_validate, a JWT/key claim splat) so it can never be forged from caller data.
values.pop("mcp_admitted_user_subject", None)
values.pop("mcp_source_team_rpm_limits", None)
values.pop("via_virtual_key", None)
if values.get("api_key") is not None:
values.update({"token": cls._safe_hash_litellm_api_key(values.get("api_key"))})
if isinstance(values.get("api_key"), str):

View file

@ -1497,6 +1497,13 @@ async def _user_api_key_auth_builder(
check_cache_only=True,
).resolve(hashed_token=hash_token(api_key))
)
# Key-cache entries are written only after the proxy validated a
# virtual key or the master key, but via_virtual_key is exclude=True
# so serialization drops it; restore it at this trusted boundary.
# The UI-login JWT fallback below constructs its token from a
# decrypted blob, not this cache, and stays unmarked.
if isinstance(valid_token, UserAPIKeyAuth):
valid_token.via_virtual_key = True
except Exception:
verbose_logger.debug("api key not found in cache.")
valid_token = None
@ -1614,6 +1621,7 @@ async def _user_api_key_auth_builder(
_user_api_key_obj = update_valid_token_with_end_user_params(
valid_token=_user_api_key_obj, end_user_params=end_user_params
)
_user_api_key_obj.via_virtual_key = True
return _user_api_key_obj
@ -2021,7 +2029,7 @@ async def _user_api_key_auth_builder(
# No token was found when looking up in the DB
raise Exception("Invalid proxy server token passed")
if valid_token_dict is not None:
return await _return_user_api_key_auth_obj(
virtual_key_auth_obj = await _return_user_api_key_auth_obj(
user_obj=user_obj,
api_key=api_key,
parent_otel_span=parent_otel_span,
@ -2029,6 +2037,8 @@ async def _user_api_key_auth_builder(
route=route,
start_time=start_time,
)
virtual_key_auth_obj.via_virtual_key = True
return virtual_key_auth_obj
except Exception as e:
return await UserAPIKeyAuthExceptionHandler._handle_authentication_error(
e=e,

View file

@ -13,7 +13,7 @@ from starlette.datastructures import Headers
import litellm
from litellm._logging import verbose_logger, verbose_proxy_logger
from litellm._service_logger import ServiceLogging
from litellm.constants import PRE_CALL_EXECUTED_GUARDRAILS_KEY
from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS, PRE_CALL_EXECUTED_GUARDRAILS_KEY
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
iter_client_callback_metadata_dicts,
@ -48,6 +48,24 @@ _EXPLICIT_SESSION_HEADERS = frozenset({"x-litellm-trace-id", "x-litellm-session-
# Session-id values must be non-empty strings of alphanumerics, hyphens, or underscores
# (covers UUIDs and most common session-id formats).
_SESSION_ID_VALUE_RE = re.compile(r"^[a-zA-Z0-9_\-]{8,}$")
_SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$")
def _stampable_key_hash(user_api_key_dict: UserAPIKeyAuth) -> str | None:
"""Only proxy-validated keys are stamped, proven by the unforgeable
via_virtual_key marker AND a known non-secret shape: the sha256 hex digest
UserAPIKeyAuth stores virtual keys in, or the master key's stable alias.
Custom-auth credentials arrive raw (never forward auth material) and hashed
JWTs rotate on re-issue (useless as a stable ban id), so both are skipped."""
api_key = user_api_key_dict.api_key
if not user_api_key_dict.via_virtual_key or api_key is None:
return None
if api_key == LITELLM_PROXY_MASTER_KEY_ALIAS or _SHA256_HEX_RE.fullmatch(api_key):
return api_key
return None
_ANTHROPIC_SESSION_ID_VALUE_RE = re.compile(r"^[a-zA-Z0-9_\-]+$")
@ -1447,6 +1465,11 @@ async def add_litellm_data_to_request(
if "user" not in data:
data["user"] = user
if litellm.overwrite_user_with_key_hash is True:
stampable_hash = _stampable_key_hash(user_api_key_dict)
if stampable_hash is not None:
data["user"] = stampable_hash
data["secret_fields"] = SecretFields(raw_headers=_raw_headers)
## Dynamic api version (Azure OpenAI endpoints) ##

View file

@ -1290,6 +1290,250 @@ async def test_scim_deactivated_user_key_is_rejected():
setattr(_proxy_server_mod, attr, val)
@pytest.mark.asyncio
async def test_cached_proxy_admin_key_sets_via_virtual_key_marker():
"""Cached PROXY_ADMIN auth objects early-return before the marked DB and
master-key returns, and cache serialization drops the exclude=True marker;
the cache-hit boundary must restore it or cached admin traffic silently
bypasses overwrite_user_with_key_hash stamping."""
from fastapi import Request
from starlette.datastructures import URL
from litellm.proxy.auth.user_api_key_auth import _user_api_key_auth_builder
from litellm.proxy.proxy_server import hash_token
api_key = "sk-cached-admin-marker-test"
hashed_key = hash_token(api_key)
cached_token = UserAPIKeyAuth(
api_key=api_key,
token=hashed_key,
user_id="cached-admin-user",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
assert cached_token.via_virtual_key is False
mock_cache = AsyncMock()
mock_cache.async_get_cache = AsyncMock(return_value=None)
mock_cache.delete_cache = MagicMock()
mock_proxy_logging_obj = MagicMock()
mock_proxy_logging_obj.internal_usage_cache = MagicMock()
mock_proxy_logging_obj.internal_usage_cache.dual_cache = AsyncMock()
mock_proxy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache = (
AsyncMock()
)
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
import litellm.proxy.proxy_server as _proxy_server_mod
_attrs_to_set = {
"prisma_client": MagicMock(),
"user_api_key_cache": mock_cache,
"proxy_logging_obj": mock_proxy_logging_obj,
"master_key": "sk-master-key",
"general_settings": {},
"llm_model_list": [],
"llm_router": None,
"open_telemetry_logger": None,
"model_max_budget_limiter": MagicMock(),
"user_custom_auth": None,
"jwt_handler": None,
"litellm_proxy_admin_name": "admin",
}
_original_values = {
attr: getattr(_proxy_server_mod, attr, None) for attr in _attrs_to_set
}
try:
for attr, val in _attrs_to_set.items():
setattr(_proxy_server_mod, attr, val)
request = Request(scope={"type": "http"})
request._url = URL(url="/chat/completions")
with patch(
"litellm.proxy.auth.resolvers.store.IdentityStore._resolve_key",
new_callable=AsyncMock,
return_value=cached_token,
):
result = await _user_api_key_auth_builder(
request=request,
api_key=f"Bearer {api_key}",
azure_api_key_header="",
anthropic_api_key_header=None,
google_ai_studio_api_key_header=None,
azure_apim_header=None,
request_data={},
)
assert isinstance(result, UserAPIKeyAuth)
assert result.user_role == LitellmUserRoles.PROXY_ADMIN
assert result.via_virtual_key is True
assert result.api_key == hashed_key
finally:
for attr, val in _original_values.items():
setattr(_proxy_server_mod, attr, val)
@pytest.mark.asyncio
async def test_master_key_auth_sets_via_virtual_key_marker():
"""Master-key requests must also be stamped by overwrite_user_with_key_hash;
the auth path substitutes the stable alias for api_key and must mark the
result as proxy-validated."""
from fastapi import Request
from starlette.datastructures import URL
from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS
from litellm.proxy.auth.user_api_key_auth import _user_api_key_auth_builder
master_key = "sk-master-key"
mock_cache = AsyncMock()
mock_cache.async_get_cache = AsyncMock(return_value=None)
mock_cache.delete_cache = MagicMock()
mock_proxy_logging_obj = MagicMock()
mock_proxy_logging_obj.internal_usage_cache = MagicMock()
mock_proxy_logging_obj.internal_usage_cache.dual_cache = AsyncMock()
mock_proxy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache = (
AsyncMock()
)
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
import litellm.proxy.proxy_server as _proxy_server_mod
_attrs_to_set = {
"prisma_client": MagicMock(),
"user_api_key_cache": mock_cache,
"proxy_logging_obj": mock_proxy_logging_obj,
"master_key": master_key,
"general_settings": {},
"llm_model_list": [],
"llm_router": None,
"open_telemetry_logger": None,
"model_max_budget_limiter": MagicMock(),
"user_custom_auth": None,
"jwt_handler": None,
"litellm_proxy_admin_name": "admin",
}
_original_values = {
attr: getattr(_proxy_server_mod, attr, None) for attr in _attrs_to_set
}
try:
for attr, val in _attrs_to_set.items():
setattr(_proxy_server_mod, attr, val)
request = Request(scope={"type": "http"})
request._url = URL(url="/chat/completions")
result = await _user_api_key_auth_builder(
request=request,
api_key=f"Bearer {master_key}",
azure_api_key_header="",
anthropic_api_key_header=None,
google_ai_studio_api_key_header=None,
azure_apim_header=None,
request_data={},
)
assert isinstance(result, UserAPIKeyAuth)
assert result.via_virtual_key is True
assert result.api_key == LITELLM_PROXY_MASTER_KEY_ALIAS
finally:
for attr, val in _original_values.items():
setattr(_proxy_server_mod, attr, val)
@pytest.mark.asyncio
async def test_db_virtual_key_auth_sets_via_virtual_key_marker():
"""via_virtual_key gates overwrite_user_with_key_hash stamping and is
forge-stripped from validated input, so the DB auth path setting it by
post-construction assignment is the only thing that turns stamping on."""
from fastapi import Request
from starlette.datastructures import URL
from litellm.proxy.auth.user_api_key_auth import _user_api_key_auth_builder
from litellm.proxy.proxy_server import hash_token
api_key = "sk-via-virtual-key-marker-test"
hashed_key = hash_token(api_key)
valid_token = UserAPIKeyAuth(
api_key=api_key,
token=hashed_key,
user_id="marker-test-user",
)
mock_cache = AsyncMock()
mock_cache.async_get_cache = AsyncMock(return_value=None)
mock_cache.delete_cache = MagicMock()
mock_proxy_logging_obj = MagicMock()
mock_proxy_logging_obj.internal_usage_cache = MagicMock()
mock_proxy_logging_obj.internal_usage_cache.dual_cache = AsyncMock()
mock_proxy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache = (
AsyncMock()
)
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
mock_prisma_client = MagicMock()
import litellm.proxy.proxy_server as _proxy_server_mod
_attrs_to_set = {
"prisma_client": mock_prisma_client,
"user_api_key_cache": mock_cache,
"proxy_logging_obj": mock_proxy_logging_obj,
"master_key": "sk-master-key",
"general_settings": {},
"llm_model_list": [],
"llm_router": None,
"open_telemetry_logger": None,
"model_max_budget_limiter": MagicMock(),
"user_custom_auth": None,
"jwt_handler": None,
"litellm_proxy_admin_name": "admin",
}
_original_values = {
attr: getattr(_proxy_server_mod, attr, None) for attr in _attrs_to_set
}
try:
for attr, val in _attrs_to_set.items():
setattr(_proxy_server_mod, attr, val)
request = Request(scope={"type": "http"})
request._url = URL(url="/chat/completions")
with (
patch(
"litellm.proxy.auth.resolvers.store.IdentityStore._resolve_key",
new_callable=AsyncMock,
return_value=valid_token,
),
patch(
"litellm.proxy.auth.user_api_key_auth.get_user_object",
new_callable=AsyncMock,
return_value=None,
),
):
result = await _user_api_key_auth_builder(
request=request,
api_key=f"Bearer {api_key}",
azure_api_key_header="",
anthropic_api_key_header=None,
google_ai_studio_api_key_header=None,
azure_apim_header=None,
request_data={},
)
assert isinstance(result, UserAPIKeyAuth)
assert result.via_virtual_key is True
assert result.api_key == hashed_key
finally:
for attr, val in _original_values.items():
setattr(_proxy_server_mod, attr, val)
@pytest.mark.asyncio
async def test_return_user_api_key_auth_obj_user_spend_and_budget():
"""

View file

@ -5226,3 +5226,198 @@ async def test_add_litellm_data_to_request_unions_metadata_tags_with_header_tags
tags = updated["litellm_metadata"]["tags"]
assert "header-tag" in tags
assert "body-tag" in tags
def _make_chat_request_mock() -> MagicMock:
return _make_request_mock("/v1/chat/completions", {"Content-Type": "application/json"})
@pytest.mark.asyncio
async def test_overwrite_user_with_key_hash_clobbers_caller_supplied_user(monkeypatch):
"""The flag exists so providers can ban by a tamper-proof id; a caller-chosen
`user` must never survive, and the raw sk- key must never be forwarded."""
from litellm.proxy._types import hash_token
monkeypatch.setattr(litellm, "overwrite_user_with_key_hash", True)
raw_key = "sk-overwrite-user-test-1234"
user_api_key_dict = UserAPIKeyAuth(api_key=raw_key)
user_api_key_dict.via_virtual_key = True
data = {"model": "gpt-4o", "user": "attacker-chosen-id"}
updated_data = await add_litellm_data_to_request(
data=data,
request=_make_chat_request_mock(),
user_api_key_dict=user_api_key_dict,
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
assert updated_data["user"] == hash_token(raw_key)
assert updated_data["user"] != "attacker-chosen-id"
assert raw_key not in updated_data["user"]
@pytest.mark.asyncio
async def test_overwrite_user_with_key_hash_sets_user_when_absent(monkeypatch):
from litellm.proxy._types import hash_token
monkeypatch.setattr(litellm, "overwrite_user_with_key_hash", True)
raw_key = "sk-overwrite-user-test-5678"
user_api_key_dict = UserAPIKeyAuth(api_key=raw_key)
user_api_key_dict.via_virtual_key = True
data = {"model": "gpt-4o"}
updated_data = await add_litellm_data_to_request(
data=data,
request=_make_chat_request_mock(),
user_api_key_dict=user_api_key_dict,
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
assert updated_data["user"] == hash_token(raw_key)
@pytest.mark.asyncio
async def test_overwrite_user_with_key_hash_disabled_preserves_caller_user():
assert litellm.overwrite_user_with_key_hash is False
user_api_key_dict = UserAPIKeyAuth(api_key="sk-overwrite-user-test-9999")
user_api_key_dict.via_virtual_key = True
data = {"model": "gpt-4o", "user": "caller-chosen-id"}
updated_data = await add_litellm_data_to_request(
data=data,
request=_make_chat_request_mock(),
user_api_key_dict=user_api_key_dict,
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
assert updated_data["user"] == "caller-chosen-id"
@pytest.mark.asyncio
async def test_overwrite_user_with_key_hash_skips_custom_auth_credential(monkeypatch):
"""Custom-auth credentials are not sk-prefixed or JWTs, so UserAPIKeyAuth stores
them raw; the stamp must skip them entirely so auth material never leaks."""
monkeypatch.setattr(litellm, "overwrite_user_with_key_hash", True)
raw_credential = "my-custom-auth-credential-abc123"
user_api_key_dict = UserAPIKeyAuth(api_key=raw_credential)
assert user_api_key_dict.api_key == raw_credential
updated_data = await add_litellm_data_to_request(
data={"model": "gpt-4o", "user": "caller-chosen-id"},
request=_make_chat_request_mock(),
user_api_key_dict=user_api_key_dict,
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
assert updated_data["user"] == "caller-chosen-id"
@pytest.mark.asyncio
async def test_overwrite_user_with_key_hash_skips_jwt_auth(monkeypatch):
"""A hashed JWT rotates on every token re-issue, so it is useless as a stable
ban id; JWT-authenticated requests are not stamped."""
from litellm.proxy._types import hash_token
monkeypatch.setattr(litellm, "overwrite_user_with_key_hash", True)
hashed_jwt = f"hashed-jwt-{hash_token('some-jwt-token')}"
user_api_key_dict = UserAPIKeyAuth(api_key=hashed_jwt)
updated_data = await add_litellm_data_to_request(
data={"model": "gpt-4o", "user": "caller-chosen-id"},
request=_make_chat_request_mock(),
user_api_key_dict=user_api_key_dict,
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
assert updated_data["user"] == "caller-chosen-id"
@pytest.mark.asyncio
async def test_overwrite_user_with_key_hash_skips_hex_shaped_custom_credential(monkeypatch):
"""A custom-auth credential that happens to be 64 hex chars is indistinguishable
from a key hash by shape alone; only the server-set via_virtual_key marker may
authorize stamping, so this raw credential must never be forwarded."""
monkeypatch.setattr(litellm, "overwrite_user_with_key_hash", True)
hex_shaped_credential = "a" * 64
user_api_key_dict = UserAPIKeyAuth(api_key=hex_shaped_credential)
assert user_api_key_dict.api_key == hex_shaped_credential
assert user_api_key_dict.via_virtual_key is False
updated_data = await add_litellm_data_to_request(
data={"model": "gpt-4o", "user": "caller-chosen-id"},
request=_make_chat_request_mock(),
user_api_key_dict=user_api_key_dict,
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
assert updated_data["user"] == "caller-chosen-id"
def test_via_virtual_key_cannot_be_forged_from_validated_input():
from_kwargs = UserAPIKeyAuth(api_key="b" * 64, via_virtual_key=True)
assert from_kwargs.via_virtual_key is False
from_dict = UserAPIKeyAuth.model_validate({"api_key": "b" * 64, "via_virtual_key": True})
assert from_dict.via_virtual_key is False
@pytest.mark.asyncio
async def test_overwrite_user_with_key_hash_stamps_master_key_alias(monkeypatch):
"""Master-key requests carry the stable alias instead of a hash (so the master
key never propagates anywhere); the alias is the stampable id for them."""
from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS
monkeypatch.setattr(litellm, "overwrite_user_with_key_hash", True)
user_api_key_dict = UserAPIKeyAuth(api_key=LITELLM_PROXY_MASTER_KEY_ALIAS)
user_api_key_dict.via_virtual_key = True
updated_data = await add_litellm_data_to_request(
data={"model": "gpt-4o", "user": "attacker-chosen-id"},
request=_make_chat_request_mock(),
user_api_key_dict=user_api_key_dict,
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
assert updated_data["user"] == LITELLM_PROXY_MASTER_KEY_ALIAS
@pytest.mark.asyncio
async def test_overwrite_user_with_key_hash_rejects_alias_without_marker(monkeypatch):
from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS
monkeypatch.setattr(litellm, "overwrite_user_with_key_hash", True)
user_api_key_dict = UserAPIKeyAuth(api_key=LITELLM_PROXY_MASTER_KEY_ALIAS)
assert user_api_key_dict.via_virtual_key is False
updated_data = await add_litellm_data_to_request(
data={"model": "gpt-4o", "user": "caller-chosen-id"},
request=_make_chat_request_mock(),
user_api_key_dict=user_api_key_dict,
proxy_config=MagicMock(),
general_settings={},
version="test-version",
)
assert updated_data["user"] == "caller-chosen-id"