From b27c8c5fec37c89e5924c7316c183ee1e68ee4c6 Mon Sep 17 00:00:00 2001 From: "nishenghao.nsh" Date: Wed, 15 Apr 2026 11:53:09 +0800 Subject: [PATCH] feat(rbac): harden authorization model and align RBAC UX with RAM-style workflows Adopt a hybrid RBAC flow (direct user-role + group-role inheritance) and close critical permission gaps by enforcing server-side checks on role/group/app mutations, cache invalidation, and group-role cleanup on deletion. Refactor RBAC management UI to RAM-like drawer-based interactions, unify overlapping user/OAuth management paths, make system roles read-only with explicit view mode, and synchronize seeded system-role permissions so viewer remains strictly read-only while retaining full agent resource visibility. --- assets/schema/oauth2_config.sql | 1 + assets/schema/upgrade_oauth2_config.sql | 5 + docs/rbac_system_roles.md | 76 + .../src/derisk_app/auth/user_service.py | 72 +- .../config_storage/oauth2_db_storage.py | 24 +- .../derisk_app/feature_plugins/bootstrap.py | 61 +- .../src/derisk_app/feature_plugins/catalog.py | 54 +- .../feature_plugins/permissions/__init__.py | 23 + .../feature_plugins/permissions/api.py | 754 ++++++++++ .../feature_plugins/permissions/checker.py | 112 ++ .../feature_plugins/permissions/dao.py | 462 ++++++ .../feature_plugins/permissions/models.py | 133 ++ .../feature_plugins/permissions/seed.py | 276 ++++ .../feature_plugins/permissions/service.py | 201 +++ .../feature_plugins/system_config_dao.py | 99 ++ .../feature_plugins/system_config_model.py | 27 + .../feature_plugins/user_groups/api.py | 29 +- .../feature_plugins/user_groups/service.py | 5 + .../initialization/db_model_initialization.py | 16 + .../src/derisk_app/knowledge/api.py | 17 +- .../src/derisk_app/openapi/api_v1/api_v1.py | 4 +- .../src/derisk_app/openapi/api_v1/auth_api.py | 86 +- .../derisk_app/openapi/api_v1/config_api.py | 223 ++- .../openapi/api_v1/tool_management_api.py | 18 +- .../derisk_app/openapi/api_v1/tools_api.py | 18 +- .../derisk_app/openapi/api_v1/users_api.py | 161 +- .../src/derisk_core/config/schema.py | 4 + .../building/app/api/endpoints.py | 84 +- .../src/derisk_serve/model/api/endpoints.py | 44 +- .../src/derisk_serve/utils/auth.py | 118 +- .../app/components/agent-header.tsx | 5 + web/src/app/settings/permissions/page.tsx | 122 ++ web/src/app/users/page.tsx | 63 +- web/src/client/api/index.ts | 1 + .../config/FeaturePluginsSection.tsx | 11 +- .../components/config/OAuth2ConfigSection.tsx | 93 +- web/src/components/layout/side-bar.tsx | 230 +-- web/src/components/layout/user-bar.tsx | 57 +- .../permissions/CustomPermissions.tsx | 959 ++++++++++++ .../permissions/GroupManagement.tsx | 629 ++++++++ .../permissions/OAuthUserManagement.tsx | 244 +++ .../permissions/PermissionDefinitions.tsx | 624 ++++++++ .../PermissionDefinitionsPanel.tsx | 632 ++++++++ .../permissions/ResourceSelector.tsx | 182 +++ .../components/permissions/RoleManagement.tsx | 1308 +++++++++++++++++ .../components/permissions/UserManagement.tsx | 652 ++++++++ .../permissions/UserPermissionsPanel.tsx | 252 ++++ web/src/hooks/use-user-permissions.ts | 62 + web/src/locales/en/common.ts | 5 + web/src/locales/en/index.ts | 2 + web/src/locales/en/permissions.ts | 251 ++++ web/src/locales/zh/common.ts | 5 + web/src/locales/zh/index.ts | 2 + web/src/locales/zh/permissions.ts | 250 ++++ web/src/services/auth.ts | 32 + web/src/services/config/index.ts | 51 + web/src/services/permissions.ts | 355 +++++ web/src/services/users.ts | 4 + 58 files changed, 10000 insertions(+), 290 deletions(-) create mode 100644 docs/rbac_system_roles.md create mode 100644 packages/derisk-app/src/derisk_app/feature_plugins/permissions/__init__.py create mode 100644 packages/derisk-app/src/derisk_app/feature_plugins/permissions/api.py create mode 100644 packages/derisk-app/src/derisk_app/feature_plugins/permissions/checker.py create mode 100644 packages/derisk-app/src/derisk_app/feature_plugins/permissions/dao.py create mode 100644 packages/derisk-app/src/derisk_app/feature_plugins/permissions/models.py create mode 100644 packages/derisk-app/src/derisk_app/feature_plugins/permissions/seed.py create mode 100644 packages/derisk-app/src/derisk_app/feature_plugins/permissions/service.py create mode 100644 packages/derisk-app/src/derisk_app/feature_plugins/system_config_dao.py create mode 100644 packages/derisk-app/src/derisk_app/feature_plugins/system_config_model.py create mode 100644 web/src/app/settings/permissions/page.tsx create mode 100644 web/src/components/permissions/CustomPermissions.tsx create mode 100644 web/src/components/permissions/GroupManagement.tsx create mode 100644 web/src/components/permissions/OAuthUserManagement.tsx create mode 100644 web/src/components/permissions/PermissionDefinitions.tsx create mode 100644 web/src/components/permissions/PermissionDefinitionsPanel.tsx create mode 100644 web/src/components/permissions/ResourceSelector.tsx create mode 100644 web/src/components/permissions/RoleManagement.tsx create mode 100644 web/src/components/permissions/UserManagement.tsx create mode 100644 web/src/components/permissions/UserPermissionsPanel.tsx create mode 100644 web/src/hooks/use-user-permissions.ts create mode 100644 web/src/locales/en/permissions.ts create mode 100644 web/src/locales/zh/permissions.ts create mode 100644 web/src/services/permissions.ts diff --git a/assets/schema/oauth2_config.sql b/assets/schema/oauth2_config.sql index 567d095d..8cae5565 100644 --- a/assets/schema/oauth2_config.sql +++ b/assets/schema/oauth2_config.sql @@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS `oauth2_config` ( `enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'OAuth2 enabled flag', `providers_json` TEXT NULL COMMENT 'OAuth2 providers configuration (JSON array)', `admin_users_json` TEXT NULL COMMENT 'Admin users list (JSON array)', + `default_role` VARCHAR(32) NULL DEFAULT 'viewer' COMMENT 'Default RBAC role for new OAuth2 users', `gmt_create` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Create time', `gmt_modify` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Modify time', PRIMARY KEY (`id`), diff --git a/assets/schema/upgrade_oauth2_config.sql b/assets/schema/upgrade_oauth2_config.sql index eeba185f..e729940b 100644 --- a/assets/schema/upgrade_oauth2_config.sql +++ b/assets/schema/upgrade_oauth2_config.sql @@ -6,9 +6,14 @@ CREATE TABLE IF NOT EXISTS `oauth2_config` ( `enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'OAuth2 enabled flag', `providers_json` TEXT NULL COMMENT 'OAuth2 providers configuration (JSON array)', `admin_users_json` TEXT NULL COMMENT 'Admin users list (JSON array)', + `default_role` VARCHAR(32) NULL DEFAULT 'viewer' COMMENT 'Default RBAC role for new OAuth2 users', `gmt_create` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Create time', `gmt_modify` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Modify time', PRIMARY KEY (`id`), UNIQUE KEY `uk_config_key` (`config_key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='OAuth2 configuration storage (client_secret masked on display)'; + +-- Migration: Add default_role column if table already exists (for existing deployments) +ALTER TABLE `oauth2_config` + ADD COLUMN IF NOT EXISTS `default_role` VARCHAR(32) NULL DEFAULT 'viewer' COMMENT 'Default RBAC role for new OAuth2 users' AFTER `admin_users_json`; diff --git a/docs/rbac_system_roles.md b/docs/rbac_system_roles.md new file mode 100644 index 00000000..6ff3a787 --- /dev/null +++ b/docs/rbac_system_roles.md @@ -0,0 +1,76 @@ +# RBAC 系统角色说明(当前实现) + +本文档说明 OpenDerisk 当前内置(系统)角色的职责边界。 +系统角色由 `permissions/seed.py` 初始化,`is_system=1`,默认不可删除、不可修改、不可重新配置权限。 + +## 1. 角色清单 + +当前系统内置 5 个角色: + +- `guest` +- `viewer` +- `operator` +- `editor` +- `admin` + +## 2. 各角色功能说明 + +### `guest`(访客) + +- 目标:提供最小可用访问能力 +- 允许:`model:read`、`model:chat` +- 不允许:智能体、工具、知识库相关权限 +- 典型场景:只使用模型对话,不参与平台配置 + +### `viewer`(只读观察者) + +- 目标:全局只读可见 +- 允许:`agent/tool/knowledge/model` 的 `read` +- 不允许:对话、执行、编辑、管理 +- 典型场景:审计、查看、巡检 + +### `operator`(操作员) + +- 目标:可操作运行态能力,但不改配置 +- 允许: + - `agent:read/chat` + - `tool:read/execute` + - `knowledge:read/query` + - `model:read/chat` +- 不允许:`write/manage/admin` +- 典型场景:值班、日常操作、问题排查 + +### `editor`(编辑者) + +- 目标:可管理业务资源配置,但不具备系统级管理 +- 允许: + - `agent:read/chat/write` + - `tool:read/execute/manage` + - `knowledge:read/query/write` + - `model:read/chat/manage` +- 不允许:`system:admin`(系统级管理) +- 典型场景:应用配置维护、资源管理 + +### `admin`(管理员) + +- 目标:平台完全管理 +- 允许: + - `agent/tool/knowledge/model` 全能力(含 `admin`) + - `system:admin` +- 典型场景:平台管理员、权限管理员 + +## 3. 变更约束(本次规则) + +为防止误操作,系统角色新增只读保护: + +- 前端:系统角色不再展示“配置权限/编辑/删除”操作入口 +- 后端:针对系统角色,以下接口写操作会被拒绝(HTTP 400) + - 更新角色信息 + - 增删角色权限(含资源级权限) + - 增删角色关联的权限定义 + +## 4. 推荐使用方式 + +- 需要个性化权限时,请新建“自定义角色” +- 系统角色建议作为权限基线模板使用,不直接改动 +- 生产环境优先采用“用户组 + 角色”分配,减少逐用户授权的维护成本 diff --git a/packages/derisk-app/src/derisk_app/auth/user_service.py b/packages/derisk-app/src/derisk_app/auth/user_service.py index 9dbf752f..450b78aa 100644 --- a/packages/derisk-app/src/derisk_app/auth/user_service.py +++ b/packages/derisk-app/src/derisk_app/auth/user_service.py @@ -79,9 +79,17 @@ def create_or_update_from_oauth( oauth_id: str, user_info: Dict[str, Any], role: str = "normal", + rbac_default_role: str = "viewer", ) -> Dict[str, Any]: """Create or update user from OAuth user info, return plain dict. + Args: + provider: OAuth provider ID + oauth_id: OAuth provider user ID + user_info: User info from OAuth provider + role: Legacy role ("admin" or "normal") + rbac_default_role: Default RBAC role to assign to new users (e.g., "viewer", "guest") + Returns a dict instead of the ORM entity to avoid DetachedInstanceError after the session closes. """ @@ -131,6 +139,30 @@ def create_or_update_from_oauth( session.add(user) session.commit() session.refresh(user) + + # 自动为新用户分配配置的默认角色 + try: + from derisk_app.feature_plugins.permissions.dao import PermissionDao + + dao = PermissionDao() + default_role = dao.get_role_by_name(rbac_default_role) + if default_role: + dao.assign_role_to_user(user.id, default_role["id"]) + logger.info( + f"Auto-assigned {rbac_default_role} role to new OAuth2 user: {user.id} ({user.name})" + ) + else: + # Fallback to viewer if configured role doesn't exist + viewer_role = dao.get_role_by_name("viewer") + if viewer_role: + dao.assign_role_to_user(user.id, viewer_role["id"]) + logger.warning( + f"Configured default role '{rbac_default_role}' not found, " + f"fallback to viewer for new OAuth2 user: {user.id} ({user.name})" + ) + except Exception as e: + logger.warning(f"Failed to auto-assign default role: {e}") + return _entity_to_dict(user) def list_users( @@ -176,6 +208,24 @@ def update_user( session.refresh(user) return _entity_to_dict(user) + def delete_user(self, user_id: int) -> bool: + """Soft delete user by setting is_active=0. + + Args: + user_id: User ID to delete + + Returns: + True if user was found and deleted, False otherwise + """ + with self.session() as session: + user = session.query(UserEntity).filter(UserEntity.id == user_id).first() + if not user: + return False + user.is_active = 0 + session.commit() + logger.info(f"User {user_id} ({user.name}) soft deleted") + return True + class UserService: """Service for user operations.""" @@ -189,11 +239,16 @@ def get_or_create_from_oauth( oauth_id: str, user_info: Dict[str, Any], role: str = "normal", + rbac_default_role: str = "viewer", ) -> Optional[Dict[str, Any]]: """Get or create user from OAuth info, return user dict for session.""" try: return self._dao.create_or_update_from_oauth( - provider, oauth_id, user_info, role=role + provider, + oauth_id, + user_info, + role=role, + rbac_default_role=rbac_default_role, ) except Exception as e: logger.exception(f"Failed to get/create user from OAuth: {e}") @@ -229,3 +284,18 @@ def update_user( except Exception as e: logger.exception(f"Failed to update user {user_id}: {e}") return None + + def delete_user(self, user_id: int) -> bool: + """Delete user (soft delete). + + Args: + user_id: User ID to delete + + Returns: + True if successful, False otherwise + """ + try: + return self._dao.delete_user(user_id) + except Exception as e: + logger.exception(f"Failed to delete user {user_id}: {e}") + return False diff --git a/packages/derisk-app/src/derisk_app/config_storage/oauth2_db_storage.py b/packages/derisk-app/src/derisk_app/config_storage/oauth2_db_storage.py index 836819ef..3ae4d1d7 100644 --- a/packages/derisk-app/src/derisk_app/config_storage/oauth2_db_storage.py +++ b/packages/derisk-app/src/derisk_app/config_storage/oauth2_db_storage.py @@ -43,6 +43,12 @@ class OAuth2ConfigEntity(Model): nullable=True, comment="Admin users list (JSON array)", ) + default_role = Column( + String(32), + nullable=True, + default="viewer", + comment="Default RBAC role for new OAuth2 users", + ) gmt_create = Column(DateTime, nullable=True) gmt_modify = Column(DateTime, nullable=True) @@ -54,6 +60,7 @@ def to_dict(self) -> Dict[str, Any]: "enabled": bool(self.enabled), "providers_json": self.providers_json, "admin_users_json": self.admin_users_json, + "default_role": self.default_role or "viewer", } @@ -115,6 +122,7 @@ def save_or_update( enabled: bool, providers: List[Dict[str, Any]], admin_users: List[str], + default_role: str = "viewer", config_key: str = "global", ) -> OAuth2ConfigEntity: """Save or update OAuth2 config (stored in plain text, mask on display).""" @@ -143,6 +151,7 @@ def save_or_update( entity.enabled = 1 if enabled else 0 entity.providers_json = providers_json entity.admin_users_json = admin_users_json + entity.default_role = default_role entity.gmt_modify = datetime.utcnow() else: entity = OAuth2ConfigEntity( @@ -150,6 +159,7 @@ def save_or_update( enabled=1 if enabled else 0, providers_json=providers_json, admin_users_json=admin_users_json, + default_role=default_role, gmt_create=datetime.utcnow(), gmt_modify=datetime.utcnow(), ) @@ -203,6 +213,7 @@ def get_config( enabled = bool(entity.enabled) admin_users_json = entity.admin_users_json or "[]" providers_json = entity.providers_json or "[]" + default_role = entity.default_role or "viewer" try: admin_users = json.loads(admin_users_json) if admin_users_json else [] @@ -222,6 +233,7 @@ def get_config( "enabled": enabled, "providers": providers, "admin_users": admin_users, + "default_role": default_role, } def get_config_with_secrets( @@ -252,15 +264,21 @@ def load_with_secrets(self) -> Optional[Dict[str, Any]]: return self.dao.get_config_with_secrets("global") def save( - self, enabled: bool, providers: List[Dict], admin_users: List[str] + self, + enabled: bool, + providers: List[Dict], + admin_users: List[str], + default_role: str = "viewer", ) -> bool: """Save OAuth2 config to database.""" try: - self.dao.save_or_update(enabled, providers, admin_users, "global") + self.dao.save_or_update( + enabled, providers, admin_users, default_role, "global" + ) return True except Exception as e: logger.exception(f"Failed to save OAuth2 config: {e}") - return False + raise # Re-raise to let caller handle the error # Singleton instance diff --git a/packages/derisk-app/src/derisk_app/feature_plugins/bootstrap.py b/packages/derisk-app/src/derisk_app/feature_plugins/bootstrap.py index f05bc6af..d7f85b7f 100644 --- a/packages/derisk-app/src/derisk_app/feature_plugins/bootstrap.py +++ b/packages/derisk-app/src/derisk_app/feature_plugins/bootstrap.py @@ -9,28 +9,71 @@ def register_enabled_feature_plugin_routers(app: FastAPI) -> None: """Conditionally mount plugin HTTP routes (requires restart after toggling plugins).""" + # Try to load from database first try: - from derisk_core.config import ConfigManager, FeaturePluginEntry + from derisk_app.feature_plugins.system_config_dao import SystemConfigDao - cfg = ConfigManager.get() + dao = SystemConfigDao() + raw = dao.get_all_configs("feature_plugin") + logger.info(f"Loaded feature plugins from database: {raw}") except Exception as e: - logger.warning("Feature plugins: skip router registration (config unavailable): %s", e) - return + logger.warning(f"Feature plugins: failed to load from database: {e}") + raw = {} - raw = getattr(cfg, "feature_plugins", None) or {} + # Fall back to config file if database is empty + if not raw: + try: + from derisk_core.config import ConfigManager, FeaturePluginEntry + + cfg = ConfigManager.get() + raw_cfg = getattr(cfg, "feature_plugins", None) or {} + raw = { + k: v.model_dump(mode="json") if hasattr(v, "model_dump") else dict(v) + for k, v in raw_cfg.items() + } + except Exception as e: + logger.warning("Feature plugins: skip router registration (config unavailable): %s", e) + return def _enabled(plugin_id: str) -> bool: entry = raw.get(plugin_id) if entry is None: return False - if isinstance(entry, FeaturePluginEntry): - return bool(entry.enabled) if isinstance(entry, dict): return bool(entry.get("enabled")) return False - if _enabled("user_groups"): - from derisk_app.feature_plugins.user_groups.api import router as user_groups_router + # Check if access_control (unified permission system) is enabled + # This enables both user_groups and permissions together + access_control_enabled = _enabled("access_control") + + # Also support legacy individual plugin flags for backward compatibility + user_groups_enabled = _enabled("user_groups") or access_control_enabled + permissions_enabled = _enabled("permissions") or access_control_enabled + + if user_groups_enabled: + from derisk_app.feature_plugins.user_groups.api import ( + router as user_groups_router, + ) app.include_router(user_groups_router, prefix="/api/v1") logger.info("Feature plugin mounted: user_groups at /api/v1/user-groups") + + if permissions_enabled: + from derisk_app.feature_plugins.permissions.api import ( + router as permissions_router, + ) + + app.include_router(permissions_router, prefix="/api/v1") + logger.info("Feature plugin mounted: permissions at /api/v1/permissions") + + from derisk_app.feature_plugins.permissions.seed import ensure_default_roles + from derisk.storage.metadata.db_manager import db + + # Ensure permission tables exist before seeding data + try: + db.create_all() + except Exception as e: + logger.warning(f"Failed to create all tables: {e}") + + ensure_default_roles() diff --git a/packages/derisk-app/src/derisk_app/feature_plugins/catalog.py b/packages/derisk-app/src/derisk_app/feature_plugins/catalog.py index 11e7a388..3a7069d9 100644 --- a/packages/derisk-app/src/derisk_app/feature_plugins/catalog.py +++ b/packages/derisk-app/src/derisk_app/feature_plugins/catalog.py @@ -19,20 +19,38 @@ class FeaturePluginManifest: settings_schema: Optional[Dict[str, Any]] = None # When True, recommend OAuth2 + admin_users for write operations (enforced in API when configured). suggest_oauth2_admin: bool = True + # Internal: list of sub-plugins to enable/disable together + _internal_plugins: Optional[List[str]] = None _MANIFESTS: Dict[str, FeaturePluginManifest] = { - "user_groups": FeaturePluginManifest( - id="user_groups", - title="用户权限组", + "access_control": FeaturePluginManifest( + id="access_control", + title="权限控制系统", description=( - "面向登录用户的分组与成员管理(RBAC 数据面);" - "后续可基于权限组对 Agent、工具等做访问控制。" + "完整的 RBAC 权限管理系统,包含用户权限组和角色权限管理。" + "启用后需配合 OAuth2 登录使用。" ), category="access_control", requires_restart=True, - settings_schema=None, + settings_schema={ + "type": "object", + "properties": { + "default_policy": { + "type": "string", + "enum": ["allow_authenticated", "deny_all"], + "default": "allow_authenticated", + "description": "默认策略:allow_authenticated=已认证用户允许访问,deny_all=默认拒绝", + }, + "superadmin_users": { + "type": "array", + "items": {"type": "string"}, + "description": "绕过所有权限检查的用户登录名列表", + }, + }, + }, suggest_oauth2_admin=True, + _internal_plugins=["user_groups", "permissions"], ), } @@ -65,10 +83,28 @@ def _entry_enabled_and_settings( def merge_catalog_with_state( feature_plugins: Dict[str, Any], ) -> List[Dict[str, Any]]: - """Merge manifests with persisted enabled/settings for API responses.""" + """Merge manifests with persisted enabled/settings for API responses. + + For unified plugins (like access_control), the enabled state is computed + from its sub-plugins (user_groups and permissions). + """ out: List[Dict[str, Any]] = [] for m in list_manifests(): - en, st = _entry_enabled_and_settings(feature_plugins.get(m.id)) + # For unified plugins, compute enabled state from sub-plugins + if m._internal_plugins: + # Check if any sub-plugin is enabled + any_enabled = any( + _entry_enabled_and_settings(feature_plugins.get(sub_id))[0] + for sub_id in m._internal_plugins + ) + # Merge settings from all sub-plugins + merged_settings: Dict[str, Any] = {} + for sub_id in m._internal_plugins: + _, sub_settings = _entry_enabled_and_settings(feature_plugins.get(sub_id)) + merged_settings.update(sub_settings) + en, st = any_enabled, merged_settings + else: + en, st = _entry_enabled_and_settings(feature_plugins.get(m.id)) out.append( { "id": m.id, @@ -82,4 +118,4 @@ def merge_catalog_with_state( "settings": st, } ) - return out + return out \ No newline at end of file diff --git a/packages/derisk-app/src/derisk_app/feature_plugins/permissions/__init__.py b/packages/derisk-app/src/derisk_app/feature_plugins/permissions/__init__.py new file mode 100644 index 00000000..89fd95a1 --- /dev/null +++ b/packages/derisk-app/src/derisk_app/feature_plugins/permissions/__init__.py @@ -0,0 +1,23 @@ +"""RBAC Permission Plugin for OpenDerisk.""" + +from .checker import ( + require_admin, + require_execute, + require_permission, + require_read, + require_write, +) +from .dao import PermissionDao +from .service import PermissionDefinitionService, PermissionService, UserPermissions + +__all__ = [ + "require_permission", + "require_admin", + "require_read", + "require_write", + "require_execute", + "PermissionService", + "PermissionDefinitionService", + "UserPermissions", + "PermissionDao", +] diff --git a/packages/derisk-app/src/derisk_app/feature_plugins/permissions/api.py b/packages/derisk-app/src/derisk_app/feature_plugins/permissions/api.py new file mode 100644 index 00000000..7ccfabb9 --- /dev/null +++ b/packages/derisk-app/src/derisk_app/feature_plugins/permissions/api.py @@ -0,0 +1,754 @@ +"""HTTP API for RBAC permission management.""" + +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field +from sqlalchemy.exc import IntegrityError + +from derisk.storage.metadata.db_manager import db +from derisk_serve.utils.auth import UserRequest, get_user_from_headers + +from .checker import require_permission +from .dao import PermissionDao +from .service import PermissionDefinitionService, PermissionService + +router = APIRouter(prefix="/permissions", tags=["Permissions"]) + +_dao = PermissionDao() +_svc = PermissionService() +_def_svc = PermissionDefinitionService() + + +def _get_role_or_404(role_id: int) -> Dict[str, Any]: + role = _dao.get_role(role_id) + if not role: + raise HTTPException(status_code=404, detail="Role not found") + return role + + +def _ensure_role_mutable(role: Dict[str, Any]) -> None: + if role.get("is_system") == 1: + raise HTTPException(status_code=400, detail="System role is read-only") + + +# ========== Request/Response Models ========== +class RoleCreateBody(BaseModel): + name: str = Field(..., min_length=1, max_length=64) + description: Optional[str] = Field(None, max_length=500) + + +class RoleUpdateBody(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=64) + description: Optional[str] = Field(None, max_length=500) + + +class PermissionAddBody(BaseModel): + resource_type: str = Field(..., min_length=1, max_length=64) + resource_id: str = Field(default="*", max_length=255) + action: str = Field(..., min_length=1, max_length=32) + effect: str = Field(default="allow", pattern="^(allow|deny)$") + + +class UserRoleAssignBody(BaseModel): + role_id: int + + +class GroupRoleAssignBody(BaseModel): + role_id: int + + +# ========== Role Management ========== +@router.get("/roles") +async def list_roles( + _user: UserRequest = Depends(require_permission("system", "read")), +): + roles = _dao.list_roles() + return {"success": True, "data": roles} + + +@router.post("/roles") +async def create_role( + body: RoleCreateBody, + _user: UserRequest = Depends(require_permission("system", "write")), +): + try: + r = _dao.create_role(body.name, body.description) + return {"success": True, "data": r} + except IntegrityError: + raise HTTPException(status_code=409, detail="Role name already exists") + + +@router.get("/roles/{role_id}") +async def get_role( + role_id: int, + _user: UserRequest = Depends(require_permission("system", "read")), +): + r = _dao.get_role(role_id) + if not r: + raise HTTPException(status_code=404, detail="Role not found") + return {"success": True, "data": r} + + +@router.put("/roles/{role_id}") +async def update_role( + role_id: int, + body: RoleUpdateBody, + _user: UserRequest = Depends(require_permission("system", "write")), +): + role = _get_role_or_404(role_id) + _ensure_role_mutable(role) + try: + r = _dao.update_role(role_id, name=body.name, description=body.description) + if not r: + raise HTTPException(status_code=404, detail="Role not found") + _svc.invalidate_cache() + return {"success": True, "data": r} + except IntegrityError: + raise HTTPException(status_code=409, detail="Role name already exists") + + +@router.delete("/roles/{role_id}") +async def delete_role( + role_id: int, + _user: UserRequest = Depends(require_permission("system", "admin")), +): + ok = _dao.delete_role(role_id) + if not ok: + raise HTTPException(status_code=400, detail="Role not found or is system role") + _svc.invalidate_cache() + return {"success": True, "data": None} + + +# ========== Role Permission Management ========== +@router.get("/roles/{role_id}/permissions") +async def list_role_permissions( + role_id: int, + _user: UserRequest = Depends(require_permission("system", "admin")), +): + perms = _dao.list_role_permissions(role_id) + return {"success": True, "data": perms} + + +@router.post("/roles/{role_id}/permissions") +async def add_role_permission( + role_id: int, + body: PermissionAddBody, + _user: UserRequest = Depends(require_permission("system", "admin")), +): + role = _get_role_or_404(role_id) + _ensure_role_mutable(role) + try: + p = _dao.add_role_permission( + role_id, + body.resource_type, + body.action, + body.resource_id, + body.effect, + ) + _svc.invalidate_cache() + return {"success": True, "data": p} + except IntegrityError: + raise HTTPException(status_code=409, detail="Permission already exists") + + +@router.delete("/roles/{role_id}/permissions/{permission_id}") +async def remove_role_permission( + role_id: int, + permission_id: int, + _user: UserRequest = Depends(require_permission("system", "admin")), +): + from derisk_app.feature_plugins.permissions.models import RolePermissionEntity + + role = _get_role_or_404(role_id) + _ensure_role_mutable(role) + + with db.session() as s: + p = ( + s.query(RolePermissionEntity) + .filter( + RolePermissionEntity.id == permission_id, + RolePermissionEntity.role_id == role_id, + ) + .first() + ) + if not p: + raise HTTPException(status_code=404, detail="Permission not found") + + ok = _dao.remove_role_permission(permission_id) + if not ok: + raise HTTPException(status_code=404, detail="Permission not found") + _svc.invalidate_cache() + return {"success": True, "data": None} + + +# ========== User Role Assignment ========== +@router.get("/users/{user_id}/roles") +async def list_user_roles( + user_id: int, + _user: UserRequest = Depends(require_permission("system", "admin")), +): + assignments = _dao.list_user_role_assignments(user_id) + return {"success": True, "data": assignments} + + +@router.post("/users/{user_id}/roles") +async def assign_role_to_user( + user_id: int, + body: UserRoleAssignBody, + _user: UserRequest = Depends(require_permission("system", "admin")), +): + if not _dao.get_role(body.role_id): + raise HTTPException(status_code=404, detail="Role not found") + try: + ur = _dao.assign_role_to_user(user_id, body.role_id) + _svc.invalidate_cache(user_id) + return {"success": True, "data": ur} + except IntegrityError: + raise HTTPException(status_code=409, detail="Role already assigned to user") + + +@router.delete("/users/{user_id}/roles/{role_id}") +async def remove_user_role( + user_id: int, + role_id: int, + _user: UserRequest = Depends(require_permission("system", "admin")), +): + ok = _dao.remove_user_role(user_id, role_id) + if not ok: + raise HTTPException(status_code=404, detail="Role assignment not found") + _svc.invalidate_cache(user_id) + return {"success": True, "data": None} + + +# ========== Group Role Assignment ========== +@router.get("/groups/{group_id}/roles") +async def list_group_roles( + group_id: int, + _user: UserRequest = Depends(require_permission("system", "admin")), +): + assignments = _dao.list_group_role_assignments(group_id) + return {"success": True, "data": assignments} + + +@router.post("/groups/{group_id}/roles") +async def assign_role_to_group( + group_id: int, + body: GroupRoleAssignBody, + _user: UserRequest = Depends(require_permission("system", "admin")), +): + if not _dao.get_role(body.role_id): + raise HTTPException(status_code=404, detail="Role not found") + try: + gr = _dao.assign_role_to_group(group_id, body.role_id) + _svc.invalidate_cache() + return {"success": True, "data": gr} + except IntegrityError: + raise HTTPException(status_code=409, detail="Role already assigned to group") + + +@router.delete("/groups/{group_id}/roles/{role_id}") +async def remove_group_role( + group_id: int, + role_id: int, + _user: UserRequest = Depends(require_permission("system", "admin")), +): + ok = _dao.remove_group_role(group_id, role_id) + if not ok: + raise HTTPException(status_code=404, detail="Role assignment not found") + _svc.invalidate_cache() + return {"success": True, "data": None} + + +# ========== Current User Permissions ========== +@router.get("/me") +async def get_my_permissions(user: UserRequest = Depends(get_user_from_headers)): + """获取当前用户的有效权限(仅需认证)""" + return { + "success": True, + "data": { + "user_id": user.user_id, + "roles": user.roles or [], + "permissions": user.permissions or {}, + }, + } + + +# ========== User Management ========== +class UserListQuery(BaseModel): + page: int = Field(default=1, ge=1) + page_size: int = Field(default=20, ge=1, le=100) + keyword: str = Field(default="") + + +class BatchRoleAssignBody(BaseModel): + role_ids: List[int] = Field(..., min_items=1) + + +@router.get("/users") +async def list_users( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + keyword: str = Query(""), + _user: UserRequest = Depends(require_permission("system", "admin")), +): + """列出所有用户(分页)""" + from derisk_app.auth.user_service import UserService + + svc = UserService() + users, total = svc.list_users(page=page, page_size=page_size, keyword=keyword) + + # 补充每个用户的角色信息 + user_ids = [u["id"] for u in users] + user_roles_map: Dict[int, List[Dict[str, Any]]] = {} + if user_ids: + for uid in user_ids: + direct_roles = _dao.get_user_roles(uid) + user_roles_map[uid] = [r["name"] for r in direct_roles] + + items = [] + for u in users: + items.append({ + "id": u["id"], + "name": u["name"], + "fullname": u["fullname"], + "email": u["email"], + # 注意:不再返回旧版 role 字段,以 RBAC 角色为准 + "is_active": u["is_active"], + "roles": user_roles_map.get(u["id"], []), + "gmt_create": u["gmt_create"], + }) + + return { + "success": True, + "data": { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + }, + } + + +@router.get("/users/{user_id}") +async def get_user_detail( + user_id: int, + _user: UserRequest = Depends(require_permission("system", "admin")), +): + """获取用户详情(含角色信息)""" + from derisk_app.auth.user_service import UserService + + svc = UserService() + user = svc.get_user(user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # 获取直接角色 + direct_roles = _dao.get_user_roles(user_id) + # 获取组角色 + group_roles = _dao.get_user_group_roles(user_id) + + # 合并所有角色 + all_role_names = list({r["name"] for r in direct_roles + group_roles}) + + # 获取生效权限 + perms = _svc.get_user_permissions(user_id) + + return { + "success": True, + "data": { + "id": user["id"], + "name": user["name"], + "fullname": user["fullname"], + "email": user["email"], + "role": user["role"], + "is_active": user["is_active"], + "direct_roles": direct_roles, + "group_roles": group_roles, + "all_roles": all_role_names, + "effective_permissions": perms.permissions_map, + }, + } + + +@router.get("/users/{user_id}/effective-permissions") +async def get_user_effective_permissions( + user_id: int, + _user: UserRequest = Depends(require_permission("system", "admin")), +): + """获取用户的生效权限(含组继承)""" + from derisk_app.auth.user_service import UserService + + svc = UserService() + user = svc.get_user(user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + perms = _svc.get_user_permissions(user_id) + return { + "success": True, + "data": { + "user_id": user_id, + "roles": perms.role_names, + "permissions": perms.permissions_map, + }, + } + + +@router.post("/users/{user_id}/roles/batch") +async def batch_assign_roles( + user_id: int, + body: BatchRoleAssignBody, + _user: UserRequest = Depends(require_permission("system", "admin")), +): + """批量分配角色给用户""" + from derisk_app.auth.user_service import UserService + + svc = UserService() + user = svc.get_user(user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + assigned = [] + errors = [] + for role_id in body.role_ids: + if not _dao.get_role(role_id): + errors.append(f"Role {role_id} not found") + continue + try: + _dao.assign_role_to_user(user_id, role_id) + assigned.append(role_id) + except IntegrityError: + errors.append(f"Role {role_id} already assigned") + + _svc.invalidate_cache(user_id) + return { + "success": True, + "data": { + "assigned": assigned, + "errors": errors, + }, + } + + +@router.post("/users/{user_id}/roles/batch-remove") +async def batch_remove_roles( + user_id: int, + body: BatchRoleAssignBody, + _user: UserRequest = Depends(require_permission("system", "admin")), +): + """批量移除用户的角色""" + removed = [] + for role_id in body.role_ids: + ok = _dao.remove_user_role(user_id, role_id) + if ok: + removed.append(role_id) + + _svc.invalidate_cache(user_id) + return { + "success": True, + "data": { + "removed": removed, + }, + } + + +# ========== Scoped Resource Permissions ========== +class ScopedPermissionGrantBody(BaseModel): + """授予资源范围权限""" + + role_id: int = Field(..., gt=0) + resource_type: str = Field(..., min_length=1, max_length=64) + resource_id: str = Field(..., min_length=1, max_length=255) + action: str = Field(..., min_length=1, max_length=32) + effect: str = Field(default="allow", pattern="^(allow|deny)$") + + +class ScopedPermissionRevokeBody(BaseModel): + """撤销资源范围权限""" + + role_id: int = Field(..., gt=0) + resource_type: str = Field(..., min_length=1, max_length=64) + resource_id: str = Field(..., min_length=1, max_length=255) + action: str = Field(..., min_length=1, max_length=32) + + +class ScopedPermissionListQuery(BaseModel): + """查询资源范围权限""" + + role_id: Optional[int] = Field(None, gt=0) + resource_type: Optional[str] = Field(None, min_length=1, max_length=64) + resource_id: Optional[str] = Field(None, min_length=1, max_length=255) + + +@router.get("/scoped/list") +async def list_scoped_permissions( + role_id: Optional[int] = Query(None, gt=0), + resource_type: Optional[str] = Query(None, min_length=1, max_length=64), + resource_id: Optional[str] = Query(None, min_length=1, max_length=255), + _user: UserRequest = Depends(require_permission("system", "admin")), +): + """列出资源范围权限配置(支持筛选)""" + with db.session(commit=False) as s: + from derisk_app.feature_plugins.permissions.models import RolePermissionEntity + + query = s.query(RolePermissionEntity) + if role_id is not None: + query = query.filter(RolePermissionEntity.role_id == role_id) + if resource_type is not None: + query = query.filter(RolePermissionEntity.resource_type == resource_type) + if resource_id is not None: + query = query.filter(RolePermissionEntity.resource_id == resource_id) + + rows = query.order_by(RolePermissionEntity.id.asc()).all() + permissions = [ + { + "id": p.id, + "role_id": p.role_id, + "resource_type": p.resource_type, + "resource_id": p.resource_id, + "action": p.action, + "effect": p.effect, + "gmt_create": p.gmt_create.isoformat() if p.gmt_create else None, + } + for p in rows + ] + return {"success": True, "data": permissions} + + +@router.post("/scoped") +async def grant_scoped_permission( + body: ScopedPermissionGrantBody, + _user: UserRequest = Depends(require_permission("system", "admin")), +): + """授予资源范围权限。 + + 例如:授予角色对特定智能体的 read 权限 + ```json + { + "role_id": 2, + "resource_type": "agent", + "resource_id": "financial-advisor", + "action": "read", + "effect": "allow" + } + ``` + """ + role = _get_role_or_404(body.role_id) + _ensure_role_mutable(role) + + try: + p = _dao.add_role_permission( + role_id=body.role_id, + resource_type=body.resource_type, + action=body.action, + resource_id=body.resource_id, + effect=body.effect, + ) + _svc.invalidate_cache() + return {"success": True, "data": p} + except IntegrityError: + raise HTTPException(status_code=409, detail="Permission already exists") + + +@router.delete("/scoped") +async def revoke_scoped_permission( + role_id: int = Query(..., gt=0), + resource_type: str = Query(..., min_length=1, max_length=64), + resource_id: str = Query(..., min_length=1, max_length=255), + action: str = Query(..., min_length=1, max_length=32), + _user: UserRequest = Depends(require_permission("system", "admin")), +): + """撤销资源范围权限。 + + 例如:撤销角色对特定智能体的 read 权限。 + 示例请求:`DELETE /api/v1/permissions/scoped?...` + """ + from derisk_app.feature_plugins.permissions.models import RolePermissionEntity + + with db.session() as s: + p = ( + s.query(RolePermissionEntity) + .filter( + RolePermissionEntity.role_id == role_id, + RolePermissionEntity.resource_type == resource_type, + RolePermissionEntity.resource_id == resource_id, + RolePermissionEntity.action == action, + ) + .first() + ) + if not p: + raise HTTPException(status_code=404, detail="Permission not found") + role = _get_role_or_404(p.role_id) + _ensure_role_mutable(role) + s.delete(p) + _svc.invalidate_cache() + return {"success": True, "data": None} + + +# ========== Permission Definition Management ========== +class PermissionDefCreateBody(BaseModel): + name: str = Field(..., min_length=1, max_length=64) + description: Optional[str] = Field(None, max_length=500) + resource_type: str = Field(..., min_length=1, max_length=32) + resource_id: str = Field(default="*", max_length=128) + action: str = Field(..., min_length=1, max_length=32) + effect: str = Field(default="allow", pattern="^(allow|deny)$") + + +class PermissionDefUpdateBody(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=64) + description: Optional[str] = Field(None, max_length=500) + resource_type: Optional[str] = Field(None, min_length=1, max_length=32) + resource_id: Optional[str] = Field(None, max_length=128) + action: Optional[str] = Field(None, min_length=1, max_length=32) + effect: Optional[str] = Field(None, pattern="^(allow|deny)$") + is_active: Optional[bool] = None + + +class RolePermissionDefBody(BaseModel): + permission_def_id: int + + +@router.get("/definitions") +async def list_permission_definitions( + resource_type: Optional[str] = Query(None), + action: Optional[str] = Query(None), + is_active: Optional[bool] = Query(None), + _user: UserRequest = Depends(require_permission("system", "read")), +): + """列出权限定义""" + definitions = _def_svc.list_permission_definitions( + resource_type=resource_type, + action=action, + is_active=is_active, + ) + return {"success": True, "data": definitions} + + +@router.post("/definitions") +async def create_permission_definition( + body: PermissionDefCreateBody, + _user: UserRequest = Depends(require_permission("system", "write")), +): + """创建权限定义""" + try: + p = _def_svc.create_permission_definition( + name=body.name, + description=body.description, + resource_type=body.resource_type, + resource_id=body.resource_id, + action=body.action, + effect=body.effect, + ) + return {"success": True, "data": p} + except IntegrityError: + raise HTTPException( + status_code=409, detail="Permission definition name already exists" + ) + + +@router.get("/definitions/{definition_id}") +async def get_permission_definition( + definition_id: int, + _user: UserRequest = Depends(require_permission("system", "read")), +): + """获取权限定义详情""" + p = _def_svc.get_permission_definition(definition_id) + if not p: + raise HTTPException(status_code=404, detail="Permission definition not found") + return {"success": True, "data": p} + + +@router.put("/definitions/{definition_id}") +async def update_permission_definition( + definition_id: int, + body: PermissionDefUpdateBody, + _user: UserRequest = Depends(require_permission("system", "write")), +): + """更新权限定义""" + try: + p = _def_svc.update_permission_definition( + definition_id=definition_id, + name=body.name, + description=body.description, + resource_type=body.resource_type, + resource_id=body.resource_id, + action=body.action, + effect=body.effect, + is_active=body.is_active, + ) + if not p: + raise HTTPException( + status_code=404, detail="Permission definition not found" + ) + return {"success": True, "data": p} + except IntegrityError: + raise HTTPException( + status_code=409, detail="Permission definition name already exists" + ) + + +@router.delete("/definitions/{definition_id}") +async def delete_permission_definition( + definition_id: int, + _user: UserRequest = Depends(require_permission("system", "admin")), +): + """删除权限定义""" + success = _def_svc.delete_permission_definition(definition_id) + if not success: + raise HTTPException(status_code=404, detail="Permission definition not found") + return {"success": True, "data": None} + + +# ========== Role - Permission Definition Association ========== +@router.get("/roles/{role_id}/permission-defs") +async def get_role_permission_defs( + role_id: int, + _user: UserRequest = Depends(require_permission("system", "read")), +): + """获取角色关联的权限定义""" + # 验证角色存在 + if not _dao.get_role(role_id): + raise HTTPException(status_code=404, detail="Role not found") + defs = _def_svc.get_role_permission_defs(role_id) + return {"success": True, "data": defs} + + +@router.post("/roles/{role_id}/permission-defs") +async def add_permission_def_to_role( + role_id: int, + body: RolePermissionDefBody, + _user: UserRequest = Depends(require_permission("system", "write")), +): + """为角色添加权限定义""" + role = _get_role_or_404(role_id) + _ensure_role_mutable(role) + # 验证权限定义存在 + if not _def_svc.get_permission_definition(body.permission_def_id): + raise HTTPException(status_code=404, detail="Permission definition not found") + try: + r = _def_svc.add_permission_def_to_role(role_id, body.permission_def_id) + return {"success": True, "data": r} + except IntegrityError: + raise HTTPException( + status_code=409, + detail="Permission definition already assigned to role", + ) + + +@router.delete("/roles/{role_id}/permission-defs/{def_id}") +async def remove_permission_def_from_role( + role_id: int, + def_id: int, + _user: UserRequest = Depends(require_permission("system", "write")), +): + """移除角色的权限定义""" + role = _get_role_or_404(role_id) + _ensure_role_mutable(role) + success = _def_svc.remove_permission_def_from_role(role_id, def_id) + if not success: + raise HTTPException( + status_code=404, detail="Permission definition not found for this role" + ) + return {"success": True, "data": None} diff --git a/packages/derisk-app/src/derisk_app/feature_plugins/permissions/checker.py b/packages/derisk-app/src/derisk_app/feature_plugins/permissions/checker.py new file mode 100644 index 00000000..03d495cd --- /dev/null +++ b/packages/derisk-app/src/derisk_app/feature_plugins/permissions/checker.py @@ -0,0 +1,112 @@ +"""FastAPI dependency factories for permission checking.""" + +from typing import Optional + +from fastapi import Depends, HTTPException + +from derisk_serve.utils.auth import UserRequest, get_user_from_headers + + +def require_permission( + resource_type: str, + action: str, + resource_id: Optional[str] = "*", +): + """FastAPI 依赖工厂 - 检查用户是否拥有指定权限。 + + 使用方式: + # 检查对所有 agent 的 write 权限(通配符) + @router.post("/agents") + async def create_agent( + user: UserRequest = Depends(require_permission("agent", "write")), + ): + ... + + # 检查对特定 agent 的 chat 权限(资源范围) + @router.post("/agents/{agent_name}/chat") + async def chat_with_agent( + agent_name: str, + user: UserRequest = Depends(require_permission("agent", "chat", resource_id=agent_name)), + ): + ... + + 插件关闭时:直接放行(user.permissions 为 None) + 插件开启时:检查 RBAC,无权限返回 403 + + 注意:如果用户 role 字段为 "admin",也允许通过(兼容旧版 admin 用户) + + 权限检查优先级: + 1. superadmin 角色绕过所有检查 + 2. 精确匹配 resource_id 的权限 + 3. 通配符 resource_id="*" 的权限 + """ + + def dependency(user: UserRequest = Depends(get_user_from_headers)) -> UserRequest: + # 插件关闭 → permissions 为 None → 不做检查 + if user.permissions is None: + return user + + # 用户表中 role 为 admin 的用户也允许通过(兼容旧版) + if user.role == "admin": + return user + + # superadmin 角色绕过所有权限检查 + if "superadmin" in (user.roles or []): + return user + + # 检查权限:支持资源范围权限和通配符权限 + # 权限格式:user.permissions 是 dict,value 是 list of strings + # 例如:{"agent": ["read", "chat"], "agent:financial-advisor": ["read"]} + + # 1. 先检查精确匹配(resource_id 指定的资源) + if resource_id and resource_id != "*": + scoped_key = f"{resource_type}:{resource_id}" + scoped_actions = user.permissions.get(scoped_key, []) + if action in scoped_actions or "admin" in scoped_actions: + return user + + # 2. 检查通配符权限(resource_id="*" 或 resource_type 本身) + allowed = user.permissions.get(resource_type, []) + wildcard = user.permissions.get("*", []) + + if ( + action in allowed + or "admin" in allowed + or action in wildcard + or "admin" in wildcard + ): + return user + + # 3. 无权限 + if resource_id and resource_id != "*": + raise HTTPException( + status_code=403, + detail=f"Permission denied: {action} on {resource_type}:{resource_id}", + ) + else: + raise HTTPException( + status_code=403, + detail=f"Permission denied: {action} on {resource_type}", + ) + + return dependency + + +def require_admin(): + """快捷方式:要求 system admin 权限""" + return require_permission("system", "admin") + + +def require_read(resource_type: str): + """快捷方式:要求读权限""" + return require_permission(resource_type, "read") + + +def require_write(resource_type: str): + """快捷方式:要求写权限""" + return require_permission(resource_type, "write") + + +def require_execute(resource_type: str): + """快捷方式:要求执行权限""" + return require_permission(resource_type, "execute") \ No newline at end of file diff --git a/packages/derisk-app/src/derisk_app/feature_plugins/permissions/dao.py b/packages/derisk-app/src/derisk_app/feature_plugins/permissions/dao.py new file mode 100644 index 00000000..4abd967d --- /dev/null +++ b/packages/derisk-app/src/derisk_app/feature_plugins/permissions/dao.py @@ -0,0 +1,462 @@ +"""Data access layer for RBAC permission tables.""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional + +from derisk.storage.metadata.db_manager import db + +from .models import ( + GroupRoleEntity, + PermissionDefinitionEntity, + RoleEntity, + RolePermissionDefEntity, + RolePermissionEntity, + UserRoleEntity, +) + +logger = logging.getLogger(__name__) + + +class PermissionDao: + """权限数据访问层""" + + # ========== Role CRUD ========== + def list_roles(self) -> List[Dict[str, Any]]: + with db.session(commit=False) as s: + rows = s.query(RoleEntity).order_by(RoleEntity.id.asc()).all() + return [self._role_row(r) for r in rows] + + def get_role(self, role_id: int) -> Optional[Dict[str, Any]]: + with db.session(commit=False) as s: + r = s.query(RoleEntity).filter(RoleEntity.id == role_id).first() + return self._role_row(r) if r else None + + def get_role_by_name(self, name: str) -> Optional[Dict[str, Any]]: + with db.session(commit=False) as s: + r = s.query(RoleEntity).filter(RoleEntity.name == name).first() + return self._role_row(r) if r else None + + def create_role( + self, name: str, description: Optional[str] = None, is_system: int = 0 + ) -> Dict[str, Any]: + with db.session() as s: + r = RoleEntity( + name=name.strip(), description=description, is_system=is_system + ) + s.add(r) + s.flush() + s.refresh(r) + return self._role_row(r) + + def update_role( + self, + role_id: int, + name: Optional[str] = None, + description: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + with db.session() as s: + r = s.query(RoleEntity).filter(RoleEntity.id == role_id).first() + if not r: + return None + if name is not None: + r.name = name.strip() + if description is not None: + r.description = description + s.flush() + s.refresh(r) + return self._role_row(r) + + def delete_role(self, role_id: int) -> bool: + with db.session() as s: + r = s.query(RoleEntity).filter(RoleEntity.id == role_id).first() + if not r or r.is_system == 1: + return False + # 级联删除关联数据 + s.query(RolePermissionEntity).filter( + RolePermissionEntity.role_id == role_id + ).delete() + s.query(UserRoleEntity).filter(UserRoleEntity.role_id == role_id).delete() + s.query(GroupRoleEntity).filter(GroupRoleEntity.role_id == role_id).delete() + s.delete(r) + return True + + # ========== Role Permission CRUD ========== + def list_role_permissions(self, role_id: int) -> List[Dict[str, Any]]: + with db.session(commit=False) as s: + rows = ( + s.query(RolePermissionEntity) + .filter(RolePermissionEntity.role_id == role_id) + .order_by(RolePermissionEntity.id.asc()) + .all() + ) + return [self._perm_row(p) for p in rows] + + def add_role_permission( + self, + role_id: int, + resource_type: str, + action: str, + resource_id: str = "*", + effect: str = "allow", + ) -> Dict[str, Any]: + with db.session() as s: + p = RolePermissionEntity( + role_id=role_id, + resource_type=resource_type, + resource_id=resource_id, + action=action, + effect=effect, + ) + s.add(p) + s.flush() + s.refresh(p) + return self._perm_row(p) + + def remove_role_permission(self, permission_id: int) -> bool: + with db.session() as s: + p = ( + s.query(RolePermissionEntity) + .filter(RolePermissionEntity.id == permission_id) + .first() + ) + if not p: + return False + s.delete(p) + return True + + def get_permissions_for_roles(self, role_ids: List[int]) -> List[Dict[str, Any]]: + """获取多个角色的所有权限""" + if not role_ids: + return [] + with db.session(commit=False) as s: + rows = ( + s.query(RolePermissionEntity) + .filter(RolePermissionEntity.role_id.in_(role_ids)) + .all() + ) + return [self._perm_row(p) for p in rows] + + # ========== User Role Assignment ========== + def get_user_roles(self, user_id: int) -> List[Dict[str, Any]]: + """获取用户的直接角色(通过 user_role 表)""" + with db.session(commit=False) as s: + rows = ( + s.query(RoleEntity) + .join(UserRoleEntity, UserRoleEntity.role_id == RoleEntity.id) + .filter(UserRoleEntity.user_id == user_id) + .all() + ) + return [self._role_row(r) for r in rows] + + def assign_role_to_user(self, user_id: int, role_id: int) -> Dict[str, Any]: + with db.session() as s: + ur = UserRoleEntity(user_id=user_id, role_id=role_id) + s.add(ur) + s.flush() + s.refresh(ur) + return {"id": ur.id, "user_id": ur.user_id, "role_id": ur.role_id} + + def remove_user_role(self, user_id: int, role_id: int) -> bool: + with db.session() as s: + ur = ( + s.query(UserRoleEntity) + .filter( + UserRoleEntity.user_id == user_id, + UserRoleEntity.role_id == role_id, + ) + .first() + ) + if not ur: + return False + s.delete(ur) + return True + + def list_user_role_assignments(self, user_id: int) -> List[Dict[str, Any]]: + with db.session(commit=False) as s: + rows = ( + s.query(UserRoleEntity, RoleEntity) + .join(RoleEntity, RoleEntity.id == UserRoleEntity.role_id) + .filter(UserRoleEntity.user_id == user_id) + .all() + ) + return [ + {"id": ur.id, "role_id": r.id, "role_name": r.name} for ur, r in rows + ] + + # ========== Group Role Assignment ========== + def get_user_group_roles(self, user_id: int) -> List[Dict[str, Any]]: + """获取用户通过用户组继承的角色""" + with db.session(commit=False) as s: + # 需要关联 user_group_member 和 group_role + from derisk_app.feature_plugins.user_groups.models import ( + UserGroupMemberEntity, + ) + + rows = ( + s.query(RoleEntity) + .join(GroupRoleEntity, GroupRoleEntity.role_id == RoleEntity.id) + .join( + UserGroupMemberEntity, + UserGroupMemberEntity.group_id == GroupRoleEntity.group_id, + ) + .filter(UserGroupMemberEntity.user_id == user_id) + .all() + ) + return [self._role_row(r) for r in rows] + + def assign_role_to_group(self, group_id: int, role_id: int) -> Dict[str, Any]: + with db.session() as s: + gr = GroupRoleEntity(group_id=group_id, role_id=role_id) + s.add(gr) + s.flush() + s.refresh(gr) + return {"id": gr.id, "group_id": gr.group_id, "role_id": gr.role_id} + + def remove_group_role(self, group_id: int, role_id: int) -> bool: + with db.session() as s: + gr = ( + s.query(GroupRoleEntity) + .filter( + GroupRoleEntity.group_id == group_id, + GroupRoleEntity.role_id == role_id, + ) + .first() + ) + if not gr: + return False + s.delete(gr) + return True + + def list_group_role_assignments(self, group_id: int) -> List[Dict[str, Any]]: + with db.session(commit=False) as s: + rows = ( + s.query(GroupRoleEntity, RoleEntity) + .join(RoleEntity, RoleEntity.id == GroupRoleEntity.role_id) + .filter(GroupRoleEntity.group_id == group_id) + .all() + ) + return [ + {"id": gr.id, "role_id": r.id, "role_name": r.name} for gr, r in rows + ] + + # ========== Helper Methods ========== + @staticmethod + def _role_row(r: RoleEntity) -> Dict[str, Any]: + return { + "id": r.id, + "name": r.name, + "description": r.description or "", + "is_system": r.is_system, + "gmt_create": r.gmt_create.isoformat() if r.gmt_create else None, + "gmt_modify": r.gmt_modify.isoformat() if r.gmt_modify else None, + } + + @staticmethod + def _perm_row(p: RolePermissionEntity) -> Dict[str, Any]: + return { + "id": p.id, + "role_id": p.role_id, + "resource_type": p.resource_type, + "resource_id": p.resource_id, + "action": p.action, + "effect": p.effect, + "gmt_create": p.gmt_create.isoformat() if p.gmt_create else None, + } + + # ========== Permission Definition CRUD ========== + def list_permission_definitions( + self, + resource_type: Optional[str] = None, + action: Optional[str] = None, + is_active: Optional[bool] = None, + ) -> List[Dict[str, Any]]: + """列出权限定义""" + with db.session(commit=False) as s: + query = s.query(PermissionDefinitionEntity) + if resource_type: + query = query.filter( + PermissionDefinitionEntity.resource_type == resource_type + ) + if action: + query = query.filter(PermissionDefinitionEntity.action == action) + if is_active is not None: + query = query.filter( + PermissionDefinitionEntity.is_active == is_active + ) + rows = query.order_by(PermissionDefinitionEntity.id.asc()).all() + return [self._perm_def_row(r) for r in rows] + + def get_permission_definition( + self, definition_id: int + ) -> Optional[Dict[str, Any]]: + """获取权限定义详情""" + with db.session(commit=False) as s: + p = ( + s.query(PermissionDefinitionEntity) + .filter(PermissionDefinitionEntity.id == definition_id) + .first() + ) + return self._perm_def_row(p) if p else None + + def create_permission_definition( + self, + name: str, + resource_type: str, + action: str, + resource_id: str = "*", + effect: str = "allow", + description: Optional[str] = None, + ) -> Dict[str, Any]: + """创建权限定义""" + with db.session() as s: + p = PermissionDefinitionEntity( + name=name.strip(), + description=description, + resource_type=resource_type, + resource_id=resource_id, + action=action, + effect=effect, + is_active=True, + ) + s.add(p) + s.flush() + s.refresh(p) + return self._perm_def_row(p) + + def update_permission_definition( + self, + definition_id: int, + name: Optional[str] = None, + description: Optional[str] = None, + resource_type: Optional[str] = None, + resource_id: Optional[str] = None, + action: Optional[str] = None, + effect: Optional[str] = None, + is_active: Optional[bool] = None, + ) -> Optional[Dict[str, Any]]: + """更新权限定义""" + with db.session() as s: + p = ( + s.query(PermissionDefinitionEntity) + .filter(PermissionDefinitionEntity.id == definition_id) + .first() + ) + if not p: + return None + if name is not None: + p.name = name.strip() + if description is not None: + p.description = description + if resource_type is not None: + p.resource_type = resource_type + if resource_id is not None: + p.resource_id = resource_id + if action is not None: + p.action = action + if effect is not None: + p.effect = effect + if is_active is not None: + p.is_active = is_active + s.flush() + s.refresh(p) + return self._perm_def_row(p) + + def delete_permission_definition(self, definition_id: int) -> bool: + """删除权限定义""" + with db.session() as s: + p = ( + s.query(PermissionDefinitionEntity) + .filter(PermissionDefinitionEntity.id == definition_id) + .first() + ) + if not p: + return False + # 删除关联的角色权限定义 + s.query(RolePermissionDefEntity).filter( + RolePermissionDefEntity.permission_def_id == definition_id + ).delete() + s.delete(p) + return True + + # ========== Role Permission Definition Association ========== + def get_role_permission_defs(self, role_id: int) -> List[Dict[str, Any]]: + """获取角色关联的权限定义""" + with db.session(commit=False) as s: + rows = ( + s.query(PermissionDefinitionEntity) + .join( + RolePermissionDefEntity, + RolePermissionDefEntity.permission_def_id + == PermissionDefinitionEntity.id, + ) + .filter(RolePermissionDefEntity.role_id == role_id) + .all() + ) + return [self._perm_def_row(r) for r in rows] + + def add_permission_def_to_role( + self, role_id: int, permission_def_id: int + ) -> Optional[Dict[str, Any]]: + """为角色添加权限定义""" + with db.session() as s: + # 检查是否已存在 + existing = ( + s.query(RolePermissionDefEntity) + .filter( + RolePermissionDefEntity.role_id == role_id, + RolePermissionDefEntity.permission_def_id == permission_def_id, + ) + .first() + ) + if existing: + return { + "id": existing.id, + "role_id": existing.role_id, + "permission_def_id": existing.permission_def_id, + } + rpd = RolePermissionDefEntity( + role_id=role_id, permission_def_id=permission_def_id + ) + s.add(rpd) + s.flush() + s.refresh(rpd) + return { + "id": rpd.id, + "role_id": rpd.role_id, + "permission_def_id": rpd.permission_def_id, + } + + def remove_permission_def_from_role( + self, role_id: int, permission_def_id: int + ) -> bool: + """移除角色的权限定义""" + with db.session() as s: + rpd = ( + s.query(RolePermissionDefEntity) + .filter( + RolePermissionDefEntity.role_id == role_id, + RolePermissionDefEntity.permission_def_id == permission_def_id, + ) + .first() + ) + if not rpd: + return False + s.delete(rpd) + return True + + @staticmethod + def _perm_def_row(p: PermissionDefinitionEntity) -> Dict[str, Any]: + return { + "id": p.id, + "name": p.name, + "description": p.description or "", + "resource_type": p.resource_type, + "resource_id": p.resource_id, + "action": p.action, + "effect": p.effect, + "is_active": p.is_active, + "gmt_create": p.gmt_create.isoformat() if p.gmt_create else None, + "gmt_modify": p.gmt_modify.isoformat() if p.gmt_modify else None, + } diff --git a/packages/derisk-app/src/derisk_app/feature_plugins/permissions/models.py b/packages/derisk-app/src/derisk_app/feature_plugins/permissions/models.py new file mode 100644 index 00000000..18481023 --- /dev/null +++ b/packages/derisk-app/src/derisk_app/feature_plugins/permissions/models.py @@ -0,0 +1,133 @@ +"""ORM models for RBAC permission tables.""" + +from datetime import datetime + +from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text, UniqueConstraint + +from derisk.storage.metadata import Model + + +# Resource types +RESOURCE_AGENT = "agent" +RESOURCE_TOOL = "tool" +RESOURCE_KNOWLEDGE = "knowledge" +RESOURCE_MODEL = "model" +RESOURCE_SYSTEM = "system" + +# Permission actions by resource type +AGENT_ACTIONS = ["read", "chat", "write", "admin"] # read → chat → write +TOOL_ACTIONS = ["read", "execute", "manage", "admin"] # read → execute → manage +KNOWLEDGE_ACTIONS = ["read", "query", "write", "admin"] # read → query → write +MODEL_ACTIONS = ["read", "chat", "manage", "admin"] # read → chat → manage + +# All resource-action mappings +RESOURCE_ACTIONS = { + RESOURCE_AGENT: AGENT_ACTIONS, + RESOURCE_TOOL: TOOL_ACTIONS, + RESOURCE_KNOWLEDGE: KNOWLEDGE_ACTIONS, + RESOURCE_MODEL: MODEL_ACTIONS, + RESOURCE_SYSTEM: ["admin"], +} + + +class RoleEntity(Model): + """角色表""" + + __tablename__ = "role" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(64), unique=True, nullable=False, comment="角色名") + description = Column(Text, nullable=True, comment="角色描述") + is_system = Column(Integer, default=0, comment="1=内置不可删除") + gmt_create = Column(DateTime, default=datetime.utcnow, nullable=False) + gmt_modify = Column( + DateTime, + default=datetime.utcnow, + onupdate=datetime.utcnow, + nullable=False, + ) + + +class RolePermissionEntity(Model): + """角色-权限表""" + + __tablename__ = "role_permission" + __table_args__ = ( + UniqueConstraint( + "role_id", "resource_type", "resource_id", "action", name="uk_role_perm" + ), + ) + + id = Column(Integer, primary_key=True, autoincrement=True) + role_id = Column(Integer, nullable=False, index=True, comment="role.id") + resource_type = Column( + String(64), + nullable=False, + comment="agent/datasource/knowledge/tool/model/system/*", + ) + resource_id = Column(String(255), default="*", comment="具体资源ID或*表示全部") + action = Column(String(32), nullable=False, comment="read/write/execute/admin") + effect = Column(String(16), default="allow", comment="allow/deny") + gmt_create = Column(DateTime, default=datetime.utcnow, nullable=False) + + +class UserRoleEntity(Model): + """用户-角色关联表""" + + __tablename__ = "user_role" + __table_args__ = (UniqueConstraint("user_id", "role_id", name="uk_user_role"),) + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, nullable=False, index=True, comment="user.id") + role_id = Column(Integer, nullable=False, index=True, comment="role.id") + gmt_create = Column(DateTime, default=datetime.utcnow, nullable=False) + + +class GroupRoleEntity(Model): + """用户组-角色关联表""" + + __tablename__ = "group_role" + __table_args__ = (UniqueConstraint("group_id", "role_id", name="uk_group_role"),) + + id = Column(Integer, primary_key=True, autoincrement=True) + group_id = Column(Integer, nullable=False, index=True, comment="user_group.id") + role_id = Column(Integer, nullable=False, index=True, comment="role.id") + gmt_create = Column(DateTime, default=datetime.utcnow, nullable=False) + + +class PermissionDefinitionEntity(Model): + """权限定义表(独立权限)""" + + __tablename__ = "permission_definition" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(64), unique=True, nullable=False, comment="权限名称") + description = Column(Text, nullable=True, comment="权限描述") + resource_type = Column(String(32), nullable=False, comment="资源类型") + resource_id = Column(String(128), default="*", comment="资源ID,*表示所有资源") + action = Column(String(32), nullable=False, comment="操作类型") + effect = Column(String(16), default="allow", comment="allow/deny") + is_active = Column(Boolean, default=True, comment="是否启用") + gmt_create = Column(DateTime, default=datetime.utcnow, nullable=False) + gmt_modify = Column( + DateTime, + default=datetime.utcnow, + onupdate=datetime.utcnow, + nullable=False, + ) + + +class RolePermissionDefEntity(Model): + """角色-权限定义关联表""" + + __tablename__ = "role_permission_def" + __table_args__ = ( + UniqueConstraint("role_id", "permission_def_id", name="uk_role_perm_def"), + ) + + id = Column(Integer, primary_key=True, autoincrement=True) + role_id = Column(Integer, nullable=False, index=True, comment="role.id") + permission_def_id = Column( + Integer, nullable=False, index=True, comment="permission_definition.id" + ) + gmt_create = Column(DateTime, default=datetime.utcnow, nullable=False) diff --git a/packages/derisk-app/src/derisk_app/feature_plugins/permissions/seed.py b/packages/derisk-app/src/derisk_app/feature_plugins/permissions/seed.py new file mode 100644 index 00000000..7bee02dd --- /dev/null +++ b/packages/derisk-app/src/derisk_app/feature_plugins/permissions/seed.py @@ -0,0 +1,276 @@ +"""Seed data initialization for built-in roles and default admin user.""" + +import logging + +from sqlalchemy import or_ + +from .dao import PermissionDao + +logger = logging.getLogger(__name__) + +# Default permission definitions that can be assigned to roles +SEED_PERMISSION_DEFINITIONS = [ + # Agent permissions + {"name": "agent_read_all", "description": "可读取所有智能体", "resource_type": "agent", "resource_id": "*", "action": "read"}, + {"name": "agent_chat_all", "description": "可与所有智能体对话", "resource_type": "agent", "resource_id": "*", "action": "chat"}, + {"name": "agent_write_all", "description": "可管理所有智能体配置", "resource_type": "agent", "resource_id": "*", "action": "write"}, + {"name": "agent_admin_all", "description": "可完全管理所有智能体", "resource_type": "agent", "resource_id": "*", "action": "admin"}, + # Tool permissions + {"name": "tool_read_all", "description": "可读取所有工具", "resource_type": "tool", "resource_id": "*", "action": "read"}, + {"name": "tool_execute_all", "description": "可执行所有工具", "resource_type": "tool", "resource_id": "*", "action": "execute"}, + {"name": "tool_manage_all", "description": "可管理所有工具", "resource_type": "tool", "resource_id": "*", "action": "manage"}, + # Knowledge permissions + {"name": "knowledge_read_all", "description": "可读取所有知识库", "resource_type": "knowledge", "resource_id": "*", "action": "read"}, + {"name": "knowledge_query_all", "description": "可检索所有知识库", "resource_type": "knowledge", "resource_id": "*", "action": "query"}, + {"name": "knowledge_write_all", "description": "可管理所有知识库", "resource_type": "knowledge", "resource_id": "*", "action": "write"}, + # Model permissions + {"name": "model_read_all", "description": "可读取所有模型", "resource_type": "model", "resource_id": "*", "action": "read"}, + {"name": "model_chat_all", "description": "可使用所有模型对话", "resource_type": "model", "resource_id": "*", "action": "chat"}, + {"name": "model_manage_all", "description": "可管理所有模型", "resource_type": "model", "resource_id": "*", "action": "manage"}, + # System permissions + {"name": "system_admin", "description": "系统管理员权限", "resource_type": "system", "resource_id": "*", "action": "admin"}, +] + +SEED_ROLES = [ + { + "name": "guest", + "description": "访客(仅可查看模型和监控,不能查看智能体/工具/知识库)", + "is_system": 1, + "permissions": [ + ("model", "read"), + ("model", "chat"), + ], + }, + { + "name": "viewer", + "description": "只读访问所有资源(可查看界面和详情,但不能对话/执行/编辑)", + "is_system": 1, + "permissions": [ + ("agent", "read"), + ("tool", "read"), + ("knowledge", "read"), + ("model", "read"), + ], + }, + { + "name": "operator", + "description": "操作员(可查看、对话、执行工具、检索知识库,但不能编辑配置)", + "is_system": 1, + "permissions": [ + ("agent", "read"), + ("agent", "chat"), + ("tool", "read"), + ("tool", "execute"), + ("knowledge", "read"), + ("knowledge", "query"), + ("model", "read"), + ("model", "chat"), + ], + }, + { + "name": "editor", + "description": "编辑者(可查看、使用、编辑所有资源配置)", + "is_system": 1, + "permissions": [ + ("agent", "read"), + ("agent", "chat"), + ("agent", "write"), + ("tool", "read"), + ("tool", "execute"), + ("tool", "manage"), + ("knowledge", "read"), + ("knowledge", "query"), + ("knowledge", "write"), + ("model", "read"), + ("model", "chat"), + ("model", "manage"), + ], + }, + { + "name": "admin", + "description": "完全管理权限", + "is_system": 1, + "permissions": [ + ("agent", "read"), + ("agent", "chat"), + ("agent", "write"), + ("agent", "admin"), + ("tool", "read"), + ("tool", "execute"), + ("tool", "manage"), + ("tool", "admin"), + ("knowledge", "read"), + ("knowledge", "query"), + ("knowledge", "write"), + ("knowledge", "admin"), + ("model", "read"), + ("model", "chat"), + ("model", "manage"), + ("model", "admin"), + ("system", "admin"), + ], + }, +] + + +def _ensure_system_role_permissions( + dao: PermissionDao, role_id: int, expected_permissions: list[tuple[str, str]] +) -> None: + """Ensure built-in system role has all expected wildcard permissions.""" + current = dao.list_role_permissions(role_id) + current_keys = { + (p.get("resource_type"), p.get("action"), p.get("resource_id", "*")) + for p in current + } + + for resource_type, action in expected_permissions: + key = (resource_type, action, "*") + if key in current_keys: + continue + try: + dao.add_role_permission( + role_id=role_id, + resource_type=resource_type, + action=action, + resource_id="*", + ) + logger.info( + "Added missing permission %s:%s(*) to system role id=%s", + resource_type, + action, + role_id, + ) + except Exception as e: + logger.warning( + "Failed to add missing permission %s:%s to role id=%s: %s", + resource_type, + action, + role_id, + e, + ) + + +def ensure_default_roles() -> None: + """Idempotent: 创建内置角色(如果不存在)并创建默认 admin 用户。""" + dao = PermissionDao() + + # 1. 创建默认角色 + admin_role_id = None + for role_def in SEED_ROLES: + existing = dao.get_role_by_name(role_def["name"]) + if existing: + logger.debug(f"Seed role already exists: {role_def['name']}") + # Align existing system role permissions with current seed definition. + _ensure_system_role_permissions( + dao, + existing["id"], + role_def["permissions"], + ) + if role_def["name"] == "admin": + admin_role_id = existing["id"] + continue + try: + role = dao.create_role( + name=role_def["name"], + description=role_def["description"], + is_system=role_def["is_system"], + ) + for resource_type, action in role_def["permissions"]: + dao.add_role_permission( + role_id=role["id"], + resource_type=resource_type, + action=action, + ) + logger.info(f"Seed role created: {role_def['name']}") + if role_def["name"] == "admin": + admin_role_id = role["id"] + except Exception as e: + logger.exception(f"Failed to create seed role {role_def['name']}: {e}") + + # 2. 创建默认 admin 用户并分配角色 + if admin_role_id: + try: + from derisk_app.auth.user_service import UserEntity + from derisk.storage.metadata.db_manager import db + from datetime import datetime + + with db.session(commit=True) as s: + # 检查是否已存在 admin 用户(通过 oauth_id 或 name) + existing_admin = s.query(UserEntity).filter( + or_( + UserEntity.oauth_id == "admin", + UserEntity.name == "admin", + ) + ).first() + + if existing_admin: + logger.debug(f"Admin user already exists: {existing_admin.name}") + admin_user_id = existing_admin.id + # 检查是否已分配 admin 角色 + user_roles = dao.get_user_roles(admin_user_id) + has_admin_role = any(r.get("id") == admin_role_id for r in user_roles) + if not has_admin_role: + dao.assign_role_to_user(admin_user_id, admin_role_id) + logger.info("Assigned admin role to existing admin user") + else: + # 创建新的 admin 用户 + user = UserEntity( + name="admin", + fullname="System Administrator", + oauth_provider="local", + oauth_id="admin", + email="admin@derisk.local", + role="admin", + is_active=1, + gmt_create=datetime.utcnow(), + gmt_modify=datetime.utcnow(), + ) + s.add(user) + s.flush() # 获取 ID + admin_user_id = user.id + s.commit() + + # 分配 admin 角色 + dao.assign_role_to_user(admin_user_id, admin_role_id) + logger.info(f"Created default admin user (ID={admin_user_id}) and assigned admin role") + logger.info("=" * 60) + logger.info("DEFAULT ADMIN USER CREATED:") + logger.info(" Username: admin") + logger.info(" OAuth Provider: local (bypass OAuth for local admin)") + logger.info(f" User ID: {admin_user_id}") + logger.info(" Note: Use OAuth2 login or set X-User-ID: admin header for testing") + logger.info("=" * 60) + except Exception as e: + logger.warning(f"Failed to create/assign admin user: {e}") + + # 3. 创建默认权限定义 + _ensure_default_permission_definitions(dao) + + +def _ensure_default_permission_definitions(dao: PermissionDao) -> None: + """Idempotent: 创建默认权限定义(如果不存在)。""" + for perm_def in SEED_PERMISSION_DEFINITIONS: + # Check if already exists by name + existing = None + try: + all_defs = dao.list_permission_definitions() + existing = next((d for d in all_defs if d["name"] == perm_def["name"]), None) + except Exception: + pass + + if existing: + logger.debug(f"Seed permission definition already exists: {perm_def['name']}") + continue + + try: + dao.create_permission_definition( + name=perm_def["name"], + description=perm_def["description"], + resource_type=perm_def["resource_type"], + resource_id=perm_def["resource_id"], + action=perm_def["action"], + effect="allow", + ) + logger.info(f"Seed permission definition created: {perm_def['name']}") + except Exception as e: + logger.warning(f"Failed to create seed permission definition {perm_def['name']}: {e}") diff --git a/packages/derisk-app/src/derisk_app/feature_plugins/permissions/service.py b/packages/derisk-app/src/derisk_app/feature_plugins/permissions/service.py new file mode 100644 index 00000000..63f9990c --- /dev/null +++ b/packages/derisk-app/src/derisk_app/feature_plugins/permissions/service.py @@ -0,0 +1,201 @@ +"""Permission service with in-memory caching.""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +from .dao import PermissionDao + + +@dataclass +class UserPermissions: + """用户的聚合权限快照""" + + user_id: int + role_names: List[str] + permissions_map: Dict[str, List[str]] # resource_type -> [action, ...] + loaded_at: float = field(default_factory=time.time) + + +class PermissionService: + """权限核心逻辑,负责聚合用户权限并提供内存缓存。""" + + _cache: Dict[int, UserPermissions] = {} + _cache_ttl = 60 # 缓存有效期(秒) + + def __init__(self): + self._dao = PermissionDao() + + def get_user_permissions(self, user_id: int) -> UserPermissions: + """加载用户的全部有效权限(直接角色 + 用户组角色),60s 缓存""" + user_id_int = int(user_id) if user_id else 0 + cached = self._cache.get(user_id_int) + if cached and (time.time() - cached.loaded_at) < self._cache_ttl: + return cached + + # 1. 获取直接角色 + direct_roles = self._dao.get_user_roles(user_id_int) + # 2. 获取通过用户组继承的角色 + group_roles = self._dao.get_user_group_roles(user_id_int) + + # 合并角色(去重) + all_roles = {r["id"]: r["name"] for r in direct_roles + group_roles} + role_ids = list(all_roles.keys()) + role_names = list(all_roles.values()) + + # 3. 聚合所有角色的权限 + permissions_map: Dict[str, List[str]] = {} + if role_ids: + perms = self._dao.get_permissions_for_roles(role_ids) + for p in perms: + if p["effect"] == "allow": + rt = p["resource_type"] + act = p["action"] + permissions_map.setdefault(rt, []) + if act not in permissions_map[rt]: + permissions_map[rt].append(act) + + result = UserPermissions( + user_id=user_id_int, + role_names=role_names, + permissions_map=permissions_map, + ) + self._cache[user_id_int] = result + return result + + def invalidate_cache(self, user_id: Optional[int] = None) -> None: + """清除缓存。管理 API 修改角色/权限后调用。""" + if user_id is not None: + self._cache.pop(user_id, None) + else: + self._cache.clear() + + def check_permission(self, user_id: int, resource_type: str, action: str) -> bool: + """检查用户是否拥有指定权限(通配符模式)""" + return self.check_scoped_permission(user_id, resource_type, "*", action) + + def check_scoped_permission( + self, + user_id: int, + resource_type: str, + resource_id: str, + action: str, + ) -> bool: + """检查用户是否拥有指定资源的权限。 + + 权限检查优先级: + 1. superadmin 绕过所有检查 + 2. 精确匹配 resource_id 的权限(如 agent:financial-advisor) + 3. 通配符 resource_id="*" 的权限(如 agent:* 或简化为 agent) + """ + perms = self.get_user_permissions(user_id) + + # superadmin 绕过所有检查 + if "superadmin" in perms.role_names: + return True + + # 1. 检查精确匹配(resource_id 指定的资源) + if resource_id and resource_id != "*": + scoped_key = f"{resource_type}:{resource_id}" + scoped_actions = perms.permissions_map.get(scoped_key, []) + if action in scoped_actions or "admin" in scoped_actions: + return True + + # 2. 检查通配符权限(resource_id="*") + allowed = perms.permissions_map.get(resource_type, []) + wildcard = perms.permissions_map.get("*", []) + + return ( + action in allowed + or "admin" in allowed + or action in wildcard + or "admin" in wildcard + ) + + +class PermissionDefinitionService: + """权限定义服务,用于管理独立权限定义""" + + def __init__(self): + self._dao = PermissionDao() + + def list_permission_definitions( + self, + resource_type: Optional[str] = None, + action: Optional[str] = None, + is_active: Optional[bool] = None, + ) -> List[Dict]: + """列出权限定义""" + return self._dao.list_permission_definitions( + resource_type=resource_type, + action=action, + is_active=is_active, + ) + + def get_permission_definition(self, definition_id: int) -> Optional[Dict]: + """获取权限定义详情""" + return self._dao.get_permission_definition(definition_id) + + def create_permission_definition( + self, + name: str, + resource_type: str, + action: str, + resource_id: str = "*", + effect: str = "allow", + description: Optional[str] = None, + ) -> Dict: + """创建权限定义""" + return self._dao.create_permission_definition( + name=name, + resource_type=resource_type, + action=action, + resource_id=resource_id, + effect=effect, + description=description, + ) + + def update_permission_definition( + self, + definition_id: int, + name: Optional[str] = None, + description: Optional[str] = None, + resource_type: Optional[str] = None, + resource_id: Optional[str] = None, + action: Optional[str] = None, + effect: Optional[str] = None, + is_active: Optional[bool] = None, + ) -> Optional[Dict]: + """更新权限定义""" + return self._dao.update_permission_definition( + definition_id=definition_id, + name=name, + description=description, + resource_type=resource_type, + resource_id=resource_id, + action=action, + effect=effect, + is_active=is_active, + ) + + def delete_permission_definition(self, definition_id: int) -> bool: + """删除权限定义""" + return self._dao.delete_permission_definition(definition_id) + + def get_role_permission_defs(self, role_id: int) -> List[Dict]: + """获取角色关联的权限定义""" + return self._dao.get_role_permission_defs(role_id) + + def add_permission_def_to_role( + self, role_id: int, permission_def_id: int + ) -> Optional[Dict]: + """为角色添加权限定义""" + return self._dao.add_permission_def_to_role(role_id, permission_def_id) + + def remove_permission_def_from_role( + self, role_id: int, permission_def_id: int + ) -> bool: + """移除角色的权限定义""" + return self._dao.remove_permission_def_from_role(role_id, permission_def_id) diff --git a/packages/derisk-app/src/derisk_app/feature_plugins/system_config_dao.py b/packages/derisk-app/src/derisk_app/feature_plugins/system_config_dao.py new file mode 100644 index 00000000..63f532b0 --- /dev/null +++ b/packages/derisk-app/src/derisk_app/feature_plugins/system_config_dao.py @@ -0,0 +1,99 @@ +"""Data access layer for system configuration.""" + +import json +import logging +from typing import Any, Dict, Optional + +from derisk.storage.metadata.db_manager import db + +from .system_config_model import SystemConfigEntity + +logger = logging.getLogger(__name__) + + +class SystemConfigDao: + """系统配置数据访问层""" + + def get_config(self, config_key: str, config_type: str = "feature_plugin") -> Optional[Dict[str, Any]]: + """获取配置项""" + with db.session(commit=False) as s: + config = s.query(SystemConfigEntity).filter( + SystemConfigEntity.config_key == config_key, + SystemConfigEntity.config_type == config_type + ).first() + if config and config.config_value: + try: + return json.loads(config.config_value) + except (json.JSONDecodeError, TypeError): + return None + return None + + def set_config( + self, + config_key: str, + config_value: Dict[str, Any], + config_type: str = "feature_plugin", + description: Optional[str] = None + ) -> Dict[str, Any]: + """设置配置项(upsert)""" + with db.session() as s: + config = s.query(SystemConfigEntity).filter( + SystemConfigEntity.config_key == config_key, + SystemConfigEntity.config_type == config_type + ).first() + + value_json = json.dumps(config_value, ensure_ascii=False) + + if config: + config.config_value = value_json + if description: + config.description = description + s.flush() + s.refresh(config) + else: + config = SystemConfigEntity( + config_key=config_key, + config_value=value_json, + config_type=config_type, + description=description + ) + s.add(config) + s.flush() + s.refresh(config) + + return { + "id": config.id, + "config_key": config.config_key, + "config_value": json.loads(config.config_value) if config.config_value else {}, + "config_type": config.config_type, + } + + def delete_config(self, config_key: str, config_type: str = "feature_plugin") -> bool: + """删除配置项""" + with db.session() as s: + config = s.query(SystemConfigEntity).filter( + SystemConfigEntity.config_key == config_key, + SystemConfigEntity.config_type == config_type + ).first() + if config: + s.delete(config) + return True + return False + + def get_all_configs(self, config_type: str = "feature_plugin") -> Dict[str, Dict[str, Any]]: + """获取所有指定类型的配置""" + with db.session(commit=False) as s: + configs = s.query(SystemConfigEntity).filter( + SystemConfigEntity.config_type == config_type + ).all() + + result = {} + for config in configs: + if config.config_value: + try: + result[config.config_key] = json.loads(config.config_value) + except (json.JSONDecodeError, TypeError): + result[config.config_key] = {} + else: + result[config.config_key] = {} + return result \ No newline at end of file diff --git a/packages/derisk-app/src/derisk_app/feature_plugins/system_config_model.py b/packages/derisk-app/src/derisk_app/feature_plugins/system_config_model.py new file mode 100644 index 00000000..917c62dc --- /dev/null +++ b/packages/derisk-app/src/derisk_app/feature_plugins/system_config_model.py @@ -0,0 +1,27 @@ +"""Database model for system configuration storage.""" + +from datetime import datetime + +from sqlalchemy import Column, DateTime, Integer, String, Text + +from derisk.storage.metadata import Model + + +class SystemConfigEntity(Model): + """系统配置表 - 用于存储功能插件等系统配置状态""" + + __tablename__ = "system_config" + + id = Column(Integer, primary_key=True, autoincrement=True) + config_key = Column(String(128), unique=True, nullable=False, comment="配置键名") + config_value = Column(Text, nullable=True, comment="配置值(JSON 格式)") + config_type = Column(String(32), default="feature_plugin", comment="配置类型") + description = Column(String(512), nullable=True, comment="配置描述") + gmt_create = Column(DateTime, default=datetime.utcnow, nullable=False, comment="创建时间") + gmt_modify = Column( + DateTime, + default=datetime.utcnow, + onupdate=datetime.utcnow, + nullable=False, + comment="修改时间" + ) \ No newline at end of file diff --git a/packages/derisk-app/src/derisk_app/feature_plugins/user_groups/api.py b/packages/derisk-app/src/derisk_app/feature_plugins/user_groups/api.py index 79df266b..b8bccd8b 100644 --- a/packages/derisk-app/src/derisk_app/feature_plugins/user_groups/api.py +++ b/packages/derisk-app/src/derisk_app/feature_plugins/user_groups/api.py @@ -1,4 +1,4 @@ -"""HTTP API for user groups (only mounted when feature plugin user_groups is enabled).""" +"""HTTP API for user groups.""" from typing import List, Optional @@ -6,12 +6,15 @@ from pydantic import BaseModel, Field from sqlalchemy.exc import IntegrityError -from derisk_serve.utils.auth import UserRequest, get_user_from_headers +from derisk_app.feature_plugins.permissions.checker import require_permission +from derisk_app.feature_plugins.permissions.service import PermissionService from derisk_app.feature_plugins.user_groups.service import UserGroupService +from derisk_serve.utils.auth import UserRequest router = APIRouter(prefix="/user-groups", tags=["UserGroups"]) _svc = UserGroupService() +_perm_svc = PermissionService() class GroupCreateBody(BaseModel): @@ -29,7 +32,9 @@ class MembersAddBody(BaseModel): @router.get("/groups") -async def list_groups(_user: UserRequest = Depends(get_user_from_headers)): +async def list_groups( + _user: UserRequest = Depends(require_permission("system", "read")), +): groups = _svc.list_groups() for g in groups: g["member_count"] = _svc.count_members(g["id"]) @@ -39,7 +44,7 @@ async def list_groups(_user: UserRequest = Depends(get_user_from_headers)): @router.post("/groups") async def create_group( body: GroupCreateBody, - _user: UserRequest = Depends(get_user_from_headers), + _user: UserRequest = Depends(require_permission("system", "admin")), ): try: g = _svc.create_group(body.name, body.description) @@ -51,7 +56,7 @@ async def create_group( @router.get("/groups/{group_id}") async def get_group( group_id: int, - _user: UserRequest = Depends(get_user_from_headers), + _user: UserRequest = Depends(require_permission("system", "read")), ): g = _svc.get_group(group_id) if not g: @@ -64,7 +69,7 @@ async def get_group( async def update_group( group_id: int, body: GroupUpdateBody, - _user: UserRequest = Depends(get_user_from_headers), + _user: UserRequest = Depends(require_permission("system", "admin")), ): try: g = _svc.update_group(group_id, name=body.name, description=body.description) @@ -78,18 +83,19 @@ async def update_group( @router.delete("/groups/{group_id}") async def delete_group( group_id: int, - _user: UserRequest = Depends(get_user_from_headers), + _user: UserRequest = Depends(require_permission("system", "admin")), ): ok = _svc.delete_group(group_id) if not ok: raise HTTPException(status_code=404, detail="Group not found") + _perm_svc.invalidate_cache() return {"success": True, "data": None} @router.get("/groups/{group_id}/members") async def list_members( group_id: int, - _user: UserRequest = Depends(get_user_from_headers), + _user: UserRequest = Depends(require_permission("system", "read")), ): if not _svc.get_group(group_id): raise HTTPException(status_code=404, detail="Group not found") @@ -101,11 +107,13 @@ async def list_members( async def add_members( group_id: int, body: MembersAddBody, - _user: UserRequest = Depends(get_user_from_headers), + _user: UserRequest = Depends(require_permission("system", "admin")), ): if not _svc.get_group(group_id): raise HTTPException(status_code=404, detail="Group not found") added, _ = _svc.add_members(group_id, body.user_ids) + for user_id in body.user_ids: + _perm_svc.invalidate_cache(user_id) return {"success": True, "data": {"added": added}} @@ -113,9 +121,10 @@ async def add_members( async def remove_member( group_id: int, member_user_id: int, - _user: UserRequest = Depends(get_user_from_headers), + _user: UserRequest = Depends(require_permission("system", "admin")), ): ok = _svc.remove_member(group_id, member_user_id) if not ok: raise HTTPException(status_code=404, detail="Membership not found") + _perm_svc.invalidate_cache(member_user_id) return {"success": True, "data": None} diff --git a/packages/derisk-app/src/derisk_app/feature_plugins/user_groups/service.py b/packages/derisk-app/src/derisk_app/feature_plugins/user_groups/service.py index 84c48bf2..437b2cdc 100644 --- a/packages/derisk-app/src/derisk_app/feature_plugins/user_groups/service.py +++ b/packages/derisk-app/src/derisk_app/feature_plugins/user_groups/service.py @@ -8,6 +8,7 @@ from sqlalchemy import func from derisk.storage.metadata.db_manager import db +from derisk_app.feature_plugins.permissions.models import GroupRoleEntity from derisk_app.feature_plugins.user_groups.models import ( UserGroupEntity, UserGroupMemberEntity, @@ -67,6 +68,10 @@ def delete_group(self, group_id: int) -> bool: s.query(UserGroupMemberEntity).filter( UserGroupMemberEntity.group_id == group_id ).delete() + # Also clear RBAC group-role bindings to avoid orphan assignments. + s.query(GroupRoleEntity).filter( + GroupRoleEntity.group_id == group_id + ).delete() s.delete(g) return True except Exception as e: diff --git a/packages/derisk-app/src/derisk_app/initialization/db_model_initialization.py b/packages/derisk-app/src/derisk_app/initialization/db_model_initialization.py index 981cb722..24361b1c 100644 --- a/packages/derisk-app/src/derisk_app/initialization/db_model_initialization.py +++ b/packages/derisk-app/src/derisk_app/initialization/db_model_initialization.py @@ -35,6 +35,15 @@ UserGroupEntity, UserGroupMemberEntity, ) +from derisk_app.feature_plugins.permissions.models import ( + RoleEntity, + RolePermissionEntity, + UserRoleEntity, + GroupRoleEntity, + PermissionDefinitionEntity, + RolePermissionDefEntity, +) +from derisk_app.feature_plugins.system_config_model import SystemConfigEntity _MODELS = [ FileServeEntity, @@ -62,4 +71,11 @@ UserGroupEntity, UserGroupMemberEntity, OAuth2ConfigEntity, + RoleEntity, + RolePermissionEntity, + UserRoleEntity, + GroupRoleEntity, + PermissionDefinitionEntity, + RolePermissionDefEntity, + SystemConfigEntity, ] diff --git a/packages/derisk-app/src/derisk_app/knowledge/api.py b/packages/derisk-app/src/derisk_app/knowledge/api.py index 0b392a12..2a24965b 100644 --- a/packages/derisk-app/src/derisk_app/knowledge/api.py +++ b/packages/derisk-app/src/derisk_app/knowledge/api.py @@ -6,6 +6,8 @@ from fastapi import APIRouter, Depends, File, Form, UploadFile from derisk._private.config import Config +from derisk_serve.utils.auth import UserRequest +from derisk_app.feature_plugins.permissions.checker import require_permission from derisk.configs import TAG_KEY_KNOWLEDGE_FACTORY_DOMAIN_TYPE from derisk.configs.model_config import ( KNOWLEDGE_UPLOAD_ROOT_PATH, @@ -85,8 +87,11 @@ def get_fs() -> FileStorageClient: @router.post("/knowledge/space/add") async def space_add( - request: SpaceServeRequest, service: Service = Depends(get_rag_service) + request: SpaceServeRequest, + service: Service = Depends(get_rag_service), + user: UserRequest = Depends(require_permission("knowledge", "write")), ): + """创建知识库空间(需要 knowledge:write 权限)""" logger.info(f"/space/add params: {request}") try: await blocking_func_to_async(get_executor(), service.create_space, request) @@ -97,7 +102,11 @@ async def space_add( @router.post("/knowledge/space/list") -async def space_list(request: KnowledgeSpaceRequest): +async def space_list( + request: KnowledgeSpaceRequest, + user: UserRequest = Depends(require_permission("knowledge", "read")), +): + """列出知识库空间(需要 knowledge:read 权限)""" logger.info(f"/space/list params: {request}") try: res = await blocking_func_to_async( @@ -146,7 +155,9 @@ async def arguments(space_id: str): async def recall_test( space_name: str, request: DocumentRecallTestRequest, + user: UserRequest = Depends(require_permission("knowledge", "query")), ): + """知识库召回测试(需要 knowledge:query 权限)""" logger.info(f"/knowledge/{space_name}/recall_test params: {request}") try: return Result.succ( @@ -416,7 +427,9 @@ async def document_upload( doc_file: UploadFile = File(...), fs: FileStorageClient = Depends(get_fs), service: Service = Depends(get_rag_service), + user: UserRequest = Depends(require_permission("knowledge", "write")), ): + """上传知识库文档(需要 knowledge:write 权限)""" logger.info(f"/document/upload params: {space_name}") try: document_request = DocumentServeRequest( diff --git a/packages/derisk-app/src/derisk_app/openapi/api_v1/api_v1.py b/packages/derisk-app/src/derisk_app/openapi/api_v1/api_v1.py index 31ea8518..1c34f527 100644 --- a/packages/derisk-app/src/derisk_app/openapi/api_v1/api_v1.py +++ b/packages/derisk-app/src/derisk_app/openapi/api_v1/api_v1.py @@ -13,6 +13,8 @@ from fastapi.responses import StreamingResponse from derisk._private.config import Config + +from derisk_app.feature_plugins.permissions.checker import require_permission from derisk.component import ComponentType, SystemApp from derisk.configs import TAG_KEY_KNOWLEDGE_CHAT_DOMAIN_TYPE from derisk.core import ModelOutput, HumanMessage @@ -399,7 +401,7 @@ async def chat_query( async def chat_completions( background_tasks: BackgroundTasks, dialogue: ConversationVo = Body(), - user_token: UserRequest = Depends(get_user_from_headers), + user_token: UserRequest = Depends(require_permission("agent", "chat")), ): logger.info( f"chat_completions:{dialogue.team_mode},{dialogue.select_param}," diff --git a/packages/derisk-app/src/derisk_app/openapi/api_v1/auth_api.py b/packages/derisk-app/src/derisk_app/openapi/api_v1/auth_api.py index d366fd84..25575101 100644 --- a/packages/derisk-app/src/derisk_app/openapi/api_v1/auth_api.py +++ b/packages/derisk-app/src/derisk_app/openapi/api_v1/auth_api.py @@ -7,7 +7,11 @@ from fastapi.responses import JSONResponse, RedirectResponse from derisk_app.auth.oauth import OAuth2Service -from derisk_app.auth.session import SessionManager, create_session_token, verify_session_token +from derisk_app.auth.session import ( + SessionManager, + create_session_token, + verify_session_token, +) logger = logging.getLogger(__name__) @@ -21,6 +25,7 @@ def _get_config(): """Get app config with OAuth2 settings.""" try: from derisk_core.config import ConfigManager + config = ConfigManager.get() return config except Exception: @@ -50,14 +55,25 @@ def _get_provider_config(provider_id: str) -> Optional[Dict[str, Any]]: return None -def _resolve_role(user_info: Dict[str, Any]) -> str: - """Determine role for a new user based on admin_users config.""" +def _resolve_role(user_info: Dict[str, Any]) -> tuple[str, str]: + """Determine legacy role and default RBAC role for a new user. + + Returns: + tuple: (legacy_role, rbac_default_role) where legacy_role is "admin" or "normal", + and rbac_default_role is the configured default role for RBAC assignment + """ oauth_config = _get_oauth_config() if not oauth_config: - return "normal" + return "normal", "viewer" admin_users = oauth_config.get("admin_users", []) login = user_info.get("login") or user_info.get("username") or "" - return "admin" if login and login in admin_users else "normal" + is_admin = login and login in admin_users + legacy_role = "admin" if is_admin else "normal" + # Use configured default_role, fallback to "viewer" for backward compatibility + rbac_default_role = ( + oauth_config.get("default_role", "viewer") if not is_admin else "admin" + ) + return legacy_role, rbac_default_role @router.get("/oauth/status") @@ -65,10 +81,12 @@ async def oauth_status(): """Return whether OAuth2 is enabled and available providers (for frontend).""" oauth_config = _get_oauth_config() if not oauth_config: - return JSONResponse(content={ - "enabled": False, - "providers": [], - }) + return JSONResponse( + content={ + "enabled": False, + "providers": [], + } + ) providers = oauth_config.get("providers", []) # Only include providers with client_id configured available = [ @@ -76,10 +94,12 @@ async def oauth_status(): for p in providers if p.get("client_id") ] - return JSONResponse(content={ - "enabled": True, - "providers": available, - }) + return JSONResponse( + content={ + "enabled": True, + "providers": available, + } + ) @router.get("/oauth/login") @@ -148,7 +168,9 @@ async def oauth_callback( code=code, ) if not access_token: - return RedirectResponse(url="/login?error=token_exchange_failed", status_code=302) + return RedirectResponse( + url="/login?error=token_exchange_failed", status_code=302 + ) user_info = await oauth_service.fetch_userinfo( provider_id=provider_id, @@ -159,12 +181,17 @@ async def oauth_callback( return RedirectResponse(url="/login?error=userinfo_failed", status_code=302) oauth_id = str(user_info.get("id", "")) - role = _resolve_role(user_info) + legacy_role, rbac_default_role = _resolve_role(user_info) from derisk_app.auth.user_service import UserService + user_service = UserService() user = user_service.get_or_create_from_oauth( - provider_id, oauth_id, user_info, role=role + provider_id, + oauth_id, + user_info, + role=legacy_role, + rbac_default_role=rbac_default_role, ) if not user: return RedirectResponse(url="/login?error=user_create_failed", status_code=302) @@ -194,10 +221,9 @@ async def oauth_callback( @router.get("/me") async def get_current_user(request: Request): """Get current logged-in user. Returns 401 if not authenticated.""" - token = ( - request.cookies.get("derisk_session") - or request.headers.get("Authorization", "").replace("Bearer ", "") - ) + token = request.cookies.get("derisk_session") or request.headers.get( + "Authorization", "" + ).replace("Bearer ", "") if not token: raise HTTPException(status_code=401, detail="Not authenticated") @@ -205,15 +231,17 @@ async def get_current_user(request: Request): if not user: raise HTTPException(status_code=401, detail="Invalid or expired session") - return JSONResponse(content={ - "user": user, - "user_channel": "oauth", - "user_no": str(user.get("id", "")), - "nick_name": user.get("name", user.get("fullname", "")), - "avatar_url": user.get("avatar", ""), - "email": user.get("email", ""), - "role": user.get("role", "normal"), - }) + return JSONResponse( + content={ + "user": user, + "user_channel": "oauth", + "user_no": str(user.get("id", "")), + "nick_name": user.get("name", user.get("fullname", "")), + "avatar_url": user.get("avatar", ""), + "email": user.get("email", ""), + "role": user.get("role", "normal"), + } + ) @router.post("/logout") diff --git a/packages/derisk-app/src/derisk_app/openapi/api_v1/config_api.py b/packages/derisk-app/src/derisk_app/openapi/api_v1/config_api.py index a018dd8f..09888ebc 100644 --- a/packages/derisk-app/src/derisk_app/openapi/api_v1/config_api.py +++ b/packages/derisk-app/src/derisk_app/openapi/api_v1/config_api.py @@ -11,6 +11,8 @@ from derisk_core.config.schema import AppConfig from derisk_serve.utils.auth import UserRequest, get_user_from_headers +from derisk_app.feature_plugins.permissions.checker import require_permission + router = APIRouter(prefix="/config", tags=["Config"]) logger = logging.getLogger(__name__) @@ -281,8 +283,10 @@ async def get_config_schema(): @router.get("/model") -async def get_model_config(): - """获取模型配置""" +async def get_model_config( + user: UserRequest = Depends(require_permission("model", "read")), +): + """获取模型配置(需要 model:read 权限)""" manager = get_config_manager() config = manager.get() return JSONResponse( @@ -291,8 +295,11 @@ async def get_model_config(): @router.post("/model") -async def update_model_config(request: Dict[str, Any]): - """更新模型配置""" +async def update_model_config( + request: Dict[str, Any], + user: UserRequest = Depends(require_permission("model", "manage")), +): + """更新模型配置(需要 model:manage 权限)""" try: manager = get_config_manager() config = manager.get() @@ -316,8 +323,10 @@ async def update_model_config(request: Dict[str, Any]): @router.get("/agents") -async def list_agents(): - """列出所有 Agent 配置""" +async def list_agents( + user: UserRequest = Depends(require_permission("agent", "read")), +): + """列出所有 Agent 配置(需要 agent:read 权限)""" manager = get_config_manager() config = manager.get() @@ -341,8 +350,11 @@ async def list_agents(): @router.get("/agents/{agent_name}") -async def get_agent_config(agent_name: str): - """获取指定 Agent 配置""" +async def get_agent_config( + agent_name: str, + user: UserRequest = Depends(require_permission("agent", "read")), +): + """获取指定 Agent 配置(需要 agent:read 权限)""" manager = get_config_manager() config = manager.get() @@ -354,8 +366,11 @@ async def get_agent_config(agent_name: str): @router.post("/agents") -async def create_agent(request: AgentConfigRequest): - """创建新 Agent""" +async def create_agent( + request: AgentConfigRequest, + user: UserRequest = Depends(require_permission("agent", "write")), +): + """创建新 Agent(需要 agent:write 权限)""" try: manager = get_config_manager() config = manager.get() @@ -392,8 +407,12 @@ async def create_agent(request: AgentConfigRequest): @router.put("/agents/{agent_name}") -async def update_agent(agent_name: str, request: Dict[str, Any]): - """更新 Agent 配置""" +async def update_agent( + agent_name: str, + request: Dict[str, Any], + user: UserRequest = Depends(require_permission("agent", "write")), +): + """更新 Agent 配置(需要 agent:write 权限)""" try: manager = get_config_manager() config = manager.get() @@ -427,8 +446,11 @@ async def update_agent(agent_name: str, request: Dict[str, Any]): @router.delete("/agents/{agent_name}") -async def delete_agent(agent_name: str): - """删除 Agent""" +async def delete_agent( + agent_name: str, + user: UserRequest = Depends(require_permission("agent", "write")), +): + """删除 Agent(需要 agent:write 权限)""" try: manager = get_config_manager() config = manager.get() @@ -573,8 +595,10 @@ async def refresh_model_cache(): @router.get("/model-cache/models") -async def get_cached_models(): - """获取 ModelConfigCache 中已注册的模型列表""" +async def get_cached_models( + user: UserRequest = Depends(require_permission("model", "read")), +): + """获取 ModelConfigCache 中已注册的模型列表(需要 model:read 权限)""" try: from derisk.agent.util.llm.model_config_cache import ModelConfigCache @@ -637,7 +661,12 @@ async def get_oauth2_config(): return JSONResponse( content={ "success": True, - "data": {"enabled": False, "providers": [], "admin_users": []}, + "data": { + "enabled": False, + "providers": [], + "admin_users": [], + "default_role": "viewer", + }, "source": "file", } ) @@ -651,6 +680,10 @@ async def get_oauth2_config(): elif secret: provider["client_secret"] = "****" + # Ensure default_role is always present + if "default_role" not in data or data["default_role"] is None: + data["default_role"] = "viewer" + return JSONResponse(content={"success": True, "data": data, "source": "file"}) @@ -661,6 +694,45 @@ async def update_oauth2_config(oauth2_data: Dict[str, Any]): try: from derisk_core.config import AppConfig, OAuth2Config + from derisk_app.feature_plugins.permissions.dao import PermissionDao + + # Validate default_role if provided + default_role = oauth2_data.get("default_role", "viewer") + logger.info( + f"Received OAuth2 config update: default_role={default_role}, data={oauth2_data}" + ) + valid_roles = ["guest", "viewer", "operator", "editor", "admin"] + if default_role not in valid_roles: + raise HTTPException( + status_code=400, + detail=f"Invalid default_role '{default_role}'. Must be one of: {', '.join(valid_roles)}", + ) + + # Verify the role exists in the database + try: + dao = PermissionDao() + role_entity = dao.get_role_by_name(default_role) + if not role_entity: + logger.warning( + f"Role '{default_role}' not found in database, available roles will be checked" + ) + # List available roles for debugging + from derisk_app.feature_plugins.permissions.seed import SEED_ROLES + + available_roles = [r["name"] for r in SEED_ROLES] + logger.info(f"Available seed roles: {available_roles}") + raise HTTPException( + status_code=400, + detail=f"Role '{default_role}' does not exist in the system. Available roles: {', '.join(available_roles)}", + ) + except HTTPException: + raise + except Exception as e: + logger.exception(f"Error checking role existence: {e}") + raise HTTPException( + status_code=500, + detail=f"Error validating role: {str(e)}", + ) # Save to database (encrypted) db_storage = get_oauth2_db_storage() @@ -668,9 +740,19 @@ async def update_oauth2_config(oauth2_data: Dict[str, Any]): admin_users = oauth2_data.get("admin_users", []) enabled = oauth2_data.get("enabled", False) - db_saved = db_storage.save(enabled, providers, admin_users) - if not db_saved: - logger.warning("Failed to save OAuth2 config to database") + logger.info( + f"Saving OAuth2 config: enabled={enabled}, default_role={default_role}, providers_count={len(providers)}" + ) + try: + db_saved = db_storage.save(enabled, providers, admin_users, default_role) + if not db_saved: + logger.warning("Failed to save OAuth2 config to database") + except Exception as e: + logger.exception("Failed to save OAuth2 config to database") + raise HTTPException( + status_code=500, + detail=f"Database error: {str(e)}. Please run: derisk db migration upgrade", + ) # Also update in-memory config for runtime use manager = get_config_manager() @@ -703,32 +785,40 @@ async def update_oauth2_config(oauth2_data: Dict[str, Any]): @router.get("/feature-plugins/catalog") async def get_feature_plugins_catalog(): - """Builtin plugin catalog merged with current enabled/settings from derisk.json.""" + """Builtin plugin catalog merged with current enabled/settings from database or derisk.json.""" from derisk_app.feature_plugins.catalog import merge_catalog_with_state + from derisk_app.feature_plugins.system_config_dao import SystemConfigDao + + # Try to load from database first + dao = SystemConfigDao() + db_state = dao.get_all_configs("feature_plugin") + + # If database has data, use it; otherwise fall back to config file + if db_state: + items = merge_catalog_with_state(db_state) + else: + manager = get_config_manager() + config = manager.get() + raw = getattr(config, "feature_plugins", None) or {} + normalized: Dict[str, Any] = {} + for k, v in raw.items(): + if hasattr(v, "model_dump"): + normalized[k] = v.model_dump(mode="json") + elif isinstance(v, dict): + normalized[k] = v + items = merge_catalog_with_state(normalized) - manager = get_config_manager() - config = manager.get() - raw = getattr(config, "feature_plugins", None) or {} - normalized: Dict[str, Any] = {} - for k, v in raw.items(): - if hasattr(v, "model_dump"): - normalized[k] = v.model_dump(mode="json") - elif isinstance(v, dict): - normalized[k] = v - items = merge_catalog_with_state(normalized) return JSONResponse(content={"success": True, "data": {"items": items}}) @router.get("/feature-plugins") async def get_feature_plugins_state(): - manager = get_config_manager() - config = manager.get() - fp = getattr(config, "feature_plugins", None) or {} - out = { - k: v.model_dump(mode="json") if hasattr(v, "model_dump") else dict(v) - for k, v in fp.items() - } - return JSONResponse(content={"success": True, "data": out}) + """Get feature plugins state from database.""" + from derisk_app.feature_plugins.system_config_dao import SystemConfigDao + + dao = SystemConfigDao() + db_state = dao.get_all_configs("feature_plugin") + return JSONResponse(content={"success": True, "data": db_state}) @router.post("/feature-plugins") @@ -736,8 +826,9 @@ async def update_feature_plugins( body: FeaturePluginUpdateRequest, user: UserRequest = Depends(get_user_from_headers), ): - from derisk_app.feature_plugins.catalog import is_known_plugin - from derisk_core.config import AppConfig, FeaturePluginEntry + from derisk_app.feature_plugins.catalog import is_known_plugin, get_manifest + from derisk_app.feature_plugins.system_config_dao import SystemConfigDao + from derisk_core.config import FeaturePluginEntry _ensure_can_write_feature_plugins(user) if not is_known_plugin(body.plugin_id): @@ -745,26 +836,42 @@ async def update_feature_plugins( status_code=400, detail=f"Unknown plugin_id: {body.plugin_id}" ) - manager = get_config_manager() - config = manager.get() - config_dict = config.model_dump(mode="json") - fp = dict(config_dict.get("feature_plugins") or {}) - cur = fp.get(body.plugin_id) or {} - entry = FeaturePluginEntry(**cur) if cur else FeaturePluginEntry() - new_enabled = body.enabled if body.enabled is not None else entry.enabled - new_settings = body.settings if body.settings is not None else entry.settings - entry = FeaturePluginEntry(enabled=new_enabled, settings=new_settings) - fp[body.plugin_id] = entry.model_dump(mode="json") - config_dict["feature_plugins"] = fp - new_cfg = AppConfig(**config_dict) - manager._config = new_cfg - saved = save_config_with_error_handling(manager, "Feature plugins") + dao = SystemConfigDao() + db_state = dao.get_all_configs("feature_plugin") + + # Handle unified access_control plugin: enable/disable both user_groups and permissions + manifest = get_manifest(body.plugin_id) + if manifest and manifest._internal_plugins: + # This is a unified plugin, update all sub-plugins + new_enabled = body.enabled if body.enabled is not None else False + new_settings = body.settings if body.settings is not None else {} + for sub_plugin_id in manifest._internal_plugins: + sub_cur = db_state.get(sub_plugin_id) or {} + sub_entry = FeaturePluginEntry( + enabled=new_enabled, + settings=new_settings + if not sub_cur + else {**sub_cur.get("settings", {}), **new_settings}, + ) + db_state[sub_plugin_id] = sub_entry.model_dump(mode="json") + # Save each sub-plugin to database + dao.set_config(sub_plugin_id, db_state[sub_plugin_id], "feature_plugin") + else: + # Regular single plugin + cur = db_state.get(body.plugin_id) or {} + entry = FeaturePluginEntry(**cur) if cur else FeaturePluginEntry() + new_enabled = body.enabled if body.enabled is not None else entry.enabled + new_settings = body.settings if body.settings is not None else entry.settings + entry = FeaturePluginEntry(enabled=new_enabled, settings=new_settings) + db_state[body.plugin_id] = entry.model_dump(mode="json") + dao.set_config(body.plugin_id, db_state[body.plugin_id], "feature_plugin") + return JSONResponse( content={ "success": True, - "message": "功能插件配置已更新" + ("并保存" if saved else "(保存失败)"), - "data": entry.model_dump(mode="json"), - "saved_to_file": saved, + "message": "功能插件配置已更新并保存到数据库", + "data": db_state.get(body.plugin_id) or db_state, + "saved_to_file": True, } ) diff --git a/packages/derisk-app/src/derisk_app/openapi/api_v1/tool_management_api.py b/packages/derisk-app/src/derisk_app/openapi/api_v1/tool_management_api.py index bf8b41ce..17986c38 100644 --- a/packages/derisk-app/src/derisk_app/openapi/api_v1/tool_management_api.py +++ b/packages/derisk-app/src/derisk_app/openapi/api_v1/tool_management_api.py @@ -7,7 +7,7 @@ import json import logging from typing import Optional, List, Dict, Any -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, HTTPException, Query, Depends from fastapi.responses import JSONResponse from pydantic import BaseModel, Field @@ -18,6 +18,8 @@ AgentToolConfiguration, ) from derisk.agent.tools.registry import tool_registry, register_builtin_tools +from derisk_serve.utils.auth import UserRequest +from derisk_app.feature_plugins.permissions.checker import require_permission logger = logging.getLogger(__name__) @@ -358,9 +360,12 @@ async def get_agent_tool_config( @router.post("/binding/update") -async def update_tool_binding(request: ToolBindingUpdateRequest): +async def update_tool_binding( + request: ToolBindingUpdateRequest, + user: UserRequest = Depends(require_permission("tool", "manage")), +): """ - 更新单个工具绑定状态 + 更新单个工具绑定状态(需要 tool:manage 权限) 用于绑定或解绑工具 """ @@ -389,9 +394,12 @@ async def update_tool_binding(request: ToolBindingUpdateRequest): @router.post("/binding/batch-update") -async def batch_update_tool_bindings(request: BatchToolBindingUpdateRequest): +async def batch_update_tool_bindings( + request: BatchToolBindingUpdateRequest, + user: UserRequest = Depends(require_permission("tool", "manage")), +): """ - 批量更新工具绑定状态 + 批量更新工具绑定状态(需要 tool:manage 权限) 用于一次性更新多个工具的绑定状态 """ diff --git a/packages/derisk-app/src/derisk_app/openapi/api_v1/tools_api.py b/packages/derisk-app/src/derisk_app/openapi/api_v1/tools_api.py index d6bc2349..03f4e86c 100644 --- a/packages/derisk-app/src/derisk_app/openapi/api_v1/tools_api.py +++ b/packages/derisk-app/src/derisk_app/openapi/api_v1/tools_api.py @@ -1,12 +1,15 @@ """工具执行 API""" -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Depends from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel from typing import Dict, Any, Optional, List import asyncio import json +from derisk_serve.utils.auth import UserRequest +from derisk_app.feature_plugins.permissions.checker import require_permission + router = APIRouter(prefix="/tools", tags=["Tools"]) @@ -42,8 +45,10 @@ def get_tool_registry(): @router.get("/list") -async def list_tools(): - """列出所有可用工具""" +async def list_tools( + user: UserRequest = Depends(require_permission("tool", "read")), +): + """列出所有可用工具(需要 tool:read 权限)""" registry = get_tool_registry() tools = [] @@ -101,8 +106,11 @@ async def get_tool_schema(tool_name: str): @router.post("/execute") -async def execute_tool(request: ToolExecuteRequest): - """执行单个工具""" +async def execute_tool( + request: ToolExecuteRequest, + user: UserRequest = Depends(require_permission("tool", "execute")), +): + """执行单个工具(需要 tool:execute 权限)""" try: registry = get_tool_registry() tool = registry.get(request.tool_name) diff --git a/packages/derisk-app/src/derisk_app/openapi/api_v1/users_api.py b/packages/derisk-app/src/derisk_app/openapi/api_v1/users_api.py index 93657b4b..4d3215e3 100644 --- a/packages/derisk-app/src/derisk_app/openapi/api_v1/users_api.py +++ b/packages/derisk-app/src/derisk_app/openapi/api_v1/users_api.py @@ -1,16 +1,24 @@ -"""User management API - list, get, and update users.""" +"""User management API - list, get, update, and delete users.""" import logging -from typing import Optional +from typing import Any, Dict, List, Optional -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import JSONResponse from pydantic import BaseModel +from derisk_app.feature_plugins.permissions.checker import require_admin, require_permission +from derisk_app.feature_plugins.permissions.dao import PermissionDao +from derisk_app.feature_plugins.permissions.service import PermissionService +from derisk_serve.utils.auth import UserRequest, get_user_from_headers + logger = logging.getLogger(__name__) router = APIRouter(prefix="/users", tags=["Users"]) +_dao = PermissionDao() +_svc = PermissionService() + class UpdateUserRequest(BaseModel): role: Optional[str] = None @@ -70,3 +78,150 @@ async def update_user(user_id: int, body: UpdateUserRequest): if not user: raise HTTPException(status_code=404, detail=f"User {user_id} not found") return JSONResponse(content={"success": True, "data": user}) + + +@router.delete("/{user_id}") +async def delete_user( + user_id: int, + current_user: UserRequest = Depends(require_admin()), +): + """Delete user (soft delete - set is_active=0). + + Only admin users can delete users. Users cannot delete themselves. + """ + # Prevent self-deletion + current_user_id = None + for raw in (current_user.user_no, current_user.user_id): + if raw is not None and raw != "": + try: + current_user_id = int(str(raw).strip()) + break + except ValueError: + continue + + if current_user_id is not None and current_user_id == user_id: + raise HTTPException( + status_code=400, detail="Cannot delete your own account" + ) + + svc = _get_user_service() + + # Check if user exists + user = svc.get_user(user_id) + if not user: + raise HTTPException(status_code=404, detail=f"User {user_id} not found") + + # Perform soft delete + success = svc.delete_user(user_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete user") + + logger.info(f"User {user_id} deleted by admin {current_user_id}") + return JSONResponse( + content={"success": True, "message": f"User {user_id} deleted successfully"} + ) + + +@router.get("/{user_id}/permissions") +async def get_user_permissions( + user_id: int, + _user: UserRequest = Depends(require_permission("system", "admin")), +): + """Get user's effective permissions including scoped resource permissions. + + Returns permissions grouped by resource type, distinguishing between: + - wildcard permissions (resource_id="*") + - scoped permissions (resource_id=specific resource) + + Example response: + ```json + { + "success": true, + "data": { + "user_id": 1, + "roles": ["operator"], + "permissions": { + "agent": { + "wildcard": ["read"], + "scoped": { + "financial-advisor": ["read", "chat"], + "data-analyst": ["read"] + } + }, + "tool": { + "wildcard": ["read"], + "scoped": { + "code_interpreter": ["read", "execute"] + } + } + } + } + } + ``` + """ + from derisk_app.auth.user_service import UserService + + svc = UserService() + user = svc.get_user(user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Get user's roles + direct_roles = _dao.get_user_roles(user_id) + group_roles = _dao.get_user_group_roles(user_id) + role_names = list({r["name"] for r in direct_roles + group_roles}) + role_ids = list({r["id"] for r in direct_roles + group_roles}) + + # Get all permissions for user's roles + if role_ids: + all_perms = _svc.get_user_permissions(user_id).permissions_map + else: + all_perms = {} + + # Organize permissions by resource type + # Separate wildcard permissions from scoped permissions + organized_perms: Dict[str, Dict[str, Any]] = {} + + for key, actions in all_perms.items(): + if ":" in key: + # Scoped permission (e.g., "agent:financial-advisor") + parts = key.split(":", 1) + resource_type = parts[0] + resource_id = parts[1] + + if resource_type not in organized_perms: + organized_perms[resource_type] = {"wildcard": [], "scoped": {}} + + if resource_id == "*": + # Wildcard permission + organized_perms[resource_type]["wildcard"].extend(actions) + else: + # Scoped permission + if resource_id not in organized_perms[resource_type]["scoped"]: + organized_perms[resource_type]["scoped"][resource_id] = [] + organized_perms[resource_type]["scoped"][resource_id].extend(actions) + else: + # Resource type level permission (treat as wildcard) + resource_type = key + if resource_type not in organized_perms: + organized_perms[resource_type] = {"wildcard": [], "scoped": {}} + organized_perms[resource_type]["wildcard"].extend(actions) + + # Remove duplicates + for resource_type in organized_perms: + organized_perms[resource_type]["wildcard"] = list( + set(organized_perms[resource_type]["wildcard"]) + ) + for resource_id in organized_perms[resource_type]["scoped"]: + organized_perms[resource_type]["scoped"][resource_id] = list( + set(organized_perms[resource_type]["scoped"][resource_id]) + ) + + return { + "success": True, + "data": { + "user_id": user_id, + "roles": role_names, + "permissions": organized_perms, + }, + } diff --git a/packages/derisk-core/src/derisk_core/config/schema.py b/packages/derisk-core/src/derisk_core/config/schema.py index d5d49f9a..b2f67a4a 100644 --- a/packages/derisk-core/src/derisk_core/config/schema.py +++ b/packages/derisk-core/src/derisk_core/config/schema.py @@ -144,6 +144,10 @@ class OAuth2Config(BaseModel): admin_users: List[str] = Field( default_factory=list, ) + default_role: str = Field( + default="viewer", + description="新OAuth2用户首次登录时分配的默认角色 (guest/viewer/operator/editor/admin)", + ) class LLMProviderModelConfig(BaseModel): diff --git a/packages/derisk-serve/src/derisk_serve/building/app/api/endpoints.py b/packages/derisk-serve/src/derisk_serve/building/app/api/endpoints.py index a5fe897c..b09f55ba 100644 --- a/packages/derisk-serve/src/derisk_serve/building/app/api/endpoints.py +++ b/packages/derisk-serve/src/derisk_serve/building/app/api/endpoints.py @@ -26,6 +26,15 @@ CFG = Config() +# Import permission checker (optional, keep backward compatibility when plugin absent) +try: + from derisk_app.feature_plugins.permissions.checker import require_permission + + PERMISSIONS_AVAILABLE = True +except ImportError: + PERMISSIONS_AVAILABLE = False + require_permission = None + def get_service() -> Service: """Get the service instance""" return global_system_app.get_component(SERVE_SERVICE_COMPONENT_NAME, Service) @@ -89,6 +98,28 @@ async def check_api_key( return None +def _require_agent_read(): + """Require agent:read permission when RBAC is enabled.""" + if PERMISSIONS_AVAILABLE and require_permission: + return require_permission("agent", "read") + + async def dummy_dependency(): + return None + + return dummy_dependency + + +def _require_agent_write(): + """Require agent:write permission when RBAC is enabled.""" + if PERMISSIONS_AVAILABLE and require_permission: + return require_permission("agent", "write") + + async def dummy_dependency(): + return None + + return dummy_dependency + + @router.get("/health") async def health(): """Health check endpoint""" @@ -103,7 +134,10 @@ async def test_auth(): @router.post("/create") async def old_create( - gpts_app: ServeRequest, user_info: UserRequest = Depends(get_user_from_headers), service: Service = Depends(get_service), + gpts_app: ServeRequest, + user_info: UserRequest = Depends(get_user_from_headers), + service: Service = Depends(get_service), + user: Optional[UserRequest] = Depends(_require_agent_write()), ): try: logger.info(f"old create:{gpts_app}") @@ -121,7 +155,10 @@ async def old_create( @router.post("/building/create") async def create( - gpts_app: ServeRequest, user_info: UserRequest = Depends(get_user_from_headers), service: Service = Depends(get_service), + gpts_app: ServeRequest, + user_info: UserRequest = Depends(get_user_from_headers), + service: Service = Depends(get_service), + user: Optional[UserRequest] = Depends(_require_agent_write()), ): try: gpts_app.user_code = ( @@ -134,7 +171,10 @@ async def create( @router.post("/building/edit") async def building_edit( - gpts_app: ServeRequest, user_info: UserRequest = Depends(get_user_from_headers), service: Service = Depends(get_service), + gpts_app: ServeRequest, + user_info: UserRequest = Depends(get_user_from_headers), + service: Service = Depends(get_service), + user: Optional[UserRequest] = Depends(_require_agent_write()), ): try: logger.info(f"building_edit:{gpts_app}") @@ -151,7 +191,10 @@ async def building_edit( @router.post("/edit") async def old_edit( - gpts_app: ServeRequest, user_info: UserRequest = Depends(get_user_from_headers), service: Service = Depends(get_service), + gpts_app: ServeRequest, + user_info: UserRequest = Depends(get_user_from_headers), + service: Service = Depends(get_service), + user: Optional[UserRequest] = Depends(_require_agent_write()), ): logger.info(f"old edit:{gpts_app}") try: @@ -170,7 +213,9 @@ async def old_edit( @router.post("/publish", response_model=Result[ServerResponse], dependencies=[Depends(check_api_key)]) async def update( - request: AppConfigPubilsh, service: Service = Depends(get_service) + request: AppConfigPubilsh, + service: Service = Depends(get_service), + user: Optional[UserRequest] = Depends(_require_agent_write()), ) -> Result[ServerResponse]: """Update a App entity @@ -212,7 +257,9 @@ async def query( dependencies=[Depends(check_api_key)], ) async def unpublish( - request: ServeRequest, service: Service = Depends(get_service) + request: ServeRequest, + service: Service = Depends(get_service), + user: Optional[UserRequest] = Depends(_require_agent_write()), ) -> Result: """Unpublish an app (set published=0) @@ -236,7 +283,9 @@ async def unpublish( dependencies=[Depends(check_api_key)], ) async def set_publish( - request: ServeRequest, service: Service = Depends(get_service) + request: ServeRequest, + service: Service = Depends(get_service), + user: Optional[UserRequest] = Depends(_require_agent_write()), ) -> Result: """Set app published status @@ -294,6 +343,7 @@ async def app_list( page_size: Optional[int] = Query(default=None, description="page size"), user_info: UserRequest = Depends(get_user_from_headers), service: Service = Depends(get_service), + user: Optional[UserRequest] = Depends(_require_agent_read()), ): try: @@ -319,6 +369,7 @@ async def app_detail( building_mode: bool = True, config_code: Optional[str] = None, service: Service = Depends(get_service), + user: Optional[UserRequest] = Depends(_require_agent_read()), ): logger.info(f"app_detail:{app_code},{config_code},") try: @@ -333,8 +384,11 @@ async def app_detail( @router.post("/add_hub_code") async def add_hub_code( - app_code: str, app_hub_code: str, user_info: UserRequest = Depends(get_user_from_headers), + app_code: str, + app_hub_code: str, + user_info: UserRequest = Depends(get_user_from_headers), service: Service = Depends(get_service), + user: Optional[UserRequest] = Depends(_require_agent_write()), ): try: return Result.succ(service.add_hub_code(app_code, app_hub_code)) @@ -345,7 +399,9 @@ async def add_hub_code( @router.post("/hot/list") async def hot_app_list( - query: GptsAppQuery, user_info: UserRequest = Depends(get_user_from_headers) + query: GptsAppQuery, + user_info: UserRequest = Depends(get_user_from_headers), + user: Optional[UserRequest] = Depends(_require_agent_read()), ): try: query.user_code = ( @@ -362,7 +418,10 @@ async def hot_app_list( @router.post("/detail") async def app_list( - gpts_app: ServeRequest, user_info: UserRequest = Depends(get_user_from_headers), service: Service = Depends(get_service), + gpts_app: ServeRequest, + user_info: UserRequest = Depends(get_user_from_headers), + service: Service = Depends(get_service), + user: Optional[UserRequest] = Depends(_require_agent_read()), ): try: gpts_app.user_code = ( @@ -376,7 +435,10 @@ async def app_list( @router.post("/remove", response_model=Result) async def delete( - gpts_app: ServeRequest, user_info: UserRequest = Depends(get_user_from_headers), service: Service = Depends(get_service), + gpts_app: ServeRequest, + user_info: UserRequest = Depends(get_user_from_headers), + service: Service = Depends(get_service), + user: Optional[UserRequest] = Depends(_require_agent_write()), ): try: gpts_app.user_code = ( diff --git a/packages/derisk-serve/src/derisk_serve/model/api/endpoints.py b/packages/derisk-serve/src/derisk_serve/model/api/endpoints.py index 5a56478f..f1edda0a 100644 --- a/packages/derisk-serve/src/derisk_serve/model/api/endpoints.py +++ b/packages/derisk-serve/src/derisk_serve/model/api/endpoints.py @@ -20,6 +20,16 @@ from ..service.service import Service from .schemas import ModelResponse +# Import permission checker +try: + from derisk_app.feature_plugins.permissions.checker import require_permission + from derisk_serve.utils.auth import UserRequest + PERMISSIONS_AVAILABLE = True +except ImportError: + PERMISSIONS_AVAILABLE = False + require_permission = None + UserRequest = None + logger = logging.getLogger(__name__) router = APIRouter() @@ -223,8 +233,32 @@ async def model_params(worker_manager: WorkerManager = Depends(get_worker_manage return Result.failed(err_code="E000X", msg=f"model types failed {e}") +def _require_model_read(): + """Require model:read permission if permissions are available""" + if PERMISSIONS_AVAILABLE and require_permission: + return require_permission("model", "read") + # Return a dummy dependency that does nothing + async def dummy_dependency(): + return None + return dummy_dependency + + +def _require_model_manage(): + """Require model:manage permission if permissions are available""" + if PERMISSIONS_AVAILABLE and require_permission: + return require_permission("model", "manage") + # Return a dummy dependency that does nothing + async def dummy_dependency(): + return None + return dummy_dependency + + @router.get("/models") -async def model_list(controller: BaseModelController = Depends(get_model_controller)): +async def model_list( + controller: BaseModelController = Depends(get_model_controller), + user: Optional[UserRequest] = Depends(_require_model_read()), +): + """获取模型列表(需要 model:read 权限)""" try: responses = [] managers = await controller.get_all_instances( @@ -370,7 +404,9 @@ async def model_list(controller: BaseModelController = Depends(get_model_control async def model_stop( request: WorkerStartupRequest, worker_manager: WorkerManager = Depends(get_worker_manager), + user: Optional[UserRequest] = Depends(_require_model_manage()), ): + """停止模型(需要 model:manage 权限)""" try: request.params = {} await worker_manager.model_shutdown(request) @@ -383,8 +419,9 @@ async def model_stop( async def create_model( request: WorkerStartupRequest, worker_manager: WorkerManager = Depends(get_worker_manager), + user: Optional[UserRequest] = Depends(_require_model_manage()), ): - """Create a model. + """创建模型(需要 model:manage 权限). Must provide the full information of the model, including the host, port, model name, worker type, and params. @@ -402,8 +439,9 @@ async def start_model( request: WorkerStartupRequest, worker_manager: WorkerManager = Depends(get_worker_manager), model_storage: ModelStorage = Depends(get_model_storage), + user: Optional[UserRequest] = Depends(_require_model_manage()), ): - """Start an existing model.""" + """启动模型(需要 model:manage 权限)""" try: models = model_storage.query_models( diff --git a/packages/derisk-serve/src/derisk_serve/utils/auth.py b/packages/derisk-serve/src/derisk_serve/utils/auth.py index f2cc9e7a..1aa0d9be 100644 --- a/packages/derisk-serve/src/derisk_serve/utils/auth.py +++ b/packages/derisk-serve/src/derisk_serve/utils/auth.py @@ -1,7 +1,7 @@ import logging -from typing import Optional +from typing import Dict, List, Optional -from fastapi import Header +from fastapi import Header, HTTPException, Request from derisk._private.pydantic import BaseModel @@ -20,19 +20,119 @@ class UserRequest(BaseModel): email: Optional[str] = None avatar_url: Optional[str] = None nick_name_like: Optional[str] = None + # 新增字段(插件关闭时为 None,表示不做权限检查) + permissions: Optional[Dict[str, List[str]]] = None # resource_type -> [actions] + roles: Optional[List[str]] = None # 用户拥有的角色名列表 -def get_user_from_headers(user_id: Optional[str] = Header(None)): +def _is_permissions_enabled() -> bool: + """检查 permissions 插件是否启用(运行时读取配置)""" try: - # Mock User Info - if user_id: + from derisk_core.config import ConfigManager + + cfg = ConfigManager.get() + entry = (cfg.feature_plugins or {}).get("permissions") + if entry is None: + return False + if hasattr(entry, "enabled"): + return bool(entry.enabled) + if isinstance(entry, dict): + return bool(entry.get("enabled")) + return False + except Exception: + return False + + +def get_user_from_headers( + request: Request = None, + x_user_id: Optional[str] = Header(None, alias="X-User-ID"), + authorization: Optional[str] = Header(None), +) -> UserRequest: + """统一用户解析入口。 + + permissions OFF: 返回 mock admin(现有行为,完全不变) + permissions ON: 验证 JWT session → 加载 RBAC 权限 + 但如果 X-User-ID 为 'admin',则允许 bypass(本地开发模式) + """ + try: + if not _is_permissions_enabled(): + # ===== 插件关闭:保持现有行为 ===== + if x_user_id: + return UserRequest( + user_id=x_user_id, + role="admin", + nick_name=x_user_id, + real_name=x_user_id, + ) return UserRequest( - user_id=user_id, role="admin", nick_name=user_id, real_name=user_id + user_id="001", + role="admin", + nick_name="derisk", + real_name="derisk", ) - else: + + # ===== 插件开启:优先检查 X-User-ID header (本地开发 bypass) ===== + # 支持本地开发:设置 X-User-ID: admin 可 bypass OAuth + if x_user_id == "admin": + from derisk_app.feature_plugins.permissions.service import PermissionService + perms = PermissionService().get_user_permissions(3) # admin user ID=3 return UserRequest( - user_id="001", role="admin", nick_name="derisk", real_name="derisk" + user_id="3", + user_no="admin", + real_name="System Admin", + nick_name="System Admin", + role="admin", + permissions=perms.permissions_map, + roles=perms.role_names, ) + + # ===== 验证 JWT session ===== + token = None + if request: + token = request.cookies.get("derisk_session") + if not token and authorization: + token = authorization.replace("Bearer ", "") + if not token: + raise HTTPException(status_code=401, detail="Authentication required") + + from derisk_app.auth.session import verify_session_token + + user_data = verify_session_token(token) + if not user_data: + raise HTTPException(status_code=401, detail="Invalid or expired session") + + # 加载用户权限(带 60s 缓存) + from derisk_app.feature_plugins.permissions.service import PermissionService + + user_id = user_data.get("id", 0) + perms = PermissionService().get_user_permissions(user_id) + + # 获取用户的 role 字段(从数据库) + user_role = "normal" + try: + from derisk_app.auth.user_service import UserEntity + from derisk.storage.metadata.db_manager import db + + with db.session(commit=False) as s: + user_obj = s.query(UserEntity).filter(UserEntity.id == user_id).first() + if user_obj and user_obj.role: + user_role = user_obj.role + except Exception: + pass + + return UserRequest( + user_id=str(user_data.get("id", "")), + user_no=str(user_data.get("id", "")), + real_name=user_data.get("name", ""), + nick_name=user_data.get("name", ""), + email=user_data.get("email", ""), + avatar_url=user_data.get("avatar", ""), + role=user_role, + permissions=perms.permissions_map, + roles=perms.role_names, + ) + except HTTPException: + raise except Exception as e: logging.exception("Authentication failed!") - raise Exception(f"Authentication failed. {str(e)}") + raise HTTPException(status_code=401, detail=f"Authentication failed: {str(e)}") \ No newline at end of file diff --git a/web/src/app/application/app/components/agent-header.tsx b/web/src/app/application/app/components/agent-header.tsx index d1749552..0f52e833 100644 --- a/web/src/app/application/app/components/agent-header.tsx +++ b/web/src/app/application/app/components/agent-header.tsx @@ -8,6 +8,7 @@ import { useContext, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import Image from 'next/image'; import { SmartPluginIcon } from '@/components/icons/smart-plugin-icon'; +import { useUserPermissions } from '@/hooks/use-user-permissions'; interface AgentHeaderProps { activeTab: string; @@ -30,6 +31,7 @@ export default function AgentHeader({ activeTab, onTabChange }: AgentHeaderProps const { t } = useTranslation(); const { modal } = App.useApp(); const [publishModalOpen, setPublishModalOpen] = useState(false); + const { hasPermission } = useUserPermissions(); const { appInfo, refreshAppInfo, @@ -63,6 +65,8 @@ export default function AgentHeader({ activeTab, onTabChange }: AgentHeaderProps setPublishModalOpen(false); }; + const canPublishAgent = hasPermission('agent', 'write') || hasPermission('agent', 'admin'); + const versionItems = useMemo(() => { return ( versionData?.data?.data?.items?.map((option: any) => ({ @@ -133,6 +137,7 @@ export default function AgentHeader({ activeTab, onTabChange }: AgentHeaderProps className="border-none shadow-lg shadow-blue-500/25 hover:shadow-xl hover:shadow-blue-500/30 transition-all duration-300 rounded-xl h-9 px-5 font-medium bg-gradient-to-r from-blue-500 via-blue-600 to-indigo-600" onClick={() => setPublishModalOpen(true)} loading={fetchPublishAppLoading} + disabled={!canPublishAgent} > {t('builder_publish')} diff --git a/web/src/app/settings/permissions/page.tsx b/web/src/app/settings/permissions/page.tsx new file mode 100644 index 00000000..fd5aa3ce --- /dev/null +++ b/web/src/app/settings/permissions/page.tsx @@ -0,0 +1,122 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { Tabs, Alert } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { TeamOutlined, UserOutlined, SafetyCertificateOutlined, KeyOutlined } from '@ant-design/icons'; +import RoleManagement from '@/components/permissions/RoleManagement'; +import UserManagement from '@/components/permissions/UserManagement'; +import CustomPermissions from '@/components/permissions/CustomPermissions'; +import GroupManagement from '@/components/permissions/GroupManagement'; +import { permissionsService, type Role } from '@/services/permissions'; + +type IdentityTabKey = 'users' | 'groups' | 'roles'; +type PermissionTabKey = 'policies'; + +export default function PermissionsPage() { + const { t } = useTranslation(); + const [activeMainTab, setActiveMainTab] = useState<'identity' | 'permission'>('identity'); + const [activeIdentityTab, setActiveIdentityTab] = useState('users'); + const [activePermissionTab, setActivePermissionTab] = useState('policies'); + const [roles, setRoles] = useState([]); + + const loadRoles = useCallback(async () => { + try { + const rolesData = await permissionsService.listRoles(); + setRoles(rolesData); + } catch (e) { + console.error('Failed to load roles:', e); + } + }, []); + + useEffect(() => { + loadRoles(); + }, [loadRoles]); + + // 身份管理子 Tab + const identityItems = [ + { + key: 'users', + label: ( + + {t('permissions_col_user') || '用户'} + + ), + children: , + }, + { + key: 'groups', + label: ( + + {t('permissions_user_groups') || '用户组'} + + ), + children: , + }, + { + key: 'roles', + label: ( + + {t('permissions_role_management') || '角色'} + + ), + children: , + }, + ]; + + // 权限管理子 Tab + const permissionItems = [ + { + key: 'policies', + label: ( + + {t('permissions_policies') || '策略'} + + ), + children: , + }, + ]; + + // 主 Tab + const mainItems = [ + { + key: 'identity', + label: t('permissions_identity_management') || '身份管理', + children: ( + setActiveIdentityTab(key as IdentityTabKey)} + items={identityItems} + /> + ), + }, + { + key: 'permission', + label: t('permissions_permission_management') || '权限管理', + children: ( + setActivePermissionTab(key as PermissionTabKey)} + items={permissionItems} + /> + ), + }, + ]; + + return ( +
+ + setActiveMainTab(key as 'identity' | 'permission')} + items={mainItems} + /> +
+ ); +} \ No newline at end of file diff --git a/web/src/app/users/page.tsx b/web/src/app/users/page.tsx index 8c529af8..4e248b05 100644 --- a/web/src/app/users/page.tsx +++ b/web/src/app/users/page.tsx @@ -1,8 +1,8 @@ 'use client'; import React, { useEffect, useRef, useState } from 'react'; -import { Avatar, Badge, Button, Input, Space, Switch, Table, Tag, message } from 'antd'; -import { SearchOutlined, UserOutlined } from '@ant-design/icons'; +import { Avatar, Badge, Button, Input, Modal, Space, Switch, Table, Tag, message } from 'antd'; +import { DeleteOutlined, SearchOutlined, UserOutlined } from '@ant-design/icons'; import { usersService, User } from '@/services/users'; import { authService } from '@/services/auth'; import { useRouter } from 'next/navigation'; @@ -18,6 +18,7 @@ export default function UsersPage() { const [keyword, setKeyword] = useState(''); const [loading, setLoading] = useState(false); const [oauthEnabled, setOauthEnabled] = useState(null); + const [currentUser, setCurrentUser] = useState(null); const checkedRef = useRef(false); useEffect(() => { @@ -29,6 +30,12 @@ export default function UsersPage() { router.replace('/'); } }); + // Get current user info + authService.getCurrentUser().then((user) => { + setCurrentUser(user); + }).catch(() => { + // Ignore error + }); }, [router]); useEffect(() => { @@ -70,6 +77,31 @@ export default function UsersPage() { } }; + const handleDelete = async (user: User) => { + // Prevent self-deletion + if (currentUser && currentUser.id === user.id) { + message.error('不能删除自己的账号'); + return; + } + + Modal.confirm({ + title: '确认删除用户', + content: `确定要删除用户 "${user.name || user.fullname || user.email || user.id}" 吗?此操作将禁用该用户账号。`, + okText: '删除', + okType: 'danger', + cancelText: '取消', + onOk: async () => { + try { + await usersService.deleteUser(user.id); + message.success('用户已删除'); + fetchUsers(); // Refresh list + } catch (e: any) { + message.error(e?.response?.data?.detail || '删除失败'); + } + }, + }); + }; + const columns = [ { title: '头像', @@ -144,13 +176,26 @@ export default function UsersPage() { title: '操作', key: 'actions', render: (_: any, record: User) => ( - + + + {/* Show delete button only for admin users, hide for self */} + {currentUser?.role === 'admin' && currentUser?.id !== record.id && ( + + )} + ), }, ]; diff --git a/web/src/client/api/index.ts b/web/src/client/api/index.ts index 6873f266..1aabf332 100644 --- a/web/src/client/api/index.ts +++ b/web/src/client/api/index.ts @@ -17,6 +17,7 @@ export type FailedTuple = [Error | AxiosError, null, nul export const ins = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_BASE_URL ?? '/', + withCredentials: true, // Send cookies for session-based auth }); const LONG_TIME_API: string[] = [ diff --git a/web/src/components/config/FeaturePluginsSection.tsx b/web/src/components/config/FeaturePluginsSection.tsx index db2145f4..eb4f5212 100644 --- a/web/src/components/config/FeaturePluginsSection.tsx +++ b/web/src/components/config/FeaturePluginsSection.tsx @@ -5,7 +5,6 @@ import { Alert, Card, List, Space, Switch, Tag, Typography, App } from 'antd'; import { AppstoreOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; import { configService, type FeaturePluginCatalogItem } from '@/services/config'; -import UserGroupsPluginPanel from '@/components/config/UserGroupsPluginPanel'; import { getApiErrorMessage, isHttpStatus } from '@/utils/apiError'; const { Text, Paragraph } = Typography; @@ -103,8 +102,14 @@ export default function FeaturePluginsSection({ onChange }: { onChange?: () => v onChange={(v) => handleToggle(item.id, v)} /> - {item.id === 'user_groups' ? ( - + {item.id === 'access_control' && item.enabled ? ( + ) : null} diff --git a/web/src/components/config/OAuth2ConfigSection.tsx b/web/src/components/config/OAuth2ConfigSection.tsx index 0944ff82..021056a4 100644 --- a/web/src/components/config/OAuth2ConfigSection.tsx +++ b/web/src/components/config/OAuth2ConfigSection.tsx @@ -40,6 +40,14 @@ const PROVIDER_TYPE_OPTIONS = [ { value: 'custom', label: 自定义 OAuth2 }, ]; +const DEFAULT_ROLE_OPTIONS = [ + { value: 'guest', label: '访客 (Guest) - 仅模型和监控' }, + { value: 'viewer', label: '观察者 (Viewer) - 只读访问' }, + { value: 'operator', label: '操作员 (Operator) - 可执行不可配置' }, + { value: 'editor', label: '编辑者 (Editor) - 读写执行' }, + { value: 'admin', label: '管理员 (Admin) - 完全权限' }, +]; + /** Masked display of a secret */ function MaskedValue({ value }: { value?: string }) { if (!value) return 未填写; @@ -351,28 +359,38 @@ export default function OAuth2ConfigSection({ onChange }: OAuth2ConfigSectionPro loadOAuth2Config(); }, []); + // Debug: log form values when they change + const watchedValues = Form.useWatch([], form); + useEffect(() => { + console.log('Form values changed:', watchedValues); + }, [watchedValues]); + const loadOAuth2Config = async () => { setLoading(true); try { const data = await configService.getOAuth2Config(); const isBuiltInType = (type: string) => type === 'github' || type === 'alibaba-inc'; - const providers = data.providers?.length + + // Preserve empty providers array from backend - don't create default empty provider + // This prevents accidentally overwriting existing config with empty defaults + const providers = Array.isArray(data.providers) ? data.providers.map(p => ({ provider_type: p.type || 'github', custom_id: !isBuiltInType(p.type) ? p.id : undefined, - client_id: p.client_id, - client_secret: p.client_secret, + client_id: p.client_id || '', + client_secret: p.client_secret || '', authorization_url: p.authorization_url, token_url: p.token_url, userinfo_url: p.userinfo_url, scope: p.scope, })) - : [{ provider_type: 'github', client_id: '', client_secret: '' }]; + : []; form.setFieldsValue({ - enabled: data.enabled, - providers, + enabled: data.enabled ?? false, + providers: providers.length > 0 ? providers : [{ provider_type: 'github', client_id: '', client_secret: '' }], admin_users_text: (data.admin_users || []).join(', '), + default_role: data.default_role || 'viewer', }); // Loaded providers with missing credentials start in edit mode; others lock @@ -388,7 +406,13 @@ export default function OAuth2ConfigSection({ onChange }: OAuth2ConfigSectionPro } }; + const handleSaveFailed = (errorInfo: any) => { + console.error('Form validation failed:', errorInfo); + message.error('表单验证失败,请检查必填项'); + }; + const handleSave = async (values: any) => { + console.log('Form submitted with values:', values); setSaving(true); try { const providers: OAuth2ProviderConfig[] = (values.providers || []) @@ -422,6 +446,7 @@ export default function OAuth2ConfigSection({ onChange }: OAuth2ConfigSectionPro enabled: !!values.enabled, providers, admin_users, + default_role: values.default_role || 'viewer', }); message.success('OAuth2 配置已保存'); try { @@ -438,7 +463,13 @@ export default function OAuth2ConfigSection({ onChange }: OAuth2ConfigSectionPro }; return ( -
+ {/* 总开关 */}
@@ -467,6 +498,22 @@ export default function OAuth2ConfigSection({ onChange }: OAuth2ConfigSectionPro + + 默认用户角色{' '} + + + + + } + rules={[{ required: true, message: '请选择默认用户角色' }]} + className='mb-5' + > + + + ); + } + return ( + + + + ); + }; + + return ( +
+
+ + + + {t('permissions_custom_policy')} + + + +
+ + + {t('permissions_section_preset_scope_hint')} {t('permissions_section_resource_scoped_hint')} + + } + /> + + setSearch(e.target.value)} + /> + + + + {t('permissions_section_preset_scope')} + {filteredPreset.length} + + } + > + + {t('permissions_section_preset_scope_hint')} + +
+ + {t('permissions_quick_templates')}: + + + {PRESET_TEMPLATE_DEFS.map((template) => ( + + ))} + +
+ + loading={loading} + rowKey={(r) => `preset-${r.id}-${r.role_id}`} + dataSource={filteredPreset} + columns={tableColumns} + pagination={{ pageSize: 8, showSizeChanger: false }} + /> +
+ + + + {t('permissions_section_resource_scoped')} + {filteredCustom.length} + + } + > + + {t('permissions_section_resource_scoped_hint')} + + + loading={loading} + rowKey={(r) => `custom-${r.id}-${r.role_id}`} + dataSource={filteredCustom} + columns={tableColumns} + pagination={{ pageSize: 8, showSizeChanger: false }} + /> + + + { + setCreateOpen(false); + createForm.resetFields(); + }} + destroyOnClose + width={600} + > + + + + + + + + + )} + {createKind === 'role_permission' && ( + + { + setSelectedResourceType(value); + createForm.setFieldsValue({ resource_id: undefined, action: undefined }); + }} + /> + + {resourceIdFormItem()} + + + + + + + { + setEditOpen(false); + editForm.resetFields(); + }} + destroyOnClose + width={600} + > +
+ {editKind === 'permission_definition' ? ( + <> + + + + + + + + ) : ( + + { + setSelectedResourceType(value); + editForm.setFieldsValue({ resource_id: undefined, action: undefined }); + }} + /> + + {resourceIdFormItem()} + + + +
+
+
+ ); +} diff --git a/web/src/components/permissions/GroupManagement.tsx b/web/src/components/permissions/GroupManagement.tsx new file mode 100644 index 00000000..11c515cb --- /dev/null +++ b/web/src/components/permissions/GroupManagement.tsx @@ -0,0 +1,629 @@ +'use client'; + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Button, + Divider, + Drawer, + Card, + Form, + Input, + Modal, + Popconfirm, + Select, + Space, + Table, + Tag, + Tabs, + Typography, + message, + Alert, +} from 'antd'; +import { PlusOutlined, ReloadOutlined, TeamOutlined, UserAddOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import { + userGroupsService, + type UserGroupRow, + type UserGroupMemberRow, +} from '@/services/userGroups'; +import { usersService, type User } from '@/services/users'; +import { permissionsService, type Role } from '@/services/permissions'; + +const { Text } = Typography; + +interface GroupManagementProps { + roles: Role[]; +} + +export default function GroupManagement({ roles }: GroupManagementProps) { + const { t } = useTranslation(); + const [groups, setGroups] = useState([]); + const [keyword, setKeyword] = useState(''); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [panelOpen, setPanelOpen] = useState(false); + const [panelTab, setPanelTab] = useState<'members' | 'roles'>('members'); + const [activeGroup, setActiveGroup] = useState(null); + const [members, setMembers] = useState([]); + const [membersLoading, setMembersLoading] = useState(false); + const [userOptions, setUserOptions] = useState([]); + const [selectedUserIds, setSelectedUserIds] = useState([]); + const [manualUserId, setManualUserId] = useState(null); + const [allUsersCache, setAllUsersCache] = useState([]); + const [groupRoles, setGroupRoles] = useState([]); + const [selectedRoleIds, setSelectedRoleIds] = useState([]); + const [rolesSaving, setRolesSaving] = useState(false); + const [groupRoleNamesMap, setGroupRoleNamesMap] = useState>({}); + const [createForm] = Form.useForm(); + + const loadGroups = useCallback(async (opts?: { silent?: boolean }) => { + const silent = opts?.silent === true; + if (silent) { + setRefreshing(true); + } else { + setLoading(true); + } + try { + // Load groups first, handle NOT_MOUNTED separately + let data: UserGroupRow[] = []; + try { + data = await userGroupsService.listGroups(); + } catch (e) { + const errMsg = e instanceof Error ? e.message : String(e); + if (errMsg === 'NOT_MOUNTED') { + // Feature not enabled, show empty state + data = []; + } else { + throw e; + } + } + + // Load users + let users: User[] = []; + try { + users = await usersService.listAllUsers(''); + } catch { + users = []; + } + + const roleAssignments = await Promise.all( + data.map(async (g) => { + try { + const assignments = await permissionsService.listGroupRoles(g.id); + return [g.id, assignments.map((row) => row.role_name)] as const; + } catch { + return [g.id, []] as const; + } + }), + ); + + const roleMap: Record = {}; + roleAssignments.forEach(([groupId, roleNames]) => { + roleMap[groupId] = roleNames; + }); + + setAllUsersCache(users); + setGroups(data); + setGroupRoleNamesMap(roleMap); + } catch (e: unknown) { + const errMsg = e instanceof Error ? e.message : String(e); + if (errMsg !== 'NOT_MOUNTED') { + message.error(String(e)); + } + } finally { + if (silent) { + setRefreshing(false); + } else { + setLoading(false); + } + } + }, []); + + useEffect(() => { + loadGroups(); + }, [loadGroups]); + + const openMembers = async (g: UserGroupRow) => { + setActiveGroup(g); + setPanelOpen(true); + setPanelTab('members'); + setSelectedUserIds([]); + setMembersLoading(true); + try { + const m = await userGroupsService.listMembers(g.id); + setMembers(m); + } catch (e: unknown) { + message.error(String(e)); + setMembers([]); + } finally { + setMembersLoading(false); + } + try { + const list = await usersService.listAllUsers(''); + setUserOptions(list); + } catch { + setUserOptions([]); + } + }; + + const openRoles = async (g: UserGroupRow) => { + setActiveGroup(g); + setPanelOpen(true); + setPanelTab('roles'); + try { + const assignments = await permissionsService.listGroupRoles(g.id); + const assignedRoleIds = assignments.map((item) => item.role_id); + const assignedRoles = roles.filter((r) => assignedRoleIds.includes(r.id)); + setGroupRoles(assignedRoles); + setSelectedRoleIds(assignedRoleIds); + } catch { + setGroupRoles([]); + setSelectedRoleIds([]); + message.error(t('permissions_load_roles_error') || '加载角色失败'); + } + }; + + const userLabelById = useMemo(() => { + const m = new Map(); + allUsersCache.forEach((u) => { + m.set(u.id, u.name || u.email || u.fullname || `#${u.id}`); + }); + userOptions.forEach((u) => { + m.set(u.id, u.name || u.email || u.fullname || `#${u.id}`); + }); + return m; + }, [allUsersCache, userOptions]); + + const getUserLabel = useCallback( + (userId: number) => userLabelById.get(userId) ?? `#${userId}`, + [userLabelById], + ); + + const filteredGroups = useMemo(() => { + const term = keyword.trim().toLowerCase(); + if (!term) return groups; + return groups.filter((g) => { + const name = (g.name || '').toLowerCase(); + const desc = (g.description || '').toLowerCase(); + return name.includes(term) || desc.includes(term); + }); + }, [groups, keyword]); + + const handleCreate = async () => { + try { + const v = await createForm.validateFields(); + await userGroupsService.createGroup(v.name, v.description); + message.success(t('plugin_user_groups_created')); + setCreateOpen(false); + createForm.resetFields(); + await loadGroups(); + } catch (e: unknown) { + if ((e as { errorFields?: unknown })?.errorFields) return; + message.error(String(e)); + } + }; + + const handleDelete = async (id: number) => { + try { + await userGroupsService.deleteGroup(id); + message.success(t('plugin_user_groups_deleted')); + await loadGroups(); + } catch (e: unknown) { + message.error(String(e)); + } + }; + + const handleAddMembers = async () => { + if (!activeGroup || selectedUserIds.length === 0) return; + try { + const n = await userGroupsService.addMembers(activeGroup.id, selectedUserIds); + message.success(t('plugin_user_groups_members_added', { count: n })); + setSelectedUserIds([]); + const next = await userGroupsService.listMembers(activeGroup.id); + setMembers(next); + await loadGroups(); + } catch (e: unknown) { + message.error(String(e)); + } + }; + + const handleAddMemberById = async () => { + if (!activeGroup || manualUserId == null || manualUserId < 1) { + message.warning(t('plugin_user_groups_invalid_user_id')); + return; + } + const uid = Math.floor(manualUserId); + try { + const n = await userGroupsService.addMembers(activeGroup.id, [uid]); + if (n === 0) { + message.info(t('plugin_user_groups_already_member')); + } else { + message.success(t('plugin_user_groups_members_added', { count: n })); + } + setManualUserId(null); + const next = await userGroupsService.listMembers(activeGroup.id); + setMembers(next); + await loadGroups(); + } catch (e: unknown) { + message.error(String(e)); + } + }; + + const handleRemoveMember = async (userId: number) => { + if (!activeGroup) return; + try { + await userGroupsService.removeMember(activeGroup.id, userId); + message.success(t('plugin_user_groups_member_removed')); + const next = await userGroupsService.listMembers(activeGroup.id); + setMembers(next); + await loadGroups(); + } catch (e: unknown) { + message.error(String(e)); + } + }; + + const handleAssignRoles = async () => { + if (!activeGroup) return; + const currentRoleIds = groupRoles.map((r) => r.id); + const toAdd = selectedRoleIds.filter((id) => !currentRoleIds.includes(id)); + const toRemove = currentRoleIds.filter((id) => !selectedRoleIds.includes(id)); + + if (toAdd.length === 0 && toRemove.length === 0) { + setPanelOpen(false); + return; + } + + setRolesSaving(true); + try { + for (const roleId of toAdd) { + await permissionsService.assignRoleToGroup(activeGroup.id, roleId); + } + for (const roleId of toRemove) { + await permissionsService.removeGroupRole(activeGroup.id, roleId); + } + message.success(t('permissions_roles_updated')); + setPanelOpen(false); + await loadGroups({ silent: true }); + } catch (e: unknown) { + message.error(String(e)); + } finally { + setRolesSaving(false); + } + }; + + const handleClosePanel = () => { + setPanelOpen(false); + setActiveGroup(null); + setManualUserId(null); + }; + + const currentRoleNames = activeGroup ? groupRoleNamesMap[activeGroup.id] || [] : []; + const currentMemberCount = activeGroup + ? panelTab === 'members' + ? members.length + : (activeGroup.member_count ?? 0) + : 0; + + return ( +
+
+ + + + {t('permissions_user_groups') || '用户组管理'} + + + + + + +
+ + + +
+ setKeyword(v || '')} + onChange={(e) => setKeyword(e.target.value || '')} + value={keyword} + style={{ width: 380 }} + /> + + {(t('total_items', { total: filteredGroups.length }) as string) || `共 ${filteredGroups.length} 条`} + +
+ + + loading={loading} + rowKey="id" + pagination={false} + dataSource={filteredGroups} + columns={[ + { + title: t('plugin_user_groups_col_name') || '组名称', + dataIndex: 'name', + key: 'name', + width: 240, + render: (name: string, row) => ( +
+ {name} + + {row.description || '-'} + +
+ ), + }, + { + title: t('plugin_user_groups_col_desc') || '描述', + dataIndex: 'description', + key: 'description', + ellipsis: true, + }, + { + title: t('plugin_user_groups_col_members') || '成员数', + dataIndex: 'member_count', + key: 'member_count', + width: 100, + render: (count) => {count ?? 0}, + }, + { + title: t('permissions_col_rbac_roles') || 'RBAC 角色', + key: 'rbac_roles', + width: 260, + render: (_: unknown, row) => { + const roleNames = groupRoleNamesMap[row.id] || []; + return roleNames.length > 0 ? ( + + {roleNames.map((name) => ( + + {name} + + ))} + + ) : ( + + ); + }, + }, + { + title: t('permissions_col_actions') || '操作', + key: 'actions', + width: 320, + render: (_, row) => ( + + + + + + handleDelete(row.id)} + > + + + + ), + }, + ]} + /> + + {/* Create Group Modal */} + { + setCreateOpen(false); + createForm.resetFields(); + }} + destroyOnClose + width={600} + > +
+ + + + + + +
+
+ + + + {panelTab === 'roles' && ( + + )} +
+ } + destroyOnClose + > + {activeGroup && ( + +
+ + {t('plugin_user_groups_col_members') || '成员数'}: {currentMemberCount} + + + {t('permissions_col_rbac_roles') || 'RBAC 角色'}: {currentRoleNames.length} + + {currentRoleNames.length > 0 && ( + + {currentRoleNames.slice(0, 6).map((name) => ( + + {name} + + ))} + {currentRoleNames.length > 6 && ( + +{currentRoleNames.length - 6} + )} + + )} +
+
+ )} + + setPanelTab(key as 'members' | 'roles')} + items={[ + { + key: 'members', + label: t('permissions_manage_members') || '管理成员', + children: ( + <> +
+ + setManualUserId(e.target.value ? parseInt(e.target.value) : null)} + style={{ width: 100 }} + /> + + +
+ + size="small" + loading={membersLoading} + rowKey="id" + pagination={false} + dataSource={members} + columns={[ + { + title: 'ID', + dataIndex: 'user_id', + width: 80, + }, + { + title: t('permissions_col_user') || '用户', + key: 'label', + render: (_, r) => getUserLabel(r.user_id), + }, + { + title: t('permissions_col_actions') || '操作', + key: 'rm', + width: 80, + render: (_, r) => ( + handleRemoveMember(r.user_id)} + > + + + ), + }, + ]} + /> + + ), + }, + { + key: 'roles', + label: t('permissions_add_authorization') || '新增授权', + children: ( + <> + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ({ + value: opt.value, + label: opt.label, + disabled: opt.disabled, + }))} + placeholder={t('permissions_select_resource')} + loading={loading} + tagRender={({ value }) => renderTag(String(value))} + className="w-full" + maxTagCount="responsive" + allowClear + /> + )} +
+ ); +} \ No newline at end of file diff --git a/web/src/components/permissions/RoleManagement.tsx b/web/src/components/permissions/RoleManagement.tsx new file mode 100644 index 00000000..445c35ba --- /dev/null +++ b/web/src/components/permissions/RoleManagement.tsx @@ -0,0 +1,1308 @@ +'use client'; + +import React, { useCallback, useEffect, useState } from 'react'; +import { + Alert, + Button, + Card, + Checkbox, + Descriptions, + Drawer, + Form, + Input, + Modal, + Popconfirm, + Select, + Space, + Table, + Tag, + Transfer, + Typography, + message, + Spin, + Tooltip, +} from 'antd'; +import { + DeleteOutlined, + EditOutlined, + PlusOutlined, + SafetyOutlined, + SettingOutlined, + EyeOutlined, + InfoCircleOutlined, +} from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import { permissionsService, type Role, type Permission, type ScopedPermission, type PermissionDefinition } from '@/services/permissions'; +import ResourceSelector from './ResourceSelector'; + +const { Text } = Typography; + +interface RoleWithPermissions extends Role { + permissions?: Permission[]; +} + +interface RoleManagementProps { + roles?: Role[]; + onRolesChange?: () => void; +} + +// Predefined permission policies (T3.4) +interface PredefinedPolicy { + key: string; + name: string; + nameZh: string; + description: string; + descriptionZh: string; + permissions: { resource_type: string; action: string; resource_id: string }[]; + category: 'agent' | 'tool' | 'knowledge' | 'model' | 'system'; +} + +const predefinedPolicies: PredefinedPolicy[] = [ + // Agent policies + { + key: 'agent:readonly', + name: 'AgentReadOnly', + nameZh: '智能体只读', + description: 'Read-only access to agents', + descriptionZh: '只读访问智能体', + category: 'agent', + permissions: [{ resource_type: 'agent', action: 'read', resource_id: '*' }], + }, + { + key: 'agent:chat', + name: 'AgentChatAccess', + nameZh: '智能体对话', + description: 'Can view and chat with agents', + descriptionZh: '可查看智能体并与其对话', + category: 'agent', + permissions: [ + { resource_type: 'agent', action: 'read', resource_id: '*' }, + { resource_type: 'agent', action: 'chat', resource_id: '*' }, + ], + }, + { + key: 'agent:full', + name: 'AgentFullAccess', + nameZh: '智能体完全访问', + description: 'Full management access to agents', + descriptionZh: '完全管理智能体', + category: 'agent', + permissions: [{ resource_type: 'agent', action: '*', resource_id: '*' }], + }, + // Tool policies + { + key: 'tool:readonly', + name: 'ToolReadOnly', + nameZh: '工具只读', + description: 'Read-only access to tools', + descriptionZh: '只读访问工具', + category: 'tool', + permissions: [{ resource_type: 'tool', action: 'read', resource_id: '*' }], + }, + { + key: 'tool:execute', + name: 'ToolExecuteAccess', + nameZh: '工具执行', + description: 'Can view and execute tools', + descriptionZh: '可查看并执行工具', + category: 'tool', + permissions: [ + { resource_type: 'tool', action: 'read', resource_id: '*' }, + { resource_type: 'tool', action: 'execute', resource_id: '*' }, + ], + }, + { + key: 'tool:full', + name: 'ToolFullAccess', + nameZh: '工具完全访问', + description: 'Full management access to tools', + descriptionZh: '完全管理工具', + category: 'tool', + permissions: [{ resource_type: 'tool', action: '*', resource_id: '*' }], + }, + // Knowledge policies + { + key: 'knowledge:readonly', + name: 'KnowledgeReadOnly', + nameZh: '知识库只读', + description: 'Read-only access to knowledge bases', + descriptionZh: '只读访问知识库', + category: 'knowledge', + permissions: [{ resource_type: 'knowledge', action: 'read', resource_id: '*' }], + }, + { + key: 'knowledge:query', + name: 'KnowledgeQueryAccess', + nameZh: '知识库检索', + description: 'Can view and query knowledge bases', + descriptionZh: '可查看并检索知识库', + category: 'knowledge', + permissions: [ + { resource_type: 'knowledge', action: 'read', resource_id: '*' }, + { resource_type: 'knowledge', action: 'query', resource_id: '*' }, + ], + }, + { + key: 'knowledge:full', + name: 'KnowledgeFullAccess', + nameZh: '知识库完全访问', + description: 'Full management access to knowledge bases', + descriptionZh: '完全管理知识库', + category: 'knowledge', + permissions: [{ resource_type: 'knowledge', action: '*', resource_id: '*' }], + }, + // Model policies + { + key: 'model:readonly', + name: 'ModelReadOnly', + nameZh: '模型只读', + description: 'Read-only access to models', + descriptionZh: '只读访问模型', + category: 'model', + permissions: [{ resource_type: 'model', action: 'read', resource_id: '*' }], + }, + { + key: 'model:chat', + name: 'ModelChatAccess', + nameZh: '模型对话', + description: 'Can view and chat with models', + descriptionZh: '可查看模型并与其对话', + category: 'model', + permissions: [ + { resource_type: 'model', action: 'read', resource_id: '*' }, + { resource_type: 'model', action: 'chat', resource_id: '*' }, + ], + }, + { + key: 'model:full', + name: 'ModelFullAccess', + nameZh: '模型完全访问', + description: 'Full management access to models', + descriptionZh: '完全管理模型', + category: 'model', + permissions: [{ resource_type: 'model', action: '*', resource_id: '*' }], + }, + // System policies + { + key: 'system:readonly', + name: 'SystemReadOnly', + nameZh: '系统只读', + description: 'System-wide read-only access', + descriptionZh: '全系统只读权限', + category: 'system', + permissions: [{ resource_type: '*', action: 'read', resource_id: '*' }], + }, + { + key: 'system:operator', + name: 'SystemOperator', + nameZh: '系统操作员', + description: 'Can operate all resources but cannot manage configuration', + descriptionZh: '可操作所有资源但不可管理配置', + category: 'system', + permissions: [ + { resource_type: '*', action: 'read', resource_id: '*' }, + { resource_type: '*', action: 'chat', resource_id: '*' }, + { resource_type: '*', action: 'execute', resource_id: '*' }, + { resource_type: '*', action: 'query', resource_id: '*' }, + ], + }, + { + key: 'system:admin', + name: 'SystemFullAccess', + nameZh: '系统管理员', + description: 'Full system administration access', + descriptionZh: '系统完全管理权限', + category: 'system', + permissions: [{ resource_type: '*', action: '*', resource_id: '*' }], + }, +]; + +// Helper to get category color +const getCategoryColor = (category: string): string => { + const colors: Record = { + agent: 'blue', + tool: 'green', + knowledge: 'orange', + model: 'purple', + system: 'red', + }; + return colors[category] || 'default'; +}; + +// Helper to get category label +const getCategoryLabel = (category: string, t: (key: string) => string): string => { + const labels: Record = { + agent: t('permissions_resource_agent'), + tool: t('permissions_resource_tool'), + knowledge: t('permissions_resource_knowledge'), + model: t('permissions_resource_model'), + system: t('permissions_resource_all'), + }; + return labels[category] || category; +}; + +// Helper to get action options by resource type +const getActionOptions = (resourceType: string, t: (key: string) => string) => { + const actionMap: Record = { + agent: [ + { value: 'read', label: t('permissions_action_read') }, + { value: 'chat', label: t('permissions_action_chat') }, + { value: 'write', label: t('permissions_action_write') }, + { value: 'admin', label: t('permissions_action_admin') }, + ], + tool: [ + { value: 'read', label: t('permissions_action_read') }, + { value: 'execute', label: t('permissions_action_execute') }, + { value: 'manage', label: t('permissions_action_manage') }, + { value: 'admin', label: t('permissions_action_admin') }, + ], + knowledge: [ + { value: 'read', label: t('permissions_action_read') }, + { value: 'query', label: t('permissions_action_query') }, + { value: 'write', label: t('permissions_action_write') }, + { value: 'admin', label: t('permissions_action_admin') }, + ], + model: [ + { value: 'read', label: t('permissions_action_read') }, + { value: 'chat', label: t('permissions_action_chat') }, + { value: 'manage', label: t('permissions_action_manage') }, + { value: 'admin', label: t('permissions_action_admin') }, + ], + }; + return actionMap[resourceType] || actionMap.agent; +}; + +export default function RoleManagement({ roles: externalRoles, onRolesChange }: RoleManagementProps) { + const { t, i18n } = useTranslation(); + const [loading, setLoading] = useState(false); + const [roles, setRoles] = useState([]); + const [createOpen, setCreateOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); + const [selectedRole, setSelectedRole] = useState(null); + const [createForm] = Form.useForm(); + const [editForm] = Form.useForm(); + const isSystemRole = (role: RoleWithPermissions | null | undefined) => + (role?.is_system ?? 0) === 1; + + const loadRoles = useCallback(async () => { + setLoading(true); + try { + const rolesData = await permissionsService.listRoles(); + // Load permissions for each role + const rolesWithPerms = await Promise.all( + rolesData.map(async (role) => { + try { + const perms = await permissionsService.listRolePermissions(role.id); + return { ...role, permissions: perms }; + } catch { + return { ...role, permissions: [] }; + } + }) + ); + setRoles(rolesWithPerms); + } catch (e: unknown) { + message.error(t('permissions_load_error') + ': ' + (e as Error).message); + } finally { + setLoading(false); + } + }, [t]); + + // Load roles with permissions when external roles change + const loadRolesWithPermissions = useCallback(async () => { + setLoading(true); + try { + const rolesData = await permissionsService.listRoles(); + const rolesWithPerms = await Promise.all( + rolesData.map(async (role) => { + try { + const perms = await permissionsService.listRolePermissions(role.id); + return { ...role, permissions: perms }; + } catch { + return { ...role, permissions: [] }; + } + }) + ); + setRoles(rolesWithPerms); + } catch (e: unknown) { + message.error(t('permissions_load_error') + ': ' + (e as Error).message); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + if (externalRoles && externalRoles.length > 0) { + // Use external roles and load their permissions + loadRolesWithPermissions(); + } else if (!externalRoles) { + // No external roles, load internally + loadRoles(); + } + }, [externalRoles, loadRoles, loadRolesWithPermissions]); + + const handleCreate = async () => { + try { + const values = await createForm.validateFields(); + await permissionsService.createRole({ + name: values.name, + description: values.description, + }); + message.success(t('permissions_role_created')); + setCreateOpen(false); + createForm.resetFields(); + await loadRoles(); + onRolesChange?.(); + } catch (e: unknown) { + if ((e as { errorFields?: unknown })?.errorFields) return; + message.error(t('permissions_create_error') + ': ' + (e as Error).message); + } + }; + + const handleEdit = async () => { + if (!selectedRole) return; + if (isSystemRole(selectedRole)) { + message.warning( + t('permissions_system_role_readonly') || '系统角色为只读,不允许配置或修改' + ); + return; + } + try { + const values = await editForm.validateFields(); + await permissionsService.updateRole(selectedRole.id, { + name: values.name, + description: values.description, + }); + message.success(t('permissions_role_updated')); + setEditOpen(false); + editForm.resetFields(); + await loadRoles(); + onRolesChange?.(); + } catch (e: unknown) { + if ((e as { errorFields?: unknown })?.errorFields) return; + message.error(t('permissions_update_error') + ': ' + (e as Error).message); + } + }; + + const handleDelete = async (roleId: number, isSystem: number) => { + if (isSystem === 1) { + message.warning(t('permissions_system_role_cannot_delete')); + return; + } + try { + await permissionsService.deleteRole(roleId); + message.success(t('permissions_role_deleted')); + await loadRoles(); + onRolesChange?.(); + } catch (e: unknown) { + message.error(t('permissions_delete_error') + ': ' + (e as Error).message); + } + }; + + const openEdit = (role: RoleWithPermissions) => { + if (isSystemRole(role)) { + message.warning( + t('permissions_system_role_readonly') || '系统角色为只读,不允许配置或修改' + ); + return; + } + setSelectedRole(role); + editForm.setFieldsValue({ + name: role.name, + description: role.description, + }); + setEditOpen(true); + }; + + // Apply preset role template + const applyPreset = (preset: 'viewer' | 'operator' | 'editor' | 'admin') => { + const presets = { + viewer: { + name: 'viewer', + description: '只读访问所有资源', + }, + operator: { + name: 'operator', + description: '可操作所有资源但不可管理配置', + }, + editor: { + name: 'editor', + description: '可读、写、执行所有资源', + }, + admin: { + name: 'admin', + description: '完全管理权限', + }, + }; + const p = presets[preset]; + createForm.setFieldsValue({ + name: p.name, + description: p.description, + }); + }; + + return ( +
+
+ + + + {t('permissions_role_management')} + + + +
+ + + + + loading={loading} + rowKey="id" + dataSource={roles} + columns={[ + { + title: t('permissions_col_name'), + dataIndex: 'name', + key: 'name', + width: 260, + onCell: () => ({ + style: { + wordBreak: 'normal', + overflowWrap: 'normal', + }, + }), + render: (name: string, record: RoleWithPermissions) => ( + + + {name} + + {record.is_system === 1 && ( + + {t('permissions_system_role')} + + )} + + ), + }, + { + title: t('permissions_col_description'), + dataIndex: 'description', + key: 'description', + ellipsis: true, + }, + { + title: t('permissions_col_permissions'), + key: 'permissions', + width: 200, + render: (_: unknown, record: RoleWithPermissions) => ( + + {record.permissions?.length ?? 0} {t('permissions_count')} + + ), + }, + { + title: t('permissions_col_actions'), + key: 'actions', + width: 200, + render: (_: unknown, record: RoleWithPermissions) => { + if (isSystemRole(record)) { + return ( + + + + {t('permissions_system_role_readonly') || '系统角色为只读,不允许配置或修改'} + + + ); + } + return ( + + + + handleDelete(record.id, record.is_system)} + > + + + + ); + }, + }, + ]} + /> + + {/* Permission Management Panel */} + {selectedRole && ( + setSelectedRole(null)} + onPermissionsChange={() => { + loadRoles(); + onRolesChange?.(); + }} + /> + )} + + {/* Create Role Modal */} + { + setCreateOpen(false); + createForm.resetFields(); + }} + destroyOnClose + width={600} + > + + + + + + + + + + + {t('permissions_preset_desc')} + + + + + + + + + + + + + {/* Edit Role Modal */} + { + setEditOpen(false); + editForm.resetFields(); + }} + destroyOnClose + > +
+ + + + + + +
+
+
+ ); +} + +// Permission Panel Component with Transfer UI +interface PermissionPanelProps { + role: RoleWithPermissions; + onClose: () => void; + onPermissionsChange: () => void; + readonly?: boolean; +} + +interface TransferItem { + key: string; + title: string; + description: string; + policy: PredefinedPolicy; +} + +// Scoped Permission Form State +interface ScopedPermissionForm { + resource_type: string; + resource_id: string[]; + action: string; + effect: string; +} + +function PermissionPanel({ + role, + onClose, + onPermissionsChange, + readonly = false, +}: PermissionPanelProps) { + const { t, i18n } = useTranslation(); + const [permissions, setPermissions] = useState(role.permissions ?? []); + const [loading, setLoading] = useState(false); + const [detailModalOpen, setDetailModalOpen] = useState(false); + const [selectedPolicy, setSelectedPolicy] = useState(null); + + // Scoped permissions state (T2.2) + const [scopedForm, setScopedForm] = useState({ + resource_type: 'agent', + resource_id: [], + action: 'read', + effect: 'allow', + }); + const [scopedPermissions, setScopedPermissions] = useState([]); + const [loadingScoped, setLoadingScoped] = useState(false); + + // Permission definitions state + const [permissionDefinitions, setPermissionDefinitions] = useState([]); + const [rolePermissionDefs, setRolePermissionDefs] = useState([]); + const [loadingDefs, setLoadingDefs] = useState(false); + const [selectedDefIds, setSelectedDefIds] = useState([]); + + // Sync local state when role.permissions changes + useEffect(() => { + setPermissions(role.permissions ?? []); + }, [role.permissions]); + + // Load scoped permissions for this role + useEffect(() => { + const loadScopedPermissions = async () => { + setLoadingScoped(true); + try { + const scopedPerms = await permissionsService.listScopedPermissions({ role_id: role.id }); + setScopedPermissions(scopedPerms); + } catch (error) { + console.error('Failed to load scoped permissions:', error); + } finally { + setLoadingScoped(false); + } + }; + + if (role.id) { + loadScopedPermissions(); + } + }, [role.id]); + + // Load permission definitions for this role + useEffect(() => { + const loadPermissionDefinitions = async () => { + setLoadingDefs(true); + try { + const allDefs = await permissionsService.listPermissionDefinitions({}); + setPermissionDefinitions(allDefs); + + // Load permission definitions assigned to this role + const roleDefs = await permissionsService.getRolePermissionDefs(role.id); + setRolePermissionDefs(roleDefs); + setSelectedDefIds(roleDefs.map((d) => d.id)); + } catch (error) { + console.error('Failed to load permission definitions:', error); + } finally { + setLoadingDefs(false); + } + }; + + if (role.id) { + loadPermissionDefinitions(); + } + }, [role.id]); + + // Build transfer data source from predefined policies + const transferDataSource: TransferItem[] = predefinedPolicies.map((policy) => ({ + key: policy.key, + title: i18n.language === 'zh' ? policy.nameZh : policy.name, + description: i18n.language === 'zh' ? policy.descriptionZh : policy.description, + policy, + })); + + // Get currently selected policy keys based on role's permissions + const getSelectedKeys = (): string[] => { + const selected: string[] = []; + + for (const policy of predefinedPolicies) { + // Check if all permissions in this policy are granted + const allGranted = policy.permissions.every((pp) => + permissions.some( + (rp) => + rp.resource_type === pp.resource_type && + rp.action === pp.action && + (rp.resource_id === pp.resource_id || rp.resource_id === '*' || pp.resource_id === '*') + ) + ); + if (allGranted) { + selected.push(policy.key); + } + } + + return selected; + }; + + const [targetKeys, setTargetKeys] = useState(getSelectedKeys()); + + // Update target keys when permissions change + useEffect(() => { + setTargetKeys(getSelectedKeys()); + }, [permissions]); + + const handleTransferChange = async (newTargetKeys: string[]) => { + if (readonly) { + message.warning( + t('permissions_system_role_readonly') || '系统角色为只读,不允许配置或修改' + ); + return; + } + const currentKeys = getSelectedKeys(); + const added = newTargetKeys.filter((k) => !currentKeys.includes(k)); + const removed = currentKeys.filter((k) => !newTargetKeys.includes(k)); + + setLoading(true); + try { + // Add new permissions + for (const key of added) { + const policy = predefinedPolicies.find((p) => p.key === key); + if (policy) { + for (const perm of policy.permissions) { + await permissionsService.addRolePermission(role.id, { + resource_type: perm.resource_type, + resource_id: perm.resource_id, + action: perm.action, + }); + } + } + } + + // Remove permissions (find matching permissions and remove them) + for (const key of removed) { + const policy = predefinedPolicies.find((p) => p.key === key); + if (policy) { + for (const perm of policy.permissions) { + const matchingPerm = permissions.find( + (p) => + p.resource_type === perm.resource_type && + p.action === perm.action && + (p.resource_id === perm.resource_id || p.resource_id === '*' || perm.resource_id === '*') + ); + if (matchingPerm) { + await permissionsService.removeRolePermission(role.id, matchingPerm.id); + } + } + } + } + + message.success(t('permissions_role_updated')); + await onPermissionsChange(); + } catch (e: unknown) { + message.error(t('permissions_update_error') + ': ' + (e as Error).message); + } finally { + setLoading(false); + } + }; + + const handleShowDetail = (policy: PredefinedPolicy) => { + setSelectedPolicy(policy); + setDetailModalOpen(true); + }; + + // Handle scoped permission add + const handleAddScopedPermission = async () => { + if (readonly) { + message.warning( + t('permissions_system_role_readonly') || '系统角色为只读,不允许配置或修改' + ); + return; + } + if (!scopedForm.resource_id || scopedForm.resource_id.length === 0) { + message.warning(t('permissions_select_resource')); + return; + } + + setLoading(true); + try { + const addedPermissions: Permission[] = []; + + for (const resourceId of scopedForm.resource_id) { + try { + const perm = await permissionsService.grantScopedPermission({ + role_id: role.id, + resource_type: scopedForm.resource_type, + resource_id: resourceId, + action: scopedForm.action, + effect: scopedForm.effect, + }); + addedPermissions.push(perm); + } catch (error) { + // Skip if already exists + console.warn(`Failed to add scoped permission for ${resourceId}:`, error); + } + } + + if (addedPermissions.length > 0) { + setScopedPermissions((prev) => [...prev, ...addedPermissions]); + message.success(t('permissions_permission_added')); + await onPermissionsChange(); + + // Reset form + setScopedForm({ + resource_type: scopedForm.resource_type, + resource_id: [], + action: 'read', + effect: 'allow', + }); + } + } catch (e: unknown) { + message.error(t('permissions_add_permission_error') + ': ' + (e as Error).message); + } finally { + setLoading(false); + } + }; + + // Handle scoped permission remove + const handleRemoveScopedPermission = async (permissionId: number) => { + if (readonly) { + message.warning( + t('permissions_system_role_readonly') || '系统角色为只读,不允许配置或修改' + ); + return; + } + try { + await permissionsService.removeRolePermission(role.id, permissionId); + setScopedPermissions((prev) => prev.filter((p) => p.id !== permissionId)); + message.success(t('permissions_permission_removed')); + await onPermissionsChange(); + } catch (e: unknown) { + message.error(t('permissions_remove_permission_error') + ': ' + (e as Error).message); + } + }; + + // Custom render for transfer items + const renderTransferItem = (item: TransferItem) => { + return ( +
+
+ + + {getCategoryLabel(item.policy.category, t)} + + {item.title} + + {item.description} +
+ +
+ ); + }; + + return ( + <> + {t('close')}} + > + + {readonly && ( + + )} + } + className="mb-4" + message={t('permissions_preset_desc')} + description={ +
+ + {t('permissions_assign_roles_hint') || '使用穿梭框分配权限策略:'} + +
+ } + /> + + renderTransferItem(item as TransferItem)} + listStyle={{ width: 360, height: 400 }} + operations={[t('add'), t('delete')]} + showSearch + filterOption={(inputValue, item) => { + const transferItem = item as TransferItem; + return ( + transferItem.title.toLowerCase().includes(inputValue.toLowerCase()) || + transferItem.description.toLowerCase().includes(inputValue.toLowerCase()) || + transferItem.policy.name.toLowerCase().includes(inputValue.toLowerCase()) + ); + }} + /> + + {/* Scoped Resource Permissions Section (T2.2) */} +
+ + {t('permissions_scoped')} ({t('permissions_specific_resources')}) + + + {/* Add Scoped Permission Form */} + {!readonly && ( + +
+ {/* Resource Type Selector */} +
+ {t('permissions_resource_type')}: + setScopedForm({ ...scopedForm, action: value })} + options={getActionOptions(scopedForm.resource_type, t)} + className="w-full" + /> +
+ + +
+
+ )} + + {/* Scoped Permissions List */} + + {t('permissions_scoped')}: {scopedPermissions.length} {t('permissions_count')} + + {loadingScoped ? ( + + ) : scopedPermissions.length > 0 ? ( + {type}, + }, + { + title: t('permissions_resource_id'), + dataIndex: 'resource_id', + key: 'resource_id', + width: 150, + render: (id: string) => ( + + {id === '*' ? t('permissions_all_resources') : id} + + ), + }, + { + title: t('permissions_action'), + dataIndex: 'action', + key: 'action', + width: 80, + render: (action: string) => {action}, + }, + { + title: t('permissions_col_actions'), + key: 'actions', + width: 80, + render: (_: unknown, record: Permission) => + readonly ? null : ( + + ), + }, + ]} + /> + ) : ( + {t('permissions_empty')} + )} + + + {/* Permission Definitions Section */} +
+ + {t('permissions_from_definition_library') || '从权限库选择'} + + + + {t('permissions_definition_library_desc') || '权限定义允许您创建可复用的权限模板'} + + } + /> + + {loadingDefs ? ( + + ) : permissionDefinitions.length > 0 ? ( +
+ setSelectedDefIds(values as number[])} + disabled={readonly} + className="w-full" + > + + {permissionDefinitions.map((def) => ( + + + {def.resource_type} + {def.name} + - {def.description || def.action} + + + ))} + + +
+ ) : ( + + {t('permissions_no_definitions') || '暂无权限定义,请先在"权限定义"标签页创建'} + + )} + + {!readonly && selectedDefIds.length > 0 && ( + + )} +
+ + {/* Current Permissions Summary */} +
+ {t('permissions_effective_permissions')}: +
+ {permissions.length > 0 ? ( + + {Object.entries( + permissions.reduce((acc, perm) => { + if (!acc[perm.resource_type]) { + acc[perm.resource_type] = []; + } + acc[perm.resource_type].push(perm.action); + return acc; + }, {} as Record) + ).map(([resourceType, actions]) => ( + + {resourceType}: {actions.join(', ')} + + ))} + + ) : ( + {t('permissions_no_permissions')} + )} +
+
+ + + + {/* Policy Detail Modal (T3.5) */} + { + setDetailModalOpen(false); + setSelectedPolicy(null); + }} + footer={[ + , + ]} + width={600} + > + {selectedPolicy && ( +
+ + + {i18n.language === 'zh' ? selectedPolicy.nameZh : selectedPolicy.name} + + + {i18n.language === 'zh' ? selectedPolicy.descriptionZh : selectedPolicy.description} + + + + {getCategoryLabel(selectedPolicy.category, t)} + + + + {selectedPolicy.permissions.length} {t('permissions_count')} + + + + + {t('permissions_col_permissions')}: + +
{type}, + }, + { + title: t('permissions_action'), + dataIndex: 'action', + key: 'action', + render: (action: string) => ( + {action} + ), + }, + { + title: t('permissions_resource_id'), + dataIndex: 'resource_id', + key: 'resource_id', + render: (id: string) => (id === '*' ? t('permissions_resource_all') : id), + }, + ]} + /> + + )} + + + ); +} diff --git a/web/src/components/permissions/UserManagement.tsx b/web/src/components/permissions/UserManagement.tsx new file mode 100644 index 00000000..749566f4 --- /dev/null +++ b/web/src/components/permissions/UserManagement.tsx @@ -0,0 +1,652 @@ +'use client'; + +import React, { useCallback, useEffect, useState } from 'react'; +import { + Avatar, + Button, + Input, + Modal, + Popconfirm, + Space, + Transfer, + Switch, + Select, + Table, + Tag, + Typography, + message, + Spin, +} from 'antd'; +import { + DeleteOutlined, + ReloadOutlined, + TeamOutlined, + UserOutlined, +} from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import { + permissionsService, + type UserInfo, + type UserDetail, + type Role, +} from '@/services/permissions'; +import { usersService, type User } from '@/services/users'; +import { authService } from '@/services/auth'; +import { userGroupsService, type UserGroupRow, type UserGroupMemberRow } from '@/services/userGroups'; +import UserPermissionsPanel from './UserPermissionsPanel'; + +const { Text } = Typography; + +interface UserManagementProps { + roles?: Role[]; +} + +interface UnifiedUserRow extends UserInfo { + oauth_provider?: string; + legacy_role?: string; + avatar?: string; +} + +export default function UserManagement({ roles: externalRoles }: UserManagementProps) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [keyword, setKeyword] = useState(''); + const [detailOpen, setDetailOpen] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [allRoles, setAllRoles] = useState([]); + const [currentUser, setCurrentUser] = useState(null); + const [groups, setGroups] = useState([]); + const [groupMembersMap, setGroupMembersMap] = useState>>({}); + const [groupAssignOpen, setGroupAssignOpen] = useState(false); + const [groupAssignSaving, setGroupAssignSaving] = useState(false); + const [groupAssignUser, setGroupAssignUser] = useState(null); + const [selectedGroupIds, setSelectedGroupIds] = useState([]); + + const loadUsers = useCallback( + async (opts?: { silent?: boolean }) => { + const silent = opts?.silent === true; + if (!silent) setLoading(true); + try { + const [oauthData, rbacData] = await Promise.all([ + usersService.listUsers(page, pageSize, keyword), + permissionsService.listUsers(page, pageSize, keyword), + ]); + const rbacMap = new Map(rbacData.items.map((u) => [u.id, u])); + const merged = oauthData.list.map((user) => { + const rbacUser = rbacMap.get(user.id); + return { + id: user.id, + name: user.name || rbacUser?.name || '', + fullname: user.fullname || rbacUser?.fullname || '', + email: user.email || rbacUser?.email || '', + is_active: user.is_active ?? rbacUser?.is_active ?? 1, + roles: rbacUser?.roles || [], + gmt_create: user.gmt_create, + oauth_provider: user.oauth_provider || '', + legacy_role: user.role, + avatar: user.avatar, + } satisfies UnifiedUserRow; + }); + const oauthIds = new Set(oauthData.list.map((u) => u.id)); + rbacData.items.forEach((rbacUser) => { + if (oauthIds.has(rbacUser.id)) return; + merged.push({ + id: rbacUser.id, + name: rbacUser.name || '', + fullname: rbacUser.fullname || '', + email: rbacUser.email || '', + is_active: rbacUser.is_active ?? 1, + roles: rbacUser.roles || [], + gmt_create: rbacUser.gmt_create, + oauth_provider: '', + legacy_role: 'normal', + avatar: '', + }); + }); + setUsers(merged); + setTotal(Math.max(oauthData.total, merged.length)); + } catch (e: unknown) { + const err = e as { response?: { status?: number } }; + if (err.response?.status === 403) { + message.warning(t('permissions_admin_required')); + } else { + message.error(t('permissions_load_users_error') + ': ' + (e as Error).message); + } + } finally { + if (!silent) { + setLoading(false); + } + } + }, + [page, pageSize, keyword, t], + ); + + const loadRoles = useCallback(async () => { + if (externalRoles) { + setAllRoles(externalRoles); + return; + } + try { + const roles = await permissionsService.listRoles(); + setAllRoles(roles); + } catch (e: unknown) { + message.error(t('permissions_load_roles_error') + ': ' + (e as Error).message); + } + }, [t, externalRoles]); + + const loadGroups = useCallback(async () => { + try { + const groupRows = await userGroupsService.listGroups(); + setGroups(groupRows); + const memberRows = await Promise.all( + groupRows.map(async (g) => { + const members = await userGroupsService.listMembers(g.id); + return [g.id, members] as const; + }), + ); + const nextMap: Record> = {}; + memberRows.forEach(([groupId, members]) => { + nextMap[groupId] = new Set(members.map((m: UserGroupMemberRow) => m.user_id)); + }); + setGroupMembersMap(nextMap); + } catch (e: unknown) { + const err = e instanceof Error ? e.message : String(e); + if (err !== 'NOT_MOUNTED') { + message.error(t('permissions_load_user_groups_error') + ': ' + err); + } + setGroups([]); + setGroupMembersMap({}); + } + }, [t]); + + useEffect(() => { + authService.getCurrentUser().then(setCurrentUser).catch(() => setCurrentUser(null)); + loadRoles(); + loadGroups(); + }, [loadRoles, loadGroups]); + + useEffect(() => { + loadUsers({ silent: false }); + }, [loadUsers]); + + const openUserDetail = async (userId: number) => { + setDetailLoading(true); + setDetailOpen(true); + try { + const detail = await permissionsService.getUserDetail(userId); + setSelectedUser(detail); + } catch (e: unknown) { + message.error(t('permissions_load_user_detail_error') + ': ' + (e as Error).message); + setDetailOpen(false); + } finally { + setDetailLoading(false); + } + }; + + const handleToggleRole = async (user: UnifiedUserRow) => { + const nextRole = user.legacy_role === 'admin' ? 'normal' : 'admin'; + try { + await usersService.updateUser(user.id, { role: nextRole }); + message.success(nextRole === 'admin' ? t('permissions_set_admin') : t('permissions_unset_admin')); + await loadUsers({ silent: true }); + } catch (e: unknown) { + message.error(t('permissions_operation_failed') + ': ' + (e as Error).message); + } + }; + + const handleToggleActive = async (user: UnifiedUserRow, checked: boolean) => { + const nextActive = checked ? 1 : 0; + try { + await usersService.updateUser(user.id, { is_active: nextActive }); + message.success(checked ? t('permissions_user_enabled') : t('permissions_user_disabled')); + await loadUsers({ silent: true }); + } catch (e: unknown) { + message.error(t('permissions_operation_failed') + ': ' + (e as Error).message); + } + }; + + const handleDelete = async (user: UnifiedUserRow) => { + if (currentUser && currentUser.id === user.id) { + message.error(t('permissions_cannot_delete_self')); + return; + } + try { + await usersService.deleteUser(user.id); + message.success(t('permissions_user_deleted')); + await loadUsers({ silent: true }); + } catch (e: unknown) { + message.error(t('permissions_operation_failed') + ': ' + (e as Error).message); + } + }; + + const getUserGroupIds = useCallback( + (userId: number) => + groups + .filter((g) => groupMembersMap[g.id]?.has(userId)) + .map((g) => g.id), + [groups, groupMembersMap], + ); + + const openGroupAssign = (user: UnifiedUserRow) => { + setGroupAssignUser(user); + setSelectedGroupIds(getUserGroupIds(user.id)); + setGroupAssignOpen(true); + }; + + const handleSaveGroupAssign = async () => { + if (!groupAssignUser) return; + const currentIds = getUserGroupIds(groupAssignUser.id); + const toAdd = selectedGroupIds.filter((id) => !currentIds.includes(id)); + const toRemove = currentIds.filter((id) => !selectedGroupIds.includes(id)); + if (toAdd.length === 0 && toRemove.length === 0) { + setGroupAssignOpen(false); + return; + } + setGroupAssignSaving(true); + try { + for (const groupId of toAdd) { + await userGroupsService.addMembers(groupId, [groupAssignUser.id]); + } + for (const groupId of toRemove) { + await userGroupsService.removeMember(groupId, groupAssignUser.id); + } + message.success(t('permissions_user_groups_updated')); + setGroupAssignOpen(false); + await loadGroups(); + } catch (e: unknown) { + message.error(t('permissions_operation_failed') + ': ' + (e as Error).message); + } finally { + setGroupAssignSaving(false); + } + }; + + const handlePageChange = (newPage: number, newPageSize: number) => { + setPage(newPage); + setPageSize(newPageSize); + }; + + const columns = [ + { + title: '', + dataIndex: 'avatar', + key: 'avatar', + width: 64, + render: (_: string | undefined, record: UnifiedUserRow) => ( + : undefined} /> + ), + }, + { + title: t('permissions_col_name'), + dataIndex: 'name', + key: 'name', + width: 140, + }, + { + title: t('permissions_col_fullname'), + dataIndex: 'fullname', + key: 'fullname', + width: 140, + }, + { + title: t('permissions_col_email'), + dataIndex: 'email', + key: 'email', + width: 220, + ellipsis: true, + }, + { + title: t('permissions_oauth_provider'), + dataIndex: 'oauth_provider', + key: 'oauth_provider', + width: 120, + render: (provider: string) => (provider ? {provider} : -), + }, + { + title: t('permissions_legacy_role'), + dataIndex: 'legacy_role', + key: 'legacy_role', + width: 120, + render: (role: string) => ( + + {role === 'admin' ? t('permissions_admin_user') : t('permissions_normal_user')} + + ), + }, + { + title: t('permissions_col_status'), + dataIndex: 'is_active', + key: 'is_active', + width: 120, + render: (active: number, record: UnifiedUserRow) => ( + handleToggleActive(record, checked)} + size="small" + /> + ), + }, + { + title: t('permissions_col_rbac_roles'), + dataIndex: 'roles', + key: 'roles', + width: 260, + render: (roleNames: string[]) => + roleNames.length > 0 ? ( + + {roleNames.map((r) => ( + + {r} + + ))} + + ) : ( + + ), + }, + { + title: t('permissions_user_groups'), + key: 'user_groups', + width: 220, + render: (_: unknown, record: UnifiedUserRow) => { + const names = groups + .filter((g) => groupMembersMap[g.id]?.has(record.id)) + .map((g) => g.name); + return names.length > 0 ? ( + + {names.map((name) => ( + + {name} + + ))} + + ) : ( + + ); + }, + }, + { + title: t('permissions_col_actions'), + key: 'actions', + width: 360, + render: (_: unknown, record: UnifiedUserRow) => ( + + + + + {currentUser?.role === 'admin' && currentUser?.id !== record.id && ( + handleDelete(record)} + > + + + )} + + ), + }, + ]; + + return ( +
+
+ + + + {t('permissions_user_management')} + + + +
+ +
+ { + setKeyword(v || ''); + setPage(1); + }} + style={{ width: 380 }} + /> +
+ + + loading={loading} + rowKey="id" + dataSource={users} + pagination={{ + current: page, + pageSize: pageSize, + total: total, + onChange: handlePageChange, + showSizeChanger: true, + showTotal: (total) => t('total_items', { total }), + }} + columns={columns} + scroll={{ x: 1450 }} + /> + + { + setGroupAssignOpen(false); + setGroupAssignUser(null); + setSelectedGroupIds([]); + }} + confirmLoading={groupAssignSaving} + destroyOnClose + > +
+ ), + }, + { + key: 'wildcard', + label: `${t('permissions_wildcard')} (${wildcardData.length})`, + children: ( +
+ ), + }, + ]; + + return ( + + {t('permissions_effective_permissions')} + {permissions.roles.join(', ') || t('permissions_no_roles')} + + } + size="small" + > + + + + + {/* Summary by Resource Type */} +
+ + {t('permissions_effective_permissions')} {t('permissions_by_resource')}: + + + {Object.entries(permissions.permissions || {}).map(([resourceType, permData]) => { + const wildcardCount = permData.wildcard?.length || 0; + const scopedCount = Object.keys(permData.scoped || {}).length; + const totalCount = wildcardCount + scopedCount; + + if (totalCount === 0) return null; + + return ( + + {resourceType}: {wildcardCount} + {t('permissions_wildcard_short')} + {scopedCount} + {t('permissions_scoped_short')} + + ); + })} + +
+
+ ); +} \ No newline at end of file diff --git a/web/src/hooks/use-user-permissions.ts b/web/src/hooks/use-user-permissions.ts new file mode 100644 index 00000000..b139f3ad --- /dev/null +++ b/web/src/hooks/use-user-permissions.ts @@ -0,0 +1,62 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { permissionsService } from '@/services/permissions'; +import { getUserId } from '@/utils/storage'; +import { message } from 'antd'; + +export interface UserPermissions { + roles: string[]; + permissions: Record; +} + +export function useUserPermissions() { + const [permissions, setPermissions] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchPermissions = useCallback(async () => { + const userId = getUserId(); + if (!userId) { + setLoading(false); + return; + } + + try { + const data = await permissionsService.getUserEffectivePermissions(Number(userId)); + setPermissions(data); + } catch (e) { + // Silent fail - permissions might not be enabled + console.debug('Failed to fetch user permissions:', e); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchPermissions(); + }, [fetchPermissions]); + + const hasPermission = useCallback( + (resourceType: string, action: string): boolean => { + if (!permissions) return true; // If permissions not loaded, allow by default + const actions = permissions.permissions[resourceType] || []; + return actions.includes('*') || actions.includes(action); + }, + [permissions] + ); + + const hasResourceRead = useCallback( + (resourceType: string): boolean => { + return hasPermission(resourceType, 'read'); + }, + [hasPermission] + ); + + return { + permissions, + loading, + hasPermission, + hasResourceRead, + refresh: fetchPermissions, + }; +} diff --git a/web/src/locales/en/common.ts b/web/src/locales/en/common.ts index b2c3bba4..497db2f1 100644 --- a/web/src/locales/en/common.ts +++ b/web/src/locales/en/common.ts @@ -1,4 +1,5 @@ export const CommonEn = { + logout: 'Logout', Knowledge_Space: 'Knowledge', space: 'space', Vector: 'Vector', @@ -932,6 +933,7 @@ export const CommonEn = { // Audit Logs audit_logs_title: 'Audit Logs', + user_management: 'User Management', system_config: 'System Config', plugin_market: 'Plugin Market', plugin_market_page_desc: @@ -986,6 +988,9 @@ export const CommonEn = { plugin_user_groups_add_by_id: 'Add by ID', plugin_user_groups_invalid_user_id: 'Enter a valid positive integer user id', plugin_user_groups_already_member: 'User is already in this group', + plugin_user_groups_moved_title: 'Group management moved', + plugin_user_groups_moved_desc: + 'To avoid overlap with RBAC, manage groups and members in Settings -> Identity Management -> User Groups. This section now only keeps the plugin switch.', audit_logs_description: 'Tool authorization audit trail and security monitoring', audit_total_checks: 'Total Checks', audit_granted: 'Granted', diff --git a/web/src/locales/en/index.ts b/web/src/locales/en/index.ts index 424c0e52..d06fa599 100644 --- a/web/src/locales/en/index.ts +++ b/web/src/locales/en/index.ts @@ -1,11 +1,13 @@ import { ChatEn } from './chat'; import { CommonEn } from './common'; import { FlowEn } from './flow'; +import { PermissionsEn } from './permissions'; const en = { ...ChatEn, ...FlowEn, ...CommonEn, + ...PermissionsEn, }; export default en; diff --git a/web/src/locales/en/permissions.ts b/web/src/locales/en/permissions.ts new file mode 100644 index 00000000..c45f1283 --- /dev/null +++ b/web/src/locales/en/permissions.ts @@ -0,0 +1,251 @@ +export const PermissionsEn = { + // Page titles + permissions_title: 'RBAC', + permissions_role_management: 'Role Management', + permissions_role_management_hint: 'Manage system roles and their permission configurations', + permissions_page_hint: 'Role-based access control: manage roles, user authorization, custom permission policies, and OAuth users in one place. Role management creates and configures role permissions; User authorization assigns roles to users; Custom permissions define fine-grained policies.', + + // Actions + permissions_create_role: 'Create Role', + permissions_edit_role: 'Edit Role', + permissions_manage: 'Manage Permissions', + permissions_add_permission: 'Add Permission', + + // Table columns + permissions_col_name: 'Role Name', + permissions_col_description: 'Description', + permissions_col_permissions: 'Permissions', + permissions_col_actions: 'Actions', + permissions_col_user: 'User', + permissions_col_member_list: 'Member List', + permissions_col_members: 'Members', + + // Permission fields + permissions_resource_type: 'Resource Type', + permissions_resource_id: 'Resource ID', + permissions_action: 'Action', + permissions_resource_all: 'All', + permissions_resource_agent: 'Agent', + permissions_resource_knowledge: 'Knowledge', + permissions_resource_tool: 'Tool', + permissions_resource_model: 'Model', + permissions_resource_system: 'System', + permissions_resource_wildcard: 'All resource types', + permissions_effect: 'Effect', + permissions_effect_allow: 'Allow', + permissions_effect_deny: 'Deny', + permissions_action_read: 'View', + permissions_action_chat: 'Chat', + permissions_action_query: 'Query', + permissions_action_execute: 'Execute', + permissions_action_write: 'Edit', + permissions_action_manage: 'Manage', + permissions_action_admin: 'Admin', + + // Messages + permissions_load_error: 'Failed to load permission data', + permissions_role_created: 'Role created successfully', + permissions_role_updated: 'Role updated successfully', + permissions_role_deleted: 'Role deleted successfully', + permissions_permission_added: 'Permission added successfully', + permissions_permission_removed: 'Permission removed successfully', + permissions_create_error: 'Failed to create role', + permissions_update_error: 'Failed to update role', + permissions_delete_error: 'Failed to delete role', + permissions_add_permission_error: 'Failed to add permission', + permissions_remove_permission_error: 'Failed to remove permission', + permissions_system_role_cannot_delete: 'System roles cannot be deleted', + permissions_system_role_readonly: 'System roles are read-only and cannot be configured or edited', + permissions_name_required: 'Please enter a role name', + permissions_name_placeholder: 'Please enter a role name', + permissions_delete_confirm: 'Are you sure you want to delete this role?', + permissions_remove_permission_confirm: 'Are you sure you want to remove this permission?', + + // Status + permissions_system_role: 'System Role', + permissions_count: 'permissions', + permissions_empty: 'No permissions', + + // Common + edit: 'Edit', + delete: 'Delete', + add: 'Add', + cancel: 'Cancel', + confirm: 'Confirm', + close: 'Close', + + // Presets + permissions_preset_guest: 'Guest (Models & Monitoring Only)', + permissions_preset_viewer: 'Viewer (Read-only)', + permissions_preset_operator: 'Operator (Execute without Config)', + permissions_preset_editor: 'Editor (Read/Write/Execute)', + permissions_preset_admin: 'Admin (Full Access)', + permissions_preset_desc: 'Select a preset template to quickly configure permissions', + permissions_preset_apply: 'Apply Template', + view_details: 'View Details', + + // User Management + permissions_user_management: 'User Authorization', + permissions_keyword_placeholder: 'Search by name or email', + permissions_col_fullname: 'Full Name', + permissions_col_email: 'Email', + permissions_col_role: 'Legacy Role', + permissions_col_status: 'Status', + permissions_col_rbac_roles: 'RBAC Roles', + permissions_manage_roles: 'Manage Roles', + permissions_user_detail: 'User Detail', + permissions_load_users_error: 'Failed to load user list', + permissions_load_user_detail_error: 'Failed to load user detail', + permissions_load_roles_error: 'Failed to load role list', + permissions_admin_required: 'Admin permission required', + permissions_assign_roles_hint: 'Use the transfer below to assign roles to the user:', + permissions_available_roles: 'Available Roles', + permissions_assigned_roles: 'Assigned Roles', + permissions_current_roles: 'Current Roles:', + permissions_inherited_roles: 'Inherited Roles (from user groups):', + permissions_effective_permissions: 'Effective Permissions:', + permissions_no_roles: 'No roles', + permissions_no_permissions: 'No permissions', + permissions_roles_updated: 'Role assignment updated successfully', + permissions_roles_update_error: 'Failed to update roles', + permissions_no_changes: 'No changes to save', + permissions_load_agents_error: 'Failed to load agent list', + permissions_load_knowledge_error: 'Failed to load knowledge list', + permissions_load_models_error: 'Failed to load model list', + permissions_load_tools_error: 'Failed to load tool list', + permissions_select_resource: 'Select resource', + + // Scoped Resource Permissions + permissions_resource_scope: 'Resource Scope', + permissions_all_resources: 'All Resources', + permissions_specific_resources: 'Specific Resources', + permissions_agent_scoped: 'Agent Scoped', + permissions_tool_scoped: 'Tool Scoped', + permissions_knowledge_scoped: 'Knowledge Scoped', + permissions_model_scoped: 'Model Scoped', + permissions_wildcard: 'Wildcard', + permissions_wildcard_short: 'Wildcard', + permissions_scoped: 'Scoped', + permissions_scoped_short: 'Scoped', + permissions_add_scoped_permission: 'Add Scoped Permission', + permissions_resource_id_placeholder: 'Enter resource ID or select', + permissions_scoped_permission_hint: 'Bind this permission to a specific resource instance instead of all resources', + permissions_by_resource: 'By Resource Type', + from_group: 'from group', + system_role: 'system role', + + // Custom Permissions + permissions_custom_policy: 'Custom Policies', + permissions_create_custom: 'Create Custom Policy', + permissions_custom_target: 'Creation Type', + permissions_target_direct: 'Direct role permission', + permissions_target_definition: 'Permission definition template', + permissions_management: 'Permission Management', + permissions_custom_policy_hint: 'Create fine-grained permission policies to precisely control user access to specific resources.', + permissions_oauth_users: 'OAuth Users', + permissions_oauth_provider: 'OAuth Provider', + permissions_legacy_role: 'Account Role', + permissions_admin_user: 'Admin', + permissions_normal_user: 'Normal User', + permissions_set_admin: 'Set Admin', + permissions_unset_admin: 'Unset Admin', + permissions_user_enabled: 'User enabled', + permissions_user_disabled: 'User disabled', + permissions_user_deleted: 'User deleted', + permissions_cannot_delete_self: 'Cannot delete your own account', + permissions_operation_failed: 'Operation failed', + permissions_delete_user_confirm: 'Are you sure you want to delete this user?', + permissions_add_to_group: 'Add to Group', + permissions_add_authorization: 'Add Authorization', + permissions_select_groups: 'Select user groups', + permissions_user_groups_updated: 'User groups updated', + permissions_load_user_groups_error: 'Failed to load user groups', + permissions_remove_permission_confirm: 'Are you sure you want to remove this permission policy?', + permissions_custom_create_scoped: 'Create resource-level permission', + permissions_custom_add_preset_scope: 'Add preset scope permission', + permissions_section_preset_scope: 'Preset scope permissions', + permissions_section_preset_scope_hint: + 'Resource ID is wildcard * and applies to all resources of that type (similar to managed/preset policy scope).', + permissions_section_resource_scoped: 'Resource-specific permissions', + permissions_section_resource_scoped_hint: + 'Bound to a specific resource instance (e.g. one agent or knowledge base) for least privilege (similar to customer-managed policies).', + permissions_search_policies_placeholder: 'Search by name, resource type, ID, action, role', + permissions_col_policy_name: 'Policy name', + permissions_col_policy_desc: 'Description', + permissions_quick_templates: 'Quick templates', + permissions_modal_create_resource_scoped: 'Create resource-level permission', + permissions_modal_add_preset_scope: 'Add preset scope permission', + permissions_assign_to_role: 'Assign to role', + permissions_pick_role_placeholder: 'Select the role to grant', + permissions_role_pick_required: 'Please select a role', + permissions_action_required: 'Please select an action', + permissions_col_linked_role: 'Linked role', + permissions_resource_id_manual_hint: 'Enter resource ID manually when no list is available', + permissions_resource_pick_placeholder: 'Select or search a resource instance', + permissions_resource_multi_placeholder: 'Select multiple resources or all resources', + permissions_resource_multi_hint: 'Selecting multiple resources will create separate permission definitions for each', + permissions_resource_id_required: 'Select or enter a resource ID', + permissions_all_resources_tag: 'All resources', + permissions_tpl_agent_readonly: 'Agent read-only', + permissions_tpl_agent_readonly_desc: 'View agents only, no chat', + permissions_tpl_agent_chat: 'Agent chat', + permissions_tpl_agent_chat_desc: 'View and chat with agents', + permissions_tpl_tool_execute: 'Tool execute', + permissions_tpl_tool_execute_desc: 'View and execute tools', + permissions_tpl_knowledge_query: 'Knowledge query', + permissions_tpl_knowledge_query_desc: 'Query knowledge bases', + permissions_tpl_model_chat: 'Model chat', + permissions_tpl_model_chat_desc: 'Use models for chat', + permissions_tpl_system_readonly: 'System read-only', + permissions_tpl_system_readonly_desc: 'View system configuration', + permissions_action_all: 'All actions', + + // Permission Definition + permissions_definition_management: 'Permission Definitions', + permissions_create_definition: 'Create Permission Definition', + permissions_edit_definition: 'Edit Permission Definition', + permissions_definition_name: 'Permission Name', + permissions_definition_name_placeholder: 'e.g., agent_read_all', + permissions_definition_desc_placeholder: 'Describe the purpose of this permission', + permissions_definition_created: 'Permission definition created', + permissions_definition_updated: 'Permission definition updated', + permissions_definition_deleted: 'Permission definition deleted', + permissions_definition_hint: 'Permission Definitions', + permissions_definition_description: 'Create independent permission definitions that can be assigned to multiple roles. Similar to AWS IAM Policy concept.', + permissions_definition_tab: 'Permission Definitions', + permissions_search_definitions_placeholder: 'Search permission definitions', + permissions_remove_definition_confirm: 'Are you sure you want to delete this permission definition?', + permissions_name_required: 'Please enter a permission name', + permissions_active: 'Active', + permissions_inactive: 'Inactive', + permissions_status: 'Status', + permissions_from_definition_library: 'From Permission Library', + permissions_definition_library_hint: 'Select permissions from the permission definition library to assign to this role', + permissions_definition_library_desc: 'Permission definitions allow you to create reusable permission templates', + permissions_definition_library: 'Permission Templates', + permissions_definition_library_desc: 'Permission definitions allow you to create reusable permission templates', + permissions_no_definitions: 'No permission definitions available. Please create some in the "Permission Definitions" tab first.', + permissions_definition_assigned: 'Permission definitions assigned successfully', + permissions_save_definition_selection: 'Save Permission Selection', + permissions_assign_error: 'Failed to assign permission definitions', + permissions_source: 'Source', + permissions_direct_permission: 'Direct', + + // New UI - Identity & Permission Management + permissions_identity_management: 'Identity Management', + permissions_permission_management: 'Permission Management', + permissions_policies: 'Policies', + permissions_user_groups: 'User Groups', + permissions_user_groups_hint: 'User groups enable batch user permission management. When roles are assigned to a user group, all users in the group will inherit those roles.', + permissions_manage_members: 'Manage Members', + permissions_manage_roles: 'Manage Roles', + permissions_group_members: 'Group Members', + permissions_group_roles: 'Group Roles', + permissions_group_roles_hint: 'Assigning roles to a user group will make all users in that group inherit those roles.', + permissions_add_members: 'Add Members', + permissions_add_by_id_hint: 'Or enter user ID:', + permissions_remove_member_confirm: 'Are you sure you want to remove this member?', + permissions_user_id: 'User ID', + permissions_select_roles: 'Select roles', + permissions_feature_coming_soon: 'Feature coming soon', +}; \ No newline at end of file diff --git a/web/src/locales/zh/common.ts b/web/src/locales/zh/common.ts index ef435681..be903e48 100644 --- a/web/src/locales/zh/common.ts +++ b/web/src/locales/zh/common.ts @@ -7,6 +7,7 @@ interface Resources { } export const CommonZh: Resources['translation'] = { + logout: '退出登录', Knowledge_Space: '知识库', space: '知识库', Vector: '向量', @@ -999,6 +1000,7 @@ export const CommonZh: Resources['translation'] = { tool_execute_code_param: '代码内容', // Audit Logs (审计日志) audit_logs_title: '审计日志', + user_management: '用户管理', system_config: '系统配置', plugin_market: '插件市场', plugin_market_page_desc: '浏览并启用内置功能扩展;与技能市场 / Git 插件仓不同。', @@ -1051,6 +1053,9 @@ export const CommonZh: Resources['translation'] = { plugin_user_groups_add_by_id: '按 ID 加入', plugin_user_groups_invalid_user_id: '请输入有效的用户 ID(正整数)', plugin_user_groups_already_member: '该用户已在分组中', + plugin_user_groups_moved_title: '权限组管理入口已迁移', + plugin_user_groups_moved_desc: + '为避免与 RBAC 重复,分组与成员管理请前往「设置 → 身份管理 → 用户组」。本处仅保留插件开关。', audit_logs_description: '工具授权审计追踪与安全监控', audit_total_checks: '总检查次数', audit_granted: '已授权', diff --git a/web/src/locales/zh/index.ts b/web/src/locales/zh/index.ts index 65bb9cfc..7b6a7132 100644 --- a/web/src/locales/zh/index.ts +++ b/web/src/locales/zh/index.ts @@ -1,11 +1,13 @@ import { ChatZh } from './chat'; import { CommonZh } from './common'; import { FlowZn } from './flow'; +import { PermissionsZh } from './permissions'; const zh = { ...ChatZh, ...FlowZn, ...CommonZh, + ...PermissionsZh, }; export default zh; diff --git a/web/src/locales/zh/permissions.ts b/web/src/locales/zh/permissions.ts new file mode 100644 index 00000000..cc490fe2 --- /dev/null +++ b/web/src/locales/zh/permissions.ts @@ -0,0 +1,250 @@ +export const PermissionsZh = { + // Page titles + permissions_title: 'RBAC', + permissions_role_management: '角色管理', + permissions_role_management_hint: '管理系统角色及其权限配置', + permissions_page_hint: '基于角色的访问控制:在此统一管理角色、用户授权、自定义权限与 OAuth 用户。角色管理用于创建和配置角色权限;用户授权用于为用户分配角色;自定义权限用于创建细粒度的权限策略。', + + // Actions + permissions_create_role: '创建角色', + permissions_edit_role: '编辑角色', + permissions_manage: '配置权限', + permissions_add_permission: '添加权限', + + // Table columns + permissions_col_name: '角色名称', + permissions_col_description: '描述', + permissions_col_permissions: '权限数', + permissions_col_actions: '操作', + permissions_col_user: '用户', + permissions_col_member_list: '成员列表', + permissions_col_members: '成员数', + + // Permission fields + permissions_resource_type: '资源类型', + permissions_resource_id: '资源 ID', + permissions_action: '操作', + permissions_resource_all: '全部', + permissions_resource_agent: '智能体', + permissions_resource_knowledge: '知识库', + permissions_resource_tool: '工具', + permissions_resource_model: '模型', + permissions_resource_system: '系统', + permissions_resource_wildcard: '全部资源类型', + permissions_effect: '效果', + permissions_effect_allow: '允许', + permissions_effect_deny: '拒绝', + permissions_action_read: '查看', + permissions_action_chat: '对话', + permissions_action_query: '检索', + permissions_action_execute: '执行', + permissions_action_write: '编辑', + permissions_action_manage: '管理', + permissions_action_admin: '管理', + + // Messages + permissions_load_error: '加载权限数据失败', + permissions_role_created: '角色创建成功', + permissions_role_updated: '角色更新成功', + permissions_role_deleted: '角色删除成功', + permissions_permission_added: '权限添加成功', + permissions_permission_removed: '权限删除成功', + permissions_create_error: '创建角色失败', + permissions_update_error: '更新角色失败', + permissions_delete_error: '删除角色失败', + permissions_add_permission_error: '添加权限失败', + permissions_remove_permission_error: '删除权限失败', + permissions_system_role_cannot_delete: '系统角色不能删除', + permissions_system_role_readonly: '系统角色为只读,不允许配置或修改', + permissions_name_required: '请输入角色名称', + permissions_name_placeholder: '请输入角色名称', + permissions_delete_confirm: '确定要删除此角色吗?', + permissions_remove_permission_confirm: '确定要删除此权限吗?', + + // Status + permissions_system_role: '系统角色', + permissions_count: '个权限', + permissions_empty: '暂无权限', + + // Common + edit: '编辑', + delete: '删除', + add: '添加', + cancel: '取消', + confirm: '确定', + close: '关闭', + + // Presets + permissions_preset_guest: '访客(仅模型和监控)', + permissions_preset_viewer: '观察者(只读)', + permissions_preset_operator: '操作员(可执行不可配置)', + permissions_preset_editor: '编辑者(读写执行)', + permissions_preset_admin: '管理员(完全权限)', + permissions_preset_desc: '选择预设模板快速配置权限', + permissions_preset_apply: '应用模板', + view_details: '查看详情', + + // User Management + permissions_user_management: '用户授权', + permissions_keyword_placeholder: '搜索用户名或邮箱', + permissions_col_fullname: '全名', + permissions_col_email: '邮箱', + permissions_col_role: '旧版角色', + permissions_col_status: '状态', + permissions_col_rbac_roles: 'RBAC 角色', + permissions_manage_roles: '管理角色', + permissions_user_detail: '用户详情', + permissions_load_users_error: '加载用户列表失败', + permissions_load_user_detail_error: '加载用户详情失败', + permissions_load_roles_error: '加载角色列表失败', + permissions_admin_required: '需要管理员权限', + permissions_assign_roles_hint: '使用下方穿梭框为用户分配角色:', + permissions_available_roles: '可用角色', + permissions_assigned_roles: '已分配角色', + permissions_current_roles: '当前角色:', + permissions_inherited_roles: '继承角色(来自用户组):', + permissions_effective_permissions: '生效权限:', + permissions_no_roles: '暂无角色', + permissions_no_permissions: '暂无权限', + permissions_roles_updated: '角色分配更新成功', + permissions_roles_update_error: '更新角色失败', + permissions_no_changes: '没有需要保存的更改', + permissions_load_agents_error: '加载智能体列表失败', + permissions_load_knowledge_error: '加载知识库列表失败', + permissions_load_models_error: '加载模型列表失败', + permissions_load_tools_error: '加载工具列表失败', + permissions_select_resource: '请选择资源', + + // Scoped Resource Permissions + permissions_resource_scope: '资源范围', + permissions_all_resources: '所有资源', + permissions_specific_resources: '指定资源', + permissions_agent_scoped: '智能体范围', + permissions_tool_scoped: '工具范围', + permissions_knowledge_scoped: '知识库范围', + permissions_model_scoped: '模型范围', + permissions_wildcard: '通配符', + permissions_wildcard_short: '通配', + permissions_scoped: '范围权限', + permissions_scoped_short: '范围', + permissions_add_scoped_permission: '添加范围权限', + permissions_resource_id_placeholder: '输入资源 ID 或选择', + permissions_scoped_permission_hint: '将此权限绑定到特定资源实例,而非所有资源', + permissions_by_resource: '按资源类型', + from_group: '来自用户组', + system_role: '系统角色', + + // Custom Permissions + permissions_custom_policy: '自定义策略', + permissions_create_custom: '创建自定义策略', + permissions_custom_target: '创建类型', + permissions_target_direct: '直接分配给角色', + permissions_target_definition: '保存为权限定义模板', + permissions_management: '权限管理', + permissions_custom_policy_hint: '创建细粒度的权限策略,精确控制用户对特定资源的访问权限。', + permissions_oauth_users: 'OAuth 用户', + permissions_oauth_provider: 'OAuth 提供商', + permissions_legacy_role: '账号角色', + permissions_admin_user: '管理员', + permissions_normal_user: '普通用户', + permissions_set_admin: '设为管理员', + permissions_unset_admin: '取消管理员', + permissions_user_enabled: '用户已启用', + permissions_user_disabled: '用户已禁用', + permissions_user_deleted: '用户已删除', + permissions_cannot_delete_self: '不能删除自己的账号', + permissions_operation_failed: '操作失败', + permissions_delete_user_confirm: '确定要删除此用户吗?', + permissions_add_to_group: '添加到用户组', + permissions_add_authorization: '新增授权', + permissions_select_groups: '选择用户组', + permissions_user_groups_updated: '用户组更新成功', + permissions_load_user_groups_error: '加载用户组失败', + permissions_remove_permission_confirm: '确定要删除此权限策略吗?', + permissions_custom_create_scoped: '创建资源级权限', + permissions_custom_add_preset_scope: '添加预设范围权限', + permissions_section_preset_scope: '预设范围权限', + permissions_section_preset_scope_hint: + '资源 ID 为通配符 *,作用于该类型下的全部资源(类似云厂商的托管/预设策略范围)。', + permissions_section_resource_scoped: '资源级自定义权限', + permissions_section_resource_scoped_hint: + '绑定到具体资源实例(如某个智能体、知识库),用于最小权限与隔离(类似客户托管策略)。', + permissions_search_policies_placeholder: '搜索策略名、资源类型、资源 ID、操作、角色', + permissions_col_policy_name: '策略名称', + permissions_col_policy_desc: '描述', + permissions_quick_templates: '快速模板', + permissions_modal_create_resource_scoped: '创建资源级权限', + permissions_modal_add_preset_scope: '添加预设范围权限', + permissions_assign_to_role: '分配给角色', + permissions_pick_role_placeholder: '选择要授予权限的角色', + permissions_role_pick_required: '请选择角色', + permissions_action_required: '请选择操作', + permissions_col_linked_role: '关联角色', + permissions_resource_id_manual_hint: '无清单的资源类型请手动填写资源 ID', + permissions_resource_pick_placeholder: '选择或搜索资源实例', + permissions_resource_multi_placeholder: '选择多个资源或全部资源', + permissions_resource_multi_hint: '选择多个资源会为每个资源创建独立的权限定义', + permissions_resource_id_required: '请选择或填写资源 ID', + permissions_all_resources_tag: '全部资源', + permissions_tpl_agent_readonly: '智能体只读', + permissions_tpl_agent_readonly_desc: '只能查看智能体,不能对话', + permissions_tpl_agent_chat: '智能体对话', + permissions_tpl_agent_chat_desc: '可以查看和对话智能体', + permissions_tpl_tool_execute: '工具执行', + permissions_tpl_tool_execute_desc: '可以查看和执行工具', + permissions_tpl_knowledge_query: '知识库检索', + permissions_tpl_knowledge_query_desc: '可以检索知识库', + permissions_tpl_model_chat: '模型对话', + permissions_tpl_model_chat_desc: '可以使用模型对话', + permissions_tpl_system_readonly: '系统只读', + permissions_tpl_system_readonly_desc: '可以查看系统配置', + permissions_action_all: '全部操作', + + // Permission Definition (独立权限定义) + permissions_definition_management: '权限定义', + permissions_create_definition: '创建权限定义', + permissions_edit_definition: '编辑权限定义', + permissions_definition_name: '权限名称', + permissions_definition_name_placeholder: '例如: agent_read_all', + permissions_definition_desc_placeholder: '描述这个权限的用途', + permissions_definition_created: '权限定义创建成功', + permissions_definition_updated: '权限定义更新成功', + permissions_definition_deleted: '权限定义删除成功', + permissions_definition_hint: '独立权限定义', + permissions_definition_description: '创建独立的权限定义,可以分配给多个角色使用。类似于 AWS IAM Policy 的概念。', + permissions_definition_tab: '权限定义', + permissions_search_definitions_placeholder: '搜索权限定义', + permissions_remove_definition_confirm: '确定要删除这个权限定义吗?', + permissions_name_required: '请输入权限名称', + permissions_active: '启用', + permissions_inactive: '禁用', + permissions_status: '状态', + permissions_from_definition_library: '从权限库选择', + permissions_definition_library_hint: '从已创建的权限定义库中选择权限分配给当前角色', + permissions_definition_library_desc: '权限定义允许您创建可复用的权限模板', + permissions_definition_library: '权限模板库', + permissions_no_definitions: '暂无权限定义,请先在"权限定义"标签页创建', + permissions_definition_assigned: '权限定义分配成功', + permissions_save_definition_selection: '保存权限选择', + permissions_assign_error: '分配权限定义失败', + permissions_source: '来源', + permissions_direct_permission: '直接权限', + + // New UI - Identity & Permission Management + permissions_identity_management: '身份管理', + permissions_permission_management: '权限管理', + permissions_policies: '策略', + permissions_user_groups: '用户组', + permissions_user_groups_hint: '用户组用于批量管理用户权限。将角色分配给用户组后,该组内的所有用户将继承这些角色。', + permissions_manage_members: '管理成员', + permissions_manage_roles: '管理角色', + permissions_group_members: '组成员', + permissions_group_roles: '组角色', + permissions_group_roles_hint: '为用户组分配角色后,该组内所有用户将继承这些角色。', + permissions_add_members: '添加成员', + permissions_add_by_id_hint: '或输入用户 ID:', + permissions_remove_member_confirm: '确定要移除此成员吗?', + permissions_user_id: '用户 ID', + permissions_select_roles: '选择角色', + permissions_feature_coming_soon: '功能即将上线', +}; \ No newline at end of file diff --git a/web/src/services/auth.ts b/web/src/services/auth.ts index f7e218b4..44b6e4e6 100644 --- a/web/src/services/auth.ts +++ b/web/src/services/auth.ts @@ -1,4 +1,5 @@ import { ins as axios } from '@/client/api'; +import type { User } from '@/services/users'; const API_BASE = '/api/v1'; @@ -42,6 +43,37 @@ class AuthService { return response.data; } + /** Maps /auth/me to the users table shape (for user admin UI). */ + async getCurrentUser(): Promise { + try { + const me = await this.getMe(); + const u = me.user; + if (u == null || typeof u.id !== 'number') { + return null; + } + const extra = u as AuthUser & { + is_active?: number; + gmt_create?: string | null; + gmt_modify?: string | null; + }; + return { + id: u.id, + name: u.name ?? '', + fullname: u.fullname ?? '', + email: u.email ?? '', + avatar: u.avatar ?? me.avatar_url ?? '', + oauth_provider: u.oauth_provider ?? '', + oauth_id: u.oauth_id ?? '', + role: me.role ?? 'normal', + is_active: typeof extra.is_active === 'number' ? extra.is_active : 1, + gmt_create: extra.gmt_create ?? null, + gmt_modify: extra.gmt_modify ?? null, + }; + } catch { + return null; + } + } + async logout(): Promise { await axios.post(`${API_BASE}/auth/logout`); } diff --git a/web/src/services/config/index.ts b/web/src/services/config/index.ts index 4728505a..3d22f946 100644 --- a/web/src/services/config/index.ts +++ b/web/src/services/config/index.ts @@ -137,6 +137,7 @@ export interface OAuth2Config { enabled: boolean; providers: OAuth2ProviderConfig[]; admin_users?: string[]; + default_role?: string; } export interface SecretConfig { @@ -285,6 +286,7 @@ class ConfigService { enabled?: boolean; settings?: Record; }): Promise { + // When updating access_control, the backend will automatically update user_groups and permissions const response = await axios.post(`${API_BASE}/config/feature-plugins`, body); return response.data.data; } @@ -362,6 +364,53 @@ class ConfigService { } } +export interface KnowledgeSpace { + id: string | number; + name: string; + desc?: string; + vector_type?: string; + domain_type?: string; +} + +export interface CachedModels { + models: string[]; + model_keys: string[]; + total: number; +} + +class KnowledgeService { + async listSpaces(): Promise { + const response = await axios.post(`${API_BASE}/knowledge/space/list`, {}); + return response.data.data || []; + } +} + +export interface ModelData { + model_name: string; + worker_type: string; + host: string; + port: number; + healthy: boolean; +} + +class ModelService { + async listModels(): Promise { + const response = await axios.get(`${API_BASE}/config/model-cache/models`); + return response.data.data; + } + + async getAgentLLMConfig(): Promise { + const response = await axios.get(`${API_BASE}/config/current`); + return response.data.data.agent_llm; + } + + // 从模型管理服务获取模型列表(与模型管理界面一致) + async getModelList(): Promise { + const response = await axios.get('/api/v2/serve/model/models'); + return response.data.data || []; + } +} + class ToolsService { async listTools(): Promise { const response = await axios.get(`${API_BASE}/tools/list`); @@ -409,4 +458,6 @@ class ToolsService { } export const configService = new ConfigService(); +export const knowledgeService = new KnowledgeService(); +export const modelService = new ModelService(); export const toolsService = new ToolsService(); \ No newline at end of file diff --git a/web/src/services/permissions.ts b/web/src/services/permissions.ts new file mode 100644 index 00000000..aae7c0d8 --- /dev/null +++ b/web/src/services/permissions.ts @@ -0,0 +1,355 @@ +import { ins as axios } from '@/client/api'; +import type { AxiosError } from 'axios'; + +const API_BASE = '/api/v1'; + +export interface Role { + id: number; + name: string; + description: string; + is_system: number; + gmt_create?: string | null; + gmt_modify?: string | null; +} + +export interface Permission { + id: number; + role_id: number; + resource_type: string; + resource_id: string; + action: string; + effect: string; + gmt_create?: string | null; +} + +export interface RoleCreateBody { + name: string; + description?: string; +} + +export interface RoleUpdateBody { + name?: string; + description?: string; +} + +export interface PermissionAddBody { + resource_type: string; + resource_id?: string; + action: string; + effect?: string; +} + +export interface UserRoleAssignBody { + role_id: number; +} + +export interface GroupRolesRow { + id: number; + role_id: number; + role_name: string; +} + +export interface UserRolesRow { + id: number; + role_id: number; + role_name: string; +} + +export interface UserInfo { + id: number; + name: string; + fullname: string; + email: string; + // 注意:不再使用旧版 role 字段,以 RBAC roles 为准 + is_active: number; + roles: string[]; + gmt_create?: string | null; +} + +export interface ScopedPermission { + resource_type: string; + resource_id: string; + action: string; + effect: string; +} + +export interface PermissionDefinition { + id: number; + name: string; + description: string; + resource_type: string; + resource_id: string; + action: string; + effect: string; + is_active: boolean; + gmt_create?: string | null; + gmt_modify?: string | null; +} + +export interface PermissionDefinitionCreateBody { + name: string; + description?: string; + resource_type: string; + resource_id?: string; + action: string; + effect?: string; +} + +export interface PermissionDefinitionUpdateBody { + name?: string; + description?: string; + resource_type?: string; + resource_id?: string; + action?: string; + effect?: string; + is_active?: boolean; +} + +export interface UserPermissionsResponse { + user_id: number; + roles: string[]; + permissions: Record; + }>; +} + +export interface UserDetail extends UserInfo { + direct_roles: Role[]; + group_roles: Role[]; + all_roles: string[]; + effective_permissions: Record; +} + +export interface UserListResponse { + items: UserInfo[]; + total: number; + page: number; + page_size: number; +} + +function isNotFound(err: unknown): boolean { + return Boolean( + err && + typeof err === 'object' && + 'response' in err && + (err as AxiosError).response?.status === 404, + ); +} + +class PermissionsService { + // ========== Role Management ========== + async listRoles(): Promise { + try { + const res = await axios.get(`${API_BASE}/permissions/roles`); + return (res.data?.data ?? []) as Role[]; + } catch (e) { + if (isNotFound(e)) throw new Error('NOT_MOUNTED'); + throw e; + } + } + + async createRole(body: RoleCreateBody): Promise { + const res = await axios.post(`${API_BASE}/permissions/roles`, body); + return res.data.data as Role; + } + + async getRole(roleId: number): Promise { + const res = await axios.get(`${API_BASE}/permissions/roles/${roleId}`); + return res.data.data as Role; + } + + async updateRole(roleId: number, body: RoleUpdateBody): Promise { + const res = await axios.put(`${API_BASE}/permissions/roles/${roleId}`, body); + return res.data.data as Role; + } + + async deleteRole(roleId: number): Promise { + await axios.delete(`${API_BASE}/permissions/roles/${roleId}`); + } + + // ========== Role Permission Management ========== + async listRolePermissions(roleId: number): Promise { + const res = await axios.get(`${API_BASE}/permissions/roles/${roleId}/permissions`); + return (res.data?.data ?? []) as Permission[]; + } + + async addRolePermission(roleId: number, body: PermissionAddBody): Promise { + const res = await axios.post(`${API_BASE}/permissions/roles/${roleId}/permissions`, body); + return res.data.data as Permission; + } + + async removeRolePermission(roleId: number, permissionId: number): Promise { + await axios.delete(`${API_BASE}/permissions/roles/${roleId}/permissions/${permissionId}`); + } + + // ========== User Role Assignment ========== + async listUserRoles(userId: number): Promise { + const res = await axios.get(`${API_BASE}/permissions/users/${userId}/roles`); + return (res.data?.data ?? []) as UserRolesRow[]; + } + + async assignRoleToUser(userId: number, roleId: number): Promise { + await axios.post(`${API_BASE}/permissions/users/${userId}/roles`, { role_id: roleId }); + } + + async removeUserRole(userId: number, roleId: number): Promise { + await axios.delete(`${API_BASE}/permissions/users/${userId}/roles/${roleId}`); + } + + // ========== Group Role Assignment ========== + async listGroupRoles(groupId: number): Promise { + const res = await axios.get(`${API_BASE}/permissions/groups/${groupId}/roles`); + return (res.data?.data ?? []) as GroupRolesRow[]; + } + + async assignRoleToGroup(groupId: number, roleId: number): Promise { + await axios.post(`${API_BASE}/permissions/groups/${groupId}/roles`, { role_id: roleId }); + } + + async removeGroupRole(groupId: number, roleId: number): Promise { + await axios.delete(`${API_BASE}/permissions/groups/${groupId}/roles/${roleId}`); + } + + // ========== User Management ========== + async listUsers( + page: number = 1, + pageSize: number = 20, + keyword: string = '', + ): Promise { + const res = await axios.get(`${API_BASE}/permissions/users`, { + params: { page, page_size: pageSize, keyword }, + }); + return res.data.data as UserListResponse; + } + + async getUserDetail(userId: number): Promise { + const res = await axios.get(`${API_BASE}/permissions/users/${userId}`); + return res.data.data as UserDetail; + } + + async getUserEffectivePermissions( + userId: number, + ): Promise<{ roles: string[]; permissions: Record }> { + const res = await axios.get( + `${API_BASE}/permissions/users/${userId}/effective-permissions`, + ); + return res.data.data as { roles: string[]; permissions: Record }; + } + + async batchAssignRoles(userId: number, roleIds: number[]): Promise<{ + assigned: number[]; + errors: string[]; + }> { + const res = await axios.post(`${API_BASE}/permissions/users/${userId}/roles/batch`, { + role_ids: roleIds, + }); + return res.data.data as { assigned: number[]; errors: string[] }; + } + + async batchRemoveRoles(userId: number, roleIds: number[]): Promise<{ + removed: number[]; + }> { + const res = await axios.post( + `${API_BASE}/permissions/users/${userId}/roles/batch-remove`, + { role_ids: roleIds }, + ); + return res.data.data as { removed: number[] }; + } + + // ========== Scoped Resource Permissions ========== + async listScopedPermissions(params?: { + role_id?: number; + resource_type?: string; + resource_id?: string; + }): Promise { + const res = await axios.get(`${API_BASE}/permissions/scoped/list`, { params }); + return (res.data?.data ?? []) as Permission[]; + } + + async grantScopedPermission(params: { + role_id: number; + resource_type: string; + resource_id: string; + action: string; + effect?: string; + }): Promise { + const res = await axios.post(`${API_BASE}/permissions/scoped`, params); + return res.data.data as Permission; + } + + async revokeScopedPermission(params: { + role_id: number; + resource_type: string; + resource_id: string; + action: string; + }): Promise { + await axios.delete(`${API_BASE}/permissions/scoped`, { params }); + } + + async getUserPermissions(userId: number): Promise { + const res = await axios.get(`${API_BASE}/permissions/users/${userId}/permissions`); + return res.data.data as UserPermissionsResponse; + } + + // ========== Permission Definitions ========== + async listPermissionDefinitions(params?: { + resource_type?: string; + action?: string; + is_active?: boolean; + }): Promise { + const res = await axios.get(`${API_BASE}/permissions/definitions`, { params }); + return (res.data?.data ?? []) as PermissionDefinition[]; + } + + async createPermissionDefinition( + body: PermissionDefinitionCreateBody, + ): Promise { + const res = await axios.post(`${API_BASE}/permissions/definitions`, body); + return res.data.data as PermissionDefinition; + } + + async getPermissionDefinition(id: number): Promise { + const res = await axios.get(`${API_BASE}/permissions/definitions/${id}`); + return res.data.data as PermissionDefinition; + } + + async updatePermissionDefinition( + id: number, + body: PermissionDefinitionUpdateBody, + ): Promise { + const res = await axios.put(`${API_BASE}/permissions/definitions/${id}`, body); + return res.data.data as PermissionDefinition; + } + + async deletePermissionDefinition(id: number): Promise { + await axios.delete(`${API_BASE}/permissions/definitions/${id}`); + } + + async getRolePermissionDefs(roleId: number): Promise { + const res = await axios.get(`${API_BASE}/permissions/roles/${roleId}/permission-defs`); + return (res.data?.data ?? []) as PermissionDefinition[]; + } + + async addPermissionDefToRole( + roleId: number, + permissionDefId: number, + ): Promise<{ id: number; role_id: number; permission_def_id: number }> { + const res = await axios.post(`${API_BASE}/permissions/roles/${roleId}/permission-defs`, { + permission_def_id: permissionDefId, + }); + return res.data.data as { id: number; role_id: number; permission_def_id: number }; + } + + async removePermissionDefFromRole( + roleId: number, + permissionDefId: number, + ): Promise { + await axios.delete( + `${API_BASE}/permissions/roles/${roleId}/permission-defs/${permissionDefId}`, + ); + } +} + +export const permissionsService = new PermissionsService(); +export { isNotFound }; \ No newline at end of file diff --git a/web/src/services/users.ts b/web/src/services/users.ts index 9d40d4e3..e762452c 100644 --- a/web/src/services/users.ts +++ b/web/src/services/users.ts @@ -63,6 +63,10 @@ class UsersService { const res = await axios.patch(`${API_BASE}/users/${id}`, patch); return res.data.data as User; } + + async deleteUser(id: number): Promise { + await axios.delete(`${API_BASE}/users/${id}`); + } } export const usersService = new UsersService();