Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions assets/schema/oauth2_config.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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`),
Expand Down
5 changes: 5 additions & 0 deletions assets/schema/upgrade_oauth2_config.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
76 changes: 76 additions & 0 deletions docs/rbac_system_roles.md
Original file line number Diff line number Diff line change
@@ -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. 推荐使用方式

- 需要个性化权限时,请新建“自定义角色”
- 系统角色建议作为权限基线模板使用,不直接改动
- 生产环境优先采用“用户组 + 角色”分配,减少逐用户授权的维护成本
72 changes: 71 additions & 1 deletion packages/derisk-app/src/derisk_app/auth/user_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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."""
Expand All @@ -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}")
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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",
}


Expand Down Expand Up @@ -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)."""
Expand Down Expand Up @@ -143,13 +151,15 @@ 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(
config_key=config_key,
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(),
)
Expand Down Expand Up @@ -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 []
Expand All @@ -222,6 +233,7 @@ def get_config(
"enabled": enabled,
"providers": providers,
"admin_users": admin_users,
"default_role": default_role,
}

def get_config_with_secrets(
Expand Down Expand Up @@ -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
Expand Down
61 changes: 52 additions & 9 deletions packages/derisk-app/src/derisk_app/feature_plugins/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading
Loading