feat: RBAC (#37107)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: fatelei <fatelei@gmail.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: 盐粒 Yanli <yanli@dify.ai>
Co-authored-by: Charles Yao <chongbinyao33@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: Jingyi <jingyi.qi@dify.ai>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Xiyuan Chen <52963600+GareArc@users.noreply.github.com>
Co-authored-by: gigglewang <gigglewang@dify.ai>
Co-authored-by: chariri <w@chariri.moe>
Co-authored-by: Evan <2869018789@qq.com>
Co-authored-by: zyssyz123 <916125788@qq.com>
This commit is contained in:
Wu Tianwei 2026-06-19 00:35:29 +08:00 committed by GitHub
parent 8732d1463a
commit 33edf97f81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
845 changed files with 34384 additions and 5115 deletions

View File

@ -9,6 +9,7 @@ on:
- "release/e-*"
- "hotfix/**"
- "feat/hitl-backend"
- "feat/rbac"
tags:
- "*"

View File

@ -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",

112
api/commands/rbac.py Normal file
View File

@ -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"))

View File

@ -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):
"""

View File

@ -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):

View File

@ -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}")

View File

@ -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",

View File

@ -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):

View File

@ -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]:

View File

@ -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"""

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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/<string:app_id>/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)

View File

@ -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))

View File

@ -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)

View File

@ -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)

View File

@ -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))

View File

@ -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(

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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, [])

View File

@ -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))

View File

@ -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))

View File

@ -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

View File

@ -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):
"""

View File

@ -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

View File

@ -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 {})

View File

@ -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(

View File

@ -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

View File

@ -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))

View File

@ -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)

View File

@ -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):

View File

@ -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()

View File

@ -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)

View File

@ -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/<uuid:dataset_id>")
@ -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)

View File

@ -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.

View File

@ -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,

View File

@ -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/<uuid:dataset_id>/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)

View File

@ -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)

View File

@ -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)

View File

@ -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 {})

View File

@ -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())

View File

@ -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

View File

@ -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.

View File

@ -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)

View File

@ -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

View File

@ -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:

View File

@ -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):

View File

@ -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)

View File

@ -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

View File

@ -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/<uuid:role_id>")
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/<uuid:role_id>/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/<uuid:role_id>/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/<uuid:policy_id>")
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/<uuid:policy_id>/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/<uuid:binding_id>/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/<uuid:binding_id>/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/<uuid:app_id>/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/<uuid:app_id>/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/<uuid:app_id>/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/<uuid:app_id>/users/<uuid:target_account_id>/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/<uuid:app_id>/access-policies/<uuid:policy_id>/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/<uuid:app_id>/access-policies/<string:policy_id>/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/<uuid:dataset_id>/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/<uuid:dataset_id>/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/<uuid:dataset_id>/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/<uuid:dataset_id>/users/<uuid:target_account_id>/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/<uuid:dataset_id>/access-policies/<uuid:policy_id>/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/<uuid:dataset_id>/access-policies/<string:policy_id>/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/<uuid:policy_id>/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/<uuid:policy_id>/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/<uuid:policy_id>/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/<uuid:policy_id>/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/<uuid:policy_id>/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/<uuid:policy_id>/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/<uuid:member_id>/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/<uuid:role_id>/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())
)

View File

@ -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."""

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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/<uuid:dataset_id>")
@ -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",

View File

@ -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"

View File

@ -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,

View File

@ -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),
}

View File

@ -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

View File

@ -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")

View File

@ -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:

View File

@ -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")

View File

@ -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

View File

@ -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(

View File

@ -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(

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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 |

View File

@ -1613,6 +1613,7 @@ Default configuration for form inputs.
| max_plugin_package_size | integer, <br>**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 |

View File

@ -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"

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -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")

File diff suppressed because it is too large Load Diff

View File

@ -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)

View File

@ -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)

View File

@ -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,

View File

@ -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.

View File

@ -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()

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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},

View File

@ -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

View File

@ -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()

View File

@ -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},

View File

@ -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},

View File

@ -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"] == []

View File

@ -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"]

View File

@ -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"]

View File

@ -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)

View File

@ -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

View File

@ -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"

View File

@ -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."""

View File

@ -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()

View File

@ -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

Some files were not shown because too many files have changed in this diff Show More