/ restai / settings.py
settings.py
  1  """Settings persistence and seeding.
  2  
  3  GUI-managed settings live in the `settings` DB table. Consumers read them
  4  through `restai.config.<NAME>` — the `__getattr__` in `restai/config.py`
  5  translates that to a DB lookup on every access, so multi-worker uvicorn
  6  deployments see admin changes immediately without any in-process mirror.
  7  
  8  Why no env-var fallback / no `setattr(config, ...)` mirror anymore:
  9    Earlier versions seeded these settings from env vars on first install AND
 10    pushed each updated value back onto the `restai.config` module via setattr.
 11    The mirror only landed in the worker that handled the PATCH, so other
 12    workers still saw the old value (or the empty default) — that's the
 13    multi-worker drift that broke `POST /settings/docker/test` intermittently.
 14    Settings are now DB-only and admins set them in the platform Settings page.
 15  """
 16  from restai.models.databasemodels import SettingDatabase
 17  
 18  # Mapping: setting_key -> default_value (string form). Env vars no longer
 19  # bootstrap these — admins set everything in the GUI.
 20  SETTINGS_DEFAULTS = {
 21      "app_name": "RESTai",
 22      "hide_branding": "false",
 23      "proxy_enabled": "false",
 24      "proxy_url": "",
 25      "proxy_key": "",
 26      "proxy_team_id": "",
 27      "max_audio_upload_size": "10",
 28      "currency": "EUR",
 29      "redis_host": "",
 30      "redis_port": "6379",
 31      "redis_password": "",
 32      "redis_database": "0",
 33      # Authentication
 34      "auth_disable_local": "false",
 35      "sso_auto_create_user": "false",
 36      "sso_allowed_domains": "*",
 37      "sso_auto_restricted": "true",
 38      "sso_auto_team_id": "",
 39      # Google OAuth
 40      "sso_google_client_id": "",
 41      "sso_google_client_secret": "",
 42      "sso_google_redirect_uri": "",
 43      "sso_google_scope": "openid email profile",
 44      # Microsoft OAuth
 45      "sso_microsoft_client_id": "",
 46      "sso_microsoft_client_secret": "",
 47      "sso_microsoft_tenant_id": "",
 48      "sso_microsoft_redirect_uri": "",
 49      "sso_microsoft_scope": "openid email profile",
 50      # GitHub OAuth
 51      "sso_github_client_id": "",
 52      "sso_github_client_secret": "",
 53      "sso_github_redirect_uri": "",
 54      "sso_github_scope": "user:email",
 55      # Generic OIDC
 56      "sso_oidc_client_id": "",
 57      "sso_oidc_client_secret": "",
 58      "sso_oidc_provider_url": "",
 59      "sso_oidc_redirect_uri": "",
 60      "sso_oidc_scopes": "openid email profile",
 61      "sso_oidc_provider_name": "SSO",
 62      "sso_oidc_email_claim": "email",
 63      # GPU. Empty string means "auto-detect" — config.RESTAI_GPU resolves it.
 64      "gpu_enabled": "",
 65      "gpu_worker_devices": "",
 66      # MCP
 67      "mcp_enabled": "false",
 68      # System LLM
 69      "system_llm": "",
 70      # Docker
 71      "docker_enabled": "false",
 72      "docker_url": "",
 73      "docker_image": "python:3.12-slim",
 74      "docker_timeout": "900",
 75      "docker_network": "none",
 76      "docker_read_only": "true",
 77      # Agentic Browser (Playwright-backed per-chat Chromium container)
 78      "browser_enabled": "false",
 79      "browser_image": "mcr.microsoft.com/playwright/python:v1.48.0-jammy",
 80      "browser_network": "bridge",
 81      "browser_timeout": "900",
 82      # Retention
 83      "data_retention_days": "0",
 84      # 2FA
 85      "enforce_2fa": "false",
 86      # Password rotation reminder. 0 = disabled (no warning ever).
 87      # Soft-only — passwords stay valid past the threshold; the login
 88      # response just includes a `password_warning` field so the UI can
 89      # nudge the user to rotate.
 90      "password_max_age_days": "0",
 91      # Telemetry
 92      "telemetry_instance_id": "",
 93  }
 94  
 95  _BOOL_KEYS = {"hide_branding", "proxy_enabled", "auth_disable_local", "sso_auto_create_user", "sso_auto_restricted", "gpu_enabled", "mcp_enabled", "docker_enabled", "docker_read_only", "browser_enabled", "enforce_2fa"}
 96  _INT_KEYS = {"max_audio_upload_size", "data_retention_days", "docker_timeout", "browser_timeout", "password_max_age_days"}
 97  
 98  # Secret keys that should be masked in API responses
 99  _SECRET_KEYS = {
100      "proxy_key", "redis_password",
101      "sso_google_client_secret", "sso_microsoft_client_secret",
102      "sso_github_client_secret", "sso_oidc_client_secret",
103  }
104  
105  
106  def _to_bool(val: str) -> bool:
107      return val.lower() in ("true", "1")
108  
109  
110  def ensure_settings_table(engine):
111      SettingDatabase.__table__.create(engine, checkfirst=True)
112  
113  
114  def seed_defaults(db_wrapper):
115      """Insert any missing default rows. Safe to run on every boot — never
116      overwrites existing values."""
117      existing = {s.key for s in db_wrapper.get_settings()}
118      for key, default in SETTINGS_DEFAULTS.items():
119          if key not in existing:
120              db_wrapper.upsert_setting(key, default)
121  
122  
123  def update_setting(db_wrapper, key: str, value: str):
124      """Persist a single setting. No process-local mirror — every consumer
125      reads through `restai.config` which routes to the DB on demand."""
126      db_wrapper.upsert_setting(key, value)
127  
128  
129  def mask_key(value: str) -> str:
130      if not value:
131          return ""
132      if len(value) > 4:
133          return "****" + value[-4:]
134      return "****"
135  
136  
137  def get_all_settings(db_wrapper) -> dict:
138      rows = {s.key: s.value or "" for s in db_wrapper.get_settings()}
139      return {
140          "app_name": rows.get("app_name", "RESTai"),
141          "hide_branding": _to_bool(rows.get("hide_branding", "false")),
142          "proxy_enabled": _to_bool(rows.get("proxy_enabled", "false")),
143          "proxy_url": rows.get("proxy_url", ""),
144          "proxy_key": mask_key(rows.get("proxy_key", "")),
145          "proxy_team_id": rows.get("proxy_team_id", ""),
146          "max_audio_upload_size": int(rows.get("max_audio_upload_size", "10")),
147          "currency": rows.get("currency", "EUR"),
148          "redis_host": rows.get("redis_host", ""),
149          "redis_port": rows.get("redis_port", "6379"),
150          "redis_password": mask_key(rows.get("redis_password", "")),
151          "redis_database": rows.get("redis_database", "0"),
152          # Authentication
153          "auth_disable_local": _to_bool(rows.get("auth_disable_local", "false")),
154          "sso_auto_create_user": _to_bool(rows.get("sso_auto_create_user", "false")),
155          "sso_allowed_domains": rows.get("sso_allowed_domains", "*"),
156          "sso_auto_restricted": _to_bool(rows.get("sso_auto_restricted", "true")),
157          "sso_auto_team_id": rows.get("sso_auto_team_id", ""),
158          # Google OAuth
159          "sso_google_client_id": rows.get("sso_google_client_id", ""),
160          "sso_google_client_secret": mask_key(rows.get("sso_google_client_secret", "")),
161          "sso_google_redirect_uri": rows.get("sso_google_redirect_uri", ""),
162          "sso_google_scope": rows.get("sso_google_scope", "openid email profile"),
163          # Microsoft OAuth
164          "sso_microsoft_client_id": rows.get("sso_microsoft_client_id", ""),
165          "sso_microsoft_client_secret": mask_key(rows.get("sso_microsoft_client_secret", "")),
166          "sso_microsoft_tenant_id": rows.get("sso_microsoft_tenant_id", ""),
167          "sso_microsoft_redirect_uri": rows.get("sso_microsoft_redirect_uri", ""),
168          "sso_microsoft_scope": rows.get("sso_microsoft_scope", "openid email profile"),
169          # GitHub OAuth
170          "sso_github_client_id": rows.get("sso_github_client_id", ""),
171          "sso_github_client_secret": mask_key(rows.get("sso_github_client_secret", "")),
172          "sso_github_redirect_uri": rows.get("sso_github_redirect_uri", ""),
173          "sso_github_scope": rows.get("sso_github_scope", "user:email"),
174          # Generic OIDC
175          "sso_oidc_client_id": rows.get("sso_oidc_client_id", ""),
176          "sso_oidc_client_secret": mask_key(rows.get("sso_oidc_client_secret", "")),
177          "sso_oidc_provider_url": rows.get("sso_oidc_provider_url", ""),
178          "sso_oidc_redirect_uri": rows.get("sso_oidc_redirect_uri", ""),
179          "sso_oidc_scopes": rows.get("sso_oidc_scopes", "openid email profile"),
180          "sso_oidc_provider_name": rows.get("sso_oidc_provider_name", "SSO"),
181          "sso_oidc_email_claim": rows.get("sso_oidc_email_claim", "email"),
182          # GPU
183          "gpu_enabled": _to_bool(rows.get("gpu_enabled", "false")),
184          "gpu_worker_devices": rows.get("gpu_worker_devices", ""),
185          # MCP
186          "mcp_enabled": _to_bool(rows.get("mcp_enabled", "false")),
187          # System LLM
188          "system_llm": rows.get("system_llm", ""),
189          # Docker
190          "docker_enabled": _to_bool(rows.get("docker_enabled", "false")),
191          "docker_url": rows.get("docker_url", ""),
192          "docker_image": rows.get("docker_image", "python:3.12-slim"),
193          "docker_timeout": int(rows.get("docker_timeout", "900") or "900"),
194          "docker_network": rows.get("docker_network", "none"),
195          "docker_read_only": _to_bool(rows.get("docker_read_only", "true")),
196          # Agentic Browser
197          "browser_enabled": _to_bool(rows.get("browser_enabled", "false")),
198          "browser_image": rows.get("browser_image", "mcr.microsoft.com/playwright/python:v1.48.0-jammy"),
199          "browser_network": rows.get("browser_network", "bridge"),
200          "browser_timeout": int(rows.get("browser_timeout", "900") or "900"),
201          # Retention
202          "data_retention_days": int(rows.get("data_retention_days", "0") or "0"),
203          # 2FA
204          "enforce_2fa": _to_bool(rows.get("enforce_2fa", "false")),
205          # Password rotation
206          "password_max_age_days": int(rows.get("password_max_age_days", "0") or "0"),
207      }
208  
209  
210  def reinit_oauth(app):
211      """Rebuild OAuth providers from the current DB-backed settings.
212  
213      Each authlib client is per-process, so this only refreshes the worker
214      handling the PATCH. Other workers pick up the change on their next OAuth
215      request because `OAUTH_PROVIDERS` reads through `__getattr__` on every
216      `register` invocation. (The cached `oauth_manager.oauth` instance on
217      other workers may still hold stale clients until they are re-built —
218      acceptable trade-off given OAuth flows are admin-frequency, not
219      request-frequency.)
220      """
221      from restai import config
222      config.load_oauth_providers()
223      if hasattr(app.state, "oauth_manager"):
224          app.state.oauth_manager.reinit()