Skip to content

Commit ffbfd92

Browse files
niiish32xnishenghao.nsh
andauthored
feat: plugin market, user_groups, and unified admin/auth for feature toggles (#170)
Co-authored-by: nishenghao.nsh <nishenghao.nsh@oceanbase.com>
1 parent c7b626d commit ffbfd92

24 files changed

Lines changed: 1544 additions & 5 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ def mount_routers(app: FastAPI, param: Optional[ApplicationConfig] = None):
134134
app.include_router(streaming_config_router, tags=["Streaming Config"])
135135
logger.info("[Streaming] Config API routes registered at /api/v1/streaming-config")
136136

137+
from derisk_app.feature_plugins.bootstrap import register_enabled_feature_plugin_routers
138+
139+
register_enabled_feature_plugin_routers(app)
140+
137141

138142
def mount_static_files(app: FastAPI, param: ApplicationConfig):
139143
if param.service.web.new_web_ui:
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Builtin feature plugins (official catalog + optional routers)."""
2+
3+
from derisk_app.feature_plugins.catalog import (
4+
FeaturePluginManifest,
5+
get_manifest,
6+
list_manifests,
7+
merge_catalog_with_state,
8+
)
9+
10+
__all__ = [
11+
"FeaturePluginManifest",
12+
"get_manifest",
13+
"list_manifests",
14+
"merge_catalog_with_state",
15+
]
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Register routers for enabled builtin feature plugins at process startup."""
2+
3+
import logging
4+
5+
from fastapi import FastAPI
6+
7+
logger = logging.getLogger(__name__)
8+
9+
10+
def register_enabled_feature_plugin_routers(app: FastAPI) -> None:
11+
"""Conditionally mount plugin HTTP routes (requires restart after toggling plugins)."""
12+
try:
13+
from derisk_core.config import ConfigManager, FeaturePluginEntry
14+
15+
cfg = ConfigManager.get()
16+
except Exception as e:
17+
logger.warning("Feature plugins: skip router registration (config unavailable): %s", e)
18+
return
19+
20+
raw = getattr(cfg, "feature_plugins", None) or {}
21+
22+
def _enabled(plugin_id: str) -> bool:
23+
entry = raw.get(plugin_id)
24+
if entry is None:
25+
return False
26+
if isinstance(entry, FeaturePluginEntry):
27+
return bool(entry.enabled)
28+
if isinstance(entry, dict):
29+
return bool(entry.get("enabled"))
30+
return False
31+
32+
if _enabled("user_groups"):
33+
from derisk_app.feature_plugins.user_groups.api import router as user_groups_router
34+
35+
app.include_router(user_groups_router, prefix="/api/v1")
36+
logger.info("Feature plugin mounted: user_groups at /api/v1/user-groups")
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Code-defined catalog for builtin feature plugins (metadata + JSON Schema hints)."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from typing import Any, Dict, List, Optional, Union
7+
8+
9+
@dataclass(frozen=True)
10+
class FeaturePluginManifest:
11+
"""Static manifest shipped with the product; not stored in derisk.json."""
12+
13+
id: str
14+
title: str
15+
description: str
16+
category: str
17+
requires_restart: bool = True
18+
# Optional JSON Schema object for plugin-specific settings (UI forms).
19+
settings_schema: Optional[Dict[str, Any]] = None
20+
# When True, recommend OAuth2 + admin_users for write operations (enforced in API when configured).
21+
suggest_oauth2_admin: bool = True
22+
23+
24+
_MANIFESTS: Dict[str, FeaturePluginManifest] = {
25+
"user_groups": FeaturePluginManifest(
26+
id="user_groups",
27+
title="用户权限组",
28+
description=(
29+
"面向登录用户的分组与成员管理(RBAC 数据面);"
30+
"后续可基于权限组对 Agent、工具等做访问控制。"
31+
),
32+
category="access_control",
33+
requires_restart=True,
34+
settings_schema=None,
35+
suggest_oauth2_admin=True,
36+
),
37+
}
38+
39+
40+
def list_manifests() -> List[FeaturePluginManifest]:
41+
return list(_MANIFESTS.values())
42+
43+
44+
def get_manifest(plugin_id: str) -> Optional[FeaturePluginManifest]:
45+
return _MANIFESTS.get(plugin_id)
46+
47+
48+
def is_known_plugin(plugin_id: str) -> bool:
49+
return plugin_id in _MANIFESTS
50+
51+
52+
def _entry_enabled_and_settings(
53+
entry: Optional[Union[Dict[str, Any], Any]],
54+
) -> tuple[bool, Dict[str, Any]]:
55+
"""Normalize FeaturePluginEntry, dict, or None."""
56+
if entry is None:
57+
return False, {}
58+
if isinstance(entry, dict):
59+
return bool(entry.get("enabled")), dict(entry.get("settings") or {})
60+
enabled = bool(getattr(entry, "enabled", False))
61+
raw = getattr(entry, "settings", None) or {}
62+
return enabled, dict(raw) if isinstance(raw, dict) else {}
63+
64+
65+
def merge_catalog_with_state(
66+
feature_plugins: Dict[str, Any],
67+
) -> List[Dict[str, Any]]:
68+
"""Merge manifests with persisted enabled/settings for API responses."""
69+
out: List[Dict[str, Any]] = []
70+
for m in list_manifests():
71+
en, st = _entry_enabled_and_settings(feature_plugins.get(m.id))
72+
out.append(
73+
{
74+
"id": m.id,
75+
"title": m.title,
76+
"description": m.description,
77+
"category": m.category,
78+
"requires_restart": m.requires_restart,
79+
"settings_schema": m.settings_schema,
80+
"suggest_oauth2_admin": m.suggest_oauth2_admin,
81+
"enabled": en,
82+
"settings": st,
83+
}
84+
)
85+
return out
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""User groups plugin (RBAC-style grouping for logged-in users).
2+
3+
Import ``derisk_app.feature_plugins.user_groups.api`` for the FastAPI router;
4+
this package ``__init__`` stays lightweight so ORM models can load without
5+
registering routes (e.g. during DB migrations).
6+
"""
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""HTTP API for user groups (only mounted when feature plugin user_groups is enabled)."""
2+
3+
from typing import List, Optional
4+
5+
from fastapi import APIRouter, Depends, HTTPException
6+
from pydantic import BaseModel, Field
7+
from sqlalchemy.exc import IntegrityError
8+
9+
from derisk_serve.utils.auth import UserRequest, get_user_from_headers
10+
from derisk_app.feature_plugins.user_groups.service import UserGroupService
11+
12+
router = APIRouter(prefix="/user-groups", tags=["UserGroups"])
13+
14+
_svc = UserGroupService()
15+
16+
17+
class GroupCreateBody(BaseModel):
18+
name: str = Field(..., min_length=1, max_length=128)
19+
description: Optional[str] = Field(None, max_length=2000)
20+
21+
22+
class GroupUpdateBody(BaseModel):
23+
name: Optional[str] = Field(None, min_length=1, max_length=128)
24+
description: Optional[str] = Field(None, max_length=2000)
25+
26+
27+
class MembersAddBody(BaseModel):
28+
user_ids: List[int] = Field(..., min_length=1)
29+
30+
31+
@router.get("/groups")
32+
async def list_groups(_user: UserRequest = Depends(get_user_from_headers)):
33+
groups = _svc.list_groups()
34+
for g in groups:
35+
g["member_count"] = _svc.count_members(g["id"])
36+
return {"success": True, "data": groups}
37+
38+
39+
@router.post("/groups")
40+
async def create_group(
41+
body: GroupCreateBody,
42+
_user: UserRequest = Depends(get_user_from_headers),
43+
):
44+
try:
45+
g = _svc.create_group(body.name, body.description)
46+
return {"success": True, "data": g}
47+
except IntegrityError:
48+
raise HTTPException(status_code=409, detail="Group name already exists")
49+
50+
51+
@router.get("/groups/{group_id}")
52+
async def get_group(
53+
group_id: int,
54+
_user: UserRequest = Depends(get_user_from_headers),
55+
):
56+
g = _svc.get_group(group_id)
57+
if not g:
58+
raise HTTPException(status_code=404, detail="Group not found")
59+
g["member_count"] = _svc.count_members(group_id)
60+
return {"success": True, "data": g}
61+
62+
63+
@router.put("/groups/{group_id}")
64+
async def update_group(
65+
group_id: int,
66+
body: GroupUpdateBody,
67+
_user: UserRequest = Depends(get_user_from_headers),
68+
):
69+
try:
70+
g = _svc.update_group(group_id, name=body.name, description=body.description)
71+
if not g:
72+
raise HTTPException(status_code=404, detail="Group not found")
73+
return {"success": True, "data": g}
74+
except IntegrityError:
75+
raise HTTPException(status_code=409, detail="Group name already exists")
76+
77+
78+
@router.delete("/groups/{group_id}")
79+
async def delete_group(
80+
group_id: int,
81+
_user: UserRequest = Depends(get_user_from_headers),
82+
):
83+
ok = _svc.delete_group(group_id)
84+
if not ok:
85+
raise HTTPException(status_code=404, detail="Group not found")
86+
return {"success": True, "data": None}
87+
88+
89+
@router.get("/groups/{group_id}/members")
90+
async def list_members(
91+
group_id: int,
92+
_user: UserRequest = Depends(get_user_from_headers),
93+
):
94+
if not _svc.get_group(group_id):
95+
raise HTTPException(status_code=404, detail="Group not found")
96+
members = _svc.list_members(group_id)
97+
return {"success": True, "data": members}
98+
99+
100+
@router.post("/groups/{group_id}/members")
101+
async def add_members(
102+
group_id: int,
103+
body: MembersAddBody,
104+
_user: UserRequest = Depends(get_user_from_headers),
105+
):
106+
if not _svc.get_group(group_id):
107+
raise HTTPException(status_code=404, detail="Group not found")
108+
added, _ = _svc.add_members(group_id, body.user_ids)
109+
return {"success": True, "data": {"added": added}}
110+
111+
112+
@router.delete("/groups/{group_id}/members/{member_user_id}")
113+
async def remove_member(
114+
group_id: int,
115+
member_user_id: int,
116+
_user: UserRequest = Depends(get_user_from_headers),
117+
):
118+
ok = _svc.remove_member(group_id, member_user_id)
119+
if not ok:
120+
raise HTTPException(status_code=404, detail="Membership not found")
121+
return {"success": True, "data": None}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""ORM models for user_group / user_group_member tables."""
2+
3+
from datetime import datetime
4+
5+
from sqlalchemy import Column, DateTime, Integer, String, Text, UniqueConstraint
6+
7+
from derisk.storage.metadata import Model
8+
9+
10+
class UserGroupEntity(Model):
11+
__tablename__ = "user_group"
12+
13+
id = Column(Integer, primary_key=True, autoincrement=True)
14+
name = Column(String(128), nullable=False, unique=True, comment="Group name")
15+
description = Column(Text, nullable=True, comment="Description")
16+
gmt_create = Column(DateTime, default=datetime.utcnow, nullable=False)
17+
gmt_modify = Column(
18+
DateTime,
19+
default=datetime.utcnow,
20+
onupdate=datetime.utcnow,
21+
nullable=False,
22+
)
23+
24+
25+
class UserGroupMemberEntity(Model):
26+
__tablename__ = "user_group_member"
27+
__table_args__ = (
28+
UniqueConstraint("group_id", "user_id", name="uk_user_group_member"),
29+
)
30+
31+
id = Column(Integer, primary_key=True, autoincrement=True)
32+
group_id = Column(Integer, nullable=False, index=True, comment="user_group.id")
33+
user_id = Column(Integer, nullable=False, index=True, comment="user.id")
34+
gmt_create = Column(DateTime, default=datetime.utcnow, nullable=False)
35+
gmt_modify = Column(
36+
DateTime,
37+
default=datetime.utcnow,
38+
onupdate=datetime.utcnow,
39+
nullable=False,
40+
)

0 commit comments

Comments
 (0)