feat: add protected base model access

Allow same-owner Workspace models to inherit explicitly protected base models without granting direct user access.
This commit is contained in:
Serge Medvedev 2026-08-11 18:10:03 +00:00
parent 724d2ebbf1
commit 8984376cfd
7 changed files with 231 additions and 29 deletions

View file

@ -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:

View file

@ -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

View file

@ -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]';
}

View file

@ -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'}
/>

View file

@ -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 @@
<div class="flex gap-2 items-center">
<div>
<div class="p-2 bg-black/5 dark:bg-white/5 rounded-full">
{#if getVisibility(accessGrants ?? []) === 'private'}
{#if ['private', 'protected'].includes(getVisibility(accessGrants ?? []))}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
@ -525,10 +543,15 @@
class="outline-none bg-transparent text-sm font-normal block w-fit pr-8 max-w-full placeholder-gray-400"
value={getVisibility(accessGrants ?? [])}
on:change={(e) => {
setVisibility((e.target as HTMLSelectElement).value as 'private' | 'public' | 'open');
setVisibility(
(e.target as HTMLSelectElement).value as 'private' | 'protected' | 'public' | 'open'
);
}}
>
<option class=" text-gray-700" value="private">{$i18n.t('Private')}</option>
{#if allowProtected && (share || hasProtectedGrant(accessGrants ?? []))}
<option class=" text-gray-700" value="protected">Protected</option>
{/if}
{#if (share && sharePublic) || hasPublicReadGrant(accessGrants ?? [])}
<option class=" text-gray-700" value="public">{$i18n.t('Public')}</option>
{/if}
@ -541,6 +564,8 @@
<div class=" text-xs text-gray-400 font-normal">
{#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}

View file

@ -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}
/>
</div>

View file

@ -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