feat: encrypt user valve values at rest using Fernet

User valves (per-user API keys/config for tools and functions) were
stored as plaintext JSON in the database. This adds Fernet symmetric
encryption at the model layer so valve data is encrypted at rest.

- Uses WEBUI_SECRET_KEY for encryption (no extra env var needed)
- Add valve_encryption.py with encrypt/decrypt helpers using Fernet
- Wire encrypt/decrypt in tools and functions model get/update methods
- Add migration 019 to encrypt existing plaintext valve data
- Backward compatible: decrypt_user_valves handles legacy plaintext dicts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ethan-l-geotab 2026-04-13 16:03:11 -04:00
parent 9c64d84ad9
commit 8cf75e5c18
4 changed files with 169 additions and 6 deletions

View file

@ -0,0 +1,116 @@
"""Peewee migrations -- 019_encrypt_user_valves.py.
Encrypts existing plaintext user valve data stored in user.settings JSON.
Some examples (model - class or model name)::
> Model = migrator.orm['table_name'] # Return model in current state by name
> Model = migrator.ModelClass # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.run(func, *args, **kwargs) # Run python function with the given args
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.add_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
> migrator.add_constraint(model, name, sql)
> migrator.drop_index(model, *col_names)
> migrator.drop_not_null(model, *field_names)
> migrator.drop_constraints(model, *constraints)
"""
from contextlib import suppress
import json
import peewee as pw
from peewee_migrate import Migrator
from open_webui.utils.valve_encryption import encrypt_user_valves
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Encrypt existing plaintext user valves in user.settings."""
cursor = database.execute_sql('SELECT "id", "settings" FROM "user" WHERE "settings" IS NOT NULL')
for user_id, settings_raw in cursor.fetchall():
if not settings_raw:
continue
settings = (
json.loads(settings_raw)
if isinstance(settings_raw, str)
else settings_raw
)
changed = False
for category in ("tools", "functions"):
valves = (
settings.get(category, {}).get("valves", {})
if isinstance(settings.get(category), dict)
else {}
)
for valve_id, valve_data in valves.items():
if isinstance(valve_data, dict) and valve_data:
settings[category]["valves"][valve_id] = encrypt_user_valves(
valve_data
)
changed = True
if changed:
database.execute_sql(
'UPDATE "user" SET "settings" = %s WHERE "id" = %s',
(json.dumps(settings), user_id),
)
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Decrypt user valves back to plaintext."""
from open_webui.utils.crypto import decrypt_user_valves
cursor = database.execute_sql('SELECT "id", "settings" FROM "user" WHERE "settings" IS NOT NULL')
for user_id, settings_raw in cursor.fetchall():
if not settings_raw:
continue
settings = (
json.loads(settings_raw)
if isinstance(settings_raw, str)
else settings_raw
)
changed = False
for category in ("tools", "functions"):
valves = (
settings.get(category, {}).get("valves", {})
if isinstance(settings.get(category), dict)
else {}
)
for valve_id, valve_data in valves.items():
if isinstance(valve_data, str):
try:
settings[category]["valves"][valve_id] = decrypt_user_valves(
valve_data
)
changed = True
except Exception:
pass
if changed:
database.execute_sql(
'UPDATE "user" SET "settings" = %s WHERE "id" = %s',
(json.dumps(settings), user_id),
)

View file

@ -6,6 +6,7 @@ from sqlalchemy import select, delete, update
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.users import Users, UserModel, UserResponse
from open_webui.utils.valve_encryption import decrypt_user_valves, encrypt_user_valves
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, Index
@ -357,7 +358,8 @@ class FunctionsTable:
if 'valves' not in user_settings['functions']:
user_settings['functions']['valves'] = {}
return user_settings['functions']['valves'].get(id, {})
stored = user_settings['functions']['valves'].get(id, {})
return decrypt_user_valves(stored)
except Exception as e:
log.exception(f'Error getting user values by id {id} and user id {user_id}')
return None
@ -375,12 +377,12 @@ class FunctionsTable:
if 'valves' not in user_settings['functions']:
user_settings['functions']['valves'] = {}
user_settings['functions']['valves'][id] = valves
user_settings['functions']['valves'][id] = encrypt_user_valves(valves)
# Update the user settings in the database
await Users.update_user_by_id(user_id, {'settings': user_settings}, db=db)
return user_settings['functions']['valves'][id]
return valves
except Exception as e:
log.exception(f'Error updating user valves by id {id} and user_id {user_id}: {e}')
return None

View file

@ -8,6 +8,7 @@ from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.users import Users, UserResponse
from open_webui.models.groups import Groups
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from open_webui.utils.valve_encryption import decrypt_user_valves, encrypt_user_valves
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import BigInteger, Column, String, Text
@ -244,7 +245,8 @@ class ToolsTable:
if 'valves' not in user_settings['tools']:
user_settings['tools']['valves'] = {}
return user_settings['tools']['valves'].get(id, {})
stored = user_settings['tools']['valves'].get(id, {})
return decrypt_user_valves(stored)
except Exception as e:
log.exception(f'Error getting user values by id {id} and user_id {user_id}: {e}')
return None
@ -262,12 +264,12 @@ class ToolsTable:
if 'valves' not in user_settings['tools']:
user_settings['tools']['valves'] = {}
user_settings['tools']['valves'][id] = valves
user_settings['tools']['valves'][id] = encrypt_user_valves(valves)
# Update the user settings in the database
await Users.update_user_by_id(user_id, {'settings': user_settings}, db=db)
return user_settings['tools']['valves'][id]
return valves
except Exception as e:
log.exception(f'Error updating user valves by id {id} and user_id {user_id}: {e}')
return None

View file

@ -0,0 +1,43 @@
import base64
import hashlib
import json
import logging
from cryptography.fernet import Fernet
from open_webui.env import WEBUI_SECRET_KEY
log = logging.getLogger(__name__)
def _make_fernet(key: str) -> Fernet:
"""Derive a Fernet instance from an arbitrary string key."""
if len(key) != 44:
key_bytes = hashlib.sha256(key.encode()).digest()
key_encoded = base64.urlsafe_b64encode(key_bytes)
else:
key_encoded = key.encode()
return Fernet(key_encoded)
_fernet = _make_fernet(WEBUI_SECRET_KEY)
def encrypt_user_valves(valves: dict) -> str:
"""Encrypt a UserValves dict to an opaque string for DB storage."""
valves_json = json.dumps(valves)
return _fernet.encrypt(valves_json.encode()).decode()
def decrypt_user_valves(stored) -> dict:
"""Decrypt UserValves from DB storage. Handles both encrypted (str) and legacy plaintext (dict)."""
if isinstance(stored, dict):
return stored
if isinstance(stored, str):
try:
decrypted = _fernet.decrypt(stored.encode()).decode()
return json.loads(decrypted)
except Exception as e:
log.error(f"Error decrypting user valves: {type(e).__name__}: {e}")
raise
return {}