diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 21660e00de..4b852c1a49 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -68,8 +68,8 @@ class AppListApi(Resource): parser.add_argument('icon_background', type=str, location='json') args = parser.parse_args() - # The role of the current user in the ta table must be admin or owner - if not current_user.is_admin_or_owner: + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: raise Forbidden() if 'mode' not in args or args['mode'] is None: @@ -89,8 +89,8 @@ class AppImportApi(Resource): @cloud_edition_billing_resource_check('apps') def post(self): """Import app""" - # The role of the current user in the ta table must be admin or owner - if not current_user.is_admin_or_owner: + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: raise Forbidden() parser = reqparse.RequestParser() @@ -147,7 +147,7 @@ class AppApi(Resource): @get_app_model def delete(self, app_model): """Delete app""" - if not current_user.is_admin_or_owner: + if not current_user.is_editor: raise Forbidden() app_service = AppService() @@ -164,8 +164,8 @@ class AppCopyApi(Resource): @marshal_with(app_detail_fields_with_site) def post(self, app_model): """Copy app""" - # The role of the current user in the ta table must be admin or owner - if not current_user.is_admin_or_owner: + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: raise Forbidden() parser = reqparse.RequestParser() @@ -238,6 +238,9 @@ class AppSiteStatus(Resource): @get_app_model @marshal_with(app_detail_fields) def post(self, app_model): + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: + raise Forbidden() parser = reqparse.RequestParser() parser.add_argument('enable_site', type=bool, required=True, location='json') args = parser.parse_args() @@ -255,6 +258,9 @@ class AppApiStatus(Resource): @get_app_model @marshal_with(app_detail_fields) def post(self, app_model): + # The role of the current user in the ta table must be admin or owner + if not current_user.is_admin_or_owner: + raise Forbidden() parser = reqparse.RequestParser() parser.add_argument('enable_api', type=bool, required=True, location='json') args = parser.parse_args() diff --git a/api/controllers/console/app/conversation.py b/api/controllers/console/app/conversation.py index 71b52d5ceb..6475150405 100644 --- a/api/controllers/console/app/conversation.py +++ b/api/controllers/console/app/conversation.py @@ -6,7 +6,7 @@ from flask_restful import Resource, marshal_with, reqparse from flask_restful.inputs import int_range from sqlalchemy import func, or_ from sqlalchemy.orm import joinedload -from werkzeug.exceptions import NotFound +from werkzeug.exceptions import Forbidden, NotFound from controllers.console import api from controllers.console.app.wraps import get_app_model @@ -33,6 +33,8 @@ class CompletionConversationApi(Resource): @get_app_model(mode=AppMode.COMPLETION) @marshal_with(conversation_pagination_fields) def get(self, app_model): + if not current_user.is_admin_or_owner: + raise Forbidden() parser = reqparse.RequestParser() parser.add_argument('keyword', type=str, location='args') parser.add_argument('start', type=datetime_string('%Y-%m-%d %H:%M'), location='args') @@ -106,6 +108,8 @@ class CompletionConversationDetailApi(Resource): @get_app_model(mode=AppMode.COMPLETION) @marshal_with(conversation_message_detail_fields) def get(self, app_model, conversation_id): + if not current_user.is_admin_or_owner: + raise Forbidden() conversation_id = str(conversation_id) return _get_conversation(app_model, conversation_id) @@ -115,6 +119,8 @@ class CompletionConversationDetailApi(Resource): @account_initialization_required @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT]) def delete(self, app_model, conversation_id): + if not current_user.is_admin_or_owner: + raise Forbidden() conversation_id = str(conversation_id) conversation = db.session.query(Conversation) \ @@ -137,6 +143,8 @@ class ChatConversationApi(Resource): @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT]) @marshal_with(conversation_with_summary_pagination_fields) def get(self, app_model): + if not current_user.is_admin_or_owner: + raise Forbidden() parser = reqparse.RequestParser() parser.add_argument('keyword', type=str, location='args') parser.add_argument('start', type=datetime_string('%Y-%m-%d %H:%M'), location='args') @@ -225,6 +233,8 @@ class ChatConversationDetailApi(Resource): @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT]) @marshal_with(conversation_detail_fields) def get(self, app_model, conversation_id): + if not current_user.is_admin_or_owner: + raise Forbidden() conversation_id = str(conversation_id) return _get_conversation(app_model, conversation_id) @@ -234,6 +244,8 @@ class ChatConversationDetailApi(Resource): @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT]) @account_initialization_required def delete(self, app_model, conversation_id): + if not current_user.is_admin_or_owner: + raise Forbidden() conversation_id = str(conversation_id) conversation = db.session.query(Conversation) \ diff --git a/api/controllers/console/app/site.py b/api/controllers/console/app/site.py index 592009fd88..ee9e0cf9b5 100644 --- a/api/controllers/console/app/site.py +++ b/api/controllers/console/app/site.py @@ -40,8 +40,8 @@ class AppSite(Resource): def post(self, app_model): args = parse_app_site_args() - # The role of the current user in the ta table must be admin or owner - if not current_user.is_admin_or_owner: + # The role of the current user in the ta table must be editor, admin, or owner + if not current_user.is_editor: raise Forbidden() site = db.session.query(Site). \ diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index 7a40c1c32b..81ef0b8925 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -107,8 +107,8 @@ class DatasetListApi(Resource): help='Invalid indexing technique.') args = parser.parse_args() - # The role of the current user in the ta table must be admin or owner - if not current_user.is_admin_or_owner: + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: raise Forbidden() try: @@ -195,8 +195,8 @@ class DatasetApi(Resource): parser.add_argument('retrieval_model', type=dict, location='json', help='Invalid retrieval model.') args = parser.parse_args() - # The role of the current user in the ta table must be admin or owner - if not current_user.is_admin_or_owner: + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: raise Forbidden() dataset = DatasetService.update_dataset( @@ -213,8 +213,8 @@ class DatasetApi(Resource): def delete(self, dataset_id): dataset_id_str = str(dataset_id) - # The role of the current user in the ta table must be admin or owner - if not current_user.is_admin_or_owner: + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: raise Forbidden() try: diff --git a/api/controllers/console/datasets/datasets_document.py b/api/controllers/console/datasets/datasets_document.py index 5498d22e78..cdb8a46277 100644 --- a/api/controllers/console/datasets/datasets_document.py +++ b/api/controllers/console/datasets/datasets_document.py @@ -226,8 +226,8 @@ class DatasetDocumentListApi(Resource): if not dataset: raise NotFound('Dataset not found.') - # The role of the current user in the ta table must be admin or owner - if not current_user.is_admin_or_owner: + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: raise Forbidden() try: @@ -278,8 +278,8 @@ class DatasetInitApi(Resource): @marshal_with(dataset_and_document_fields) @cloud_edition_billing_resource_check('vector_space') def post(self): - # The role of the current user in the ta table must be admin or owner - if not current_user.is_admin_or_owner: + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: raise Forbidden() parser = reqparse.RequestParser() @@ -632,8 +632,8 @@ class DocumentProcessingApi(DocumentResource): document_id = str(document_id) document = self.get_document(dataset_id, document_id) - # The role of the current user in the ta table must be admin or owner - if not current_user.is_admin_or_owner: + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: raise Forbidden() if action == "pause": @@ -696,8 +696,8 @@ class DocumentMetadataApi(DocumentResource): doc_type = req_data.get('doc_type') doc_metadata = req_data.get('doc_metadata') - # The role of the current user in the ta table must be admin or owner - if not current_user.is_admin_or_owner: + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: raise Forbidden() if doc_type is None or doc_metadata is None: @@ -743,8 +743,8 @@ class DocumentStatusApi(DocumentResource): document = self.get_document(dataset_id, document_id) - # The role of the current user in the ta table must be admin or owner - if not current_user.is_admin_or_owner: + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: raise Forbidden() indexing_cache_key = 'document_{}_indexing'.format(document.id) diff --git a/api/controllers/console/datasets/datasets_segments.py b/api/controllers/console/datasets/datasets_segments.py index 0a88a0d8d4..a189aac3f1 100644 --- a/api/controllers/console/datasets/datasets_segments.py +++ b/api/controllers/console/datasets/datasets_segments.py @@ -126,8 +126,8 @@ class DatasetDocumentSegmentApi(Resource): raise NotFound('Dataset not found.') # check user's model setting DatasetService.check_dataset_model_setting(dataset) - # The role of the current user in the ta table must be admin or owner - if not current_user.is_admin_or_owner: + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: raise Forbidden() try: @@ -302,8 +302,8 @@ class DatasetDocumentSegmentUpdateApi(Resource): ).first() if not segment: raise NotFound('Segment not found.') - # The role of the current user in the ta table must be admin or owner - if not current_user.is_admin_or_owner: + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: raise Forbidden() try: DatasetService.check_dataset_permission(dataset, current_user) diff --git a/api/controllers/console/tag/tags.py b/api/controllers/console/tag/tags.py index 5dcba301cb..55b212358d 100644 --- a/api/controllers/console/tag/tags.py +++ b/api/controllers/console/tag/tags.py @@ -35,8 +35,8 @@ class TagListApi(Resource): @login_required @account_initialization_required def post(self): - # The role of the current user in the ta table must be admin or owner - if not current_user.is_admin_or_owner: + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: raise Forbidden() parser = reqparse.RequestParser() @@ -67,8 +67,8 @@ class TagUpdateDeleteApi(Resource): @account_initialization_required def patch(self, tag_id): tag_id = str(tag_id) - # The role of the current user in the ta table must be admin or owner - if not current_user.is_admin_or_owner: + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: raise Forbidden() parser = reqparse.RequestParser() @@ -94,8 +94,8 @@ class TagUpdateDeleteApi(Resource): @account_initialization_required def delete(self, tag_id): tag_id = str(tag_id) - # The role of the current user in the ta table must be admin or owner - if not current_user.is_admin_or_owner: + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: raise Forbidden() TagService.delete_tag(tag_id) @@ -109,8 +109,8 @@ class TagBindingCreateApi(Resource): @login_required @account_initialization_required def post(self): - # The role of the current user in the ta table must be admin or owner - if not current_user.is_admin_or_owner: + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: raise Forbidden() parser = reqparse.RequestParser() @@ -134,8 +134,8 @@ class TagBindingDeleteApi(Resource): @login_required @account_initialization_required def post(self): - # The role of the current user in the ta table must be admin or owner - if not current_user.is_admin_or_owner: + # The role of the current user in the ta table must be admin, owner, or editor + if not current_user.is_editor: raise Forbidden() parser = reqparse.RequestParser() diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index 922b95cde5..f404ca7efc 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -43,7 +43,7 @@ class MemberInviteEmailApi(Resource): invitee_emails = args['emails'] invitee_role = args['role'] interface_language = args['language'] - if invitee_role not in [TenantAccountRole.ADMIN, TenantAccountRole.NORMAL]: + if not TenantAccountRole.is_non_owner_role(invitee_role): return {'code': 'invalid-role', 'message': 'Invalid role'}, 400 inviter = current_user @@ -114,7 +114,7 @@ class MemberUpdateRoleApi(Resource): args = parser.parse_args() new_role = args['role'] - if new_role not in ['admin', 'normal', 'owner']: + if not TenantAccountRole.is_valid_role(new_role): return {'code': 'invalid-role', 'message': 'Invalid role'}, 400 member = Account.query.get(str(member_id)) diff --git a/api/controllers/console/workspace/models.py b/api/controllers/console/workspace/models.py index 76ae6a4ab9..69f2253e97 100644 --- a/api/controllers/console/workspace/models.py +++ b/api/controllers/console/workspace/models.py @@ -11,7 +11,6 @@ from core.model_runtime.entities.model_entities import ModelType from core.model_runtime.errors.validate import CredentialsValidateFailedError from core.model_runtime.utils.encoders import jsonable_encoder from libs.login import login_required -from models.account import TenantAccountRole from services.model_load_balancing_service import ModelLoadBalancingService from services.model_provider_service import ModelProviderService @@ -43,6 +42,9 @@ class DefaultModelApi(Resource): @login_required @account_initialization_required def post(self): + if not current_user.is_admin_or_owner: + raise Forbidden() + parser = reqparse.RequestParser() parser.add_argument('model_settings', type=list, required=True, nullable=False, location='json') args = parser.parse_args() @@ -96,7 +98,7 @@ class ModelProviderModelApi(Resource): @login_required @account_initialization_required def post(self, provider: str): - if not TenantAccountRole.is_privileged_role(current_user.current_tenant.current_role): + if not current_user.is_admin_or_owner: raise Forbidden() tenant_id = current_user.current_tenant_id @@ -162,7 +164,7 @@ class ModelProviderModelApi(Resource): @login_required @account_initialization_required def delete(self, provider: str): - if not TenantAccountRole.is_privileged_role(current_user.current_tenant.current_role): + if not current_user.is_admin_or_owner: raise Forbidden() tenant_id = current_user.current_tenant_id diff --git a/api/models/account.py b/api/models/account.py index 3d5e955732..4911757b07 100644 --- a/api/models/account.py +++ b/api/models/account.py @@ -106,6 +106,9 @@ class Account(UserMixin, db.Model): def is_admin_or_owner(self): return TenantAccountRole.is_privileged_role(self._current_tenant.current_role) + @property + def is_editor(self): + return TenantAccountRole.is_editing_role(self._current_tenant.current_role) class TenantStatus(str, enum.Enum): NORMAL = 'normal' @@ -115,11 +118,24 @@ class TenantStatus(str, enum.Enum): class TenantAccountRole(str, enum.Enum): OWNER = 'owner' ADMIN = 'admin' + EDITOR = 'editor' NORMAL = 'normal' + @staticmethod + def is_valid_role(role: str) -> bool: + return role and role in {TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR, TenantAccountRole.NORMAL} + @staticmethod def is_privileged_role(role: str) -> bool: - return role and role in {TenantAccountRole.ADMIN, TenantAccountRole.OWNER} + return role and role in {TenantAccountRole.OWNER, TenantAccountRole.ADMIN} + + @staticmethod + def is_non_owner_role(role: str) -> bool: + return role and role in {TenantAccountRole.ADMIN, TenantAccountRole.EDITOR, TenantAccountRole.NORMAL} + + @staticmethod + def is_editing_role(role: str) -> bool: + return role and role in {TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR} class Tenant(db.Model): diff --git a/api/tests/unit_tests/models/test_account.py b/api/tests/unit_tests/models/test_account.py index ddb4e8cb75..006b99fb7d 100644 --- a/api/tests/unit_tests/models/test_account.py +++ b/api/tests/unit_tests/models/test_account.py @@ -4,9 +4,11 @@ from models.account import TenantAccountRole def test_account_is_privileged_role() -> None: assert TenantAccountRole.ADMIN == 'admin' assert TenantAccountRole.OWNER == 'owner' + assert TenantAccountRole.EDITOR == 'editor' assert TenantAccountRole.NORMAL == 'normal' assert TenantAccountRole.is_privileged_role(TenantAccountRole.ADMIN) assert TenantAccountRole.is_privileged_role(TenantAccountRole.OWNER) assert not TenantAccountRole.is_privileged_role(TenantAccountRole.NORMAL) + assert not TenantAccountRole.is_privileged_role(TenantAccountRole.EDITOR) assert not TenantAccountRole.is_privileged_role('') diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout.tsx index 014b109477..d9ab9d9696 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout.tsx @@ -32,7 +32,7 @@ const AppDetailLayout: FC = (props) => { const pathname = usePathname() const media = useBreakpoints() const isMobile = media === MediaType.mobile - const { isCurrentWorkspaceManager } = useAppContext() + const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext() const { appDetail, setAppDetail, setAppSiderbarExpand } = useStore(useShallow(state => ({ appDetail: state.appDetail, setAppDetail: state.setAppDetail, @@ -45,9 +45,9 @@ const AppDetailLayout: FC = (props) => { selectedIcon: NavIcon }>>([]) - const getNavigations = useCallback((appId: string, isCurrentWorkspaceManager: boolean, mode: string) => { + const getNavigations = useCallback((appId: string, isCurrentWorkspaceManager: boolean, isCurrentWorkspaceEditor: boolean, mode: string) => { const navs = [ - ...(isCurrentWorkspaceManager + ...(isCurrentWorkspaceEditor ? [{ name: t('common.appMenus.promptEng'), href: `/app/${appId}/${(mode === 'workflow' || mode === 'advanced-chat') ? 'workflow' : 'configuration'}`, @@ -62,14 +62,17 @@ const AppDetailLayout: FC = (props) => { icon: TerminalSquare, selectedIcon: TerminalSquareSolid, }, - { - name: mode !== 'workflow' - ? t('common.appMenus.logAndAnn') - : t('common.appMenus.logs'), - href: `/app/${appId}/logs`, - icon: FileHeart02, - selectedIcon: FileHeart02Solid, - }, + ...(isCurrentWorkspaceManager + ? [{ + name: mode !== 'workflow' + ? t('common.appMenus.logAndAnn') + : t('common.appMenus.logs'), + href: `/app/${appId}/logs`, + icon: FileHeart02, + selectedIcon: FileHeart02Solid, + }] + : [] + ), { name: t('common.appMenus.overview'), href: `/app/${appId}/overview`, @@ -104,10 +107,10 @@ const AppDetailLayout: FC = (props) => { } else { setAppDetail(res) - setNavigation(getNavigations(appId, isCurrentWorkspaceManager, res.mode)) + setNavigation(getNavigations(appId, isCurrentWorkspaceManager, isCurrentWorkspaceEditor, res.mode)) } }) - }, [appId, isCurrentWorkspaceManager]) + }, [appId, isCurrentWorkspaceManager, isCurrentWorkspaceEditor]) useUnmount(() => { setAppDetail() diff --git a/web/app/(commonLayout)/apps/AppCard.tsx b/web/app/(commonLayout)/apps/AppCard.tsx index 6d82fbe278..59040f207d 100644 --- a/web/app/(commonLayout)/apps/AppCard.tsx +++ b/web/app/(commonLayout)/apps/AppCard.tsx @@ -37,7 +37,7 @@ export type AppCardProps = { const AppCard = ({ app, onRefresh }: AppCardProps) => { const { t } = useTranslation() const { notify } = useContext(ToastContext) - const { isCurrentWorkspaceManager } = useAppContext() + const { isCurrentWorkspaceEditor } = useAppContext() const { onPlanInfoChanged } = useProviderContext() const { push } = useRouter() @@ -116,7 +116,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { onRefresh() mutateApps() onPlanInfoChanged() - getRedirection(isCurrentWorkspaceManager, newApp, push) + getRedirection(isCurrentWorkspaceEditor, newApp, push) } catch (e) { notify({ type: 'error', message: t('app.newApp.appCreateFailed') }) @@ -224,7 +224,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
{ e.preventDefault() - getRedirection(isCurrentWorkspaceManager, app, push) + getRedirection(isCurrentWorkspaceEditor, app, push) }} className='group flex col-span-1 bg-white border-2 border-solid border-transparent rounded-xl shadow-sm min-h-[160px] flex flex-col transition-all duration-200 ease-in-out cursor-pointer hover:shadow-lg' > @@ -298,7 +298,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { />
- {isCurrentWorkspaceManager && ( + {isCurrentWorkspaceEditor && ( <>
diff --git a/web/app/(commonLayout)/apps/Apps.tsx b/web/app/(commonLayout)/apps/Apps.tsx index 744bb9c9d7..ff79075fae 100644 --- a/web/app/(commonLayout)/apps/Apps.tsx +++ b/web/app/(commonLayout)/apps/Apps.tsx @@ -50,7 +50,7 @@ const getKey = ( const Apps = () => { const { t } = useTranslation() - const { isCurrentWorkspaceManager } = useAppContext() + const { isCurrentWorkspaceEditor } = useAppContext() const showTagManagementModal = useTagStore(s => s.showTagManagementModal) const [activeTab, setActiveTab] = useTabSearchParams({ defaultTab: 'all', @@ -130,7 +130,7 @@ const Apps = () => {