Merge branch 'open-webui:dev' into dev

This commit is contained in:
Kevin Rohn 2026-02-10 14:15:27 +01:00 • committed by GitHub
commit 9450d5a174
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
100 changed files with 6273 additions and 1940 deletions

View file

@ -194,6 +194,15 @@ ENABLE_FORWARD_USER_INFO_HEADERS = (
os.environ.get("ENABLE_FORWARD_USER_INFO_HEADERS", "False").lower() == "true"
)
# Header names for user info forwarding (customizable via environment variables)
FORWARD_USER_INFO_HEADER_USER_NAME = os.environ.get("FORWARD_USER_INFO_HEADER_USER_NAME", "X-OpenWebUI-User-Name")
FORWARD_USER_INFO_HEADER_USER_ID = os.environ.get("FORWARD_USER_INFO_HEADER_USER_ID", "X-OpenWebUI-User-Id")
FORWARD_USER_INFO_HEADER_USER_EMAIL = os.environ.get("FORWARD_USER_INFO_HEADER_USER_EMAIL", "X-OpenWebUI-User-Email")
FORWARD_USER_INFO_HEADER_USER_ROLE = os.environ.get("FORWARD_USER_INFO_HEADER_USER_ROLE", "X-OpenWebUI-User-Role")
# Header name for chat ID forwarding (customizable via environment variable)
FORWARD_SESSION_INFO_HEADER_CHAT_ID = os.environ.get("FORWARD_SESSION_INFO_HEADER_CHAT_ID", "X-OpenWebUI-Chat-Id")
# Experimental feature, may be removed in future
ENABLE_STAR_SESSIONS_MIDDLEWARE = (
os.environ.get("ENABLE_STAR_SESSIONS_MIDDLEWARE", "False").lower() == "true"

View file

@ -35,7 +35,6 @@ from open_webui.utils.plugin import (
get_function_module_from_cache,
)
from open_webui.utils.tools import get_tools
from open_webui.utils.access_control import has_access
from open_webui.env import GLOBAL_LOG_LEVEL

View file

@ -518,7 +518,6 @@ from open_webui.utils.middleware import (
process_chat_payload,
process_chat_response,
)
from open_webui.utils.access_control import has_access
from open_webui.utils.auth import (
get_license_data,
@ -1333,7 +1332,9 @@ class APIKeyRestrictionMiddleware(BaseHTTPMiddleware):
token = None
if auth_header:
scheme, token = auth_header.split(" ")
parts = auth_header.split(" ", 1)
if len(parts) == 2:
token = parts[1]
# Only apply restrictions if an sk- API key is used
if token and token.startswith("sk-"):
@ -1806,6 +1807,16 @@ async def chat_completion(
except Exception as e:
log.debug(f"Error cleaning up: {e}")
pass
# Emit chat:active=false when task completes
try:
if metadata.get("chat_id"):
event_emitter = get_event_emitter(metadata, update_db=False)
if event_emitter:
await event_emitter(
{"type": "chat:active", "data": {"active": False}}
)
except Exception as e:
log.debug(f"Error emitting chat:active: {e}")
if (
metadata.get("session_id")
@ -1818,6 +1829,12 @@ async def chat_completion(
process_chat(request, form_data, user, metadata, model),
id=metadata["chat_id"],
)
# Emit chat:active=true when task starts
event_emitter = get_event_emitter(metadata, update_db=False)
if event_emitter:
await event_emitter(
{"type": "chat:active", "data": {"active": True}}
)
return {"status": True, "task_id": task_id}
else:
return await process_chat(request, form_data, user, metadata, model)

View file

@ -0,0 +1,350 @@
"""Add access_grant table
Revision ID: f1e2d3c4b5a6
Revises: 8452d01d26d7
Create Date: 2026-02-05 10:00:00.000000
Migrates from JSON access_control columns to normalized access_grant table.
Access control semantics:
- NULL: Public access (all users can read) -> insert user:* for read
- {}: Private/owner-only (no grants) -> insert nothing
- {read: {...}, write: {...}}: Custom permissions -> insert specific grants
"""
from typing import Sequence, Union
import time
import uuid
from alembic import op
import sqlalchemy as sa
from open_webui.migrations.util import get_existing_tables
revision: str = "f1e2d3c4b5a6"
down_revision: Union[str, None] = "8452d01d26d7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
existing_tables = set(get_existing_tables())
# Create access_grant table
if "access_grant" not in existing_tables:
op.create_table(
"access_grant",
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
sa.Column("resource_type", sa.Text(), nullable=False),
sa.Column("resource_id", sa.Text(), nullable=False),
sa.Column("principal_type", sa.Text(), nullable=False),
sa.Column("principal_id", sa.Text(), nullable=False),
sa.Column("permission", sa.Text(), nullable=False),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.UniqueConstraint(
"resource_type",
"resource_id",
"principal_type",
"principal_id",
"permission",
name="uq_access_grant_grant",
),
)
op.create_index(
"idx_access_grant_resource",
"access_grant",
["resource_type", "resource_id"],
)
op.create_index(
"idx_access_grant_principal",
"access_grant",
["principal_type", "principal_id"],
)
# Backfill existing access_control JSON data
conn = op.get_bind()
# Tables with access_control JSON columns: (table_name, resource_type)
resource_tables = [
("knowledge", "knowledge"),
("prompt", "prompt"),
("tool", "tool"),
("model", "model"),
("note", "note"),
("channel", "channel"),
("file", "file"),
]
now = int(time.time())
inserted = set()
for table_name, resource_type in resource_tables:
if table_name not in existing_tables:
continue
# Query all rows
try:
result = conn.execute(
sa.text(f'SELECT id, access_control FROM "{table_name}"')
)
rows = result.fetchall()
except Exception:
continue
for row in rows:
resource_id = row[0]
access_control_json = row[1]
# Handle NULL or JSON "null" = public access (user:* for read)
# Could be Python None (SQL NULL) or string "null" (JSON null)
# EXCEPTION: files with NULL are PRIVATE (owner-only), not public
is_null = (
access_control_json is None or
access_control_json == "null" or
(isinstance(access_control_json, str) and access_control_json.strip().lower() == "null")
)
if is_null:
# Files: NULL = private (no entry needed, owner has implicit access)
# Other resources: NULL = public (insert user:* for read)
if resource_type == "file":
continue # Private - no entry needed
key = (resource_type, resource_id, "user", "*", "read")
if key not in inserted:
try:
conn.execute(
sa.text(
"""
INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at)
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
"""
),
{
"id": str(uuid.uuid4()),
"resource_type": resource_type,
"resource_id": resource_id,
"principal_type": "user",
"principal_id": "*",
"permission": "read",
"created_at": now,
},
)
inserted.add(key)
except Exception:
pass
continue
# Handle JSON parsing
if isinstance(access_control_json, str):
import json
try:
access_control_json = json.loads(access_control_json)
except Exception:
continue
# Handle {} = private/owner-only - NO entries needed
# Owner access is implicit, no grants to store
if not access_control_json or not isinstance(access_control_json, dict):
continue
# Check if it's effectively empty (no read/write keys with content)
read_data = access_control_json.get("read", {})
write_data = access_control_json.get("write", {})
has_read_grants = read_data.get("group_ids", []) or read_data.get(
"user_ids", []
)
has_write_grants = write_data.get("group_ids", []) or write_data.get(
"user_ids", []
)
if not has_read_grants and not has_write_grants:
# Empty permissions = private, no grants needed
continue
# Extract permissions and insert into access_grant table
for permission in ["read", "write"]:
perm_data = access_control_json.get(permission, {})
if not perm_data:
continue
for group_id in perm_data.get("group_ids", []):
key = (resource_type, resource_id, "group", group_id, permission)
if key in inserted:
continue
try:
conn.execute(
sa.text(
"""
INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at)
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
"""
),
{
"id": str(uuid.uuid4()),
"resource_type": resource_type,
"resource_id": resource_id,
"principal_type": "group",
"principal_id": group_id,
"permission": permission,
"created_at": now,
},
)
inserted.add(key)
except Exception:
pass
for user_id in perm_data.get("user_ids", []):
key = (resource_type, resource_id, "user", user_id, permission)
if key in inserted:
continue
try:
conn.execute(
sa.text(
"""
INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at)
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
"""
),
{
"id": str(uuid.uuid4()),
"resource_type": resource_type,
"resource_id": resource_id,
"principal_type": "user",
"principal_id": user_id,
"permission": permission,
"created_at": now,
},
)
inserted.add(key)
except Exception:
pass
# Drop access_control columns from resource tables
for table_name, _ in resource_tables:
if table_name not in existing_tables:
continue
try:
with op.batch_alter_table(table_name) as batch:
batch.drop_column("access_control")
except Exception:
pass
def downgrade() -> None:
import json
conn = op.get_bind()
# Resource tables mapping: (table_name, resource_type)
resource_tables = [
("knowledge", "knowledge"),
("prompt", "prompt"),
("tool", "tool"),
("model", "model"),
("note", "note"),
("channel", "channel"),
("file", "file"),
]
# Step 1: Re-add access_control columns to resource tables
for table_name, _ in resource_tables:
try:
with op.batch_alter_table(table_name) as batch:
batch.add_column(sa.Column("access_control", sa.JSON(), nullable=True))
except Exception:
pass
# Step 2: Query access_grant table and reconstruct JSON for each resource
for table_name, resource_type in resource_tables:
try:
# Get all grants for this resource type
result = conn.execute(
sa.text("""
SELECT resource_id, principal_type, principal_id, permission
FROM access_grant
WHERE resource_type = :resource_type
"""),
{"resource_type": resource_type}
)
rows = result.fetchall()
except Exception:
continue
# Group by resource_id and reconstruct JSON structure
resource_grants = {}
for row in rows:
resource_id = row[0]
principal_type = row[1]
principal_id = row[2]
permission = row[3]
if resource_id not in resource_grants:
resource_grants[resource_id] = {
"is_public": False,
"read": {"group_ids": [], "user_ids": []},
"write": {"group_ids": [], "user_ids": []},
}
# Handle public access (user:* for read)
if principal_type == "user" and principal_id == "*" and permission == "read":
resource_grants[resource_id]["is_public"] = True
continue
# Add to appropriate list
if permission in ["read", "write"]:
if principal_type == "group":
if principal_id not in resource_grants[resource_id][permission]["group_ids"]:
resource_grants[resource_id][permission]["group_ids"].append(principal_id)
elif principal_type == "user":
if principal_id not in resource_grants[resource_id][permission]["user_ids"]:
resource_grants[resource_id][permission]["user_ids"].append(principal_id)
# Step 3: Update each resource with reconstructed JSON
for resource_id, grants in resource_grants.items():
if grants["is_public"]:
# Public = NULL
access_control_value = None
elif (not grants["read"]["group_ids"] and not grants["read"]["user_ids"] and
not grants["write"]["group_ids"] and not grants["write"]["user_ids"]):
# No grants = should not happen (would mean no entries), default to {}
access_control_value = json.dumps({})
else:
# Custom permissions
access_control_value = json.dumps({
"read": grants["read"],
"write": grants["write"],
})
try:
conn.execute(
sa.text(f'UPDATE "{table_name}" SET access_control = :access_control WHERE id = :id'),
{"access_control": access_control_value, "id": resource_id}
)
except Exception:
pass
# Step 4: Set all resources WITHOUT entries to private
# For files: NULL means private (owner-only), so leave as NULL
# For other resources: {} means private, so update to {}
if resource_type != "file":
try:
conn.execute(
sa.text(f'''
UPDATE "{table_name}"
SET access_control = :private_value
WHERE id NOT IN (
SELECT DISTINCT resource_id FROM access_grant WHERE resource_type = :resource_type
)
AND access_control IS NULL
'''),
{"private_value": json.dumps({}), "resource_type": resource_type}
)
except Exception:
pass
# For files, NULL stays NULL - no action needed
# Step 5: Drop the access_grant table
op.drop_index("idx_access_grant_principal", table_name="access_grant")
op.drop_index("idx_access_grant_resource", table_name="access_grant")
op.drop_table("access_grant")

View file

@ -0,0 +1,776 @@
import logging
import time
import uuid
from typing import Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, UniqueConstraint, or_, and_
from sqlalchemy.dialects.postgresql import JSONB
log = logging.getLogger(__name__)
####################
# AccessGrant DB Schema
####################
class AccessGrant(Base):
__tablename__ = "access_grant"
id = Column(Text, primary_key=True)
resource_type = Column(Text, nullable=False) # "knowledge", "model", "prompt", "tool", "note", "channel", "file"
resource_id = Column(Text, nullable=False)
principal_type = Column(Text, nullable=False) # "user" or "group"
principal_id = Column(Text, nullable=False) # user_id, group_id, or "*" (wildcard for public)
permission = Column(Text, nullable=False) # "read" or "write"
created_at = Column(BigInteger, nullable=False)
__table_args__ = (
UniqueConstraint(
"resource_type",
"resource_id",
"principal_type",
"principal_id",
"permission",
name="uq_access_grant_grant",
),
)
class AccessGrantModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
resource_type: str
resource_id: str
principal_type: str
principal_id: str
permission: str
created_at: int
class AccessGrantResponse(BaseModel):
"""Slim grant model for API responses — resource context is implicit from the parent."""
id: str
principal_type: str
principal_id: str
permission: str
@classmethod
def from_grant(cls, grant: "AccessGrantModel") -> "AccessGrantResponse":
return cls(
id=grant.id,
principal_type=grant.principal_type,
principal_id=grant.principal_id,
permission=grant.permission,
)
####################
# Conversion utilities
####################
def access_control_to_grants(
resource_type: str,
resource_id: str,
access_control: Optional[dict],
) -> list[dict]:
"""
Convert an old-style access_control JSON dict to a flat list of grant dicts.
Semantics:
- None → public read (user:* read) — except files which are private
- {} → private/owner-only (no grants)
- {read: {group_ids, user_ids}, write: {group_ids, user_ids}} → specific grants
Returns a list of dicts with keys: resource_type, resource_id, principal_type, principal_id, permission
"""
grants = []
if access_control is None:
# NULL → public read (user:* for read)
# Exception: files with NULL are private (owner-only), no grants needed
if resource_type != "file":
grants.append(
{
"resource_type": resource_type,
"resource_id": resource_id,
"principal_type": "user",
"principal_id": "*",
"permission": "read",
}
)
return grants
# {} → private/owner-only, no grants
if not access_control:
return grants
# Parse structured permissions
for permission in ["read", "write"]:
perm_data = access_control.get(permission, {})
if not perm_data:
continue
for group_id in perm_data.get("group_ids", []):
grants.append(
{
"resource_type": resource_type,
"resource_id": resource_id,
"principal_type": "group",
"principal_id": group_id,
"permission": permission,
}
)
for user_id in perm_data.get("user_ids", []):
grants.append(
{
"resource_type": resource_type,
"resource_id": resource_id,
"principal_type": "user",
"principal_id": user_id,
"permission": permission,
}
)
return grants
def normalize_access_grants(access_grants: Optional[list]) -> list[dict]:
"""
Normalize direct access_grants payloads from API forms.
Keeps only valid grants and removes duplicates by
(principal_type, principal_id, permission).
"""
if not access_grants:
return []
deduped = {}
for grant in access_grants:
if isinstance(grant, BaseModel):
grant = grant.model_dump()
if not isinstance(grant, dict):
continue
principal_type = grant.get("principal_type")
principal_id = grant.get("principal_id")
permission = grant.get("permission")
if principal_type not in ("user", "group"):
continue
if permission not in ("read", "write"):
continue
if not isinstance(principal_id, str) or not principal_id:
continue
key = (principal_type, principal_id, permission)
deduped[key] = {
"id": grant.get("id")
if isinstance(grant.get("id"), str) and grant.get("id")
else str(uuid.uuid4()),
"principal_type": principal_type,
"principal_id": principal_id,
"permission": permission,
}
return list(deduped.values())
def has_public_read_access_grant(access_grants: Optional[list]) -> bool:
"""
Returns True when a direct grant list includes wildcard public-read.
"""
for grant in normalize_access_grants(access_grants):
if (
grant["principal_type"] == "user"
and grant["principal_id"] == "*"
and grant["permission"] == "read"
):
return True
return False
def grants_to_access_control(grants: list) -> Optional[dict]:
"""
Convert a list of grant objects (AccessGrantModel or AccessGrantResponse)
back to the old-style access_control JSON dict for backward compatibility.
Semantics:
- [] (empty) → {} (private/owner-only)
- Contains user:*:read → None (public), but write grants are preserved
- Otherwise → {read: {group_ids, user_ids}, write: {group_ids, user_ids}}
Note: "public" (user:*:read) still allows additional write permissions
to coexist. When the wildcard read is present the function returns None
for the legacy dict, so callers that need write info should inspect the
grants list directly.
"""
if not grants:
return {} # No grants = private/owner-only
result = {
"read": {"group_ids": [], "user_ids": []},
"write": {"group_ids": [], "user_ids": []},
}
is_public = False
for grant in grants:
if (
grant.principal_type == "user"
and grant.principal_id == "*"
and grant.permission == "read"
):
is_public = True
continue # Don't add wildcard to user_ids list
if grant.permission not in ("read", "write"):
continue
if grant.principal_type == "group":
if grant.principal_id not in result[grant.permission]["group_ids"]:
result[grant.permission]["group_ids"].append(grant.principal_id)
elif grant.principal_type == "user":
if grant.principal_id not in result[grant.permission]["user_ids"]:
result[grant.permission]["user_ids"].append(grant.principal_id)
if is_public:
return None # Public read access
return result
####################
# Table Operations
####################
class AccessGrantsTable:
def grant_access(
self,
resource_type: str,
resource_id: str,
principal_type: str,
principal_id: str,
permission: str,
db: Optional[Session] = None,
) -> Optional[AccessGrantModel]:
"""Add a single access grant. Idempotent (ignores duplicates)."""
with get_db_context(db) as db:
# Check for existing grant
existing = (
db.query(AccessGrant)
.filter_by(
resource_type=resource_type,
resource_id=resource_id,
principal_type=principal_type,
principal_id=principal_id,
permission=permission,
)
.first()
)
if existing:
return AccessGrantModel.model_validate(existing)
grant = AccessGrant(
id=str(uuid.uuid4()),
resource_type=resource_type,
resource_id=resource_id,
principal_type=principal_type,
principal_id=principal_id,
permission=permission,
created_at=int(time.time()),
)
db.add(grant)
db.commit()
db.refresh(grant)
return AccessGrantModel.model_validate(grant)
def revoke_access(
self,
resource_type: str,
resource_id: str,
principal_type: str,
principal_id: str,
permission: str,
db: Optional[Session] = None,
) -> bool:
"""Remove a single access grant."""
with get_db_context(db) as db:
deleted = (
db.query(AccessGrant)
.filter_by(
resource_type=resource_type,
resource_id=resource_id,
principal_type=principal_type,
principal_id=principal_id,
permission=permission,
)
.delete()
)
db.commit()
return deleted > 0
def revoke_all_access(
self,
resource_type: str,
resource_id: str,
db: Optional[Session] = None,
) -> int:
"""Remove all access grants for a resource."""
with get_db_context(db) as db:
deleted = (
db.query(AccessGrant)
.filter_by(
resource_type=resource_type,
resource_id=resource_id,
)
.delete()
)
db.commit()
return deleted
def set_access_control(
self,
resource_type: str,
resource_id: str,
access_control: Optional[dict],
db: Optional[Session] = None,
) -> list[AccessGrantModel]:
"""
Replace all grants for a resource from an access_control JSON dict.
This is the primary bridge for backward compat with the frontend.
"""
with get_db_context(db) as db:
# Delete all existing grants for this resource
db.query(AccessGrant).filter_by(
resource_type=resource_type,
resource_id=resource_id,
).delete()
# Convert JSON to grant dicts
grant_dicts = access_control_to_grants(
resource_type, resource_id, access_control
)
# Insert new grants
results = []
for grant_dict in grant_dicts:
grant = AccessGrant(
id=str(uuid.uuid4()),
**grant_dict,
created_at=int(time.time()),
)
db.add(grant)
results.append(grant)
db.commit()
return [AccessGrantModel.model_validate(g) for g in results]
def set_access_grants(
self,
resource_type: str,
resource_id: str,
access_grants: Optional[list],
db: Optional[Session] = None,
) -> list[AccessGrantModel]:
"""
Replace all grants for a resource from a direct access_grants list.
"""
with get_db_context(db) as db:
db.query(AccessGrant).filter_by(
resource_type=resource_type,
resource_id=resource_id,
).delete()
normalized_grants = normalize_access_grants(access_grants)
results = []
for grant_dict in normalized_grants:
grant = AccessGrant(
id=grant_dict["id"],
resource_type=resource_type,
resource_id=resource_id,
principal_type=grant_dict["principal_type"],
principal_id=grant_dict["principal_id"],
permission=grant_dict["permission"],
created_at=int(time.time()),
)
db.add(grant)
results.append(grant)
db.commit()
return [AccessGrantModel.model_validate(g) for g in results]
def get_access_control(
self,
resource_type: str,
resource_id: str,
db: Optional[Session] = None,
) -> Optional[dict]:
"""
Reconstruct the old-style access_control JSON dict from grants.
For backward compat with the frontend.
"""
with get_db_context(db) as db:
grants = (
db.query(AccessGrant)
.filter_by(
resource_type=resource_type,
resource_id=resource_id,
)
.all()
)
grant_models = [AccessGrantModel.model_validate(g) for g in grants]
return grants_to_access_control(grant_models)
def get_grants_by_resource(
self,
resource_type: str,
resource_id: str,
db: Optional[Session] = None,
) -> list[AccessGrantModel]:
"""Get all grants for a specific resource."""
with get_db_context(db) as db:
grants = (
db.query(AccessGrant)
.filter_by(
resource_type=resource_type,
resource_id=resource_id,
)
.all()
)
return [AccessGrantModel.model_validate(g) for g in grants]
def has_access(
self,
user_id: str,
resource_type: str,
resource_id: str,
permission: str = "read",
user_group_ids: Optional[set[str]] = None,
db: Optional[Session] = None,
) -> bool:
"""
Check if a user has the specified permission on a resource.
Access is granted if any of the following is true:
- There's a grant for user:* (public) with the requested permission
- There's a grant for the specific user with the requested permission
- There's a grant for any of the user's groups with the requested permission
"""
with get_db_context(db) as db:
# Build conditions for matching grants
conditions = [
# Public access
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == "*",
),
# Direct user access
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == user_id,
),
]
# Group access
if user_group_ids is None:
from open_webui.models.groups import Groups
user_groups = Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
if user_group_ids:
conditions.append(
and_(
AccessGrant.principal_type == "group",
AccessGrant.principal_id.in_(user_group_ids),
)
)
exists = (
db.query(AccessGrant)
.filter(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == resource_id,
AccessGrant.permission == permission,
or_(*conditions),
)
.first()
)
return exists is not None
def get_users_with_access(
self,
resource_type: str,
resource_id: str,
permission: str = "read",
db: Optional[Session] = None,
) -> list:
"""
Get all users who have the specified permission on a resource.
Returns a list of UserModel instances.
"""
from open_webui.models.users import Users, UserModel
from open_webui.models.groups import Groups
with get_db_context(db) as db:
grants = (
db.query(AccessGrant)
.filter_by(
resource_type=resource_type,
resource_id=resource_id,
permission=permission,
)
.all()
)
# Check for public access
for grant in grants:
if grant.principal_type == "user" and grant.principal_id == "*":
result = Users.get_users(filter={"roles": ["!pending"]}, db=db)
return result.get("users", [])
user_ids_with_access = set()
for grant in grants:
if grant.principal_type == "user":
user_ids_with_access.add(grant.principal_id)
elif grant.principal_type == "group":
group_user_ids = Groups.get_group_user_ids_by_id(
grant.principal_id, db=db
)
if group_user_ids:
user_ids_with_access.update(group_user_ids)
if not user_ids_with_access:
return []
return Users.get_users_by_user_ids(list(user_ids_with_access), db=db)
def has_permission_filter(
self,
db,
query,
DocumentModel,
filter: dict,
resource_type: str,
permission: str = "read",
):
"""
Apply access control filtering to a SQLAlchemy query by JOINing with access_grant.
This replaces the old JSON-column-based filtering with a proper relational JOIN.
"""
group_ids = filter.get("group_ids", [])
user_id = filter.get("user_id")
if permission == "read_only":
return self._has_read_only_permission_filter(
db, query, DocumentModel, filter, resource_type
)
# Build principal conditions
principal_conditions = []
if group_ids or user_id:
# Public access: user:* read
principal_conditions.append(
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == "*",
)
)
if user_id:
# Owner always has access
principal_conditions.append(DocumentModel.user_id == user_id)
# Direct user grant
principal_conditions.append(
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == user_id,
)
)
if group_ids:
# Group grants
principal_conditions.append(
and_(
AccessGrant.principal_type == "group",
AccessGrant.principal_id.in_(group_ids),
)
)
if not principal_conditions:
return query
# LEFT JOIN access_grant and filter
# We use a subquery approach to avoid duplicates from multiple matching grants
from sqlalchemy import exists as sa_exists, select
grant_exists = (
select(AccessGrant.id)
.where(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == permission,
or_(
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == "*",
),
*(
[
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == user_id,
)
]
if user_id
else []
),
*(
[
and_(
AccessGrant.principal_type == "group",
AccessGrant.principal_id.in_(group_ids),
)
]
if group_ids
else []
),
),
)
.correlate(DocumentModel)
.exists()
)
# Owner OR has a matching grant
owner_or_grant = [grant_exists]
if user_id:
owner_or_grant.append(DocumentModel.user_id == user_id)
query = query.filter(or_(*owner_or_grant))
return query
def _has_read_only_permission_filter(
self,
db,
query,
DocumentModel,
filter: dict,
resource_type: str,
):
"""
Filter for items where user has read BUT NOT write access.
Public items are NOT considered read_only.
"""
group_ids = filter.get("group_ids", [])
user_id = filter.get("user_id")
from sqlalchemy import exists as sa_exists, select
# Has read grant (not public)
read_grant_exists = (
select(AccessGrant.id)
.where(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == "read",
or_(
*(
[
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == user_id,
)
]
if user_id
else []
),
*(
[
and_(
AccessGrant.principal_type == "group",
AccessGrant.principal_id.in_(group_ids),
)
]
if group_ids
else []
),
),
)
.correlate(DocumentModel)
.exists()
)
# Does NOT have write grant
write_grant_exists = (
select(AccessGrant.id)
.where(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == "write",
or_(
*(
[
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == user_id,
)
]
if user_id
else []
),
*(
[
and_(
AccessGrant.principal_type == "group",
AccessGrant.principal_id.in_(group_ids),
)
]
if group_ids
else []
),
),
)
.correlate(DocumentModel)
.exists()
)
# Is NOT public
public_grant_exists = (
select(AccessGrant.id)
.where(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == "read",
AccessGrant.principal_type == "user",
AccessGrant.principal_id == "*",
)
.correlate(DocumentModel)
.exists()
)
conditions = [read_grant_exists, ~write_grant_exists, ~public_grant_exists]
# Not owner
if user_id:
conditions.append(DocumentModel.user_id != user_id)
query = query.filter(and_(*conditions))
return query
AccessGrants = AccessGrantsTable()

