Skip to content

Commit c734463

Browse files
committed
feat: Add option to delete or keep API keys when uninstalling plugin
Add user-facing option to manage credentials during plugin deletion. Users can now choose to delete or keep API keys/credentials when uninstalling a plugin. Backend changes: - Add PluginCredentialService for credential management - Add check_plugin_credentials() method to PluginService - Modify uninstall() to accept delete_credentials and credential_ids params - Add GET /plugin/uninstall/check-credentials endpoint - Update POST /plugin/uninstall endpoint with credential options Frontend changes: - Update plugin action component with credential checking - Add radio button UI for delete/keep credential options - Update plugin detail panel with same functionality - Add checkPluginCredentials() and update uninstallPlugin() in service layer - Add internationalization for credential management UI Tests: - Add 26 backend tests (pytest) covering service and API layers - Add 15 frontend tests (jest) covering UI and service integration - Test coverage includes: credential retrieval, deletion, API endpoints, state management, user flow, and edge cases - All tests follow project conventions with proper mocking Fixes #27531
1 parent 20403c6 commit c734463

File tree

15 files changed

+2161
-23
lines changed

15 files changed

+2161
-23
lines changed

api/controllers/console/workspace/plugin.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -526,8 +526,38 @@ def post(self):
526526
raise ValueError(e)
527527

528528

529+
parser_check_credentials = reqparse.RequestParser().add_argument(
530+
"plugin_installation_id", type=str, required=True, location="args"
531+
)
532+
533+
534+
@console_ns.route("/workspaces/current/plugin/uninstall/check-credentials")
535+
class PluginUninstallCheckCredentialsApi(Resource):
536+
@api.expect(parser_check_credentials)
537+
@setup_required
538+
@login_required
539+
@account_initialization_required
540+
@plugin_permission_required(install_required=True)
541+
def get(self):
542+
"""Check if plugin has associated credentials before uninstall."""
543+
args = parser_check_credentials.parse_args()
544+
545+
_, tenant_id = current_account_with_tenant()
546+
547+
try:
548+
credentials = PluginService.check_plugin_credentials(tenant_id, args["plugin_installation_id"])
549+
return {
550+
"has_credentials": len(credentials) > 0,
551+
"credentials": [cred.to_dict() for cred in credentials],
552+
}
553+
except PluginDaemonClientSideError as e:
554+
raise ValueError(e)
555+
556+
529557
parser_uninstall = reqparse.RequestParser().add_argument(
530558
"plugin_installation_id", type=str, required=True, location="json"
559+
).add_argument("delete_credentials", type=bool, required=False, default=False, location="json").add_argument(
560+
"credential_ids", type=list, required=False, location="json"
531561
)
532562

533563

@@ -544,7 +574,13 @@ def post(self):
544574
_, tenant_id = current_account_with_tenant()
545575

