mirror of
https://github.com/open-webui/open-webui.git
synced 2026-11-11 08:57:17 +00:00
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:
parent
724d2ebbf1
commit
8984376cfd
7 changed files with 231 additions and 29 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
140
test/backend/test_protected_model_access.py
Normal file
140
test/backend/test_protected_model_access.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue