from __future__ import annotations import logging from collections.abc import Sequence from enum import StrEnum from typing import Any, TypeVar from flask import has_request_context, request from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from configs import dify_config from core.rbac import RBACResourceWhitelistScope from models import TenantAccountJoin, TenantAccountRole from services.enterprise.base import EnterpriseRequest T = TypeVar("T") logger = logging.getLogger(__name__) class _RBACModel(BaseModel): model_config = ConfigDict(populate_by_name=True, extra="ignore") class Pagination(_RBACModel): total_count: int = 0 per_page: int = 0 current_page: int = 0 total_pages: int = 0 class Paginated[T](_RBACModel): data: list[T] = Field(default_factory=list) pagination: Pagination | None = None class MembersInRole(_RBACModel): account_id: str = "" account_name: str = "" class RBACResourceType(StrEnum): """Resource types understood by access policies.""" APP = "app" DATASET = "dataset" class RBACRoleType(StrEnum): """The only concrete role type after the access-policy refactor.""" WORKSPACE = "workspace" class PermissionCatalogItem(_RBACModel): key: str name: str description: str = "" class PermissionCatalogGroup(_RBACModel): group_key: str group_name: str description: str = "" permissions: list[PermissionCatalogItem] = Field(default_factory=list) class PermissionCatalogResponse(_RBACModel): groups: list[PermissionCatalogGroup] = Field(default_factory=list) class RBACRole(_RBACModel): id: str tenant_id: str | None = None type: str category: str = "" name: str description: str = "" is_builtin: bool = False permission_keys: list[str] = Field(default_factory=list) role_tag: str = "" @field_validator("permission_keys", mode="before") @classmethod def _coerce_permission_keys(cls, value: Any) -> list[str]: if value is None: return [] return value class RBACRoleAccount(_RBACModel): account_id: str account_name: str = "" email: str = "" avatar: str = "" class MemberRoleSummary(_RBACModel): id: str name: str class ResourcePermissionKeys(_RBACModel): resource_id: str permission_keys: list[str] = Field(default_factory=list) class ResourcePermissionKeysBatchResponse(_RBACModel): data: list[ResourcePermissionKeys] = Field(default_factory=list) class AccessPolicy(_RBACModel): id: str tenant_id: str = "" resource_type: str policy_key: str = "" name: str description: str = "" permission_keys: list[str] = Field(default_factory=list) is_builtin: bool = False category: str = "" created_at: int = 0 updated_at: int = 0 @field_validator("permission_keys", mode="before") @classmethod def _coerce_permission_keys(cls, value: Any) -> list[str]: if value is None: return [] return value class AccessPolicyRoleBinding(_RBACModel): id: str tenant_id: str = "" access_policy_id: str resource_type: str resource_id: str = "" role_id: str role_name: str = "" created_at: int = 0 class AccessPolicyMemberBinding(_RBACModel): id: str tenant_id: str = "" access_policy_id: str resource_type: str resource_id: str = "" account_id: str account_name: str = "" created_at: int = 0 class AccessPolicyBindingState(_RBACModel): binding_id: str is_locked: bool = False class AccessPolicyRole(BaseModel): role_id: str role_name: str binding_id: str is_locked: bool = False role_tag: str = "" class AccessPolicyAccount(BaseModel): account_id: str account_name: str binding_id: str is_locked: bool = False avatar: str = "" email: str = "" class AccessMatrixItem(_RBACModel): policy: AccessPolicy | None = None roles: list[AccessPolicyRole] = Field(default_factory=list) accounts: list[AccessPolicyAccount] = Field(default_factory=list) @field_validator("roles", "accounts", mode="before") @classmethod def _coerce_empty_lists(cls, value: Any) -> list[dict[str, Any]]: if value is None: return [] return value class AppAccessMatrix(_RBACModel): app_id: str = Field(default="", validation_alias=AliasChoices("app_id", "resource_id")) items: list[AccessMatrixItem] = Field(default_factory=list) class DatasetAccessMatrix(_RBACModel): dataset_id: str = Field(default="", validation_alias=AliasChoices("dataset_id", "resource_id")) items: list[AccessMatrixItem] = Field(default_factory=list) class WorkspaceAccessMatrix(_RBACModel): items: list[AccessMatrixItem] = Field(default_factory=list) pagination: Pagination | None = None class RoleBindingsResponse(_RBACModel): data: list[AccessPolicyRoleBinding] = Field(default_factory=list) class MemberBindingsResponse(_RBACModel): data: list[AccessPolicyMemberBinding] = Field(default_factory=list) class ResourceWhitelist(_RBACModel): account_ids: list[str] = Field(default_factory=list) @field_validator("account_ids", mode="before") @classmethod def _coerce_account_ids(cls, value: Any) -> list[str]: if value is None: return [] return value class ResourceWhitelistResources(_RBACModel): unrestricted: bool = False resource_ids: list[str] = Field(default_factory=list) @field_validator("resource_ids", mode="before") @classmethod def _coerce_resource_ids(cls, value: Any) -> list[str]: if value is None: return [] return value class ResourceUserAccessPolicies(_RBACModel): account: RBACRoleAccount roles: list[RBACRole] = Field(default_factory=list) access_policies: list[AccessPolicy] = Field(default_factory=list) @field_validator("access_policies", "roles", mode="before") @classmethod def _coerce_none_to_list(cls, value: Any) -> Any: if value is None: return [] return value class ResourceUserAccessPoliciesResponse(_RBACModel): scope: RBACResourceWhitelistScope data: list[ResourceUserAccessPolicies] = Field(default_factory=list) class ReplaceUserAccessPolicies(_RBACModel): access_policy_ids: list[str] = Field(default_factory=list) account_ids: list[str] = Field(default_factory=list) @field_validator("access_policy_ids", mode="before") @classmethod def _coerce_access_policy_ids(cls, value: Any) -> list[str]: if value is None: return [] return value class ReplaceUserAccessPoliciesResponse(_RBACModel): access_policies: list[AccessPolicy] = Field(default_factory=list) class MemberRolesResponse(_RBACModel): account_id: str roles: list[RBACRole] = Field(default_factory=list) class MemberRolesBatchResponse(_RBACModel): data: list[MemberRolesResponse] = Field(default_factory=list) class WorkspacePermissionSnapshot(_RBACModel): permission_keys: list[str] = Field(default_factory=list) class ResourcePermissionSnapshot(_RBACModel): default_permission_keys: list[str] = Field(default_factory=list) overrides: list[ResourcePermissionKeys] = Field(default_factory=list) def permission_keys_by_resource_ids(self, resource_ids: list[str]) -> dict[str, list[str]]: result = {str(resource_id): list(self.default_permission_keys) for resource_id in resource_ids} for override in self.overrides: resource_id = str(override.resource_id) if resource_id in result: result[resource_id] = list(override.permission_keys) return result class MyPermissionsResponse(_RBACModel): workspace: WorkspacePermissionSnapshot = Field(default_factory=WorkspacePermissionSnapshot) app: ResourcePermissionSnapshot = Field(default_factory=ResourcePermissionSnapshot) dataset: ResourcePermissionSnapshot = Field(default_factory=ResourcePermissionSnapshot) # Fallback permission snapshots for legacy Dify tenant roles when external RBAC is disabled. # Keep these keys aligned with langgenius/rbac's built-in workspace roles and access policies. _LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [ "workspace.member.manage", "workspace.role.manage", "data_source.manage", "api_extension.manage", "customization.manage", "plugin.install", "plugin.plugin_preferences", "plugin.model_config", "plugin.delete", "plugin.debug", "credential.use", "credential.create", "credential.manage", "app.acl.preview", "app_library.access", "app.create_and_management", "app.tag.manage", "dataset.acl.preview", "dataset.create_and_management", "dataset.tag.manage", "dataset.external.connect", "dataset.api_key.manage", "snippets.create_and_modify", "snippets.management", "tool.manage", "mcp.manage", ] _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [ "workspace.member.manage", "workspace.role.manage", "data_source.manage", "api_extension.manage", "customization.manage", "plugin.install", "plugin.plugin_preferences", "plugin.model_config", "plugin.delete", "plugin.debug", "credential.use", "credential.create", "credential.manage", "app_library.access", "app.create_and_management", "app.tag.manage", "dataset.create_and_management", "dataset.tag.manage", "dataset.external.connect", "dataset.api_key.manage", "snippets.create_and_modify", "snippets.management", "tool.manage", "mcp.manage", ] _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [ "api_extension.manage", "plugin.install", "credential.use", "app_library.access", "app.create_and_management", "app.tag.manage", "dataset.create_and_management", "dataset.tag.manage", "dataset.external.connect", "snippets.create_and_modify", "tool.manage", ] _LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [ "api_extension.manage", "plugin.install", "credential.use", "app_library.access", ] _LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [ "plugin.install", "dataset.create_and_management", "dataset.external.connect", ] _LEGACY_APP_OWNER_KEYS: list[str] = [ "app.acl.preview", "app.acl.view_layout", "app.acl.test_and_run", "app.acl.edit", "app.acl.import_export_dsl", "app.acl.delete", "app.acl.release_and_version", "app.acl.monitor", "app.acl.access_config", "app.acl.tracing_config", "app.acl.log_and_annotation", ] _LEGACY_APP_ADMIN_KEYS: list[str] = [ "app.acl.preview", "app.acl.view_layout", "app.acl.test_and_run", "app.acl.edit", "app.acl.import_export_dsl", "app.acl.delete", "app.acl.release_and_version", "app.acl.monitor", "app.acl.access_config", "app.acl.access_config", "app.acl.tracing_config", "app.acl.log_and_annotation", ] _LEGACY_APP_EDITOR_KEYS: list[str] = [ "app.acl.preview", "app.acl.view_layout", "app.acl.test_and_run", "app.acl.edit", "app.acl.import_export_dsl", "app.acl.delete", "app.acl.release_and_version", "app.acl.monitor", "app.acl.log_and_annotation", "app.acl.access_config", ] _LEGACY_APP_NORMAL_KEYS: list[str] = [ "app.acl.monitor", ] _LEGACY_DATASET_OWNER_KEYS: list[str] = [ "dataset.acl.preview", "dataset.acl.readonly", "dataset.acl.edit", "dataset.acl.import_export_dsl", "dataset.acl.pipeline_test", "dataset.acl.document_download", "dataset.acl.retrieval_recall", "dataset.acl.use", "dataset.acl.delete_file", "dataset.acl.pipeline_release", "dataset.acl.delete", "dataset.acl.access_config", "dataset.api_key.manage", ] _LEGACY_DATASET_ADMIN_KEYS: list[str] = [ "dataset.acl.preview", "dataset.acl.readonly", "dataset.acl.edit", "dataset.acl.import_export_dsl", "dataset.acl.pipeline_test", "dataset.acl.document_download", "dataset.acl.retrieval_recall", "dataset.acl.use", "dataset.acl.delete_file", "dataset.acl.pipeline_release", "dataset.acl.delete", "dataset.acl.access_config", "dataset.api_key.manage", ] _LEGACY_DATASET_EDITOR_KEYS: list[str] = [ "dataset.acl.preview", "dataset.acl.readonly", "dataset.acl.edit", "dataset.acl.import_export_dsl", "dataset.acl.pipeline_test", "dataset.acl.document_download", "dataset.acl.retrieval_recall", "dataset.acl.use", "dataset.acl.delete_file", "dataset.acl.pipeline_release", ] _LEGACY_DATASET_DATASET_OPERATOR_KEYS: list[str] = [ "dataset.acl.readonly", "dataset.acl.edit", "dataset.acl.import_export_dsl", "dataset.acl.pipeline_test", "dataset.acl.document_download", "dataset.acl.retrieval_recall", "dataset.acl.use", "dataset.acl.delete_file", "dataset.acl.pipeline_release", ] _LEGACY_MY_PERMISSIONS: dict[TenantAccountRole, dict[str, list[str]]] = { TenantAccountRole.OWNER: { "workspace": _LEGACY_WORKSPACE_OWNER_KEYS, "app": _LEGACY_APP_OWNER_KEYS, "dataset": _LEGACY_DATASET_OWNER_KEYS, }, TenantAccountRole.ADMIN: { "workspace": _LEGACY_WORKSPACE_ADMIN_KEYS, "app": _LEGACY_APP_ADMIN_KEYS, "dataset": _LEGACY_DATASET_ADMIN_KEYS, }, TenantAccountRole.EDITOR: { "workspace": _LEGACY_WORKSPACE_EDITOR_KEYS, "app": _LEGACY_APP_EDITOR_KEYS, "dataset": _LEGACY_DATASET_EDITOR_KEYS, }, TenantAccountRole.NORMAL: { "workspace": _LEGACY_WORKSPACE_NORMAL_KEYS, "app": _LEGACY_APP_NORMAL_KEYS, }, TenantAccountRole.DATASET_OPERATOR: { "workspace": _LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS, "dataset": _LEGACY_DATASET_DATASET_OPERATOR_KEYS, }, } def _legacy_role_permission_keys(role: TenantAccountRole) -> list[str]: permissions = _LEGACY_MY_PERMISSIONS.get(role, {}) return list( dict.fromkeys( [ *permissions.get("workspace", []), *permissions.get("app", []), *permissions.get("dataset", []), ] ) ) def _legacy_member_roles_response( tenant_id: str, member_account_id: str, role: TenantAccountRole | str | None ) -> MemberRolesResponse: if not role: return MemberRolesResponse(account_id=member_account_id, roles=[]) tenant_role = TenantAccountRole(role) role_value = tenant_role.value return MemberRolesResponse( account_id=member_account_id, roles=[ RBACRole( id=role_value, name=role_value, description="", is_builtin=True, type="", permission_keys=_legacy_role_permission_keys(tenant_role), role_tag="owner" if tenant_role == TenantAccountRole.OWNER else role_value, tenant_id=tenant_id, ) ], ) def _legacy_my_permissions(tenant_id: str, account_id: str | None, *, session: Session) -> MyPermissionsResponse: if not account_id: return MyPermissionsResponse() try: role = session.scalar( select(TenantAccountJoin.role).where( TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.account_id == account_id, ) ) if not role: return MyPermissionsResponse() try: tenant_role = TenantAccountRole(role) except ValueError: return MyPermissionsResponse() except SQLAlchemyError: return MyPermissionsResponse() permissions = _LEGACY_MY_PERMISSIONS.get(tenant_role, {}) return MyPermissionsResponse( workspace=WorkspacePermissionSnapshot(permission_keys=list(permissions.get("workspace", []))), app=ResourcePermissionSnapshot(default_permission_keys=list(permissions.get("app", []))), dataset=ResourcePermissionSnapshot(default_permission_keys=list(permissions.get("dataset", []))), ) def _legacy_resource_permission_keys_batch( tenant_id: str, account_id: str | None, resource_ids: list[str], resource_type: RBACResourceType, *, session: Session, ) -> dict[str, list[str]]: snapshot = _legacy_my_permissions(tenant_id, account_id, session=session) if resource_type == RBACResourceType.APP: permission_keys = snapshot.app.default_permission_keys else: permission_keys = snapshot.dataset.default_permission_keys return {str(resource_id): list(permission_keys) for resource_id in resource_ids} # ---------- Mutation request models ---------- class RoleMutation(_RBACModel): """Payload shared by role create & update. ``type`` defaults to ``workspace`` because that is the only concrete role type supported by the enterprise backend today (see biz.RBACRoleType). """ name: str description: str = "" permission_keys: list[str] = Field(default_factory=list) type: RBACRoleType = RBACRoleType.WORKSPACE class AccessPolicyCreate(_RBACModel): name: str resource_type: RBACResourceType description: str = "" permission_keys: list[str] = Field(default_factory=list) class AccessPolicyUpdate(_RBACModel): name: str description: str = "" permission_keys: list[str] = Field(default_factory=list) class ReplaceRoleBindings(_RBACModel): role_ids: list[str] = Field(default_factory=list) @field_validator("role_ids", mode="before") @classmethod def _coerce_role_ids(cls, value: Any) -> list[str]: if value is None: return [] return value class ReplaceMemberBindings(_RBACModel): scope: RBACResourceWhitelistScope = RBACResourceWhitelistScope.SPECIFIC @field_validator("scope") @classmethod def _normalize_scope(cls, value: Any) -> RBACResourceWhitelistScope: scope = str(value or "").strip().lower() if scope == "": return RBACResourceWhitelistScope.SPECIFIC try: return RBACResourceWhitelistScope(scope) except ValueError as exc: raise ValueError(f"invalid scope: {value}") from exc class DeleteMemberBindings(_RBACModel): account_ids: list[str] = Field(default_factory=list) @field_validator("account_ids", mode="before") @classmethod def _coerce_account_ids(cls, value: Any) -> list[str]: if value is None: return [] return value class ReplaceBindings(_RBACModel): role_ids: list[str] = Field(default_factory=list) account_ids: list[str] = Field(default_factory=list) @field_validator("role_ids", "account_ids", mode="before") @classmethod def _coerce_bindings(cls, value: Any) -> list[str]: if value is None: return [] return value class ListOption(_RBACModel): page_number: int | None = None results_per_page: int | None = None reverse: bool | None = None def to_params(self, extra: dict[str, Any] | None = None) -> dict[str, Any]: params: dict[str, Any] = {} if self.page_number is not None: params["page_number"] = self.page_number if self.results_per_page is not None: params["results_per_page"] = self.results_per_page if self.reverse is not None: # httpx renders `True` as the string "True"; we want the inner # handler to match on the lowercase form it compares against. params["reverse"] = "true" if self.reverse else "false" if extra: params.update({k: v for k, v in extra.items() if v is not None}) return params _INNER_PREFIX = "/rbac" def _request_language_param() -> str | None: if not has_request_context(): return None language = (request.args.get("language") or "").strip().lower() if language in {"en", "ja", "zh"}: return language return None def _inner_call( method: str, endpoint: str, *, tenant_id: str, account_id: str | None = None, json: Any | None = None, params: dict[str, Any] | None = None, ) -> Any: """Thin wrapper around `EnterpriseRequest.send_inner_rbac_request`. Kept as a module-level helper (rather than a nested-class method) so that unit tests can monkey-patch this single entry point instead of every individual `Roles.*`, `AccessPolicies.*`, … method. """ language = _request_language_param() if language and (not params or "language" not in params): params = dict(params or {}) params["language"] = language return EnterpriseRequest.send_inner_rbac_request( method, endpoint, tenant_id=tenant_id, account_id=account_id, json=json, params=params, timeout=dify_config.ENTERPRISE_RBAC_REQUEST_TIMEOUT, ) def _resource_id_params(resource_type: RBACResourceType | str, resource_id: str) -> dict[str, str]: resource_type_value = resource_type.value if isinstance(resource_type, RBACResourceType) else str(resource_type) resource_id = resource_id.strip() if resource_type_value == RBACResourceType.APP.value: return {"resource_type": resource_type_value, "app_id": resource_id} if resource_type_value == RBACResourceType.DATASET.value: return {"resource_type": resource_type_value, "dataset_id": resource_id} raise ValueError(f"unsupported resource_type: {resource_type_value}") def try_sync_creator_access_policy_member_bindings( tenant_id: str, account_id: str, resource_type: RBACResourceType | str, resource_id: str, ) -> None: if not dify_config.RBAC_ENABLED: return try: RBACService.AccessPolicies.sync_creator_access_policy_member_bindings( tenant_id, account_id, resource_type=resource_type, resource_id=resource_id, ) except Exception: logger.warning( "Failed to sync creator access policy member binding for " "tenant_id=%s resource_type=%s resource_id=%s account_id=%s", tenant_id, resource_type.value if isinstance(resource_type, RBACResourceType) else resource_type, resource_id, account_id, exc_info=True, ) class RBACService: """Single entry point grouping every inner RBAC call by feature area. Each nested class keeps the classmethods tightly scoped to one URL family so call sites read naturally (e.g. ``RBACService.Roles.create(tenant_id, account_id, payload)``). """ # ------------------------------------------------------------------ # Permission catalog (screenshot 3: 新增/编辑角色 弹窗内的权限列表). # ------------------------------------------------------------------ class Catalog: @staticmethod def workspace(tenant_id: str, account_id: str | None = None) -> PermissionCatalogResponse: data = _inner_call( "GET", f"{_INNER_PREFIX}/role-permissions/catalog", tenant_id=tenant_id, account_id=account_id, ) return PermissionCatalogResponse.model_validate(data or {}) @staticmethod def app(tenant_id: str, account_id: str | None = None) -> PermissionCatalogResponse: data = _inner_call( "GET", f"{_INNER_PREFIX}/role-permissions/catalog/app", tenant_id=tenant_id, account_id=account_id, ) return PermissionCatalogResponse.model_validate(data or {}) @staticmethod def dataset(tenant_id: str, account_id: str | None = None) -> PermissionCatalogResponse: data = _inner_call( "GET", f"{_INNER_PREFIX}/role-permissions/catalog/dataset", tenant_id=tenant_id, account_id=account_id, ) return PermissionCatalogResponse.model_validate(data or {}) # ------------------------------------------------------------------ # Role CRUD (Settings > Permissions). # ------------------------------------------------------------------ class Roles: @staticmethod def list( tenant_id: str, account_id: str | None = None, include_owner: int | None = None, *, options: ListOption | None = None, ) -> Paginated[RBACRole]: params = (options or ListOption()).to_params({"include_owner": include_owner}) params["dataset_operator_enabled"] = dify_config.DATASET_OPERATOR_ENABLED data = _inner_call( "GET", f"{_INNER_PREFIX}/roles", tenant_id=tenant_id, account_id=account_id, params=params or None, ) data = data or {} return Paginated[RBACRole]( data=[RBACRole.model_validate(item) for item in data.get("data") or []], pagination=Pagination.model_validate(data["pagination"]) if data.get("pagination") else None, ) @staticmethod def list_members_by_role( tenant_id: str, role_id: str | None = None, *, options: ListOption | None = None, ) -> Paginated[MembersInRole]: params = (options or ListOption()).to_params({"role_id": role_id}) data = _inner_call( "GET", f"{_INNER_PREFIX}/roles/members", tenant_id=tenant_id, params=params or None, ) data = data or {} return Paginated[MembersInRole]( data=[MembersInRole.model_validate(item) for item in data.get("data") or []], pagination=Pagination.model_validate(data["pagination"]) if data.get("pagination") else None, ) @staticmethod def get(tenant_id: str, account_id: str | None, role_id: str) -> RBACRole: data = _inner_call( "GET", f"{_INNER_PREFIX}/roles/item", tenant_id=tenant_id, account_id=account_id, params={"id": role_id}, ) return RBACRole.model_validate(data or {}) @staticmethod def create(tenant_id: str, account_id: str | None, payload: RoleMutation) -> RBACRole: data = _inner_call( "POST", f"{_INNER_PREFIX}/roles", tenant_id=tenant_id, account_id=account_id, json=payload.model_dump(mode="json"), ) return RBACRole.model_validate(data or {}) @staticmethod def update(tenant_id: str, account_id: str | None, role_id: str, payload: RoleMutation) -> RBACRole: data = _inner_call( "PUT", f"{_INNER_PREFIX}/roles/item", tenant_id=tenant_id, account_id=account_id, params={"id": role_id}, json=payload.model_dump(mode="json"), ) return RBACRole.model_validate(data or {}) @staticmethod def delete(tenant_id: str, account_id: str | None, role_id: str) -> None: _inner_call( "DELETE", f"{_INNER_PREFIX}/roles/item", tenant_id=tenant_id, account_id=account_id, params={"id": role_id}, ) @staticmethod def copy(tenant_id: str, account_id: str | None, role_id: str, copy_member: bool = True) -> RBACRole: data = _inner_call( "POST", f"{_INNER_PREFIX}/roles/copy", tenant_id=tenant_id, account_id=account_id, json={"copy_member": copy_member}, params={"id": role_id}, ) return RBACRole.model_validate(data or {}) @staticmethod def members( tenant_id: str, account_id: str | None, role_id: str, *, options: ListOption | None = None, ) -> Paginated[RBACRoleAccount]: params = (options or ListOption()).to_params({"role_id": role_id}) data = _inner_call( "GET", f"{_INNER_PREFIX}/roles/members", tenant_id=tenant_id, account_id=account_id, params=params, ) data = data or {} return Paginated[RBACRoleAccount]( data=[RBACRoleAccount.model_validate(item) for item in data.get("data") or []], pagination=Pagination.model_validate(data["pagination"]) if data.get("pagination") else None, ) # ------------------------------------------------------------------ # Access policies (Settings > Access Rules: create/edit permission sets). # ------------------------------------------------------------------ class AccessPolicies: @staticmethod def list( tenant_id: str, account_id: str | None = None, *, resource_type: RBACResourceType | str | None = None, options: ListOption | None = None, ) -> Paginated[AccessPolicy]: extra: dict[str, Any] = {} if resource_type is not None: extra["resource_type"] = ( resource_type.value if isinstance(resource_type, RBACResourceType) else resource_type ) params = (options or ListOption()).to_params(extra) data = _inner_call( "GET", f"{_INNER_PREFIX}/access-policies", tenant_id=tenant_id, account_id=account_id, params=params or None, ) data = data or {} return Paginated[AccessPolicy]( data=[AccessPolicy.model_validate(item) for item in data.get("data") or []], pagination=Pagination.model_validate(data["pagination"]) if data.get("pagination") else None, ) @staticmethod def get(tenant_id: str, account_id: str | None, policy_id: str) -> AccessPolicy: data = _inner_call( "GET", f"{_INNER_PREFIX}/access-policies/item", tenant_id=tenant_id, account_id=account_id, params={"id": policy_id}, ) return AccessPolicy.model_validate(data or {}) @staticmethod def create(tenant_id: str, account_id: str | None, payload: AccessPolicyCreate) -> AccessPolicy: data = _inner_call( "POST", f"{_INNER_PREFIX}/access-policies", tenant_id=tenant_id, account_id=account_id, json=payload.model_dump(mode="json"), ) return AccessPolicy.model_validate(data or {}) @staticmethod def update( tenant_id: str, account_id: str | None, policy_id: str, payload: AccessPolicyUpdate, ) -> AccessPolicy: data = _inner_call( "PUT", f"{_INNER_PREFIX}/access-policies/item", tenant_id=tenant_id, account_id=account_id, params={"id": policy_id}, json=payload.model_dump(mode="json"), ) return AccessPolicy.model_validate(data or {}) @staticmethod def copy(tenant_id: str, account_id: str | None, policy_id: str) -> AccessPolicy: data = _inner_call( "POST", f"{_INNER_PREFIX}/access-policies/copy", tenant_id=tenant_id, account_id=account_id, params={"id": policy_id}, ) return AccessPolicy.model_validate(data or {}) @staticmethod def delete(tenant_id: str, account_id: str | None, policy_id: str) -> None: _inner_call( "DELETE", f"{_INNER_PREFIX}/access-policies/item", tenant_id=tenant_id, account_id=account_id, params={"id": policy_id}, ) @staticmethod def sync_creator_access_policy_member_bindings( tenant_id: str, account_id: str | None, *, resource_type: RBACResourceType | str, resource_id: str, ) -> Sequence[AccessPolicyMemberBinding]: params = _resource_id_params(resource_type, resource_id) data = _inner_call( "PUT", f"{_INNER_PREFIX}/access-policies/creator-member-bindings", tenant_id=tenant_id, account_id=account_id, params=params, ) items: list[Any] = [] if isinstance(data, dict): items = data.get("data") or [] return [AccessPolicyMemberBinding.model_validate(item) for item in items] # ------------------------------------------------------------------ # Access-policy bindings (lock / unlock a single binding). # ------------------------------------------------------------------ class AccessPolicyBindings: @staticmethod def lock(tenant_id: str, account_id: str | None, binding_id: str) -> AccessPolicyBindingState: data = _inner_call( "PUT", f"{_INNER_PREFIX}/access-policy-bindings/lock", tenant_id=tenant_id, account_id=account_id, json={"binding_id": binding_id}, ) return AccessPolicyBindingState.model_validate(data or {}) @staticmethod def unlock(tenant_id: str, account_id: str | None, binding_id: str) -> AccessPolicyBindingState: data = _inner_call( "PUT", f"{_INNER_PREFIX}/access-policy-bindings/unlock", tenant_id=tenant_id, account_id=account_id, json={"binding_id": binding_id}, ) return AccessPolicyBindingState.model_validate(data or {}) # ------------------------------------------------------------------ # Per-app access (screenshot 1: App Access Config). # ------------------------------------------------------------------ class AppAccess: @staticmethod def whitelist_resources(tenant_id: str, account_id: str | None) -> ResourceWhitelistResources: data = _inner_call( "GET", f"{_INNER_PREFIX}/apps/whitelist/resources", tenant_id=tenant_id, account_id=account_id, ) return ResourceWhitelistResources.model_validate(data or {}) @staticmethod def user_access_policies( tenant_id: str, account_id: str | None, app_id: str ) -> ResourceUserAccessPoliciesResponse: data = _inner_call( "GET", f"{_INNER_PREFIX}/apps/user-access-policies", tenant_id=tenant_id, account_id=account_id, params={"app_id": app_id}, ) return ResourceUserAccessPoliciesResponse.model_validate(data or {}) @staticmethod def replace_user_access_policies( tenant_id: str, account_id: str | None, app_id: str, target_account_id: str | None, payload: ReplaceUserAccessPolicies, ) -> ReplaceUserAccessPoliciesResponse: request_data = payload.model_dump(mode="json") data = _inner_call( "PUT", f"{_INNER_PREFIX}/apps/user-access-policies", tenant_id=tenant_id, account_id=account_id, params={"app_id": app_id, "account_id": target_account_id}, json=request_data, ) return ReplaceUserAccessPoliciesResponse.model_validate(data or {}) @staticmethod def whitelist(tenant_id: str, account_id: str | None, app_id: str) -> ResourceWhitelist: data = _inner_call( "GET", f"{_INNER_PREFIX}/apps/whitelist", tenant_id=tenant_id, account_id=account_id, params={"app_id": app_id}, ) return ResourceWhitelist.model_validate(data or {}) @staticmethod def replace_whitelist( tenant_id: str, account_id: str | None, app_id: str, payload: ReplaceMemberBindings, ) -> ResourceWhitelist: data = _inner_call( "PUT", f"{_INNER_PREFIX}/apps/whitelist", tenant_id=tenant_id, account_id=account_id, params={"app_id": app_id}, json=payload.model_dump(mode="json"), ) return ResourceWhitelist.model_validate(data or {}) @staticmethod def matrix(tenant_id: str, account_id: str | None, app_id: str) -> AppAccessMatrix: data = _inner_call( "GET", f"{_INNER_PREFIX}/apps/access-policy", tenant_id=tenant_id, account_id=account_id, params={"app_id": app_id}, ) return AppAccessMatrix.model_validate(data or {}) @staticmethod def list_role_bindings( tenant_id: str, account_id: str | None, app_id: str, policy_id: str, ) -> RoleBindingsResponse: data = _inner_call( "GET", f"{_INNER_PREFIX}/apps/access-policy/role-bindings", tenant_id=tenant_id, account_id=account_id, params={"app_id": app_id, "policy_id": policy_id}, ) return RoleBindingsResponse.model_validate(data or {}) @staticmethod def replace_role_bindings( tenant_id: str, account_id: str | None, app_id: str, policy_id: str, payload: ReplaceRoleBindings, ) -> RoleBindingsResponse: data = _inner_call( "PUT", f"{_INNER_PREFIX}/apps/access-policy/role-bindings", tenant_id=tenant_id, account_id=account_id, params={"app_id": app_id, "policy_id": policy_id}, json=payload.model_dump(mode="json"), ) return RoleBindingsResponse.model_validate(data or {}) @staticmethod def list_member_bindings( tenant_id: str, account_id: str | None, app_id: str, policy_id: str, ) -> MemberBindingsResponse: data = _inner_call( "GET", f"{_INNER_PREFIX}/apps/access-policy/member-bindings", tenant_id=tenant_id, account_id=account_id, params={"app_id": app_id, "policy_id": policy_id}, ) return MemberBindingsResponse.model_validate(data or {}) @staticmethod def delete_member_bindings( tenant_id: str, account_id: str | None, app_id: str, policy_id: str, payload: DeleteMemberBindings, ) -> None: _inner_call( "DELETE", f"{_INNER_PREFIX}/apps/access-policy/member-bindings", tenant_id=tenant_id, account_id=account_id, params={"app_id": app_id, "policy_id": policy_id}, json=payload.model_dump(mode="json"), ) @staticmethod def replace_bindings( tenant_id: str, account_id: str | None, app_id: str, policy_id: str, payload: ReplaceBindings, ) -> AccessMatrixItem: data = _inner_call( "PUT", f"{_INNER_PREFIX}/apps/access-policy/bindings", tenant_id=tenant_id, account_id=account_id, params={"app_id": app_id, "policy_id": policy_id}, json=payload.model_dump(mode="json"), ) return AccessMatrixItem.model_validate(data or {}) # ------------------------------------------------------------------ # Per-dataset access (screenshot 1: Knowledge Base Access Config). # ------------------------------------------------------------------ class DatasetAccess: @staticmethod def whitelist_resources(tenant_id: str, account_id: str | None) -> ResourceWhitelistResources: data = _inner_call( "GET", f"{_INNER_PREFIX}/datasets/whitelist/resources", tenant_id=tenant_id, account_id=account_id, ) return ResourceWhitelistResources.model_validate(data or {}) @staticmethod def user_access_policies( tenant_id: str, account_id: str | None, dataset_id: str ) -> ResourceUserAccessPoliciesResponse: data = _inner_call( "GET", f"{_INNER_PREFIX}/datasets/user-access-policies", tenant_id=tenant_id, account_id=account_id, params={"dataset_id": dataset_id}, ) return ResourceUserAccessPoliciesResponse.model_validate(data or {}) @staticmethod def replace_user_access_policies( tenant_id: str, account_id: str | None, dataset_id: str, target_account_id: str | None, payload: ReplaceUserAccessPolicies, ) -> ReplaceUserAccessPoliciesResponse: data = _inner_call( "PUT", f"{_INNER_PREFIX}/datasets/user-access-policies", tenant_id=tenant_id, account_id=account_id, params={"dataset_id": dataset_id, "account_id": target_account_id}, json=payload.model_dump(mode="json", exclude_unset=True), ) return ReplaceUserAccessPoliciesResponse.model_validate(data or {}) @staticmethod def whitelist(tenant_id: str, account_id: str | None, dataset_id: str) -> ResourceWhitelist: data = _inner_call( "GET", f"{_INNER_PREFIX}/datasets/whitelist", tenant_id=tenant_id, account_id=account_id, params={"dataset_id": dataset_id}, ) return ResourceWhitelist.model_validate(data or {}) @staticmethod def replace_whitelist( tenant_id: str, account_id: str | None, dataset_id: str, payload: ReplaceMemberBindings, ) -> ResourceWhitelist: data = _inner_call( "PUT", f"{_INNER_PREFIX}/datasets/whitelist", tenant_id=tenant_id, account_id=account_id, params={"dataset_id": dataset_id}, json=payload.model_dump(mode="json"), ) return ResourceWhitelist.model_validate(data or {}) @staticmethod def matrix(tenant_id: str, account_id: str | None, dataset_id: str) -> DatasetAccessMatrix: data = _inner_call( "GET", f"{_INNER_PREFIX}/datasets/access-policy", tenant_id=tenant_id, account_id=account_id, params={"dataset_id": dataset_id}, ) return DatasetAccessMatrix.model_validate(data or {}) @staticmethod def list_role_bindings( tenant_id: str, account_id: str | None, dataset_id: str, policy_id: str, ) -> RoleBindingsResponse: data = _inner_call( "GET", f"{_INNER_PREFIX}/datasets/access-policy/role-bindings", tenant_id=tenant_id, account_id=account_id, params={"dataset_id": dataset_id, "policy_id": policy_id}, ) return RoleBindingsResponse.model_validate(data or {}) @staticmethod def replace_role_bindings( tenant_id: str, account_id: str | None, dataset_id: str, policy_id: str, payload: ReplaceRoleBindings, ) -> RoleBindingsResponse: data = _inner_call( "PUT", f"{_INNER_PREFIX}/datasets/access-policy/role-bindings", tenant_id=tenant_id, account_id=account_id, params={"dataset_id": dataset_id, "policy_id": policy_id}, json=payload.model_dump(mode="json"), ) return RoleBindingsResponse.model_validate(data or {}) @staticmethod def list_member_bindings( tenant_id: str, account_id: str | None, dataset_id: str, policy_id: str, ) -> MemberBindingsResponse: data = _inner_call( "GET", f"{_INNER_PREFIX}/datasets/access-policy/member-bindings", tenant_id=tenant_id, account_id=account_id, params={"dataset_id": dataset_id, "policy_id": policy_id}, ) return MemberBindingsResponse.model_validate(data or {}) @staticmethod def delete_member_bindings( tenant_id: str, account_id: str | None, dataset_id: str, policy_id: str, payload: DeleteMemberBindings, ) -> None: _inner_call( "DELETE", f"{_INNER_PREFIX}/datasets/access-policy/member-bindings", tenant_id=tenant_id, account_id=account_id, params={"dataset_id": dataset_id, "policy_id": policy_id}, json=payload.model_dump(mode="json"), ) @staticmethod def replace_bindings( tenant_id: str, account_id: str | None, dataset_id: str, policy_id: str, payload: ReplaceBindings, ) -> AccessMatrixItem: data = _inner_call( "PUT", f"{_INNER_PREFIX}/datasets/access-policy/bindings", tenant_id=tenant_id, account_id=account_id, params={"dataset_id": dataset_id, "policy_id": policy_id}, json=payload.model_dump(mode="json"), ) return AccessMatrixItem.model_validate(data or {}) # ------------------------------------------------------------------ # Workspace-level access (screenshot 2: Settings > Access Rules). # ------------------------------------------------------------------ class WorkspaceAccess: @staticmethod def app_matrix( tenant_id: str, account_id: str | None = None, *, options: ListOption | None = None, ) -> WorkspaceAccessMatrix: data = _inner_call( "GET", f"{_INNER_PREFIX}/workspace/apps/access-policy", tenant_id=tenant_id, account_id=account_id, params=(options or ListOption()).to_params() or None, ) return WorkspaceAccessMatrix.model_validate(data or {}) @staticmethod def dataset_matrix( tenant_id: str, account_id: str | None = None, *, options: ListOption | None = None, ) -> WorkspaceAccessMatrix: data = _inner_call( "GET", f"{_INNER_PREFIX}/workspace/datasets/access-policy", tenant_id=tenant_id, account_id=account_id, params=(options or ListOption()).to_params() or None, ) return WorkspaceAccessMatrix.model_validate(data or {}) @staticmethod def list_app_role_bindings( tenant_id: str, account_id: str | None, policy_id: str, ) -> RoleBindingsResponse: data = _inner_call( "GET", f"{_INNER_PREFIX}/workspace/apps/access-policy/role-bindings", tenant_id=tenant_id, account_id=account_id, params={"policy_id": policy_id}, ) return RoleBindingsResponse.model_validate(data or {}) @staticmethod def replace_app_role_bindings( tenant_id: str, account_id: str | None, policy_id: str, payload: ReplaceRoleBindings, ) -> RoleBindingsResponse: data = _inner_call( "PUT", f"{_INNER_PREFIX}/workspace/apps/access-policy/role-bindings", tenant_id=tenant_id, account_id=account_id, params={"policy_id": policy_id}, json=payload.model_dump(mode="json"), ) return RoleBindingsResponse.model_validate(data or {}) @staticmethod def list_app_member_bindings( tenant_id: str, account_id: str | None, policy_id: str, ) -> MemberBindingsResponse: data = _inner_call( "GET", f"{_INNER_PREFIX}/workspace/apps/access-policy/member-bindings", tenant_id=tenant_id, account_id=account_id, params={"policy_id": policy_id}, ) return MemberBindingsResponse.model_validate(data or {}) @staticmethod def replace_app_bindings( tenant_id: str, account_id: str | None, policy_id: str, payload: ReplaceBindings, ) -> AccessMatrixItem: data = _inner_call( "PUT", f"{_INNER_PREFIX}/workspace/apps/access-policy/bindings", tenant_id=tenant_id, account_id=account_id, params={"policy_id": policy_id}, json=payload.model_dump(mode="json"), ) return AccessMatrixItem.model_validate(data or {}) @staticmethod def list_dataset_role_bindings( tenant_id: str, account_id: str | None, policy_id: str, ) -> RoleBindingsResponse: data = _inner_call( "GET", f"{_INNER_PREFIX}/workspace/datasets/access-policy/role-bindings", tenant_id=tenant_id, account_id=account_id, params={"policy_id": policy_id}, ) return RoleBindingsResponse.model_validate(data or {}) @staticmethod def replace_dataset_role_bindings( tenant_id: str, account_id: str | None, policy_id: str, payload: ReplaceRoleBindings, ) -> RoleBindingsResponse: data = _inner_call( "PUT", f"{_INNER_PREFIX}/workspace/datasets/access-policy/role-bindings", tenant_id=tenant_id, account_id=account_id, params={"policy_id": policy_id}, json=payload.model_dump(mode="json"), ) return RoleBindingsResponse.model_validate(data or {}) @staticmethod def list_dataset_member_bindings( tenant_id: str, account_id: str | None, policy_id: str, ) -> MemberBindingsResponse: data = _inner_call( "GET", f"{_INNER_PREFIX}/workspace/datasets/access-policy/member-bindings", tenant_id=tenant_id, account_id=account_id, params={"policy_id": policy_id}, ) return MemberBindingsResponse.model_validate(data or {}) @staticmethod def replace_dataset_bindings( tenant_id: str, account_id: str | None, policy_id: str, payload: ReplaceBindings, ) -> AccessMatrixItem: data = _inner_call( "PUT", f"{_INNER_PREFIX}/workspace/datasets/access-policy/bindings", tenant_id=tenant_id, account_id=account_id, params={"policy_id": policy_id}, json=payload.model_dump(mode="json"), ) return AccessMatrixItem.model_validate(data or {}) class MemberRoles: @staticmethod def get( tenant_id: str, account_id: str | None, member_account_id: str, *, session: Session ) -> MemberRolesResponse: if dify_config.RBAC_ENABLED: data = _inner_call( "GET", f"{_INNER_PREFIX}/members/rbac-roles", tenant_id=tenant_id, account_id=account_id, params={"account_id": member_account_id}, ) rst = MemberRolesResponse.model_validate(data or {}) return rst else: role = session.scalar( select(TenantAccountJoin.role).where( TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.account_id == member_account_id, ) ) return _legacy_member_roles_response(tenant_id, member_account_id, role) @staticmethod def batch_get( tenant_id: str, account_id: str | None, member_account_ids: list[str], ) -> list[MemberRolesResponse]: data = _inner_call( "POST", f"{_INNER_PREFIX}/members/rbac-roles/batch", tenant_id=tenant_id, account_id=account_id, json={"member_ids": member_account_ids}, ) items = [] if isinstance(data, dict): items = [{"account_id": account_id, "roles": roles} for account_id, roles in data.items()] rst = [] for item in items: tmp = MemberRolesResponse.model_validate(item) rst.append(tmp) return rst @staticmethod def replace( tenant_id: str, account_id: str | None, member_account_id: str, role_ids: list[str], *, session: Session, ) -> MemberRolesResponse: if not dify_config.RBAC_ENABLED: if len(role_ids) != 1: raise ValueError("Legacy workspace member role update requires exactly one role.") tenant_role = TenantAccountRole(role_ids[0]) target_member_join = session.scalar( select(TenantAccountJoin).where( TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.account_id == member_account_id, ) ) if not target_member_join: raise ValueError("Member not in tenant.") if tenant_role == TenantAccountRole.OWNER: current_owner_join = session.scalar( select(TenantAccountJoin).where( TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.role == TenantAccountRole.OWNER, ) ) if current_owner_join and current_owner_join.account_id != member_account_id: current_owner_join.role = TenantAccountRole.ADMIN target_member_join.role = tenant_role session.commit() return _legacy_member_roles_response(tenant_id, member_account_id, tenant_role) data = _inner_call( "PUT", f"{_INNER_PREFIX}/members/rbac-roles", tenant_id=tenant_id, account_id=account_id, params={"account_id": member_account_id}, json={"role_ids": role_ids}, ) return MemberRolesResponse.model_validate(data or {}) @staticmethod def delete_rbac_bindings(tenant_id: str, account_id: str): data = _inner_call( "DELETE", f"{_INNER_PREFIX}/members/rbac-bindings", tenant_id=tenant_id, account_id=account_id, params={"account_id": account_id}, ) return data class CheckAccess: """Call the ``/inner/api/rbac/check-access`` endpoint.""" @staticmethod def check( tenant_id: str, account_id: str | None, *, scene: str, resource_type: str | None = None, resource_id: str | None = None, ) -> bool: """Return ``True`` if the account is allowed, ``False`` otherwise.""" if not dify_config.RBAC_ENABLED: return True payload: dict[str, Any] = { "account_id": account_id or "", "tenant_id": tenant_id, "scene": scene, } if resource_type: payload["resource_type"] = resource_type if resource_id: payload["resource_id"] = resource_id data = _inner_call( "POST", f"{_INNER_PREFIX}/check-access", tenant_id=tenant_id, account_id=account_id, json=payload, ) return bool(data.get("allowed", False)) class AppPermissions: @staticmethod def batch_get( tenant_id: str, account_id: str | None, app_ids: list[str], *, session: Session, ) -> dict[str, list[str]]: if not app_ids: return {} if not dify_config.RBAC_ENABLED: return _legacy_resource_permission_keys_batch( tenant_id, account_id, app_ids, RBACResourceType.APP, session=session ) data = _inner_call( "POST", f"{_INNER_PREFIX}/apps/permission-keys/batch", tenant_id=tenant_id, account_id=account_id, json={"app_ids": app_ids}, ) return _parse_resource_permission_keys_batch(data, resource_id_key="app_id") class DatasetPermissions: @staticmethod def batch_get( tenant_id: str, account_id: str | None, dataset_ids: list[str], *, session: Session, ) -> dict[str, list[str]]: if not dataset_ids: return {} if not dify_config.RBAC_ENABLED: return _legacy_resource_permission_keys_batch( tenant_id, account_id, dataset_ids, RBACResourceType.DATASET, session=session ) data = _inner_call( "POST", f"{_INNER_PREFIX}/datasets/permission-keys/batch", tenant_id=tenant_id, account_id=account_id, json={"dataset_ids": dataset_ids}, ) return _parse_resource_permission_keys_batch(data, resource_id_key="dataset_id") class MyPermissions: @staticmethod def get( tenant_id: str, account_id: str | None, *, app_id: str | None = None, dataset_id: str | None = None, session: Session, ) -> MyPermissionsResponse: if not dify_config.RBAC_ENABLED: return _legacy_my_permissions(tenant_id, account_id, session=session) data = _inner_call( "GET", f"{_INNER_PREFIX}/my-permissions", tenant_id=tenant_id, account_id=account_id, params={ k: v for k, v in { "app_id": app_id, "dataset_id": dataset_id, }.items() if v is not None } or None, ) return MyPermissionsResponse.model_validate(data or {}) def _parse_resource_permission_keys_batch(data: Any, *, resource_id_key: str) -> dict[str, list[str]]: if not data: return {} if isinstance(data, dict): permissions = data.get("permissions") if isinstance(permissions, dict): return {str(key): [str(item) for item in (value or [])] for key, value in permissions.items()} items = data.get("data") if items is None: items = data.get("items") if items is None: items = data.get("apps") if resource_id_key == "app_id" else data.get("datasets") if isinstance(items, dict): items = [{"resource_id": key, "permission_keys": value} for key, value in items.items()] elif isinstance(data, list): items = data else: items = [] result: dict[str, list[str]] = {} for item in items or []: if not isinstance(item, dict): continue resource_id = item.get("resource_id") or item.get(resource_id_key) if not resource_id: continue permission_keys = item.get("permission_keys") or [] result[str(resource_id)] = [str(permission_key) for permission_key in permission_keys] return result