From 3a525a609c1c1ec1956bf9570b227ce75e0fb447 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Thu, 7 May 2026 14:16:02 +0800 Subject: [PATCH] feat: rbac service (#35874) Co-authored-by: twwu --- api/controllers/console/__init__.py | 2 + api/controllers/console/workspace/rbac.py | 643 +++++++++++++++ api/services/enterprise/base.py | 51 ++ api/services/enterprise/rbac_service.py | 774 ++++++++++++++++++ .../console/workspace/test_rbac.py | 124 +++ .../services/enterprise/test_rbac_service.py | 315 +++++++ .../[appId]/access-config/page.tsx | 16 + .../(appDetailLayout)/[appId]/layout-main.tsx | 99 ++- .../[datasetId]/access-config/page.tsx | 15 + .../[datasetId]/layout-main.tsx | 9 + .../components/access-config-modal/index.tsx | 120 +++ .../components/access-rules-editor/index.tsx | 103 +++ .../components/app/access-config/index.tsx | 74 ++ .../apps/app-access-config-modal/index.tsx | 108 +++ web/app/components/apps/app-card.tsx | 26 + .../datasets/access-config/index.tsx | 83 ++ .../__tests__/operations.spec.tsx | 9 + .../__tests__/dataset-card-modals.spec.tsx | 3 + .../__tests__/operations-dropdown.spec.tsx | 1 + .../components/dataset-card-modals.tsx | 15 + .../components/operations-dropdown.tsx | 3 + .../dataset-access-config-modal/index.tsx | 108 +++ .../hooks/use-dataset-card-state.ts | 12 + .../datasets/list/dataset-card/index.tsx | 4 + .../datasets/list/dataset-card/operations.tsx | 17 +- .../access-rule-row-menu.tsx | 69 ++ .../access-rules-page/access-rule-row.tsx | 86 ++ .../access-rules-page/access-rule-section.tsx | 62 ++ .../add-rule-targets-modal/index.tsx | 361 ++++++++ .../access-rules-page/index.tsx | 260 ++++++ .../permission-set-modal/index.tsx | 225 +++++ .../permission-picker.tsx | 197 +++++ .../permission-set-modal/permissions-data.ts | 103 +++ .../access-rules-page/role-tag.tsx | 39 + .../header/account-setting/constants.ts | 2 + .../header/account-setting/index.tsx | 23 +- .../members-page/__tests__/index.spec.tsx | 72 +- .../members-page/assign-roles-modal/index.tsx | 218 +++++ .../account-setting/members-page/index.tsx | 96 ++- .../member-details-modal/index.tsx | 145 ++++ .../permission-role-chip.tsx | 102 +++ .../member-details-modal/role-permissions.ts | 36 + .../members-page/member-menu.tsx | 134 +++ .../members-page/member-row.tsx | 117 +++ .../members-page/role-badges.tsx | 53 ++ .../permissions-page/index.tsx | 186 +++++ .../permissions-page/role-list/index.tsx | 70 ++ .../permissions-page/role-list/row-menu.tsx | 74 ++ .../permissions-page/role-list/row.tsx | 53 ++ .../permissions-page/role-modal/index.tsx | 151 ++++ .../role-modal/permission-field.tsx | 62 ++ .../role-modal/permission-picker.tsx | 173 ++++ .../role-modal/permissions-data.ts | 66 ++ web/context/provider-context-provider.tsx | 1 + web/context/provider-context.ts | 2 + web/i18n/en-US/common.json | 27 + web/i18n/zh-Hans/common.json | 27 + 57 files changed, 5930 insertions(+), 96 deletions(-) create mode 100644 api/controllers/console/workspace/rbac.py create mode 100644 api/services/enterprise/rbac_service.py create mode 100644 api/tests/unit_tests/controllers/console/workspace/test_rbac.py create mode 100644 api/tests/unit_tests/services/enterprise/test_rbac_service.py create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/page.tsx create mode 100644 web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/access-config/page.tsx create mode 100644 web/app/components/access-config-modal/index.tsx create mode 100644 web/app/components/access-rules-editor/index.tsx create mode 100644 web/app/components/app/access-config/index.tsx create mode 100644 web/app/components/apps/app-access-config-modal/index.tsx create mode 100644 web/app/components/datasets/access-config/index.tsx create mode 100644 web/app/components/datasets/list/dataset-card/dataset-access-config-modal/index.tsx create mode 100644 web/app/components/header/account-setting/access-rules-page/access-rule-row-menu.tsx create mode 100644 web/app/components/header/account-setting/access-rules-page/access-rule-row.tsx create mode 100644 web/app/components/header/account-setting/access-rules-page/access-rule-section.tsx create mode 100644 web/app/components/header/account-setting/access-rules-page/add-rule-targets-modal/index.tsx create mode 100644 web/app/components/header/account-setting/access-rules-page/index.tsx create mode 100644 web/app/components/header/account-setting/access-rules-page/permission-set-modal/index.tsx create mode 100644 web/app/components/header/account-setting/access-rules-page/permission-set-modal/permission-picker.tsx create mode 100644 web/app/components/header/account-setting/access-rules-page/permission-set-modal/permissions-data.ts create mode 100644 web/app/components/header/account-setting/access-rules-page/role-tag.tsx create mode 100644 web/app/components/header/account-setting/members-page/assign-roles-modal/index.tsx create mode 100644 web/app/components/header/account-setting/members-page/member-details-modal/index.tsx create mode 100644 web/app/components/header/account-setting/members-page/member-details-modal/permission-role-chip.tsx create mode 100644 web/app/components/header/account-setting/members-page/member-details-modal/role-permissions.ts create mode 100644 web/app/components/header/account-setting/members-page/member-menu.tsx create mode 100644 web/app/components/header/account-setting/members-page/member-row.tsx create mode 100644 web/app/components/header/account-setting/members-page/role-badges.tsx create mode 100644 web/app/components/header/account-setting/permissions-page/index.tsx create mode 100644 web/app/components/header/account-setting/permissions-page/role-list/index.tsx create mode 100644 web/app/components/header/account-setting/permissions-page/role-list/row-menu.tsx create mode 100644 web/app/components/header/account-setting/permissions-page/role-list/row.tsx create mode 100644 web/app/components/header/account-setting/permissions-page/role-modal/index.tsx create mode 100644 web/app/components/header/account-setting/permissions-page/role-modal/permission-field.tsx create mode 100644 web/app/components/header/account-setting/permissions-page/role-modal/permission-picker.tsx create mode 100644 web/app/components/header/account-setting/permissions-page/role-modal/permissions-data.ts diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index 7302a4edf5..ad04f2af6e 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -139,6 +139,7 @@ from .workspace import ( model_providers, models, plugin, + rbac, snippets, tool_providers, trigger_providers, @@ -208,6 +209,7 @@ __all__ = [ "rag_pipeline_draft_variable", "rag_pipeline_import", "rag_pipeline_workflow", + "rbac", "recommended_app", "saved_message", "setup", diff --git a/api/controllers/console/workspace/rbac.py b/api/controllers/console/workspace/rbac.py new file mode 100644 index 0000000000..172154488d --- /dev/null +++ b/api/controllers/console/workspace/rbac.py @@ -0,0 +1,643 @@ +"""Dify Console controllers that proxy the enterprise RBAC surface. + +Each route here is a thin adapter: it validates the pydantic payload shown in +the screenshots (`Settings > Permissions`, `Settings > Access Rules`, +`App/Knowledge Base Access Config`, and the `Settings > Members` role +assignment dialog), pulls ``tenant_id`` / ``account_id`` from the current +Dify session and forwards to the inner RBAC client defined in +``services/enterprise/rbac_service.py``. The client then calls the +``/inner/api/rbac/*`` endpoints on dify-enterprise over HTTP using the +shared ``Enterprise-Api-Secret-Key`` header. +""" + +from __future__ import annotations + +from collections.abc import Callable +from functools import wraps +from typing import Any + +from flask_restx import Resource +from pydantic import BaseModel, ValidationError +from werkzeug.exceptions import Forbidden, NotFound + +from configs import dify_config +from controllers.console import console_ns +from controllers.console.wraps import account_initialization_required, setup_required +from libs.login import current_account_with_tenant, login_required +from services.enterprise import rbac_service as svc + + +# --------------------------------------------------------------------------- +# Shared helpers. +# --------------------------------------------------------------------------- + + +def enterprise_only[**P, R](view: Callable[P, R]) -> Callable[P, R]: + """Reject every call when the Dify install is not running in enterprise + mode. The dashboard UI shown in the screenshots is an enterprise-only + feature, so every route here should fail fast (and clearly) in community. + """ + + @wraps(view) + def decorated(*args: P.args, **kwargs: P.kwargs) -> R: + if not dify_config.ENTERPRISE_ENABLED: + raise Forbidden("Enterprise edition is not enabled") + return view(*args, **kwargs) + + return decorated + + +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") + + +# --------------------------------------------------------------------------- +# Permission catalogs. +# --------------------------------------------------------------------------- + + +@console_ns.route("/workspaces/current/rbac/role-permissions/catalog") +class RBACWorkspaceCatalogApi(Resource): + @enterprise_only + @setup_required + @login_required + @account_initialization_required + 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): + @enterprise_only + @setup_required + @login_required + @account_initialization_required + 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): + @enterprise_only + @setup_required + @login_required + @account_initialization_required + 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 + role_key: str + description: str = "" + permission_keys: list[str] = [] + + def to_mutation(self) -> svc.RoleMutation: + return svc.RoleMutation( + name=self.name, + role_key=self.role_key, + description=self.description, + permission_keys=list(self.permission_keys), + ) + + +@console_ns.route("/workspaces/current/rbac/roles") +class RBACRolesApi(Resource): + @enterprise_only + @setup_required + @login_required + @account_initialization_required + def get(self): + tenant_id, account_id = _current_ids() + options = svc.ListOption() + return _dump(svc.RBACService.Roles.list(tenant_id, account_id, options=options)) + + @enterprise_only + @setup_required + @login_required + @account_initialization_required + def post(self): + tenant_id, account_id = _current_ids() + request = _payload(_RoleUpsertRequest) + role = svc.RBACService.Roles.create(tenant_id, account_id, request.to_mutation()) + return _dump(role), 201 + + +@console_ns.route("/workspaces/current/rbac/roles/") +class RBACRoleItemApi(Resource): + @enterprise_only + @setup_required + @login_required + @account_initialization_required + def get(self, role_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.Roles.get(tenant_id, account_id, str(role_id))) + + @enterprise_only + @setup_required + @login_required + @account_initialization_required + 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) + + @enterprise_only + @setup_required + @login_required + @account_initialization_required + 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"} + + +# --------------------------------------------------------------------------- +# 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): + @enterprise_only + @setup_required + @login_required + @account_initialization_required + 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. + from flask import request + + resource_type = request.args.get("resource_type") or None + return _dump( + svc.RBACService.AccessPolicies.list( + tenant_id, + account_id, + resource_type=resource_type, + options=svc.ListOption(), + ) + ) + + @enterprise_only + @setup_required + @login_required + @account_initialization_required + def post(self): + tenant_id, account_id = _current_ids() + request = _payload(_AccessPolicyCreateRequest) + policy = svc.RBACService.AccessPolicies.create( + tenant_id, + account_id, + svc.AccessPolicyCreate( + name=request.name, + resource_type=request.resource_type, + description=request.description, + permission_keys=list(request.permission_keys), + ), + ) + return _dump(policy), 201 + + +@console_ns.route("/workspaces/current/rbac/access-policies/") +class RBACAccessPolicyItemApi(Resource): + @enterprise_only + @setup_required + @login_required + @account_initialization_required + def get(self, policy_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.AccessPolicies.get(tenant_id, account_id, str(policy_id))) + + @enterprise_only + @setup_required + @login_required + @account_initialization_required + 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) + + @enterprise_only + @setup_required + @login_required + @account_initialization_required + def delete(self, policy_id): + tenant_id, account_id = _current_ids() + svc.RBACService.AccessPolicies.delete(tenant_id, account_id, str(policy_id)) + return {"result": "success"} + + +@console_ns.route("/workspaces/current/rbac/access-policies//copy") +class RBACAccessPolicyCopyApi(Resource): + @enterprise_only + @setup_required + @login_required + @account_initialization_required + 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 + + +# --------------------------------------------------------------------------- +# Per-app access (App Access Config). +# --------------------------------------------------------------------------- + + +class _ReplaceRoleBindingsRequest(BaseModel): + role_keys: list[str] = [] + + +class _ReplaceMemberBindingsRequest(BaseModel): + account_ids: list[str] = [] + + +@console_ns.route("/workspaces/current/rbac/apps//access-policy") +class RBACAppMatrixApi(Resource): + @enterprise_only + @setup_required + @login_required + @account_initialization_required + def get(self, app_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.AppAccess.matrix(tenant_id, account_id, str(app_id))) + + +@console_ns.route("/workspaces/current/rbac/apps//access-policies//role-bindings") +class RBACAppRoleBindingsApi(Resource): + @enterprise_only + @setup_required + @login_required + @account_initialization_required + 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)) + ) + + @enterprise_only + @setup_required + @login_required + @account_initialization_required + def put(self, app_id, policy_id): + tenant_id, account_id = _current_ids() + request = _payload(_ReplaceRoleBindingsRequest) + return _dump( + svc.RBACService.AppAccess.replace_role_bindings( + tenant_id, + account_id, + str(app_id), + str(policy_id), + svc.ReplaceRoleBindings(role_keys=list(request.role_keys)), + ) + ) + + +@console_ns.route("/workspaces/current/rbac/apps//access-policies//member-bindings") +class RBACAppMemberBindingsApi(Resource): + @enterprise_only + @setup_required + @login_required + @account_initialization_required + 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)) + ) + + @enterprise_only + @setup_required + @login_required + @account_initialization_required + def put(self, app_id, policy_id): + tenant_id, account_id = _current_ids() + request = _payload(_ReplaceMemberBindingsRequest) + return _dump( + svc.RBACService.AppAccess.replace_member_bindings( + tenant_id, + account_id, + str(app_id), + str(policy_id), + svc.ReplaceMemberBindings(account_ids=list(request.account_ids)), + ) + ) + + +# --------------------------------------------------------------------------- +# Per-dataset access (Knowledge Base Access Config). +# --------------------------------------------------------------------------- + + +@console_ns.route("/workspaces/current/rbac/datasets//access-policy") +class RBACDatasetMatrixApi(Resource): + @enterprise_only + @setup_required + @login_required + @account_initialization_required + def get(self, dataset_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.DatasetAccess.matrix(tenant_id, account_id, str(dataset_id))) + + +@console_ns.route("/workspaces/current/rbac/datasets//access-policies//role-bindings") +class RBACDatasetRoleBindingsApi(Resource): + @enterprise_only + @setup_required + @login_required + @account_initialization_required + 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) + ) + ) + + @enterprise_only + @setup_required + @login_required + @account_initialization_required + def put(self, dataset_id, policy_id): + tenant_id, account_id = _current_ids() + request = _payload(_ReplaceRoleBindingsRequest) + return _dump( + svc.RBACService.DatasetAccess.replace_role_bindings( + tenant_id, + account_id, + str(dataset_id), + str(policy_id), + svc.ReplaceRoleBindings(role_keys=list(request.role_keys)), + ) + ) + + +@console_ns.route( + "/workspaces/current/rbac/datasets//access-policies//member-bindings" +) +class RBACDatasetMemberBindingsApi(Resource): + @enterprise_only + @setup_required + @login_required + @account_initialization_required + 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) + ) + ) + + @enterprise_only + @setup_required + @login_required + @account_initialization_required + def put(self, dataset_id, policy_id): + tenant_id, account_id = _current_ids() + request = _payload(_ReplaceMemberBindingsRequest) + return _dump( + svc.RBACService.DatasetAccess.replace_member_bindings( + tenant_id, + account_id, + str(dataset_id), + str(policy_id), + svc.ReplaceMemberBindings(account_ids=list(request.account_ids)), + ) + ) + + +# --------------------------------------------------------------------------- +# Workspace-level access (Settings > Access Rules). +# --------------------------------------------------------------------------- + + +@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policy") +class RBACWorkspaceAppMatrixApi(Resource): + @enterprise_only + @setup_required + @login_required + @account_initialization_required + def get(self): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.WorkspaceAccess.app_matrix(tenant_id, account_id)) + + +@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policies//role-bindings") +class RBACWorkspaceAppRoleBindingsApi(Resource): + @enterprise_only + @setup_required + @login_required + @account_initialization_required + 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)) + ) + + @enterprise_only + @setup_required + @login_required + @account_initialization_required + def put(self, policy_id): + tenant_id, account_id = _current_ids() + request = _payload(_ReplaceRoleBindingsRequest) + return _dump( + svc.RBACService.WorkspaceAccess.replace_app_role_bindings( + tenant_id, + account_id, + str(policy_id), + svc.ReplaceRoleBindings(role_keys=list(request.role_keys)), + ) + ) + + +@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policies//member-bindings") +class RBACWorkspaceAppMemberBindingsApi(Resource): + @enterprise_only + @setup_required + @login_required + @account_initialization_required + 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)) + ) + + @enterprise_only + @setup_required + @login_required + @account_initialization_required + def put(self, policy_id): + tenant_id, account_id = _current_ids() + request = _payload(_ReplaceMemberBindingsRequest) + return _dump( + svc.RBACService.WorkspaceAccess.replace_app_member_bindings( + tenant_id, + account_id, + str(policy_id), + svc.ReplaceMemberBindings(account_ids=list(request.account_ids)), + ) + ) + + +@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policy") +class RBACWorkspaceDatasetMatrixApi(Resource): + @enterprise_only + @setup_required + @login_required + @account_initialization_required + def get(self): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.WorkspaceAccess.dataset_matrix(tenant_id, account_id)) + + +@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policies//role-bindings") +class RBACWorkspaceDatasetRoleBindingsApi(Resource): + @enterprise_only + @setup_required + @login_required + @account_initialization_required + 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)) + ) + + @enterprise_only + @setup_required + @login_required + @account_initialization_required + def put(self, policy_id): + tenant_id, account_id = _current_ids() + request = _payload(_ReplaceRoleBindingsRequest) + return _dump( + svc.RBACService.WorkspaceAccess.replace_dataset_role_bindings( + tenant_id, + account_id, + str(policy_id), + svc.ReplaceRoleBindings(role_keys=list(request.role_keys)), + ) + ) + + +@console_ns.route("/workspaces/current/rbac/workspace/datasets/access-policies//member-bindings") +class RBACWorkspaceDatasetMemberBindingsApi(Resource): + @enterprise_only + @setup_required + @login_required + @account_initialization_required + 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)) + ) + + @enterprise_only + @setup_required + @login_required + @account_initialization_required + def put(self, policy_id): + tenant_id, account_id = _current_ids() + request = _payload(_ReplaceMemberBindingsRequest) + return _dump( + svc.RBACService.WorkspaceAccess.replace_dataset_member_bindings( + tenant_id, + account_id, + str(policy_id), + svc.ReplaceMemberBindings(account_ids=list(request.account_ids)), + ) + ) + + +# --------------------------------------------------------------------------- +# Member ↔ role bindings (Settings > Members > Assign roles). +# --------------------------------------------------------------------------- + + +class _ReplaceMemberRolesRequest(BaseModel): + role_keys: list[str] = [] + + +@console_ns.route("/workspaces/current/rbac/members//rbac-roles") +class RBACMemberRolesApi(Resource): + @enterprise_only + @setup_required + @login_required + @account_initialization_required + def get(self, member_id): + tenant_id, account_id = _current_ids() + return _dump(svc.RBACService.MemberRoles.get(tenant_id, account_id, str(member_id))) + + @enterprise_only + @setup_required + @login_required + @account_initialization_required + 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_keys=list(request.role_keys), + ) + ) diff --git a/api/services/enterprise/base.py b/api/services/enterprise/base.py index 68835e76d0..1d22da00f4 100644 --- a/api/services/enterprise/base.py +++ b/api/services/enterprise/base.py @@ -5,6 +5,7 @@ from typing import Any import httpx +from configs import dify_config from core.helper.trace_id_helper import generate_traceparent_header from services.errors.enterprise import ( EnterpriseAPIBadRequestError, @@ -16,6 +17,11 @@ from services.errors.enterprise import ( 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 = { @@ -49,8 +55,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() @@ -122,6 +136,43 @@ class EnterpriseRequest(BaseRequest): 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 dify_config.ENTERPRISE_ENABLED: + raise EnterpriseAPIError("Enterprise edition is not enabled") + 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 + return cls.send_request( + method, + endpoint, + json=json, + params=params, + timeout=timeout, + extra_headers=inner_headers, + ) + class EnterprisePluginManagerRequest(BaseRequest): base_url = os.environ.get("ENTERPRISE_PLUGIN_MANAGER_API_URL", "ENTERPRISE_PLUGIN_MANAGER_API_URL") diff --git a/api/services/enterprise/rbac_service.py b/api/services/enterprise/rbac_service.py new file mode 100644 index 0000000000..bf34447918 --- /dev/null +++ b/api/services/enterprise/rbac_service.py @@ -0,0 +1,774 @@ +from __future__ import annotations + +from enum import StrEnum +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel, ConfigDict, Field + +from services.enterprise.base import EnterpriseRequest + +T = TypeVar("T") + + +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(_RBACModel, Generic[T]): + data: list[T] = Field(default_factory=list) + pagination: Pagination | None = None + + +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 = "" + role_key: str + name: str + description: str = "" + is_builtin: bool = False + permission_keys: list[str] = 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 + + +class AccessPolicyRoleBinding(_RBACModel): + id: str + tenant_id: str = "" + access_policy_id: str + resource_type: str + resource_id: str = "" + role_key: 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 + created_at: int = 0 + + +class AccessMatrixItem(_RBACModel): + policy: AccessPolicy | None = None + role_keys: list[str] = Field(default_factory=list) + account_ids: list[str] = Field(default_factory=list) + + +class AppAccessMatrix(_RBACModel): + app_id: str = "" + items: list[AccessMatrixItem] = Field(default_factory=list) + + +class DatasetAccessMatrix(_RBACModel): + dataset_id: str = "" + items: list[AccessMatrixItem] = Field(default_factory=list) + + +class WorkspaceAccessMatrix(_RBACModel): + items: list[AccessMatrixItem] = Field(default_factory=list) + + +class RoleBindingsResponse(_RBACModel): + data: list[AccessPolicyRoleBinding] = Field(default_factory=list) + + +class MemberBindingsResponse(_RBACModel): + data: list[AccessPolicyMemberBinding] = Field(default_factory=list) + + +class MemberRolesResponse(_RBACModel): + account_id: str + roles: list[RBACRole] = Field(default_factory=list) + + +# ---------- 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 + role_key: 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_keys: list[str] = Field(default_factory=list) + + +class ReplaceMemberBindings(_RBACModel): + account_ids: list[str] = Field(default_factory=list) + + +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 _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. + """ + return EnterpriseRequest.send_inner_rbac_request( + method, + endpoint, + tenant_id=tenant_id, + account_id=account_id, + json=json, + params=params, + ) + + +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, + *, + options: ListOption | None = None, + ) -> Paginated[RBACRole]: + params = (options or ListOption()).to_params() + 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 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}, + ) + + # ------------------------------------------------------------------ + # 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}, + ) + + # ------------------------------------------------------------------ + # Per-app access (screenshot 1: App Access Config). + # ------------------------------------------------------------------ + class AppAccess: + @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 replace_member_bindings( + tenant_id: str, + account_id: str | None, + app_id: str, + policy_id: str, + payload: ReplaceMemberBindings, + ) -> MemberBindingsResponse: + data = _inner_call( + "PUT", + 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"), + ) + return MemberBindingsResponse.model_validate(data or {}) + + # ------------------------------------------------------------------ + # Per-dataset access (screenshot 1: Knowledge Base Access Config). + # ------------------------------------------------------------------ + class DatasetAccess: + @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 replace_member_bindings( + tenant_id: str, + account_id: str | None, + dataset_id: str, + policy_id: str, + payload: ReplaceMemberBindings, + ) -> MemberBindingsResponse: + data = _inner_call( + "PUT", + 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"), + ) + return MemberBindingsResponse.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) -> WorkspaceAccessMatrix: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/workspace/apps/access-policy", + tenant_id=tenant_id, + account_id=account_id, + ) + return WorkspaceAccessMatrix.model_validate(data or {}) + + @staticmethod + def dataset_matrix(tenant_id: str, account_id: str | None = None) -> WorkspaceAccessMatrix: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/workspace/datasets/access-policy", + tenant_id=tenant_id, + account_id=account_id, + ) + 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_member_bindings( + tenant_id: str, + account_id: str | None, + policy_id: str, + payload: ReplaceMemberBindings, + ) -> MemberBindingsResponse: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/workspace/apps/access-policy/member-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + return MemberBindingsResponse.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_member_bindings( + tenant_id: str, + account_id: str | None, + policy_id: str, + payload: ReplaceMemberBindings, + ) -> MemberBindingsResponse: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/workspace/datasets/access-policy/member-bindings", + tenant_id=tenant_id, + account_id=account_id, + params={"policy_id": policy_id}, + json=payload.model_dump(mode="json"), + ) + return MemberBindingsResponse.model_validate(data or {}) + + # ------------------------------------------------------------------ + # Member ↔ role bindings (screenshot 3: Settings > Members > Assign roles). + # ------------------------------------------------------------------ + class MemberRoles: + @staticmethod + def get(tenant_id: str, account_id: str | None, member_account_id: str) -> MemberRolesResponse: + data = _inner_call( + "GET", + f"{_INNER_PREFIX}/members/rbac-roles", + tenant_id=tenant_id, + account_id=account_id, + params={"account_id": member_account_id}, + ) + return MemberRolesResponse.model_validate(data or {}) + + @staticmethod + def replace( + tenant_id: str, + account_id: str | None, + member_account_id: str, + role_keys: list[str], + ) -> MemberRolesResponse: + data = _inner_call( + "PUT", + f"{_INNER_PREFIX}/members/rbac-roles", + tenant_id=tenant_id, + account_id=account_id, + params={"account_id": member_account_id}, + json={"role_keys": role_keys}, + ) + return MemberRolesResponse.model_validate(data or {}) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_rbac.py b/api/tests/unit_tests/controllers/console/workspace/test_rbac.py new file mode 100644 index 0000000000..664ffe4b0c --- /dev/null +++ b/api/tests/unit_tests/controllers/console/workspace/test_rbac.py @@ -0,0 +1,124 @@ +"""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 three Flask-layer concerns the service layer cannot +exercise: + +* ``enterprise_only`` rejects community-edition calls with 403 (it is the + outermost decorator, so it fires before any auth middleware). +* ``_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 + +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 TestEnterpriseGate: + """``enterprise_only`` is the outermost decorator on every resource, so we + can exercise it directly — no auth stubs required. + """ + + def test_catalog_forbidden_when_disabled(self, app): + with app.test_request_context("/workspaces/current/rbac/role-permissions/catalog"), _enabled(False): + with pytest.raises(Forbidden): + rbac_mod.RBACWorkspaceCatalogApi().get() + + def test_roles_post_forbidden_when_disabled(self, app): + with ( + app.test_request_context("/workspaces/current/rbac/roles", method="POST", json={}), + _enabled(False), + ): + with pytest.raises(Forbidden): + rbac_mod.RBACRolesApi().post() + + +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 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_and_key(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", + "role_key": "workspace.owner", + "description": "full access", + "permission_keys": ["workspace.member.manage"], + } + ) + mutation = payload.to_mutation() + assert mutation.role_key == "workspace.owner" + 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_replace_role_bindings_defaults_empty(self): + parsed = rbac_mod._ReplaceRoleBindingsRequest.model_validate({}) + assert parsed.role_keys == [] + + +class TestDumpHelper: + def test_dump_returns_plain_dict(self): + role = rbac_mod.svc.RBACRole(id="role-1", type="workspace", role_key="workspace.owner", name="Owner") + dumped = rbac_mod._dump(role) + assert isinstance(dumped, dict) + assert dumped["role_key"] == "workspace.owner" diff --git a/api/tests/unit_tests/services/enterprise/test_rbac_service.py b/api/tests/unit_tests/services/enterprise/test_rbac_service.py new file mode 100644 index 0000000000..27ce50b76b --- /dev/null +++ b/api/tests/unit_tests/services/enterprise/test_rbac_service.py @@ -0,0 +1,315 @@ +"""Unit tests for services.enterprise.rbac_service. + +The enterprise RBAC client is almost pure glue: each method turns a single +``EnterpriseRequest.send_inner_rbac_request`` call into a pydantic response +model. Rather than spinning up an HTTP server we monkeypatch that helper and +assert on the arguments it received; that catches both routing regressions +(wrong method / wrong path / wrong params) and model-shape regressions in +one place. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from services.enterprise import rbac_service as svc + +MODULE = "services.enterprise.rbac_service" + + +@pytest.fixture +def mock_send(): + with patch(f"{MODULE}.EnterpriseRequest.send_inner_rbac_request") as send: + yield send + + +def _call_args(send: MagicMock) -> SimpleNamespace: + """Return the most recent (method, endpoint, kwargs) sent to the mock.""" + send.assert_called_once() + args, kwargs = send.call_args + return SimpleNamespace(method=args[0], endpoint=args[1], **kwargs) + + +class TestCatalog: + def test_workspace_catalog(self, mock_send: MagicMock): + mock_send.return_value = {"groups": [{"group_key": "workspace", "group_name": "工作空间", "permissions": []}]} + + out = svc.RBACService.Catalog.workspace("tenant-1", account_id="acct-1") + + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/role-permissions/catalog" + assert call.tenant_id == "tenant-1" + assert call.account_id == "acct-1" + assert call.json is None + assert call.params is None + assert len(out.groups) == 1 + assert out.groups[0].group_key == "workspace" + + def test_app_catalog_endpoint(self, mock_send: MagicMock): + mock_send.return_value = {"groups": []} + svc.RBACService.Catalog.app("tenant-1") + assert mock_send.call_args.args[1] == "/rbac/role-permissions/catalog/app" + + def test_dataset_catalog_endpoint(self, mock_send: MagicMock): + mock_send.return_value = {"groups": []} + svc.RBACService.Catalog.dataset("tenant-1") + assert mock_send.call_args.args[1] == "/rbac/role-permissions/catalog/dataset" + + +class TestRoles: + def test_list_forwards_pagination_options(self, mock_send: MagicMock): + mock_send.return_value = { + "data": [ + { + "id": "role-1", + "tenant_id": "tenant-1", + "type": "workspace", + "category": "global_custom", + "role_key": "workspace.owner", + "name": "Owner", + "permission_keys": ["workspace.member.manage"], + } + ], + "pagination": {"total_count": 1, "per_page": 20, "current_page": 1, "total_pages": 1}, + } + + out = svc.RBACService.Roles.list( + "tenant-1", + "acct-1", + options=svc.ListOption(page_number=2, results_per_page=50, reverse=True), + ) + + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/roles" + assert call.params == {"page_number": 2, "results_per_page": 50, "reverse": "true"} + assert out.pagination and out.pagination.total_count == 1 + assert out.data[0].role_key == "workspace.owner" + + def test_list_omits_params_when_default(self, mock_send: MagicMock): + mock_send.return_value = {"data": [], "pagination": None} + svc.RBACService.Roles.list("tenant-1") + assert _call_args(mock_send).params is None + + def test_get_passes_id_query_param(self, mock_send: MagicMock): + mock_send.return_value = { + "id": "role-1", + "type": "workspace", + "role_key": "workspace.owner", + "name": "Owner", + } + svc.RBACService.Roles.get("tenant-1", "acct-1", "role-1") + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/roles/item" + assert call.params == {"id": "role-1"} + + def test_create_sends_body(self, mock_send: MagicMock): + mock_send.return_value = { + "id": "role-1", + "type": "workspace", + "role_key": "workspace.owner", + "name": "Owner", + } + payload = svc.RoleMutation( + name="Owner", + role_key="workspace.owner", + description="full access", + permission_keys=["workspace.member.manage"], + ) + svc.RBACService.Roles.create("tenant-1", "acct-1", payload) + + call = _call_args(mock_send) + assert call.method == "POST" + assert call.endpoint == "/rbac/roles" + assert call.json == { + "name": "Owner", + "role_key": "workspace.owner", + "description": "full access", + "permission_keys": ["workspace.member.manage"], + "type": "workspace", + } + + def test_update_sends_id_param_and_body(self, mock_send: MagicMock): + mock_send.return_value = { + "id": "role-1", + "type": "workspace", + "role_key": "workspace.owner", + "name": "Owner", + } + payload = svc.RoleMutation(name="Owner", role_key="workspace.owner", permission_keys=["x"]) + svc.RBACService.Roles.update("tenant-1", "acct-1", "role-1", payload) + + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/roles/item" + assert call.params == {"id": "role-1"} + assert call.json["role_key"] == "workspace.owner" + + def test_delete_uses_delete_method(self, mock_send: MagicMock): + mock_send.return_value = {"message": "success"} + svc.RBACService.Roles.delete("tenant-1", None, "role-1") + + call = _call_args(mock_send) + assert call.method == "DELETE" + assert call.endpoint == "/rbac/roles/item" + assert call.params == {"id": "role-1"} + assert call.account_id is None + + +class TestAccessPolicies: + def test_list_filters_by_resource_type(self, mock_send: MagicMock): + mock_send.return_value = {"data": [], "pagination": None} + svc.RBACService.AccessPolicies.list( + "tenant-1", + "acct-1", + resource_type=svc.RBACResourceType.APP, + options=svc.ListOption(page_number=1), + ) + call = _call_args(mock_send) + assert call.endpoint == "/rbac/access-policies" + assert call.params == {"page_number": 1, "resource_type": "app"} + + def test_copy_sends_post_with_id_param(self, mock_send: MagicMock): + mock_send.return_value = { + "id": "policy-1-copy", + "resource_type": "app", + "name": "Full access copy", + } + svc.RBACService.AccessPolicies.copy("tenant-1", "acct-1", "policy-1") + call = _call_args(mock_send) + assert call.method == "POST" + assert call.endpoint == "/rbac/access-policies/copy" + assert call.params == {"id": "policy-1"} + + def test_create_serialises_resource_type_enum(self, mock_send: MagicMock): + mock_send.return_value = {"id": "policy-1", "resource_type": "dataset", "name": "KB only"} + payload = svc.AccessPolicyCreate( + name="KB only", + resource_type=svc.RBACResourceType.DATASET, + permission_keys=["dataset.acl.readonly"], + ) + svc.RBACService.AccessPolicies.create("tenant-1", "acct-1", payload) + call = _call_args(mock_send) + assert call.method == "POST" + assert call.json == { + "name": "KB only", + "resource_type": "dataset", + "description": "", + "permission_keys": ["dataset.acl.readonly"], + } + + +class TestResourceAccess: + def test_app_matrix(self, mock_send: MagicMock): + mock_send.return_value = {"app_id": "app-1", "items": []} + out = svc.RBACService.AppAccess.matrix("tenant-1", "acct-1", "app-1") + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/apps/access-policy" + assert call.params == {"app_id": "app-1"} + assert out.app_id == "app-1" + + def test_app_replace_role_bindings(self, mock_send: MagicMock): + mock_send.return_value = {"data": []} + payload = svc.ReplaceRoleBindings(role_keys=["workspace.owner"]) + svc.RBACService.AppAccess.replace_role_bindings("tenant-1", "acct-1", "app-1", "policy-1", payload) + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/apps/access-policy/role-bindings" + assert call.params == {"app_id": "app-1", "policy_id": "policy-1"} + assert call.json == {"role_keys": ["workspace.owner"]} + + def test_dataset_replace_member_bindings(self, mock_send: MagicMock): + mock_send.return_value = {"data": []} + payload = svc.ReplaceMemberBindings(account_ids=["acct-2"]) + svc.RBACService.DatasetAccess.replace_member_bindings( + "tenant-1", "acct-1", "ds-1", "policy-1", payload + ) + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/datasets/access-policy/member-bindings" + assert call.params == {"dataset_id": "ds-1", "policy_id": "policy-1"} + assert call.json == {"account_ids": ["acct-2"]} + + +class TestWorkspaceAccess: + def test_app_matrix(self, mock_send: MagicMock): + mock_send.return_value = {"items": []} + svc.RBACService.WorkspaceAccess.app_matrix("tenant-1") + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/workspace/apps/access-policy" + assert call.params is None + + def test_dataset_matrix(self, mock_send: MagicMock): + mock_send.return_value = {"items": []} + svc.RBACService.WorkspaceAccess.dataset_matrix("tenant-1") + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/workspace/datasets/access-policy" + assert call.params is None + + def test_dataset_replace_role_bindings(self, mock_send: MagicMock): + mock_send.return_value = {"data": []} + payload = svc.ReplaceRoleBindings(role_keys=["workspace.editor"]) + svc.RBACService.WorkspaceAccess.replace_dataset_role_bindings( + "tenant-1", "acct-1", "policy-1", payload + ) + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/workspace/datasets/access-policy/role-bindings" + assert call.params == {"policy_id": "policy-1"} + assert call.json == {"role_keys": ["workspace.editor"]} + + +class TestMemberRoles: + def test_get(self, mock_send: MagicMock): + mock_send.return_value = { + "account_id": "acct-2", + "roles": [ + { + "id": "role-1", + "type": "workspace", + "role_key": "workspace.member", + "name": "Member", + } + ], + } + out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2") + call = _call_args(mock_send) + assert call.method == "GET" + assert call.endpoint == "/rbac/members/rbac-roles" + assert call.params == {"account_id": "acct-2"} + assert out.account_id == "acct-2" + assert out.roles[0].role_key == "workspace.member" + + def test_replace(self, mock_send: MagicMock): + mock_send.return_value = {"account_id": "acct-2", "roles": []} + svc.RBACService.MemberRoles.replace( + "tenant-1", "acct-1", "acct-2", role_keys=["workspace.owner", "workspace.editor"] + ) + call = _call_args(mock_send) + assert call.method == "PUT" + assert call.endpoint == "/rbac/members/rbac-roles" + assert call.params == {"account_id": "acct-2"} + assert call.json == {"role_keys": ["workspace.owner", "workspace.editor"]} + + +class TestListOption: + def test_empty_produces_empty_params(self): + assert svc.ListOption().to_params() == {} + + def test_reverse_serialises_as_lowercase_bool(self): + assert svc.ListOption(reverse=False).to_params()["reverse"] == "false" + assert svc.ListOption(reverse=True).to_params()["reverse"] == "true" + + def test_extra_overrides_merge(self): + assert svc.ListOption(page_number=1).to_params({"resource_type": "app", "skip": None}) == { + "page_number": 1, + "resource_type": "app", + } diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/page.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/page.tsx new file mode 100644 index 0000000000..85ce85bc39 --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-config/page.tsx @@ -0,0 +1,16 @@ +import type { Locale } from '@/i18n-config' +import AppAccessConfigPage from '@/app/components/app/access-config' + +export type AccessConfigPageProps = { + params: Promise<{ locale: Locale, appId: string }> +} + +const AccessConfig = async (props: AccessConfigPageProps) => { + const params = await props.params + + const { appId } = params + + return +} + +export default AccessConfig diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index 5c3a237aca..6172762652 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -12,6 +12,8 @@ import { RiTerminalBoxLine, RiTerminalWindowFill, RiTerminalWindowLine, + RiUserSettingsFill, + RiUserSettingsLine, } from '@remixicon/react' import { useUnmount } from 'ahooks' import * as React from 'react' @@ -73,51 +75,58 @@ const AppDetailLayout: FC = (props) => { }>>([]) const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => { - const navConfig = [] - - if (isCurrentWorkspaceEditor) { - navConfig.push({ - name: t('appMenus.promptEng', { ns: 'common' }), - href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`, - icon: RiTerminalWindowLine, - selectedIcon: RiTerminalWindowFill, - }) - } - - navConfig.push({ - name: t('appMenus.apiAccess', { ns: 'common' }), - href: `/app/${appId}/develop`, - icon: RiTerminalBoxLine, - selectedIcon: RiTerminalBoxFill, - }) - - if (isCurrentWorkspaceEditor) { - navConfig.push({ - name: mode !== AppModeEnum.WORKFLOW - ? t('appMenus.logAndAnn', { ns: 'common' }) - : t('appMenus.logs', { ns: 'common' }), - href: `/app/${appId}/logs`, - icon: RiFileList3Line, - selectedIcon: RiFileList3Fill, - }) - } - - navConfig.push({ - name: t('appMenus.overview', { ns: 'common' }), - href: `/app/${appId}/overview`, - icon: RiDashboard2Line, - selectedIcon: RiDashboard2Fill, - }) - - if (isCurrentWorkspaceEditor && canAccessSnippetsAndEvaluation) { - navConfig.push({ - name: t('appMenus.evaluation', { ns: 'common' }), - href: `/app/${appId}/evaluation`, - icon: EvaluationIcon, - selectedIcon: EvaluationIcon, - }) - } - + const navConfig = [ + ...(isCurrentWorkspaceEditor + ? [{ + name: t('appMenus.promptEng', { ns: 'common' }), + href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`, + icon: RiTerminalWindowLine, + selectedIcon: RiTerminalWindowFill, + }] + : [] + ), + { + name: t('appMenus.apiAccess', { ns: 'common' }), + href: `/app/${appId}/develop`, + icon: RiTerminalBoxLine, + selectedIcon: RiTerminalBoxFill, + }, + ...(isCurrentWorkspaceEditor + ? [{ + name: mode !== AppModeEnum.WORKFLOW + ? t('appMenus.logAndAnn', { ns: 'common' }) + : t('appMenus.logs', { ns: 'common' }), + href: `/app/${appId}/logs`, + icon: RiFileList3Line, + selectedIcon: RiFileList3Fill, + }] + : [] + ), + { + name: t('appMenus.overview', { ns: 'common' }), + href: `/app/${appId}/overview`, + icon: RiDashboard2Line, + selectedIcon: RiDashboard2Fill, + }, + ...(isCurrentWorkspaceEditor && canAccessSnippetsAndEvaluation + ? [{ + name: t('appMenus.evaluation', { ns: 'common' }), + href: `/app/${appId}/evaluation`, + icon: EvaluationIcon, + selectedIcon: EvaluationIcon, + }] + : [] + ), + ...(isCurrentWorkspaceEditor + ? [{ + name: 'Access Config', + href: `/app/${appId}/access-config`, + icon: RiUserSettingsLine, + selectedIcon: RiUserSettingsFill, + }] + : [] + ), + ] return navConfig }, [canAccessSnippetsAndEvaluation, t]) diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/access-config/page.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/access-config/page.tsx new file mode 100644 index 0000000000..66a809a795 --- /dev/null +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/access-config/page.tsx @@ -0,0 +1,15 @@ +import DatasetAccessConfigPage from '@/app/components/datasets/access-config' + +type Props = { + params: Promise<{ datasetId: string }> +} + +const AccessConfig = async (props: Props) => { + const params = await props.params + + const { datasetId } = params + + return +} + +export default AccessConfig diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index 179557b40e..6e6ed5fedf 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -9,6 +9,8 @@ import { RiFileTextLine, RiFocus2Fill, RiFocus2Line, + RiUserSettingsFill, + RiUserSettingsLine, } from '@remixicon/react' import * as React from 'react' import { useEffect, useMemo, useState } from 'react' @@ -90,6 +92,13 @@ const DatasetDetailLayout: FC = (props) => { selectedIcon: RiEqualizer2Fill, disabled: false, }, + { + name: 'Access Config', + href: `/datasets/${datasetId}/access-config`, + icon: RiUserSettingsLine, + selectedIcon: RiUserSettingsFill, + disabled: false, + }, ] if (datasetRes?.provider !== 'external') { diff --git a/web/app/components/access-config-modal/index.tsx b/web/app/components/access-config-modal/index.tsx new file mode 100644 index 0000000000..a0052bd397 --- /dev/null +++ b/web/app/components/access-config-modal/index.tsx @@ -0,0 +1,120 @@ +'use client' + +import type { AccessRule } from '@/app/components/header/account-setting/access-rules-page/access-rule-row' +import { Button } from '@langgenius/dify-ui/button' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' +import { ScrollArea } from '@langgenius/dify-ui/scroll-area' +import { useCallback, useState } from 'react' +import AccessRulesEditor from '@/app/components/access-rules-editor' + +export type AccessConfigModalProps = { + open: boolean + title: string + description: string + initialRules: AccessRule[] + /** + * Optional override label for the primary action. Defaults to "Save". + */ + saveLabel?: string + /** + * Optional override label for the cancel action. Defaults to "Cancel". + */ + cancelLabel?: string + onClose: () => void + onSave?: (rules: AccessRule[]) => void +} + +type AccessConfigModalBodyProps = Omit + +const AccessConfigModalBody = ({ + title, + description, + initialRules, + saveLabel = 'Save', + cancelLabel = 'Cancel', + onClose, + onSave, +}: AccessConfigModalBodyProps) => { + const [rules, setRules] = useState(initialRules) + + const handleSave = useCallback(() => { + onSave?.(rules) + onClose() + }, [onClose, onSave, rules]) + + return ( + +
+ +
+ + {title} + + + {description} + +
+
+ + + + + +
+ + +
+
+ ) +} + +const AccessConfigModal = ({ + open, + title, + description, + initialRules, + saveLabel, + cancelLabel, + onClose, + onSave, +}: AccessConfigModalProps) => { + return ( + { + if (!nextOpen) + onClose() + }} + > + {open && ( + + )} + + ) +} + +export default AccessConfigModal diff --git a/web/app/components/access-rules-editor/index.tsx b/web/app/components/access-rules-editor/index.tsx new file mode 100644 index 0000000000..82a11ae4c5 --- /dev/null +++ b/web/app/components/access-rules-editor/index.tsx @@ -0,0 +1,103 @@ +'use client' + +import type { + AccessRule, + AssignedRole, +} from '@/app/components/header/account-setting/access-rules-page/access-rule-row' +import { cn } from '@langgenius/dify-ui/cn' +import { useCallback, useState } from 'react' +import AccessRuleRow from '@/app/components/header/account-setting/access-rules-page/access-rule-row' +import AddRuleTargetsModal from '@/app/components/header/account-setting/access-rules-page/add-rule-targets-modal' + +export type AccessRulesEditorProps = { + rules: AccessRule[] + /** + * Called whenever assigned roles/members are mutated. The editor is + * controlled when this callback is provided, uncontrolled (with internal + * state seeded from `rules`) otherwise. + */ + onRulesChange?: (rules: AccessRule[]) => void + className?: string +} + +const AccessRulesEditor = ({ + rules: rulesProp, + onRulesChange, + className, +}: AccessRulesEditorProps) => { + const isControlled = typeof onRulesChange === 'function' + const [internalRules, setInternalRules] = useState(rulesProp) + const rules = isControlled ? rulesProp : internalRules + + const updateRules = useCallback( + (updater: (prev: AccessRule[]) => AccessRule[]) => { + if (isControlled) { + onRulesChange(updater(rulesProp)) + return + } + setInternalRules(prev => updater(prev)) + }, + [isControlled, onRulesChange, rulesProp], + ) + + const [addingRule, setAddingRule] = useState(null) + + const handleAddRole = useCallback((rule: AccessRule) => { + setAddingRule(rule) + }, []) + + const handleCloseAddModal = useCallback(() => { + setAddingRule(null) + }, []) + + const handleAddSubmit = useCallback( + (_selection: { roleIds: string[], memberIds: string[] }) => { + // TODO: wire up to API when backend is ready. + }, + [], + ) + + const handleRemoveRole = useCallback( + (target: AccessRule, role: AssignedRole) => { + updateRules(prev => + prev.map(rule => + rule.id === target.id + ? { + ...rule, + assignedRoles: rule.assignedRoles.filter(r => r.id !== role.id), + } + : rule, + ), + ) + }, + [updateRules], + ) + + return ( +
+ {rules.map((rule, index) => ( + 0 && 'border-t border-divider-subtle')} + /> + ))} + + {addingRule && ( + role.id)} + initialMemberIds={[]} + onClose={handleCloseAddModal} + onSubmit={handleAddSubmit} + /> + )} +
+ ) +} + +export default AccessRulesEditor diff --git a/web/app/components/app/access-config/index.tsx b/web/app/components/app/access-config/index.tsx new file mode 100644 index 0000000000..a7dd2cc0dd --- /dev/null +++ b/web/app/components/app/access-config/index.tsx @@ -0,0 +1,74 @@ +'use client' + +import type { AccessRule } from '@/app/components/header/account-setting/access-rules-page/access-rule-row' +import { ScrollArea } from '@langgenius/dify-ui/scroll-area' +import AccessRulesEditor from '@/app/components/access-rules-editor' + +// TODO: replace with the per-app access rules fetched from the access-rules +// API once available. Mirrors the workspace-level App access rules catalog. +const DEFAULT_APP_ACCESS_RULES: AccessRule[] = [ + { + id: 'app-full-access', + name: 'Full access', + description: 'Highest level. Can edit, publish, delete apps, and manage access for this app.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, + { + id: 'app-can-edit', + name: 'Can edit', + description: 'Modify Prompts, adjust workflows, change variables. Test and publish updates.', + assignedRoles: [ + { id: 'app-editor', name: 'App Editor' }, + { id: 'it-staff', name: 'IT Staff' }, + ], + permissions: [], + }, + { + id: 'app-can-view-and-use', + name: 'Can view & use', + description: 'View and use the app. Access Prompt and workflow logs. Cannot modify.', + assignedRoles: [ + { id: 'tester', name: 'Tester' }, + { id: 'ops-staff', name: 'Ops Staff' }, + { id: 'member', name: 'Member' }, + ], + permissions: [], + }, + { + id: 'app-can-preview', + name: 'Can preview', + description: 'View the app in the list only. Cannot open the editor or use the app.', + assignedRoles: [ + { id: 'partner', name: 'Partner' }, + ], + permissions: [], + }, +] + +type AppAccessConfigPageProps = { + appId: string +} + +const AppAccessConfigPage = ({ appId: _appId }: AppAccessConfigPageProps) => { + return ( + +
+

