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()