diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index b1955b056d2..2c355fc7292 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -31,6 +31,7 @@ ) from open_webui.internal.db import Base, get_db from open_webui.utils.redis import get_redis_connection +from open_webui.models.permissions import Permissions class EndpointFilter(logging.Filter): @@ -1130,7 +1131,6 @@ def oidc_oauth_register(client): os.environ.get("USER_PERMISSIONS_FEATURES_NOTES", "True").lower() == "true" ) - DEFAULT_USER_PERMISSIONS = { "workspace": { "models": USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS, @@ -1167,10 +1167,44 @@ def oidc_oauth_register(client): }, } -USER_PERMISSIONS = PersistentConfig( - "USER_PERMISSIONS", - "user.permissions", - DEFAULT_USER_PERMISSIONS, +DEFAULT_USER_PERMISSIONS_LABELS = { + "workspace": { + "models": "Models Access", + "knowledge": "Knowledge Access", + "prompts": "Prompts Access", + "tools": "Tools Access", + }, + "sharing": { + "public_models": "Models Public Sharing", + "public_knowledge": "Knowledge Public Sharing", + "public_prompts": "Prompts Public Sharing", + "public_tools": "Tools Public Sharing", + }, + "chat": { + "controls": "Allow Chat Controls", + "file_upload": "Allow File Upload", + "delete": "Allow Chat Delete", + "edit": "Allow Chat Edit", + "share": "Allow Chat Share", + "export": "Allow Chat Export", + "stt": "Allow Speech to Text", + "tts": "Allow Text to Speech", + "call": "Allow Call", + "multiple_models": "Allow Multiple Models in Chat", + "temporary": "Allow Temporary Chat", + "temporary_enforced": "Enforce Temporary Chat", + }, + "features": { + "direct_tool_servers": "Direct Tool Servers", + "web_search": "Web Search", + "image_generation": "Image Generation", + "code_interpreter": "Code Interpreter", + "notes": "Notes", + }, +} + +USER_PERMISSIONS = Permissions.set_initial_permissions( + DEFAULT_USER_PERMISSIONS, DEFAULT_USER_PERMISSIONS_LABELS ) ENABLE_CHANNELS = PersistentConfig( diff --git a/backend/open_webui/constants.py b/backend/open_webui/constants.py index 95c54a0d270..5b4f6362de5 100644 --- a/backend/open_webui/constants.py +++ b/backend/open_webui/constants.py @@ -104,6 +104,22 @@ def __str__(self) -> str: ) FILE_NOT_PROCESSED = "Extracted content is not available for this file. Please ensure that the file is processed before proceeding." + PERMISSION_FETCH_FAILED = "Permissions fetch failed. Please try again later." + PERMISSION_NOT_FOUND = ( + lambda name="", category="": f"Permission '{name}' with category '{category}' was not found" + ) + + ROLE_ALREADY_EXISTS = ( + lambda role="": f"A role with the name '{role}' already exists" + ) + ROLE_DO_NOT_EXISTS = "A role does not exist." + ROLE_ALREADY_ASSIGNED_TO_GROUP = () + ROLE_DELETE_ERROR = "Oops! Something went wrong. We encountered an issue while trying to delete the role. Please give it another shot." + ROLE_NOT_FOUND = ( + "This role does not exist. Please check the role name and try again." + ) + ROLE_ERROR = "Oops! Something went wrong. Please try again later." + class TASKS(str, Enum): def __str__(self) -> str: diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index a5aee4bb829..1f33b5b33f8 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -78,6 +78,8 @@ tools, users, utils, + roles, + permissions, ) from open_webui.routers.retrieval import ( @@ -588,6 +590,7 @@ async def lifespan(app: FastAPI): app.state.config.RESPONSE_WATERMARK = RESPONSE_WATERMARK app.state.config.USER_PERMISSIONS = USER_PERMISSIONS + app.state.config.WEBHOOK_URL = WEBHOOK_URL app.state.config.BANNERS = WEBUI_BANNERS app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST @@ -1052,6 +1055,11 @@ async def inspect_websocket(request: Request, call_next): ) app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"]) +app.include_router(roles.router, prefix="/api/v1/roles", tags=["roles"]) +app.include_router( + permissions.router, prefix="/api/v1/permissions", tags=["permissions"] +) + try: audit_level = AuditLevel(AUDIT_LOG_LEVEL) @@ -1405,7 +1413,7 @@ async def get_app_config(request: Request): "max_size": app.state.config.FILE_MAX_SIZE, "max_count": app.state.config.FILE_MAX_COUNT, }, - "permissions": {**app.state.config.USER_PERMISSIONS}, + "permissions": USER_PERMISSIONS, "google_drive": { "client_id": GOOGLE_DRIVE_CLIENT_ID.value, "api_key": GOOGLE_DRIVE_API_KEY.value, diff --git a/backend/open_webui/migrations/versions/04c6df61a317_added_permissions_to_database.py b/backend/open_webui/migrations/versions/04c6df61a317_added_permissions_to_database.py new file mode 100644 index 00000000000..c13f9cd2649 --- /dev/null +++ b/backend/open_webui/migrations/versions/04c6df61a317_added_permissions_to_database.py @@ -0,0 +1,55 @@ +"""Added permissions to the database + +Revision ID: 04c6df61a317 +Revises: 262aff902ca3 +Create Date: 2025-04-22 14:55:58.948054 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import Enum + + +# revision identifiers, used by Alembic. +revision: str = "04c6df61a317" +down_revision: Union[str, None] = "262aff902ca3" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade(): + # Create a permissions table. + op.create_table( + "permissions", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("name", sa.String(), nullable=False), + sa.Column("label", sa.String(), nullable=False), + sa.Column( + "category", + Enum("workspace", "sharing", "chat", "features", name="permissioncategory"), + nullable=False, + ), + sa.Column("description", sa.String()), + ) + + # Create a role_permissions join table to allow many-to-many relationships between roles and permissions. + op.create_table( + "role_permissions", + sa.Column("role_id", sa.Integer(), nullable=False), + sa.Column("permission_id", sa.Integer(), nullable=False), + sa.Column("value", sa.Boolean(), default=False), # Added value column here + sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["permission_id"], ["permissions.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("role_id", "permission_id"), + ) + + +def downgrade(): + op.drop_table("role_permissions") + op.drop_table("permissions") + op.execute("DROP TYPE permissioncategory") diff --git a/backend/open_webui/migrations/versions/262aff902ca3_added_roles_tabel.py b/backend/open_webui/migrations/versions/262aff902ca3_added_roles_tabel.py new file mode 100644 index 00000000000..41357a582d6 --- /dev/null +++ b/backend/open_webui/migrations/versions/262aff902ca3_added_roles_tabel.py @@ -0,0 +1,49 @@ +"""Added roles tabel + +Revision ID: 262aff902ca3 +Revises: 9f0c9cd09105 +Create Date: 2025-04-14 14:25:33.528446 + +""" + +import time + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "262aff902ca3" +down_revision: Union[str, None] = "9f0c9cd09105" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade(): + op.create_table( + "roles", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("name", sa.String()), + sa.Column("created_at", sa.BigInteger(), nullable=True), + sa.Column("updated_at", sa.BigInteger(), nullable=True), + ) + + # Insert default roles into the database. + current_time = int(time.time()) + + connection = op.get_bind() + connection.execute( + sa.text( + f""" + INSERT INTO roles (name, created_at, updated_at) VALUES + ('pending', {current_time}, {current_time}), + ('admin', {current_time}, {current_time}), + ('user', {current_time}, {current_time}) + """ + ) + ) + + +def downgrade(): + op.drop_table("roles") diff --git a/backend/open_webui/models/permissions.py b/backend/open_webui/models/permissions.py new file mode 100644 index 00000000000..1a915bdf1c4 --- /dev/null +++ b/backend/open_webui/models/permissions.py @@ -0,0 +1,481 @@ +import os +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.orm import relationship +from sqlalchemy import Column, String, Integer, Enum as SQLAlchemyEnum + +from open_webui.internal.db import Base, get_db +from open_webui.models.roles import RolePermission, Role + +persistent_config = os.environ.get("ENABLE_PERSISTENT_CONFIG", "True").lower() == "true" + +#################### +# Role DB Schema +#################### + + +class PermissionCategory(str, Enum): + workspace = "workspace" + sharing = "sharing" + chat = "chat" + features = "features" + + +class RolePermissionModel(BaseModel): + role_id: int + permission_id: int + value: bool = False + + model_config = ConfigDict(from_attributes=True) + + +class Permission(Base): + __tablename__ = "permissions" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String, nullable=False) + label = Column(String, nullable=False) + category = Column(SQLAlchemyEnum(PermissionCategory), nullable=False) + description = Column(String) + + role_associations = relationship("RolePermission", back_populates="permission") + + # Convenience property to access roles directly if needed + @property + def roles(self): + return [assoc.role for assoc in self.role_associations] + + +class PermissionModel(BaseModel): + id: int = Field(default=None) + name: str + label: str + category: PermissionCategory + description: str | None + value: bool = False + + model_config = ConfigDict(from_attributes=True) + + +class PermissionEmptyModel(BaseModel): + id: int = Field(default=None) + name: str + label: str + category: PermissionCategory + description: str | None + + model_config = ConfigDict(from_attributes=True) + + +class PermissionCreateModel(BaseModel): + name: str + label: str + category: PermissionCategory + description: str | None = None + + model_config = ConfigDict(from_attributes=True) + + +#################### +# Forms +#################### + + +class PermissionRoleForm(BaseModel): + permission_name: str + category: PermissionCategory + value: bool = False + + +class PermissionAddForm(BaseModel): + name: str + label: str + category: PermissionCategory + description: str | None = None + + +#################### +# Database operations +#################### + + +class PermissionsTable: + + def get_ordre_by_category( + self, role_name: str = "user" + ) -> dict[PermissionCategory, dict[str, bool]]: + with get_db() as db: + result = {} + + # First, get the user role permissions as defaults if requesting permissions for a different role + if role_name != "user": + user_role = db.query(Role).filter_by(name="user").first() + if user_role: + # Query the RolePermission association objects for the user role + user_role_permissions = ( + db.query(RolePermission).filter_by(role_id=user_role.id).all() + ) + + # Create a mapping of permission_id to value for user role + user_permission_values = { + rp.permission_id: rp.value for rp in user_role_permissions + } + + # Get all permissions associated with user role + user_permissions = ( + db.query(Permission) + .filter( + Permission.id.in_( + [rp.permission_id for rp in user_role_permissions] + ) + ) + .all() + ) + + # Build default permissions dictionary + for perm in user_permissions: + category = perm.category.value + if category not in result: + result[category] = {} + + result[category][perm.name] = user_permission_values[perm.id] + + # Now get the specified role's permissions + target_role = db.query(Role).filter_by(name=role_name).first() + if not target_role: + # Return user defaults if target role doesn't exist + return result + + target_role_permissions = ( + db.query(RolePermission).filter_by(role_id=target_role.id).all() + ) + target_permission_values = { + rp.permission_id: rp.value for rp in target_role_permissions + } + target_permissions = ( + db.query(Permission) + .filter( + Permission.id.in_( + [rp.permission_id for rp in target_role_permissions] + ) + ) + .all() + ) + + # Merge target role permissions with defaults + for perm in target_permissions: + category = perm.category.value + if category not in result: + result[category] = {} + + # Override default with target role's value + result[category][perm.name] = target_permission_values[perm.id] + + # Add all permissions from the permission table that are missing in the result + # with a default value of False + all_permissions = db.query(Permission).all() + for perm in all_permissions: + category = perm.category.value + if category not in result: + result[category] = {} + + # Only add if not already in result + if perm.name not in result[category]: + result[category][perm.name] = False + + return result + + def set_initial_permissions( + self, + default_permissions: dict[PermissionCategory, dict[str, bool]], + default_labels: dict[PermissionCategory, dict[str, str]], + role_name: str = "user", + ) -> dict[PermissionCategory, dict[str, bool]]: + with get_db() as db: + role = db.query(Role).filter_by(name=role_name).order_by(Role.id).first() + if not role: + raise ValueError(f"Role '{role_name}' not found") + + # No permissions exist, initialize with defaults + for category_str, perms in default_permissions.items(): + # Convert string category to enum + try: + category = PermissionCategory(category_str) + except ValueError: + continue # Skip invalid categories + + for perm_name, value in perms.items(): + existing_permission = ( + db.query(Permission) + .filter_by(name=perm_name, category=category) + .first() + ) + + if not existing_permission: + new_permission = Permission( + name=perm_name, + category=category, + label=default_labels[category_str][perm_name], + description=f"Default {category.value} permission for {perm_name}", + ) + db.add(new_permission) + db.flush() + + # Create the association with the value + role_permission = RolePermission( + role_id=role.id, + permission_id=new_permission.id, + value=value, + ) + db.add(role_permission) + else: + if not persistent_config: + # Override store permission with the ones from env. + role_permission = ( + db.query(RolePermission) + .filter_by( + role_id=role.id, + permission_id=existing_permission.id, + ) + .first() + ) + role_permission.value = value + + try: + db.commit() + except Exception as e: + db.rollback() + raise e + + # Return current permissions structure + return self.get_ordre_by_category() + + def get(self, permission_name: str, category: PermissionCategory): + with get_db() as db: + try: + # Find the permission + db_permission = ( + db.query(Permission) + .filter_by(name=permission_name, category=category) + .first() + ) + + if not db_permission: + return None + + # Create and return a PermissionModel instance + return PermissionModel( + id=db_permission.id, + name=db_permission.name, + label=db_permission.label, + category=db_permission.category, + description=db_permission.description, + # Default value to False as we don't have a role context + value=False, + ) + + except Exception as e: + print(f"Error getting permission: {e}") + return None + + def get_all(self) -> list[PermissionEmptyModel]: + with get_db() as db: + try: + # Query all permissions from the database + db_permissions = db.query(Permission).all() + + # Convert database objects to PermissionModel instances + permissions = [ + PermissionEmptyModel( + id=p.id, + name=p.name, + label=p.label, + category=p.category, + description=p.description, + ) + for p in db_permissions + ] + + return permissions + + except Exception as e: + print(f"Error getting all permissions: {e}") + return [] + + def add(self, permission: PermissionCreateModel) -> PermissionEmptyModel | None: + with get_db() as db: + try: + existing_permission = ( + db.query(Permission) + .filter_by(name=permission.name, category=permission.category) + .first() + ) + + if existing_permission: + # Permission exists. + return None + + new_permission = Permission( + name=permission.name, + label=permission.label, + category=permission.category, + description=permission.description, + ) + db.add(new_permission) + db.commit() + db.refresh(new_permission) + + return PermissionEmptyModel.model_validate(new_permission) + + except Exception as e: + print(f"Error adding permission: {e}") + db.rollback() + return None + + def update(self, permission: PermissionEmptyModel) -> PermissionEmptyModel | None: + with get_db() as db: + try: + db_permission = ( + db.query(Permission) + .filter_by(name=permission["name"], category=permission["category"]) + .first() + ) + + if not db_permission: + return None + + if "description" in permission: + db_permission.description = permission["description"] + + db.commit() + db.refresh(db_permission) + + return PermissionEmptyModel.model_validate(db_permission) + + except Exception as e: + print(f"Error updating permission: {e}") + db.rollback() + return None + + def delete(self, permission_name: str, category: PermissionCategory) -> bool: + with get_db() as db: + try: + # Find the permission + db_permission = ( + db.query(Permission) + .filter_by(name=permission_name, category=category) + .first() + ) + + if not db_permission: + return False + + # Delete all role associations first to avoid foreign key constraints + db.query(RolePermission).filter_by( + permission_id=db_permission.id + ).delete() + + # Then delete the permission itself + db.delete(db_permission) + db.commit() + return True + + except Exception as e: + print(f"Error deleting permission: {e}") + db.rollback() + return False + + def link( + self, permission_id: int, role_id: int, value: bool = False + ) -> RolePermissionModel | None: + with get_db() as db: + try: + # Check if permission exists + permission = db.query(Permission).filter_by(id=permission_id).first() + if not permission: + return None + + # Check if role exists + role = db.query(Role).filter_by(id=role_id).first() + if not role: + return None + + # Check if the link already exists + existing_link = ( + db.query(RolePermission) + .filter_by(role_id=role_id, permission_id=permission_id) + .first() + ) + + if existing_link: + # If link already exists, update its value + existing_link.value = value + db.commit() + return RolePermissionModel.model_validate(existing_link) + + # Create new association + role_permission = RolePermission( + role_id=role_id, permission_id=permission_id, value=value + ) + from pprint import pprint + + pprint(role_permission) + + db.add(role_permission) + db.commit() + db.refresh(role_permission) + + return RolePermissionModel.model_validate(role_permission) + + except Exception as e: + print(f"Error linking permission to role: {e}") + db.rollback() + return None + + def unlink(self, permission_id: int, role_id: int) -> bool: + with get_db() as db: + try: + # Find the association + deleted = ( + db.query(RolePermission) + .filter_by(role_id=role_id, permission_id=permission_id) + .delete() + ) + + db.commit() + return deleted > 0 + + except Exception as e: + print(f"Error unlinking permission from role: {e}") + db.rollback() + return False + + def exists(self, permission: PermissionModel, role_name: str = "user") -> bool: + with get_db() as db: + # First find the role + role = db.query(Role).filter_by(name=role_name).first() + if not role: + return False + + # Then check if there's a permission with this name and category + db_permission = ( + db.query(Permission) + .filter_by(name=permission["name"], category=permission["category"]) + .first() + ) + + if not db_permission: + return False + + # Finally, check if there's an association between them + association = ( + db.query(RolePermission) + .filter_by(role_id=role.id, permission_id=db_permission.id) + .first() + ) + + return association is not None + + +Permissions = PermissionsTable() diff --git a/backend/open_webui/models/roles.py b/backend/open_webui/models/roles.py new file mode 100644 index 00000000000..caabc738045 --- /dev/null +++ b/backend/open_webui/models/roles.py @@ -0,0 +1,151 @@ +import time + +from typing import Optional +from pydantic import BaseModel, ConfigDict +from sqlalchemy import Boolean, BigInteger, Column, String, Integer, ForeignKey +from sqlalchemy.orm import relationship + +from open_webui.internal.db import Base, get_db + + +#################### +# Association table for the many-to-many relationship (role <-> permission) with value +#################### +class RolePermission(Base): + __tablename__ = "role_permissions" + + role_id = Column(Integer, ForeignKey("roles.id"), primary_key=True) + permission_id = Column(Integer, ForeignKey("permissions.id"), primary_key=True) + value = Column(Boolean, default=False) + + # Add relationships to both sides + role = relationship("Role", back_populates="permission_associations") + permission = relationship("Permission", back_populates="role_associations") + + +#################### +# Role DB Schema +#################### + + +class Role(Base): + __tablename__ = "roles" + + id = Column(Integer, primary_key=True) + name = Column(String) + updated_at = Column(BigInteger) + created_at = Column(BigInteger) + + permission_associations = relationship("RolePermission", back_populates="role") + + @property + def permissions(self): + return [assoc.permission for assoc in self.permission_associations] + + +class RoleModel(BaseModel): + id: int + name: str + updated_at: int + created_at: int + + model_config = ConfigDict(from_attributes=True) + + +#################### +# Forms +#################### + + +class RoleForm(BaseModel): + role: str + + +class RolesTable: + def insert_new_role( + self, + name: str, + ) -> Optional[RoleModel]: + with get_db() as db: + result = Role( + name=name, created_at=int(time.time()), updated_at=int(time.time()) + ) + db.add(result) + db.commit() + db.refresh(result) + if result: + return RoleModel.model_validate(result) + + return None + + def get_role_by_id(self, id: str) -> Optional[RoleModel]: + try: + with get_db() as db: + role = db.query(Role).filter_by(id=id).first() + return RoleModel.model_validate(role) + except Exception: + return None + + def get_role_by_name(self, name: str) -> Optional[RoleModel]: + try: + with get_db() as db: + role = db.query(Role).filter_by(name=name).first() + return RoleModel.model_validate(role) + except Exception: + return None + + def update_name_by_id(self, role_id: str, name: str) -> Optional[RoleModel]: + with get_db() as db: + db.query(Role).filter_by(id=role_id).update( + {"name": name, "updated_at": int(time.time())} + ) + db.commit() + return self.get_role_by_id(role_id) + + def get_roles( + self, skip: Optional[int] = None, limit: Optional[int] = None + ) -> list[RoleModel]: + with get_db() as db: + + query = db.query(Role).order_by(Role.id) + + if skip: + query = query.offset(skip) + if limit: + query = query.limit(limit) + + roles = query.all() + + return [RoleModel.model_validate(role) for role in roles] + + def get_num_roles(self) -> Optional[int]: + with get_db() as db: + return db.query(Role).count() + + def delete_by_id(self, role_id: str) -> bool: + try: + with get_db() as db: + db.query(Role).filter_by(id=role_id).delete() + db.commit() + + return True + except Exception: + return False + + def add_role_if_role_do_not_exists(self, role_name: str) -> Optional[RoleModel]: + # Check if role already exists + existing_role = self.get_role_by_name(role_name) + if existing_role: + return existing_role + + # Role is allowed and doesn't exist, so create it + try: + return self.insert_new_role(name=role_name) + except Exception: + return None + + def exists(self, role_name: str) -> bool: + return self.get_role_by_name(role_name) is not None + + +Roles = RolesTable() diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 793bdfd30a2..bbdbd33c2bb 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -48,6 +48,7 @@ ) from open_webui.utils.webhook import post_webhook from open_webui.utils.access_control import get_permissions +from open_webui.models.permissions import Permissions from typing import Optional, List @@ -108,7 +109,7 @@ async def get_session_user( ) user_permissions = get_permissions( - user.id, request.app.state.config.USER_PERMISSIONS + user.id, Permissions.get_ordre_by_category(user.role) ) return { @@ -329,7 +330,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm): ) user_permissions = get_permissions( - user.id, request.app.state.config.USER_PERMISSIONS + user.id, Permissions.get_ordre_by_category(user.role) ) return { @@ -427,7 +428,7 @@ async def signin(request: Request, response: Response, form_data: SigninForm): ) user_permissions = get_permissions( - user.id, request.app.state.config.USER_PERMISSIONS + user.id, Permissions.get_ordre_by_category(user.role) ) return { @@ -537,7 +538,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm): ) user_permissions = get_permissions( - user.id, request.app.state.config.USER_PERMISSIONS + user.id, Permissions.get_ordre_by_category(user.role) ) if user_count == 0: diff --git a/backend/open_webui/routers/permissions.py b/backend/open_webui/routers/permissions.py new file mode 100644 index 00000000000..f0966c3d5aa --- /dev/null +++ b/backend/open_webui/routers/permissions.py @@ -0,0 +1,49 @@ +import logging +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, status + +from open_webui.constants import ERROR_MESSAGES +from open_webui.env import SRC_LOG_LEVELS +from open_webui.utils.auth import get_admin_user +from open_webui.models.roles import RoleForm +from open_webui.models.permissions import ( + Permissions, + PermissionModel, + PermissionCreateModel, + PermissionEmptyModel, + PermissionCategory, + PermissionAddForm, +) + + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MODELS"]) + +router = APIRouter() + + +############################ +# GetPermissions +############################ + + +@router.get("/", response_model=list[PermissionEmptyModel]) +async def get_permissions(user=Depends(get_admin_user)): + return Permissions.get_all() + + +############################ +# AddPermission +############################ + + +@router.post("/", response_model=Optional[PermissionEmptyModel]) +async def add_permissions(form_data: PermissionAddForm, user=Depends(get_admin_user)): + permission = Permissions.add(permission=form_data) + if permission: + return permission + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Something went wrong. Please try again.", + ) diff --git a/backend/open_webui/routers/roles.py b/backend/open_webui/routers/roles.py new file mode 100644 index 00000000000..cd2732b0188 --- /dev/null +++ b/backend/open_webui/routers/roles.py @@ -0,0 +1,198 @@ +import logging +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, status + +from open_webui.constants import ERROR_MESSAGES +from open_webui.env import SRC_LOG_LEVELS +from open_webui.utils.auth import get_admin_user +from open_webui.models.roles import RoleModel, Roles, RoleForm +from open_webui.models.permissions import ( + Permissions, + PermissionModel, + PermissionCategory, + PermissionRoleForm, +) + + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MODELS"]) + +router = APIRouter() + + +############################ +# GetRoles +############################ + + +@router.get("/", response_model=list[RoleModel]) +async def get_roles( + skip: Optional[int] = None, + limit: Optional[int] = None, + user=Depends(get_admin_user), +): + return Roles.get_roles(skip, limit) + + +############################ +# AddRole +############################ + + +@router.post("/", response_model=Optional[RoleModel]) +async def add_role(form_data: RoleForm, user=Depends(get_admin_user)): + # Check if the role already exists + existing_role = Roles.get_role_by_name(name=form_data.role) + if existing_role: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=ERROR_MESSAGES.ROLE_ALREADY_EXISTS(role=form_data.role), + ) + + return Roles.insert_new_role(name=form_data.role) + + +############################ +# UpdateRoleById +############################ + + +@router.post("/{role_id}", response_model=Optional[RoleModel]) +async def update_role_name( + role_id: int, form_data: RoleForm, user=Depends(get_admin_user) +): + # Check if the role already exists + existing_role = Roles.get_role_by_id(role_id) + if not existing_role: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=ERROR_MESSAGES.ROLE_DO_NOT_EXISTS, + ) + + return Roles.update_name_by_id(role_id, form_data.role) + + +############################ +# DeleteRoleById +############################ + + +@router.delete("/{role_name}", response_model=bool) +async def delete_role_by_id(role_name: str, user=Depends(get_admin_user)): + role = Roles.get_role_by_name(role_name) + if not role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.ROLE_DO_NOT_EXISTS, + ) + + result = Roles.delete_by_id(role.id) + if result: + return True + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES.ROLE_DELETE_ERROR, + ) + + +############################ +# GetPermissionForRole +############################ + + +@router.get("/{role_name}/permissions") +async def get_default_permissions_by_role_name( + role_name: str, user=Depends(get_admin_user) +): + if not Roles.exists(role_name): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES.ROLE_NOT_FOUND, + ) + + permissions = Permissions.get_ordre_by_category(role_name) + + if permissions: + return permissions + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES.PERMISSION_FETCH_FAILED, + ) + + +############################ +# LinkPermissionToRole +########################### + + +@router.post("/{role_name}/permission/link") +async def link_default_permission_to_role( + role_name: str, form_data: PermissionRoleForm, user=Depends(get_admin_user) +): + permission = Permissions.get(form_data.permission_name, form_data.category) + if not permission: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.PERMISSION_NOT_FOUND( + name=form_data.permission_name, category=form_data.category.value + ), + ) + + role = Roles.get_role_by_name(role_name) + if not role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.ROLE_NOT_FOUND, + ) + + result = Permissions.link( + permission_id=permission.id, role_id=role.id, value=form_data.value + ) + if result: + return True + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES.ROLE_ERROR, + ) + + +############################ +# UnlinkPermissionToRole +########################### + + +@router.delete("/{role_name}/permission/{permission_category}/{permission_name}") +async def unlink_default_permission_from_role( + role_name: str, + permission_name: str, + category: PermissionCategory, + user=Depends(get_admin_user), +): + + role = Roles.get_role_by_name(role_name) + if not role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.ROLE_NOT_FOUND, + ) + + permission = Permissions.get(permission_name, category) + if not permission: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.PERMISSION_NOT_FOUND( + name=permission_name, category=category + ), + ) + + result = Permissions.unlink(permission_id=permission.id, role_id=role.id) + if result: + return True + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=ERROR_MESSAGES.ROLE_ERROR, + ) diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 8702ae50bae..f71fbfcd528 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -20,9 +20,18 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel -from open_webui.utils.auth import get_admin_user, get_password_hash, get_verified_user -from open_webui.utils.access_control import get_permissions, has_permission - +from open_webui.utils.auth import ( + get_admin_user, + get_password_hash, + get_verified_user, + get_current_user, +) +from open_webui.utils.access_control import get_permissions +from open_webui.models.permissions import ( + Permissions, + PermissionCategory, + PermissionModel, +) log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["MODELS"]) @@ -86,7 +95,7 @@ async def get_user_groups(user=Depends(get_verified_user)): @router.get("/permissions") async def get_user_permissisions(request: Request, user=Depends(get_verified_user)): user_permissions = get_permissions( - user.id, request.app.state.config.USER_PERMISSIONS + user.id, Permissions.get_ordre_by_category(user.role) ) return user_permissions @@ -139,30 +148,43 @@ class UserPermissions(BaseModel): features: FeaturesPermissions -@router.get("/default/permissions", response_model=UserPermissions) +@router.get( + "/default/permissions", response_model=dict[PermissionCategory, dict[str, bool]] +) async def get_default_user_permissions(request: Request, user=Depends(get_admin_user)): - return { - "workspace": WorkspacePermissions( - **request.app.state.config.USER_PERMISSIONS.get("workspace", {}) - ), - "sharing": SharingPermissions( - **request.app.state.config.USER_PERMISSIONS.get("sharing", {}) - ), - "chat": ChatPermissions( - **request.app.state.config.USER_PERMISSIONS.get("chat", {}) - ), - "features": FeaturesPermissions( - **request.app.state.config.USER_PERMISSIONS.get("features", {}) - ), - } + return Permissions.get_ordre_by_category() @router.post("/default/permissions") async def update_default_user_permissions( - request: Request, form_data: UserPermissions, user=Depends(get_admin_user) + form_data: UserPermissions, user=Depends(get_admin_user) ): - request.app.state.config.USER_PERMISSIONS = form_data.model_dump() - return request.app.state.config.USER_PERMISSIONS + permissions_dict = form_data.model_dump() + + for category_str, permissions in permissions_dict.items(): + try: + category = PermissionCategory(category_str) + + for permission_name, value in permissions.items(): + permission_data = { + "name": permission_name, + "category": category, + "value": value, + "description": f"Default {category.value} permission for {permission_name}", + } + + if Permissions.exists(permission_data): + print(f"Updating existing permission: {permission_data}") + Permissions.update(permission_data) + else: + print(f"Adding new permission: {permission_data}") + Permissions.add(permission_data) + + except ValueError: + # Skip invalid categories + continue + + return Permissions.get_ordre_by_category() ############################ diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index f6004515fc6..64cc7885cf8 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -16,6 +16,8 @@ from open_webui.models.auths import Auths from open_webui.models.users import Users + +from open_webui.models.roles import Roles from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm, GroupForm from open_webui.config import ( DEFAULT_USER_ROLE, @@ -87,6 +89,17 @@ def __init__(self, app): def get_client(self, provider_name): return self.oauth.create_client(provider_name) + def find_first_role_match(self, oauth_roles, allowed_roles): + # Convert to sets for more efficient lookup if lists are large + oauth_roles_set = set(oauth_roles) + allowed_roles_set = set(allowed_roles) + + # Find the intersection of the two sets + matching_roles = oauth_roles_set.intersection(allowed_roles_set) + + # Return the first matching role if any matches found + return next(iter(matching_roles), None) + def get_user_role(self, user, user_data): if user and Users.get_num_users() == 1: # If the user is the only user, assign the role "admin" - actually repairs role for single user on login @@ -125,8 +138,17 @@ def get_user_role(self, user, user_data): for allowed_role in oauth_allowed_roles: # If the user has any of the allowed roles, assign the role "user" if allowed_role in oauth_roles: - log.debug("Assigned user the user role") - role = "user" + first_match = self.find_first_role_match( + oauth_roles, oauth_allowed_roles + ) + if first_match: + Roles.add_role_if_role_do_not_exists(first_match) + role = first_match + else: + # Fallback to role user. + role = "user" + + log.debug(f"Assigned user the {role} role") break for admin_role in oauth_admin_roles: # If the user has any of the admin roles, assign the role "admin" diff --git a/src/lib/apis/permissions/index.ts b/src/lib/apis/permissions/index.ts new file mode 100644 index 00000000000..df2edd6d6f0 --- /dev/null +++ b/src/lib/apis/permissions/index.ts @@ -0,0 +1,67 @@ +import { WEBUI_API_BASE_URL } from '$lib/constants'; + +export const getPermissions = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/permissions/`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const addPermission = async ( + token: string, + category: string, + name: string, + label: string, + description: string +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/permissions/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + name: name, + label: label, + category: category, + description: description + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/apis/roles/index.ts b/src/lib/apis/roles/index.ts new file mode 100644 index 00000000000..d7876aa50cf --- /dev/null +++ b/src/lib/apis/roles/index.ts @@ -0,0 +1,214 @@ +import { WEBUI_API_BASE_URL } from '$lib/constants'; + +export const getRoles = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/roles/`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const addRole = async (token: string, roleName: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/roles/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + role: roleName + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateRole = async (token: string, roleId: number, roleName: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/roles/${roleId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + role: roleName + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deleteRole = async (token: string, roleName: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/roles/${roleName}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getRolePermissions = async (token: string, roleName: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/roles/${roleName}/permissions`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const linkRoleToPermissions = async ( + token: string, + roleName: string, + categoryName: string, + permissionName: string, + value: boolean +) => { + let error = null; + const res = await fetch(`${WEBUI_API_BASE_URL}/roles/${roleName}/permission/link`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + permission_name: permissionName, + category: categoryName, + value: value + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const unlinkRoleFromPermissions = async ( + token: string, + roleName: string, + categoryName: string, + permissionName: string +) => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/roles/${roleName}/permission/${categoryName}/${permissionName}`, + { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + } + ) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/components/admin/Users.svelte b/src/lib/components/admin/Users.svelte index e777757e0ea..1d66d62554e 100644 --- a/src/lib/components/admin/Users.svelte +++ b/src/lib/components/admin/Users.svelte @@ -7,6 +7,9 @@ import UserList from './Users/UserList.svelte'; import Groups from './Users/Groups.svelte'; + import Roles from './Users/RoleList.svelte'; + import PermissionList from './Users/PermissionList.svelte'; + import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte'; const i18n = getContext('i18n'); @@ -85,6 +88,45 @@