feat/enh: api keys user permission

breaking change, `ENABLE_API_KEY` renamed to `ENABLE_API_KEYS` and disabled by default and must be explicitly toggled on.
This commit is contained in:
Timothy Jaeryang Baek 2025-11-19 01:50:52 -05:00
parent f89c170566
commit 7031bb9067
10 changed files with 90 additions and 53 deletions

View file

@ -287,25 +287,30 @@ class AppConfig:
# WEBUI_AUTH (Required for security) # WEBUI_AUTH (Required for security)
#################################### ####################################
ENABLE_API_KEY = PersistentConfig( ENABLE_API_KEYS = PersistentConfig(
"ENABLE_API_KEY", "ENABLE_API_KEYS",
"auth.api_key.enable", "auth.enable_api_keys",
os.environ.get("ENABLE_API_KEY", "True").lower() == "true", os.environ.get("ENABLE_API_KEYS", "False").lower() == "true",
) )
ENABLE_API_KEY_ENDPOINT_RESTRICTIONS = PersistentConfig( ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = PersistentConfig(
"ENABLE_API_KEY_ENDPOINT_RESTRICTIONS", "ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS",
"auth.api_key.endpoint_restrictions", "auth.api_key.endpoint_restrictions",
os.environ.get("ENABLE_API_KEY_ENDPOINT_RESTRICTIONS", "False").lower() == "true", os.environ.get(
"ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS",
os.environ.get("ENABLE_API_KEY_ENDPOINT_RESTRICTIONS", "False"),
).lower()
== "true",
) )
API_KEY_ALLOWED_ENDPOINTS = PersistentConfig( API_KEYS_ALLOWED_ENDPOINTS = PersistentConfig(
"API_KEY_ALLOWED_ENDPOINTS", "API_KEYS_ALLOWED_ENDPOINTS",
"auth.api_key.allowed_endpoints", "auth.api_key.allowed_endpoints",
os.environ.get("API_KEY_ALLOWED_ENDPOINTS", ""), os.environ.get(
"API_KEYS_ALLOWED_ENDPOINTS", os.environ.get("API_KEY_ALLOWED_ENDPOINTS", "")
),
) )
JWT_EXPIRES_IN = PersistentConfig( JWT_EXPIRES_IN = PersistentConfig(
"JWT_EXPIRES_IN", "auth.jwt_expiry", os.environ.get("JWT_EXPIRES_IN", "4w") "JWT_EXPIRES_IN", "auth.jwt_expiry", os.environ.get("JWT_EXPIRES_IN", "4w")
) )
@ -1395,6 +1400,10 @@ USER_PERMISSIONS_FEATURES_NOTES = (
os.environ.get("USER_PERMISSIONS_FEATURES_NOTES", "True").lower() == "true" os.environ.get("USER_PERMISSIONS_FEATURES_NOTES", "True").lower() == "true"
) )
USER_PERMISSIONS_FEATURES_API_KEYS = (
os.environ.get("USER_PERMISSIONS_FEATURES_API_KEYS", "False").lower() == "true"
)
DEFAULT_USER_PERMISSIONS = { DEFAULT_USER_PERMISSIONS = {
"workspace": { "workspace": {
@ -1438,6 +1447,7 @@ DEFAULT_USER_PERMISSIONS = {
"temporary_enforced": USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED, "temporary_enforced": USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED,
}, },
"features": { "features": {
"api_keys": USER_PERMISSIONS_FEATURES_API_KEYS,
"direct_tool_servers": USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS, "direct_tool_servers": USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS,
"web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH, "web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH,
"image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION, "image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION,

View file

@ -357,9 +357,9 @@ from open_webui.config import (
JWT_EXPIRES_IN, JWT_EXPIRES_IN,
ENABLE_SIGNUP, ENABLE_SIGNUP,
ENABLE_LOGIN_FORM, ENABLE_LOGIN_FORM,
ENABLE_API_KEY, ENABLE_API_KEYS,
ENABLE_API_KEY_ENDPOINT_RESTRICTIONS, ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
API_KEY_ALLOWED_ENDPOINTS, API_KEYS_ALLOWED_ENDPOINTS,
ENABLE_CHANNELS, ENABLE_CHANNELS,
ENABLE_NOTES, ENABLE_NOTES,
ENABLE_COMMUNITY_SHARING, ENABLE_COMMUNITY_SHARING,
@ -741,11 +741,11 @@ app.state.config.WEBUI_URL = WEBUI_URL
app.state.config.ENABLE_SIGNUP = ENABLE_SIGNUP app.state.config.ENABLE_SIGNUP = ENABLE_SIGNUP
app.state.config.ENABLE_LOGIN_FORM = ENABLE_LOGIN_FORM app.state.config.ENABLE_LOGIN_FORM = ENABLE_LOGIN_FORM
app.state.config.ENABLE_API_KEY = ENABLE_API_KEY app.state.config.ENABLE_API_KEYS = ENABLE_API_KEYS
app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS = ( app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = (
ENABLE_API_KEY_ENDPOINT_RESTRICTIONS ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS
) )
app.state.config.API_KEY_ALLOWED_ENDPOINTS = API_KEY_ALLOWED_ENDPOINTS app.state.config.API_KEYS_ALLOWED_ENDPOINTS = API_KEYS_ALLOWED_ENDPOINTS
app.state.config.JWT_EXPIRES_IN = JWT_EXPIRES_IN app.state.config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
@ -1286,11 +1286,11 @@ class APIKeyRestrictionMiddleware(BaseHTTPMiddleware):
# Only apply restrictions if an sk- API key is used # Only apply restrictions if an sk- API key is used
if token and token.startswith("sk-"): if token and token.startswith("sk-"):
# Check if restrictions are enabled # Check if restrictions are enabled
if request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS: if request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS:
allowed_paths = [ allowed_paths = [
path.strip() path.strip()
for path in str( for path in str(
request.app.state.config.API_KEY_ALLOWED_ENDPOINTS request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS
).split(",") ).split(",")
if path.strip() if path.strip()
] ]
@ -1333,7 +1333,7 @@ async def check_url(request: Request, call_next):
request.headers.get("Authorization") request.headers.get("Authorization")
) )
request.state.enable_api_key = app.state.config.ENABLE_API_KEY request.state.enable_api_keys = app.state.config.ENABLE_API_KEYS
response = await call_next(request) response = await call_next(request)
process_time = int(time.time()) - start_time process_time = int(time.time()) - start_time
response.headers["X-Process-Time"] = str(process_time) response.headers["X-Process-Time"] = str(process_time)
@ -1839,7 +1839,7 @@ async def get_app_config(request: Request):
"auth_trusted_header": bool(app.state.AUTH_TRUSTED_EMAIL_HEADER), "auth_trusted_header": bool(app.state.AUTH_TRUSTED_EMAIL_HEADER),
"enable_signup_password_confirmation": ENABLE_SIGNUP_PASSWORD_CONFIRMATION, "enable_signup_password_confirmation": ENABLE_SIGNUP_PASSWORD_CONFIRMATION,
"enable_ldap": app.state.config.ENABLE_LDAP, "enable_ldap": app.state.config.ENABLE_LDAP,
"enable_api_key": app.state.config.ENABLE_API_KEY, "enable_api_keys": app.state.config.ENABLE_API_KEYS,
"enable_signup": app.state.config.ENABLE_SIGNUP, "enable_signup": app.state.config.ENABLE_SIGNUP,
"enable_login_form": app.state.config.ENABLE_LOGIN_FORM, "enable_login_form": app.state.config.ENABLE_LOGIN_FORM,
"enable_websocket": ENABLE_WEBSOCKET_SUPPORT, "enable_websocket": ENABLE_WEBSOCKET_SUPPORT,

View file

@ -55,7 +55,7 @@ from open_webui.utils.auth import (
get_http_authorization_cred, get_http_authorization_cred,
) )
from open_webui.utils.webhook import post_webhook from open_webui.utils.webhook import post_webhook
from open_webui.utils.access_control import get_permissions from open_webui.utils.access_control import get_permissions, has_permission
from typing import Optional, List from typing import Optional, List
@ -853,9 +853,9 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS, "SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
"WEBUI_URL": request.app.state.config.WEBUI_URL, "WEBUI_URL": request.app.state.config.WEBUI_URL,
"ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP, "ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP,
"ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY, "ENABLE_API_KEYS": request.app.state.config.ENABLE_API_KEYS,
"ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS, "ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
"API_KEY_ALLOWED_ENDPOINTS": request.app.state.config.API_KEY_ALLOWED_ENDPOINTS, "API_KEYS_ALLOWED_ENDPOINTS": request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS,
"DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE, "DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN, "JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING, "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
@ -873,9 +873,9 @@ class AdminConfig(BaseModel):
SHOW_ADMIN_DETAILS: bool SHOW_ADMIN_DETAILS: bool
WEBUI_URL: str WEBUI_URL: str
ENABLE_SIGNUP: bool ENABLE_SIGNUP: bool
ENABLE_API_KEY: bool ENABLE_API_KEYS: bool
ENABLE_API_KEY_ENDPOINT_RESTRICTIONS: bool ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS: bool
API_KEY_ALLOWED_ENDPOINTS: str API_KEYS_ALLOWED_ENDPOINTS: str
DEFAULT_USER_ROLE: str DEFAULT_USER_ROLE: str
JWT_EXPIRES_IN: str JWT_EXPIRES_IN: str
ENABLE_COMMUNITY_SHARING: bool ENABLE_COMMUNITY_SHARING: bool
@ -896,12 +896,12 @@ async def update_admin_config(
request.app.state.config.WEBUI_URL = form_data.WEBUI_URL request.app.state.config.WEBUI_URL = form_data.WEBUI_URL
request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP
request.app.state.config.ENABLE_API_KEY = form_data.ENABLE_API_KEY request.app.state.config.ENABLE_API_KEYS = form_data.ENABLE_API_KEYS
request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS = ( request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = (
form_data.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS form_data.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS
) )
request.app.state.config.API_KEY_ALLOWED_ENDPOINTS = ( request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS = (
form_data.API_KEY_ALLOWED_ENDPOINTS form_data.API_KEYS_ALLOWED_ENDPOINTS
) )
request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS
@ -936,9 +936,9 @@ async def update_admin_config(
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS, "SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
"WEBUI_URL": request.app.state.config.WEBUI_URL, "WEBUI_URL": request.app.state.config.WEBUI_URL,
"ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP, "ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP,
"ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY, "ENABLE_API_KEYS": request.app.state.config.ENABLE_API_KEYS,
"ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS, "ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
"API_KEY_ALLOWED_ENDPOINTS": request.app.state.config.API_KEY_ALLOWED_ENDPOINTS, "API_KEYS_ALLOWED_ENDPOINTS": request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS,
"DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE, "DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN, "JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING, "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
@ -1063,9 +1063,11 @@ async def update_ldap_config(
# create api key # create api key
@router.post("/api_key", response_model=ApiKey) @router.post("/api_key", response_model=ApiKey)
async def generate_api_key(request: Request, user=Depends(get_current_user)): async def generate_api_key(request: Request, user=Depends(get_current_user)):
if not request.app.state.config.ENABLE_API_KEY: if not request.app.state.config.ENABLE_API_KEYS or not has_permission(
user.id, "features.api_keys", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException( raise HTTPException(
status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.API_KEY_CREATION_NOT_ALLOWED, detail=ERROR_MESSAGES.API_KEY_CREATION_NOT_ALLOWED,
) )

View file

@ -208,6 +208,7 @@ class ChatPermissions(BaseModel):
class FeaturesPermissions(BaseModel): class FeaturesPermissions(BaseModel):
api_keys: bool = False
direct_tool_servers: bool = False direct_tool_servers: bool = False
web_search: bool = True web_search: bool = True
image_generation: bool = True image_generation: bool = True

View file

@ -21,6 +21,8 @@ from typing import Optional, Union, List, Dict
from opentelemetry import trace from opentelemetry import trace
from open_webui.utils.access_control import has_permission
from open_webui.models.users import Users from open_webui.models.users import Users
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
@ -228,13 +230,17 @@ def get_current_user(
# auth by api key # auth by api key
if token.startswith("sk-"): if token.startswith("sk-"):
if not request.state.enable_api_key: user = get_current_user_by_api_key(token)
if not request.state.enable_api_keys or not has_permission(
user.id,
"features.api_keys",
request.app.state.config.USER_PERMISSIONS,
):
raise HTTPException( raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED
) )
user = get_current_user_by_api_key(token)
# Add user info to current span # Add user info to current span
current_span = trace.get_current_span() current_span = trace.get_current_span()
if current_span: if current_span:

View file

@ -338,21 +338,21 @@
</div> </div>
<div class="mb-2.5 flex w-full justify-between pr-2"> <div class="mb-2.5 flex w-full justify-between pr-2">
<div class=" self-center text-xs font-medium">{$i18n.t('Enable API Key')}</div> <div class=" self-center text-xs font-medium">{$i18n.t('Enable API Keys')}</div>
<Switch bind:state={adminConfig.ENABLE_API_KEY} /> <Switch bind:state={adminConfig.ENABLE_API_KEYS} />
</div> </div>
{#if adminConfig?.ENABLE_API_KEY} {#if adminConfig?.ENABLE_API_KEYS}
<div class="mb-2.5 flex w-full justify-between pr-2"> <div class="mb-2.5 flex w-full justify-between pr-2">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{$i18n.t('API Key Endpoint Restrictions')} {$i18n.t('API Key Endpoint Restrictions')}
</div> </div>
<Switch bind:state={adminConfig.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS} /> <Switch bind:state={adminConfig.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS} />
</div> </div>
{#if adminConfig?.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS} {#if adminConfig?.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS}
<div class=" flex w-full flex-col pr-2 mb-2.5"> <div class=" flex w-full flex-col pr-2 mb-2.5">
<div class=" text-xs font-medium"> <div class=" text-xs font-medium">
{$i18n.t('Allowed Endpoints')} {$i18n.t('Allowed Endpoints')}
@ -362,7 +362,7 @@
class="w-full mt-1 text-sm dark:text-gray-300 bg-transparent outline-hidden" class="w-full mt-1 text-sm dark:text-gray-300 bg-transparent outline-hidden"
type="text" type="text"
placeholder={`e.g.) /api/v1/messages, /api/v1/channels`} placeholder={`e.g.) /api/v1/messages, /api/v1/channels`}
bind:value={adminConfig.API_KEY_ALLOWED_ENDPOINTS} bind:value={adminConfig.API_KEYS_ALLOWED_ENDPOINTS}
/> />
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500"> <div class="mt-2 text-xs text-gray-400 dark:text-gray-500">

View file

@ -77,6 +77,7 @@
temporary_enforced: false temporary_enforced: false
}, },
features: { features: {
api_keys: false,
direct_tool_servers: false, direct_tool_servers: false,
web_search: true, web_search: true,
image_generation: true, image_generation: true,

View file

@ -48,6 +48,7 @@
temporary_enforced: false temporary_enforced: false
}, },
features: { features: {
api_keys: false,
direct_tool_servers: false, direct_tool_servers: false,
web_search: true, web_search: true,
image_generation: true, image_generation: true,
@ -613,6 +614,22 @@
<div> <div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Features Permissions')}</div> <div class=" mb-2 text-sm font-medium">{$i18n.t('Features Permissions')}</div>
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('API Keys')}
</div>
<Switch bind:state={permissions.features.api_keys} />
</div>
{#if defaultPermissions?.features?.api_keys && !permissions.features.api_keys}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1"> <div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">

View file

@ -242,7 +242,7 @@
</div> </div>
{/if} {/if}
{#if ($config?.features?.enable_api_key ?? true) || $user?.role === 'admin'} {#if ($config?.features?.enable_api_keys ?? true) && ($user?.role === 'admin' || ($user?.permissions?.features?.api_keys ?? false))}
<div class="flex justify-between items-center text-sm mt-2"> <div class="flex justify-between items-center text-sm mt-2">
<div class=" font-medium">{$i18n.t('API keys')}</div> <div class=" font-medium">{$i18n.t('API keys')}</div>
<button <button
@ -255,9 +255,9 @@
</div> </div>
{#if showAPIKeys} {#if showAPIKeys}
<div class="flex flex-col py-2.5"> <div class="flex flex-col">
{#if $user?.role === 'admin'} {#if $user?.role === 'admin'}
<div class="justify-between w-full"> <div class="justify-between w-full mt-2">
<div class="flex justify-between w-full"> <div class="flex justify-between w-full">
<div class="self-center text-xs font-medium mb-1">{$i18n.t('JWT Token')}</div> <div class="self-center text-xs font-medium mb-1">{$i18n.t('JWT Token')}</div>
</div> </div>
@ -312,7 +312,7 @@
</div> </div>
{/if} {/if}
{#if $config?.features?.enable_api_key ?? true} {#if ($config?.features?.enable_api_keys ?? true) && ($user?.role === 'admin' || ($user?.permissions?.features?.api_keys ?? false))}
<div class="justify-between w-full mt-2"> <div class="justify-between w-full mt-2">
{#if $user?.role === 'admin'} {#if $user?.role === 'admin'}
<div class="flex justify-between w-full"> <div class="flex justify-between w-full">

View file

@ -264,7 +264,7 @@ type Config = {
features: { features: {
auth: boolean; auth: boolean;
auth_trusted_header: boolean; auth_trusted_header: boolean;
enable_api_key: boolean; enable_api_keys: boolean;
enable_signup: boolean; enable_signup: boolean;
enable_login_form: boolean; enable_login_form: boolean;
enable_web_search?: boolean; enable_web_search?: boolean;