View file

@ -7,8 +7,12 @@ from typing import Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.groups import Groups
from open_webui.models.access_grants import (
AccessGrantModel,
AccessGrants,
)
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.dialects.postgresql import JSONB
@ -47,7 +51,6 @@ class Channel(Base):
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
access_control = Column(JSON, nullable=True)
created_at = Column(BigInteger)
@ -76,7 +79,7 @@ class ChannelModel(BaseModel):
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
created_at: int # timestamp in epoch (time_ns)
@ -237,7 +240,7 @@ class ChannelForm(BaseModel):
is_private: Optional[bool] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
access_grants: Optional[list[dict]] = None
group_ids: Optional[list[str]] = None
user_ids: Optional[list[str]] = None
@ -252,6 +255,18 @@ class ChannelWebhookForm(BaseModel):
class ChannelTable:
def _get_access_grants(
self, channel_id: str, db: Optional[Session] = None
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("channel", channel_id, db=db)
def _to_channel_model(
self, channel: Channel, db: Optional[Session] = None
) -> ChannelModel:
channel_data = ChannelModel.model_validate(channel).model_dump(exclude={"access_grants"})
access_grants = self._get_access_grants(channel_data["id"], db=db)
channel_data["access_grants"] = access_grants
return ChannelModel.model_validate(channel_data)
def _collect_unique_user_ids(
self,
@ -316,16 +331,17 @@ class ChannelTable:
with get_db_context(db) as db:
channel = ChannelModel(
**{
**form_data.model_dump(),
**form_data.model_dump(exclude={"access_grants"}),
"type": form_data.type if form_data.type else None,
"name": form_data.name.lower(),
"id": str(uuid.uuid4()),
"user_id": user_id,
"created_at": int(time.time_ns()),
"updated_at": int(time.time_ns()),
"access_grants": [],
}
)
new_channel = Channel(**channel.model_dump())
new_channel = Channel(**channel.model_dump(exclude={"access_grants"}))
if form_data.type in ["group", "dm"]:
users = self._collect_unique_user_ids(
@ -342,54 +358,25 @@ class ChannelTable:
db.add_all(memberships)
db.add(new_channel)
db.commit()
return channel
AccessGrants.set_access_grants(
"channel", new_channel.id, form_data.access_grants, db=db
)
return self._to_channel_model(new_channel, db=db)
def get_channels(self, db: Optional[Session] = None) -> list[ChannelModel]:
with get_db_context(db) as db:
channels = db.query(Channel).all()
return [ChannelModel.model_validate(channel) for channel in channels]
return [self._to_channel_model(channel, db=db) for channel in channels]
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
group_ids = filter.get("group_ids", [])
user_id = filter.get("user_id")
dialect_name = db.bind.dialect.name
# Public access
conditions = []
if group_ids or user_id:
conditions.extend(
[
Channel.access_control.is_(None),
cast(Channel.access_control, String) == "null",
]
)
# User-level permission
if user_id:
conditions.append(Channel.user_id == user_id)
# Group-level permission
if group_ids:
group_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_conditions.append(
Channel.access_control[permission]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_conditions.append(
cast(
Channel.access_control[permission]["group_ids"],
JSONB,
).contains([gid])
)
conditions.append(or_(*group_conditions))
if conditions:
query = query.filter(or_(*conditions))
return query
return AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Channel,
filter=filter,
resource_type="channel",
permission=permission,
)
def get_channels_by_user_id(
self, user_id: str, db: Optional[Session] = None
@ -428,7 +415,7 @@ class ChannelTable:
standard_channels = query.all()
all_channels = membership_channels + standard_channels
return [ChannelModel.model_validate(c) for c in all_channels]
return [self._to_channel_model(c, db=db) for c in all_channels]
def get_dm_channel_by_user_ids(
self, user_ids: list[str], db: Optional[Session] = None
@ -463,7 +450,7 @@ class ChannelTable:
.first()
)
return ChannelModel.model_validate(channel) if channel else None
return self._to_channel_model(channel, db=db) if channel else None
def add_members_to_channel(
self,
@ -722,7 +709,7 @@ class ChannelTable:
try:
with get_db_context(db) as db:
channel = db.query(Channel).filter(Channel.id == id).first()
return ChannelModel.model_validate(channel) if channel else None
return self._to_channel_model(channel, db=db) if channel else None
except Exception:
return None
@ -735,7 +722,7 @@ class ChannelTable:
)
channel_ids = [cf.channel_id for cf in channel_files]
channels = db.query(Channel).filter(Channel.id.in_(channel_ids)).all()
return [ChannelModel.model_validate(channel) for channel in channels]
return [self._to_channel_model(channel, db=db) for channel in channels]
def get_channels_by_file_id_and_user_id(
self, file_id: str, user_id: str, db: Optional[Session] = None
@ -783,7 +770,9 @@ class ChannelTable:
.first()
)
if membership:
allowed_channels.append(ChannelModel.model_validate(channel))
allowed_channels.append(
self._to_channel_model(channel, db=db)
)
continue
# --- Case B: standard channel => rely on ACL permissions ---
@ -798,7 +787,7 @@ class ChannelTable:
allowed = query.first()
if allowed:
allowed_channels.append(ChannelModel.model_validate(allowed))
allowed_channels.append(self._to_channel_model(allowed, db=db))
return allowed_channels
@ -832,7 +821,7 @@ class ChannelTable:
.first()
)
if membership:
return ChannelModel.model_validate(channel)
return self._to_channel_model(channel, db=db)
else:
return None
@ -854,7 +843,7 @@ class ChannelTable:
channel_allowed = query.first()
return (
ChannelModel.model_validate(channel_allowed)
self._to_channel_model(channel_allowed, db=db)
if channel_allowed
else None
)
@ -874,11 +863,14 @@ class ChannelTable:
channel.data = form_data.data
channel.meta = form_data.meta
channel.access_control = form_data.access_control
if form_data.access_grants is not None:
AccessGrants.set_access_grants(
"channel", id, form_data.access_grants, db=db
)
channel.updated_at = int(time.time_ns())
db.commit()
return ChannelModel.model_validate(channel) if channel else None
return self._to_channel_model(channel, db=db) if channel else None
def add_file_to_channel_by_id(
self, channel_id: str, file_id: str, user_id: str, db: Optional[Session] = None
@ -947,6 +939,7 @@ class ChannelTable:
def delete_channel_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
AccessGrants.revoke_all_access("channel", id, db=db)
db.query(Channel).filter(Channel.id == id).delete()
db.commit()
return True

View file

@ -26,8 +26,6 @@ class File(Base):
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
access_control = Column(JSON, nullable=True)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
@ -45,8 +43,6 @@ class FileModel(BaseModel):
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
created_at: Optional[int] # timestamp in epoch
updated_at: Optional[int] # timestamp in epoch
@ -113,7 +109,6 @@ class FileForm(BaseModel):
path: str
data: dict = {}
meta: dict = {}
access_control: Optional[dict] = None
class FileUpdateForm(BaseModel):

View file

@ -22,6 +22,7 @@ from sqlalchemy import (
ForeignKey,
cast,
or_,
select,
)
@ -99,6 +100,16 @@ class GroupResponse(GroupModel):
member_count: Optional[int] = None
class GroupInfoResponse(BaseModel):
id: str
user_id: str
name: str
description: str
member_count: Optional[int] = None
created_at: int
updated_at: int
class GroupForm(BaseModel):
name: str
description: str
@ -171,22 +182,22 @@ class GroupTable:
if share_value:
# Groups open to anyone: data is null, config.share is null, or share is true
# Use case-insensitive string comparison to handle variations like "True", "TRUE"
# Handle potential JSON boolean to string casting issues by checking for both string 'true' and boolean equivalence if possible,
anyone_can_share = or_(
Group.data.is_(None),
json_share_str.is_(None),
json_share_lower == "true",
json_share_lower == "1", # Handle SQLite boolean true
)
if member_id:
# Also include member-only groups where user is a member
member_groups_subq = (
db.query(GroupMember.group_id)
.filter(GroupMember.user_id == member_id)
.subquery()
member_groups_select = select(GroupMember.group_id).where(
GroupMember.user_id == member_id
)
members_only_and_is_member = and_(
json_share_lower == "members",
Group.id.in_(member_groups_subq),
Group.id.in_(member_groups_select),
)
query = query.filter(
or_(anyone_can_share, members_only_and_is_member)
@ -305,14 +316,14 @@ class GroupTable:
def get_group_user_ids_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[list[str]]:
) -> list[str]:
with get_db_context(db) as db:
members = (
db.query(GroupMember.user_id).filter(GroupMember.group_id == id).all()
)
if not members:
return None
return []
return [m[0] for m in members]

View file

@ -15,9 +15,10 @@ from open_webui.models.files import (
)
from open_webui.models.groups import Groups
from open_webui.models.users import User, UserModel, Users, UserResponse
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import (
BigInteger,
Column,
@ -29,9 +30,6 @@ from sqlalchemy import (
or_,
)
from open_webui.utils.access_control import has_access
from open_webui.utils.db.access_control import has_permission
log = logging.getLogger(__name__)
@ -50,22 +48,6 @@ class Knowledge(Base):
description = Column(Text)
meta = Column(JSON, nullable=True)
access_control = Column(JSON, nullable=True) # Controls data access levels.
# Defines access control rules for this entry.
# - `None`: Public access, available to all users with the "user" role.
# - `{}`: Private access, restricted exclusively to the owner.
# - Custom permissions: Specific access control for reading and writing;
# Can specify group or user-level restrictions:
# {
# "read": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# },
# "write": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# }
# }
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
@ -82,7 +64,7 @@ class KnowledgeModel(BaseModel):
meta: Optional[dict] = None
access_control: Optional[dict] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
@ -139,7 +121,7 @@ class KnowledgeUserResponse(KnowledgeUserModel):
class KnowledgeForm(BaseModel):
name: str
description: str
access_control: Optional[dict] = None
access_grants: Optional[list[dict]] = None
class FileUserResponse(FileModelResponse):
@ -157,27 +139,47 @@ class KnowledgeFileListResponse(BaseModel):
class KnowledgeTable:
def _get_access_grants(
self, knowledge_id: str, db: Optional[Session] = None
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("knowledge", knowledge_id, db=db)
def _to_knowledge_model(
self, knowledge: Knowledge, db: Optional[Session] = None
) -> KnowledgeModel:
knowledge_data = KnowledgeModel.model_validate(knowledge).model_dump(
exclude={"access_grants"}
)
knowledge_data["access_grants"] = self._get_access_grants(
knowledge_data["id"], db=db
)
return KnowledgeModel.model_validate(knowledge_data)
def insert_new_knowledge(
self, user_id: str, form_data: KnowledgeForm, db: Optional[Session] = None
) -> Optional[KnowledgeModel]:
with get_db_context(db) as db:
knowledge = KnowledgeModel(
**{
**form_data.model_dump(),
**form_data.model_dump(exclude={"access_grants"}),
"id": str(uuid.uuid4()),
"user_id": user_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
"access_grants": [],
}
)
try:
result = Knowledge(**knowledge.model_dump())
result = Knowledge(**knowledge.model_dump(exclude={"access_grants"}))
db.add(result)
db.commit()
db.refresh(result)
AccessGrants.set_access_grants(
"knowledge", result.id, form_data.access_grants, db=db
)
if result:
return KnowledgeModel.model_validate(result)
return self._to_knowledge_model(result, db=db)
else:
return None
except Exception:
@ -201,7 +203,7 @@ class KnowledgeTable:
knowledge_bases.append(
KnowledgeUserModel.model_validate(
{
**KnowledgeModel.model_validate(knowledge).model_dump(),
**self._to_knowledge_model(knowledge, db=db).model_dump(),
"user": user.model_dump() if user else None,
}
)
@ -241,7 +243,14 @@ class KnowledgeTable:
elif view_option == "shared":
query = query.filter(Knowledge.user_id != user_id)
query = has_permission(db, Knowledge, query, filter)
query = AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Knowledge,
filter=filter,
resource_type="knowledge",
permission="read",
)
query = query.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc())
@ -258,8 +267,8 @@ class KnowledgeTable:
knowledge_bases.append(
KnowledgeUserModel.model_validate(
{
**KnowledgeModel.model_validate(
knowledge_base
**self._to_knowledge_model(
knowledge_base, db=db
).model_dump(),
"user": (
UserModel.model_validate(user).model_dump()
@ -294,7 +303,14 @@ class KnowledgeTable:
# Apply access-control directly to the joined query
# This makes the database handle filtering, even with 10k+ KBs
query = has_permission(db, Knowledge, query, filter)
query = AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Knowledge,
filter=filter,
resource_type="knowledge",
permission="read",
)
# Apply filename search
if filter:
@ -327,8 +343,8 @@ class KnowledgeTable:
if user
else None
),
collection=KnowledgeModel.model_validate(
knowledge
collection=self._to_knowledge_model(
knowledge, db=db
).model_dump(),
)
)
@ -350,7 +366,14 @@ class KnowledgeTable:
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
return has_access(user_id, permission, knowledge.access_control, user_group_ids)
return AccessGrants.has_access(
user_id=user_id,
resource_type="knowledge",
resource_id=knowledge.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
def get_knowledge_bases_by_user_id(
self, user_id: str, permission: str = "write", db: Optional[Session] = None
@ -363,8 +386,13 @@ class KnowledgeTable:
knowledge_base
for knowledge_base in knowledge_bases
if knowledge_base.user_id == user_id
or has_access(
user_id, permission, knowledge_base.access_control, user_group_ids
or AccessGrants.has_access(
user_id=user_id,
resource_type="knowledge",
resource_id=knowledge_base.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
]
@ -374,7 +402,9 @@ class KnowledgeTable:
try:
with get_db_context(db) as db:
knowledge = db.query(Knowledge).filter_by(id=id).first()
return KnowledgeModel.model_validate(knowledge) if knowledge else None
return (
self._to_knowledge_model(knowledge, db=db) if knowledge else None
)
except Exception:
return None
@ -391,7 +421,14 @@ class KnowledgeTable:
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
if has_access(user_id, "write", knowledge.access_control, user_group_ids):
if AccessGrants.has_access(
user_id=user_id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="write",
user_group_ids=user_group_ids,
db=db,
):
return knowledge
return None
@ -406,9 +443,7 @@ class KnowledgeTable:
.filter(KnowledgeFile.file_id == file_id)
.all()
)
return [
KnowledgeModel.model_validate(knowledge) for knowledge in knowledges
]
return [self._to_knowledge_model(knowledge, db=db) for knowledge in knowledges]
except Exception:
return []
@ -591,11 +626,15 @@ class KnowledgeTable:
knowledge = self.get_knowledge_by_id(id=id, db=db)
db.query(Knowledge).filter_by(id=id).update(
{
**form_data.model_dump(),
**form_data.model_dump(exclude={"access_grants"}),
"updated_at": int(time.time()),
}
)
db.commit()
if form_data.access_grants is not None:
AccessGrants.set_access_grants(
"knowledge", id, form_data.access_grants, db=db
)
return self.get_knowledge_by_id(id=id, db=db)
except Exception as e:
log.exception(e)
@ -622,6 +661,7 @@ class KnowledgeTable:
def delete_knowledge_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:
AccessGrants.revoke_all_access("knowledge", id, db=db)
db.query(Knowledge).filter_by(id=id).delete()
db.commit()
return True
@ -631,6 +671,9 @@ class KnowledgeTable:
def delete_all_knowledge(self, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
try:
knowledge_ids = [row[0] for row in db.query(Knowledge.id).all()]
for knowledge_id in knowledge_ids:
AccessGrants.revoke_all_access("knowledge", knowledge_id, db=db)
db.query(Knowledge).delete()
db.commit()

View file

@ -7,18 +7,16 @@ from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.groups import Groups
from open_webui.models.users import User, UserModel, Users, UserResponse
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import String, cast, or_, and_, func
from sqlalchemy.dialects import postgresql, sqlite
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
from open_webui.utils.access_control import has_access
from sqlalchemy import BigInteger, Column, Text, Boolean
log = logging.getLogger(__name__)
@ -80,23 +78,6 @@ class Model(Base):
Holds a JSON encoded blob of metadata, see `ModelMeta`.
"""
access_control = Column(JSON, nullable=True) # Controls data access levels.
# Defines access control rules for this entry.
# - `None`: Public access, available to all users with the "user" role.
# - `{}`: Private access, restricted exclusively to the owner.
# - Custom permissions: Specific access control for reading and writing;
# Can specify group or user-level restrictions:
# {
# "read": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# },
# "write": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# }
# }
is_active = Column(Boolean, default=True)
updated_at = Column(BigInteger)
@ -112,7 +93,7 @@ class ModelModel(BaseModel):
params: ModelParams
meta: ModelMeta
access_control: Optional[dict] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
is_active: bool
updated_at: int # timestamp in epoch
@ -154,31 +135,45 @@ class ModelForm(BaseModel):
name: str
meta: ModelMeta
params: ModelParams
access_control: Optional[dict] = None
access_grants: Optional[list[dict]] = None
is_active: bool = True
class ModelsTable:
def _get_access_grants(
self, model_id: str, db: Optional[Session] = None
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("model", model_id, db=db)
def _to_model_model(self, model: Model, db: Optional[Session] = None) -> ModelModel:
model_data = ModelModel.model_validate(model).model_dump(
exclude={"access_grants"}
)
model_data["access_grants"] = self._get_access_grants(model_data["id"], db=db)
return ModelModel.model_validate(model_data)
def insert_new_model(
self, form_data: ModelForm, user_id: str, db: Optional[Session] = None
) -> Optional[ModelModel]:
model = ModelModel(
**{
**form_data.model_dump(),
"user_id": user_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
}
)
try:
with get_db_context(db) as db:
result = Model(**model.model_dump())
result = Model(
**{
**form_data.model_dump(exclude={"access_grants"}),
"user_id": user_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
}
)
db.add(result)
db.commit()
db.refresh(result)
AccessGrants.set_access_grants(
"model", result.id, form_data.access_grants, db=db
)
if result:
return ModelModel.model_validate(result)
return self._to_model_model(result, db=db)
else:
return None
except Exception as e:
@ -187,7 +182,7 @@ class ModelsTable:
def get_all_models(self, db: Optional[Session] = None) -> list[ModelModel]:
with get_db_context(db) as db:
return [ModelModel.model_validate(model) for model in db.query(Model).all()]
return [self._to_model_model(model, db=db) for model in db.query(Model).all()]
def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]:
with get_db_context(db) as db:
@ -204,7 +199,7 @@ class ModelsTable:
models.append(
ModelUserResponse.model_validate(
{
**ModelModel.model_validate(model).model_dump(),
**self._to_model_model(model, db=db).model_dump(),
"user": user.model_dump() if user else None,
}
)
@ -214,7 +209,7 @@ class ModelsTable:
def get_base_models(self, db: Optional[Session] = None) -> list[ModelModel]:
with get_db_context(db) as db:
return [
ModelModel.model_validate(model)
self._to_model_model(model, db=db)
for model in db.query(Model).filter(Model.base_model_id == None).all()
]
@ -229,50 +224,25 @@ class ModelsTable:
model
for model in models
if model.user_id == user_id
or has_access(user_id, permission, model.access_control, user_group_ids)
or AccessGrants.has_access(
user_id=user_id,
resource_type="model",
resource_id=model.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
]
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
group_ids = filter.get("group_ids", [])
user_id = filter.get("user_id")
dialect_name = db.bind.dialect.name
# Public access
conditions = []
if group_ids or user_id:
conditions.extend(
[
Model.access_control.is_(None),
cast(Model.access_control, String) == "null",
]
)
# User-level permission
if user_id:
conditions.append(Model.user_id == user_id)
# Group-level permission
if group_ids:
group_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_conditions.append(
Model.access_control[permission]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_conditions.append(
cast(
Model.access_control[permission]["group_ids"],
JSONB,
).contains([gid])
)
conditions.append(or_(*group_conditions))
if conditions:
query = query.filter(or_(*conditions))
return query
return AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Model,
filter=filter,
resource_type="model",
permission=permission,
)
def search_models(
self,
@ -358,7 +328,7 @@ class ModelsTable:
for model, user in items:
models.append(
ModelUserResponse(
**ModelModel.model_validate(model).model_dump(),
**self._to_model_model(model, db=db).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
@ -375,7 +345,7 @@ class ModelsTable:
try:
with get_db_context(db) as db:
model = db.get(Model, id)
return ModelModel.model_validate(model)
return self._to_model_model(model, db=db) if model else None
except Exception:
return None
@ -385,7 +355,7 @@ class ModelsTable:
try:
with get_db_context(db) as db:
models = db.query(Model).filter(Model.id.in_(ids)).all()
return [ModelModel.model_validate(model) for model in models]
return [self._to_model_model(model, db=db) for model in models]
except Exception:
return []
@ -403,7 +373,7 @@ class ModelsTable:
db.commit()
db.refresh(model)
return ModelModel.model_validate(model)
return self._to_model_model(model, db=db)
except Exception:
return None
@ -413,14 +383,16 @@ class ModelsTable:
try:
with get_db_context(db) as db:
# update only the fields that are present in the model
data = model.model_dump(exclude={"id"})
data = model.model_dump(exclude={"id", "access_grants"})
result = db.query(Model).filter_by(id=id).update(data)
db.commit()
if model.access_grants is not None:
AccessGrants.set_access_grants(
"model", id, model.access_grants, db=db
)
model = db.get(Model, id)
db.refresh(model)
return ModelModel.model_validate(model)
return self.get_model_by_id(id, db=db)
except Exception as e:
log.exception(f"Failed to update the model by id {id}: {e}")
return None
@ -428,6 +400,7 @@ class ModelsTable:
def delete_model_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:
AccessGrants.revoke_all_access("model", id, db=db)
db.query(Model).filter_by(id=id).delete()
db.commit()
@ -438,6 +411,9 @@ class ModelsTable:
def delete_all_models(self, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:
model_ids = [row[0] for row in db.query(Model.id).all()]
for model_id in model_ids:
AccessGrants.revoke_all_access("model", model_id, db=db)
db.query(Model).delete()
db.commit()
@ -462,7 +438,7 @@ class ModelsTable:
if model.id in existing_ids:
db.query(Model).filter_by(id=model.id).update(
{
**model.model_dump(),
**model.model_dump(exclude={"access_grants"}),
"user_id": user_id,
"updated_at": int(time.time()),
}
@ -470,22 +446,27 @@ class ModelsTable:
else:
new_model = Model(
**{
**model.model_dump(),
**model.model_dump(exclude={"access_grants"}),
"user_id": user_id,
"updated_at": int(time.time()),
}
)
db.add(new_model)
AccessGrants.set_access_grants(
"model", model.id, model.access_grants, db=db
)
# Remove models that are no longer present
for model in existing_models:
if model.id not in new_model_ids:
AccessGrants.revoke_all_access("model", model.id, db=db)
db.delete(model)
db.commit()
return [
ModelModel.model_validate(model) for model in db.query(Model).all()
self._to_model_model(model, db=db)
for model in db.query(Model).all()
]
except Exception as e:
log.exception(f"Error syncing models for user {user_id}: {e}")

View file

@ -7,17 +7,13 @@ from functools import lru_cache
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db, get_db_context
from open_webui.models.groups import Groups
from open_webui.utils.access_control import has_access
from open_webui.models.users import User, UserModel, Users, UserResponse
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import or_, func, select, and_, text, cast, or_, and_, func
from sqlalchemy.sql import exists
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import BigInteger, Column, Text, JSON
from sqlalchemy import or_, func, cast
####################
# Note DB Schema
@ -34,8 +30,6 @@ class Note(Base):
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
access_control = Column(JSON, nullable=True)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
@ -50,7 +44,7 @@ class NoteModel(BaseModel):
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
@ -65,14 +59,14 @@ class NoteForm(BaseModel):
title: str
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
access_grants: Optional[list[dict]] = None
class NoteUpdateForm(BaseModel):
title: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
access_grants: Optional[list[dict]] = None
class NoteUserResponse(NoteModel):
@ -94,122 +88,25 @@ class NoteListResponse(BaseModel):
class NoteTable:
def _get_access_grants(
self, note_id: str, db: Optional[Session] = None
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("note", note_id, db=db)
def _to_note_model(self, note: Note, db: Optional[Session] = None) -> NoteModel:
note_data = NoteModel.model_validate(note).model_dump(exclude={"access_grants"})
note_data["access_grants"] = self._get_access_grants(note_data["id"], db=db)
return NoteModel.model_validate(note_data)
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
group_ids = filter.get("group_ids", [])
user_id = filter.get("user_id")
dialect_name = db.bind.dialect.name
conditions = []
# Handle read_only permission separately
if permission == "read_only":
# For read_only, we want items where:
# 1. User has explicit read permission (via groups or user-level)
# 2. BUT does NOT have write permission
# 3. Public items are NOT considered read_only
read_conditions = []
# Group-level read permission
if group_ids:
group_read_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_read_conditions.append(
Note.access_control["read"]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_read_conditions.append(
cast(
Note.access_control["read"]["group_ids"],
JSONB,
).contains([gid])
)
if group_read_conditions:
read_conditions.append(or_(*group_read_conditions))
# Combine read conditions
if read_conditions:
has_read = or_(*read_conditions)
else:
# If no read conditions, return empty result
return query.filter(False)
# Now exclude items where user has write permission
write_exclusions = []
# Exclude items owned by user (they have implicit write)
if user_id:
write_exclusions.append(Note.user_id != user_id)
# Exclude items where user has explicit write permission via groups
if group_ids:
group_write_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_write_conditions.append(
Note.access_control["write"]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_write_conditions.append(
cast(
Note.access_control["write"]["group_ids"],
JSONB,
).contains([gid])
)
if group_write_conditions:
# User should NOT have write permission
write_exclusions.append(~or_(*group_write_conditions))
# Exclude public items (items without access_control)
write_exclusions.append(Note.access_control.isnot(None))
write_exclusions.append(cast(Note.access_control, String) != "null")
# Combine: has read AND does not have write AND not public
if write_exclusions:
query = query.filter(and_(has_read, *write_exclusions))
else:
query = query.filter(has_read)
return query
# Original logic for other permissions (read, write, etc.)
# Public access conditions
if group_ids or user_id:
conditions.extend(
[
Note.access_control.is_(None),
cast(Note.access_control, String) == "null",
]
)
# User-level permission (owner has all permissions)
if user_id:
conditions.append(Note.user_id == user_id)
# Group-level permission
if group_ids:
group_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_conditions.append(
Note.access_control[permission]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_conditions.append(
cast(
Note.access_control[permission]["group_ids"],
JSONB,
).contains([gid])
)
conditions.append(or_(*group_conditions))
if conditions:
query = query.filter(or_(*conditions))
return query
return AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Note,
filter=filter,
resource_type="note",
permission=permission,
)
def insert_new_note(
self, user_id: str, form_data: NoteForm, db: Optional[Session] = None
@ -219,17 +116,21 @@ class NoteTable:
**{
"id": str(uuid.uuid4()),
"user_id": user_id,
**form_data.model_dump(),
**form_data.model_dump(exclude={"access_grants"}),
"created_at": int(time.time_ns()),
"updated_at": int(time.time_ns()),
"access_grants": [],
}
)
new_note = Note(**note.model_dump())
new_note = Note(**note.model_dump(exclude={"access_grants"}))
db.add(new_note)
db.commit()
return note
AccessGrants.set_access_grants(
"note", note.id, form_data.access_grants, db=db
)
return self._to_note_model(new_note, db=db)
def get_notes(
self, skip: int = 0, limit: int = 50, db: Optional[Session] = None
@ -241,7 +142,7 @@ class NoteTable:
if limit is not None:
query = query.limit(limit)
notes = query.all()
return [NoteModel.model_validate(note) for note in notes]
return [self._to_note_model(note, db=db) for note in notes]
def search_notes(
self,
@ -330,7 +231,7 @@ class NoteTable:
for note, user in items:
notes.append(
NoteUserResponse(
**NoteModel.model_validate(note).model_dump(),
**self._to_note_model(note, db=db).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
@ -365,14 +266,14 @@ class NoteTable:
query = query.limit(limit)
notes = query.all()
return [NoteModel.model_validate(note) for note in notes]
return [self._to_note_model(note, db=db) for note in notes]
def get_note_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[NoteModel]:
with get_db_context(db) as db:
note = db.query(Note).filter(Note.id == id).first()
return NoteModel.model_validate(note) if note else None
return self._to_note_model(note, db=db) if note else None
def update_note_by_id(
self, id: str, form_data: NoteUpdateForm, db: Optional[Session] = None
@ -391,17 +292,20 @@ class NoteTable:
if "meta" in form_data:
note.meta = {**note.meta, **form_data["meta"]}
if "access_control" in form_data:
note.access_control = form_data["access_control"]
if "access_grants" in form_data:
AccessGrants.set_access_grants(
"note", id, form_data["access_grants"], db=db
)
note.updated_at = int(time.time_ns())
db.commit()
return NoteModel.model_validate(note) if note else None
return self._to_note_model(note, db=db) if note else None
def delete_note_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:
AccessGrants.revoke_all_access("note", id, db=db)
db.query(Note).filter(Note.id == id).delete()
db.commit()
return True

View file

@ -45,6 +45,7 @@ class PromptHistoryModel(BaseModel):
class PromptHistoryResponse(PromptHistoryModel):
"""Response model with user info."""
user: Optional[UserResponse] = None
@ -91,16 +92,20 @@ class PromptHistoryTable:
.limit(limit)
.all()
)
# Get user info for each entry
user_ids = list(set(e.user_id for e in entries))
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
return [
PromptHistoryResponse(
**PromptHistoryModel.model_validate(entry).model_dump(),
user=users_dict.get(entry.user_id).model_dump() if users_dict.get(entry.user_id) else None,
user=(
users_dict.get(entry.user_id).model_dump()
if users_dict.get(entry.user_id)
else None
),
)
for entry in entries
]
@ -112,7 +117,9 @@ class PromptHistoryTable:
) -> Optional[PromptHistoryModel]:
"""Get a specific history entry by ID."""
with get_db_context(db) as db:
entry = db.query(PromptHistory).filter(PromptHistory.id == history_id).first()
entry = (
db.query(PromptHistory).filter(PromptHistory.id == history_id).first()
)
if entry:
return PromptHistoryModel.model_validate(entry)
return None
@ -155,27 +162,31 @@ class PromptHistoryTable:
) -> Optional[dict]:
"""Compute diff between two history entries."""
with get_db_context(db) as db:
from_entry = db.query(PromptHistory).filter(PromptHistory.id == from_id).first()
from_entry = (
db.query(PromptHistory).filter(PromptHistory.id == from_id).first()
)
to_entry = db.query(PromptHistory).filter(PromptHistory.id == to_id).first()
if not from_entry or not to_entry:
return None
from_snapshot = from_entry.snapshot
to_snapshot = to_entry.snapshot
# Compute diff for content field
from_content = from_snapshot.get("content", "")
to_content = to_snapshot.get("content", "")
diff_lines = list(difflib.unified_diff(
from_content.splitlines(keepends=True),
to_content.splitlines(keepends=True),
fromfile=f"v{from_id[:8]}",
tofile=f"v{to_id[:8]}",
lineterm="",
))
diff_lines = list(
difflib.unified_diff(
from_content.splitlines(keepends=True),
to_content.splitlines(keepends=True),
fromfile=f"v{from_id[:8]}",
tofile=f"v{to_id[:8]}",
lineterm="",
)
)
return {
"from_id": from_id,
"to_id": to_id,
@ -183,7 +194,6 @@ class PromptHistoryTable:
"to_snapshot": to_snapshot,
"content_diff": diff_lines,
"name_changed": from_snapshot.get("name") != to_snapshot.get("name"),
"access_control_changed": from_snapshot.get("access_control") != to_snapshot.get("access_control"),
}
def delete_history_by_prompt_id(
@ -193,7 +203,9 @@ class PromptHistoryTable:
) -> bool:
"""Delete all history entries for a prompt."""
with get_db_context(db) as db:
db.query(PromptHistory).filter(PromptHistory.prompt_id == prompt_id).delete()
db.query(PromptHistory).filter(
PromptHistory.prompt_id == prompt_id
).delete()
db.commit()
return True

View file

@ -7,15 +7,13 @@ from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse
from open_webui.models.prompt_history import PromptHistories
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, or_, func, cast
from open_webui.utils.access_control import has_access
####################
# Prompts DB Schema
####################
@ -37,23 +35,6 @@ class Prompt(Base):
created_at = Column(BigInteger, nullable=True)
updated_at = Column(BigInteger, nullable=True)
access_control = Column(JSON, nullable=True) # Controls data access levels.
# Defines access control rules for this entry.
# - `None`: Public access, available to all users with the "user" role.
# - `{}`: Private access, restricted exclusively to the owner.
# - Custom permissions: Specific access control for reading and writing;
# Can specify group or user-level restrictions:
# {
# "read": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# },
# "write": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# }
# }
class PromptModel(BaseModel):
id: Optional[str] = None
@ -68,7 +49,7 @@ class PromptModel(BaseModel):
version_id: Optional[str] = None
created_at: Optional[int] = None
updated_at: Optional[int] = None
access_control: Optional[dict] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
model_config = ConfigDict(from_attributes=True)
@ -104,13 +85,27 @@ class PromptForm(BaseModel):
data: Optional[dict] = None
meta: Optional[dict] = None
tags: Optional[list[str]] = None
access_control: Optional[dict] = None
access_grants: Optional[list[dict]] = None
version_id: Optional[str] = None # Active version
commit_message: Optional[str] = None # For history tracking
is_production: Optional[bool] = True # Whether to set new version as production
class PromptsTable:
def _get_access_grants(
self, prompt_id: str, db: Optional[Session] = None
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("prompt", prompt_id, db=db)
def _to_prompt_model(
self, prompt: Prompt, db: Optional[Session] = None
) -> PromptModel:
prompt_data = PromptModel.model_validate(prompt).model_dump(
exclude={"access_grants"}
)
prompt_data["access_grants"] = self._get_access_grants(prompt_data["id"], db=db)
return PromptModel.model_validate(prompt_data)
def insert_new_prompt(
self, user_id: str, form_data: PromptForm, db: Optional[Session] = None
) -> Optional[PromptModel]:
@ -126,7 +121,7 @@ class PromptsTable:
data=form_data.data or {},
meta=form_data.meta or {},
tags=form_data.tags or [],
access_control=form_data.access_control,
access_grants=[],
is_active=True,
created_at=now,
updated_at=now,
@ -134,12 +129,16 @@ class PromptsTable:
try:
with get_db_context(db) as db:
result = Prompt(**prompt.model_dump())
result = Prompt(**prompt.model_dump(exclude={"access_grants"}))
db.add(result)
db.commit()
db.refresh(result)
AccessGrants.set_access_grants(
"prompt", prompt_id, form_data.access_grants, db=db
)
if result:
current_access_grants = self._get_access_grants(prompt_id, db=db)
snapshot = {
"name": form_data.name,
"content": form_data.content,
@ -147,7 +146,7 @@ class PromptsTable:
"data": form_data.data or {},
"meta": form_data.meta or {},
"tags": form_data.tags or [],
"access_control": form_data.access_control,
"access_grants": [grant.model_dump() for grant in current_access_grants],
}
history_entry = PromptHistories.create_history_entry(
@ -165,7 +164,7 @@ class PromptsTable:
db.commit()
db.refresh(result)
return PromptModel.model_validate(result)
return self._to_prompt_model(result, db=db)
else:
return None
except Exception:
@ -179,7 +178,7 @@ class PromptsTable:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
if prompt:
return PromptModel.model_validate(prompt)
return self._to_prompt_model(prompt, db=db)
return None
except Exception:
return None
@ -191,7 +190,7 @@ class PromptsTable:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(command=command).first()
if prompt:
return PromptModel.model_validate(prompt)
return self._to_prompt_model(prompt, db=db)
return None
except Exception:
return None
@ -216,7 +215,7 @@ class PromptsTable:
prompts.append(
PromptUserResponse.model_validate(
{
**PromptModel.model_validate(prompt).model_dump(),
**self._to_prompt_model(prompt, db=db).model_dump(),
"user": user.model_dump() if user else None,
}
)
@ -236,7 +235,14 @@ class PromptsTable:
prompt
for prompt in prompts
if prompt.user_id == user_id
or has_access(user_id, permission, prompt.access_control, user_group_ids)
or AccessGrants.has_access(
user_id=user_id,
resource_type="prompt",
resource_id=prompt.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
]
def search_prompts(
@ -273,17 +279,15 @@ class PromptsTable:
elif view_option == "shared":
query = query.filter(Prompt.user_id != user_id)
# Apply access control filtering
group_ids = filter.get("group_ids", [])
filter_user_id = filter.get("user_id")
if filter_user_id:
# User must have access: owner OR public OR explicit access
access_conditions = [
Prompt.user_id == filter_user_id, # Owner
Prompt.access_control == None, # Public
]
query = query.filter(or_(*access_conditions))
# Apply access grant filtering
query = AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Prompt,
filter=filter,
resource_type="prompt",
permission="read",
)
tag = filter.get("tag")
if tag:
@ -329,7 +333,7 @@ class PromptsTable:
for prompt, user in items:
prompts.append(
PromptUserResponse(
**PromptModel.model_validate(prompt).model_dump(),
**self._to_prompt_model(prompt, db=db).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
@ -358,12 +362,13 @@ class PromptsTable:
prompt.id, db=db
)
parent_id = latest_history.id if latest_history else None
current_access_grants = self._get_access_grants(prompt.id, db=db)
# Check if content changed to decide on history creation
content_changed = (
prompt.name != form_data.name
or prompt.content != form_data.content
or prompt.access_control != form_data.access_control
or form_data.access_grants is not None
)
# Update prompt fields
@ -371,8 +376,12 @@ class PromptsTable:
prompt.content = form_data.content
prompt.data = form_data.data or prompt.data
prompt.meta = form_data.meta or prompt.meta
prompt.access_control = form_data.access_control
prompt.updated_at = int(time.time())
if form_data.access_grants is not None:
AccessGrants.set_access_grants(
"prompt", prompt.id, form_data.access_grants, db=db
)
current_access_grants = self._get_access_grants(prompt.id, db=db)
db.commit()
@ -384,7 +393,9 @@ class PromptsTable:
"command": command,
"data": form_data.data or {},
"meta": form_data.meta or {},
"access_control": form_data.access_control,
"access_grants": [
grant.model_dump() for grant in current_access_grants
],
}
history_entry = PromptHistories.create_history_entry(
@ -401,7 +412,7 @@ class PromptsTable:
prompt.version_id = history_entry.id
db.commit()
return PromptModel.model_validate(prompt)
return self._to_prompt_model(prompt, db=db)
except Exception:
return None
@ -422,13 +433,14 @@ class PromptsTable:
prompt.id, db=db
)
parent_id = latest_history.id if latest_history else None
current_access_grants = self._get_access_grants(prompt.id, db=db)
# Check if content changed to decide on history creation
content_changed = (
prompt.name != form_data.name
or prompt.command != form_data.command
or prompt.content != form_data.content
or prompt.access_control != form_data.access_control
or form_data.access_grants is not None
or (form_data.tags is not None and prompt.tags != form_data.tags)
)
@ -438,10 +450,15 @@ class PromptsTable:
prompt.content = form_data.content
prompt.data = form_data.data or prompt.data
prompt.meta = form_data.meta or prompt.meta
prompt.access_control = form_data.access_control
if form_data.tags is not None:
prompt.tags = form_data.tags
if form_data.access_grants is not None:
AccessGrants.set_access_grants(
"prompt", prompt.id, form_data.access_grants, db=db
)
current_access_grants = self._get_access_grants(prompt.id, db=db)
prompt.updated_at = int(time.time())
@ -456,7 +473,9 @@ class PromptsTable:
"data": form_data.data or {},
"meta": form_data.meta or {},
"tags": prompt.tags or [],
"access_control": form_data.access_control,
"access_grants": [
grant.model_dump() for grant in current_access_grants
],
}
history_entry = PromptHistories.create_history_entry(
@ -473,7 +492,7 @@ class PromptsTable:
prompt.version_id = history_entry.id
db.commit()
return PromptModel.model_validate(prompt)
return self._to_prompt_model(prompt, db=db)
except Exception:
return None
@ -501,7 +520,7 @@ class PromptsTable:
prompt.updated_at = int(time.time())
db.commit()
return PromptModel.model_validate(prompt)
return self._to_prompt_model(prompt, db=db)
except Exception:
return None
@ -533,13 +552,13 @@ class PromptsTable:
prompt.data = snapshot.get("data", prompt.data)
prompt.meta = snapshot.get("meta", prompt.meta)
prompt.tags = snapshot.get("tags", prompt.tags)
# Note: command and access_control are not restored from snapshot
# Note: command and access_grants are not restored from snapshot
prompt.version_id = version_id
prompt.updated_at = int(time.time())
db.commit()
return PromptModel.model_validate(prompt)
return self._to_prompt_model(prompt, db=db)
except Exception:
return None
@ -552,6 +571,7 @@ class PromptsTable:
prompt = db.query(Prompt).filter_by(command=command).first()
if prompt:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
prompt.is_active = False
prompt.updated_at = int(time.time())
@ -568,6 +588,7 @@ class PromptsTable:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
if prompt:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
prompt.is_active = False
prompt.updated_at = int(time.time())
@ -586,6 +607,7 @@ class PromptsTable:
prompt = db.query(Prompt).filter_by(command=command).first()
if prompt:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
# Delete prompt
db.query(Prompt).filter_by(command=command).delete()

View file

@ -6,11 +6,10 @@ from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.users import Users, UserResponse
from open_webui.models.groups import Groups
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, JSON
from open_webui.utils.access_control import has_access
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import BigInteger, Column, String, Text
log = logging.getLogger(__name__)
@ -31,23 +30,6 @@ class Tool(Base):
meta = Column(JSONField)
valves = Column(JSONField)
access_control = Column(JSON, nullable=True) # Controls data access levels.
# Defines access control rules for this entry.
# - `None`: Public access, available to all users with the "user" role.
# - `{}`: Private access, restricted exclusively to the owner.
# - Custom permissions: Specific access control for reading and writing;
# Can specify group or user-level restrictions:
# {
# "read": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# },
# "write": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# }
# }
updated_at = Column(BigInteger)
created_at = Column(BigInteger)
@ -64,7 +46,7 @@ class ToolModel(BaseModel):
content: str
specs: list[dict]
meta: ToolMeta
access_control: Optional[dict] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
@ -86,7 +68,7 @@ class ToolResponse(BaseModel):
user_id: str
name: str
meta: ToolMeta
access_control: Optional[dict] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
@ -106,7 +88,7 @@ class ToolForm(BaseModel):
name: str
content: str
meta: ToolMeta
access_control: Optional[dict] = None
access_grants: Optional[list[dict]] = None
class ToolValves(BaseModel):
@ -114,6 +96,16 @@ class ToolValves(BaseModel):
class ToolsTable:
def _get_access_grants(
self, tool_id: str, db: Optional[Session] = None
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("tool", tool_id, db=db)
def _to_tool_model(self, tool: Tool, db: Optional[Session] = None) -> ToolModel:
tool_data = ToolModel.model_validate(tool).model_dump(exclude={"access_grants"})
tool_data["access_grants"] = self._get_access_grants(tool_data["id"], db=db)
return ToolModel.model_validate(tool_data)
def insert_new_tool(
self,
user_id: str,
@ -122,23 +114,24 @@ class ToolsTable:
db: Optional[Session] = None,
) -> Optional[ToolModel]:
with get_db_context(db) as db:
tool = ToolModel(
**{
**form_data.model_dump(),
"specs": specs,
"user_id": user_id,
"updated_at": int(time.time()),
"created_at": int(time.time()),
}
)
try:
result = Tool(**tool.model_dump())
result = Tool(
**{
**form_data.model_dump(exclude={"access_grants"}),
"specs": specs,
"user_id": user_id,
"updated_at": int(time.time()),
"created_at": int(time.time()),
}
)
db.add(result)
db.commit()
db.refresh(result)
AccessGrants.set_access_grants(
"tool", result.id, form_data.access_grants, db=db
)
if result:
return ToolModel.model_validate(result)
return self._to_tool_model(result, db=db)
else:
return None
except Exception as e:
@ -151,7 +144,7 @@ class ToolsTable:
try:
with get_db_context(db) as db:
tool = db.get(Tool, id)
return ToolModel.model_validate(tool)
return self._to_tool_model(tool, db=db) if tool else None
except Exception:
return None
@ -170,7 +163,7 @@ class ToolsTable:
tools.append(
ToolUserModel.model_validate(
{
**ToolModel.model_validate(tool).model_dump(),
**self._to_tool_model(tool, db=db).model_dump(),
"user": user.model_dump() if user else None,
}
)
@ -189,7 +182,14 @@ class ToolsTable:
tool
for tool in tools
if tool.user_id == user_id
or has_access(user_id, permission, tool.access_control, user_group_ids)
or AccessGrants.has_access(
user_id=user_id,
resource_type="tool",
resource_id=tool.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
]
def get_tool_valves_by_id(
@ -266,20 +266,24 @@ class ToolsTable:
) -> Optional[ToolModel]:
try:
with get_db_context(db) as db:
access_grants = updated.pop("access_grants", None)
db.query(Tool).filter_by(id=id).update(
{**updated, "updated_at": int(time.time())}
)
db.commit()
if access_grants is not None:
AccessGrants.set_access_grants("tool", id, access_grants, db=db)
tool = db.query(Tool).get(id)
db.refresh(tool)
return ToolModel.model_validate(tool)
return self._to_tool_model(tool, db=db)
except Exception:
return None
def delete_tool_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:
AccessGrants.revoke_all_access("tool", id, db=db)
db.query(Tool).filter_by(id=id).delete()
db.commit()

View file

@ -184,6 +184,9 @@ class UserInfoResponse(UserStatus):
name: str
email: str
role: str
bio: Optional[str] = None
groups: Optional[list] = []
is_active: bool = False
class UserIdNameResponse(BaseModel):

View file

@ -29,9 +29,9 @@ from open_webui.models.knowledge import Knowledges
from open_webui.models.chats import Chats
from open_webui.models.notes import Notes
from open_webui.models.access_grants import AccessGrants
from open_webui.retrieval.vector.main import GetResult
from open_webui.utils.access_control import has_access
from open_webui.utils.headers import include_user_info_headers
from open_webui.utils.misc import get_message_list
@ -999,7 +999,12 @@ async def get_sources_from_items(
if note and (
user.role == "admin"
or note.user_id == user.id
or has_access(user.id, "read", note.access_control)
or AccessGrants.has_access(
user_id=user.id,
resource_type="note",
resource_id=note.id,
permission="read",
)
):
# User has access to the note
query_result = {
@ -1091,7 +1096,12 @@ async def get_sources_from_items(
if knowledge_base and (
user.role == "admin"
or knowledge_base.user_id == user.id
or has_access(user.id, "read", knowledge_base.access_control)
or AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge_base.id,
permission="read",
)
):
if (
item.get("context") == "full"
@ -1100,7 +1110,12 @@ async def get_sources_from_items(
if knowledge_base and (
user.role == "admin"
or knowledge_base.user_id == user.id
or has_access(user.id, "read", knowledge_base.access_control)
or AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge_base.id,
permission="read",
)
):
files = Knowledges.get_files_by_id(knowledge_base.id)

View file

@ -211,7 +211,7 @@ class OpenSearchClient(VectorDBBase):
for item in batch
]
bulk(self.client, actions)
self.client.indices.refresh(self._get_index_name(collection_name))
self.client.indices.refresh(index=self._get_index_name(collection_name))
def upsert(self, collection_name: str, items: list[VectorItem]):
self._create_index_if_not_exists(
@ -234,7 +234,7 @@ class OpenSearchClient(VectorDBBase):
for item in batch
]
bulk(self.client, actions)
self.client.indices.refresh(self._get_index_name(collection_name))
self.client.indices.refresh(index=self._get_index_name(collection_name))
def delete(
self,
@ -263,7 +263,7 @@ class OpenSearchClient(VectorDBBase):
self.client.delete_by_query(
index=self._get_index_name(collection_name), body=query_body
)
self.client.indices.refresh(self._get_index_name(collection_name))
self.client.indices.refresh(index=self._get_index_name(collection_name))
def reset(self):
indices = self.client.indices.get(index=f"{self.index_prefix}_*")

View file

@ -8,6 +8,7 @@ from fastapi import Request
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.utils.headers import include_user_info_headers
from open_webui.env import FORWARD_SESSION_INFO_HEADER_CHAT_ID
log = logging.getLogger(__name__)
@ -31,7 +32,7 @@ def search_external(
chat_id = getattr(request.state, "chat_id", None)
if chat_id:
headers["X-OpenWebUI-Chat-Id"] = str(chat_id)
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = str(chat_id)
response = requests.post(
external_url,

View file

@ -174,7 +174,7 @@ class URLProcessingMixin:
def _safe_process_url_sync(self, url: str) -> bool:
"""Synchronous version of safety checks."""
if self.verify_ssl and not self._verify_ssl_cert(url):
if self.verify_ssl and not verify_ssl_cert(url):
raise ValueError(f"SSL certificate verification failed for {url}")
self._sync_wait_for_rate_limit()
return True

View file

@ -11,6 +11,7 @@ from fastapi import Request
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.utils.headers import include_user_info_headers
from open_webui.env import FORWARD_SESSION_INFO_HEADER_CHAT_ID
from xml.etree import ElementTree as ET
from xml.etree.ElementTree import Element
@ -50,7 +51,7 @@ def search_yandex(
chat_id = getattr(request.state, "chat_id", None)
if chat_id:
headers["X-OpenWebUI-Chat-Id"] = str(chat_id)
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = str(chat_id)
payload = {} if yandex_search_config == "" else json.loads(yandex_search_config)

View file

@ -36,6 +36,7 @@ from open_webui.models.channels import (
ChannelWebhookModel,
ChannelWebhookForm,
)
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.models.messages import (
Messages,
MessageModel,
@ -60,12 +61,7 @@ from open_webui.utils.chat import generate_chat_completion
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import (
has_access,
get_users_with_access,
get_permitted_group_and_user_ids,
has_permission,
)
from open_webui.utils.access_control import has_permission
from open_webui.utils.webhook import post_webhook
from open_webui.utils.channels import extract_mentions, replace_mentions
from open_webui.internal.db import get_session
@ -76,6 +72,66 @@ log = logging.getLogger(__name__)
router = APIRouter()
def channel_has_access(
user_id: str,
channel: ChannelModel,
permission: str = "read",
strict: bool = True,
db: Optional[Session] = None,
) -> bool:
if AccessGrants.has_access(
user_id=user_id,
resource_type="channel",
resource_id=channel.id,
permission=permission,
db=db,
):
return True
if (
not strict
and permission == "write"
and has_public_read_access_grant(channel.access_grants)
):
return True
return False
def get_channel_users_with_access(
channel: ChannelModel, permission: str = "read", db: Optional[Session] = None
):
return AccessGrants.get_users_with_access(
resource_type="channel",
resource_id=channel.id,
permission=permission,
db=db,
)
def get_channel_permitted_group_and_user_ids(
channel: ChannelModel, permission: str = "read"
) -> Optional[dict[str, list[str]]]:
if permission == "read" and has_public_read_access_grant(channel.access_grants):
return None
user_ids = []
group_ids = []
for grant in channel.access_grants:
if grant.permission != permission:
continue
if grant.principal_type == "group":
group_ids.append(grant.principal_id)
elif grant.principal_type == "user" and grant.principal_id != "*":
user_ids.append(grant.principal_id)
return {
"user_ids": list(dict.fromkeys(user_ids)),
"group_ids": list(dict.fromkeys(group_ids)),
}
############################
# Channels Enabled Dependency
############################
@ -418,22 +474,22 @@ async def get_channel_by_id(
}
)
else:
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control, db=db
if user.role != "admin" and not channel_has_access(
user.id, channel, permission="read", db=db
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
write_access = has_access(
write_access = channel_has_access(
user.id,
type="write",
access_control=channel.access_control,
channel,
permission="write",
strict=False,
db=db,
)
user_count = len(get_users_with_access("read", channel.access_control))
user_count = len(get_channel_users_with_access(channel, "read", db=db))
channel_member = Channels.get_member_by_channel_and_user_id(
channel.id, user.id, db=db
@ -527,8 +583,8 @@ async def get_channel_members_by_id(
filter["channel_id"] = channel.id
else:
filter["roles"] = ["!pending"]
permitted_ids = get_permitted_group_and_user_ids(
"read", channel.access_control
permitted_ids = get_channel_permitted_group_and_user_ids(
channel, permission="read"
)
if permitted_ids:
filter["user_ids"] = permitted_ids.get("user_ids")
@ -811,8 +867,8 @@ async def get_channel_messages(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control, db=db
if user.role != "admin" and not channel_has_access(
user.id, channel, permission="read", db=db
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@ -888,8 +944,8 @@ async def get_pinned_channel_messages(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control, db=db
if user.role != "admin" and not channel_has_access(
user.id, channel, permission="read", db=db
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@ -946,7 +1002,7 @@ async def get_pinned_channel_messages(
async def send_notification(
name, webui_url, channel, message, active_user_ids, db=None
):
users = get_users_with_access("read", channel.access_control)
users = get_channel_users_with_access(channel, "read", db=db)
for user in users:
if (user.id not in active_user_ids) and Channels.is_user_channel_member(
@ -1173,10 +1229,10 @@ async def new_message_handler(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
if user.role != "admin" and not channel_has_access(
user.id,
type="write",
access_control=channel.access_control,
channel,
permission="write",
strict=False,
db=db,
):
@ -1318,8 +1374,8 @@ async def get_channel_message(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control, db=db
if user.role != "admin" and not channel_has_access(
user.id, channel, permission="read", db=db
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@ -1372,8 +1428,8 @@ async def get_channel_message_data(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control, db=db
if user.role != "admin" and not channel_has_access(
user.id, channel, permission="read", db=db
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@ -1426,8 +1482,8 @@ async def pin_channel_message(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control, db=db
if user.role != "admin" and not channel_has_access(
user.id, channel, permission="read", db=db
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@ -1492,8 +1548,8 @@ async def get_channel_thread_messages(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control, db=db
if user.role != "admin" and not channel_has_access(
user.id, channel, permission="read", db=db
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@ -1577,8 +1633,8 @@ async def update_message_by_id(
if (
user.role != "admin"
and message.user_id != user.id
and not has_access(
user.id, type="read", access_control=channel.access_control, db=db
and not channel_has_access(
user.id, channel, permission="read", db=db
)
):
raise HTTPException(
@ -1644,10 +1700,10 @@ async def add_reaction_to_message(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
if user.role != "admin" and not channel_has_access(
user.id,
type="write",
access_control=channel.access_control,
channel,
permission="write",
strict=False,
db=db,
):
@ -1723,10 +1779,10 @@ async def remove_reaction_by_id_and_user_id_and_name(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
if user.role != "admin" and not channel_has_access(
user.id,
type="write",
access_control=channel.access_control,
channel,
permission="write",
strict=False,
db=db,
):
@ -1818,10 +1874,10 @@ async def delete_message_by_id(
if (
user.role != "admin"
and message.user_id != user.id
and not has_access(
and not channel_has_access(
user.id,
type="write",
access_control=channel.access_control,
channel,
permission="write",
strict=False,
db=db,
)

View file

@ -38,6 +38,7 @@ from open_webui.models.files import (
from open_webui.models.chats import Chats
from open_webui.models.knowledge import Knowledges
from open_webui.models.groups import Groups
from open_webui.models.access_grants import AccessGrants
from open_webui.routers.retrieval import ProcessFileForm, process_file
@ -47,7 +48,6 @@ from open_webui.storage.provider import Storage
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access
from open_webui.utils.misc import strict_match_mime_type
from pydantic import BaseModel
@ -82,8 +82,13 @@ def has_access_to_file(
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
}
for knowledge_base in knowledge_bases:
if knowledge_base.user_id == user.id or has_access(
user.id, access_type, knowledge_base.access_control, user_group_ids, db=db
if knowledge_base.user_id == user.id or AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge_base.id,
permission=access_type,
user_group_ids=user_group_ids,
db=db,
):
return True

View file

@ -7,6 +7,7 @@ from open_webui.models.users import Users, UserInfoResponse
from open_webui.models.groups import (
Groups,
GroupForm,
GroupInfoResponse,
GroupUpdateForm,
GroupResponse,
UserIdsForm,
@ -104,6 +105,23 @@ async def get_group_by_id(
)
@router.get("/id/{id}/info", response_model=Optional[GroupInfoResponse])
async def get_group_info_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
group = Groups.get_group_by_id(id, db=db)
if group:
return GroupInfoResponse(
**group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id, db=db),
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
)
############################
# ExportGroupById
############################

View file

@ -29,7 +29,8 @@ from open_webui.storage.provider import Storage
from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_verified_user, get_admin_user
from open_webui.utils.access_control import has_access, has_permission
from open_webui.utils.access_control import has_permission
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
@ -133,8 +134,12 @@ async def get_knowledge_bases(
write_access=(
user.id == knowledge_base.user_id
or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or has_access(
user.id, "write", knowledge_base.access_control, db=db
or AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge_base.id,
permission="write",
db=db,
)
),
)
@ -180,8 +185,12 @@ async def search_knowledge_bases(
write_access=(
user.id == knowledge_base.user_id
or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or has_access(
user.id, "write", knowledge_base.access_control, db=db
or AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge_base.id,
permission="write",
db=db,
)
),
)
@ -243,14 +252,14 @@ async def create_new_knowledge(
# Check if user can share publicly
if (
user.role != "admin"
and form_data.access_control == None
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_knowledge",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_control = {}
form_data.access_grants = []
knowledge = Knowledges.insert_new_knowledge(user.id, form_data)
@ -387,7 +396,13 @@ async def get_knowledge_by_id(
if (
user.role == "admin"
or knowledge.user_id == user.id
or has_access(user.id, "read", knowledge.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="read",
db=db,
)
):
return KnowledgeFilesResponse(
@ -395,7 +410,13 @@ async def get_knowledge_by_id(
write_access=(
user.id == knowledge.user_id
or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or has_access(user.id, "write", knowledge.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="write",
db=db,
)
),
)
else:
@ -435,7 +456,12 @@ async def update_knowledge_by_id(
# Is the user the original creator, in a group with write access, or an admin
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="write",
)
and user.role != "admin"
):
raise HTTPException(
@ -446,14 +472,14 @@ async def update_knowledge_by_id(
# Check if user can share publicly
if (
user.role != "admin"
and form_data.access_control == None
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_knowledge",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_control = {}
form_data.access_grants = []
knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data)
if knowledge:
@ -502,7 +528,13 @@ async def get_knowledge_files_by_id(
if not (
user.role == "admin"
or knowledge.user_id == user.id
or has_access(user.id, "read", knowledge.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="read",
db=db,
)
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -555,7 +587,13 @@ def add_file_to_knowledge_by_id(
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@ -624,7 +662,13 @@ def update_file_from_knowledge_by_id(
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="write",
db=db,
)
and user.role != "admin"
):
@ -693,7 +737,13 @@ def remove_file_from_knowledge_by_id(
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@ -770,7 +820,13 @@ async def delete_knowledge_by_id(
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@ -802,7 +858,7 @@ async def delete_knowledge_by_id(
base_model_id=model.base_model_id,
meta=model.meta,
params=model.params,
access_control=model.access_control,
access_grants=model.access_grants,
is_active=model.is_active,
)
Models.update_model_by_id(model.id, model_form, db=db)
@ -839,7 +895,13 @@ async def reset_knowledge_by_id(
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@ -882,7 +944,13 @@ async def add_files_to_knowledge_batch(
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@ -890,17 +958,19 @@ async def add_files_to_knowledge_batch(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Get files content
# Batch-fetch all files to avoid N+1 queries
log.info(f"files/batch/add - {len(form_data)} files")
files: List[FileModel] = []
for form in form_data:
file = Files.get_file_by_id(form.file_id, db=db)
if not file:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File {form.file_id} not found",
)
files.append(file)
file_ids = [form.file_id for form in form_data]
files = Files.get_files_by_ids(file_ids, db=db)
# Verify all requested files were found
found_ids = {file.id for file in files}
missing_ids = [fid for fid in file_ids if fid not in found_ids]
if missing_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File {missing_ids[0]} not found",
)
# Process files
try:

View file

@ -15,6 +15,7 @@ from open_webui.models.models import (
ModelAccessResponse,
Models,
)
from open_webui.models.access_grants import AccessGrants
from pydantic import BaseModel
from open_webui.constants import ERROR_MESSAGES
@ -30,7 +31,7 @@ from fastapi.responses import FileResponse, StreamingResponse
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
from open_webui.utils.access_control import has_permission
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
@ -98,7 +99,13 @@ async def get_models(
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model.user_id
or has_access(user.id, "write", model.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model.id,
permission="write",
db=db,
)
),
)
for model in result.items
@ -315,14 +322,26 @@ async def get_model_by_id(
if (
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or model.user_id == user.id
or has_access(user.id, "read", model.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model.id,
permission="read",
db=db,
)
):
return ModelAccessResponse(
**model.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model.user_id
or has_access(user.id, "write", model.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model.id,
permission="write",
db=db,
)
),
)
else:
@ -393,7 +412,13 @@ async def toggle_model_by_id(
if (
user.role == "admin"
or model.user_id == user.id
or has_access(user.id, "write", model.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model.id,
permission="write",
db=db,
)
):
model = Models.toggle_model_by_id(id, db=db)
@ -436,7 +461,13 @@ async def update_model_by_id(
if (
model.user_id != user.id
and not has_access(user.id, "write", model.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@ -471,7 +502,13 @@ async def delete_model_by_id(
if (
user.role != "admin"
and model.user_id != user.id
and not has_access(user.id, "write", model.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model.id,
permission="write",
db=db,
)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,

View file

@ -27,7 +27,8 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
from open_webui.utils.access_control import has_permission
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
@ -200,8 +201,12 @@ async def get_note_by_id(
if user.role != "admin" and (
user.id != note.user_id
and (
not has_access(
user.id, type="read", access_control=note.access_control, db=db
not AccessGrants.has_access(
user_id=user.id,
resource_type="note",
resource_id=note.id,
permission="read",
db=db,
)
)
):
@ -212,13 +217,14 @@ async def get_note_by_id(
write_access = (
user.role == "admin"
or (user.id == note.user_id)
or has_access(
user.id,
type="write",
access_control=note.access_control,
strict=False,
or AccessGrants.has_access(
user_id=user.id,
resource_type="note",
resource_id=note.id,
permission="write",
db=db,
)
or has_public_read_access_grant(note.access_grants)
)
return NoteResponse(**note.model_dump(), write_access=write_access)
@ -253,8 +259,12 @@ async def update_note_by_id(
if user.role != "admin" and (
user.id != note.user_id
and not has_access(
user.id, type="write", access_control=note.access_control, db=db
and not AccessGrants.has_access(
user_id=user.id,
resource_type="note",
resource_id=note.id,
permission="write",
db=db,
)
):
raise HTTPException(
@ -264,7 +274,7 @@ async def update_note_by_id(
# Check if user can share publicly
if (
user.role != "admin"
and form_data.access_control == None
and has_public_read_access_grant(form_data.access_grants)
and not has_permission(
user.id,
"sharing.public_notes",
@ -272,7 +282,7 @@ async def update_note_by_id(
db=db,
)
):
form_data.access_control = {}
form_data.access_grants = []
try:
note = Notes.update_note_by_id(id, form_data, db=db)
@ -318,8 +328,12 @@ async def delete_note_by_id(
if user.role != "admin" and (
user.id != note.user_id
and not has_access(
user.id, type="write", access_control=note.access_control, db=db
and not AccessGrants.has_access(
user_id=user.id,
resource_type="note",
resource_id=note.id,
permission="write",
db=db,
)
):
raise HTTPException(

View file

@ -23,6 +23,7 @@ from open_webui.models.users import UserModel
from open_webui.env import (
ENABLE_FORWARD_USER_INFO_HEADERS,
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
)
from fastapi import (
@ -44,6 +45,7 @@ from open_webui.internal.db import get_session
from open_webui.models.models import Models
from open_webui.models.access_grants import AccessGrants
from open_webui.utils.misc import (
calculate_sha256,
)
@ -53,9 +55,6 @@ from open_webui.utils.payload import (
apply_system_prompt_to_body,
)
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access
from open_webui.config import (
UPLOAD_DIR,
)
@ -137,7 +136,7 @@ async def send_post_request(
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
if metadata and metadata.get("chat_id"):
headers["X-OpenWebUI-Chat-Id"] = metadata.get("chat_id")
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get("chat_id")
r = await session.post(
url,
@ -430,8 +429,12 @@ async def get_filtered_models(models, user, db=None):
for model in models.get("models", []):
model_info = Models.get_model_by_id(model["model"], db=db)
if model_info:
if user.id == model_info.user_id or has_access(
user.id, type="read", access_control=model_info.access_control, db=db
if user.id == model_info.user_id or AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model_info.id,
permission="read",
db=db,
):
filtered_models.append(model)
return filtered_models
@ -442,6 +445,9 @@ async def get_filtered_models(models, user, db=None):
async def get_ollama_tags(
request: Request, url_idx: Optional[int] = None, user=Depends(get_verified_user)
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
models = []
if url_idx is None:
@ -706,6 +712,9 @@ async def pull_model(
url_idx: int = 0,
user=Depends(get_admin_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
form_data = form_data.model_dump(exclude_none=True)
form_data["model"] = form_data.get("model", form_data.get("name"))
@ -737,6 +746,9 @@ async def push_model(
url_idx: Optional[int] = None,
user=Depends(get_admin_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
if url_idx is None:
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
@ -776,6 +788,9 @@ async def create_model(
url_idx: int = 0,
user=Depends(get_admin_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
log.debug(f"form_data: {form_data}")
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
@ -800,6 +815,9 @@ async def copy_model(
url_idx: Optional[int] = None,
user=Depends(get_admin_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
if url_idx is None:
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
@ -860,6 +878,9 @@ async def delete_model(
url_idx: Optional[int] = None,
user=Depends(get_admin_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
form_data = form_data.model_dump(exclude_none=True)
form_data["model"] = form_data.get("model", form_data.get("name"))
@ -922,6 +943,9 @@ async def delete_model(
async def show_model_info(
request: Request, form_data: ModelNameForm, user=Depends(get_verified_user)
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
form_data = form_data.model_dump(exclude_none=True)
form_data["model"] = form_data.get("model", form_data.get("name"))
@ -994,6 +1018,9 @@ async def embed(
url_idx: Optional[int] = None,
user=Depends(get_verified_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
log.info(f"generate_ollama_batch_embeddings {form_data}")
if url_idx is None:
@ -1076,6 +1103,9 @@ async def embeddings(
url_idx: Optional[int] = None,
user=Depends(get_verified_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
log.info(f"generate_ollama_embeddings {form_data}")
if url_idx is None:
@ -1166,6 +1196,9 @@ async def generate_completion(
url_idx: Optional[int] = None,
user=Depends(get_verified_user),
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
if url_idx is None:
await get_all_models(request, user=user)
models = request.app.state.OLLAMA_MODELS
@ -1258,8 +1291,11 @@ async def generate_chat_completion(
bypass_filter: Optional[bool] = False,
bypass_system_prompt: bool = False,
):
if not request.app.state.config.ENABLE_OLLAMA_API:
raise HTTPException(status_code=503, detail="Ollama API is disabled")
# NOTE: We intentionally do NOT use Depends(get_session) here.
# Database operations (get_model_by_id, has_access) manage their own short-lived sessions.
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
# This prevents holding a connection during the entire LLM call (30-60+ seconds),
# which would exhaust the connection pool under concurrent load.
if BYPASS_MODEL_ACCESS_CONTROL:
@ -1306,10 +1342,11 @@ async def generate_chat_completion(
if not bypass_filter and user.role == "user":
if not (
user.id == model_info.user_id
or has_access(
user.id,
type="read",
access_control=model_info.access_control,
or AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model_info.id,
permission="read",
)
):
raise HTTPException(
@ -1383,7 +1420,7 @@ async def generate_openai_completion(
user=Depends(get_verified_user),
):
# NOTE: We intentionally do NOT use Depends(get_session) here.
# Database operations (get_model_by_id, has_access) manage their own short-lived sessions.
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
# This prevents holding a connection during the entire LLM call (30-60+ seconds),
# which would exhaust the connection pool under concurrent load.
metadata = form_data.pop("metadata", None)
@ -1418,10 +1455,11 @@ async def generate_openai_completion(
if user.role == "user":
if not (
user.id == model_info.user_id
or has_access(
user.id,
type="read",
access_control=model_info.access_control,
or AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model_info.id,
permission="read",
)
):
raise HTTPException(
@ -1468,7 +1506,7 @@ async def generate_openai_chat_completion(
user=Depends(get_verified_user),
):
# NOTE: We intentionally do NOT use Depends(get_session) here.
# Database operations (get_model_by_id, has_access) manage their own short-lived sessions.
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
# This prevents holding a connection during the entire LLM call (30-60+ seconds),
# which would exhaust the connection pool under concurrent load.
metadata = form_data.pop("metadata", None)
@ -1507,10 +1545,11 @@ async def generate_openai_chat_completion(
if user.role == "user":
if not (
user.id == model_info.user_id
or has_access(
user.id,
type="read",
access_control=model_info.access_control,
or AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model_info.id,
permission="read",
)
):
raise HTTPException(
@ -1608,10 +1647,11 @@ async def get_openai_models(
for model in models:
model_info = Models.get_model_by_id(model["id"], db=db)
if model_info:
if user.id == model_info.user_id or has_access(
user.id,
type="read",
access_control=model_info.access_control,
if user.id == model_info.user_id or AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model_info.id,
permission="read",
db=db,
):
filtered_models.append(model)

View file

@ -24,6 +24,7 @@ from sqlalchemy.orm import Session
from open_webui.internal.db import get_session
from open_webui.models.models import Models
from open_webui.models.access_grants import AccessGrants
from open_webui.config import (
CACHE_DIR,
)
@ -33,6 +34,7 @@ from open_webui.env import (
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
ENABLE_FORWARD_USER_INFO_HEADERS,
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
BYPASS_MODEL_ACCESS_CONTROL,
)
from open_webui.models.users import UserModel
@ -50,7 +52,6 @@ from open_webui.utils.misc import (
)
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access
from open_webui.utils.headers import include_user_info_headers
@ -142,7 +143,7 @@ async def get_headers_and_cookies(
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
if metadata and metadata.get("chat_id"):
headers["X-OpenWebUI-Chat-Id"] = metadata.get("chat_id")
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get("chat_id")
token = None
auth_type = config.get("auth_type")
@ -462,8 +463,12 @@ async def get_filtered_models(models, user, db=None):
for model in models.get("data", []):
model_info = Models.get_model_by_id(model["id"], db=db)
if model_info:
if user.id == model_info.user_id or has_access(
user.id, type="read", access_control=model_info.access_control, db=db
if user.id == model_info.user_id or AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model_info.id,
permission="read",
db=db,
):
filtered_models.append(model)
return filtered_models
@ -544,6 +549,9 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
async def get_models(
request: Request, url_idx: Optional[int] = None, user=Depends(get_verified_user)
):
if not request.app.state.config.ENABLE_OPENAI_API:
raise HTTPException(status_code=503, detail="OpenAI API is disabled")
models = {
"data": [],
}
@ -854,6 +862,33 @@ def convert_to_responses_payload(payload: dict) -> dict:
if "max_tokens" in responses_payload:
responses_payload["max_output_tokens"] = responses_payload.pop("max_tokens")
# Remove Chat Completions-only parameters not supported by the Responses API
for unsupported_key in ("stream_options", "logit_bias", "frequency_penalty", "presence_penalty", "stop"):
responses_payload.pop(unsupported_key, None)
# Convert Chat Completions tools format to Responses API format
# Chat Completions: {"type": "function", "function": {"name": ..., "description": ..., "parameters": ...}}
# Responses API: {"type": "function", "name": ..., "description": ..., "parameters": ...}
if "tools" in responses_payload and isinstance(responses_payload["tools"], list):
converted_tools = []
for tool in responses_payload["tools"]:
if isinstance(tool, dict) and "function" in tool:
func = tool["function"]
converted_tool = {"type": tool.get("type", "function")}
if isinstance(func, dict):
converted_tool["name"] = func.get("name", "")
if "description" in func:
converted_tool["description"] = func["description"]
if "parameters" in func:
converted_tool["parameters"] = func["parameters"]
if "strict" in func:
converted_tool["strict"] = func["strict"]
converted_tools.append(converted_tool)
else:
# Already in correct format or unknown format, pass through
converted_tools.append(tool)
responses_payload["tools"] = converted_tools
return responses_payload
@ -876,7 +911,7 @@ async def generate_chat_completion(
bypass_system_prompt: bool = False,
):
# NOTE: We intentionally do NOT use Depends(get_session) here.
# Database operations (get_model_by_id, has_access) manage their own short-lived sessions.
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
# This prevents holding a connection during the entire LLM call (30-60+ seconds),
# which would exhaust the connection pool under concurrent load.
if BYPASS_MODEL_ACCESS_CONTROL:
@ -914,10 +949,11 @@ async def generate_chat_completion(
if not bypass_filter and user.role == "user":
if not (
user.id == model_info.user_id
or has_access(
user.id,
type="read",
access_control=model_info.access_control,
or AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model_info.id,
permission="read",
)
):
raise HTTPException(

View file

@ -9,6 +9,7 @@ from open_webui.models.prompts import (
PromptModel,
Prompts,
)
from open_webui.models.access_grants import AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.prompt_history import (
PromptHistories,
@ -17,7 +18,7 @@ from open_webui.models.prompt_history import (
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission
from open_webui.utils.access_control import has_permission
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
@ -115,7 +116,13 @@ async def get_prompt_list(
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == prompt.user_id
or has_access(user.id, "write", prompt.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="write",
db=db,
)
),
)
for prompt in result.items
@ -186,14 +193,26 @@ async def get_prompt_by_command(
if (
user.role == "admin"
or prompt.user_id == user.id
or has_access(user.id, "read", prompt.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="read",
db=db,
)
):
return PromptAccessResponse(
**prompt.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == prompt.user_id
or has_access(user.id, "write", prompt.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="write",
db=db,
)
),
)
@ -218,14 +237,26 @@ async def get_prompt_by_id(
if (
user.role == "admin"
or prompt.user_id == user.id
or has_access(user.id, "read", prompt.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="read",
db=db,
)
):
return PromptAccessResponse(
**prompt.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == prompt.user_id
or has_access(user.id, "write", prompt.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="write",
db=db,
)
),
)
@ -258,7 +289,13 @@ async def update_prompt_by_id(
# Is the user the original creator, in a group with write access, or an admin
if (
prompt.user_id != user.id
and not has_access(user.id, "write", prompt.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@ -311,7 +348,13 @@ async def update_prompt_metadata(
if (
prompt.user_id != user.id
and not has_access(user.id, "write", prompt.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@ -356,7 +399,13 @@ async def set_prompt_version(
if (
prompt.user_id != user.id
and not has_access(user.id, "write", prompt.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@ -395,7 +444,13 @@ async def delete_prompt_by_id(
if (
prompt.user_id != user.id
and not has_access(user.id, "write", prompt.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@ -434,7 +489,13 @@ async def get_prompt_history(
if not (
user.role == "admin"
or prompt.user_id == user.id
or has_access(user.id, "read", prompt.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="read",
db=db,
)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -469,7 +530,13 @@ async def get_prompt_history_entry(
if not (
user.role == "admin"
or prompt.user_id == user.id
or has_access(user.id, "read", prompt.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="read",
db=db,
)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -508,7 +575,13 @@ async def delete_prompt_history_entry(
if not (
user.role == "admin"
or prompt.user_id == user.id
or has_access(user.id, "write", prompt.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="write",
db=db,
)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -553,7 +626,13 @@ async def get_prompt_diff(
if not (
user.role == "admin"
or prompt.user_id == user.id
or has_access(user.id, "read", prompt.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="read",
db=db,
)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,

View file

@ -1056,13 +1056,25 @@ async def update_rag_config(
)
# File upload settings
request.app.state.config.FILE_MAX_SIZE = form_data.FILE_MAX_SIZE
request.app.state.config.FILE_MAX_COUNT = form_data.FILE_MAX_COUNT
request.app.state.config.FILE_MAX_SIZE = (
form_data.FILE_MAX_SIZE
if form_data.FILE_MAX_SIZE is not None
else request.app.state.config.FILE_MAX_SIZE
)
request.app.state.config.FILE_MAX_COUNT = (
form_data.FILE_MAX_COUNT
if form_data.FILE_MAX_COUNT is not None
else request.app.state.config.FILE_MAX_COUNT
)
request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH = (
form_data.FILE_IMAGE_COMPRESSION_WIDTH
if form_data.FILE_IMAGE_COMPRESSION_WIDTH is not None
else request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH
)
request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT = (
form_data.FILE_IMAGE_COMPRESSION_HEIGHT
if form_data.FILE_IMAGE_COMPRESSION_HEIGHT is not None
else request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT
)
request.app.state.config.ALLOWED_FILE_EXTENSIONS = (
form_data.ALLOWED_FILE_EXTENSIONS

View file

@ -49,6 +49,21 @@ router = APIRouter()
##################################
class ActiveChatsForm(BaseModel):
chat_ids: list[str]
@router.post("/active/chats")
async def check_active_chats(
request: Request, form_data: ActiveChatsForm, user=Depends(get_verified_user)
):
"""Check which chat IDs have active tasks."""
from open_webui.tasks import get_active_chat_ids
active = await get_active_chat_ids(request.app.state.redis, form_data.chat_ids)
return {"active_chat_ids": active}
@router.get("/config")
async def get_task_config(request: Request, user=Depends(get_verified_user)):
return {

View file

@ -21,6 +21,7 @@ from open_webui.models.tools import (
ToolAccessResponse,
Tools,
)
from open_webui.models.access_grants import AccessGrants
from open_webui.utils.plugin import (
load_tool_module_by_id,
replace_imports,
@ -156,7 +157,24 @@ async def get_tools(
tool
for tool in tools
if tool.user_id == user.id
or has_access(user.id, "read", tool.access_control, user_group_ids, db=db)
or (
has_access(
user.id,
"read",
getattr(tool, "access_control", None),
user_group_ids,
db=db,
)
if str(tool.id).startswith("server:")
else AccessGrants.has_access(
user_id=user.id,
resource_type="tool",
resource_id=tool.id,
permission="read",
user_group_ids=user_group_ids,
db=db,
)
)
]
return tools
@ -181,7 +199,13 @@ async def get_tool_list(
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == tool.user_id
or has_access(user.id, "write", tool.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="tool",
resource_id=tool.id,
permission="write",
db=db,
)
),
)
for tool in tools
@ -382,14 +406,26 @@ async def get_tools_by_id(
if (
user.role == "admin"
or tools.user_id == user.id
or has_access(user.id, "read", tools.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="tool",
resource_id=tools.id,
permission="read",
db=db,
)
):
return ToolAccessResponse(
**tools.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == tools.user_id
or has_access(user.id, "write", tools.access_control, db=db)
or AccessGrants.has_access(
user_id=user.id,
resource_type="tool",
resource_id=tools.id,
permission="write",
db=db,
)
),
)
else:
@ -427,7 +463,13 @@ async def update_tools_by_id(
# Is the user the original creator, in a group with write access, or an admin
if (
tools.user_id != user.id
and not has_access(user.id, "write", tools.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="tool",
resource_id=tools.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@ -489,7 +531,13 @@ async def delete_tools_by_id(
if (
tools.user_id != user.id
and not has_access(user.id, "write", tools.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="tool",
resource_id=tools.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
@ -588,7 +636,13 @@ async def update_tools_valves_by_id(
if (
tools.user_id != user.id
and not has_access(user.id, "write", tools.access_control, db=db)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="tool",
resource_id=tools.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(

View file

@ -19,7 +19,7 @@ from open_webui.models.users import (
UserModel,
UserGroupIdsModel,
UserGroupIdsListResponse,
UserInfoListResponse,
UserInfoResponse,
UserInfoListResponse,
UserRoleUpdateForm,
UserStatus,
@ -446,7 +446,7 @@ class UserActiveResponse(UserStatus):
@router.get("/{user_id}", response_model=UserActiveResponse)
async def get_user_by_id(
user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
# Check if user_id is a shared chat
# If it is, get the user_id from the chat
@ -478,6 +478,27 @@ async def get_user_by_id(
)
@router.get("/{user_id}/info", response_model=UserInfoResponse)
async def get_user_info_by_id(
user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
user = Users.get_user_by_id(user_id, db=db)
if user:
groups = Groups.get_groups_by_member_id(user_id, db=db)
return UserInfoResponse(
**{
**user.model_dump(),
"groups": [{"id": group.id, "name": group.name} for group in groups],
"is_active": Users.is_user_active(user_id, db=db),
}
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
@router.get("/{user_id}/oauth/sessions")
async def get_user_oauth_sessions_by_id(
user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)

View file

@ -42,7 +42,7 @@ from open_webui.utils.auth import decode_token
from open_webui.socket.utils import RedisDict, RedisLock, YdocManager
from open_webui.tasks import create_task, stop_item_tasks
from open_webui.utils.redis import get_redis_connection
from open_webui.utils.access_control import has_access, get_users_with_access
from open_webui.models.access_grants import AccessGrants
from open_webui.env import (
@ -405,7 +405,12 @@ async def join_note(sid, data):
if (
user.role != "admin"
and user.id != note.user_id
and not has_access(user.id, type="read", access_control=note.access_control)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="note",
resource_id=note.id,
permission="read",
)
):
log.error(f"User {user.id} does not have access to note {data['note_id']}")
return
@ -467,8 +472,11 @@ async def ydoc_document_join(sid, data):
if (
user.get("role") != "admin"
and user.get("id") != note.user_id
and not has_access(
user.get("id"), type="read", access_control=note.access_control
and not AccessGrants.has_access(
user_id=user.get("id"),
resource_type="note",
resource_id=note.id,
permission="read",
)
):
log.error(
@ -537,8 +545,11 @@ async def document_save_handler(document_id, data, user):
if (
user.get("role") != "admin"
and user.get("id") != note.user_id
and not has_access(
user.get("id"), type="read", access_control=note.access_control
and not AccessGrants.has_access(
user_id=user.get("id"),
resource_type="note",
resource_id=note.id,
permission="read",
)
):
log.error(f"User {user.get('id')} does not have access to note {note_id}")

View file

@ -183,3 +183,18 @@ async def stop_item_tasks(redis: Redis, item_id: str):
return result # Return the first failure
return {"status": True, "message": f"All tasks for item {item_id} stopped."}
async def has_active_tasks(redis, chat_id: str) -> bool:
"""Check if a chat has any active tasks."""
task_ids = await list_task_ids_by_item_id(redis, chat_id)
return len(task_ids) > 0
async def get_active_chat_ids(redis, chat_ids: List[str]) -> List[str]:
"""Filter a list of chat_ids to only those with active tasks."""
active = []
for chat_id in chat_ids:
if await has_active_tasks(redis, chat_id):
active.append(chat_id)
return active

View file

@ -36,7 +36,7 @@ from open_webui.models.chats import Chats
from open_webui.models.channels import Channels, ChannelMember, Channel
from open_webui.models.messages import Messages, Message
from open_webui.models.groups import Groups
from open_webui.utils.sanitize import strip_markdown_code_fences
from open_webui.utils.sanitize import sanitize_code
log = logging.getLogger(__name__)
@ -371,8 +371,8 @@ async def execute_code(
return json.dumps({"error": "Request context not available"})
try:
# Strip markdown fences if model included them
code = strip_markdown_code_fences(code)
# Sanitize code (strips ANSI codes and markdown fences)
code = sanitize_code(code)
# Import blocked modules from config (same as middleware)
from open_webui.config import CODE_INTERPRETER_BLOCKED_MODULES
@ -743,10 +743,14 @@ async def view_note(
user_id = __user__.get("id")
user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)]
from open_webui.utils.access_control import has_access
from open_webui.models.access_grants import AccessGrants
if note.user_id != user_id and not has_access(
user_id, "read", note.access_control, user_group_ids
if note.user_id != user_id and not AccessGrants.has_access(
user_id=user_id,
resource_type="note",
resource_id=note.id,
permission="read",
user_group_ids=set(user_group_ids),
):
return json.dumps({"error": "Access denied"})
@ -797,7 +801,7 @@ async def write_note(
form = NoteForm(
title=title,
data={"content": {"md": content}},
access_control={}, # Private by default - only owner can access
access_grants=[], # Private by default - only owner can access
)
new_note = Notes.insert_new_note(user_id, form)
@ -852,10 +856,14 @@ async def replace_note_content(
user_id = __user__.get("id")
user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)]
from open_webui.utils.access_control import has_access
from open_webui.models.access_grants import AccessGrants
if note.user_id != user_id and not has_access(
user_id, "write", note.access_control, user_group_ids
if note.user_id != user_id and not AccessGrants.has_access(
user_id=user_id,
resource_type="note",
resource_id=note.id,
permission="write",
user_group_ids=set(user_group_ids),
):
return json.dumps({"error": "Write access denied"})
@ -1532,7 +1540,7 @@ async def view_knowledge_file(
try:
from open_webui.models.files import Files
from open_webui.models.knowledge import Knowledges
from open_webui.utils.access_control import has_access
from open_webui.models.access_grants import AccessGrants
user_id = __user__.get("id")
user_role = __user__.get("role", "user")
@ -1551,8 +1559,12 @@ async def view_knowledge_file(
if (
user_role == "admin"
or knowledge_base.user_id == user_id
or has_access(
user_id, "read", knowledge_base.access_control, user_group_ids
or AccessGrants.has_access(
user_id=user_id,
resource_type="knowledge",
resource_id=knowledge_base.id,
permission="read",
user_group_ids=set(user_group_ids),
)
):
has_knowledge_access = True
@ -1631,7 +1643,7 @@ async def query_knowledge_files(
from open_webui.models.files import Files
from open_webui.models.notes import Notes
from open_webui.retrieval.utils import query_collection
from open_webui.utils.access_control import has_access
from open_webui.models.access_grants import AccessGrants
user_id = __user__.get("id")
user_role = __user__.get("role", "user")
@ -1656,8 +1668,12 @@ async def query_knowledge_files(
if knowledge and (
user_role == "admin"
or knowledge.user_id == user_id
or has_access(
user_id, "read", knowledge.access_control, user_group_ids
or AccessGrants.has_access(
user_id=user_id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="read",
user_group_ids=set(user_group_ids),
)
):
collection_names.append(item_id)
@ -1674,7 +1690,12 @@ async def query_knowledge_files(
if note and (
user_role == "admin"
or note.user_id == user_id
or has_access(user_id, "read", note.access_control)
or AccessGrants.has_access(
user_id=user_id,
resource_type="note",
resource_id=note.id,
permission="read",
)
):
content = note.data.get("content", {}).get("md", "")
note_results.append(
@ -1693,8 +1714,12 @@ async def query_knowledge_files(
if knowledge and (
user_role == "admin"
or knowledge.user_id == user_id
or has_access(
user_id, "read", knowledge.access_control, user_group_ids
or AccessGrants.has_access(
user_id=user_id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="read",
user_group_ids=set(user_group_ids),
)
):
collection_names.append(knowledge_id)

View file

@ -1,11 +1,18 @@
from urllib.parse import quote
from open_webui.env import (
FORWARD_USER_INFO_HEADER_USER_NAME,
FORWARD_USER_INFO_HEADER_USER_ID,
FORWARD_USER_INFO_HEADER_USER_EMAIL,
FORWARD_USER_INFO_HEADER_USER_ROLE,
)
def include_user_info_headers(headers, user):
return {
**headers,
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
FORWARD_USER_INFO_HEADER_USER_NAME: quote(user.name, safe=" "),
FORWARD_USER_INFO_HEADER_USER_ID: user.id,
FORWARD_USER_INFO_HEADER_USER_EMAIL: user.email,
FORWARD_USER_INFO_HEADER_USER_ROLE: user.role,
}

File diff suppressed because it is too large Load diff

View file

@ -128,22 +128,40 @@ def get_content_from_message(message: dict) -> Optional[str]:
return None
def convert_output_to_messages(output: list) -> list[dict]:
def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
"""
Convert OR-aligned output items to OpenAI-format messages for LLM consumption.
This is the inverse of convert_content_blocks_to_output() in middleware.py.
Convert OR-aligned output items to OpenAI Chat Completion-format messages.
This reconstructs the full conversation from the stored Responses API-native
output items, including assistant messages with tool_calls arrays and tool
role messages.
Args:
output: List of OR-aligned output items (Responses API format).
raw: If True, include reasoning blocks (with original tags) and code
interpreter blocks for LLM re-processing follow-ups.
"""
if not output or not isinstance(output, list):
return []
messages = []
pending_tool_calls = []
pending_content = []
def flush_pending():
nonlocal pending_content, pending_tool_calls
if pending_content or pending_tool_calls:
messages.append({
"role": "assistant",
"content": "\n".join(pending_content) if pending_content else "",
**({"tool_calls": pending_tool_calls} if pending_tool_calls else {}),
})
pending_content = []
pending_tool_calls = []
for item in output:
item_type = item.get("type", "")
if item_type == "message":
# Extract text from output_text content parts
content_parts = item.get("content", [])
@ -153,58 +171,86 @@ def convert_output_to_messages(output: list) -> list[dict]:
text += part.get("text", "")
if text:
pending_content.append(text)
elif item_type == "function_call":
# Collect tool calls to batch into assistant message
arguments = item.get("arguments", "{}")
# Ensure arguments is always a JSON string
if not isinstance(arguments, str):
arguments = json.dumps(arguments)
pending_tool_calls.append({
"id": item.get("call_id", ""),
"type": "function",
"function": {
"name": item.get("name", ""),
"arguments": item.get("arguments", "{}"),
"arguments": arguments,
}
})
elif item_type == "function_call_output":
# Flush any pending content/tool_calls before adding tool result
if pending_content or pending_tool_calls:
messages.append({
"role": "assistant",
"content": "\n".join(pending_content) if pending_content else "",
**({"tool_calls": pending_tool_calls} if pending_tool_calls else {}),
})
pending_content = []
pending_tool_calls = []
flush_pending()
# Extract text from output content parts
output_parts = item.get("output", [])
content = ""
for part in output_parts:
if part.get("type") == "input_text":
content += part.get("text", "")
messages.append({
"role": "tool",
"tool_call_id": item.get("call_id", ""),
"content": content,
})
elif item_type == "reasoning":
# Skip reasoning blocks for LLM messages
pass
if raw:
# Include reasoning with original tags for LLM re-processing
reasoning_text = ""
source_list = item.get("summary", []) or item.get("content", [])
for part in source_list:
if part.get("type") == "output_text":
reasoning_text += part.get("text", "")
elif "text" in part:
reasoning_text += part.get("text", "")
if reasoning_text:
start_tag = item.get("start_tag", "<think>")
end_tag = item.get("end_tag", "</think>")
pending_content.append(
f"{start_tag}{reasoning_text}{end_tag}"
)
# else: skip reasoning blocks for normal LLM messages
elif item_type == "open_webui:code_interpreter":
if raw:
# Include code interpreter content for LLM re-processing
code = item.get("code", "")
code_output = item.get("output", "")
if code:
lang = item.get("lang", "python")
pending_content.append(f"```{lang}\n{code}\n```")
if code_output:
if isinstance(code_output, dict):
stdout = code_output.get("stdout", "")
result = code_output.get("result", "")
output_text = stdout or result
else:
output_text = str(code_output)
if output_text:
pending_content.append(f"Output:\n{output_text}")
# else: skip extension types
elif item_type.startswith("open_webui:"):
# Skip extension types
# Skip other extension types
pass
# Flush remaining content/tool_calls
if pending_content or pending_tool_calls:
messages.append({
"role": "assistant",
"content": "\n".join(pending_content) if pending_content else "",
**({"tool_calls": pending_tool_calls} if pending_tool_calls else {}),
})
flush_pending()
return messages

View file

@ -13,6 +13,7 @@ from open_webui.functions import get_function_models
from open_webui.models.functions import Functions
from open_webui.models.models import Models
from open_webui.models.access_grants import AccessGrants
from open_webui.models.groups import Groups
@ -354,8 +355,12 @@ def check_model_access(user, model, db=None):
raise Exception("Model not found")
elif not (
user.id == model_info.user_id
or has_access(
user.id, type="read", access_control=model_info.access_control, db=db
or AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model_info.id,
permission="read",
db=db,
)
):
raise Exception("Model not found")
@ -395,11 +400,13 @@ def get_filtered_models(models, user, db=None):
if (
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model_info.user_id
or has_access(
user.id,
type="read",
access_control=model_info.access_control,
or AccessGrants.has_access(
user_id=user.id,
resource_type="model",
resource_id=model_info.id,
permission="read",
user_group_ids=user_group_ids,
db=db,
)
):
filtered_models.append(model)

View file

@ -1,5 +1,25 @@
import re
# ANSI escape code pattern - matches all common ANSI sequences
# This includes color codes, cursor movement, and other terminal control sequences
ANSI_ESCAPE_PATTERN = re.compile(r'\x1b\[[0-9;]*[A-Za-z]|\x1b\([AB]|\x1b[PX^_].*?\x1b\\|\x1b\].*?(?:\x07|\x1b\\)')
def strip_ansi_codes(text: str) -> str:
"""
Strip ANSI escape codes from text.
ANSI escape codes can be introduced by LLMs that include terminal
color codes in their output. These codes cause syntax errors when
the code is sent to Jupyter for execution.
Common ANSI codes include:
- Color codes: \x1b[31m (red), \x1b[32m (green), etc.
- Reset codes: \x1b[0m, \x1b[39m
- Cursor movement: \x1b[1A, \x1b[2J, etc.
"""
return ANSI_ESCAPE_PATTERN.sub('', text)
def strip_markdown_code_fences(code: str) -> str:
"""
@ -19,3 +39,20 @@ def strip_markdown_code_fences(code: str) -> str:
# Remove closing fence
code = re.sub(r"\n?```\s*$", "", code)
return code.strip()
def sanitize_code(code: str) -> str:
"""
Sanitize code for execution by applying all necessary cleanup steps.
This is the recommended function to use before sending code to
interpreters like Jupyter or Pyodide.
Steps applied:
1. Strip ANSI escape codes (from LLM output)
2. Strip markdown code fences (if model included them)
"""
code = strip_ansi_codes(code)
code = strip_markdown_code_fences(code)
return code

View file

@ -38,6 +38,7 @@ from open_webui.utils.misc import is_string_allowed
from open_webui.models.tools import Tools
from open_webui.models.users import UserModel
from open_webui.models.groups import Groups
from open_webui.models.access_grants import AccessGrants
from open_webui.utils.plugin import load_tool_module_by_id
from open_webui.utils.access_control import has_access
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
@ -45,7 +46,10 @@ from open_webui.env import (
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA,
AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
ENABLE_FORWARD_USER_INFO_HEADERS,
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
)
from open_webui.utils.headers import include_user_info_headers
from open_webui.tools.builtin import (
search_web,
fetch_url,
@ -165,7 +169,13 @@ async def get_tools(
if (
not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
and tool.user_id != user.id
and not has_access(user.id, "read", tool.access_control, user_group_ids)
and not AccessGrants.has_access(
user_id=user.id,
resource_type="tool",
resource_id=tool.id,
permission="read",
user_group_ids=user_group_ids,
)
):
log.warning(f"Access denied to tool {tool_id} for user {user.id}")
continue
@ -335,6 +345,13 @@ async def get_tools(
for key, value in connection_headers.items():
headers[key] = value
# Add user info headers if enabled
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
metadata = extra_params.get("__metadata__", {})
if metadata and metadata.get("chat_id"):
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get("chat_id")
def make_tool_function(
function_name, tool_server_data, headers
):
@ -697,9 +714,10 @@ def convert_openapi_to_tool_payload(openapi_spec):
"parameters": {"type": "object", "properties": {}, "required": []},
}
# Extract path and query parameters
for param in operation.get("parameters", []):
param_name = param["name"]
param_name = param.get("name")
if not param_name:
continue
param_schema = param.get("schema", {})
description = param_schema.get("description", "")
if not description:
@ -971,8 +989,10 @@ async def execute_tool_server(
body_params = {}
for param in operation.get("parameters", []):
param_name = param["name"]
param_in = param["in"]
param_name = param.get("name")
if not param_name:
continue
param_in = param.get("in")
if param_name in params:
if param_in == "path":
path_params[param_name] = params[param_name]

View file

@ -1,19 +1,19 @@
# Minimal requirements for backend to run
# WIP: use this as a reference to build a minimal docker image
fastapi==0.128.0
fastapi==0.128.5
uvicorn[standard]==0.40.0
pydantic==2.12.5
python-multipart==0.0.22
itsdangerous==2.2.0
python-socketio==5.16.0
python-socketio==5.16.1
python-jose==3.5.0
cryptography
bcrypt==5.0.0
argon2-cffi==25.1.0
PyJWT[crypto]==2.10.1
authlib==1.6.6
PyJWT[crypto]==2.11.0
authlib==1.6.7
requests==2.32.5
aiohttp==3.13.2 # do not update to 3.13.3 - broken
@ -29,19 +29,19 @@ alembic==1.18.3
peewee==3.19.0
peewee-migrate==1.14.3
pycrdt==0.12.45
pycrdt==0.12.46
redis
APScheduler==3.11.2
RestrictedPython==8.1
loguru==0.7.3
asgiref==3.11.0
asgiref==3.11.1
mcp==1.26.0
openai
langchain==1.2.7
langchain==1.2.9
langchain-community==0.4.1
langchain-classic==1.0.1
langchain-text-splitters==1.1.0

View file

@ -1,16 +1,16 @@
fastapi==0.128.0
fastapi==0.128.5
uvicorn[standard]==0.40.0
pydantic==2.12.5
python-multipart==0.0.22
itsdangerous==2.2.0
python-socketio==5.16.0
python-socketio==5.16.1
python-jose==3.5.0
cryptography
bcrypt==5.0.0
argon2-cffi==25.1.0
PyJWT[crypto]==2.10.1
authlib==1.6.6
PyJWT[crypto]==2.11.0
authlib==1.6.7
requests==2.32.5
aiohttp==3.13.2 # do not update to 3.13.3 - broken
@ -27,14 +27,15 @@ alembic==1.18.3
peewee==3.19.0
peewee-migrate==1.14.3
pycrdt==0.12.45
pycrdt==0.12.46
redis
APScheduler==3.11.2
RestrictedPython==8.1
pytz==2025.2
loguru==0.7.3
asgiref==3.11.0
asgiref==3.11.1
# AI libraries
tiktoken
@ -42,9 +43,9 @@ mcp==1.26.0
openai
anthropic
google-genai==1.60.0
google-genai==1.62.0
langchain==1.2.7
langchain==1.2.9
langchain-community==0.4.1
langchain-classic==1.0.1
langchain-text-splitters==1.1.0
@ -54,7 +55,7 @@ chromadb==1.4.1
weaviate-client==4.19.2
opensearch-py==3.1.0
transformers==4.57.6
transformers==5.1.0
sentence-transformers==5.2.2
accelerate
pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897
@ -62,17 +63,17 @@ einops==0.8.2
ftfy==6.3.1
chardet==5.2.0
pypdf==6.6.2
pypdf==6.7.0
fpdf2==2.8.5
pymdown-extensions==10.20.1
docx2txt==0.9
python-pptx==1.0.2
unstructured==0.18.31
msoffcrypto-tool==5.4.2
msoffcrypto-tool==6.0.0
nltk==3.9.2
Markdown==3.10.1
pypandoc==1.16.2
pandas==2.3.3
pandas==3.0.0
openpyxl==3.1.5
pyxlsb==1.0.10
xlrd==2.0.2
@ -82,11 +83,11 @@ sentencepiece
soundfile==0.13.1
pillow==12.1.0
opencv-python-headless==4.13.0.90
opencv-python-headless==4.13.0.92
rapidocr-onnxruntime==1.4.4
rank-bm25==0.2.2
onnxruntime==1.23.2
onnxruntime==1.24.1
faster-whisper==1.2.1
black==26.1.0
@ -107,7 +108,7 @@ google-auth-httplib2
google-auth-oauthlib
googleapis-common-protos==1.72.0
google-cloud-storage==3.8.0
google-cloud-storage==3.9.0
## Databases
pymongo
@ -115,12 +116,12 @@ psycopg2-binary==2.9.11
pgvector==0.4.2
PyMySQL==1.1.2
boto3==1.42.38
boto3==1.42.44
pymilvus==2.6.8
qdrant-client==1.16.2
playwright==1.58.0 # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
elasticsearch==9.2.1
elasticsearch==9.3.0
pinecone==6.0.2
oracledb==3.4.2

View file

@ -6,19 +6,19 @@ authors = [
]
license = { file = "LICENSE" }
dependencies = [
"fastapi==0.128.0",
"fastapi==0.128.5",
"uvicorn[standard]==0.40.0",
"pydantic==2.12.5",
"python-multipart==0.0.22",
"itsdangerous==2.2.0",
"python-socketio==5.16.0",
"python-socketio==5.16.1",
"python-jose==3.5.0",
"cryptography",
"bcrypt==5.0.0",
"argon2-cffi==25.1.0",
"PyJWT[crypto]==2.10.1",
"authlib==1.6.6",
"PyJWT[crypto]==2.11.0",
"authlib==1.6.7",
"requests==2.32.5",
"aiohttp==3.13.2", # do not update to 3.13.3 - broken
@ -35,23 +35,24 @@ dependencies = [
"peewee==3.19.0",
"peewee-migrate==1.14.3",
"pycrdt==0.12.45",
"pycrdt==0.12.46",
"redis",
"pytz==2025.2",
"APScheduler==3.11.2",
"RestrictedPython==8.1",
"loguru==0.7.3",
"asgiref==3.11.0",
"asgiref==3.11.1",
"tiktoken",
"mcp==1.26.0",
"openai",
"anthropic",
"google-genai==1.60.0",
"google-genai==1.62.0",
"langchain==1.2.7",
"langchain==1.2.9",
"langchain-community==0.4.1",
"langchain-classic==1.0.1",
"langchain-text-splitters==1.1.0",
@ -60,9 +61,9 @@ dependencies = [
"chromadb==1.4.1",
"opensearch-py==3.1.0",
"PyMySQL==1.1.2",
"boto3==1.42.38",
"boto3==1.42.44",
"transformers==4.57.6",
"transformers==5.1.0",
"sentence-transformers==5.2.2",
"accelerate",
"pyarrow==20.0.0", # fix: pin pyarrow version to 20 for rpi compatibility #15897
@ -70,17 +71,17 @@ dependencies = [
"ftfy==6.3.1",
"chardet==5.2.0",
"pypdf==6.6.2",
"pypdf==6.7.0",
"fpdf2==2.8.5",
"pymdown-extensions==10.20.1",
"docx2txt==0.9",
"python-pptx==1.0.2",
"unstructured==0.18.31",
"msoffcrypto-tool==5.4.2",
"msoffcrypto-tool==6.0.0",
"nltk==3.9.2",
"Markdown==3.10.1",
"pypandoc==1.16.2",
"pandas==2.3.3",
"pandas==3.0.0",
"openpyxl==3.1.5",
"pyxlsb==1.0.10",
"xlrd==2.0.2",
@ -91,11 +92,11 @@ dependencies = [
"azure-ai-documentintelligence==1.0.2",
"pillow==12.1.0",
"opencv-python-headless==4.13.0.90",
"opencv-python-headless==4.13.0.92",
"rapidocr-onnxruntime==1.4.4",
"rank-bm25==0.2.2",
"onnxruntime==1.23.2",
"onnxruntime==1.24.1",
"faster-whisper==1.2.1",
"black==26.1.0",
@ -110,7 +111,7 @@ dependencies = [
"google-auth-oauthlib",
"googleapis-common-protos==1.72.0",
"google-cloud-storage==3.8.0",
"google-cloud-storage==3.9.0",
"azure-identity==1.25.1",
"azure-storage-blob==12.28.0",
@ -146,7 +147,7 @@ all = [
"pytest~=8.3.2",
"pytest-docker~=3.2.5",
"playwright==1.58.0", # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
"elasticsearch==9.2.1",
"elasticsearch==9.3.0",
"qdrant-client==1.16.2",

View file

@ -3,10 +3,11 @@ import { WEBUI_API_BASE_URL } from '$lib/constants';
type ChannelForm = {
type?: string;
name: string;
is_private?: boolean;
is_private?: boolean | null;
data?: object;
meta?: object;
access_control?: object;
access_grants?: object[];
group_ids?: string[];
user_ids?: string[];
};

View file

@ -99,6 +99,38 @@ export const getGroupById = async (token: string, id: string) => {
return res;
};
export const getGroupInfoById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/groups/id/${id}/info`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateGroupById = async (token: string, id: string, group: object) => {
let error = null;

View file

@ -449,8 +449,9 @@ export const executeToolServer = async (
if (operation.parameters) {
operation.parameters.forEach((param: any) => {
const paramName = param.name;
const paramIn = param.in;
const paramName = param?.name;
if (!paramName) return;
const paramIn = param?.in;
if (params.hasOwnProperty(paramName)) {
if (paramIn === 'path') {
pathParams[paramName] = params[paramName];
@ -1674,7 +1675,7 @@ export interface ModelMeta {
profile_image_url?: string;
}
export interface ModelParams {}
export interface ModelParams { }
export type GlobalModelConfig = ModelConfig[];

View file

@ -4,7 +4,7 @@ export const createNewKnowledge = async (
token: string,
name: string,
description: string,
accessControl: null | object
accessGrants: object[]
) => {
let error = null;
@ -18,7 +18,7 @@ export const createNewKnowledge = async (
body: JSON.stringify({
name: name,
description: description,
access_control: accessControl
access_grants: accessGrants
})
})
.then(async (res) => {
@ -248,7 +248,7 @@ type KnowledgeUpdateForm = {
name?: string;
description?: string;
data?: object;
access_control?: null | object;
access_grants?: object[];
};
export const updateKnowledgeById = async (token: string, id: string, form: KnowledgeUpdateForm) => {
@ -265,7 +265,7 @@ export const updateKnowledgeById = async (token: string, id: string, form: Knowl
name: form?.name ? form.name : undefined,
description: form?.description ? form.description : undefined,
data: form?.data ? form.data : undefined,
access_control: form.access_control
access_grants: form.access_grants
})
})
.then(async (res) => {

View file

@ -5,7 +5,7 @@ type NoteItem = {
title: string;
data: object;
meta?: null | object;
access_control?: null | object;
access_grants?: object[];
};
export const createNewNote = async (token: string, note: NoteItem) => {

View file

@ -7,7 +7,7 @@ type PromptItem = {
content: string;
data?: object | null;
meta?: object | null;
access_control?: null | object;
access_grants?: object[];
version_id?: string | null; // Active version
commit_message?: string | null; // For history tracking
is_production?: boolean; // Whether to set new version as production
@ -23,7 +23,7 @@ type PromptHistoryItem = {
command: string;
data: object;
meta: object;
access_control: object | null;
access_grants: object[];
};
user_id: string;
commit_message: string | null;
@ -42,7 +42,7 @@ type PromptDiff = {
to_snapshot: object;
content_diff: string[];
name_changed: boolean;
access_control_changed: boolean;
access_grants_changed: boolean;
};
export const createNewPrompt = async (token: string, prompt: PromptItem) => {
@ -611,4 +611,3 @@ export const getPromptDiff = async (
return res;
};

View file

@ -0,0 +1,14 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export const checkActiveChats = async (token: string, chatIds: string[]) => {
const res = await fetch(`${WEBUI_API_BASE_URL}/tasks/active/chats`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ chat_ids: chatIds })
});
if (!res.ok) throw await res.json();
return res.json();
};

View file

@ -300,10 +300,11 @@ export const updateUserSettings = async (token: string, settings: object) => {
return res;
};
export const getUserById = async (token: string, userId: string) => {
export const getUserInfoById = async (token: string, userId: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/users/${userId}`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/users/${userId}/info`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',

View file

@ -5,6 +5,7 @@
import { marked } from 'marked';
import { createEventDispatcher, onMount } from 'svelte';
import XMark from '$lib/components/icons/XMark.svelte';
const dispatch = createEventDispatcher();
@ -15,6 +16,7 @@
let startX = 0,
startY = 0;
let moved = false;
let closeButtonElement: HTMLButtonElement;
const DRAG_THRESHOLD_PX = 6;
const clickHandler = () => {
@ -22,6 +24,10 @@
dispatch('closeToast');
};
const closeHandler = () => {
dispatch('closeToast');
};
function onPointerDown(e: PointerEvent) {
startX = e.clientX;
startY = e.clientY;
@ -43,6 +49,11 @@
// Release capture if taken
(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
// Skip if clicking the close button
if (closeButtonElement && (e.target === closeButtonElement || closeButtonElement.contains(e.target as Node))) {
return;
}
// Only treat as a click if there wasn't a drag
if (!moved) {
clickHandler();
@ -71,7 +82,7 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="flex gap-2.5 text-left min-w-[var(--width)] w-full dark:bg-gray-850 dark:text-white bg-white text-black border border-gray-100 dark:border-gray-800 rounded-3xl px-4 py-3.5 cursor-pointer select-none"
class="group relative flex gap-2.5 text-left min-w-[var(--width)] w-full dark:bg-gray-850 dark:text-white bg-white text-black border border-gray-100 dark:border-gray-800 rounded-3xl px-4 py-3.5 cursor-pointer select-none"
on:dragstart|preventDefault
on:pointerdown={onPointerDown}
on:pointermove={onPointerMove}
@ -84,6 +95,16 @@
}
}}
>
<!-- Close button (visible on hover) -->
<button
bind:this={closeButtonElement}
class="absolute -top-0.5 -left-0.5 p-0.5 rounded-full opacity-0 group-hover:opacity-100 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-opacity z-10"
on:click|stopPropagation={closeHandler}
aria-label="Dismiss notification"
>
<XMark className="size-3" />
</button>
<div class="shrink-0 self-top -translate-y-0.5">
<img src="{WEBUI_BASE_URL}/static/favicon.png" alt="favicon" class="size-6 rounded-full" />
</div>

View file

@ -9,7 +9,6 @@
import Pencil from '$lib/components/icons/Pencil.svelte';
import User from '$lib/components/icons/User.svelte';
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
import EditGroupModal from './EditGroupModal.svelte';
export let group = {
@ -70,9 +69,6 @@
}}
>
<div class="flex items-center gap-1.5 w-full font-medium flex-1">
<div>
<UserCircleSolid className="size-4" />
</div>
<div class="line-clamp-1">
{group.name}
</div>

View file

@ -15,13 +15,32 @@
import AddMembersModal from './ChannelInfoModal/AddMembersModal.svelte';
export let show = false;
export let channel = null;
export let channel: any = null;
export let onUpdate = () => {};
let showAddMembersModal = false;
const submitHandler = async () => {};
const hasPublicReadGrant = (grants: any) =>
Array.isArray(grants) &&
grants.some(
(grant) =>
grant?.principal_type === 'user' &&
grant?.principal_id === '*' &&
grant?.permission === 'read'
);
const isPublicChannel = (channel: any): boolean => {
if (channel?.type === 'group') {
if (typeof channel?.is_private === 'boolean') {
return !channel.is_private;
}
return hasPublicReadGrant(channel?.access_grants);
}
return hasPublicReadGrant(channel?.access_grants);
};
const removeMemberHandler = async (userId) => {
const res = await removeMembersById(localStorage.token, channel.id, {
user_ids: [userId]
@ -62,7 +81,7 @@
</div>
{:else}
<div class=" size-4 justify-center flex items-center">
{#if channel?.type === 'group' ? !channel?.is_private : channel?.access_control === null}
{#if isPublicChannel(channel)}
<Hashtag className="size-3.5" strokeWidth="2.5" />
{:else}
<Lock className="size-5.5" strokeWidth="2" />

View file

@ -129,6 +129,25 @@
onDestroy(() => {
window.removeEventListener('keydown', keydownListener);
});
const hasPublicReadGrant = (grants: any) =>
Array.isArray(grants) &&
grants.some(
(grant) =>
grant?.principal_type === 'user' &&
grant?.principal_id === '*' &&
grant?.permission === 'read'
);
const isPublicChannel = (channel: any): boolean => {
if (channel?.type === 'group') {
if (typeof channel?.is_private === 'boolean') {
return !channel.is_private;
}
return hasPublicReadGrant(channel?.access_grants);
}
return hasPublicReadGrant(channel?.access_grants);
};
</script>
{#if filteredItems.length}
@ -165,7 +184,7 @@
>
{#if item.type === 'channel'}
<div class=" size-4 justify-center flex items-center mr-0.5">
{#if item?.data?.access_control === null}
{#if isPublicChannel(item?.data)}
<Hashtag className="size-3" strokeWidth="2.5" />
{:else}
<Lock className="size-[15px]" strokeWidth="2" />

View file

@ -3,7 +3,7 @@
import { LinkPreview } from 'bits-ui';
const i18n = getContext('i18n');
import { getUserById } from '$lib/apis/users';
import { getUserInfoById } from '$lib/apis/users';
import UserStatus from './UserStatus.svelte';
@ -16,7 +16,7 @@
let user = null;
onMount(async () => {
if (id) {
user = await getUserById(localStorage.token, id).catch((error) => {
user = await getUserInfoById(localStorage.token, id).catch((error) => {
console.error('Error fetching user by ID:', error);
return null;
});

View file

@ -26,6 +26,25 @@
let showChannelPinnedMessagesModal = false;
let showChannelInfoModal = false;
const hasPublicReadGrant = (grants: any) =>
Array.isArray(grants) &&
grants.some(
(grant) =>
grant?.principal_type === 'user' &&
grant?.principal_id === '*' &&
grant?.permission === 'read'
);
const isPublicChannel = (channel: any): boolean => {
if (channel?.type === 'group') {
if (typeof channel?.is_private === 'boolean') {
return !channel.is_private;
}
return hasPublicReadGrant(channel?.access_grants);
}
return hasPublicReadGrant(channel?.access_grants);
};
export let channel;
export let onPin = (messageId, pinned) => {};
@ -112,7 +131,7 @@
{/if}
{:else}
<div class=" size-4.5 justify-center flex items-center">
{#if channel?.type === 'group' ? !channel?.is_private : channel?.access_control === null}
{#if isPublicChannel(channel)}
<Hashtag className="size-3.5" strokeWidth="2.5" />
{:else}
<Lock className="size-5" strokeWidth="2" />

View file

@ -151,7 +151,7 @@
return {
...file,
user: undefined,
access_control: undefined
access_grants: undefined
};
}),
selectedToolIds,
@ -1634,7 +1634,7 @@
<div class="size-4 items-center flex justify-center">
<img
src={filter.icon}
class="size-3.5 {filter.icon.includes('svg')
class="size-3.5 {filter.icon.includes('data:image/svg')
? 'dark:invert-[80%]'
: ''}"
style="fill: currentColor;"
@ -1746,7 +1746,7 @@
<!-- {$i18n.t('Create Note')} -->
<Tooltip content={$i18n.t('Create note')} className=" flex items-center">
<button
id="send-message-button"
id="create-note-button"
class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 self-center"
type="button"
disabled={prompt === '' && files.length === 0}

View file

@ -162,7 +162,7 @@
<div class="size-4 items-center flex justify-center">
<img
src={filter.icon}
class="size-3.5 {filter.icon.includes('svg')
class="size-3.5 {filter.icon.includes('data:image/svg')
? 'dark:invert-[80%]'
: ''}"
style="fill: currentColor;"

View file

@ -17,6 +17,7 @@
import KatexRenderer from './KatexRenderer.svelte';
import AlertRenderer, { alertComponent } from './AlertRenderer.svelte';
import Collapsible from '$lib/components/common/Collapsible.svelte';
import ToolCallDisplay from '$lib/components/common/ToolCallDisplay.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Download from '$lib/components/icons/Download.svelte';
@ -134,6 +135,7 @@
<div class="scrollbar-hidden relative overflow-x-auto max-w-full">
<table
class=" w-full text-sm text-left text-gray-500 dark:text-gray-400 max-w-full rounded-xl"
dir="auto"
>
<thead
class="text-xs text-gray-700 uppercase bg-white dark:bg-gray-900 dark:text-gray-400 border-none"
@ -322,7 +324,15 @@
.replace(/<summary>.*?<\/summary>/gi, '')
.trim()}
{#if textContent.length > 0}
{#if token?.attributes?.type === 'tool_calls'}
<!-- Tool calls have dedicated handling with ToolCallDisplay component -->
<ToolCallDisplay
id={`${id}-${tokenIdx}-tc`}
attributes={token.attributes}
open={false}
className="w-full space-y-1"
/>
{:else if textContent.length > 0}
<Collapsible
title={token.summary}
open={$settings?.expandDetails ?? false}

View file

@ -1397,7 +1397,7 @@
<div class="size-4">
<img
src={action.icon}
class="w-4 h-4 {action.icon.includes('svg')
class="w-4 h-4 {action.icon.includes('data:image/svg')
? 'dark:invert-[80%]'
: ''}"
style="fill: currentColor;"

View file

@ -14,7 +14,6 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import { getUserById } from '$lib/apis/users';
import User from '$lib/components/icons/User.svelte';
import UserProfileImage from './Account/UserProfileImage.svelte';

View file

@ -23,6 +23,7 @@
import { toast } from 'svelte-sonner';
export let chatId = '';
export let disabled = false;
let tags = [];
const getTags = async () => {
@ -73,6 +74,7 @@
<Tags
{tags}
{disabled}
suggestionTags={$_tags ?? []}
on:delete={(e) => {
deleteTag(e.detail);

View file

@ -35,11 +35,6 @@
import ChevronUp from '../icons/ChevronUp.svelte';
import ChevronDown from '../icons/ChevronDown.svelte';
import Spinner from './Spinner.svelte';
import CodeBlock from '../chat/Messages/CodeBlock.svelte';
import Markdown from '../chat/Messages/Markdown.svelte';
import Image from './Image.svelte';
import FullHeightIframe from './FullHeightIframe.svelte';
import { settings } from '$lib/stores';
export let open = false;
@ -62,162 +57,9 @@
$: onChange(open);
const collapsibleId = uuidv4();
function parseJSONString(str) {
try {
return parseJSONString(JSON.parse(str));
} catch (e) {
return str;
}
}
function formatJSONString(str) {
try {
const parsed = parseJSONString(str);
// If parsed is an object/array, then it's valid JSON
if (typeof parsed === 'object') {
return JSON.stringify(parsed, null, 2);
} else {
// It's a primitive value like a number, boolean, etc.
return `${JSON.stringify(String(parsed))}`;
}
} catch (e) {
// Not valid JSON, return as-is
return str;
}
}
</script>
<div {id} class={className}>
{#if attributes?.type === 'tool_calls'}
{@const args = decode(attributes?.arguments)}
{@const result = decode(attributes?.result ?? '')}
{@const files = parseJSONString(decode(attributes?.files ?? ''))}
{@const embeds = parseJSONString(decode(attributes?.embeds ?? ''))}
{#if embeds && Array.isArray(embeds) && embeds.length > 0}
<div class="py-1 w-full cursor-pointer">
<div class=" w-full text-xs text-gray-500">
<div class="">
{attributes.name}
</div>
</div>
{#each embeds as embed, idx}
<div class="my-2" id={`${collapsibleId}-tool-calls-${attributes?.id}-embed-${idx}`}>
<FullHeightIframe
src={embed}
{args}
allowScripts={true}
allowForms={true}
allowSameOrigin={true}
allowPopups={true}
/>
</div>
{/each}
</div>
{:else}
<div
class="{buttonClassName} cursor-pointer"
on:pointerup={() => {
if (!disabled) {
open = !open;
}
}}
>
<div
class=" w-full font-medium flex items-center justify-between gap-2 {attributes?.done &&
attributes?.done !== 'true'
? 'shimmer'
: ''}
"
>
{#if attributes?.done && attributes?.done !== 'true'}
<div>
<Spinner className="size-4" />
</div>
{/if}
<div class="">
{#if attributes?.done === 'true'}
<Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}`}
content={$i18n.t('View Result from **{{NAME}}**', {
NAME: attributes.name
})}
/>
{:else}
<Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}-executing`}
content={$i18n.t('Executing **{{NAME}}**...', {
NAME: attributes.name
})}
/>
{/if}
</div>
<div class="flex self-center translate-y-[1px]">
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" />
{/if}
</div>
</div>
</div>
{#if !grow}
{#if open && !hide}
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
{#if attributes?.type === 'tool_calls'}
{#if attributes?.done === 'true'}
<Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}-result`}
content={`> \`\`\`json
> ${formatJSONString(args)}
> ${formatJSONString(result)}
> \`\`\``}
/>
{:else}
<Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}-result`}
content={`> \`\`\`json
> ${formatJSONString(args)}
> \`\`\``}
/>
{/if}
{:else}
<slot name="content" />
{/if}
</div>
{/if}
{/if}
{/if}
{#if attributes?.done === 'true'}
{#if typeof files === 'object'}
{#each files ?? [] as file, idx}
{#if typeof file === 'string'}
{#if file.startsWith('data:image/')}
<Image
id={`${collapsibleId}-tool-calls-${attributes?.id}-result-${idx}`}
src={file}
alt="Image"
/>
{/if}
{:else if typeof file === 'object'}
{#if (file.type === 'image' || (file?.content_type ?? '').startsWith('image/')) && file.url}
<Image
id={`${collapsibleId}-tool-calls-${attributes?.id}-result-${idx}`}
src={file.url}
alt="Image"
/>
{/if}
{/if}
{/each}
{/if}
{/if}
{:else}
{#if title !== null}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
@ -333,5 +175,4 @@
</div>
{/if}
{/if}
{/if}
</div>

View file

@ -897,6 +897,34 @@
oncompositionend(event);
return false;
},
beforeinput: (view, event) => {
// Workaround for Gboard's clipboard suggestion strip which sends
// multi-line pastes as 'insertText' rather than a standard paste event.
// Manually insert with hard breaks to preserve multi-line formatting.
const isAndroid = /Android/i.test(navigator.userAgent);
if (isAndroid && event.inputType === 'insertText' && event.data?.includes('\n')) {
event.preventDefault();
const { state, dispatch } = view;
const { from, to } = state.selection;
const lines = event.data.split('\n');
const nodes = [];
lines.forEach((line, index) => {
if (index > 0) {
nodes.push(state.schema.nodes.hardBreak.create());
}
if (line.length > 0) {
nodes.push(state.schema.text(line));
}
});
const fragment = Fragment.fromArray(nodes);
dispatch(state.tr.replaceWith(from, to, fragment).scrollIntoView());
return true;
}
return false;
},
focus: (view, event) => {
eventDispatch('focus', { event });
return false;

View file

@ -7,6 +7,7 @@
export let tags = [];
export let suggestionTags = [];
export let disabled = false;
let inputValue = '';
@ -22,22 +23,25 @@
<div class="flex flex-wrap items-center gap-1 w-full">
<TagList
{tags}
{disabled}
on:delete={(e) => {
dispatch('delete', e.detail);
}}
/>
<input
bind:value={inputValue}
class="flex-1 min-w-24 {tags.length > 0
? 'px-0.5'
: ''} text-xs bg-transparent outline-hidden placeholder:text-gray-400 dark:placeholder:text-gray-500"
placeholder={$i18n.t('Add a tag...')}
on:keydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
addTag();
}
}}
/>
{#if !disabled}
<input
bind:value={inputValue}
class="flex-1 min-w-24 {tags.length > 0
? 'px-0.5'
: ''} text-xs bg-transparent outline-hidden placeholder:text-gray-400 dark:placeholder:text-gray-500"
placeholder={$i18n.t('Add a tag...')}
on:keydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
addTag();
}
}}
/>
{/if}
</div>

View file

@ -5,6 +5,7 @@
const i18n = getContext('i18n');
export let suggestionTags = [];
export let disabled = false;
let tagName = '';
let showInput = false;
@ -30,7 +31,9 @@
};
</script>
{#if showInput}
{#if disabled}
<!-- hidden when disabled -->
{:else if showInput}
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-gray-200/80 dark:bg-gray-700">
<span class="text-gray-500 dark:text-blue-400">+</span>
<input

View file

@ -6,18 +6,27 @@
import XMark from '$lib/components/icons/XMark.svelte';
export let tag;
export let disabled = false;
export let onDelete = () => {};
</script>
{#if tag}
<button
type="button"
class="flex items-center gap-1 px-1.5 py-[1px] rounded-full bg-gray-100/50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-800 text-gray-600 dark:text-gray-300 text-xs font-medium hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
on:click={() => {
onDelete();
}}
>
<span class="line-clamp-1">{tag.name}</span>
<XMark className="size-3" strokeWidth="2.5" />
</button>
{#if disabled}
<span
class="flex items-center gap-1 px-1.5 py-[1px] rounded-full bg-gray-100/50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-800 text-gray-600 dark:text-gray-300 text-xs font-medium"
>
<span class="line-clamp-1">{tag.name}</span>
</span>
{:else}
<button
type="button"
class="flex items-center gap-1 px-1.5 py-[1px] rounded-full bg-gray-100/50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-800 text-gray-600 dark:text-gray-300 text-xs font-medium hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
on:click={() => {
onDelete();
}}
>
<span class="line-clamp-1">{tag.name}</span>
<XMark className="size-3" strokeWidth="2.5" />
</button>
{/if}
{/if}

View file

@ -7,11 +7,13 @@
const dispatch = createEventDispatcher();
export let tags = [];
export let disabled = false;
</script>
{#each tags as tag}
<TagItem
{tag}
{disabled}
onDelete={() => {
dispatch('delete', tag.name);
}}

View file

@ -0,0 +1,186 @@
<script lang="ts">
import { decode } from 'html-entities';
import { v4 as uuidv4 } from 'uuid';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import { slide } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import ChevronUp from '../icons/ChevronUp.svelte';
import ChevronDown from '../icons/ChevronDown.svelte';
import Spinner from './Spinner.svelte';
import Markdown from '../chat/Messages/Markdown.svelte';
import Image from './Image.svelte';
import FullHeightIframe from './FullHeightIframe.svelte';
export let id: string = '';
export let attributes: {
type?: string;
id?: string;
name?: string;
arguments?: string;
result?: string;
files?: string;
embeds?: string;
done?: string;
} = {};
export let open = false;
export let className = '';
export let buttonClassName =
'w-fit text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition';
const componentId = id || uuidv4();
function parseJSONString(str: string) {
try {
return parseJSONString(JSON.parse(str));
} catch (e) {
return str;
}
}
function formatJSONString(str: string) {
try {
const parsed = parseJSONString(str);
// If parsed is an object/array, then it's valid JSON
if (typeof parsed === 'object') {
return JSON.stringify(parsed, null, 2);
} else {
// It's a primitive value like a number, boolean, etc.
return `${JSON.stringify(String(parsed))}`;
}
} catch (e) {
// Not valid JSON, return as-is
return str;
}
}
// Decode and parse attributes
$: args = decode(attributes?.arguments ?? '');
$: result = decode(attributes?.result ?? '');
$: files = parseJSONString(decode(attributes?.files ?? ''));
$: embeds = parseJSONString(decode(attributes?.embeds ?? ''));
$: isDone = attributes?.done === 'true';
$: isExecuting = attributes?.done && attributes?.done !== 'true';
</script>
<div {id} class={className}>
{#if embeds && Array.isArray(embeds) && embeds.length > 0}
<!-- Embed Mode: Show iframes without collapsible behavior -->
<div class="py-1 w-full cursor-pointer">
<div class="w-full text-xs text-gray-500">
<div class="">
{attributes.name}
</div>
</div>
{#each embeds as embed, idx}
<div class="my-2" id={`${componentId}-tool-call-embed-${idx}`}>
<FullHeightIframe
src={embed}
{args}
allowScripts={true}
allowForms={true}
allowSameOrigin={true}
allowPopups={true}
/>
</div>
{/each}
</div>
{:else}
<!-- Standard collapsible tool call display -->
<div
class="{buttonClassName} cursor-pointer"
on:pointerup={() => {
open = !open;
}}
>
<div
class="w-full font-medium flex items-center justify-between gap-2 {isExecuting
? 'shimmer'
: ''}"
>
{#if isExecuting}
<div>
<Spinner className="size-4" />
</div>
{/if}
<div class="">
{#if isDone}
<Markdown
id={`${componentId}-tool-call-title`}
content={$i18n.t('View Result from **{{NAME}}**', {
NAME: attributes.name
})}
/>
{:else}
<Markdown
id={`${componentId}-tool-call-executing`}
content={$i18n.t('Executing **{{NAME}}**...', {
NAME: attributes.name
})}
/>
{/if}
</div>
<div class="flex self-center translate-y-[1px]">
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" />
{/if}
</div>
</div>
</div>
{#if open}
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
{#if isDone}
<Markdown
id={`${componentId}-tool-call-result`}
content={`> \`\`\`json
> ${formatJSONString(args)}
> ${formatJSONString(result)}
> \`\`\``}
/>
{:else}
<Markdown
id={`${componentId}-tool-call-args`}
content={`> \`\`\`json
> ${formatJSONString(args)}
> \`\`\``}
/>
{/if}
</div>
{/if}
{/if}
<!-- Files display (images etc.) when done -->
{#if isDone}
{#if typeof files === 'object'}
{#each files ?? [] as file, idx}
{#if typeof file === 'string'}
{#if file.startsWith('data:image/')}
<Image
id={`${componentId}-tool-call-result-${idx}`}
src={file}
alt="Image"
/>
{/if}
{:else if typeof file === 'object'}
{#if (file.type === 'image' || (file?.content_type ?? '').startsWith('image/')) && file.url}
<Image
id={`${componentId}-tool-call-result-${idx}`}
src={file.url}
alt="Image"
/>
{/if}
{/if}
{/each}
{/if}
{/if}
</div>

View file

@ -26,7 +26,8 @@
models,
selectedFolder,
WEBUI_NAME,
sidebarWidth
sidebarWidth,
activeChatIds
} from '$lib/stores';
import { onMount, getContext, tick, onDestroy } from 'svelte';
@ -42,6 +43,7 @@
importChats
} from '$lib/apis/chats';
import { createNewFolder, getFolders, updateFolderParentIdById } from '$lib/apis/folders';
import { checkActiveChats } from '$lib/apis/tasks';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import ArchivedChatsModal from './ArchivedChatsModal.svelte';
@ -469,6 +471,17 @@
await initChannels();
}
await initChatList();
// Check which chats have active tasks
const allChatIds = [...$chats.map((c) => c.id), ...$pinnedChats.map((c) => c.id)];
if (allChatIds.length > 0) {
try {
const res = await checkActiveChats(localStorage.token, allChatIds);
activeChatIds.set(new Set(res.active_chat_ids || []));
} catch (e) {
console.debug('Failed to check active chats:', e);
}
}
}
}),
settings.subscribe((value) => {
@ -493,8 +506,32 @@
dropZone?.addEventListener('dragover', onDragOver);
dropZone?.addEventListener('drop', onDrop);
dropZone?.addEventListener('dragleave', onDragLeave);
// Listen for real-time chat:active events via the events channel
$socket?.off('events', chatActiveEventHandler);
$socket?.on('events', chatActiveEventHandler);
});
// Handler for chat:active events (defined outside onMount for proper cleanup)
const chatActiveEventHandler = (event: {
chat_id: string;
message_id: string;
data: { type: string; data: any };
}) => {
if (event.data?.type === 'chat:active') {
const { active } = event.data.data;
activeChatIds.update((ids) => {
const newSet = new Set(ids);
if (active) {
newSet.add(event.chat_id);
} else {
newSet.delete(event.chat_id);
}
return newSet;
});
}
};
onDestroy(() => {
if (unsubscribers && unsubscribers.length > 0) {
unsubscribers.forEach((unsubscriber) => {
@ -518,6 +555,9 @@
dropZone?.removeEventListener('dragover', onDragOver);
dropZone?.removeEventListener('drop', onDrop);
dropZone?.removeEventListener('dragleave', onDragLeave);
// Clean up socket listener
$socket?.off('events', chatActiveEventHandler);
});
const newChatHandler = async () => {
@ -560,7 +600,8 @@
<ChannelModal
bind:show={showCreateChannel}
onSubmit={async ({ type, name, is_private, access_control, group_ids, user_ids }) => {
onSubmit={async (payload: any) => {
let { type, name, is_private, access_grants, group_ids, user_ids } = payload ?? {};
name = name?.trim();
if (type === 'dm') {
@ -579,7 +620,7 @@
type: type,
name: name,
is_private: is_private,
access_control: access_control,
access_grants: access_grants,
group_ids: group_ids,
user_ids: user_ids
}).catch((error) => {
@ -815,9 +856,6 @@
{#if $config?.features?.enable_user_status}
<div class="absolute -bottom-0.5 -right-0.5">
<span class="relative flex size-2.5">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"
></span>
<span
class="relative inline-flex size-2.5 rounded-full {true
? 'bg-green-500'
@ -1247,6 +1285,7 @@
className=""
id={chat.id}
title={chat.title}
createdAt={chat.created_at}
{shiftKey}
selected={selectedChatId === chat.id}
on:select={() => {
@ -1307,6 +1346,7 @@
className=""
id={chat.id}
title={chat.title}
createdAt={chat.created_at}
{shiftKey}
selected={selectedChatId === chat.id}
on:select={() => {
@ -1384,9 +1424,6 @@
{#if $config?.features?.enable_user_status}
<div class="absolute -bottom-0.5 -right-0.5">
<span class="relative flex size-2.5">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"
></span>
<span
class="relative inline-flex size-2.5 rounded-full {true
? 'bg-green-500'

View file

@ -25,6 +25,25 @@
let showEditChannelModal = false;
let itemElement;
const hasPublicReadGrant = (grants: any) =>
Array.isArray(grants) &&
grants.some(
(grant) =>
grant?.principal_type === 'user' &&
grant?.principal_id === '*' &&
grant?.permission === 'read'
);
const isPublicChannel = (channel: any): boolean => {
if (channel?.type === 'group') {
if (typeof channel?.is_private === 'boolean') {
return !channel.is_private;
}
return hasPublicReadGrant(channel?.access_grants);
}
return hasPublicReadGrant(channel?.access_grants);
};
</script>
<ChannelModal
@ -32,11 +51,12 @@
{channel}
edit={true}
{onUpdate}
onSubmit={async ({ name, is_private, access_control, group_ids, user_ids }) => {
onSubmit={async (payload: any) => {
const { name, is_private, access_grants, group_ids, user_ids } = payload ?? {};
const res = await updateChannelById(localStorage.token, channel.id, {
name,
is_private,
access_control,
access_grants,
group_ids,
user_ids
}).catch((error) => {
@ -123,7 +143,7 @@
{/if}
{:else}
<div class=" size-4 justify-center flex items-center ml-1">
{#if channel?.type === 'group' ? !channel?.is_private : channel?.access_control === null}
{#if isPublicChannel(channel)}
<Hashtag className="size-3.5" strokeWidth="2.5" />
{:else}
<Lock className="size-[15px]" strokeWidth="2" />

View file

@ -23,7 +23,7 @@
export let onSubmit: Function = () => {};
export let onUpdate: Function = () => {};
export let channel = null;
export let channel: any = null;
export let edit = false;
let channelTypes = ['group', 'dm'];
@ -31,7 +31,7 @@
let name = '';
let isPrivate = null;
let accessControl = {};
let accessGrants = [];
let groupIds = [];
let userIds = [];
@ -65,8 +65,8 @@
await onSubmit({
type: type,
name: name.replace(/\s/g, '-'),
is_private: type === 'group' ? isPrivate : null,
access_control: type === '' ? accessControl : {},
is_private: type === 'group' ? (isPrivate ?? true) : null,
access_grants: type === '' ? accessGrants : [],
group_ids: groupIds,
user_ids: userIds
});
@ -85,8 +85,12 @@
if (channel) {
name = channel?.name ?? '';
isPrivate = channel?.is_private ?? null;
accessControl = channel.access_control;
if (type === 'group') {
isPrivate = typeof channel?.is_private === 'boolean' ? channel.is_private : true;
} else {
isPrivate = null;
}
accessGrants = channel?.access_grants ?? [];
userIds = channel?.user_ids ?? [];
}
};
@ -102,8 +106,14 @@
const deleteHandler = async () => {
showDeleteConfirmDialog = false;
if (!channel?.id) {
show = false;
return;
}
const res = await deleteChannelById(localStorage.token, channel.id).catch((error) => {
const channelId = channel.id;
const res = await deleteChannelById(localStorage.token, channelId).catch((error) => {
toast.error(error.message);
});
@ -111,7 +121,7 @@
toast.success($i18n.t('Channel deleted successfully'));
onUpdate();
if ($page.url.pathname === `/channels/${channel.id}`) {
if ($page.url.pathname === `/channels/${channelId}`) {
goto('/');
}
}
@ -122,7 +132,7 @@
const resetHandler = () => {
type = '';
name = '';
accessControl = {};
accessGrants = [];
userIds = [];
loading = false;
};
@ -226,11 +236,11 @@
{#if type !== 'dm'}
<div class="-mx-2 mb-1 mt-2.5 px-2">
{#if type === ''}
<AccessControl bind:accessControl accessRoles={['read', 'write']} />
<AccessControl bind:accessGrants accessRoles={['read', 'write']} />
{:else if type === 'group'}
<Visibility
state={isPrivate ? 'private' : 'public'}
onChange={(value) => {
onChange={(value: string) => {
if (value === 'private') {
isPrivate = true;
} else {

View file

@ -27,7 +27,8 @@
showSidebar,
currentChatPage,
tags,
selectedFolder
selectedFolder,
activeChatIds
} from '$lib/stores';
import ChatMenu from './ChatMenu.svelte';
@ -41,18 +42,40 @@
import XMark from '$lib/components/icons/XMark.svelte';
import Document from '$lib/components/icons/Document.svelte';
import Sparkles from '$lib/components/icons/Sparkles.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import { generateTitle } from '$lib/apis';
export let className = '';
export let id;
export let title;
export let createdAt: number | null = null;
export let selected = false;
export let shiftKey = false;
export let onDragEnd = () => {};
function formatTimeAgo(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp * 1000; // timestamp is in seconds
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const weeks = Math.floor(days / 7);
const years = Math.floor(days / 365);
if (years > 0) return `${years}y`;
if (weeks > 0) return `${weeks}w`;
if (days > 0) return `${days}d`;
if (hours > 0) return `${hours}h`;
if (minutes > 0) return `${minutes}m`;
return '1m';
}
let chat = null;
let mouseOver = false;
@ -423,11 +446,25 @@
on:focus={(e) => {}}
draggable="false"
>
<div class=" flex self-center flex-1 w-full">
<div dir="auto" class=" text-left self-center overflow-hidden w-full h-[20px] truncate">
<!-- Loading spinner for active chat (left side) -->
{#if $activeChatIds.has(id)}
<div class="shrink-0 self-center pr-2">
<Spinner className="size-3" />
</div>
{/if}
<div class="flex self-center flex-1 w-full min-w-0">
<div dir="auto" class="text-left self-center overflow-hidden w-full h-[20px] truncate">
{title}
</div>
</div>
<!-- Time ago indicator -->
{#if createdAt && !mouseOver}
<div class="shrink-0 self-center text-[10px] text-gray-400 dark:text-gray-500 pl-2">
{formatTimeAgo(createdAt)}
</div>
{/if}
</a>
{/if}

View file

@ -643,6 +643,7 @@
<ChatItem
id={chat.id}
title={chat.title}
createdAt={chat.created_at}
{shiftKey}
on:change={(e) => {
dispatch('change', e.detail);

View file

@ -110,9 +110,6 @@
{#if $user?.is_active ?? true}
<div>
<span class="relative flex size-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
/>
<span class="relative inline-flex rounded-full size-2 bg-green-500" />
</span>
</div>
@ -371,9 +368,6 @@
>
<div class=" flex items-center">
<span class="relative flex size-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
/>
<span class="relative inline-flex rounded-full size-2 bg-green-500" />
</span>
</div>

View file

@ -108,9 +108,18 @@
},
// pages: [], // TODO: Implement pages for notes to allow users to create multiple pages in a note
meta: null,
access_control: {}
access_grants: []
};
const hasPublicReadGrant = (grants) =>
Array.isArray(grants) &&
grants.some(
(grant) =>
grant?.principal_type === 'user' &&
grant?.principal_id === '*' &&
grant?.permission === 'read'
);
let files = [];
let messages = [];
@ -161,6 +170,9 @@
if (res) {
note = res;
if (!Array.isArray(note?.access_grants)) {
note.access_grants = [];
}
files = res.data.files || [];
if (note?.write_access) {
@ -193,7 +205,7 @@
data: {
files: files
},
access_control: note?.access_control
access_grants: note?.access_grants ?? []
}).catch((e) => {
toast.error(`${e}`);
});
@ -765,8 +777,8 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
console.log('noteEventHandler', _note);
if (_note.id !== id) return;
if (_note.access_control && _note.access_control !== note.access_control) {
note.access_control = _note.access_control;
if (_note.access_grants && _note.access_grants !== note.access_grants) {
note.access_grants = _note.access_grants;
}
if (_note.data && _note.data.files) {
@ -851,7 +863,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
{#if note}
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessControl={note.access_control}
bind:accessGrants={note.access_grants}
accessRoles={['read', 'write']}
onChange={() => {
changeDebounceHandler();
@ -1114,7 +1126,11 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
}}
disabled={note?.user_id !== $user?.id && $user?.role !== 'admin'}
>
<span> {note?.access_control ? $i18n.t('Private') : $i18n.t('Everyone')} </span>
<span>
{hasPublicReadGrant(note?.access_grants)
? $i18n.t('Everyone')
: $i18n.t('Private')}
</span>
</button>
{:else}
<div>

View file

@ -129,7 +129,7 @@
}
},
meta: null,
access_control: {}
access_grants: []
}).catch((error) => {
toast.error(`${error}`);
return null;

View file

@ -120,7 +120,7 @@ export const createNoteHandler = async (title: string, md?: string, html?: strin
}
},
meta: null,
access_control: {}
access_grants: []
}).catch((error) => {
toast.error(`${error}`);
return null;

View file

@ -15,7 +15,7 @@
let name = '';
let description = '';
let accessControl = {};
let accessGrants = [];
const submitHandler = async () => {
loading = true;
@ -32,7 +32,7 @@
localStorage.token,
name,
description,
accessControl
accessGrants
).catch((e) => {
toast.error(`${e}`);
});
@ -114,7 +114,7 @@
<div class="mt-2">
<AccessControl
bind:accessControl
bind:accessGrants
accessRoles={['read', 'write']}
share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}

View file

@ -75,6 +75,8 @@
file_ids: string[];
};
files: any[];
access_grants?: any[];
write_access?: boolean;
};
let id = null;
@ -614,7 +616,7 @@
...knowledge,
name: knowledge.name,
description: knowledge.description,
access_control: knowledge.access_control
access_grants: knowledge.access_grants ?? []
}).catch((e) => {
toast.error(`${e}`);
});
@ -745,6 +747,9 @@
if (res) {
knowledge = res;
if (!Array.isArray(knowledge?.access_grants)) {
knowledge.access_grants = [];
}
knowledgeId = knowledge?.id;
} else {
goto('/workspace/knowledge');
@ -828,9 +833,11 @@
{#if id && knowledge}
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessControl={knowledge.access_control}
bind:accessGrants={knowledge.access_grants}
share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_knowledge ||
$user?.role === 'admin' ||
knowledge?.write_access}
onChange={() => {
changeDebounceHandler();
}}

View file

@ -108,7 +108,7 @@
let builtinTools = {};
let actionIds = [];
let accessControl = {};
let accessGrants = [];
let tts = { voice: '' };
const submitHandler = async () => {
@ -140,7 +140,7 @@
info.params = { ...info.params, ...params };
info.access_control = accessControl;
info.access_grants = accessGrants;
info.meta.capabilities = capabilities;
if (enableDescription) {
@ -301,14 +301,7 @@
builtinTools = model?.meta?.builtinTools ?? {};
tts = { voice: model?.meta?.tts?.voice ?? '' };
if ('access_control' in model) {
accessControl = model.access_control;
} else {
accessControl = {};
}
console.log(model?.access_control);
console.log(accessControl);
accessGrants = model?.access_grants ?? [];
info = {
...info,
@ -334,10 +327,10 @@
{#if loaded}
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessControl
bind:accessGrants
accessRoles={['read', 'write']}
share={$user?.permissions?.sharing?.models || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_models || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_models || $user?.role === 'admin' || edit}
/>
{#if onBack}

View file

@ -46,7 +46,7 @@
let commitMessage = '';
let isProduction = true;
let accessControl = {};
let accessGrants = [];
let showAccessControlModal = false;
let hasManualEdit = false;
@ -87,7 +87,7 @@
command,
content,
tags: tags.map((tag) => tag.name),
access_control: accessControl,
access_grants: accessGrants,
commit_message: commitMessage || undefined,
is_production: isProduction
});
@ -259,7 +259,7 @@
command = prompt.command.at(0) === '/' ? prompt.command.slice(1) : prompt.command;
content = prompt.content;
tags = (prompt.tags || []).map((tag) => ({ name: tag }));
accessControl = prompt?.access_control === undefined ? {} : prompt?.access_control;
accessGrants = prompt?.access_grants === undefined ? [] : prompt?.access_grants;
// Store originals for revert on collision
originalName = name;
@ -286,10 +286,10 @@
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessControl
bind:accessGrants
accessRoles={['read', 'write']}
share={$user?.permissions?.sharing?.prompts || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_prompts || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_prompts || $user?.role === 'admin' || edit}
/>
<!-- Edit Modal -->
@ -435,6 +435,7 @@
<div class="flex-1 min-w-0">
<Tags
{tags}
{disabled}
{suggestionTags}
on:add={(e) => {
tags = [...tags, { name: e.detail }];

View file

@ -30,7 +30,7 @@
description: ''
};
export let content = '';
export let accessControl = {};
export let accessGrants = [];
let _content = '';
@ -161,7 +161,7 @@ class Tools:
name,
meta,
content,
access_control: accessControl
access_grants: accessGrants
});
};
@ -187,10 +187,10 @@ class Tools:
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessControl
bind:accessGrants
accessRoles={['read', 'write']}
share={$user?.permissions?.sharing?.tools || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_tools || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_tools || $user?.role === 'admin' || edit}
/>
<div class=" flex flex-col justify-between w-full overflow-y-auto h-full">

View file

@ -3,75 +3,430 @@
const i18n = getContext('i18n');
import { getGroups } from '$lib/apis/groups';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
import { getGroups, getGroupById, getGroupInfoById } from '$lib/apis/groups';
import { getUserInfoById } from '$lib/apis/users';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import XMark from '$lib/components/icons/XMark.svelte';
import Badge from '$lib/components/common/Badge.svelte';
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import AddAccessModal from './AddAccessModal.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
type AccessGrant = {
id?: string;
principal_type: 'user' | 'group';
principal_id: string;
permission: 'read' | 'write';
};
type LegacyAccessControl = {
read: { group_ids: string[]; user_ids: string[] };
write: { group_ids: string[]; user_ids: string[] };
};
export let onChange: Function = () => {};
export let accessRoles = ['read'];
export let accessControl = {};
export let accessGrants: AccessGrant[] | any = [];
export let accessControl: any = undefined;
export let share = true;
export let sharePublic = true;
let selectedGroupId = '';
let groups = [];
let groups: any[] = [];
const resolvingGroupIds = new Set<string>();
let userById: Record<string, any> = {};
const resolvingUserIds = new Set<string>();
$: if (!sharePublic && accessControl === null) {
initPublicAccess();
}
let showAddAccessModal = false;
const initPublicAccess = () => {
if (!sharePublic && accessControl === null) {
accessControl = {
read: {
group_ids: [],
user_ids: []
},
write: {
group_ids: [],
user_ids: []
const dedupeAccessGrants = (grants: AccessGrant[] | null | undefined): AccessGrant[] => {
if (!Array.isArray(grants)) return [];
const map = new Map<string, AccessGrant>();
for (const grant of grants) {
if (!grant) continue;
const key = `${grant.principal_type}:${grant.principal_id}:${grant.permission}`;
if (!grant.principal_type || !grant.principal_id || !grant.permission) continue;
map.set(key, {
id: grant.id,
principal_type: grant.principal_type,
principal_id: grant.principal_id,
permission: grant.permission
});
}
return Array.from(map.values());
};
const legacyAccessControlToGrants = (accessControl: any): AccessGrant[] => {
if (accessControl === null) {
return [
{
principal_type: 'user',
principal_id: '*',
permission: 'read'
}
};
onChange(accessControl);
];
}
if (!accessControl || typeof accessControl !== 'object') {
return [];
}
const grants: AccessGrant[] = [];
for (const permission of ['read', 'write'] as const) {
const entry = accessControl?.[permission] ?? {};
for (const groupId of entry?.group_ids ?? []) {
grants.push({
principal_type: 'group',
principal_id: groupId,
permission
});
}
for (const userId of entry?.user_ids ?? []) {
grants.push({
principal_type: 'user',
principal_id: userId,
permission
});
}
}
return dedupeAccessGrants(grants);
};
const grantsToLegacyAccessControl = (grants: AccessGrant[]): null | LegacyAccessControl => {
const normalized = dedupeAccessGrants(grants);
if (hasPublicReadGrant(normalized)) {
return null;
}
const result: LegacyAccessControl = {
read: { group_ids: [], user_ids: [] },
write: { group_ids: [], user_ids: [] }
};
for (const grant of normalized) {
if (!['read', 'write'].includes(grant.permission)) {
continue;
}
if (grant.principal_type === 'group') {
if (!result[grant.permission].group_ids.includes(grant.principal_id)) {
result[grant.permission].group_ids = [
...result[grant.permission].group_ids,
grant.principal_id
];
}
} else if (grant.principal_type === 'user' && grant.principal_id !== '*') {
if (!result[grant.permission].user_ids.includes(grant.principal_id)) {
result[grant.permission].user_ids = [
...result[grant.permission].user_ids,
grant.principal_id
];
}
}
}
return result;
};
const normalizeInputToGrants = (value: any): AccessGrant[] => {
if (value === null) {
return legacyAccessControlToGrants(null);
}
if (Array.isArray(value)) {
return dedupeAccessGrants(value);
}
if (value && typeof value === 'object' && ('read' in value || 'write' in value)) {
return legacyAccessControlToGrants(value);
}
return [];
};
const stableStringify = (value: any): string => {
try {
return JSON.stringify(value ?? null);
} catch {
return '';
}
};
const hasPublicReadGrant = (grants: AccessGrant[]): boolean =>
grants.some(
(grant) =>
grant.principal_type === 'user' && grant.principal_id === '*' && grant.permission === 'read'
);
const currentGrants = (): AccessGrant[] =>
Array.isArray(accessGrants) ? (accessGrants as AccessGrant[]) : [];
const getPrincipalIdsByPermission = (
principalType: 'user' | 'group',
permission: 'read' | 'write'
): string[] =>
Array.from(
new Set(
currentGrants()
.filter(
(grant) => grant.principal_type === principalType && grant.permission === permission
)
.map((grant) => grant.principal_id)
)
);
const hasPrincipalGrant = (
principalType: 'user' | 'group',
principalId: string,
permission: 'read' | 'write'
): boolean =>
currentGrants().some(
(grant) =>
grant.principal_type === principalType &&
grant.principal_id === principalId &&
grant.permission === permission
);
const commitAccessGrants = (nextGrants: AccessGrant[]) => {
accessGrants = dedupeAccessGrants(nextGrants);
onChange(accessGrants);
};
const setPublic = (isPublic: boolean) => {
const filtered = currentGrants().filter(
(grant) =>
!(
grant.principal_type === 'user' &&
grant.principal_id === '*' &&
grant.permission === 'read'
)
);
if (isPublic) {
filtered.push({
principal_type: 'user',
principal_id: '*',
permission: 'read'
});
}
commitAccessGrants(filtered);
};
const upsertPrincipalGrant = (
principalType: 'user' | 'group',
principalId: string,
permission: 'read' | 'write',
grants: AccessGrant[]
): AccessGrant[] => {
if (
grants.some(
(grant) =>
grant.principal_type === principalType &&
grant.principal_id === principalId &&
grant.permission === permission
)
) {
return grants;
}
return [
...grants,
{
principal_type: principalType,
principal_id: principalId,
permission
}
];
};
const removePrincipalGrant = (
principalType: 'user' | 'group',
principalId: string,
permission: 'read' | 'write',
grants: AccessGrant[]
): AccessGrant[] =>
grants.filter(
(grant) =>
!(
grant.principal_type === principalType &&
grant.principal_id === principalId &&
grant.permission === permission
)
);
const removePrincipal = (principalType: 'user' | 'group', principalId: string) => {
let next = [...currentGrants()];
next = removePrincipalGrant(principalType, principalId, 'read', next);
next = removePrincipalGrant(principalType, principalId, 'write', next);
commitAccessGrants(next);
};
const togglePrincipalWrite = (principalType: 'user' | 'group', principalId: string) => {
let next = [...currentGrants()];
const hasWrite = hasPrincipalGrant(principalType, principalId, 'write');
if (hasWrite) {
next = removePrincipalGrant(principalType, principalId, 'write', next);
} else {
next = upsertPrincipalGrant(principalType, principalId, 'read', next);
next = upsertPrincipalGrant(principalType, principalId, 'write', next);
}
commitAccessGrants(next);
};
const ensureUsersByIds = async (userIds: string[]) => {
const pendingIds = userIds.filter((id) => !userById[id] && !resolvingUserIds.has(id));
if (!pendingIds.length) return;
for (const id of pendingIds) {
resolvingUserIds.add(id);
}
const fetched = await Promise.all(
pendingIds.map(async (id) => {
const user = await getUserInfoById(localStorage.token, id).catch((error) => {
console.error(error);
return null;
});
return { id, user };
})
);
const nextUserById = { ...userById };
for (const item of fetched) {
if (item.user?.id) {
nextUserById[item.id] = item.user;
}
resolvingUserIds.delete(item.id);
}
userById = nextUserById;
};
const handleAddAccess = ({ userIds, groupIds }: { userIds: string[]; groupIds: string[] }) => {
let next = [...currentGrants()];
for (const groupId of groupIds) {
next = upsertPrincipalGrant('group', groupId, 'read', next);
}
for (const userId of userIds) {
next = upsertPrincipalGrant('user', userId, 'read', next);
}
commitAccessGrants(next);
};
// NOTE: We must reference `accessGrants` directly in each reactive
// expression so Svelte tracks the dependency.
const ensureGroupsByIds = async (groupIds: string[]) => {
const pendingIds = groupIds.filter(
(id) => !groups.find((g) => g.id === id) && !resolvingGroupIds.has(id)
);
if (!pendingIds.length) return;
for (const id of pendingIds) {
resolvingGroupIds.add(id);
}
const fetched = await Promise.all(
pendingIds.map(async (id) => {
const group = await getGroupInfoById(localStorage.token, id).catch((error) => {
console.error(error);
return null;
});
return group;
})
);
const newGroups = fetched.filter((g) => g);
if (newGroups.length > 0) {
groups = [...groups, ...newGroups].filter(
(g, index, self) => index === self.findIndex((t) => t.id === g.id)
);
}
for (const id of pendingIds) {
resolvingGroupIds.delete(id);
}
};
$: if (readGroupIds.length > 0 || writeGroupIds.length > 0) {
void ensureGroupsByIds([...readGroupIds, ...writeGroupIds]);
}
$: readGroupIds = (accessGrants, getPrincipalIdsByPermission('group', 'read'));
$: writeGroupIds = (accessGrants, getPrincipalIdsByPermission('group', 'write'));
$: readUserIds =
(accessGrants, getPrincipalIdsByPermission('user', 'read').filter((id) => id !== '*'));
$: writeUserIds =
(accessGrants, getPrincipalIdsByPermission('user', 'write').filter((id) => id !== '*'));
$: selectedUserIds = Array.from(new Set([...readUserIds, ...writeUserIds]));
$: selectedUsers = selectedUserIds
.map((id) => {
return userById[id] ?? { id, name: id, email: '' };
})
.sort((a, b) => a.name.localeCompare(b.name));
$: accessGroups = groups
.filter((group) => readGroupIds.includes(group.id) || writeGroupIds.includes(group.id))
.sort((a, b) => a.name.localeCompare(b.name));
$: if (selectedUserIds.length > 0) {
void ensureUsersByIds(selectedUserIds);
}
$: {
if (accessControl !== undefined) {
const normalizedGrants = normalizeInputToGrants(accessControl);
if (stableStringify(normalizedGrants) !== stableStringify(accessGrants)) {
accessGrants = normalizedGrants;
}
}
}
$: {
const normalizedGrants = normalizeInputToGrants(accessGrants);
if (stableStringify(normalizedGrants) !== stableStringify(accessGrants)) {
accessGrants = normalizedGrants;
}
if (accessControl !== undefined) {
const nextAccessControl = grantsToLegacyAccessControl(normalizedGrants);
if (stableStringify(nextAccessControl) !== stableStringify(accessControl)) {
accessControl = nextAccessControl;
}
}
}
onMount(async () => {
groups = await getGroups(localStorage.token, true).catch((error) => {
console.log('AccessControl mounted', { accessGrants, accessControl });
const res = await getGroups(localStorage.token, true).catch((error) => {
console.error(error);
return [];
});
if (accessControl === null) {
initPublicAccess();
} else {
accessControl = {
read: {
group_ids: accessControl?.read?.group_ids ?? [],
user_ids: accessControl?.read?.user_ids ?? []
},
write: {
group_ids: accessControl?.write?.group_ids ?? [],
user_ids: accessControl?.write?.user_ids ?? []
}
};
}
console.log('getGroups res', res);
groups = [...groups, ...res].filter(
(g, index, self) => index === self.findIndex((t) => t.id === g.id)
);
});
$: console.log('AccessControl state', {
accessGrants,
readGroupIds,
writeGroupIds,
selectedUserIds,
groups,
accessGroups,
selectedUsers
});
</script>
<div class=" rounded-lg flex flex-col gap-2">
<div class="">
<div class=" text-xs font-medium mb-2.5 text-gray-500">{$i18n.t('Visibility')}</div>
<AddAccessModal bind:show={showAddAccessModal} onAdd={handleAddAccess} />
<div class="flex gap-2.5 items-center mb-1">
<div class=" rounded-lg flex flex-col gap-1">
<div class="py-2">
<div class="flex gap-2.5 items-center">
<div>
<div class=" p-2 bg-black/5 dark:bg-white/5 rounded-full">
{#if accessControl !== null}
{#if !hasPublicReadGrant(accessGrants ?? [])}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
@ -106,36 +461,28 @@
</div>
<div>
<select
id="models"
class="dark:bg-gray-900 outline-hidden bg-transparent text-sm font-medium block w-fit pr-10 max-w-full placeholder-gray-400"
value={accessControl !== null ? 'private' : 'public'}
on:change={(e) => {
if (e.target.value === 'public') {
accessControl = null;
} else {
accessControl = {
read: {
group_ids: [],
user_ids: []
},
write: {
group_ids: [],
user_ids: []
}
};
}
onChange(accessControl);
}}
<Tooltip
content={!(share && sharePublic) && !hasPublicReadGrant(accessGrants ?? [])
? $i18n.t('You do not have permission to make this public')
: ''}
>
<option class=" text-gray-700" value="private" selected>{$i18n.t('Private')}</option>
{#if share && sharePublic}
<option class=" text-gray-700" value="public" selected>{$i18n.t('Public')}</option>
{/if}
</select>
<select
id="models"
class="dark:bg-gray-900 outline-none bg-transparent text-sm font-medium block w-fit pr-10 max-w-full placeholder-gray-400"
value={!hasPublicReadGrant(accessGrants ?? []) ? 'private' : 'public'}
on:change={(e) => {
setPublic((e.target as HTMLSelectElement).value === 'public');
}}
>
<option class=" text-gray-700" value="private">{$i18n.t('Private')}</option>
{#if (share && sharePublic) || hasPublicReadGrant(accessGrants ?? [])}
<option class=" text-gray-700" value="public">{$i18n.t('Public')}</option>
{/if}
</select>
</Tooltip>
<div class=" text-xs text-gray-400 font-medium">
{#if accessControl !== null}
{#if !hasPublicReadGrant(accessGrants ?? [])}
{$i18n.t('Only select users and groups with permission can access')}
{:else}
{$i18n.t('Accessible to all users')}
@ -146,116 +493,126 @@
</div>
{#if share}
{#if accessControl !== null}
{@const accessGroups = groups.filter((group) =>
(accessControl?.read?.group_ids ?? []).includes(group.id)
)}
<div class="flex items-center justify-between text-xs font-medium text-gray-500 my-1">
<div>
<div class="">
<div class="flex justify-between mb-2.5">
<div class="text-xs font-medium text-gray-500">
{$i18n.t('Groups')}
{$i18n.t('Access List')}
</div>
<div class="flex gap-1">
<button
class="px-2 py-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition text-xs font-medium flex items-center gap-1"
type="button"
on:click={() => {
showAddAccessModal = true;
}}
>
<Plus className="size-3" />
{$i18n.t('Add Access')}
</button>
</div>
</div>
<!-- List -->
<div class="flex flex-col gap-2">
<!-- Groups -->
{#each accessGroups as group}
<div class="flex items-center gap-3 justify-between text-sm w-full transition pb-1">
<div class="flex items-center gap-2 w-full flex-1">
<!-- Placeholder for group icon vs user icon -->
<div
class="size-5 rounded-full bg-gray-100 dark:bg-gray-850 flex items-center justify-center text-xs"
>
{group.name.charAt(0).toUpperCase()}
</div>
<div class="truncate text-sm flex items-center gap-2">
{group.name}
<span class="text-xs text-gray-400 font-normal"
>{group?.member_count} {$i18n.t('members')}</span
>
</div>
</div>
{#if accessGroups.length > 0}
<div class="flex flex-col gap-1.5 mb-2 px-0.5 mx-0.5">
{#each accessGroups as group}
<div class="flex items-center gap-3 justify-between text-sm w-full transition">
<div class="flex items-center gap-1.5 w-full">
<div>
{group.name} <span class="text-xs text-gray-500">{group?.member_count}</span>
</div>
</div>
<div class="w-full flex justify-end items-center gap-2">
<button
type="button"
on:click={() => {
if (accessRoles.includes('write')) {
togglePrincipalWrite('group', group.id);
}
}}
>
{#if writeGroupIds.includes(group.id)}
<Badge type={'success'} content={$i18n.t('Write')} />
{:else}
<Badge type={'info'} content={$i18n.t('Read')} />
{/if}
</button>
<div class="w-full flex justify-end items-center gap-0.5">
<button
class=""
type="button"
on:click={() => {
if (accessRoles.includes('write')) {
if ((accessControl?.write?.group_ids ?? []).includes(group.id)) {
accessControl.write.group_ids = (
accessControl?.write?.group_ids ?? []
).filter((group_id) => group_id !== group.id);
} else {
accessControl.write.group_ids = [
...(accessControl?.write?.group_ids ?? []),
group.id
];
}
onChange(accessControl);
}
}}
>
{#if (accessControl?.write?.group_ids ?? []).includes(group.id)}
<Badge type={'success'} content={$i18n.t('Write')} />
{:else}
<Badge type={'info'} content={$i18n.t('Read')} />
{/if}
</button>
<button
class=" rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition"
type="button"
on:click={() => {
accessControl.read.group_ids = (
accessControl?.read?.group_ids ?? []
).filter((id) => id !== group.id);
accessControl.write.group_ids = (
accessControl?.write?.group_ids ?? []
).filter((id) => id !== group.id);
onChange(accessControl);
}}
>
<XMark />
</button>
</div>
</div>
{/each}
</div>
{/if}
<!-- <div class="flex items-center justify-center">
<div class="text-gray-500 text-xs text-center py-2 px-10">
{$i18n.t('No groups with access, add a group to grant access')}
</div>
</div> -->
<div class="mb-1">
<div class="flex w-full">
<div class="flex flex-1 items-center">
<div class="w-full px-0.5">
<select
class=" outline-hidden bg-transparent text-sm block w-full pr-10 max-w-full
{selectedGroupId ? '' : 'text-gray-500'}
dark:placeholder-gray-500"
bind:value={selectedGroupId}
on:change={() => {
if (selectedGroupId !== '') {
accessControl.read.group_ids = [
...(accessControl?.read?.group_ids ?? []),
selectedGroupId
];
selectedGroupId = '';
onChange(accessControl);
}
}}
>
<option class=" text-gray-700" value="" disabled selected
>{$i18n.t('Select a group')}</option
>
{#each groups.filter((group) => !(accessControl?.read?.group_ids ?? []).includes(group.id)) as group}
<option class=" text-gray-700" value={group.id}>{group.name}</option>
{/each}
</select>
</div>
</div>
</div>
<button
class=" rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition"
type="button"
on:click={() => {
removePrincipal('group', group.id);
}}
>
<XMark className="size-4" />
</button>
</div>
</div>
</div>
{/if}
{/each}
<!-- Users -->
{#each selectedUsers as user}
<div
class="flex items-center gap-3 justify-between text-sm w-full transition border-b border-gray-50 dark:border-gray-850 pb-2 last:border-0"
>
<div class="flex items-center gap-2 w-full flex-1">
<img
class="rounded-full size-5 object-cover"
src={`${WEBUI_API_BASE_URL}/users/${user.id}/profile/image`}
alt={user.name ?? user.id}
/>
<div class="w-full">
<Tooltip content={user.email} placement="top-start">
<div class="truncate text-sm">{user.name ?? user.id}</div>
</Tooltip>
</div>
</div>
<div class="w-full flex justify-end items-center gap-2">
<button
type="button"
on:click={() => {
if (accessRoles.includes('write')) {
togglePrincipalWrite('user', user.id);
}
}}
>
{#if writeUserIds.includes(user.id)}
<Badge type={'success'} content={$i18n.t('Write')} />
{:else}
<Badge type={'info'} content={$i18n.t('Read')} />
{/if}
</button>
<button
class=" rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition"
type="button"
on:click={() => {
removePrincipal('user', user.id);
}}
>
<XMark className="size-4" />
</button>
</div>
</div>
{/each}
{#if !hasPublicReadGrant(accessGrants ?? []) && accessGroups.length === 0 && selectedUsers.length === 0}
<div class="text-xs text-gray-500 text-center py-4">
{$i18n.t('No access grants. Private to you.')}
</div>
{/if}
</div>
{/if}
</div>

View file

@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { getContext } from 'svelte';
const i18n = getContext('i18n');
@ -6,8 +6,16 @@
import AccessControl from './AccessControl.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
type AccessGrant = {
id?: string;
principal_type: 'user' | 'group';
principal_id: string;
permission: 'read' | 'write';
};
export let show = false;
export let accessControl = {};
export let accessGrants: AccessGrant[] = [];
export let accessControl: any = undefined;
export let accessRoles = ['read'];
export let share = true;
@ -33,7 +41,14 @@
</div>
<div class="w-full px-5 pb-4 dark:text-white">
<AccessControl bind:accessControl {onChange} {accessRoles} {share} {sharePublic} />
<AccessControl
bind:accessGrants
bind:accessControl
{onChange}
{accessRoles}
{share}
{sharePublic}
/>
</div>
</div>
</Modal>

View file

@ -0,0 +1,69 @@
<script lang="ts">
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import MemberSelector from '$lib/components/workspace/common/MemberSelector.svelte';
export let show = false;
export let onAdd = (payload: { userIds: string[]; groupIds: string[] }) => {};
let userIds: string[] = [];
let groupIds: string[] = [];
let loading = false;
const submitHandler = () => {
loading = true;
onAdd({ userIds, groupIds });
show = false;
userIds = [];
groupIds = [];
loading = false;
};
</script>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
<div class="self-center text-base">
<div class="flex items-center gap-0.5 shrink-0">
{$i18n.t('Add Access')}
</div>
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-3 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form
class="flex flex-col w-full"
on:submit|preventDefault={() => {
submitHandler();
}}
>
<div class="flex flex-col w-full h-full pb-2">
<MemberSelector bind:userIds bind:groupIds includeGroups={true} />
</div>
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-950 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
type="submit"
>
{$i18n.t('Add')}
</button>
</div>
</form>
</div>
</div>
</div>
</Modal>

View file

@ -5,7 +5,7 @@
const i18n = getContext('i18n');
import { user as _user } from '$lib/stores';
import { getUserById, searchUsers } from '$lib/apis/users';
import { getUserInfoById, searchUsers } from '$lib/apis/users';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import XMark from '$lib/components/icons/XMark.svelte';
@ -84,7 +84,7 @@
if (userIds.length > 0) {
userIds.forEach(async (id) => {
const res = await getUserById(localStorage.token, id).catch((error) => {
const res = await getUserInfoById(localStorage.token, id).catch((error) => {
console.error(error);
return null;
});

File diff suppressed because it is too large Load diff

View file

@ -131,6 +131,10 @@
"code": "lt-LT",
"title": "Lithuanian (Lietuvių)"
},
{
"code": "lv-LV",
"title": "Latvian (Latviešu)"
},
{
"code": "ms-MY",
"title": "Malay (Bahasa Malaysia)"

File diff suppressed because it is too large Load diff

View file

@ -27,6 +27,7 @@ export const mobile = writable(false);
export const socket: Writable<null | Socket> = writable(null);
export const activeUserIds: Writable<null | string[]> = writable(null);
export const activeChatIds: Writable<Set<string>> = writable(new Set());
export const USAGE_POOL: Writable<null | string[]> = writable(null);
export const theme = writable('system');

View file

@ -1277,17 +1277,20 @@ export const convertOpenApiToToolPayload = (openApiSpec) => {
// Extract path and query parameters
if (operation.parameters) {
operation.parameters.forEach((param) => {
let description = param.schema.description || param.description || '';
if (param.schema.enum && Array.isArray(param.schema.enum)) {
description += `. Possible values: ${param.schema.enum.join(', ')}`;
const paramName = param?.name;
if (!paramName) return;
const paramSchema = param?.schema ?? {};
let description = paramSchema.description || param.description || '';
if (paramSchema.enum && Array.isArray(paramSchema.enum)) {
description += `. Possible values: ${paramSchema.enum.join(', ')}`;
}
tool.parameters.properties[param.name] = {
type: param.schema.type,
tool.parameters.properties[paramName] = {
type: paramSchema.type,
description: description
};
if (param.required) {
tool.parameters.required.push(param.name);
tool.parameters.required.push(paramName);
}
});
}

View file

@ -35,7 +35,7 @@
content: updatedPrompt.content,
version_id: updatedPrompt.version_id,
tags: updatedPrompt.tags,
access_control: updatedPrompt?.access_control === undefined ? {} : updatedPrompt?.access_control
access_grants: updatedPrompt?.access_grants === undefined ? [] : updatedPrompt?.access_grants
};
}
};
@ -59,7 +59,7 @@
content: _prompt.content,
version_id: _prompt.version_id,
tags: _prompt.tags,
access_control: _prompt?.access_control === undefined ? {} : _prompt?.access_control
access_grants: _prompt?.access_grants === undefined ? [] : _prompt?.access_grants
};
} else {
goto('/workspace/prompts');

View file

@ -10,10 +10,11 @@
import PromptEditor from '$lib/components/workspace/Prompts/PromptEditor.svelte';
let prompt: {
title: string;
name: string;
command: string;
content: string;
access_control: any | null;
tags: string[];
access_grants: any[];
} | null = null;
let clone = false;
@ -50,7 +51,7 @@
command: _prompt.command,
content: _prompt.content,
tags: _prompt.tags || [],
access_control: _prompt.access_control !== undefined ? _prompt.access_control : {}
access_grants: _prompt.access_grants !== undefined ? _prompt.access_grants : []
};
});
@ -70,7 +71,7 @@
command: _prompt.command,
content: _prompt.content,
tags: _prompt.tags || [],
access_control: _prompt.access_control !== undefined ? _prompt.access_control : {}
access_grants: _prompt.access_grants !== undefined ? _prompt.access_grants : []
};
}
});

View file

@ -37,7 +37,7 @@
name: data.name,
meta: data.meta,
content: data.content,
access_control: data.access_control
access_grants: data.access_grants
}).catch((error) => {
toast.error(`${error}`);
return null;
@ -87,7 +87,7 @@
name={tool?.name ?? ''}
meta={tool?.meta ?? { description: '' }}
content={tool?.content ?? ''}
accessControl={tool?.access_control !== undefined ? tool.access_control : {}}
accessGrants={tool?.access_grants !== undefined ? tool.access_grants : []}
{clone}
onSave={(value) => {
saveHandler(value);

View file

@ -37,7 +37,7 @@
name: data.name,
meta: data.meta,
content: data.content,
access_control: data.access_control
access_grants: data.access_grants
}).catch((error) => {
toast.error(`${error}`);
return null;
@ -83,7 +83,7 @@
name={tool.name}
meta={tool.meta}
content={tool.content}
accessControl={tool.access_control}
accessGrants={tool.access_grants ?? []}
onSave={(value) => {
saveHandler(value);
}}

View file

@ -12,7 +12,7 @@
import Messages from '$lib/components/chat/Messages.svelte';
import { getUserById, getUserSettings } from '$lib/apis/users';
import { getUserInfoById, getUserSettings } from '$lib/apis/users';
import { getModels } from '$lib/apis';
import { toast } from 'svelte-sonner';
import localizedFormat from 'dayjs/plugin/localizedFormat';
@ -92,7 +92,7 @@
});
if (chat) {
user = await getUserById(localStorage.token, chat.user_id).catch((error) => {
user = await getUserInfoById(localStorage.token, chat.user_id).catch((error) => {
console.error(error);
return null;
});