Skip to content

Commit 7de9cf4

Browse files
Curryfactory-droid[bot]
andcommitted
feat(plugins): unify workspace plugin center
Add workspace-scoped lifecycle, permission gates, capability provenance, and Plugin Center UI integration while preserving metadata-only Dify import semantics. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1 parent 02342ae commit 7de9cf4

32 files changed

Lines changed: 1421 additions & 381 deletions

‎backend/api/v1/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
v1_router.include_router(plan_ir.router)
6161
v1_router.include_router(plans.router)
6262
v1_router.include_router(plugins.router)
63+
v1_router.include_router(plugins.workspace_router)
6364
v1_router.include_router(presets.router)
6465
v1_router.include_router(providers.router)
6566
v1_router.include_router(sources.router)

‎backend/api/v1/plugins.py‎

Lines changed: 179 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,45 @@
22

33
from __future__ import annotations
44

5-
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
5+
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
66
from sqlalchemy.ext.asyncio import AsyncSession
77

88
from backend.api.v1.dify_imports import get_dify_graphon_client
99
from backend.database import get_db
1010
from backend.plugins.capability_catalog import build_plugin_node_catalog
1111
from backend.plugins.dify_package import MAX_COMPRESSED_BYTES, DifyPackageError
1212
from backend.schemas.common import ApiResponse
13-
from backend.schemas.plugin import PluginInstallationRead, PluginNodeCatalogRead
13+
from backend.schemas.plugin import (
14+
PluginInstallationRead,
15+
PluginInstallationUpdate,
16+
PluginNodeCatalogRead,
17+
)
18+
from backend.security.identity import RequestIdentity, get_request_identity
19+
from backend.security.workspace_rbac import (
20+
WorkspacePermission,
21+
get_workspace_access,
22+
require_permission,
23+
)
1424
from backend.services.plugin_registry_service import (
1525
PluginRegistryError,
1626
delete_plugin_installation,
1727
get_plugin_installation,
1828
import_dify_plugin,
1929
list_plugin_installations,
30+
update_plugin_installation,
2031
)
2132
from backend.workflow.dify_graphon_client import DifyGraphonClient
2233

2334
router = APIRouter(prefix="/plugins", tags=["plugins"])
35+
workspace_router = APIRouter(prefix="/workspaces", tags=["plugins"])
36+
37+
38+
def require_platform_admin(
39+
identity: RequestIdentity = Depends(get_request_identity),
40+
) -> RequestIdentity:
41+
if not identity.is_platform_admin:
42+
raise HTTPException(status.HTTP_403_FORBIDDEN, "Platform Admin required")
43+
return identity
2444

2545