546576
try:
547-
return {"success": PluginService.uninstall(tenant_id, args["plugin_installation_id"])}
577+
result = PluginService.uninstall(
578+
tenant_id,
579+
args["plugin_installation_id"],
580+
delete_credentials=args.get("delete_credentials", False),
581+
credential_ids=args.get("credential_ids", []),
582+
)
583+
return {"success": result}
548584
except PluginDaemonClientSideError as e:
549585
raise ValueError(e)
550586

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""Service for managing plugin credentials during installation and uninstallation."""
2+
3+
import logging
4+
from collections.abc import Sequence
5+
6+
from sqlalchemy import select
7+
8+
from extensions.ext_database import db
9+
from models.provider import ProviderCredential, ProviderModelCredential
10+
from models.provider_ids import GenericProviderID
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
class PluginCredentialInfo:
16+
"""Information about a plugin credential."""
17+
18+
def __init__(self, credential_id: str, credential_name: str, credential_type: str, provider_name: str):
19+
self.credential_id = credential_id
20+
self.credential_name = credential_name
21+
self.credential_type = credential_type # "provider" or "model"
22+
self.provider_name = provider_name
23+
24+
def to_dict(self) -> dict:
25+
return {
26+
"credential_id": self.credential_id,
27+
"credential_name": self.credential_name,
28+
"credential_type": self.credential_type,
29+
"provider_name": self.provider_name,
30+
}
31+
32+
33+
class PluginCredentialService:
34+
"""Service for managing plugin credentials."""
35+
36+
@staticmethod
37+
def get_plugin_credentials(tenant_id: str, plugin_id: str) -> list[PluginCredentialInfo]:
38+
"""
39+
Get all credentials associated with a plugin.
40+
41+
Args:
42+
tenant_id: Tenant ID
43+
plugin_id: Plugin ID in format "organization/plugin_name"
44+
45+
Returns:
46+
List of PluginCredentialInfo objects
47+
"""
48+
logger.info(f"Getting credentials for plugin_id: {plugin_id}, tenant_id: {tenant_id}")
49+
credentials = []
50+
51+
# Query provider credentials
52+
provider_credentials = db.session.scalars(
53+
select(ProviderCredential).where(
54+
ProviderCredential.tenant_id == tenant_id,
55+
ProviderCredential.provider_name.like(f"{plugin_id}/%"),
56+
)
57+
).all()
58+
logger.info(f"Found {len(provider_credentials)} provider credentials")
59+
60+
for cred in provider_credentials:
61+
credentials.append(
62+
PluginCredentialInfo(
63+
credential_id=cred.id,
64+
credential_name=cred.credential_name,
65+
credential_type="provider",
66+
provider_name=cred.provider_name,
67+
)
68+
)
69+
70+
# Query provider model credentials
71+
model_credentials = db.session.scalars(
72+
select(ProviderModelCredential).where(
73+
ProviderModelCredential.tenant_id == tenant_id,
74+
ProviderModelCredential.provider_name.like(f"{plugin_id}/%"),
75+
)
76+
).all()
77+
78+
for cred in model_credentials:
79+
credentials.append(
80+
PluginCredentialInfo(
81+
credential_id=cred.id,
82+
credential_name=cred.credential_name,
83+
credential_type="model",
84+
provider_name=cred.provider_name,
85+
)
86+
)
87+
88+
return credentials
89+
90+
@staticmethod
91+
def delete_plugin_credentials(tenant_id: str, credential_ids: Sequence[str]) -> int:
92+
"""
93+
Delete plugin credentials by IDs.
94+
95+
Args:
96+
tenant_id: Tenant ID
97+
credential_ids: List of credential IDs to delete
98+
99+
Returns:
100+
Number of credentials deleted
101+
"""
102+
logger.info(f"Deleting credentials: {credential_ids} for tenant: {tenant_id}")
103+
deleted_count = 0
104+
105+
for credential_id in credential_ids:
106+
# Try deleting from provider_credentials
107+
provider_cred = db.session.scalars(
108+
select(ProviderCredential).where(
109+
ProviderCredential.id == credential_id, ProviderCredential.tenant_id == tenant_id
110+
)
111+
).first()
112+
113+
if provider_cred:
114+
db.session.delete(provider_cred)
115+
deleted_count += 1
116+
continue
117+
118+
# Try deleting from provider_model_credentials
119+
model_cred = db.session.scalars(
120+
select(ProviderModelCredential).where(
121+
ProviderModelCredential.id == credential_id, ProviderModelCredential.tenant_id == tenant_id
122+
)
123+
).first()
124+
125+
if model_cred:
126+
db.session.delete(model_cred)
127+
deleted_count += 1
128+
129+
db.session.commit()
130+
logger.info(f"Deleted {deleted_count} plugin credentials for tenant {tenant_id}")
131+
return deleted_count
132+
133+
@staticmethod
134+
def delete_all_plugin_credentials(tenant_id: str, plugin_id: str) -> int:
135+
"""
136+
Delete all credentials associated with a plugin.
137+
138+
Args:
139+
tenant_id: Tenant ID
140+
plugin_id: Plugin ID in format "organization/plugin_name"
141+
142+
Returns:
143+
Number of credentials deleted
144+
"""
145+
credentials = PluginCredentialService.get_plugin_credentials(tenant_id, plugin_id)
146+
credential_ids = [cred.credential_id for cred in credentials]
147+
return PluginCredentialService.delete_plugin_credentials(tenant_id, credential_ids)
148+

api/services/plugin/plugin_service.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22
from collections.abc import Mapping, Sequence
33
from mimetypes import guess_type
4+
from typing import Optional
45

56
from pydantic import BaseModel
67
from yarl import URL
@@ -504,7 +505,79 @@ def install_from_marketplace_pkg(tenant_id: str, plugin_unique_identifiers: Sequ
504505
)
505506

506507
@staticmethod
507-
def uninstall(tenant_id: str, plugin_installation_id: str) -> bool:
508+
def check_plugin_credentials(tenant_id: str, plugin_installation_id: str):
509+
"""
510+
Check if a plugin has associated credentials.
511+
512+
Args:
513+
tenant_id: Tenant ID
514+
plugin_installation_id: Plugin installation ID
515+
516+
Returns:
517+
List of PluginCredentialInfo objects
518+
"""
519+
from services.plugin.plugin_credential_service import PluginCredentialService
520+
521+
# Get plugin installation to extract plugin_id
522+
manager = PluginInstaller()
523+
plugins = manager.list_plugins(tenant_id)
524+
plugin = next((p for p in plugins if p.installation_id == plugin_installation_id), None)
525+
526+
if not plugin:
527+
return []
528+
529+
plugin_id = plugin.plugin_id
530+
return PluginCredentialService.get_plugin_credentials(tenant_id, plugin_id)
531+
532+
@staticmethod
533+
def uninstall(
534+
tenant_id: str,
535+
plugin_installation_id: str,
536+
delete_credentials: bool = False,
537+
credential_ids: Optional[Sequence[str]] = None,
538+
) -> bool:
539+
"""
540+
Uninstall a plugin and optionally delete its credentials.
541+
542+
Args:
543+
tenant_id: Tenant ID
544+
plugin_installation_id: Plugin installation ID
545+
delete_credentials: Whether to delete associated credentials
546+
credential_ids: Specific credential IDs to delete (if None, deletes all)
547+
548+
Returns:
549+
True if successful
550+
"""
551+
import logging
552+
from services.plugin.plugin_credential_service import PluginCredentialService
553+
554+
logger = logging.getLogger(__name__)
555+
logger.info(
556+
f"Uninstalling plugin {plugin_installation_id}, delete_credentials={delete_credentials}, "
557+
f"credential_ids={credential_ids}"
558+
)
559+
560+
# Get plugin information before uninstalling
561+
if delete_credentials:
562+
manager = PluginInstaller()
563+
plugins = manager.list_plugins(tenant_id)
564+
plugin = next((p for p in plugins if p.installation_id == plugin_installation_id), None)
565+
566+
if plugin:
567+
plugin_id = plugin.plugin_id
568+
logger.info(f"Plugin found: {plugin_id}, proceeding with credential deletion")
569+
if credential_ids:
570+
# Delete specific credentials
571+
logger.info(f"Deleting specific credentials: {credential_ids}")
572+
PluginCredentialService.delete_plugin_credentials(tenant_id, credential_ids)
573+
else:
574+
# Delete all plugin credentials
575+
logger.info(f"Deleting all credentials for plugin: {plugin_id}")
576+
PluginCredentialService.delete_all_plugin_credentials(tenant_id, plugin_id)
577+
else:
578+
logger.warning(f"Plugin not found: {plugin_installation_id}")
579+
580+
# Uninstall the plugin
508581
manager = PluginInstaller()
509582
return manager.uninstall(tenant_id, plugin_installation_id)
510583

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Workspace controller tests
2+

0 commit comments

Comments
 (0)