diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index 247e0a57a67..2ff4e8c2123 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -9,6 +9,7 @@ on: - "release/e-*" - "hotfix/**" - "feat/hitl-backend" + - "feat/rbac" tags: - "*" diff --git a/api/commands/__init__.py b/api/commands/__init__.py index ea4c5aaa2a8..94321ed1e49 100644 --- a/api/commands/__init__.py +++ b/api/commands/__init__.py @@ -22,6 +22,7 @@ from .plugin import ( setup_system_trigger_oauth_client, transform_datasource_credentials, ) +from .rbac import migrate_member_roles_to_rbac from .retention import ( archive_workflow_runs, clean_expired_messages, @@ -74,6 +75,7 @@ __all__ = [ "migrate_annotation_vector_database", "migrate_data_for_plugin", "migrate_knowledge_vector_database", + "migrate_member_roles_to_rbac", "migrate_oss", "migration_data_wizard", "old_metadata_migration", diff --git a/api/commands/rbac.py b/api/commands/rbac.py new file mode 100644 index 00000000000..33eb5858da4 --- /dev/null +++ b/api/commands/rbac.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import click +from sqlalchemy import select + +from core.db.session_factory import session_factory +from models import TenantAccountJoin, TenantAccountRole +from services.enterprise.rbac_service import ListOption, RBACService + + +def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_role: str) -> str: + """Resolve a legacy workspace role to the current tenant's builtin RBAC role id. + + The migration replays the old `TenantAccountJoin.role` values onto the + RBAC member-role binding API. Builtin RBAC roles are tenant-scoped and + identified by runtime ids, so the command must look them up per tenant. + """ + expected_builtin_tag = { + TenantAccountRole.OWNER.value: "owner", + TenantAccountRole.ADMIN.value: "admin", + TenantAccountRole.EDITOR.value: "editor", + TenantAccountRole.NORMAL.value: "normal", + TenantAccountRole.DATASET_OPERATOR.value: "dataset_operator", + }.get(legacy_role) + if not expected_builtin_tag: + raise ValueError(f"Unsupported legacy workspace role: {legacy_role}") + + roles = RBACService.Roles.list( + tenant_id=tenant_id, + account_id=operator_account_id, + options=ListOption(page_number=1, results_per_page=100), + ).data + for role in roles: + if role.is_builtin and role.category == "global_system_default" and role.role_tag == expected_builtin_tag: + return str(role.id) + + raise ValueError(f"Builtin RBAC role not found for tenant={tenant_id}, legacy_role={legacy_role}") + + +@click.command( + "rbac-migrate-member-roles", help="Migrate legacy workspace member roles into RBAC member-role bindings." +) +@click.option("--tenant-id", help="Only migrate a single workspace.") +@click.option("--dry-run", is_flag=True, default=False, help="Preview the migration without writing RBAC bindings.") +def migrate_member_roles_to_rbac(tenant_id: str | None, dry_run: bool) -> None: + """Backfill RBAC member-role bindings from legacy `TenantAccountJoin.role` data. + + This is an offline migration command for workspaces that already have + members in the legacy role model but need matching records in the RBAC + member-role binding store. + """ + click.echo(click.style("Starting RBAC member-role migration.", fg="green")) + + with session_factory.create_session() as session: + stmt = select(TenantAccountJoin).order_by(TenantAccountJoin.tenant_id.asc(), TenantAccountJoin.id.asc()) + if tenant_id: + stmt = stmt.where(TenantAccountJoin.tenant_id == tenant_id) + + joins = list(session.scalars(stmt).all()) + + if not joins: + click.echo(click.style("No workspace members found for migration.", fg="yellow")) + return + + owner_account_by_tenant: dict[str, str] = {} + resolved_role_ids: dict[tuple[str, str], str] = {} + migrated_count = 0 + + for join in joins: + workspace_id = str(join.tenant_id) + member_account_id = str(join.account_id) + legacy_role = str(join.role) + + if workspace_id not in owner_account_by_tenant: + owner_join = next( + ( + item + for item in joins + if str(item.tenant_id) == workspace_id and str(item.role) == TenantAccountRole.OWNER.value + ), + None, + ) + if not owner_join: + raise ValueError(f"Workspace owner not found for tenant={workspace_id}") + owner_account_by_tenant[workspace_id] = str(owner_join.account_id) + + operator_account_id = owner_account_by_tenant[workspace_id] + cache_key = (workspace_id, legacy_role) + if cache_key not in resolved_role_ids: + resolved_role_ids[cache_key] = _resolve_builtin_role_id(workspace_id, operator_account_id, legacy_role) + + resolved_role_id = resolved_role_ids[cache_key] + click.echo( + f"tenant={workspace_id} member={member_account_id} " + f"legacy_role={legacy_role} -> rbac_role_id={resolved_role_id}" + ) + + if dry_run: + continue + + RBACService.MemberRoles.replace( + tenant_id=workspace_id, + account_id=operator_account_id, + member_account_id=member_account_id, + role_ids=[resolved_role_id], + ) + migrated_count += 1 + + if dry_run: + click.echo(click.style("Dry run completed. No RBAC bindings were written.", fg="yellow")) + else: + click.echo(click.style(f"RBAC member-role migration completed. Migrated {migrated_count} members.", fg="green")) diff --git a/api/configs/enterprise/__init__.py b/api/configs/enterprise/__init__.py index 705ea67bcbc..2c0eaeeb81b 100644 --- a/api/configs/enterprise/__init__.py +++ b/api/configs/enterprise/__init__.py @@ -29,6 +29,11 @@ class EnterpriseFeatureConfig(BaseSettings): "This helps gain runtime performance by trading off consistency.", ) + RBAC_ENABLED: bool = Field( + description="Enable enterprise RBAC APIs. When disabled, compatibility responses fall back to legacy roles.", + default=False, + ) + class EnterpriseTelemetryConfig(BaseSettings): """ diff --git a/api/controllers/common/fields.py b/api/controllers/common/fields.py index 06d68a5df16..6a0b35aa633 100644 --- a/api/controllers/common/fields.py +++ b/api/controllers/common/fields.py @@ -153,6 +153,7 @@ class ApiBaseUrlResponse(ResponseModel): class NewAppResponse(ResponseModel): new_app_id: str + permission_keys: list[str] = Field(default_factory=list) class Parameters(BaseModel): diff --git a/api/controllers/common/wraps.py b/api/controllers/common/wraps.py index c481f6eca94..7e39b4f37cd 100644 --- a/api/controllers/common/wraps.py +++ b/api/controllers/common/wraps.py @@ -1,7 +1,36 @@ +"""Shared decorator utilities for Dify controller layers. + +This module provides decorators that are not tied to any single API group (e.g. +console, inner, service). Currently it exposes the RBAC permission gate, which +can be applied to any blueprint. + +Key exports +----------- +``rbac_permission_required`` – decorator that enforces enterprise RBAC access + control. When ``RBAC_ENABLED`` is ``False`` it is a no-op. + +``RBACPermission``, ``RBACResourceScope`` – re-exported from ``core.rbac`` so + callers only need a single import site. + +Private helpers +--------------- +``_extract_resource_id``, ``_is_resource_owned_by_current_user`` – kept module- + private but accessible via the module namespace for unit-test patching. +""" + from collections.abc import Callable from functools import wraps +from sqlalchemy import select +from werkzeug.exceptions import Forbidden, NotFound + +from configs import dify_config from core.rbac import RBACPermission, RBACResourceScope +from extensions.ext_database import db +from libs.login import current_account_with_tenant +from models.dataset import Dataset +from models.model import App +from services.enterprise.rbac_service import RBACService __all__ = ["RBACPermission", "RBACResourceScope", "rbac_permission_required"] @@ -14,17 +43,105 @@ def rbac_permission_required[**P, R]( ) -> Callable[[Callable[P, R]], Callable[P, R]]: """Check enterprise RBAC permissions for the current user. + When ``RBAC_ENABLED`` is ``False`` the decorator is a no-op and the + request passes through unchanged. When enabled it extracts the resource ID + from ``request.view_args`` for resource-scoped checks, calls the RBAC + service ``check-access`` endpoint, and raises ``Forbidden`` if the access + is denied. For workspace-level checks, set ``resource_required=False`` so + the RBAC request omits ``resource_id``. + Args: resource_type: The :class:`RBACResourceScope` member (app/dataset/workspace). - scene: The :class:`RBACPermission` permission point. + scene: The :class:`RBACPermission` permission point, e.g. ``RBACPermission.APP_DELETE``. resource_required: Whether a concrete resource ID is required. """ def decorator(view: Callable[P, R]) -> Callable[P, R]: @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs) -> R: + if not dify_config.RBAC_ENABLED: + return view(*args, **kwargs) + + current_user, current_tenant_id = current_account_with_tenant() + check_resource_type = None if resource_type == RBACResourceScope.WORKSPACE else resource_type + resource_id = None + if resource_required and check_resource_type: + resource_id = _extract_resource_id(resource_type, kwargs) + if _is_resource_owned_by_current_user(current_tenant_id, current_user.id, resource_type, resource_id): + return view(*args, **kwargs) + allowed = RBACService.CheckAccess.check( + current_tenant_id, + current_user.id, + scene=scene, + resource_type=check_resource_type, + resource_id=resource_id, + ) + + if not allowed: + raise Forbidden() + return view(*args, **kwargs) return decorated return decorator + + +def _is_resource_owned_by_current_user( + tenant_id: str, account_id: str, resource_type: RBACResourceScope, resource_id: str +) -> bool: + if resource_type == RBACResourceScope.APP: + maintainer = db.session.scalar( + select(App.maintainer).where( + App.id == resource_id, + App.tenant_id == tenant_id, + App.status == "normal", + ) + ) + return maintainer == account_id + + if resource_type == RBACResourceScope.DATASET: + maintainer = db.session.scalar( + select(Dataset.maintainer).where( + Dataset.id == resource_id, + Dataset.tenant_id == tenant_id, + ) + ) + return maintainer == account_id + + return False + + +def _extract_resource_id(resource_type: RBACResourceScope, path_args: dict[str, object] | None = None) -> str: + """Extract the resource ID from matched path arguments. + + Some legacy route classes use neutral names such as ``resource_id`` for + app/dataset resources, and Agent App routes use ``agent_id`` as the app id. + Dataset endpoints behind a rag-pipeline route contain ``pipeline_id`` + instead of ``dataset_id``. In that case we look up the associated + ``Dataset`` row via ``Dataset.pipeline_id``. + """ + from flask import request + + view_args = request.view_args or {} + matched_args = {**view_args, **(path_args or {})} + + if resource_type == RBACResourceScope.APP: + app_id = matched_args.get("app_id") or matched_args.get("agent_id") or matched_args.get("resource_id") + if not app_id: + raise ValueError("Missing app_id in request path") + return str(app_id) + + if resource_type == RBACResourceScope.DATASET: + dataset_id = matched_args.get("dataset_id") or matched_args.get("resource_id") + if dataset_id: + return str(dataset_id) + + pipeline_id = matched_args.get("pipeline_id") + if pipeline_id: + dataset = db.session.scalar(select(Dataset).where(Dataset.pipeline_id == str(pipeline_id))) + if not dataset: + raise NotFound("Dataset not found for pipeline") + return str(dataset.id) + raise ValueError("Missing dataset_id or pipeline_id in request path") + raise ValueError(f"Unknown resource_type: {resource_type}") diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index e2bf0bd22ce..cd8d6e0ab46 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -139,6 +139,7 @@ from .workspace import ( model_providers, models, plugin, + rbac, snippets, tool_providers, trigger_providers, @@ -212,6 +213,7 @@ __all__ = [ "rag_pipeline_draft_variable", "rag_pipeline_import", "rag_pipeline_workflow", + "rbac", "recommended_app", "saved_message", "setup", diff --git a/api/controllers/console/agent/composer.py b/api/controllers/console/agent/composer.py index 6915a54db93..2cd01e427f7 100644 --- a/api/controllers/console/agent/composer.py +++ b/api/controllers/console/agent/composer.py @@ -7,8 +7,11 @@ from controllers.console import console_ns from controllers.console.agent.app_helpers import resolve_agent_app_model from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user_id, @@ -70,6 +73,7 @@ class WorkflowAgentComposerApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) @with_current_user_id @with_current_tenant_id @@ -166,6 +170,7 @@ class WorkflowAgentComposerSaveToRosterApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) @with_current_user_id @with_current_tenant_id @@ -203,6 +208,7 @@ class AgentComposerApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user_id @with_current_tenant_id def put(self, tenant_id: str, account_id: str, agent_id: UUID): diff --git a/api/controllers/console/apikey.py b/api/controllers/console/apikey.py index 57470dc9770..dcea303d7cc 100644 --- a/api/controllers/console/apikey.py +++ b/api/controllers/console/apikey.py @@ -9,6 +9,7 @@ from sqlalchemy import delete, func, select from sqlalchemy.orm import sessionmaker from werkzeug.exceptions import Forbidden +from configs import dify_config from controllers.common.schema import register_response_schema_models from extensions.ext_database import db from fields.base import ResponseModel @@ -22,8 +23,11 @@ from services.api_token_service import ApiTokenCache from . import console_ns from .wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -143,7 +147,7 @@ class BaseApiKeyResource(Resource): assert self.resource_id_field is not None, "resource_id_field must be set" _get_resource(resource_id, current_tenant_id, self.resource_model) - if not current_user.is_admin_or_owner: + if not dify_config.RBAC_ENABLED and not current_user.is_admin_or_owner: raise Forbidden() key = db.session.scalar( @@ -186,6 +190,7 @@ class AppApiKeyListResource(BaseApiKeyListResource): @console_ns.response(400, "Maximum keys exceeded") @with_current_tenant_id @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) def post(self, current_tenant_id: str, resource_id: UUID) -> tuple[dict[str, object], int]: """Create a new API key for an app""" return dump_response(ApiKeyItem, self._create_api_key(str(resource_id), current_tenant_id)), 201 @@ -204,6 +209,7 @@ class AppApiKeyResource(BaseApiKeyResource): @console_ns.response(204, "API key deleted successfully") @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) def delete( self, current_tenant_id: str, current_user: Account, resource_id: UUID, api_key_id: UUID ) -> tuple[str, int]: @@ -234,6 +240,7 @@ class DatasetApiKeyListResource(BaseApiKeyListResource): @console_ns.response(400, "Maximum keys exceeded") @with_current_tenant_id @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE) def post(self, current_tenant_id: str, resource_id: UUID) -> tuple[dict[str, object], int]: """Create a new API key for a dataset""" return dump_response(ApiKeyItem, self._create_api_key(str(resource_id), current_tenant_id)), 201 @@ -252,6 +259,7 @@ class DatasetApiKeyResource(BaseApiKeyResource): @console_ns.response(204, "API key deleted successfully") @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE) def delete( self, current_tenant_id: str, current_user: Account, resource_id: UUID, api_key_id: UUID ) -> tuple[str, int]: diff --git a/api/controllers/console/app/agent.py b/api/controllers/console/app/agent.py index 6731be67831..a53a174da42 100644 --- a/api/controllers/console/app/agent.py +++ b/api/controllers/console/app/agent.py @@ -17,7 +17,10 @@ from controllers.console import console_ns from controllers.console.agent.app_helpers import resolve_agent_app_model from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -351,6 +354,7 @@ class AgentLogApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.AGENT_CHAT]) def get(self, app_model: App): """Get agent logs""" diff --git a/api/controllers/console/app/agent_app_feature.py b/api/controllers/console/app/agent_app_feature.py index 79d7589873c..5d2b77c97f1 100644 --- a/api/controllers/console/app/agent_app_feature.py +++ b/api/controllers/console/app/agent_app_feature.py @@ -19,8 +19,11 @@ from controllers.common.schema import register_response_schema_models, register_ from controllers.console import console_ns from controllers.console.agent.app_helpers import resolve_agent_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -78,6 +81,7 @@ class AgentAppFeatureConfigResource(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) @account_initialization_required @with_current_user @with_current_tenant_id diff --git a/api/controllers/console/app/annotation.py b/api/controllers/console/app/annotation.py index dd0b7e9ef5f..edf3a98af8c 100644 --- a/api/controllers/console/app/annotation.py +++ b/api/controllers/console/app/annotation.py @@ -9,11 +9,14 @@ from controllers.common.errors import NoFileUploadedError, TooManyFilesError from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, annotation_import_concurrency_limit, annotation_import_rate_limit, cloud_edition_billing_resource_check, edit_permission_required, + rbac_permission_required, setup_required, ) from extensions.ext_redis import redis_client @@ -155,6 +158,7 @@ class AnnotationReplyActionApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("annotation") @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) def post(self, app_id: UUID, action: Literal["enable", "disable"]): args = AnnotationReplyPayload.model_validate(console_ns.payload) match action: @@ -185,6 +189,7 @@ class AppAnnotationSettingDetailApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, app_id: UUID): result = AppAnnotationService.get_app_annotation_setting_by_app_id(str(app_id)) return result, 200 @@ -202,6 +207,7 @@ class AppAnnotationSettingUpdateApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) def post(self, app_id: UUID, annotation_setting_id: UUID): annotation_setting_id_str = str(annotation_setting_id) @@ -230,6 +236,7 @@ class AnnotationReplyActionStatusApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("annotation") @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, app_id: UUID, job_id: UUID, action: str): job_id_str = str(job_id) app_annotation_job_key = f"{action}_app_annotation_job_{job_id_str}" @@ -258,6 +265,7 @@ class AnnotationApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, app_id: UUID): args = AnnotationListQuery.model_validate(request.args.to_dict(flat=True)) page = args.page @@ -286,6 +294,7 @@ class AnnotationApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("annotation") @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) def post(self, app_id: UUID): args = CreateAnnotationPayload.model_validate(console_ns.payload) upsert_args: UpsertAnnotationArgs = {} @@ -304,6 +313,7 @@ class AnnotationApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) @console_ns.response(204, "Annotations deleted successfully") def delete(self, app_id: UUID): @@ -342,6 +352,7 @@ class AnnotationExportApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, app_id: UUID): annotation_list = AppAnnotationService.export_annotation_list_by_app_id(str(app_id)) annotation_models = TypeAdapter(list[Annotation]).validate_python(annotation_list, from_attributes=True) @@ -369,6 +380,7 @@ class AnnotationUpdateDeleteApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("annotation") @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) def post(self, app_id: UUID, annotation_id: UUID): args = UpdateAnnotationPayload.model_validate(console_ns.payload) update_args: UpdateAnnotationArgs = {} @@ -383,6 +395,7 @@ class AnnotationUpdateDeleteApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @console_ns.response(204, "Annotation deleted successfully") def delete(self, app_id: UUID, annotation_id: UUID): AppAnnotationService.delete_app_annotation(str(app_id), str(annotation_id)) @@ -410,6 +423,7 @@ class AnnotationBatchImportApi(Resource): @annotation_import_rate_limit @annotation_import_concurrency_limit @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) def post(self, app_id: UUID): from configs import dify_config @@ -462,6 +476,7 @@ class AnnotationBatchImportStatusApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("annotation") @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, app_id: UUID, job_id: UUID): indexing_cache_key = f"app_annotation_batch_import_{str(job_id)}" cache_result = redis_client.get(indexing_cache_key) @@ -492,6 +507,7 @@ class AnnotationHitHistoryListApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, app_id: UUID, annotation_id: UUID): page = request.args.get("page", default=1, type=int) limit = request.args.get("limit", default=20, type=int) diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 0e897ac44de..cd8d9ff3785 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -3,7 +3,7 @@ import re import uuid from collections.abc import Sequence from datetime import datetime -from typing import Any, Literal, cast +from typing import Any, Literal from flask import request from flask_restx import Resource @@ -11,8 +11,9 @@ from pydantic import AliasChoices, BaseModel, Field, computed_field, field_valid from sqlalchemy import select from sqlalchemy.orm import Session from werkzeug.datastructures import MultiDict -from werkzeug.exceptions import BadRequest +from werkzeug.exceptions import BadRequest, NotFound +from configs import dify_config from controllers.common.fields import RedirectUrlResponse, SimpleResultResponse from controllers.common.helpers import FileInfo from controllers.common.schema import ( @@ -25,11 +26,14 @@ from controllers.console import console_ns from controllers.console.app.wraps import get_app_model, with_session from controllers.console.workspace.models import LoadBalancingPayload from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, cloud_edition_billing_resource_check, edit_permission_required, enterprise_license_required, is_admin_or_owner_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -48,6 +52,7 @@ from models import Account, App, DatasetPermissionEnum, Workflow from models.model import IconType from services.app_dsl_service import AppDslService from services.app_service import AppListParams, AppListSortBy, AppService, CreateAppParams, StarredAppListParams +from services.enterprise import rbac_service as enterprise_rbac_service from services.enterprise.enterprise_service import EnterpriseService from services.entities.dsl_entities import ImportMode, ImportStatus from services.entities.knowledge_entities.knowledge_entities import ( @@ -72,12 +77,14 @@ _logger = logging.getLogger(__name__) _TAG_IDS_BRACKET_PATTERN = re.compile(r"^tag_ids\[(\d+)\]$") _CREATOR_IDS_BRACKET_PATTERN = re.compile(r"^creator_ids\[(\d+)\]$") AppListMode = Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"] +DEFAULT_APP_LIST_MODE: AppListMode = "all" +APP_LIST_PERMISSION_KEYS = frozenset({"app.preview", "app.acl.preview", "app.full_access"}) class AppListBaseQuery(BaseModel): page: int = Field(default=1, ge=1, le=99999, description="Page number (1-99999)") limit: int = Field(default=20, ge=1, le=100, description="Page size (1-100)") - mode: AppListMode = Field(default=cast(AppListMode, "all"), description="App mode filter") + mode: AppListMode = Field(default=DEFAULT_APP_LIST_MODE, description="App mode filter") sort_by: AppListSortBy = Field( default="last_modified", description="Sort apps by last modified, recently created, or earliest created", @@ -160,6 +167,10 @@ def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, return normalized +def _has_app_list_permission(permission_keys: Sequence[str]) -> bool: + return any(permission_key in APP_LIST_PERMISSION_KEYS for permission_key in permission_keys) + + class CreateAppPayload(BaseModel): name: str = Field(..., min_length=1, description="App name") description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400) @@ -398,11 +409,13 @@ class AppPartial(ResponseModel): create_user_name: str | None = None author_name: str | None = None has_draft_trigger: bool | None = None + permission_keys: list[str] = Field(default_factory=list) # For Agent App type: the roster Agent backing this app (None otherwise). bound_agent_id: str | None = None # For Agent App responses exposed through /agent. app_id: str | None = None is_starred: bool = False + maintainer: str | None = None @computed_field(return_type=str | None) # type: ignore @property @@ -438,6 +451,8 @@ class AppDetail(ResponseModel): updated_at: int | None = None access_mode: str | None = None tags: list[Tag] = Field(default_factory=list) + permission_keys: list[str] = Field(default_factory=list) + maintainer: str | None = None @field_validator("created_at", "updated_at", mode="before") @classmethod @@ -592,6 +607,44 @@ class AppListApi(Resource): is_created_by_me=args.is_created_by_me, ) + permissions = enterprise_rbac_service.RBACService.MyPermissions.get( + str(current_tenant_id), + current_user_id, + ) + if dify_config.RBAC_ENABLED: + whitelist_scope = enterprise_rbac_service.RBACService.AppAccess.whitelist_resources( + str(current_tenant_id), + current_user_id, + ) + can_manage_own_apps = "app.create_and_management" in permissions.workspace.permission_keys + has_default_preview = _has_app_list_permission( + permissions.app.default_permission_keys + ) or _has_app_list_permission(permissions.workspace.permission_keys) + permission_app_ids: set[str] | None = None + if not has_default_preview: + permission_app_ids = { + override.resource_id + for override in permissions.app.overrides + if _has_app_list_permission(override.permission_keys) + } + + if getattr(whitelist_scope, "unrestricted", False): + accessible_app_ids = permission_app_ids + else: + accessible_app_ids = set(whitelist_scope.resource_ids) + if permission_app_ids is not None: + accessible_app_ids |= permission_app_ids + elif has_default_preview: + accessible_app_ids = None + + if accessible_app_ids: + params.accessible_app_ids = sorted(accessible_app_ids) + params.include_own_apps = can_manage_own_apps + elif accessible_app_ids is not None and can_manage_own_apps: + params.is_created_by_me = True + elif accessible_app_ids is not None: + params.accessible_app_ids = [] + # get app list app_service = AppService() app_pagination = app_service.get_paginate_apps(current_user_id, current_tenant_id, params, db.session) @@ -599,9 +652,20 @@ class AppListApi(Resource): empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[]) return empty.model_dump(mode="json"), 200 + app_ids = [str(app.id) for app in app_pagination.items] + permission_keys_map = permissions.app.permission_keys_by_resource_ids(app_ids) _enrich_app_list_items(session, apps=app_pagination.items, tenant_id=current_tenant_id) pagination_model = AppPagination.model_validate(app_pagination, from_attributes=True) + if app_pagination.items: + pagination_model = pagination_model.model_copy( + update={ + "data": [ + item.model_copy(update={"permission_keys": permission_keys_map.get(str(item.id), [])}) + for item in pagination_model.data + ] + } + ) return pagination_model.model_dump(mode="json"), 200 @console_ns.doc("create_app") @@ -613,6 +677,7 @@ class AppListApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT, resource_required=False) @cloud_edition_billing_resource_check("apps") @edit_permission_required @with_current_user @@ -631,7 +696,14 @@ class AppListApi(Resource): app_service = AppService() app = app_service.create_app(current_tenant_id, params, current_user) - app_detail = AppDetailWithSite.model_validate(app, from_attributes=True) + permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get( + str(current_tenant_id), + current_user.id, + [str(app.id)], + ) + app_detail = AppDetailWithSite.model_validate(app, from_attributes=True).model_copy( + update={"permission_keys": permission_keys_map.get(str(app.id), [])} + ) return app_detail.model_dump(mode="json"), 201 @@ -717,8 +789,11 @@ class AppApi(Resource): @login_required @account_initialization_required @enterprise_license_required + @with_current_user + @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=None) - def get(self, app_model: App): + def get(self, current_tenant_id: str, current_user: Account, app_model: App): """Get app detail""" app_service = AppService() @@ -728,7 +803,16 @@ class AppApi(Resource): app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id)) app_model.access_mode = app_setting.access_mode - response_model = AppDetailWithSite.model_validate(app_model, from_attributes=True) + permissions = enterprise_rbac_service.RBACService.MyPermissions.get( + str(current_tenant_id), + current_user.id, + app_id=str(app_model.id), + ) + permission_keys_map = permissions.app.permission_keys_by_resource_ids([str(app_model.id)]) + + response_model = AppDetailWithSite.model_validate(app_model, from_attributes=True).model_copy( + update={"permission_keys": permission_keys_map.get(str(app_model.id), [])} + ) return response_model.model_dump(mode="json") @console_ns.doc("update_app") @@ -741,8 +825,9 @@ class AppApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=None) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) + @get_app_model(mode=None) def put(self, app_model: App): """Update app""" args = UpdateAppPayload.model_validate(console_ns.payload) @@ -767,11 +852,12 @@ class AppApi(Resource): @console_ns.doc(params={"app_id": "Application ID"}) @console_ns.response(204, "App deleted successfully") @console_ns.response(403, "Insufficient permissions") - @get_app_model @setup_required @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_DELETE) + @get_app_model def delete(self, app_model: App): """Delete app""" app_service = AppService() @@ -791,10 +877,12 @@ class AppCopyApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=None) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) @with_current_user - def post(self, current_user: Account, app_model: App): + @with_current_tenant_id + @get_app_model(mode=None) + def post(self, current_tenant_id: str, current_user: Account, app_model: App): """Copy app""" # The role of the current user in the ta table must be admin, owner, or editor args = CopyAppPayload.model_validate(console_ns.payload or {}) @@ -836,7 +924,17 @@ class AppCopyApi(Resource): stmt = select(App).where(App.id == result.app_id) app = session.scalar(stmt) - response_model = AppDetailWithSite.model_validate(app, from_attributes=True) + if not app: + raise NotFound("App not found") + + permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get( + str(current_tenant_id), + current_user.id, + [str(app.id)], + ) + response_model = AppDetailWithSite.model_validate(app, from_attributes=True).model_copy( + update={"permission_keys": permission_keys_map.get(str(app.id), [])} + ) return response_model.model_dump(mode="json"), 201 @@ -848,11 +946,12 @@ class AppExportApi(Resource): @console_ns.doc(params=query_params_from_model(AppExportQuery)) @console_ns.response(200, "App exported successfully", console_ns.models[AppExportResponse.__name__]) @console_ns.response(403, "Insufficient permissions") - @get_app_model @setup_required @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL) + @get_app_model def get(self, app_model: App): """Export app""" args = AppExportQuery.model_validate(request.args.to_dict(flat=True)) @@ -873,12 +972,12 @@ class AppPublishToCreatorsPlatformApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=None) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL) @with_current_user_id + @get_app_model(mode=None) def post(self, current_user_id: str, app_model: App): """Publish app to Creators Platform""" - from configs import dify_config from core.helper.creators import get_redirect_url, upload_dsl if not dify_config.CREATORS_PLATFORM_FEATURES_ENABLED: @@ -903,8 +1002,9 @@ class AppNameApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=None) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) + @get_app_model(mode=None) def post(self, app_model: App): args = AppNamePayload.model_validate(console_ns.payload) @@ -925,8 +1025,9 @@ class AppIconApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=None) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) + @get_app_model(mode=None) def post(self, app_model: App): args = AppIconPayload.model_validate(console_ns.payload or {}) @@ -952,8 +1053,9 @@ class AppSiteStatus(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=None) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) + @get_app_model(mode=None) def post(self, app_model: App): args = AppSiteStatusPayload.model_validate(console_ns.payload) @@ -975,6 +1077,7 @@ class AppApiStatus(Resource): @login_required @is_admin_or_owner_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) @get_app_model(mode=None) def post(self, app_model: App): args = AppApiStatusPayload.model_validate(console_ns.payload) @@ -999,6 +1102,7 @@ class AppTraceApi(Resource): @login_required @account_initialization_required @with_session + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model def get(self, session: Session, app_model: App): """Get app trace""" @@ -1020,6 +1124,7 @@ class AppTraceApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) @get_app_model def post(self, app_model: App): # add app trace diff --git a/api/controllers/console/app/app_import.py b/api/controllers/console/app/app_import.py index cbdcdc8f10b..d58c5df1e96 100644 --- a/api/controllers/console/app/app_import.py +++ b/api/controllers/console/app/app_import.py @@ -2,25 +2,36 @@ from flask_restx import Resource from pydantic import BaseModel, Field from sqlalchemy.orm import Session +from configs import dify_config from controllers.common.schema import register_enum_models, register_schema_models from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, cloud_edition_billing_resource_check, edit_permission_required, + rbac_permission_required, setup_required, with_current_user, ) from extensions.ext_database import db -from libs.login import login_required +from extensions.ext_redis import redis_client +from libs.login import current_account_with_tenant, login_required from models.account import Account from models.model import App -from services.app_dsl_service import AppDslService, Import +from services.app_dsl_service import ( + IMPORT_INFO_REDIS_KEY_PREFIX, + AppDslService, + Import, + PendingData, +) from services.enterprise.enterprise_service import EnterpriseService from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus from services.feature_service import FeatureService from .. import console_ns +from .permission_keys import get_app_permission_keys class AppImportPayload(BaseModel): @@ -39,6 +50,24 @@ register_enum_models(console_ns, ImportStatus) register_schema_models(console_ns, AppImportPayload, Import, CheckDependenciesResult) +def _current_user_and_tenant_id(current_user: Account | None) -> tuple[Account, str | None]: + if current_user is None: + account, tenant_id = current_account_with_tenant() + return account, str(tenant_id) if tenant_id else None + + current_tenant_id = getattr(current_user, "current_tenant_id", None) + if current_tenant_id: + return current_user, str(current_tenant_id) + + current_tenant = getattr(current_user, "current_tenant", None) + current_tenant_object_id = getattr(current_tenant, "id", None) + if current_tenant_object_id: + return current_user, str(current_tenant_object_id) + + account, fallback_tenant_id = current_account_with_tenant() + return account, str(fallback_tenant_id) if fallback_tenant_id else None + + @console_ns.route("/apps/imports") class AppImportApi(Resource): @console_ns.expect(console_ns.models[AppImportPayload.__name__]) @@ -50,10 +79,11 @@ class AppImportApi(Resource): @account_initialization_required @cloud_edition_billing_resource_check("apps") @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL, resource_required=False) @with_current_user - def post(self, current_user: Account): - # Check user role first + def post(self, current_user: Account | None = None): args = AppImportPayload.model_validate(console_ns.payload) + current_user = current_user if current_user is not None else _current_user_and_tenant_id(None)[0] # AppDslService performs internal commits for some creation paths, so use a plain # Session here instead of nesting it inside sessionmaker(...).begin(). @@ -77,6 +107,20 @@ class AppImportApi(Resource): session.rollback() else: session.commit() + + is_created_app = args.app_id is None and result.status in { + ImportStatus.COMPLETED, + ImportStatus.COMPLETED_WITH_WARNINGS, + } + if dify_config.RBAC_ENABLED and is_created_app and result.app_id: + current_user, current_tenant_id = _current_user_and_tenant_id(current_user) + if current_tenant_id: + result.permission_keys = get_app_permission_keys( + current_tenant_id, + current_user.id, + result.app_id, + ) + if result.app_id and FeatureService.get_system_features().webapp_auth.enabled: # update web app setting as private EnterpriseService.WebAppAuth.update_app_access_mode(result.app_id, "private") @@ -99,9 +143,16 @@ class AppImportConfirmApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL, resource_required=False) @with_current_user - def post(self, current_user: Account, import_id: str): - # Check user role first + def post(self, current_user: Account | None = None, import_id: str = ""): + current_user = current_user if current_user is not None else _current_user_and_tenant_id(None)[0] + redis_key = f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}" + pending_data_raw = redis_client.get(redis_key) + pending_data: PendingData | None = None + if pending_data_raw: + pending_data = PendingData.model_validate_json(pending_data_raw) + with Session(db.engine, expire_on_commit=False) as session: import_service = AppDslService(session) # Confirm import @@ -112,6 +163,24 @@ class AppImportConfirmApi(Resource): else: session.commit() + is_created_app = bool( + pending_data + and pending_data.app_id is None + and result.status + in { + ImportStatus.COMPLETED, + ImportStatus.COMPLETED_WITH_WARNINGS, + } + ) + if dify_config.RBAC_ENABLED and is_created_app and result.app_id: + current_user, current_tenant_id = _current_user_and_tenant_id(current_user) + if current_tenant_id: + result.permission_keys = get_app_permission_keys( + current_tenant_id, + current_user.id, + result.app_id, + ) + # Return appropriate status code based on result if result.status == ImportStatus.FAILED: return result.model_dump(mode="json"), 400 @@ -120,12 +189,17 @@ class AppImportConfirmApi(Resource): @console_ns.route("/apps/imports//check-dependencies") class AppImportCheckDependenciesApi(Resource): - @console_ns.response(200, "Dependencies checked", console_ns.models[CheckDependenciesResult.__name__]) + @console_ns.response( + 200, + "Dependencies checked", + console_ns.models[CheckDependenciesResult.__name__], + ) @setup_required @login_required @get_app_model @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, app_model: App): with Session(db.engine, expire_on_commit=False) as session: import_service = AppDslService(session) diff --git a/api/controllers/console/app/audio.py b/api/controllers/console/app/audio.py index 7ef43570c2f..43b41903f60 100644 --- a/api/controllers/console/app/audio.py +++ b/api/controllers/console/app/audio.py @@ -22,7 +22,13 @@ from controllers.console.app.error import ( UnsupportedAudioTypeError, ) from controllers.console.app.wraps import get_app_model -from controllers.console.wraps import account_initialization_required, setup_required +from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + rbac_permission_required, + setup_required, +) from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from graphon.model_runtime.errors.invoke import InvokeError from libs.login import login_required @@ -126,10 +132,10 @@ class ChatMessageTextApi(Resource): console_ns.models[AudioBinaryResponse.__name__], ) @console_ns.response(400, "Bad request - Invalid parameters") - @get_app_model @setup_required @login_required @account_initialization_required + @get_app_model def post(self, app_model: App): try: payload = TextToSpeechPayload.model_validate(console_ns.payload) @@ -180,10 +186,11 @@ class TextModesApi(Resource): console_ns.models[TextToSpeechVoiceListResponse.__name__], ) @console_ns.response(400, "Invalid language parameter") - @get_app_model @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model def get(self, app_model: App): try: args = TextToSpeechVoiceQuery.model_validate(request.args.to_dict(flat=True)) diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index 853f7b023ff..62b95ad22e4 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -22,8 +22,11 @@ from controllers.console.app.error import ( ) from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -113,8 +116,9 @@ class CompletionMessageApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=AppMode.COMPLETION) @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) + @get_app_model(mode=AppMode.COMPLETION) def post(self, current_user: Account, app_model: App): args_model = CompletionMessagePayload.model_validate(console_ns.payload) args = args_model.model_dump(exclude_none=True, by_alias=True) @@ -159,8 +163,8 @@ class CompletionMessageStopApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=AppMode.COMPLETION) @with_current_user_id + @get_app_model(mode=AppMode.COMPLETION) def post(self, current_user_id: str, app_model: App, task_id: str): AppTaskService.stop_task( @@ -185,9 +189,10 @@ class ChatMessageApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.AGENT]) @edit_permission_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) + @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.AGENT]) def post(self, current_user: Account, app_model: App): return _create_chat_message(current_user=current_user, app_model=app_model) @@ -205,6 +210,7 @@ class AgentChatMessageApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @with_current_user @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID): @@ -221,8 +227,8 @@ class ChatMessageStopApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) @with_current_user_id + @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) def post(self, current_user_id: str, app_model: App, task_id: str): return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id) diff --git a/api/controllers/console/app/conversation.py b/api/controllers/console/app/conversation.py index b9fcf2073d7..ec34c26fedd 100644 --- a/api/controllers/console/app/conversation.py +++ b/api/controllers/console/app/conversation.py @@ -13,8 +13,11 @@ from controllers.common.schema import query_params_from_model, register_schema_m from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_user, ) @@ -97,9 +100,10 @@ class CompletionConversationApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=AppMode.COMPLETION) @edit_permission_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=AppMode.COMPLETION) def get(self, current_user: Account, app_model: App): args = CompletionConversationQuery.model_validate(request.args.to_dict(flat=True)) @@ -169,9 +173,10 @@ class CompletionConversationDetailApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=AppMode.COMPLETION) @edit_permission_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=AppMode.COMPLETION) def get(self, current_user: Account, app_model: App, conversation_id: UUID): conversation_id_str = str(conversation_id) return ConversationMessageDetailResponse.model_validate( @@ -187,9 +192,10 @@ class CompletionConversationDetailApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=AppMode.COMPLETION) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user + @get_app_model(mode=AppMode.COMPLETION) def delete(self, current_user: Account, app_model: App, conversation_id: UUID): conversation_id_str = str(conversation_id) @@ -212,9 +218,10 @@ class ChatConversationApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) @edit_permission_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) def get(self, current_user: Account, app_model: App): args = ChatConversationQuery.model_validate(request.args.to_dict(flat=True)) @@ -323,9 +330,10 @@ class ChatConversationDetailApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) @edit_permission_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) def get(self, current_user: Account, app_model: App, conversation_id: UUID): conversation_id_str = str(conversation_id) return ConversationDetailResponse.model_validate( @@ -340,10 +348,11 @@ class ChatConversationDetailApi(Resource): @console_ns.response(404, "Conversation not found") @setup_required @login_required - @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user + @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) def delete(self, current_user: Account, app_model: App, conversation_id: UUID): conversation_id_str = str(conversation_id) diff --git a/api/controllers/console/app/conversation_variables.py b/api/controllers/console/app/conversation_variables.py index 9cf3f278eac..3069dd3011c 100644 --- a/api/controllers/console/app/conversation_variables.py +++ b/api/controllers/console/app/conversation_variables.py @@ -12,7 +12,13 @@ from sqlalchemy.orm import sessionmaker from controllers.common.schema import query_params_from_model, register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model -from controllers.console.wraps import account_initialization_required, setup_required +from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + rbac_permission_required, + setup_required, +) from extensions.ext_database import db from fields._value_type_serializer import serialize_value_type from fields.base import ResponseModel @@ -93,6 +99,7 @@ class ConversationVariablesApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) @get_app_model(mode=AppMode.ADVANCED_CHAT) def get(self, app_model: App): args = ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True)) diff --git a/api/controllers/console/app/mcp_server.py b/api/controllers/console/app/mcp_server.py index 8d1eb700739..6d6a56b5e1d 100644 --- a/api/controllers/console/app/mcp_server.py +++ b/api/controllers/console/app/mcp_server.py @@ -12,8 +12,11 @@ from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, ) @@ -83,6 +86,7 @@ class AppMCPServerController(Resource): @login_required @account_initialization_required @setup_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model def get(self, app_model: App): server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.app_id == app_model.id).limit(1)) @@ -99,11 +103,12 @@ class AppMCPServerController(Resource): ) @console_ns.response(403, "Insufficient permissions") @account_initialization_required - @get_app_model @login_required @setup_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_tenant_id + @get_app_model def post(self, current_tenant_id: str, app_model: App): payload = MCPServerCreatePayload.model_validate(console_ns.payload or {}) @@ -133,11 +138,12 @@ class AppMCPServerController(Resource): ) @console_ns.response(403, "Insufficient permissions") @console_ns.response(404, "Server not found") - @get_app_model @login_required @setup_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) + @get_app_model def put(self, app_model: App): payload = MCPServerUpdatePayload.model_validate(console_ns.payload or {}) server = db.session.get(AppMCPServer, payload.id) @@ -174,6 +180,7 @@ class AppMCPServerRefreshController(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @with_current_tenant_id def get(self, current_tenant_id: str, server_id: UUID): server = db.session.scalar( diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py index ef112b1b1e4..1406fbc634b 100644 --- a/api/controllers/console/app/message.py +++ b/api/controllers/console/app/message.py @@ -23,8 +23,11 @@ from controllers.console.app.error import ( from controllers.console.app.wraps import get_app_model from controllers.console.explore.error import AppSuggestedQuestionsAfterAnswerDisabledError from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -182,8 +185,9 @@ class ChatMessageListApi(Resource): @login_required @account_initialization_required @setup_required - @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) def get(self, app_model: App): return _list_chat_messages(app_model=app_model) @@ -200,6 +204,7 @@ class AgentChatMessageListApi(Resource): @account_initialization_required @setup_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @with_current_tenant_id def get(self, current_tenant_id: str, agent_id: UUID): app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) @@ -215,11 +220,11 @@ class MessageFeedbackApi(Resource): @console_ns.response(200, "Feedback updated successfully", console_ns.models[SimpleResultResponse.__name__]) @console_ns.response(404, "Message not found") @console_ns.response(403, "Insufficient permissions") - @get_app_model @setup_required @login_required @account_initialization_required @with_current_user + @get_app_model def post(self, current_user: Account, app_model: App): return _update_message_feedback(current_user=current_user, app_model=app_model) @@ -252,10 +257,11 @@ class MessageAnnotationCountApi(Resource): "Annotation count retrieved successfully", console_ns.models[AnnotationCountResponse.__name__], ) - @get_app_model @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model def get(self, app_model: App): count = db.session.scalar( select(func.count(MessageAnnotation.id)).where(MessageAnnotation.app_id == app_model.id) @@ -278,8 +284,9 @@ class MessageSuggestedQuestionApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) def get(self, current_user: Account, app_model: App, message_id: UUID): return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id) @@ -318,10 +325,11 @@ class MessageFeedbackExportApi(Resource): ) @console_ns.response(400, "Invalid parameters") @console_ns.response(500, "Internal server error") - @get_app_model @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model def get(self, app_model: App): args = FeedbackExportQuery.model_validate(request.args.to_dict()) @@ -356,10 +364,11 @@ class MessageApi(Resource): @console_ns.doc(params={"app_id": "Application ID", "message_id": "Message ID"}) @console_ns.response(200, "Message retrieved successfully", console_ns.models[MessageDetailResponse.__name__]) @console_ns.response(404, "Message not found") - @get_app_model @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model def get(self, app_model: App, message_id: UUID): return _get_message_detail(app_model=app_model, message_id=message_id) diff --git a/api/controllers/console/app/model_config.py b/api/controllers/console/app/model_config.py index 6e2e20c0a35..3a016e3b9b2 100644 --- a/api/controllers/console/app/model_config.py +++ b/api/controllers/console/app/model_config.py @@ -10,8 +10,11 @@ from controllers.common.schema import register_response_schema_models, register_ from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user_id, @@ -86,10 +89,11 @@ class ModelConfigResource(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @account_initialization_required - @get_app_model(mode=[AppMode.AGENT_CHAT, AppMode.CHAT, AppMode.COMPLETION]) @with_current_user_id @with_current_tenant_id + @get_app_model(mode=[AppMode.AGENT_CHAT, AppMode.CHAT, AppMode.COMPLETION]) def post(self, current_tenant_id: str, current_user_id: str, app_model: App): """Modify app model config""" # validate config diff --git a/api/controllers/console/app/ops_trace.py b/api/controllers/console/app/ops_trace.py index c9f9308c2ea..d350ff52770 100644 --- a/api/controllers/console/app/ops_trace.py +++ b/api/controllers/console/app/ops_trace.py @@ -9,7 +9,13 @@ from controllers.common.schema import query_params_from_model, register_response from controllers.console import console_ns from controllers.console.app.error import TracingConfigCheckError, TracingConfigIsExist, TracingConfigNotExist from controllers.console.app.wraps import get_app_model -from controllers.console.wraps import account_initialization_required, setup_required +from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + rbac_permission_required, + setup_required, +) from fields.base import ResponseModel from libs.login import login_required from models import App @@ -64,6 +70,7 @@ class TraceAppConfigApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) @get_app_model def get(self, app_model: App): args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore diff --git a/api/controllers/console/app/permission_keys.py b/api/controllers/console/app/permission_keys.py new file mode 100644 index 00000000000..810ea04e377 --- /dev/null +++ b/api/controllers/console/app/permission_keys.py @@ -0,0 +1,6 @@ +from services.enterprise import rbac_service as enterprise_rbac_service + + +def get_app_permission_keys(tenant_id: str, account_id: str | None, app_id: str) -> list[str]: + permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get(tenant_id, account_id, [app_id]) + return permission_keys_map.get(app_id, []) diff --git a/api/controllers/console/app/site.py b/api/controllers/console/app/site.py index df398fa7b9c..edc79f8fbc6 100644 --- a/api/controllers/console/app/site.py +++ b/api/controllers/console/app/site.py @@ -10,9 +10,12 @@ from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, is_admin_or_owner_required, + rbac_permission_required, setup_required, with_current_user, ) @@ -85,9 +88,10 @@ class AppSite(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) @account_initialization_required - @get_app_model @with_current_user + @get_app_model def post(self, current_user: Account, app_model: App): args = AppSiteUpdatePayload.model_validate(console_ns.payload or {}) site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1)) @@ -134,9 +138,10 @@ class AppSiteAccessTokenReset(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) @account_initialization_required - @get_app_model @with_current_user + @get_app_model def post(self, current_user: Account, app_model: App): site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1)) diff --git a/api/controllers/console/app/statistic.py b/api/controllers/console/app/statistic.py index bc0120fe4f8..fbb6d3e987f 100644 --- a/api/controllers/console/app/statistic.py +++ b/api/controllers/console/app/statistic.py @@ -8,7 +8,14 @@ from pydantic import BaseModel, Field, field_validator from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model -from controllers.console.wraps import account_initialization_required, setup_required, with_current_user +from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + rbac_permission_required, + setup_required, + with_current_user, +) from core.app.entities.app_invoke_entities import InvokeFrom from extensions.ext_database import db from fields.base import ResponseModel @@ -131,11 +138,12 @@ class DailyMessageStatistic(Resource): "Daily message statistics retrieved successfully", console_ns.models[DailyMessageStatisticResponse.__name__], ) - @get_app_model @setup_required @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model def get(self, account: Account, app_model: App): args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -191,11 +199,12 @@ class DailyConversationStatistic(Resource): "Daily conversation statistics retrieved successfully", console_ns.models[DailyConversationStatisticResponse.__name__], ) - @get_app_model @setup_required @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model def get(self, account: Account, app_model: App): args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -250,11 +259,12 @@ class DailyTerminalsStatistic(Resource): "Daily terminal statistics retrieved successfully", console_ns.models[DailyTerminalStatisticResponse.__name__], ) - @get_app_model @setup_required @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model def get(self, account: Account, app_model: App): args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -310,11 +320,12 @@ class DailyTokenCostStatistic(Resource): "Daily token cost statistics retrieved successfully", console_ns.models[DailyTokenCostStatisticResponse.__name__], ) - @get_app_model @setup_required @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model def get(self, account: Account, app_model: App): args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -376,8 +387,9 @@ class AverageSessionInteractionStatistic(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) def get(self, account: Account, app_model: App): args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -452,11 +464,12 @@ class UserSatisfactionRateStatistic(Resource): "User satisfaction rate statistics retrieved successfully", console_ns.models[UserSatisfactionRateStatisticResponse.__name__], ) - @get_app_model @setup_required @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model def get(self, account: Account, app_model: App): args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -524,8 +537,9 @@ class AverageResponseTimeStatistic(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=AppMode.COMPLETION) @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model(mode=AppMode.COMPLETION) def get(self, account: Account, app_model: App): args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) @@ -581,11 +595,12 @@ class TokensPerSecondStatistic(Resource): "Tokens per second statistics retrieved successfully", console_ns.models[TokensPerSecondStatisticResponse.__name__], ) - @get_app_model @setup_required @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model def get(self, account: Account, app_model: App): args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True)) diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index a8969f4d5ec..aff32035233 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -26,10 +26,14 @@ from controllers.console.app.error import ( DraftWorkflowNotExist, DraftWorkflowNotSync, ) +from controllers.console.app.permission_keys import get_app_permission_keys from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -436,8 +440,9 @@ class DraftWorkflowApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App): """ Get draft workflow @@ -483,6 +488,7 @@ class DraftWorkflowApi(Resource): @console_ns.response(403, "Permission denied") @with_current_user @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def post(self, current_user: Account, app_model: App): """ Sync draft workflow @@ -548,6 +554,7 @@ class AdvancedChatDraftWorkflowRunApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.ADVANCED_CHAT]) @with_current_user @edit_permission_required @@ -597,6 +604,7 @@ class AdvancedChatDraftRunIterationNodeApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.ADVANCED_CHAT]) @with_current_user @edit_permission_required @@ -639,6 +647,7 @@ class WorkflowDraftRunIterationNodeApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user @edit_permission_required @@ -677,6 +686,7 @@ class AdvancedChatDraftRunLoopNodeApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.ADVANCED_CHAT]) @with_current_user @edit_permission_required @@ -719,6 +729,7 @@ class WorkflowDraftRunLoopNodeApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user @edit_permission_required @@ -793,6 +804,7 @@ class AdvancedChatDraftHumanInputFormPreviewApi(Resource): @get_app_model(mode=[AppMode.ADVANCED_CHAT]) @with_current_user @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def post(self, current_user: Account, app_model: App, node_id: str): """ Preview human input form content and placeholders @@ -824,6 +836,7 @@ class AdvancedChatDraftHumanInputFormRunApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.ADVANCED_CHAT]) @with_current_user @edit_permission_required @@ -857,6 +870,7 @@ class WorkflowDraftHumanInputFormPreviewApi(Resource): @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def post(self, current_user: Account, app_model: App, node_id: str): """ Preview human input form content and placeholders @@ -888,6 +902,7 @@ class WorkflowDraftHumanInputFormRunApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user @edit_permission_required @@ -921,6 +936,7 @@ class WorkflowDraftHumanInputDeliveryTestApi(Resource): @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) @with_current_user @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) def post(self, current_user: Account, app_model: App, node_id: str): """ Test human input delivery @@ -952,6 +968,7 @@ class DraftWorkflowRunApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user @edit_permission_required @@ -990,8 +1007,9 @@ class WorkflowTaskStopApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def post(self, app_model: App, task_id: str): """ Stop workflow task @@ -1022,6 +1040,7 @@ class DraftWorkflowNodeRunApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @with_current_user @edit_permission_required @@ -1072,8 +1091,9 @@ class PublishedWorkflowApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App): """ Get published workflow @@ -1093,6 +1113,7 @@ class PublishedWorkflowApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @with_current_user @edit_permission_required @@ -1141,8 +1162,9 @@ class DefaultBlockConfigsApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App): """ Get default block config @@ -1167,8 +1189,9 @@ class DefaultBlockConfigApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, block_type: str): """ Get default block config @@ -1205,14 +1228,15 @@ class ConvertToWorkflowApi(Resource): @account_initialization_required @get_app_model(mode=[AppMode.CHAT, AppMode.COMPLETION]) @with_current_user + @with_current_tenant_id @edit_permission_required - def post(self, current_user: Account, app_model: App): + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) + def post(self, current_tenant_id: str, current_user: Account, app_model: App): """ Convert basic mode of chatbot app to workflow mode Convert expert mode of chatbot app to workflow mode Convert Completion App to Workflow App """ - payload = console_ns.payload or {} args = ConvertToWorkflowPayload.model_validate(payload).model_dump(exclude_none=True) @@ -1223,6 +1247,7 @@ class ConvertToWorkflowApi(Resource): # return app id return { "new_app_id": new_app_model.id, + "permission_keys": get_app_permission_keys(str(current_tenant_id), current_user.id, str(new_app_model.id)), } @@ -1245,6 +1270,7 @@ class WorkflowFeaturesApi(Resource): @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @with_current_user @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def post(self, current_user: Account, app_model: App): args = WorkflowFeaturesPayload.model_validate(console_ns.payload or {}) @@ -1270,6 +1296,7 @@ class PublishedAllWorkflowApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @with_current_user @edit_permission_required @@ -1322,6 +1349,7 @@ class DraftWorkflowRestoreApi(Resource): @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @with_current_user @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) def post(self, current_user: Account, app_model: App, workflow_id: str): workflow_service = WorkflowService() @@ -1360,6 +1388,7 @@ class WorkflowByIdApi(Resource): @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @with_current_user @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) def patch(self, current_user: Account, app_model: App, workflow_id: str): """ Update workflow attributes @@ -1398,6 +1427,7 @@ class WorkflowByIdApi(Resource): @account_initialization_required @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @console_ns.response(204, "Workflow deleted successfully") def delete(self, app_model: App, workflow_id: str): """ @@ -1436,6 +1466,7 @@ class DraftWorkflowNodeLastRunApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, node_id: str): srv = WorkflowService() @@ -1480,6 +1511,7 @@ class DraftWorkflowTriggerRunApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user @edit_permission_required @@ -1548,6 +1580,7 @@ class DraftWorkflowTriggerNodeApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user @edit_permission_required @@ -1631,6 +1664,7 @@ class DraftWorkflowTriggerRunAllApi(Resource): @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) def post(self, current_user: Account, app_model: App): """ Full workflow debug when the start node is a trigger diff --git a/api/controllers/console/app/workflow_app_log.py b/api/controllers/console/app/workflow_app_log.py index 72bececd999..cf94ceff853 100644 --- a/api/controllers/console/app/workflow_app_log.py +++ b/api/controllers/console/app/workflow_app_log.py @@ -10,7 +10,13 @@ from sqlalchemy.orm import sessionmaker from controllers.common.schema import query_params_from_model, register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model -from controllers.console.wraps import account_initialization_required, setup_required +from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + rbac_permission_required, + setup_required, +) from extensions.ext_database import db from fields.base import ResponseModel from fields.end_user_fields import SimpleEndUser @@ -175,6 +181,7 @@ class WorkflowAppLogApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) @get_app_model(mode=[AppMode.WORKFLOW]) def get(self, app_model: App): """ @@ -218,6 +225,7 @@ class WorkflowArchivedLogApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) @get_app_model(mode=[AppMode.WORKFLOW]) def get(self, app_model: App): """ diff --git a/api/controllers/console/app/workflow_comment.py b/api/controllers/console/app/workflow_comment.py index 3cecd450545..a9bf85ed36c 100644 --- a/api/controllers/console/app/workflow_comment.py +++ b/api/controllers/console/app/workflow_comment.py @@ -8,8 +8,11 @@ from controllers.common.schema import register_response_schema_models, register_ from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -218,8 +221,9 @@ class WorkflowCommentListApi(Resource): @login_required @setup_required @account_initialization_required - @get_app_model() @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model() def get(self, current_tenant_id: str, app_model: App): """Get all comments for a workflow.""" comments = WorkflowCommentService.get_comments(tenant_id=current_tenant_id, app_id=app_model.id) @@ -234,10 +238,11 @@ class WorkflowCommentListApi(Resource): @login_required @setup_required @account_initialization_required - @get_app_model() @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user @with_current_tenant_id + @get_app_model() def post(self, current_tenant_id: str, current_user: Account, app_model: App): """Create a new workflow comment.""" payload = WorkflowCommentCreatePayload.model_validate(console_ns.payload or {}) @@ -266,8 +271,9 @@ class WorkflowCommentDetailApi(Resource): @login_required @setup_required @account_initialization_required - @get_app_model() @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model() def get(self, current_tenant_id: str, app_model: App, comment_id: str): """Get a specific workflow comment.""" comment = WorkflowCommentService.get_comment( @@ -284,10 +290,11 @@ class WorkflowCommentDetailApi(Resource): @login_required @setup_required @account_initialization_required - @get_app_model() @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user @with_current_tenant_id + @get_app_model() def put(self, current_tenant_id: str, current_user: Account, app_model: App, comment_id: str): """Update a workflow comment.""" payload = WorkflowCommentUpdatePayload.model_validate(console_ns.payload or {}) @@ -312,10 +319,11 @@ class WorkflowCommentDetailApi(Resource): @login_required @setup_required @account_initialization_required - @get_app_model() @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user @with_current_tenant_id + @get_app_model() def delete(self, current_tenant_id: str, current_user: Account, app_model: App, comment_id: str): """Delete a workflow comment.""" WorkflowCommentService.delete_comment( @@ -339,10 +347,11 @@ class WorkflowCommentResolveApi(Resource): @login_required @setup_required @account_initialization_required - @get_app_model() @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user @with_current_tenant_id + @get_app_model() def post(self, current_tenant_id: str, current_user: Account, app_model: App, comment_id: str): """Resolve a workflow comment.""" comment = WorkflowCommentService.resolve_comment( @@ -367,10 +376,11 @@ class WorkflowCommentReplyApi(Resource): @login_required @setup_required @account_initialization_required - @get_app_model() @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user @with_current_tenant_id + @get_app_model() def post(self, current_tenant_id: str, current_user: Account, app_model: App, comment_id: str): """Add a reply to a workflow comment.""" # Validate comment access first @@ -402,10 +412,11 @@ class WorkflowCommentReplyDetailApi(Resource): @login_required @setup_required @account_initialization_required - @get_app_model() @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user @with_current_tenant_id + @get_app_model() def put(self, current_tenant_id: str, current_user: Account, app_model: App, comment_id: str, reply_id: str): """Update a comment reply.""" # Validate comment access first @@ -434,10 +445,11 @@ class WorkflowCommentReplyDetailApi(Resource): @login_required @setup_required @account_initialization_required - @get_app_model() @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user @with_current_tenant_id + @get_app_model() def delete(self, current_tenant_id: str, current_user: Account, app_model: App, comment_id: str, reply_id: str): """Delete a comment reply.""" # Validate comment access first @@ -469,8 +481,9 @@ class WorkflowCommentMentionUsersApi(Resource): @login_required @setup_required @account_initialization_required - @get_app_model() @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model() def get(self, current_user: Account, app_model: App): """Get all users in current tenant for mentions.""" current_tenant = current_user.current_tenant # need the tenant object here diff --git a/api/controllers/console/app/workflow_draft_variable.py b/api/controllers/console/app/workflow_draft_variable.py index 11411115c1b..ff82572b87e 100644 --- a/api/controllers/console/app/workflow_draft_variable.py +++ b/api/controllers/console/app/workflow_draft_variable.py @@ -18,8 +18,11 @@ from controllers.console.app.error import ( ) from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_user, ) @@ -283,6 +286,7 @@ def _api_prerequisite[T, **P, R]( @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @with_current_user @wraps(f) @@ -304,6 +308,7 @@ class WorkflowVariableCollectionApi(Resource): ) @_api_prerequisite @marshal_with(workflow_draft_variable_list_without_value_model) + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, current_user: Account, app_model: App): """ Get draft workflow @@ -368,6 +373,7 @@ class NodeVariableCollectionApi(Resource): @console_ns.response(200, "Node variables retrieved successfully", workflow_draft_variable_list_model) @_api_prerequisite @marshal_with(workflow_draft_variable_list_model) + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, current_user: Account, app_model: App, node_id: str): validate_node_id(node_id) with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session: @@ -402,6 +408,7 @@ class VariableApi(Resource): @console_ns.response(404, "Variable not found") @_api_prerequisite @marshal_with(workflow_draft_variable_model) + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, current_user: Account, app_model: App, variable_id: UUID): draft_var_srv = WorkflowDraftVariableService( session=db.session(), @@ -574,6 +581,7 @@ class ConversationVariableCollectionApi(Resource): @console_ns.response(404, "Draft workflow not found") @_api_prerequisite @marshal_with(workflow_draft_variable_list_model) + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, current_user: Account, app_model: App): # NOTE(QuantumGhost): Prefill conversation variables into the draft variables table # so their IDs can be returned to the caller. @@ -599,8 +607,9 @@ class ConversationVariableCollectionApi(Resource): @login_required @account_initialization_required @edit_permission_required - @get_app_model(mode=AppMode.ADVANCED_CHAT) + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user + @get_app_model(mode=AppMode.ADVANCED_CHAT) def post(self, current_user: Account, app_model: App): payload = ConversationVariableUpdatePayload.model_validate(console_ns.payload or {}) @@ -628,6 +637,7 @@ class SystemVariableCollectionApi(Resource): @console_ns.response(200, "System variables retrieved successfully", workflow_draft_variable_list_model) @_api_prerequisite @marshal_with(workflow_draft_variable_list_model) + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, current_user: Account, app_model: App): return _get_variable_list(app_model, SYSTEM_VARIABLE_NODE_ID, current_user.id) @@ -644,6 +654,7 @@ class EnvironmentVariableCollectionApi(Resource): ) @console_ns.response(404, "Draft workflow not found") @_api_prerequisite + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) def get(self, _current_user: Account, app_model: App): """ Get draft workflow @@ -688,8 +699,9 @@ class EnvironmentVariableCollectionApi(Resource): @login_required @account_initialization_required @edit_permission_required - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @with_current_user + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def post(self, current_user: Account, app_model: App): payload = EnvironmentVariableUpdatePayload.model_validate(console_ns.payload or {}) diff --git a/api/controllers/console/app/workflow_node_output_inspector.py b/api/controllers/console/app/workflow_node_output_inspector.py index 98f86ad7cb6..6ed59d6c566 100644 --- a/api/controllers/console/app/workflow_node_output_inspector.py +++ b/api/controllers/console/app/workflow_node_output_inspector.py @@ -34,7 +34,13 @@ from controllers.common.fields import EventStreamResponse from controllers.common.schema import register_response_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model -from controllers.console.wraps import account_initialization_required, setup_required +from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + rbac_permission_required, + setup_required, +) from libs.exception import BaseHTTPException from libs.login import login_required from models import App, AppMode @@ -146,6 +152,7 @@ class WorkflowDraftRunNodeOutputsApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, run_id: UUID): return _serve_snapshot(app_model, run_id) @@ -169,6 +176,7 @@ class WorkflowDraftRunNodeOutputDetailApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, run_id: UUID, node_id: str): return _serve_node_detail(app_model, run_id, node_id) @@ -195,6 +203,7 @@ class WorkflowDraftRunNodeOutputPreviewApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, run_id: UUID, node_id: str, output_name: str): return _serve_output_preview(app_model, run_id, node_id, output_name) @@ -338,6 +347,7 @@ class WorkflowDraftRunNodeOutputEventsApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, run_id: UUID): return Response( @@ -368,6 +378,7 @@ class WorkflowPublishedRunNodeOutputsApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, run_id: UUID): return _serve_snapshot(app_model, run_id) @@ -391,6 +402,7 @@ class WorkflowPublishedRunNodeOutputDetailApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, run_id: UUID, node_id: str): return _serve_node_detail(app_model, run_id, node_id) @@ -418,6 +430,7 @@ class WorkflowPublishedRunNodeOutputPreviewApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, run_id: UUID, node_id: str, output_name: str): return _serve_output_preview(app_model, run_id, node_id, output_name) @@ -439,6 +452,7 @@ class WorkflowPublishedRunNodeOutputEventsApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, run_id: UUID): return Response( diff --git a/api/controllers/console/app/workflow_run.py b/api/controllers/console/app/workflow_run.py index 359daa12c20..374537229ca 100644 --- a/api/controllers/console/app/workflow_run.py +++ b/api/controllers/console/app/workflow_run.py @@ -14,7 +14,10 @@ from controllers.common.schema import query_params_from_model, register_response from controllers.console import console_ns from controllers.console.app.wraps import get_app_model from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -155,6 +158,7 @@ class AdvancedChatAppWorkflowRunListApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) @get_app_model(mode=[AppMode.ADVANCED_CHAT]) def get(self, app_model: App): """ @@ -193,6 +197,7 @@ class WorkflowRunExportApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) @get_app_model() def get(self, app_model: App, run_id: UUID): tenant_id = app_model.tenant_id @@ -251,6 +256,7 @@ class AdvancedChatAppWorkflowRunCountApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) @get_app_model(mode=[AppMode.ADVANCED_CHAT]) def get(self, app_model: App): """ @@ -291,6 +297,7 @@ class WorkflowRunListApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App): """ @@ -332,6 +339,7 @@ class WorkflowRunCountApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App): """ @@ -372,6 +380,7 @@ class WorkflowRunDetailApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, app_model: App, run_id: UUID): """ @@ -401,8 +410,9 @@ class WorkflowRunNodeExecutionListApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) def get(self, current_user: Account, app_model: App, run_id: UUID): """ Get workflow run node execution list diff --git a/api/controllers/console/app/workflow_statistic.py b/api/controllers/console/app/workflow_statistic.py index ec2a5ffce11..0346d510fbc 100644 --- a/api/controllers/console/app/workflow_statistic.py +++ b/api/controllers/console/app/workflow_statistic.py @@ -6,7 +6,14 @@ from sqlalchemy.orm import sessionmaker from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.app.wraps import get_app_model -from controllers.console.wraps import account_initialization_required, setup_required, with_current_user +from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + rbac_permission_required, + setup_required, + with_current_user, +) from extensions.ext_database import db from fields.base import ResponseModel from libs.datetime_utils import parse_time_range @@ -91,11 +98,12 @@ class WorkflowDailyRunsStatistic(Resource): "Daily runs statistics retrieved successfully", console_ns.models[WorkflowDailyRunsStatisticResponse.__name__], ) - @get_app_model @setup_required @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model def get(self, account: Account, app_model: App): args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) @@ -134,11 +142,12 @@ class WorkflowDailyTerminalsStatistic(Resource): "Daily terminals statistics retrieved successfully", console_ns.models[WorkflowDailyTerminalsStatisticResponse.__name__], ) - @get_app_model @setup_required @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model def get(self, account: Account, app_model: App): args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) @@ -177,11 +186,12 @@ class WorkflowDailyTokenCostStatistic(Resource): "Daily token cost statistics retrieved successfully", console_ns.models[WorkflowDailyTokenCostStatisticResponse.__name__], ) - @get_app_model @setup_required @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model def get(self, account: Account, app_model: App): args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) @@ -223,8 +233,9 @@ class WorkflowAverageAppInteractionStatistic(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=[AppMode.WORKFLOW]) @with_current_user + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR) + @get_app_model(mode=[AppMode.WORKFLOW]) def get(self, account: Account, app_model: App): args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True)) diff --git a/api/controllers/console/app/workflow_trigger.py b/api/controllers/console/app/workflow_trigger.py index 6a1bd843ee2..8bd41b3e429 100644 --- a/api/controllers/console/app/workflow_trigger.py +++ b/api/controllers/console/app/workflow_trigger.py @@ -19,7 +19,15 @@ from models.trigger import AppTrigger, WorkflowWebhookTrigger from .. import console_ns from ..app.wraps import get_app_model -from ..wraps import account_initialization_required, edit_permission_required, setup_required, with_current_tenant_id +from ..wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + edit_permission_required, + rbac_permission_required, + setup_required, + with_current_tenant_id, +) logger = logging.getLogger(__name__) @@ -90,8 +98,9 @@ class WebhookTriggerApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=AppMode.WORKFLOW) @console_ns.response(200, "Success", console_ns.models[WebhookTriggerResponse.__name__]) + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=AppMode.WORKFLOW) def get(self, app_model: App): """Get webhook trigger for a node""" args = Parser.model_validate(request.args.to_dict(flat=True)) @@ -122,9 +131,10 @@ class AppTriggersApi(Resource): @setup_required @login_required @account_initialization_required - @get_app_model(mode=AppMode.WORKFLOW) @console_ns.response(200, "Success", console_ns.models[WorkflowTriggerListResponse.__name__]) @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @get_app_model(mode=AppMode.WORKFLOW) def get(self, current_tenant_id: str, app_model: App): """Get app triggers list""" with sessionmaker(db.engine, expire_on_commit=False).begin() as session: @@ -162,9 +172,10 @@ class AppTriggerEnableApi(Resource): @login_required @account_initialization_required @edit_permission_required - @get_app_model(mode=AppMode.WORKFLOW) + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) @console_ns.response(200, "Success", console_ns.models[WorkflowTriggerResponse.__name__]) @with_current_tenant_id + @get_app_model(mode=AppMode.WORKFLOW) def post(self, current_tenant_id: str, app_model: App): """Update app trigger (enable/disable)""" args = ParserEnable.model_validate(console_ns.payload) diff --git a/api/controllers/console/auth/data_source_bearer_auth.py b/api/controllers/console/auth/data_source_bearer_auth.py index f06db799498..a9c97401105 100644 --- a/api/controllers/console/auth/data_source_bearer_auth.py +++ b/api/controllers/console/auth/data_source_bearer_auth.py @@ -11,7 +11,15 @@ from services.auth.api_key_auth_service import ApiKeyAuthService from .. import console_ns from ..auth.error import ApiKeyAuthFailedError -from ..wraps import account_initialization_required, is_admin_or_owner_required, setup_required, with_current_tenant_id +from ..wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + is_admin_or_owner_required, + rbac_permission_required, + setup_required, + with_current_tenant_id, +) class ApiKeyAuthBindingPayload(BaseModel): @@ -75,6 +83,7 @@ class ApiKeyAuthDataSourceBinding(Resource): @login_required @account_initialization_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @console_ns.expect(console_ns.models[ApiKeyAuthBindingPayload.__name__]) @with_current_tenant_id def post(self, current_tenant_id: str): @@ -95,6 +104,7 @@ class ApiKeyAuthDataSourceBindingDelete(Resource): @login_required @account_initialization_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @console_ns.response(204, "Binding deleted successfully") @with_current_tenant_id def delete(self, current_tenant_id: str, binding_id: UUID): diff --git a/api/controllers/console/auth/data_source_oauth.py b/api/controllers/console/auth/data_source_oauth.py index f1493b5e6f4..6ad2efa360a 100644 --- a/api/controllers/console/auth/data_source_oauth.py +++ b/api/controllers/console/auth/data_source_oauth.py @@ -13,7 +13,14 @@ from libs.login import login_required from libs.oauth_data_source import NotionOAuth from .. import console_ns -from ..wraps import account_initialization_required, is_admin_or_owner_required, setup_required +from ..wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + is_admin_or_owner_required, + rbac_permission_required, + setup_required, +) logger = logging.getLogger(__name__) @@ -75,6 +82,7 @@ class OAuthDataSource(Resource): @console_ns.response(400, "Invalid provider") @console_ns.response(403, "Admin privileges required") @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) def get(self, provider: str): # The role of the current user in the table must be admin or owner OAUTH_DATASOURCE_PROVIDERS = get_oauth_providers() diff --git a/api/controllers/console/datasets/data_source.py b/api/controllers/console/datasets/data_source.py index 07db712fba1..6dd13b485bc 100644 --- a/api/controllers/console/datasets/data_source.py +++ b/api/controllers/console/datasets/data_source.py @@ -31,7 +31,15 @@ from services.datasource_provider_service import DatasourceProviderService from tasks.document_indexing_sync_task import document_indexing_sync_task from .. import console_ns -from ..wraps import account_initialization_required, setup_required, with_current_tenant_id, with_current_user +from ..wraps import ( + RBACPermission, + RBACResourceScope, + account_initialization_required, + rbac_permission_required, + setup_required, + with_current_tenant_id, + with_current_user, +) class NotionEstimatePayload(BaseModel): @@ -390,6 +398,7 @@ class DataSourceNotionDatasetSyncApi(Resource): @login_required @account_initialization_required @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, dataset_id: UUID) -> tuple[dict[str, str], int]: dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) @@ -408,6 +417,7 @@ class DataSourceNotionDocumentSyncApi(Resource): @login_required @account_initialization_required @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, dataset_id: UUID, document_id: UUID) -> tuple[dict[str, str], int]: dataset_id_str = str(dataset_id) document_id_str = str(document_id) diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index 623c02631c0..55bc85483d5 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -17,10 +17,13 @@ from controllers.console.apikey import ApiKeyItem, ApiKeyList from controllers.console.app.error import ProviderNotInitializeError from controllers.console.datasets.error import DatasetInUseError, DatasetNameDuplicateError, IndexingEstimateError from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, cloud_edition_billing_rate_limit_check, enterprise_license_required, is_admin_or_owner_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -46,9 +49,16 @@ from models.enums import ApiTokenType, SegmentStatus from models.provider_ids import ModelProviderID from services.api_token_service import ApiTokenCache from services.dataset_service import DatasetPermissionService, DatasetService, DocumentService +from services.enterprise import rbac_service as enterprise_rbac_service register_response_schema_models(console_ns, ApiBaseUrlResponse, SimpleResultResponse, UsageCheckResponse) +DATASET_LIST_PERMISSION_KEYS = frozenset({"dataset.preview", "dataset.acl.preview", "dataset.full_access"}) + + +def _has_dataset_list_permission(permission_keys: list[str]) -> bool: + return any(permission_key in DATASET_LIST_PERMISSION_KEYS for permission_key in permission_keys) + def _validate_indexing_technique(value: str | None) -> str | None: if value is None: @@ -402,9 +412,49 @@ class DatasetListApi(Resource): if "tag_ids" in request.args: query_params["tag_ids"] = request.args.getlist("tag_ids") query = ConsoleDatasetListQuery.model_validate(query_params) - # provider = request.args.get("provider", default="vendor") + + permissions = enterprise_rbac_service.RBACService.MyPermissions.get( + str(current_tenant_id), + current_user.id, + ) + + accessible_dataset_ids: list[str] | None = None + include_own_datasets = False + if dify_config.RBAC_ENABLED: + whitelist_scope = enterprise_rbac_service.RBACService.DatasetAccess.whitelist_resources( + str(current_tenant_id), + current_user.id, + ) + has_default_readonly = _has_dataset_list_permission( + permissions.dataset.default_permission_keys + ) or _has_dataset_list_permission(permissions.workspace.permission_keys) + permission_dataset_ids: set[str] | None = None + if not has_default_readonly: + permission_dataset_ids = { + override.resource_id + for override in permissions.dataset.overrides + if _has_dataset_list_permission(override.permission_keys) + } + if getattr(whitelist_scope, "unrestricted", False): + filtered_dataset_ids = permission_dataset_ids + else: + filtered_dataset_ids = set(whitelist_scope.resource_ids) + if permission_dataset_ids is not None: + filtered_dataset_ids |= permission_dataset_ids + elif has_default_readonly: + filtered_dataset_ids = None + if filtered_dataset_ids is not None: + accessible_dataset_ids = sorted(filtered_dataset_ids) + include_own_datasets = "dataset.create_and_management" in permissions.workspace.permission_keys + if query.ids: - datasets, total = DatasetService.get_datasets_by_ids(query.ids, current_tenant_id) + datasets, total = DatasetService.get_datasets_by_ids( + query.ids, + current_tenant_id, + user=current_user, + accessible_dataset_ids=accessible_dataset_ids, + include_own_datasets=include_own_datasets, + ) else: datasets, total = DatasetService.get_datasets( query.page, @@ -415,8 +465,15 @@ class DatasetListApi(Resource): query.keyword, query.tag_ids, query.include_all, + accessible_dataset_ids=accessible_dataset_ids, + include_own_datasets=include_own_datasets, ) + permission_keys_map = {} + if datasets: + dataset_ids = [str(dataset.id) for dataset in datasets] + permission_keys_map = permissions.dataset.permission_keys_by_resource_ids(dataset_ids) + # check embedding setting provider_manager = create_plugin_provider_manager(tenant_id=current_tenant_id) configurations = provider_manager.get_configurations(tenant_id=current_tenant_id) @@ -431,13 +488,13 @@ class DatasetListApi(Resource): dataset_ids = [item["id"] for item in data if item.get("permission") == "partial_members"] partial_members_map: dict[str, list[str]] = {} if dataset_ids: - permissions = db.session.execute( + partial_member_rows = db.session.execute( select(DatasetPermission.dataset_id, DatasetPermission.account_id).where( DatasetPermission.dataset_id.in_(dataset_ids) ) ).all() - for dataset_id, account_id in permissions: + for dataset_id, account_id in partial_member_rows: partial_members_map.setdefault(dataset_id, []).append(account_id) for item in data: @@ -456,6 +513,7 @@ class DatasetListApi(Resource): item.update({"partial_member_list": partial_members_map.get(item["id"], [])}) else: item.update({"partial_member_list": []}) + item["permission_keys"] = permission_keys_map.get(str(item["id"]), []) response = { "data": data, @@ -474,6 +532,9 @@ class DatasetListApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required( + RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT, resource_required=False + ) @cloud_edition_billing_rate_limit_check("knowledge") @with_current_user @with_current_tenant_id @@ -484,6 +545,11 @@ class DatasetListApi(Resource): if not current_user.is_dataset_editor: raise Forbidden() + if dify_config.RBAC_ENABLED: + permission = DatasetPermissionEnum.ALL_TEAM + else: + permission = payload.permission or DatasetPermissionEnum.ONLY_ME + try: dataset = DatasetService.create_empty_dataset( tenant_id=current_tenant_id, @@ -491,7 +557,7 @@ class DatasetListApi(Resource): description=payload.description, indexing_technique=payload.indexing_technique, account=current_user, - permission=payload.permission or DatasetPermissionEnum.ONLY_ME, + permission=permission, provider=payload.provider, external_knowledge_api_id=payload.external_knowledge_api_id, external_knowledge_id=payload.external_knowledge_id, @@ -499,7 +565,17 @@ class DatasetListApi(Resource): except services.errors.dataset.DatasetNameDuplicateError: raise DatasetNameDuplicateError() - return dump_response(DatasetDetailResponse, dataset), 201 + permission_keys_map = enterprise_rbac_service.RBACService.DatasetPermissions.batch_get( + str(current_tenant_id), + current_user.id, + [str(dataset.id)], + ) + + item = DatasetDetailWithPartialMembersResponse.model_validate(dataset, from_attributes=True).model_dump( + mode="json" + ) + item["permission_keys"] = permission_keys_map.get(str(dataset.id), []) + return item, 201 @console_ns.route("/datasets/") @@ -517,6 +593,7 @@ class DatasetApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) @with_current_user @with_current_tenant_id def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID): @@ -528,7 +605,14 @@ class DatasetApi(Resource): DatasetService.check_dataset_permission(dataset, current_user) except services.errors.account.NoPermissionError as e: raise Forbidden(str(e)) + permissions = enterprise_rbac_service.RBACService.MyPermissions.get( + str(current_tenant_id), + current_user.id, + dataset_id=dataset_id_str, + ) + permission_keys_map = permissions.dataset.permission_keys_by_resource_ids([dataset_id_str]) data = dump_response(DatasetDetailResponse, dataset) + data["permission_keys"] = permission_keys_map.get(dataset_id_str, []) if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY: if dataset.embedding_model_provider: provider_id = ModelProviderID(dataset.embedding_model_provider) @@ -574,6 +658,7 @@ class DatasetApi(Resource): @cloud_edition_billing_rate_limit_check("knowledge") @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def patch(self, current_tenant_id: str, current_user: Account, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) @@ -593,16 +678,23 @@ class DatasetApi(Resource): payload.is_multimodal = is_multimodal payload_data = payload.model_dump(exclude_unset=True) # The role of the current user in the ta table must be admin, owner, editor, or dataset_operator - DatasetPermissionService.check_permission( - current_user, dataset, payload.permission, payload.partial_member_list - ) + if not dify_config.RBAC_ENABLED: + DatasetPermissionService.check_permission( + current_user, dataset, payload.permission, payload.partial_member_list + ) dataset = DatasetService.update_dataset(dataset_id_str, payload_data, current_user) if dataset is None: raise NotFound("Dataset not found.") + permission_keys_map = enterprise_rbac_service.RBACService.DatasetPermissions.batch_get( + str(current_tenant_id), + current_user.id, + [dataset_id_str], + ) result_data = dump_response(DatasetDetailResponse, dataset) + result_data["permission_keys"] = permission_keys_map.get(dataset_id_str, []) tenant_id = current_tenant_id if payload.partial_member_list is not None and payload.permission == DatasetPermissionEnum.PARTIAL_TEAM: @@ -622,6 +714,7 @@ class DatasetApi(Resource): @cloud_edition_billing_rate_limit_check("knowledge") @console_ns.response(204, "Dataset deleted successfully") @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def delete(self, current_user: Account, dataset_id: UUID): dataset_id_str = str(dataset_id) @@ -651,6 +744,7 @@ class DatasetUseCheckApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, dataset_id: UUID): dataset_id_str = str(dataset_id) @@ -672,6 +766,7 @@ class DatasetQueryApi(Resource): @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, current_user: Account, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) @@ -812,6 +907,7 @@ class DatasetRelatedAppListApi(Resource): @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, current_user: Account, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) @@ -848,6 +944,7 @@ class DatasetIndexingStatusApi(Resource): @login_required @account_initialization_required @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, current_tenant_id: str, dataset_id: UUID): dataset_id_str = str(dataset_id) documents = db.session.scalars( @@ -917,6 +1014,7 @@ class DatasetApiKeyApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, current_tenant_id: str): @@ -957,6 +1055,7 @@ class DatasetApiDeleteApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def delete(self, current_tenant_id: str, api_key_id: UUID): @@ -991,6 +1090,7 @@ class DatasetEnableApiApi(Resource): @login_required @account_initialization_required @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, dataset_id: UUID, status: str): dataset_id_str = str(dataset_id) @@ -1060,6 +1160,7 @@ class DatasetErrorDocs(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) @@ -1086,6 +1187,7 @@ class DatasetPermissionUserListApi(Resource): @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, current_user: Account, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) @@ -1115,6 +1217,7 @@ class DatasetAutoDisableLogApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) diff --git a/api/controllers/console/datasets/datasets_document.py b/api/controllers/console/datasets/datasets_document.py index d4d50600a09..07e150617bf 100644 --- a/api/controllers/console/datasets/datasets_document.py +++ b/api/controllers/console/datasets/datasets_document.py @@ -19,6 +19,7 @@ from controllers.common.controller_schemas import DocumentBatchDownloadZipPayloa from controllers.common.fields import BinaryFileResponse, SimpleResultMessageResponse, SimpleResultResponse, UrlResponse from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns +from controllers.console.wraps import RBACPermission, RBACResourceScope, rbac_permission_required from core.errors.error import ( LLMBadRequestError, ModelCurrentlyNotSupportError, @@ -289,6 +290,7 @@ class DatasetDocumentListApi(Resource): @account_initialization_required @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID): dataset_id_str = str(dataset_id) raw_args = request.args.to_dict() @@ -415,6 +417,7 @@ class DatasetDocumentListApi(Resource): @console_ns.expect(console_ns.models[KnowledgeConfig.__name__]) @console_ns.response(200, "Documents created successfully", console_ns.models[DatasetAndDocumentResponse.__name__]) @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_user: Account, dataset_id: UUID): dataset_id_str = str(dataset_id) @@ -458,6 +461,7 @@ class DatasetDocumentListApi(Resource): @account_initialization_required @cloud_edition_billing_rate_limit_check("knowledge") @console_ns.response(204, "Documents deleted successfully") + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def delete(self, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) @@ -555,6 +559,7 @@ class DocumentIndexingEstimateApi(DocumentResource): @account_initialization_required @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID): dataset_id_str = str(dataset_id) document_id_str = str(document_id) @@ -626,6 +631,7 @@ class DocumentBatchIndexingEstimateApi(DocumentResource): @account_initialization_required @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, batch: str): dataset_id_str = str(dataset_id) documents = self.get_batch_documents(dataset_id_str, batch, current_user) @@ -726,6 +732,7 @@ class DocumentBatchIndexingStatusApi(DocumentResource): @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, current_user: Account, dataset_id: UUID, batch: str): dataset_id_str = str(dataset_id) documents = self.get_batch_documents(dataset_id_str, batch, current_user) @@ -783,6 +790,7 @@ class DocumentIndexingStatusApi(DocumentResource): @account_initialization_required @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID): dataset_id_str = str(dataset_id) document_id_str = str(document_id) @@ -846,6 +854,7 @@ class DocumentApi(DocumentResource): @account_initialization_required @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID): dataset_id_str = str(dataset_id) document_id_str = str(document_id) @@ -937,6 +946,7 @@ class DocumentApi(DocumentResource): @console_ns.response(204, "Document deleted successfully") @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def delete(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID): dataset_id_str = str(dataset_id) document_id_str = str(document_id) @@ -969,6 +979,7 @@ class DocumentDownloadApi(DocumentResource): @cloud_edition_billing_rate_limit_check("knowledge") @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_DOCUMENT_DOWNLOAD) def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID) -> dict[str, Any]: # Reuse the shared permission/tenant checks implemented in DocumentResource. document = self.get_document(str(dataset_id), str(document_id), current_user, current_tenant_id) @@ -989,6 +1000,7 @@ class DocumentBatchDownloadZipApi(DocumentResource): @console_ns.expect(console_ns.models[DocumentBatchDownloadZipPayload.__name__]) @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_tenant_id: str, current_user: Account, dataset_id: UUID): """Stream a ZIP archive containing the requested uploaded documents.""" # Parse and validate request payload. @@ -1037,6 +1049,7 @@ class DocumentProcessingApi(DocumentResource): @cloud_edition_billing_rate_limit_check("knowledge") @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def patch( self, current_tenant_id: str, @@ -1093,6 +1106,7 @@ class DocumentMetadataApi(DocumentResource): @account_initialization_required @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def put(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID): dataset_id_str = str(dataset_id) document_id_str = str(document_id) @@ -1142,6 +1156,7 @@ class DocumentStatusApi(DocumentResource): @cloud_edition_billing_rate_limit_check("knowledge") @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def patch( self, current_user: Account, dataset_id: UUID, action: Literal["enable", "disable", "archive", "un_archive"] ): @@ -1181,6 +1196,7 @@ class DocumentPauseApi(DocumentResource): @account_initialization_required @cloud_edition_billing_rate_limit_check("knowledge") @console_ns.response(204, "Document paused successfully") + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def patch(self, dataset_id: UUID, document_id: UUID): """pause document.""" dataset_id_str = str(dataset_id) @@ -1216,6 +1232,7 @@ class DocumentRecoverApi(DocumentResource): @account_initialization_required @cloud_edition_billing_rate_limit_check("knowledge") @console_ns.response(204, "Document resumed successfully") + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def patch(self, dataset_id: UUID, document_id: UUID): """recover document.""" dataset_id_str = str(dataset_id) @@ -1249,6 +1266,7 @@ class DocumentRetryApi(DocumentResource): @cloud_edition_billing_rate_limit_check("knowledge") @console_ns.expect(console_ns.models[DocumentRetryPayload.__name__]) @console_ns.response(204, "Documents retry started successfully") + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, dataset_id: UUID): """retry document.""" payload = DocumentRetryPayload.model_validate(console_ns.payload or {}) @@ -1290,6 +1308,7 @@ class DocumentRenameApi(DocumentResource): @console_ns.response(200, "Document renamed successfully", console_ns.models[DocumentResponse.__name__]) @console_ns.expect(console_ns.models[DocumentRenamePayload.__name__]) @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_user: Account, dataset_id: UUID, document_id: UUID): # The role of the current user in the ta table must be admin, owner, editor, or dataset_operator if not current_user.is_dataset_editor: @@ -1315,6 +1334,7 @@ class WebsiteDocumentSyncApi(DocumentResource): @account_initialization_required @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, current_tenant_id: str, dataset_id: UUID, document_id: UUID): """sync website document.""" dataset_id_str = str(dataset_id) @@ -1348,6 +1368,7 @@ class DocumentPipelineExecutionLogApi(DocumentResource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, dataset_id: UUID, document_id: UUID): dataset_id_str = str(dataset_id) document_id_str = str(document_id) @@ -1398,6 +1419,7 @@ class DocumentGenerateSummaryApi(Resource): @account_initialization_required @cloud_edition_billing_rate_limit_check("knowledge") @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_user: Account, dataset_id: UUID): """ Generate summary index for specified documents. @@ -1491,6 +1513,7 @@ class DocumentSummaryStatusApi(DocumentResource): @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, current_user: Account, dataset_id: UUID, document_id: UUID): """ Get summary index generation status for a document. diff --git a/api/controllers/console/datasets/datasets_segments.py b/api/controllers/console/datasets/datasets_segments.py index 4e521100abe..4858b5ff6b0 100644 --- a/api/controllers/console/datasets/datasets_segments.py +++ b/api/controllers/console/datasets/datasets_segments.py @@ -28,10 +28,13 @@ from controllers.console.datasets.error import ( InvalidActionError, ) from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, cloud_edition_billing_knowledge_limit_check, cloud_edition_billing_rate_limit_check, cloud_edition_billing_resource_check, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -169,6 +172,7 @@ class DatasetDocumentSegmentListApi(Resource): @account_initialization_required @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID): dataset_id_str = str(dataset_id) document_id_str = str(document_id) @@ -278,6 +282,7 @@ class DatasetDocumentSegmentListApi(Resource): @console_ns.doc(params=query_params_from_model(SegmentIdListQuery)) @console_ns.response(204, "Segments deleted successfully") @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def delete(self, current_user: Account, dataset_id: UUID, document_id: UUID): # check dataset dataset_id_str = str(dataset_id) @@ -316,6 +321,7 @@ class DatasetDocumentSegmentApi(Resource): @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def patch( self, current_tenant_id: str, @@ -384,6 +390,7 @@ class DatasetDocumentSegmentAddApi(Resource): @console_ns.response(200, "Segment created successfully", console_ns.models[SegmentDetailResponse.__name__]) @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID): # check dataset dataset_id_str = str(dataset_id) @@ -442,6 +449,7 @@ class DatasetDocumentSegmentUpdateApi(Resource): @console_ns.response(200, "Segment updated successfully", console_ns.models[SegmentDetailResponse.__name__]) @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def patch( self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID, segment_id: UUID ): @@ -513,6 +521,7 @@ class DatasetDocumentSegmentUpdateApi(Resource): @console_ns.response(204, "Segment deleted successfully") @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def delete( self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID, segment_id: UUID ): @@ -563,6 +572,7 @@ class DatasetDocumentSegmentBatchImportApi(Resource): @console_ns.expect(console_ns.models[BatchImportPayload.__name__]) @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID): # check dataset dataset_id_str = str(dataset_id) @@ -608,6 +618,7 @@ class DatasetDocumentSegmentBatchImportApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, job_id=None, dataset_id: UUID | None = None, document_id: UUID | None = None): if job_id is None: raise NotFound("The job does not exist.") @@ -634,6 +645,7 @@ class ChildChunkAddApi(Resource): @console_ns.response(200, "Child chunk created successfully", console_ns.models[ChildChunkDetailResponse.__name__]) @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post( self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID, segment_id: UUID ): @@ -693,6 +705,7 @@ class ChildChunkAddApi(Resource): @login_required @account_initialization_required @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, current_tenant_id: str, dataset_id: UUID, document_id: UUID, segment_id: UUID): # check dataset dataset_id_str = str(dataset_id) @@ -747,6 +760,7 @@ class ChildChunkAddApi(Resource): @console_ns.expect(console_ns.models[ChildChunkBatchUpdatePayload.__name__]) @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def patch( self, current_tenant_id: str, current_user: Account, dataset_id: UUID, document_id: UUID, segment_id: UUID ): @@ -799,6 +813,7 @@ class ChildChunkUpdateApi(Resource): @console_ns.response(204, "Child chunk deleted successfully") @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def delete( self, current_tenant_id: str, @@ -866,6 +881,7 @@ class ChildChunkUpdateApi(Resource): @console_ns.response(200, "Child chunk updated successfully", console_ns.models[ChildChunkDetailResponse.__name__]) @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def patch( self, current_tenant_id: str, diff --git a/api/controllers/console/datasets/external.py b/api/controllers/console/datasets/external.py index fb19ad81c67..033c9a69af6 100644 --- a/api/controllers/console/datasets/external.py +++ b/api/controllers/console/datasets/external.py @@ -17,8 +17,11 @@ from controllers.common.schema import ( from controllers.console import console_ns from controllers.console.datasets.error import DatasetNameDuplicateError from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -40,6 +43,7 @@ from fields.dataset_fields import ( from libs.login import login_required from models import Account from services.dataset_service import DatasetService +from services.enterprise import rbac_service as enterprise_rbac_service from services.external_knowledge_service import ExternalDatasetService from services.hit_testing_service import HitTestingService from services.knowledge_service import BedrockRetrievalSetting, ExternalDatasetTestService @@ -319,6 +323,7 @@ class ExternalDatasetCreateApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EXTERNAL_CONNECT) @with_current_user @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account): @@ -339,7 +344,16 @@ class ExternalDatasetCreateApi(Resource): except services.errors.dataset.DatasetNameDuplicateError: raise DatasetNameDuplicateError() - return marshal(dataset, dataset_detail_fields), 201 + item = marshal(dataset, dataset_detail_fields) + dataset_id_str = item["id"] + permission_keys_map = enterprise_rbac_service.RBACService.DatasetPermissions.batch_get( + str(current_tenant_id), + current_user.id, + [dataset_id_str], + ) + item["permission_keys"] = permission_keys_map.get(dataset_id_str, []) + + return item, 201 @console_ns.route("/datasets//external-hit-testing") @@ -359,6 +373,7 @@ class ExternalKnowledgeHitTestingApi(Resource): @login_required @account_initialization_required @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_PIPELINE_TEST) def post(self, current_user: Account, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) diff --git a/api/controllers/console/datasets/hit_testing.py b/api/controllers/console/datasets/hit_testing.py index c08ed2fe9f0..739f0250333 100644 --- a/api/controllers/console/datasets/hit_testing.py +++ b/api/controllers/console/datasets/hit_testing.py @@ -5,6 +5,7 @@ from uuid import UUID from flask_restx import Resource from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.console.wraps import RBACPermission, RBACResourceScope, rbac_permission_required from fields.hit_testing_fields import HitTestingResponse from libs.helper import dump_response from libs.login import login_required @@ -43,6 +44,7 @@ class HitTestingApi(Resource, DatasetsHitTestingBase): @cloud_edition_billing_rate_limit_check("knowledge") @with_current_tenant_id @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_PIPELINE_TEST) def post(self, current_user: Account, current_tenant_id: str, dataset_id: UUID) -> dict[str, object]: dataset_id_str = str(dataset_id) diff --git a/api/controllers/console/datasets/metadata.py b/api/controllers/console/datasets/metadata.py index ec4c5bedb61..7195fe066fd 100644 --- a/api/controllers/console/datasets/metadata.py +++ b/api/controllers/console/datasets/metadata.py @@ -8,8 +8,11 @@ from controllers.common.controller_schemas import MetadataUpdatePayload from controllers.common.schema import register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, enterprise_license_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -52,6 +55,7 @@ class DatasetMetadataCreateApi(Resource): @console_ns.expect(console_ns.models[MetadataArgs.__name__]) @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_tenant_id: str, current_user: Account, dataset_id: UUID): metadata_args = MetadataArgs.model_validate(console_ns.payload or {}) @@ -71,6 +75,7 @@ class DatasetMetadataCreateApi(Resource): @console_ns.response( 200, "Metadata retrieved successfully", console_ns.models[DatasetMetadataListResponse.__name__] ) + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT) def get(self, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) @@ -90,6 +95,7 @@ class DatasetMetadataApi(Resource): @console_ns.expect(console_ns.models[MetadataUpdatePayload.__name__]) @with_current_user @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def patch(self, current_tenant_id: str, current_user: Account, dataset_id: UUID, metadata_id: UUID): payload = MetadataUpdatePayload.model_validate(console_ns.payload or {}) name = payload.name @@ -112,6 +118,7 @@ class DatasetMetadataApi(Resource): @enterprise_license_required @console_ns.response(204, "Metadata deleted successfully") @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def delete(self, current_user: Account, dataset_id: UUID, metadata_id: UUID): dataset_id_str = str(dataset_id) metadata_id_str = str(metadata_id) @@ -149,6 +156,7 @@ class DatasetMetadataBuiltInFieldActionApi(Resource): @enterprise_license_required @console_ns.response(204, "Action completed successfully") @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_user: Account, dataset_id: UUID, action: Literal["enable", "disable"]): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) @@ -177,6 +185,7 @@ class DocumentMetadataEditApi(Resource): "Documents metadata updated successfully", ) @with_current_user + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_user: Account, dataset_id: UUID): dataset_id_str = str(dataset_id) dataset = DatasetService.get_dataset(dataset_id_str) diff --git a/api/controllers/console/datasets/rag_pipeline/datasource_auth.py b/api/controllers/console/datasets/rag_pipeline/datasource_auth.py index 980f116e216..c5ca1d155de 100644 --- a/api/controllers/console/datasets/rag_pipeline/datasource_auth.py +++ b/api/controllers/console/datasets/rag_pipeline/datasource_auth.py @@ -10,8 +10,11 @@ from controllers.common.fields import RedirectResponse, SimpleResultResponse from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -104,6 +107,7 @@ class DatasourcePluginOAuthAuthorizationUrl(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @with_current_user @with_current_tenant_id def get(self, current_tenant_id: str, current_user: Account, provider_id: str): @@ -218,6 +222,7 @@ class DatasourceAuth(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @with_current_tenant_id def post(self, current_tenant_id: str, provider_id: str): payload = DatasourceCredentialPayload.model_validate(console_ns.payload or {}) @@ -262,6 +267,7 @@ class DatasourceAuthDeleteApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @with_current_tenant_id def post(self, current_tenant_id: str, provider_id: str): datasource_provider_id = DatasourceProviderID(provider_id) @@ -287,6 +293,7 @@ class DatasourceAuthUpdateApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @with_current_tenant_id def post(self, current_tenant_id: str, provider_id: str): datasource_provider_id = DatasourceProviderID(provider_id) @@ -338,6 +345,7 @@ class DatasourceAuthOauthCustomClient(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @with_current_tenant_id def post(self, current_tenant_id: str, provider_id: str): payload = DatasourceCustomClientPayload.model_validate(console_ns.payload or {}) @@ -374,6 +382,7 @@ class DatasourceAuthDefaultApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @with_current_tenant_id def post(self, current_tenant_id: str, provider_id: str): payload = DatasourceDefaultPayload.model_validate(console_ns.payload or {}) @@ -395,6 +404,7 @@ class DatasourceUpdateProviderNameApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @with_current_tenant_id def post(self, current_tenant_id: str, provider_id: str): payload = DatasourceUpdateNamePayload.model_validate(console_ns.payload or {}) diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py index e0c8d95b766..0b83dc9312d 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_import.py @@ -13,8 +13,11 @@ from controllers.common.schema import ( from controllers.console import console_ns from controllers.console.datasets.wraps import get_rag_pipeline from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_user, ) @@ -78,6 +81,9 @@ class RagPipelineImportApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required( + RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT, resource_required=False + ) @with_current_user def post(self, current_user: Account) -> JsonResponseWithStatus: # Check user role first @@ -122,6 +128,9 @@ class RagPipelineImportConfirmApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required( + RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT, resource_required=False + ) @with_current_user def post(self, current_user: Account, import_id: str) -> JsonResponseWithStatus: with Session(db.engine, expire_on_commit=False) as session: @@ -151,6 +160,7 @@ class RagPipelineImportCheckDependenciesApi(Resource): @get_rag_pipeline @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_READONLY) def get(self, pipeline: Pipeline) -> JsonResponseWithStatus: with Session(db.engine, expire_on_commit=False) as session: import_service = RagPipelineDslService(session) @@ -168,6 +178,7 @@ class RagPipelineExportApi(Resource): @get_rag_pipeline @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_IMPORT_EXPORT_DSL) def get(self, pipeline: Pipeline) -> JsonResponseWithStatus: # Add include_secret params query = IncludeSecretQuery.model_validate(request.args.to_dict()) diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py index 7c941e14368..fdc55ea9737 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py @@ -28,8 +28,11 @@ from controllers.console.app.workflow import ( ) from controllers.console.datasets.wraps import get_rag_pipeline from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -186,6 +189,7 @@ class DraftRagPipelineApi(Resource): @account_initialization_required @get_rag_pipeline @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def get(self, pipeline: Pipeline): """ Get draft rag pipeline's workflow @@ -206,6 +210,7 @@ class DraftRagPipelineApi(Resource): @with_current_user @get_rag_pipeline @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @console_ns.expect(console_ns.models[DraftWorkflowSyncPayload.__name__]) @console_ns.response(200, "Success", console_ns.models[RagPipelineWorkflowSyncResponse.__name__]) def post(self, current_user: Account, pipeline: Pipeline): @@ -266,6 +271,7 @@ class RagPipelineDraftRunIterationNodeApi(Resource): @with_current_user @get_rag_pipeline @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_user: Account, pipeline: Pipeline, node_id: str): """ Run draft workflow iteration node @@ -298,6 +304,7 @@ class RagPipelineDraftRunLoopNodeApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @with_current_user @get_rag_pipeline def post(self, current_user: Account, pipeline: Pipeline, node_id: str): @@ -332,6 +339,7 @@ class DraftRagPipelineRunApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @with_current_user @get_rag_pipeline def post(self, current_user: Account, pipeline: Pipeline): @@ -363,6 +371,7 @@ class PublishedRagPipelineRunApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @with_current_user @get_rag_pipeline def post(self, current_user: Account, pipeline: Pipeline): @@ -395,6 +404,7 @@ class RagPipelinePublishedDatasourceNodeRunApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @with_current_user @get_rag_pipeline def post(self, current_user: Account, pipeline: Pipeline, node_id: str): @@ -426,6 +436,7 @@ class RagPipelineDraftDatasourceNodeRunApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @account_initialization_required @with_current_user @get_rag_pipeline @@ -462,6 +473,7 @@ class RagPipelineDraftNodeRunApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @account_initialization_required @with_current_user @get_rag_pipeline @@ -491,6 +503,7 @@ class RagPipelineTaskStopApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @account_initialization_required @with_current_user @get_rag_pipeline @@ -514,6 +527,7 @@ class PublishedRagPipelineApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @get_rag_pipeline def get(self, pipeline: Pipeline): """ @@ -537,6 +551,7 @@ class PublishedRagPipelineApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @with_current_user @get_rag_pipeline def post(self, current_user: Account, pipeline: Pipeline): @@ -571,6 +586,7 @@ class DefaultRagPipelineBlockConfigsApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @get_rag_pipeline def get(self, pipeline: Pipeline): """ @@ -593,6 +609,7 @@ class DefaultRagPipelineBlockConfigApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @get_rag_pipeline def get(self, pipeline: Pipeline, block_type: str): """ @@ -625,6 +642,7 @@ class PublishedAllRagPipelineApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @with_current_user @get_rag_pipeline def get(self, current_user: Account, pipeline: Pipeline): @@ -670,6 +688,7 @@ class RagPipelineDraftWorkflowRestoreApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @with_current_user @get_rag_pipeline def post(self, current_user: Account, pipeline: Pipeline, workflow_id: str): @@ -704,6 +723,7 @@ class RagPipelineByIdApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @with_current_user @get_rag_pipeline @console_ns.expect(console_ns.models[WorkflowUpdatePayload.__name__]) @@ -739,6 +759,7 @@ class RagPipelineByIdApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) @get_rag_pipeline def delete(self, pipeline: Pipeline, workflow_id: str): """ @@ -775,6 +796,7 @@ class PublishedRagPipelineSecondStepApi(Resource): @account_initialization_required @get_rag_pipeline @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def get(self, pipeline: Pipeline): """ Get second step parameters of rag pipeline @@ -797,6 +819,7 @@ class PublishedRagPipelineFirstStepApi(Resource): @account_initialization_required @get_rag_pipeline @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def get(self, pipeline: Pipeline): """ Get first step parameters of rag pipeline @@ -819,6 +842,7 @@ class DraftRagPipelineFirstStepApi(Resource): @account_initialization_required @get_rag_pipeline @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def get(self, pipeline: Pipeline): """ Get first step parameters of rag pipeline @@ -841,6 +865,7 @@ class DraftRagPipelineSecondStepApi(Resource): @account_initialization_required @get_rag_pipeline @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def get(self, pipeline: Pipeline): """ Get second step parameters of rag pipeline @@ -1012,6 +1037,7 @@ class RagPipelineDatasourceVariableApi(Resource): @with_current_user @get_rag_pipeline @edit_permission_required + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) def post(self, current_user: Account, pipeline: Pipeline): """ Set datasource variables diff --git a/api/controllers/console/snippets/snippet_workflow.py b/api/controllers/console/snippets/snippet_workflow.py index 243c29e6719..5af885ab91b 100644 --- a/api/controllers/console/snippets/snippet_workflow.py +++ b/api/controllers/console/snippets/snippet_workflow.py @@ -31,8 +31,11 @@ from controllers.console.snippets.payloads import ( WorkflowRunQuery, ) from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_user, ) @@ -154,6 +157,7 @@ class SnippetDraftWorkflowApi(Resource): @account_initialization_required @get_snippet @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False) def get(self, snippet: CustomizedSnippet): """Get draft workflow for snippet.""" snippet_service = _snippet_service() @@ -181,6 +185,9 @@ class SnippetDraftWorkflowApi(Resource): @with_current_user @get_snippet @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) def post(self, current_user: Account, snippet: CustomizedSnippet): """Sync draft workflow for snippet.""" payload = SnippetDraftSyncPayload.model_validate(console_ns.payload or {}) @@ -219,6 +226,7 @@ class SnippetDraftConfigApi(Resource): @account_initialization_required @get_snippet @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False) def get(self, snippet: CustomizedSnippet): """Get snippet draft workflow configuration limits.""" return { @@ -240,6 +248,7 @@ class SnippetPublishedWorkflowApi(Resource): @account_initialization_required @get_snippet @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False) def get(self, snippet: CustomizedSnippet): """Get published workflow for snippet.""" if not snippet.is_published: @@ -265,6 +274,9 @@ class SnippetPublishedWorkflowApi(Resource): @with_current_user @get_snippet @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) def post(self, current_user: Account, snippet: CustomizedSnippet): """Publish snippet workflow.""" snippet_service = _snippet_service() @@ -301,6 +313,7 @@ class SnippetDefaultBlockConfigsApi(Resource): @account_initialization_required @get_snippet @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False) def get(self, snippet: CustomizedSnippet): """Get default block configurations for snippet workflow.""" snippet_service = _snippet_service() @@ -323,6 +336,7 @@ class SnippetPublishedAllWorkflowApi(Resource): @account_initialization_required @get_snippet @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False) def get(self, snippet: CustomizedSnippet): """Get all published workflow versions for snippet.""" args = SnippetWorkflowListQuery.model_validate(request.args.to_dict(flat=True)) @@ -361,6 +375,9 @@ class SnippetDraftWorkflowRestoreApi(Resource): @with_current_user @get_snippet @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) def post(self, current_user: Account, snippet: CustomizedSnippet, workflow_id: str): """Restore a published snippet workflow version into the draft workflow.""" snippet_service = _snippet_service() @@ -486,6 +503,9 @@ class SnippetDraftNodeRunApi(Resource): @with_current_user @get_snippet @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str): """ Run a single node in snippet draft workflow. @@ -574,6 +594,9 @@ class SnippetDraftRunIterationNodeApi(Resource): @with_current_user @get_snippet @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str): """ Run a draft workflow iteration node for snippet. @@ -619,6 +642,9 @@ class SnippetDraftRunLoopNodeApi(Resource): @with_current_user @get_snippet @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str): """ Run a draft workflow loop node for snippet. @@ -662,6 +688,9 @@ class SnippetDraftWorkflowRunApi(Resource): @with_current_user @get_snippet @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) def post(self, current_user: Account, snippet: CustomizedSnippet): """ Run draft workflow for snippet. @@ -700,6 +729,9 @@ class SnippetWorkflowTaskStopApi(Resource): @account_initialization_required @get_snippet @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) def post(self, snippet: CustomizedSnippet, task_id: str): """ Stop a running snippet workflow task. diff --git a/api/controllers/console/snippets/snippet_workflow_draft_variable.py b/api/controllers/console/snippets/snippet_workflow_draft_variable.py index a28ba07b5dd..4befd259666 100644 --- a/api/controllers/console/snippets/snippet_workflow_draft_variable.py +++ b/api/controllers/console/snippets/snippet_workflow_draft_variable.py @@ -34,8 +34,11 @@ from controllers.console.app.workflow_draft_variable import ( ) from controllers.console.snippets.snippet_workflow import get_snippet from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_user, ) @@ -102,6 +105,7 @@ class SnippetWorkflowVariableCollectionApi(Resource): ) @_snippet_draft_var_prerequisite @marshal_with(workflow_draft_variable_list_without_value_model) + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False) def get(self, current_user: Account, snippet: CustomizedSnippet) -> WorkflowDraftVariableList: args = WorkflowDraftVariableListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore @@ -125,6 +129,9 @@ class SnippetWorkflowVariableCollectionApi(Resource): @console_ns.doc(description="Delete all draft workflow variables for the current user (snippet scope)") @console_ns.response(204, "Workflow variables deleted successfully") @_snippet_draft_var_prerequisite + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) def delete(self, current_user: Account, snippet: CustomizedSnippet) -> Response: draft_var_srv = WorkflowDraftVariableService(session=db.session()) draft_var_srv.delete_user_workflow_variables(snippet.id, user_id=current_user.id) diff --git a/api/controllers/console/workspace/endpoint.py b/api/controllers/console/workspace/endpoint.py index 9c5aa166bc0..821e30ee7e3 100644 --- a/api/controllers/console/workspace/endpoint.py +++ b/api/controllers/console/workspace/endpoint.py @@ -15,8 +15,11 @@ from pydantic import BaseModel, Field from controllers.common.schema import query_params_from_model, register_schema_models from controllers.console import console_ns from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, is_admin_or_owner_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user_id, @@ -166,6 +169,7 @@ class EndpointCollectionApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False) @account_initialization_required @with_current_user_id @with_current_tenant_id @@ -194,6 +198,7 @@ class DeprecatedEndpointCreateApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False) @account_initialization_required @with_current_user_id @with_current_tenant_id @@ -285,6 +290,7 @@ class EndpointItemApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False) @account_initialization_required @with_current_user_id @with_current_tenant_id @@ -304,6 +310,7 @@ class EndpointItemApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False) @account_initialization_required @with_current_user_id @with_current_tenant_id @@ -333,6 +340,7 @@ class DeprecatedEndpointDeleteApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False) @account_initialization_required @with_current_user_id @with_current_tenant_id @@ -363,6 +371,7 @@ class DeprecatedEndpointUpdateApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False) @account_initialization_required @with_current_user_id @with_current_tenant_id @@ -385,6 +394,7 @@ class EndpointEnableApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False) @account_initialization_required @with_current_user_id @with_current_tenant_id @@ -412,6 +422,7 @@ class EndpointDisableApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False) @account_initialization_required @with_current_user_id @with_current_tenant_id diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index 59fd3e2c5b5..b3230d77e69 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -32,16 +32,17 @@ from extensions.ext_redis import redis_client from fields.base import ResponseModel from fields.member_fields import AccountWithRole, AccountWithRoleList from libs.helper import extract_remote_ip -from libs.login import login_required +from libs.login import current_account_with_tenant, login_required from models.account import Account, TenantAccountJoin, TenantAccountRole from services.account_service import AccountService, RegisterService, TenantService +from services.enterprise import rbac_service as enterprise_rbac_service from services.errors.account import AccountAlreadyInTenantError from services.feature_service import FeatureService class MemberInvitePayload(BaseModel): emails: list[str] = Field(default_factory=list) - role: TenantAccountRole + role: str language: str | None = None @@ -107,6 +108,22 @@ def _is_role_enabled(role: TenantAccountRole | str, tenant_id: str) -> bool: return FeatureService.get_features(tenant_id=tenant_id, exclude_vector_space=True).dataset_operator_enabled +def _serialize_member_roles( + current_role: str | None, member_roles: list[enterprise_rbac_service.RBACRole] +) -> list[dict[str, str]]: + if dify_config.RBAC_ENABLED: + return [{"id": role.id, "name": role.name} for role in member_roles] + else: + if current_role: + return [{"id": current_role, "name": current_role}] + return [] + + +def _normalize_enum_value(value: object) -> str: + normalized = getattr(value, "value", value) + return str(normalized) if normalized is not None else "" + + def _normalize_invitee_emails(emails: list[str]) -> list[str]: return list(dict.fromkeys(email.lower() for email in emails)) @@ -164,11 +181,42 @@ class MemberListApi(Resource): @account_initialization_required @console_ns.response(200, "Success", console_ns.models[AccountWithRoleList.__name__]) @with_current_user - def get(self, current_user: Account): + def get(self, current_user: Account | None = None): + if current_user is None: + current_user, _ = current_account_with_tenant() if not current_user.current_tenant: raise ValueError("No current tenant") members = TenantService.get_tenant_members(current_user.current_tenant) - member_models = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True) + if dify_config.RBAC_ENABLED: + member_ids = [member.id for member in members] + member_roles = enterprise_rbac_service.RBACService.MemberRoles.batch_get( + str(current_user.current_tenant.id), + current_user.id, + member_ids, + ) + roles_map = {item.account_id: item.roles for item in member_roles} + else: + roles_map = {} + + serialized_members = [] + for member in members: + current_role = _normalize_enum_value(member.current_role) + serialized_members.append( + { + "id": member.id, + "name": member.name, + "email": member.email, + "avatar": member.avatar, + "last_login_at": member.last_login_at, + "last_active_at": member.last_active_at, + "created_at": member.created_at, + "role": current_role, + "roles": _serialize_member_roles(current_role, roles_map.get(member.id, [])), + "status": _normalize_enum_value(member.status), + } + ) + + member_models = TypeAdapter(list[AccountWithRole]).validate_python(serialized_members) response = AccountWithRoleList(accounts=member_models) return response.model_dump(mode="json"), 200 @@ -190,8 +238,11 @@ class MemberInviteEmailApi(Resource): invitee_emails = _normalize_invitee_emails(args.emails) invitee_role = args.role interface_language = args.language - if not TenantAccountRole.is_non_owner_role(invitee_role): - return {"code": "invalid-role", "message": "Invalid role"}, 400 + if not dify_config.RBAC_ENABLED: + if not TenantAccountRole.is_valid_role(invitee_role): + return {"code": "invalid-role", "message": "Invalid role"}, 400 + if not TenantAccountRole.is_non_owner_role(TenantAccountRole(invitee_role)): + return {"code": "invalid-role", "message": "Invalid role"}, 400 inviter = current_user if not inviter.current_tenant: raise ValueError("No current tenant") @@ -208,8 +259,9 @@ class MemberInviteEmailApi(Resource): tenant_id = inviter.current_tenant.id with redis_client.lock(f"workspace_member_invite:{tenant_id}", timeout=60): - new_member_count = _count_new_member_invites(tenant_id, invitee_emails) - _check_member_invite_limits(tenant_id, new_member_count) + if dify_config.ENTERPRISE_ENABLED is True or dify_config.BILLING_ENABLED is True: + new_member_count = _count_new_member_invites(tenant_id, invitee_emails) + _check_member_invite_limits(tenant_id, new_member_count) for invitee_email in invitee_emails: try: diff --git a/api/controllers/console/workspace/model_providers.py b/api/controllers/console/workspace/model_providers.py index 07c124890cf..8fda67f4ef8 100644 --- a/api/controllers/console/workspace/model_providers.py +++ b/api/controllers/console/workspace/model_providers.py @@ -9,8 +9,11 @@ from controllers.common.fields import BinaryFileResponse, SimpleResultResponse from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, is_admin_or_owner_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -166,6 +169,7 @@ class ModelProviderCredentialApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, current_tenant_id: str, provider: str): @@ -191,6 +195,7 @@ class ModelProviderCredentialApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def put(self, current_tenant_id: str, provider: str): @@ -217,6 +222,7 @@ class ModelProviderCredentialApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def delete(self, current_tenant_id: str, provider: str): @@ -238,6 +244,7 @@ class ModelProviderCredentialSwitchApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, current_tenant_id: str, provider: str): @@ -319,6 +326,7 @@ class PreferredProviderTypeUpdateApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, provider: str): diff --git a/api/controllers/console/workspace/models.py b/api/controllers/console/workspace/models.py index 2139d6f18e0..e82c0fbc2db 100644 --- a/api/controllers/console/workspace/models.py +++ b/api/controllers/console/workspace/models.py @@ -14,8 +14,11 @@ from controllers.common.schema import ( ) from controllers.console import console_ns from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, is_admin_or_owner_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -217,6 +220,7 @@ class DefaultModelApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str): @@ -263,6 +267,7 @@ class ModelProviderModelApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, provider: str): @@ -310,6 +315,7 @@ class ModelProviderModelApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_tenant_id def delete(self, tenant_id: str, provider: str): @@ -389,6 +395,7 @@ class ModelProviderModelCredentialApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, provider: str): @@ -421,6 +428,7 @@ class ModelProviderModelCredentialApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def put(self, current_tenant_id: str, provider: str): @@ -448,6 +456,7 @@ class ModelProviderModelCredentialApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def delete(self, current_tenant_id: str, provider: str): @@ -472,6 +481,7 @@ class ModelProviderModelCredentialSwitchApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, current_tenant_id: str, provider: str): @@ -498,6 +508,7 @@ class ModelProviderModelEnableApi(Resource): @login_required @account_initialization_required @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) def patch(self, tenant_id: str, provider: str): args = ParserDeleteModels.model_validate(console_ns.payload) @@ -519,6 +530,7 @@ class ModelProviderModelDisableApi(Resource): @login_required @account_initialization_required @with_current_tenant_id + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) def patch(self, tenant_id: str, provider: str): args = ParserDeleteModels.model_validate(console_ns.payload) diff --git a/api/controllers/console/workspace/plugin.py b/api/controllers/console/workspace/plugin.py index 94979e25b36..d599466002d 100644 --- a/api/controllers/console/workspace/plugin.py +++ b/api/controllers/console/workspace/plugin.py @@ -20,8 +20,11 @@ from controllers.common.schema import ( from controllers.console import console_ns from controllers.console.workspace import plugin_permission_required from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, is_admin_or_owner_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -975,6 +978,7 @@ class PluginFetchDynamicSelectOptionsApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -1005,6 +1009,7 @@ class PluginFetchDynamicSelectOptionsWithCredentialsApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id diff --git a/api/controllers/console/workspace/rbac.py b/api/controllers/console/workspace/rbac.py new file mode 100644 index 00000000000..1b213a4f741 --- /dev/null +++ b/api/controllers/console/workspace/rbac.py @@ -0,0 +1,939 @@ +from __future__ import annotations + +from enum import StrEnum +from typing import Any + +from flask import request +from flask_restx import Resource +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError, field_validator +from sqlalchemy import select +from werkzeug.exceptions import NotFound + +from configs import dify_config +from controllers.common.schema import register_response_schema_models +from controllers.console import console_ns +from controllers.console.wraps import RBACPermission, RBACResourceScope, rbac_permission_required +from core.db.session_factory import session_factory +from libs.login import current_account_with_tenant, login_required +from models import Account +from services.enterprise import rbac_service as svc + + +class _RBACRoleList(svc.Paginated[svc.RBACRole]): + pass + + +class _RBACRoleAccountList(svc.Paginated[svc.RBACRoleAccount]): + pass + + +class _AccessPolicyList(svc.Paginated[svc.AccessPolicy]): + pass + + +class _MembersInRoleList(svc.Paginated[svc.MembersInRole]): + pass + + +register_response_schema_models( + console_ns, + svc.PermissionCatalogResponse, + svc.RBACRole, + _RBACRoleList, + _RBACRoleAccountList, + _MembersInRoleList, + svc.AccessPolicy, + _AccessPolicyList, + svc.AccessPolicyBindingState, + svc.MyPermissionsResponse, + svc.AppAccessMatrix, + svc.DatasetAccessMatrix, + svc.WorkspaceAccessMatrix, + svc.ResourceWhitelist, + svc.ResourceUserAccessPoliciesResponse, + svc.ReplaceUserAccessPoliciesResponse, + svc.RoleBindingsResponse, + svc.MemberBindingsResponse, + svc.MemberRolesResponse, + svc.AccessMatrixItem, +) + +_LEGACY_ROLE_PERMISSION_KEYS: dict[str, list[str]] = { + # This is a compatibility projection from the pre-RBAC workspace roles into + # the 2.0 permission matrix documented in "权限整理2.0". It intentionally + # models the product-facing role surface for the new RBAC UI instead of the + # legacy backend's exact hard-authorization checks. + "owner": [ + *svc._LEGACY_WORKSPACE_OWNER_KEYS, + *svc._LEGACY_APP_OWNER_KEYS, + *svc._LEGACY_DATASET_OWNER_KEYS, + ], + "admin": [ + *svc._LEGACY_WORKSPACE_ADMIN_KEYS, + *svc._LEGACY_APP_ADMIN_KEYS, + *svc._LEGACY_DATASET_ADMIN_KEYS, + ], + "editor": [ + *svc._LEGACY_WORKSPACE_EDITOR_KEYS, + *svc._LEGACY_APP_EDITOR_KEYS, + *svc._LEGACY_DATASET_EDITOR_KEYS, + ], + "normal": [ + *svc._LEGACY_WORKSPACE_NORMAL_KEYS, + *svc._LEGACY_APP_NORMAL_KEYS, + ], + "dataset_operator": [ + *svc._LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS, + *svc._LEGACY_DATASET_DATASET_OPERATOR_KEYS, + ], +} + + +def _current_ids() -> tuple[str, str]: + """Return ``(tenant_id, account_id)`` for the authenticated user, or + raise a 404 when no tenant is associated with the session. + """ + + user, tenant_id = current_account_with_tenant() + if not tenant_id: + raise NotFound("Current workspace not found") + return tenant_id, user.id + + +def _payload(model: type[BaseModel]) -> Any: + """Validate the JSON body against ``model`` or raise ``ValidationError``. + + ``ValidationError`` bubbles up as HTTP 400 thanks to + ``controllers/common/helpers.py`` error handling. + """ + try: + return model.model_validate(console_ns.payload or {}) + except ValidationError as exc: + # Re-raise as-is so the upstream error handler renders a 400. + raise exc + + +def _dump(model: BaseModel) -> dict[str, Any]: + return model.model_dump(mode="json") + + +def _account_names_by_ids(account_ids: list[str]) -> dict[str, dict[str, str]]: + ids = sorted({account_id.strip() for account_id in account_ids if account_id and account_id.strip()}) + if not ids: + return {} + + with session_factory.create_session() as session: + rows = session.execute( + select(Account.id, Account.name, Account.avatar, Account.email).where(Account.id.in_(ids)) + ).all() + + return { + account_id: { + "name": name or "", + "avatar": avatar or "", + "email": email or "", + } + for account_id, name, avatar, email in rows + } + + +def _hydrate_access_matrix_account_names(items: list[svc.AccessMatrixItem]) -> None: + account_ids: list[str] = [] + for item in items: + for account in item.accounts: + account_id = account.account_id + if account_id and not account.account_name: + account_ids.append(account_id) + + account_names = _account_names_by_ids(account_ids) + if not account_names: + return + + for item in items: + for account in item.accounts: + account_id = str(account.account_id or "").strip() + if account_id and not account.account_name: + account.account_name = account_names.get(account_id, {}).get("name", "") + account.avatar = account_names.get(account_id, {}).get("avatar", "") + account.email = account_names.get(account_id, {}).get("email", "") + + +def _hydrate_resource_user_account_names(items: list[svc.ResourceUserAccessPolicies]) -> None: + account_names = _account_names_by_ids([item.account.account_id for item in items]) + for item in items: + account_id = item.account.account_id + if account_id and not item.account.account_name: + item.account.account_name = account_names.get(account_id, {}).get("name", "") + item.account.avatar = account_names.get(account_id, {}).get("avatar", "") + item.account.email = account_names.get(account_id, {}).get("email", "") + + +class _PaginationQuery(BaseModel): + model_config = ConfigDict(extra="ignore") + + page_number: int | None = Field(default=None, ge=1, validation_alias=AliasChoices("page", "page_number")) + results_per_page: int | None = Field( + default=None, ge=1, le=99999, validation_alias=AliasChoices("limit", "results_per_page") + ) + reverse: bool | None = None + + def to_inner_options(self) -> svc.ListOption: + return svc.ListOption.model_validate(self.model_dump()) + + +class _RolesListQuery(_PaginationQuery): + include_owner: int = Field(default=0, ge=0, le=1) + + +class CopyRoleParam(BaseModel): + copy_member: bool = True + + +def _pagination_options() -> svc.ListOption: + return _PaginationQuery.model_validate(request.args.to_dict(flat=True)).to_inner_options() + + +def _legacy_workspace_roles( + options: svc.ListOption | None = None, *, include_owner: int = 0 +) -> svc.Paginated[svc.RBACRole]: + """Return the built-in legacy workspace roles in the RBAC list shape. + + This keeps the new `/rbac/roles` endpoint compatible with the original + Dify role model when enterprise RBAC is disabled. + """ + + legacy_roles = [ + svc.RBACRole( + id=role_name, + tenant_id="", + type=svc.RBACRoleType.WORKSPACE.value, + category="global_system_default", + name=role_name, + description="", + is_builtin=True, + permission_keys=list(_LEGACY_ROLE_PERMISSION_KEYS[role_name]), + role_tag="owner" if role_name == "owner" else "", + ) + for role_name in ("owner", "admin", "editor", "normal", "dataset_operator") + ] + + if not include_owner: + legacy_roles = [r for r in legacy_roles if r.name != "owner"] + + page_number = options.page_number if options and options.page_number is not None else 1 + results_per_page = ( + options.results_per_page if options and options.results_per_page is not None else len(legacy_roles) + ) + reverse = options.reverse if options and options.reverse is not None else False + + ordered_roles = list(reversed(legacy_roles)) if reverse else legacy_roles + start = max(page_number - 1, 0) * results_per_page + end = start + results_per_page + paged_roles = ordered_roles[start:end] + total_count = len(legacy_roles) + total_pages = (total_count + results_per_page - 1) // results_per_page if results_per_page > 0 else 0 + + return svc.Paginated[svc.RBACRole]( + data=paged_roles, + pagination=svc.Pagination( + total_count=total_count, + per_page=results_per_page, + current_page=page_number, + total_pages=total_pages, + ), + ) + + +# --------------------------------------------------------------------------- +# Permission catalogs. +# --------------------------------------------------------------------------- + + +@console_ns.route("/workspaces/current/rbac/role-permissions/catalog") +class RBACWorkspaceCatalogApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.PermissionCatalogResponse.__name__]) + def get(self): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.Catalog.workspace(tenant_id, account_id)) + + +@console_ns.route("/workspaces/current/rbac/role-permissions/catalog/app") +class RBACAppCatalogApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.PermissionCatalogResponse.__name__]) + def get(self): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.Catalog.app(tenant_id, account_id)) + + +@console_ns.route("/workspaces/current/rbac/role-permissions/catalog/dataset") +class RBACDatasetCatalogApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.PermissionCatalogResponse.__name__]) + def get(self): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.Catalog.dataset(tenant_id, account_id)) + + +# --------------------------------------------------------------------------- +# Roles. +# --------------------------------------------------------------------------- + + +class _RoleUpsertRequest(BaseModel): + """Accepts the payload sent by the Create/Edit Role dialog.""" + + name: str + description: str = "" + permission_keys: list[str] = [] + + def to_mutation(self) -> svc.RoleMutation: + return svc.RoleMutation( + name=self.name, + description=self.description, + permission_keys=list(self.permission_keys), + ) + + +@console_ns.route("/workspaces/current/rbac/roles") +class RBACRolesApi(Resource): + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[_RBACRoleList.__name__]) + def get(self): + tenant_id, account_id = _current_ids() + query = _RolesListQuery.model_validate(request.args.to_dict(flat=True)) + options = query.to_inner_options() + if not dify_config.RBAC_ENABLED: + result = _legacy_workspace_roles(options, include_owner=query.include_owner) + else: + result = svc.RBACService.Roles.list( + tenant_id, account_id, include_owner=query.include_owner, options=options + ) + + return _dump(result) + + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(201, "Role created", console_ns.models[svc.RBACRole.__name__]) + def post(self): + tenant_id, account_id = _current_ids() + request = _payload(_RoleUpsertRequest) + role = svc.RBACService.Roles.create(tenant_id, account_id, request.to_mutation()) + return _dump(role), 201 + + +@console_ns.route("/workspaces/current/rbac/roles/") +class RBACRoleItemApi(Resource): + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[svc.RBACRole.__name__]) + def get(self, role_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.Roles.get(tenant_id, account_id, str(role_id))) + + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[svc.RBACRole.__name__]) + def put(self, role_id): + tenant_id, account_id = _current_ids() + request = _payload(_RoleUpsertRequest) + role = svc.RBACService.Roles.update(tenant_id, account_id, str(role_id), request.to_mutation()) + return _dump(role) + + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[svc.RBACRole.__name__]) + def delete(self, role_id): + tenant_id, account_id = _current_ids() + svc.RBACService.Roles.delete(tenant_id, account_id, str(role_id)) + return {"result": "success"} + + +@console_ns.route("/workspaces/current/rbac/roles//copy") +class RBACRoleCopyApi(Resource): + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(201, "Role copied", console_ns.models[svc.RBACRole.__name__]) + def post(self, role_id): + tenant_id, account_id = _current_ids() + request = _payload(CopyRoleParam) + role = svc.RBACService.Roles.copy(tenant_id, account_id, str(role_id), copy_member=request.copy_member) + return _dump(role), 201 + + +@console_ns.route("/workspaces/current/rbac/roles//members") +class RBACRoleMembersApi(Resource): + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[_RBACRoleAccountList.__name__]) + def get(self, role_id): + tenant_id, account_id = _current_ids() + return _dump( + svc.RBACService.Roles.members( + tenant_id, + account_id, + str(role_id), + options=_pagination_options(), + ) + ) + + +# --------------------------------------------------------------------------- +# Access policies (tenant-level permission sets). +# --------------------------------------------------------------------------- + + +class _AccessPolicyCreateRequest(BaseModel): + name: str + resource_type: svc.RBACResourceType + description: str = "" + permission_keys: list[str] = [] + + +class _AccessPolicyUpdateRequest(BaseModel): + name: str + description: str = "" + permission_keys: list[str] = [] + + +@console_ns.route("/workspaces/current/rbac/access-policies") +class RBACAccessPoliciesApi(Resource): + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[_AccessPolicyList.__name__]) + def get(self): + tenant_id, account_id = _current_ids() + # `resource_type` is exposed as a query argument so the UI can show + # only app-scoped or only dataset-scoped permission sets. + resource_type = request.args.get("resource_type") or None + return _dump( + svc.RBACService.AccessPolicies.list( + tenant_id, + account_id, + resource_type=resource_type, + options=_pagination_options(), + ) + ) + + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(201, "Policy created", console_ns.models[svc.AccessPolicy.__name__]) + def post(self): + tenant_id, account_id = _current_ids() + request = _payload(_AccessPolicyCreateRequest) + policy = svc.RBACService.AccessPolicies.create( + tenant_id, + account_id, + svc.AccessPolicyCreate( + name=request.name, + resource_type=request.resource_type, + description=request.description, + permission_keys=list(request.permission_keys), + ), + ) + return _dump(policy), 201 + + +@console_ns.route("/workspaces/current/rbac/access-policies/") +class RBACAccessPolicyItemApi(Resource): + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[svc.AccessPolicy.__name__]) + def get(self, policy_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.AccessPolicies.get(tenant_id, account_id, str(policy_id))) + + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[svc.AccessPolicy.__name__]) + def put(self, policy_id): + tenant_id, account_id = _current_ids() + request = _payload(_AccessPolicyUpdateRequest) + policy = svc.RBACService.AccessPolicies.update( + tenant_id, + account_id, + str(policy_id), + svc.AccessPolicyUpdate( + name=request.name, + description=request.description, + permission_keys=list(request.permission_keys), + ), + ) + return _dump(policy) + + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[svc.AccessPolicy.__name__]) + def delete(self, policy_id): + tenant_id, account_id = _current_ids() + svc.RBACService.AccessPolicies.delete(tenant_id, account_id, str(policy_id)) + return {"result": "success"} + + +@console_ns.route("/workspaces/current/rbac/access-policies//copy") +class RBACAccessPolicyCopyApi(Resource): + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(201, "Policy copied", console_ns.models[svc.AccessPolicy.__name__]) + def post(self, policy_id): + tenant_id, account_id = _current_ids() + policy = svc.RBACService.AccessPolicies.copy(tenant_id, account_id, str(policy_id)) + return _dump(policy), 201 + + +@console_ns.route("/workspaces/current/rbac/access-policy-bindings//lock") +class RBACAccessPolicyBindingLockApi(Resource): + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[svc.AccessPolicyBindingState.__name__]) + def put(self, binding_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.AccessPolicyBindings.lock(tenant_id, account_id, str(binding_id))) + + +@console_ns.route("/workspaces/current/rbac/access-policy-bindings//unlock") +class RBACAccessPolicyBindingUnlockApi(Resource): + @login_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + @console_ns.response(200, "Success", console_ns.models[svc.AccessPolicyBindingState.__name__]) + def put(self, binding_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.AccessPolicyBindings.unlock(tenant_id, account_id, str(binding_id))) + + +# --------------------------------------------------------------------------- +# Per-app access (App Access Config). +# --------------------------------------------------------------------------- + + +class _AccessScope(StrEnum): + ALL = "all" + SPECIFIC = "specific" + ONLY_ME = "only_me" + + +class _ResourceAccessScopeRequest(BaseModel): + scope: _AccessScope + + +class _ReplaceBindingsRequest(BaseModel): + role_ids: list[str] = Field(default_factory=list) + account_ids: list[str] = Field(default_factory=list) + + @field_validator("role_ids", "account_ids", mode="before") + @classmethod + def _coerce_bindings(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +class _DeleteMemberBindingsRequest(BaseModel): + account_ids: list[str] = Field(default_factory=list) + + @field_validator("account_ids", mode="before") + @classmethod + def _coerce_account_ids(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +@console_ns.route("/workspaces/current/rbac/my-permissions") +class RBACMyPermissionsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.MyPermissionsResponse.__name__]) + def get(self): + tenant_id, account_id = _current_ids() + return _dump( + svc.RBACService.MyPermissions.get( + tenant_id, + account_id, + app_id=request.args.get("app_id") or None, + dataset_id=request.args.get("dataset_id") or None, + ) + ) + + +@console_ns.route("/workspaces/current/rbac/apps//access-policy") +class RBACAppMatrixApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.AppAccessMatrix.__name__]) + def get(self, app_id): + tenant_id, account_id = _current_ids() + result = svc.RBACService.AppAccess.matrix(tenant_id, account_id, str(app_id)) + _hydrate_access_matrix_account_names(result.items) + return _dump(result) + + +@console_ns.route("/workspaces/current/rbac/apps//whitelist") +class RBACAppWhitelistApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.ResourceWhitelist.__name__]) + def get(self, app_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.AppAccess.whitelist(tenant_id, account_id, str(app_id))) + + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.ResourceWhitelist.__name__]) + def put(self, app_id): + tenant_id, account_id = _current_ids() + request = _payload(_ResourceAccessScopeRequest) + return _dump( + svc.RBACService.AppAccess.replace_whitelist( + tenant_id, + account_id, + str(app_id), + svc.ReplaceMemberBindings(scope=request.scope.value), + ) + ) + + +@console_ns.route("/workspaces/current/rbac/apps//user-access-policies") +class RBACAppUserAccessPoliciesApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.ResourceUserAccessPoliciesResponse.__name__]) + def get(self, app_id): + tenant_id, account_id = _current_ids() + result = svc.RBACService.AppAccess.user_access_policies(tenant_id, account_id, str(app_id)) + _hydrate_resource_user_account_names(result.data) + return _dump(result) + + +@console_ns.route("/workspaces/current/rbac/apps//users//access-policies") +class RBACAppUserAccessPolicyAssignmentApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.ReplaceUserAccessPoliciesResponse.__name__]) + def put(self, app_id, target_account_id): + tenant_id, account_id = _current_ids() + payload = _payload(svc.ReplaceUserAccessPolicies) + return _dump( + svc.RBACService.AppAccess.replace_user_access_policies( + tenant_id, + account_id, + str(app_id), + str(target_account_id), + payload, + ) + ) + + +@console_ns.route("/workspaces/current/rbac/apps//access-policies//role-bindings") +class RBACAppRoleBindingsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.RoleBindingsResponse.__name__]) + def get(self, app_id, policy_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.AppAccess.list_role_bindings(tenant_id, account_id, str(app_id), str(policy_id))) + + +@console_ns.route("/workspaces/current/rbac/apps//access-policies//member-bindings") +class RBACAppMemberBindingsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.MemberBindingsResponse.__name__]) + def get(self, app_id, policy_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.AppAccess.list_member_bindings(tenant_id, account_id, str(app_id), str(policy_id))) + + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.MemberBindingsResponse.__name__]) + def delete(self, app_id, policy_id): + tenant_id, account_id = _current_ids() + request_body = _payload(_DeleteMemberBindingsRequest) + svc.RBACService.AppAccess.delete_member_bindings( + tenant_id, + account_id, + str(app_id), + str(policy_id), + svc.DeleteMemberBindings(account_ids=request_body.account_ids), + ) + return {"result": "success"} + + +# --------------------------------------------------------------------------- +# Per-dataset access (Knowledge Base Access Config). +# --------------------------------------------------------------------------- + + +@console_ns.route("/workspaces/current/rbac/datasets//access-policy") +class RBACDatasetMatrixApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.DatasetAccessMatrix.__name__]) + def get(self, dataset_id): + tenant_id, account_id = _current_ids() + result = svc.RBACService.DatasetAccess.matrix(tenant_id, account_id, str(dataset_id)) + _hydrate_access_matrix_account_names(result.items) + return _dump(result) + + +@console_ns.route("/workspaces/current/rbac/datasets//whitelist") +class RBACDatasetWhitelistApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.ResourceWhitelist.__name__]) + def get(self, dataset_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.DatasetAccess.whitelist(tenant_id, account_id, str(dataset_id))) + + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.ResourceWhitelist.__name__]) + def put(self, dataset_id): + tenant_id, account_id = _current_ids() + request = _payload(_ResourceAccessScopeRequest) + return _dump( + svc.RBACService.DatasetAccess.replace_whitelist( + tenant_id, + account_id, + str(dataset_id), + svc.ReplaceMemberBindings(scope=request.scope.value), + ) + ) + + +@console_ns.route("/workspaces/current/rbac/datasets//user-access-policies") +class RBACDatasetUserAccessPoliciesApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.ResourceUserAccessPoliciesResponse.__name__]) + def get(self, dataset_id): + tenant_id, account_id = _current_ids() + result = svc.RBACService.DatasetAccess.user_access_policies(tenant_id, account_id, str(dataset_id)) + _hydrate_resource_user_account_names(result.data) + return _dump(result) + + +@console_ns.route("/workspaces/current/rbac/datasets//users//access-policies") +class RBACDatasetUserAccessPolicyAssignmentApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.ReplaceUserAccessPoliciesResponse.__name__]) + def put(self, dataset_id, target_account_id): + tenant_id, account_id = _current_ids() + payload = _payload(svc.ReplaceUserAccessPolicies) + return _dump( + svc.RBACService.DatasetAccess.replace_user_access_policies( + tenant_id, + account_id, + str(dataset_id), + str(target_account_id), + payload, + ) + ) + + +@console_ns.route("/workspaces/current/rbac/datasets//access-policies//role-bindings") +class RBACDatasetRoleBindingsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.RoleBindingsResponse.__name__]) + def get(self, dataset_id, policy_id): + tenant_id, account_id = _current_ids() + return _dump( + svc.RBACService.DatasetAccess.list_role_bindings(tenant_id, account_id, str(dataset_id), str(policy_id)) + ) + + +@console_ns.route( + "/workspaces/current/rbac/datasets//access-policies//member-bindings" +) +class RBACDatasetMemberBindingsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.MemberBindingsResponse.__name__]) + def get(self, dataset_id, policy_id): + tenant_id, account_id = _current_ids() + return _dump( + svc.RBACService.DatasetAccess.list_member_bindings(tenant_id, account_id, str(dataset_id), str(policy_id)) + ) + + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.MemberBindingsResponse.__name__]) + def delete(self, dataset_id, policy_id): + tenant_id, account_id = _current_ids() + request_body = _payload(_DeleteMemberBindingsRequest) + svc.RBACService.DatasetAccess.delete_member_bindings( + tenant_id, + account_id, + str(dataset_id), + str(policy_id), + svc.DeleteMemberBindings(account_ids=request_body.account_ids), + ) + return {"result": "success"} + + +# --------------------------------------------------------------------------- +# Workspace-level access (Settings > Access Rules). +# --------------------------------------------------------------------------- + + +@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policy") +class RBACWorkspaceAppMatrixApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.WorkspaceAccessMatrix.__name__]) + def get(self): + tenant_id, account_id = _current_ids() + options = _pagination_options() + result = svc.RBACService.WorkspaceAccess.app_matrix(tenant_id, account_id, options=options) + _hydrate_access_matrix_account_names(result.items) + return _dump(result) + + +@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policies//role-bindings") +class RBACWorkspaceAppRoleBindingsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.RoleBindingsResponse.__name__]) + def get(self, policy_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.WorkspaceAccess.list_app_role_bindings(tenant_id, account_id, str(policy_id))) + + +@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policies//bindings") +class RBACWorkspaceAppBindingsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.AccessMatrixItem.__name__]) + def put(self, policy_id): + tenant_id, account_id = _current_ids() + request = _payload(_ReplaceBindingsRequest) + return _dump( + svc.RBACService.WorkspaceAccess.replace_app_bindings( + tenant_id, + account_id, + str(policy_id), + svc.ReplaceBindings(role_ids=list(request.role_ids), account_ids=list(request.account_ids)), + ) + ) + + +@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policies//member-bindings") +class RBACWorkspaceAppMemberBindingsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.MemberBindingsResponse.__name__]) + def get(self, policy_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.WorkspaceAccess.list_app_member_bindings(tenant_id, account_id, str(policy_id))) + + +@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policy") +class RBACWorkspaceDatasetMatrixApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.WorkspaceAccessMatrix.__name__]) + def get(self): + tenant_id, account_id = _current_ids() + options = _pagination_options() + result = svc.RBACService.WorkspaceAccess.dataset_matrix(tenant_id, account_id, options=options) + _hydrate_access_matrix_account_names(result.items) + return _dump(result) + + +@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policies//role-bindings") +class RBACWorkspaceDatasetRoleBindingsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.RoleBindingsResponse.__name__]) + def get(self, policy_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.WorkspaceAccess.list_dataset_role_bindings(tenant_id, account_id, str(policy_id))) + + +@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policies//bindings") +class RBACWorkspaceDatasetBindingsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.AccessMatrixItem.__name__]) + def put(self, policy_id): + tenant_id, account_id = _current_ids() + request = _payload(_ReplaceBindingsRequest) + return _dump( + svc.RBACService.WorkspaceAccess.replace_dataset_bindings( + tenant_id, + account_id, + str(policy_id), + svc.ReplaceBindings(role_ids=list(request.role_ids), account_ids=list(request.account_ids)), + ) + ) + + +@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policies//member-bindings") +class RBACWorkspaceDatasetMemberBindingsApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.MemberBindingsResponse.__name__]) + def get(self, policy_id): + tenant_id, account_id = _current_ids() + return _dump( + svc.RBACService.WorkspaceAccess.list_dataset_member_bindings(tenant_id, account_id, str(policy_id)) + ) + + +# --------------------------------------------------------------------------- +# Member ↔ role bindings (Settings > Members > Assign roles). +# --------------------------------------------------------------------------- + + +class _ReplaceMemberRolesRequest(BaseModel): + role_ids: list[str] = [] + + @field_validator("role_ids", mode="before") + @classmethod + def _coerce_role_ids(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +@console_ns.route("/workspaces/current/rbac/members//rbac-roles") +class RBACMemberRolesApi(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.MemberRolesResponse.__name__]) + def get(self, member_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.MemberRoles.get(tenant_id, account_id, str(member_id))) + + @login_required + @console_ns.response(200, "Success", console_ns.models[svc.MemberRolesResponse.__name__]) + def put(self, member_id): + tenant_id, account_id = _current_ids() + request = _payload(_ReplaceMemberRolesRequest) + return _dump( + svc.RBACService.MemberRoles.replace( + tenant_id, + account_id, + str(member_id), + role_ids=list(request.role_ids), + ) + ) + + +@console_ns.route("/workspaces/current/rbac/roles//members") +class ListMembersByRole(Resource): + @login_required + @console_ns.response(200, "Success", console_ns.models[_MembersInRoleList.__name__]) + def get(self, role_id): + tenant_id, account_id = _current_ids() + return _dump( + svc.RBACService.Roles.list_members_by_role(tenant_id, role_id=role_id, options=_pagination_options()) + ) diff --git a/api/controllers/console/workspace/snippets.py b/api/controllers/console/workspace/snippets.py index 7fcca1f79e8..e8f0b228c8b 100644 --- a/api/controllers/console/workspace/snippets.py +++ b/api/controllers/console/workspace/snippets.py @@ -21,8 +21,11 @@ from controllers.console.snippets.payloads import ( UpdateSnippetPayload, ) from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -151,6 +154,9 @@ class CustomizedSnippetsApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) @with_current_user @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account): @@ -213,6 +219,9 @@ class CustomizedSnippetDetailApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) @with_current_user @with_current_tenant_id def patch(self, current_tenant_id: str, current_user: Account, snippet_id: str): @@ -257,6 +266,7 @@ class CustomizedSnippetDetailApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False) @with_current_tenant_id def delete(self, current_tenant_id: str, snippet_id: str): """Delete customized snippet.""" @@ -292,6 +302,9 @@ class CustomizedSnippetExportApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) @with_current_tenant_id def get(self, current_tenant_id: str, snippet_id: str): """Export snippet as DSL.""" @@ -337,6 +350,9 @@ class CustomizedSnippetImportApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) @with_current_user def post(self, current_user: Account): """Import snippet from DSL.""" @@ -375,6 +391,9 @@ class CustomizedSnippetImportConfirmApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) @with_current_user def post(self, current_user: Account, import_id: str): """Confirm a pending snippet import.""" @@ -403,6 +422,9 @@ class CustomizedSnippetCheckDependenciesApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) @with_current_tenant_id def get(self, current_tenant_id: str, snippet_id: str): """Check dependencies for a snippet.""" @@ -433,6 +455,9 @@ class CustomizedSnippetUseCountIncrementApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False + ) @with_current_tenant_id def post(self, current_tenant_id: str, snippet_id: str): """Increment snippet use count when it is inserted into a workflow.""" diff --git a/api/controllers/console/workspace/tool_providers.py b/api/controllers/console/workspace/tool_providers.py index 94600f38860..9a92571594c 100644 --- a/api/controllers/console/workspace/tool_providers.py +++ b/api/controllers/console/workspace/tool_providers.py @@ -14,9 +14,12 @@ from controllers.common.fields import BinaryFileResponse, RedirectResponse, Simp from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, enterprise_license_required, is_admin_or_owner_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -369,6 +372,7 @@ class ToolBuiltinProviderDeleteApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, provider: str): @@ -411,6 +415,7 @@ class ToolBuiltinProviderUpdateApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -471,6 +476,7 @@ class ToolApiProviderAddApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.TOOL_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -540,6 +546,7 @@ class ToolApiProviderUpdateApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.TOOL_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -568,6 +575,7 @@ class ToolApiProviderDeleteApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.TOOL_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -659,6 +667,7 @@ class ToolWorkflowProviderCreateApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.TOOL_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -686,6 +695,7 @@ class ToolWorkflowProviderUpdateApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.TOOL_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -713,6 +723,7 @@ class ToolWorkflowProviderDeleteApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.TOOL_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -858,6 +869,7 @@ class ToolPluginOAuthApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -959,6 +971,7 @@ class ToolBuiltinProviderSetDefaultApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, current_tenant_id: str, provider: str): @@ -975,6 +988,7 @@ class ToolOAuthCustomClient(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, provider: str): diff --git a/api/controllers/console/workspace/trigger_providers.py b/api/controllers/console/workspace/trigger_providers.py index ff1a5c18bd9..b960a5eb9c3 100644 --- a/api/controllers/console/workspace/trigger_providers.py +++ b/api/controllers/console/workspace/trigger_providers.py @@ -27,9 +27,12 @@ from services.trigger.trigger_subscription_operator_service import TriggerSubscr from .. import console_ns from ..wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, edit_permission_required, is_admin_or_owner_required, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -143,6 +146,7 @@ class TriggerSubscriptionListApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -172,6 +176,7 @@ class TriggerSubscriptionBuilderCreateApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -201,6 +206,7 @@ class TriggerSubscriptionBuilderGetApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required def get(self, provider: str, subscription_builder_id: str): """Get a subscription instance for a trigger provider""" @@ -218,6 +224,7 @@ class TriggerSubscriptionBuilderVerifyApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -250,6 +257,7 @@ class TriggerSubscriptionBuilderUpdateApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, provider: str, subscription_builder_id: str): @@ -282,6 +290,7 @@ class TriggerSubscriptionBuilderLogsApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required def get(self, provider: str, subscription_builder_id: str): """Get the request logs for a subscription instance for a trigger provider""" @@ -302,6 +311,7 @@ class TriggerSubscriptionBuilderBuildApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id @@ -336,6 +346,7 @@ class TriggerSubscriptionUpdateApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, subscription_id: str): @@ -393,6 +404,7 @@ class TriggerSubscriptionDeleteApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, subscription_id: str): @@ -581,6 +593,7 @@ class TriggerOAuthClientManageApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_tenant_id def get(self, tenant_id: str, provider: str): @@ -626,6 +639,7 @@ class TriggerOAuthClientManageApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_tenant_id def post(self, tenant_id: str, provider: str): @@ -650,6 +664,7 @@ class TriggerOAuthClientManageApi(Resource): @setup_required @login_required @is_admin_or_owner_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) @with_current_tenant_id @@ -678,6 +693,7 @@ class TriggerSubscriptionVerifyApi(Resource): @setup_required @login_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False) @account_initialization_required @with_current_user @with_current_tenant_id diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index 1c0f210123a..017793ffe0b 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -9,9 +9,14 @@ from typing import Any, Concatenate, overload from flask import abort, request from pydantic import BaseModel, ValidationError from sqlalchemy import select -from werkzeug.exceptions import UnprocessableEntity +from werkzeug.exceptions import Forbidden, UnprocessableEntity from configs import dify_config +from controllers.common.wraps import ( + RBACPermission, + RBACResourceScope, + rbac_permission_required, +) from controllers.console.auth.error import AuthenticationFailedError, EmailCodeError from controllers.console.workspace.error import AccountNotInitializedError from enums.cloud_plan import CloudPlan @@ -28,6 +33,10 @@ from services.operation_service import OperationService, UtmInfo from .error import NotInitValidateError, NotSetupError, UnauthorizedAndForceLogout +# Re-exported so controllers can import the RBAC enums and decorator alongside +# other console wraps from this module. +__all__ = ["RBACPermission", "RBACResourceScope", "rbac_permission_required"] + # Field names for decryption FIELD_NAME_PASSWORD = "password" FIELD_NAME_CODE = "code" @@ -335,15 +344,15 @@ def knowledge_pipeline_publish_enabled[**P, R](view: Callable[P, R]) -> Callable def edit_permission_required[**P, R](f: Callable[P, R]) -> Callable[P, R]: @wraps(f) def decorated_function(*args: P.args, **kwargs: P.kwargs): - from werkzeug.exceptions import Forbidden from libs.login import current_user - user = current_user._get_current_object() # type: ignore - if not isinstance(user, Account): - raise Forbidden() - if not current_user.has_edit_permission: - raise Forbidden() + if not dify_config.RBAC_ENABLED: + user = current_user._get_current_object() # type: ignore + if not isinstance(user, Account): + raise Forbidden() + if not current_user.has_edit_permission: + raise Forbidden() return f(*args, **kwargs) return decorated_function @@ -352,13 +361,13 @@ def edit_permission_required[**P, R](f: Callable[P, R]) -> Callable[P, R]: def is_admin_or_owner_required[**P, R](f: Callable[P, R]) -> Callable[P, R]: @wraps(f) def decorated_function(*args: P.args, **kwargs: P.kwargs): - from werkzeug.exceptions import Forbidden from libs.login import current_user - user = current_user._get_current_object() - if not isinstance(user, Account) or not user.is_admin_or_owner: - raise Forbidden() + if not dify_config.RBAC_ENABLED: + user = current_user._get_current_object() + if not isinstance(user, Account) or not user.is_admin_or_owner: + raise Forbidden() return f(*args, **kwargs) return decorated_function diff --git a/api/controllers/service_api/dataset/dataset.py b/api/controllers/service_api/dataset/dataset.py index c8f95f3acd4..292c39f69bc 100644 --- a/api/controllers/service_api/dataset/dataset.py +++ b/api/controllers/service_api/dataset/dataset.py @@ -1,4 +1,4 @@ -from typing import Annotated, Literal, override +from typing import Annotated, Any, Literal, override from uuid import UUID from flask import request @@ -15,6 +15,7 @@ from pydantic import ( from werkzeug.exceptions import Forbidden, NotFound import services +from configs import dify_config from controllers.common.fields import SimpleResultResponse from controllers.common.schema import ( query_params_from_model, @@ -84,6 +85,32 @@ PartialMemberList = Annotated[ ] +_SERVICE_DATASET_DETAIL_EXCLUDE = {"permission_keys"} +_SERVICE_DATASET_LIST_EXCLUDE = {"data": {"__all__": _SERVICE_DATASET_DETAIL_EXCLUDE}} + + +def _dump_service_dataset_detail(dataset: Any) -> dict[str, Any]: + return DatasetDetailResponse.model_validate(dataset, from_attributes=True).model_dump( + mode="json", + exclude=_SERVICE_DATASET_DETAIL_EXCLUDE, + ) + + +def _dump_service_dataset_list(response: dict[str, Any]) -> dict[str, Any]: + return DatasetListResponse.model_validate(response).model_dump( + mode="json", + exclude=_SERVICE_DATASET_LIST_EXCLUDE, + ) + + +def _dump_service_dataset_with_partial_members(data: dict[str, Any]) -> dict[str, Any]: + exclude: set[str] = set(_SERVICE_DATASET_DETAIL_EXCLUDE) + if "partial_member_list" not in data: + exclude.add("partial_member_list") + + return DatasetDetailWithPartialMembersResponse.model_validate(data).model_dump(mode="json", exclude=exclude) + + class DatasetCreatePayload(BaseModel): name: str = Field(..., min_length=1, max_length=40, description="Name of the knowledge base.") description: str = Field(default="", description="Description of the knowledge base.", max_length=400) @@ -383,7 +410,14 @@ class DatasetListApi(DatasetApiResource): # provider = request.args.get("provider", default="vendor") datasets, total = DatasetService.get_datasets( - query.page, query.limit, tenant_id, current_user, query.keyword, query.tag_ids, query.include_all + query.page, + query.limit, + db.session, + tenant_id, + current_user, + query.keyword, + query.tag_ids, + query.include_all, ) # check embedding setting assert isinstance(current_user, Account) @@ -398,7 +432,7 @@ class DatasetListApi(DatasetApiResource): for embedding_model in embedding_models: model_names.append(f"{embedding_model.model}:{embedding_model.provider.provider}") - data = [dump_response(DatasetDetailResponse, dataset) for dataset in datasets] + data = [_dump_service_dataset_detail(dataset) for dataset in datasets] for item in data: if item["indexing_technique"] == IndexTechniqueType.HIGH_QUALITY and item["embedding_model_provider"]: item["embedding_model_provider"] = str(ModelProviderID(item["embedding_model_provider"])) @@ -416,7 +450,7 @@ class DatasetListApi(DatasetApiResource): "total": total, "page": query.page, } - return dump_response(DatasetListResponse, response), 200 + return _dump_service_dataset_list(response), 200 @service_api_ns.doc( summary="Create an Empty Knowledge Base", @@ -489,7 +523,7 @@ class DatasetListApi(DatasetApiResource): except services.errors.dataset.DatasetNameDuplicateError: raise DatasetNameDuplicateError() - return dump_response(DatasetDetailResponse, dataset), 200 + return _dump_service_dataset_detail(dataset), 200 @service_api_ns.route("/datasets/") @@ -534,7 +568,7 @@ class DatasetApi(DatasetApiResource): DatasetService.check_dataset_permission(dataset, current_user) except services.errors.account.NoPermissionError as e: raise Forbidden(str(e)) - data = dump_response(DatasetDetailResponse, dataset) + data = _dump_service_dataset_detail(dataset) # check embedding setting assert isinstance(current_user, Account) cid = current_user.current_tenant_id @@ -566,13 +600,7 @@ class DatasetApi(DatasetApiResource): part_users_list = DatasetPermissionService.get_dataset_partial_member_list(dataset_id_str) data.update({"partial_member_list": part_users_list}) - return ( - DatasetDetailWithPartialMembersResponse.model_validate(data).model_dump( - mode="json", - exclude={"partial_member_list"} if "partial_member_list" not in data else set(), - ), - 200, - ) + return _dump_service_dataset_with_partial_members(data), 200 @service_api_ns.doc( summary="Update Knowledge Base", @@ -641,20 +669,21 @@ class DatasetApi(DatasetApiResource): retrieval_model.reranking_model.reranking_model_name, ) - # The role of the current user in the ta table must be admin, owner, editor, or dataset_operator - DatasetPermissionService.check_permission( - current_user, - dataset, - str(payload.permission) if payload.permission else None, - payload.partial_member_list, - ) + if not dify_config.RBAC_ENABLED: + # The role of the current user in the ta table must be admin, owner, editor, or dataset_operator + DatasetPermissionService.check_permission( + current_user, + dataset, + str(payload.permission) if payload.permission else None, + payload.partial_member_list, + ) dataset = DatasetService.update_dataset(dataset_id_str, update_data, current_user) if dataset is None: raise NotFound("Dataset not found.") - result_data = dump_response(DatasetDetailResponse, dataset) + result_data = _dump_service_dataset_detail(dataset) assert isinstance(current_user, Account) tenant_id = current_user.current_tenant_id @@ -667,7 +696,7 @@ class DatasetApi(DatasetApiResource): partial_member_list = DatasetPermissionService.get_dataset_partial_member_list(dataset_id_str) result_data.update({"partial_member_list": partial_member_list}) - return DatasetDetailWithPartialMembersResponse.model_validate(result_data).model_dump(mode="json"), 200 + return _dump_service_dataset_with_partial_members(result_data), 200 @service_api_ns.doc( summary="Delete Knowledge Base", diff --git a/api/core/rbac/entities.py b/api/core/rbac/entities.py index 5389e16eefc..7f08a530f57 100644 --- a/api/core/rbac/entities.py +++ b/api/core/rbac/entities.py @@ -25,6 +25,7 @@ class RBACPermission(StrEnum): APP_CREATE_AND_MANAGEMENT = "app_create_and_management" APP_RELEASE_AND_VERSION = "app_release_and_version" APP_IMPORT_EXPORT_DSL = "app_import_export_dsl" + APP_EDIT = "app_edit" APP_MONITOR = "app_monitor" APP_DELETE = "app_delete" @@ -33,5 +34,22 @@ class RBACPermission(StrEnum): DATASET_CREATE_AND_MANAGEMENT = "dataset_create_and_management" DATASET_PIPELINE_TEST = "dataset_pipeline_test" DATASET_DOCUMENT_DOWNLOAD = "dataset_document_download" + DATASET_API_KEY_MANAGE = "dataset_api_key_manage" + DATASET_EXTERNAL_CONNECT = "dataset_external_connect" + DATASET_IMPORT_EXPORT_DSL = "dataset_import_export_dsl" WORKSPACE_ROLE_MANAGE = "workspace_role_manage" + + SNIPPETS_CREATE_AND_MODIFY = "snippets_create_and_modify" + SNIPPETS_MANAGE = "snippets_management" + + PLUGIN_INSTALL = "plugin_install" + PLUGIN_PREFERENCES = "plugin_preferences" + PLUGIN_MANAGE = "plugin_manage" + PLUGIN_DEBUG = "plugin_debug" + + CREDENTIAL_USE = "credential_use" + CREDENTIAL_MANAGE = "credential_manage" + + TOOL_MANAGE = "tool_manage" + MCP_MANAGE = "mcp_manage" diff --git a/api/extensions/ext_commands.py b/api/extensions/ext_commands.py index 4d60bdb5f65..6cd4b08b900 100644 --- a/api/extensions/ext_commands.py +++ b/api/extensions/ext_commands.py @@ -26,6 +26,7 @@ def init_app(app: DifyApp): install_plugins, install_rag_pipeline_plugins, migrate_data_for_plugin, + migrate_member_roles_to_rbac, migrate_oss, migration_data_wizard, old_metadata_migration, @@ -54,6 +55,7 @@ def init_app(app: DifyApp): upgrade_db, fix_app_site_missing, migrate_data_for_plugin, + migrate_member_roles_to_rbac, backfill_plugin_auto_upgrade, extract_plugins, extract_unique_plugins, diff --git a/api/fields/app_fields.py b/api/fields/app_fields.py index 9b197da4433..96d8fbdf34c 100644 --- a/api/fields/app_fields.py +++ b/api/fields/app_fields.py @@ -100,6 +100,7 @@ app_detail_fields = { "updated_at": TimestampField, "access_mode": fields.String, "tags": fields.List(fields.Nested(tag_fields)), + "permission_keys": fields.List(fields.String()), } prompt_config_fields = { @@ -137,6 +138,7 @@ app_partial_fields = { "create_user_name": fields.String, "author_name": fields.String, "has_draft_trigger": fields.Boolean, + "permission_keys": fields.List(fields.String()), } @@ -217,6 +219,7 @@ app_detail_fields_with_site = { "deleted_tools": fields.List(fields.Nested(deleted_tool_fields)), "access_mode": fields.String, "tags": fields.List(fields.Nested(tag_fields)), + "permission_keys": fields.List(fields.String()), "site": fields.Nested(site_fields), } diff --git a/api/fields/dataset_fields.py b/api/fields/dataset_fields.py index 35a22ea4044..ea506d2a7e4 100644 --- a/api/fields/dataset_fields.py +++ b/api/fields/dataset_fields.py @@ -15,6 +15,7 @@ dataset_fields = { "indexing_technique": fields.String, "created_by": fields.String, "created_at": TimestampField, + "permission_keys": fields.List(fields.String()), } @@ -143,6 +144,7 @@ dataset_detail_fields = { "total_available_documents": fields.Integer, "enable_api": fields.Boolean, "is_multimodal": fields.Boolean, + "permission_keys": fields.List(fields.String()), } @@ -267,6 +269,8 @@ class DatasetDetailResponse(ResponseModel): total_available_documents: int enable_api: bool is_multimodal: bool + permission_keys: list[str] = Field(default_factory=list) + maintainer: str | None = None @field_validator("created_at", "updated_at", mode="before") @classmethod diff --git a/api/fields/member_fields.py b/api/fields/member_fields.py index 7ae5e3b652b..9bbcbef8429 100644 --- a/api/fields/member_fields.py +++ b/api/fields/member_fields.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime from flask_restx import fields -from pydantic import computed_field, field_validator +from pydantic import Field, computed_field, field_validator from fields.base import ResponseModel from libs.helper import build_avatar_url, to_timestamp @@ -56,6 +56,7 @@ class AccountWithRole(_AccountAvatar): last_active_at: int | None = None created_at: int | None = None role: str + roles: list[dict[str, str]] = Field(default_factory=list) status: str @field_validator("last_login_at", "last_active_at", "created_at", mode="before") diff --git a/api/libs/oauth_bearer.py b/api/libs/oauth_bearer.py index 7433c6c177a..36de4b85ae0 100644 --- a/api/libs/oauth_bearer.py +++ b/api/libs/oauth_bearer.py @@ -490,7 +490,8 @@ def check_workspace_membership( account_id: uuid.UUID | str, tenant_id: str, token_hash: str, - membership_cache: dict[str, bool], + membership_cache: dict[str, bool] | None = None, + cached_verdicts: dict[str, bool] | None = None, ) -> None: """Layer-0 enforcement core. Raises `Forbidden` on deny, returns on allow. @@ -499,7 +500,8 @@ def check_workspace_membership( short-circuiting on EE / SSO subjects before invoking — this function runs the membership + active-status checks unconditionally. """ - cached = membership_cache.get(tenant_id) + cache = membership_cache if membership_cache is not None else cached_verdicts or {} + cached = cache.get(tenant_id) if cached is True: return if cached is False: diff --git a/api/migrations/versions/2026_06_15_1200-a7c4e9d2f681_add_resource_maintainers.py b/api/migrations/versions/2026_06_15_1200-a7c4e9d2f681_add_resource_maintainers.py new file mode 100644 index 00000000000..06731d5c42e --- /dev/null +++ b/api/migrations/versions/2026_06_15_1200-a7c4e9d2f681_add_resource_maintainers.py @@ -0,0 +1,41 @@ +"""add resource maintainers + +Revision ID: a7c4e9d2f681 +Revises: 9f4b7c2d1a80 +Create Date: 2026-06-15 12:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +import models.types + +# revision identifiers, used by Alembic. +revision = "a7c4e9d2f681" +down_revision = "d2f1a4b8c3e0" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("apps", schema=None) as batch_op: + batch_op.add_column(sa.Column("maintainer", models.types.StringUUID(), nullable=True)) + batch_op.create_index("app_tenant_maintainer_idx", ["tenant_id", "maintainer"], unique=False) + + with op.batch_alter_table("datasets", schema=None) as batch_op: + batch_op.add_column(sa.Column("maintainer", models.types.StringUUID(), nullable=True)) + batch_op.create_index("dataset_tenant_maintainer_idx", ["tenant_id", "maintainer"], unique=False) + + op.execute(sa.text("UPDATE apps SET maintainer = created_by WHERE maintainer IS NULL")) + op.execute(sa.text("UPDATE datasets SET maintainer = created_by WHERE maintainer IS NULL")) + + +def downgrade() -> None: + with op.batch_alter_table("datasets", schema=None) as batch_op: + batch_op.drop_index("dataset_tenant_maintainer_idx") + batch_op.drop_column("maintainer") + + with op.batch_alter_table("apps", schema=None) as batch_op: + batch_op.drop_index("app_tenant_maintainer_idx") + batch_op.drop_column("maintainer") diff --git a/api/models/account.py b/api/models/account.py index 66766693a5b..df152e1783c 100644 --- a/api/models/account.py +++ b/api/models/account.py @@ -11,6 +11,8 @@ from sqlalchemy import DateTime, String, func, select from sqlalchemy.orm import Mapped, Session, mapped_column from typing_extensions import deprecated +from configs import dify_config + from .base import TypeBase from .engine import db from .types import EnumText, LongText, StringUUID @@ -187,10 +189,14 @@ class Account(UserMixin, TypeBase): # check current_user.current_tenant.current_role in ['admin', 'owner'] @property def is_admin_or_owner(self): + if dify_config.RBAC_ENABLED: + return True return TenantAccountRole.is_privileged_role(self.role) @property def is_admin(self): + if dify_config.RBAC_ENABLED: + return True return TenantAccountRole.is_admin_role(self.role) @property @@ -216,14 +222,20 @@ class Account(UserMixin, TypeBase): - `ADMIN` - `EDITOR` """ + if dify_config.RBAC_ENABLED: + return True return TenantAccountRole.is_editing_role(self.role) @property def is_dataset_editor(self): + if dify_config.RBAC_ENABLED: + return True return TenantAccountRole.is_dataset_edit_role(self.role) @property def is_dataset_operator(self): + if dify_config.RBAC_ENABLED: + return True return self.role == TenantAccountRole.DATASET_OPERATOR diff --git a/api/models/dataset.py b/api/models/dataset.py index 1644551925b..998bc02ee85 100644 --- a/api/models/dataset.py +++ b/api/models/dataset.py @@ -167,6 +167,7 @@ class Dataset(Base): __table_args__ = ( sa.PrimaryKeyConstraint("id", name="dataset_pkey"), sa.Index("dataset_tenant_idx", "tenant_id"), + sa.Index("dataset_tenant_maintainer_idx", "tenant_id", "maintainer"), adjusted_json_index("retrieval_model_idx", "retrieval_model"), ) @@ -188,6 +189,7 @@ class Dataset(Base): indexing_technique: Mapped[IndexTechniqueType | None] = mapped_column(EnumText(IndexTechniqueType, length=255)) index_struct = mapped_column(LongText, nullable=True) created_by = mapped_column(StringUUID, nullable=False) + maintainer: Mapped[str | None] = mapped_column(StringUUID, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) updated_by = mapped_column(StringUUID, nullable=True) updated_at = mapped_column( diff --git a/api/models/model.py b/api/models/model.py index 69d2a4a7f19..ebcadbb05ac 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -395,7 +395,11 @@ class IconType(StrEnum): class App(Base): __tablename__ = "apps" - __table_args__ = (sa.PrimaryKeyConstraint("id", name="app_pkey"), sa.Index("app_tenant_id_idx", "tenant_id")) + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="app_pkey"), + sa.Index("app_tenant_id_idx", "tenant_id"), + sa.Index("app_tenant_maintainer_idx", "tenant_id", "maintainer"), + ) if TYPE_CHECKING: # Response-only attributes attached by app list/detail enrichers. @@ -426,6 +430,7 @@ class App(Base): tracing = mapped_column(LongText, nullable=True) max_active_requests: Mapped[int | None] created_by = mapped_column(StringUUID, nullable=True) + maintainer: Mapped[str | None] = mapped_column(StringUUID, nullable=True) created_at = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp()) updated_by = mapped_column(StringUUID, nullable=True) updated_at: Mapped[datetime] = mapped_column( diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 32bc41e945d..123a2e6e04b 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -10202,6 +10202,539 @@ Returns permission flags that control workspace features like member invitations | ---- | ----------- | ------ | | 200 | Success | **application/json**: [PluginCategoryListResponse](#plugincategorylistresponse)
| +### [GET] /workspaces/current/rbac/access-policies +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [_AccessPolicyList](#_accesspolicylist)
| + +### [POST] /workspaces/current/rbac/access-policies +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Policy created | **application/json**: [AccessPolicy](#accesspolicy)
| + +### [DELETE] /workspaces/current/rbac/access-policies/{policy_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AccessPolicy](#accesspolicy)
| + +### [GET] /workspaces/current/rbac/access-policies/{policy_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AccessPolicy](#accesspolicy)
| + +### [PUT] /workspaces/current/rbac/access-policies/{policy_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AccessPolicy](#accesspolicy)
| + +### [POST] /workspaces/current/rbac/access-policies/{policy_id}/copy +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Policy copied | **application/json**: [AccessPolicy](#accesspolicy)
| + +### [PUT] /workspaces/current/rbac/access-policy-bindings/{binding_id}/lock +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| binding_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AccessPolicyBindingState](#accesspolicybindingstate)
| + +### [PUT] /workspaces/current/rbac/access-policy-bindings/{binding_id}/unlock +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| binding_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AccessPolicyBindingState](#accesspolicybindingstate)
| + +### [DELETE] /workspaces/current/rbac/apps/{app_id}/access-policies/{policy_id}/member-bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | +| policy_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MemberBindingsResponse](#memberbindingsresponse)
| + +### [GET] /workspaces/current/rbac/apps/{app_id}/access-policies/{policy_id}/member-bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | +| policy_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MemberBindingsResponse](#memberbindingsresponse)
| + +### [GET] /workspaces/current/rbac/apps/{app_id}/access-policies/{policy_id}/role-bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RoleBindingsResponse](#rolebindingsresponse)
| + +### [GET] /workspaces/current/rbac/apps/{app_id}/access-policy +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AppAccessMatrix](#appaccessmatrix)
| + +### [GET] /workspaces/current/rbac/apps/{app_id}/user-access-policies +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ResourceUserAccessPoliciesResponse](#resourceuseraccesspoliciesresponse)
| + +### [PUT] /workspaces/current/rbac/apps/{app_id}/users/{target_account_id}/access-policies +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | +| target_account_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ReplaceUserAccessPoliciesResponse](#replaceuseraccesspoliciesresponse)
| + +### [GET] /workspaces/current/rbac/apps/{app_id}/whitelist +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ResourceWhitelist](#resourcewhitelist)
| + +### [PUT] /workspaces/current/rbac/apps/{app_id}/whitelist +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ResourceWhitelist](#resourcewhitelist)
| + +### [DELETE] /workspaces/current/rbac/datasets/{dataset_id}/access-policies/{policy_id}/member-bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string (uuid) | +| policy_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MemberBindingsResponse](#memberbindingsresponse)
| + +### [GET] /workspaces/current/rbac/datasets/{dataset_id}/access-policies/{policy_id}/member-bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string (uuid) | +| policy_id | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MemberBindingsResponse](#memberbindingsresponse)
| + +### [GET] /workspaces/current/rbac/datasets/{dataset_id}/access-policies/{policy_id}/role-bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string (uuid) | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RoleBindingsResponse](#rolebindingsresponse)
| + +### [GET] /workspaces/current/rbac/datasets/{dataset_id}/access-policy +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [DatasetAccessMatrix](#datasetaccessmatrix)
| + +### [GET] /workspaces/current/rbac/datasets/{dataset_id}/user-access-policies +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ResourceUserAccessPoliciesResponse](#resourceuseraccesspoliciesresponse)
| + +### [PUT] /workspaces/current/rbac/datasets/{dataset_id}/users/{target_account_id}/access-policies +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string (uuid) | +| target_account_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ReplaceUserAccessPoliciesResponse](#replaceuseraccesspoliciesresponse)
| + +### [GET] /workspaces/current/rbac/datasets/{dataset_id}/whitelist +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ResourceWhitelist](#resourcewhitelist)
| + +### [PUT] /workspaces/current/rbac/datasets/{dataset_id}/whitelist +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| dataset_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [ResourceWhitelist](#resourcewhitelist)
| + +### [GET] /workspaces/current/rbac/members/{member_id}/rbac-roles +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| member_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MemberRolesResponse](#memberrolesresponse)
| + +### [PUT] /workspaces/current/rbac/members/{member_id}/rbac-roles +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| member_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MemberRolesResponse](#memberrolesresponse)
| + +### [GET] /workspaces/current/rbac/my-permissions +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MyPermissionsResponse](#mypermissionsresponse)
| + +### [GET] /workspaces/current/rbac/role-permissions/catalog +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PermissionCatalogResponse](#permissioncatalogresponse)
| + +### [GET] /workspaces/current/rbac/role-permissions/catalog/app +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PermissionCatalogResponse](#permissioncatalogresponse)
| + +### [GET] /workspaces/current/rbac/role-permissions/catalog/dataset +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [PermissionCatalogResponse](#permissioncatalogresponse)
| + +### [GET] /workspaces/current/rbac/roles +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [_RBACRoleList](#_rbacrolelist)
| + +### [POST] /workspaces/current/rbac/roles +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Role created | **application/json**: [RBACRole](#rbacrole)
| + +### [DELETE] /workspaces/current/rbac/roles/{role_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| role_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RBACRole](#rbacrole)
| + +### [GET] /workspaces/current/rbac/roles/{role_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| role_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RBACRole](#rbacrole)
| + +### [PUT] /workspaces/current/rbac/roles/{role_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| role_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RBACRole](#rbacrole)
| + +### [POST] /workspaces/current/rbac/roles/{role_id}/copy +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| role_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Role copied | **application/json**: [RBACRole](#rbacrole)
| + +### [GET] /workspaces/current/rbac/roles/{role_id}/members +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| role_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [_MembersInRoleList](#_membersinrolelist)
| + +### [PUT] /workspaces/current/rbac/workspace/apps/access-policies/{policy_id}/bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AccessMatrixItem](#accessmatrixitem)
| + +### [GET] /workspaces/current/rbac/workspace/apps/access-policies/{policy_id}/member-bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MemberBindingsResponse](#memberbindingsresponse)
| + +### [GET] /workspaces/current/rbac/workspace/apps/access-policies/{policy_id}/role-bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RoleBindingsResponse](#rolebindingsresponse)
| + +### [GET] /workspaces/current/rbac/workspace/apps/access-policy +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [WorkspaceAccessMatrix](#workspaceaccessmatrix)
| + +### [PUT] /workspaces/current/rbac/workspace/datasets/access-policies/{policy_id}/bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [AccessMatrixItem](#accessmatrixitem)
| + +### [GET] /workspaces/current/rbac/workspace/datasets/access-policies/{policy_id}/member-bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [MemberBindingsResponse](#memberbindingsresponse)
| + +### [GET] /workspaces/current/rbac/workspace/datasets/access-policies/{policy_id}/role-bindings +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| policy_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RoleBindingsResponse](#rolebindingsresponse)
| + +### [GET] /workspaces/current/rbac/workspace/datasets/access-policy +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [WorkspaceAccessMatrix](#workspaceaccessmatrix)
| + ### [GET] /workspaces/current/tool-labels #### Responses @@ -11130,6 +11663,84 @@ Default namespace | id | string | | Yes | | name | string | | Yes | +#### AccessMatrixItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| accounts | [ [AccessPolicyAccount](#accesspolicyaccount) ] | | No | +| policy | [AccessPolicy](#accesspolicy) | | No | +| roles | [ [AccessPolicyRole](#accesspolicyrole) ] | | No | + +#### AccessPolicy + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| category | string | | No | +| created_at | integer | | No | +| description | string | | No | +| id | string | | Yes | +| is_builtin | boolean | | No | +| name | string | | Yes | +| permission_keys | [ string ] | | No | +| policy_key | string | | No | +| resource_type | string | | Yes | +| tenant_id | string | | No | +| updated_at | integer | | No | + +#### AccessPolicyAccount + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| account_id | string | | Yes | +| account_name | string | | Yes | +| avatar | string | | No | +| binding_id | string | | Yes | +| email | string | | No | +| is_locked | boolean | | No | + +#### AccessPolicyBindingState + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| binding_id | string | | Yes | +| is_locked | boolean | | No | + +#### AccessPolicyMemberBinding + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_policy_id | string | | Yes | +| account_id | string | | Yes | +| account_name | string | | No | +| created_at | integer | | No | +| id | string | | Yes | +| resource_id | string | | No | +| resource_type | string | | Yes | +| tenant_id | string | | No | + +#### AccessPolicyRole + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| binding_id | string | | Yes | +| is_locked | boolean | | No | +| role_id | string | | Yes | +| role_name | string | | Yes | +| role_tag | string | | No | + +#### AccessPolicyRoleBinding + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_policy_id | string | | Yes | +| created_at | integer | | No | +| id | string | | Yes | +| resource_id | string | | No | +| resource_type | string | | Yes | +| role_id | string | | Yes | +| role_name | string | | No | +| tenant_id | string | | No | + #### Account | Name | Type | Description | Required | @@ -11240,6 +11851,7 @@ Default namespace | last_login_at | integer | | No | | name | string | | Yes | | role | string | | Yes | +| roles | [ object ] | | No | | status | string | | Yes | #### AccountWithRoleList @@ -11384,10 +11996,12 @@ Default namespace | icon_type | string | | No | | icon_url | string | | Yes | | id | string | | Yes | +| maintainer | string | | No | | max_active_requests | integer | | No | | mode | string | | Yes | | model_config | [ModelConfig](#modelconfig) | | No | | name | string | | Yes | +| permission_keys | [ string ] | | No | | role | string | | No | | site | [Site](#site) | | No | | tags | [ [Tag](#tag) ] | | No | @@ -11444,10 +12058,12 @@ default (the config form sends the full desired feature state on save). | icon_url | string | | Yes | | id | string | | Yes | | is_starred | boolean | | No | +| maintainer | string | | No | | max_active_requests | integer | | No | | mode | string | | Yes | | model_config | [ModelConfigPartial](#modelconfigpartial) | | No | | name | string | | Yes | +| permission_keys | [ string ] | | No | | published_reference_count | integer | | No | | published_references | [ [AgentAppPublishedReferenceResponse](#agentapppublishedreferenceresponse) ] | | No | | role | string | | No | @@ -12850,6 +13466,13 @@ Enum class for api provider schema type. | schema_type | [ApiProviderSchemaType](#apiproviderschematype) | | Yes | | tool_name | string | | Yes | +#### AppAccessMatrix + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_id | string | | No | +| items | [ [AccessMatrixItem](#accessmatrixitem) ] | | No | + #### AppApiStatusPayload | Name | Type | Description | Required | @@ -12870,8 +13493,10 @@ Enum class for api provider schema type. | icon | string | | No | | icon_background | string | | No | | id | string | | Yes | +| maintainer | string | | No | | mode_compatible_with_agent | string | | Yes | | name | string | | Yes | +| permission_keys | [ string ] | | No | | tags | [ [Tag](#tag) ] | | No | | tracing | [JSONValue](#jsonvalue) | | No | | updated_at | integer | | No | @@ -12898,10 +13523,12 @@ Enum class for api provider schema type. | icon_type | string | | No | | icon_url | string | | Yes | | id | string | | Yes | +| maintainer | string | | No | | max_active_requests | integer | | No | | mode | string | | Yes | | model_config | [ModelConfig](#modelconfig) | | No | | name | string | | Yes | +| permission_keys | [ string ] | | No | | site | [Site](#site) | | No | | tags | [ [Tag](#tag) ] | | No | | tracing | [JSONValue](#jsonvalue) | | No | @@ -13020,10 +13647,12 @@ AppMCPServer Status Enum | icon_url | string | | Yes | | id | string | | Yes | | is_starred | boolean | | No | +| maintainer | string | | No | | max_active_requests | integer | | No | | mode | string | | Yes | | model_config | [ModelConfigPartial](#modelconfigpartial) | | No | | name | string | | Yes | +| permission_keys | [ string ] | | No | | tags | [ [Tag](#tag) ] | | No | | updated_at | integer | | No | | updated_by | string | | No | @@ -14019,6 +14648,13 @@ Model class for provider custom model configuration. | workspace_id | string | | Yes | | workspace_name | string | | Yes | +#### DatasetAccessMatrix + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| dataset_id | string | | No | +| items | [ [AccessMatrixItem](#accessmatrixitem) ] | | No | + #### DatasetAndDocumentResponse | Name | Type | Description | Required | @@ -14067,6 +14703,7 @@ Model class for provider custom model configuration. | is_published | boolean | | No | | name | string | | No | | permission | string | | No | +| permission_keys | [ string ] | | No | | pipeline_id | string | | No | | provider | string | | No | | retrieval_model_dict | [DatasetRetrievalModel](#datasetretrievalmodel) | | No | @@ -14105,8 +14742,10 @@ Model class for provider custom model configuration. | indexing_technique | string | | Yes | | is_multimodal | boolean | | Yes | | is_published | boolean | | Yes | +| maintainer | string | | No | | name | string | | Yes | | permission | string | | Yes | +| permission_keys | [ string ] | | No | | pipeline_id | string | | Yes | | provider | string | | Yes | | retrieval_model_dict | [DatasetRetrievalModelResponse](#datasetretrievalmodelresponse) | | Yes | @@ -14145,9 +14784,11 @@ Model class for provider custom model configuration. | indexing_technique | string | | Yes | | is_multimodal | boolean | | Yes | | is_published | boolean | | Yes | +| maintainer | string | | No | | name | string | | Yes | | partial_member_list | [ string ] | | No | | permission | string | | Yes | +| permission_keys | [ string ] | | No | | pipeline_id | string | | Yes | | provider | string | | Yes | | retrieval_model_dict | [DatasetRetrievalModelResponse](#datasetretrievalmodelresponse) | | Yes | @@ -14249,9 +14890,11 @@ Model class for provider custom model configuration. | indexing_technique | string | | Yes | | is_multimodal | boolean | | Yes | | is_published | boolean | | Yes | +| maintainer | string | | No | | name | string | | Yes | | partial_member_list | [ string ] | | Yes | | permission | string | | Yes | +| permission_keys | [ string ] | | No | | pipeline_id | string | | Yes | | provider | string | | Yes | | retrieval_model_dict | [DatasetRetrievalModelResponse](#datasetretrievalmodelresponse) | | Yes | @@ -15677,6 +16320,7 @@ How Dify forwards the end-user's identity to an MCP server. | error | string | | No | | id | string | | Yes | | imported_dsl_version | string | | No | +| permission_keys | [ string ] | | No | | status | [ImportStatus](#importstatus) | | Yes | #### ImportStatus @@ -16066,13 +16710,19 @@ Enum class for large language model mode. | result | string | | Yes | | tenant_id | string | | Yes | +#### MemberBindingsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AccessPolicyMemberBinding](#accesspolicymemberbinding) ] | | No | + #### MemberInvitePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | emails | [ string ] | | No | | language | string | | No | -| role | [TenantAccountRole](#tenantaccountrole) | | Yes | +| role | string | | Yes | #### MemberInviteResponse @@ -16097,6 +16747,20 @@ Enum class for large language model mode. | ---- | ---- | ----------- | -------- | | role | string | | Yes | +#### MemberRolesResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| account_id | string | | Yes | +| roles | [ [RBACRole](#rbacrole) ] | | No | + +#### MembersInRole + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| account_id | string | | No | +| account_name | string | | No | + #### MessageDetail | Name | Type | Description | Required | @@ -16407,11 +17071,20 @@ Model with provider entity. | ---- | ---- | ----------- | -------- | | response_mode | string,
**Available values:** "blocking", "streaming" | *Enum:* `"blocking"`, `"streaming"` | Yes | +#### MyPermissionsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app | [ResourcePermissionSnapshot](#resourcepermissionsnapshot) | | No | +| dataset | [ResourcePermissionSnapshot](#resourcepermissionsnapshot) | | No | +| workspace | [WorkspacePermissionSnapshot](#workspacepermissionsnapshot) | | No | + #### NewAppResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | new_app_id | string | | Yes | +| permission_keys | [ string ] | | No | #### NodeIdQuery @@ -16717,6 +17390,15 @@ output check fails and any configured retry attempts have been exhausted. | page | integer | | Yes | | total | integer | | Yes | +#### Pagination + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| current_page | integer | | No | +| per_page | integer | | No | +| total_count | integer | | No | +| total_pages | integer | | No | + #### PaginationQuery | Name | Type | Description | Required | @@ -17092,6 +17774,29 @@ Enum class for parameter type. | node_title | string | | Yes | | pause_type | [HumanInputPauseTypeResponse](#humaninputpausetyperesponse) | | Yes | +#### PermissionCatalogGroup + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| group_key | string | | Yes | +| group_name | string | | Yes | +| permissions | [ [PermissionCatalogItem](#permissioncatalogitem) ] | | No | + +#### PermissionCatalogItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| key | string | | Yes | +| name | string | | Yes | + +#### PermissionCatalogResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| groups | [ [PermissionCatalogGroup](#permissioncataloggroup) ] | | No | + #### PermissionEnum Shared permission levels for resources (datasets, credentials, etc.) @@ -17650,6 +18355,29 @@ Model class for provider quota configuration. | ---- | ---- | ----------- | -------- | | QuotaUnit | string | | | +#### RBACRole + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| category | string | | No | +| description | string | | No | +| id | string | | Yes | +| is_builtin | boolean | | No | +| name | string | | Yes | +| permission_keys | [ string ] | | No | +| role_tag | string | | No | +| tenant_id | string | | No | +| type | string | | Yes | + +#### RBACRoleAccount + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| account_id | string | | Yes | +| account_name | string | | No | +| avatar | string | | No | +| email | string | | No | + #### RagPipelineDatasetImportPayload | Name | Type | Description | Required | @@ -17811,6 +18539,12 @@ Model class for provider quota configuration. | ---- | ---- | ----------- | -------- | | url | string | URL to fetch | Yes | +#### ReplaceUserAccessPoliciesResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_policies | [ [AccessPolicy](#accesspolicy) ] | | No | + #### RerankingModel | Name | Type | Description | Required | @@ -17818,6 +18552,41 @@ Model class for provider quota configuration. | reranking_model_name | string | Name of the reranking model. | No | | reranking_provider_name | string | Provider name of the reranking model. | No | +#### ResourcePermissionKeys + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| permission_keys | [ string ] | | No | +| resource_id | string | | Yes | + +#### ResourcePermissionSnapshot + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| default_permission_keys | [ string ] | | No | +| overrides | [ [ResourcePermissionKeys](#resourcepermissionkeys) ] | | No | + +#### ResourceUserAccessPolicies + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| access_policies | [ [AccessPolicy](#accesspolicy) ] | | No | +| account | [RBACRoleAccount](#rbacroleaccount) | | Yes | +| roles | [ [RBACRole](#rbacrole) ] | | No | + +#### ResourceUserAccessPoliciesResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [ResourceUserAccessPolicies](#resourceuseraccesspolicies) ] | | No | +| scope | string | | Yes | + +#### ResourceWhitelist + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| account_ids | [ string ] | | No | + #### RestrictModel | Name | Type | Description | Required | @@ -17880,6 +18649,12 @@ Model class for provider quota configuration. | summary | string | | No | | word_count | integer | | No | +#### RoleBindingsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AccessPolicyRoleBinding](#accesspolicyrolebinding) ] | | No | + #### RosterListQuery | Name | Type | Description | Required | @@ -18575,6 +19350,7 @@ Model class for provider system configuration response. | max_plugin_package_size | integer,
**Default:** 15728640 | | Yes | | plugin_installation_permission | [PluginInstallationPermissionModel](#plugininstallationpermissionmodel) | | Yes | | plugin_manager | [PluginManagerModel](#pluginmanagermodel) | | Yes | +| rbac_enabled | boolean | | Yes | | sso_enforced_for_signin | boolean | | Yes | | sso_enforced_for_signin_protocol | string | | Yes | | webapp_auth | [WebAppAuthModel](#webappauthmodel) | | Yes | @@ -18848,6 +19624,7 @@ Enum class for tool provider | mode | string | | No | | model_config | [TrialAppModelConfig](#trialappmodelconfig) | | No | | name | string | | No | +| permission_keys | [ string ] | | No | | site | [TrialSite](#trialsite) | | No | | tags | [ [TrialTag](#trialtag) ] | | No | | updated_at | long | | No | @@ -18906,6 +19683,7 @@ Enum class for tool provider | indexing_technique | string | | No | | name | string | | No | | permission | string | | No | +| permission_keys | [ string ] | | No | #### TrialDatasetList @@ -20107,6 +20885,13 @@ Workflow tool configuration | marked_comment | string | | No | | marked_name | string | | No | +#### WorkspaceAccessMatrix + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| items | [ [AccessMatrixItem](#accessmatrixitem) ] | | No | +| pagination | [Pagination](#pagination) | | No | + #### WorkspaceCustomConfigPayload | Name | Type | Description | Required | @@ -20174,6 +20959,19 @@ Workflow tool configuration | allow_owner_transfer | boolean | | Yes | | workspace_id | string | | Yes | +#### WorkspacePermissionSnapshot + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| permission_keys | [ string ] | | No | + +#### _AccessPolicyList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AccessPolicy](#accesspolicy) ] | | No | +| pagination | [Pagination](#pagination) | | No | + #### _AnonymousInlineModel_744ff9cc03e6 | Name | Type | Description | Required | @@ -20218,6 +21016,27 @@ Workflow tool configuration | model_provider_name | string | | No | | summary_prompt | string | | No | +#### _MembersInRoleList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [MembersInRole](#membersinrole) ] | | No | +| pagination | [Pagination](#pagination) | | No | + +#### _RBACRoleAccountList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [RBACRoleAccount](#rbacroleaccount) ] | | No | +| pagination | [Pagination](#pagination) | | No | + +#### _RBACRoleList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [RBACRole](#rbacrole) ] | | No | +| pagination | [Pagination](#pagination) | | No | + #### core__tools__entities__common_entities__I18nObject Model class for i18n object. diff --git a/api/openapi/markdown/openapi-openapi.md b/api/openapi/markdown/openapi-openapi.md index 4b1da3f1c59..ce0150e8e88 100644 --- a/api/openapi/markdown/openapi-openapi.md +++ b/api/openapi/markdown/openapi-openapi.md @@ -805,6 +805,7 @@ Liveness payload for `GET /openapi/v1/_health` — no auth required. | error | string | | No | | id | string | | Yes | | imported_dsl_version | string | | No | +| permission_keys | [ string ] | | No | | status | [ImportStatus](#importstatus) | | Yes | #### ImportStatus diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md index a58ca2766d7..8fc5e75e3cf 100644 --- a/api/openapi/markdown/service-openapi.md +++ b/api/openapi/markdown/service-openapi.md @@ -2701,8 +2701,10 @@ Enum class for custom configuration status. | indexing_technique | string | | Yes | | is_multimodal | boolean | | Yes | | is_published | boolean | | Yes | +| maintainer | string | | No | | name | string | | Yes | | permission | string | | Yes | +| permission_keys | [ string ] | | No | | pipeline_id | string | | Yes | | provider | string | | Yes | | retrieval_model_dict | [DatasetRetrievalModelResponse](#datasetretrievalmodelresponse) | | Yes | @@ -2741,9 +2743,11 @@ Enum class for custom configuration status. | indexing_technique | string | | Yes | | is_multimodal | boolean | | Yes | | is_published | boolean | | Yes | +| maintainer | string | | No | | name | string | | Yes | | partial_member_list | [ string ] | | No | | permission | string | | Yes | +| permission_keys | [ string ] | | No | | pipeline_id | string | | Yes | | provider | string | | Yes | | retrieval_model_dict | [DatasetRetrievalModelResponse](#datasetretrievalmodelresponse) | | Yes | diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index bedaf964748..569e3706caa 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -1613,6 +1613,7 @@ Default configuration for form inputs. | max_plugin_package_size | integer,
**Default:** 15728640 | | Yes | | plugin_installation_permission | [PluginInstallationPermissionModel](#plugininstallationpermissionmodel) | | Yes | | plugin_manager | [PluginManagerModel](#pluginmanagermodel) | | Yes | +| rbac_enabled | boolean | | Yes | | sso_enforced_for_signin | boolean | | Yes | | sso_enforced_for_signin_protocol | string | | Yes | | webapp_auth | [WebAppAuthModel](#webappauthmodel) | | Yes | diff --git a/api/services/account_service.py b/api/services/account_service.py index 5c55c8bfc46..a608f544747 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -10,23 +10,11 @@ from typing import Any, NotRequired, TypedDict, cast from pydantic import BaseModel, TypeAdapter, ValidationError from sqlalchemy import Row, delete, func, select, update from sqlalchemy.orm import Session, scoped_session - -from core.db.session_factory import session_factory - - -class InvitationData(TypedDict): - account_id: str - email: str - workspace_id: str - role: NotRequired[str] - requires_setup: NotRequired[bool] - - -_invitation_adapter: TypeAdapter[InvitationData] = TypeAdapter(InvitationData) from werkzeug.exceptions import Unauthorized from configs import dify_config from constants.languages import get_valid_language, language_timezone_mapping +from core.db.session_factory import session_factory from events.tenant_event import tenant_was_created from extensions.ext_database import db from extensions.ext_redis import redis_client, redis_fallback @@ -47,8 +35,10 @@ from models.account import ( TenantPluginAutoUpgradeStrategy, TenantStatus, ) -from models.model import DifySetup +from models.dataset import Dataset +from models.model import App, DifySetup from services.billing_service import BillingService +from services.enterprise.rbac_service import ListOption, RBACService from services.entities.auth_entities import ( ChangeEmailNewEmailToken, ChangeEmailOldEmailToken, @@ -92,6 +82,17 @@ from tasks.mail_reset_password_task import ( send_reset_password_mail_task_when_account_not_exist, ) + +class InvitationData(TypedDict): + account_id: str + email: str + workspace_id: str + role: NotRequired[str] + requires_setup: NotRequired[bool] + + +_invitation_adapter: TypeAdapter[InvitationData] = TypeAdapter(InvitationData) + logger = logging.getLogger(__name__) _change_email_token_adapter: TypeAdapter[ChangeEmailTokenData] = TypeAdapter(ChangeEmailTokenData) @@ -149,6 +150,67 @@ class AccountService: OWNER_TRANSFER_MAX_ERROR_LIMITS = 5 EMAIL_REGISTER_MAX_ERROR_LIMITS = 5 + @staticmethod + def _resolve_legacy_role_id(tenant_id: str, account_id: str, role: TenantAccountRole) -> str: + """Resolve a legacy workspace role to the corresponding RBAC role id. + + Looks up the builtin RBAC role whose tag matches the legacy role name + (e.g. ``TenantAccountRole.ADMIN`` → builtin role with tag ``"admin"``). + """ + options = ListOption(page_number=1, results_per_page=100) + roles = RBACService.Roles.list(tenant_id, account_id, options=options).data + + expected_tag = { + TenantAccountRole.OWNER: "owner", + TenantAccountRole.ADMIN: "admin", + TenantAccountRole.EDITOR: "editor", + TenantAccountRole.NORMAL: "normal", + TenantAccountRole.DATASET_OPERATOR: "dataset_operator", + }[role] + for rbac_role in roles: + if ( + rbac_role.is_builtin + and rbac_role.category == "global_system_default" + and rbac_role.role_tag == expected_tag + ): + return str(rbac_role.id) + + raise ValueError(f"Builtin RBAC role not found for {role.value} in tenant {tenant_id}") + + @staticmethod + def get_workspace_permission_keys(tenant_id: str, account_id: str) -> set[str]: + permissions = RBACService.MyPermissions.get(tenant_id, account_id) + return set(getattr(getattr(permissions, "workspace", None), "permission_keys", []) or []) + + @staticmethod + def get_rbac_workspace_owner_account_id(tenant_id: str, actor_account_id: str) -> str: + """Return the account id bound to the workspace owner RBAC role.""" + owner_role_id = AccountService._resolve_legacy_role_id( + tenant_id=tenant_id, + account_id=actor_account_id, + role=TenantAccountRole.OWNER, + ) + owner_members = RBACService.Roles.members( + tenant_id=tenant_id, + account_id=actor_account_id, + role_id=owner_role_id, + options=ListOption(page_number=1, results_per_page=1), + ).data + if not owner_members: + raise ValueError(f"Workspace RBAC owner not found for tenant {tenant_id}.") + return owner_members[0].account_id + + @staticmethod + def is_rbac_workspace_owner(tenant_id: str, actor_account_id: str, member_account_id: str) -> bool: + roles = RBACService.MemberRoles.get( + tenant_id=tenant_id, + account_id=actor_account_id, + member_account_id=member_account_id, + ).roles + return any( + role.is_builtin and role.category == "global_system_default" and role.role_tag == "owner" for role in roles + ) + @staticmethod def _get_refresh_token_key(refresh_token: str) -> str: return f"{REFRESH_TOKEN_PREFIX}{refresh_token}" @@ -1219,6 +1281,14 @@ class TenantService: else: tenant = TenantService.create_tenant(name=f"{account.name}'s Workspace", is_setup=is_setup) TenantService.create_tenant_member(tenant, account, role="owner") + if dify_config.RBAC_ENABLED: + owner_role_id = AccountService._resolve_legacy_role_id(str(tenant.id), account.id, TenantAccountRole.OWNER) + RBACService.MemberRoles.replace( + tenant_id=str(tenant.id), + account_id=account.id, + member_account_id=account.id, + role_ids=[owner_role_id], + ) account.current_tenant = tenant db.session.commit() tenant_was_created.send(tenant) @@ -1347,6 +1417,7 @@ class TenantService: """ if not account_id: return None + role = session.execute( select(TenantAccountJoin.role).where( TenantAccountJoin.tenant_id == tenant_id, @@ -1533,11 +1604,6 @@ class TenantService: @staticmethod def check_member_permission(tenant: Tenant, operator: Account, member: Account | None, action: str): """Check member permission""" - perms = { - "add": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN], - "remove": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN], - "update": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN], - } if action not in {"add", "remove", "update"}: raise InvalidActionError("Invalid action.") @@ -1545,6 +1611,31 @@ class TenantService: if operator.id == member.id: raise CannotOperateSelfError("Cannot operate self.") + if dify_config.RBAC_ENABLED: + workspace_permission_keys = AccountService.get_workspace_permission_keys( + str(tenant.id), + str(operator.id), + ) + required_permission_key = ( + "workspace.member.manage" if action in {"add", "remove"} else "workspace.role.manage" + ) + if required_permission_key not in workspace_permission_keys: + raise NoPermissionError(f"No permission to {action} member.") + + if ( + action == "remove" + and member + and AccountService.is_rbac_workspace_owner(str(tenant.id), str(operator.id), str(member.id)) + ): + raise NoPermissionError(f"No permission to {action} member.") + return + + perms = { + "add": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN], + "remove": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN], + "update": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN], + } + ta_operator = db.session.scalar( select(TenantAccountJoin) .where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == operator.id) @@ -1567,6 +1658,8 @@ class TenantService: def remove_member_from_tenant(tenant: Tenant, account: Account, operator: Account): """Remove member from tenant. + Apps and datasets maintained by the removed member are reassigned to + the workspace owner without changing their immutable creator records. If the removed member has ``AccountStatus.PENDING`` (invited but never activated) and no remaining workspace memberships, the orphaned account record is deleted as well. @@ -1589,6 +1682,37 @@ class TenantService: account_id = account.id account_email = account.email + owner_id: str | None + if dify_config.RBAC_ENABLED: + owner_id = AccountService.get_rbac_workspace_owner_account_id(str(tenant.id), str(operator.id)) + else: + owner_id = db.session.scalar( + select(TenantAccountJoin.account_id) + .where( + TenantAccountJoin.tenant_id == tenant.id, + TenantAccountJoin.role == TenantAccountRole.OWNER, + ) + .limit(1) + ) + if owner_id is None: + raise ValueError(f"Workspace owner not found for tenant {tenant.id}.") + + db.session.execute( + update(App) + .where( + App.tenant_id == tenant.id, + App.maintainer == account_id, + ) + .values(maintainer=owner_id) + ) + db.session.execute( + update(Dataset) + .where( + Dataset.tenant_id == tenant.id, + Dataset.maintainer == account_id, + ) + .values(maintainer=owner_id) + ) db.session.delete(ta) # Clean up orphaned pending accounts (invited but never activated) @@ -1660,11 +1784,37 @@ class TenantService: .where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.role == "owner") .limit(1) ) - if current_owner_join: - current_owner_join.role = TenantAccountRole.ADMIN + if not dify_config.RBAC_ENABLED: + if current_owner_join: + current_owner_join.role = TenantAccountRole.ADMIN + elif current_owner_join: + admin_role_id = AccountService._resolve_legacy_role_id( + tenant_id=str(tenant.id), + account_id=operator.id, + role=TenantAccountRole.ADMIN, + ) + RBACService.MemberRoles.replace( + tenant_id=str(tenant.id), + account_id=operator.id, + member_account_id=str(current_owner_join.account_id), + role_ids=[admin_role_id], + ) # Update the role of the target member - target_member_join.role = new_tenant_role + if dify_config.RBAC_ENABLED: + resolved_role_id = AccountService._resolve_legacy_role_id( + tenant_id=str(tenant.id), + account_id=operator.id, + role=TenantAccountRole.OWNER, + ) + RBACService.MemberRoles.replace( + tenant_id=str(tenant.id), + account_id=operator.id, + member_account_id=member.id, + role_ids=[resolved_role_id], + ) + else: + target_member_join.role = new_tenant_role db.session.commit() @staticmethod @@ -1798,6 +1948,7 @@ class RegisterService: raise ValueError("Inviter is required") normalized_email = email.lower() + tenant_join_role = TenantAccountRole.NORMAL.value if dify_config.RBAC_ENABLED else role """Invite new member""" # Check workspace permission for member invitations @@ -1819,8 +1970,7 @@ class RegisterService: status=AccountStatus.PENDING, is_setup=True, ) - # Create new tenant member for invited tenant - TenantService.create_tenant_member(tenant, account, role) + TenantService.create_tenant_member(tenant, account, tenant_join_role) TenantService.switch_tenant(account, tenant.id) requires_setup = True else: @@ -1832,12 +1982,29 @@ class RegisterService: ) requires_setup = account.status == AccountStatus.PENDING - if not ta and account.status == AccountStatus.PENDING: - TenantService.create_tenant_member(tenant, account, role) + if not ta and (account.status == AccountStatus.PENDING or dify_config.RBAC_ENABLED): + TenantService.create_tenant_member(tenant, account, tenant_join_role) # Support resend invitation email when the account is pending status - if ta and account.status != AccountStatus.PENDING: - raise AccountAlreadyInTenantError("Account already in tenant.") + if account.status != AccountStatus.PENDING: + if dify_config.RBAC_ENABLED and not ta: + RBACService.MemberRoles.replace( + tenant_id=str(tenant.id), + account_id=inviter.id, + member_account_id=account.id, + role_ids=[role], + ) + if ta or dify_config.RBAC_ENABLED: + raise AccountAlreadyInTenantError("Account already in tenant.") + + # Assign RBAC role if RBAC is enabled + if dify_config.RBAC_ENABLED: + RBACService.MemberRoles.replace( + tenant_id=str(tenant.id), + account_id=inviter.id, + member_account_id=account.id, + role_ids=[role], + ) token = cls.generate_invite_token(tenant, account, role, requires_setup=requires_setup) language = account.interface_language or "en-US" diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index e69cff6a294..52e936bf1ee 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -11,7 +11,7 @@ import yaml from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad from packaging.version import parse as parse_version -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.orm import Session @@ -60,6 +60,7 @@ class Import(BaseModel): status: ImportStatus app_id: str | None = None app_mode: str | None = None + permission_keys: list[str] = Field(default_factory=list) current_dsl_version: str = CURRENT_DSL_VERSION imported_dsl_version: str = "" error: str = "" @@ -433,6 +434,7 @@ class AppDslService: app.enable_api = True app.use_icon_as_answer_icon = app_data.get("use_icon_as_answer_icon", False) app.created_by = account.id + app.maintainer = account.id app.updated_by = account.id self._session.add(app) diff --git a/api/services/app_service.py b/api/services/app_service.py index cd0d08bf3e7..0f346433265 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -7,7 +7,7 @@ from typing import Any, Literal, NotRequired, TypedDict, cast, override import sqlalchemy as sa from flask_sqlalchemy.pagination import Pagination from pydantic import BaseModel, Field -from sqlalchemy import select +from sqlalchemy import ColumnElement, select from sqlalchemy.orm import Session, scoped_session from configs import dify_config @@ -28,6 +28,7 @@ from models.agent import Agent, AgentIconType, AgentScope, AgentSource, AgentSta from models.model import App, AppMode, AppModelConfig, IconType, Site from models.tools import ApiToolProvider from services.billing_service import BillingService +from services.enterprise import rbac_service as enterprise_rbac_service from services.enterprise.enterprise_service import EnterpriseService from services.feature_service import FeatureService from services.openapi.visibility import apply_openapi_gate, is_openapi_visible @@ -48,6 +49,8 @@ class AppListBaseParams(BaseModel): tag_ids: list[str] | None = None creator_ids: list[str] | None = None is_created_by_me: bool | None = None + accessible_app_ids: list[str] | None = None + include_own_apps: bool = False class AppListParams(AppListBaseParams): @@ -106,6 +109,11 @@ class AppService: if params.is_created_by_me: filters.append(App.created_by == user_id) + elif params.accessible_app_ids is not None: + accessible_filter: ColumnElement[bool] = App.id.in_(params.accessible_app_ids) + if params.include_own_apps: + accessible_filter = sa.or_(App.maintainer == user_id, accessible_filter) + filters.append(accessible_filter) if params.creator_ids: filters.append(App.created_by.in_(params.creator_ids)) if params.name: @@ -375,6 +383,7 @@ class AppService: app.api_rpm = params.api_rpm app.max_active_requests = params.max_active_requests app.created_by = account.id + app.maintainer = account.id app.updated_by = account.id db.session.add(app) @@ -426,6 +435,12 @@ class AppService: db.session.commit() app_was_created.send(app, account=account) + enterprise_rbac_service.try_sync_creator_access_policy_member_bindings( + tenant_id, + account.id, + enterprise_rbac_service.RBACResourceType.APP, + app.id, + ) if FeatureService.get_system_features().webapp_auth.enabled: # update web app setting as private diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py index e8b17a137f9..125f3a8e6b8 100644 --- a/api/services/dataset_service.py +++ b/api/services/dataset_service.py @@ -12,7 +12,7 @@ from typing import Annotated, Any, Literal, TypedDict, cast import sqlalchemy as sa from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator from redis.exceptions import LockNotOwnedError -from sqlalchemy import delete, exists, func, select, update +from sqlalchemy import ColumnElement, delete, exists, func, select, update from sqlalchemy.orm import Session, scoped_session, sessionmaker from werkzeug.exceptions import Forbidden, NotFound @@ -67,6 +67,7 @@ from models.source import DataSourceOauthBinding from models.workflow import Workflow from services.document_indexing_proxy.document_indexing_task_proxy import DocumentIndexingTaskProxy from services.document_indexing_proxy.duplicate_document_indexing_task_proxy import DuplicateDocumentIndexingTaskProxy +from services.enterprise import rbac_service as enterprise_rbac_service from services.entities.knowledge_entities.knowledge_entities import ( ChildChunkUpdateArgs, KnowledgeConfig, @@ -234,12 +235,37 @@ class _EstimateArgs(BaseModel): class DatasetService: + @staticmethod + def _can_manage_all_datasets(tenant_id: str, account_id: str) -> bool: + if not dify_config.RBAC_ENABLED: + return False + + permissions = enterprise_rbac_service.RBACService.MyPermissions.get(tenant_id, account_id) + workspace_permission_keys = getattr(getattr(permissions, "workspace", None), "permission_keys", []) or [] + return "dataset.create_and_management" in workspace_permission_keys + @staticmethod def get_datasets( - page, per_page, session: scoped_session, tenant_id=None, user=None, search=None, tag_ids=None, include_all=False + page, + per_page, + session: scoped_session | Session | None = None, + tenant_id=None, + user=None, + search=None, + tag_ids=None, + include_all=False, + accessible_dataset_ids: list[str] | None = None, + include_own_datasets: bool = False, ): + session = session or db.session query = select(Dataset).where(Dataset.tenant_id == tenant_id).order_by(Dataset.created_at.desc(), Dataset.id) + if dify_config.RBAC_ENABLED and accessible_dataset_ids is not None: + accessible_filter: ColumnElement[bool] = Dataset.id.in_(accessible_dataset_ids) + if include_own_datasets and user: + accessible_filter = sa.or_(Dataset.maintainer == user.id, accessible_filter) + query = query.where(accessible_filter) + if user: # get permitted dataset ids dataset_permission = db.session.scalars( @@ -248,8 +274,7 @@ class DatasetService: ) ).all() permitted_dataset_ids = {dp.dataset_id for dp in dataset_permission} if dataset_permission else None - - if user.current_role == TenantAccountRole.DATASET_OPERATOR: + if not dify_config.RBAC_ENABLED and user.current_role == TenantAccountRole.DATASET_OPERATOR: # only show datasets that the user has permission to access # Check if permitted_dataset_ids is not empty to avoid WHERE false condition if permitted_dataset_ids and len(permitted_dataset_ids) > 0: @@ -257,34 +282,50 @@ class DatasetService: else: return [], 0 else: - if user.current_role != TenantAccountRole.OWNER or not include_all: - # show all datasets that the user has permission to access - # Check if permitted_dataset_ids is not empty to avoid WHERE false condition - if permitted_dataset_ids and len(permitted_dataset_ids) > 0: - query = query.where( - sa.or_( - Dataset.permission == DatasetPermissionEnum.ALL_TEAM, - sa.and_( - Dataset.permission == DatasetPermissionEnum.ONLY_ME, Dataset.created_by == user.id - ), - sa.and_( - Dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM, - Dataset.id.in_(permitted_dataset_ids), - ), - ) - ) + if dify_config.RBAC_ENABLED: + can_manage_all_datasets = DatasetService._can_manage_all_datasets(str(tenant_id), str(user.id)) + should_show_all_datasets = include_all and can_manage_all_datasets + else: + should_show_all_datasets = user.current_role == TenantAccountRole.OWNER and include_all + + if not should_show_all_datasets: + if dify_config.RBAC_ENABLED: + # RBAC mode: show all datasets. Permission control is enforced + # via permission_keys on each item and @rbac_permission_required decorators. + pass else: - query = query.where( - sa.or_( - Dataset.permission == DatasetPermissionEnum.ALL_TEAM, - sa.and_( - Dataset.permission == DatasetPermissionEnum.ONLY_ME, Dataset.created_by == user.id - ), + # Keep legacy visibility rules when RBAC is disabled. + if permitted_dataset_ids and len(permitted_dataset_ids) > 0: + query = query.where( + sa.or_( + Dataset.permission == DatasetPermissionEnum.ALL_TEAM, + sa.and_( + Dataset.permission == DatasetPermissionEnum.ONLY_ME, + Dataset.maintainer == user.id, + ), + sa.and_( + Dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM, + Dataset.id.in_(permitted_dataset_ids), + ), + ) + ) + else: + query = query.where( + sa.or_( + Dataset.permission == DatasetPermissionEnum.ALL_TEAM, + sa.and_( + Dataset.permission == DatasetPermissionEnum.ONLY_ME, + Dataset.maintainer == user.id, + ), + ) ) - ) else: - # if no user, only show datasets that are shared with all team members - query = query.where(Dataset.permission == DatasetPermissionEnum.ALL_TEAM) + if dify_config.RBAC_ENABLED: + # Without an account we cannot resolve RBAC resource visibility. + query = query.where(sa.false()) + else: + # if no user, only show datasets that are shared with all team members + query = query.where(Dataset.permission == DatasetPermissionEnum.ALL_TEAM) if search: escaped_search = helper.escape_like_pattern(search) @@ -329,12 +370,28 @@ class DatasetService: return {"mode": mode, "rules": rules} @staticmethod - def get_datasets_by_ids(ids, tenant_id): + def get_datasets_by_ids( + ids, + tenant_id, + user=None, + accessible_dataset_ids: list[str] | None = None, + include_own_datasets: bool = False, + ): # Check if ids is not empty to avoid WHERE false condition if not ids or len(ids) == 0: return [], 0 stmt = select(Dataset).where(Dataset.id.in_(ids), Dataset.tenant_id == tenant_id) + if dify_config.RBAC_ENABLED and accessible_dataset_ids is not None: + requested_dataset_ids = set(ids) + accessible_dataset_ids = [ + dataset_id for dataset_id in accessible_dataset_ids if dataset_id in requested_dataset_ids + ] + accessible_filter: ColumnElement[bool] = Dataset.id.in_(accessible_dataset_ids) + if include_own_datasets and user: + accessible_filter = sa.or_(Dataset.maintainer == user.id, accessible_filter) + stmt = stmt.where(accessible_filter) + datasets = db.paginate(select=stmt, page=1, per_page=len(ids), max_per_page=len(ids), error_out=False) return datasets.items, datasets.total @@ -392,6 +449,7 @@ class DatasetService: # dataset = Dataset(name=name, provider=provider, config=config) dataset.description = description dataset.created_by = account.id + dataset.maintainer = account.id dataset.updated_by = account.id dataset.tenant_id = tenant_id dataset.embedding_model_provider = embedding_model.provider if embedding_model else None @@ -422,6 +480,12 @@ class DatasetService: db.session.add(external_knowledge_binding) db.session.commit() + enterprise_rbac_service.try_sync_creator_access_policy_member_bindings( + tenant_id, + account.id, + enterprise_rbac_service.RBACResourceType.DATASET, + dataset.id, + ) return dataset @staticmethod @@ -467,6 +531,7 @@ class DatasetService: runtime_mode=DatasetRuntimeMode.RAG_PIPELINE, icon_info=rag_pipeline_dataset_create_entity.icon_info.model_dump(), created_by=current_user.id, + maintainer=current_user.id, pipeline_id=pipeline.id, ) db.session.add(dataset) @@ -1265,12 +1330,12 @@ class DatasetService: logger.debug("User %s does not have permission to access dataset %s", user.id, dataset.id) raise NoPermissionError("You do not have permission to access this dataset.") if user.current_role != TenantAccountRole.OWNER: - if dataset.permission == DatasetPermissionEnum.ONLY_ME and dataset.created_by != user.id: + if dataset.permission == DatasetPermissionEnum.ONLY_ME and dataset.maintainer != user.id: logger.debug("User %s does not have permission to access dataset %s", user.id, dataset.id) raise NoPermissionError("You do not have permission to access this dataset.") if dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM: - # For partial team permission, user needs explicit permission or be the creator - if dataset.created_by != user.id: + # For partial team permission, user needs explicit permission or be the maintainer. + if dataset.maintainer != user.id: user_permission = db.session.scalar( select(DatasetPermission) .where(DatasetPermission.dataset_id == dataset.id, DatasetPermission.account_id == user.id) @@ -1290,7 +1355,7 @@ class DatasetService: if user.current_role != TenantAccountRole.OWNER: if dataset.permission == DatasetPermissionEnum.ONLY_ME: - if dataset.created_by != user.id: + if dataset.maintainer != user.id: raise NoPermissionError("You do not have permission to access this dataset.") elif dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM: @@ -2864,6 +2929,7 @@ class DocumentService: data_source_type=knowledge_config.data_source.info_list.data_source_type, indexing_technique=IndexTechniqueType(knowledge_config.indexing_technique), created_by=account.id, + maintainer=account.id, embedding_model=knowledge_config.embedding_model, embedding_model_provider=knowledge_config.embedding_model_provider, collection_binding_id=dataset_collection_binding_id, diff --git a/api/services/enterprise/base.py b/api/services/enterprise/base.py index c1637847e25..71deec752e5 100644 --- a/api/services/enterprise/base.py +++ b/api/services/enterprise/base.py @@ -36,6 +36,11 @@ class MCPIdentityRefreshError(MCPTokenError): logger = logging.getLogger(__name__) +# Headers recognised by dify-enterprise's /inner/api/rbac/* endpoints. +# Keep in sync with pkg/enterprise/service/rbac_inner_handlers.go. +INNER_TENANT_ID_HEADER = "X-Inner-Tenant-Id" +INNER_ACCOUNT_ID_HEADER = "X-Inner-Account-Id" + class BaseRequest: proxies: Mapping[str, str] | None = { @@ -69,8 +74,16 @@ class BaseRequest: *, timeout: float | httpx.Timeout | None = None, raise_for_status: bool = False, + extra_headers: Mapping[str, str] | None = None, ) -> Any: headers = {"Content-Type": "application/json", cls.secret_key_header: cls.secret_key} + if extra_headers: + # Explicitly ignore empty values so callers can pass optional + # headers (e.g. `X-Inner-Account-Id`) without having to branch. + for key, value in extra_headers.items(): + if value is None or value == "": + continue + headers[key] = value url = f"{cls.base_url}{endpoint}" mounts = cls._build_mounts() @@ -139,9 +152,60 @@ class BaseRequest: class EnterpriseRequest(BaseRequest): base_url = os.environ.get("ENTERPRISE_API_URL", "ENTERPRISE_API_URL") + rbac_base_url = os.environ.get("ENTERPRISE_RBAC_API_URL", base_url) secret_key = os.environ.get("ENTERPRISE_API_SECRET_KEY", "ENTERPRISE_API_SECRET_KEY") secret_key_header = "Enterprise-Api-Secret-Key" + @classmethod + def send_inner_rbac_request( + cls, + method: str, + endpoint: str, + *, + tenant_id: str, + account_id: str | None = None, + json: Any | None = None, + params: Mapping[str, Any] | None = None, + timeout: float | httpx.Timeout | None = None, + ) -> Any: + """Call an /inner/api/rbac/* endpoint on dify-enterprise. + + Inner RBAC endpoints require three headers on top of the standard + Enterprise-Api-Secret-Key: the tenant the call targets and (optionally) + the account acting on behalf of the workspace. This helper centralises + both the assertions and the header wiring so callers only have to + supply business payload. + """ + if not tenant_id: + raise ValueError("tenant_id must be provided for inner RBAC requests") + + inner_headers: dict[str, str] = {INNER_TENANT_ID_HEADER: tenant_id} + if account_id: + inner_headers[INNER_ACCOUNT_ID_HEADER] = account_id + url = f"{cls.rbac_base_url}{endpoint}" + mounts = cls._build_mounts() + + try: + traceparent = generate_traceparent_header() + if traceparent: + inner_headers = dict(inner_headers) + inner_headers["traceparent"] = traceparent + except Exception: + logger.debug("Failed to generate traceparent header", exc_info=True) + + with httpx.Client(mounts=mounts) as client: + request_kwargs: dict[str, Any] = { + "json": json, + "params": params, + "headers": {"Content-Type": "application/json", cls.secret_key_header: cls.secret_key, **inner_headers}, + } + if timeout is not None: + request_kwargs["timeout"] = timeout + response = client.request(method, url, **request_kwargs) + if not response.is_success: + cls._handle_error_response(response) + return response.json() + class EnterprisePluginManagerRequest(BaseRequest): base_url = os.environ.get("ENTERPRISE_PLUGIN_MANAGER_API_URL", "ENTERPRISE_PLUGIN_MANAGER_API_URL") diff --git a/api/services/enterprise/rbac_service.py b/api/services/enterprise/rbac_service.py new file mode 100644 index 00000000000..39a3a61a781 --- /dev/null +++ b/api/services/enterprise/rbac_service.py @@ -0,0 +1,1713 @@ +from __future__ import annotations + +import logging +from collections.abc import Sequence +from enum import StrEnum +from typing import Any, TypeVar + +from flask import has_request_context, request +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError + +from configs import dify_config +from core.db.session_factory import session_factory +from models import TenantAccountJoin, TenantAccountRole +from services.enterprise.base import EnterpriseRequest + +T = TypeVar("T") +logger = logging.getLogger(__name__) + + +class _RBACModel(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + +class Pagination(_RBACModel): + total_count: int = 0 + per_page: int = 0 + current_page: int = 0 + total_pages: int = 0 + + +class Paginated[T](_RBACModel): + data: list[T] = Field(default_factory=list) + pagination: Pagination | None = None + + +class MembersInRole(_RBACModel): + account_id: str = "" + account_name: str = "" + + +class RBACResourceType(StrEnum): + """Resource types understood by access policies.""" + + APP = "app" + DATASET = "dataset" + + +class RBACRoleType(StrEnum): + """The only concrete role type after the access-policy refactor.""" + + WORKSPACE = "workspace" + + +class PermissionCatalogItem(_RBACModel): + key: str + name: str + description: str = "" + + +class PermissionCatalogGroup(_RBACModel): + group_key: str + group_name: str + description: str = "" + permissions: list[PermissionCatalogItem] = Field(default_factory=list) + + +class PermissionCatalogResponse(_RBACModel): + groups: list[PermissionCatalogGroup] = Field(default_factory=list) + + +class RBACRole(_RBACModel): + id: str + tenant_id: str | None = None + type: str + category: str = "" + name: str + description: str = "" + is_builtin: bool = False + permission_keys: list[str] = Field(default_factory=list) + role_tag: str = "" + + @field_validator("permission_keys", mode="before") + @classmethod + def _coerce_permission_keys(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +class RBACRoleAccount(_RBACModel): + account_id: str + account_name: str = "" + email: str = "" + avatar: str = "" + + +class MemberRoleSummary(_RBACModel): + id: str + name: str + + +class ResourcePermissionKeys(_RBACModel): + resource_id: str + permission_keys: list[str] = Field(default_factory=list) + + +class ResourcePermissionKeysBatchResponse(_RBACModel): + data: list[ResourcePermissionKeys] = Field(default_factory=list) + + +class AccessPolicy(_RBACModel): + id: str + tenant_id: str = "" + resource_type: str + policy_key: str = "" + name: str + description: str = "" + permission_keys: list[str] = Field(default_factory=list) + is_builtin: bool = False + category: str = "" + created_at: int = 0 + updated_at: int = 0 + + @field_validator("permission_keys", mode="before") + @classmethod + def _coerce_permission_keys(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +class AccessPolicyRoleBinding(_RBACModel): + id: str + tenant_id: str = "" + access_policy_id: str + resource_type: str + resource_id: str = "" + role_id: str + role_name: str = "" + created_at: int = 0 + + +class AccessPolicyMemberBinding(_RBACModel): + id: str + tenant_id: str = "" + access_policy_id: str + resource_type: str + resource_id: str = "" + account_id: str + account_name: str = "" + created_at: int = 0 + + +class AccessPolicyBindingState(_RBACModel): + binding_id: str + is_locked: bool = False + + +class AccessPolicyRole(BaseModel): + role_id: str + role_name: str + binding_id: str + is_locked: bool = False + role_tag: str = "" + + +class AccessPolicyAccount(BaseModel): + account_id: str + account_name: str + binding_id: str + is_locked: bool = False + avatar: str = "" + email: str = "" + + +class AccessMatrixItem(_RBACModel): + policy: AccessPolicy | None = None + roles: list[AccessPolicyRole] = Field(default_factory=list) + accounts: list[AccessPolicyAccount] = Field(default_factory=list) + + @field_validator("roles", "accounts", mode="before") + @classmethod + def _coerce_empty_lists(cls, value: Any) -> list[dict[str, Any]]: + if value is None: + return [] + return value + + +class AppAccessMatrix(_RBACModel): + app_id: str = Field(default="", validation_alias=AliasChoices("app_id", "resource_id")) + items: list[AccessMatrixItem] = Field(default_factory=list) + + +class DatasetAccessMatrix(_RBACModel): + dataset_id: str = Field(default="", validation_alias=AliasChoices("dataset_id", "resource_id")) + items: list[AccessMatrixItem] = Field(default_factory=list) + + +class WorkspaceAccessMatrix(_RBACModel): + items: list[AccessMatrixItem] = Field(default_factory=list) + pagination: Pagination | None = None + + +class RoleBindingsResponse(_RBACModel): + data: list[AccessPolicyRoleBinding] = Field(default_factory=list) + + +class MemberBindingsResponse(_RBACModel): + data: list[AccessPolicyMemberBinding] = Field(default_factory=list) + + +class ResourceWhitelist(_RBACModel): + account_ids: list[str] = Field(default_factory=list) + + @field_validator("account_ids", mode="before") + @classmethod + def _coerce_account_ids(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +class ResourceWhitelistResources(_RBACModel): + unrestricted: bool = False + resource_ids: list[str] = Field(default_factory=list) + + @field_validator("resource_ids", mode="before") + @classmethod + def _coerce_resource_ids(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +class ResourceUserAccessPolicies(_RBACModel): + account: RBACRoleAccount + roles: list[RBACRole] = Field(default_factory=list) + access_policies: list[AccessPolicy] = Field(default_factory=list) + + @field_validator("access_policies", "roles", mode="before") + @classmethod + def _coerce_none_to_list(cls, value: Any) -> Any: + if value is None: + return [] + return value + + +class ResourceUserAccessPoliciesResponse(_RBACModel): + scope: str + data: list[ResourceUserAccessPolicies] = Field(default_factory=list) + + +class ReplaceUserAccessPolicies(_RBACModel): + access_policy_ids: list[str] = Field(default_factory=list) + + @field_validator("access_policy_ids", mode="before") + @classmethod + def _coerce_access_policy_ids(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +class ReplaceUserAccessPoliciesResponse(_RBACModel): + access_policies: list[AccessPolicy] = Field(default_factory=list) + + +class MemberRolesResponse(_RBACModel): + account_id: str + roles: list[RBACRole] = Field(default_factory=list) + + +class MemberRolesBatchResponse(_RBACModel): + data: list[MemberRolesResponse] = Field(default_factory=list) + + +class WorkspacePermissionSnapshot(_RBACModel): + permission_keys: list[str] = Field(default_factory=list) + + +class ResourcePermissionSnapshot(_RBACModel): + default_permission_keys: list[str] = Field(default_factory=list) + overrides: list[ResourcePermissionKeys] = Field(default_factory=list) + + def permission_keys_by_resource_ids(self, resource_ids: list[str]) -> dict[str, list[str]]: + result = {str(resource_id): list(self.default_permission_keys) for resource_id in resource_ids} + for override in self.overrides: + resource_id = str(override.resource_id) + if resource_id in result: + result[resource_id] = list(override.permission_keys) + return result + + +class MyPermissionsResponse(_RBACModel): + workspace: WorkspacePermissionSnapshot = Field(default_factory=WorkspacePermissionSnapshot) + app: ResourcePermissionSnapshot = Field(default_factory=ResourcePermissionSnapshot) + dataset: ResourcePermissionSnapshot = Field(default_factory=ResourcePermissionSnapshot) + + +# Fallback permission snapshots for legacy Dify tenant roles when external RBAC is disabled. +# Keep these keys aligned with langgenius/rbac's built-in workspace roles and access policies. +_LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [ + "workspace.member.manage", + "workspace.role.manage", + "data_source.manage", + "api_extension.manage", + "customization.manage", + "plugin.install", + "plugin.plugin_preferences", + "plugin.manage", + "plugin.debug", + "credential.use", + "credential.manage", + "app_library.access", + "app.create_and_management", + "app.tag.manage", + "dataset.create_and_management", + "dataset.tag.manage", + "dataset.external.connect", + "tool.manage", + "mcp.manage", +] + +_LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [ + "workspace.member.manage", + "workspace.role.manage", + "data_source.manage", + "api_extension.manage", + "customization.manage", + "plugin.install", + "plugin.plugin_preferences", + "plugin.manage", + "plugin.debug", + "credential.use", + "credential.manage", + "app_library.access", + "app.create_and_management", + "app.tag.manage", + "dataset.create_and_management", + "dataset.tag.manage", + "dataset.external.connect", + "tool.manage", + "mcp.manage", +] + +_LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [ + "workspace.member.manage", + "api_extension.manage", + "plugin.install", + "credential.use", + "app_library.access", + "app.create_and_management", + "app.tag.manage", + "dataset.create_and_management", + "dataset.tag.manage", + "dataset.external.connect", + "tool.manage", +] + +_LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [ + "api_extension.manage", + "plugin.install", + "credential.use", + "app_library.access", +] + +_LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [ + "plugin.install", + "dataset.create_and_management", + "dataset.external.connect", +] + +_LEGACY_APP_OWNER_KEYS: list[str] = [ + "app.acl.view_layout", + "app.acl.test_and_run", + "app.acl.edit", + "app.acl.import_export_dsl", + "app.acl.delete", + "app.acl.release_and_version", + "app.acl.monitor", + "app.acl.access_config", +] + +_LEGACY_APP_ADMIN_KEYS: list[str] = [ + "app.acl.view_layout", + "app.acl.test_and_run", + "app.acl.edit", + "app.acl.import_export_dsl", + "app.acl.delete", + "app.acl.release_and_version", + "app.acl.monitor", + "app.acl.access_config", +] + +_LEGACY_APP_EDITOR_KEYS: list[str] = [ + "app.acl.view_layout", + "app.acl.test_and_run", + "app.acl.edit", + "app.acl.import_export_dsl", + "app.acl.delete", + "app.acl.release_and_version", + "app.acl.monitor", + "app.acl.access_config", +] + +_LEGACY_APP_NORMAL_KEYS: list[str] = [ + "app.acl.view_layout", + "app.acl.test_and_run", + "app.acl.monitor", +] + +_LEGACY_DATASET_OWNER_KEYS: list[str] = [ + "dataset.acl.readonly", + "dataset.acl.edit", + "dataset.acl.import_export_dsl", + "dataset.acl.pipeline_test", + "dataset.acl.document_download", + "dataset.acl.retrieval_recall", + "dataset.acl.use", + "dataset.acl.delete_file", + "dataset.acl.pipeline_release", + "dataset.acl.delete", + "dataset.acl.access_config", + "dataset.api_key.manage", +] + +_LEGACY_DATASET_ADMIN_KEYS: list[str] = [ + "dataset.acl.readonly", + "dataset.acl.edit", + "dataset.acl.import_export_dsl", + "dataset.acl.pipeline_test", + "dataset.acl.document_download", + "dataset.acl.retrieval_recall", + "dataset.acl.use", + "dataset.acl.delete_file", + "dataset.acl.pipeline_release", + "dataset.acl.delete", + "dataset.acl.access_config", + "dataset.api_key.manage", +] + +_LEGACY_DATASET_EDITOR_KEYS: list[str] = [ + "dataset.acl.readonly", + "dataset.acl.edit", + "dataset.acl.import_export_dsl", + "dataset.acl.pipeline_test", + "dataset.acl.document_download", + "dataset.acl.retrieval_recall", + "dataset.acl.use", + "dataset.acl.delete_file", + "dataset.acl.pipeline_release", +] + +_LEGACY_DATASET_DATASET_OPERATOR_KEYS: list[str] = [ + "dataset.acl.readonly", + "dataset.acl.edit", + "dataset.acl.import_export_dsl", + "dataset.acl.pipeline_test", + "dataset.acl.document_download", + "dataset.acl.retrieval_recall", + "dataset.acl.use", + "dataset.acl.delete_file", + "dataset.acl.pipeline_release", +] + +_LEGACY_MY_PERMISSIONS: dict[TenantAccountRole, dict[str, list[str]]] = { + TenantAccountRole.OWNER: { + "workspace": _LEGACY_WORKSPACE_OWNER_KEYS, + "app": _LEGACY_APP_OWNER_KEYS, + "dataset": _LEGACY_DATASET_OWNER_KEYS, + }, + TenantAccountRole.ADMIN: { + "workspace": _LEGACY_WORKSPACE_ADMIN_KEYS, + "app": _LEGACY_APP_ADMIN_KEYS, + "dataset": _LEGACY_DATASET_ADMIN_KEYS, + }, + TenantAccountRole.EDITOR: { + "workspace": _LEGACY_WORKSPACE_EDITOR_KEYS, + "app": _LEGACY_APP_EDITOR_KEYS, + "dataset": _LEGACY_DATASET_EDITOR_KEYS, + }, + TenantAccountRole.NORMAL: { + "workspace": _LEGACY_WORKSPACE_NORMAL_KEYS, + "app": _LEGACY_APP_NORMAL_KEYS, + }, + TenantAccountRole.DATASET_OPERATOR: { + "workspace": _LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS, + "dataset": _LEGACY_DATASET_DATASET_OPERATOR_KEYS, + }, +} + + +def _legacy_my_permissions(tenant_id: str, account_id: str | None) -> MyPermissionsResponse: + if not account_id: + return MyPermissionsResponse() + + try: + with session_factory.create_session() as session: + role = session.scalar( + select(TenantAccountJoin.role).where( + TenantAccountJoin.tenant_id == tenant_id, + TenantAccountJoin.account_id == account_id, + ) + ) + if not role: + return MyPermissionsResponse() + + try: + tenant_role = TenantAccountRole(role) + except ValueError: + return MyPermissionsResponse() + except SQLAlchemyError: + return MyPermissionsResponse() + + permissions = _LEGACY_MY_PERMISSIONS.get(tenant_role, {}) + return MyPermissionsResponse( + workspace=WorkspacePermissionSnapshot(permission_keys=list(permissions.get("workspace", []))), + app=ResourcePermissionSnapshot(default_permission_keys=list(permissions.get("app", []))), + dataset=ResourcePermissionSnapshot(default_permission_keys=list(permissions.get("dataset", []))), + ) + + +def _legacy_resource_permission_keys_batch( + tenant_id: str, + account_id: str | None, + resource_ids: list[str], + resource_type: RBACResourceType, +) -> dict[str, list[str]]: + snapshot = _legacy_my_permissions(tenant_id, account_id) + if resource_type == RBACResourceType.APP: + permission_keys = snapshot.app.default_permission_keys + else: + permission_keys = snapshot.dataset.default_permission_keys + return {str(resource_id): list(permission_keys) for resource_id in resource_ids} + + +# ---------- Mutation request models ---------- + + +class RoleMutation(_RBACModel): + """Payload shared by role create & update. + + ``type`` defaults to ``workspace`` because that is the only concrete role + type supported by the enterprise backend today (see biz.RBACRoleType). + """ + + name: str + description: str = "" + permission_keys: list[str] = Field(default_factory=list) + type: RBACRoleType = RBACRoleType.WORKSPACE + + +class AccessPolicyCreate(_RBACModel): + name: str + resource_type: RBACResourceType + description: str = "" + permission_keys: list[str] = Field(default_factory=list) + + +class AccessPolicyUpdate(_RBACModel): + name: str + description: str = "" + permission_keys: list[str] = Field(default_factory=list) + + +class ReplaceRoleBindings(_RBACModel): + role_ids: list[str] = Field(default_factory=list) + + @field_validator("role_ids", mode="before") + @classmethod + def _coerce_role_ids(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +class ReplaceMemberBindings(_RBACModel): + scope: str = "specific" + + @field_validator("scope") + @classmethod + def _normalize_scope(cls, value: Any) -> str: + scope = str(value or "").strip().lower() + if scope in {"", "specific"}: + return "specific" + if scope in {"all", "only_me"}: + return scope + raise ValueError(f"invalid scope: {value}") + + +class DeleteMemberBindings(_RBACModel): + account_ids: list[str] = Field(default_factory=list) + + @field_validator("account_ids", mode="before") + @classmethod + def _coerce_account_ids(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +class ReplaceBindings(_RBACModel): + role_ids: list[str] = Field(default_factory=list) + account_ids: list[str] = Field(default_factory=list) + + @field_validator("role_ids", "account_ids", mode="before") + @classmethod + def _coerce_bindings(cls, value: Any) -> list[str]: + if value is None: + return [] + return value + + +class ListOption(_RBACModel): + page_number: int | None = None + results_per_page: int | None = None + reverse: bool | None = None + + def to_params(self, extra: dict[str, Any] | None = None) -> dict[str, Any]: + params: dict[str, Any] = {} + if self.page_number is not None: + params["page_number"] = self.page_number + if self.results_per_page is not None: + params["results_per_page"] = self.results_per_page + if self.reverse is not None: + # httpx renders `True` as the string "True"; we want the inner + # handler to match on the lowercase form it compares against. + params["reverse"] = "true" if self.reverse else "false" + if extra: + params.update({k: v for k, v in extra.items() if v is not None}) + return params + + +_INNER_PREFIX = "/rbac" + + +def _request_language_param() -> str | None: + if not has_request_context(): + return None + language = (request.args.get("language") or "").strip().lower() + if language in {"en", "ja", "zh"}: + return language + return None + + +def _inner_call( + method: str, + endpoint: str, + *, + tenant_id: str, + account_id: str | None = None, + json: Any | None = None, + params: dict[str, Any] | None = None, +) -> Any: + """Thin wrapper around `EnterpriseRequest.send_inner_rbac_request`. + + Kept as a module-level helper (rather than a nested-class method) so that + unit tests can monkey-patch this single entry point instead of every + individual `Roles.*`, `AccessPolicies.*`, … method. + """ + language = _request_language_param() + if language and (not params or "language" not in params): + params = dict(params or {}) + params["language"] = language + return EnterpriseRequest.send_inner_rbac_request( + method, + endpoint, + tenant_id=tenant_id, + account_id=account_id, + json=json, + params=params, + ) + + +def _resource_id_params(resource_type: RBACResourceType | str, resource_id: str) -> dict[str, str]: + resource_type_value = resource_type.value if isinstance(resource_type, RBACResourceType) else str(resource_type) + resource_id = resource_id.strip() + if resource_type_value == RBACResourceType.APP.value: + return {"resource_type": resource_type_value, "app_id": resource_id} + if resource_type_value == RBACResourceType.DATASET.value: + return {"resource_type": resource_type_value, "dataset_id": resource_id} + raise ValueError(f"unsupported resource_type: {resource_type_value}") + + +def try_sync_creator_access_policy_member_bindings( + tenant_id: str, + account_id: str, + resource_type: RBACResourceType | str, + resource_id: str, +) -> None: + if not dify_config.RBAC_ENABLED: + return + try: + RBACService.AccessPolicies.sync_creator_access_policy_member_bindings( + tenant_id, + account_id, + resource_type=resource_type, + resource_id=resource_id, + ) + except Exception: + logger.warning( + "Failed to sync creator access policy member binding for " + "tenant_id=%s resource_type=%s resource_id=%s account_id=%s", + tenant_id, + resource_type.value if isinstance(resource_type, RBACResourceType) else resource_type, + resource_id, + account_id, + exc_info=True, + ) + + +class RBACService: + """Single entry point grouping every inner RBAC call by feature area. + + Each nested class keeps the classmethods tightly scoped to one URL family + so call sites read naturally (e.g. ``RBACService.Roles.create(tenant_id, + account_id, payload)``). + """ + + # ------------------------------------------------------------------ + # Permission catalog (screenshot 3: 新增/编辑角色 弹窗内的权限列表). + # ------------------------------------------------------------------ + class Catalog: + @staticmethod + def workspace(tenant_id: str, account_id: str | None = None) -> PermissionCatalogResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/role-permissions/catalog", + tenant_id=tenant_id, + account_id=account_id, + ) + return PermissionCatalogResponse.model_validate(data or {}) + + @staticmethod + def app(tenant_id: str, account_id: str | None = None) -> PermissionCatalogResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/role-permissions/catalog/app", + tenant_id=tenant_id, + account_id=account_id, + ) + return PermissionCatalogResponse.model_validate(data or {}) + + @staticmethod + def dataset(tenant_id: str, account_id: str | None = None) -> PermissionCatalogResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/role-permissions/catalog/dataset", + tenant_id=tenant_id, + account_id=account_id, + ) + return PermissionCatalogResponse.model_validate(data or {}) + + # ------------------------------------------------------------------ + # Role CRUD (Settings > Permissions). + # ------------------------------------------------------------------ + class Roles: + @staticmethod + def list( + tenant_id: str, + account_id: str | None = None, + include_owner: int | None = None, + *, + options: ListOption | None = None, + ) -> Paginated[RBACRole]: + params = (options or ListOption()).to_params({"include_owner": include_owner}) + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/roles", + tenant_id=tenant_id, + account_id=account_id, + params=params or None, + ) + data = data or {} + return Paginated[RBACRole]( + data=[RBACRole.model_validate(item) for item in data.get("data") or []], + pagination=Pagination.model_validate(data["pagination"]) if data.get("pagination") else None, + ) + + @staticmethod + def list_members_by_role( + tenant_id: str, + role_id: str | None = None, + *, + options: ListOption | None = None, + ) -> Paginated[MembersInRole]: + params = (options or ListOption()).to_params({"role_id": role_id}) + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/roles/members", + tenant_id=tenant_id, + params=params or None, + ) + data = data or {} + return Paginated[MembersInRole]( + data=[MembersInRole.model_validate(item) for item in data.get("data") or []], + pagination=Pagination.model_validate(data["pagination"]) if data.get("pagination") else None, + ) + + @staticmethod + def get(tenant_id: str, account_id: str | None, role_id: str) -> RBACRole: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/roles/item", + tenant_id=tenant_id, + account_id=account_id, + params={"id": role_id}, + ) + return RBACRole.model_validate(data or {}) + + @staticmethod + def create(tenant_id: str, account_id: str | None, payload: RoleMutation) -> RBACRole: + data = _inner_call( + "POST", + f"{_INNER_PREFIX}/roles", + tenant_id=tenant_id, + account_id=account_id, + json=payload.model_dump(mode="json"), + ) + return RBACRole.model_validate(data or {}) + + @staticmethod + def update(tenant_id: str, account_id: str | None, role_id: str, payload: RoleMutation) -> RBACRole: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/roles/item", + tenant_id=tenant_id, + account_id=account_id, + params={"id": role_id}, + json=payload.model_dump(mode="json"), + ) + return RBACRole.model_validate(data or {}) + + @staticmethod + def delete(tenant_id: str, account_id: str | None, role_id: str) -> None: + _inner_call( + "DELETE", + f"{_INNER_PREFIX}/roles/item", + tenant_id=tenant_id, + account_id=account_id, + params={"id": role_id}, + ) + + @staticmethod + def copy(tenant_id: str, account_id: str | None, role_id: str, copy_member: bool = True) -> RBACRole: + data = _inner_call( + "POST", + f"{_INNER_PREFIX}/roles/copy", + tenant_id=tenant_id, + account_id=account_id, + json={"copy_member": copy_member}, + params={"id": role_id}, + ) + + return RBACRole.model_validate(data or {}) + + @staticmethod + def members( + tenant_id: str, + account_id: str | None, + role_id: str, + *, + options: ListOption | None = None, + ) -> Paginated[RBACRoleAccount]: + params = (options or ListOption()).to_params({"role_id": role_id}) + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/roles/members", + tenant_id=tenant_id, + account_id=account_id, + params=params, + ) + data = data or {} + return Paginated[RBACRoleAccount]( + data=[RBACRoleAccount.model_validate(item) for item in data.get("data") or []], + pagination=Pagination.model_validate(data["pagination"]) if data.get("pagination") else None, + ) + + # ------------------------------------------------------------------ + # Access policies (Settings > Access Rules: create/edit permission sets). + # ------------------------------------------------------------------ + class AccessPolicies: + @staticmethod + def list( + tenant_id: str, + account_id: str | None = None, + *, + resource_type: RBACResourceType | str | None = None, + options: ListOption | None = None, + ) -> Paginated[AccessPolicy]: + extra: dict[str, Any] = {} + if resource_type is not None: + extra["resource_type"] = ( + resource_type.value if isinstance(resource_type, RBACResourceType) else resource_type + ) + params = (options or ListOption()).to_params(extra) + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/access-policies", + tenant_id=tenant_id, + account_id=account_id, + params=params or None, + ) + data = data or {} + return Paginated[AccessPolicy]( + data=[AccessPolicy.model_validate(item) for item in data.get("data") or []], + pagination=Pagination.model_validate(data["pagination"]) if data.get("pagination") else None, + ) + + @staticmethod + def get(tenant_id: str, account_id: str | None, policy_id: str) -> AccessPolicy: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/access-policies/item", + tenant_id=tenant_id, + account_id=account_id, + params={"id": policy_id}, + ) + return AccessPolicy.model_validate(data or {}) + + @staticmethod + def create(tenant_id: str, account_id: str | None, payload: AccessPolicyCreate) -> AccessPolicy: + data = _inner_call( + "POST", + f"{_INNER_PREFIX}/access-policies", + tenant_id=tenant_id, + account_id=account_id, + json=payload.model_dump(mode="json"), + ) + return AccessPolicy.model_validate(data or {}) + + @staticmethod + def update( + tenant_id: str, + account_id: str | None, + policy_id: str, + payload: AccessPolicyUpdate, + ) -> AccessPolicy: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/access-policies/item", + tenant_id=tenant_id, + account_id=account_id, + params={"id": policy_id}, + json=payload.model_dump(mode="json"), + ) + return AccessPolicy.model_validate(data or {}) + + @staticmethod + def copy(tenant_id: str, account_id: str | None, policy_id: str) -> AccessPolicy: + data = _inner_call( + "POST", + f"{_INNER_PREFIX}/access-policies/copy", + tenant_id=tenant_id, + account_id=account_id, + params={"id": policy_id}, + ) + return AccessPolicy.model_validate(data or {}) + + @staticmethod + def delete(tenant_id: str, account_id: str | None, policy_id: str) -> None: + _inner_call( + "DELETE", + f"{_INNER_PREFIX}/access-policies/item", + tenant_id=tenant_id, + account_id=account_id, + params={"id": policy_id}, + ) + + @staticmethod + def sync_creator_access_policy_member_bindings( + tenant_id: str, + account_id: str | None, + *, + resource_type: RBACResourceType | str, + resource_id: str, + ) -> Sequence[AccessPolicyMemberBinding]: + params = _resource_id_params(resource_type, resource_id) + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/access-policies/creator-member-bindings", + tenant_id=tenant_id, + account_id=account_id, + params=params, + ) + items: list[Any] = [] + if isinstance(data, dict): + items = data.get("data") or [] + return [AccessPolicyMemberBinding.model_validate(item) for item in items] + + # ------------------------------------------------------------------ + # Access-policy bindings (lock / unlock a single binding). + # ------------------------------------------------------------------ + class AccessPolicyBindings: + @staticmethod + def lock(tenant_id: str, account_id: str | None, binding_id: str) -> AccessPolicyBindingState: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/access-policy-bindings/lock", + tenant_id=tenant_id, + account_id=account_id, + json={"binding_id": binding_id}, + ) + return AccessPolicyBindingState.model_validate(data or {}) + + @staticmethod + def unlock(tenant_id: str, account_id: str | None, binding_id: str) -> AccessPolicyBindingState: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/access-policy-bindings/unlock", + tenant_id=tenant_id, + account_id=account_id, + json={"binding_id": binding_id}, + ) + return AccessPolicyBindingState.model_validate(data or {}) + + # ------------------------------------------------------------------ + # Per-app access (screenshot 1: App Access Config). + # ------------------------------------------------------------------ + class AppAccess: + @staticmethod + def whitelist_resources(tenant_id: str, account_id: str | None) -> ResourceWhitelistResources: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/apps/whitelist/resources", + tenant_id=tenant_id, + account_id=account_id, + ) + return ResourceWhitelistResources.model_validate(data or {}) + + @staticmethod + def user_access_policies( + tenant_id: str, account_id: str | None, app_id: str + ) -> ResourceUserAccessPoliciesResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/apps/user-access-policies", + tenant_id=tenant_id, + account_id=account_id, + params={"app_id": app_id}, + ) + return ResourceUserAccessPoliciesResponse.model_validate(data or {}) + + @staticmethod + def replace_user_access_policies( + tenant_id: str, + account_id: str | None, + app_id: str, + target_account_id: str, + payload: ReplaceUserAccessPolicies, + ) -> ReplaceUserAccessPoliciesResponse: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/apps/user-access-policies", + tenant_id=tenant_id, + account_id=account_id, + params={"app_id": app_id, "account_id": target_account_id}, + json=payload.model_dump(mode="json"), + ) + return ReplaceUserAccessPoliciesResponse.model_validate(data or {}) + + @staticmethod + def whitelist(tenant_id: str, account_id: str | None, app_id: str) -> ResourceWhitelist: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/apps/whitelist", + tenant_id=tenant_id, + account_id=account_id, + params={"app_id": app_id}, + ) + return ResourceWhitelist.model_validate(data or {}) + + @staticmethod + def replace_whitelist( + tenant_id: str, + account_id: str | None, + app_id: str, + payload: ReplaceMemberBindings, + ) -> ResourceWhitelist: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/apps/whitelist", + tenant_id=tenant_id, + account_id=account_id, + params={"app_id": app_id}, + json=payload.model_dump(mode="json"), + ) + return ResourceWhitelist.model_validate(data or {}) + + @staticmethod + def matrix(tenant_id: str, account_id: str | None, app_id: str) -> AppAccessMatrix: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/apps/access-policy", + tenant_id=tenant_id, + account_id=account_id, + params={"app_id": app_id}, + ) + return AppAccessMatrix.model_validate(data or {}) + + @staticmethod + def list_role_bindings( + tenant_id: str, + account_id: str | None, + app_id: str, + policy_id: str, + ) -> RoleBindingsResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/apps/access-policy/role-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"app_id": app_id, "policy_id": policy_id}, + ) + return RoleBindingsResponse.model_validate(data or {}) + + @staticmethod + def replace_role_bindings( + tenant_id: str, + account_id: str | None, + app_id: str, + policy_id: str, + payload: ReplaceRoleBindings, + ) -> RoleBindingsResponse: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/apps/access-policy/role-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"app_id": app_id, "policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + return RoleBindingsResponse.model_validate(data or {}) + + @staticmethod + def list_member_bindings( + tenant_id: str, + account_id: str | None, + app_id: str, + policy_id: str, + ) -> MemberBindingsResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/apps/access-policy/member-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"app_id": app_id, "policy_id": policy_id}, + ) + return MemberBindingsResponse.model_validate(data or {}) + + @staticmethod + def delete_member_bindings( + tenant_id: str, + account_id: str | None, + app_id: str, + policy_id: str, + payload: DeleteMemberBindings, + ) -> None: + _inner_call( + "DELETE", + f"{_INNER_PREFIX}/apps/access-policy/member-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"app_id": app_id, "policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + + @staticmethod + def replace_bindings( + tenant_id: str, + account_id: str | None, + app_id: str, + policy_id: str, + payload: ReplaceBindings, + ) -> AccessMatrixItem: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/apps/access-policy/bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"app_id": app_id, "policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + return AccessMatrixItem.model_validate(data or {}) + + # ------------------------------------------------------------------ + # Per-dataset access (screenshot 1: Knowledge Base Access Config). + # ------------------------------------------------------------------ + class DatasetAccess: + @staticmethod + def whitelist_resources(tenant_id: str, account_id: str | None) -> ResourceWhitelistResources: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/datasets/whitelist/resources", + tenant_id=tenant_id, + account_id=account_id, + ) + return ResourceWhitelistResources.model_validate(data or {}) + + @staticmethod + def user_access_policies( + tenant_id: str, account_id: str | None, dataset_id: str + ) -> ResourceUserAccessPoliciesResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/datasets/user-access-policies", + tenant_id=tenant_id, + account_id=account_id, + params={"dataset_id": dataset_id}, + ) + return ResourceUserAccessPoliciesResponse.model_validate(data or {}) + + @staticmethod + def replace_user_access_policies( + tenant_id: str, + account_id: str | None, + dataset_id: str, + target_account_id: str, + payload: ReplaceUserAccessPolicies, + ) -> ReplaceUserAccessPoliciesResponse: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/datasets/user-access-policies", + tenant_id=tenant_id, + account_id=account_id, + params={"dataset_id": dataset_id, "account_id": target_account_id}, + json=payload.model_dump(mode="json"), + ) + return ReplaceUserAccessPoliciesResponse.model_validate(data or {}) + + @staticmethod + def whitelist(tenant_id: str, account_id: str | None, dataset_id: str) -> ResourceWhitelist: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/datasets/whitelist", + tenant_id=tenant_id, + account_id=account_id, + params={"dataset_id": dataset_id}, + ) + return ResourceWhitelist.model_validate(data or {}) + + @staticmethod + def replace_whitelist( + tenant_id: str, + account_id: str | None, + dataset_id: str, + payload: ReplaceMemberBindings, + ) -> ResourceWhitelist: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/datasets/whitelist", + tenant_id=tenant_id, + account_id=account_id, + params={"dataset_id": dataset_id}, + json=payload.model_dump(mode="json"), + ) + return ResourceWhitelist.model_validate(data or {}) + + @staticmethod + def matrix(tenant_id: str, account_id: str | None, dataset_id: str) -> DatasetAccessMatrix: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/datasets/access-policy", + tenant_id=tenant_id, + account_id=account_id, + params={"dataset_id": dataset_id}, + ) + return DatasetAccessMatrix.model_validate(data or {}) + + @staticmethod + def list_role_bindings( + tenant_id: str, + account_id: str | None, + dataset_id: str, + policy_id: str, + ) -> RoleBindingsResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/datasets/access-policy/role-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"dataset_id": dataset_id, "policy_id": policy_id}, + ) + return RoleBindingsResponse.model_validate(data or {}) + + @staticmethod + def replace_role_bindings( + tenant_id: str, + account_id: str | None, + dataset_id: str, + policy_id: str, + payload: ReplaceRoleBindings, + ) -> RoleBindingsResponse: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/datasets/access-policy/role-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"dataset_id": dataset_id, "policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + return RoleBindingsResponse.model_validate(data or {}) + + @staticmethod + def list_member_bindings( + tenant_id: str, + account_id: str | None, + dataset_id: str, + policy_id: str, + ) -> MemberBindingsResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/datasets/access-policy/member-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"dataset_id": dataset_id, "policy_id": policy_id}, + ) + return MemberBindingsResponse.model_validate(data or {}) + + @staticmethod + def delete_member_bindings( + tenant_id: str, + account_id: str | None, + dataset_id: str, + policy_id: str, + payload: DeleteMemberBindings, + ) -> None: + _inner_call( + "DELETE", + f"{_INNER_PREFIX}/datasets/access-policy/member-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"dataset_id": dataset_id, "policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + + @staticmethod + def replace_bindings( + tenant_id: str, + account_id: str | None, + dataset_id: str, + policy_id: str, + payload: ReplaceBindings, + ) -> AccessMatrixItem: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/datasets/access-policy/bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"dataset_id": dataset_id, "policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + return AccessMatrixItem.model_validate(data or {}) + + # ------------------------------------------------------------------ + # Workspace-level access (screenshot 2: Settings > Access Rules). + # ------------------------------------------------------------------ + class WorkspaceAccess: + @staticmethod + def app_matrix( + tenant_id: str, + account_id: str | None = None, + *, + options: ListOption | None = None, + ) -> WorkspaceAccessMatrix: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/workspace/apps/access-policy", + tenant_id=tenant_id, + account_id=account_id, + params=(options or ListOption()).to_params() or None, + ) + return WorkspaceAccessMatrix.model_validate(data or {}) + + @staticmethod + def dataset_matrix( + tenant_id: str, + account_id: str | None = None, + *, + options: ListOption | None = None, + ) -> WorkspaceAccessMatrix: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/workspace/datasets/access-policy", + tenant_id=tenant_id, + account_id=account_id, + params=(options or ListOption()).to_params() or None, + ) + return WorkspaceAccessMatrix.model_validate(data or {}) + + @staticmethod + def list_app_role_bindings( + tenant_id: str, + account_id: str | None, + policy_id: str, + ) -> RoleBindingsResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/workspace/apps/access-policy/role-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"policy_id": policy_id}, + ) + return RoleBindingsResponse.model_validate(data or {}) + + @staticmethod + def replace_app_role_bindings( + tenant_id: str, + account_id: str | None, + policy_id: str, + payload: ReplaceRoleBindings, + ) -> RoleBindingsResponse: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/workspace/apps/access-policy/role-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + return RoleBindingsResponse.model_validate(data or {}) + + @staticmethod + def list_app_member_bindings( + tenant_id: str, + account_id: str | None, + policy_id: str, + ) -> MemberBindingsResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/workspace/apps/access-policy/member-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"policy_id": policy_id}, + ) + return MemberBindingsResponse.model_validate(data or {}) + + @staticmethod + def replace_app_bindings( + tenant_id: str, + account_id: str | None, + policy_id: str, + payload: ReplaceBindings, + ) -> AccessMatrixItem: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/workspace/apps/access-policy/bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + return AccessMatrixItem.model_validate(data or {}) + + @staticmethod + def list_dataset_role_bindings( + tenant_id: str, + account_id: str | None, + policy_id: str, + ) -> RoleBindingsResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/workspace/datasets/access-policy/role-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"policy_id": policy_id}, + ) + return RoleBindingsResponse.model_validate(data or {}) + + @staticmethod + def replace_dataset_role_bindings( + tenant_id: str, + account_id: str | None, + policy_id: str, + payload: ReplaceRoleBindings, + ) -> RoleBindingsResponse: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/workspace/datasets/access-policy/role-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + return RoleBindingsResponse.model_validate(data or {}) + + @staticmethod + def list_dataset_member_bindings( + tenant_id: str, + account_id: str | None, + policy_id: str, + ) -> MemberBindingsResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/workspace/datasets/access-policy/member-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"policy_id": policy_id}, + ) + return MemberBindingsResponse.model_validate(data or {}) + + @staticmethod + def replace_dataset_bindings( + tenant_id: str, + account_id: str | None, + policy_id: str, + payload: ReplaceBindings, + ) -> AccessMatrixItem: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/workspace/datasets/access-policy/bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + return AccessMatrixItem.model_validate(data or {}) + + # ------------------------------------------------------------------ + # Member ↔ role bindings (screenshot 3: Settings > Members > Assign roles). + # ------------------------------------------------------------------ + class MemberRoles: + @staticmethod + def get(tenant_id: str, account_id: str | None, member_account_id: str) -> MemberRolesResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/members/rbac-roles", + tenant_id=tenant_id, + account_id=account_id, + params={"account_id": member_account_id}, + ) + rst = MemberRolesResponse.model_validate(data or {}) + return rst + + @staticmethod + def batch_get( + tenant_id: str, + account_id: str | None, + member_account_ids: list[str], + ) -> list[MemberRolesResponse]: + data = _inner_call( + "POST", + f"{_INNER_PREFIX}/members/rbac-roles/batch", + tenant_id=tenant_id, + account_id=account_id, + json={"member_ids": member_account_ids}, + ) + items = [] + if isinstance(data, dict): + items = [{"account_id": account_id, "roles": roles} for account_id, roles in data.items()] + rst = [] + for item in items: + tmp = MemberRolesResponse.model_validate(item) + rst.append(tmp) + return rst + + @staticmethod + def replace( + tenant_id: str, + account_id: str | None, + member_account_id: str, + role_ids: list[str], + ) -> MemberRolesResponse: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/members/rbac-roles", + tenant_id=tenant_id, + account_id=account_id, + params={"account_id": member_account_id}, + json={"role_ids": role_ids}, + ) + return MemberRolesResponse.model_validate(data or {}) + + class CheckAccess: + """Call the ``/inner/api/rbac/check-access`` endpoint.""" + + @staticmethod + def check( + tenant_id: str, + account_id: str | None, + *, + scene: str, + resource_type: str | None = None, + resource_id: str | None = None, + ) -> bool: + """Return ``True`` if the account is allowed, ``False`` otherwise.""" + if not dify_config.RBAC_ENABLED: + return True + + payload: dict[str, Any] = { + "account_id": account_id or "", + "tenant_id": tenant_id, + "scene": scene, + } + if resource_type: + payload["resource_type"] = resource_type + if resource_id: + payload["resource_id"] = resource_id + + data = _inner_call( + "POST", + f"{_INNER_PREFIX}/check-access", + tenant_id=tenant_id, + account_id=account_id, + json=payload, + ) + return bool(data.get("allowed", False)) + + class AppPermissions: + @staticmethod + def batch_get( + tenant_id: str, + account_id: str | None, + app_ids: list[str], + ) -> dict[str, list[str]]: + if not app_ids: + return {} + if not dify_config.RBAC_ENABLED: + return _legacy_resource_permission_keys_batch(tenant_id, account_id, app_ids, RBACResourceType.APP) + data = _inner_call( + "POST", + f"{_INNER_PREFIX}/apps/permission-keys/batch", + tenant_id=tenant_id, + account_id=account_id, + json={"app_ids": app_ids}, + ) + return _parse_resource_permission_keys_batch(data, resource_id_key="app_id") + + class DatasetPermissions: + @staticmethod + def batch_get( + tenant_id: str, + account_id: str | None, + dataset_ids: list[str], + ) -> dict[str, list[str]]: + if not dataset_ids: + return {} + if not dify_config.RBAC_ENABLED: + return _legacy_resource_permission_keys_batch( + tenant_id, account_id, dataset_ids, RBACResourceType.DATASET + ) + data = _inner_call( + "POST", + f"{_INNER_PREFIX}/datasets/permission-keys/batch", + tenant_id=tenant_id, + account_id=account_id, + json={"dataset_ids": dataset_ids}, + ) + return _parse_resource_permission_keys_batch(data, resource_id_key="dataset_id") + + class MyPermissions: + @staticmethod + def get( + tenant_id: str, + account_id: str | None, + *, + app_id: str | None = None, + dataset_id: str | None = None, + ) -> MyPermissionsResponse: + if not dify_config.RBAC_ENABLED: + return _legacy_my_permissions(tenant_id, account_id) + + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/my-permissions", + tenant_id=tenant_id, + account_id=account_id, + params={ + k: v + for k, v in { + "app_id": app_id, + "dataset_id": dataset_id, + }.items() + if v is not None + } + or None, + ) + return MyPermissionsResponse.model_validate(data or {}) + + +def _parse_resource_permission_keys_batch(data: Any, *, resource_id_key: str) -> dict[str, list[str]]: + if not data: + return {} + + if isinstance(data, dict): + permissions = data.get("permissions") + if isinstance(permissions, dict): + return {str(key): [str(item) for item in (value or [])] for key, value in permissions.items()} + + items = data.get("data") + if items is None: + items = data.get("items") + if items is None: + items = data.get("apps") if resource_id_key == "app_id" else data.get("datasets") + if isinstance(items, dict): + items = [{"resource_id": key, "permission_keys": value} for key, value in items.items()] + elif isinstance(data, list): + items = data + else: + items = [] + + result: dict[str, list[str]] = {} + for item in items or []: + if not isinstance(item, dict): + continue + resource_id = item.get("resource_id") or item.get(resource_id_key) + if not resource_id: + continue + permission_keys = item.get("permission_keys") or [] + result[str(resource_id)] = [str(permission_key) for permission_key in permission_keys] + return result diff --git a/api/services/external_knowledge_service.py b/api/services/external_knowledge_service.py index 8f89bca8e2b..c0aedfaceea 100644 --- a/api/services/external_knowledge_service.py +++ b/api/services/external_knowledge_service.py @@ -280,6 +280,7 @@ class ExternalDatasetService: provider="external", retrieval_model=args.get("external_retrieval_model"), created_by=user_id, + maintainer=user_id, ) db.session.add(dataset) diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 10a15a0492b..2ae0c63ff75 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -181,6 +181,7 @@ class SystemFeatureModel(FeatureResponseModel): enable_creators_platform: bool = False enable_trial_app: bool = False enable_explore_banner: bool = False + rbac_enabled: bool = False class FeatureService: @@ -247,6 +248,7 @@ class FeatureService: @classmethod def get_system_features(cls, is_authenticated: bool = False) -> SystemFeatureModel: system_features = SystemFeatureModel() + system_features.rbac_enabled = dify_config.RBAC_ENABLED cls._fulfill_system_params_from_env(system_features) diff --git a/api/services/rag_pipeline/rag_pipeline_dsl_service.py b/api/services/rag_pipeline/rag_pipeline_dsl_service.py index 52fe9108907..bdbf3e080e9 100644 --- a/api/services/rag_pipeline/rag_pipeline_dsl_service.py +++ b/api/services/rag_pipeline/rag_pipeline_dsl_service.py @@ -283,6 +283,7 @@ class RagPipelineDslService: }, indexing_technique=IndexTechniqueType(knowledge_configuration.indexing_technique), created_by=account.id, + maintainer=account.id, retrieval_model=knowledge_configuration.retrieval_model.model_dump(), runtime_mode=DatasetRuntimeMode.RAG_PIPELINE, chunk_structure=knowledge_configuration.chunk_structure, @@ -415,6 +416,7 @@ class RagPipelineDslService: }, indexing_technique=IndexTechniqueType(knowledge_configuration.indexing_technique), created_by=account.id, + maintainer=account.id, retrieval_model=knowledge_configuration.retrieval_model.model_dump(), runtime_mode=DatasetRuntimeMode.RAG_PIPELINE, chunk_structure=knowledge_configuration.chunk_structure, diff --git a/api/services/tag_service.py b/api/services/tag_service.py index 59dd5f7bb36..b144c15e85e 100644 --- a/api/services/tag_service.py +++ b/api/services/tag_service.py @@ -55,7 +55,12 @@ class TagService: @staticmethod def get_target_ids_by_tag_ids( - tag_type: str, current_tenant_id: str, tag_ids: list[str], session: scoped_session, *, match_all: bool = False + tag_type: str, + current_tenant_id: str, + tag_ids: list[str], + session: scoped_session | Session, + *, + match_all: bool = False, ): """ Return target IDs bound to tags for the given tenant and resource type. diff --git a/api/services/workflow/workflow_converter.py b/api/services/workflow/workflow_converter.py index 5dedb9e3729..e279f1daaa3 100644 --- a/api/services/workflow/workflow_converter.py +++ b/api/services/workflow/workflow_converter.py @@ -95,6 +95,7 @@ class WorkflowConverter: new_app.is_demo = False new_app.is_public = app_model.is_public new_app.created_by = account.id + new_app.maintainer = account.id new_app.updated_by = account.id db.session.add(new_app) db.session.flush() diff --git a/api/tests/test_containers_integration_tests/controllers/service_api/dataset/test_dataset.py b/api/tests/test_containers_integration_tests/controllers/service_api/dataset/test_dataset.py index ac166df454f..1717fea789f 100644 --- a/api/tests/test_containers_integration_tests/controllers/service_api/dataset/test_dataset.py +++ b/api/tests/test_containers_integration_tests/controllers/service_api/dataset/test_dataset.py @@ -20,13 +20,13 @@ from unittest.mock import ANY, Mock, PropertyMock, patch import pytest from flask import Flask -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, scoped_session from werkzeug.exceptions import Forbidden, NotFound class SessionMatcher: def __eq__(self, other): - return isinstance(other, Session) + return isinstance(other, (Session, scoped_session)) import services @@ -366,6 +366,7 @@ DATASET_DETAIL_KEYS = { "total_available_documents", "enable_api", "is_multimodal", + "maintainer", } @@ -468,6 +469,7 @@ class TestDatasetListApiGet: mock_dataset_svc.get_datasets.assert_called_once_with( 1, 20, + SessionMatcher(), mock_tenant.id, mock_current_user, None, diff --git a/api/tests/test_containers_integration_tests/services/test_account_service.py b/api/tests/test_containers_integration_tests/services/test_account_service.py index 83d2e25d224..61dca361f3e 100644 --- a/api/tests/test_containers_integration_tests/services/test_account_service.py +++ b/api/tests/test_containers_integration_tests/services/test_account_service.py @@ -9,7 +9,7 @@ from werkzeug.exceptions import Unauthorized from configs import dify_config from controllers.console.error import AccountNotFound, NotAllowedCreateWorkspace -from models import AccountStatus, TenantAccountJoin, TenantStatus +from models import AccountStatus, App, Dataset, TenantAccountJoin, TenantStatus from services.account_service import AccountService, RegisterService, TenantService, TokenPair from services.errors.account import ( AccountAlreadyInTenantError, @@ -1799,8 +1799,32 @@ class TestTenantService: TenantService.create_tenant_member(tenant, owner_account, role="owner") TenantService.create_tenant_member(tenant, member_account, role="normal") + app = App( + tenant_id=tenant.id, + name="Member app", + mode="chat", + enable_site=True, + enable_api=True, + created_by=member_account.id, + maintainer=member_account.id, + ) + dataset = Dataset( + tenant_id=tenant.id, + name="Member dataset", + created_by=member_account.id, + maintainer=member_account.id, + ) + db_session_with_containers.add_all([app, dataset]) + db_session_with_containers.commit() + # Remove member - with patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync: + with ( + patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync, + patch( + "services.account_service.AccountService.get_rbac_workspace_owner_account_id", + return_value=owner_account.id, + ), + ): mock_sync.return_value = True TenantService.remove_member_from_tenant(tenant, member_account, owner_account) @@ -1819,6 +1843,12 @@ class TestTenantService: .first() ) assert member_join is None + db_session_with_containers.refresh(app) + db_session_with_containers.refresh(dataset) + assert app.created_by == member_account.id + assert app.maintainer == owner_account.id + assert dataset.created_by == member_account.id + assert dataset.maintainer == owner_account.id def test_remove_member_from_tenant_operate_self( self, db_session_with_containers: Session, mock_external_service_dependencies diff --git a/api/tests/test_containers_integration_tests/services/test_app_service.py b/api/tests/test_containers_integration_tests/services/test_app_service.py index 43c254d407d..0f5cd184430 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_service.py @@ -96,6 +96,7 @@ class TestAppService: assert app.api_rph == app_params.api_rph assert app.api_rpm == app_params.api_rpm assert app.created_by == account.id + assert app.maintainer == account.id assert app.updated_by == account.id assert app.status == "normal" assert app.enable_site is True diff --git a/api/tests/test_containers_integration_tests/services/test_dataset_permission_service.py b/api/tests/test_containers_integration_tests/services/test_dataset_permission_service.py index f9898e2cfac..1ea1b10a15d 100644 --- a/api/tests/test_containers_integration_tests/services/test_dataset_permission_service.py +++ b/api/tests/test_containers_integration_tests/services/test_dataset_permission_service.py @@ -73,6 +73,7 @@ class DatasetPermissionTestDataFactory: data_source_type=DataSourceType.UPLOAD_FILE, indexing_technique=IndexTechniqueType.HIGH_QUALITY, created_by=created_by, + maintainer=created_by, permission=permission, provider="vendor", retrieval_model={"top_k": 2}, diff --git a/api/tests/test_containers_integration_tests/services/test_dataset_service.py b/api/tests/test_containers_integration_tests/services/test_dataset_service.py index e6ee896a525..201b65b30d0 100644 --- a/api/tests/test_containers_integration_tests/services/test_dataset_service.py +++ b/api/tests/test_containers_integration_tests/services/test_dataset_service.py @@ -441,6 +441,7 @@ class TestDatasetServiceCreateRagPipelineDataset: assert created_dataset.name == entity.name assert created_dataset.runtime_mode == DatasetRuntimeMode.RAG_PIPELINE assert created_dataset.created_by == account.id + assert created_dataset.maintainer == account.id assert created_dataset.permission == DatasetPermissionEnum.ONLY_ME assert created_pipeline is not None assert created_pipeline.name == entity.name diff --git a/api/tests/test_containers_integration_tests/services/test_dataset_service_document.py b/api/tests/test_containers_integration_tests/services/test_dataset_service_document.py index 0c089e506bf..946ac661940 100644 --- a/api/tests/test_containers_integration_tests/services/test_dataset_service_document.py +++ b/api/tests/test_containers_integration_tests/services/test_dataset_service_document.py @@ -30,11 +30,13 @@ class DocumentServiceIntegrationFactory: created_by: str | None = None, name: str | None = None, ) -> Dataset: + resolved_created_by = created_by or str(uuid4()) dataset = Dataset( tenant_id=tenant_id or str(uuid4()), name=name or f"dataset-{uuid4()}", data_source_type=DataSourceType.UPLOAD_FILE, - created_by=created_by or str(uuid4()), + created_by=resolved_created_by, + maintainer=resolved_created_by, ) db_session_with_containers.add(dataset) db_session_with_containers.commit() diff --git a/api/tests/test_containers_integration_tests/services/test_dataset_service_permissions.py b/api/tests/test_containers_integration_tests/services/test_dataset_service_permissions.py index 0603a1e27f1..d97c16668fd 100644 --- a/api/tests/test_containers_integration_tests/services/test_dataset_service_permissions.py +++ b/api/tests/test_containers_integration_tests/services/test_dataset_service_permissions.py @@ -100,6 +100,7 @@ class DatasetPermissionIntegrationFactory: data_source_type=DataSourceType.UPLOAD_FILE, indexing_technique=indexing_technique, created_by=created_by, + maintainer=created_by, provider="vendor", permission=permission, retrieval_model={"top_k": 2}, diff --git a/api/tests/test_containers_integration_tests/services/test_dataset_service_retrieval.py b/api/tests/test_containers_integration_tests/services/test_dataset_service_retrieval.py index 27ab600871b..05632b1ec2a 100644 --- a/api/tests/test_containers_integration_tests/services/test_dataset_service_retrieval.py +++ b/api/tests/test_containers_integration_tests/services/test_dataset_service_retrieval.py @@ -106,6 +106,7 @@ class DatasetRetrievalTestDataFactory: data_source_type=DataSourceType.UPLOAD_FILE, indexing_technique=IndexTechniqueType.HIGH_QUALITY, created_by=created_by, + maintainer=created_by, permission=permission, provider="vendor", retrieval_model={"top_k": 2}, diff --git a/api/tests/unit_tests/controllers/console/app/test_app_import_api.py b/api/tests/unit_tests/controllers/console/app/test_app_import_api.py index 0cccb34b08d..08273a6e1f7 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_import_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_import_api.py @@ -13,13 +13,28 @@ from controllers.console.app import app_import as app_import_module from services.app_dsl_service import ImportStatus +def _unwrap(func): + bound_self = getattr(func, "__self__", None) + while hasattr(func, "__wrapped__"): + func = func.__wrapped__ + if bound_self is not None: + return func.__get__(bound_self, bound_self.__class__) + return func + + class _Result: - def __init__(self, status: ImportStatus, app_id: str | None = "app-1"): + def __init__( + self, + status: ImportStatus, + app_id: str | None = "app-1", + permission_keys: list[str] | None = None, + ): self.status = status self.app_id = app_id + self.permission_keys = permission_keys or [] def model_dump(self, mode: str = "json"): - return {"status": self.status, "app_id": self.app_id} + return {"status": self.status, "app_id": self.app_id, "permission_keys": self.permission_keys} def _install_features(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None: @@ -107,6 +122,72 @@ class TestAppImportApi: assert status == 200 assert response["status"] == ImportStatus.COMPLETED + def test_import_post_attaches_permission_keys_when_creating_new_app_and_rbac_enabled( + self, api, app: Flask, monkeypatch: pytest.MonkeyPatch + ) -> None: + method = _unwrap(api.post) + + _install_features(monkeypatch, enabled=False) + session = _mock_session(monkeypatch) + monkeypatch.setattr( + app_import_module, + "current_account_with_tenant", + lambda: (SimpleNamespace(id="u1"), "tenant-1"), + ) + monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr( + app_import_module.AppDslService, + "import_app", + lambda *_args, **_kwargs: _Result(ImportStatus.COMPLETED, app_id="app-123"), + ) + monkeypatch.setattr( + app_import_module, + "get_app_permission_keys", + lambda tenant_id, account_id, app_id: ["app.acl.view_layout", "app.acl.edit"], + ) + + with app.test_request_context("/console/api/apps/imports", method="POST", json={"mode": "yaml-content"}): + response, status = method() + + session.commit.assert_called_once_with() + assert status == 200 + assert response["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"] + + def test_import_post_does_not_attach_permission_keys_when_overwriting_existing_app( + self, api, app: Flask, monkeypatch: pytest.MonkeyPatch + ) -> None: + method = _unwrap(api.post) + + _install_features(monkeypatch, enabled=False) + session = _mock_session(monkeypatch) + monkeypatch.setattr( + app_import_module, + "current_account_with_tenant", + lambda: (SimpleNamespace(id="u1"), "tenant-1"), + ) + monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr( + app_import_module.AppDslService, + "import_app", + lambda *_args, **_kwargs: _Result(ImportStatus.COMPLETED, app_id="app-123"), + ) + monkeypatch.setattr( + app_import_module, + "get_app_permission_keys", + lambda *_args, **_kwargs: ["app.acl.view_layout", "app.acl.edit"], + ) + + with app.test_request_context( + "/console/api/apps/imports", + method="POST", + json={"mode": "yaml-content", "app_id": "existing-app"}, + ): + response, status = method() + + session.commit.assert_called_once_with() + assert status == 200 + assert response["permission_keys"] == [] + class TestAppImportConfirmApi: @pytest.fixture @@ -132,3 +213,79 @@ class TestAppImportConfirmApi: session.commit.assert_not_called() assert status == 400 assert response["status"] == ImportStatus.FAILED + + def test_import_confirm_attaches_permission_keys_when_creating_new_app_and_rbac_enabled( + self, api, app: Flask, monkeypatch: pytest.MonkeyPatch + ) -> None: + method = _unwrap(api.post) + + session = _mock_session(monkeypatch) + monkeypatch.setattr( + app_import_module, + "current_account_with_tenant", + lambda: (SimpleNamespace(id="u1"), "tenant-1"), + ) + monkeypatch.setattr( + app_import_module.redis_client, + "get", + lambda *_args, **_kwargs: ( + b'{"import_mode":"yaml-content","yaml_content":"app: {}","app_id":null,' + b'"name":null,"description":null,"icon_type":null,"icon":null,"icon_background":null}' + ), + ) + monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr( + app_import_module.AppDslService, + "confirm_import", + lambda *_args, **_kwargs: _Result(ImportStatus.COMPLETED, app_id="app-456"), + ) + monkeypatch.setattr( + app_import_module, + "get_app_permission_keys", + lambda tenant_id, account_id, app_id: ["app.acl.view_layout", "app.acl.edit"], + ) + + with app.test_request_context("/console/api/apps/imports/import-1/confirm", method="POST"): + response, status = method(import_id="import-1") + + session.commit.assert_called_once_with() + assert status == 200 + assert response["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"] + + def test_import_confirm_does_not_attach_permission_keys_when_overwriting_existing_app( + self, api, app: Flask, monkeypatch: pytest.MonkeyPatch + ) -> None: + method = _unwrap(api.post) + + session = _mock_session(monkeypatch) + monkeypatch.setattr( + app_import_module, + "current_account_with_tenant", + lambda: (SimpleNamespace(id="u1"), "tenant-1"), + ) + monkeypatch.setattr( + app_import_module.redis_client, + "get", + lambda *_args, **_kwargs: ( + b'{"import_mode":"yaml-content","yaml_content":"app: {}","app_id":"existing-app",' + b'"name":null,"description":null,"icon_type":null,"icon":null,"icon_background":null}' + ), + ) + monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr( + app_import_module.AppDslService, + "confirm_import", + lambda *_args, **_kwargs: _Result(ImportStatus.COMPLETED, app_id="app-456"), + ) + monkeypatch.setattr( + app_import_module, + "get_app_permission_keys", + lambda *_args, **_kwargs: ["app.acl.view_layout", "app.acl.edit"], + ) + + with app.test_request_context("/console/api/apps/imports/import-1/confirm", method="POST"): + response, status = method(import_id="import-1") + + session.commit.assert_called_once_with() + assert status == 200 + assert response["permission_keys"] == [] diff --git a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py index 9cff0c8d3ee..48a19bb0364 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py @@ -14,6 +14,8 @@ from flask.views import MethodView from pydantic import ValidationError from werkzeug.datastructures import MultiDict +from configs import dify_config + # kombu references MethodView as a global when importing celery/kombu pools. if not hasattr(builtins, "MethodView"): builtins.MethodView = MethodView # type: ignore[attr-defined] @@ -351,6 +353,7 @@ def test_app_partial_serialization_uses_aliases(app_models): create_user_name="Creator", author_name="Author", has_draft_trigger=True, + permission_keys=["app.acl.view_layout"], role="Should stay agent-only", ) @@ -364,6 +367,7 @@ def test_app_partial_serialization_uses_aliases(app_models): assert serialized["model_config"]["model"] == {"provider": "openai", "name": "gpt-4o"} assert serialized["workflow"]["id"] == "wf-1" assert serialized["tags"][0]["name"] == "Utilities" + assert serialized["permission_keys"] == ["app.acl.view_layout"] assert "role" not in serialized @@ -402,6 +406,7 @@ def test_app_detail_with_site_includes_nested_serialization(app_models): updated_at=timestamp, access_mode="public", tags=[SimpleNamespace(id="tag-2", name="Prod", type="app")], + permission_keys=["app.acl.view_layout", "app.acl.edit"], api_base_url="https://api.example.com/v1", max_active_requests=5, deleted_tools=[{"type": "api", "tool_name": "search", "provider_id": "prov"}], @@ -417,6 +422,7 @@ def test_app_detail_with_site_includes_nested_serialization(app_models): assert serialized["deleted_tools"][0]["tool_name"] == "search" assert serialized["site"]["icon_url"] == "signed:site-icon" assert serialized["site"]["created_at"] == int(timestamp.timestamp()) + assert serialized["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"] assert serialized["bound_agent_id"] == "agent-1" assert "role" not in serialized @@ -432,6 +438,7 @@ def test_app_pagination_aliases_per_page_and_has_next(app_models): icon="first-icon", created_at=_ts(15), updated_at=_ts(15), + permission_keys=["app.acl.edit"], ) item_two = SimpleNamespace( id="app-11", @@ -493,6 +500,20 @@ def test_app_list_uses_injected_session_for_draft_workflows( "FeatureService", SimpleNamespace(get_system_features=lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))), ) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.MyPermissions, + "get", + lambda tenant_id, account_id: app_module.enterprise_rbac_service.MyPermissionsResponse( + app=app_module.enterprise_rbac_service.ResourcePermissionSnapshot( + overrides=[ + app_module.enterprise_rbac_service.ResourcePermissionKeys( + resource_id="app-1", + permission_keys=["app.acl.edit"], + ) + ] + ) + ), + ) monkeypatch.setattr(app_module, "db", SimpleNamespace(session=scoped_session)) with app.test_request_context("/console/api/apps?page=1&limit=20", method="GET"): @@ -502,3 +523,351 @@ def test_app_list_uses_injected_session_for_draft_workflows( assert response["data"][0]["has_draft_trigger"] is True session.execute.assert_called_once() scoped_session.execute.assert_not_called() + assert response["data"][0]["permission_keys"] == ["app.acl.edit"] + + +def test_app_create_api_attaches_permission_keys(app, app_module): + method = app_module.AppListApi.post + while hasattr(method, "__wrapped__"): + method = method.__wrapped__ + + app_obj = SimpleNamespace( + id="app-new", + name="Created App", + description="Summary", + mode_compatible_with_agent="advanced-chat", + enable_site=True, + enable_api=True, + permission_keys=[], + ) + + with app.test_request_context("/apps", method="POST", json={}): + with pytest.MonkeyPatch.context() as monkeypatch: + app_module.console_ns.payload = { + "name": "Created App", + "description": "Summary", + "mode": "advanced-chat", + } + monkeypatch.setattr( + app_module, + "AppService", + lambda: SimpleNamespace(create_app=lambda tenant_id, params, user: app_obj), + ) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.AppPermissions, + "batch_get", + lambda tenant_id, account_id, app_ids: {"app-new": ["app.acl.view_layout", "app.acl.edit"]}, + ) + + resp, status = method(app_module.AppListApi(), "tenant-1", SimpleNamespace(id="acct-1")) + + assert status == 201 + assert resp["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"] + + +def test_app_list_api_attaches_permission_keys(app, app_module): + method = app_module.AppListApi.get + while hasattr(method, "__wrapped__"): + method = method.__wrapped__ + + app_obj = SimpleNamespace( + id="app-1", + name="List App", + desc_or_prompt="Summary", + mode_compatible_with_agent="chat", + mode="chat", + created_at=_ts(15), + updated_at=_ts(15), + permission_keys=[], + ) + pagination = SimpleNamespace(page=1, per_page=20, total=1, has_next=False, items=[app_obj]) + get_paginate_apps = MagicMock(return_value=pagination) + + with app.test_request_context("/apps"): + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr( + app_module.AppService, + "get_paginate_apps", + get_paginate_apps, + ) + monkeypatch.setattr( + app_module.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + ) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.MyPermissions, + "get", + lambda tenant_id, account_id: app_module.enterprise_rbac_service.MyPermissionsResponse( + app=app_module.enterprise_rbac_service.ResourcePermissionSnapshot( + default_permission_keys=["app.preview", "app.acl.view_layout"], + overrides=[ + app_module.enterprise_rbac_service.ResourcePermissionKeys( + resource_id="app-1", + permission_keys=["app.acl.view_layout", "app.acl.edit"], + ) + ], + ) + ), + ) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.AppAccess, + "whitelist_resources", + lambda tenant_id, account_id: SimpleNamespace(unrestricted=True, resource_ids=[]), + ) + + session = MagicMock() + session.execute.return_value.scalars.return_value.all.return_value = [] + resp, status = method(app_module.AppListApi(), "tenant-1", "acct-1", session) + + assert status == 200 + params = get_paginate_apps.call_args.args[2] + assert params.accessible_app_ids is None + assert params.is_created_by_me is None + assert resp["data"][0]["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"] + + +def test_app_list_api_limits_to_apps_created_by_current_user_without_view_permission(app, app_module): + method = app_module.AppListApi.get + while hasattr(method, "__wrapped__"): + method = method.__wrapped__ + + pagination = SimpleNamespace(page=1, per_page=20, total=0, has_next=False, items=[]) + get_paginate_apps = MagicMock(return_value=pagination) + + with app.test_request_context("/apps"): + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(app_module.AppService, "get_paginate_apps", get_paginate_apps) + monkeypatch.setattr(app_module.dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.MyPermissions, + "get", + lambda tenant_id, account_id: app_module.enterprise_rbac_service.MyPermissionsResponse( + workspace=app_module.enterprise_rbac_service.WorkspacePermissionSnapshot( + permission_keys=["app.create_and_management"] + ) + ), + ) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.AppAccess, + "whitelist_resources", + lambda tenant_id, account_id: SimpleNamespace(resource_ids=["app-shared", "app-not-permitted"]), + ) + monkeypatch.setattr( + app_module.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + ) + + session = MagicMock() + resp, status = method(app_module.AppListApi(), "tenant-1", "acct-1", session) + + assert status == 200 + assert resp["data"] == [] + params = get_paginate_apps.call_args.args[2] + assert params.accessible_app_ids == ["app-not-permitted", "app-shared"] + assert params.include_own_apps is True + assert params.is_created_by_me is None + + +def test_app_list_api_limits_to_preview_overrides_without_manage_own_permission(app, app_module): + method = app_module.AppListApi.get + while hasattr(method, "__wrapped__"): + method = method.__wrapped__ + + pagination = SimpleNamespace(page=1, per_page=20, total=0, has_next=False, items=[]) + get_paginate_apps = MagicMock(return_value=pagination) + + with app.test_request_context("/apps"): + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(app_module.AppService, "get_paginate_apps", get_paginate_apps) + monkeypatch.setattr(app_module.dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.MyPermissions, + "get", + lambda tenant_id, account_id: app_module.enterprise_rbac_service.MyPermissionsResponse( + app=app_module.enterprise_rbac_service.ResourcePermissionSnapshot( + overrides=[ + app_module.enterprise_rbac_service.ResourcePermissionKeys( + resource_id="app-acl-shared", + permission_keys=["app.acl.preview"], + ), + app_module.enterprise_rbac_service.ResourcePermissionKeys( + resource_id="app-full", + permission_keys=["app.full_access"], + ), + app_module.enterprise_rbac_service.ResourcePermissionKeys( + resource_id="app-shared", + permission_keys=["app.preview"], + ), + ] + ) + ), + ) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.AppAccess, + "whitelist_resources", + lambda tenant_id, account_id: SimpleNamespace( + resource_ids=["app-shared", "app-acl-shared", "app-full", "app-whitelist-only"] + ), + ) + monkeypatch.setattr( + app_module.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + ) + + session = MagicMock() + method(app_module.AppListApi(), "tenant-1", "acct-1", session) + + params = get_paginate_apps.call_args.args[2] + assert params.accessible_app_ids == ["app-acl-shared", "app-full", "app-shared", "app-whitelist-only"] + assert params.include_own_apps is False + assert params.is_created_by_me is None + + +def test_app_list_api_returns_no_apps_without_workspace_or_resource_view_permission(app, app_module): + method = app_module.AppListApi.get + while hasattr(method, "__wrapped__"): + method = method.__wrapped__ + + pagination = SimpleNamespace(page=1, per_page=20, total=0, has_next=False, items=[]) + get_paginate_apps = MagicMock(return_value=pagination) + + with app.test_request_context("/apps"): + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(app_module.AppService, "get_paginate_apps", get_paginate_apps) + monkeypatch.setattr(app_module.dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.MyPermissions, + "get", + lambda tenant_id, account_id: app_module.enterprise_rbac_service.MyPermissionsResponse(), + ) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.AppAccess, + "whitelist_resources", + lambda tenant_id, account_id: SimpleNamespace(resource_ids=["app-not-permitted"]), + ) + monkeypatch.setattr( + app_module.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + ) + + session = MagicMock() + method(app_module.AppListApi(), "tenant-1", "acct-1", session) + + params = get_paginate_apps.call_args.args[2] + assert params.accessible_app_ids == ["app-not-permitted"] + assert params.include_own_apps is False + assert params.is_created_by_me is None + + +def test_app_detail_api_attaches_current_user_permission_keys(app, app_module): + method = app_module.AppApi.get + while hasattr(method, "__wrapped__"): + method = method.__wrapped__ + + app_obj = SimpleNamespace( + id="app-1", + name="Detail App", + description="Summary", + mode_compatible_with_agent="chat", + enable_site=True, + enable_api=True, + permission_keys=[], + ) + + with app.test_request_context("/apps/app-1"): + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr(app_module, "AppService", lambda: SimpleNamespace(get_app=lambda app_model: app_obj)) + monkeypatch.setattr( + app_module.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + ) + get_permissions = MagicMock( + return_value=app_module.enterprise_rbac_service.MyPermissionsResponse( + app=app_module.enterprise_rbac_service.ResourcePermissionSnapshot( + overrides=[ + app_module.enterprise_rbac_service.ResourcePermissionKeys( + resource_id="app-1", + permission_keys=["app.acl.view_layout", "app.acl.edit", "app.acl.monitor"], + ) + ] + ) + ) + ) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.MyPermissions, + "get", + get_permissions, + ) + + resp = method(app_module.AppApi(), "tenant-1", SimpleNamespace(id="acct-1"), app_model=app_obj) + + get_permissions.assert_called_once_with("tenant-1", "acct-1", app_id="app-1") + assert resp["permission_keys"] == ["app.acl.view_layout", "app.acl.edit", "app.acl.monitor"] + + +def test_app_copy_api_attaches_permission_keys(app, app_module): + method = app_module.AppCopyApi.post + while hasattr(method, "__wrapped__"): + method = method.__wrapped__ + + app_obj = SimpleNamespace( + id="app-new", + name="Copied App", + description="Summary", + mode_compatible_with_agent="workflow", + enable_site=True, + enable_api=True, + permission_keys=[], + ) + + import_result = SimpleNamespace(status=app_module.ImportStatus.COMPLETED, app_id="app-new") + fake_session = MagicMock() + fake_session.__enter__.return_value = fake_session + fake_session.__exit__.return_value = None + fake_session.scalar.return_value = app_obj + + with app.test_request_context("/apps/app-original/copy", method="POST", json={}): + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr( + app_module, + "AppDslService", + lambda *_args, **_kwargs: SimpleNamespace( + export_dsl=lambda **_kwargs: "dsl", + import_app=lambda **_kwargs: import_result, + ), + ) + monkeypatch.setattr( + app_module.FeatureService, + "get_system_features", + lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), + ) + monkeypatch.setattr(app_module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr( + app_module, + "Session", + lambda *_args, **_kwargs: fake_session, + ) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.AppPermissions, + "batch_get", + lambda tenant_id, account_id, app_ids: {"app-new": ["app.acl.view_layout", "app.acl.edit"]}, + ) + + resp, status = method( + app_module.AppCopyApi(), + "tenant-1", + SimpleNamespace(id="acct-1"), + app_model=SimpleNamespace(id="app-original"), + ) + + assert status == 201 + assert fake_session.scalar.called + assert resp["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"] diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_convert_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_convert_api.py new file mode 100644 index 00000000000..dd254a31f63 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_convert_api.py @@ -0,0 +1,55 @@ +"""Unit tests for convert-to-workflow endpoint.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from flask import Flask + +from controllers.console.app import workflow as workflow_module + + +def _unwrap(func): + bound_self = getattr(func, "__self__", None) + while hasattr(func, "__wrapped__"): + func = func.__wrapped__ + if bound_self is not None: + return func.__get__(bound_self, bound_self.__class__) + return func + + +class TestConvertToWorkflowApi: + @pytest.fixture + def api(self): + return workflow_module.ConvertToWorkflowApi() + + def test_convert_to_workflow_attaches_permission_keys_when_rbac_enabled( + self, api, app: Flask, monkeypatch: pytest.MonkeyPatch + ) -> None: + method = _unwrap(api.post) + + monkeypatch.setattr( + workflow_module, + "WorkflowService", + lambda: SimpleNamespace(convert_to_workflow=lambda **_kwargs: SimpleNamespace(id="new-app-1")), + ) + monkeypatch.setattr( + workflow_module, + "get_app_permission_keys", + lambda tenant_id, account_id, app_id: ["app.acl.view_layout", "app.acl.edit"], + ) + + with app.test_request_context( + "/console/api/apps/app-1/convert-to-workflow", + method="POST", + json={}, + ): + response = method( + current_tenant_id="tenant-1", + current_user=SimpleNamespace(id="u1"), + app_model=SimpleNamespace(id="app-1"), + ) + + assert response["new_app_id"] == "new-app-1" + assert response["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"] diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py index 101a640699f..76a09558987 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py @@ -2,6 +2,7 @@ import datetime import json from contextlib import ExitStack from inspect import unwrap +from types import SimpleNamespace from unittest.mock import MagicMock, PropertyMock, patch import pytest @@ -39,6 +40,7 @@ from models.dataset import Dataset, DatasetQuery, Document from models.enums import CreatorUserRole, DataSourceType, DocumentCreatedFrom, IndexingStatus from models.model import ApiToken, App, AppMode, IconType, UploadFile from services.dataset_service import DatasetPermissionService, DatasetService +from services.enterprise import rbac_service as enterprise_rbac_service @pytest.fixture(autouse=True) @@ -211,6 +213,201 @@ class TestDatasetList: assert status == 200 assert resp["total"] == 2 + def test_get_attaches_current_user_permission_keys(self, app: Flask): + api = DatasetListApi() + method = unwrap(api.get) + current_user = self._mock_user() + dataset = make_dataset(id="dataset-1") + permissions = enterprise_rbac_service.MyPermissionsResponse( + dataset=enterprise_rbac_service.ResourcePermissionSnapshot( + default_permission_keys=["dataset.acl.readonly"], + overrides=[ + enterprise_rbac_service.ResourcePermissionKeys( + resource_id="dataset-1", + permission_keys=["dataset.acl.readonly", "dataset.acl.edit"], + ) + ], + ) + ) + + with app.test_request_context("/datasets"): + with ( + patch.object(DatasetService, "get_datasets", return_value=([dataset], 1)), + patch.object( + ProviderManager, + "get_configurations", + return_value=MagicMock(get_models=lambda **_: []), + ), + patch( + "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.MyPermissions.get", + return_value=permissions, + ) as get_permissions, + ): + resp, status = method(api, "tenant-1", current_user) + + get_permissions.assert_called_once_with("tenant-1", current_user.id) + assert status == 200 + assert resp["data"][0]["permission_keys"] == ["dataset.acl.readonly", "dataset.acl.edit"] + + def test_get_limits_to_own_datasets_without_default_read_permission(self, app: Flask): + api = DatasetListApi() + method = unwrap(api.get) + current_user = self._mock_user() + permissions = enterprise_rbac_service.MyPermissionsResponse( + workspace=enterprise_rbac_service.WorkspacePermissionSnapshot( + permission_keys=["dataset.create_and_management"] + ) + ) + + with app.test_request_context("/datasets"): + with ( + patch("controllers.console.datasets.datasets.dify_config.RBAC_ENABLED", True), + patch.object(DatasetService, "get_datasets", return_value=([], 0)) as get_datasets, + patch( + "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.MyPermissions.get", + return_value=permissions, + ), + patch( + "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.DatasetAccess.whitelist_resources", + return_value=SimpleNamespace(resource_ids=[]), + ), + patch.object( + ProviderManager, + "get_configurations", + return_value=MagicMock(get_models=lambda **_: []), + ), + ): + method(api, "tenant-1", current_user) + + assert get_datasets.call_args.kwargs["accessible_dataset_ids"] == [] + assert get_datasets.call_args.kwargs["include_own_datasets"] is True + + def test_get_workspace_owner_bypasses_dataset_whitelist(self, app: Flask): + api = DatasetListApi() + method = unwrap(api.get) + current_user = self._mock_user() + permissions = enterprise_rbac_service.MyPermissionsResponse( + dataset=enterprise_rbac_service.ResourcePermissionSnapshot(default_permission_keys=["dataset.preview"]) + ) + + with app.test_request_context("/datasets"): + with ( + patch("controllers.console.datasets.datasets.dify_config.RBAC_ENABLED", True), + patch.object(DatasetService, "get_datasets", return_value=([], 0)) as get_datasets, + patch( + "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.MyPermissions.get", + return_value=permissions, + ), + patch( + "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.DatasetAccess.whitelist_resources", + return_value=SimpleNamespace(unrestricted=True, resource_ids=[]), + ), + patch.object( + ProviderManager, + "get_configurations", + return_value=MagicMock(get_models=lambda **_: []), + ), + ): + method(api, "tenant-1", current_user) + + assert get_datasets.call_args.kwargs["accessible_dataset_ids"] is None + + def test_get_limits_to_dataset_read_overrides(self, app: Flask): + api = DatasetListApi() + method = unwrap(api.get) + current_user = self._mock_user() + permissions = enterprise_rbac_service.MyPermissionsResponse( + dataset=enterprise_rbac_service.ResourcePermissionSnapshot( + overrides=[ + enterprise_rbac_service.ResourcePermissionKeys( + resource_id="dataset-acl-shared", + permission_keys=["dataset.acl.preview"], + ), + enterprise_rbac_service.ResourcePermissionKeys( + resource_id="dataset-full", + permission_keys=["dataset.full_access"], + ), + enterprise_rbac_service.ResourcePermissionKeys( + resource_id="dataset-shared", + permission_keys=["dataset.preview"], + ), + enterprise_rbac_service.ResourcePermissionKeys( + resource_id="dataset-hidden", + permission_keys=[], + ), + ] + ) + ) + + with app.test_request_context("/datasets"): + with ( + patch("controllers.console.datasets.datasets.dify_config.RBAC_ENABLED", True), + patch.object(DatasetService, "get_datasets", return_value=([], 0)) as get_datasets, + patch( + "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.MyPermissions.get", + return_value=permissions, + ), + patch( + "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.DatasetAccess.whitelist_resources", + return_value=SimpleNamespace( + resource_ids=[ + "dataset-shared", + "dataset-acl-shared", + "dataset-full", + "dataset-whitelist-only", + ] + ), + ), + patch.object( + ProviderManager, + "get_configurations", + return_value=MagicMock(get_models=lambda **_: []), + ), + ): + method(api, "tenant-1", current_user) + + assert get_datasets.call_args.kwargs["accessible_dataset_ids"] == [ + "dataset-acl-shared", + "dataset-full", + "dataset-shared", + "dataset-whitelist-only", + ] + assert get_datasets.call_args.kwargs["include_own_datasets"] is False + + def test_get_with_ids_applies_dataset_visibility(self, app: Flask): + api = DatasetListApi() + method = unwrap(api.get) + current_user = self._mock_user() + permissions = enterprise_rbac_service.MyPermissionsResponse() + + with app.test_request_context("/datasets?ids=dataset-1"): + with ( + patch("controllers.console.datasets.datasets.dify_config.RBAC_ENABLED", True), + patch.object(DatasetService, "get_datasets_by_ids", return_value=([], 0)) as get_datasets_by_ids, + patch( + "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.MyPermissions.get", + return_value=permissions, + ), + patch( + "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.DatasetAccess.whitelist_resources", + return_value=SimpleNamespace(resource_ids=[]), + ), + patch.object( + ProviderManager, + "get_configurations", + return_value=MagicMock(get_models=lambda **_: []), + ), + ): + method(api, "tenant-1", current_user) + + get_datasets_by_ids.assert_called_once_with( + ["dataset-1"], + "tenant-1", + user=current_user, + accessible_dataset_ids=[], + include_own_datasets=False, + ) + def test_get_with_tag_ids(self, app: Flask): api = DatasetListApi() method = unwrap(api.get) @@ -504,6 +701,51 @@ class TestDatasetApiGet: assert status == 200 assert data["embedding_available"] is True + def test_get_attaches_permission_keys_when_rbac_enabled(self, app: Flask): + api = DatasetApi() + method = unwrap(api.get) + + dataset_id = "123e4567-e89b-12d3-a456-426614174000" + user = MagicMock(id="account-1") + tenant_id = "tenant-1" + dataset = make_dataset(id=dataset_id) + + with ( + app.test_request_context(f"/datasets/{dataset_id}"), + patch("controllers.console.datasets.datasets.dify_config.RBAC_ENABLED", True), + patch.object( + DatasetService, + "get_dataset", + return_value=dataset, + ), + patch.object( + DatasetService, + "check_dataset_permission", + return_value=None, + ), + patch( + "controllers.console.datasets.datasets.enterprise_rbac_service.RBACService.MyPermissions.get", + return_value=enterprise_rbac_service.MyPermissionsResponse( + dataset=enterprise_rbac_service.ResourcePermissionSnapshot( + overrides=[ + enterprise_rbac_service.ResourcePermissionKeys( + resource_id=dataset_id, + permission_keys=["dataset.acl.readonly", "dataset.acl.edit"], + ) + ] + ) + ), + ) as get_permissions, + patch("controllers.console.datasets.datasets.create_plugin_provider_manager") as provider_manager_mock, + ): + provider_manager_mock.return_value.get_configurations.return_value.get_models.return_value = [] + + data, status = method(api, tenant_id, user, dataset_id) + + get_permissions.assert_called_once_with(tenant_id, user.id, dataset_id=dataset_id) + assert status == 200 + assert data["permission_keys"] == ["dataset.acl.readonly", "dataset.acl.edit"] + def test_get_uses_default_external_retrieval_model(self, app: Flask): api = DatasetApi() method = unwrap(api.get) diff --git a/api/tests/unit_tests/controllers/console/datasets/test_external.py b/api/tests/unit_tests/controllers/console/datasets/test_external.py index 7cb41dc99cd..b7e16b91fb7 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_external.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_external.py @@ -171,6 +171,7 @@ class TestExternalDatasetCreateApi: dataset.external_retrieval_model = None dataset.doc_metadata = [] dataset.icon_info = None + dataset.permission_keys = [] dataset.summary_index_setting = MagicMock() dataset.summary_index_setting.enable = False diff --git a/api/tests/unit_tests/controllers/console/test_workspace_members.py b/api/tests/unit_tests/controllers/console/test_workspace_members.py index d8d1d02a169..cb06dbc27cd 100644 --- a/api/tests/unit_tests/controllers/console/test_workspace_members.py +++ b/api/tests/unit_tests/controllers/console/test_workspace_members.py @@ -49,6 +49,7 @@ class TestMemberInviteEmailApi: inviter = SimpleNamespace(email="Owner@Example.com", current_tenant=tenant, status="active") with ( + patch("controllers.console.workspace.members.dify_config.RBAC_ENABLED", False), patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "https://console.example.com"), patch("controllers.console.workspace.members._count_new_member_invites", return_value=1), patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", False), @@ -76,3 +77,116 @@ class TestMemberInviteEmailApi: assert call_args.kwargs["role"] == TenantAccountRole.EDITOR assert call_args.kwargs["inviter"] == account mock_csrf.assert_called_once_with(ANY, account.id) + + @patch("controllers.console.workspace.members.FeatureService.get_features") + @patch("controllers.console.workspace.members.RegisterService.invite_new_member") + @patch("controllers.console.workspace.members.current_account_with_tenant") + @patch("controllers.console.wraps.db") + @patch("libs.login.check_csrf_token", return_value=None) + def test_invite_rbac_enabled_accepts_rbac_role_id( + self, + mock_csrf, + mock_db, + mock_current_account, + mock_invite_member, + mock_get_features, + app, + ): + """When RBAC is enabled, any non-empty role string should be accepted.""" + mock_get_features.return_value = _build_feature_flags() + mock_invite_member.return_value = "rbac-token" + + tenant = SimpleNamespace(id="tenant-1", name="Test Tenant") + inviter = SimpleNamespace(email="inviter@example.com", current_tenant=tenant, status="active") + mock_current_account.return_value = (inviter, tenant.id) + + with patch("controllers.console.workspace.members.dify_config") as mock_config: + mock_config.RBAC_ENABLED = True + mock_config.CONSOLE_WEB_URL = "https://console.example.com" + with app.test_request_context( + "/workspaces/current/members/invite-email", + method="POST", + json={"emails": ["user@example.com"], "role": "rbac-role-id-abc", "language": "en-US"}, + ): + account = Account(name="tester", email="tester@example.com") + account._current_tenant = tenant + g._login_user = account + g._current_tenant = tenant + response, status_code = MemberInviteEmailApi().post() + + assert status_code == 201 + mock_invite_member.assert_called_once() + call_args = mock_invite_member.call_args + assert call_args.kwargs["role"] == "rbac-role-id-abc" + + @patch("controllers.console.workspace.members.FeatureService.get_features") + @patch("controllers.console.workspace.members.current_account_with_tenant") + @patch("controllers.console.wraps.db") + @patch("libs.login.check_csrf_token", return_value=None) + def test_invite_rbac_disabled_rejects_invalid_role( + self, + mock_csrf, + mock_db, + mock_current_account, + mock_get_features, + app, + ): + """When RBAC is disabled, an invalid role string should be rejected.""" + mock_get_features.return_value = _build_feature_flags() + + tenant = SimpleNamespace(id="tenant-1", name="Test Tenant") + inviter = SimpleNamespace(email="inviter@example.com", current_tenant=tenant, status="active") + mock_current_account.return_value = (inviter, tenant.id) + + with patch("controllers.console.workspace.members.dify_config") as mock_config: + mock_config.RBAC_ENABLED = False + mock_config.CONSOLE_WEB_URL = "https://console.example.com" + with app.test_request_context( + "/workspaces/current/members/invite-email", + method="POST", + json={"emails": ["user@example.com"], "role": "invalid-role", "language": "en-US"}, + ): + account = Account(name="tester", email="tester@example.com") + account._current_tenant = tenant + g._login_user = account + g._current_tenant = tenant + response, status_code = MemberInviteEmailApi().post() + + assert status_code == 400 + assert response["code"] == "invalid-role" + + @patch("controllers.console.workspace.members.FeatureService.get_features") + @patch("controllers.console.workspace.members.current_account_with_tenant") + @patch("controllers.console.wraps.db") + @patch("libs.login.check_csrf_token", return_value=None) + def test_invite_rbac_disabled_rejects_owner_role( + self, + mock_csrf, + mock_db, + mock_current_account, + mock_get_features, + app, + ): + """When RBAC is disabled, owner role should be rejected for invite.""" + mock_get_features.return_value = _build_feature_flags() + + tenant = SimpleNamespace(id="tenant-1", name="Test Tenant") + inviter = SimpleNamespace(email="inviter@example.com", current_tenant=tenant, status="active") + mock_current_account.return_value = (inviter, tenant.id) + + with patch("controllers.console.workspace.members.dify_config") as mock_config: + mock_config.RBAC_ENABLED = False + mock_config.CONSOLE_WEB_URL = "https://console.example.com" + with app.test_request_context( + "/workspaces/current/members/invite-email", + method="POST", + json={"emails": ["user@example.com"], "role": "owner", "language": "en-US"}, + ): + account = Account(name="tester", email="tester@example.com") + account._current_tenant = tenant + g._login_user = account + g._current_tenant = tenant + response, status_code = MemberInviteEmailApi().post() + + assert status_code == 400 + assert response["code"] == "invalid-role" diff --git a/api/tests/unit_tests/controllers/console/test_wraps.py b/api/tests/unit_tests/controllers/console/test_wraps.py index 937505dab28..96f55c85fe5 100644 --- a/api/tests/unit_tests/controllers/console/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/test_wraps.py @@ -2,24 +2,29 @@ from typing import override from unittest.mock import MagicMock, patch import pytest -from flask import Flask +from flask import Flask, request from flask_login import LoginManager, UserMixin from pydantic import BaseModel from werkzeug.exceptions import HTTPException +from controllers.common.wraps import _extract_resource_id from controllers.console.error import NotInitValidateError, NotSetupError, UnauthorizedAndForceLogout from controllers.console.workspace.error import AccountNotInitializedError from controllers.console.wraps import ( + RBACPermission, + RBACResourceScope, account_initialization_required, cloud_edition_billing_enabled, cloud_edition_billing_rate_limit_check, cloud_edition_billing_resource_check, cloud_utm_record, enterprise_license_required, + is_admin_or_owner_required, model_validate, only_edition_cloud, only_edition_enterprise, only_edition_self_hosted, + rbac_permission_required, setup_required, with_current_tenant_id, with_current_user, @@ -165,6 +170,165 @@ class TestCurrentContextInjection: assert Handler().get() == ("user-99", "tenant-456") +class TestRbacPermissionRequired: + """Test enterprise RBAC decorator.""" + + def test_resource_scoped_check_uses_resource_id(self): + current_user = make_account("account-1") + + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_DELETE) + def protected_view(**kwargs): + return "ok" + + with ( + patch("controllers.common.wraps.dify_config.RBAC_ENABLED", True), + patch("controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-1")), + patch("controllers.common.wraps._extract_resource_id", return_value="app-123") as mock_extract, + patch("controllers.common.wraps._is_resource_owned_by_current_user", return_value=False) as mock_owned, + patch("controllers.common.wraps.RBACService.CheckAccess.check", return_value=True) as mock_check, + ): + assert protected_view(app_id="app-123") == "ok" + + mock_extract.assert_called_once_with("app", {"app_id": "app-123"}) + mock_owned.assert_called_once_with("tenant-1", "account-1", "app", "app-123") + mock_check.assert_called_once_with( + "tenant-1", + "account-1", + scene="app_delete", + resource_type="app", + resource_id="app-123", + ) + + def test_workspace_scoped_check_skips_resource_id_extraction(self): + current_user = make_account("account-2") + + @rbac_permission_required( + RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT, resource_required=False + ) + def protected_view(): + return "ok" + + with ( + patch("controllers.common.wraps.dify_config.RBAC_ENABLED", True), + patch("controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-2")), + patch("controllers.common.wraps._extract_resource_id") as mock_extract, + patch("controllers.common.wraps._is_resource_owned_by_current_user", return_value=False) as mock_owned, + patch("controllers.common.wraps.RBACService.CheckAccess.check", return_value=True) as mock_check, + ): + assert protected_view() == "ok" + + mock_extract.assert_not_called() + mock_owned.assert_not_called() + mock_check.assert_called_once_with( + "tenant-2", + "account-2", + scene="dataset_create_and_management", + resource_type="dataset", + resource_id=None, + ) + + def test_workspace_scene_omits_resource_type(self): + current_user = make_account("account-3") + + @rbac_permission_required( + RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False + ) + def protected_view(): + return "ok" + + with ( + patch("controllers.common.wraps.dify_config.RBAC_ENABLED", True), + patch("controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-3")), + patch("controllers.common.wraps.RBACService.CheckAccess.check", return_value=True) as mock_check, + ): + assert protected_view() == "ok" + + mock_check.assert_called_once_with( + "tenant-3", + "account-3", + scene="workspace_role_manage", + resource_type=None, + resource_id=None, + ) + + def test_resource_owned_app_skips_rbac_check(self): + current_user = make_account("account-4") + + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_DELETE) + def protected_view(**kwargs): + return "ok" + + with ( + patch("controllers.common.wraps.dify_config.RBAC_ENABLED", True), + patch("controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-4")), + patch("controllers.common.wraps._extract_resource_id", return_value="app-123"), + patch("controllers.common.wraps._is_resource_owned_by_current_user", return_value=True) as mock_owned, + patch("controllers.common.wraps.RBACService.CheckAccess.check") as mock_check, + ): + assert protected_view(app_id="app-123") == "ok" + + mock_owned.assert_called_once_with("tenant-4", "account-4", "app", "app-123") + mock_check.assert_not_called() + + def test_resource_owned_dataset_skips_rbac_check(self): + current_user = make_account("account-5") + + @rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT) + def protected_view(**kwargs): + return "ok" + + with ( + patch("controllers.common.wraps.dify_config.RBAC_ENABLED", True), + patch("controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-5")), + patch("controllers.common.wraps._extract_resource_id", return_value="dataset-123"), + patch("controllers.common.wraps._is_resource_owned_by_current_user", return_value=True) as mock_owned, + patch("controllers.common.wraps.RBACService.CheckAccess.check") as mock_check, + ): + assert protected_view(dataset_id="dataset-123") == "ok" + + mock_owned.assert_called_once_with("tenant-5", "account-5", "dataset", "dataset-123") + mock_check.assert_not_called() + + def test_extract_resource_id_prefers_path_args(self): + app = Flask(__name__) + + with app.test_request_context("/"): + request.view_args = {"app_id": "view-app"} + + assert _extract_resource_id("app", {"app_id": "path-app"}) == "path-app" + + def test_extract_resource_id_falls_back_to_request_view_args(self): + app = Flask(__name__) + + with app.test_request_context("/"): + request.view_args = {"app_id": "view-app"} + + assert _extract_resource_id("app") == "view-app" + + def test_extract_resource_id_supports_legacy_route_aliases(self): + app = Flask(__name__) + + with app.test_request_context("/apps/app-1/api-keys"): + request.view_args = {"resource_id": "app-1"} + assert _extract_resource_id(RBACResourceScope.APP) == "app-1" + + with app.test_request_context("/agent/agent-1/features"): + request.view_args = {"agent_id": "agent-1"} + assert _extract_resource_id(RBACResourceScope.APP) == "agent-1" + + with app.test_request_context("/datasets/dataset-1/api-keys"): + request.view_args = {"resource_id": "dataset-1"} + assert _extract_resource_id(RBACResourceScope.DATASET) == "dataset-1" + + def test_legacy_admin_decorator_noops_when_rbac_enabled(self): + @is_admin_or_owner_required + def protected_view(): + return "ok" + + with patch("controllers.console.wraps.dify_config.RBAC_ENABLED", True): + assert protected_view() == "ok" + + class TestModelValidationInjection: """Test request model validation decorator.""" diff --git a/api/tests/unit_tests/controllers/console/workspace/test_members.py b/api/tests/unit_tests/controllers/console/workspace/test_members.py index 6c7879c8050..2048e3717d9 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_members.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_members.py @@ -1,4 +1,5 @@ from contextlib import nullcontext +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest @@ -43,8 +44,8 @@ class TestMemberListApi: member.name = "Member" member.email = "member@test.com" member.avatar = "avatar.png" - member.role = "admin" - member.status = "active" + member.current_role = SimpleNamespace(value="admin") + member.status = SimpleNamespace(value="active") members = [member] with ( @@ -55,6 +56,53 @@ class TestMemberListApi: assert status == 200 assert len(result["accounts"]) == 1 + assert result["accounts"][0]["role"] == "admin" + assert result["accounts"][0]["roles"] == [{"id": "admin", "name": "admin"}] + + def test_get_with_rbac_enabled_fetches_roles_in_batch(self, app): + api = MemberListApi() + method = unwrap(api.get) + + tenant = MagicMock(id="tenant-1") + user = MagicMock(id="acct-1", current_tenant=tenant) + member = SimpleNamespace( + id="m1", + name="Member", + email="member@test.com", + avatar=None, + last_login_at=1, + last_active_at=2, + created_at=3, + current_role=SimpleNamespace(value="editor"), + status=SimpleNamespace(value="active"), + ) + role_item = SimpleNamespace( + account_id="m1", + roles=[ + SimpleNamespace(id="workspace.owner", name="Owner"), + SimpleNamespace(id="workspace.editor", name="Editor"), + ], + ) + + with ( + app.test_request_context("/"), + patch("controllers.console.workspace.members.current_account_with_tenant", return_value=(user, "tenant-1")), + patch("controllers.console.workspace.members.dify_config.RBAC_ENABLED", True), + patch("controllers.console.workspace.members.TenantService.get_tenant_members", return_value=[member]), + patch( + "controllers.console.workspace.members.enterprise_rbac_service.RBACService.MemberRoles.batch_get", + return_value=[role_item], + ) as mock_batch_get, + ): + result, status = method(api) + + assert status == 200 + assert result["accounts"][0]["role"] == "editor" + assert result["accounts"][0]["roles"] == [ + {"id": "workspace.owner", "name": "Owner"}, + {"id": "workspace.editor", "name": "Editor"}, + ] + mock_batch_get.assert_called_once_with("tenant-1", "acct-1", ["m1"]) def test_get_no_tenant(self, app: Flask): api = MemberListApi() diff --git a/api/tests/unit_tests/controllers/console/workspace/test_rbac.py b/api/tests/unit_tests/controllers/console/workspace/test_rbac.py new file mode 100644 index 00000000000..1ad9637b7bb --- /dev/null +++ b/api/tests/unit_tests/controllers/console/workspace/test_rbac.py @@ -0,0 +1,503 @@ +"""Controller tests for ``controllers.console.workspace.rbac``. + +The controllers here are thin: almost every non-trivial behaviour lives in +``services.enterprise.rbac_service`` (covered by its own suite). These tests +therefore focus on the Flask-layer concerns the service layer cannot exercise: + +* ``_current_ids`` raises 404 when the session has no tenant. +* The pydantic request models accept / reject bodies as expected. + +We explicitly avoid "happy-path" integration tests through the full +decorator stack — those belong in e2e tests where a real Dify session is +available — to keep this suite fast and resilient to ancillary auth wiring +changes. +""" + +from __future__ import annotations + +import inspect +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from flask import Flask +from pydantic import ValidationError +from werkzeug.exceptions import Forbidden, NotFound + +from controllers.console.workspace import rbac as rbac_mod + + +@pytest.fixture +def app(): + flask_app = Flask(__name__) + flask_app.config["TESTING"] = True + return flask_app + + +def _enabled(enabled: bool): + return patch("controllers.console.workspace.rbac.dify_config.ENTERPRISE_ENABLED", enabled) + + +class TestCurrentIds: + def test_rejects_missing_tenant(self): + with patch("controllers.console.workspace.rbac.current_account_with_tenant") as mock_user: + mock_user.return_value = (SimpleNamespace(id="acct-1"), None) + with pytest.raises(NotFound): + rbac_mod._current_ids() + + def test_returns_tuple(self): + with patch("controllers.console.workspace.rbac.current_account_with_tenant") as mock_user: + mock_user.return_value = (SimpleNamespace(id="acct-1"), "tenant-1") + assert rbac_mod._current_ids() == ("tenant-1", "acct-1") + + +class TestAccessMatrixAccountNames: + def test_hydrates_missing_account_names(self): + items = [ + rbac_mod.svc.AccessMatrixItem( + accounts=[ + {"account_id": "acct-1", "account_name": "Alice", "binding_id": "binding-1"}, + {"account_id": "acct-2", "account_name": "", "binding_id": "binding-2"}, + ] + ) + ] + + with patch( + "controllers.console.workspace.rbac._account_names_by_ids", + return_value={"acct-2": {"name": "Bob", "avatar": "ava"}}, + ) as mock_names: + rbac_mod._hydrate_access_matrix_account_names(items) + + mock_names.assert_called_once_with(["acct-2"]) + assert items[0].accounts[0].account_id == "acct-1" + assert items[0].accounts[0].account_name == "Alice" + assert items[0].accounts[1].account_id == "acct-2" + assert items[0].accounts[1].account_name == "Bob" + assert items[0].accounts[1].avatar == "ava" + + def test_hydrates_resource_user_account_names(self): + items = [ + rbac_mod.svc.ResourceUserAccessPolicies( + account={"account_id": "acct-1", "account_name": ""}, + roles=[], + access_policies=[], + ) + ] + + with patch( + "controllers.console.workspace.rbac._account_names_by_ids", + return_value={"acct-1": {"name": "Alice", "avatar": ""}}, + ): + rbac_mod._hydrate_resource_user_account_names(items) + + assert items[0].account.account_name == "Alice" + + +class TestPydanticModels: + """The internal `_…Request` models are the contract between the browser + and the controllers. We only check non-obvious branches (enum parsing, + missing required fields) — trivial `str` fields are not worth asserting. + """ + + def test_role_upsert_requires_name(self): + with pytest.raises(ValidationError): + rbac_mod._RoleUpsertRequest.model_validate({}) + + def test_role_upsert_to_mutation_preserves_fields(self): + payload = rbac_mod._RoleUpsertRequest.model_validate( + { + "name": "Owner", + "description": "full access", + "permission_keys": ["workspace.member.manage"], + } + ) + mutation = payload.to_mutation() + assert mutation.description == "full access" + assert mutation.permission_keys == ["workspace.member.manage"] + + def test_access_policy_create_parses_resource_type_enum(self): + parsed = rbac_mod._AccessPolicyCreateRequest.model_validate( + { + "name": "Full access", + "resource_type": "app", + "description": "", + "permission_keys": [], + } + ) + assert parsed.resource_type is rbac_mod.svc.RBACResourceType.APP + + def test_access_policy_create_rejects_unknown_resource_type(self): + with pytest.raises(ValidationError): + rbac_mod._AccessPolicyCreateRequest.model_validate({"name": "bad", "resource_type": "unknown"}) + + def test_resource_access_scope_requires_scope(self): + with pytest.raises(ValidationError): + rbac_mod._ResourceAccessScopeRequest.model_validate({}) + + def test_resource_access_scope_defaults_empty_account_ids(self): + parsed = rbac_mod._ResourceAccessScopeRequest.model_validate({"scope": "specific"}) + assert parsed.scope is rbac_mod._AccessScope.SPECIFIC + + def test_resource_access_scope_coerce_null_account_ids(self): + rbac_mod._ResourceAccessScopeRequest.model_validate({"scope": "all"}) + + def test_resource_access_scope_rejects_unknown_scope(self): + with pytest.raises(ValidationError): + rbac_mod._ResourceAccessScopeRequest.model_validate({"scope": "team"}) + + def test_replace_bindings_keeps_role_binding_contract(self): + parsed = rbac_mod._ReplaceBindingsRequest.model_validate({"role_ids": None}) + assert parsed.role_ids == [] + + def test_replace_member_roles_coerce_null_list(self): + parsed = rbac_mod._ReplaceMemberRolesRequest.model_validate({"role_ids": None}) + assert parsed.role_ids == [] + + def test_pagination_query_accepts_page_and_limit_aliases(self): + parsed = rbac_mod._PaginationQuery.model_validate({"page": 3, "limit": 25, "reverse": True}) + assert parsed.page_number == 3 + assert parsed.results_per_page == 25 + assert parsed.reverse is True + + def test_pagination_query_accepts_legacy_inner_names(self): + parsed = rbac_mod._PaginationQuery.model_validate({"page_number": 4, "results_per_page": 30, "reverse": False}) + assert parsed.page_number == 4 + assert parsed.results_per_page == 30 + assert parsed.reverse is False + + +class TestPaginationMapping: + def test_roles_get_returns_legacy_compatible_roles_when_rbac_disabled(self, app): + with ( + app.test_request_context("/workspaces/current/rbac/roles?page=1&limit=2&include_owner=1"), + patch("controllers.console.workspace.rbac.dify_config.RBAC_ENABLED", False), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.Roles.list") as mock_list, + ): + response = inspect.unwrap(rbac_mod.RBACRolesApi.get)(rbac_mod.RBACRolesApi()) + + assert response["data"] == [ + { + "id": "owner", + "tenant_id": "", + "type": "workspace", + "category": "global_system_default", + "name": "owner", + "description": "", + "is_builtin": True, + "permission_keys": list(rbac_mod._LEGACY_ROLE_PERMISSION_KEYS["owner"]), + "role_tag": "owner", + }, + { + "id": "admin", + "tenant_id": "", + "type": "workspace", + "category": "global_system_default", + "name": "admin", + "description": "", + "is_builtin": True, + "permission_keys": list(rbac_mod._LEGACY_ROLE_PERMISSION_KEYS["admin"]), + "role_tag": "", + }, + ] + assert response["pagination"] == { + "total_count": 5, + "per_page": 2, + "current_page": 1, + "total_pages": 3, + } + mock_list.assert_not_called() + + def test_roles_get_filters_out_owner_when_include_owner_is_zero(self, app): + with ( + app.test_request_context("/workspaces/current/rbac/roles?include_owner=0"), + patch("controllers.console.workspace.rbac.dify_config.RBAC_ENABLED", False), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.Roles.list"), + ): + response = inspect.unwrap(rbac_mod.RBACRolesApi.get)(rbac_mod.RBACRolesApi()) + + names = [r["name"] for r in response["data"]] + assert "owner" not in names + + def test_roles_get_keeps_owner_when_include_owner_is_one(self, app): + with ( + app.test_request_context("/workspaces/current/rbac/roles?include_owner=1"), + patch("controllers.console.workspace.rbac.dify_config.RBAC_ENABLED", False), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.Roles.list"), + ): + response = inspect.unwrap(rbac_mod.RBACRolesApi.get)(rbac_mod.RBACRolesApi()) + + names = [r["name"] for r in response["data"]] + assert "owner" in names + + def test_roles_get_filters_out_owner_by_default(self, app): + with ( + app.test_request_context("/workspaces/current/rbac/roles"), + patch("controllers.console.workspace.rbac.dify_config.RBAC_ENABLED", False), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.Roles.list"), + ): + response = inspect.unwrap(rbac_mod.RBACRolesApi.get)(rbac_mod.RBACRolesApi()) + + names = [r["name"] for r in response["data"]] + assert "owner" not in names + + def test_roles_get_forwards_outer_pagination_params(self, app): + with ( + app.test_request_context("/workspaces/current/rbac/roles?page=2&limit=50&reverse=true&include_owner=1"), + patch("controllers.console.workspace.rbac.dify_config.RBAC_ENABLED", True), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.Roles.list") as mock_list, + patch("controllers.console.workspace.rbac._dump", return_value={}), + ): + inspect.unwrap(rbac_mod.RBACRolesApi.get)(rbac_mod.RBACRolesApi()) + + _, kwargs = mock_list.call_args + options = kwargs["options"] + assert options.page_number == 2 + assert options.results_per_page == 50 + assert options.reverse is True + + +class TestResourceAccessScopeBindings: + def test_app_user_access_policy_assignment_forwards_ids(self, app): + with ( + app.test_request_context( + "/workspaces/current/rbac/apps/app-1/users/acct-target/access-policies", + method="PUT", + json={"access_policy_ids": ["policy-1", "policy-2"]}, + ), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-actor")), + patch( + "controllers.console.workspace.rbac.svc.RBACService.AppAccess.replace_user_access_policies" + ) as mock_replace, + patch("controllers.console.workspace.rbac._dump", return_value={}), + ): + inspect.unwrap(rbac_mod.RBACAppUserAccessPolicyAssignmentApi.put)( + rbac_mod.RBACAppUserAccessPolicyAssignmentApi(), + "app-1", + "acct-target", + ) + + tenant_id, actor_id, app_id, target_id, payload = mock_replace.call_args.args + assert (tenant_id, actor_id, app_id, target_id) == ( + "tenant-1", + "acct-actor", + "app-1", + "acct-target", + ) + assert payload.access_policy_ids == ["policy-1", "policy-2"] + + def test_app_member_bindings_delete_forwards_account_ids(self, app): + with ( + app.test_request_context( + "/workspaces/current/rbac/apps/app-1/access-policies/policy-1/member-bindings", + method="DELETE", + json={"account_ids": ["acct-2", "acct-3"]}, + ), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-actor")), + patch("controllers.console.workspace.rbac.svc.RBACService.AppAccess.delete_member_bindings") as mock_delete, + ): + response = inspect.unwrap(rbac_mod.RBACAppMemberBindingsApi.delete)( + rbac_mod.RBACAppMemberBindingsApi(), + "app-1", + "policy-1", + ) + + assert response == {"result": "success"} + tenant_id, actor_id, app_id, policy_id, payload = mock_delete.call_args.args + assert (tenant_id, actor_id, app_id, policy_id) == ("tenant-1", "acct-actor", "app-1", "policy-1") + assert payload.account_ids == ["acct-2", "acct-3"] + + def test_dataset_member_bindings_delete_forwards_account_ids(self, app): + with ( + app.test_request_context( + "/workspaces/current/rbac/datasets/dataset-1/access-policies/policy-1/member-bindings", + method="DELETE", + json={"account_ids": ["acct-2"]}, + ), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-actor")), + patch( + "controllers.console.workspace.rbac.svc.RBACService.DatasetAccess.delete_member_bindings" + ) as mock_delete, + ): + response = inspect.unwrap(rbac_mod.RBACDatasetMemberBindingsApi.delete)( + rbac_mod.RBACDatasetMemberBindingsApi(), + "dataset-1", + "policy-1", + ) + + assert response == {"result": "success"} + tenant_id, actor_id, dataset_id, policy_id, payload = mock_delete.call_args.args + assert (tenant_id, actor_id, dataset_id, policy_id) == ("tenant-1", "acct-actor", "dataset-1", "policy-1") + assert payload.account_ids == ["acct-2"] + + +class TestPaginationForwarding: + def test_role_members_get_forwards_outer_pagination_params(self, app): + with ( + app.test_request_context("/workspaces/current/rbac/roles/role-1/members?page=2&limit=50&reverse=true"), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.Roles.members") as mock_members, + patch("controllers.console.workspace.rbac._dump", return_value={}), + ): + inspect.unwrap(rbac_mod.RBACRoleMembersApi.get)(rbac_mod.RBACRoleMembersApi(), "role-1") + + _, _, role_id = mock_members.call_args.args + _, kwargs = mock_members.call_args + assert role_id == "role-1" + options = kwargs["options"] + assert options.page_number == 2 + assert options.results_per_page == 50 + assert options.reverse is True + + def test_access_policies_get_forwards_outer_pagination_params(self, app): + with ( + app.test_request_context( + "/workspaces/current/rbac/access-policies?resource_type=app&page=3&limit=25&reverse=false" + ), + _enabled(True), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.AccessPolicies.list") as mock_list, + patch("controllers.console.workspace.rbac._dump", return_value={}), + ): + inspect.unwrap(rbac_mod.RBACAccessPoliciesApi.get)(rbac_mod.RBACAccessPoliciesApi()) + + _, kwargs = mock_list.call_args + assert kwargs["resource_type"] == "app" + options = kwargs["options"] + assert options.page_number == 3 + assert options.results_per_page == 25 + assert options.reverse is False + + def test_workspace_app_matrix_forwards_outer_pagination_params(self, app): + with ( + app.test_request_context("/workspaces/current/rbac/workspace/apps/access-policy?page=4&limit=10"), + _enabled(True), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.WorkspaceAccess.app_matrix") as mock_list, + patch("controllers.console.workspace.rbac._dump", return_value={}), + ): + inspect.unwrap(rbac_mod.RBACWorkspaceAppMatrixApi.get)(rbac_mod.RBACWorkspaceAppMatrixApi()) + + _, kwargs = mock_list.call_args + options = kwargs["options"] + assert options.page_number == 4 + assert options.results_per_page == 10 + assert options.reverse is None + + def test_workspace_dataset_matrix_forwards_outer_pagination_params(self, app): + with ( + app.test_request_context( + "/workspaces/current/rbac/workspace/datasets/access-policy?page=5&limit=15&reverse=true" + ), + _enabled(True), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.WorkspaceAccess.dataset_matrix") as mock_list, + patch("controllers.console.workspace.rbac._dump", return_value={}), + ): + inspect.unwrap(rbac_mod.RBACWorkspaceDatasetMatrixApi.get)(rbac_mod.RBACWorkspaceDatasetMatrixApi()) + + _, kwargs = mock_list.call_args + options = kwargs["options"] + assert options.page_number == 5 + assert options.results_per_page == 15 + assert options.reverse is True + + +class TestAccessPolicyBindingLockUnlock: + def test_lock_forwards_binding_id(self, app): + with ( + app.test_request_context("/workspaces/current/rbac/access-policy-bindings/binding-1/lock", method="PUT"), + _enabled(True), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.AccessPolicyBindings.lock") as mock_lock, + patch("controllers.console.workspace.rbac._dump", return_value={}), + ): + inspect.unwrap(rbac_mod.RBACAccessPolicyBindingLockApi.put)( + rbac_mod.RBACAccessPolicyBindingLockApi(), "binding-1" + ) + + mock_lock.assert_called_once_with("tenant-1", "acct-1", "binding-1") + + def test_unlock_forwards_binding_id(self, app): + with ( + app.test_request_context("/workspaces/current/rbac/access-policy-bindings/binding-1/unlock", method="PUT"), + _enabled(True), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.AccessPolicyBindings.unlock") as mock_unlock, + patch("controllers.console.workspace.rbac._dump", return_value={}), + ): + inspect.unwrap(rbac_mod.RBACAccessPolicyBindingUnlockApi.put)( + rbac_mod.RBACAccessPolicyBindingUnlockApi(), "binding-1" + ) + + mock_unlock.assert_called_once_with("tenant-1", "acct-1", "binding-1") + + +class TestRoleCopy: + def test_role_copy_forwards_path_id(self, app): + with ( + app.test_request_context("/workspaces/current/rbac/roles/role-1/copy", method="POST", json={}), + _enabled(True), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch("controllers.console.workspace.rbac.svc.RBACService.Roles.copy") as mock_copy, + patch("controllers.console.workspace.rbac._dump", return_value={}), + ): + inspect.unwrap(rbac_mod.RBACRoleCopyApi.post)(rbac_mod.RBACRoleCopyApi(), "role-1") + + mock_copy.assert_called_once_with("tenant-1", "acct-1", "role-1", copy_member=True) + + +class TestWorkspaceRbacGuards: + def test_role_create_requires_workspace_role_manage(self, app): + with ( + app.test_request_context( + "/workspaces/current/rbac/roles", + method="POST", + json={"name": "test_role", "permission_keys": []}, + ), + patch("libs.login.dify_config.LOGIN_DISABLED", True), + patch("controllers.console.wraps.dify_config.RBAC_ENABLED", True), + patch( + "controllers.common.wraps.current_account_with_tenant", + return_value=(SimpleNamespace(id="acct-1"), "tenant-1"), + ), + patch("controllers.common.wraps.RBACService.CheckAccess.check", return_value=False), + patch("controllers.console.workspace.rbac.svc.RBACService.Roles.create") as mock_create, + ): + with pytest.raises(Forbidden): + rbac_mod.RBACRolesApi().post() + + mock_create.assert_not_called() + + def test_access_policy_create_requires_workspace_role_manage(self, app): + with ( + app.test_request_context( + "/workspaces/current/rbac/access-policies", + method="POST", + json={"name": "full_access", "resource_type": "app", "permission_keys": []}, + ), + patch("libs.login.dify_config.LOGIN_DISABLED", True), + patch("controllers.console.wraps.dify_config.RBAC_ENABLED", True), + patch( + "controllers.common.wraps.current_account_with_tenant", + return_value=(SimpleNamespace(id="acct-1"), "tenant-1"), + ), + patch("controllers.common.wraps.RBACService.CheckAccess.check", return_value=False), + patch("controllers.console.workspace.rbac.svc.RBACService.AccessPolicies.create") as mock_create, + ): + with pytest.raises(Forbidden): + rbac_mod.RBACAccessPoliciesApi().post() + + mock_create.assert_not_called() + + +class TestDumpHelper: + def test_dump_returns_plain_dict(self): + role = rbac_mod.svc.RBACRole(id="role-1", type="workspace", name="Owner") + dumped = rbac_mod._dump(role) + assert isinstance(dumped, dict) + assert "role_id" not in dumped diff --git a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py index cc581c0c759..c958034126d 100644 --- a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py +++ b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin_wraps.py @@ -237,9 +237,10 @@ class TestGetUserTenant: monkeypatch.setattr(app, "login_manager", MagicMock(), raising=False) with patch("controllers.inner_api.plugin.wraps.db.session.get") as mock_get: with patch("controllers.inner_api.plugin.wraps.get_user") as mock_get_user: - mock_get.return_value = mock_tenant - mock_get_user.return_value = mock_user - result = protected_view() + with patch("controllers.inner_api.plugin.wraps.user_logged_in"): + mock_get.return_value = mock_tenant + mock_get_user.return_value = mock_user + result = protected_view() # Assert assert result["tenant"] == mock_tenant @@ -293,9 +294,10 @@ class TestGetUserTenant: monkeypatch.setattr(app, "login_manager", MagicMock(), raising=False) with patch("controllers.inner_api.plugin.wraps.db.session.get") as mock_get: with patch("controllers.inner_api.plugin.wraps.get_user") as mock_get_user: - mock_get.return_value = mock_tenant - mock_get_user.return_value = mock_user - result = protected_view() + with patch("controllers.inner_api.plugin.wraps.user_logged_in"): + mock_get.return_value = mock_tenant + mock_get_user.return_value = mock_user + result = protected_view() # Assert assert result["tenant"] == mock_tenant diff --git a/api/tests/unit_tests/controllers/openapi/__init__.py b/api/tests/unit_tests/controllers/openapi/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/api/tests/unit_tests/fields/test_dataset_fields.py b/api/tests/unit_tests/fields/test_dataset_fields.py index 125bcb26cf6..921e3882a96 100644 --- a/api/tests/unit_tests/fields/test_dataset_fields.py +++ b/api/tests/unit_tests/fields/test_dataset_fields.py @@ -82,6 +82,14 @@ def _dump_dataset_detail(payload): return DatasetDetailResponse.model_validate(payload).model_dump(mode="json") +def test_dataset_detail_preserves_permission_keys(): + response = _dump_dataset_detail( + _dataset_detail_payload(permission_keys=["dataset.acl.readonly", "dataset.acl.edit"]) + ) + + assert response["permission_keys"] == ["dataset.acl.readonly", "dataset.acl.edit"] + + def test_dataset_detail_expands_legacy_null_nested_fields(): response = _dump_dataset_detail( _dataset_detail_payload( diff --git a/api/tests/unit_tests/models/test_account_models.py b/api/tests/unit_tests/models/test_account_models.py index 25933dd15b6..512c043b0c8 100644 --- a/api/tests/unit_tests/models/test_account_models.py +++ b/api/tests/unit_tests/models/test_account_models.py @@ -12,6 +12,7 @@ This test suite covers: import base64 import secrets from datetime import UTC, datetime +from unittest.mock import patch from uuid import uuid4 import pytest @@ -347,7 +348,15 @@ class TestAccountRolePermissions: account.role = TenantAccountRole.ADMIN # Act & Assert - assert account.is_admin_or_owner + with patch("models.account.dify_config.RBAC_ENABLED", False): + assert account.is_admin_or_owner + + def test_is_admin_or_owner_with_rbac_enabled(self): + account = Account(name="Test User", email="test@example.com") + account.role = TenantAccountRole.NORMAL + + with patch("models.account.dify_config.RBAC_ENABLED", True): + assert account.is_admin_or_owner def test_is_admin_or_owner_with_owner_role(self): """Test is_admin_or_owner property with owner role.""" @@ -383,8 +392,16 @@ class TestAccountRolePermissions: owner_account.role = TenantAccountRole.OWNER # Act & Assert - assert admin_account.is_admin - assert not owner_account.is_admin + with patch("models.account.dify_config.RBAC_ENABLED", False): + assert admin_account.is_admin + assert not owner_account.is_admin + + def test_is_admin_with_rbac_enabled(self): + account = Account(name="Test User", email="test@example.com") + account.role = TenantAccountRole.NORMAL + + with patch("models.account.dify_config.RBAC_ENABLED", True): + assert account.is_admin def test_has_edit_permission_with_editing_roles(self): """Test has_edit_permission property with roles that have edit permission.""" @@ -400,7 +417,15 @@ class TestAccountRolePermissions: account.role = role # Act & Assert - assert account.has_edit_permission, f"Role {role} should have edit permission" + with patch("models.account.dify_config.RBAC_ENABLED", False): + assert account.has_edit_permission, f"Role {role} should have edit permission" + + def test_has_edit_permission_with_rbac_enabled(self): + account = Account(name="Test User", email="test@example.com") + account.role = TenantAccountRole.NORMAL + + with patch("models.account.dify_config.RBAC_ENABLED", True): + assert account.has_edit_permission def test_has_edit_permission_without_editing_roles(self): """Test has_edit_permission property with roles that don't have edit permission.""" @@ -415,7 +440,8 @@ class TestAccountRolePermissions: account.role = role # Act & Assert - assert not account.has_edit_permission, f"Role {role} should not have edit permission" + with patch("models.account.dify_config.RBAC_ENABLED", False): + assert not account.has_edit_permission, f"Role {role} should not have edit permission" def test_is_dataset_editor_property(self): """Test is_dataset_editor property.""" @@ -432,12 +458,21 @@ class TestAccountRolePermissions: account.role = role # Act & Assert - assert account.is_dataset_editor, f"Role {role} should have dataset edit permission" + with patch("models.account.dify_config.RBAC_ENABLED", False): + assert account.is_dataset_editor, f"Role {role} should have dataset edit permission" # Test normal role doesn't have dataset edit permission normal_account = Account(name="Normal User", email="normal@example.com") normal_account.role = TenantAccountRole.NORMAL - assert not normal_account.is_dataset_editor + with patch("models.account.dify_config.RBAC_ENABLED", False): + assert not normal_account.is_dataset_editor + + def test_is_dataset_editor_with_rbac_enabled(self): + account = Account(name="Test User", email="test@example.com") + account.role = TenantAccountRole.NORMAL + + with patch("models.account.dify_config.RBAC_ENABLED", True): + assert account.is_dataset_editor def test_is_dataset_operator_property(self): """Test is_dataset_operator property.""" @@ -449,8 +484,16 @@ class TestAccountRolePermissions: normal_account.role = TenantAccountRole.NORMAL # Act & Assert - assert dataset_operator.is_dataset_operator - assert not normal_account.is_dataset_operator + with patch("models.account.dify_config.RBAC_ENABLED", False): + assert dataset_operator.is_dataset_operator + assert not normal_account.is_dataset_operator + + def test_is_dataset_operator_with_rbac_enabled(self): + account = Account(name="Test User", email="test@example.com") + account.role = TenantAccountRole.NORMAL + + with patch("models.account.dify_config.RBAC_ENABLED", True): + assert account.is_dataset_operator def test_current_role_property(self): """Test current_role property.""" diff --git a/api/tests/unit_tests/services/enterprise/test_rbac_service.py b/api/tests/unit_tests/services/enterprise/test_rbac_service.py new file mode 100644 index 00000000000..aa4780af0b4 --- /dev/null +++ b/api/tests/unit_tests/services/enterprise/test_rbac_service.py @@ -0,0 +1,797 @@ +"""Unit tests for services.enterprise.rbac_service. + +The enterprise RBAC client is almost pure glue: each method turns a single +``EnterpriseRequest.send_inner_rbac_request`` call into a pydantic response +model. Rather than spinning up an HTTP server we monkeypatch that helper and +assert on the arguments it received; that catches both routing regressions +(wrong method / wrong path / wrong params) and model-shape regressions in +one place. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from flask import Flask + +from services.enterprise import rbac_service as svc + +MODULE = "services.enterprise.rbac_service" + + +@pytest.fixture +def mock_send(): + with patch(f"{MODULE}.EnterpriseRequest.send_inner_rbac_request") as send: + yield send + + +def _call_args(send: MagicMock) -> SimpleNamespace: + """Return the most recent (method, endpoint, kwargs) sent to the mock.""" + send.assert_called_once() + args, kwargs = send.call_args + return SimpleNamespace(method=args[0], endpoint=args[1], **kwargs) + + +class TestCatalog: + def test_workspace_catalog(self, mock_send: MagicMock): + mock_send.return_value = {"groups": [{"group_key": "workspace", "group_name": "工作空间", "permissions": []}]} + + out = svc.RBACService.Catalog.workspace("tenant-1", account_id="acct-1") + + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/role-permissions/catalog" + assert call.tenant_id == "tenant-1" + assert call.account_id == "acct-1" + assert call.json is None + assert call.params is None + assert len(out.groups) == 1 + assert out.groups[0].group_key == "workspace" + + def test_app_catalog_endpoint(self, mock_send: MagicMock): + mock_send.return_value = {"groups": []} + svc.RBACService.Catalog.app("tenant-1") + assert mock_send.call_args.args[1] == "/rbac/role-permissions/catalog/app" + + def test_dataset_catalog_endpoint(self, mock_send: MagicMock): + mock_send.return_value = {"groups": []} + svc.RBACService.Catalog.dataset("tenant-1") + assert mock_send.call_args.args[1] == "/rbac/role-permissions/catalog/dataset" + + +class TestRoles: + def test_list_forwards_pagination_options(self, mock_send: MagicMock): + mock_send.return_value = { + "data": [ + { + "id": "role-1", + "tenant_id": "tenant-1", + "type": "workspace", + "category": "global_custom", + "name": "Owner", + "permission_keys": ["workspace.member.manage"], + } + ], + "pagination": {"total_count": 1, "per_page": 20, "current_page": 1, "total_pages": 1}, + } + + out = svc.RBACService.Roles.list( + "tenant-1", + "acct-1", + options=svc.ListOption(page_number=2, results_per_page=50, reverse=True), + ) + + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/roles" + assert call.params == {"page_number": 2, "results_per_page": 50, "reverse": "true"} + assert out.pagination + assert out.pagination.total_count == 1 + + def test_list_omits_params_when_default(self, mock_send: MagicMock): + mock_send.return_value = {"data": [], "pagination": None} + svc.RBACService.Roles.list("tenant-1") + assert _call_args(mock_send).params is None + + def test_list_forwards_include_owner(self, mock_send: MagicMock): + mock_send.return_value = {"data": [], "pagination": None} + + svc.RBACService.Roles.list("tenant-1", include_owner=1) + + assert _call_args(mock_send).params == {"include_owner": 1} + + def test_list_coerces_null_permission_keys(self, mock_send: MagicMock): + mock_send.return_value = { + "data": [ + { + "id": "role-1", + "tenant_id": "tenant-1", + "type": "workspace", + "category": "global_custom", + "name": "Owner", + "permission_keys": None, + } + ], + "pagination": None, + } + + out = svc.RBACService.Roles.list("tenant-1") + + assert out.data[0].permission_keys == [] + + def test_get_passes_id_query_param(self, mock_send: MagicMock): + mock_send.return_value = {"id": "role-1", "type": "workspace", "name": "Owner"} + svc.RBACService.Roles.get("tenant-1", "acct-1", "role-1") + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/roles/item" + assert call.params == {"id": "role-1"} + + def test_members_forwards_role_id_and_pagination(self, mock_send: MagicMock): + mock_send.return_value = { + "role_id": "role-1", + "data": [{"account_id": "acct-2", "account_name": "Alice"}], + "pagination": {"total_count": 1, "per_page": 20, "current_page": 1, "total_pages": 1}, + } + + out = svc.RBACService.Roles.members( + "tenant-1", + "acct-1", + "role-1", + options=svc.ListOption(page_number=1, results_per_page=20), + ) + + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/roles/members" + assert call.params == {"page_number": 1, "results_per_page": 20, "role_id": "role-1"} + assert out.data[0].account_id == "acct-2" + assert out.data[0].account_name == "Alice" + assert out.pagination is not None + assert out.pagination.total_count == 1 + + def test_create_sends_body(self, mock_send: MagicMock): + mock_send.return_value = {"id": "role-1", "type": "workspace", "name": "Owner"} + payload = svc.RoleMutation(name="Owner", description="full access", permission_keys=["workspace.member.manage"]) + svc.RBACService.Roles.create("tenant-1", "acct-1", payload) + + call = _call_args(mock_send) + assert call.method == "POST" + assert call.endpoint == "/rbac/roles" + assert call.json == { + "name": "Owner", + "description": "full access", + "permission_keys": ["workspace.member.manage"], + "type": "workspace", + } + + def test_update_sends_id_param_and_body(self, mock_send: MagicMock): + mock_send.return_value = {"id": "role-1", "type": "workspace", "name": "Owner"} + payload = svc.RoleMutation(name="Owner", permission_keys=["x"]) + svc.RBACService.Roles.update("tenant-1", "acct-1", "role-1", payload) + + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/roles/item" + assert call.params == {"id": "role-1"} + assert call.json == {"name": "Owner", "description": "", "permission_keys": ["x"], "type": "workspace"} + + def test_delete_uses_delete_method(self, mock_send: MagicMock): + mock_send.return_value = {"message": "success"} + svc.RBACService.Roles.delete("tenant-1", None, "role-1") + + call = _call_args(mock_send) + assert call.method == "DELETE" + assert call.endpoint == "/rbac/roles/item" + assert call.params == {"id": "role-1"} + assert call.account_id is None + + def test_copy_sends_post_with_id_param(self, mock_send: MagicMock): + mock_send.return_value = {"id": "role-1-copy", "type": "workspace", "name": "Owner copy"} + svc.RBACService.Roles.copy("tenant-1", "acct-1", "role-1") + + call = _call_args(mock_send) + assert call.method == "POST" + assert call.endpoint == "/rbac/roles/copy" + assert call.params == {"id": "role-1"} + assert call.account_id == "acct-1" + + +class TestAccessPolicyBindings: + def test_lock_sends_put_with_binding_id(self, mock_send: MagicMock): + mock_send.return_value = {"binding_id": "binding-1", "is_locked": True} + + out = svc.RBACService.AccessPolicyBindings.lock("tenant-1", "acct-1", "binding-1") + + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/access-policy-bindings/lock" + assert call.json == {"binding_id": "binding-1"} + assert out.binding_id == "binding-1" + assert out.is_locked is True + + def test_unlock_sends_put_with_binding_id(self, mock_send: MagicMock): + mock_send.return_value = {"binding_id": "binding-1", "is_locked": False} + + out = svc.RBACService.AccessPolicyBindings.unlock("tenant-1", "acct-1", "binding-1") + + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/access-policy-bindings/unlock" + assert call.json == {"binding_id": "binding-1"} + assert out.binding_id == "binding-1" + assert out.is_locked is False + + +class TestAccessPolicies: + def test_list_filters_by_resource_type(self, mock_send: MagicMock): + mock_send.return_value = {"data": [], "pagination": None} + svc.RBACService.AccessPolicies.list( + "tenant-1", + "acct-1", + resource_type=svc.RBACResourceType.APP, + options=svc.ListOption(page_number=1), + ) + call = _call_args(mock_send) + assert call.endpoint == "/rbac/access-policies" + assert call.params == {"page_number": 1, "resource_type": "app"} + + def test_copy_sends_post_with_id_param(self, mock_send: MagicMock): + mock_send.return_value = { + "id": "policy-1-copy", + "resource_type": "app", + "name": "Full access copy", + } + svc.RBACService.AccessPolicies.copy("tenant-1", "acct-1", "policy-1") + call = _call_args(mock_send) + assert call.method == "POST" + assert call.endpoint == "/rbac/access-policies/copy" + assert call.params == {"id": "policy-1"} + + def test_create_serialises_resource_type_enum(self, mock_send: MagicMock): + mock_send.return_value = {"id": "policy-1", "resource_type": "dataset", "name": "KB only"} + payload = svc.AccessPolicyCreate( + name="KB only", + resource_type=svc.RBACResourceType.DATASET, + permission_keys=["dataset.acl.readonly"], + ) + svc.RBACService.AccessPolicies.create("tenant-1", "acct-1", payload) + call = _call_args(mock_send) + assert call.method == "POST" + assert call.json == { + "name": "KB only", + "resource_type": "dataset", + "description": "", + "permission_keys": ["dataset.acl.readonly"], + } + + +class TestResourceAccess: + def test_app_whitelist_resources(self, mock_send: MagicMock): + mock_send.return_value = {"unrestricted": True, "resource_ids": ["app-1", "app-2"]} + + out = svc.RBACService.AppAccess.whitelist_resources("tenant-1", "acct-1") + + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/apps/whitelist/resources" + assert call.params is None + assert out.unrestricted is True + assert out.resource_ids == ["app-1", "app-2"] + + def test_dataset_whitelist_resources(self, mock_send: MagicMock): + mock_send.return_value = {"resource_ids": ["dataset-1"]} + + out = svc.RBACService.DatasetAccess.whitelist_resources("tenant-1", "acct-1") + + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/datasets/whitelist/resources" + assert call.params is None + assert out.resource_ids == ["dataset-1"] + + def test_app_user_access_policies(self, mock_send: MagicMock): + mock_send.return_value = { + "scope": "app", + "data": [ + { + "account": {"account_id": "acct-1", "account_name": "Alice"}, + "roles": [ + { + "id": "role-1", + "type": "workspace", + "name": "Editor", + "permission_keys": [], + } + ], + "access_policies": [ + { + "id": "policy-1", + "resource_type": "app", + "name": "Can edit", + } + ], + } + ], + } + + out = svc.RBACService.AppAccess.user_access_policies("tenant-1", "acct-1", "app-1") + + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/apps/user-access-policies" + assert call.params == {"app_id": "app-1"} + assert out.data[0].account.account_name == "Alice" + assert out.data[0].roles[0].id == "role-1" + assert out.data[0].access_policies[0].id == "policy-1" + + def test_dataset_replace_user_access_policies(self, mock_send: MagicMock): + mock_send.return_value = { + "access_policies": [{"id": "policy-1", "resource_type": "dataset", "name": "Can edit"}] + } + payload = svc.ReplaceUserAccessPolicies(access_policy_ids=["policy-1"]) + + out = svc.RBACService.DatasetAccess.replace_user_access_policies( + "tenant-1", "acct-actor", "dataset-1", "acct-target", payload + ) + + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/datasets/user-access-policies" + assert call.params == {"dataset_id": "dataset-1", "account_id": "acct-target"} + assert call.json == {"access_policy_ids": ["policy-1"]} + assert out.access_policies[0].id == "policy-1" + + def test_dataset_whitelist(self, mock_send: MagicMock): + mock_send.return_value = {"account_ids": ["acct-2"]} + + out = svc.RBACService.DatasetAccess.whitelist("tenant-1", "acct-1", "dataset-1") + + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/datasets/whitelist" + assert call.params == {"dataset_id": "dataset-1"} + assert out.account_ids == ["acct-2"] + + def test_app_matrix(self, mock_send: MagicMock): + mock_send.return_value = {"resource_id": "app-1", "items": []} + out = svc.RBACService.AppAccess.matrix("tenant-1", "acct-1", "app-1") + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/apps/access-policy" + assert call.params == {"app_id": "app-1"} + assert out.app_id == "app-1" + + def test_dataset_matrix(self, mock_send: MagicMock): + mock_send.return_value = {"resource_id": "dataset-1", "items": []} + out = svc.RBACService.DatasetAccess.matrix("tenant-1", "acct-1", "dataset-1") + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/datasets/access-policy" + assert call.params == {"dataset_id": "dataset-1"} + assert out.dataset_id == "dataset-1" + + def test_app_role_bindings_preserve_role_name(self, mock_send: MagicMock): + mock_send.return_value = { + "data": [ + { + "id": "binding-1", + "tenant_id": "tenant-1", + "access_policy_id": "policy-1", + "resource_type": "app", + "resource_id": "app-1", + "role_id": "role-1", + "role_name": "Owner", + } + ] + } + + out = svc.RBACService.AppAccess.list_role_bindings("tenant-1", "acct-1", "app-1", "policy-1") + + assert out.data[0].role_name == "Owner" + + def test_app_member_bindings_preserve_account_name(self, mock_send: MagicMock): + mock_send.return_value = { + "data": [ + { + "id": "binding-1", + "tenant_id": "tenant-1", + "access_policy_id": "policy-1", + "resource_type": "app", + "resource_id": "app-1", + "account_id": "acct-1", + "account_name": "Alice", + } + ] + } + + out = svc.RBACService.AppAccess.list_member_bindings("tenant-1", "acct-1", "app-1", "policy-1") + + assert out.data[0].account_name == "Alice" + + def test_app_delete_member_bindings_uses_delete_method(self, mock_send: MagicMock): + mock_send.return_value = None + payload = svc.DeleteMemberBindings(account_ids=["acct-2", "acct-3"]) + svc.RBACService.AppAccess.delete_member_bindings("tenant-1", "acct-1", "app-1", "policy-1", payload) + call = _call_args(mock_send) + assert call.method == "DELETE" + assert call.endpoint == "/rbac/apps/access-policy/member-bindings" + assert call.params == {"app_id": "app-1", "policy_id": "policy-1"} + assert call.json == {"account_ids": ["acct-2", "acct-3"]} + + def test_app_replace_bindings(self, mock_send: MagicMock): + mock_send.return_value = {"data": []} + payload = svc.ReplaceBindings(role_ids=["workspace.owner"], account_ids=["acct-2"]) + svc.RBACService.AppAccess.replace_bindings("tenant-1", "acct-1", "app-1", "policy-1", payload) + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/apps/access-policy/bindings" + assert call.params == {"app_id": "app-1", "policy_id": "policy-1"} + assert call.json == {"role_ids": ["workspace.owner"], "account_ids": ["acct-2"]} + + def test_dataset_replace_bindings(self, mock_send: MagicMock): + mock_send.return_value = {"data": []} + payload = svc.ReplaceBindings(role_ids=["workspace.editor"], account_ids=["acct-2"]) + svc.RBACService.DatasetAccess.replace_bindings("tenant-1", "acct-1", "ds-1", "policy-1", payload) + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/datasets/access-policy/bindings" + assert call.params == {"dataset_id": "ds-1", "policy_id": "policy-1"} + assert call.json == {"role_ids": ["workspace.editor"], "account_ids": ["acct-2"]} + + def test_dataset_delete_member_bindings_uses_delete_method(self, mock_send: MagicMock): + mock_send.return_value = None + payload = svc.DeleteMemberBindings(account_ids=["acct-2"]) + svc.RBACService.DatasetAccess.delete_member_bindings("tenant-1", "acct-1", "ds-1", "policy-1", payload) + call = _call_args(mock_send) + assert call.method == "DELETE" + assert call.endpoint == "/rbac/datasets/access-policy/member-bindings" + assert call.params == {"dataset_id": "ds-1", "policy_id": "policy-1"} + assert call.json == {"account_ids": ["acct-2"]} + + +class TestWorkspaceAccess: + def test_app_matrix(self, mock_send: MagicMock): + mock_send.return_value = { + "items": [], + "pagination": {"total_count": 1, "per_page": 20, "current_page": 2, "total_pages": 1}, + } + out = svc.RBACService.WorkspaceAccess.app_matrix( + "tenant-1", + options=svc.ListOption(page_number=2, results_per_page=20), + ) + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/workspace/apps/access-policy" + assert call.params == {"page_number": 2, "results_per_page": 20} + assert out.pagination + assert out.pagination.current_page == 2 + + def test_dataset_matrix(self, mock_send: MagicMock): + mock_send.return_value = {"items": []} + svc.RBACService.WorkspaceAccess.dataset_matrix("tenant-1") + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/workspace/datasets/access-policy" + assert call.params is None + + def test_workspace_matrix_coerces_null_bindings(self, mock_send: MagicMock): + mock_send.return_value = { + "items": [ + { + "policy": { + "id": "policy-1", + "resource_type": "app", + "name": "Workspace App Access", + }, + "roles": None, + "accounts": None, + } + ], + "pagination": None, + } + + out = svc.RBACService.WorkspaceAccess.app_matrix("tenant-1") + + assert out.items[0].roles == [] + assert out.items[0].accounts == [] + + def test_workspace_app_replace_bindings(self, mock_send: MagicMock): + mock_send.return_value = {"data": []} + payload = svc.ReplaceBindings(role_ids=["workspace.editor"], account_ids=["acct-2"]) + svc.RBACService.WorkspaceAccess.replace_app_bindings("tenant-1", "acct-1", "policy-1", payload) + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/workspace/apps/access-policy/bindings" + assert call.params == {"policy_id": "policy-1"} + assert call.json == {"role_ids": ["workspace.editor"], "account_ids": ["acct-2"]} + + def test_workspace_dataset_replace_bindings(self, mock_send: MagicMock): + mock_send.return_value = {"data": []} + payload = svc.ReplaceBindings(role_ids=["workspace.editor"], account_ids=["acct-2"]) + svc.RBACService.WorkspaceAccess.replace_dataset_bindings("tenant-1", "acct-1", "policy-1", payload) + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/workspace/datasets/access-policy/bindings" + assert call.params == {"policy_id": "policy-1"} + assert call.json == {"role_ids": ["workspace.editor"], "account_ids": ["acct-2"]} + + def test_workspace_app_matrix_forwards_language_query_param(self, mock_send: MagicMock): + mock_send.return_value = {"items": [], "pagination": None} + app = Flask(__name__) + with app.test_request_context("/?language=en"): + svc.RBACService.WorkspaceAccess.app_matrix("tenant-1") + + call = _call_args(mock_send) + assert call.params == {"language": "en"} + + +class TestMyPermissions: + def test_resource_snapshot_maps_defaults_and_overrides(self): + snapshot = svc.ResourcePermissionSnapshot( + default_permission_keys=["app.acl.view_layout"], + overrides=[ + svc.ResourcePermissionKeys( + resource_id="app-2", + permission_keys=["app.acl.view_layout", "app.acl.edit"], + ) + ], + ) + + assert snapshot.permission_keys_by_resource_ids(["app-1", "app-2"]) == { + "app-1": ["app.acl.view_layout"], + "app-2": ["app.acl.view_layout", "app.acl.edit"], + } + + def test_get_without_payload_uses_get(self, mock_send: MagicMock): + mock_send.return_value = { + "workspace": {"permission_keys": ["workspace.member.manage"]}, + "app": {"default_permission_keys": ["app.acl.view_layout", "app.acl.test_and_run"], "overrides": []}, + "dataset": {"default_permission_keys": [], "overrides": []}, + } + + with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): + out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1") + + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/my-permissions" + assert call.json is None + assert call.params is None + assert out.workspace.permission_keys == ["workspace.member.manage"] + + @pytest.mark.parametrize( + ("role", "workspace_keys", "app_keys", "dataset_keys"), + [ + ( + "owner", + svc._LEGACY_WORKSPACE_OWNER_KEYS, + svc._LEGACY_APP_OWNER_KEYS, + svc._LEGACY_DATASET_OWNER_KEYS, + ), + ( + "admin", + svc._LEGACY_WORKSPACE_ADMIN_KEYS, + svc._LEGACY_APP_ADMIN_KEYS, + svc._LEGACY_DATASET_ADMIN_KEYS, + ), + ( + "editor", + svc._LEGACY_WORKSPACE_EDITOR_KEYS, + svc._LEGACY_APP_EDITOR_KEYS, + svc._LEGACY_DATASET_EDITOR_KEYS, + ), + ( + "normal", + svc._LEGACY_WORKSPACE_NORMAL_KEYS, + svc._LEGACY_APP_NORMAL_KEYS, + [], + ), + ( + "dataset_operator", + svc._LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS, + [], + svc._LEGACY_DATASET_DATASET_OPERATOR_KEYS, + ), + ], + ) + def test_get_uses_legacy_role_permissions_when_rbac_disabled( + self, + mock_send: MagicMock, + role: str, + workspace_keys: list[str], + app_keys: list[str], + dataset_keys: list[str], + ): + mock_session = MagicMock() + mock_session.__enter__.return_value = mock_session + mock_session.scalar.return_value = role + with ( + patch(f"{MODULE}.dify_config.RBAC_ENABLED", False), + patch(f"{MODULE}.session_factory.create_session", return_value=mock_session), + ): + out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1") + + mock_send.assert_not_called() + assert out.workspace.permission_keys == workspace_keys + assert out.app.default_permission_keys == app_keys + assert out.dataset.default_permission_keys == dataset_keys + assert out.app.overrides == [] + assert out.dataset.overrides == [] + + def test_get_returns_empty_when_role_missing_and_rbac_disabled(self, mock_send: MagicMock): + mock_session = MagicMock() + mock_session.__enter__.return_value = mock_session + mock_session.scalar.return_value = None + with ( + patch(f"{MODULE}.dify_config.RBAC_ENABLED", False), + patch(f"{MODULE}.session_factory.create_session", return_value=mock_session), + ): + out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1") + + mock_send.assert_not_called() + assert out.workspace.permission_keys == [] + assert out.app.default_permission_keys == [] + assert out.dataset.default_permission_keys == [] + + def test_get_with_single_resource_filters(self, mock_send: MagicMock): + mock_send.return_value = { + "workspace": {"permission_keys": []}, + "app": { + "default_permission_keys": [], + "overrides": [{"resource_id": "app-1", "permission_keys": ["app.acl.edit"]}], + }, + "dataset": {"default_permission_keys": [], "overrides": []}, + } + + with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): + out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", app_id="app-1") + + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/my-permissions" + assert call.params == {"app_id": "app-1"} + assert out.app.overrides[0].resource_id == "app-1" + + +class TestMemberRoles: + def test_get(self, mock_send: MagicMock): + mock_send.return_value = { + "account_id": "acct-2", + "roles": [ + { + "id": "role-1", + "type": "workspace", + "name": "Member", + } + ], + } + out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2") + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/members/rbac-roles" + assert call.params == {"account_id": "acct-2"} + assert out.account_id == "acct-2" + assert out.roles[0].name == "Member" + + def test_replace(self, mock_send: MagicMock): + mock_send.return_value = {"account_id": "acct-2", "roles": []} + svc.RBACService.MemberRoles.replace( + "tenant-1", "acct-1", "acct-2", role_ids=["workspace.owner", "workspace.editor"] + ) + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/members/rbac-roles" + assert call.params == {"account_id": "acct-2"} + assert call.json == {"role_ids": ["workspace.owner", "workspace.editor"]} + + def test_batch_get(self, mock_send: MagicMock): + mock_send.return_value = { + "acct-2": [ + {"id": "role-1", "name": "Admin", "type": "workspace"}, + {"id": "role-2", "name": "Editor", "type": "workspace"}, + ], + "acct-3": [], + } + + out = svc.RBACService.MemberRoles.batch_get("tenant-1", "acct-1", ["acct-2", "acct-3"]) + + call = _call_args(mock_send) + assert call.method == "POST" + assert call.endpoint == "/rbac/members/rbac-roles/batch" + assert call.json == {"member_ids": ["acct-2", "acct-3"]} + assert out[0].account_id == "acct-2" + assert len(out[0].roles) == 2 + assert out[1].account_id == "acct-3" + assert out[1].roles == [] + + +class TestResourcePermissions: + def test_app_permissions_batch_get(self, mock_send: MagicMock): + mock_send.return_value = { + "data": [ + {"resource_id": "app-1", "permission_keys": ["app.acl.view_layout", "app.acl.edit"]}, + {"resource_id": "app-2", "permission_keys": []}, + ] + } + + with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): + out = svc.RBACService.AppPermissions.batch_get("tenant-1", "acct-1", ["app-1", "app-2"]) + + call = _call_args(mock_send) + assert call.method == "POST" + assert call.endpoint == "/rbac/apps/permission-keys/batch" + assert call.json == {"app_ids": ["app-1", "app-2"]} + assert out == { + "app-1": ["app.acl.view_layout", "app.acl.edit"], + "app-2": [], + } + + def test_app_permissions_batch_get_uses_legacy_role_permissions_when_rbac_disabled(self, mock_send: MagicMock): + mock_session = MagicMock() + mock_session.__enter__.return_value = mock_session + mock_session.scalar.return_value = "editor" + with ( + patch(f"{MODULE}.dify_config.RBAC_ENABLED", False), + patch(f"{MODULE}.session_factory.create_session", return_value=mock_session), + ): + out = svc.RBACService.AppPermissions.batch_get("tenant-1", "acct-1", ["app-1", "app-2"]) + + mock_send.assert_not_called() + assert out == { + "app-1": svc._LEGACY_APP_EDITOR_KEYS, + "app-2": svc._LEGACY_APP_EDITOR_KEYS, + } + + def test_dataset_permissions_batch_get(self, mock_send: MagicMock): + mock_send.return_value = { + "data": [ + {"resource_id": "ds-1", "permission_keys": ["dataset.acl.readonly"]}, + {"resource_id": "ds-2", "permission_keys": ["dataset.acl.edit"]}, + ] + } + + with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): + out = svc.RBACService.DatasetPermissions.batch_get("tenant-1", "acct-1", ["ds-1", "ds-2"]) + + call = _call_args(mock_send) + assert call.method == "POST" + assert call.endpoint == "/rbac/datasets/permission-keys/batch" + assert call.json == {"dataset_ids": ["ds-1", "ds-2"]} + assert out == { + "ds-1": ["dataset.acl.readonly"], + "ds-2": ["dataset.acl.edit"], + } + + def test_dataset_permissions_batch_get_uses_legacy_role_permissions_when_rbac_disabled(self, mock_send: MagicMock): + mock_session = MagicMock() + mock_session.__enter__.return_value = mock_session + mock_session.scalar.return_value = "dataset_operator" + with ( + patch(f"{MODULE}.dify_config.RBAC_ENABLED", False), + patch(f"{MODULE}.session_factory.create_session", return_value=mock_session), + ): + out = svc.RBACService.DatasetPermissions.batch_get("tenant-1", "acct-1", ["ds-1", "ds-2"]) + + mock_send.assert_not_called() + assert out == { + "ds-1": svc._LEGACY_DATASET_DATASET_OPERATOR_KEYS, + "ds-2": svc._LEGACY_DATASET_DATASET_OPERATOR_KEYS, + } + + +class TestListOption: + def test_empty_produces_empty_params(self): + assert svc.ListOption().to_params() == {} + + def test_reverse_serialises_as_lowercase_bool(self): + assert svc.ListOption(reverse=False).to_params()["reverse"] == "false" + assert svc.ListOption(reverse=True).to_params()["reverse"] == "true" + + def test_extra_overrides_merge(self): + assert svc.ListOption(page_number=1).to_params({"resource_type": "app", "skip": None}) == { + "page_number": 1, + "resource_type": "app", + } diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index c98b717a105..0bfa4afb5ba 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -1,5 +1,6 @@ import json from datetime import datetime, timedelta +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest @@ -828,8 +829,8 @@ class TestTenantService: tenant_id="tenant-456", account_id="operator-123", role="owner" ) - # scalar calls: permission check, ta lookup, remaining count - mock_db.session.scalar.side_effect = [mock_operator_join, mock_ta, 0] + # scalar calls: permission check, ta lookup, owner_id lookup, remaining count + mock_db.session.scalar.side_effect = [mock_operator_join, mock_ta, "operator-123", 0] with patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync: mock_sync.return_value = True @@ -868,8 +869,8 @@ class TestTenantService: tenant_id="tenant-456", account_id="operator-123", role="owner" ) - # scalar calls: permission check, ta lookup, remaining count = 1 - mock_db.session.scalar.side_effect = [mock_operator_join, mock_ta, 1] + # scalar calls: permission check, ta lookup, owner_id lookup, remaining count = 1 + mock_db.session.scalar.side_effect = [mock_operator_join, mock_ta, "operator-123", 1] with patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync: mock_sync.return_value = True @@ -899,8 +900,8 @@ class TestTenantService: tenant_id="tenant-456", account_id="operator-123", role="owner" ) - # scalar calls: permission check, ta lookup (no count needed for active member) - mock_db.session.scalar.side_effect = [mock_operator_join, mock_ta] + # scalar calls: permission check, ta lookup, owner_id lookup (no count for active member) + mock_db.session.scalar.side_effect = [mock_operator_join, mock_ta, "operator-123"] with patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync: mock_sync.return_value = True @@ -974,6 +975,43 @@ class TestTenantService: assert mock_target_join.role == "admin" self._assert_database_operations_called(mock_db) + def test_create_owner_tenant_if_not_exist_rbac_enabled_assigns_owner_role( + self, mock_db_dependencies, mock_external_service_dependencies + ): + mock_account = TestAccountAssociatedDataFactory.create_account_mock(account_id="user-rbac", name="RBAC User") + mock_external_service_dependencies[ + "feature_service" + ].get_system_features.return_value.is_allow_create_workspace = True + mock_external_service_dependencies[ + "feature_service" + ].get_system_features.return_value.license.workspaces.is_available.return_value = True + + mock_tenant = MagicMock() + mock_tenant.id = "tenant-rbac" + mock_tenant.name = "RBAC User's Workspace" + + with ( + patch("services.account_service.dify_config.RBAC_ENABLED", True), + patch("services.account_service.TenantService.create_tenant", return_value=mock_tenant), + patch("services.account_service.TenantService.create_tenant_member"), + patch( + "services.account_service.AccountService._resolve_legacy_role_id", + return_value="rbac-owner-id", + ), + patch("services.account_service.RBACService") as mock_rbac_service, + patch("services.account_service.tenant_was_created.send"), + ): + mock_db_dependencies["db"].session.scalar.return_value = None + + TenantService.create_owner_tenant_if_not_exist(mock_account, is_setup=True) + + mock_rbac_service.MemberRoles.replace.assert_called_once_with( + tenant_id="tenant-rbac", + account_id="user-rbac", + member_account_id="user-rbac", + role_ids=["rbac-owner-id"], + ) + def test_admin_can_update_admin_member_role(self): """Test admin can update another non-owner member, including an admin.""" mock_tenant = MagicMock() @@ -1103,6 +1141,81 @@ class TestTenantService: with pytest.raises(NoPermissionError): TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove") + def test_rbac_member_can_remove_non_owner_member(self): + """Test RBAC workspace.member.manage allows removing a non-owner member.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-456" + mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123") + mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789") + + mock_permissions = MagicMock() + mock_permissions.workspace = MagicMock(permission_keys=["workspace.member.manage"]) + + with ( + patch("services.account_service.dify_config.RBAC_ENABLED", True), + patch("services.account_service.RBACService.MyPermissions.get", return_value=mock_permissions), + patch("services.account_service.AccountService.is_rbac_workspace_owner", return_value=False), + ): + TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove") + + def test_rbac_member_cannot_remove_without_permission(self): + """Test RBAC permission check rejects removal without workspace.member.manage.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-456" + mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123") + mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789") + + mock_permissions = MagicMock() + mock_permissions.workspace = MagicMock(permission_keys=["workspace.role.manage"]) + + with ( + patch("services.account_service.dify_config.RBAC_ENABLED", True), + patch("services.account_service.RBACService.MyPermissions.get", return_value=mock_permissions), + ): + with pytest.raises(NoPermissionError): + TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove") + + def test_rbac_member_cannot_remove_owner_member(self): + """Test RBAC permission check rejects removing an owner member.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-456" + mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123") + mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789") + + mock_permissions = MagicMock() + mock_permissions.workspace = MagicMock(permission_keys=["workspace.member.manage"]) + + with ( + patch("services.account_service.dify_config.RBAC_ENABLED", True), + patch("services.account_service.RBACService.MyPermissions.get", return_value=mock_permissions), + patch("services.account_service.AccountService.is_rbac_workspace_owner", return_value=True), + ): + with pytest.raises(NoPermissionError): + TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove") + + def test_get_rbac_workspace_owner_account_id(self): + mock_roles = MagicMock() + mock_roles.data = [SimpleNamespace(account_id="owner-account")] + mock_rbac_roles = MagicMock() + mock_rbac_roles.members.return_value = mock_roles + + with ( + patch( + "services.account_service.AccountService._resolve_legacy_role_id", + return_value="owner-role-id", + ), + patch("services.account_service.RBACService.Roles", mock_rbac_roles), + ): + owner_account_id = AccountService.get_rbac_workspace_owner_account_id("tenant-1", "acct-1") + + assert owner_account_id == "owner-account" + call = mock_rbac_roles.members.call_args + assert call.kwargs["tenant_id"] == "tenant-1" + assert call.kwargs["account_id"] == "acct-1" + assert call.kwargs["role_id"] == "owner-role-id" + assert call.kwargs["options"].page_number == 1 + assert call.kwargs["options"].results_per_page == 1 + class TestRegisterService: """ @@ -1891,6 +2004,185 @@ class TestRegisterService: inviter=None, ) + # ==================== RBAC Member Invitation Tests ==================== + + def test_invite_new_member_rbac_enabled_new_account( + self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies + ): + """When RBAC is enabled, create the member join and replace RBAC member roles.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-789" + mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-456", name="Inviter") + + with ( + patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup, + patch("services.account_service.dify_config") as mock_config, + ): + mock_lookup.return_value = None + mock_config.RBAC_ENABLED = True + + mock_new_account = TestAccountAssociatedDataFactory.create_account_mock( + account_id="new-user-rbac", email="rbac@example.com", name="rbacuser", status="pending" + ) + with ( + patch("services.account_service.RegisterService.register") as mock_register, + patch("services.account_service.TenantService.check_member_permission"), + patch("services.account_service.TenantService.create_tenant_member") as mock_create_member, + patch("services.account_service.TenantService.switch_tenant"), + patch("services.account_service.RegisterService.generate_invite_token", return_value="rbac-token"), + patch("services.account_service.RBACService") as mock_rbac_service, + ): + mock_register.return_value = mock_new_account + + result = RegisterService.invite_new_member( + tenant=mock_tenant, + email="rbac@example.com", + language="en-US", + role="rbac-role-id-123", + inviter=mock_inviter, + ) + + assert result == "rbac-token" + mock_create_member.assert_called_once_with( + mock_tenant, mock_new_account, TenantAccountRole.NORMAL.value + ) + mock_rbac_service.MemberRoles.replace.assert_called_once_with( + tenant_id=str(mock_tenant.id), + account_id=mock_inviter.id, + member_account_id=mock_new_account.id, + role_ids=["rbac-role-id-123"], + ) + + def test_invite_new_member_rbac_enabled_existing_account( + self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies + ): + """When RBAC is enabled and account exists, create the member join and replace RBAC member roles.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-789" + mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-456", name="Inviter") + mock_existing_account = TestAccountAssociatedDataFactory.create_account_mock( + account_id="existing-rbac", email="existing-rbac@example.com", status="pending" + ) + + mock_db_dependencies["db"].session.scalar.return_value = None + + with ( + patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup, + patch("services.account_service.dify_config") as mock_config, + ): + mock_lookup.return_value = mock_existing_account + mock_config.RBAC_ENABLED = True + + with ( + patch("services.account_service.TenantService.check_member_permission"), + patch("services.account_service.TenantService.create_tenant_member") as mock_create_member, + patch("services.account_service.RegisterService.generate_invite_token", return_value="rbac-token"), + patch("services.account_service.RBACService") as mock_rbac_service, + ): + result = RegisterService.invite_new_member( + tenant=mock_tenant, + email="existing-rbac@example.com", + language="en-US", + role="rbac-role-id-456", + inviter=mock_inviter, + ) + + assert result == "rbac-token" + mock_create_member.assert_called_once_with( + mock_tenant, mock_existing_account, TenantAccountRole.NORMAL.value + ) + mock_rbac_service.MemberRoles.replace.assert_called_once_with( + tenant_id=str(mock_tenant.id), + account_id=mock_inviter.id, + member_account_id=mock_existing_account.id, + role_ids=["rbac-role-id-456"], + ) + + def test_invite_new_member_rbac_enabled_existing_active_account_adds_role_before_signin_response( + self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies + ): + """Existing active accounts still need an RBAC membership before the API returns the signin URL.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-789" + mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-456", name="Inviter") + mock_existing_account = TestAccountAssociatedDataFactory.create_account_mock( + account_id="existing-rbac", email="existing-rbac@example.com", status=AccountStatus.ACTIVE + ) + + mock_db_dependencies["db"].session.scalar.return_value = None + + with ( + patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup, + patch("services.account_service.dify_config") as mock_config, + ): + mock_lookup.return_value = mock_existing_account + mock_config.RBAC_ENABLED = True + + with ( + patch("services.account_service.TenantService.check_member_permission"), + patch("services.account_service.TenantService.create_tenant_member") as mock_create_member, + patch("services.account_service.RBACService") as mock_rbac_service, + ): + with pytest.raises(AccountAlreadyInTenantError): + RegisterService.invite_new_member( + tenant=mock_tenant, + email="existing-rbac@example.com", + language="en-US", + role="rbac-role-id-456", + inviter=mock_inviter, + ) + + mock_create_member.assert_called_once_with( + mock_tenant, mock_existing_account, TenantAccountRole.NORMAL.value + ) + mock_rbac_service.MemberRoles.replace.assert_called_once_with( + tenant_id=str(mock_tenant.id), + account_id=mock_inviter.id, + member_account_id=mock_existing_account.id, + role_ids=["rbac-role-id-456"], + ) + mock_task_dependencies.delay.assert_not_called() + + def test_invite_new_member_rbac_disabled_uses_legacy_role( + self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies + ): + """When RBAC is disabled, create_tenant_member should be called and MemberRoles.replace should NOT.""" + mock_tenant = MagicMock() + mock_tenant.id = "tenant-legacy" + mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-789", name="Inviter") + + with ( + patch("services.account_service.AccountService.get_account_by_email_with_case_fallback") as mock_lookup, + patch("services.account_service.dify_config") as mock_config, + ): + mock_lookup.return_value = None + mock_config.RBAC_ENABLED = False + + mock_new_account = TestAccountAssociatedDataFactory.create_account_mock( + account_id="legacy-user", email="legacy@example.com", name="legacyuser", status="pending" + ) + with ( + patch("services.account_service.RegisterService.register") as mock_register, + patch("services.account_service.TenantService.check_member_permission"), + patch("services.account_service.TenantService.create_tenant_member") as mock_create_member, + patch("services.account_service.TenantService.switch_tenant"), + patch("services.account_service.RegisterService.generate_invite_token", return_value="legacy-token"), + patch("services.account_service.RBACService") as mock_rbac_service, + ): + mock_register.return_value = mock_new_account + + result = RegisterService.invite_new_member( + tenant=mock_tenant, + email="legacy@example.com", + language="en-US", + role="editor", + inviter=mock_inviter, + ) + + assert result == "legacy-token" + mock_create_member.assert_called_once_with(mock_tenant, mock_new_account, "editor") + mock_rbac_service.MemberRoles.replace.assert_not_called() + # ==================== Token Management Tests ==================== def test_generate_invite_token_success(self, mock_redis_dependencies): diff --git a/api/tests/unit_tests/services/test_dataset_service_dataset.py b/api/tests/unit_tests/services/test_dataset_service_dataset.py index 3d08b6fd096..46f32f93e8a 100644 --- a/api/tests/unit_tests/services/test_dataset_service_dataset.py +++ b/api/tests/unit_tests/services/test_dataset_service_dataset.py @@ -15,6 +15,7 @@ from .dataset_service_test_helpers import ( ProviderTokenNotInitError, RagPipelineDatasetCreateEntity, SimpleNamespace, + TenantAccountRole, _make_knowledge_configuration, _make_retrieval_model, _make_session_context, @@ -167,6 +168,154 @@ class TestDatasetServiceValidation: DatasetService.check_is_multimodal_model("tenant-1", "provider", "embedding-model") +class TestDatasetServiceRetrievalPermissions: + """Unit tests for dataset list permission branching.""" + + def test_get_datasets_filters_by_maintainer_and_rbac_overrides(self): + mock_db = MagicMock() + mock_db.session.scalars.return_value.all.return_value = [] + mock_db.paginate.return_value.items = [] + mock_db.paginate.return_value.total = 0 + user = DatasetServiceUnitDataFactory.create_user_mock(role=TenantAccountRole.NORMAL) + + with ( + patch("services.dataset_service.db", mock_db), + patch("services.dataset_service.dify_config.RBAC_ENABLED", True), + patch( + "services.dataset_service.enterprise_rbac_service.RBACService.MyPermissions.get", + return_value=SimpleNamespace(workspace=SimpleNamespace(permission_keys=[])), + ), + ): + DatasetService.get_datasets( + page=1, + per_page=20, + tenant_id="tenant-1", + user=user, + accessible_dataset_ids=["dataset-shared"], + include_own_datasets=True, + ) + + select_stmt = mock_db.paginate.call_args.kwargs["select"] + visibility_clause = str(select_stmt._where_criteria[1]) + assert "maintainer" in visibility_clause + assert "IN" in visibility_clause + + def test_get_datasets_filters_only_by_rbac_overrides_without_manage_own_permission(self): + mock_db = MagicMock() + mock_db.session.scalars.return_value.all.return_value = [] + mock_db.paginate.return_value.items = [] + mock_db.paginate.return_value.total = 0 + user = DatasetServiceUnitDataFactory.create_user_mock(role=TenantAccountRole.NORMAL) + + with ( + patch("services.dataset_service.db", mock_db), + patch("services.dataset_service.dify_config.RBAC_ENABLED", True), + patch( + "services.dataset_service.enterprise_rbac_service.RBACService.MyPermissions.get", + return_value=SimpleNamespace(workspace=SimpleNamespace(permission_keys=[])), + ), + ): + DatasetService.get_datasets( + page=1, + per_page=20, + tenant_id="tenant-1", + user=user, + accessible_dataset_ids=["dataset-shared"], + ) + + select_stmt = mock_db.paginate.call_args.kwargs["select"] + visibility_clause = str(select_stmt._where_criteria[1]) + assert "maintainer" not in visibility_clause + assert "IN" in visibility_clause + + def test_get_datasets_by_ids_applies_rbac_visibility(self): + mock_db = MagicMock() + mock_db.paginate.return_value.items = [] + mock_db.paginate.return_value.total = 0 + user = DatasetServiceUnitDataFactory.create_user_mock(role=TenantAccountRole.NORMAL) + + with ( + patch("services.dataset_service.db", mock_db), + patch("services.dataset_service.dify_config.RBAC_ENABLED", True), + ): + DatasetService.get_datasets_by_ids( + ["dataset-requested", "dataset-shared"], + "tenant-1", + user=user, + accessible_dataset_ids=["dataset-shared", "dataset-not-requested"], + include_own_datasets=True, + ) + + select_stmt = mock_db.paginate.call_args.kwargs["select"] + visibility_clause = str(select_stmt._where_criteria[-1]) + assert "maintainer" in visibility_clause + assert "IN" in visibility_clause + visibility_params = select_stmt._where_criteria[-1].compile().params + assert ["dataset-shared"] in visibility_params.values() + list_params = [value for value in visibility_params.values() if isinstance(value, list)] + assert all("dataset-not-requested" not in value for value in list_params) + + def test_get_datasets_rbac_include_all_uses_workspace_permission(self): + mock_db = MagicMock() + mock_db.session.scalars.return_value.all.return_value = [] + mock_db.paginate.return_value.items = [] + mock_db.paginate.return_value.total = 0 + + user = DatasetServiceUnitDataFactory.create_user_mock(role=TenantAccountRole.NORMAL) + mock_permissions = SimpleNamespace(workspace=SimpleNamespace(permission_keys=["dataset.create_and_management"])) + + with ( + patch("services.dataset_service.db", mock_db), + patch("services.dataset_service.dify_config.RBAC_ENABLED", True), + patch( + "services.dataset_service.enterprise_rbac_service.RBACService.MyPermissions.get", + return_value=mock_permissions, + ), + ): + DatasetService.get_datasets(page=1, per_page=20, tenant_id="tenant-1", user=user, include_all=True) + + mock_db.session.scalars.assert_called_once() + mock_db.paginate.assert_called_once() + select_stmt = mock_db.paginate.call_args.kwargs["select"] + assert len(select_stmt._where_criteria) == 1 + + def test_get_datasets_rbac_without_user_returns_empty_result(self): + mock_db = MagicMock() + mock_db.session.scalars.return_value.all.return_value = [] + mock_db.paginate.return_value.items = [] + mock_db.paginate.return_value.total = 0 + + with ( + patch("services.dataset_service.db", mock_db), + patch("services.dataset_service.dify_config.RBAC_ENABLED", True), + ): + DatasetService.get_datasets(page=1, per_page=20, tenant_id="tenant-1", user=None) + + mock_db.session.scalars.assert_not_called() + mock_db.paginate.assert_called_once() + select_stmt = mock_db.paginate.call_args.kwargs["select"] + assert len(select_stmt._where_criteria) == 2 + + def test_get_datasets_legacy_owner_include_all_keeps_full_access(self): + mock_db = MagicMock() + mock_db.session.scalars.return_value.all.return_value = [] + mock_db.paginate.return_value.items = [] + mock_db.paginate.return_value.total = 0 + + user = DatasetServiceUnitDataFactory.create_user_mock(role=TenantAccountRole.OWNER) + + with ( + patch("services.dataset_service.db", mock_db), + patch("services.dataset_service.dify_config.RBAC_ENABLED", False), + ): + DatasetService.get_datasets(page=1, per_page=20, tenant_id="tenant-1", user=user, include_all=True) + + mock_db.session.scalars.assert_called_once() + mock_db.paginate.assert_called_once() + select_stmt = mock_db.paginate.call_args.kwargs["select"] + assert len(select_stmt._where_criteria) == 1 + + class TestDatasetServiceCreationAndUpdate: """Unit tests for dataset creation and update helpers.""" diff --git a/e2e/support/apps.ts b/e2e/support/apps.ts index f035b5f4a1b..3c3af547a35 100644 --- a/e2e/support/apps.ts +++ b/e2e/support/apps.ts @@ -19,6 +19,6 @@ export const openBlankAppCreation = async (page: Page) => { return } - await page.getByRole('button', { name: 'Create' }).click() + await page.getByRole('button', { name: 'Create', exact: true }).click() await page.getByRole('menuitem', { name: 'Create from Blank' }).click() } diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 263481bc9d1..07c7c8c2a40 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -720,11 +720,6 @@ "count": 1 } }, - "web/app/components/app/configuration/dataset-config/index.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/app/configuration/dataset-config/params-config/__tests__/config-content.spec.tsx": { "ts/no-explicit-any": { "count": 1 @@ -965,11 +960,6 @@ "count": 1 } }, - "web/app/components/app/overview/trigger-card.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/app/overview/workflow-hidden-input-fields.tsx": { "no-restricted-imports": { "count": 1 @@ -1035,6 +1025,11 @@ "count": 1 } }, + "web/app/components/apps/new-app-card.tsx": { + "react/set-state-in-effect": { + "count": 3 + } + }, "web/app/components/base/action-button/index.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -1921,7 +1916,7 @@ }, "web/app/components/base/icons/src/vender/solid/arrows/index.ts": { "no-barrel-files/no-barrel-files": { - "count": 3 + "count": 1 } }, "web/app/components/base/icons/src/vender/solid/communication/index.ts": { @@ -2639,11 +2634,6 @@ "count": 4 } }, - "web/app/components/billing/billing-page/__tests__/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 4 - } - }, "web/app/components/billing/header-billing-btn/index.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -2929,14 +2919,6 @@ "count": 3 } }, - "web/app/components/datasets/create/step-two/index.tsx": { - "no-barrel-files/no-barrel-files": { - "count": 1 - }, - "react-hooks/exhaustive-deps": { - "count": 1 - } - }, "web/app/components/datasets/create/step-two/preview-item/index.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -3006,14 +2988,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 2 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 2 - } - }, "web/app/components/datasets/documents/components/document-list/components/index.ts": { "no-barrel-files/no-barrel-files": { "count": 2 @@ -3037,14 +3011,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/components/operations.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/datasets/documents/components/rename-modal.tsx": { "no-restricted-imports": { "count": 1 @@ -3294,10 +3260,10 @@ }, "web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-state.ts": { "react-hooks-extra/no-direct-set-state-in-use-effect": { - "count": 4 + "count": 6 }, "react/set-state-in-effect": { - "count": 4 + "count": 6 } }, "web/app/components/datasets/documents/detail/metadata/index.tsx": { @@ -3409,11 +3375,6 @@ "count": 1 } }, - "web/app/components/datasets/list/__tests__/header.spec.tsx": { - "jsx-a11y/label-has-associated-control": { - "count": 1 - } - }, "web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -3526,11 +3487,6 @@ "count": 1 } }, - "web/app/components/datasets/settings/permission-selector/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/settings/permission-selector/member-item.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -3568,6 +3524,14 @@ "count": 2 } }, + "web/app/components/develop/secret-key/secret-key-modal.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { + "count": 1 + } + }, "web/app/components/explore/banner/__tests__/indicator-button.spec.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -3803,11 +3767,6 @@ "count": 1 } }, - "web/app/components/header/account-setting/model-provider-page/model-auth/add-credential-in-load-balancing.tsx": { - "ts/no-explicit-any": { - "count": 4 - } - }, "web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 2 @@ -3830,9 +3789,6 @@ }, "jsx-a11y/no-static-element-interactions": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "web/app/components/header/account-setting/model-provider-page/model-auth/config-model.tsx": { @@ -3876,11 +3832,6 @@ "count": 7 } }, - "web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.tsx": { - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/header/account-setting/model-provider-page/model-modal/Input.tsx": { "unicorn/prefer-number-properties": { "count": 2 @@ -3981,12 +3932,7 @@ "count": 2 } }, - "web/app/components/header/account-setting/plugin-page/utils.ts": { - "ts/no-explicit-any": { - "count": 4 - } - }, - "web/app/components/header/nav/index.tsx": { + "web/app/components/header/account-setting/permission-group-list.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 }, @@ -3994,8 +3940,16 @@ "count": 1 } }, - "web/app/components/main-nav/components/web-apps-section.tsx": { - "jsx-a11y/no-autofocus": { + "web/app/components/header/index.tsx": { + "tailwindcss/no-duplicate-classes": { + "count": 1 + } + }, + "web/app/components/header/nav/index.tsx": { + "jsx-a11y/click-events-have-key-events": { + "count": 1 + }, + "jsx-a11y/no-static-element-interactions": { "count": 1 } }, @@ -4222,11 +4176,6 @@ "count": 1 } }, - "web/app/components/plugins/plugin-detail-panel/operation-dropdown.tsx": { - "jsx-a11y/anchor-has-content": { - "count": 1 - } - }, "web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -4310,10 +4259,10 @@ }, "web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx": { "jsx-a11y/click-events-have-key-events": { - "count": 4 + "count": 5 }, "jsx-a11y/no-static-element-interactions": { - "count": 4 + "count": 5 } }, "web/app/components/plugins/plugin-detail-panel/tool-selector/components/index.ts": { @@ -4820,30 +4769,11 @@ "count": 1 } }, - "web/app/components/tools/mcp/__tests__/index.spec.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 2 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 2 - } - }, "web/app/components/tools/mcp/mcp-server-param-item.tsx": { "ts/no-explicit-any": { "count": 1 } }, - "web/app/components/tools/mcp/provider-card.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 2 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 3 - } - }, "web/app/components/tools/provider/detail.tsx": { "jsx-a11y/anchor-has-content": { "count": 1 @@ -5028,11 +4958,6 @@ "count": 2 } }, - "web/app/components/workflow/block-selector/market-place-plugin/item.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - } - }, "web/app/components/workflow/block-selector/market-place-plugin/list.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -5172,14 +5097,6 @@ "count": 2 } }, - "web/app/components/workflow/header/run-and-history.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/workflow/header/scroll-to-selected-node-button.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -7630,10 +7547,62 @@ "count": 1 } }, - "web/service/access-control.ts": { - "@tanstack/query/exhaustive-deps": { + "web/service/__tests__/use-tools.spec.tsx": { + "no-restricted-imports": { "count": 1 - }, + } + }, + "web/service/access-control/__tests__/use-app-access-control.spec.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/service/access-control/__tests__/use-member-roles.spec.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/service/access-control/__tests__/use-permission-catalog.spec.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/service/access-control/__tests__/use-permission-keys.spec.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/service/access-control/__tests__/use-workspace-access-rules.spec.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/service/access-control/use-app-access-control.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/service/access-control/use-member-roles.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/service/access-control/use-permission-catalog.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/service/access-control/use-permission-keys.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/service/access-control/use-workspace-access-rules.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/service/access-control/use-workspace-roles.ts": { "no-restricted-imports": { "count": 1 } @@ -7886,9 +7855,6 @@ "web/service/use-tools.ts": { "no-restricted-imports": { "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 } }, "web/service/use-triggers.ts": { diff --git a/eslint.config.mjs b/eslint.config.mjs index 1380ed67d2e..880ef4cdc56 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,6 +7,7 @@ import markdownPreferences from 'eslint-plugin-markdown-preferences' const GENERATED_IGNORES = [ '**/storybook-static/', '**/.next/', + '**/.vinext/', 'web/next/', 'web/next-env.d.ts', '**/dist/', diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 9685823311d..64afc442406 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -38,10 +38,12 @@ export type AgentAppDetailWithSite = { icon_type?: string | null readonly icon_url: string | null id: string + maintainer?: string | null max_active_requests?: number | null mode: string model_config?: ModelConfig | null name: string + permission_keys?: Array role?: string | null site?: Site | null tags?: Array @@ -307,10 +309,12 @@ export type AgentAppPartial = { readonly icon_url: string | null id: string is_starred?: boolean + maintainer?: string | null max_active_requests?: number | null mode: string model_config?: ModelConfigPartial | null name: string + permission_keys?: Array published_reference_count?: number published_references?: Array role?: string | null @@ -1446,10 +1450,12 @@ export type AgentAppDetailWithSiteWritable = { icon_background?: string | null icon_type?: string | null id: string + maintainer?: string | null max_active_requests?: number | null mode: string model_config?: ModelConfig | null name: string + permission_keys?: Array role?: string | null site?: SiteWritable | null tags?: Array @@ -1476,10 +1482,12 @@ export type AgentAppPartialWritable = { icon_type?: string | null id: string is_starred?: boolean + maintainer?: string | null max_active_requests?: number | null mode: string model_config?: ModelConfigPartial | null name: string + permission_keys?: Array published_reference_count?: number published_references?: Array role?: string | null diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index ac16494f3fe..7d6bd6f5eb2 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -618,10 +618,12 @@ export const zAgentAppPartial = z.object({ icon_url: z.string().nullable(), id: z.string(), is_starred: z.boolean().optional().default(false), + maintainer: z.string().nullish(), max_active_requests: z.int().nullish(), mode: z.string(), model_config: zModelConfigPartial.nullish(), name: z.string(), + permission_keys: z.array(z.string()).optional(), published_reference_count: z.int().optional().default(0), published_references: z.array(zAgentAppPublishedReferenceResponse).optional(), role: z.string().nullish(), @@ -680,10 +682,12 @@ export const zAgentAppDetailWithSite = z.object({ icon_type: z.string().nullish(), icon_url: z.string().nullable(), id: z.string(), + maintainer: z.string().nullish(), max_active_requests: z.int().nullish(), mode: z.string(), model_config: zModelConfig.nullish(), name: z.string(), + permission_keys: z.array(z.string()).optional(), role: z.string().nullish(), site: zSite.nullish(), tags: z.array(zTag).optional(), @@ -2014,10 +2018,12 @@ export const zAgentAppPartialWritable = z.object({ icon_type: z.string().nullish(), id: z.string(), is_starred: z.boolean().optional().default(false), + maintainer: z.string().nullish(), max_active_requests: z.int().nullish(), mode: z.string(), model_config: zModelConfigPartial.nullish(), name: z.string(), + permission_keys: z.array(z.string()).optional(), published_reference_count: z.int().optional().default(0), published_references: z.array(zAgentAppPublishedReferenceResponse).optional(), role: z.string().nullish(), @@ -2077,10 +2083,12 @@ export const zAgentAppDetailWithSiteWritable = z.object({ icon_background: z.string().nullish(), icon_type: z.string().nullish(), id: z.string(), + maintainer: z.string().nullish(), max_active_requests: z.int().nullish(), mode: z.string(), model_config: zModelConfig.nullish(), name: z.string(), + permission_keys: z.array(z.string()).optional(), role: z.string().nullish(), site: zSiteWritable.nullish(), tags: z.array(zTag).optional(), diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 26a1b627184..904252c77eb 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -37,10 +37,12 @@ export type AppDetailWithSite = { icon_type?: string | null readonly icon_url: string | null id: string + maintainer?: string | null max_active_requests?: number | null mode: string model_config?: ModelConfig | null name: string + permission_keys?: Array site?: Site | null tags?: Array tracing?: JsonValue | null @@ -69,6 +71,7 @@ export type Import = { error?: string id: string imported_dsl_version?: string + permission_keys?: Array status: ImportStatus } @@ -312,8 +315,10 @@ export type AppDetail = { icon?: string | null icon_background?: string | null id: string + maintainer?: string | null mode_compatible_with_agent: string name: string + permission_keys?: Array tags?: Array tracing?: JsonValue | null updated_at?: number | null @@ -413,6 +418,7 @@ export type ConvertToWorkflowPayload = { export type NewAppResponse = { new_app_id: string + permission_keys?: Array } export type CopyAppPayload = { @@ -1163,10 +1169,12 @@ export type AppPartial = { readonly icon_url: string | null id: string is_starred?: boolean + maintainer?: string | null max_active_requests?: number | null mode: string model_config?: ModelConfigPartial | null name: string + permission_keys?: Array tags?: Array updated_at?: number | null updated_by?: string | null @@ -1616,6 +1624,9 @@ export type AccountWithRole = { last_login_at?: number | null name: string role: string + roles?: Array<{ + [key: string]: string + }> status: string } @@ -2592,10 +2603,12 @@ export type AppDetailWithSiteWritable = { icon_background?: string | null icon_type?: string | null id: string + maintainer?: string | null max_active_requests?: number | null mode: string model_config?: ModelConfig | null name: string + permission_keys?: Array site?: SiteWritable | null tags?: Array tracing?: JsonValue | null @@ -2643,10 +2656,12 @@ export type AppPartialWritable = { icon_type?: string | null id: string is_starred?: boolean + maintainer?: string | null max_active_requests?: number | null mode: string model_config?: ModelConfigPartial | null name: string + permission_keys?: Array tags?: Array updated_at?: number | null updated_by?: string | null diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 4449b5c1c70..4a6f397bcbd 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -270,6 +270,7 @@ export const zConvertToWorkflowPayload = z.object({ */ export const zNewAppResponse = z.object({ new_app_id: z.string(), + permission_keys: z.array(z.string()).optional(), }) /** @@ -906,6 +907,7 @@ export const zImport = z.object({ error: z.string().optional().default(''), id: z.string(), imported_dsl_version: z.string().optional().default(''), + permission_keys: z.array(z.string()).optional(), status: zImportStatus, }) @@ -1485,6 +1487,7 @@ export const zAccountWithRole = z.object({ last_login_at: z.int().nullish(), name: z.string(), role: z.string(), + roles: z.array(z.record(z.string(), z.string())).optional(), status: z.string(), }) @@ -1954,10 +1957,12 @@ export const zAppPartial = z.object({ icon_url: z.string().nullable(), id: z.string(), is_starred: z.boolean().optional().default(false), + maintainer: z.string().nullish(), max_active_requests: z.int().nullish(), mode: z.string(), model_config: zModelConfigPartial.nullish(), name: z.string(), + permission_keys: z.array(z.string()).optional(), tags: z.array(zTag).optional(), updated_at: z.int().nullish(), updated_by: z.string().nullish(), @@ -2012,10 +2017,12 @@ export const zAppDetailWithSite = z.object({ icon_type: z.string().nullish(), icon_url: z.string().nullable(), id: z.string(), + maintainer: z.string().nullish(), max_active_requests: z.int().nullish(), mode: z.string(), model_config: zModelConfig.nullish(), name: z.string(), + permission_keys: z.array(z.string()).optional(), site: zSite.nullish(), tags: z.array(zTag).optional(), tracing: zJsonValue.nullish(), @@ -2039,8 +2046,10 @@ export const zAppDetail = z.object({ icon: z.string().nullish(), icon_background: z.string().nullish(), id: z.string(), + maintainer: z.string().nullish(), mode_compatible_with_agent: z.string(), name: z.string(), + permission_keys: z.array(z.string()).optional(), tags: z.array(zTag).optional(), tracing: zJsonValue.nullish(), updated_at: z.int().nullish(), @@ -3481,10 +3490,12 @@ export const zAppPartialWritable = z.object({ icon_type: z.string().nullish(), id: z.string(), is_starred: z.boolean().optional().default(false), + maintainer: z.string().nullish(), max_active_requests: z.int().nullish(), mode: z.string(), model_config: zModelConfigPartial.nullish(), name: z.string(), + permission_keys: z.array(z.string()).optional(), tags: z.array(zTag).optional(), updated_at: z.int().nullish(), updated_by: z.string().nullish(), @@ -3540,10 +3551,12 @@ export const zAppDetailWithSiteWritable = z.object({ icon_background: z.string().nullish(), icon_type: z.string().nullish(), id: z.string(), + maintainer: z.string().nullish(), max_active_requests: z.int().nullish(), mode: z.string(), model_config: zModelConfig.nullish(), name: z.string(), + permission_keys: z.array(z.string()).optional(), site: zSiteWritable.nullish(), tags: z.array(zTag).optional(), tracing: zJsonValue.nullish(), diff --git a/packages/contracts/generated/api/console/datasets/types.gen.ts b/packages/contracts/generated/api/console/datasets/types.gen.ts index 987837ab4ed..f904ea7d717 100644 --- a/packages/contracts/generated/api/console/datasets/types.gen.ts +++ b/packages/contracts/generated/api/console/datasets/types.gen.ts @@ -45,8 +45,10 @@ export type DatasetDetailResponse = { indexing_technique: string | null is_multimodal: boolean is_published: boolean + maintainer?: string | null name: string permission: string + permission_keys?: Array pipeline_id: string | null provider: string retrieval_model_dict: DatasetRetrievalModelResponse @@ -120,6 +122,7 @@ export type DatasetDetail = { is_published?: boolean name?: string permission?: string + permission_keys?: Array pipeline_id?: string provider?: string retrieval_model_dict?: DatasetRetrievalModel @@ -264,9 +267,11 @@ export type DatasetDetailWithPartialMembersResponse = { indexing_technique: string | null is_multimodal: boolean is_published: boolean + maintainer?: string | null name: string partial_member_list?: Array | null permission: string + permission_keys?: Array pipeline_id: string | null provider: string retrieval_model_dict: DatasetRetrievalModelResponse @@ -566,9 +571,11 @@ export type DatasetListItemResponse = { indexing_technique: string | null is_multimodal: boolean is_published: boolean + maintainer?: string | null name: string partial_member_list: Array permission: string + permission_keys?: Array pipeline_id: string | null provider: string retrieval_model_dict: DatasetRetrievalModelResponse diff --git a/packages/contracts/generated/api/console/datasets/zod.gen.ts b/packages/contracts/generated/api/console/datasets/zod.gen.ts index fc6a33c3e65..10ceb11cb5d 100644 --- a/packages/contracts/generated/api/console/datasets/zod.gen.ts +++ b/packages/contracts/generated/api/console/datasets/zod.gen.ts @@ -963,8 +963,10 @@ export const zDatasetDetailResponse = z.object({ indexing_technique: z.string().nullable(), is_multimodal: z.boolean(), is_published: z.boolean(), + maintainer: z.string().nullish(), name: z.string(), permission: z.string(), + permission_keys: z.array(z.string()).optional(), pipeline_id: z.string().nullable(), provider: z.string(), retrieval_model_dict: zDatasetRetrievalModelResponse, @@ -1004,9 +1006,11 @@ export const zDatasetDetailWithPartialMembersResponse = z.object({ indexing_technique: z.string().nullable(), is_multimodal: z.boolean(), is_published: z.boolean(), + maintainer: z.string().nullish(), name: z.string(), partial_member_list: z.array(z.string()).nullish(), permission: z.string(), + permission_keys: z.array(z.string()).optional(), pipeline_id: z.string().nullable(), provider: z.string(), retrieval_model_dict: zDatasetRetrievalModelResponse, @@ -1046,9 +1050,11 @@ export const zDatasetListItemResponse = z.object({ indexing_technique: z.string().nullable(), is_multimodal: z.boolean(), is_published: z.boolean(), + maintainer: z.string().nullish(), name: z.string(), partial_member_list: z.array(z.string()), permission: z.string(), + permission_keys: z.array(z.string()).optional(), pipeline_id: z.string().nullable(), provider: z.string(), retrieval_model_dict: zDatasetRetrievalModelResponse, @@ -1133,6 +1139,7 @@ export const zDatasetDetail = z.object({ is_published: z.boolean().optional(), name: z.string().optional(), permission: z.string().optional(), + permission_keys: z.array(z.string()).optional(), pipeline_id: z.string().optional(), provider: z.string().optional(), retrieval_model_dict: zDatasetRetrievalModel.optional(), diff --git a/packages/contracts/generated/api/console/rag/types.gen.ts b/packages/contracts/generated/api/console/rag/types.gen.ts index 49572f971b7..b9862a8d1e8 100644 --- a/packages/contracts/generated/api/console/rag/types.gen.ts +++ b/packages/contracts/generated/api/console/rag/types.gen.ts @@ -53,8 +53,10 @@ export type DatasetDetailResponse = { indexing_technique: string | null is_multimodal: boolean is_published: boolean + maintainer?: string | null name: string permission: string + permission_keys?: Array pipeline_id: string | null provider: string retrieval_model_dict: DatasetRetrievalModelResponse diff --git a/packages/contracts/generated/api/console/rag/zod.gen.ts b/packages/contracts/generated/api/console/rag/zod.gen.ts index 71ed29509a5..717db30baa7 100644 --- a/packages/contracts/generated/api/console/rag/zod.gen.ts +++ b/packages/contracts/generated/api/console/rag/zod.gen.ts @@ -656,8 +656,10 @@ export const zDatasetDetailResponse = z.object({ indexing_technique: z.string().nullable(), is_multimodal: z.boolean(), is_published: z.boolean(), + maintainer: z.string().nullish(), name: z.string(), permission: z.string(), + permission_keys: z.array(z.string()).optional(), pipeline_id: z.string().nullable(), provider: z.string(), retrieval_model_dict: zDatasetRetrievalModelResponse, diff --git a/packages/contracts/generated/api/console/system-features/types.gen.ts b/packages/contracts/generated/api/console/system-features/types.gen.ts index 01c77ed076d..f1dcc7fc4b4 100644 --- a/packages/contracts/generated/api/console/system-features/types.gen.ts +++ b/packages/contracts/generated/api/console/system-features/types.gen.ts @@ -23,6 +23,7 @@ export type SystemFeatureModel = { max_plugin_package_size: number plugin_installation_permission: PluginInstallationPermissionModel plugin_manager: PluginManagerModel + rbac_enabled: boolean sso_enforced_for_signin: boolean sso_enforced_for_signin_protocol: string webapp_auth: WebAppAuthModel diff --git a/packages/contracts/generated/api/console/system-features/zod.gen.ts b/packages/contracts/generated/api/console/system-features/zod.gen.ts index a7a8946ad50..80b27e7843a 100644 --- a/packages/contracts/generated/api/console/system-features/zod.gen.ts +++ b/packages/contracts/generated/api/console/system-features/zod.gen.ts @@ -126,6 +126,7 @@ export const zSystemFeatureModel = z.object({ restrict_to_marketplace_only: false, }), plugin_manager: zPluginManagerModel.default({ enabled: false }), + rbac_enabled: z.boolean().default(false), sso_enforced_for_signin: z.boolean().default(false), sso_enforced_for_signin_protocol: z.string().default(''), webapp_auth: zWebAppAuthModel.default({ diff --git a/packages/contracts/generated/api/console/trial-apps/types.gen.ts b/packages/contracts/generated/api/console/trial-apps/types.gen.ts index 5021da0afdf..894da1102ee 100644 --- a/packages/contracts/generated/api/console/trial-apps/types.gen.ts +++ b/packages/contracts/generated/api/console/trial-apps/types.gen.ts @@ -22,6 +22,7 @@ export type TrialAppDetailWithSite = { mode?: string model_config?: TrialAppModelConfig name?: string + permission_keys?: Array site?: TrialSite tags?: Array updated_at?: number @@ -269,6 +270,7 @@ export type TrialDataset = { indexing_technique?: string name?: string permission?: string + permission_keys?: Array } export type JsonObject = { diff --git a/packages/contracts/generated/api/console/trial-apps/zod.gen.ts b/packages/contracts/generated/api/console/trial-apps/zod.gen.ts index 7e6b5fbb6d4..b8768790ef9 100644 --- a/packages/contracts/generated/api/console/trial-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/trial-apps/zod.gen.ts @@ -237,6 +237,7 @@ export const zTrialAppDetailWithSite = z.object({ mode: z.string().optional(), model_config: zTrialAppModelConfig.optional(), name: z.string().optional(), + permission_keys: z.array(z.string()).optional(), site: zTrialSite.optional(), tags: z.array(zTrialTag).optional(), updated_at: z.coerce @@ -286,6 +287,7 @@ export const zTrialDataset = z.object({ indexing_technique: z.string().optional(), name: z.string().optional(), permission: z.string().optional(), + permission_keys: z.array(z.string()).optional(), }) export const zTrialDatasetList = z.object({ diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts index 630e8f6b354..7e676564999 100644 --- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts @@ -19,6 +19,14 @@ import { zDeleteWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse, zDeleteWorkspacesCurrentModelProvidersByProviderModelsPath, zDeleteWorkspacesCurrentModelProvidersByProviderModelsResponse, + zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath, + zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse, + zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsPath, + zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse, + zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsPath, + zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse, + zDeleteWorkspacesCurrentRbacRolesByRoleIdPath, + zDeleteWorkspacesCurrentRbacRolesByRoleIdResponse, zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientPath, zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse, zDeleteWorkspacesCurrentToolProviderMcpBody, @@ -90,6 +98,50 @@ import { zGetWorkspacesCurrentPluginTasksByTaskIdResponse, zGetWorkspacesCurrentPluginTasksQuery, zGetWorkspacesCurrentPluginTasksResponse, + zGetWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath, + zGetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse, + zGetWorkspacesCurrentRbacAccessPoliciesResponse, + zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsPath, + zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse, + zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsPath, + zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponse, + zGetWorkspacesCurrentRbacAppsByAppIdAccessPolicyPath, + zGetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponse, + zGetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesPath, + zGetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponse, + zGetWorkspacesCurrentRbacAppsByAppIdWhitelistPath, + zGetWorkspacesCurrentRbacAppsByAppIdWhitelistResponse, + zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsPath, + zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse, + zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsPath, + zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponse, + zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyPath, + zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponse, + zGetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesPath, + zGetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponse, + zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistPath, + zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse, + zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesPath, + zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse, + zGetWorkspacesCurrentRbacMyPermissionsResponse, + zGetWorkspacesCurrentRbacRolePermissionsCatalogAppResponse, + zGetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponse, + zGetWorkspacesCurrentRbacRolePermissionsCatalogResponse, + zGetWorkspacesCurrentRbacRolesByRoleIdMembersPath, + zGetWorkspacesCurrentRbacRolesByRoleIdMembersResponse, + zGetWorkspacesCurrentRbacRolesByRoleIdPath, + zGetWorkspacesCurrentRbacRolesByRoleIdResponse, + zGetWorkspacesCurrentRbacRolesResponse, + zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsPath, + zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponse, + zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsPath, + zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponse, + zGetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponse, + zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsPath, + zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponse, + zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsPath, + zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponse, + zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse, zGetWorkspacesCurrentToolLabelsResponse, zGetWorkspacesCurrentToolProviderApiGetQuery, zGetWorkspacesCurrentToolProviderApiGetResponse, @@ -251,6 +303,12 @@ import { zPostWorkspacesCurrentPluginUploadGithubBody, zPostWorkspacesCurrentPluginUploadGithubResponse, zPostWorkspacesCurrentPluginUploadPkgResponse, + zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyPath, + zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponse, + zPostWorkspacesCurrentRbacAccessPoliciesResponse, + zPostWorkspacesCurrentRbacRolesByRoleIdCopyPath, + zPostWorkspacesCurrentRbacRolesByRoleIdCopyResponse, + zPostWorkspacesCurrentRbacRolesResponse, zPostWorkspacesCurrentResponse, zPostWorkspacesCurrentToolProviderApiAddBody, zPostWorkspacesCurrentToolProviderApiAddResponse, @@ -326,6 +384,28 @@ import { zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsBody, zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsPath, zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse, + zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath, + zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse, + zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockPath, + zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponse, + zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockPath, + zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponse, + zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesPath, + zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponse, + zPutWorkspacesCurrentRbacAppsByAppIdWhitelistPath, + zPutWorkspacesCurrentRbacAppsByAppIdWhitelistResponse, + zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesPath, + zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponse, + zPutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistPath, + zPutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse, + zPutWorkspacesCurrentRbacMembersByMemberIdRbacRolesPath, + zPutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse, + zPutWorkspacesCurrentRbacRolesByRoleIdPath, + zPutWorkspacesCurrentRbacRolesByRoleIdResponse, + zPutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsPath, + zPutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponse, + zPutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsPath, + zPutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponse, zPutWorkspacesCurrentToolProviderMcpBody, zPutWorkspacesCurrentToolProviderMcpResponse, } from './zod.gen' @@ -2017,7 +2097,840 @@ export const plugin2 = { byCategory, } +export const post44 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopy', + path: '/workspaces/current/rbac/access-policies/{policy_id}/copy', + successStatus: 201, + tags: ['console'], + }) + .input(z.object({ params: zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyPath })) + .output(zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponse) + +export const copy = { + post: post44, +} + +export const delete9 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteWorkspacesCurrentRbacAccessPoliciesByPolicyId', + path: '/workspaces/current/rbac/access-policies/{policy_id}', + tags: ['console'], + }) + .input(z.object({ params: zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath })) + .output(zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse) + export const get33 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacAccessPoliciesByPolicyId', + path: '/workspaces/current/rbac/access-policies/{policy_id}', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath })) + .output(zGetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse) + +export const put4 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacAccessPoliciesByPolicyId', + path: '/workspaces/current/rbac/access-policies/{policy_id}', + tags: ['console'], + }) + .input(z.object({ params: zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath })) + .output(zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse) + +export const byPolicyId = { + delete: delete9, + get: get33, + put: put4, + copy, +} + +export const get34 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacAccessPolicies', + path: '/workspaces/current/rbac/access-policies', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentRbacAccessPoliciesResponse) + +export const post45 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentRbacAccessPolicies', + path: '/workspaces/current/rbac/access-policies', + successStatus: 201, + tags: ['console'], + }) + .output(zPostWorkspacesCurrentRbacAccessPoliciesResponse) + +export const accessPolicies = { + get: get34, + post: post45, + byPolicyId, +} + +export const put5 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLock', + path: '/workspaces/current/rbac/access-policy-bindings/{binding_id}/lock', + tags: ['console'], + }) + .input(z.object({ params: zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockPath })) + .output(zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponse) + +export const lock = { + put: put5, +} + +export const put6 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlock', + path: '/workspaces/current/rbac/access-policy-bindings/{binding_id}/unlock', + tags: ['console'], + }) + .input(z.object({ params: zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockPath })) + .output(zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponse) + +export const unlock = { + put: put6, +} + +export const byBindingId = { + lock, + unlock, +} + +export const accessPolicyBindings = { + byBindingId, +} + +export const delete10 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindings', + path: '/workspaces/current/rbac/apps/{app_id}/access-policies/{policy_id}/member-bindings', + tags: ['console'], + }) + .input( + z.object({ + params: zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsPath, + }), + ) + .output(zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse) + +export const get35 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindings', + path: '/workspaces/current/rbac/apps/{app_id}/access-policies/{policy_id}/member-bindings', + tags: ['console'], + }) + .input( + z.object({ + params: zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsPath, + }), + ) + .output(zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse) + +export const memberBindings = { + delete: delete10, + get: get35, +} + +export const get36 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindings', + path: '/workspaces/current/rbac/apps/{app_id}/access-policies/{policy_id}/role-bindings', + tags: ['console'], + }) + .input( + z.object({ + params: zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsPath, + }), + ) + .output(zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponse) + +export const roleBindings = { + get: get36, +} + +export const byPolicyId2 = { + memberBindings, + roleBindings, +} + +export const accessPolicies2 = { + byPolicyId: byPolicyId2, +} + +export const get37 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacAppsByAppIdAccessPolicy', + path: '/workspaces/current/rbac/apps/{app_id}/access-policy', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentRbacAppsByAppIdAccessPolicyPath })) + .output(zGetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponse) + +export const accessPolicy = { + get: get37, +} + +export const get38 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacAppsByAppIdUserAccessPolicies', + path: '/workspaces/current/rbac/apps/{app_id}/user-access-policies', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesPath })) + .output(zGetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponse) + +export const userAccessPolicies = { + get: get38, +} + +export const put7 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPolicies', + path: '/workspaces/current/rbac/apps/{app_id}/users/{target_account_id}/access-policies', + tags: ['console'], + }) + .input( + z.object({ + params: zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesPath, + }), + ) + .output(zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponse) + +export const accessPolicies3 = { + put: put7, +} + +export const byTargetAccountId = { + accessPolicies: accessPolicies3, +} + +export const users = { + byTargetAccountId, +} + +export const get39 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacAppsByAppIdWhitelist', + path: '/workspaces/current/rbac/apps/{app_id}/whitelist', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentRbacAppsByAppIdWhitelistPath })) + .output(zGetWorkspacesCurrentRbacAppsByAppIdWhitelistResponse) + +export const put8 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacAppsByAppIdWhitelist', + path: '/workspaces/current/rbac/apps/{app_id}/whitelist', + tags: ['console'], + }) + .input(z.object({ params: zPutWorkspacesCurrentRbacAppsByAppIdWhitelistPath })) + .output(zPutWorkspacesCurrentRbacAppsByAppIdWhitelistResponse) + +export const whitelist = { + get: get39, + put: put8, +} + +export const byAppId = { + accessPolicies: accessPolicies2, + accessPolicy, + userAccessPolicies, + users, + whitelist, +} + +export const apps = { + byAppId, +} + +export const delete11 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: + 'deleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindings', + path: '/workspaces/current/rbac/datasets/{dataset_id}/access-policies/{policy_id}/member-bindings', + tags: ['console'], + }) + .input( + z.object({ + params: + zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsPath, + }), + ) + .output( + zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse, + ) + +export const get40 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: + 'getWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindings', + path: '/workspaces/current/rbac/datasets/{dataset_id}/access-policies/{policy_id}/member-bindings', + tags: ['console'], + }) + .input( + z.object({ + params: + zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsPath, + }), + ) + .output( + zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse, + ) + +export const memberBindings2 = { + delete: delete11, + get: get40, +} + +export const get41 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindings', + path: '/workspaces/current/rbac/datasets/{dataset_id}/access-policies/{policy_id}/role-bindings', + tags: ['console'], + }) + .input( + z.object({ + params: zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsPath, + }), + ) + .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponse) + +export const roleBindings2 = { + get: get41, +} + +export const byPolicyId3 = { + memberBindings: memberBindings2, + roleBindings: roleBindings2, +} + +export const accessPolicies4 = { + byPolicyId: byPolicyId3, +} + +export const get42 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicy', + path: '/workspaces/current/rbac/datasets/{dataset_id}/access-policy', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyPath })) + .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponse) + +export const accessPolicy2 = { + get: get42, +} + +export const get43 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPolicies', + path: '/workspaces/current/rbac/datasets/{dataset_id}/user-access-policies', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesPath })) + .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponse) + +export const userAccessPolicies2 = { + get: get43, +} + +export const put9 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPolicies', + path: '/workspaces/current/rbac/datasets/{dataset_id}/users/{target_account_id}/access-policies', + tags: ['console'], + }) + .input( + z.object({ + params: zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesPath, + }), + ) + .output(zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponse) + +export const accessPolicies5 = { + put: put9, +} + +export const byTargetAccountId2 = { + accessPolicies: accessPolicies5, +} + +export const users2 = { + byTargetAccountId: byTargetAccountId2, +} + +export const get44 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacDatasetsByDatasetIdWhitelist', + path: '/workspaces/current/rbac/datasets/{dataset_id}/whitelist', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistPath })) + .output(zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse) + +export const put10 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacDatasetsByDatasetIdWhitelist', + path: '/workspaces/current/rbac/datasets/{dataset_id}/whitelist', + tags: ['console'], + }) + .input(z.object({ params: zPutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistPath })) + .output(zPutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse) + +export const whitelist2 = { + get: get44, + put: put10, +} + +export const byDatasetId = { + accessPolicies: accessPolicies4, + accessPolicy: accessPolicy2, + userAccessPolicies: userAccessPolicies2, + users: users2, + whitelist: whitelist2, +} + +export const datasets = { + byDatasetId, +} + +export const get45 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacMembersByMemberIdRbacRoles', + path: '/workspaces/current/rbac/members/{member_id}/rbac-roles', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesPath })) + .output(zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse) + +export const put11 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacMembersByMemberIdRbacRoles', + path: '/workspaces/current/rbac/members/{member_id}/rbac-roles', + tags: ['console'], + }) + .input(z.object({ params: zPutWorkspacesCurrentRbacMembersByMemberIdRbacRolesPath })) + .output(zPutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse) + +export const rbacRoles = { + get: get45, + put: put11, +} + +export const byMemberId2 = { + rbacRoles, +} + +export const members2 = { + byMemberId: byMemberId2, +} + +export const get46 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacMyPermissions', + path: '/workspaces/current/rbac/my-permissions', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentRbacMyPermissionsResponse) + +export const myPermissions = { + get: get46, +} + +export const get47 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacRolePermissionsCatalogApp', + path: '/workspaces/current/rbac/role-permissions/catalog/app', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogAppResponse) + +export const app = { + get: get47, +} + +export const get48 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacRolePermissionsCatalogDataset', + path: '/workspaces/current/rbac/role-permissions/catalog/dataset', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponse) + +export const dataset = { + get: get48, +} + +export const get49 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacRolePermissionsCatalog', + path: '/workspaces/current/rbac/role-permissions/catalog', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentRbacRolePermissionsCatalogResponse) + +export const catalog = { + get: get49, + app, + dataset, +} + +export const rolePermissions = { + catalog, +} + +export const post46 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentRbacRolesByRoleIdCopy', + path: '/workspaces/current/rbac/roles/{role_id}/copy', + successStatus: 201, + tags: ['console'], + }) + .input(z.object({ params: zPostWorkspacesCurrentRbacRolesByRoleIdCopyPath })) + .output(zPostWorkspacesCurrentRbacRolesByRoleIdCopyResponse) + +export const copy2 = { + post: post46, +} + +export const get50 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacRolesByRoleIdMembers', + path: '/workspaces/current/rbac/roles/{role_id}/members', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentRbacRolesByRoleIdMembersPath })) + .output(zGetWorkspacesCurrentRbacRolesByRoleIdMembersResponse) + +export const members3 = { + get: get50, +} + +export const delete12 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteWorkspacesCurrentRbacRolesByRoleId', + path: '/workspaces/current/rbac/roles/{role_id}', + tags: ['console'], + }) + .input(z.object({ params: zDeleteWorkspacesCurrentRbacRolesByRoleIdPath })) + .output(zDeleteWorkspacesCurrentRbacRolesByRoleIdResponse) + +export const get51 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacRolesByRoleId', + path: '/workspaces/current/rbac/roles/{role_id}', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentRbacRolesByRoleIdPath })) + .output(zGetWorkspacesCurrentRbacRolesByRoleIdResponse) + +export const put12 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacRolesByRoleId', + path: '/workspaces/current/rbac/roles/{role_id}', + tags: ['console'], + }) + .input(z.object({ params: zPutWorkspacesCurrentRbacRolesByRoleIdPath })) + .output(zPutWorkspacesCurrentRbacRolesByRoleIdResponse) + +export const byRoleId = { + delete: delete12, + get: get51, + put: put12, + copy: copy2, + members: members3, +} + +export const get52 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacRoles', + path: '/workspaces/current/rbac/roles', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentRbacRolesResponse) + +export const post47 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentRbacRoles', + path: '/workspaces/current/rbac/roles', + successStatus: 201, + tags: ['console'], + }) + .output(zPostWorkspacesCurrentRbacRolesResponse) + +export const roles = { + get: get52, + post: post47, + byRoleId, +} + +export const put13 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindings', + path: '/workspaces/current/rbac/workspace/apps/access-policies/{policy_id}/bindings', + tags: ['console'], + }) + .input( + z.object({ + params: zPutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsPath, + }), + ) + .output(zPutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponse) + +export const bindings = { + put: put13, +} + +export const get53 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindings', + path: '/workspaces/current/rbac/workspace/apps/access-policies/{policy_id}/member-bindings', + tags: ['console'], + }) + .input( + z.object({ + params: zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsPath, + }), + ) + .output(zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponse) + +export const memberBindings3 = { + get: get53, +} + +export const get54 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindings', + path: '/workspaces/current/rbac/workspace/apps/access-policies/{policy_id}/role-bindings', + tags: ['console'], + }) + .input( + z.object({ + params: zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsPath, + }), + ) + .output(zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponse) + +export const roleBindings3 = { + get: get54, +} + +export const byPolicyId4 = { + bindings, + memberBindings: memberBindings3, + roleBindings: roleBindings3, +} + +export const accessPolicies6 = { + byPolicyId: byPolicyId4, +} + +export const get55 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacWorkspaceAppsAccessPolicy', + path: '/workspaces/current/rbac/workspace/apps/access-policy', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponse) + +export const accessPolicy3 = { + get: get55, +} + +export const apps2 = { + accessPolicies: accessPolicies6, + accessPolicy: accessPolicy3, +} + +export const put14 = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindings', + path: '/workspaces/current/rbac/workspace/datasets/access-policies/{policy_id}/bindings', + tags: ['console'], + }) + .input( + z.object({ + params: zPutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsPath, + }), + ) + .output(zPutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponse) + +export const bindings2 = { + put: put14, +} + +export const get56 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindings', + path: '/workspaces/current/rbac/workspace/datasets/access-policies/{policy_id}/member-bindings', + tags: ['console'], + }) + .input( + z.object({ + params: zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsPath, + }), + ) + .output(zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponse) + +export const memberBindings4 = { + get: get56, +} + +export const get57 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindings', + path: '/workspaces/current/rbac/workspace/datasets/access-policies/{policy_id}/role-bindings', + tags: ['console'], + }) + .input( + z.object({ + params: zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsPath, + }), + ) + .output(zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponse) + +export const roleBindings4 = { + get: get57, +} + +export const byPolicyId5 = { + bindings: bindings2, + memberBindings: memberBindings4, + roleBindings: roleBindings4, +} + +export const accessPolicies7 = { + byPolicyId: byPolicyId5, +} + +export const get58 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicy', + path: '/workspaces/current/rbac/workspace/datasets/access-policy', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse) + +export const accessPolicy4 = { + get: get58, +} + +export const datasets2 = { + accessPolicies: accessPolicies7, + accessPolicy: accessPolicy4, +} + +export const workspace = { + apps: apps2, + datasets: datasets2, +} + +export const rbac = { + accessPolicies, + accessPolicyBindings, + apps, + datasets, + members: members2, + myPermissions, + rolePermissions, + roles, + workspace, +} + +export const get59 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2028,10 +2941,10 @@ export const get33 = oc .output(zGetWorkspacesCurrentToolLabelsResponse) export const toolLabels = { - get: get33, + get: get59, } -export const post44 = oc +export const post48 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2043,10 +2956,10 @@ export const post44 = oc .output(zPostWorkspacesCurrentToolProviderApiAddResponse) export const add = { - post: post44, + post: post48, } -export const post45 = oc +export const post49 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2057,11 +2970,11 @@ export const post45 = oc .input(z.object({ body: zPostWorkspacesCurrentToolProviderApiDeleteBody })) .output(zPostWorkspacesCurrentToolProviderApiDeleteResponse) -export const delete9 = { - post: post45, +export const delete13 = { + post: post49, } -export const get34 = oc +export const get60 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2072,11 +2985,11 @@ export const get34 = oc .input(z.object({ query: zGetWorkspacesCurrentToolProviderApiGetQuery })) .output(zGetWorkspacesCurrentToolProviderApiGetResponse) -export const get35 = { - get: get34, +export const get61 = { + get: get60, } -export const get36 = oc +export const get62 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2088,10 +3001,10 @@ export const get36 = oc .output(zGetWorkspacesCurrentToolProviderApiRemoteResponse) export const remote = { - get: get36, + get: get62, } -export const post46 = oc +export const post50 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2103,10 +3016,10 @@ export const post46 = oc .output(zPostWorkspacesCurrentToolProviderApiSchemaResponse) export const schema = { - post: post46, + post: post50, } -export const post47 = oc +export const post51 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2118,14 +3031,14 @@ export const post47 = oc .output(zPostWorkspacesCurrentToolProviderApiTestPreResponse) export const pre = { - post: post47, + post: post51, } export const test = { pre, } -export const get37 = oc +export const get63 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2137,10 +3050,10 @@ export const get37 = oc .output(zGetWorkspacesCurrentToolProviderApiToolsResponse) export const tools = { - get: get37, + get: get63, } -export const post48 = oc +export const post52 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2152,13 +3065,13 @@ export const post48 = oc .output(zPostWorkspacesCurrentToolProviderApiUpdateResponse) export const update2 = { - post: post48, + post: post52, } export const api = { add, - delete: delete9, - get: get35, + delete: delete13, + get: get61, remote, schema, test, @@ -2166,7 +3079,7 @@ export const api = { update: update2, } -export const post49 = oc +export const post53 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2183,10 +3096,10 @@ export const post49 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderAddResponse) export const add2 = { - post: post49, + post: post53, } -export const get38 = oc +export const get64 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2203,10 +3116,10 @@ export const get38 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponse) export const info = { - get: get38, + get: get64, } -export const get39 = oc +export const get65 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2226,7 +3139,7 @@ export const get39 = oc ) export const byCredentialType = { - get: get39, + get: get65, } export const schema2 = { @@ -2238,7 +3151,7 @@ export const credential = { schema: schema2, } -export const get40 = oc +export const get66 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2255,10 +3168,10 @@ export const get40 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponse) export const credentials3 = { - get: get40, + get: get66, } -export const post50 = oc +export const post54 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2275,10 +3188,10 @@ export const post50 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialResponse) export const defaultCredential = { - post: post50, + post: post54, } -export const post51 = oc +export const post55 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2294,11 +3207,11 @@ export const post51 = oc ) .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponse) -export const delete10 = { - post: post51, +export const delete14 = { + post: post55, } -export const get41 = oc +export const get67 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2310,10 +3223,10 @@ export const get41 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderIconResponse) export const icon2 = { - get: get41, + get: get67, } -export const get42 = oc +export const get68 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2325,10 +3238,10 @@ export const get42 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponse) export const info2 = { - get: get42, + get: get68, } -export const get43 = oc +export const get69 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2342,10 +3255,10 @@ export const get43 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponse) export const clientSchema = { - get: get43, + get: get69, } -export const delete11 = oc +export const delete15 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -2360,7 +3273,7 @@ export const delete11 = oc ) .output(zDeleteWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) -export const get44 = oc +export const get70 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2373,7 +3286,7 @@ export const get44 = oc ) .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) -export const post52 = oc +export const post56 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2390,9 +3303,9 @@ export const post52 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) export const customClient = { - delete: delete11, - get: get44, - post: post52, + delete: delete15, + get: get70, + post: post56, } export const oauth = { @@ -2400,7 +3313,7 @@ export const oauth = { customClient, } -export const get45 = oc +export const get71 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2412,10 +3325,10 @@ export const get45 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponse) export const tools2 = { - get: get45, + get: get71, } -export const post53 = oc +export const post57 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2432,7 +3345,7 @@ export const post53 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderUpdateResponse) export const update3 = { - post: post53, + post: post57, } export const byProvider2 = { @@ -2440,7 +3353,7 @@ export const byProvider2 = { credential, credentials: credentials3, defaultCredential, - delete: delete10, + delete: delete14, icon: icon2, info: info2, oauth, @@ -2452,7 +3365,7 @@ export const builtin = { byProvider: byProvider2, } -export const post54 = oc +export const post58 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2464,10 +3377,10 @@ export const post54 = oc .output(zPostWorkspacesCurrentToolProviderMcpAuthResponse) export const auth = { - post: post54, + post: post58, } -export const get46 = oc +export const get72 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2479,14 +3392,14 @@ export const get46 = oc .output(zGetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponse) export const byProviderId = { - get: get46, + get: get72, } export const tools3 = { byProviderId, } -export const get47 = oc +export const get73 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2498,14 +3411,14 @@ export const get47 = oc .output(zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponse) export const byProviderId2 = { - get: get47, + get: get73, } export const update4 = { byProviderId: byProviderId2, } -export const delete12 = oc +export const delete16 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -2516,7 +3429,7 @@ export const delete12 = oc .input(z.object({ body: zDeleteWorkspacesCurrentToolProviderMcpBody })) .output(zDeleteWorkspacesCurrentToolProviderMcpResponse) -export const post55 = oc +export const post59 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2527,7 +3440,7 @@ export const post55 = oc .input(z.object({ body: zPostWorkspacesCurrentToolProviderMcpBody })) .output(zPostWorkspacesCurrentToolProviderMcpResponse) -export const put4 = oc +export const put15 = oc .route({ inputStructure: 'detailed', method: 'PUT', @@ -2539,15 +3452,15 @@ export const put4 = oc .output(zPutWorkspacesCurrentToolProviderMcpResponse) export const mcp = { - delete: delete12, - post: post55, - put: put4, + delete: delete16, + post: post59, + put: put15, auth, tools: tools3, update: update4, } -export const post56 = oc +export const post60 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2559,10 +3472,10 @@ export const post56 = oc .output(zPostWorkspacesCurrentToolProviderWorkflowCreateResponse) export const create2 = { - post: post56, + post: post60, } -export const post57 = oc +export const post61 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2573,11 +3486,11 @@ export const post57 = oc .input(z.object({ body: zPostWorkspacesCurrentToolProviderWorkflowDeleteBody })) .output(zPostWorkspacesCurrentToolProviderWorkflowDeleteResponse) -export const delete13 = { - post: post57, +export const delete17 = { + post: post61, } -export const get48 = oc +export const get74 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2588,11 +3501,11 @@ export const get48 = oc .input(z.object({ query: zGetWorkspacesCurrentToolProviderWorkflowGetQuery.optional() })) .output(zGetWorkspacesCurrentToolProviderWorkflowGetResponse) -export const get49 = { - get: get48, +export const get75 = { + get: get74, } -export const get50 = oc +export const get76 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2604,10 +3517,10 @@ export const get50 = oc .output(zGetWorkspacesCurrentToolProviderWorkflowToolsResponse) export const tools4 = { - get: get50, + get: get76, } -export const post58 = oc +export const post62 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2619,13 +3532,13 @@ export const post58 = oc .output(zPostWorkspacesCurrentToolProviderWorkflowUpdateResponse) export const update5 = { - post: post58, + post: post62, } export const workflow = { create: create2, - delete: delete13, - get: get49, + delete: delete17, + get: get75, tools: tools4, update: update5, } @@ -2637,7 +3550,7 @@ export const toolProvider = { workflow, } -export const get51 = oc +export const get77 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2649,10 +3562,10 @@ export const get51 = oc .output(zGetWorkspacesCurrentToolProvidersResponse) export const toolProviders = { - get: get51, + get: get77, } -export const get52 = oc +export const get78 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2663,10 +3576,10 @@ export const get52 = oc .output(zGetWorkspacesCurrentToolsApiResponse) export const api2 = { - get: get52, + get: get78, } -export const get53 = oc +export const get79 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2677,10 +3590,10 @@ export const get53 = oc .output(zGetWorkspacesCurrentToolsBuiltinResponse) export const builtin2 = { - get: get53, + get: get79, } -export const get54 = oc +export const get80 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2691,10 +3604,10 @@ export const get54 = oc .output(zGetWorkspacesCurrentToolsMcpResponse) export const mcp2 = { - get: get54, + get: get80, } -export const get55 = oc +export const get81 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2705,7 +3618,7 @@ export const get55 = oc .output(zGetWorkspacesCurrentToolsWorkflowResponse) export const workflow2 = { - get: get55, + get: get81, } export const tools5 = { @@ -2715,7 +3628,7 @@ export const tools5 = { workflow: workflow2, } -export const get56 = oc +export const get82 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2727,13 +3640,13 @@ export const get56 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderIconResponse) export const icon3 = { - get: get56, + get: get82, } /** * Get info for a trigger provider */ -export const get57 = oc +export const get83 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2746,13 +3659,13 @@ export const get57 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderInfoResponse) export const info3 = { - get: get57, + get: get83, } /** * Remove custom OAuth client configuration */ -export const delete14 = oc +export const delete18 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -2767,7 +3680,7 @@ export const delete14 = oc /** * Get OAuth client configuration for a provider */ -export const get58 = oc +export const get84 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2782,7 +3695,7 @@ export const get58 = oc /** * Configure custom OAuth client for a provider */ -export const post59 = oc +export const post63 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2800,9 +3713,9 @@ export const post59 = oc .output(zPostWorkspacesCurrentTriggerProviderByProviderOauthClientResponse) export const client = { - delete: delete14, - get: get58, - post: post59, + delete: delete18, + get: get84, + post: post63, } export const oauth2 = { @@ -2812,7 +3725,7 @@ export const oauth2 = { /** * Build a subscription instance for a trigger provider */ -export const post60 = oc +export const post64 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2834,7 +3747,7 @@ export const post60 = oc ) export const bySubscriptionBuilderId = { - post: post60, + post: post64, } export const build = { @@ -2844,7 +3757,7 @@ export const build = { /** * Add a new subscription instance for a trigger provider */ -export const post61 = oc +export const post65 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2862,13 +3775,13 @@ export const post61 = oc .output(zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateResponse) export const create3 = { - post: post61, + post: post65, } /** * Get the request logs for a subscription instance for a trigger provider */ -export const get59 = oc +export const get85 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2889,7 +3802,7 @@ export const get59 = oc ) export const bySubscriptionBuilderId2 = { - get: get59, + get: get85, } export const logs = { @@ -2899,7 +3812,7 @@ export const logs = { /** * Update a subscription instance for a trigger provider */ -export const post62 = oc +export const post66 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2921,7 +3834,7 @@ export const post62 = oc ) export const bySubscriptionBuilderId3 = { - post: post62, + post: post66, } export const update6 = { @@ -2931,7 +3844,7 @@ export const update6 = { /** * Verify and update a subscription instance for a trigger provider */ -export const post63 = oc +export const post67 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -2953,7 +3866,7 @@ export const post63 = oc ) export const bySubscriptionBuilderId4 = { - post: post63, + post: post67, } export const verifyAndUpdate = { @@ -2963,7 +3876,7 @@ export const verifyAndUpdate = { /** * Get a subscription instance for a trigger provider */ -export const get60 = oc +export const get86 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2984,7 +3897,7 @@ export const get60 = oc ) export const bySubscriptionBuilderId5 = { - get: get60, + get: get86, } export const builder = { @@ -2999,7 +3912,7 @@ export const builder = { /** * List all trigger subscriptions for the current tenant's provider */ -export const get61 = oc +export const get87 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3012,13 +3925,13 @@ export const get61 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponse) export const list4 = { - get: get61, + get: get87, } /** * Initiate OAuth authorization flow for a trigger provider */ -export const get62 = oc +export const get88 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3035,7 +3948,7 @@ export const get62 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponse) export const authorize = { - get: get62, + get: get88, } export const oauth3 = { @@ -3045,7 +3958,7 @@ export const oauth3 = { /** * Verify credentials for an existing subscription (edit mode only) */ -export const post64 = oc +export const post68 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3067,7 +3980,7 @@ export const post64 = oc ) export const bySubscriptionId = { - post: post64, + post: post68, } export const verify = { @@ -3091,7 +4004,7 @@ export const byProvider3 = { /** * Delete a subscription instance */ -export const post65 = oc +export const post69 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3107,14 +4020,14 @@ export const post65 = oc ) .output(zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsDeleteResponse) -export const delete15 = { - post: post65, +export const delete19 = { + post: post69, } /** * Update a subscription instance */ -export const post66 = oc +export const post70 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3132,11 +4045,11 @@ export const post66 = oc .output(zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdateResponse) export const update7 = { - post: post66, + post: post70, } export const subscriptions2 = { - delete: delete15, + delete: delete19, update: update7, } @@ -3152,7 +4065,7 @@ export const triggerProvider = { /** * List all trigger providers for the current tenant */ -export const get63 = oc +export const get89 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3164,10 +4077,10 @@ export const get63 = oc .output(zGetWorkspacesCurrentTriggersResponse) export const triggers = { - get: get63, + get: get89, } -export const post67 = oc +export const post71 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3178,7 +4091,7 @@ export const post67 = oc .output(zPostWorkspacesCurrentResponse) export const current = { - post: post67, + post: post71, agentProvider, agentProviders, customizedSnippets, @@ -3190,6 +4103,7 @@ export const current = { models: models2, permission, plugin: plugin2, + rbac, toolLabels, toolProvider, toolProviders, @@ -3198,7 +4112,7 @@ export const current = { triggers, } -export const post68 = oc +export const post72 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3210,14 +4124,14 @@ export const post68 = oc .output(zPostWorkspacesCustomConfigWebappLogoUploadResponse) export const upload2 = { - post: post68, + post: post72, } export const webappLogo = { upload: upload2, } -export const post69 = oc +export const post73 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3229,11 +4143,11 @@ export const post69 = oc .output(zPostWorkspacesCustomConfigResponse) export const customConfig = { - post: post69, + post: post73, webappLogo, } -export const post70 = oc +export const post74 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3245,10 +4159,10 @@ export const post70 = oc .output(zPostWorkspacesInfoResponse) export const info4 = { - post: post70, + post: post74, } -export const post71 = oc +export const post75 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3260,10 +4174,10 @@ export const post71 = oc .output(zPostWorkspacesSwitchResponse) export const switch3 = { - post: post71, + post: post75, } -export const get64 = oc +export const get90 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3275,7 +4189,7 @@ export const get64 = oc .output(zGetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponse) export const byLang = { - get: get64, + get: get90, } export const byIconType = { @@ -3294,7 +4208,7 @@ export const byTenantId = { modelProviders: modelProviders2, } -export const get65 = oc +export const get91 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3305,7 +4219,7 @@ export const get65 = oc .output(zGetWorkspacesResponse) export const workspaces = { - get: get65, + get: get91, current, customConfig, info: info4, diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index 59ee3242e75..29f23567e95 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -182,7 +182,7 @@ export type EndpointUpdatePayload = { export type MemberInvitePayload = { emails?: Array language?: string | null - role: TenantAccountRole + role: string } export type MemberInviteResponse = { @@ -506,6 +506,109 @@ export type PluginCategoryListResponse = { plugins: Array } +export type AccessPolicyList = { + data?: Array + pagination?: Pagination | null +} + +export type AccessPolicy = { + category?: string + created_at?: number + description?: string + id: string + is_builtin?: boolean + name: string + permission_keys?: Array + policy_key?: string + resource_type: string + tenant_id?: string + updated_at?: number +} + +export type AccessPolicyBindingState = { + binding_id: string + is_locked?: boolean +} + +export type MemberBindingsResponse = { + data?: Array +} + +export type RoleBindingsResponse = { + data?: Array +} + +export type AppAccessMatrix = { + app_id?: string + items?: Array +} + +export type ResourceUserAccessPoliciesResponse = { + data?: Array + scope: string +} + +export type ReplaceUserAccessPoliciesResponse = { + access_policies?: Array +} + +export type ResourceWhitelist = { + account_ids?: Array +} + +export type DatasetAccessMatrix = { + dataset_id?: string + items?: Array +} + +export type MemberRolesResponse = { + account_id: string + roles?: Array +} + +export type MyPermissionsResponse = { + app?: ResourcePermissionSnapshot + dataset?: ResourcePermissionSnapshot + workspace?: WorkspacePermissionSnapshot +} + +export type PermissionCatalogResponse = { + groups?: Array +} + +export type RbacRoleList = { + data?: Array + pagination?: Pagination | null +} + +export type RbacRole = { + category?: string + description?: string + id: string + is_builtin?: boolean + name: string + permission_keys?: Array + role_tag?: string + tenant_id?: string | null + type: string +} + +export type MembersInRoleList = { + data?: Array + pagination?: Pagination | null +} + +export type AccessMatrixItem = { + accounts?: Array + policy?: AccessPolicy | null + roles?: Array +} + +export type WorkspaceAccessMatrix = { + items?: Array + pagination?: Pagination | null +} + export type ToolProviderOpaqueResponse = unknown export type ApiToolProviderAddPayload = { @@ -827,6 +930,9 @@ export type AccountWithRole = { last_login_at?: number | null name: string role: string + roles?: Array<{ + [key: string]: string + }> status: string } @@ -842,8 +948,6 @@ export type Inner = { provider?: string | null } -export type TenantAccountRole = 'admin' | 'dataset_operator' | 'editor' | 'normal' | 'owner' - export type MemberInviteResultResponse = { email: string message?: string | null @@ -1009,6 +1113,79 @@ export type PluginCategoryInstalledPluginResponse = { version: string } +export type Pagination = { + current_page?: number + per_page?: number + total_count?: number + total_pages?: number +} + +export type AccessPolicyMemberBinding = { + access_policy_id: string + account_id: string + account_name?: string + created_at?: number + id: string + resource_id?: string + resource_type: string + tenant_id?: string +} + +export type AccessPolicyRoleBinding = { + access_policy_id: string + created_at?: number + id: string + resource_id?: string + resource_type: string + role_id: string + role_name?: string + tenant_id?: string +} + +export type ResourceUserAccessPolicies = { + access_policies?: Array + account: RbacRoleAccount + roles?: Array +} + +export type ResourcePermissionSnapshot = { + default_permission_keys?: Array + overrides?: Array +} + +export type WorkspacePermissionSnapshot = { + permission_keys?: Array +} + +export type PermissionCatalogGroup = { + description?: string + group_key: string + group_name: string + permissions?: Array +} + +export type MembersInRole = { + account_id?: string + account_name?: string +} + +export type AccessPolicyAccount = { + account_id: string + account_name: string + avatar?: string + binding_id: string + email?: string + is_locked?: boolean +} + +export type AccessPolicyRole = { + binding_id: string + is_locked?: boolean + role_id: string + role_name: string + role_tag?: string +} + export type ApiProviderSchemaType = 'openai_actions' | 'openai_plugin' | 'openapi' | 'swagger' export type CredentialType = 'api-key' | 'oauth2' | 'unauthorized' @@ -1208,6 +1385,24 @@ export type PluginDeclarationResponse = { export type PluginInstallationSource = 'github' | 'marketplace' | 'package' | 'remote' +export type RbacRoleAccount = { + account_id: string + account_name?: string + avatar?: string + email?: string +} + +export type ResourcePermissionKeys = { + permission_keys?: Array + resource_id: string +} + +export type PermissionCatalogItem = { + description?: string + key: string + name: string +} + export type ToolParameterForm = 'form' | 'llm' | 'schema' export type AiModelEntityResponse = { @@ -2791,6 +2986,723 @@ export type GetWorkspacesCurrentPluginByCategoryListResponses = { export type GetWorkspacesCurrentPluginByCategoryListResponse = GetWorkspacesCurrentPluginByCategoryListResponses[keyof GetWorkspacesCurrentPluginByCategoryListResponses] +export type GetWorkspacesCurrentRbacAccessPoliciesData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/rbac/access-policies' +} + +export type GetWorkspacesCurrentRbacAccessPoliciesResponses = { + 200: AccessPolicyList +} + +export type GetWorkspacesCurrentRbacAccessPoliciesResponse + = GetWorkspacesCurrentRbacAccessPoliciesResponses[keyof GetWorkspacesCurrentRbacAccessPoliciesResponses] + +export type PostWorkspacesCurrentRbacAccessPoliciesData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/rbac/access-policies' +} + +export type PostWorkspacesCurrentRbacAccessPoliciesResponses = { + 201: AccessPolicy +} + +export type PostWorkspacesCurrentRbacAccessPoliciesResponse + = PostWorkspacesCurrentRbacAccessPoliciesResponses[keyof PostWorkspacesCurrentRbacAccessPoliciesResponses] + +export type DeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdData = { + body?: never + path: { + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/access-policies/{policy_id}' +} + +export type DeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses = { + 200: AccessPolicy +} + +export type DeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse + = DeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses[keyof DeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses] + +export type GetWorkspacesCurrentRbacAccessPoliciesByPolicyIdData = { + body?: never + path: { + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/access-policies/{policy_id}' +} + +export type GetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses = { + 200: AccessPolicy +} + +export type GetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse + = GetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses[keyof GetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses] + +export type PutWorkspacesCurrentRbacAccessPoliciesByPolicyIdData = { + body?: never + path: { + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/access-policies/{policy_id}' +} + +export type PutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses = { + 200: AccessPolicy +} + +export type PutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse + = PutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses[keyof PutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponses] + +export type PostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyData = { + body?: never + path: { + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/access-policies/{policy_id}/copy' +} + +export type PostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponses = { + 201: AccessPolicy +} + +export type PostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponse + = PostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponses[keyof PostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponses] + +export type PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockData = { + body?: never + path: { + binding_id: string + } + query?: never + url: '/workspaces/current/rbac/access-policy-bindings/{binding_id}/lock' +} + +export type PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponses = { + 200: AccessPolicyBindingState +} + +export type PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponse + = PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponses[keyof PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponses] + +export type PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockData = { + body?: never + path: { + binding_id: string + } + query?: never + url: '/workspaces/current/rbac/access-policy-bindings/{binding_id}/unlock' +} + +export type PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponses = { + 200: AccessPolicyBindingState +} + +export type PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponse + = PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponses[keyof PutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponses] + +export type DeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsData = { + body?: never + path: { + app_id: string + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/apps/{app_id}/access-policies/{policy_id}/member-bindings' +} + +export type DeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses + = { + 200: MemberBindingsResponse + } + +export type DeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse + = DeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses[keyof DeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses] + +export type GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsData = { + body?: never + path: { + app_id: string + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/apps/{app_id}/access-policies/{policy_id}/member-bindings' +} + +export type GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses = { + 200: MemberBindingsResponse +} + +export type GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse + = GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses[keyof GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponses] + +export type GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsData = { + body?: never + path: { + app_id: string + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/apps/{app_id}/access-policies/{policy_id}/role-bindings' +} + +export type GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponses = { + 200: RoleBindingsResponse +} + +export type GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponse + = GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponses[keyof GetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponses] + +export type GetWorkspacesCurrentRbacAppsByAppIdAccessPolicyData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/workspaces/current/rbac/apps/{app_id}/access-policy' +} + +export type GetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponses = { + 200: AppAccessMatrix +} + +export type GetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponse + = GetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponses[keyof GetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponses] + +export type GetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/workspaces/current/rbac/apps/{app_id}/user-access-policies' +} + +export type GetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponses = { + 200: ResourceUserAccessPoliciesResponse +} + +export type GetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponse + = GetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponses[keyof GetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponses] + +export type PutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesData = { + body?: never + path: { + app_id: string + target_account_id: string + } + query?: never + url: '/workspaces/current/rbac/apps/{app_id}/users/{target_account_id}/access-policies' +} + +export type PutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponses = { + 200: ReplaceUserAccessPoliciesResponse +} + +export type PutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponse + = PutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponses[keyof PutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponses] + +export type GetWorkspacesCurrentRbacAppsByAppIdWhitelistData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/workspaces/current/rbac/apps/{app_id}/whitelist' +} + +export type GetWorkspacesCurrentRbacAppsByAppIdWhitelistResponses = { + 200: ResourceWhitelist +} + +export type GetWorkspacesCurrentRbacAppsByAppIdWhitelistResponse + = GetWorkspacesCurrentRbacAppsByAppIdWhitelistResponses[keyof GetWorkspacesCurrentRbacAppsByAppIdWhitelistResponses] + +export type PutWorkspacesCurrentRbacAppsByAppIdWhitelistData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/workspaces/current/rbac/apps/{app_id}/whitelist' +} + +export type PutWorkspacesCurrentRbacAppsByAppIdWhitelistResponses = { + 200: ResourceWhitelist +} + +export type PutWorkspacesCurrentRbacAppsByAppIdWhitelistResponse + = PutWorkspacesCurrentRbacAppsByAppIdWhitelistResponses[keyof PutWorkspacesCurrentRbacAppsByAppIdWhitelistResponses] + +export type DeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsData + = { + body?: never + path: { + dataset_id: string + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/datasets/{dataset_id}/access-policies/{policy_id}/member-bindings' + } + +export type DeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses + = { + 200: MemberBindingsResponse + } + +export type DeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse + = DeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses[keyof DeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses] + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsData + = { + body?: never + path: { + dataset_id: string + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/datasets/{dataset_id}/access-policies/{policy_id}/member-bindings' + } + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses + = { + 200: MemberBindingsResponse + } + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse + = GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses[keyof GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponses] + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsData = { + body?: never + path: { + dataset_id: string + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/datasets/{dataset_id}/access-policies/{policy_id}/role-bindings' +} + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponses + = { + 200: RoleBindingsResponse + } + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponse + = GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponses[keyof GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponses] + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyData = { + body?: never + path: { + dataset_id: string + } + query?: never + url: '/workspaces/current/rbac/datasets/{dataset_id}/access-policy' +} + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponses = { + 200: DatasetAccessMatrix +} + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponse + = GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponses[keyof GetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponses] + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesData = { + body?: never + path: { + dataset_id: string + } + query?: never + url: '/workspaces/current/rbac/datasets/{dataset_id}/user-access-policies' +} + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponses = { + 200: ResourceUserAccessPoliciesResponse +} + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponse + = GetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponses[keyof GetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponses] + +export type PutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesData = { + body?: never + path: { + dataset_id: string + target_account_id: string + } + query?: never + url: '/workspaces/current/rbac/datasets/{dataset_id}/users/{target_account_id}/access-policies' +} + +export type PutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponses + = { + 200: ReplaceUserAccessPoliciesResponse + } + +export type PutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponse + = PutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponses[keyof PutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponses] + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistData = { + body?: never + path: { + dataset_id: string + } + query?: never + url: '/workspaces/current/rbac/datasets/{dataset_id}/whitelist' +} + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses = { + 200: ResourceWhitelist +} + +export type GetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse + = GetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses[keyof GetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses] + +export type PutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistData = { + body?: never + path: { + dataset_id: string + } + query?: never + url: '/workspaces/current/rbac/datasets/{dataset_id}/whitelist' +} + +export type PutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses = { + 200: ResourceWhitelist +} + +export type PutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse + = PutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses[keyof PutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponses] + +export type GetWorkspacesCurrentRbacMembersByMemberIdRbacRolesData = { + body?: never + path: { + member_id: string + } + query?: never + url: '/workspaces/current/rbac/members/{member_id}/rbac-roles' +} + +export type GetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses = { + 200: MemberRolesResponse +} + +export type GetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse + = GetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses[keyof GetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses] + +export type PutWorkspacesCurrentRbacMembersByMemberIdRbacRolesData = { + body?: never + path: { + member_id: string + } + query?: never + url: '/workspaces/current/rbac/members/{member_id}/rbac-roles' +} + +export type PutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses = { + 200: MemberRolesResponse +} + +export type PutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse + = PutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses[keyof PutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponses] + +export type GetWorkspacesCurrentRbacMyPermissionsData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/rbac/my-permissions' +} + +export type GetWorkspacesCurrentRbacMyPermissionsResponses = { + 200: MyPermissionsResponse +} + +export type GetWorkspacesCurrentRbacMyPermissionsResponse + = GetWorkspacesCurrentRbacMyPermissionsResponses[keyof GetWorkspacesCurrentRbacMyPermissionsResponses] + +export type GetWorkspacesCurrentRbacRolePermissionsCatalogData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/rbac/role-permissions/catalog' +} + +export type GetWorkspacesCurrentRbacRolePermissionsCatalogResponses = { + 200: PermissionCatalogResponse +} + +export type GetWorkspacesCurrentRbacRolePermissionsCatalogResponse + = GetWorkspacesCurrentRbacRolePermissionsCatalogResponses[keyof GetWorkspacesCurrentRbacRolePermissionsCatalogResponses] + +export type GetWorkspacesCurrentRbacRolePermissionsCatalogAppData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/rbac/role-permissions/catalog/app' +} + +export type GetWorkspacesCurrentRbacRolePermissionsCatalogAppResponses = { + 200: PermissionCatalogResponse +} + +export type GetWorkspacesCurrentRbacRolePermissionsCatalogAppResponse + = GetWorkspacesCurrentRbacRolePermissionsCatalogAppResponses[keyof GetWorkspacesCurrentRbacRolePermissionsCatalogAppResponses] + +export type GetWorkspacesCurrentRbacRolePermissionsCatalogDatasetData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/rbac/role-permissions/catalog/dataset' +} + +export type GetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponses = { + 200: PermissionCatalogResponse +} + +export type GetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponse + = GetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponses[keyof GetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponses] + +export type GetWorkspacesCurrentRbacRolesData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/rbac/roles' +} + +export type GetWorkspacesCurrentRbacRolesResponses = { + 200: RbacRoleList +} + +export type GetWorkspacesCurrentRbacRolesResponse + = GetWorkspacesCurrentRbacRolesResponses[keyof GetWorkspacesCurrentRbacRolesResponses] + +export type PostWorkspacesCurrentRbacRolesData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/rbac/roles' +} + +export type PostWorkspacesCurrentRbacRolesResponses = { + 201: RbacRole +} + +export type PostWorkspacesCurrentRbacRolesResponse + = PostWorkspacesCurrentRbacRolesResponses[keyof PostWorkspacesCurrentRbacRolesResponses] + +export type DeleteWorkspacesCurrentRbacRolesByRoleIdData = { + body?: never + path: { + role_id: string + } + query?: never + url: '/workspaces/current/rbac/roles/{role_id}' +} + +export type DeleteWorkspacesCurrentRbacRolesByRoleIdResponses = { + 200: RbacRole +} + +export type DeleteWorkspacesCurrentRbacRolesByRoleIdResponse + = DeleteWorkspacesCurrentRbacRolesByRoleIdResponses[keyof DeleteWorkspacesCurrentRbacRolesByRoleIdResponses] + +export type GetWorkspacesCurrentRbacRolesByRoleIdData = { + body?: never + path: { + role_id: string + } + query?: never + url: '/workspaces/current/rbac/roles/{role_id}' +} + +export type GetWorkspacesCurrentRbacRolesByRoleIdResponses = { + 200: RbacRole +} + +export type GetWorkspacesCurrentRbacRolesByRoleIdResponse + = GetWorkspacesCurrentRbacRolesByRoleIdResponses[keyof GetWorkspacesCurrentRbacRolesByRoleIdResponses] + +export type PutWorkspacesCurrentRbacRolesByRoleIdData = { + body?: never + path: { + role_id: string + } + query?: never + url: '/workspaces/current/rbac/roles/{role_id}' +} + +export type PutWorkspacesCurrentRbacRolesByRoleIdResponses = { + 200: RbacRole +} + +export type PutWorkspacesCurrentRbacRolesByRoleIdResponse + = PutWorkspacesCurrentRbacRolesByRoleIdResponses[keyof PutWorkspacesCurrentRbacRolesByRoleIdResponses] + +export type PostWorkspacesCurrentRbacRolesByRoleIdCopyData = { + body?: never + path: { + role_id: string + } + query?: never + url: '/workspaces/current/rbac/roles/{role_id}/copy' +} + +export type PostWorkspacesCurrentRbacRolesByRoleIdCopyResponses = { + 201: RbacRole +} + +export type PostWorkspacesCurrentRbacRolesByRoleIdCopyResponse + = PostWorkspacesCurrentRbacRolesByRoleIdCopyResponses[keyof PostWorkspacesCurrentRbacRolesByRoleIdCopyResponses] + +export type GetWorkspacesCurrentRbacRolesByRoleIdMembersData = { + body?: never + path: { + role_id: string + } + query?: never + url: '/workspaces/current/rbac/roles/{role_id}/members' +} + +export type GetWorkspacesCurrentRbacRolesByRoleIdMembersResponses = { + 200: MembersInRoleList +} + +export type GetWorkspacesCurrentRbacRolesByRoleIdMembersResponse + = GetWorkspacesCurrentRbacRolesByRoleIdMembersResponses[keyof GetWorkspacesCurrentRbacRolesByRoleIdMembersResponses] + +export type PutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsData = { + body?: never + path: { + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/workspace/apps/access-policies/{policy_id}/bindings' +} + +export type PutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponses = { + 200: AccessMatrixItem +} + +export type PutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponse + = PutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponses[keyof PutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponses] + +export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsData = { + body?: never + path: { + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/workspace/apps/access-policies/{policy_id}/member-bindings' +} + +export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponses = { + 200: MemberBindingsResponse +} + +export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponse + = GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponses[keyof GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponses] + +export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsData = { + body?: never + path: { + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/workspace/apps/access-policies/{policy_id}/role-bindings' +} + +export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponses = { + 200: RoleBindingsResponse +} + +export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponse + = GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponses[keyof GetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponses] + +export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/rbac/workspace/apps/access-policy' +} + +export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponses = { + 200: WorkspaceAccessMatrix +} + +export type GetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponse + = GetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponses[keyof GetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponses] + +export type PutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsData = { + body?: never + path: { + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/workspace/datasets/access-policies/{policy_id}/bindings' +} + +export type PutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponses = { + 200: AccessMatrixItem +} + +export type PutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponse + = PutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponses[keyof PutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponses] + +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsData = { + body?: never + path: { + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/workspace/datasets/access-policies/{policy_id}/member-bindings' +} + +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponses + = { + 200: MemberBindingsResponse + } + +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponse + = GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponses[keyof GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponses] + +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsData = { + body?: never + path: { + policy_id: string + } + query?: never + url: '/workspaces/current/rbac/workspace/datasets/access-policies/{policy_id}/role-bindings' +} + +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponses + = { + 200: RoleBindingsResponse + } + +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponse + = GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponses[keyof GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponses] + +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyData = { + body?: never + path?: never + query?: never + url: '/workspaces/current/rbac/workspace/datasets/access-policy' +} + +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses = { + 200: WorkspaceAccessMatrix +} + +export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse + = GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses[keyof GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses] + export type GetWorkspacesCurrentToolLabelsData = { body?: never path?: never diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index 8c430ddec8b..fb6f643d7a9 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -138,6 +138,15 @@ export const zEndpointUpdatePayload = z.object({ settings: z.record(z.string(), z.unknown()), }) +/** + * MemberInvitePayload + */ +export const zMemberInvitePayload = z.object({ + emails: z.array(z.string()).optional(), + language: z.string().nullish(), + role: z.string(), +}) + /** * OwnerTransferCheckPayload */ @@ -446,6 +455,68 @@ export const zParserGithubUpload = z.object({ version: z.string(), }) +/** + * AccessPolicy + */ +export const zAccessPolicy = z.object({ + category: z.string().optional().default(''), + created_at: z.int().optional().default(0), + description: z.string().optional().default(''), + id: z.string(), + is_builtin: z.boolean().optional().default(false), + name: z.string(), + permission_keys: z.array(z.string()).optional(), + policy_key: z.string().optional().default(''), + resource_type: z.string(), + tenant_id: z.string().optional().default(''), + updated_at: z.int().optional().default(0), +}) + +/** + * AccessPolicyBindingState + */ +export const zAccessPolicyBindingState = z.object({ + binding_id: z.string(), + is_locked: z.boolean().optional().default(false), +}) + +/** + * ReplaceUserAccessPoliciesResponse + */ +export const zReplaceUserAccessPoliciesResponse = z.object({ + access_policies: z.array(zAccessPolicy).optional(), +}) + +/** + * ResourceWhitelist + */ +export const zResourceWhitelist = z.object({ + account_ids: z.array(z.string()).optional(), +}) + +/** + * RBACRole + */ +export const zRbacRole = z.object({ + category: z.string().optional().default(''), + description: z.string().optional().default(''), + id: z.string(), + is_builtin: z.boolean().optional().default(false), + name: z.string(), + permission_keys: z.array(z.string()).optional(), + role_tag: z.string().optional().default(''), + tenant_id: z.string().nullish(), + type: z.string(), +}) + +/** + * MemberRolesResponse + */ +export const zMemberRolesResponse = z.object({ + account_id: z.string(), + roles: z.array(zRbacRole).optional(), +}) + /** * ToolProviderOpaqueResponse */ @@ -829,6 +900,7 @@ export const zAccountWithRole = z.object({ last_login_at: z.int().nullish(), name: z.string(), role: z.string(), + roles: z.array(z.record(z.string(), z.string())).optional(), status: z.string(), }) @@ -839,20 +911,6 @@ export const zAccountWithRoleList = z.object({ accounts: z.array(zAccountWithRole), }) -/** - * TenantAccountRole - */ -export const zTenantAccountRole = z.enum(['admin', 'dataset_operator', 'editor', 'normal', 'owner']) - -/** - * MemberInvitePayload - */ -export const zMemberInvitePayload = z.object({ - emails: z.array(z.string()).optional(), - language: z.string().nullish(), - role: zTenantAccountRole, -}) - /** * MemberInviteResultResponse */ @@ -1061,6 +1119,153 @@ export const zPluginPermissionResponse = z.object({ install_permission: zInstallPermission, }) +/** + * Pagination + */ +export const zPagination = z.object({ + current_page: z.int().optional().default(0), + per_page: z.int().optional().default(0), + total_count: z.int().optional().default(0), + total_pages: z.int().optional().default(0), +}) + +/** + * _AccessPolicyList + */ +export const zAccessPolicyList = z.object({ + data: z.array(zAccessPolicy).optional(), + pagination: zPagination.nullish(), +}) + +/** + * _RBACRoleList + */ +export const zRbacRoleList = z.object({ + data: z.array(zRbacRole).optional(), + pagination: zPagination.nullish(), +}) + +/** + * AccessPolicyMemberBinding + */ +export const zAccessPolicyMemberBinding = z.object({ + access_policy_id: z.string(), + account_id: z.string(), + account_name: z.string().optional().default(''), + created_at: z.int().optional().default(0), + id: z.string(), + resource_id: z.string().optional().default(''), + resource_type: z.string(), + tenant_id: z.string().optional().default(''), +}) + +/** + * MemberBindingsResponse + */ +export const zMemberBindingsResponse = z.object({ + data: z.array(zAccessPolicyMemberBinding).optional(), +}) + +/** + * AccessPolicyRoleBinding + */ +export const zAccessPolicyRoleBinding = z.object({ + access_policy_id: z.string(), + created_at: z.int().optional().default(0), + id: z.string(), + resource_id: z.string().optional().default(''), + resource_type: z.string(), + role_id: z.string(), + role_name: z.string().optional().default(''), + tenant_id: z.string().optional().default(''), +}) + +/** + * RoleBindingsResponse + */ +export const zRoleBindingsResponse = z.object({ + data: z.array(zAccessPolicyRoleBinding).optional(), +}) + +/** + * WorkspacePermissionSnapshot + */ +export const zWorkspacePermissionSnapshot = z.object({ + permission_keys: z.array(z.string()).optional(), +}) + +/** + * MembersInRole + */ +export const zMembersInRole = z.object({ + account_id: z.string().optional().default(''), + account_name: z.string().optional().default(''), +}) + +/** + * _MembersInRoleList + */ +export const zMembersInRoleList = z.object({ + data: z.array(zMembersInRole).optional(), + pagination: zPagination.nullish(), +}) + +/** + * AccessPolicyAccount + */ +export const zAccessPolicyAccount = z.object({ + account_id: z.string(), + account_name: z.string(), + avatar: z.string().optional().default(''), + binding_id: z.string(), + email: z.string().optional().default(''), + is_locked: z.boolean().optional().default(false), +}) + +/** + * AccessPolicyRole + */ +export const zAccessPolicyRole = z.object({ + binding_id: z.string(), + is_locked: z.boolean().optional().default(false), + role_id: z.string(), + role_name: z.string(), + role_tag: z.string().optional().default(''), +}) + +/** + * AccessMatrixItem + */ +export const zAccessMatrixItem = z.object({ + accounts: z.array(zAccessPolicyAccount).optional(), + policy: zAccessPolicy.nullish(), + roles: z.array(zAccessPolicyRole).optional(), +}) + +/** + * AppAccessMatrix + */ +export const zAppAccessMatrix = z.object({ + app_id: z.string().optional().default(''), + items: z.array(zAccessMatrixItem).optional(), +}) + +/** + * DatasetAccessMatrix + */ +export const zDatasetAccessMatrix = z.object({ + dataset_id: z.string().optional().default(''), + items: z.array(zAccessMatrixItem).optional(), +}) + +/** + * WorkspaceAccessMatrix + */ +export const zWorkspaceAccessMatrix = z.object({ + items: z.array(zAccessMatrixItem).optional(), + pagination: zPagination.nullish(), +}) + /** * ApiProviderSchemaType * @@ -1465,6 +1670,84 @@ export const zPluginCategoryBuiltinToolProviderResponse = z.object({ */ export const zPluginInstallationSource = z.enum(['github', 'marketplace', 'package', 'remote']) +/** + * RBACRoleAccount + */ +export const zRbacRoleAccount = z.object({ + account_id: z.string(), + account_name: z.string().optional().default(''), + avatar: z.string().optional().default(''), + email: z.string().optional().default(''), +}) + +/** + * ResourceUserAccessPolicies + */ +export const zResourceUserAccessPolicies = z.object({ + access_policies: z.array(zAccessPolicy).optional(), + account: zRbacRoleAccount, + roles: z.array(zRbacRole).optional(), +}) + +/** + * ResourceUserAccessPoliciesResponse + */ +export const zResourceUserAccessPoliciesResponse = z.object({ + data: z.array(zResourceUserAccessPolicies).optional(), + scope: z.string(), +}) + +/** + * ResourcePermissionKeys + */ +export const zResourcePermissionKeys = z.object({ + permission_keys: z.array(z.string()).optional(), + resource_id: z.string(), +}) + +/** + * ResourcePermissionSnapshot + */ +export const zResourcePermissionSnapshot = z.object({ + default_permission_keys: z.array(z.string()).optional(), + overrides: z.array(zResourcePermissionKeys).optional(), +}) + +/** + * MyPermissionsResponse + */ +export const zMyPermissionsResponse = z.object({ + app: zResourcePermissionSnapshot.optional(), + dataset: zResourcePermissionSnapshot.optional(), + workspace: zWorkspacePermissionSnapshot.optional(), +}) + +/** + * PermissionCatalogItem + */ +export const zPermissionCatalogItem = z.object({ + description: z.string().optional().default(''), + key: z.string(), + name: z.string(), +}) + +/** + * PermissionCatalogGroup + */ +export const zPermissionCatalogGroup = z.object({ + description: z.string().optional().default(''), + group_key: z.string(), + group_name: z.string(), + permissions: z.array(zPermissionCatalogItem).optional(), +}) + +/** + * PermissionCatalogResponse + */ +export const zPermissionCatalogResponse = z.object({ + groups: z.array(zPermissionCatalogGroup).optional(), +}) + /** * ToolParameterForm */ @@ -2711,6 +2994,411 @@ export const zGetWorkspacesCurrentPluginByCategoryListQuery = z.object({ */ export const zGetWorkspacesCurrentPluginByCategoryListResponse = zPluginCategoryListResponse +/** + * Success + */ +export const zGetWorkspacesCurrentRbacAccessPoliciesResponse = zAccessPolicyList + +/** + * Policy created + */ +export const zPostWorkspacesCurrentRbacAccessPoliciesResponse = zAccessPolicy + +export const zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath = z.object({ + policy_id: z.uuid(), +}) + +/** + * Success + */ +export const zDeleteWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse = zAccessPolicy + +export const zGetWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath = z.object({ + policy_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse = zAccessPolicy + +export const zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdPath = z.object({ + policy_id: z.uuid(), +}) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacAccessPoliciesByPolicyIdResponse = zAccessPolicy + +export const zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyPath = z.object({ + policy_id: z.uuid(), +}) + +/** + * Policy copied + */ +export const zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponse = zAccessPolicy + +export const zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockPath = z.object({ + binding_id: z.uuid(), +}) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdLockResponse + = zAccessPolicyBindingState + +export const zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockPath = z.object({ + binding_id: z.uuid(), +}) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacAccessPolicyBindingsByBindingIdUnlockResponse + = zAccessPolicyBindingState + +export const zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsPath + = z.object({ + app_id: z.uuid(), + policy_id: z.string(), + }) + +/** + * Success + */ +export const zDeleteWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse + = zMemberBindingsResponse + +export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsPath + = z.object({ + app_id: z.uuid(), + policy_id: z.string(), + }) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdMemberBindingsResponse + = zMemberBindingsResponse + +export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsPath + = z.object({ + app_id: z.uuid(), + policy_id: z.uuid(), + }) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPoliciesByPolicyIdRoleBindingsResponse + = zRoleBindingsResponse + +export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPolicyPath = z.object({ + app_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacAppsByAppIdAccessPolicyResponse = zAppAccessMatrix + +export const zGetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesPath = z.object({ + app_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacAppsByAppIdUserAccessPoliciesResponse + = zResourceUserAccessPoliciesResponse + +export const zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesPath + = z.object({ + app_id: z.uuid(), + target_account_id: z.uuid(), + }) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacAppsByAppIdUsersByTargetAccountIdAccessPoliciesResponse + = zReplaceUserAccessPoliciesResponse + +export const zGetWorkspacesCurrentRbacAppsByAppIdWhitelistPath = z.object({ + app_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacAppsByAppIdWhitelistResponse = zResourceWhitelist + +export const zPutWorkspacesCurrentRbacAppsByAppIdWhitelistPath = z.object({ + app_id: z.uuid(), +}) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacAppsByAppIdWhitelistResponse = zResourceWhitelist + +export const zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsPath + = z.object({ + dataset_id: z.uuid(), + policy_id: z.string(), + }) + +/** + * Success + */ +export const zDeleteWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse + = zMemberBindingsResponse + +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsPath + = z.object({ + dataset_id: z.uuid(), + policy_id: z.string(), + }) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdMemberBindingsResponse + = zMemberBindingsResponse + +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsPath + = z.object({ + dataset_id: z.uuid(), + policy_id: z.uuid(), + }) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPoliciesByPolicyIdRoleBindingsResponse + = zRoleBindingsResponse + +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyPath = z.object({ + dataset_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdAccessPolicyResponse = zDatasetAccessMatrix + +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesPath = z.object({ + dataset_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdUserAccessPoliciesResponse + = zResourceUserAccessPoliciesResponse + +export const zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesPath + = z.object({ + dataset_id: z.uuid(), + target_account_id: z.uuid(), + }) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacDatasetsByDatasetIdUsersByTargetAccountIdAccessPoliciesResponse + = zReplaceUserAccessPoliciesResponse + +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistPath = z.object({ + dataset_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse = zResourceWhitelist + +export const zPutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistPath = z.object({ + dataset_id: z.uuid(), +}) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacDatasetsByDatasetIdWhitelistResponse = zResourceWhitelist + +export const zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesPath = z.object({ + member_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse = zMemberRolesResponse + +export const zPutWorkspacesCurrentRbacMembersByMemberIdRbacRolesPath = z.object({ + member_id: z.uuid(), +}) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacMembersByMemberIdRbacRolesResponse = zMemberRolesResponse + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacMyPermissionsResponse = zMyPermissionsResponse + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacRolePermissionsCatalogResponse = zPermissionCatalogResponse + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacRolePermissionsCatalogAppResponse = zPermissionCatalogResponse + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacRolePermissionsCatalogDatasetResponse + = zPermissionCatalogResponse + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacRolesResponse = zRbacRoleList + +/** + * Role created + */ +export const zPostWorkspacesCurrentRbacRolesResponse = zRbacRole + +export const zDeleteWorkspacesCurrentRbacRolesByRoleIdPath = z.object({ + role_id: z.uuid(), +}) + +/** + * Success + */ +export const zDeleteWorkspacesCurrentRbacRolesByRoleIdResponse = zRbacRole + +export const zGetWorkspacesCurrentRbacRolesByRoleIdPath = z.object({ + role_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacRolesByRoleIdResponse = zRbacRole + +export const zPutWorkspacesCurrentRbacRolesByRoleIdPath = z.object({ + role_id: z.uuid(), +}) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacRolesByRoleIdResponse = zRbacRole + +export const zPostWorkspacesCurrentRbacRolesByRoleIdCopyPath = z.object({ + role_id: z.uuid(), +}) + +/** + * Role copied + */ +export const zPostWorkspacesCurrentRbacRolesByRoleIdCopyResponse = zRbacRole + +export const zGetWorkspacesCurrentRbacRolesByRoleIdMembersPath = z.object({ + role_id: z.uuid(), +}) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacRolesByRoleIdMembersResponse = zMembersInRoleList + +export const zPutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsPath = z.object({ + policy_id: z.uuid(), +}) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdBindingsResponse + = zAccessMatrixItem + +export const zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsPath + = z.object({ + policy_id: z.uuid(), + }) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdMemberBindingsResponse + = zMemberBindingsResponse + +export const zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsPath + = z.object({ + policy_id: z.uuid(), + }) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacWorkspaceAppsAccessPoliciesByPolicyIdRoleBindingsResponse + = zRoleBindingsResponse + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacWorkspaceAppsAccessPolicyResponse = zWorkspaceAccessMatrix + +export const zPutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsPath + = z.object({ + policy_id: z.uuid(), + }) + +/** + * Success + */ +export const zPutWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdBindingsResponse + = zAccessMatrixItem + +export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsPath + = z.object({ + policy_id: z.uuid(), + }) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdMemberBindingsResponse + = zMemberBindingsResponse + +export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsPath + = z.object({ + policy_id: z.uuid(), + }) + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdRoleBindingsResponse + = zRoleBindingsResponse + +/** + * Success + */ +export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse = zWorkspaceAccessMatrix + /** * Success */ diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index 244ce92417c..e1217f3f6d7 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -257,6 +257,7 @@ export type Import = { error?: string id: string imported_dsl_version?: string + permission_keys?: Array status: ImportStatus } diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index df0d82117a0..51a3cb8f480 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -263,6 +263,7 @@ export const zImport = z.object({ error: z.string().optional().default(''), id: z.string(), imported_dsl_version: z.string().optional().default(''), + permission_keys: z.array(z.string()).optional(), status: zImportStatus, }) diff --git a/packages/contracts/generated/api/service/types.gen.ts b/packages/contracts/generated/api/service/types.gen.ts index 687fc29f1db..97921643514 100644 --- a/packages/contracts/generated/api/service/types.gen.ts +++ b/packages/contracts/generated/api/service/types.gen.ts @@ -352,8 +352,10 @@ export type DatasetDetailResponse = { indexing_technique: string | null is_multimodal: boolean is_published: boolean + maintainer?: string | null name: string permission: string + permission_keys?: Array pipeline_id: string | null provider: string retrieval_model_dict: DatasetRetrievalModelResponse @@ -390,9 +392,11 @@ export type DatasetDetailWithPartialMembersResponse = { indexing_technique: string | null is_multimodal: boolean is_published: boolean + maintainer?: string | null name: string partial_member_list?: Array | null permission: string + permission_keys?: Array pipeline_id: string | null provider: string retrieval_model_dict: DatasetRetrievalModelResponse diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index 5dcac8cf9db..6ccc5671cb2 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -602,8 +602,10 @@ export const zDatasetDetailResponse = z.object({ indexing_technique: z.string().nullable(), is_multimodal: z.boolean(), is_published: z.boolean(), + maintainer: z.string().nullish(), name: z.string(), permission: z.string(), + permission_keys: z.array(z.string()).optional(), pipeline_id: z.string().nullable(), provider: z.string(), retrieval_model_dict: zDatasetRetrievalModelResponse, @@ -643,9 +645,11 @@ export const zDatasetDetailWithPartialMembersResponse = z.object({ indexing_technique: z.string().nullable(), is_multimodal: z.boolean(), is_published: z.boolean(), + maintainer: z.string().nullish(), name: z.string(), partial_member_list: z.array(z.string()).nullish(), permission: z.string(), + permission_keys: z.array(z.string()).optional(), pipeline_id: z.string().nullable(), provider: z.string(), retrieval_model_dict: zDatasetRetrievalModelResponse, diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index a1f03e0b3c3..61c9cf103be 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -566,6 +566,7 @@ export type SystemFeatureModel = { max_plugin_package_size: number plugin_installation_permission: PluginInstallationPermissionModel plugin_manager: PluginManagerModel + rbac_enabled: boolean sso_enforced_for_signin: boolean sso_enforced_for_signin_protocol: string webapp_auth: WebAppAuthModel diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index 8c35ac0ca54..8045ad341e3 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -861,6 +861,7 @@ export const zSystemFeatureModel = z.object({ restrict_to_marketplace_only: false, }), plugin_manager: zPluginManagerModel.default({ enabled: false }), + rbac_enabled: z.boolean().default(false), sso_enforced_for_signin: z.boolean().default(false), sso_enforced_for_signin_protocol: z.string().default(''), webapp_auth: zWebAppAuthModel.default({ diff --git a/packages/contracts/generated/enterprise/types.gen.ts b/packages/contracts/generated/enterprise/types.gen.ts index 0c990967a3c..600f8975678 100644 --- a/packages/contracts/generated/enterprise/types.gen.ts +++ b/packages/contracts/generated/enterprise/types.gen.ts @@ -1290,6 +1290,11 @@ export type GetMfaInfoReply = { globalEnabled?: boolean } +export type GetMemberRbacRolesReply = { + accountId?: string + roles?: Array +} + export type GetMemberReply = { account?: AccountDetail } @@ -1660,6 +1665,16 @@ export type PluginInstallationSettingsReply = { restrictToMarketplaceOnly?: boolean } +export type RbacRole = { + id?: string + type?: string + name?: string + description?: string + isBuiltin?: boolean + category?: string + permissionKeys?: Array +} + export type ResetMemberPasswordReply = { id?: string password?: string @@ -1939,6 +1954,16 @@ export type UpdateMfaStatusRes = { message?: string } +export type UpdateMemberRbacRolesReply = { + accountId?: string + roles?: Array +} + +export type UpdateMemberRbacRolesReq = { + id?: string + roleIds?: Array +} + export type UpdateMemberReply = { account?: Account } diff --git a/packages/contracts/generated/enterprise/zod.gen.ts b/packages/contracts/generated/enterprise/zod.gen.ts index 8e4251b2095..d7a42b35d4c 100644 --- a/packages/contracts/generated/enterprise/zod.gen.ts +++ b/packages/contracts/generated/enterprise/zod.gen.ts @@ -1492,6 +1492,21 @@ export const zPluginInstallationSettingsReply = z.object({ restrictToMarketplaceOnly: z.boolean().optional(), }) +export const zRbacRole = z.object({ + id: z.string().optional(), + type: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + isBuiltin: z.boolean().optional(), + category: z.string().optional(), + permissionKeys: z.array(z.string()).optional(), +}) + +export const zGetMemberRbacRolesReply = z.object({ + accountId: z.string().optional(), + roles: z.array(zRbacRole).optional(), +}) + export const zResetMemberPasswordReply = z.object({ id: z.string().optional(), password: z.string().optional(), @@ -1944,6 +1959,16 @@ export const zUpdateMfaStatusRes = z.object({ message: z.string().optional(), }) +export const zUpdateMemberRbacRolesReply = z.object({ + accountId: z.string().optional(), + roles: z.array(zRbacRole).optional(), +}) + +export const zUpdateMemberRbacRolesReq = z.object({ + id: z.string().optional(), + roleIds: z.array(z.string()).optional(), +}) + export const zUpdateMemberReply = z.object({ account: zAccount.optional(), }) diff --git a/packages/contracts/openapi-ts.api.config.ts b/packages/contracts/openapi-ts.api.config.ts index 26c9e4cef40..8fce8a25bd3 100644 --- a/packages/contracts/openapi-ts.api.config.ts +++ b/packages/contracts/openapi-ts.api.config.ts @@ -183,7 +183,17 @@ const addOperationIds = (document: SwaggerDocument) => { } const hasSuccessResponse = (operation: SwaggerOperation) => { - return Object.keys(operation.responses ?? {}).some(status => /^2\d\d$/.test(status)) + return Object.entries(operation.responses ?? {}).some(([status, response]) => { + if (!/^2\d\d$/.test(status)) + return false + if (!isObject(response)) + return false + const content = (response as JsonObject).content + // 204 No Content is a valid success response without a body + if (!isObject(content) || Object.keys(content).length === 0) + return status === '204' + return true + }) } const filterContractOperations = (document: SwaggerDocument) => { diff --git a/web/__tests__/app-sidebar/dataset-info-flow.test.tsx b/web/__tests__/app-sidebar/dataset-info-flow.test.tsx index 3093b2809dc..bff6f5a29c5 100644 --- a/web/__tests__/app-sidebar/dataset-info-flow.test.tsx +++ b/web/__tests__/app-sidebar/dataset-info-flow.test.tsx @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import DatasetInfo from '@/app/components/app-sidebar/dataset-info' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' +import { DatasetACLPermission } from '@/utils/permission' const mockReplace = vi.fn() const mockInvalidDatasetList = vi.fn() @@ -33,11 +34,6 @@ vi.mock('@/context/dataset-detail', () => ({ }), })) -vi.mock('@/context/app-context', () => ({ - useSelector: (selector: (state: { isCurrentWorkspaceDatasetOperator: boolean }) => unknown) => - selector({ isCurrentWorkspaceDatasetOperator: false }), -})) - vi.mock('@/hooks/use-knowledge', () => ({ useKnowledge: () => ({ formatIndexingTechniqueAndMethod: () => 'indexing-technique', @@ -153,6 +149,11 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ enable_api: false, is_multimodal: false, is_published: true, + permission_keys: [ + DatasetACLPermission.Edit, + DatasetACLPermission.Delete, + DatasetACLPermission.ImportExportDSL, + ], ...overrides, }) diff --git a/web/__tests__/app/app-access-control-flow.test.tsx b/web/__tests__/app/app-access-control-flow.test.tsx index 415b48c5cad..3ab3587f258 100644 --- a/web/__tests__/app/app-access-control-flow.test.tsx +++ b/web/__tests__/app/app-access-control-flow.test.tsx @@ -56,7 +56,7 @@ vi.mock('@/hooks/use-async-window-open', () => ({ useAsyncWindowOpen: () => vi.fn(), })) -vi.mock('@/service/access-control', () => ({ +vi.mock('@/service/access-control/use-app-access-control', () => ({ useGetUserCanAccessApp: () => ({ data: { result: true }, isLoading: false, @@ -127,7 +127,8 @@ describe('App Access Control Flow', () => { }) it('refreshes app detail after confirming access control updates', async () => { - renderWithQueryClient() + const { queryClient } = renderWithQueryClient() + const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue() fireEvent.click(screen.getByRole('button', { name: 'workflow.common.publish' })) fireEvent.click(screen.getByText('app.accessControlDialog.accessItems.specific')) @@ -137,11 +138,7 @@ describe('App Access Control Flow', () => { fireEvent.click(screen.getByRole('button', { name: 'confirm-access-control' })) await waitFor(() => { - expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) - expect(mockSetAppDetail).toHaveBeenCalledWith(expect.objectContaining({ - id: 'app-1', - access_mode: AccessMode.PUBLIC, - })) + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['apps', 'detail', 'app-1'] }) }) await waitFor(() => { diff --git a/web/__tests__/app/app-publisher-flow.test.tsx b/web/__tests__/app/app-publisher-flow.test.tsx index 593211ce9dd..37019fba17e 100644 --- a/web/__tests__/app/app-publisher-flow.test.tsx +++ b/web/__tests__/app/app-publisher-flow.test.tsx @@ -59,7 +59,7 @@ vi.mock('@/hooks/use-async-window-open', () => ({ useAsyncWindowOpen: () => mockOpenAsyncWindow, })) -vi.mock('@/service/access-control', () => ({ +vi.mock('@/service/access-control/use-app-access-control', () => ({ useGetUserCanAccessApp: () => ({ data: { result: true }, isLoading: false, diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx index 67bd892b4ec..1c8624ddde9 100644 --- a/web/__tests__/apps/app-card-operations-flow.test.tsx +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -17,6 +17,7 @@ import { AppCard } from '@/app/components/apps/app-card' import { AccessMode } from '@/models/access-control' import { exportAppConfig, updateAppInfo } from '@/service/apps' import { AppModeEnum } from '@/types/app' +import { AppACLPermission } from '@/utils/permission' let mockIsCurrentWorkspaceEditor = true let mockSystemFeatures = { @@ -82,6 +83,17 @@ vi.mock('@/next/dynamic', () => ({ vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor, + userProfile: { id: 'user-1' }, + workspacePermissionKeys: mockIsCurrentWorkspaceEditor ? ['app.create_and_management'] : [], + }), + useSelector: (selector: (state: { + isCurrentWorkspaceEditor: boolean + userProfile: { id: string } + workspacePermissionKeys: string[] + }) => T): T => selector({ + isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor, + userProfile: { id: 'user-1' }, + workspacePermissionKeys: mockIsCurrentWorkspaceEditor ? ['app.create_and_management'] : [], }), })) @@ -121,7 +133,7 @@ vi.mock('@/service/workflow', () => ({ fetchWorkflowDraft: vi.fn().mockResolvedValue({ environment_variables: [] }), })) -vi.mock('@/service/access-control', () => ({ +vi.mock('@/service/access-control/use-app-access-control', () => ({ useGetUserCanAccessApp: () => ({ data: { result: true }, isLoading: false }), })) @@ -234,6 +246,14 @@ const createMockApp = (overrides: Partial = {}): App => ({ tags: overrides.tags ?? [], access_mode: overrides.access_mode ?? AccessMode.PUBLIC, max_active_requests: overrides.max_active_requests ?? null, + created_by: overrides.created_by ?? 'user-1', + permission_keys: overrides.permission_keys ?? [ + AppACLPermission.Edit, + AppACLPermission.ImportExportDSL, + AppACLPermission.Delete, + AppACLPermission.ReleaseAndVersion, + AppACLPermission.AccessConfig, + ], }) const mockOnRefresh = vi.fn() @@ -350,12 +370,11 @@ describe('App Card Operations Flow', () => { // -- Access mode display -- describe('Access Mode Display', () => { - it('should not render operations menu for non-editor users', () => { + it('should not render operations menu when user has no app permissions', () => { mockIsCurrentWorkspaceEditor = false - renderAppCard({ name: 'Readonly App' }) + renderAppCard({ name: 'Readonly App', created_by: 'another-user', permission_keys: [] }) - expect(screen.queryByText('app.editApp')).not.toBeInTheDocument() - expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.more' })).not.toBeInTheDocument() }) }) diff --git a/web/__tests__/apps/app-list-browsing-flow.test.tsx b/web/__tests__/apps/app-list-browsing-flow.test.tsx index 39d9219be66..555702a66f9 100644 --- a/web/__tests__/apps/app-list-browsing-flow.test.tsx +++ b/web/__tests__/apps/app-list-browsing-flow.test.tsx @@ -20,6 +20,7 @@ import { AppModeEnum } from '@/types/app' let mockIsCurrentWorkspaceEditor = true let mockIsCurrentWorkspaceDatasetOperator = false let mockIsLoadingCurrentWorkspace = false +let mockWorkspacePermissionKeys: string[] = ['app.create_and_management'] let mockSystemFeatures = { branding: { enabled: false }, @@ -65,6 +66,12 @@ vi.mock('@/context/app-context', () => ({ isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace, userProfile: { id: 'member-1' }, + isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace, + workspacePermissionKeys: mockWorkspacePermissionKeys, + }), + useSelector: (selector: (state: { userProfile: { id: string }, workspacePermissionKeys: string[] }) => unknown) => selector({ + userProfile: { id: 'user-1' }, + workspacePermissionKeys: mockWorkspacePermissionKeys, }), })) @@ -215,6 +222,7 @@ describe('App List Browsing Flow', () => { mockIsCurrentWorkspaceEditor = true mockIsCurrentWorkspaceDatasetOperator = false mockIsLoadingCurrentWorkspace = false + mockWorkspacePermissionKeys = ['app.create_and_management'] mockSystemFeatures = { branding: { enabled: false }, webapp_auth: { enabled: false }, @@ -303,8 +311,9 @@ describe('App List Browsing Flow', () => { expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeInTheDocument() }) - it('should hide the create menu when user is not a workspace editor', () => { + it('should hide the create menu when user lacks app creation permission', () => { mockIsCurrentWorkspaceEditor = false + mockWorkspacePermissionKeys = [] mockPages = [createPage([ createMockApp({ name: 'Test App' }), ])] @@ -312,6 +321,9 @@ describe('App List Browsing Flow', () => { renderList() expect(screen.queryByRole('button', { name: 'common.operation.create' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'app.newApp.startFromBlank' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'app.newApp.startFromTemplate' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'app.importDSL' })).not.toBeInTheDocument() }) }) @@ -346,8 +358,9 @@ describe('App List Browsing Flow', () => { expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument() }) - it('should hide drag-drop hint for non-editors', () => { + it('should hide drag-drop hint without app creation permission', () => { mockIsCurrentWorkspaceEditor = false + mockWorkspacePermissionKeys = [] mockPages = [createPage([createMockApp()])] renderList() diff --git a/web/__tests__/apps/create-app-flow.test.tsx b/web/__tests__/apps/create-app-flow.test.tsx index 1fcc22c4d28..237dec91230 100644 --- a/web/__tests__/apps/create-app-flow.test.tsx +++ b/web/__tests__/apps/create-app-flow.test.tsx @@ -21,6 +21,7 @@ import { AppModeEnum } from '@/types/app' let mockIsCurrentWorkspaceEditor = true let mockIsCurrentWorkspaceDatasetOperator = false let mockIsLoadingCurrentWorkspace = false +let mockWorkspacePermissionKeys: string[] = ['app.create_and_management'] let mockSystemFeatures = { branding: { enabled: false }, webapp_auth: { enabled: false }, @@ -51,6 +52,12 @@ vi.mock('@/context/app-context', () => ({ isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace, + isLoadingWorkspacePermissionKeys: mockIsLoadingCurrentWorkspace, + workspacePermissionKeys: mockWorkspacePermissionKeys, + }), + useSelector: (selector: (state: { userProfile: { id: string }, workspacePermissionKeys: string[] }) => unknown) => selector({ + userProfile: { id: 'user-1' }, + workspacePermissionKeys: mockWorkspacePermissionKeys, }), })) @@ -261,6 +268,7 @@ describe('Create App Flow', () => { mockIsCurrentWorkspaceEditor = true mockIsCurrentWorkspaceDatasetOperator = false mockIsLoadingCurrentWorkspace = false + mockWorkspacePermissionKeys = ['app.create_and_management'] mockSystemFeatures = { branding: { enabled: false }, webapp_auth: { enabled: false }, @@ -282,8 +290,9 @@ describe('Create App Flow', () => { expect(screen.getByText('app.importDSL')).toBeInTheDocument() }) - it('should not render the create menu when user is not an editor', () => { + it('should render disabled the create menu when user lacks app creation permission', () => { mockIsCurrentWorkspaceEditor = false + mockWorkspacePermissionKeys = [] renderList() expect(screen.queryByRole('button', { name: 'common.operation.create' })).not.toBeInTheDocument() diff --git a/web/__tests__/billing/billing-integration.test.tsx b/web/__tests__/billing/billing-integration.test.tsx index 57f6faddcc7..2fde9b506f4 100644 --- a/web/__tests__/billing/billing-integration.test.tsx +++ b/web/__tests__/billing/billing-integration.test.tsx @@ -127,6 +127,11 @@ const setupProviderContext = (planOverrides: PlanOverrides = {}, extra: Record = {}) => { mockAppCtx = { isCurrentWorkspaceManager: true, + workspacePermissionKeys: [ + 'billing.view', + 'billing.manage', + 'billing.subscription.manage', + ], userProfile: { email: 'test@example.com' }, langGeniusVersionInfo: { current_version: '1.0.0' }, ...overrides, @@ -228,9 +233,12 @@ describe('Billing Page + Plan Integration', () => { // Verify billing URL button visibility and behavior describe('Billing URL button', () => { - it('should show billing button when enableBilling and isCurrentWorkspaceManager', () => { + it('should show billing button when subscription management permission is granted', () => { setupProviderContext({ type: Plan.sandbox }) - setupAppContext({ isCurrentWorkspaceManager: true }) + setupAppContext({ + isCurrentWorkspaceManager: false, + workspacePermissionKeys: ['billing.subscription.manage'], + }) render() @@ -238,9 +246,12 @@ describe('Billing Page + Plan Integration', () => { expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument() }) - it('should hide billing button when user is not workspace manager', () => { + it('should hide billing button when subscription management permission is missing', () => { setupProviderContext({ type: Plan.sandbox }) - setupAppContext({ isCurrentWorkspaceManager: false }) + setupAppContext({ + isCurrentWorkspaceManager: true, + workspacePermissionKeys: ['billing.view', 'billing.manage'], + }) render() @@ -254,6 +265,17 @@ describe('Billing Page + Plan Integration', () => { expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument() }) + + it('should hide billing button when no billing permissions are granted', () => { + setupProviderContext({ type: Plan.sandbox }) + setupAppContext({ + workspacePermissionKeys: [], + }) + + render() + + expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument() + }) }) }) diff --git a/web/__tests__/billing/cloud-plan-payment-flow.test.tsx b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx index 7682de5f884..5a80f8cd456 100644 --- a/web/__tests__/billing/cloud-plan-payment-flow.test.tsx +++ b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx @@ -60,6 +60,11 @@ vi.mock('@/next/navigation', () => ({ const setupAppContext = (overrides: Record = {}) => { mockAppCtx = { isCurrentWorkspaceManager: true, + workspacePermissionKeys: [ + 'billing.view', + 'billing.manage', + 'billing.subscription.manage', + ], ...overrides, } } @@ -276,8 +281,27 @@ describe('Cloud Plan Payment Flow', () => { // ─── 5. Permission Check ──────────────────────────────────────────────── describe('Permission check', () => { - it('should show error toast when non-manager clicks upgrade button', async () => { - setupAppContext({ isCurrentWorkspaceManager: false }) + it('should change plans when billing manage permission is granted without manager role', async () => { + setupAppContext({ + isCurrentWorkspaceManager: false, + workspacePermissionKeys: ['billing.manage'], + }) + const user = userEvent.setup() + renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional }) + + const button = getPlanButton('billing.plansCommon.startBuilding') + await user.click(button) + + await waitFor(() => { + expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month') + }) + }) + + it('should show error toast when billing manage permission is missing for plan changes', async () => { + setupAppContext({ + isCurrentWorkspaceManager: false, + workspacePermissionKeys: ['billing.view', 'billing.subscription.manage'], + }) const user = userEvent.setup() renderCloudPlanItem({ currentPlan: Plan.sandbox, plan: Plan.professional }) @@ -287,8 +311,41 @@ describe('Cloud Plan Payment Flow', () => { await waitFor(() => { expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument() }) - // Should not proceed with payment expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled() }) + + it('should open billing portal when subscription management permission is granted without manager role', async () => { + setupAppContext({ + isCurrentWorkspaceManager: false, + workspacePermissionKeys: ['billing.subscription.manage'], + }) + const user = userEvent.setup() + renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional }) + + const button = getPlanButton('billing.plansCommon.currentPlan') + await user.click(button) + + await waitFor(() => { + expect(mockOpenAsyncWindow).toHaveBeenCalled() + }) + expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled() + }) + + it('should show error toast when subscription management permission is missing for current paid plan', async () => { + setupAppContext({ + isCurrentWorkspaceManager: false, + workspacePermissionKeys: ['billing.view', 'billing.manage'], + }) + const user = userEvent.setup() + renderCloudPlanItem({ currentPlan: Plan.professional, plan: Plan.professional }) + + const button = getPlanButton('billing.plansCommon.currentPlan') + await user.click(button) + + await waitFor(() => { + expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument() + }) + expect(mockOpenAsyncWindow).not.toHaveBeenCalled() + }) }) }) diff --git a/web/__tests__/billing/education-verification-flow.test.tsx b/web/__tests__/billing/education-verification-flow.test.tsx index 6098d446348..42131506208 100644 --- a/web/__tests__/billing/education-verification-flow.test.tsx +++ b/web/__tests__/billing/education-verification-flow.test.tsx @@ -143,6 +143,11 @@ const setupContexts = ( } mockAppCtx = { isCurrentWorkspaceManager: true, + workspacePermissionKeys: [ + 'billing.view', + 'billing.manage', + 'billing.subscription.manage', + ], userProfile: { email: 'student@university.edu' }, langGeniusVersionInfo: { current_version: '1.0.0' }, ...appOverrides, diff --git a/web/__tests__/billing/pricing-modal-flow.test.tsx b/web/__tests__/billing/pricing-modal-flow.test.tsx index 2ec72986181..c06f56772d0 100644 --- a/web/__tests__/billing/pricing-modal-flow.test.tsx +++ b/web/__tests__/billing/pricing-modal-flow.test.tsx @@ -112,6 +112,11 @@ const setupContexts = (planOverrides: Record = {}, appOverrides } mockAppCtx = { isCurrentWorkspaceManager: true, + workspacePermissionKeys: [ + 'billing.view', + 'billing.manage', + 'billing.subscription.manage', + ], userProfile: { email: 'test@example.com' }, langGeniusVersionInfo: { current_version: '1.0.0' }, ...appOverrides, diff --git a/web/__tests__/billing/self-hosted-plan-flow.test.tsx b/web/__tests__/billing/self-hosted-plan-flow.test.tsx index f2b5cd3531c..08a3d0fed54 100644 --- a/web/__tests__/billing/self-hosted-plan-flow.test.tsx +++ b/web/__tests__/billing/self-hosted-plan-flow.test.tsx @@ -49,6 +49,7 @@ vi.mock('@/app/components/billing/pricing/plans/self-hosted-plan-item/list', () const setupAppContext = (overrides: Record = {}) => { mockAppCtx = { isCurrentWorkspaceManager: true, + workspacePermissionKeys: ['billing.manage'], ...overrides, } } @@ -179,8 +180,22 @@ describe('Self-Hosted Plan Flow', () => { // ─── 3. Permission Check ──────────────────────────────────────────────── describe('Permission check', () => { - it('should show error toast when non-manager clicks community button', async () => { - setupAppContext({ isCurrentWorkspaceManager: false }) + it('should redirect when billing manage permission is granted without manager role', async () => { + setupAppContext({ + isCurrentWorkspaceManager: false, + workspacePermissionKeys: ['billing.manage'], + }) + const user = userEvent.setup() + renderSelfHostedPlanItem(SelfHostedPlan.community) + + const button = screen.getByRole('button') + await user.click(button) + + expect(assignedHref).toBe(getStartedWithCommunityUrl) + }) + + it('should show error toast when billing manage permission is missing for community button', async () => { + setupAppContext({ workspacePermissionKeys: [] }) const user = userEvent.setup() renderSelfHostedPlanItem(SelfHostedPlan.community) @@ -194,8 +209,8 @@ describe('Self-Hosted Plan Flow', () => { expect(assignedHref).toBe('') }) - it('should show error toast when non-manager clicks premium button', async () => { - setupAppContext({ isCurrentWorkspaceManager: false }) + it('should show error toast when billing manage permission is missing for premium button', async () => { + setupAppContext({ workspacePermissionKeys: [] }) const user = userEvent.setup() renderSelfHostedPlanItem(SelfHostedPlan.premium) @@ -208,8 +223,8 @@ describe('Self-Hosted Plan Flow', () => { expect(assignedHref).toBe('') }) - it('should show error toast when non-manager clicks enterprise button', async () => { - setupAppContext({ isCurrentWorkspaceManager: false }) + it('should show error toast when billing manage permission is missing for enterprise button', async () => { + setupAppContext({ workspacePermissionKeys: [] }) const user = userEvent.setup() renderSelfHostedPlanItem(SelfHostedPlan.enterprise) diff --git a/web/__tests__/custom/custom-page-flow.test.tsx b/web/__tests__/custom/custom-page-flow.test.tsx index 66158ec5062..5650f0893c5 100644 --- a/web/__tests__/custom/custom-page-flow.test.tsx +++ b/web/__tests__/custom/custom-page-flow.test.tsx @@ -51,7 +51,7 @@ const createBrandState = (overrides: Partial> webappBrandRemoved: false, uploadDisabled: false, workspaceLogo: 'https://example.com/workspace-logo.png', - isCurrentWorkspaceManager: true, + canManageCustomBrand: true, isSandbox: false, handleApply: vi.fn(), handleCancel: vi.fn(), diff --git a/web/__tests__/datasets/dataset-settings-flow.test.tsx b/web/__tests__/datasets/dataset-settings-flow.test.tsx index eaafcafe626..1c07128eb67 100644 --- a/web/__tests__/datasets/dataset-settings-flow.test.tsx +++ b/web/__tests__/datasets/dataset-settings-flow.test.tsx @@ -16,6 +16,7 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType, WeightedScoreEnum } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' +import { DatasetACLPermission } from '@/utils/permission' // --- Mocks --- @@ -28,7 +29,13 @@ const mockInvalidDatasetList = vi.fn() const mockUpdateDatasetSetting = vi.fn().mockResolvedValue({}) vi.mock('@/context/app-context', () => ({ - useSelector: () => false, + useSelector: (selector: (state: { + userProfile: { id: string } + workspacePermissionKeys: string[] + }) => T): T => selector({ + userProfile: { id: 'user-1' }, + workspacePermissionKeys: ['dataset.create_and_management'], + }), })) vi.mock('@/service/datasets', () => ({ @@ -128,6 +135,7 @@ const createMockDataset = (overrides?: Partial): DataSet => ({ runtime_mode: 'general', enable_api: true, is_multimodal: false, + permission_keys: [DatasetACLPermission.Edit], ...overrides, } as DataSet) diff --git a/web/__tests__/develop/api-key-management-flow.test.tsx b/web/__tests__/develop/api-key-management-flow.test.tsx index 233e152f8fa..0267fdd45e1 100644 --- a/web/__tests__/develop/api-key-management-flow.test.tsx +++ b/web/__tests__/develop/api-key-management-flow.test.tsx @@ -86,13 +86,13 @@ describe('API Key management flow', () => { expect(screen.getByText('https://api.dify.ai/v1')).toBeInTheDocument() expect(screen.getByText('appApi.ok')).toBeInTheDocument() - expect(screen.getByText('appApi.apiKey')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'appApi.apiKey' })).toBeDisabled() }) it('clicking API Key button opens SecretKeyModal with real modal content', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - render() + render() // Click API Key button (rendered by SecretKeyButton) await act(async () => { @@ -112,7 +112,7 @@ describe('API Key management flow', () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) mockIsLoading.mockReturnValue(true) - render() + render() await act(async () => { await user.click(screen.getByText('appApi.apiKey')) @@ -130,7 +130,7 @@ describe('API Key management flow', () => { it('modal can be closed by clicking X icon', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) - render() + render() // Open modal await act(async () => { @@ -143,7 +143,7 @@ describe('API Key management flow', () => { }) // Click X icon to close - const closeIcon = document.body.querySelector('svg.cursor-pointer') + const closeIcon = document.body.querySelector('.i-heroicons-x-mark-20-solid.cursor-pointer') expect(closeIcon).toBeInTheDocument() await act(async () => { @@ -161,7 +161,7 @@ describe('API Key management flow', () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) const { rerender } = render( - , + , ) expect(screen.getByText('http://localhost:5001/v1')).toBeInTheDocument() @@ -177,14 +177,14 @@ describe('API Key management flow', () => { }) // Close modal, update URL and re-verify - const xIcon = document.body.querySelector('svg.cursor-pointer') + const xIcon = document.body.querySelector('.i-heroicons-x-mark-20-solid.cursor-pointer') await act(async () => { await user.click(xIcon!) }) await flushUI() rerender( - , + , ) expect(screen.getByText('https://api.production.com/v1')).toBeInTheDocument() diff --git a/web/__tests__/develop/develop-page-flow.test.tsx b/web/__tests__/develop/develop-page-flow.test.tsx index 703f7362f1f..4c4bcd0f064 100644 --- a/web/__tests__/develop/develop-page-flow.test.tsx +++ b/web/__tests__/develop/develop-page-flow.test.tsx @@ -56,6 +56,8 @@ vi.mock('@/context/app-context', () => ({ isCurrentWorkspaceManager: true, isCurrentWorkspaceEditor: true, }), + useSelector: (selector: (state: { userProfile: { id: string }, workspacePermissionKeys: string[] }) => unknown) => + selector({ userProfile: { id: 'user-1' }, workspacePermissionKeys: ['app.create_and_management'] }), })) vi.mock('@/hooks/use-timestamp', () => ({ @@ -108,6 +110,7 @@ describe('DevelopMain page flow', () => { name: 'Test App', api_base_url: 'https://api.test.com/v1', mode: AppModeEnum.CHAT, + permission_keys: ['app.acl.edit'], } render() @@ -128,6 +131,7 @@ describe('DevelopMain page flow', () => { name: 'Chat App', api_base_url: 'https://api.test.com/v1', mode: AppModeEnum.CHAT, + permission_keys: ['app.acl.edit'], } const { container } = render() @@ -150,6 +154,7 @@ describe('DevelopMain page flow', () => { name: 'My App', api_base_url: 'https://api.example.com/v1', mode: AppModeEnum.COMPLETION, + permission_keys: ['app.acl.edit'], } rerender() @@ -166,6 +171,7 @@ describe('DevelopMain page flow', () => { name: 'Test App', api_base_url: 'https://api.test.com/v1', mode: AppModeEnum.WORKFLOW, + permission_keys: ['app.acl.edit'], } render() @@ -196,6 +202,7 @@ describe('DevelopMain page flow', () => { name: `${mode} App`, api_base_url: 'https://api.test.com/v1', mode, + permission_keys: ['app.acl.edit'], } const { container, unmount } = render() @@ -216,6 +223,7 @@ describe('DevelopMain page flow', () => { name: 'Test App', api_base_url: 'https://api.test.com/v1', mode: AppModeEnum.CHAT, + permission_keys: ['app.acl.edit'], } render() diff --git a/web/__tests__/embedded-user-id-store.test.tsx b/web/__tests__/embedded-user-id-store.test.tsx index 7e9eec644bc..7adb33b7ffd 100644 --- a/web/__tests__/embedded-user-id-store.test.tsx +++ b/web/__tests__/embedded-user-id-store.test.tsx @@ -1,23 +1,25 @@ import { screen, waitFor } from '@testing-library/react' import * as React from 'react' -import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' +import { renderToString } from 'react-dom/server' +import { createSystemFeaturesWrapper, renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import WebAppStoreProvider, { useWebAppStore } from '@/context/web-app-context' import { AccessMode } from '@/models/access-control' -vi.mock('@/next/navigation', () => ({ +const navigationMocks = vi.hoisted(() => ({ usePathname: vi.fn(() => '/chatbot/sample-app'), - useSearchParams: vi.fn(() => { - const params = new URLSearchParams() - return params - }), + useSearchParams: vi.fn(() => new URLSearchParams()), +})) + +const useGetWebAppAccessModeByCodeMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/next/navigation', () => ({ + usePathname: navigationMocks.usePathname, + useSearchParams: navigationMocks.useSearchParams, })) vi.mock('@/service/use-share', () => ({ - useGetWebAppAccessModeByCode: vi.fn(() => ({ - isLoading: false, - data: { accessMode: AccessMode.PUBLIC }, - })), + useGetWebAppAccessModeByCode: (...args: unknown[]) => useGetWebAppAccessModeByCodeMock(...args), })) const mockGetProcessedSystemVariablesFromUrlParams = vi.fn() @@ -61,10 +63,50 @@ const initialWebAppStore = (() => { beforeEach(() => { mockGetProcessedSystemVariablesFromUrlParams.mockReset() + navigationMocks.usePathname.mockReset() + navigationMocks.usePathname.mockReturnValue('/chatbot/sample-app') + navigationMocks.useSearchParams.mockReset() + navigationMocks.useSearchParams.mockReturnValue(new URLSearchParams()) + useGetWebAppAccessModeByCodeMock.mockReset() + useGetWebAppAccessModeByCodeMock.mockReturnValue({ + isLoading: false, + data: { accessMode: AccessMode.PUBLIC }, + }) useWebAppStore.setState(initialWebAppStore, true) }) describe('WebAppStoreProvider embedded user id handling', () => { + it('parses share code from redirect_url during server render without window', () => { + const params = new URLSearchParams() + params.set('redirect_url', encodeURIComponent('/chatbot/redirected-app')) + navigationMocks.usePathname.mockReturnValue('/webapp-signin') + navigationMocks.useSearchParams.mockReturnValue(params) + const originalWindow = globalThis.window + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: undefined, + }) + const { wrapper: Wrapper } = createSystemFeaturesWrapper() + + try { + expect(() => renderToString( + + +
+ + , + )).not.toThrow() + } + finally { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: originalWindow, + }) + } + + expect(useGetWebAppAccessModeByCodeMock).toHaveBeenCalledWith('redirected-app') + }) + it('hydrates embedded user and conversation ids from system variables', async () => { mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({ user_id: 'iframe-user-123', diff --git a/web/__tests__/explore/explore-app-list-flow.test.tsx b/web/__tests__/explore/explore-app-list-flow.test.tsx index f93353443c2..786fef89486 100644 --- a/web/__tests__/explore/explore-app-list-flow.test.tsx +++ b/web/__tests__/explore/explore-app-list-flow.test.tsx @@ -15,6 +15,13 @@ import { fetchAppDetail, fetchAppList, fetchBanners } from '@/service/explore' import { useMembers } from '@/service/use-common' import { AppModeEnum } from '@/types/app' +type MockAppContext = { + userProfile: { id: string } + workspacePermissionKeys: string[] +} + +const mockUseAppContext = vi.hoisted(() => vi.fn<() => MockAppContext>()) + const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}' let mockTabValue = allCategoriesEn const mockSetTab = vi.fn() @@ -109,7 +116,8 @@ vi.mock('@/service/client', () => ({ })) vi.mock('@/context/app-context', () => ({ - useAppContext: vi.fn(), + useAppContext: mockUseAppContext, + useSelector: (selector: (state: MockAppContext) => T): T => selector(mockUseAppContext()), })) vi.mock('@/service/use-common', () => ({ @@ -188,6 +196,7 @@ const createApp = (overrides: Partial = {}): App => ({ const mockMemberRole = (hasEditPermission: boolean) => { ;(useAppContext as Mock).mockReturnValue({ userProfile: { id: 'user-1' }, + workspacePermissionKeys: hasEditPermission ? ['app.create_and_management'] : [], }) ;(useMembers as Mock).mockReturnValue({ data: { diff --git a/web/__tests__/explore/installed-app-flow.test.tsx b/web/__tests__/explore/installed-app-flow.test.tsx index 66d88cdf926..b37e69bce92 100644 --- a/web/__tests__/explore/installed-app-flow.test.tsx +++ b/web/__tests__/explore/installed-app-flow.test.tsx @@ -11,7 +11,7 @@ import { render, screen, waitFor } from '@testing-library/react' import InstalledApp from '@/app/components/explore/installed-app' import { useWebAppStore } from '@/context/web-app-context' import { AccessMode } from '@/models/access-control' -import { useGetUserCanAccessApp } from '@/service/access-control' +import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control' import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams, useGetInstalledApps } from '@/service/use-explore' import { AppModeEnum } from '@/types/app' @@ -19,7 +19,7 @@ vi.mock('@/context/web-app-context', () => ({ useWebAppStore: vi.fn(), })) -vi.mock('@/service/access-control', () => ({ +vi.mock('@/service/access-control/use-app-access-control', () => ({ useGetUserCanAccessApp: vi.fn(), })) diff --git a/web/__tests__/header/nav-flow.test.tsx b/web/__tests__/header/nav-flow.test.tsx index dba7b4bf4af..33927f19da4 100644 --- a/web/__tests__/header/nav-flow.test.tsx +++ b/web/__tests__/header/nav-flow.test.tsx @@ -43,6 +43,7 @@ vi.mock('@/app/components/app/store', () => ({ vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor, + workspacePermissionKeys: mockIsCurrentWorkspaceEditor ? ['app.create_and_management'] : [], }), })) diff --git a/web/__tests__/plugins/plugin-auth-flow.test.tsx b/web/__tests__/plugins/plugin-auth-flow.test.tsx index c4d28e3f34b..cc42d5204ba 100644 --- a/web/__tests__/plugins/plugin-auth-flow.test.tsx +++ b/web/__tests__/plugins/plugin-auth-flow.test.tsx @@ -90,7 +90,6 @@ describe('Plugin Authentication Flow Integration', () => { canOAuth: false, canApiKey: true, credentials: [], - disabled: false, invalidPluginCredentialInfo: vi.fn(), notAllowCustomCredential: false, }) @@ -108,7 +107,6 @@ describe('Plugin Authentication Flow Integration', () => { canOAuth: true, canApiKey: true, credentials: [], - disabled: false, invalidPluginCredentialInfo: vi.fn(), notAllowCustomCredential: false, }) @@ -125,7 +123,6 @@ describe('Plugin Authentication Flow Integration', () => { canOAuth: false, canApiKey: true, credentials: [], - disabled: false, invalidPluginCredentialInfo: vi.fn(), notAllowCustomCredential: false, }) @@ -147,7 +144,6 @@ describe('Plugin Authentication Flow Integration', () => { credentials: [ { id: 'cred-1', name: 'My API Key', is_default: true }, ], - disabled: false, invalidPluginCredentialInfo: vi.fn(), notAllowCustomCredential: false, }) @@ -165,7 +161,6 @@ describe('Plugin Authentication Flow Integration', () => { canOAuth: false, canApiKey: true, credentials: [{ id: 'cred-1', name: 'Key', is_default: true }], - disabled: false, invalidPluginCredentialInfo: vi.fn(), notAllowCustomCredential: false, }) @@ -187,7 +182,6 @@ describe('Plugin Authentication Flow Integration', () => { canOAuth: false, canApiKey: true, credentials: [{ id: 'cred-1', name: 'Key', is_default: true }], - disabled: false, invalidPluginCredentialInfo: vi.fn(), notAllowCustomCredential: false, }) @@ -207,7 +201,6 @@ describe('Plugin Authentication Flow Integration', () => { canOAuth: false, canApiKey: true, credentials: [], - disabled: false, invalidPluginCredentialInfo: vi.fn(), notAllowCustomCredential: false, }) @@ -229,7 +222,6 @@ describe('Plugin Authentication Flow Integration', () => { canOAuth: true, canApiKey: false, credentials: [], - disabled: false, invalidPluginCredentialInfo: vi.fn(), notAllowCustomCredential: false, }) @@ -258,7 +250,6 @@ describe('Plugin Authentication Flow Integration', () => { { id: 'cred-2', name: 'API Key 2', is_default: false }, { id: 'cred-3', name: 'OAuth Token', is_default: false, credential_type: CredentialTypeEnum.OAUTH2 }, ], - disabled: false, invalidPluginCredentialInfo: vi.fn(), notAllowCustomCredential: false, }) diff --git a/web/__tests__/plugins/plugin-page-shell-flow.test.tsx b/web/__tests__/plugins/plugin-page-shell-flow.test.tsx index 421d805d072..ac090ec577b 100644 --- a/web/__tests__/plugins/plugin-page-shell-flow.test.tsx +++ b/web/__tests__/plugins/plugin-page-shell-flow.test.tsx @@ -34,6 +34,14 @@ vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ isCurrentWorkspaceManager: false, isCurrentWorkspaceOwner: false, + langGeniusVersionInfo: { + current_version: '1.0.0', + }, + workspacePermissionKeys: [ + 'plugin.install', + 'plugin.manage', + 'plugin.plugin_preferences', + ], }), })) diff --git a/web/__tests__/tools/provider-list-shell-flow.test.tsx b/web/__tests__/tools/provider-list-shell-flow.test.tsx index 346834c642c..dcf88dda46e 100644 --- a/web/__tests__/tools/provider-list-shell-flow.test.tsx +++ b/web/__tests__/tools/provider-list-shell-flow.test.tsx @@ -20,6 +20,18 @@ vi.mock('@/app/components/plugins/hooks', () => ({ }), })) +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + userProfile: { id: 'user-1', timezone: 'UTC' }, + workspacePermissionKeys: ['tool.manage', 'mcp.manage', 'plugin.install', 'plugin.manage', 'plugin.plugin_preferences'], + langGeniusVersionInfo: { current_version: '1.0.0' }, + }), + useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => + selector({ + workspacePermissionKeys: ['tool.manage', 'mcp.manage', 'plugin.install', 'plugin.manage', 'plugin.plugin_preferences'], + }), +})) + vi.mock('@/service/use-tools', () => ({ useAllToolProviders: () => ({ data: [ @@ -69,6 +81,8 @@ vi.mock('@/service/use-plugins', () => ({ : null, }), useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList, + useMutationPluginPermissionSettings: () => ({ mutate: vi.fn(), isPending: false }), + usePluginPermissionSettings: () => ({ data: undefined, isLoading: false, isFetching: false, error: null }), })) vi.mock('@/app/components/tools/labels/filter', () => ({ @@ -152,6 +166,10 @@ vi.mock('@/app/components/tools/mcp', () => ({ default: ({ searchText }: { searchText: string }) =>
{searchText}
, })) +vi.mock('@/app/components/header/account-setting/update-setting-dialog', () => ({ + default: () => null, +})) + const renderProviderList = (searchParams = '') => { const { wrapper: SysWrapper } = createSystemFeaturesWrapper({ systemFeatures: { enable_marketplace: true }, diff --git a/web/__tests__/tools/tool-browsing-and-filtering.test.tsx b/web/__tests__/tools/tool-browsing-and-filtering.test.tsx index d470326805e..be00661f710 100644 --- a/web/__tests__/tools/tool-browsing-and-filtering.test.tsx +++ b/web/__tests__/tools/tool-browsing-and-filtering.test.tsx @@ -43,9 +43,23 @@ vi.mock('@/app/components/plugins/hooks', () => ({ }), })) +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + userProfile: { id: 'user-1', timezone: 'UTC' }, + workspacePermissionKeys: ['tool.manage', 'mcp.manage', 'plugin.install', 'plugin.manage', 'plugin.plugin_preferences'], + langGeniusVersionInfo: { current_version: '1.0.0' }, + }), + useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => + selector({ + workspacePermissionKeys: ['tool.manage', 'mcp.manage', 'plugin.install', 'plugin.manage', 'plugin.plugin_preferences'], + }), +})) + vi.mock('@/service/use-plugins', () => ({ useCheckInstalled: () => ({ data: null }), useInvalidateInstalledPluginList: () => vi.fn(), + useMutationPluginPermissionSettings: () => ({ mutate: vi.fn(), isPending: false }), + usePluginPermissionSettings: () => ({ data: undefined, isLoading: false, isFetching: false, error: null }), })) const mockCollections: Collection[] = [ @@ -198,6 +212,10 @@ vi.mock('@/app/components/tools/mcp', () => ({ default: () =>
MCP List
, })) +vi.mock('@/app/components/header/account-setting/update-setting-dialog', () => ({ + default: () => null, +})) + vi.mock('@langgenius/dify-ui/cn', () => ({ cn: (...args: unknown[]) => args.filter(Boolean).join(' '), })) diff --git a/web/__tests__/tools/tool-provider-detail-flow.test.tsx b/web/__tests__/tools/tool-provider-detail-flow.test.tsx index 7b873601d48..e99e7ac24c2 100644 --- a/web/__tests__/tools/tool-provider-detail-flow.test.tsx +++ b/web/__tests__/tools/tool-provider-detail-flow.test.tsx @@ -48,6 +48,9 @@ vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ isCurrentWorkspaceManager: true, }), + useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({ + workspacePermissionKeys: ['tool.manage', 'credential.manage', 'credential.use'], + }), })) const mockSetShowModelModal = vi.fn() diff --git a/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx b/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx index 761d8c1c8bf..8fa969fa899 100644 --- a/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx +++ b/web/app/(commonLayout)/__tests__/role-route-guard.spec.tsx @@ -47,14 +47,14 @@ vi.mock('@/service/client', () => ({ const mockUseQuery = vi.mocked(useQuery) -function renderGuard(children: ReactNode, systemFeatures: { enable_app_deploy?: boolean } = {}) { +function renderGuard(children: ReactNode) { return renderWithSystemFeatures( {children} , { systemFeatures: { - enable_app_deploy: systemFeatures.enable_app_deploy ?? true, + enable_app_deploy: true, }, }, ) @@ -70,86 +70,37 @@ const setCurrentWorkspaceQuery = (overrides: { role?: string, isPending?: boolea describe('RoleRouteGuard', () => { beforeEach(() => { vi.clearAllMocks() - mockPathname = '/apps' + mockPathname = '/app/app-1' setCurrentWorkspaceQuery() }) - it('should render loading while workspace is loading', () => { - setCurrentWorkspaceQuery({ isPending: true }) - - renderGuard(
content
) - - expect(screen.getByRole('status')).toBeInTheDocument() - expect(screen.queryByText('content')).not.toBeInTheDocument() - expect(mocks.redirect).not.toHaveBeenCalled() - expect(mocks.currentWorkspaceQueryOptions).toHaveBeenCalledWith({ - select: expect.any(Function), - }) - }) - - it('should redirect dataset operator on guarded routes', () => { - setCurrentWorkspaceQuery({ role: 'dataset_operator' }) - - expect(() => renderGuard(
content
)).toThrow('NEXT_REDIRECT:/datasets') - - expect(mocks.redirect).toHaveBeenCalledWith('/datasets') - }) - - it('should allow dataset operator on routes outside the guarded list', () => { - mockPathname = '/new-route' - setCurrentWorkspaceQuery({ role: 'dataset_operator' }) + it.each(['/', '/apps', '/app/app-1', '/deployments/create', '/snippets', '/explore/apps', '/tools', '/integrations', '/datasets'])('should allow %s without workspace role checks', (pathname) => { + mockPathname = pathname renderGuard(
content
) expect(screen.getByText('content')).toBeInTheDocument() expect(mocks.redirect).not.toHaveBeenCalled() + expect(mockUseQuery).not.toHaveBeenCalled() + expect(mocks.currentWorkspaceQueryOptions).not.toHaveBeenCalled() }) - it('should redirect dataset operator on deployments routes', () => { + it('should redirect deployments route when app deploy is disabled', () => { mockPathname = '/deployments/create' - setCurrentWorkspaceQuery({ role: 'dataset_operator' }) - expect(() => renderGuard(
content
)).toThrow('NEXT_REDIRECT:/datasets') + expect(() => renderWithSystemFeatures( + +
content
+
, + { + systemFeatures: { + enable_app_deploy: false, + }, + }, + )).toThrow('NEXT_REDIRECT:/apps') - expect(mocks.redirect).toHaveBeenCalledWith('/datasets') - }) - - it('should prefer app deploy redirect when app deploy is disabled', () => { - mockPathname = '/deployments/create' - setCurrentWorkspaceQuery({ role: 'dataset_operator' }) - - expect(() => renderGuard(
content
, { enable_app_deploy: false })).toThrow('NEXT_REDIRECT:/') - - expect(mocks.redirect).toHaveBeenCalledWith('/') - }) - - it('should redirect app deploy routes when app deploy is disabled', () => { - mockPathname = '/deployments/create' - setCurrentWorkspaceQuery({ role: 'editor' }) - - expect(() => renderGuard(
content
, { enable_app_deploy: false })).toThrow('NEXT_REDIRECT:/') - - expect(mocks.redirect).toHaveBeenCalledWith('/') - }) - - it('should allow dataset operator on non-guarded routes', () => { - mockPathname = '/plugins' - setCurrentWorkspaceQuery({ role: 'dataset_operator' }) - - renderGuard(
content
) - - expect(screen.getByText('content')).toBeInTheDocument() - expect(mocks.redirect).not.toHaveBeenCalled() - }) - - it('should not block non-guarded routes while workspace is loading', () => { - mockPathname = '/plugins' - setCurrentWorkspaceQuery({ isPending: true }) - - renderGuard(
content
) - - expect(screen.getByText('content')).toBeInTheDocument() - expect(screen.queryByRole('status')).not.toBeInTheDocument() - expect(mocks.redirect).not.toHaveBeenCalled() + expect(mocks.redirect).toHaveBeenCalledWith('/apps') + expect(mockUseQuery).not.toHaveBeenCalled() + expect(mocks.currentWorkspaceQueryOptions).not.toHaveBeenCalled() }) }) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx index f1e12b34c70..f9742f35e13 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx @@ -4,11 +4,12 @@ import { useStore } from '@/app/components/app/store' import { usePathname, useRouter } from '@/next/navigation' import { fetchAppDetailDirect } from '@/service/apps' import { AppModeEnum } from '@/types/app' +import { AppACLPermission } from '@/utils/permission' import AppDetailLayout from '../layout-main' const mockReplace = vi.fn() let mockPathname = '/app/app-1/workflow' -let mockIsCurrentWorkspaceEditor = true +let mockIsLoadingWorkspacePermissionKeys = false vi.mock('@/next/navigation', () => ({ usePathname: vi.fn(), @@ -22,8 +23,10 @@ vi.mock('@/service/apps', () => ({ vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ currentWorkspace: { id: 'workspace-1' }, - isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor, isLoadingCurrentWorkspace: false, + isLoadingWorkspacePermissionKeys: mockIsLoadingWorkspacePermissionKeys, + userProfile: { id: 'user-1' }, + workspacePermissionKeys: [], }), })) @@ -39,6 +42,7 @@ const createAppDetail = (overrides: Partial = {}) => ({ id: 'app-1', name: 'Demo App', mode: AppModeEnum.WORKFLOW, + permission_keys: [AppACLPermission.ViewLayout, AppACLPermission.Monitor], ...overrides, }) as App @@ -52,7 +56,7 @@ describe('AppDetailLayout', () => { beforeEach(() => { vi.clearAllMocks() mockPathname = '/app/app-1/workflow' - mockIsCurrentWorkspaceEditor = true + mockIsLoadingWorkspacePermissionKeys = false mockUsePathname.mockImplementation(() => mockPathname) mockUseRouter.mockReturnValue({ back: vi.fn(), @@ -99,8 +103,171 @@ describe('AppDetailLayout', () => { }) it('should redirect restricted app pages before exposing app detail content', async () => { - mockIsCurrentWorkspaceEditor = false mockPathname = '/app/app-1/logs' + mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] })) + + render( + +
App page content
+
, + ) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/app/app-1/workflow') + }) + expect(screen.queryByText('App page content')).not.toBeInTheDocument() + expect(useStore.getState().appDetail).toBeUndefined() + }) + + it('should allow users with monitor access to open logs directly', async () => { + mockPathname = '/app/app-1/logs' + mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.Monitor] })) + + render( + +
App page content
+
, + ) + + await waitForAppContent() + + expect(mockReplace).not.toHaveBeenCalledWith('/app/app-1/overview') + expect(useStore.getState().appDetail?.id).toBe('app-1') + }) + + it('should allow users with layout access to open workflow pages directly', async () => { + mockPathname = '/app/app-1/workflow' + + render( + +
App page content
+
, + ) + + await waitForAppContent() + + expect(mockReplace).not.toHaveBeenCalledWith('/app/app-1/overview') + expect(useStore.getState().appDetail?.id).toBe('app-1') + }) + + it('should redirect workflow pages when layout access is missing', async () => { + mockPathname = '/app/app-1/workflow' + mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [] })) + + render( + +
App page content
+
, + ) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/app/app-1/develop') + }) + expect(screen.queryByText('App page content')).not.toBeInTheDocument() + expect(useStore.getState().appDetail).toBeUndefined() + }) + + it('should redirect overview pages when monitor access is missing', async () => { + mockPathname = '/app/app-1/overview' + mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] })) + + render( + +
App page content
+
, + ) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/app/app-1/workflow') + }) + expect(screen.queryByText('App page content')).not.toBeInTheDocument() + expect(useStore.getState().appDetail).toBeUndefined() + }) + + it('should wait for workspace permission keys before redirecting restricted pages', async () => { + mockIsLoadingWorkspacePermissionKeys = true + mockPathname = '/app/app-1/overview' + mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] })) + + const { rerender } = render( + +
App page content
+
, + ) + + await waitFor(() => { + expect(mockFetchAppDetailDirect).toHaveBeenCalledTimes(1) + }) + expect(mockReplace).not.toHaveBeenCalled() + expect(screen.queryByText('App page content')).not.toBeInTheDocument() + + mockIsLoadingWorkspacePermissionKeys = false + rerender( + +
App page content
+
, + ) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/app/app-1/workflow') + }) + }) + + it('should allow users with monitor access to open overview directly', async () => { + mockPathname = '/app/app-1/overview' + mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.Monitor] })) + + render( + +
App page content
+
, + ) + + await waitForAppContent() + + expect(mockReplace).not.toHaveBeenCalled() + expect(useStore.getState().appDetail?.id).toBe('app-1') + }) + + it('should redirect access config pages when access config access is missing', async () => { + mockPathname = '/app/app-1/access-config' + mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] })) + + render( + +
App page content
+
, + ) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/app/app-1/workflow') + }) + expect(screen.queryByText('App page content')).not.toBeInTheDocument() + expect(useStore.getState().appDetail).toBeUndefined() + }) + + it('should allow users with access config access to open access config directly', async () => { + mockPathname = '/app/app-1/access-config' + mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [AppACLPermission.AccessConfig] })) + + render( + +
App page content
+
, + ) + + await waitForAppContent() + + expect(mockReplace).not.toHaveBeenCalled() + expect(useStore.getState().appDetail?.id).toBe('app-1') + }) + + it('should redirect annotation pages when edit access is missing', async () => { + mockPathname = '/app/app-1/annotations' + mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ + mode: AppModeEnum.CHAT, + permission_keys: [AppACLPermission.Monitor], + })) render( @@ -114,4 +281,23 @@ describe('AppDetailLayout', () => { expect(screen.queryByText('App page content')).not.toBeInTheDocument() expect(useStore.getState().appDetail).toBeUndefined() }) + + it('should allow users with edit access to open annotations directly', async () => { + mockPathname = '/app/app-1/annotations' + mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ + mode: AppModeEnum.CHAT, + permission_keys: [AppACLPermission.Edit], + })) + + render( + +
App page content
+
, + ) + + await waitForAppContent() + + expect(mockReplace).not.toHaveBeenCalled() + expect(useStore.getState().appDetail?.id).toBe('app-1') + }) }) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/__tests__/page.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/__tests__/page.spec.tsx new file mode 100644 index 00000000000..1aa69c277b4 --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/__tests__/page.spec.tsx @@ -0,0 +1,24 @@ +import { render, screen } from '@testing-library/react' +import AccessConfig from '../page' + +vi.mock('@/app/components/app/access-config', () => ({ + default: ({ appId }: { appId: string }) => ( +
+ app access config + {appId} +
+ ), +})) + +describe('App access config route', () => { + // Route rendering resolves the async app id params for the client page. + describe('Rendering', () => { + it('should pass app id from route params', async () => { + render(await AccessConfig({ + params: Promise.resolve({ locale: 'en-US', appId: 'app-route-id' }), + })) + + expect(screen.getByTestId('app-access-config')).toHaveAttribute('data-app-id', 'app-route-id') + }) + }) +}) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/page.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/page.tsx new file mode 100644 index 00000000000..85ce85bc398 --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/page.tsx @@ -0,0 +1,16 @@ +import type { Locale } from '@/i18n-config' +import AppAccessConfigPage from '@/app/components/app/access-config' + +export type AccessConfigPageProps = { + params: Promise<{ locale: Locale, appId: string }> +} + +const AccessConfig = async (props: AccessConfigPageProps) => { + const params = await props.params + + const { appId } = params + + return +} + +export default AccessConfig diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index 8eb6a9bd0e3..dcbcba4116d 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -13,6 +13,8 @@ import useDocumentTitle from '@/hooks/use-document-title' import { usePathname, useRouter } from '@/next/navigation' import { fetchAppDetailDirect } from '@/service/apps' import { AppModeEnum } from '@/types/app' +import { getRedirectionPath } from '@/utils/app-redirection' +import { getAppACLCapabilities } from '@/utils/permission' type IAppDetailLayoutProps = { children: React.ReactNode @@ -34,7 +36,7 @@ const AppDetailLayout: FC = (props) => { const { t } = useTranslation() const router = useRouter() const pathname = usePathname() - const { isCurrentWorkspaceEditor, isLoadingCurrentWorkspace, currentWorkspace } = useAppContext() + const { isLoadingCurrentWorkspace, isLoadingWorkspacePermissionKeys, currentWorkspace, userProfile, workspacePermissionKeys } = useAppContext() const { appDetail, setAppDetail } = useStore(useShallow(state => ({ appDetail: state.appDetail, setAppDetail: state.setAppDetail, @@ -84,15 +86,33 @@ const AppDetailLayout: FC = (props) => { }, [appId, router, setAppDetail]) useEffect(() => { - if (!routeAppDetail || !currentWorkspace.id || isLoadingCurrentWorkspace || isLoadingAppDetail) + if (!routeAppDetail || !currentWorkspace.id || isLoadingCurrentWorkspace || isLoadingWorkspacePermissionKeys || isLoadingAppDetail) return if (routeAppDetail.id !== appId) return - // redirection - const canIEditApp = isCurrentWorkspaceEditor - if (!canIEditApp && (pathname.endsWith('configuration') || pathname.endsWith('workflow') || pathname.endsWith('logs') || pathname.endsWith('annotations'))) { - router.replace(`/app/${appId}/overview`) + const appACLCapabilities = getAppACLCapabilities(routeAppDetail.permission_keys, { + currentUserId: userProfile?.id, + resourceMaintainer: routeAppDetail.maintainer, + workspacePermissionKeys, + }) + const isLayoutPath = pathname.endsWith('configuration') || pathname.endsWith('workflow') + const isLogsPath = pathname.endsWith('logs') + const isAnnotationsPath = pathname.endsWith('annotations') + const isOverviewPath = pathname.endsWith('overview') + const isAccessConfigPath = pathname.endsWith('access-config') + if ( + (isLayoutPath && !appACLCapabilities.canAccessLayout) + || (isLogsPath && !appACLCapabilities.canMonitor) + || (isAnnotationsPath && !appACLCapabilities.canEdit) + || (isOverviewPath && !appACLCapabilities.canMonitor) + || (isAccessConfigPath && !appACLCapabilities.canAccessConfig) + ) { + router.replace(getRedirectionPath(routeAppDetail, { + currentUserId: userProfile?.id, + resourceMaintainer: routeAppDetail.maintainer, + workspacePermissionKeys, + })) return } if ((routeAppDetail.mode === AppModeEnum.WORKFLOW || routeAppDetail.mode === AppModeEnum.ADVANCED_CHAT) && (pathname).endsWith('configuration')) { @@ -105,7 +125,7 @@ const AppDetailLayout: FC = (props) => { if (appDetailRes && appDetail?.id !== appDetailRes.id) setAppDetail({ ...appDetailRes, enable_sso: false }) - }, [appDetail?.id, appDetailRes, appId, currentWorkspace.id, isCurrentWorkspaceEditor, isLoadingAppDetail, isLoadingCurrentWorkspace, pathname, routeAppDetail, router, setAppDetail]) + }, [appDetail?.id, appDetailRes, appId, currentWorkspace.id, isLoadingAppDetail, isLoadingCurrentWorkspace, isLoadingWorkspacePermissionKeys, pathname, routeAppDetail, router, setAppDetail, userProfile?.id, workspacePermissionKeys]) if (!appDetail) { return ( diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/card-view.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/card-view.spec.tsx new file mode 100644 index 00000000000..4099e1fce56 --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/card-view.spec.tsx @@ -0,0 +1,159 @@ +import type { App } from '@/types/app' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import CardView from '../card-view' + +const mockAppState = vi.hoisted(() => ({ + appDetail: { + id: 'app-1', + mode: 'chat', + permission_keys: [] as string[], + }, + setAppDetail: vi.fn(), +})) + +const mockUpdateAppSiteStatus = vi.hoisted(() => vi.fn()) +const mockUpdateAppSiteConfig = vi.hoisted(() => vi.fn()) +const mockUpdateAppSiteAccessToken = vi.hoisted(() => vi.fn()) +const mockInvalidateQueries = vi.hoisted(() => vi.fn()) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: typeof mockAppState) => T): T => selector(mockAppState), +})) + +vi.mock('@/service/use-workflow', () => ({ + useAppWorkflow: () => ({ data: undefined }), +})) + +vi.mock('@/service/apps', () => ({ + updateAppSiteStatus: (...args: unknown[]) => mockUpdateAppSiteStatus(...args), + updateAppSiteConfig: (...args: unknown[]) => mockUpdateAppSiteConfig(...args), + updateAppSiteAccessToken: (...args: unknown[]) => mockUpdateAppSiteAccessToken(...args), +})) + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ + invalidateQueries: mockInvalidateQueries, + }), +})) + +vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({ + collaborationManager: { + onAppStateUpdate: vi.fn(() => vi.fn()), + }, +})) + +vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({ + webSocketClient: { + getSocket: vi.fn(() => null), + }, +})) + +vi.mock('@/app/components/app/overview/app-card', () => ({ + default: ({ + cardType, + onChangeStatus, + onGenerateCode, + onSaveSiteConfig, + }: { + cardType: string + onChangeStatus?: (value: boolean) => void + onGenerateCode?: () => void + onSaveSiteConfig?: (params: Record) => void + }) => ( +
+ + {onGenerateCode && ( + + )} + {onSaveSiteConfig && ( + + )} +
+ ), +})) + +vi.mock('@/app/components/app/overview/trigger-card', () => ({ + default: () =>
trigger card
, +})) + +vi.mock('@/app/components/tools/mcp/mcp-service-card', () => ({ + default: () =>
mcp card
, +})) + +describe('CardView ACL edit guards', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAppState.appDetail = { + id: 'app-1', + mode: 'chat', + permission_keys: [], + } + mockUpdateAppSiteStatus.mockResolvedValue(mockAppState.appDetail as App) + mockUpdateAppSiteConfig.mockResolvedValue(mockAppState.appDetail as App) + mockUpdateAppSiteAccessToken.mockResolvedValue({ code: 'token' }) + mockInvalidateQueries.mockResolvedValue(undefined) + }) + + // User-facing card actions should not mutate app settings without app ACL edit permission. + describe('Permissions', () => { + it('should not call write APIs when app ACL edit permission is missing', async () => { + const user = userEvent.setup() + + render() + + await user.click(screen.getByRole('button', { name: /toggle webapp/ })) + await user.click(screen.getByRole('button', { name: /save webapp/ })) + await user.click(screen.getByRole('button', { name: /generate webapp/ })) + await user.click(screen.getByRole('button', { name: /toggle api/ })) + + expect(mockUpdateAppSiteStatus).not.toHaveBeenCalled() + expect(mockUpdateAppSiteConfig).not.toHaveBeenCalled() + expect(mockUpdateAppSiteAccessToken).not.toHaveBeenCalled() + }) + + it('should call write APIs when app ACL edit permission is present', async () => { + const user = userEvent.setup() + mockAppState.appDetail.permission_keys = ['app.acl.edit'] + + render() + + await user.click(screen.getByRole('button', { name: /toggle webapp/ })) + await user.click(screen.getByRole('button', { name: /save webapp/ })) + await user.click(screen.getByRole('button', { name: /generate webapp/ })) + await user.click(screen.getByRole('button', { name: /toggle api/ })) + + await waitFor(() => { + expect(mockUpdateAppSiteStatus).toHaveBeenCalledTimes(2) + }) + expect(mockUpdateAppSiteStatus).toHaveBeenCalledWith({ + url: '/apps/app-1/site-enable', + body: { enable_site: true }, + }) + expect(mockUpdateAppSiteStatus).toHaveBeenCalledWith({ + url: '/apps/app-1/api-enable', + body: { enable_api: true }, + }) + expect(mockUpdateAppSiteConfig).toHaveBeenCalledWith({ + url: '/apps/app-1/site', + body: { title: 'Site title' }, + }) + expect(mockUpdateAppSiteAccessToken).toHaveBeenCalledWith({ + url: '/apps/app-1/site/access-token-reset', + }) + expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['apps', 'detail', 'app-1'] }) + }) + }) +}) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/chart-view.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/chart-view.spec.tsx new file mode 100644 index 00000000000..75516435b66 --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/chart-view.spec.tsx @@ -0,0 +1,127 @@ +import { render, screen } from '@testing-library/react' +import { AppACLPermission } from '@/utils/permission' +import ChartView from '../chart-view' + +const testState = vi.hoisted(() => ({ + appDetail: { + id: 'app-1', + mode: 'chat', + maintainer: 'maintainer-1', + permission_keys: [] as string[], + }, + currentUserId: 'user-1', + workspacePermissionKeys: [] as string[], + chartRenderSpy: vi.fn(), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { appDetail: typeof testState.appDetail }) => T): T => selector({ + appDetail: testState.appDetail, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useSelector: vi.fn((selector: (state: { userProfile: { id: string }, workspacePermissionKeys: string[] }) => unknown) => selector({ + userProfile: { id: testState.currentUserId }, + workspacePermissionKeys: testState.workspacePermissionKeys, + })), +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => path, +})) + +vi.mock('@/app/components/app/overview/app-chart', () => ({ + AvgResponseTime: () => { + testState.chartRenderSpy('avg-response-time') + return
avg response time chart
+ }, + AvgSessionInteractions: () => { + testState.chartRenderSpy('avg-session-interactions') + return
avg session interactions chart
+ }, + AvgUserInteractions: () => { + testState.chartRenderSpy('avg-user-interactions') + return
avg user interactions chart
+ }, + ConversationsChart: () => { + testState.chartRenderSpy('conversations') + return
conversations chart
+ }, + CostChart: () => { + testState.chartRenderSpy('cost') + return
cost chart
+ }, + EndUsersChart: () => { + testState.chartRenderSpy('end-users') + return
end users chart
+ }, + MessagesChart: () => { + testState.chartRenderSpy('messages') + return
messages chart
+ }, + TokenPerSecond: () => { + testState.chartRenderSpy('token-per-second') + return
token per second chart
+ }, + UserSatisfactionRate: () => { + testState.chartRenderSpy('user-satisfaction-rate') + return
user satisfaction rate chart
+ }, + WorkflowCostChart: () => { + testState.chartRenderSpy('workflow-cost') + return
workflow cost chart
+ }, + WorkflowDailyTerminalsChart: () => { + testState.chartRenderSpy('workflow-daily-terminals') + return
workflow daily terminals chart
+ }, + WorkflowMessagesChart: () => { + testState.chartRenderSpy('workflow-messages') + return
workflow messages chart
+ }, +})) + +vi.mock('../long-time-range-picker', () => ({ + default: () => , +})) + +vi.mock('../time-range-picker', () => ({ + default: () => , +})) + +describe('ChartView monitor permission', () => { + beforeEach(() => { + vi.clearAllMocks() + testState.appDetail = { + id: 'app-1', + mode: 'chat', + maintainer: 'maintainer-1', + permission_keys: [], + } + testState.currentUserId = 'user-1' + testState.workspacePermissionKeys = [] + }) + + // Monitoring charts are part of the app monitor permission surface. + describe('Permissions', () => { + it('should not render monitoring charts when app monitor permission is missing', () => { + render(header action} />) + + expect(screen.queryByRole('heading', { name: 'common.appMenus.overview' })).not.toBeInTheDocument() + expect(screen.queryByText('header action')).not.toBeInTheDocument() + expect(testState.chartRenderSpy).not.toHaveBeenCalled() + }) + + it('should render monitoring charts when app monitor permission is granted', () => { + testState.appDetail.permission_keys = [AppACLPermission.Monitor] + + render(header action} />) + + expect(screen.getByRole('heading', { name: 'common.appMenus.overview' })).toBeInTheDocument() + expect(screen.getByText('header action')).toBeInTheDocument() + expect(screen.getByText('conversations chart')).toBeInTheDocument() + expect(testState.chartRenderSpy).toHaveBeenCalledWith('conversations') + }) + }) +}) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/view.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/view.spec.tsx new file mode 100644 index 00000000000..5ac607fc301 --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/view.spec.tsx @@ -0,0 +1,82 @@ +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import { AppACLPermission } from '@/utils/permission' +import OverviewView from '../view' + +const testState = vi.hoisted(() => ({ + appDetail: { + id: 'app-1', + mode: 'chat', + maintainer: 'maintainer-1', + permission_keys: [] as string[], + }, + currentUserId: 'user-1', + workspacePermissionKeys: [] as string[], +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { appDetail: typeof testState.appDetail }) => T): T => selector({ + appDetail: testState.appDetail, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useSelector: vi.fn((selector: (state: { userProfile: { id: string }, workspacePermissionKeys: string[] }) => unknown) => selector({ + userProfile: { id: testState.currentUserId }, + workspacePermissionKeys: testState.workspacePermissionKeys, + })), +})) + +vi.mock('@/app/components/app/overview/apikey-info-panel', () => ({ + default: () =>
api key info panel
, +})) + +vi.mock('../chart-view', () => ({ + default: ({ appId, headerRight }: { appId: string, headerRight: ReactNode }) => ( +
+ chart view + {' '} + {appId} + {headerRight} +
+ ), +})) + +vi.mock('../tracing/panel', () => ({ + default: () => , +})) + +describe('OverviewView monitor permission', () => { + beforeEach(() => { + vi.clearAllMocks() + testState.appDetail = { + id: 'app-1', + mode: 'chat', + maintainer: 'maintainer-1', + permission_keys: [], + } + testState.currentUserId = 'user-1' + testState.workspacePermissionKeys = [] + }) + + // The overview page should be controlled as one monitor-permission surface. + describe('Permissions', () => { + it('should not render overview page content when app monitor permission is missing', () => { + render() + + expect(screen.queryByText('api key info panel')).not.toBeInTheDocument() + expect(screen.queryByText(/chart view app-1/)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'tracing' })).not.toBeInTheDocument() + }) + + it('should render overview page content when app monitor permission is granted', () => { + testState.appDetail.permission_keys = [AppACLPermission.Monitor] + + render() + + expect(screen.getByText('api key info panel')).toBeInTheDocument() + expect(screen.getByText(/chart view app-1/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'tracing' })).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx index 06465e3551a..fd7a35c86da 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx @@ -6,8 +6,8 @@ import type { UpdateAppSiteCodeResponse } from '@/models/app' import type { App } from '@/types/app' import type { I18nKeysByPrefix } from '@/types/i18n' import { toast } from '@langgenius/dify-ui/toast' +import { useQueryClient } from '@tanstack/react-query' import { useSetLocalStorage } from 'foxact/use-local-storage' -import * as React from 'react' import { useCallback, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import AppCard from '@/app/components/app/overview/app-card' @@ -19,15 +19,17 @@ import { collaborationManager } from '@/app/components/workflow/collaboration/co import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' import { isTriggerNode } from '@/app/components/workflow/types' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' +import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { - fetchAppDetail, updateAppSiteAccessToken, updateAppSiteConfig, updateAppSiteStatus, } from '@/service/apps' +import { appDetailQueryKeyPrefix } from '@/service/use-apps' import { useAppWorkflow } from '@/service/use-workflow' import { AppModeEnum } from '@/types/app' import { asyncRunSafe } from '@/utils' +import { getAppACLCapabilities } from '@/utils/permission' type ICardViewProps = { appId: string @@ -37,8 +39,15 @@ type ICardViewProps = { const CardView: FC = ({ appId, isInPanel, className }) => { const { t } = useTranslation() + const queryClient = useQueryClient() const appDetail = useAppStore(state => state.appDetail) - const setAppDetail = useAppStore(state => state.setAppDetail) + const currentUserId = useAppContextWithSelector(state => state.userProfile?.id) + const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const canEditApp = useMemo(() => getAppACLCapabilities(appDetail?.permission_keys, { + currentUserId, + resourceMaintainer: appDetail?.maintainer, + workspacePermissionKeys, + }).canEdit, [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys]) const isWorkflowApp = appDetail?.mode === AppModeEnum.WORKFLOW const showMCPCard = isInPanel @@ -80,13 +89,12 @@ const CardView: FC = ({ appId, isInPanel, className }) => { const updateAppDetail = useCallback(async () => { try { - const res = await fetchAppDetail({ url: '/apps', id: appId }) - setAppDetail({ ...res }) + await queryClient.invalidateQueries({ queryKey: [...appDetailQueryKeyPrefix, appId] }) } catch (error) { console.error(error) } - }, [appId, setAppDetail]) + }, [appId, queryClient]) const handleCallbackResult = (err: Error | null, message?: I18nKeysByPrefix<'common', 'actionMsg.'>) => { const type = err ? 'error' : 'success' @@ -129,6 +137,9 @@ const CardView: FC = ({ appId, isInPanel, className }) => { }, [appId, updateAppDetail]) const onChangeSiteStatus = async (value: boolean) => { + if (!canEditApp) + return + const [err] = await asyncRunSafe( updateAppSiteStatus({ url: `/apps/${appId}/site-enable`, @@ -140,6 +151,9 @@ const CardView: FC = ({ appId, isInPanel, className }) => { } const onChangeApiStatus = async (value: boolean) => { + if (!canEditApp) + return + const [err] = await asyncRunSafe( updateAppSiteStatus({ url: `/apps/${appId}/api-enable`, @@ -151,6 +165,9 @@ const CardView: FC = ({ appId, isInPanel, className }) => { } const onSaveSiteConfig: IAppCardProps['onSaveSiteConfig'] = async (params) => { + if (!canEditApp) + return + const [err] = await asyncRunSafe( updateAppSiteConfig({ url: `/apps/${appId}/site`, @@ -164,6 +181,9 @@ const CardView: FC = ({ appId, isInPanel, className }) => { } const onGenerateCode = async () => { + if (!canEditApp) + return + const [err] = await asyncRunSafe( updateAppSiteAccessToken({ url: `/apps/${appId}/site/access-token-reset`, diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx index e935fee8f37..a4ee7d06a92 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx @@ -10,7 +10,9 @@ import { TIME_PERIOD_MAPPING as LONG_TIME_PERIOD_MAPPING } from '@/app/component import { AvgResponseTime, AvgSessionInteractions, AvgUserInteractions, ConversationsChart, CostChart, EndUsersChart, MessagesChart, TokenPerSecond, UserSatisfactionRate, WorkflowCostChart, WorkflowDailyTerminalsChart, WorkflowMessagesChart } from '@/app/components/app/overview/app-chart' import { useStore as useAppStore } from '@/app/components/app/store' import { IS_CLOUD_EDITION } from '@/config' +import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { useDocLink } from '@/context/i18n' +import { getAppACLCapabilities } from '@/utils/permission' import LongTimeRangePicker from './long-time-range-picker' import TimeRangePicker from './time-range-picker' @@ -37,6 +39,13 @@ export default function ChartView({ appId, headerRight }: IChartViewProps) { const { t } = useTranslation() const docLink = useDocLink() const appDetail = useAppStore(state => state.appDetail) + const currentUserId = useAppContextWithSelector(state => state.userProfile?.id) + const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const canMonitor = React.useMemo(() => getAppACLCapabilities(appDetail?.permission_keys, { + currentUserId, + resourceMaintainer: appDetail?.maintainer, + workspacePermissionKeys, + }).canMonitor, [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys]) const isChatApp = appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow' const isWorkflow = appDetail?.mode === 'workflow' const [period, setPeriod] = useState(IS_CLOUD_EDITION @@ -44,7 +53,7 @@ export default function ChartView({ appId, headerRight }: IChartViewProps) { : { name: t('filter.period.last7days', { ns: 'appLog' }), query: { start: today.subtract(7, 'day').startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } }, ) - if (!appDetail) + if (!appDetail || !canMonitor) return null return ( diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/page.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/page.tsx index 92ec0d59ec3..7b84d311fc9 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/page.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/page.tsx @@ -1,7 +1,5 @@ import * as React from 'react' -import ApikeyInfoPanel from '@/app/components/app/overview/apikey-info-panel' -import ChartView from './chart-view' -import TracingPanel from './tracing/panel' +import OverviewView from './view' export type IDevelopProps = { params: Promise<{ appId: string }> @@ -15,15 +13,7 @@ const Overview = async (props: IDevelopProps) => { } = params return ( -
- -
- } - /> -
-
+ ) } diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/panel.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/panel.spec.tsx new file mode 100644 index 00000000000..dc2970513a0 --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/panel.spec.tsx @@ -0,0 +1,137 @@ +import type { ComponentProps, ReactNode } from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import { fetchTracingConfig, fetchTracingStatus, updateTracingStatus } from '@/service/apps' +import { AppACLPermission } from '@/utils/permission' +import Panel from '../panel' + +const testState = vi.hoisted(() => ({ + appPermissionKeys: [] as string[], + workspacePermissionKeys: [] as string[], + configButtonProps: [] as Array<{ + readOnly: boolean + hasConfigured: boolean + }>, +})) + +vi.mock('@/next/navigation', () => ({ + usePathname: () => '/app/app-1/overview', +})) + +vi.mock('@/context/app-context', () => ({ + useSelector: vi.fn((selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({ + workspacePermissionKeys: testState.workspacePermissionKeys, + })), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: vi.fn((selector: (state: { appDetail: { permission_keys: string[] } }) => unknown) => selector({ + appDetail: { + permission_keys: testState.appPermissionKeys, + }, + })), +})) + +vi.mock('@/service/apps', () => ({ + fetchTracingStatus: vi.fn(), + fetchTracingConfig: vi.fn(), + updateTracingStatus: vi.fn(), +})) + +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: vi.fn(), +})) + +vi.mock('@langgenius/dify-ui/status-dot', () => ({ + StatusDot: ({ status }: { status: string }) => {status}, +})) + +vi.mock('@/app/components/base/icons/src/public/tracing', () => ({ + AliyunIcon: () => , + ArizeIcon: () => , + DatabricksIcon: () => , + LangfuseIcon: () => , + LangsmithIcon: () => , + MlflowIcon: () => , + OpikIcon: () => , + PhoenixIcon: () => , + TencentIcon: () => , + TracingIcon: () => , + WeaveIcon: () => , +})) + +vi.mock('../config-button', () => ({ + default: ({ children, ...props }: ComponentProps<'div'> & { readOnly: boolean, hasConfigured: boolean, children?: ReactNode }) => { + testState.configButtonProps.push({ + readOnly: props.readOnly, + hasConfigured: props.hasConfigured, + }) + + return ( +
+ {children} +
+ ) + }, +})) + +const mockedFetchTracingStatus = vi.mocked(fetchTracingStatus) +const mockedFetchTracingConfig = vi.mocked(fetchTracingConfig) +const mockedUpdateTracingStatus = vi.mocked(updateTracingStatus) + +const renderPanel = async () => { + render() + + await screen.findAllByTestId('config-button') +} + +describe('Tracing overview panel permissions', () => { + beforeEach(() => { + testState.appPermissionKeys = [] + testState.workspacePermissionKeys = [] + testState.configButtonProps = [] + mockedFetchTracingStatus.mockResolvedValue({ + enabled: false, + tracing_provider: null, + }) + mockedFetchTracingConfig.mockResolvedValue({ + tracing_provider: 'langfuse', + tracing_config: {}, + has_not_configured: true, + } as Awaited>) + mockedUpdateTracingStatus.mockResolvedValue({ + result: 'success', + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('marks tracing config as read-only without app monitor or workspace tracking permissions', async () => { + await renderPanel() + + await waitFor(() => { + expect(testState.configButtonProps[0]).toMatchObject({ + readOnly: true, + hasConfigured: false, + }) + }) + }) + + it('allows tracing config when app ACL includes monitor permission', async () => { + testState.appPermissionKeys = [AppACLPermission.Monitor] + + await renderPanel() + + await waitFor(() => { + expect(testState.configButtonProps[0]).toMatchObject({ + readOnly: false, + hasConfigured: false, + }) + }) + }) +}) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx index a12a28a7018..7c0de99e0f5 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx @@ -5,20 +5,29 @@ import type { TracingStatus } from '@/models/app' import { cn } from '@langgenius/dify-ui/cn' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { toast } from '@langgenius/dify-ui/toast' -import { - RiArrowDownDoubleLine, - RiEqualizer2Line, -} from '@remixicon/react' import { useBoolean } from 'ahooks' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useStore as useAppStore } from '@/app/components/app/store' import Divider from '@/app/components/base/divider' -import { AliyunIcon, ArizeIcon, DatabricksIcon, LangfuseIcon, LangsmithIcon, MlflowIcon, OpikIcon, PhoenixIcon, TencentIcon, WeaveIcon } from '@/app/components/base/icons/src/public/tracing' +import { + AliyunIcon, + ArizeIcon, + DatabricksIcon, + LangfuseIcon, + LangsmithIcon, + MlflowIcon, + OpikIcon, + PhoenixIcon, + TencentIcon, + WeaveIcon, +} from '@/app/components/base/icons/src/public/tracing' import Loading from '@/app/components/base/loading' -import { useAppContext } from '@/context/app-context' +import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { usePathname } from '@/next/navigation' import { fetchTracingConfig as doFetchTracingConfig, fetchTracingStatus, updateTracingStatus } from '@/service/apps' +import { getAppACLCapabilities } from '@/utils/permission' import ConfigButton from './config-button' import TracingIcon from './tracing-icon' import { TracingProvider } from './type' @@ -30,8 +39,16 @@ const Panel: FC = () => { const pathname = usePathname() const matched = /\/app\/([^/]+)/.exec(pathname) const appId = (matched?.length && matched[1]) ? matched[1] : '' - const { isCurrentWorkspaceEditor } = useAppContext() - const readOnly = !isCurrentWorkspaceEditor + const currentUserId = useAppContextWithSelector(state => state.userProfile?.id) + const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const appDetail = useAppStore(s => s.appDetail) + const appACLCapabilities = React.useMemo(() => getAppACLCapabilities(appDetail?.permission_keys, { + currentUserId, + resourceMaintainer: appDetail?.maintainer, + workspacePermissionKeys, + }), [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys]) + const canConfigTracing = appACLCapabilities.canMonitor + const readOnly = !canConfigTracing const [isLoaded, { setTrue: setLoaded, @@ -253,11 +270,11 @@ const Panel: FC = () => {
{t(`${I18N_PREFIX}.title`, { ns: 'app' })}
- +
- +
@@ -297,7 +314,7 @@ const Panel: FC = () => { {InUseProviderIcon && }
- +
diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/view.tsx new file mode 100644 index 00000000000..7f27286a657 --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/view.tsx @@ -0,0 +1,41 @@ +'use client' + +import * as React from 'react' +import ApikeyInfoPanel from '@/app/components/app/overview/apikey-info-panel' +import { useStore as useAppStore } from '@/app/components/app/store' +import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { getAppACLCapabilities } from '@/utils/permission' +import ChartView from './chart-view' +import TracingPanel from './tracing/panel' + +type OverviewViewProps = { + appId: string +} + +const OverviewView = ({ appId }: OverviewViewProps) => { + const appDetail = useAppStore(state => state.appDetail) + const currentUserId = useAppContextWithSelector(state => state.userProfile?.id) + const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const canMonitor = React.useMemo(() => getAppACLCapabilities(appDetail?.permission_keys, { + currentUserId, + resourceMaintainer: appDetail?.maintainer, + workspacePermissionKeys, + }).canMonitor, [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys]) + + if (!appDetail || !canMonitor) + return null + + return ( +
+ +
+ } + /> +
+
+ ) +} + +export default OverviewView diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx index ae1fedf8312..23a9672a220 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx @@ -1,6 +1,7 @@ import { render, screen, waitFor } from '@testing-library/react' import { usePathname, useRouter } from '@/next/navigation' import { useDatasetDetail } from '@/service/knowledge/use-dataset' +import { DatasetACLPermission } from '@/utils/permission' import DatasetDetailLayout from '../layout-main' const mockReplace = vi.fn() @@ -17,6 +18,10 @@ vi.mock('@/service/knowledge/use-dataset', () => ({ vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ isCurrentWorkspaceDatasetOperator: false, + isLoadingCurrentWorkspace: false, + isLoadingWorkspacePermissionKeys: false, + userProfile: { id: 'user-1' }, + workspacePermissionKeys: [], }), })) @@ -198,4 +203,94 @@ describe('DatasetDetailLayout', () => { expect(screen.getByText('Create from pipeline content').parentElement).not.toHaveClass('rounded-lg') }) }) + + describe('Permission Route Guards', () => { + it('should redirect from hit testing when retrieval recall permission is missing', async () => { + // Arrange + mockUsePathname.mockReturnValue('/datasets/dataset-1/hitTesting') + mockUseDatasetDetail.mockReturnValue({ + data: { + id: 'dataset-1', + name: 'Dataset 1', + provider: 'external', + runtime_mode: 'general', + is_published: true, + permission_keys: [], + }, + error: null, + refetch: vi.fn(), + } as unknown as ReturnType) + + // Act + render( + +
Hit testing content
+
, + ) + + // Assert + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/datasets/dataset-1/settings') + }) + expect(screen.queryByText('Hit testing content')).not.toBeInTheDocument() + }) + + it('should redirect from access config when access config permission is missing', async () => { + // Arrange + mockUsePathname.mockReturnValue('/datasets/dataset-1/access-config') + mockUseDatasetDetail.mockReturnValue({ + data: { + id: 'dataset-1', + name: 'Dataset 1', + provider: 'vendor', + runtime_mode: 'general', + is_published: true, + permission_keys: [], + }, + error: null, + refetch: vi.fn(), + } as unknown as ReturnType) + + // Act + render( + +
Access config content
+
, + ) + + // Assert + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/datasets/dataset-1/documents') + }) + expect(screen.queryByText('Access config content')).not.toBeInTheDocument() + }) + + it('should render access config when access config permission is granted', () => { + // Arrange + mockUsePathname.mockReturnValue('/datasets/dataset-1/access-config') + mockUseDatasetDetail.mockReturnValue({ + data: { + id: 'dataset-1', + name: 'Dataset 1', + provider: 'vendor', + runtime_mode: 'general', + is_published: true, + permission_keys: [DatasetACLPermission.AccessConfig], + }, + error: null, + refetch: vi.fn(), + } as unknown as ReturnType) + + // Act + render( + +
Access config content
+
, + ) + + // Assert + expect(screen.getByText('Access config content')).toBeInTheDocument() + expect(mockReplace).not.toHaveBeenCalled() + }) + }) }) diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/access-config/__tests__/page.spec.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/access-config/__tests__/page.spec.tsx new file mode 100644 index 00000000000..b1eec08101f --- /dev/null +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/access-config/__tests__/page.spec.tsx @@ -0,0 +1,24 @@ +import { render, screen } from '@testing-library/react' +import AccessConfig from '../page' + +vi.mock('@/app/components/datasets/access-config', () => ({ + default: ({ datasetId }: { datasetId: string }) => ( +
+ dataset access config + {datasetId} +
+ ), +})) + +describe('Dataset access config route', () => { + // Route rendering resolves the async dataset id params for the client page. + describe('Rendering', () => { + it('should pass dataset id from route params', async () => { + render(await AccessConfig({ + params: Promise.resolve({ datasetId: 'dataset-route-id' }), + })) + + expect(screen.getByTestId('dataset-access-config')).toHaveAttribute('data-dataset-id', 'dataset-route-id') + }) + }) +}) diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/access-config/page.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/access-config/page.tsx new file mode 100644 index 00000000000..66a809a795d --- /dev/null +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/access-config/page.tsx @@ -0,0 +1,15 @@ +import DatasetAccessConfigPage from '@/app/components/datasets/access-config' + +type Props = { + params: Promise<{ datasetId: string }> +} + +const AccessConfig = async (props: Props) => { + const params = await props.params + + const { datasetId } = params + + return +} + +export default AccessConfig diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index dd08e312545..3791009061d 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -1,14 +1,17 @@ 'use client' import type { FC } from 'react' +import type { DataSet } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' +import { useAppContext } from '@/context/app-context' import DatasetDetailContext from '@/context/dataset-detail' import useDocumentTitle from '@/hooks/use-document-title' import { usePathname, useRouter } from '@/next/navigation' import { useDatasetDetail } from '@/service/knowledge/use-dataset' +import { getDatasetACLCapabilities } from '@/utils/permission' type IAppDetailLayoutProps = { children: React.ReactNode @@ -28,6 +31,23 @@ const shouldRedirectToDatasetList = (error: unknown) => { return status === 403 || status === 404 } +const getDatasetRedirectionPath = ( + dataset: DataSet, + datasetACLCapabilities: ReturnType, +) => { + if (dataset.provider === 'external') { + if (datasetACLCapabilities.canRetrievalRecall) + return `/datasets/${dataset.id}/hitTesting` + + return `/datasets/${dataset.id}/settings` + } + + if (dataset.runtime_mode === 'rag_pipeline' && !dataset.is_published) + return `/datasets/${dataset.id}/pipeline` + + return `/datasets/${dataset.id}/documents` +} + const DatasetDetailLayout: FC = (props) => { const { children, @@ -36,9 +56,32 @@ const DatasetDetailLayout: FC = (props) => { const { t } = useTranslation() const router = useRouter() const pathname = usePathname() + const { + isLoadingCurrentWorkspace, + isLoadingWorkspacePermissionKeys, + userProfile, + workspacePermissionKeys, + } = useAppContext() const { data: datasetRes, error, refetch: mutateDatasetRes } = useDatasetDetail(datasetId) const shouldRedirect = shouldRedirectToDatasetList(error) + const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(datasetRes?.permission_keys, { + currentUserId: userProfile?.id, + resourceMaintainer: datasetRes?.maintainer, + workspacePermissionKeys, + }), [datasetRes?.maintainer, datasetRes?.permission_keys, userProfile?.id, workspacePermissionKeys]) + const isAccessConfigPath = pathname.endsWith('/access-config') + const isHitTestingPath = pathname.endsWith('/hitTesting') + const isPermissionControlledPath = isAccessConfigPath || isHitTestingPath + const isCheckingRouteAccess = !!datasetRes + && isPermissionControlledPath + && (isLoadingCurrentWorkspace || !!isLoadingWorkspacePermissionKeys) + const shouldRedirectUnauthorizedRoute = !!datasetRes + && !isCheckingRouteAccess + && ( + (isAccessConfigPath && !datasetACLCapabilities.canAccessConfig) + || (isHitTestingPath && !datasetACLCapabilities.canRetrievalRecall) + ) useDocumentTitle(datasetRes?.name || t('menus.datasets', { ns: 'common' })) @@ -47,12 +90,22 @@ const DatasetDetailLayout: FC = (props) => { router.replace('/datasets') }, [router, shouldRedirect]) + useEffect(() => { + if (!datasetRes || !shouldRedirectUnauthorizedRoute) + return + + router.replace(getDatasetRedirectionPath(datasetRes, datasetACLCapabilities)) + }, [datasetACLCapabilities, datasetRes, router, shouldRedirectUnauthorizedRoute]) + if (!datasetRes && !error) return if (shouldRedirect) return + if (isCheckingRouteAccess || shouldRedirectUnauthorizedRoute) + return + const isPipelinePage = pathname.endsWith('/pipeline') || pathname.includes('/create-from-pipeline') return ( diff --git a/web/app/(commonLayout)/datasets/layout.spec.tsx b/web/app/(commonLayout)/datasets/layout.spec.tsx index 7abc2253ce2..4db962ff301 100644 --- a/web/app/(commonLayout)/datasets/layout.spec.tsx +++ b/web/app/(commonLayout)/datasets/layout.spec.tsx @@ -5,15 +5,19 @@ import DatasetsLayout from './layout' const mockReplace = vi.fn() const mockUseAppContext = vi.fn() +let mockPathname = '/datasets' +let mockExternalKnowledgeApiProviderEnabled: boolean | undefined vi.mock('@/next/navigation', () => ({ useRouter: () => ({ replace: mockReplace, }), + usePathname: () => mockPathname, })) vi.mock('@/context/app-context', () => ({ useAppContext: () => mockUseAppContext(), + useSelector: (selector: (state: AppContextMock) => unknown) => selector(mockUseAppContext()), })) vi.mock('@/context/external-api-panel-context', () => ({ @@ -21,13 +25,18 @@ vi.mock('@/context/external-api-panel-context', () => ({ })) vi.mock('@/context/external-knowledge-api-context', () => ({ - ExternalKnowledgeApiProvider: ({ children }: { children: ReactNode }) => <>{children}, + ExternalKnowledgeApiProvider: ({ children, enabled }: { children: ReactNode, enabled?: boolean }) => { + mockExternalKnowledgeApiProviderEnabled = enabled + return <>{children} + }, })) type AppContextMock = { isCurrentWorkspaceEditor: boolean isCurrentWorkspaceDatasetOperator: boolean isLoadingCurrentWorkspace: boolean + isLoadingWorkspacePermissionKeys: boolean + workspacePermissionKeys: string[] currentWorkspace: { id: string } @@ -37,6 +46,8 @@ const baseContext: AppContextMock = { isCurrentWorkspaceEditor: true, isCurrentWorkspaceDatasetOperator: false, isLoadingCurrentWorkspace: false, + isLoadingWorkspacePermissionKeys: false, + workspacePermissionKeys: [], currentWorkspace: { id: 'workspace-1', }, @@ -52,6 +63,8 @@ const setAppContext = (overrides: Partial = {}) => { describe('DatasetsLayout', () => { beforeEach(() => { vi.clearAllMocks() + mockPathname = '/datasets' + mockExternalKnowledgeApiProviderEnabled = undefined setAppContext() }) @@ -72,10 +85,10 @@ describe('DatasetsLayout', () => { expect(mockReplace).not.toHaveBeenCalled() }) - it('should redirect non-editor and non-dataset-operator users to /apps', async () => { + it('should render loading while workspace permission keys are loading', () => { setAppContext({ - isCurrentWorkspaceEditor: false, - isCurrentWorkspaceDatasetOperator: false, + isLoadingWorkspacePermissionKeys: true, + workspacePermissionKeys: [], }) render(( @@ -84,16 +97,16 @@ describe('DatasetsLayout', () => { )) + expect(screen.getByRole('status')).toBeInTheDocument() expect(screen.queryByText('datasets')).not.toBeInTheDocument() - await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/apps') - }) + expect(mockReplace).not.toHaveBeenCalled() }) - it('should render children for dataset operators', () => { + it('should render children without a page-level dataset permission', () => { setAppContext({ - isCurrentWorkspaceEditor: false, + isCurrentWorkspaceEditor: true, isCurrentWorkspaceDatasetOperator: true, + workspacePermissionKeys: ['dataset.create_and_management', 'dataset.external.connect'], }) render(( @@ -105,4 +118,120 @@ describe('DatasetsLayout', () => { expect(screen.getByText('datasets')).toBeInTheDocument() expect(mockReplace).not.toHaveBeenCalled() }) + + it('should render children on the dataset list route without dataset permissions', () => { + setAppContext({ + isCurrentWorkspaceEditor: false, + isCurrentWorkspaceDatasetOperator: false, + workspacePermissionKeys: [], + }) + + render(( + +
datasets
+
+ )) + + expect(screen.getByText('datasets')).toBeInTheDocument() + expect(mockReplace).not.toHaveBeenCalled() + }) + + it.each([ + '/datasets/create', + '/datasets/create-from-pipeline', + ])('should redirect direct dataset creation route to /datasets without dataset.create_and_management: %s', async (pathname) => { + mockPathname = pathname + setAppContext({ + workspacePermissionKeys: [], + }) + + render(( + +
datasets
+
+ )) + + expect(screen.queryByText('datasets')).not.toBeInTheDocument() + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/datasets') + }) + }) + + it('should render direct dataset creation route when workspace has dataset.create_and_management', () => { + mockPathname = '/datasets/create' + setAppContext({ + workspacePermissionKeys: ['dataset.create_and_management'], + }) + + render(( + +
datasets
+
+ )) + + expect(screen.getByText('datasets')).toBeInTheDocument() + expect(mockReplace).not.toHaveBeenCalled() + }) + + it('should redirect direct external dataset connection route to /datasets without dataset.external.connect', async () => { + mockPathname = '/datasets/connect' + setAppContext({ + workspacePermissionKeys: [], + }) + + render(( + +
datasets
+
+ )) + + expect(screen.queryByText('datasets')).not.toBeInTheDocument() + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/datasets') + }) + }) + + it('should render direct external dataset connection route when workspace has dataset.external.connect', () => { + mockPathname = '/datasets/connect' + setAppContext({ + workspacePermissionKeys: ['dataset.external.connect'], + }) + + render(( + +
datasets
+
+ )) + + expect(screen.getByText('datasets')).toBeInTheDocument() + expect(mockReplace).not.toHaveBeenCalled() + }) + + it('should disable external knowledge API queries without dataset.external.connect', () => { + setAppContext({ + workspacePermissionKeys: [], + }) + + render(( + +
datasets
+
+ )) + + expect(mockExternalKnowledgeApiProviderEnabled).toBe(false) + }) + + it('should enable external knowledge API queries with dataset.external.connect', () => { + setAppContext({ + workspacePermissionKeys: ['dataset.external.connect'], + }) + + render(( + +
datasets
+
+ )) + + expect(mockExternalKnowledgeApiProviderEnabled).toBe(true) + }) }) diff --git a/web/app/(commonLayout)/datasets/layout.tsx b/web/app/(commonLayout)/datasets/layout.tsx index a465f8222b0..8f6777dedfb 100644 --- a/web/app/(commonLayout)/datasets/layout.tsx +++ b/web/app/(commonLayout)/datasets/layout.tsx @@ -2,32 +2,53 @@ import { useEffect } from 'react' import Loading from '@/app/components/base/loading' -import { useAppContext } from '@/context/app-context' +import { useSelector as useAppContextSelector } from '@/context/app-context' import { ExternalApiPanelProvider } from '@/context/external-api-panel-context' import { ExternalKnowledgeApiProvider } from '@/context/external-knowledge-api-context' -import { useRouter } from '@/next/navigation' +import { usePathname, useRouter } from '@/next/navigation' +import { hasPermission } from '@/utils/permission' + +const isDatasetCreatePath = (pathname: string) => { + return pathname === '/datasets/create' + || pathname.startsWith('/datasets/create/') + || pathname === '/datasets/create-from-pipeline' + || pathname.startsWith('/datasets/create-from-pipeline/') +} + +const isDatasetExternalConnectPath = (pathname: string) => { + return pathname === '/datasets/connect' + || pathname.startsWith('/datasets/connect/') +} export default function DatasetsLayout({ children }: { children: React.ReactNode }) { - const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, currentWorkspace, isLoadingCurrentWorkspace } = useAppContext() + const currentWorkspaceId = useAppContextSelector(state => state.currentWorkspace.id) + const isLoadingCurrentWorkspace = useAppContextSelector(state => state.isLoadingCurrentWorkspace) + const isLoadingWorkspacePermissionKeys = useAppContextSelector(state => state.isLoadingWorkspacePermissionKeys) + const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys) const router = useRouter() - const shouldRedirect = !isLoadingCurrentWorkspace - && currentWorkspace.id - && !(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) + const pathname = usePathname() + const isLoadingAccess = isLoadingCurrentWorkspace || !!isLoadingWorkspacePermissionKeys + const canCreateDataset = hasPermission(workspacePermissionKeys, 'dataset.create_and_management') + const canConnectExternalDataset = hasPermission(workspacePermissionKeys, 'dataset.external.connect') + const shouldRedirectToDatasets = !isLoadingAccess + && !!currentWorkspaceId + && ((isDatasetCreatePath(pathname) && !canCreateDataset) + || (isDatasetExternalConnectPath(pathname) && !canConnectExternalDataset)) useEffect(() => { - if (shouldRedirect) - router.replace('/apps') - }, [shouldRedirect, router]) + if (shouldRedirectToDatasets) + router.replace('/datasets') + }, [shouldRedirectToDatasets, router]) - if (isLoadingCurrentWorkspace || !currentWorkspace.id) + if (isLoadingAccess || !currentWorkspaceId) return - if (shouldRedirect) { + if (shouldRedirectToDatasets) { return null } return ( - + {children} diff --git a/web/app/(commonLayout)/marketplace/__tests__/page.spec.tsx b/web/app/(commonLayout)/marketplace/__tests__/page.spec.tsx index ea33cc0ddf1..f0d3eecda30 100644 --- a/web/app/(commonLayout)/marketplace/__tests__/page.spec.tsx +++ b/web/app/(commonLayout)/marketplace/__tests__/page.spec.tsx @@ -1,13 +1,18 @@ +import type { ReactNode } from 'react' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import MarketplacePage from '../page' vi.mock('@/app/components/plugins/marketplace', () => ({ default: ({ showInstallButton }: { showInstallButton?: boolean }) => ( -
Marketplace
+
Marketplace
), })) +vi.mock('@/app/components/plugins/marketplace/marketplace-install-permission-provider', () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, +})) + describe('MarketplacePage', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/(commonLayout)/marketplace/page.tsx b/web/app/(commonLayout)/marketplace/page.tsx index 0d0232ae939..b87761b2bfa 100644 --- a/web/app/(commonLayout)/marketplace/page.tsx +++ b/web/app/(commonLayout)/marketplace/page.tsx @@ -1,5 +1,6 @@ import type { SearchParams } from 'nuqs' import Marketplace from '@/app/components/plugins/marketplace' +import MarketplaceInstallPermissionProvider from '@/app/components/plugins/marketplace/marketplace-install-permission-provider' type MarketplacePageProps = { searchParams?: Promise @@ -10,10 +11,13 @@ const MarketplacePage = ({ }: MarketplacePageProps) => { return (
- + + +
) } diff --git a/web/app/(commonLayout)/plugins/page.tsx b/web/app/(commonLayout)/plugins/page.tsx index f62512295b1..55dd57b7ea2 100644 --- a/web/app/(commonLayout)/plugins/page.tsx +++ b/web/app/(commonLayout)/plugins/page.tsx @@ -20,7 +20,7 @@ const PluginList = async ({ return ( } - marketplace={} + marketplace={} /> ) } diff --git a/web/app/(commonLayout)/role-route-guard.tsx b/web/app/(commonLayout)/role-route-guard.tsx index fc81c091716..90526cbb3ca 100644 --- a/web/app/(commonLayout)/role-route-guard.tsx +++ b/web/app/(commonLayout)/role-route-guard.tsx @@ -1,39 +1,21 @@ 'use client' import type { ReactNode } from 'react' -import { useQuery, useSuspenseQuery } from '@tanstack/react-query' -import Loading from '@/app/components/base/loading' +import { useSuspenseQuery } from '@tanstack/react-query' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { redirect, usePathname } from '@/next/navigation' -import { consoleQuery } from '@/service/client' - -const datasetOperatorRedirectRoutes = ['/', '/apps', '/app', '/deployments', '/snippets', '/roster', '/explore', '/tools', '/integrations'] as const function isPathUnderRoute(pathname: string, route: string) { return pathname === route || pathname.startsWith(`${route}/`) } export function RoleRouteGuard({ children }: { children: ReactNode }) { - const currentWorkspaceRoleQuery = useQuery(consoleQuery.workspaces.current.post.queryOptions({ - select: workspace => workspace.role, - })) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const pathname = usePathname() - const shouldGuardRoute = datasetOperatorRedirectRoutes.some(route => isPathUnderRoute(pathname, route)) - const shouldRedirectDatasetOperator = shouldGuardRoute - && !currentWorkspaceRoleQuery.isPending - && currentWorkspaceRoleQuery.data === 'dataset_operator' const shouldRedirectAppDeploy = isPathUnderRoute(pathname, '/deployments') && !systemFeatures.enable_app_deploy - // Block rendering only for guarded routes to avoid permission flicker. - if (shouldGuardRoute && currentWorkspaceRoleQuery.isPending) - return - if (shouldRedirectAppDeploy) - redirect('/') - - if (shouldRedirectDatasetOperator) - redirect('/datasets') + redirect('/apps') return <>{children} } diff --git a/web/app/(shareLayout)/components/__tests__/authenticated-layout.spec.tsx b/web/app/(shareLayout)/components/__tests__/authenticated-layout.spec.tsx index 26abb993847..9768daf58be 100644 --- a/web/app/(shareLayout)/components/__tests__/authenticated-layout.spec.tsx +++ b/web/app/(shareLayout)/components/__tests__/authenticated-layout.spec.tsx @@ -84,7 +84,7 @@ vi.mock('@/service/use-share', () => ({ useGetWebAppMeta: () => appMetaQueryState, })) -vi.mock('@/service/access-control', () => ({ +vi.mock('@/service/access-control/use-app-access-control', () => ({ useGetUserCanAccessApp: () => userCanAccessAppQueryState, })) diff --git a/web/app/(shareLayout)/components/authenticated-layout.tsx b/web/app/(shareLayout)/components/authenticated-layout.tsx index 9cc7ea76012..ae2466146c3 100644 --- a/web/app/(shareLayout)/components/authenticated-layout.tsx +++ b/web/app/(shareLayout)/components/authenticated-layout.tsx @@ -7,7 +7,7 @@ import AppUnavailable from '@/app/components/base/app-unavailable' import Loading from '@/app/components/base/loading' import { useWebAppStore } from '@/context/web-app-context' import { usePathname, useRouter, useSearchParams } from '@/next/navigation' -import { useGetUserCanAccessApp } from '@/service/access-control' +import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control' import { useGetWebAppInfo, useGetWebAppMeta, useGetWebAppParams } from '@/service/use-share' import { webAppLogout } from '@/service/webapp-auth' diff --git a/web/app/components/access-rules-editor/__tests__/index.spec.tsx b/web/app/components/access-rules-editor/__tests__/index.spec.tsx new file mode 100644 index 00000000000..abb6508afa1 --- /dev/null +++ b/web/app/components/access-rules-editor/__tests__/index.spec.tsx @@ -0,0 +1,290 @@ +import type { AccessPolicyWithBindings, ResourceUserAccessSetting } from '@/models/access-control' +import type { Member } from '@/models/common' +import { fireEvent, render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import AccessRulesEditor from '../index' + +const mockMembers = vi.hoisted(() => ({ + accounts: [] as Member[] | null, + isLoading: false, +})) + +vi.mock('@/service/use-common', () => ({ + useMembers: vi.fn(() => ({ + data: { accounts: mockMembers.accounts }, + isLoading: mockMembers.isLoading, + })), +})) + +const createRule = (resourceType: 'app' | 'dataset'): AccessPolicyWithBindings => ({ + policy: { + id: `${resourceType}-policy-id`, + tenant_id: 'tenant-id', + resource_type: resourceType, + policy_key: `${resourceType}-policy-key`, + name: `${resourceType} policy`, + description: `${resourceType} policy description`, + permission_keys: [], + is_builtin: false, + category: 'global_custom', + created_at: '2026-05-22T00:00:00Z', + updated_at: '2026-05-22T00:00:00Z', + }, + roles: [], + accounts: [], +}) + +const createUserAccessSetting = (): ResourceUserAccessSetting => ({ + account: { + account_id: 'account-1', + account_name: 'Evan', + email: 'evan@example.com', + }, + roles: [{ + id: 'role-1', + type: 'app', + category: 'global_custom', + name: 'Maintainer', + is_builtin: false, + permission_keys: [], + }], + access_policies: [{ + id: 'app-policy-id', + tenant_id: 'tenant-id', + resource_type: 'app', + policy_key: 'app-policy-key', + name: 'Manage', + description: 'Can manage this app', + permission_keys: [], + is_builtin: false, + category: 'global_custom', + }], +}) + +const createDefaultUserAccessSetting = (): ResourceUserAccessSetting => ({ + ...createUserAccessSetting(), + access_policies: [], +}) + +const createMember = (overrides: Partial = {}): Member => ({ + id: 'account-2', + name: 'Mia', + email: 'mia@example.com', + avatar: '', + avatar_url: '', + status: 'active', + role: 'normal', + roles: [], + ...overrides, +} as Member) + +describe('AccessRulesEditor', () => { + beforeEach(() => { + vi.clearAllMocks() + mockMembers.accounts = [] + mockMembers.isLoading = false + }) + + it('should render loading state before empty or row content', () => { + render( + , + ) + + expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument() + expect(screen.queryByText('permission.accessRule.noUserAccessSettings')).not.toBeInTheDocument() + }) + + it('should render empty state when there are no user access settings', () => { + render( + , + ) + + expect(screen.getByText('permission.accessRule.noUserAccessSettings')).toBeInTheDocument() + }) + + it('should disable resource access controls before open scope is available', () => { + render( + , + ) + + expect(screen.getByText('permission.accessRule.resourceOpenScope')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'permission.accessRule.resourceOpenScopeDescription' })).toBeInTheDocument() + + const allMembersButton = screen.getByRole('button', { name: /permission\.accessRule\.allPermittedMembers/ }) + const specificMembersButton = screen.getByRole('button', { name: /permission\.accessRule\.specificMembersOnly/ }) + expect(allMembersButton).toBeDisabled() + expect(specificMembersButton).toBeDisabled() + expect(allMembersButton).toHaveAttribute('aria-pressed', 'false') + expect(specificMembersButton).toHaveAttribute('aria-pressed', 'false') + }) + + it('should render resource access controls and update account exceptions', () => { + const onOpenScopeChange = vi.fn() + const onUserAccessPoliciesChange = vi.fn() + const onRemoveAccessPolicyMemberBinding = vi.fn() + + render( + , + ) + + expect(screen.getByText('permission.accessRule.resourceOpenScope')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /permission\.accessRule\.specificMembersOnly/ })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByText('permission.accessRule.individualPermissionSettings')).toBeInTheDocument() + expect(screen.getByText('Evan')).toBeInTheDocument() + expect(screen.getByText('evan@example.com')).toBeInTheDocument() + expect(screen.queryByText('Maintainer')).not.toBeInTheDocument() + expect(screen.getAllByText('Manage').length).toBeGreaterThan(0) + + fireEvent.click(screen.getByRole('button', { name: /permission\.accessRule\.allPermittedMembers/ })) + expect(onOpenScopeChange).not.toHaveBeenCalled() + expect(screen.getByText('permission.accessRule.changeOpenScopeTitle')).toBeInTheDocument() + expect(screen.getByText('permission.accessRule.changeOpenScopeDescription')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.change' })) + expect(onOpenScopeChange).toHaveBeenCalledWith('all') + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' })) + expect(onRemoveAccessPolicyMemberBinding).toHaveBeenCalledWith('account-1', 'app-policy-id') + }) + + it('should render the fixed default option when an account has no exception policy', () => { + render( + , + ) + + expect(screen.getByText('permission.accessRule.defaultPermission')).toBeInTheDocument() + }) + + it('should mark maintainer rows and prevent editing them', () => { + const onUserAccessPoliciesChange = vi.fn() + const onRemoveAccessPolicyMemberBinding = vi.fn() + + render( + , + ) + + expect(screen.getByText('permission.accessRule.maintainer')).toBeInTheDocument() + expect(screen.getByLabelText(/permission\.accessRule\.exceptionPermissionFor/)).toBeDisabled() + + const removeButton = screen.getByRole('button', { name: 'common.operation.remove' }) + expect(removeButton).toBeDisabled() + + fireEvent.click(removeButton) + expect(onUserAccessPoliciesChange).not.toHaveBeenCalled() + expect(onRemoveAccessPolicyMemberBinding).not.toHaveBeenCalled() + }) + + it('should keep open scope unchanged when the confirmation is cancelled', () => { + const onOpenScopeChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /permission\.accessRule\.allPermittedMembers/ })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + expect(onOpenScopeChange).not.toHaveBeenCalled() + }) + + it('should add unassigned members with default permission', async () => { + const user = userEvent.setup() + const onAddAccessSubject = vi.fn() + mockMembers.accounts = [ + createMember({ + id: 'account-1', + name: 'Evan', + email: 'evan@example.com', + }), + createMember(), + ] + + render( + , + ) + + await user.click(screen.getByRole('button', { name: 'common.operation.add' })) + + const dialog = screen.getByRole('dialog', { name: 'permission.accessRule.addMembersTitle' }) + expect(within(dialog).getByText('Evan')).toBeInTheDocument() + expect(within(dialog).getByRole('button', { name: 'common.operation.added' })).toBeDisabled() + expect(within(dialog).queryByRole('button', { name: 'permission.accessRule.addMemberAria:{"name":"Evan"}' })).not.toBeInTheDocument() + expect(within(dialog).getByText('Mia')).toBeInTheDocument() + expect(within(dialog).queryByRole('tablist')).not.toBeInTheDocument() + + await user.click(within(dialog).getByRole('button', { name: 'permission.accessRule.addMemberAria:{"name":"Mia"}' })) + + expect(onAddAccessSubject).toHaveBeenCalledWith('account-2', ['default']) + }) +}) diff --git a/web/app/components/access-rules-editor/add-access-subject-popover.tsx b/web/app/components/access-rules-editor/add-access-subject-popover.tsx new file mode 100644 index 00000000000..68e26f4b8f2 --- /dev/null +++ b/web/app/components/access-rules-editor/add-access-subject-popover.tsx @@ -0,0 +1,181 @@ +'use client' + +import type { ResourceUserAccessSetting } from '@/models/access-control' +import type { Member } from '@/models/common' +import { Avatar } from '@langgenius/dify-ui/avatar' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { Input } from '@langgenius/dify-ui/input' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' +import { memo, useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Loading from '@/app/components/base/loading' +import { useMembers } from '@/service/use-common' +import { DEFAULT_ACCESS_POLICY_ID } from './constants' + +type AddAccessSubjectPopoverProps = { + userAccessSettings: ResourceUserAccessSetting[] + updatingAccountId: string | null + onAddAccessSubject: (accountId: string, accessPolicyIds: string[]) => void +} + +function AddAccessSubjectPopover({ + userAccessSettings, + updatingAccountId, + onAddAccessSubject, +}: AddAccessSubjectPopoverProps) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [searchValue, setSearchValue] = useState('') + const { data: membersData, isLoading } = useMembers() + const existingAccountIds = useMemo(() => { + return new Set(userAccessSettings.map(setting => setting.account.account_id)) + }, [userAccessSettings]) + const availableMembers = useMemo(() => { + const normalizedSearchValue = searchValue.trim().toLowerCase() + + return (membersData?.accounts ?? []).filter((member) => { + if (!normalizedSearchValue) + return true + + const name = member.name || '' + const email = member.email || '' + return name.toLowerCase().includes(normalizedSearchValue) + || email.toLowerCase().includes(normalizedSearchValue) + }) + }, [membersData?.accounts, searchValue]) + + const handleAddMember = useCallback((member: Member) => { + onAddAccessSubject(member.id, [DEFAULT_ACCESS_POLICY_ID]) + }, [onAddAccessSubject]) + + const handleOpenChange = useCallback((nextOpen: boolean) => { + if (!nextOpen) + setSearchValue('') + + setOpen(nextOpen) + }, []) + + const addLabel = t('operation.add', { ns: 'common' }) + const addedLabel = t('operation.added', { ns: 'common' }) + + return ( + + + + {addLabel} + + )} + /> + +
+
+
+
+ {isLoading + ? ( +
+ +
+ ) + : availableMembers.length === 0 + ? ( +
+ {t('accessRule.noAvailableMembers', { ns: 'permission' })} +
+ ) + : ( +
    + {availableMembers.map((member) => { + const isAdded = existingAccountIds.has(member.id) + const isUpdating = updatingAccountId === member.id + const memberName = member.name || member.email + + return ( +
  • + +
    +
    + {memberName} +
    +
    + {member.email} +
    +
    + {isAdded + ? ( + + ) + : ( + + )} +
  • + ) + })} +
+ )} +
+
+ ) +} + +export default memo(AddAccessSubjectPopover) diff --git a/web/app/components/access-rules-editor/constants.ts b/web/app/components/access-rules-editor/constants.ts new file mode 100644 index 00000000000..2e5fe085136 --- /dev/null +++ b/web/app/components/access-rules-editor/constants.ts @@ -0,0 +1,2 @@ +export const ACCESS_RULE_TABLE_GRID = 'grid-cols-[minmax(0,0.72fr)_minmax(0,1fr)_72px]' +export const DEFAULT_ACCESS_POLICY_ID = 'default' diff --git a/web/app/components/access-rules-editor/index.tsx b/web/app/components/access-rules-editor/index.tsx new file mode 100644 index 00000000000..4029780a721 --- /dev/null +++ b/web/app/components/access-rules-editor/index.tsx @@ -0,0 +1,130 @@ +'use client' + +import type { + AccessPolicyWithBindings, + ResourceOpenScope, + ResourceUserAccessSetting, +} from '@/models/access-control' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import Loading from '@/app/components/base/loading' +import AddAccessSubjectPopover from './add-access-subject-popover' +import { ACCESS_RULE_TABLE_GRID } from './constants' +import ResourceOpenScopeSection from './open-scope-section' +import TitleInfotip from './title-infotip' +import UserAccessPolicyRow from './user-access-policy-row' + +export type AccessRulesEditorProps = { + rules: AccessPolicyWithBindings[] + userAccessSettings: ResourceUserAccessSetting[] + isLoadingRules: boolean + isLoadingUserAccessSettings: boolean + openScope?: ResourceOpenScope + isUpdatingOpenScope: boolean + updatingAccountId: string | null + maintainerId?: string | null + className?: string + onOpenScopeChange?: (openScope: ResourceOpenScope) => void + onUserAccessPoliciesChange?: (accountId: string, accessPolicyIds: string[]) => void + onRemoveAccessPolicyMemberBinding?: (accountId: string, accessPolicyId: string) => void + onAddAccessSubject?: (accountId: string, accessPolicyIds: string[]) => void +} + +export default function AccessRulesEditor({ + rules, + userAccessSettings, + isLoadingRules, + isLoadingUserAccessSettings, + openScope, + isUpdatingOpenScope, + updatingAccountId, + maintainerId, + className, + onOpenScopeChange, + onUserAccessPoliciesChange, + onRemoveAccessPolicyMemberBinding, + onAddAccessSubject, +}: AccessRulesEditorProps) { + const { t } = useTranslation() + const isLoading = isLoadingRules || isLoadingUserAccessSettings + const individualPermissionSettingsTip = t('accessRule.individualPermissionSettingsTip', { ns: 'permission' }) + const policyOptions = useMemo(() => { + return rules.map(rule => ({ + id: rule.policy.id, + name: rule.policy.name, + })) + }, [rules]) + + return ( +
+ +
+
+

+ {t('accessRule.individualPermissionSettings', { ns: 'permission' })} +

+ +
+ {onAddAccessSubject + ? ( + + ) + : ( + + )} +
+
+
+
{t('accessRule.member', { ns: 'permission' })}
+
{t('accessRule.permission', { ns: 'permission' })}
+
{t('accessRule.actions', { ns: 'permission' })}
+
+ {isLoading + ? ( +
+ +
+ ) + : userAccessSettings.length === 0 + ? ( +
+ {t('accessRule.noUserAccessSettings', { ns: 'permission' })} +
+ ) + : ( +
+ {userAccessSettings.map((setting, index) => ( + 0 && 'border-t border-divider-subtle')} + onChange={onUserAccessPoliciesChange} + onRemove={onRemoveAccessPolicyMemberBinding} + /> + ))} +
+ )} +
+
+ ) +} diff --git a/web/app/components/access-rules-editor/open-scope-confirm-dialog.tsx b/web/app/components/access-rules-editor/open-scope-confirm-dialog.tsx new file mode 100644 index 00000000000..bfdb9a9e12f --- /dev/null +++ b/web/app/components/access-rules-editor/open-scope-confirm-dialog.tsx @@ -0,0 +1,59 @@ +'use client' + +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' +import { memo, useCallback } from 'react' +import { useTranslation } from 'react-i18next' + +type OpenScopeConfirmDialogProps = { + open: boolean + onCancel: () => void + onConfirm: () => void +} + +function OpenScopeConfirmDialog({ + open, + onCancel, + onConfirm, +}: OpenScopeConfirmDialogProps) { + const { t } = useTranslation() + const handleOpenChange = useCallback((nextOpen: boolean) => { + if (!nextOpen) + onCancel() + }, [onCancel]) + + return ( + + +
+ + {t('accessRule.changeOpenScopeTitle', { ns: 'permission' })} + + } className="system-md-regular text-text-secondary"> + {t('accessRule.changeOpenScopeDescription', { ns: 'permission' })} + +
+ + + {t('operation.cancel', { ns: 'common' })} + + + {t('operation.change', { ns: 'common' })} + + +
+
+ ) +} + +export default memo(OpenScopeConfirmDialog) diff --git a/web/app/components/access-rules-editor/open-scope-option.tsx b/web/app/components/access-rules-editor/open-scope-option.tsx new file mode 100644 index 00000000000..fb2fc20943e --- /dev/null +++ b/web/app/components/access-rules-editor/open-scope-option.tsx @@ -0,0 +1,45 @@ +'use client' + +import type { ResourceOpenScope } from '@/models/access-control' +import { cn } from '@langgenius/dify-ui/cn' +import { memo } from 'react' + +type OpenScopeOptionProps = { + value: ResourceOpenScope + selected: boolean + disabled: boolean + title: string + description: string + onChange?: (openScope: ResourceOpenScope) => void +} + +function OpenScopeOption({ + value, + selected, + disabled, + title, + description, + onChange, +}: OpenScopeOptionProps) { + return ( + + ) +} + +export default memo(OpenScopeOption) diff --git a/web/app/components/access-rules-editor/open-scope-section.tsx b/web/app/components/access-rules-editor/open-scope-section.tsx new file mode 100644 index 00000000000..62cabc40ccb --- /dev/null +++ b/web/app/components/access-rules-editor/open-scope-section.tsx @@ -0,0 +1,79 @@ +'use client' + +import type { ResourceOpenScope } from '@/models/access-control' +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OpenScopeConfirmDialog from './open-scope-confirm-dialog' +import OpenScopeOption from './open-scope-option' +import TitleInfotip from './title-infotip' + +type ResourceOpenScopeSectionProps = { + value?: ResourceOpenScope + disabled: boolean + onChange?: (openScope: ResourceOpenScope) => void +} + +function ResourceOpenScopeSection({ + value, + disabled, + onChange, +}: ResourceOpenScopeSectionProps) { + const { t } = useTranslation() + const [pendingOpenScope, setPendingOpenScope] = useState(null) + const resourceOpenScopeDescription = t('accessRule.resourceOpenScopeDescription', { ns: 'permission' }) + + const handleRequestChange = useCallback((nextOpenScope: ResourceOpenScope) => { + if (nextOpenScope === value) + return + + setPendingOpenScope(nextOpenScope) + }, [value]) + + const handleCancelChange = useCallback(() => { + setPendingOpenScope(null) + }, []) + + const handleConfirmChange = useCallback(() => { + if (!pendingOpenScope) + return + + onChange?.(pendingOpenScope) + setPendingOpenScope(null) + }, [onChange, pendingOpenScope]) + + return ( +
+
+

+ {t('accessRule.resourceOpenScope', { ns: 'permission' })} +

+ +
+
+ + +
+ +
+ ) +} + +export default memo(ResourceOpenScopeSection) diff --git a/web/app/components/access-rules-editor/title-infotip.tsx b/web/app/components/access-rules-editor/title-infotip.tsx new file mode 100644 index 00000000000..d0b150c2dc6 --- /dev/null +++ b/web/app/components/access-rules-editor/title-infotip.tsx @@ -0,0 +1,14 @@ +'use client' + +import { Infotip } from '@/app/components/base/infotip' + +export default function TitleInfotip({ content }: { content: string }) { + return ( + + {content} + + ) +} diff --git a/web/app/components/access-rules-editor/user-access-policy-row.tsx b/web/app/components/access-rules-editor/user-access-policy-row.tsx new file mode 100644 index 00000000000..4c3e9d7100c --- /dev/null +++ b/web/app/components/access-rules-editor/user-access-policy-row.tsx @@ -0,0 +1,133 @@ +'use client' + +import type { ResourceUserAccessSetting } from '@/models/access-control' +import { Avatar } from '@langgenius/dify-ui/avatar' +import { cn } from '@langgenius/dify-ui/cn' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, + SelectValue, +} from '@langgenius/dify-ui/select' +import { memo, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { ACCESS_RULE_TABLE_GRID, DEFAULT_ACCESS_POLICY_ID } from './constants' + +type PolicyOption = { + id: string + name: string +} + +type UserAccessPolicyRowProps = { + setting: ResourceUserAccessSetting + policyOptions: PolicyOption[] + disabled: boolean + isMaintainer?: boolean + className?: string + onChange?: (accountId: string, accessPolicyIds: string[]) => void + onRemove?: (accountId: string, accessPolicyId: string) => void +} + +function UserAccessPolicyRow({ + setting, + policyOptions, + disabled, + isMaintainer = false, + className, + onChange, + onRemove, +}: UserAccessPolicyRowProps) { + const { t } = useTranslation() + const accountId = setting.account.account_id + const selectedPolicy = setting.access_policies[0] + const selectedAccessPolicyId = selectedPolicy?.id + const selectedPolicyId = selectedAccessPolicyId ?? DEFAULT_ACCESS_POLICY_ID + const isPolicySelectDisabled = disabled || isMaintainer || !onChange + const isRemoveDisabled = disabled || isMaintainer || !onRemove || !selectedAccessPolicyId + const defaultAccessPolicyName = t('accessRule.defaultPermission', { ns: 'permission' }) + const accountEmail = setting.account.email || setting.account.account_name + + const handlePolicyChange = useCallback((nextPolicyId: string | null) => { + if (isPolicySelectDisabled || !nextPolicyId || nextPolicyId === selectedPolicyId) + return + + onChange?.(accountId, [nextPolicyId]) + }, [accountId, isPolicySelectDisabled, onChange, selectedPolicyId]) + + const handleRemove = useCallback(() => { + if (isRemoveDisabled || !selectedAccessPolicyId) + return + + onRemove?.(accountId, selectedAccessPolicyId) + }, [accountId, isRemoveDisabled, onRemove, selectedAccessPolicyId]) + + return ( +
+
+ +
+
+ + {setting.account.account_name} + + {isMaintainer && ( + + {t('accessRule.maintainer', { ns: 'permission' })} + + )} +
+

+ {accountEmail} +

+
+
+ + +
+ ) +} + +export default memo(UserAccessPolicyRow) diff --git a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx index 19d7dd5a436..6dc9f5dfb19 100644 --- a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx @@ -1,10 +1,11 @@ import { render, screen } from '@testing-library/react' +import { AppACLPermission } from '@/utils/permission' import AppDetailSection from '../app-detail-section' import { useAppInfoActions } from '../app-info/use-app-info-actions' let mockAppMode = 'chat' -let mockIsCurrentWorkspaceEditor = true let mockPathname = '/app/app-1/logs' +let mockAppPermissionKeys: string[] = [] vi.mock('@/app/components/app/store', () => ({ useStore: (selector: (state: Record) => unknown) => selector({ @@ -15,13 +16,15 @@ vi.mock('@/app/components/app/store', () => ({ icon: '🤖', icon_type: 'emoji', icon_background: '#fff', + permission_keys: mockAppPermissionKeys, }, }), })) vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ - isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor, + userProfile: { id: 'user-1' }, + workspacePermissionKeys: [], }), })) @@ -51,13 +54,13 @@ describe('AppDetailSection', () => { beforeEach(() => { vi.clearAllMocks() mockAppMode = 'chat' - mockIsCurrentWorkspaceEditor = true mockPathname = '/app/app-1/logs' + mockAppPermissionKeys = [AppACLPermission.Monitor] }) // Rendering behavior for app detail navigation entries. describe('Rendering', () => { - it('should split logs and annotations into separate navigation links for chat apps', () => { + it('should render logs and overview for chat apps with app monitor permission', () => { // Arrange mockAppMode = 'chat' @@ -66,13 +69,28 @@ describe('AppDetailSection', () => { // Assert expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute('href', '/app/app-1/logs') + expect(screen.getByRole('link', { name: 'common.appMenus.overview' })).toHaveAttribute('href', '/app/app-1/overview') + expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument() + }) + + it('should render annotations for chat apps with app edit permission', () => { + // Arrange + mockAppMode = 'chat' + mockAppPermissionKeys = [AppACLPermission.Edit] + + // Act + render() + + // Assert expect(screen.getByRole('link', { name: 'common.appMenus.annotations' })).toHaveAttribute('href', '/app/app-1/annotations') expect(screen.getByRole('link', { name: 'common.appMenus.annotations' })).toHaveAttribute('data-icon', 'Annotations') + expect(screen.queryByRole('link', { name: 'common.appMenus.overview' })).not.toBeInTheDocument() }) it('should render dividers before logs and after annotations for chat apps', () => { // Arrange mockAppMode = 'chat' + mockAppPermissionKeys = [AppACLPermission.Monitor, AppACLPermission.Edit] // Act render() @@ -116,9 +134,9 @@ describe('AppDetailSection', () => { expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument() }) - it('should not render log group dividers for non-editor users', () => { + it('should not render monitor group dividers without monitor or edit permission', () => { // Arrange - mockIsCurrentWorkspaceEditor = false + mockAppPermissionKeys = [] // Act render() @@ -127,6 +145,62 @@ describe('AppDetailSection', () => { expect(screen.queryAllByRole('separator')).toHaveLength(0) expect(screen.queryByRole('link', { name: 'common.appMenus.logs' })).not.toBeInTheDocument() expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'common.appMenus.overview' })).not.toBeInTheDocument() + }) + + it('should render logs for users with app monitor permission', () => { + // Arrange + mockAppPermissionKeys = [AppACLPermission.Monitor] + + // Act + render() + + // Assert + expect(screen.getByRole('link', { name: 'common.appMenus.logs' })).toHaveAttribute('href', '/app/app-1/logs') + expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument() + expect(screen.getAllByRole('separator')).toHaveLength(2) + }) + + it('should render the layout navigation for users with view layout permission', () => { + // Arrange + mockAppPermissionKeys = [AppACLPermission.ViewLayout] + + // Act + render() + + // Assert + expect(screen.getByRole('link', { name: 'common.appMenus.promptEng' })).toHaveAttribute('href', '/app/app-1/configuration') + expect(screen.queryByRole('link', { name: 'common.appMenus.logs' })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'common.appMenus.annotations' })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'common.appMenus.overview' })).not.toBeInTheDocument() + }) + + it('should hide the layout navigation when layout access is missing', () => { + // Act + render() + + // Assert + expect(screen.queryByRole('link', { name: 'common.appMenus.promptEng' })).not.toBeInTheDocument() + }) + + it('should render resource access navigation when app access config permission is granted', () => { + // Arrange + mockAppPermissionKeys = [AppACLPermission.AccessConfig] + + // Act + render() + + // Assert + expect(screen.getByRole('link', { name: 'common.settings.resourceAccess' })).toHaveAttribute('href', '/app/app-1/access-config') + expect(screen.queryByRole('link', { name: 'common.appMenus.overview' })).not.toBeInTheDocument() + }) + + it('should hide resource access navigation when app access config permission is missing', () => { + // Act + render() + + // Assert + expect(screen.queryByRole('link', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument() }) it('should pass collapsed mode to app info and navigation links when collapsed', () => { diff --git a/web/app/components/app-sidebar/__tests__/app-sidebar-dropdown.spec.tsx b/web/app/components/app-sidebar/__tests__/app-sidebar-dropdown.spec.tsx index 5557118c784..98f4d7b672c 100644 --- a/web/app/components/app-sidebar/__tests__/app-sidebar-dropdown.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/app-sidebar-dropdown.spec.tsx @@ -13,12 +13,6 @@ vi.mock('@/app/components/app/store', () => ({ }), })) -vi.mock('@/context/app-context', () => ({ - useAppContext: () => ({ - isCurrentWorkspaceEditor: true, - }), -})) - vi.mock('@langgenius/dify-ui/dropdown-menu', () => { const DropdownMenuContext = React.createContext<{ isOpen: boolean, setOpen: (open: boolean) => void } | null>(null) @@ -175,9 +169,9 @@ describe('AppSidebarDropdown', () => { render() const appName = screen.getByText('Test App') - const appInfoArea = appName.closest('[class*="cursor-pointer"]') - if (appInfoArea) - await user.click(appInfoArea) + await user.click(appName) + + expect(screen.getByTestId('app-info')).toHaveAttribute('data-open', 'true') }) it('should display workflow mode label', () => { diff --git a/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx b/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx index ac02bb1f4e8..25763fb11ae 100644 --- a/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx @@ -1,9 +1,9 @@ import type { DataSet, RelatedAppResponse } from '@/models/datasets' import { render, screen } from '@testing-library/react' +import { DatasetACLPermission } from '@/utils/permission' import DatasetDetailSection from '../dataset-detail-section' let mockPathname = '/datasets/dataset-1/documents' -let mockIsDatasetOperator = false let mockDataset: DataSet | undefined let mockRelatedApps: RelatedAppResponse | undefined @@ -13,7 +13,8 @@ vi.mock('@/next/navigation', () => ({ vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ - isCurrentWorkspaceDatasetOperator: mockIsDatasetOperator, + userProfile: { id: 'user-1' }, + workspacePermissionKeys: [], }), })) @@ -27,7 +28,12 @@ vi.mock('../dataset-info', () => ({ })) vi.mock('../nav-link', () => ({ - default: ({ name, href }: { name: string, href: string }) => {name}, + default: ({ name, href, disabled }: { name: string, href: string, disabled?: boolean }) => { + if (disabled) + return + + return {name} + }, })) vi.mock('../../datasets/extra-info', () => ({ @@ -63,6 +69,7 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ score_threshold: 0, }, enable_api: true, + permission_keys: [DatasetACLPermission.Edit], ...overrides, } as DataSet) @@ -70,7 +77,6 @@ describe('DatasetDetailSection', () => { beforeEach(() => { vi.clearAllMocks() mockPathname = '/datasets/dataset-1/documents' - mockIsDatasetOperator = false mockDataset = createDataset() mockRelatedApps = { data: [], @@ -87,4 +93,47 @@ describe('DatasetDetailSection', () => { expect(extraInfo).toHaveAttribute('data-document-count', '120') expect(extraInfo.parentElement).toHaveClass('mt-auto', 'shrink-0') }) + + it('should hide dataset stats and API access when dataset edit permission is missing', () => { + mockDataset = createDataset({ + permission_keys: [DatasetACLPermission.Readonly], + }) + + render() + + expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument() + }) + + it('should render resource access navigation when dataset access config permission is granted', () => { + mockDataset = createDataset({ + permission_keys: [DatasetACLPermission.AccessConfig], + }) + + render() + + expect(screen.getByRole('link', { name: 'common.settings.resourceAccess' })).toHaveAttribute('href', '/datasets/dataset-1/access-config') + }) + + it('should hide resource access navigation when dataset access config permission is missing', () => { + render() + + expect(screen.queryByRole('link', { name: 'common.settings.resourceAccess' })).not.toBeInTheDocument() + }) + + it('should render hit testing navigation as a link when retrieval recall permission is granted', () => { + mockDataset = createDataset({ + permission_keys: [DatasetACLPermission.RetrievalRecall], + }) + + render() + + expect(screen.getByRole('link', { name: 'common.datasetMenus.hitTesting' })).toHaveAttribute('href', '/datasets/dataset-1/hitTesting') + }) + + it('should disable hit testing navigation when retrieval recall permission is missing', () => { + render() + + expect(screen.getByRole('button', { name: 'common.datasetMenus.hitTesting' })).toBeDisabled() + expect(screen.queryByRole('link', { name: 'common.datasetMenus.hitTesting' })).not.toBeInTheDocument() + }) }) diff --git a/web/app/components/app-sidebar/__tests__/index.spec.tsx b/web/app/components/app-sidebar/__tests__/index.spec.tsx index 6aefcca8c33..cf3537a1464 100644 --- a/web/app/components/app-sidebar/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/index.spec.tsx @@ -110,7 +110,7 @@ describe('AppDetailNav', () => { it('should apply expanded width class', () => { const { container } = render() const sidebar = container.firstElementChild as HTMLElement - expect(sidebar).toHaveClass('w-[216px]') + expect(sidebar).toHaveClass('w-55') }) it('should apply collapsed width class', () => { diff --git a/web/app/components/app-sidebar/app-detail-section.tsx b/web/app/components/app-sidebar/app-detail-section.tsx index 9d77e17df76..5df0a4ef573 100644 --- a/web/app/components/app-sidebar/app-detail-section.tsx +++ b/web/app/components/app-sidebar/app-detail-section.tsx @@ -8,6 +8,8 @@ import { RiDashboard2Line, RiFileList3Fill, RiFileList3Line, + RiLock2Fill, + RiLock2Line, RiTerminalBoxFill, RiTerminalBoxLine, RiTerminalWindowFill, @@ -21,6 +23,7 @@ import Annotations from '@/app/components/base/icons/src/vender/Annotations' import { useAppContext } from '@/context/app-context' import { usePathname } from '@/next/navigation' import { AppModeEnum } from '@/types/app' +import { getAppACLCapabilities } from '@/utils/permission' import { AppInfoView } from './app-info' import { useAppInfoActions } from './app-info/use-app-info-actions' import NavLink from './nav-link' @@ -68,7 +71,7 @@ const AppDetailSection = ({ }: AppDetailSectionProps) => { const { t } = useTranslation() const pathname = usePathname() - const { isCurrentWorkspaceEditor } = useAppContext() + const { userProfile, workspacePermissionKeys } = useAppContext() const appDetail = useStore(state => state.appDetail) const appInfoActions = useAppInfoActions({ resetKey: appDetail?.id, @@ -81,9 +84,14 @@ const AppDetailSection = ({ const appId = appDetail.id const isWorkflowApp = appDetail.mode === AppModeEnum.WORKFLOW || appDetail.mode === AppModeEnum.ADVANCED_CHAT const supportsAnnotations = appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.COMPLETION + const appACLCapabilities = getAppACLCapabilities(appDetail.permission_keys, { + currentUserId: userProfile?.id, + resourceMaintainer: appDetail.maintainer, + workspacePermissionKeys, + }) return [ - ...(isCurrentWorkspaceEditor + ...(appACLCapabilities.canAccessLayout ? [{ name: t('appMenus.promptEng', { ns: 'common' }), href: `/app/${appId}/${isWorkflowApp ? 'workflow' : 'configuration'}`, @@ -98,34 +106,49 @@ const AppDetailSection = ({ icon: RiTerminalBoxLine, selectedIcon: RiTerminalBoxFill, }, - ...(isCurrentWorkspaceEditor + ...(appACLCapabilities.canMonitor ? [{ name: t('appMenus.logs', { ns: 'common' }), href: `/app/${appId}/logs`, icon: RiFileList3Line, selectedIcon: RiFileList3Fill, - }, ...(supportsAnnotations - ? [{ - name: t('appMenus.annotations', { ns: 'common' }), - href: `/app/${appId}/annotations`, - icon: AnnotationNavIcon, - selectedIcon: AnnotationNavIcon, - }] - : [])] + }] + : [] + ), + ...(appACLCapabilities.canEdit && supportsAnnotations + ? [{ + name: t('appMenus.annotations', { ns: 'common' }), + href: `/app/${appId}/annotations`, + icon: AnnotationNavIcon, + selectedIcon: AnnotationNavIcon, + }] + : [] + ), + ...(appACLCapabilities.canMonitor + ? [{ + name: t('appMenus.overview', { ns: 'common' }), + href: `/app/${appId}/overview`, + icon: RiDashboard2Line, + selectedIcon: RiDashboard2Fill, + }] + : [] + ), + ...(appACLCapabilities.canAccessConfig + ? [{ + name: t('settings.resourceAccess', { ns: 'common' }), + href: `/app/${appId}/access-config`, + icon: RiLock2Line, + selectedIcon: RiLock2Fill, + }] : [] ), - { - name: t('appMenus.overview', { ns: 'common' }), - href: `/app/${appId}/overview`, - icon: RiDashboard2Line, - selectedIcon: RiDashboard2Fill, - }, ] - }, [appDetail, isCurrentWorkspaceEditor, t]) + }, [appDetail, t, userProfile?.id, workspacePermissionKeys]) if (!appDetail) return null + const hasLogsNavigation = navigation.some(isLogsNavItem) const hasAnnotationsNavigation = navigation.some(isAnnotationsNavItem) return ( @@ -147,7 +170,7 @@ const AppDetailSection = ({ - {!isCurrentWorkspaceDatasetOperator && ( + {datasetACLCapabilities.canEdit && (
= {}): DataSet => ({ runtime_mode: 'rag_pipeline', enable_api: false, is_multimodal: false, + permission_keys: [ + DatasetACLPermission.Edit, + DatasetACLPermission.Delete, + DatasetACLPermission.ImportExportDSL, + ], ...overrides, }) @@ -89,11 +94,6 @@ vi.mock('@/context/dataset-detail', () => ({ useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => selector({ dataset: mockDataset }), })) -vi.mock('@/context/app-context', () => ({ - useSelector: (selector: (state: { isCurrentWorkspaceDatasetOperator: boolean }) => unknown) => - selector({ isCurrentWorkspaceDatasetOperator: mockIsDatasetOperator }), -})) - vi.mock('@/service/knowledge/use-dataset', () => ({ datasetDetailQueryKeyPrefix: ['dataset', 'detail'], useInvalidDatasetList: () => mockInvalidDatasetList, @@ -143,7 +143,6 @@ describe('Dropdown callback coverage', () => { beforeEach(() => { vi.clearAllMocks() mockDataset = createDataset({ pipeline_id: 'pipeline-1', runtime_mode: 'rag_pipeline' }) - mockIsDatasetOperator = false mockExportPipeline.mockResolvedValue({ data: 'pipeline-content' }) mockCheckIsUsedInApp.mockResolvedValue({ is_using: false }) mockDeleteDataset.mockResolvedValue({}) diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx index e1c4aa7b2b7..63e05435c99 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx @@ -8,13 +8,14 @@ import { DataSourceType, } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' +import { DatasetACLPermission } from '@/utils/permission' import DatasetInfo from '..' import Dropdown from '../dropdown' import Menu from '../menu' import MenuItem from '../menu-item' let mockDataset: DataSet -let mockIsDatasetOperator = false +const mockPush = vi.fn() const mockReplace = vi.fn() const mockInvalidDatasetList = vi.fn() const mockInvalidDatasetDetail = vi.fn() @@ -87,11 +88,17 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ runtime_mode: 'rag_pipeline', enable_api: false, is_multimodal: false, + permission_keys: [ + DatasetACLPermission.Edit, + DatasetACLPermission.Delete, + DatasetACLPermission.ImportExportDSL, + ], ...overrides, }) vi.mock('@/next/navigation', () => ({ useRouter: () => ({ + push: mockPush, replace: mockReplace, }), })) @@ -100,11 +107,6 @@ vi.mock('@/context/dataset-detail', () => ({ useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => selector({ dataset: mockDataset }), })) -vi.mock('@/context/app-context', () => ({ - useSelector: (selector: (state: { isCurrentWorkspaceDatasetOperator: boolean }) => unknown) => - selector({ isCurrentWorkspaceDatasetOperator: mockIsDatasetOperator }), -})) - vi.mock('@/service/knowledge/use-dataset', () => ({ datasetDetailQueryKeyPrefix: ['dataset', 'detail'], useInvalidDatasetList: () => mockInvalidDatasetList, @@ -161,7 +163,6 @@ describe('DatasetInfo', () => { beforeEach(() => { vi.clearAllMocks() mockDataset = createDataset() - mockIsDatasetOperator = false }) // Rendering of dataset summary details based on expand and dataset state. @@ -273,6 +274,21 @@ describe('Menu', () => { expect(screen.getByText('common.operation.delete')).toBeInTheDocument() }) + it('should show resource access option when enabled', () => { + render( + , + ) + + expect(screen.getByText('common.settings.resourceAccess')).toBeInTheDocument() + }) + it('should hide export and delete options when not rag pipeline and not deletable', () => { // Arrange mockDataset = createDataset({ runtime_mode: 'general' }) @@ -331,6 +347,26 @@ describe('Menu', () => { expect(handleExportPipeline).toHaveBeenCalledTimes(1) expect(detectIsUsedByApp).toHaveBeenCalledTimes(1) }) + + it('should invoke access config callback from its menu item', async () => { + const user = userEvent.setup() + const openAccessConfig = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('common.settings.resourceAccess')) + + expect(openAccessConfig).toHaveBeenCalledTimes(1) + }) }) }) @@ -338,7 +374,6 @@ describe('Dropdown', () => { beforeEach(() => { vi.clearAllMocks() mockDataset = createDataset({ pipeline_id: 'pipeline-1', runtime_mode: 'rag_pipeline' }) - mockIsDatasetOperator = false mockExportPipeline.mockResolvedValue({ data: 'pipeline-content' }) mockCheckIsUsedInApp.mockResolvedValue({ is_using: false }) mockDeleteDataset.mockResolvedValue({}) @@ -356,12 +391,19 @@ describe('Dropdown', () => { } }) - // Rendering behavior based on workspace role. + // Rendering behavior based on dataset ACL permission keys. describe('Rendering', () => { - it('should hide delete option when user is dataset operator', async () => { + it('should hide delete option when dataset lacks delete ACL permission', async () => { const user = userEvent.setup() // Arrange - mockIsDatasetOperator = true + mockDataset = createDataset({ + pipeline_id: 'pipeline-1', + runtime_mode: 'rag_pipeline', + permission_keys: [ + DatasetACLPermission.Edit, + DatasetACLPermission.ImportExportDSL, + ], + }) render() // Act @@ -370,6 +412,24 @@ describe('Dropdown', () => { // Assert expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() }) + + it('should show resource access option when dataset only has access config ACL permission', async () => { + const user = userEvent.setup() + // Arrange + mockDataset = createDataset({ + runtime_mode: 'general', + permission_keys: [DatasetACLPermission.AccessConfig], + }) + render() + + // Act + await openMenu(user) + + // Assert + expect(screen.getByText('common.settings.resourceAccess')).toBeInTheDocument() + expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument() + expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() + }) }) // User interactions that trigger modals and exports. @@ -441,5 +501,22 @@ describe('Dropdown', () => { expect(mockInvalidDatasetList).toHaveBeenCalledTimes(1) expect(mockReplace).toHaveBeenCalledWith('/datasets') }) + + it('should navigate to dataset access config when resource access is clicked', async () => { + const user = userEvent.setup() + // Arrange + mockDataset = createDataset({ + runtime_mode: 'general', + permission_keys: [DatasetACLPermission.AccessConfig], + }) + render() + + // Act + await openMenu(user) + await user.click(screen.getByText('common.settings.resourceAccess')) + + // Assert + expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/access-config') + }) }) }) diff --git a/web/app/components/app-sidebar/dataset-info/dropdown.tsx b/web/app/components/app-sidebar/dataset-info/dropdown.tsx index 8d37fadb656..6539e6e1715 100644 --- a/web/app/components/app-sidebar/dataset-info/dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-info/dropdown.tsx @@ -26,6 +26,7 @@ import { datasetDetailQueryKeyPrefix, useInvalidDatasetList } from '@/service/kn import { useInvalid } from '@/service/use-base' import { useExportPipelineDSL } from '@/service/use-pipeline' import { downloadBlob } from '@/utils/download' +import { getDatasetACLCapabilities } from '@/utils/permission' import ActionButton from '../../base/action-button' import RenameDatasetModal from '../../datasets/rename-modal' import Menu from './menu' @@ -59,14 +60,24 @@ const DropDown = ({ triggerClassName, }: DropDownProps) => { const { t } = useTranslation() - const { replace } = useRouter() + const { push, replace } = useRouter() const [open, setOpen] = useState(false) const [showRenameModal, setShowRenameModal] = useState(false) const [confirmMessage, setConfirmMessage] = useState('') const [showConfirmDelete, setShowConfirmDelete] = useState(false) - const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator) const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet + const currentUserId = useAppContextWithSelector(state => state.userProfile?.id) + const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(dataset?.permission_keys, { + currentUserId, + resourceMaintainer: dataset?.maintainer, + workspacePermissionKeys, + }), [dataset?.maintainer, dataset?.permission_keys, currentUserId, workspacePermissionKeys]) + const canShowOperations = datasetACLCapabilities.canEdit + || datasetACLCapabilities.canImportExportDSL + || datasetACLCapabilities.canAccessConfig + || datasetACLCapabilities.canDelete const invalidDatasetList = useInvalidDatasetList() const invalidDatasetDetail = useInvalid([...datasetDetailQueryKeyPrefix, dataset.id]) @@ -115,6 +126,11 @@ const DropDown = ({ } }, [dataset.id, t]) + const openAccessConfig = useCallback(() => { + setOpen(false) + push(`/datasets/${dataset.id}/access-config`) + }, [dataset.id, push]) + const onConfirmDelete = useCallback(async () => { try { await deleteDataset(dataset.id) @@ -127,6 +143,9 @@ const DropDown = ({ } }, [dataset.id, replace, invalidDatasetList, t]) + if (!canShowOperations) + return null + return ( {showRenameModal && ( diff --git a/web/app/components/app-sidebar/dataset-info/menu.tsx b/web/app/components/app-sidebar/dataset-info/menu.tsx index 8192bbda42c..ae8bd2ec270 100644 --- a/web/app/components/app-sidebar/dataset-info/menu.tsx +++ b/web/app/components/app-sidebar/dataset-info/menu.tsx @@ -1,4 +1,9 @@ -import { RiDeleteBinLine, RiEditLine, RiFileDownloadLine } from '@remixicon/react' +import { + RiDeleteBinLine, + RiEditLine, + RiFileDownloadLine, + RiLock2Line, +} from '@remixicon/react' import * as React from 'react' import { useTranslation } from 'react-i18next' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' @@ -6,17 +11,25 @@ import Divider from '../../base/divider' import MenuItem from './menu-item' type MenuProps = { + showEdit?: boolean showDelete: boolean + showExportPipeline?: boolean + showAccessConfig?: boolean openRenameModal: () => void handleExportPipeline: () => void detectIsUsedByApp: () => void + openAccessConfig?: () => void } const Menu = ({ + showEdit = true, showDelete, + showExportPipeline = true, + showAccessConfig = false, openRenameModal, handleExportPipeline, detectIsUsedByApp, + openAccessConfig, }: MenuProps) => { const { t } = useTranslation() const runtimeMode = useDatasetDetailContextWithSelector(state => state.dataset?.runtime_mode) @@ -24,18 +37,27 @@ const Menu = ({ return (
- - {runtimeMode === 'rag_pipeline' && ( + {showEdit && ( + + )} + {showExportPipeline && runtimeMode === 'rag_pipeline' && ( )} + {showAccessConfig && ( + + )}
{showDelete && ( <> diff --git a/web/app/components/app-sidebar/index.tsx b/web/app/components/app-sidebar/index.tsx index 22ae25ddb0a..3e4fb51ee21 100644 --- a/web/app/components/app-sidebar/index.tsx +++ b/web/app/components/app-sidebar/index.tsx @@ -71,7 +71,7 @@ const AppDetailNav = ({ ref={sidebarRef} className={cn( 'flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all', - expand ? 'w-[216px]' : 'w-14', + expand ? 'w-55' : 'w-14', )} >
void) | undefined +vi.mock('@/context/app-context', () => ({ + useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => T): T => selector({ + workspacePermissionKeys: mockWorkspacePermissionKeys, + }), +})) + vi.mock('@/next/navigation', () => ({ useRouter: () => ({ replace: mockReplace, @@ -156,6 +163,7 @@ const mockSnippet: SnippetDetail = { describe('SnippetInfoDropdown', () => { beforeEach(() => { vi.clearAllMocks() + mockWorkspacePermissionKeys = ['snippets.create_and_modify', 'snippets.management'] mockDropdownOpen = false mockDropdownOnOpenChange = undefined }) @@ -167,6 +175,35 @@ describe('SnippetInfoDropdown', () => { expect(screen.getByRole('button')).toBeInTheDocument() }) + + it('should render nothing without snippet create or management permission', () => { + mockWorkspacePermissionKeys = [] + + render() + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('should split edit from export and delete actions by snippet permission', async () => { + const user = userEvent.setup() + mockWorkspacePermissionKeys = ['snippets.create_and_modify'] + + const { unmount } = render() + await user.click(screen.getByRole('button')) + + expect(screen.getByText('snippet.menu.editInfo')).toBeInTheDocument() + expect(screen.queryByText('snippet.menu.exportSnippet')).not.toBeInTheDocument() + expect(screen.queryByText('snippet.menu.deleteSnippet')).not.toBeInTheDocument() + + unmount() + mockWorkspacePermissionKeys = ['snippets.management'] + render() + await user.click(screen.getByRole('button')) + + expect(screen.queryByText('snippet.menu.editInfo')).not.toBeInTheDocument() + expect(screen.getByText('snippet.menu.exportSnippet')).toBeInTheDocument() + expect(screen.getByText('snippet.menu.deleteSnippet')).toBeInTheDocument() + }) }) // Edit flow should seed the dialog with current snippet info and submit updates. @@ -207,6 +244,7 @@ describe('SnippetInfoDropdown', () => { describe('Export Snippet', () => { it('should export and download the snippet yaml', async () => { const user = userEvent.setup() + mockWorkspacePermissionKeys = ['snippets.management'] mockExportMutateAsync.mockResolvedValue('yaml: content') render() @@ -226,6 +264,7 @@ describe('SnippetInfoDropdown', () => { it('should show an error toast when export fails', async () => { const user = userEvent.setup() + mockWorkspacePermissionKeys = ['snippets.management'] mockExportMutateAsync.mockRejectedValue(new Error('export failed')) render() diff --git a/web/app/components/app-sidebar/snippet-info/dropdown.tsx b/web/app/components/app-sidebar/snippet-info/dropdown.tsx index eb108fd2005..00400019f92 100644 --- a/web/app/components/app-sidebar/snippet-info/dropdown.tsx +++ b/web/app/components/app-sidebar/snippet-info/dropdown.tsx @@ -22,6 +22,8 @@ import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useTranslation } from 'react-i18next' import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog' +import { canCreateAndModifySnippets, canManageSnippets } from '@/app/components/snippets/utils/permission' +import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { useRouter } from '@/next/navigation' import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets' @@ -34,12 +36,16 @@ type SnippetInfoDropdownProps = { const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => { const { t } = useTranslation('snippet') const { replace } = useRouter() + const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) const [open, setOpen] = React.useState(false) const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false) const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false) const updateSnippetMutation = useUpdateSnippetMutation() const exportSnippetMutation = useExportSnippetMutation() const deleteSnippetMutation = useDeleteSnippetMutation() + const canCreateAndModifySnippet = canCreateAndModifySnippets(workspacePermissionKeys) + const canManageSnippet = canManageSnippets(workspacePermissionKeys) + const canShowOperations = canCreateAndModifySnippet || canManageSnippet const initialValue = React.useMemo(() => ({ name: snippet.name, @@ -52,6 +58,9 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => { }, []) const handleExportSnippet = React.useCallback(async () => { + if (!canManageSnippet) + return + setOpen(false) try { const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id }) @@ -61,7 +70,7 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => { catch { toast.error(t('exportFailed')) } - }, [exportSnippetMutation, snippet.id, snippet.name, t]) + }, [canManageSnippet, exportSnippetMutation, snippet.id, snippet.name, t]) const handleEditSnippet = React.useCallback(async ({ name, description }: { name: string @@ -99,6 +108,9 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => { }) }, [deleteSnippetMutation, replace, snippet.id, t]) + if (!canShowOperations) + return null + return ( <> @@ -112,26 +124,32 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => { sideOffset={4} popupClassName="w-[180px] p-1" > - - - {t('menu.editInfo')} - - - - {t('menu.exportSnippet')} - - - { - setOpen(false) - setIsDeleteDialogOpen(true) - }} - > - - {t('menu.deleteSnippet')} - + {canCreateAndModifySnippet && ( + + + {t('menu.editInfo')} + + )} + {canManageSnippet && ( + <> + + + {t('menu.exportSnippet')} + + + { + setOpen(false) + setIsDeleteDialogOpen(true) + }} + > + + {t('menu.deleteSnippet')} + + + )} diff --git a/web/app/components/app/access-config/__tests__/index.spec.tsx b/web/app/components/app/access-config/__tests__/index.spec.tsx new file mode 100644 index 00000000000..49e8c86825d --- /dev/null +++ b/web/app/components/app/access-config/__tests__/index.spec.tsx @@ -0,0 +1,214 @@ +import type { AccessRulesEditorProps } from '@/app/components/access-rules-editor' +import { render, screen } from '@testing-library/react' +import { useStore } from '@/app/components/app/store' +import { + useAppAccessRules, + useAppUserAccessSettings, +} from '@/service/access-control/use-app-access-config' +import { AppACLPermission } from '@/utils/permission' +import AppAccessConfigPage from '../index' + +const mockAppContext = vi.hoisted(() => ({ + userProfile: { id: 'user-1' }, + workspacePermissionKeys: [] as string[], +})) + +const mockAppAccessRules = vi.hoisted(() => ({ + items: [] as AccessRulesEditorProps['rules'], + isLoading: false, +})) + +const mockAppUserAccessSettings = vi.hoisted(() => ({ + data: [] as NonNullable, + scope: 'specific' as AccessRulesEditorProps['openScope'], + isLoading: false, +})) + +const mockAccessRulesEditor = vi.hoisted(() => ({ + props: null as AccessRulesEditorProps | null, +})) + +const mockMutations = vi.hoisted(() => ({ + updateOpenScope: vi.fn(), + updateUserAccessSettings: vi.fn(), + removeMemberBindings: vi.fn(), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => mockAppContext, +})) + +vi.mock('@/service/access-control/use-app-access-config', () => ({ + useAppAccessRules: vi.fn(() => ({ + data: { items: mockAppAccessRules.items }, + isLoading: mockAppAccessRules.isLoading, + })), + useAppUserAccessSettings: vi.fn(() => ({ + data: mockAppUserAccessSettings.scope + ? { data: mockAppUserAccessSettings.data, scope: mockAppUserAccessSettings.scope } + : undefined, + isLoading: mockAppUserAccessSettings.isLoading, + })), + useUpdateAppOpenScope: vi.fn(() => ({ + mutate: mockMutations.updateOpenScope, + isPending: false, + })), + useUpdateAppUserAccessSettings: vi.fn(() => ({ + mutate: mockMutations.updateUserAccessSettings, + })), + useRemoveAppAccessPolicyMemberBindings: vi.fn(() => ({ + mutate: mockMutations.removeMemberBindings, + })), +})) + +vi.mock('@/app/components/access-rules-editor', () => ({ + default: (props: AccessRulesEditorProps) => { + mockAccessRulesEditor.props = props + return ( +
+ ) + }, +})) + +describe('AppAccessConfigPage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAppContext.userProfile = { id: 'user-1' } + mockAppContext.workspacePermissionKeys = [] + mockAppAccessRules.items = [] + mockAppAccessRules.isLoading = false + mockAppUserAccessSettings.data = [] + mockAppUserAccessSettings.scope = 'specific' + mockAppUserAccessSettings.isLoading = false + mockAccessRulesEditor.props = null + useStore.setState({ + appDetail: { + id: 'app-1', + maintainer: 'account-1', + permission_keys: [AppACLPermission.AccessConfig], + } as NonNullable['appDetail']>, + }) + }) + + // Rendering wires the app access rules into the shared editor. + describe('Rendering', () => { + it('should render access config title and pass app rules to the editor', () => { + render() + + expect(screen.getByRole('heading', { name: 'common.settings.resourceAccess' })).toBeInTheDocument() + expect(screen.getByText('permission.accessRule.appDescription')).toBeInTheDocument() + expect(screen.getByTestId('access-rules-editor')).toBeInTheDocument() + expect(mockAccessRulesEditor.props?.className).toBe('w-full max-w-200') + expect(mockAccessRulesEditor.props?.rules).toEqual([]) + expect(mockAccessRulesEditor.props?.userAccessSettings).toEqual([]) + expect(mockAccessRulesEditor.props?.openScope).toBe('specific') + }) + + it('should not pass open scope before user access settings finish loading', () => { + mockAppUserAccessSettings.scope = undefined + mockAppUserAccessSettings.isLoading = true + + render() + + expect(mockAccessRulesEditor.props?.openScope).toBeUndefined() + }) + + it('should pass the user access settings open scope to the editor', () => { + mockAppUserAccessSettings.scope = 'all' + + render() + + expect(mockAccessRulesEditor.props?.openScope).toBe('all') + }) + + it('should pass access rule loading state to the editor', () => { + mockAppAccessRules.isLoading = true + mockAppUserAccessSettings.isLoading = true + + render() + + expect(mockAccessRulesEditor.props?.isLoadingRules).toBe(true) + expect(mockAccessRulesEditor.props?.isLoadingUserAccessSettings).toBe(true) + expect(mockAccessRulesEditor.props?.isUpdatingOpenScope).toBe(true) + }) + + it('should pass the app maintainer id from app detail to the editor', () => { + useStore.setState({ + appDetail: { + id: 'app-1', + maintainer: 'account-1', + permission_keys: [AppACLPermission.AccessConfig], + } as NonNullable['appDetail']>, + }) + + render() + + expect(mockAccessRulesEditor.props?.maintainerId).toBe('account-1') + }) + + it('should wire open scope and user policy updates', () => { + render() + + mockAccessRulesEditor.props?.onOpenScopeChange?.('all') + expect(mockMutations.updateOpenScope).toHaveBeenCalledWith('all', expect.objectContaining({ + onError: expect.any(Function), + })) + + mockAccessRulesEditor.props?.onUserAccessPoliciesChange?.('account-1', ['policy-1']) + expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith({ + accountId: 'account-1', + accessPolicyIds: ['policy-1'], + }, expect.objectContaining({ + onSettled: expect.any(Function), + })) + + mockAccessRulesEditor.props?.onAddAccessSubject?.('account-2', ['default']) + expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith({ + accountId: 'account-2', + accessPolicyIds: ['default'], + }, expect.objectContaining({ + onSettled: expect.any(Function), + })) + + mockAccessRulesEditor.props?.onRemoveAccessPolicyMemberBinding?.('account-3', 'policy-3') + expect(mockMutations.removeMemberBindings).toHaveBeenCalledWith({ + accessPolicyId: 'policy-3', + accountIds: ['account-3'], + }, expect.objectContaining({ + onSettled: expect.any(Function), + })) + }) + + it('should not mount access config data hooks when access config permission is missing', () => { + useStore.setState({ + appDetail: { + id: 'app-1', + maintainer: 'account-1', + permission_keys: [AppACLPermission.ViewLayout], + } as NonNullable['appDetail']>, + }) + + render() + + expect(screen.queryByTestId('access-rules-editor')).not.toBeInTheDocument() + expect(useAppAccessRules).not.toHaveBeenCalled() + expect(useAppUserAccessSettings).not.toHaveBeenCalled() + }) + + it('should allow the app maintainer with app management workspace permission', () => { + mockAppContext.userProfile = { id: 'account-1' } + mockAppContext.workspacePermissionKeys = ['app.create_and_management'] + useStore.setState({ + appDetail: { + id: 'app-1', + maintainer: 'account-1', + permission_keys: [] as string[], + } as NonNullable['appDetail']>, + }) + + render() + + expect(screen.getByTestId('access-rules-editor')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/app/access-config/index.tsx b/web/app/components/app/access-config/index.tsx new file mode 100644 index 00000000000..9704540bbd7 --- /dev/null +++ b/web/app/components/app/access-config/index.tsx @@ -0,0 +1,120 @@ +'use client' + +import type { ResourceOpenScope } from '@/models/access-control' +import { ScrollArea } from '@langgenius/dify-ui/scroll-area' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import AccessRulesEditor from '@/app/components/access-rules-editor' +import { useStore } from '@/app/components/app/store' +import { useAppContext } from '@/context/app-context' +import { useLocale } from '@/context/i18n' +import { getAccessControlTemplateLanguage } from '@/i18n-config/language' +import { + useAppAccessRules, + useAppUserAccessSettings, + useRemoveAppAccessPolicyMemberBindings, + useUpdateAppOpenScope, + useUpdateAppUserAccessSettings, +} from '@/service/access-control/use-app-access-config' +import { getAppACLCapabilities } from '@/utils/permission' + +type AppAccessConfigPageProps = { + appId: string +} + +type AppAccessConfigContentProps = { + appId: string + maintainerId?: string | null +} + +const AppAccessConfigContent = ({ appId, maintainerId }: AppAccessConfigContentProps) => { + const { t } = useTranslation() + const locale = useLocale() + const language = useMemo(() => getAccessControlTemplateLanguage(locale), [locale]) + const { data: appAccessRulesResponse, isLoading: isLoadingAppAccessRules } = useAppAccessRules(appId, language) + const { data: appUserAccessSettingsResponse, isLoading: isLoadingAppUserAccessSettings } = useAppUserAccessSettings(appId, language) + const { mutate: updateAppOpenScope, isPending: isUpdatingAppOpenScope } = useUpdateAppOpenScope(appId) + const { mutate: updateAppUserAccessSettings } = useUpdateAppUserAccessSettings(appId) + const { mutate: removeAppAccessPolicyMemberBindings } = useRemoveAppAccessPolicyMemberBindings(appId) + const [optimisticOpenScope, setOptimisticOpenScope] = useState(null) + const [updatingAccountId, setUpdatingAccountId] = useState(null) + + const appAccessRules = appAccessRulesResponse?.items || [] + const appUserAccessSettings = appUserAccessSettingsResponse?.data || [] + const openScope = optimisticOpenScope || appUserAccessSettingsResponse?.scope + + const handleOpenScopeChange = useCallback((nextOpenScope: ResourceOpenScope) => { + if (nextOpenScope === openScope) + return + + const previousOptimisticOpenScope = optimisticOpenScope + setOptimisticOpenScope(nextOpenScope) + updateAppOpenScope(nextOpenScope, { + onError: () => setOptimisticOpenScope(previousOptimisticOpenScope), + }) + }, [openScope, optimisticOpenScope, updateAppOpenScope]) + + const handleUserAccessPoliciesChange = useCallback((accountId: string, accessPolicyIds: string[]) => { + setUpdatingAccountId(accountId) + updateAppUserAccessSettings( + { accountId, accessPolicyIds }, + { onSettled: () => setUpdatingAccountId(null) }, + ) + }, [updateAppUserAccessSettings]) + + const handleRemoveAccessPolicyMemberBinding = useCallback((accountId: string, accessPolicyId: string) => { + setUpdatingAccountId(accountId) + removeAppAccessPolicyMemberBindings( + { accessPolicyId, accountIds: [accountId] }, + { onSettled: () => setUpdatingAccountId(null) }, + ) + }, [removeAppAccessPolicyMemberBindings]) + + return ( + +
+

{t('settings.resourceAccess', { ns: 'common' })}

+

+ {t('accessRule.appDescription', { ns: 'permission' })} +

+
+
+ +
+
+ ) +} + +const AppAccessConfigPage = ({ appId }: AppAccessConfigPageProps) => { + const { userProfile, workspacePermissionKeys } = useAppContext() + const appDetail = useStore(state => state.appDetail) + const appACLCapabilities = useMemo(() => getAppACLCapabilities(appDetail?.permission_keys, { + currentUserId: userProfile?.id, + resourceMaintainer: appDetail?.maintainer, + workspacePermissionKeys, + }), [appDetail?.maintainer, appDetail?.permission_keys, userProfile?.id, workspacePermissionKeys]) + + if (!appDetail || appDetail.id !== appId || !appACLCapabilities.canAccessConfig) + return null + + return +} + +export default AppAccessConfigPage diff --git a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx index 1a8a181f68c..c8bce5401e6 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx @@ -35,7 +35,7 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('@/service/access-control', () => ({ +vi.mock('@/service/access-control/use-app-access-control', () => ({ useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), })) diff --git a/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx index b447e9c381b..11ba79e1851 100644 --- a/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx @@ -18,7 +18,7 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('@/service/access-control', () => ({ +vi.mock('@/service/access-control/use-app-access-control', () => ({ useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), })) diff --git a/web/app/components/app/app-access-control/__tests__/index.spec.tsx b/web/app/components/app/app-access-control/__tests__/index.spec.tsx index f3cb47f16b8..dd5e2f4f22f 100644 --- a/web/app/components/app/app-access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/index.spec.tsx @@ -22,7 +22,7 @@ const mockUseMutation = vi.hoisted(() => vi.fn()) const mockUseAppWhiteListSubjects = vi.fn() const mockUseSearchForWhiteListCandidates = vi.fn() -vi.mock('@/service/access-control', () => ({ +vi.mock('@/service/access-control/use-app-access-control', () => ({ useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), })) diff --git a/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx index 044e5aa3d60..5e8ec42be8a 100644 --- a/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx @@ -10,6 +10,10 @@ vi.mock('@/service/access-control', () => ({ useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), })) +vi.mock('@/service/access-control/use-app-access-control', () => ({ + useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), +})) + const createGroup = (overrides: Partial = {}): AccessControlGroup => ({ id: 'group-1', name: 'Group One', diff --git a/web/app/components/app/app-access-control/access-subject-selector/add-button.tsx b/web/app/components/app/app-access-control/access-subject-selector/add-button.tsx index 43da1de6a70..42fc822e866 100644 --- a/web/app/components/app/app-access-control/access-subject-selector/add-button.tsx +++ b/web/app/components/app/app-access-control/access-subject-selector/add-button.tsx @@ -21,7 +21,7 @@ import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { SkeletonRectangle } from '@/app/components/base/skeleton' -import { useSearchForWhiteListCandidates } from '@/service/access-control' +import { useSearchForWhiteListCandidates } from '@/service/access-control/use-app-access-control' import { SelectedGroupsBreadCrumb, SubjectItem } from './subject-options' import { getSubjectLabel, diff --git a/web/app/components/app/app-access-control/index.tsx b/web/app/components/app/app-access-control/index.tsx index d07e6b1b7d7..18c28d12757 100644 --- a/web/app/components/app/app-access-control/index.tsx +++ b/web/app/components/app/app-access-control/index.tsx @@ -7,7 +7,7 @@ import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { AccessMode } from '@/models/access-control' -import { useAppWhiteListSubjects } from '@/service/access-control' +import { useAppWhiteListSubjects } from '@/service/access-control/use-app-access-control' import { consoleQuery } from '@/service/client' import { AccessControlDialog } from './access-control-dialog' import { AccessControlDialogContent } from './access-control-dialog-content' diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index f918b28a898..f5b800ee40b 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -34,6 +34,7 @@ const hotkeyMocks = vi.hoisted(() => ({ })) let mockAppDetail: Record | null = null +let mockWorkspacePermissionKeys: string[] = ['tool.manage'] vi.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -65,7 +66,7 @@ vi.mock('@/hooks/use-async-window-open', () => ({ useAsyncWindowOpen: () => mockOpenAsyncWindow, })) -vi.mock('@/service/access-control', () => ({ +vi.mock('@/service/access-control/use-app-access-control', () => ({ useGetUserCanAccessApp: (params: unknown) => { mockUseGetUserCanAccessApp(params) return { @@ -108,6 +109,9 @@ vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ isCurrentWorkspaceManager: true, }), + useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => T): T => selector({ + workspacePermissionKeys: mockWorkspacePermissionKeys, + }), })) vi.mock('@langgenius/dify-ui/toast', () => ({ @@ -190,6 +194,7 @@ describe('AppPublisher', () => { sectionProps.summary = null sectionProps.access = null sectionProps.actions = null + mockWorkspacePermissionKeys = ['tool.manage'] mockAppDetail = { id: 'app-1', name: 'Demo App', @@ -362,6 +367,26 @@ describe('AppPublisher', () => { expect(screen.getByTestId('workflow-tool-drawer')).toBeInTheDocument() }) + it('should not open workflow tool drawer without tool.manage', () => { + mockWorkspacePermissionKeys = [] + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + } + + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-workflow-tool')) + + expect(screen.queryByTestId('workflow-tool-drawer')).not.toBeInTheDocument() + expect(sectionProps.actions?.workflowToolAvailable).toBe(false) + }) + it('should close embedded and access control panels through child callbacks', async () => { render( { }) it('should refresh app detail after access control confirmation', async () => { - render( + const { queryClient } = render( , ) + const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue() fireEvent.click(screen.getByText('common.publish')) fireEvent.click(screen.getByText('publisher-access-control')) @@ -396,11 +422,7 @@ describe('AppPublisher', () => { fireEvent.click(screen.getByText('confirm-access-control')) await waitFor(() => { - expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) - expect(mockSetAppDetail).toHaveBeenCalledWith({ - id: 'app-1', - access_mode: AccessMode.PUBLIC, - }) + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['apps', 'detail', 'app-1'] }) }) }) diff --git a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx index 1572f3d4e07..6675dd49f39 100644 --- a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx @@ -243,7 +243,6 @@ describe('app-publisher sections', () => { workflowToolAvailable={false} workflowToolIsLoading={false} workflowToolOutdated={false} - workflowToolIsCurrentWorkspaceManager workflowToolMessage="workflow-disabled" onConfigureWorkflowTool={vi.fn()} />, @@ -282,7 +281,6 @@ describe('app-publisher sections', () => { workflowToolAvailable workflowToolIsLoading={false} workflowToolOutdated={false} - workflowToolIsCurrentWorkspaceManager onConfigureWorkflowTool={vi.fn()} />, ) @@ -309,7 +307,6 @@ describe('app-publisher sections', () => { workflowToolAvailable workflowToolIsLoading={false} workflowToolOutdated={false} - workflowToolIsCurrentWorkspaceManager onConfigureWorkflowTool={vi.fn()} />, ) diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 961c33cf72f..f3e65682a8f 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -8,7 +8,7 @@ import { Button } from '@langgenius/dify-ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' import { useHotkey } from '@tanstack/react-hotkeys' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { use, useEffect, @@ -26,6 +26,7 @@ import EmbeddedModal from '@/app/components/app/overview/embedded' import { useStore as useAppStore } from '@/app/components/app/store' import { trackEvent } from '@/app/components/base/amplitude' import { buildInstalledAppPath } from '@/app/components/explore/installed-app/routes' +import { useCanManageTools } from '@/app/components/tools/hooks/use-tool-permissions' import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool' import { useConfigureButton } from '@/app/components/tools/workflow-tool/hooks/use-configure-button' import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' @@ -36,9 +37,10 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { AccessMode } from '@/models/access-control' -import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control' -import { fetchAppDetailDirect, publishToCreatorsPlatform } from '@/service/apps' +import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control' +import { publishToCreatorsPlatform } from '@/service/apps' import { fetchInstalledAppList } from '@/service/explore' +import { appDetailQueryKeyPrefix } from '@/service/use-apps' import { useInvalidateAppWorkflow } from '@/service/use-workflow' import { fetchPublishedWorkflow } from '@/service/workflow' import { AppModeEnum } from '@/types/app' @@ -80,7 +82,7 @@ export type AppPublisherProps = { hasHumanInputNode?: boolean } -const PUBLISH_SHORTCUT = ['Mod', 'Shift', 'P'] +const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P'] export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams @@ -126,7 +128,8 @@ export function AppPublisher({ const workflowStore = use(WorkflowContext) const appDetail = useAppStore(state => state.appDetail) - const setAppDetail = useAppStore(s => s.setAppDetail) + const canManageTools = useCanManageTools() + const queryClient = useQueryClient() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { formatTimeFromNow } = useFormatTimeFromNow() const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {} @@ -237,8 +240,7 @@ export function AppPublisher({ if (!appDetail) return try { - const res = await fetchAppDetailDirect({ url: '/apps', id: appDetail.id }) - setAppDetail(res) + await queryClient.invalidateQueries({ queryKey: [...appDetailQueryKeyPrefix, appDetail.id] }) } finally { setShowAppAccessControl(false) @@ -319,10 +321,11 @@ export function AppPublisher({ }, [appDetail?.id, invalidateAppWorkflow, workflowStore]) const hasPublishedVersion = !!publishedAt + const workflowToolVisible = appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode + const workflowToolAvailableForUser = workflowToolAvailable && canManageTools const workflowToolMessage = !hasPublishedVersion || !workflowToolAvailable ? t('common.workflowAsToolDisabledHint', { ns: 'workflow' }) : undefined - const workflowToolVisible = appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode const workflowToolPublished = !!toolPublished function closeWorkflowToolDrawer() { setWorkflowToolDrawerOpen(false) @@ -332,7 +335,7 @@ export function AppPublisher({ background: (appDetail?.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground, } const workflowTool = useConfigureButton({ - enabled: workflowToolVisible, + enabled: workflowToolVisible && canManageTools, published: workflowToolPublished, detailNeedUpdate: workflowToolPublished && published, workflowAppId: appDetail?.id ?? '', @@ -346,6 +349,9 @@ export function AppPublisher({ onConfigured: closeWorkflowToolDrawer, }) function openWorkflowToolDrawer() { + if (!canManageTools) + return + handleOpenChange(false) setWorkflowToolDrawerOpen(true) } @@ -429,10 +435,9 @@ export function AppPublisher({ showBatchRunConfig={hiddenLaunchVariables.length > 0 && (appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION)} showRunConfig={hiddenLaunchVariables.length > 0} toolPublished={toolPublished} - workflowToolAvailable={workflowToolAvailable} + workflowToolAvailable={workflowToolAvailableForUser} workflowToolIsLoading={workflowTool.isLoading} workflowToolOutdated={workflowTool.outdated} - workflowToolIsCurrentWorkspaceManager={workflowTool.isCurrentWorkspaceManager} workflowToolMessage={workflowToolMessage} onConfigureWorkflowTool={openWorkflowToolDrawer} /> @@ -471,7 +476,7 @@ export function AppPublisher({ onSubmit={handleWorkflowLaunchConfirm} /> - {workflowToolDrawerOpen && ( + {workflowToolDrawerOpen && canManageTools && ( void } @@ -275,7 +273,6 @@ export const PublisherActionsSection = ({ workflowToolAvailable = true, workflowToolIsLoading, workflowToolOutdated, - workflowToolIsCurrentWorkspaceManager, workflowToolMessage, onConfigureWorkflowTool, }: ActionsSectionProps) => { @@ -297,7 +294,7 @@ export const PublisherActionsSection = ({ actionButton={showRunConfig ? { ariaLabel: t('operation.config', { ns: 'common' }), - icon: , + icon: , onClick: () => handleOpenRunConfig?.(appURL), } : undefined} @@ -316,7 +313,7 @@ export const PublisherActionsSection = ({ actionButton={showBatchRunConfig ? { ariaLabel: t('operation.config', { ns: 'common' }), - icon: , + icon: , onClick: () => handleOpenRunConfig?.(`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`), } : undefined} @@ -366,7 +363,6 @@ export const PublisherActionsSection = ({ published={!!toolPublished} isLoading={workflowToolIsLoading} outdated={workflowToolOutdated} - isCurrentWorkspaceManager={workflowToolIsCurrentWorkspaceManager} onConfigure={onConfigureWorkflowTool} disabledReason={workflowToolMessage} /> diff --git a/web/app/components/app/configuration/config/agent-setting-button.tsx b/web/app/components/app/configuration/config/agent-setting-button.tsx index 68a3f8cef16..ff95d4c8c9d 100644 --- a/web/app/components/app/configuration/config/agent-setting-button.tsx +++ b/web/app/components/app/configuration/config/agent-setting-button.tsx @@ -2,7 +2,6 @@ import type { FC } from 'react' import type { AgentConfig } from '@/models/debug' import { Button } from '@langgenius/dify-ui/button' -import { RiSettings2Line } from '@remixicon/react' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -12,6 +11,7 @@ type Props = Readonly<{ isFunctionCall: boolean isChatModel: boolean agentConfig?: AgentConfig + disabled?: boolean onAgentSettingChange: (payload: AgentConfig) => void }> @@ -20,14 +20,15 @@ const AgentSettingButton: FC = ({ isFunctionCall, isChatModel, agentConfig, + disabled = false, }) => { const { t } = useTranslation() const [isShowAgentSetting, setIsShowAgentSetting] = useState(false) return ( <> - {isShowAgentSetting && ( diff --git a/web/app/components/app/configuration/configuration-view.tsx b/web/app/components/app/configuration/configuration-view.tsx index a33280bb06c..90f2d429720 100644 --- a/web/app/components/app/configuration/configuration-view.tsx +++ b/web/app/components/app/configuration/configuration-view.tsx @@ -109,6 +109,7 @@ const ConfigurationView: FC = ({ isChatModel={contextValue.modelModeType === ModelModeType.chat} agentConfig={modelConfig.agentConfig} isFunctionCall={contextValue.isFunctionCall} + disabled={contextValue.readonly} onAgentSettingChange={onAgentSettingChange} /> )} @@ -119,6 +120,7 @@ const ConfigurationView: FC = ({ provider={modelConfig.provider} completionParams={contextValue.completionParams} modelId={modelConfig.model_id} + readonly={contextValue.readonly} setModel={onModelChange} onCompletionParamsChange={onCompletionParamsChange} debugWithMultipleModel={debugWithMultipleModel} @@ -245,7 +247,7 @@ const ConfigurationView: FC = ({ inWorkflow={false} showFileUpload={false} isChatMode={contextValue.mode !== AppModeEnum.COMPLETION} - disabled={false} + disabled={!!contextValue.readonly} onChange={onFeaturesChange} onClose={onCloseFeaturePanel} promptVariables={promptVariables} diff --git a/web/app/components/app/configuration/dataset-config/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/__tests__/index.spec.tsx index 25e38b48552..56dac4e06e9 100644 --- a/web/app/components/app/configuration/dataset-config/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/__tests__/index.spec.tsx @@ -8,7 +8,7 @@ import { ComparisonOperator, LogicalOperator } from '@/app/components/workflow/n import { getSelectedDatasetsMode } from '@/app/components/workflow/nodes/knowledge-retrieval/utils' import { DatasetPermission, DataSourceType } from '@/models/datasets' import { AppModeEnum, ModelModeType, RETRIEVE_TYPE } from '@/types/app' -import { hasEditPermissionForDataset } from '@/utils/permission' +import { DatasetACLPermission, getDatasetACLCapabilities, hasEditPermissionForDataset } from '@/utils/permission' import DatasetConfig from '../index' // Mock external dependencies @@ -42,10 +42,29 @@ vi.mock('@/context/app-context', () => ({ userProfile: { id: 'user-123', }, + workspacePermissionKeys: [], })), })) vi.mock('@/utils/permission', () => ({ + DatasetACLPermission: { + Readonly: 'dataset.acl.readonly', + Edit: 'dataset.acl.edit', + Use: 'dataset.acl.use', + }, + getDatasetACLCapabilities: vi.fn(() => ({ + canReadonly: false, + canEdit: true, + canImportExportDSL: false, + canPipelineTest: false, + canDocumentDownload: false, + canRetrievalRecall: false, + canUse: true, + canDeleteFile: false, + canPipelineRelease: false, + canDelete: false, + canAccessConfig: false, + })), hasEditPermissionForDataset: vi.fn(() => true), })) @@ -234,6 +253,7 @@ const createMockDataset = (overrides: Partial = {}): DataSet => { indexing_technique: 'high_quality' as any, author_name: 'Test Author', created_by: 'user-123', + maintainer: 'user-123', updated_by: 'user-123', updated_at: Date.now(), app_count: 0, @@ -306,6 +326,19 @@ const renderDatasetConfig = (contextOverrides: Partial describe('DatasetConfig', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(getDatasetACLCapabilities).mockReturnValue({ + canReadonly: false, + canEdit: true, + canImportExportDSL: false, + canPipelineTest: false, + canDocumentDownload: false, + canRetrievalRecall: false, + canUse: true, + canDeleteFile: false, + canPipelineRelease: false, + canDelete: false, + canAccessConfig: false, + }) mockConfigContext.dataSets = [] mockConfigContext.setDataSets = vi.fn() mockConfigContext.setModelConfig = vi.fn() @@ -438,6 +471,38 @@ describe('DatasetConfig', () => { expect(screen.getByTestId(`card-item-${dataset.id}`))!.toBeInTheDocument() }) + + it('should disable dataset editing when ACL does not grant edit even if legacy dataset permission allows it', () => { + const dataset = createMockDataset({ + permission: DatasetPermission.allTeamMembers, + permission_keys: [DatasetACLPermission.Use], + }) + vi.mocked(hasEditPermissionForDataset).mockReturnValue(true) + vi.mocked(getDatasetACLCapabilities).mockReturnValue({ + canReadonly: false, + canEdit: false, + canImportExportDSL: false, + canPipelineTest: false, + canDocumentDownload: false, + canRetrievalRecall: false, + canUse: true, + canDeleteFile: false, + canPipelineRelease: false, + canDelete: false, + canAccessConfig: false, + }) + + renderDatasetConfig({ + dataSets: [dataset], + }) + + expect(screen.getByTestId(`card-item-${dataset.id}`))!.toBeInTheDocument() + expect(screen.queryByText('Edit')).not.toBeInTheDocument() + expect(getDatasetACLCapabilities).toHaveBeenCalledWith(dataset.permission_keys, expect.objectContaining({ + currentUserId: 'user-123', + resourceMaintainer: dataset.maintainer, + })) + }) }) describe('Context Variables', () => { diff --git a/web/app/components/app/configuration/dataset-config/index.tsx b/web/app/components/app/configuration/dataset-config/index.tsx index 6ea2a638396..aa5624099aa 100644 --- a/web/app/components/app/configuration/dataset-config/index.tsx +++ b/web/app/components/app/configuration/dataset-config/index.tsx @@ -31,7 +31,7 @@ import { import { useSelector as useAppContextSelector } from '@/context/app-context' import ConfigContext from '@/context/debug-configuration' import { AppModeEnum } from '@/types/app' -import { hasEditPermissionForDataset } from '@/utils/permission' +import { getDatasetACLCapabilities } from '@/utils/permission' import FeaturePanel from '../base/feature-panel' import OperationBtn from '../base/operation-btn' import { useFormattingChangedDispatcher } from '../debug/hooks' @@ -45,7 +45,8 @@ type Props = Readonly<{ }> const DatasetConfig: FC = ({ readonly, hideMetadataFilter }) => { const { t } = useTranslation() - const userProfile = useAppContextSelector(s => s.userProfile) + const currentUserId = useAppContextSelector(s => s.userProfile?.id) + const workspacePermissionKeys = useAppContextSelector(s => s.workspacePermissionKeys) const { mode, dataSets: dataSet, @@ -155,17 +156,17 @@ const DatasetConfig: FC = ({ readonly, hideMetadataFilter }) => { const formattedDataset = useMemo(() => { return dataSet.map((item) => { - const datasetConfig = { - createdBy: item.created_by, - partialMemberList: item.partial_member_list || [], - permission: item.permission, - } + const datasetACLCapabilities = getDatasetACLCapabilities(item.permission_keys, { + currentUserId, + resourceMaintainer: item.maintainer, + workspacePermissionKeys, + }) return { ...item, - editable: hasEditPermissionForDataset(userProfile?.id || '', datasetConfig), + editable: datasetACLCapabilities.canEdit, } }) - }, [dataSet, userProfile?.id]) + }, [currentUserId, dataSet, workspacePermissionKeys]) const metadataList = useMemo(() => { return intersectionBy(...formattedDataset.filter((dataset) => { @@ -249,7 +250,7 @@ const DatasetConfig: FC = ({ readonly, hideMetadataFilter }) => { setDatasetConfigs(newInputs) }, [setDatasetConfigs, datasetConfigsRef]) - const handleMetadataCompletionParamsChange = useCallback((newParams: Record) => { + const handleMetadataCompletionParamsChange = useCallback((newParams: Record) => { const newInputs = produce(datasetConfigsRef.current!, (draft) => { draft.metadata_model_config = { ...draft.metadata_model_config!, diff --git a/web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx index fe475cf8711..46dac5351ce 100644 --- a/web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/select-dataset/__tests__/index.spec.tsx @@ -33,6 +33,13 @@ vi.mock('@/service/knowledge/use-dataset', () => ({ useInfiniteDatasets: (...args: any[]) => mockUseInfiniteDatasets(...args), })) +let mockWorkspacePermissionKeys = ['dataset.create_and_management'] +vi.mock('@/context/app-context', () => ({ + useSelector: (selector: (state: { workspacePermissionKeys: string[] }) => unknown) => selector({ + workspacePermissionKeys: mockWorkspacePermissionKeys, + }), +})) + vi.mock('@/hooks/use-knowledge', () => ({ useKnowledge: () => ({ formatIndexingTechniqueAndMethod: (tech: string, method: string) => `${tech}:${method}`, @@ -78,6 +85,7 @@ const makeDataset = (overrides: Partial): DataSet => ({ describe('SelectDataSet', () => { beforeEach(() => { vi.clearAllMocks() + mockWorkspacePermissionKeys = ['dataset.create_and_management'] }) it('renders dataset entries, allows selection, and fires onSelect', async () => { @@ -139,6 +147,25 @@ describe('SelectDataSet', () => { expect(screen.getByRole('button', { name: 'common.operation.add' })).toBeDisabled() }) + it('should hide the create dataset link when dataset.create_and_management is unavailable', async () => { + mockWorkspacePermissionKeys = [] + mockUseInfiniteDatasets.mockReturnValue({ + data: { pages: [{ data: [] }] }, + isLoading: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + hasNextPage: false, + }) + + await act(async () => { + render() + }) + + expect(screen.getByText('appDebug.feature.dataSet.noDataSet')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'appDebug.feature.dataSet.toCreate' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.add' })).toBeDisabled() + }) + it('uses selectedIds as the initial modal selection', async () => { const datasetOne = makeDataset({ id: 'set-1', diff --git a/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx b/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx index 8b1d7b106f8..60c4d79a1a6 100644 --- a/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx +++ b/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx @@ -13,9 +13,11 @@ import Badge from '@/app/components/base/badge' import Loading from '@/app/components/base/loading' import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import FeatureIcon from '@/app/components/header/account-setting/model-provider-page/model-selector/feature-icon' +import { useSelector as useAppContextSelector } from '@/context/app-context' import { useKnowledge } from '@/hooks/use-knowledge' import Link from '@/next/link' import { useInfiniteDatasets } from '@/service/knowledge/use-dataset' +import { hasPermission } from '@/utils/permission' type ISelectDataSetProps = { isShow: boolean @@ -36,6 +38,8 @@ const SelectDataSet: FC = ({ const [selectedIdsInModal, setSelectedIdsInModal] = useState(() => selectedIds) const canSelectMulti = true const { formatIndexingTechniqueAndMethod } = useKnowledge() + const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys) + const canCreateDataset = hasPermission(workspacePermissionKeys, 'dataset.create_and_management') const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage } = useInfiniteDatasets( { page: 1 }, { enabled: isShow, staleTime: 0, refetchOnMount: 'always' }, @@ -107,7 +111,9 @@ const SelectDataSet: FC = ({ {hasNoData && (
{t('feature.dataSet.noDataSet', { ns: 'appDebug' })} - {t('feature.dataSet.toCreate', { ns: 'appDebug' })} + {canCreateDataset && ( + {t('feature.dataSet.toCreate', { ns: 'appDebug' })} + )}
)} diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx index cc7f574208d..092ad6d7ec2 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx @@ -1,15 +1,19 @@ import type { MockedFunction } from 'vitest' import type { DataSet } from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' +import { defaultSystemFeatures } from '@/features/system-features/config' import { ChunkingMode, DatasetPermission, DataSourceType, RerankingModeEnum } from '@/models/datasets' import { updateDatasetSetting } from '@/service/datasets' import { useMembers } from '@/service/use-common' import { RETRIEVE_METHOD } from '@/types/app' +import { DatasetACLPermission } from '@/utils/permission' import SettingsModal from '../index' const toastMocks = vi.hoisted(() => ({ @@ -33,7 +37,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ const mockOnCancel = vi.fn() const mockOnSave = vi.fn() const mockSetShowAccountSettingModal = vi.fn() -let mockIsWorkspaceDatasetOperator = false const mockUseModelList = vi.fn() const mockUseModelListAndDefaultModel = vi.fn() @@ -65,14 +68,20 @@ vi.mock('@/service/use-common', async () => ({ })) vi.mock('@/context/app-context', () => ({ - useAppContext: () => ({ isCurrentWorkspaceDatasetOperator: mockIsWorkspaceDatasetOperator }), - useSelector: (selector: (value: { userProfile: { id: string, name: string, email: string, avatar_url: string } }) => T) => selector({ + useAppContext: () => { + throw new Error('legacy workspace dataset_operator state should not be used by SettingsModal') + }, + useSelector: (selector: (value: { + userProfile: { id: string, name: string, email: string, avatar_url: string } + workspacePermissionKeys: string[] + }) => T) => selector({ userProfile: { id: 'user-1', name: 'User One', email: 'user@example.com', avatar_url: 'avatar.png', }, + workspacePermissionKeys: [], }), })) @@ -185,6 +194,7 @@ const createDataset = (overrides: Partial = {}, retrievalOverrides: Par runtime_mode: 'general', enable_api: true, is_multimodal: false, + permission_keys: [DatasetACLPermission.Edit], ...overrides, retrieval_model_dict: { ...retrievalConfig, @@ -198,12 +208,19 @@ const createDataset = (overrides: Partial = {}, retrievalOverrides: Par } const renderWithProviders = (dataset: DataSet) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + queryClient.setQueryData(systemFeaturesQueryOptions().queryKey, defaultSystemFeatures) + return render( - , + + + , ) } @@ -220,7 +237,6 @@ const renderSettingsModal = async (dataset: DataSet) => { describe('SettingsModal', () => { beforeEach(() => { vi.clearAllMocks() - mockIsWorkspaceDatasetOperator = false mockUseMembers.mockReturnValue({ data: { accounts: [ @@ -379,6 +395,18 @@ describe('SettingsModal', () => { }) }) + // Dataset ACL permissions control whether the legacy dataset permission selector is editable. + describe('Permission Handling', () => { + it('should disable permission selector when dataset lacks edit ACL permission', async () => { + const dataset = createDataset({ permission_keys: [DatasetACLPermission.Readonly] }) + + const { container } = renderWithProviders(dataset) + await waitFor(() => expect(mockUseMembers).toHaveBeenCalled()) + + expect(container.querySelector('[class*="cursor-not-allowed"]')).toBeInTheDocument() + }) + }) + // Validation guardrails before saving. describe('Validation', () => { it('should block save when dataset name is empty', async () => { diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx index 5350bb04474..2a832586dfd 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx @@ -21,7 +21,6 @@ import { ModelTypeEnum } from '@/app/components/header/account-setting/model-pro import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting' -import { useAppContext } from '@/context/app-context' import { useDocLink } from '@/context/i18n' import { DatasetPermission } from '@/models/datasets' import { updateDatasetSetting } from '@/service/datasets' @@ -57,7 +56,6 @@ const SettingsModal: FC = ({ const isExternal = currentDataset.provider === 'external' const openIntegrationsSetting = useIntegrationsSetting() const [loading, setLoading] = useState(false) - const { isCurrentWorkspaceDatasetOperator } = useAppContext() const [localeCurrentDataset, setLocaleCurrentDataset] = useState({ ...currentDataset }) const [topK, setTopK] = useState(localeCurrentDataset?.external_retrieval_model.top_k ?? 2) const [scoreThreshold, setScoreThreshold] = useState(localeCurrentDataset?.external_retrieval_model.score_threshold ?? 0.5) @@ -240,7 +238,7 @@ const SettingsModal: FC = ({
handleValueChange('permission', v!)} diff --git a/web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx b/web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx index 01f3e2834a7..dca45fbbe74 100644 --- a/web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx +++ b/web/app/components/app/configuration/debug/__tests__/chat-user-input.spec.tsx @@ -138,10 +138,12 @@ const createContextValue = (overrides: Partial<{ modelConfig: ModelConfig setInputs: (inputs: Inputs) => void readonly: boolean + canTestAndRun: boolean }> = {}) => ({ modelConfig: createModelConfig(), setInputs: mockSetInputs, readonly: false, + canTestAndRun: true, ...overrides, }) @@ -485,49 +487,79 @@ describe('ChatUserInput', () => { }) }) - describe('Readonly Mode', () => { - it('should set string input as readonly when readonly is true', () => { + describe('Debug Permission', () => { + it('should keep string input editable when configuration is readonly but test/run is allowed', () => { mockUseContext.mockReturnValue(createContextValue({ modelConfig: createModelConfig([ createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), ]), readonly: true, + canTestAndRun: true, + })) + + render() + expect(screen.getByTestId('input-Name')).not.toHaveAttribute('readonly') + }) + + it('should set string input as readonly when test/run is denied even if configuration is editable', () => { + mockUseContext.mockReturnValue(createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + ]), + readonly: false, + canTestAndRun: false, })) render() expect(screen.getByTestId('input-Name')).toHaveAttribute('readonly') }) - it('should set paragraph input as readonly when readonly is true', () => { + it('should set string input as readonly when configuration is readonly and test/run is denied', () => { + mockUseContext.mockReturnValue(createContextValue({ + modelConfig: createModelConfig([ + createPromptVariable({ key: 'name', name: 'Name', type: 'string' }), + ]), + readonly: true, + canTestAndRun: false, + })) + + render() + expect(screen.getByTestId('input-Name')).toHaveAttribute('readonly') + }) + + it('should set paragraph input as readonly when configuration is readonly and test/run is denied', () => { mockUseContext.mockReturnValue(createContextValue({ modelConfig: createModelConfig([ createPromptVariable({ key: 'desc', name: 'Description', type: 'paragraph' }), ]), readonly: true, + canTestAndRun: false, })) render() expect(screen.getByRole('textbox', { name: 'Description' })).toHaveAttribute('readonly') }) - it('should disable select when readonly is true', () => { + it('should disable select when configuration is readonly and test/run is denied', () => { mockUseContext.mockReturnValue(createContextValue({ modelConfig: createModelConfig([ createPromptVariable({ key: 'choice', name: 'Choice', type: 'select', options: ['A', 'B'] }), ]), readonly: true, + canTestAndRun: false, })) render() expect(screen.getByTestId('select-input')).toBeDisabled() }) - it('should disable checkbox when readonly is true', () => { + it('should disable checkbox when configuration is readonly and test/run is denied', () => { mockUseContext.mockReturnValue(createContextValue({ modelConfig: createModelConfig([ createPromptVariable({ key: 'enabled', name: 'Enabled', type: 'checkbox' }), ]), readonly: true, + canTestAndRun: false, })) render() diff --git a/web/app/components/app/configuration/debug/__tests__/index.spec.tsx b/web/app/components/app/configuration/debug/__tests__/index.spec.tsx index c17a74e24c3..b9e1400c67a 100644 --- a/web/app/components/app/configuration/debug/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/debug/__tests__/index.spec.tsx @@ -258,6 +258,7 @@ vi.mock('../debug-with-single-model', () => { const createContextValue = (overrides: Partial = {}): DebugContextValue => ({ readonly: false, + canTestAndRun: true, appId: 'app-id', isAPIKeySet: true, isTrailFinished: false, @@ -540,10 +541,23 @@ describe('Debug', () => { expect(screen.queryByTestId('chat-user-input')).not.toBeInTheDocument() }) - it('should not render refresh action when readonly is true', () => { + it('should keep refresh action available when readonly app still has test/run permission', () => { renderDebug({ contextValue: { readonly: true, + canTestAndRun: true, + }, + }) + + fireEvent.click(screen.getAllByTestId('action-button')[0]!) + expect(mockState.mockHandleRestart).toHaveBeenCalledTimes(1) + }) + + it('should not render refresh action when test/run permission is missing', () => { + renderDebug({ + contextValue: { + readonly: false, + canTestAndRun: false, }, }) @@ -907,6 +921,26 @@ describe('Debug', () => { expect(screen.getByRole('button', { name: 'common.modelProvider.addModel(4/4)' }))!.toBeDisabled() }) + it('should disable add-model button when test/run permission is missing', () => { + const onMultipleModelConfigsChange = vi.fn() + + renderDebug({ + contextValue: { + canTestAndRun: false, + }, + props: { + debugWithMultipleModel: true, + multipleModelConfigs: [{ id: 'model-1', model: 'vision-model', provider: 'openai', parameters: {} }], + onMultipleModelConfigsChange, + }, + }) + + const addModelButton = screen.getByRole('button', { name: 'common.modelProvider.addModel(1/4)' }) + expect(addModelButton).toBeDisabled() + fireEvent.click(addModelButton) + expect(onMultipleModelConfigsChange).not.toHaveBeenCalled() + }) + it('should emit completion event in multiple-model completion mode', () => { renderDebug({ contextValue: { diff --git a/web/app/components/app/configuration/debug/chat-user-input.tsx b/web/app/components/app/configuration/debug/chat-user-input.tsx index 65322cae1b1..251cf819e5d 100644 --- a/web/app/components/app/configuration/debug/chat-user-input.tsx +++ b/web/app/components/app/configuration/debug/chat-user-input.tsx @@ -18,7 +18,8 @@ const ChatUserInput = ({ inputs, }: Props) => { const { t } = useTranslation() - const { modelConfig, setInputs, readonly } = useContext(ConfigContext) + const { modelConfig, setInputs, canTestAndRun = false } = useContext(ConfigContext) + const debugInputReadonly = !canTestAndRun const promptVariables = modelConfig.configs.prompt_variables.filter(({ key, name }) => { return key && key?.trim() && name && name?.trim() @@ -51,6 +52,8 @@ const ChatUserInput = ({ }, [promptVariables, inputs, setInputs]) const handleInputValueChange = (key: string, value: string | boolean) => { + if (debugInputReadonly) + return if (!(key in promptVariableObj)) return @@ -88,7 +91,7 @@ const ChatUserInput = ({ placeholder={name} autoFocus={index === 0} maxLength={max_length} - readOnly={readonly} + readOnly={debugInputReadonly} /> )} {type === 'paragraph' && ( @@ -98,13 +101,13 @@ const ChatUserInput = ({ placeholder={name} value={inputs[key] ? `${inputs[key]}` : ''} onValueChange={(value) => { handleInputValueChange(key, value) }} - readOnly={readonly} + readOnly={debugInputReadonly} /> )} {type === 'select' && (