From 8984376cfdcd4b13ddf86529c6c9e1386d3e0cb0 Mon Sep 17 00:00:00 2001 From: Serge Medvedev Date: Tue, 11 Aug 2026 18:10:03 +0000 Subject: [PATCH] feat: add protected base model access Allow same-owner Workspace models to inherit explicitly protected base models without granting direct user access. --- backend/open_webui/models/access_grants.py | 28 +++- .../utils/access_control/__init__.py | 27 ++-- .../components/admin/Settings/Models.svelte | 15 +- .../workspace/Models/ModelEditor.svelte | 1 + .../workspace/common/AccessControl.svelte | 43 ++++-- .../common/AccessControlModal.svelte | 6 +- test/backend/test_protected_model_access.py | 140 ++++++++++++++++++ 7 files changed, 231 insertions(+), 29 deletions(-) create mode 100644 test/backend/test_protected_model_access.py diff --git a/backend/open_webui/models/access_grants.py b/backend/open_webui/models/access_grants.py index 7cca23546c..4bacda4b10 100644 --- a/backend/open_webui/models/access_grants.py +++ b/backend/open_webui/models/access_grants.py @@ -13,7 +13,9 @@ log = logging.getLogger(__name__) PRINCIPAL_TYPE_ANYONE = 'anyone' PRINCIPAL_TYPE_GROUP = 'group' +PRINCIPAL_TYPE_MODEL = 'model' PRINCIPAL_TYPE_USER = 'user' +PERMISSION_INHERIT = 'inherit' WILDCARD_PRINCIPAL_ID = '*' @@ -147,7 +149,7 @@ def access_control_to_grants( return grants -def normalize_access_grants(access_grants: Optional[list]) -> list[dict]: +def normalize_access_grants(access_grants: Optional[list], resource_type: str | None = None) -> list[dict]: """ Normalize direct access_grants payloads from API forms. @@ -168,12 +170,17 @@ def normalize_access_grants(access_grants: Optional[list]) -> list[dict]: principal_id = grant.get('principal_id') permission = grant.get('permission') - if principal_type not in (PRINCIPAL_TYPE_USER, PRINCIPAL_TYPE_GROUP, PRINCIPAL_TYPE_ANYONE): - continue - if permission not in ('read', 'write'): - continue if not isinstance(principal_id, str) or not principal_id: continue + if principal_type == PRINCIPAL_TYPE_MODEL: + if not ( + resource_type == 'model' and principal_id == WILDCARD_PRINCIPAL_ID and permission == PERMISSION_INHERIT + ): + continue + elif principal_type not in (PRINCIPAL_TYPE_USER, PRINCIPAL_TYPE_GROUP, PRINCIPAL_TYPE_ANYONE): + continue + elif permission not in ('read', 'write'): + continue if principal_type == PRINCIPAL_TYPE_ANYONE and (principal_id != WILDCARD_PRINCIPAL_ID or permission != 'read'): continue @@ -188,6 +195,15 @@ def normalize_access_grants(access_grants: Optional[list]) -> list[dict]: return list(deduped.values()) +def has_model_inherit_grant(access_grants: Optional[list]) -> bool: + return any( + grant['principal_type'] == PRINCIPAL_TYPE_MODEL + and grant['principal_id'] == WILDCARD_PRINCIPAL_ID + and grant['permission'] == PERMISSION_INHERIT + for grant in normalize_access_grants(access_grants, resource_type='model') + ) + + def has_public_read_access_grant(access_grants: Optional[list]) -> bool: """ Returns True when a direct grant list includes wildcard public-read. @@ -458,7 +474,7 @@ class AccessGrantsTable: ) ) - normalized_grants = normalize_access_grants(access_grants) + normalized_grants = normalize_access_grants(access_grants, resource_type=resource_type) results = [] for grant_dict in normalized_grants: diff --git a/backend/open_webui/utils/access_control/__init__.py b/backend/open_webui/utils/access_control/__init__.py index 2f8c89a1d6..174826f86f 100644 --- a/backend/open_webui/utils/access_control/__init__.py +++ b/backend/open_webui/utils/access_control/__init__.py @@ -307,21 +307,21 @@ async def has_base_model_access( db=None, ) -> bool: """ - Walk the ``base_model_id`` chain and verify the caller has read access - at every hop. + Walk the ``base_model_id`` chain and require direct read access or an + explicit same-owner inheritance grant at every registered hop. - A base model without a ``model`` table row is admin-only, matching how - unregistered models are treated for direct use (``get_filtered_models`` - hides them from non-admins and ``check_model_access`` rejects them), so - a shared preset cannot be used to reach a base model the caller could - not use directly. Returns ``False`` the moment any hop denies access. + A base model without a ``model`` table row remains admin-only, matching + direct-use behavior. Returns ``False`` as soon as any hop denies access. """ - from open_webui.models.access_grants import AccessGrants + from open_webui.models.access_grants import AccessGrants, has_model_inherit_grant from open_webui.models.models import Models - base_model_id = getattr(model_info, 'base_model_id', None) - seen = {model_info.id} - while base_model_id and base_model_id not in seen: + parent_model_info = model_info + base_model_id = getattr(parent_model_info, 'base_model_id', None) + seen = {parent_model_info.id} + while base_model_id: + if base_model_id in seen: + return False seen.add(base_model_id) base_model_info = await Models.get_model_by_id(base_model_id, db=db) if base_model_info is None: @@ -336,8 +336,13 @@ async def has_base_model_access( user_group_ids=user_group_ids, db=db, ) + or ( + has_model_inherit_grant(base_model_info.access_grants) + and parent_model_info.user_id == base_model_info.user_id + ) ): return False + parent_model_info = base_model_info base_model_id = getattr(base_model_info, 'base_model_id', None) return True diff --git a/src/lib/components/admin/Settings/Models.svelte b/src/lib/components/admin/Settings/Models.svelte index 4a0106d306..f7afb33339 100644 --- a/src/lib/components/admin/Settings/Models.svelte +++ b/src/lib/components/admin/Settings/Models.svelte @@ -110,12 +110,22 @@ ); }; - const isSharedModel = (model) => (model?.access_grants ?? []).length > 0 && !isPublicModel(model); + const isProtectedModel = (model) => { + return (model?.access_grants ?? []).some( + (g) => g.principal_type === 'model' && g.principal_id === '*' && g.permission === 'inherit' + ); + }; + + const isSharedModel = (model) => + (model?.access_grants ?? []).length > 0 && !isPublicModel(model) && !isProtectedModel(model); const modelAccessLabel = (model) => { if (isPublicModel(model)) { return $i18n.t('Public'); } + if (isProtectedModel(model)) { + return 'Protected'; + } if (isSharedModel(model)) { return $i18n.t('Shared'); } @@ -126,6 +136,9 @@ if (isPublicModel(model)) { return 'text-[#4f7a5a] dark:text-[#8db395]'; } + if (isProtectedModel(model)) { + return 'text-gray-500 dark:text-gray-400'; + } if (isSharedModel(model)) { return 'text-[#4f6f93] dark:text-[#8ba6c6]'; } diff --git a/src/lib/components/workspace/Models/ModelEditor.svelte b/src/lib/components/workspace/Models/ModelEditor.svelte index 06cfb16ede..9056e073b9 100644 --- a/src/lib/components/workspace/Models/ModelEditor.svelte +++ b/src/lib/components/workspace/Models/ModelEditor.svelte @@ -498,6 +498,7 @@ accessRoles={preset ? ['read', 'write'] : ['read']} share={$user?.permissions?.sharing?.models || $user?.role === 'admin'} sharePublic={$user?.permissions?.sharing?.public_models || $user?.role === 'admin'} + allowProtected={true} shareUsers={($user?.permissions?.access_grants?.allow_users ?? true) || $user?.role === 'admin'} /> diff --git a/src/lib/components/workspace/common/AccessControl.svelte b/src/lib/components/workspace/common/AccessControl.svelte index 275d9e35b7..2e11723267 100644 --- a/src/lib/components/workspace/common/AccessControl.svelte +++ b/src/lib/components/workspace/common/AccessControl.svelte @@ -16,9 +16,9 @@ type AccessGrant = { id?: string; - principal_type: 'user' | 'group' | 'anyone'; + principal_type: 'user' | 'group' | 'anyone' | 'model'; principal_id: string; - permission: 'read' | 'write'; + permission: 'read' | 'write' | 'inherit'; }; type LegacyAccessControl = { @@ -35,6 +35,7 @@ export let share = true; export let sharePublic = true; export let shareOpen = false; + export let allowProtected = false; export let shareUsers = true; export let allowGroups = true; export let defaultPermission: 'read' | 'write' = 'read'; @@ -112,7 +113,7 @@ }; for (const grant of normalized) { - if (!['read', 'write'].includes(grant.permission)) { + if (grant.permission !== 'read' && grant.permission !== 'write') { continue; } @@ -171,6 +172,14 @@ grant.permission === 'read' ); + const hasProtectedGrant = (grants: AccessGrant[]): boolean => + grants.some( + (grant) => + grant.principal_type === 'model' && + grant.principal_id === '*' && + grant.permission === 'inherit' + ); + const hasPublicWriteGrant = (grants: AccessGrant[]): boolean => grants.some( (grant) => @@ -214,18 +223,21 @@ onChange(accessGrants); }; - const getVisibility = (grants: AccessGrant[]): 'private' | 'public' | 'open' => { + const getVisibility = (grants: AccessGrant[]): 'private' | 'protected' | 'public' | 'open' => { if (hasAnyoneReadGrant(grants)) return 'open'; if (hasPublicReadGrant(grants)) return 'public'; + if (hasProtectedGrant(grants)) return 'protected'; return 'private'; }; - const setVisibility = (visibility: 'private' | 'public' | 'open') => { + const setVisibility = (visibility: 'private' | 'protected' | 'public' | 'open') => { const filtered = currentGrants().filter( (grant) => !( - (grant.principal_type === 'user' || grant.principal_type === 'anyone') && - grant.principal_id === '*' + grant.principal_id === '*' && + (grant.principal_type === 'user' || + grant.principal_type === 'anyone' || + (grant.principal_type === 'model' && grant.permission === 'inherit')) ) ); if (visibility === 'public') { @@ -240,6 +252,12 @@ principal_id: '*', permission: 'read' }); + } else if (visibility === 'protected') { + filtered.push({ + principal_type: 'model', + principal_id: '*', + permission: 'inherit' + }); } commitAccessGrants(filtered); }; @@ -480,7 +498,7 @@
- {#if getVisibility(accessGrants ?? []) === 'private'} + {#if ['private', 'protected'].includes(getVisibility(accessGrants ?? []))} { - setVisibility((e.target as HTMLSelectElement).value as 'private' | 'public' | 'open'); + setVisibility( + (e.target as HTMLSelectElement).value as 'private' | 'protected' | 'public' | 'open' + ); }} > + {#if allowProtected && (share || hasProtectedGrant(accessGrants ?? []))} + + {/if} {#if (share && sharePublic) || hasPublicReadGrant(accessGrants ?? [])} {/if} @@ -541,6 +564,8 @@
{#if getVisibility(accessGrants ?? []) === 'private'} {$i18n.t('Only select users and groups with permission can access')} + {:else if getVisibility(accessGrants ?? []) === 'protected'} + Same as Private, but Workspace models owned by the same user can use it as a base {:else if getVisibility(accessGrants ?? []) === 'public'} {$i18n.t('Accessible to all users')} {:else} diff --git a/src/lib/components/workspace/common/AccessControlModal.svelte b/src/lib/components/workspace/common/AccessControlModal.svelte index 3d67775a89..dd76029dd1 100644 --- a/src/lib/components/workspace/common/AccessControlModal.svelte +++ b/src/lib/components/workspace/common/AccessControlModal.svelte @@ -8,9 +8,9 @@ type AccessGrant = { id?: string; - principal_type: 'user' | 'group' | 'anyone'; + principal_type: 'user' | 'group' | 'anyone' | 'model'; principal_id: string; - permission: 'read' | 'write'; + permission: 'read' | 'write' | 'inherit'; }; export let show = false; @@ -21,6 +21,7 @@ export let share = true; export let sharePublic = true; export let shareOpen = false; + export let allowProtected = false; export let shareUsers = true; export let onChange = () => {}; @@ -51,6 +52,7 @@ {share} {sharePublic} {shareOpen} + {allowProtected} {shareUsers} />
diff --git a/test/backend/test_protected_model_access.py b/test/backend/test_protected_model_access.py new file mode 100644 index 0000000000..f036eab639 --- /dev/null +++ b/test/backend/test_protected_model_access.py @@ -0,0 +1,140 @@ +import asyncio +from types import SimpleNamespace + +from open_webui.models.access_grants import AccessGrants, normalize_access_grants +from open_webui.models.models import Models +from open_webui.utils.access_control import has_base_model_access + + +def inherit_grant(): + return { + 'principal_type': 'model', + 'principal_id': '*', + 'permission': 'inherit', + } + + +def test_normalize_accepts_only_wildcard_model_inherit_for_model_resources(): + accepted = normalize_access_grants([inherit_grant()], resource_type='model') + + assert [ + { + 'principal_type': grant['principal_type'], + 'principal_id': grant['principal_id'], + 'permission': grant['permission'], + } + for grant in accepted + ] == [inherit_grant()] + + assert normalize_access_grants([inherit_grant()], resource_type='tool') == [] + assert ( + normalize_access_grants( + [{**inherit_grant(), 'principal_id': 'specific-model'}], + resource_type='model', + ) + == [] + ) + assert ( + normalize_access_grants( + [{**inherit_grant(), 'permission': 'read'}], + resource_type='model', + ) + == [] + ) + + +async def deny_direct_access(**_kwargs): + return False + + +def patch_models(monkeypatch, models_by_id): + async def get_model_by_id(model_id, db=None): + return models_by_id.get(model_id) + + monkeypatch.setattr(Models, 'get_model_by_id', get_model_by_id) + monkeypatch.setattr(AccessGrants, 'has_access', deny_direct_access) + + +def test_protected_base_allows_same_owner_wrapper(monkeypatch): + wrapper = SimpleNamespace(id='wrapper', user_id='owner', base_model_id='base') + base = SimpleNamespace( + id='base', + user_id='owner', + base_model_id=None, + access_grants=[inherit_grant()], + ) + patch_models(monkeypatch, {'base': base}) + + assert asyncio.run(has_base_model_access('caller', wrapper, user_role='user')) is True + + +def test_protected_base_denies_different_owner_wrapper(monkeypatch): + wrapper = SimpleNamespace(id='wrapper', user_id='wrapper-owner', base_model_id='base') + base = SimpleNamespace( + id='base', + user_id='base-owner', + base_model_id=None, + access_grants=[inherit_grant()], + ) + patch_models(monkeypatch, {'base': base}) + + assert asyncio.run(has_base_model_access('caller', wrapper, user_role='user')) is False + + +def test_private_base_still_denies_mediated_access(monkeypatch): + wrapper = SimpleNamespace(id='wrapper', user_id='owner', base_model_id='base') + base = SimpleNamespace(id='base', user_id='owner', base_model_id=None, access_grants=[]) + patch_models(monkeypatch, {'base': base}) + + assert asyncio.run(has_base_model_access('caller', wrapper, user_role='user')) is False + + +def test_protected_chain_allows_matching_owner_at_every_edge(monkeypatch): + wrapper = SimpleNamespace(id='wrapper', user_id='owner', base_model_id='middle') + middle = SimpleNamespace( + id='middle', + user_id='owner', + base_model_id='base', + access_grants=[inherit_grant()], + ) + base = SimpleNamespace( + id='base', + user_id='owner', + base_model_id=None, + access_grants=[inherit_grant()], + ) + patch_models(monkeypatch, {'middle': middle, 'base': base}) + + assert asyncio.run(has_base_model_access('caller', wrapper, user_role='user')) is True + + +def test_protected_chain_denies_owner_mismatch_at_deeper_edge(monkeypatch): + wrapper = SimpleNamespace(id='wrapper', user_id='owner', base_model_id='middle') + middle = SimpleNamespace( + id='middle', + user_id='owner', + base_model_id='base', + access_grants=[inherit_grant()], + ) + base = SimpleNamespace( + id='base', + user_id='other-owner', + base_model_id=None, + access_grants=[inherit_grant()], + ) + patch_models(monkeypatch, {'middle': middle, 'base': base}) + + assert asyncio.run(has_base_model_access('caller', wrapper, user_role='user')) is False + + +def test_base_model_cycle_fails_closed(monkeypatch): + wrapper = SimpleNamespace(id='wrapper', user_id='owner', base_model_id='base') + base = SimpleNamespace( + id='base', + user_id='owner', + base_model_id='wrapper', + access_grants=[inherit_grant()], + ) + patch_models(monkeypatch, {'base': base}) + + assert asyncio.run(has_base_model_access('caller', wrapper, user_role='user')) is False