Access Config

+
+ +
+
+
+ ) +} + +export default AppAccessConfigPage diff --git a/web/app/components/apps/app-access-config-modal/index.tsx b/web/app/components/apps/app-access-config-modal/index.tsx new file mode 100644 index 0000000000..528a7f4be6 --- /dev/null +++ b/web/app/components/apps/app-access-config-modal/index.tsx @@ -0,0 +1,108 @@ +'use client' + +import type { AccessRule } from '@/app/components/header/account-setting/access-rules-page/access-rule-row' +import type { App } from '@/types/app' +import AccessConfigModal from '@/app/components/access-config-modal' + +// TODO: replace with the per-app access rules fetched from the access-rules API +// once available. The catalog mirrors the workspace-level App access rules and +// adds app-specific rules that can only be assigned per-app. +const DEFAULT_APP_ACCESS_RULES: AccessRule[] = [ + { + id: 'app-full-access', + name: 'Full access', + description: 'Highest level. Can edit, publish, delete apps, and manage access for this app.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'marketing-lead', name: 'Marketing Lead' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, + { + id: 'app-can-edit', + name: 'Can edit', + description: 'Modify Prompts, adjust workflows, change variables. Test and publish updates.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'marketing-lead', name: 'Marketing Lead' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, + { + id: 'app-can-view-and-use', + name: 'Can view & use', + description: 'View and use the app. Access Prompt and workflow logs. Cannot modify.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'marketing-lead', name: 'Marketing Lead' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, + { + id: 'app-can-preview', + name: 'Can preview', + description: 'View the app in the list only. Cannot open the editor or use the app.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'marketing-lead', name: 'Marketing Lead' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, + { + id: 'app-can-optimize-prompt', + name: 'Can optimize prompt', + description: 'Dedicated prompt optimization access.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'marketing-lead', name: 'Marketing Lead' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, +] + +export type AppAccessConfigModalProps = { + open: boolean + app: Pick + onClose: () => void + onSave?: (rules: AccessRule[]) => void +} + +const AppAccessConfigModal = ({ + open, + app: _app, + onClose, + onSave, +}: AppAccessConfigModalProps) => { + return ( + + ) +} + +export default AppAccessConfigModal diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index 80aab3ce4d..305d7d339d 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -68,6 +68,9 @@ const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/ds const AccessControl = dynamic(() => import('@/app/components/app/app-access-control'), { ssr: false, }) +const AppAccessConfigModal = dynamic(() => import('@/app/components/apps/app-access-config-modal'), { + ssr: false, +}) type AppCardProps = { app: App @@ -86,6 +89,7 @@ type AppCardOperationsMenuProps = { onSwitch: () => void onDelete: () => void onAccessControl: () => void + onAccessConfig: () => void } const AppCardOperationsMenu: React.FC = ({ @@ -99,6 +103,7 @@ const AppCardOperationsMenu: React.FC = ({ onSwitch, onDelete, onAccessControl, + onAccessConfig, }) => { const { t } = useTranslation() const openAsyncWindow = useAsyncWindowOpen() @@ -167,6 +172,10 @@ const AppCardOperationsMenu: React.FC = ({ )} + handleMenuAction(e, onAccessConfig)}> + Access Config + + { const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [confirmDeleteInput, setConfirmDeleteInput] = useState('') const [showAccessControl, setShowAccessControl] = useState(false) + const [showAccessConfig, setShowAccessConfig] = useState(false) const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false) const [secretEnvList, setSecretEnvList] = useState([]) const { mutateAsync: mutateDeleteApp, isPending: isDeleting } = useDeleteAppMutation() @@ -288,6 +298,13 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => { }) }, []) + const handleShowAccessConfig = useCallback(() => { + setIsOperationsMenuOpen(false) + queueMicrotask(() => { + setShowAccessConfig(true) + }) + }, []) + const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({ name, icon_type, @@ -550,6 +567,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => { onSwitch={handleShowSwitchModal} onDelete={handleShowDeleteConfirm} onAccessControl={handleShowAccessControl} + onAccessConfig={handleShowAccessConfig} /> ) : ( @@ -564,6 +582,7 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => { onSwitch={handleShowSwitchModal} onDelete={handleShowDeleteConfirm} onAccessControl={handleShowAccessControl} + onAccessConfig={handleShowAccessConfig} /> )} @@ -670,6 +689,13 @@ const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => { {showAccessControl && ( setShowAccessControl(false)} /> )} + {showAccessConfig && ( + setShowAccessConfig(false)} + /> + )} ) } diff --git a/web/app/components/datasets/access-config/index.tsx b/web/app/components/datasets/access-config/index.tsx new file mode 100644 index 0000000000..a5c75bf738 --- /dev/null +++ b/web/app/components/datasets/access-config/index.tsx @@ -0,0 +1,83 @@ +'use client' + +import type { AccessRule } from '@/app/components/header/account-setting/access-rules-page/access-rule-row' +import { ScrollArea } from '@langgenius/dify-ui/scroll-area' +import AccessRulesEditor from '@/app/components/access-rules-editor' + +// TODO: replace with the per-knowledge-base access rules fetched from the +// access-rules API once available. Mirrors the workspace-level Knowledge Base +// access rules catalog. +const DEFAULT_KB_ACCESS_RULES: AccessRule[] = [ + { + id: 'kb-full-access', + name: 'Full access', + description: 'Highest level. Can edit, publish, delete, and manage access for this knowledge base.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, + { + id: 'kb-can-edit', + name: 'Can edit', + description: 'Edit knowledge base content, modify settings, and run tests.', + assignedRoles: [ + { id: 'kb-editor', name: 'KB Editor' }, + { id: 'ops-staff', name: 'Ops Staff' }, + { id: 'it-staff', name: 'IT Staff' }, + ], + permissions: [], + }, + { + id: 'kb-can-view-and-use', + name: 'Can view & use', + description: 'View knowledge base sources, configs, and logs. Cannot modify content.', + assignedRoles: [ + { id: 'member', name: 'Member' }, + ], + permissions: [], + }, + { + id: 'kb-can-preview', + name: 'Can preview', + description: 'View in the list only. Cannot access the detail page.', + assignedRoles: [ + { id: 'partner', name: 'Partner' }, + ], + permissions: [], + }, + { + id: 'kb-can-test', + name: 'Can test', + description: 'Test knowledge base retrieval efficiency in sandbox.', + assignedRoles: [ + { id: 'tester', name: 'Tester' }, + ], + permissions: [], + }, +] + +type DatasetAccessConfigPageProps = { + datasetId: string +} + +const DatasetAccessConfigPage = ({ datasetId: _datasetId }: DatasetAccessConfigPageProps) => { + return ( + +
+