2646
@router.get("", response_model=ApiResponse[list[PluginInstallationRead]])
@@ -79,8 +99,10 @@ async def get_plugin(
7999
)
80100
async def import_dify_plugin_file(
81101
file: UploadFile = File(...),
102+
identity: RequestIdentity = Depends(require_platform_admin),
82103
db: AsyncSession = Depends(get_db),
83104
) -> ApiResponse[PluginInstallationRead]:
105+
del identity
84106
filename = file.filename or "manifest.yaml"
85107
content = await file.read(MAX_COMPRESSED_BYTES + 1)
86108
if len(content) > MAX_COMPRESSED_BYTES:
@@ -106,15 +128,170 @@ async def import_dify_plugin_file(
106128
@router.delete("/{installation_id}", response_model=ApiResponse[None])
107129
async def delete_plugin(
108130
installation_id: str,
131+
identity: RequestIdentity = Depends(require_platform_admin),
109132
db: AsyncSession = Depends(get_db),
110133
) -> ApiResponse[None]:
134+
del identity
111135
try:
112136
await delete_plugin_installation(db, installation_id)
113137
except PluginRegistryError as exc:
114138
raise _registry_http_error(exc) from exc
115139
return ApiResponse.ok(None)
116140

117141

142+
@workspace_router.get(
143+
"/{workspace_id}/plugins",
144+
response_model=ApiResponse[list[PluginInstallationRead]],
145+
)
146+
async def list_workspace_plugins(
147+
workspace_id: str,
148+
identity: RequestIdentity = Depends(get_request_identity),
149+
db: AsyncSession = Depends(get_db),
150+
graphon_client: DifyGraphonClient = Depends(get_dify_graphon_client),
151+
) -> ApiResponse[list[PluginInstallationRead]]:
152+
access = await get_workspace_access(db, workspace_id, identity)
153+
require_permission(access, WorkspacePermission.READ)
154+
return ApiResponse.ok(
155+
await list_plugin_installations(
156+
db,
157+
workspace_id=workspace_id,
158+
dify_runtime_ready=await graphon_client.is_healthy(),
159+
)
160+
)
161+
162+
163+
@workspace_router.get(
164+
"/{workspace_id}/plugins/capabilities",
165+
response_model=ApiResponse[PluginNodeCatalogRead],
166+
)
167+
async def list_workspace_plugin_capabilities(
168+
workspace_id: str,
169+
identity: RequestIdentity = Depends(get_request_identity),
170+
db: AsyncSession = Depends(get_db),
171+
graphon_client: DifyGraphonClient = Depends(get_dify_graphon_client),
172+
) -> ApiResponse[PluginNodeCatalogRead]:
173+
access = await get_workspace_access(db, workspace_id, identity)
174+
require_permission(access, WorkspacePermission.READ)
175+
installations = await list_plugin_installations(
176+
db,
177+
workspace_id=workspace_id,
178+
dify_runtime_ready=await graphon_client.is_healthy(),
179+
)
180+
return ApiResponse.ok(build_plugin_node_catalog(installations))
181+
182+
183+
@workspace_router.get(
184+
"/{workspace_id}/plugins/{installation_id}",
185+
response_model=ApiResponse[PluginInstallationRead],
186+
)
187+
async def get_workspace_plugin(
188+
workspace_id: str,
189+
installation_id: str,
190+
identity: RequestIdentity = Depends(get_request_identity),
191+
db: AsyncSession = Depends(get_db),
192+
graphon_client: DifyGraphonClient = Depends(get_dify_graphon_client),
193+
) -> ApiResponse[PluginInstallationRead]:
194+
access = await get_workspace_access(db, workspace_id, identity)
195+
require_permission(access, WorkspacePermission.READ)
196+
installation = await get_plugin_installation(
197+
db,
198+
installation_id,
199+
workspace_id=workspace_id,
200+
dify_runtime_ready=await graphon_client.is_healthy(),
201+
)
202+
if installation is None:
203+
raise _registry_http_error(
204+
PluginRegistryError(
205+
"plugin_installation_not_found",
206+
"Plugin installation not found.",
207+
status_code=404,
208+
)
209+
)
210+
return ApiResponse.ok(installation)
211+
212+
213+
@workspace_router.post(
214+
"/{workspace_id}/plugins/import/dify",
215+
response_model=ApiResponse[PluginInstallationRead],
216+
status_code=status.HTTP_201_CREATED,
217+
)
218+
async def import_workspace_dify_plugin_file(
219+
workspace_id: str,
220+
file: UploadFile = File(...),
221+
identity: RequestIdentity = Depends(get_request_identity),
222+
db: AsyncSession = Depends(get_db),
223+
) -> ApiResponse[PluginInstallationRead]:
224+
access = await get_workspace_access(db, workspace_id, identity)
225+
require_permission(access, WorkspacePermission.MANAGE_CONFIGURATION)
226+
filename = file.filename or "manifest.yaml"
227+
content = await file.read(MAX_COMPRESSED_BYTES + 1)
228+
if len(content) > MAX_COMPRESSED_BYTES:
229+
raise HTTPException(
230+
status_code=413,
231+
detail={
232+
"code": "dify_plugin_package_too_large",
233+
"message": "The uploaded plugin package exceeds the 50 MiB compressed limit.",
234+
},
235+
)
236+
try:
237+
installation = await import_dify_plugin(
238+
db, filename=filename, content=content, workspace_id=workspace_id
239+
)
240+
except DifyPackageError as exc:
241+
raise HTTPException(
242+
status_code=422,
243+
detail={"code": exc.code, "message": exc.message},
244+
) from exc
245+
except PluginRegistryError as exc:
246+
raise _registry_http_error(exc) from exc
247+
return ApiResponse.ok(installation)
248+
249+
250+
@workspace_router.patch(
251+
"/{workspace_id}/plugins/{installation_id}",
252+
response_model=ApiResponse[PluginInstallationRead],
253+
)
254+
async def update_workspace_plugin(
255+
workspace_id: str,
256+
installation_id: str,
257+
payload: PluginInstallationUpdate,
258+
identity: RequestIdentity = Depends(get_request_identity),
259+
db: AsyncSession = Depends(get_db),
260+
) -> ApiResponse[PluginInstallationRead]:
261+
access = await get_workspace_access(db, workspace_id, identity)
262+
require_permission(access, WorkspacePermission.MANAGE_CONFIGURATION)
263+
try:
264+
installation = await update_plugin_installation(
265+
db,
266+
installation_id,
267+
workspace_id=workspace_id,
268+
enabled=payload.enabled,
269+
granted_permissions=payload.granted_permissions,
270+
)
271+
except PluginRegistryError as exc:
272+
raise _registry_http_error(exc) from exc
273+
return ApiResponse.ok(installation)
274+
275+
276+
@workspace_router.delete(
277+
"/{workspace_id}/plugins/{installation_id}",
278+
response_model=ApiResponse[None],
279+
)
280+
async def delete_workspace_plugin(
281+
workspace_id: str,
282+
installation_id: str,
283+
identity: RequestIdentity = Depends(get_request_identity),
284+
db: AsyncSession = Depends(get_db),
285+
) -> ApiResponse[None]:
286+
access = await get_workspace_access(db, workspace_id, identity)
287+
require_permission(access, WorkspacePermission.MANAGE_CONFIGURATION)
288+
try:
289+
await delete_plugin_installation(db, installation_id, workspace_id=workspace_id)
290+
except PluginRegistryError as exc:
291+
raise _registry_http_error(exc) from exc
292+
return ApiResponse.ok(None)
293+
294+
118295
def _registry_http_error(error: PluginRegistryError) -> HTTPException:
119296
return HTTPException(
120297
status_code=error.status_code,

‎backend/api/v1/studio_helpers.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from sqlalchemy.ext.asyncio import AsyncSession
77

88
from backend.api.v1.studio_schemas import ValidationRunRead
9+
from backend.models.identity import Workspace as GovernedWorkspace
910
from backend.models.studio import (
1011
StudioProject,
1112
StudioWorkflow,
@@ -59,9 +60,18 @@ def validation_projection(row: StudioWorkflowValidationRun) -> ValidationRunRead
5960
],
6061
)
6162

62-
6363
async def get_workspace(db: AsyncSession, workspace_id: str) -> StudioWorkspace:
6464
row = await db.get(StudioWorkspace, workspace_id)
65+
if row is None:
66+
governed = await db.get(GovernedWorkspace, workspace_id)
67+
if governed is not None and governed.active:
68+
row = StudioWorkspace(
69+
id=governed.id,
70+
name=governed.name,
71+
slug=f"governed-{governed.slug}",
72+
)
73+
db.add(row)
74+
await db.flush()
6575
if row is None or not row.active:
6676
raise HTTPException(status.HTTP_404_NOT_FOUND, "Workspace not found")
6777
return row

‎backend/api/v1/workspaces.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from sqlalchemy import func, select
33
from sqlalchemy.ext.asyncio import AsyncSession
44

5+
from backend.api.v1.studio_projects import bootstrap_project
6+
from backend.api.v1.studio_schemas import ProjectBootstrapCreate
57
from backend.database import get_db
68
from backend.models.identity import Team, User, Workspace, WorkspaceMembership, WorkspaceRole
79
from backend.models.workflow import Project
@@ -152,6 +154,20 @@ async def list_governance_projects(
152154
.all()
153155
)
154156
return ApiResponse.ok([ProjectRead.model_validate(row) for row in rows])
157+
@router.post(
158+
"/governance/workspaces/{workspace_id}/projects/bootstrap",
159+
response_model=ApiResponse,
160+
status_code=201,
161+
)
162+
async def bootstrap_governance_project(
163+
workspace_id: str,
164+
body: ProjectBootstrapCreate,
165+
identity: RequestIdentity = Depends(get_request_identity),
166+
db: AsyncSession = Depends(get_db),
167+
) -> ApiResponse:
168+
access = await get_workspace_access(db, workspace_id, identity)
169+
require_permission(access, WorkspacePermission.MANAGE_CONFIGURATION)
170+
return await bootstrap_project(workspace_id, body, db)
155171

156172

157173
@router.post(

0 commit comments

Comments
 (0)