Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/db/master_key_migration.py: 32%
130 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1import json
2from collections.abc import Awaitable, Callable, Mapping
3from dataclasses import dataclass
4from enum import Enum
5from typing import Final
7from pydantic import JsonValue, TypeAdapter
8from typing_extensions import assert_never
10from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
11from litellm.proxy.auth.master_key_boot_check import MIGRATE_FROM_MASTER_KEY_ENV_VAR, SALT_KEY_ENV_VAR
12from litellm.proxy.common_utils.callback_utils import CALLBACK_VAR_ENCRYPTED_PREFIX
13from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_if_encrypted_with, encrypt_value_helper
14from litellm.proxy.db.create_views import SupportsRawQueries
17@dataclass(frozen=True, slots=True)
18class _SecretColumn:
19 table: str
20 primary_key: str
21 column: str
22 is_json: bool = True
23 only_rows_with_marked_ciphertexts: bool = False
26_SECRET_COLUMNS: Final = (
27 _SecretColumn("LiteLLM_ProxyModelTable", "model_id", "litellm_params"),
28 _SecretColumn("LiteLLM_CredentialsTable", "credential_id", "credential_values"),
29 _SecretColumn("LiteLLM_Config", "param_name", "param_value"),
30 _SecretColumn("LiteLLM_SSOConfig", "id", "sso_settings"),
31 _SecretColumn("LiteLLM_CacheConfig", "id", "cache_settings"),
32 _SecretColumn("LiteLLM_ConfigOverrides", "config_type", "config_value"),
33 _SecretColumn("LiteLLM_MCPServerTable", "server_id", "credentials"),
34 _SecretColumn("LiteLLM_MCPServerTable", "server_id", "static_headers"),
35 _SecretColumn("LiteLLM_MCPServerTable", "server_id", "env_vars"),
36 _SecretColumn("LiteLLM_MCPServerTable", "server_id", "env"),
37 _SecretColumn("LiteLLM_MCPServerOAuthClient", "server_id", "credentials"),
38 _SecretColumn("LiteLLM_MCPUserCredentials", "id", "credential_b64", is_json=False),
39 _SecretColumn("LiteLLM_MCPUserEnvVars", "id", "values_b64", is_json=False),
40 _SecretColumn("LiteLLM_SSOIdentityAssertion", "user_id", "assertion_b64", is_json=False),
41 _SecretColumn("LiteLLM_TeamTable", "team_id", "metadata", only_rows_with_marked_ciphertexts=True),
42 _SecretColumn("LiteLLM_VerificationToken", "token", "metadata", only_rows_with_marked_ciphertexts=True),
43 _SecretColumn("LiteLLM_UserTable", "user_id", "metadata", only_rows_with_marked_ciphertexts=True),
44 _SecretColumn("LiteLLM_DeletedTeamTable", "id", "metadata", only_rows_with_marked_ciphertexts=True),
45 _SecretColumn("LiteLLM_DeletedVerificationToken", "id", "metadata", only_rows_with_marked_ciphertexts=True),
46)
48_STORED_VALUE: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
49_PRIMARY_KEY: Final = TypeAdapter(str)
51ReplaceCiphertext = Callable[[str], str | None]
54def replace_ciphertexts(value: JsonValue, replacement_for: ReplaceCiphertext, depth: int = 0) -> tuple[JsonValue, int]:
55 if depth > DEFAULT_MAX_RECURSE_DEPTH:
56 return value, 0
57 match value:
58 case str():
59 marker: Final = CALLBACK_VAR_ENCRYPTED_PREFIX if value.startswith(CALLBACK_VAR_ENCRYPTED_PREFIX) else ""
60 replacement: Final = replacement_for(value.removeprefix(marker))
61 return (value, 0) if replacement is None else (marker + replacement, 1)
62 case list():
63 items: Final = tuple(replace_ciphertexts(item, replacement_for, depth + 1) for item in value)
64 return [item for item, _ in items], sum(count for _, count in items)
65 case dict():
66 fields: Final = {key: replace_ciphertexts(item, replacement_for, depth + 1) for key, item in value.items()}
67 return {key: item for key, (item, _) in fields.items()}, sum(count for _, count in fields.values())
68 case _:
69 return value, 0
72async def count_values_encrypted_with(database: SupportsRawQueries, signing_key: str) -> int:
73 def keep(value: str) -> str | None:
74 return None if decrypt_if_encrypted_with(value, signing_key) is None else value
76 return sum(
77 [
78 replace_ciphertexts(_STORED_VALUE.validate_python(row[secret_column.column]), keep)[1]
79 for secret_column in await _secret_columns_in(database)
80 for row in await _rows_of(database, secret_column)
81 ]
82 )
85async def count_values_encrypted_with_or_none(
86 connect: Callable[[], Awaitable[SupportsRawQueries]], signing_key: str
87) -> int | None:
88 try:
89 return await count_values_encrypted_with(await connect(), signing_key)
90 except Exception: # noqa: BLE001 # an unreadable database must not replace the boot refusal with a traceback
91 return None
94async def reencrypt_stored_values(database: SupportsRawQueries, *, from_key: str, to_key: str) -> int:
95 def reencrypted(value: str) -> str | None:
96 plaintext: Final = decrypt_if_encrypted_with(value, from_key)
97 return None if plaintext is None else _CIPHERTEXT.validate_python(encrypt_value_helper(plaintext, to_key))
99 return sum(
100 [
101 await _reencrypt_row(database, secret_column, row, reencrypted)
102 for secret_column in await _secret_columns_in(database)
103 for row in await _rows_of(database, secret_column)
104 ]
105 )
108_CIPHERTEXT: Final = TypeAdapter(str)
111async def _secret_columns_in(database: SupportsRawQueries) -> tuple[_SecretColumn, ...]:
112 existing: Final = frozenset(
113 (row["table_name"], row["column_name"])
114 for row in await database.query_raw(
115 "SELECT table_name, column_name FROM information_schema.columns "
116 "WHERE table_schema = ANY (current_schemas(false))"
117 )
118 )
119 return tuple(
120 secret_column for secret_column in _SECRET_COLUMNS if (secret_column.table, secret_column.column) in existing
121 )
124async def _rows_of(database: SupportsRawQueries, secret_column: _SecretColumn) -> tuple[Mapping[str, object], ...]:
125 marked_only: Final = (
126 f" AND \"{secret_column.column}\"::text LIKE '%{CALLBACK_VAR_ENCRYPTED_PREFIX}%'"
127 if secret_column.only_rows_with_marked_ciphertexts
128 else ""
129 )
130 return tuple(
131 await database.query_raw(
132 f'SELECT "{secret_column.primary_key}", "{secret_column.column}" FROM "{secret_column.table}" '
133 f'WHERE "{secret_column.column}" IS NOT NULL{marked_only}'
134 )
135 )
138async def _reencrypt_row(
139 database: SupportsRawQueries,
140 secret_column: _SecretColumn,
141 row: Mapping[str, object],
142 reencrypted: ReplaceCiphertext,
143) -> int:
144 stored: Final = _STORED_VALUE.validate_python(row[secret_column.column])
145 migrated, count = replace_ciphertexts(stored, reencrypted)
146 if count == 0:
147 return 0
148 cast_to: Final = "::jsonb" if secret_column.is_json else ""
149 rows_updated: Final = await database.execute_raw(
150 f'UPDATE "{secret_column.table}" SET "{secret_column.column}" = $1{cast_to} '
151 f'WHERE "{secret_column.primary_key}" = $2 AND "{secret_column.column}" = $3{cast_to}',
152 _as_sql_parameter(migrated, secret_column),
153 _PRIMARY_KEY.validate_python(row[secret_column.primary_key]),
154 _as_sql_parameter(stored, secret_column),
155 )
156 return count if rows_updated else 0
159def _as_sql_parameter(value: JsonValue, secret_column: _SecretColumn) -> str:
160 return json.dumps(value) if secret_column.is_json else _CIPHERTEXT.validate_python(value)
163class NothingToMigrate(Enum):
164 SALT_KEY_ENCRYPTS_STORED_VALUES = "salt_key_encrypts_stored_values"
165 NO_DATABASE = "no_database"
166 NOTHING_ENCRYPTED_WITH_PREVIOUS_KEY = "nothing_encrypted_with_previous_key"
169@dataclass(frozen=True, slots=True)
170class Migrated:
171 migrated: int
172 remaining: int
175@dataclass(frozen=True, slots=True)
176class MigrationFailed:
177 error: Exception
180MigrationOutcome = NothingToMigrate | Migrated | MigrationFailed
183async def migrate_if_requested(
184 *,
185 environ: Mapping[str, str],
186 master_key: str | None,
187 connected_database: Callable[[], SupportsRawQueries | None],
188 log: Callable[[str], None],
189 raise_unless_tolerated: Callable[[Exception], None],
190) -> MigrationOutcome | None:
191 previous_master_key: Final = environ.get(MIGRATE_FROM_MASTER_KEY_ENV_VAR)
192 if previous_master_key is None or master_key is None: 192 ↛ 194line 192 didn't jump to line 194 because the condition on line 192 was always true
193 return None
194 outcome: Final = await migrate_from_previous_master_key(
195 previous_master_key=previous_master_key,
196 master_key=master_key,
197 salt_key_is_set=SALT_KEY_ENV_VAR in environ,
198 database=connected_database(),
199 log=log,
200 )
201 if isinstance(outcome, MigrationFailed):
202 raise_unless_tolerated(outcome.error)
203 return outcome
206async def migrate_from_previous_master_key(
207 *,
208 previous_master_key: str,
209 master_key: str,
210 salt_key_is_set: bool,
211 database: SupportsRawQueries | None,
212 log: Callable[[str], None],
213) -> MigrationOutcome:
214 outcome: Final = await _migrate_or_failure(
215 previous_master_key=previous_master_key,
216 master_key=master_key,
217 salt_key_is_set=salt_key_is_set,
218 database=database,
219 log=log,
220 )
221 log(describe_outcome(outcome))
222 return outcome
225async def _migrate_or_failure(
226 *,
227 previous_master_key: str,
228 master_key: str,
229 salt_key_is_set: bool,
230 database: SupportsRawQueries | None,
231 log: Callable[[str], None],
232) -> MigrationOutcome:
233 try:
234 return await _migrate(
235 previous_master_key=previous_master_key,
236 master_key=master_key,
237 salt_key_is_set=salt_key_is_set,
238 database=database,
239 log=log,
240 )
241 except Exception as error: # noqa: BLE001 # a value, so the boot applies its own database outage rule to it
242 return MigrationFailed(error=error)
245async def _migrate(
246 *,
247 previous_master_key: str,
248 master_key: str,
249 salt_key_is_set: bool,
250 database: SupportsRawQueries | None,
251 log: Callable[[str], None],
252) -> MigrationOutcome:
253 if salt_key_is_set:
254 return NothingToMigrate.SALT_KEY_ENCRYPTS_STORED_VALUES
255 if database is None:
256 return NothingToMigrate.NO_DATABASE
257 if previous_master_key == master_key:
258 return NothingToMigrate.NOTHING_ENCRYPTED_WITH_PREVIOUS_KEY
259 found: Final = await count_values_encrypted_with(database, previous_master_key)
260 if found == 0:
261 return NothingToMigrate.NOTHING_ENCRYPTED_WITH_PREVIOUS_KEY
262 log(f"Re-encrypting {found} stored value(s) from the {MIGRATE_FROM_MASTER_KEY_ENV_VAR} key to the new master key.")
263 migrated: Final = Migrated(
264 migrated=await reencrypt_stored_values(database, from_key=previous_master_key, to_key=master_key),
265 remaining=await count_values_encrypted_with(database, previous_master_key),
266 )
267 another_worker_migrated_everything: Final = migrated == Migrated(migrated=0, remaining=0)
268 return NothingToMigrate.NOTHING_ENCRYPTED_WITH_PREVIOUS_KEY if another_worker_migrated_everything else migrated
271def describe_outcome(outcome: MigrationOutcome) -> str:
272 match outcome:
273 case NothingToMigrate.SALT_KEY_ENCRYPTS_STORED_VALUES:
274 return (
275 f"{MIGRATE_FROM_MASTER_KEY_ENV_VAR} is set, but {SALT_KEY_ENV_VAR} is what encrypts your stored "
276 f"values, so there is nothing to migrate. You may now delete {MIGRATE_FROM_MASTER_KEY_ENV_VAR}."
277 )
278 case NothingToMigrate.NO_DATABASE:
279 return (
280 f"{MIGRATE_FROM_MASTER_KEY_ENV_VAR} is set, but no database is connected, so nothing was migrated. If "
281 f"this proxy has no database, you may now delete {MIGRATE_FROM_MASTER_KEY_ENV_VAR}."
282 )
283 case NothingToMigrate.NOTHING_ENCRYPTED_WITH_PREVIOUS_KEY:
284 return (
285 f"{MIGRATE_FROM_MASTER_KEY_ENV_VAR} is still set, but nothing in the database is left to migrate "
286 f"from that key. You may now delete {MIGRATE_FROM_MASTER_KEY_ENV_VAR}."
287 )
288 case Migrated(migrated=migrated, remaining=0):
289 return (
290 f"Done re-encrypting {migrated} stored value(s) with the new master key. You may now delete the "
291 f"{MIGRATE_FROM_MASTER_KEY_ENV_VAR} environment variable."
292 )
293 case Migrated(migrated=migrated, remaining=remaining):
294 return (
295 f"Re-encrypted {migrated} stored value(s), but {remaining} are still encrypted with the previous key "
296 f"because they changed during the migration. Keep {MIGRATE_FROM_MASTER_KEY_ENV_VAR} set and restart "
297 "the proxy to migrate them."
298 )
299 case MigrationFailed(error=error):
300 cause: Final = f"{type(error).__name__}: {error}"[:300]
301 return (
302 f"Could not migrate stored values from the {MIGRATE_FROM_MASTER_KEY_ENV_VAR} key ({cause}). Values "
303 "still encrypted with the previous key cannot be read until the migration succeeds. Keep "
304 f"{MIGRATE_FROM_MASTER_KEY_ENV_VAR} set and restart the proxy once the database is reachable."
305 )
306 case _:
307 assert_never(outcome)