Access Config

+
+ +
+
+
+ ) +} + +export default DatasetAccessConfigPage diff --git a/web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx index 41c0b2749b..67d4996f44 100644 --- a/web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx @@ -18,6 +18,7 @@ describe('Operations', () => { openRenameModal: vi.fn(), handleExportPipeline: vi.fn(), detectIsUsedByApp: vi.fn(), + openAccessConfig: vi.fn(), } beforeEach(() => { @@ -80,6 +81,14 @@ describe('Operations', () => { fireEvent.click(screen.getByText(/operation\.delete/)) expect(detectIsUsedByApp).toHaveBeenCalledTimes(1) }) + + it('should call openAccessConfig when access config is clicked', () => { + const openAccessConfig = vi.fn() + renderInMenu() + + fireEvent.click(screen.getByText('Access Config')) + expect(openAccessConfig).toHaveBeenCalledTimes(1) + }) }) describe('Edge Cases', () => { diff --git a/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx index 8cc10ae5ae..fd6ac86eed 100644 --- a/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx @@ -71,10 +71,12 @@ describe('DatasetCardModals', () => { modalState: { showRenameModal: false, showConfirmDelete: false, + showAccessConfig: false, confirmMessage: '', }, onCloseRename: vi.fn(), onCloseConfirm: vi.fn(), + onCloseAccessConfig: vi.fn(), onConfirmDelete: vi.fn(), onSuccess: vi.fn(), } @@ -209,6 +211,7 @@ describe('DatasetCardModals', () => { modalState={{ showRenameModal: true, showConfirmDelete: true, + showAccessConfig: false, confirmMessage: 'Delete this dataset?', }} />, diff --git a/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx index 2bb138e6dc..a013a66079 100644 --- a/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx @@ -34,6 +34,7 @@ describe('OperationsDropdown', () => { openRenameModal: vi.fn(), handleExportPipeline: vi.fn(), detectIsUsedByApp: vi.fn(), + openAccessConfig: vi.fn(), } beforeEach(() => { diff --git a/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.tsx b/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.tsx index 47a195943b..332f430ed7 100644 --- a/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.tsx +++ b/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.tsx @@ -10,11 +10,17 @@ import { } from '@langgenius/dify-ui/alert-dialog' import * as React from 'react' import { useTranslation } from 'react-i18next' +import dynamic from '@/next/dynamic' import RenameDatasetModal from '../../../rename-modal' +const DatasetAccessConfigModal = dynamic(() => import('../dataset-access-config-modal'), { + ssr: false, +}) + type ModalState = { showRenameModal: boolean showConfirmDelete: boolean + showAccessConfig: boolean confirmMessage: string } @@ -23,6 +29,7 @@ type DatasetCardModalsProps = { modalState: ModalState onCloseRename: () => void onCloseConfirm: () => void + onCloseAccessConfig: () => void onConfirmDelete: () => void onSuccess?: () => void } @@ -32,6 +39,7 @@ const DatasetCardModals = ({ modalState, onCloseRename, onCloseConfirm, + onCloseAccessConfig, onConfirmDelete, onSuccess, }: DatasetCardModalsProps) => { @@ -47,6 +55,13 @@ const DatasetCardModals = ({ onSuccess={onSuccess} /> )} + {modalState.showAccessConfig && ( + + )} !open && onCloseConfirm()}>
diff --git a/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx b/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx index a7918ef033..d17bbb3f40 100644 --- a/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx +++ b/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx @@ -14,6 +14,7 @@ type OperationsDropdownProps = { openRenameModal: () => void handleExportPipeline: (include?: boolean) => void detectIsUsedByApp: () => void + openAccessConfig: () => void } const OperationsDropdown = ({ @@ -22,6 +23,7 @@ const OperationsDropdown = ({ openRenameModal, handleExportPipeline, detectIsUsedByApp, + openAccessConfig, }: OperationsDropdownProps) => { const [open, setOpen] = React.useState(false) @@ -58,6 +60,7 @@ const OperationsDropdown = ({ openRenameModal={openRenameModal} handleExportPipeline={handleExportPipeline} detectIsUsedByApp={detectIsUsedByApp} + openAccessConfig={openAccessConfig} /> diff --git a/web/app/components/datasets/list/dataset-card/dataset-access-config-modal/index.tsx b/web/app/components/datasets/list/dataset-card/dataset-access-config-modal/index.tsx new file mode 100644 index 0000000000..a2337c6632 --- /dev/null +++ b/web/app/components/datasets/list/dataset-card/dataset-access-config-modal/index.tsx @@ -0,0 +1,108 @@ +'use client' + +import type { AccessRule } from '@/app/components/header/account-setting/access-rules-page/access-rule-row' +import type { DataSet } from '@/models/datasets' +import AccessConfigModal from '@/app/components/access-config-modal' + +// TODO: replace with the per-knowledge-base access rules fetched from the +// access-rules API once available. The catalog mirrors the workspace-level +// Knowledge Base access rules. +const DEFAULT_KB_ACCESS_RULES: AccessRule[] = [ + { + id: 'kb-full-access', + name: 'Full access', + description: 'Highest level. Can edit, publish, delete, and manage access for this knowledge base.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'marketing-lead', name: 'Marketing Lead' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, + { + id: 'kb-can-edit', + name: 'Can edit', + description: 'Edit knowledge base content, modify settings, and run tests.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'marketing-lead', name: 'Marketing Lead' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, + { + id: 'kb-can-view-and-use', + name: 'Can view & use', + description: 'View knowledge base sources, configs, and logs. Cannot modify content.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'marketing-lead', name: 'Marketing Lead' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, + { + id: 'kb-can-preview', + name: 'Can preview', + description: 'View in the list only. Cannot access the detail page.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'marketing-lead', name: 'Marketing Lead' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, + { + id: 'kb-can-test', + name: 'Can test', + description: 'Test knowledge base retrieval efficiency in sandbox.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'marketing-lead', name: 'Marketing Lead' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [], + }, +] + +export type DatasetAccessConfigModalProps = { + open: boolean + dataset: Pick + onClose: () => void + onSave?: (rules: AccessRule[]) => void +} + +const DatasetAccessConfigModal = ({ + open, + dataset: _dataset, + onClose, + onSave, +}: DatasetAccessConfigModalProps) => { + return ( + + ) +} + +export default DatasetAccessConfigModal diff --git a/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts b/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts index 6cffbb6828..2449e029bb 100644 --- a/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts +++ b/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.ts @@ -10,6 +10,7 @@ import { downloadBlob } from '@/utils/download' type ModalState = { showRenameModal: boolean showConfirmDelete: boolean + showAccessConfig: boolean confirmMessage: string } @@ -30,6 +31,7 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO const [modalState, setModalState] = useState({ showRenameModal: false, showConfirmDelete: false, + showAccessConfig: false, confirmMessage: '', }) @@ -49,6 +51,14 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO setModalState(prev => ({ ...prev, showConfirmDelete: false })) }, []) + const openAccessConfig = useCallback(() => { + setModalState(prev => ({ ...prev, showAccessConfig: true })) + }, []) + + const closeAccessConfig = useCallback(() => { + setModalState(prev => ({ ...prev, showAccessConfig: false })) + }, []) + // API mutations const { mutateAsync: checkUsage } = useCheckDatasetUsage() const { mutateAsync: deleteDatasetMutation } = useDeleteDataset() @@ -122,6 +132,8 @@ export const useDatasetCardState = ({ dataset, onSuccess }: UseDatasetCardStateO openRenameModal, closeRenameModal, closeConfirmDelete, + openAccessConfig, + closeAccessConfig, // Export state exporting, diff --git a/web/app/components/datasets/list/dataset-card/index.tsx b/web/app/components/datasets/list/dataset-card/index.tsx index 5bd032d151..d16592bcb3 100644 --- a/web/app/components/datasets/list/dataset-card/index.tsx +++ b/web/app/components/datasets/list/dataset-card/index.tsx @@ -37,6 +37,8 @@ const DatasetCard = ({ openRenameModal, closeRenameModal, closeConfirmDelete, + openAccessConfig, + closeAccessConfig, handleExportPipeline, detectIsUsedByApp, onConfirmDelete, @@ -88,6 +90,7 @@ const DatasetCard = ({ openRenameModal={openRenameModal} handleExportPipeline={handleExportPipeline} detectIsUsedByApp={detectIsUsedByApp} + openAccessConfig={openAccessConfig} />
diff --git a/web/app/components/datasets/list/dataset-card/operations.tsx b/web/app/components/datasets/list/dataset-card/operations.tsx index 1349ae05e7..443e98b2ea 100644 --- a/web/app/components/datasets/list/dataset-card/operations.tsx +++ b/web/app/components/datasets/list/dataset-card/operations.tsx @@ -11,6 +11,7 @@ type OperationsProps = { openRenameModal: () => void handleExportPipeline: () => void detectIsUsedByApp: () => void + openAccessConfig: () => void onClose?: () => void } @@ -20,6 +21,7 @@ const Operations = ({ openRenameModal, handleExportPipeline, detectIsUsedByApp, + openAccessConfig, onClose, }: OperationsProps) => { const { t } = useTranslation() @@ -39,23 +41,32 @@ const Operations = ({ detectIsUsedByApp() } + const handleAccessConfig = () => { + onClose?.() + openAccessConfig() + } + return ( <> - + {t('operation.edit', { ns: 'common' })} {showExportPipeline && ( - + {t('operations.exportPipeline', { ns: 'datasetPipeline' })} )} + + + Access Config + {showDelete && ( <> - + {t('operation.delete', { ns: 'common' })} diff --git a/web/app/components/header/account-setting/access-rules-page/access-rule-row-menu.tsx b/web/app/components/header/account-setting/access-rules-page/access-rule-row-menu.tsx new file mode 100644 index 0000000000..ace93a9d92 --- /dev/null +++ b/web/app/components/header/account-setting/access-rules-page/access-rule-row-menu.tsx @@ -0,0 +1,69 @@ +'use client' + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { useState } from 'react' +import ActionButton from '@/app/components/base/action-button' + +export type AccessRuleRowMenuProps = { + onEdit?: () => void + onCopy?: () => void + onDelete?: () => void +} + +const AccessRuleRowMenu = ({ + onEdit, + onCopy, + onDelete, +}: AccessRuleRowMenuProps) => { + const [open, setOpen] = useState(false) + + return ( + + + )} + > + + + + + Edit + + + Copy + + + + Delete + + + + ) +} + +export default AccessRuleRowMenu diff --git a/web/app/components/header/account-setting/access-rules-page/access-rule-row.tsx b/web/app/components/header/account-setting/access-rules-page/access-rule-row.tsx new file mode 100644 index 0000000000..bbd2201bd9 --- /dev/null +++ b/web/app/components/header/account-setting/access-rules-page/access-rule-row.tsx @@ -0,0 +1,86 @@ +'use client' + +import { cn } from '@langgenius/dify-ui/cn' +import { memo, useCallback } from 'react' +import AccessRuleRowMenu from './access-rule-row-menu' +import RoleTag from './role-tag' + +export type AssignedRole = { + id: string + name: string +} + +export type AccessRule = { + id: string + name: string + description: string + assignedRoles: AssignedRole[] + permissions: string[] +} + +export type AccessRuleRowProps = { + rule: AccessRule + className?: string + showMenu?: boolean + onEdit?: (rule: AccessRule) => void + onCopy?: (rule: AccessRule) => void + onDelete?: (rule: AccessRule) => void + onAddRole?: (rule: AccessRule) => void + onRemoveRole?: (rule: AccessRule, role: AssignedRole) => void +} + +const AccessRuleRow = ({ + rule, + className, + showMenu = true, + onEdit, + onCopy, + onDelete, + onAddRole, + onRemoveRole, +}: AccessRuleRowProps) => { + const handleEdit = useCallback(() => onEdit?.(rule), [onEdit, rule]) + const handleCopy = useCallback(() => onCopy?.(rule), [onCopy, rule]) + const handleDelete = useCallback(() => onDelete?.(rule), [onDelete, rule]) + const handleAddRole = useCallback(() => onAddRole?.(rule), [onAddRole, rule]) + + return ( +
+
+
+ {rule.name} +
+

+ {rule.description} +

+
+ {rule.assignedRoles.map(role => ( + onRemoveRole(rule, role) : undefined} + /> + ))} + +
+
+ {showMenu && ( + + )} +
+ ) +} + +export default memo(AccessRuleRow) diff --git a/web/app/components/header/account-setting/access-rules-page/access-rule-section.tsx b/web/app/components/header/account-setting/access-rules-page/access-rule-section.tsx new file mode 100644 index 0000000000..68664bff77 --- /dev/null +++ b/web/app/components/header/account-setting/access-rules-page/access-rule-section.tsx @@ -0,0 +1,62 @@ +'use client' + +import type { AccessRule, AssignedRole } from './access-rule-row' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { memo } from 'react' +import AccessRuleRow from './access-rule-row' + +export type AccessRuleSectionProps = { + title: string + rules: AccessRule[] + createButtonLabel: string + onCreate?: () => void + onEditRule?: (rule: AccessRule) => void + onCopyRule?: (rule: AccessRule) => void + onDeleteRule?: (rule: AccessRule) => void + onAddRole?: (rule: AccessRule) => void + onRemoveRole?: (rule: AccessRule, role: AssignedRole) => void + className?: string +} + +const AccessRuleSection = ({ + title, + rules, + createButtonLabel, + onCreate, + onEditRule, + onCopyRule, + onDeleteRule, + onAddRole, + onRemoveRole, + className, +}: AccessRuleSectionProps) => { + return ( +
+
+

+ {title} +

+ +
+
+ {rules.map((rule, index) => ( + 0 && 'border-t border-divider-subtle')} + onEdit={onEditRule} + onCopy={onCopyRule} + onDelete={onDeleteRule} + onAddRole={onAddRole} + onRemoveRole={onRemoveRole} + /> + ))} +
+
+ ) +} + +export default memo(AccessRuleSection) diff --git a/web/app/components/header/account-setting/access-rules-page/add-rule-targets-modal/index.tsx b/web/app/components/header/account-setting/access-rules-page/add-rule-targets-modal/index.tsx new file mode 100644 index 0000000000..357bd698b6 --- /dev/null +++ b/web/app/components/header/account-setting/access-rules-page/add-rule-targets-modal/index.tsx @@ -0,0 +1,361 @@ +'use client' + +import type { Member } from '@/models/common' +import { Avatar } from '@langgenius/dify-ui/avatar' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' +import { ScrollArea } from '@langgenius/dify-ui/scroll-area' +import { useCallback, useMemo, useState } from 'react' +import Checkbox from '@/app/components/base/checkbox' +import Input from '@/app/components/base/input' +import { useMembers } from '@/service/use-common' + +export type AssignableRoleOption = { + id: string + name: string + description?: string +} + +export type AssignableMemberOption = { + id: string + name: string + email: string + avatarUrl?: string | null +} + +type TabKey = 'roles' | 'members' + +type AddRuleTargetsModalBaseProps = { + ruleName?: string + initialRoleIds?: string[] + initialMemberIds?: string[] + onClose: () => void + onSubmit: (selection: { roleIds: string[], memberIds: string[] }) => void +} + +export type AddRuleTargetsModalProps = AddRuleTargetsModalBaseProps & { + open: boolean +} + +const TABS: Array<{ key: TabKey, label: string }> = [ + { key: 'roles', label: 'ROLES' }, + { key: 'members', label: 'MEMBERS' }, +] + +// TODO: replace with roles fetched from the permissions API once available. +const MOCK_ROLE_OPTIONS: AssignableRoleOption[] = [ + { id: 'admin', name: 'Admin', description: 'Full workspace management' }, + { id: 'editor', name: 'Editor', description: 'Create and edit resources' }, + { id: 'member', name: 'Member', description: 'Basic access' }, + { id: 'auditor', name: 'Auditor', description: 'View logs and audit trails' }, + { id: 'tester', name: 'Tester', description: 'Test in sandbox' }, +] + +const toMemberOption = (member: Member): AssignableMemberOption => ({ + id: member.id, + name: member.name, + email: member.email, + avatarUrl: member.avatar_url ?? member.avatar ?? null, +}) + +const AddRuleTargetsModalBody = ({ + ruleName, + initialRoleIds = [], + initialMemberIds = [], + onClose, + onSubmit, +}: AddRuleTargetsModalBaseProps) => { + const { data: membersData, isLoading: membersLoading } = useMembers() + + const roles = MOCK_ROLE_OPTIONS + + const members = useMemo(() => { + const accounts = membersData?.accounts ?? [] + return accounts + .filter(account => account.status !== 'banned' && account.status !== 'closed') + .map(toMemberOption) + }, [membersData]) + const [activeTab, setActiveTab] = useState('roles') + const [keyword, setKeyword] = useState('') + const [selectedRoleIds, setSelectedRoleIds] = useState(initialRoleIds) + const [selectedMemberIds, setSelectedMemberIds] = useState(initialMemberIds) + + const trimmed = keyword.trim().toLowerCase() + + const filteredRoles = useMemo(() => { + if (!trimmed) + return roles + return roles.filter( + role => + role.name.toLowerCase().includes(trimmed) + || role.description?.toLowerCase().includes(trimmed), + ) + }, [roles, trimmed]) + + const filteredMembers = useMemo(() => { + if (!trimmed) + return members + return members.filter( + member => + member.name.toLowerCase().includes(trimmed) + || member.email.toLowerCase().includes(trimmed), + ) + }, [members, trimmed]) + + const handleSwitchTab = useCallback((tab: TabKey) => { + setActiveTab(tab) + setKeyword('') + }, []) + + const toggleRole = useCallback((id: string) => { + setSelectedRoleIds(prev => + prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id], + ) + }, []) + + const toggleMember = useCallback((id: string) => { + setSelectedMemberIds(prev => + prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id], + ) + }, []) + + const handleConfirm = useCallback(() => { + onSubmit({ roleIds: selectedRoleIds, memberIds: selectedMemberIds }) + onClose() + }, [onClose, onSubmit, selectedMemberIds, selectedRoleIds]) + + const description = ruleName + ? `Select roles or members to grant the "${ruleName}" access level by default.` + : 'Select roles or members to grant this access level by default.' + + const summary = (() => { + const parts: string[] = [] + parts.push(`${selectedRoleIds.length} ${selectedRoleIds.length === 1 ? 'role' : 'roles'}`) + parts.push(`${selectedMemberIds.length} ${selectedMemberIds.length === 1 ? 'member' : 'members'} selected`) + return parts.join(', ') + })() + + return ( + +
+ +
+ + Add Roles or Members + + + {description} + +
+
+ +
+
+ {TABS.map((tab) => { + const active = activeTab === tab.key + return ( + + ) + })} +
+
+ +
+ setKeyword(e.target.value)} + onClear={() => setKeyword('')} + placeholder={ + activeTab === 'roles' ? 'Search roles...' : 'Search members...' + } + /> +
+ + + {activeTab === 'roles' && ( + filteredRoles.length === 0 + ? ( +
+ No matching roles +
+ ) + : ( +
    + {filteredRoles.map((role) => { + const checked = selectedRoleIds.includes(role.id) + const handleToggle = () => toggleRole(role.id) + return ( +
  • +
    { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault() + handleToggle() + } + }} + > + +
    +
    + {role.name} +
    + {role.description && ( +
    + {role.description} +
    + )} +
    +
    +
  • + ) + })} +
+ ) + )} + {activeTab === 'members' && ( + membersLoading + ? ( +
+ Loading members... +
+ ) + : filteredMembers.length === 0 + ? ( +
+ No matching members +
+ ) + : ( +
    + {filteredMembers.map((member) => { + const checked = selectedMemberIds.includes(member.id) + const handleToggle = () => toggleMember(member.id) + return ( +
  • +
    { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault() + handleToggle() + } + }} + > + + +
    +
    + {member.name} +
    +
    + {member.email} +
    +
    +
    +
  • + ) + })} +
+ ) + )} +
+ +
+
+ {summary} +
+
+ + +
+
+
+ ) +} + +const AddRuleTargetsModal = ({ + open, + ruleName, + initialRoleIds, + initialMemberIds, + onClose, + onSubmit, +}: AddRuleTargetsModalProps) => { + return ( + { + if (!nextOpen) + onClose() + }} + > + + + ) +} + +export default AddRuleTargetsModal diff --git a/web/app/components/header/account-setting/access-rules-page/index.tsx b/web/app/components/header/account-setting/access-rules-page/index.tsx new file mode 100644 index 0000000000..5f7371f3be --- /dev/null +++ b/web/app/components/header/account-setting/access-rules-page/index.tsx @@ -0,0 +1,260 @@ +'use client' + +import type { AccessRule } from './access-rule-row' +import type { PermissionSetFormValues, PermissionSetModalMode } from './permission-set-modal' +import type { ResourceType } from './permission-set-modal/permissions-data' +import { useCallback, useState } from 'react' +import AccessRuleSection from './access-rule-section' +import AddRuleTargetsModal from './add-rule-targets-modal' +import PermissionSetModal from './permission-set-modal' + +const APP_ACCESS_RULES: AccessRule[] = [ + { + id: 'app-full-access', + name: 'Full access', + description: 'Highest level. Can edit, publish, delete apps, and manage access for this app.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'app-admin', name: 'App Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [ + 'app.editing_and_layout', + 'app.test_and_debug', + 'app.delete', + 'app.import_export_dsl', + 'app.release_version_management', + 'app.annotation_management', + 'app.api_management.toggle', + 'app.api_management.create_key', + 'app.api_management.delete_key', + ], + }, + { + id: 'app-can-edit', + name: 'Can edit', + description: 'Modify Prompts, adjust workflows, change variables. Test and publish updates.', + assignedRoles: [ + { id: 'app-editor', name: 'App Editor' }, + { id: 'it-staff', name: 'IT Staff' }, + ], + permissions: [ + 'app.editing_and_layout', + 'app.test_and_debug', + 'app.release_version_management', + ], + }, + { + id: 'app-can-view-and-use', + name: 'Can view & use', + description: 'View and use the app. Access Prompt and workflow logs. Cannot modify.', + assignedRoles: [ + { id: 'tester', name: 'Tester' }, + { id: 'ops-staff', name: 'Ops Staff' }, + { id: 'member', name: 'Member' }, + ], + permissions: [ + 'app.test_and_debug', + ], + }, + { + id: 'app-can-preview', + name: 'Can preview', + description: 'View the app in the list only. Cannot open the editor or use the app.', + assignedRoles: [ + { id: 'partner', name: 'Partner' }, + ], + permissions: [], + }, +] + +const KNOWLEDGE_BASE_ACCESS_RULES: AccessRule[] = [ + { + id: 'kb-full-access', + name: 'Full access', + description: 'Highest level. Can edit, publish, delete apps, and manage access for this knowledge base.', + assignedRoles: [ + { id: 'owner', name: 'Owner' }, + { id: 'admin', name: 'Admin' }, + { id: 'kb-admin', name: 'KB Admin' }, + { id: 'executive', name: 'Executive' }, + ], + permissions: [ + 'kb.view', + 'kb.edit_configuration', + 'kb.manage_documents.add', + 'kb.manage_documents.delete', + 'kb.manage_documents.download', + 'kb.import_export_pipeline', + 'kb.pipeline_publishing_versioning', + 'kb.delete', + ], + }, + { + id: 'kb-can-edit', + name: 'Can edit', + description: 'Edit knowledge base content, modify settings, and run tests.', + assignedRoles: [ + { id: 'kb-editor', name: 'KB Editor' }, + { id: 'ops-staff', name: 'Ops Staff' }, + { id: 'it-staff', name: 'IT Staff' }, + ], + permissions: [ + 'kb.edit_configuration', + 'kb.manage_documents.add', + 'kb.manage_documents.delete', + 'kb.manage_documents.download', + ], + }, + { + id: 'kb-can-view', + name: 'Can view', + description: 'View knowledge base sources and logs. Cannot modify content.', + assignedRoles: [ + { id: 'member', name: 'Member' }, + ], + permissions: ['kb.view'], + }, + { + id: 'kb-can-preview', + name: 'Can preview', + description: 'View in the list only. Cannot access the detail page.', + assignedRoles: [ + { id: 'partner', name: 'Partner' }, + ], + permissions: [], + }, + { + id: 'kb-can-test', + name: 'Can test', + description: 'Test knowledge base retrieval efficiency in sandbox.', + assignedRoles: [ + { id: 'tester', name: 'Tester' }, + ], + permissions: ['kb.view'], + }, +] + +type PermissionSetModalState = { + mode: PermissionSetModalMode + resourceType: ResourceType + initialValues?: PermissionSetFormValues +} + +const AccessRulesPage = () => { + const [addingRule, setAddingRule] = useState(null) + const [permissionSetModalState, setPermissionSetModalState] + = useState(null) + + const closeAddModal = useCallback(() => { + setAddingRule(null) + }, []) + + const closePermissionSetModal = useCallback(() => { + setPermissionSetModalState(null) + }, []) + + const handleAddRole = useCallback((rule: AccessRule) => { + setAddingRule(rule) + }, []) + + const handleAddSubmit = useCallback( + (_selection: { roleIds: string[], memberIds: string[] }) => { + // TODO: wire up to API when backend is ready. + }, + [], + ) + + const handleCreate = useCallback((resourceType: ResourceType) => { + setPermissionSetModalState({ mode: 'create', resourceType }) + }, []) + + const handleEdit = useCallback( + (resourceType: ResourceType, rule: AccessRule) => { + setPermissionSetModalState({ + mode: 'edit', + resourceType, + initialValues: { + name: rule.name, + description: rule.description, + permissions: rule.permissions, + }, + }) + }, + [], + ) + + const handlePermissionSetSubmit = useCallback( + (_values: PermissionSetFormValues) => { + // TODO: wire up to API when backend is ready. + }, + [], + ) + + const noop = useCallback(() => { + // TODO: wire up to API when backend is ready. + }, []) + + const createApp = useCallback(() => handleCreate('app'), [handleCreate]) + const createKb = useCallback(() => handleCreate('knowledge_base'), [handleCreate]) + const editApp = useCallback( + (rule: AccessRule) => handleEdit('app', rule), + [handleEdit], + ) + const editKb = useCallback( + (rule: AccessRule) => handleEdit('knowledge_base', rule), + [handleEdit], + ) + + return ( + <> +
+ + +
+ {addingRule && ( + role.id)} + initialMemberIds={[]} + onClose={closeAddModal} + onSubmit={handleAddSubmit} + /> + )} + {permissionSetModalState && ( + + )} + + ) +} + +export default AccessRulesPage diff --git a/web/app/components/header/account-setting/access-rules-page/permission-set-modal/index.tsx b/web/app/components/header/account-setting/access-rules-page/permission-set-modal/index.tsx new file mode 100644 index 0000000000..04059c060e --- /dev/null +++ b/web/app/components/header/account-setting/access-rules-page/permission-set-modal/index.tsx @@ -0,0 +1,225 @@ +'use client' + +import type { ResourceType } from './permissions-data' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogTitle, +} from '@langgenius/dify-ui/dialog' +import { useMemo, useState } from 'react' +import Input from '@/app/components/base/input' +import Textarea from '@/app/components/base/textarea' +import PermissionPicker from './permission-picker' +import { getPermissionMap } from './permissions-data' + +export type PermissionSetModalMode = 'create' | 'edit' + +export type PermissionSetFormValues = { + name: string + description: string + permissions: string[] +} + +export type PermissionSetModalProps = { + open: boolean + mode: PermissionSetModalMode + resourceType: ResourceType + initialValues?: Partial + onClose: () => void + onSubmit: (values: PermissionSetFormValues) => void +} + +const RESOURCE_LABEL: Record = { + app: 'App', + knowledge_base: 'Knowledge Base', +} + +const buildTitle = (mode: PermissionSetModalMode, resource: ResourceType): string => { + const verb = mode === 'create' ? 'Create' : 'Edit' + return `${verb} ${RESOURCE_LABEL[resource]} permission set` +} + +const buildDescription = (mode: PermissionSetModalMode, resource: ResourceType): string => { + if (mode === 'edit') + return 'Modify the name, description, and permissions granted for this permission set.' + if (resource === 'app') + return 'Create an app permission set that can be referenced in access rules for quick authorization.' + return 'Create a knowledge base permission set that can be referenced in access rules for quick authorization.' +} + +type PermissionSetModalBodyProps = Omit + +const PermissionSetModalBody = ({ + mode, + resourceType, + initialValues, + onClose, + onSubmit, +}: PermissionSetModalBodyProps) => { + const [name, setName] = useState(initialValues?.name ?? '') + const [description, setDescription] = useState(initialValues?.description ?? '') + const [permissions, setPermissions] = useState(initialValues?.permissions ?? []) + + const permissionMap = useMemo(() => getPermissionMap(resourceType), [resourceType]) + + const trimmedName = name.trim() + const canSubmit = trimmedName.length > 0 + + const handleConfirm = () => { + if (!canSubmit) + return + onSubmit({ + name: trimmedName, + description: description.trim(), + permissions, + }) + onClose() + } + + const handleRemovePermission = (id: string) => { + setPermissions(prev => prev.filter(p => p !== id)) + } + + return ( + +
+ +
+ + {buildTitle(mode, resourceType)} + + + {buildDescription(mode, resourceType)} + +
+
+ +
+ +
+
+ + setName(e.target.value)} + placeholder="e.g. Can export DSL" + /> +
+ +
+ +