Skip to content

Commit d0e8d67

Browse files
niiish32xnishenghao.nsh
andauthored
feat(rbac): harden authorization model and align RBAC UX with RAM-s… (#191)
Co-authored-by: nishenghao.nsh <nishenghao.nsh@oceanbase.com>
1 parent 4931047 commit d0e8d67

58 files changed

Lines changed: 10000 additions & 290 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

assets/schema/oauth2_config.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS `oauth2_config` (
88
`enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'OAuth2 enabled flag',
99
`providers_json` TEXT NULL COMMENT 'OAuth2 providers configuration (JSON array)',
1010
`admin_users_json` TEXT NULL COMMENT 'Admin users list (JSON array)',
11+
`default_role` VARCHAR(32) NULL DEFAULT 'viewer' COMMENT 'Default RBAC role for new OAuth2 users',
1112
`gmt_create` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Create time',
1213
`gmt_modify` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Modify time',
1314
PRIMARY KEY (`id`),

assets/schema/upgrade_oauth2_config.sql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,14 @@ CREATE TABLE IF NOT EXISTS `oauth2_config` (
66
`enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'OAuth2 enabled flag',
77
`providers_json` TEXT NULL COMMENT 'OAuth2 providers configuration (JSON array)',
88
`admin_users_json` TEXT NULL COMMENT 'Admin users list (JSON array)',
9+
`default_role` VARCHAR(32) NULL DEFAULT 'viewer' COMMENT 'Default RBAC role for new OAuth2 users',
910
`gmt_create` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Create time',
1011
`gmt_modify` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Modify time',
1112
PRIMARY KEY (`id`),
1213
UNIQUE KEY `uk_config_key` (`config_key`)
1314
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
1415
COMMENT='OAuth2 configuration storage (client_secret masked on display)';
16+
17+
-- Migration: Add default_role column if table already exists (for existing deployments)
18+
ALTER TABLE `oauth2_config`
19+
ADD COLUMN IF NOT EXISTS `default_role` VARCHAR(32) NULL DEFAULT 'viewer' COMMENT 'Default RBAC role for new OAuth2 users' AFTER `admin_users_json`;

docs/rbac_system_roles.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# RBAC 系统角色说明(当前实现)
2+
3+
本文档说明 OpenDerisk 当前内置(系统)角色的职责边界。
4+
系统角色由 `permissions/seed.py` 初始化,`is_system=1`,默认不可删除、不可修改、不可重新配置权限。
5+
6+
## 1. 角色清单
7+
8+
当前系统内置 5 个角色:
9+
10+
- `guest`
11+
- `viewer`
12+
- `operator`
13+
- `editor`
14+
- `admin`
15+
16+
## 2. 各角色功能说明
17+
18+
### `guest`(访客)
19+
20+
- 目标:提供最小可用访问能力
21+
- 允许:`model:read``model:chat`
22+
- 不允许:智能体、工具、知识库相关权限
23+
- 典型场景:只使用模型对话,不参与平台配置
24+
25+
### `viewer`(只读观察者)
26+
27+
- 目标:全局只读可见
28+
- 允许:`agent/tool/knowledge/model``read`
29+
- 不允许:对话、执行、编辑、管理
30+
- 典型场景:审计、查看、巡检
31+
32+
### `operator`(操作员)
33+
34+
- 目标:可操作运行态能力,但不改配置
35+
- 允许:
36+
- `agent:read/chat`
37+
- `tool:read/execute`
38+
- `knowledge:read/query`
39+
- `model:read/chat`
40+
- 不允许:`write/manage/admin`
41+
- 典型场景:值班、日常操作、问题排查
42+
43+
### `editor`(编辑者)
44+
45+
- 目标:可管理业务资源配置,但不具备系统级管理
46+
- 允许:
47+
- `agent:read/chat/write`
48+
- `tool:read/execute/manage`
49+
- `knowledge:read/query/write`
50+
- `model:read/chat/manage`
51+
- 不允许:`system:admin`(系统级管理)
52+
- 典型场景:应用配置维护、资源管理
53+
54+
### `admin`(管理员)
55+
56+
- 目标:平台完全管理
57+
- 允许:
58+
- `agent/tool/knowledge/model` 全能力(含 `admin`
59+
- `system:admin`
60+
- 典型场景:平台管理员、权限管理员
61+
62+
## 3. 变更约束(本次规则)
63+
64+
为防止误操作,系统角色新增只读保护:
65+
66+
- 前端:系统角色不再展示“配置权限/编辑/删除”操作入口
67+
- 后端:针对系统角色,以下接口写操作会被拒绝(HTTP 400)
68+
- 更新角色信息
69+
- 增删角色权限(含资源级权限)
70+
- 增删角色关联的权限定义
71+
72+
## 4. 推荐使用方式
73+
74+
- 需要个性化权限时,请新建“自定义角色”
75+
- 系统角色建议作为权限基线模板使用,不直接改动
76+
- 生产环境优先采用“用户组 + 角色”分配,减少逐用户授权的维护成本

packages/derisk-app/src/derisk_app/auth/user_service.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,17 @@ def create_or_update_from_oauth(
7979
oauth_id: str,
8080
user_info: Dict[str, Any],
8181
role: str = "normal",
82+
rbac_default_role: str = "viewer",
8283
) -> Dict[str, Any]:
8384
"""Create or update user from OAuth user info, return plain dict.
8485
86+
Args:
87+
provider: OAuth provider ID
88+
oauth_id: OAuth provider user ID
89+
user_info: User info from OAuth provider
90+
role: Legacy role ("admin" or "normal")
91+
rbac_default_role: Default RBAC role to assign to new users (e.g., "viewer", "guest")
92+
8593
Returns a dict instead of the ORM entity to avoid DetachedInstanceError
8694
after the session closes.
8795
"""
@@ -131,6 +139,30 @@ def create_or_update_from_oauth(
131139
session.add(user)
132140
session.commit()
133141
session.refresh(user)
142+
143+
# 自动为新用户分配配置的默认角色
144+
try:
145+
from derisk_app.feature_plugins.permissions.dao import PermissionDao
146+
147+
dao = PermissionDao()
148+
default_role = dao.get_role_by_name(rbac_default_role)
149+
if default_role:
150+
dao.assign_role_to_user(user.id, default_role["id"])
151+
logger.info(
152+
f"Auto-assigned {rbac_default_role} role to new OAuth2 user: {user.id} ({user.name})"
153+
)
154+
else:
155+
# Fallback to viewer if configured role doesn't exist
156+
viewer_role = dao.get_role_by_name("viewer")
157+
if viewer_role:
158+
dao.assign_role_to_user(user.id, viewer_role["id"])
159+
logger.warning(
160+
f"Configured default role '{rbac_default_role}' not found, "
161+
f"fallback to viewer for new OAuth2 user: {user.id} ({user.name})"
162+
)
163+
except Exception as e:
164+
logger.warning(f"Failed to auto-assign default role: {e}")
165+
134166
return _entity_to_dict(user)
135167

136168
def list_users(
@@ -176,6 +208,24 @@ def update_user(
176208
session.refresh(user)
177209
return _entity_to_dict(user)
178210

211+
def delete_user(self, user_id: int) -> bool:
212+
"""Soft delete user by setting is_active=0.
213+
214+
Args:
215+
user_id: User ID to delete
216+
217+
Returns:
218+
True if user was found and deleted, False otherwise
219+
"""
220+
with self.session() as session:
221+
user = session.query(UserEntity).filter(UserEntity.id == user_id).first()
222+
if not user:
223+
return False
224+
user.is_active = 0
225+
session.commit()
226+
logger.info(f"User {user_id} ({user.name}) soft deleted")
227+
return True
228+
179229

180230
class UserService:
181231
"""Service for user operations."""
@@ -189,11 +239,16 @@ def get_or_create_from_oauth(
189239
oauth_id: str,
190240
user_info: Dict[str, Any],
191241
role: str = "normal",
242+
rbac_default_role: str = "viewer",
192243
) -> Optional[Dict[str, Any]]:
193244
"""Get or create user from OAuth info, return user dict for session."""
194245
try:
195246
return self._dao.create_or_update_from_oauth(
196-
provider, oauth_id, user_info, role=role
247+
provider,
248+
oauth_id,
249+
user_info,
250+
role=role,
251+
rbac_default_role=rbac_default_role,
197252
)
198253
except Exception as e:
199254
logger.exception(f"Failed to get/create user from OAuth: {e}")
@@ -229,3 +284,18 @@ def update_user(
229284
except Exception as e:
230285
logger.exception(f"Failed to update user {user_id}: {e}")
231286
return None
287+
288+
def delete_user(self, user_id: int) -> bool:
289+
"""Delete user (soft delete).
290+
291+
Args:
292+
user_id: User ID to delete
293+
294+
Returns:
295+
True if successful, False otherwise
296+
"""
297+
try:
298+
return self._dao.delete_user(user_id)
299+
except Exception as e:
300+
logger.exception(f"Failed to delete user {user_id}: {e}")
301+
return False

packages/derisk-app/src/derisk_app/config_storage/oauth2_db_storage.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ class OAuth2ConfigEntity(Model):
4343
nullable=True,
4444
comment="Admin users list (JSON array)",
4545
)
46+
default_role = Column(
47+
String(32),
48+
nullable=True,
49+
default="viewer",
50+
comment="Default RBAC role for new OAuth2 users",
51+
)
4652
gmt_create = Column(DateTime, nullable=True)
4753
gmt_modify = Column(DateTime, nullable=True)
4854

@@ -54,6 +60,7 @@ def to_dict(self) -> Dict[str, Any]:
5460
"enabled": bool(self.enabled),
5561
"providers_json": self.providers_json,
5662
"admin_users_json": self.admin_users_json,
63+
"default_role": self.default_role or "viewer",
5764
}
5865

5966

@@ -115,6 +122,7 @@ def save_or_update(
115122
enabled: bool,
116123
providers: List[Dict[str, Any]],
117124
admin_users: List[str],
125+
default_role: str = "viewer",
118126
config_key: str = "global",
119127
) -> OAuth2ConfigEntity:
120128
"""Save or update OAuth2 config (stored in plain text, mask on display)."""
@@ -143,13 +151,15 @@ def save_or_update(
143151
entity.enabled = 1 if enabled else 0
144152
entity.providers_json = providers_json
145153
entity.admin_users_json = admin_users_json
154+
entity.default_role = default_role
146155
entity.gmt_modify = datetime.utcnow()
147156
else:
148157
entity = OAuth2ConfigEntity(
149158
config_key=config_key,
150159
enabled=1 if enabled else 0,
151160
providers_json=providers_json,
152161
admin_users_json=admin_users_json,
162+
default_role=default_role,
153163
gmt_create=datetime.utcnow(),
154164
gmt_modify=datetime.utcnow(),
155165
)
@@ -203,6 +213,7 @@ def get_config(
203213
enabled = bool(entity.enabled)
204214
admin_users_json = entity.admin_users_json or "[]"
205215
providers_json = entity.providers_json or "[]"
216+
default_role = entity.default_role or "viewer"
206217

207218
try:
208219
admin_users = json.loads(admin_users_json) if admin_users_json else []
@@ -222,6 +233,7 @@ def get_config(
222233
"enabled": enabled,
223234
"providers": providers,
224235
"admin_users": admin_users,
236+
"default_role": default_role,
225237
}
226238

227239
def get_config_with_secrets(
@@ -252,15 +264,21 @@ def load_with_secrets(self) -> Optional[Dict[str, Any]]:
252264
return self.dao.get_config_with_secrets("global")
253265

254266
def save(
255-
self, enabled: bool, providers: List[Dict], admin_users: List[str]
267+
self,
268+
enabled: bool,
269+
providers: List[Dict],
270+
admin_users: List[str],
271+
default_role: str = "viewer",
256272
) -> bool:
257273
"""Save OAuth2 config to database."""
258274
try:
259-
self.dao.save_or_update(enabled, providers, admin_users, "global")
275+
self.dao.save_or_update(
276+
enabled, providers, admin_users, default_role, "global"
277+
)
260278
return True
261279
except Exception as e:
262280
logger.exception(f"Failed to save OAuth2 config: {e}")
263-
return False
281+
raise # Re-raise to let caller handle the error
264282

265283

266284
# Singleton instance

packages/derisk-app/src/derisk_app/feature_plugins/bootstrap.py

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,71 @@
99

1010
def register_enabled_feature_plugin_routers(app: FastAPI) -> None:
1111
"""Conditionally mount plugin HTTP routes (requires restart after toggling plugins)."""
12+
# Try to load from database first
1213
try:
13-
from derisk_core.config import ConfigManager, FeaturePluginEntry
14+
from derisk_app.feature_plugins.system_config_dao import SystemConfigDao
1415

15-
cfg = ConfigManager.get()
16+
dao = SystemConfigDao()
17+
raw = dao.get_all_configs("feature_plugin")
18+
logger.info(f"Loaded feature plugins from database: {raw}")
1619
except Exception as e:
17-
logger.warning("Feature plugins: skip router registration (config unavailable): %s", e)
18-
return
20+
logger.warning(f"Feature plugins: failed to load from database: {e}")
21+
raw = {}
1922

20-
raw = getattr(cfg, "feature_plugins", None) or {}
23+
# Fall back to config file if database is empty
24+
if not raw:
25+
try:
26+
from derisk_core.config import ConfigManager, FeaturePluginEntry
27+
28+
cfg = ConfigManager.get()
29+
raw_cfg = getattr(cfg, "feature_plugins", None) or {}
30+
raw = {
31+
k: v.model_dump(mode="json") if hasattr(v, "model_dump") else dict(v)
32+
for k, v in raw_cfg.items()
33+
}
34+
except Exception as e:
35+
logger.warning("Feature plugins: skip router registration (config unavailable): %s", e)
36+
return
2137

2238
def _enabled(plugin_id: str) -> bool:
2339
entry = raw.get(plugin_id)
2440
if entry is None:
2541
return False
26-
if isinstance(entry, FeaturePluginEntry):
27-
return bool(entry.enabled)
2842
if isinstance(entry, dict):
2943
return bool(entry.get("enabled"))
3044
return False
3145

32-
if _enabled("user_groups"):
33-
from derisk_app.feature_plugins.user_groups.api import router as user_groups_router
46+
# Check if access_control (unified permission system) is enabled
47+
# This enables both user_groups and permissions together
48+
access_control_enabled = _enabled("access_control")
49+
50+
# Also support legacy individual plugin flags for backward compatibility
51+
user_groups_enabled = _enabled("user_groups") or access_control_enabled
52+
permissions_enabled = _enabled("permissions") or access_control_enabled
53+
54+
if user_groups_enabled:
55+
from derisk_app.feature_plugins.user_groups.api import (
56+
router as user_groups_router,
57+
)
3458

3559
app.include_router(user_groups_router, prefix="/api/v1")
3660
logger.info("Feature plugin mounted: user_groups at /api/v1/user-groups")
61+
62+
if permissions_enabled:
63+
from derisk_app.feature_plugins.permissions.api import (
64+
router as permissions_router,
65+
)
66+
67+
app.include_router(permissions_router, prefix="/api/v1")
68+
logger.info("Feature plugin mounted: permissions at /api/v1/permissions")
69+
70+
from derisk_app.feature_plugins.permissions.seed import ensure_default_roles
71+
from derisk.storage.metadata.db_manager import db
72+
73+
# Ensure permission tables exist before seeding data
74+
try:
75+
db.create_all()
76+
except Exception as e:
77+
logger.warning(f"Failed to create all tables: {e}")
78+
79+
ensure_default_roles()

0 commit comments

Comments
 (0)