feat: per-credential visibility control for plugin credentials (#35468)

Co-authored-by: Yang <yang@Yangs-MacBook-Pro.local>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
YungLe 2026-06-01 14:56:18 +09:00 committed by GitHub
parent 687a177b24
commit 86497045c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 1282 additions and 125 deletions

3
.gitignore vendored
View File

@ -259,3 +259,6 @@ scripts/stress-test/reports/
.qoder/*
.context/
.eslintcache
# Vitest local reports
web/.vitest-reports/

View File

@ -198,12 +198,13 @@ class DatasourceAuth(Resource):
def get(self, provider_id: str):
datasource_provider_id = DatasourceProviderID(provider_id)
datasource_provider_service = DatasourceProviderService()
_, current_tenant_id = current_account_with_tenant()
user, current_tenant_id = current_account_with_tenant()
datasources = datasource_provider_service.list_datasource_credentials(
tenant_id=current_tenant_id,
provider=datasource_provider_id.provider_name,
plugin_id=datasource_provider_id.plugin_id,
user=user,
)
return {"result": datasources}, 200

View File

@ -18,7 +18,7 @@ from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs.helper import uuid_value
from libs.login import login_required
from libs.login import current_account_with_tenant, login_required
from services.model_load_balancing_service import ModelLoadBalancingService
from services.model_provider_service import ModelProviderService
@ -292,9 +292,14 @@ class ModelProviderModelCredentialApi(Resource):
)
if args.config_from == "predefined-model":
# Only the predefined-model branch needs visibility filtering by user.
# Defer the auth lookup so the other branch (and its tests) doesn't
# require flask-login setup.
user, _ = current_account_with_tenant()
available_credentials = model_provider_service.get_provider_available_credentials(
tenant_id=tenant_id,
provider=provider,
user=user,
)
else:
available_credentials = model_provider_service.get_provider_model_available_credentials(

View File

@ -69,6 +69,7 @@ class BuiltinToolAddPayload(BaseModel):
credentials: dict[str, Any]
name: str | None = Field(default=None, max_length=30)
type: CredentialType
visibility: str | None = None
class BuiltinToolUpdatePayload(BaseModel):
@ -338,6 +339,7 @@ class ToolBuiltinProviderAddApi(Resource):
credentials=payload.credentials,
name=payload.name,
api_type=CredentialType.of(payload.type),
visibility=payload.visibility,
)
@ -371,12 +373,19 @@ class ToolBuiltinProviderGetCredentialsApi(Resource):
@login_required
@account_initialization_required
def get(self, provider):
_, tenant_id = current_account_with_tenant()
user, tenant_id = current_account_with_tenant()
# Optional list of credential IDs to include even if visibility would hide them
# (used when a workflow/agent node still references another member's only_me credential).
include_credential_ids = request.args.getlist("include_credential_ids") or [
s for s in (request.args.get("include_credential_ids") or "").split(",") if s
]
return jsonable_encoder(
BuiltinToolManageService.get_builtin_tool_provider_credentials(
tenant_id=tenant_id,
provider_name=provider,
user=user,
include_credential_ids=include_credential_ids or None,
)
)
@ -859,7 +868,7 @@ class ToolOAuthCallback(Resource):
if not credentials:
raise Exception("the plugin credentials failed")
# add credentials to database
# add credentials to database — OAuth tokens default to only_me since they're personal
BuiltinToolManageService.add_builtin_tool_provider(
user_id=user_id,
tenant_id=tenant_id,
@ -867,6 +876,7 @@ class ToolOAuthCallback(Resource):
credentials=dict(credentials),
expires_at=expires_at,
api_type=CredentialType.OAUTH2,
visibility="only_me",
)
return redirect(f"{dify_config.CONSOLE_WEB_URL}/oauth-callback")
@ -946,12 +956,17 @@ class ToolBuiltinProviderGetCredentialInfoApi(Resource):
@login_required
@account_initialization_required
def get(self, provider):
_, tenant_id = current_account_with_tenant()
user, tenant_id = current_account_with_tenant()
include_credential_ids = request.args.getlist("include_credential_ids") or [
s for s in (request.args.get("include_credential_ids") or "").split(",") if s
]
return jsonable_encoder(
BuiltinToolManageService.get_builtin_tool_provider_credential_info(
tenant_id=tenant_id,
provider=provider,
user=user,
include_credential_ids=include_credential_ids or None,
)
)

View File

@ -122,12 +122,15 @@ class TriggerSubscriptionListApi(Resource):
def get(self, provider):
"""List all trigger subscriptions for the current tenant's provider"""
user = current_user
assert isinstance(user, Account)
assert user.current_tenant_id is not None
try:
return jsonable_encoder(
TriggerProviderService.list_trigger_provider_subscriptions(
tenant_id=user.current_tenant_id, provider_id=TriggerProviderID(provider)
tenant_id=user.current_tenant_id,
provider_id=TriggerProviderID(provider),
user=user,
)
)
except ValueError as e:

View File

@ -57,6 +57,7 @@ from services.feature_service import FeatureService
if TYPE_CHECKING:
from graphon.model_runtime.protocols.runtime import ModelRuntime
from models.account import Account
_credentials_adapter: TypeAdapter[dict[str, Any]] = TypeAdapter(dict[str, Any])
@ -572,14 +573,22 @@ class ProviderManager:
return provider_names
@staticmethod
def get_provider_available_credentials(tenant_id: str, provider_name: str) -> list[CredentialConfiguration]:
def get_provider_available_credentials(
tenant_id: str,
provider_name: str,
user: Account | None = None,
) -> list[CredentialConfiguration]:
"""
Get provider all credentials.
Get provider all credentials, filtered by visibility.
:param tenant_id: workspace id
:param provider_name: provider name
:param user: current user (id + admin flag drive the visibility filter)
:return:
"""
from models.credential_permission import CredentialType as CredPermType
from services.credential_permission_service import CredentialPermissionService
with session_factory.create_session() as session:
stmt = (
select(ProviderCredential)
@ -590,6 +599,16 @@ class ProviderManager:
.order_by(ProviderCredential.created_at.desc())
)
if user is not None:
stmt = CredentialPermissionService.apply_visibility_filter(
stmt,
model_id_column=ProviderCredential.id,
model_user_id_column=ProviderCredential.user_id,
model_visibility_column=ProviderCredential.visibility,
credential_type=CredPermType.PROVIDER_CREDENTIAL,
user=user,
)
available_credentials = session.scalars(stmt).all()
return [

View File

@ -129,6 +129,24 @@ class ToolProviderCredentialApiEntity(BaseModel):
default=False, description="Whether the credential is the default credential for the provider in the workspace"
)
credentials: Mapping[str, object] = Field(description="The credentials of the provider", default_factory=dict)
visibility: str = Field(
default="all_team_members",
description="Credential visibility: only_me, all_team_members, or partial_members",
)
created_by: str = Field(default="", description="User ID of the credential creator")
partial_member_list: list[str] = Field(
default_factory=list,
description="List of user IDs allowed when visibility is partial_members",
)
from_other_member: bool = Field(
default=False,
description=(
"True when this credential is being returned only because a workflow/agent node still "
"references it but it would normally be hidden from this user by the visibility filter "
"(another member's only_me credential). The frontend renders it as 'borrowed'"
"selectable until the node switches away, but not editable/deletable."
),
)
class ToolProviderCredentialInfoApiEntity(BaseModel):

View File

@ -0,0 +1,121 @@
"""add credential visibility and permission table
Revision ID: a1b2c3d4e5f6
Revises: 7885bd53f9a9
Create Date: 2026-04-11 14:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import models as models
# revision identifiers, used by Alembic.
revision = "a1b2c3d4e5f6"
down_revision = "7885bd53f9a9"
branch_labels = None
depends_on = None
def _is_pg(conn):
return conn.dialect.name == "postgresql"
def upgrade():
conn = op.get_bind()
# 1. Add visibility column to trigger_subscriptions
with op.batch_alter_table("trigger_subscriptions", schema=None) as batch_op:
batch_op.add_column(
sa.Column("visibility", sa.String(length=40), nullable=False, server_default="all_team_members")
)
# 2. Add visibility column to tool_builtin_providers
with op.batch_alter_table("tool_builtin_providers", schema=None) as batch_op:
batch_op.add_column(
sa.Column("visibility", sa.String(length=40), nullable=False, server_default="all_team_members")
)
# 3. Add user_id + visibility to datasource_providers
with op.batch_alter_table("datasource_providers", schema=None) as batch_op:
batch_op.add_column(sa.Column("user_id", models.types.StringUUID(), nullable=True))
batch_op.add_column(
sa.Column("visibility", sa.String(length=40), nullable=False, server_default="all_team_members")
)
# 4. Add user_id + visibility to provider_credentials
with op.batch_alter_table("provider_credentials", schema=None) as batch_op:
batch_op.add_column(sa.Column("user_id", models.types.StringUUID(), nullable=True))
batch_op.add_column(
sa.Column("visibility", sa.String(length=40), nullable=False, server_default="all_team_members")
)
# 5. Create credential_permissions table
if _is_pg(conn):
op.create_table(
"credential_permissions",
sa.Column(
"id", models.types.StringUUID(), server_default=sa.text("uuid_generate_v4()"), nullable=False
),
sa.Column("credential_id", models.types.StringUUID(), nullable=False),
sa.Column("credential_type", sa.String(length=40), nullable=False),
sa.Column("account_id", models.types.StringUUID(), nullable=False),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("has_permission", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column(
"created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False
),
sa.PrimaryKeyConstraint("id", name="credential_permission_pkey"),
)
else:
op.create_table(
"credential_permissions",
sa.Column("id", models.types.StringUUID(), nullable=False),
sa.Column("credential_id", models.types.StringUUID(), nullable=False),
sa.Column("credential_type", sa.String(length=40), nullable=False),
sa.Column("account_id", models.types.StringUUID(), nullable=False),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("has_permission", sa.Boolean(), nullable=False, server_default=sa.text("1")),
sa.Column(
"created_at",
sa.DateTime(),
server_default=sa.func.current_timestamp(),
nullable=False,
),
sa.PrimaryKeyConstraint("id", name="credential_permission_pkey"),
)
with op.batch_alter_table("credential_permissions", schema=None) as batch_op:
batch_op.create_index(
"idx_credential_permissions_credential", ["credential_id", "credential_type"], unique=False
)
batch_op.create_index("idx_credential_permissions_account_id", ["account_id"], unique=False)
batch_op.create_index("idx_credential_permissions_tenant_id", ["tenant_id"], unique=False)
def downgrade():
# Drop credential_permissions table
with op.batch_alter_table("credential_permissions", schema=None) as batch_op:
batch_op.drop_index("idx_credential_permissions_tenant_id")
batch_op.drop_index("idx_credential_permissions_account_id")
batch_op.drop_index("idx_credential_permissions_credential")
op.drop_table("credential_permissions")
# Remove visibility from trigger_subscriptions
with op.batch_alter_table("trigger_subscriptions", schema=None) as batch_op:
batch_op.drop_column("visibility")
# Remove visibility from tool_builtin_providers
with op.batch_alter_table("tool_builtin_providers", schema=None) as batch_op:
batch_op.drop_column("visibility")
# Remove user_id + visibility from datasource_providers
with op.batch_alter_table("datasource_providers", schema=None) as batch_op:
batch_op.drop_column("visibility")
batch_op.drop_column("user_id")
# Remove user_id + visibility from provider_credentials
with op.batch_alter_table("provider_credentials", schema=None) as batch_op:
batch_op.drop_column("visibility")
batch_op.drop_column("user_id")

View File

@ -29,6 +29,8 @@ from .comment import (
WorkflowCommentMention,
WorkflowCommentReply,
)
from .credential_permission import CredentialPermission
from .credential_permission import CredentialType as CredentialPermissionType
from .dataset import (
AppDatasetJoin,
Dataset,
@ -50,6 +52,7 @@ from .enums import (
AppTriggerStatus,
AppTriggerType,
CreatorUserRole,
PermissionEnum,
WorkflowRunTriggeredFrom,
WorkflowTriggerStatus,
)
@ -168,6 +171,8 @@ __all__ = [
"Conversation",
"ConversationVariable",
"CreatorUserRole",
"CredentialPermission",
"CredentialPermissionType",
"DataSourceApiKeyAuthBinding",
"DataSourceOauthBinding",
"Dataset",
@ -203,6 +208,7 @@ __all__ = [
"MessageFile",
"OAuthAccessToken",
"OperationLog",
"PermissionEnum",
"PinnedConversation",
"Provider",
"ProviderModel",

View File

@ -0,0 +1,53 @@
from datetime import datetime
from enum import StrEnum
from uuid import uuid4
import sqlalchemy as sa
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import TypeBase
from .types import StringUUID
class CredentialType(StrEnum):
"""Discriminator for polymorphic credential permission table."""
TRIGGER_SUBSCRIPTION = "trigger_subscription"
BUILTIN_TOOL_PROVIDER = "builtin_tool_provider"
DATASOURCE_PROVIDER = "datasource_provider"
PROVIDER_CREDENTIAL = "provider_credential"
class CredentialPermission(TypeBase):
"""
Polymorphic join table for per-credential partial-member access control.
Mirrors DatasetPermission (api/models/dataset.py) but supports all credential types
via a credential_type discriminator column.
"""
__tablename__ = "credential_permissions"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="credential_permission_pkey"),
sa.Index("idx_credential_permissions_credential", "credential_id", "credential_type"),
sa.Index("idx_credential_permissions_account_id", "account_id"),
sa.Index("idx_credential_permissions_tenant_id", "tenant_id"),
)
id: Mapped[str] = mapped_column(
StringUUID,
insert_default=lambda: str(uuid4()),
default_factory=lambda: str(uuid4()),
primary_key=True,
init=False,
)
credential_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
credential_type: Mapped[str] = mapped_column(String(40), nullable=False)
account_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
has_permission: Mapped[bool] = mapped_column(
sa.Boolean, nullable=False, server_default=sa.text("true"), default=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.current_timestamp(), init=False
)

View File

@ -1,5 +1,4 @@
import base64
import enum
import hashlib
import hmac
import json
@ -157,10 +156,10 @@ class DocumentDict(TypedDict):
hit_count: int | None
class DatasetPermissionEnum(enum.StrEnum):
ONLY_ME = "only_me"
ALL_TEAM = "all_team_members"
PARTIAL_TEAM = "partial_members"
from models.enums import PermissionEnum
# Backward-compatible alias — new code should import PermissionEnum from models.enums
DatasetPermissionEnum = PermissionEnum
class Dataset(Base):

View File

@ -356,3 +356,11 @@ class ApiTokenType(StrEnum):
APP = "app"
DATASET = "dataset"
class PermissionEnum(StrEnum):
"""Shared permission levels for resources (datasets, credentials, etc.)"""
ONLY_ME = "only_me"
ALL_TEAM = "all_team_members"
PARTIAL_TEAM = "partial_members"

View File

@ -8,7 +8,8 @@ from sqlalchemy.orm import Mapped, mapped_column
from libs.uuid_utils import uuidv7
from .base import TypeBase
from .types import AdjustedJSON, LongText, StringUUID
from .enums import PermissionEnum
from .types import AdjustedJSON, EnumText, LongText, StringUUID
class DatasourceOauthParamConfig(TypeBase):
@ -42,9 +43,16 @@ class DatasourceProvider(TypeBase):
plugin_id: Mapped[str] = mapped_column(sa.String(255), nullable=False)
auth_type: Mapped[str] = mapped_column(sa.String(255), nullable=False)
encrypted_credentials: Mapped[dict[str, Any]] = mapped_column(AdjustedJSON, nullable=False)
user_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
avatar_url: Mapped[str] = mapped_column(LongText, nullable=True, default="default")
is_default: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"), default=False)
expires_at: Mapped[int] = mapped_column(sa.Integer, nullable=False, server_default="-1", default=-1)
visibility: Mapped[PermissionEnum] = mapped_column(
EnumText(PermissionEnum, length=40),
nullable=False,
server_default=sa.text("'all_team_members'"),
default=PermissionEnum.ALL_TEAM,
)
created_at: Mapped[datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False

View File

@ -14,7 +14,7 @@ from graphon.model_runtime.entities.model_entities import ModelType
from libs.uuid_utils import uuidv7
from .base import TypeBase
from .enums import CredentialSourceType, PaymentStatus, ProviderQuotaType
from .enums import CredentialSourceType, PaymentStatus, PermissionEnum, ProviderQuotaType
from .types import EnumText, LongText, StringUUID
@ -320,6 +320,13 @@ class ProviderCredential(TypeBase):
provider_name: Mapped[str] = mapped_column(String(255), nullable=False)
credential_name: Mapped[str] = mapped_column(String(255), nullable=False)
encrypted_config: Mapped[str] = mapped_column(LongText, nullable=False)
user_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
visibility: Mapped[PermissionEnum] = mapped_column(
EnumText(PermissionEnum, length=40),
nullable=False,
server_default=sa.text("'all_team_members'"),
default=PermissionEnum.ALL_TEAM,
)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.current_timestamp(), init=False
)

View File

@ -22,6 +22,7 @@ from core.tools.entities.tool_entities import (
from .base import TypeBase
from .engine import db
from .enums import PermissionEnum
from .model import Account, App, Tenant
from .types import EnumText, LongText, StringUUID
@ -117,6 +118,12 @@ class BuiltinToolProvider(TypeBase):
default=CredentialType.API_KEY,
)
expires_at: Mapped[int] = mapped_column(sa.BigInteger, nullable=False, server_default=sa.text("-1"), default=-1)
visibility: Mapped[PermissionEnum] = mapped_column(
EnumText(PermissionEnum, length=40),
nullable=False,
server_default=sa.text("'all_team_members'"),
default=PermissionEnum.ALL_TEAM,
)
@property
def credentials(self) -> dict[str, Any]:

View File

@ -19,7 +19,7 @@ from libs.uuid_utils import uuidv7
from .base import TypeBase
from .engine import db
from .enums import AppTriggerStatus, AppTriggerType, CreatorUserRole, WorkflowTriggerStatus
from .enums import AppTriggerStatus, AppTriggerType, CreatorUserRole, PermissionEnum, WorkflowTriggerStatus
from .model import Account
from .types import EnumText, LongText, StringUUID
@ -111,6 +111,12 @@ class TriggerSubscription(TypeBase):
expires_at: Mapped[int] = mapped_column(
Integer, default=-1, comment="Subscription instance expiration timestamp, -1 for never"
)
visibility: Mapped[PermissionEnum] = mapped_column(
EnumText(PermissionEnum, length=40),
nullable=False,
server_default=sa.text("'all_team_members'"),
default=PermissionEnum.ALL_TEAM,
)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.current_timestamp(), init=False

View File

@ -11662,6 +11662,7 @@ Retrieval settings for Amazon Bedrock knowledge base queries.
| credentials | object | | Yes |
| name | string | | No |
| type | [CredentialType](#credentialtype) | | Yes |
| visibility | string | | No |
#### BuiltinToolCredentialDeletePayload
@ -12233,7 +12234,7 @@ Condition detail
| external_knowledge_id | string | | No |
| indexing_technique | string | | No |
| name | string | | Yes |
| permission | [DatasetPermissionEnum](#datasetpermissionenum) | | No |
| permission | [PermissionEnum](#permissionenum) | | No |
| provider | string | | No |
#### DatasetDetail
@ -12508,12 +12509,6 @@ Condition detail
| name | string | | Yes |
| type | string | | Yes |
#### DatasetPermissionEnum
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| DatasetPermissionEnum | string | | |
#### DatasetQueryContentResponse
| Name | Type | Description | Required |
@ -12640,7 +12635,7 @@ Condition detail
| is_multimodal | boolean | | No |
| name | string | | No |
| partial_member_list | [ object ] | | No |
| permission | [DatasetPermissionEnum](#datasetpermissionenum) | | No |
| permission | [PermissionEnum](#permissionenum) | | No |
| retrieval_model | object | | No |
| summary_index_setting | object | | No |
@ -14609,6 +14604,14 @@ Form input definition.
| icon_info | object | | No |
| name | string | | Yes |
#### PermissionEnum
Shared permission levels for resources (datasets, credentials, etc.)
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| PermissionEnum | string | Shared permission levels for resources (datasets, credentials, etc.) | |
#### PipelineVariableResponse
| Name | Type | Description | Required |

View File

@ -2378,7 +2378,7 @@ Condition detail
| external_knowledge_id | string | | No |
| indexing_technique | string | *Enum:* `"economy"`, `"high_quality"` | No |
| name | string | | Yes |
| permission | [DatasetPermissionEnum](#datasetpermissionenum) | | No |
| permission | [PermissionEnum](#permissionenum) | | No |
| provider | string | | No |
| retrieval_model | [RetrievalModel](#retrievalmodel) | | No |
| summary_index_setting | object | | No |
@ -2567,12 +2567,6 @@ Condition detail
| name | string | | Yes |
| type | string | | Yes |
#### DatasetPermissionEnum
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| DatasetPermissionEnum | string | | |
#### DatasetRerankingModelResponse
| Name | Type | Description | Required |
@ -2623,7 +2617,7 @@ Condition detail
| indexing_technique | string | *Enum:* `"economy"`, `"high_quality"` | No |
| name | string | | No |
| partial_member_list | [ object ] | | No |
| permission | [DatasetPermissionEnum](#datasetpermissionenum) | | No |
| permission | [PermissionEnum](#permissionenum) | | No |
| retrieval_model | [RetrievalModel](#retrievalmodel) | | No |
#### DatasetVectorSettingResponse
@ -3013,6 +3007,14 @@ Metadata operation data
| ---- | ---- | ----------- | -------- |
| name | string | | Yes |
#### PermissionEnum
Shared permission levels for resources (datasets, credentials, etc.)
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| PermissionEnum | string | Shared permission levels for resources (datasets, credentials, etc.) | |
#### PipelineRunApiEntity
| Name | Type | Description | Required |

View File

@ -0,0 +1,71 @@
from collections.abc import Sequence
from sqlalchemy import or_, select
from sqlalchemy.orm import InstrumentedAttribute
from extensions.ext_database import db
from models.account import Account
from models.credential_permission import CredentialPermission
from models.enums import PermissionEnum
class CredentialPermissionService:
"""
Shared service for per-credential access control.
Mirrors DatasetPermissionService but supports all credential types
via a credential_type discriminator.
"""
@classmethod
def get_partial_member_list(cls, credential_id: str, credential_type: str) -> Sequence[str]:
"""Return account_ids that have partial-member access to a credential."""
return db.session.scalars(
select(CredentialPermission.account_id).where(
CredentialPermission.credential_id == credential_id,
CredentialPermission.credential_type == credential_type,
)
).all()
@classmethod
def apply_visibility_filter(
cls,
query,
*,
model_id_column: InstrumentedAttribute,
model_user_id_column: InstrumentedAttribute,
model_visibility_column: InstrumentedAttribute,
credential_type: str,
user: Account,
):
"""
Add WHERE clauses to a SQLAlchemy query so it only returns credentials
visible to the given user.
- all_team_members: always visible
- only_me: visible only to the creator (user.id matches)
- partial_members: visible to the creator OR users in credential_permissions
- Legacy rows with NULL user_id are treated as all_team_members
- No admin bypass: personal credentials are private regardless of role
"""
# Subquery: credential_ids where user has partial-member permission
partial_subquery = (
select(CredentialPermission.credential_id)
.where(
CredentialPermission.credential_type == credential_type,
CredentialPermission.account_id == user.id,
)
.correlate_except(CredentialPermission)
)
return query.where(
or_(
# all_team is always visible
model_visibility_column == PermissionEnum.ALL_TEAM,
# legacy rows with NULL user_id treated as all_team
model_user_id_column.is_(None),
# only_me: creator sees their own
(model_user_id_column == user.id),
# partial_members: user is in the permission table
model_id_column.in_(partial_subquery),
)
)

View File

@ -1,7 +1,10 @@
import logging
import time
from collections.abc import Mapping
from typing import Any
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from models.account import Account
from sqlalchemy import delete, func, select, update
from sqlalchemy.orm import Session, sessionmaker
@ -791,24 +794,42 @@ class DatasourceProviderService:
return secret_input_form_variables
def list_datasource_credentials(self, tenant_id: str, provider: str, plugin_id: str) -> list[dict]:
def list_datasource_credentials(
self,
tenant_id: str,
provider: str,
plugin_id: str,
user: "Account | None" = None,
) -> list[dict]:
"""
list datasource credentials with obfuscated sensitive fields.
list datasource credentials with obfuscated sensitive fields,
filtered by visibility.
:param tenant_id: workspace id
:param provider_id: provider id
:param provider: provider name
:param plugin_id: plugin id
:param user: current user (id + admin flag drive the visibility filter)
:return:
"""
from models.credential_permission import CredentialType as CredPermType
from services.credential_permission_service import CredentialPermissionService
# Get all provider configurations of the current workspace
datasource_providers: list[DatasourceProvider] = list(
db.session.scalars(
select(DatasourceProvider).where(
DatasourceProvider.tenant_id == tenant_id,
DatasourceProvider.provider == provider,
DatasourceProvider.plugin_id == plugin_id,
)
).all()
query = select(DatasourceProvider).where(
DatasourceProvider.tenant_id == tenant_id,
DatasourceProvider.provider == provider,
DatasourceProvider.plugin_id == plugin_id,
)
if user is not None:
query = CredentialPermissionService.apply_visibility_filter(
query,
model_id_column=DatasourceProvider.id,
model_user_id_column=DatasourceProvider.user_id,
model_visibility_column=DatasourceProvider.visibility,
credential_type=CredPermType.DATASOURCE_PROVIDER,
user=user,
)
datasource_providers: list[DatasourceProvider] = list(db.session.scalars(query).all())
if not datasource_providers:
return []
copy_credentials_list = []

View File

@ -1,5 +1,8 @@
import logging
from typing import Any
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from models.account import Account
from core.entities.model_entities import ModelWithProviderEntity, ProviderModelWithStatusEntity
from core.plugin.impl.model_runtime_factory import create_plugin_model_provider_factory, create_plugin_provider_manager
@ -148,10 +151,11 @@ class ModelProviderService:
for model in provider_configurations.get_models(provider=provider)
]
def get_provider_available_credentials(self, tenant_id: str, provider: str):
def get_provider_available_credentials(self, tenant_id: str, provider: str, user: "Account | None" = None):
return self._get_provider_manager(tenant_id).get_provider_available_credentials(
tenant_id=tenant_id,
provider_name=provider,
user=user,
)
def get_provider_model_available_credentials(

View File

@ -30,6 +30,7 @@ from core.tools.utils.encryption import create_provider_encrypter
from core.tools.utils.system_encryption import decrypt_system_params
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from models.account import Account
from models.provider_ids import ToolProviderID
from models.tools import BuiltinToolProvider, ToolOAuthSystemClient, ToolOAuthTenantClient
from services.tools.tools_transform_service import ToolTransformService
@ -206,6 +207,9 @@ class BuiltinToolManageService:
db_provider.name = name
# Visibility is immutable after creation — no update path. To change scope,
# create a new credential. partial-member access is handled by RBAC.
except Exception as e:
raise ValueError(str(e))
return {"result": "success"}
@ -219,6 +223,7 @@ class BuiltinToolManageService:
credentials: dict[str, Any],
expires_at: int = -1,
name: str | None = None,
visibility: str | None = None,
):
"""
add builtin tool provider
@ -277,6 +282,14 @@ class BuiltinToolManageService:
cache=NoOpProviderCredentialCache(),
)
from models.enums import PermissionEnum
visibility_enum = PermissionEnum(visibility) if visibility else PermissionEnum.ALL_TEAM
# Plugin credentials only expose only_me / all_team_members at creation;
# partial-member access is handled by workspace RBAC, not per-credential.
if visibility_enum == PermissionEnum.PARTIAL_TEAM:
raise ValueError("partial_members visibility is no longer supported for plugin credentials")
db_provider = BuiltinToolProvider(
tenant_id=tenant_id,
user_id=user_id,
@ -285,9 +298,11 @@ class BuiltinToolManageService:
credential_type=api_type,
name=name,
expires_at=expires_at if expires_at is not None else -1,
visibility=visibility_enum,
)
session.add(db_provider)
session.flush()
except Exception as e:
raise ValueError(str(e))
@ -330,24 +345,69 @@ class BuiltinToolManageService:
@staticmethod
def get_builtin_tool_provider_credentials(
tenant_id: str, provider_name: str
tenant_id: str,
provider_name: str,
user: Account | None = None,
include_credential_ids: list[str] | None = None,
) -> list[ToolProviderCredentialApiEntity]:
"""
get builtin tool provider credentials
"""
with db.session.no_autoflush:
providers = db.session.scalars(
select(BuiltinToolProvider)
.where(BuiltinToolProvider.tenant_id == tenant_id, BuiltinToolProvider.provider == provider_name)
.order_by(BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc())
).all()
get builtin tool provider credentials, filtered by visibility.
if len(providers) == 0:
``user`` is used to filter the result list by per-credential visibility
(only_me / all_team_members / legacy partial_members). When ``None`` the
query returns every credential for the tenant meant for internal /
background callers that don't act on behalf of a specific user.
``include_credential_ids`` lets callers request specific credential IDs that should be
returned even if the visibility filter would normally hide them (e.g. an only_me credential
owned by another member which the current workflow/agent node still references). Those
rows are marked with ``from_other_member=True`` so the UI can render them as
borrowed-from-teammate (selectable but not editable).
"""
from models.credential_permission import CredentialType as CredPermType
from services.credential_permission_service import CredentialPermissionService
with db.session.no_autoflush:
base_filter = (
BuiltinToolProvider.tenant_id == tenant_id,
BuiltinToolProvider.provider == provider_name,
)
order = (BuiltinToolProvider.is_default.desc(), BuiltinToolProvider.created_at.asc())
visible_query = select(BuiltinToolProvider).where(*base_filter).order_by(*order)
if user is not None:
visible_query = CredentialPermissionService.apply_visibility_filter(
visible_query,
model_id_column=BuiltinToolProvider.id,
model_user_id_column=BuiltinToolProvider.user_id,
model_visibility_column=BuiltinToolProvider.visibility,
credential_type=CredPermType.BUILTIN_TOOL_PROVIDER,
user=user,
)
visible_providers = list(db.session.scalars(visible_query).all())
# Fetch any explicitly-included IDs that the visibility filter excluded.
borrowed_ids: set[str] = set()
borrowed_providers: list[BuiltinToolProvider] = []
if include_credential_ids:
visible_id_set = {p.id for p in visible_providers}
wanted_ids = [cid for cid in include_credential_ids if cid and cid not in visible_id_set]
if wanted_ids:
borrowed_query = (
select(BuiltinToolProvider)
.where(*base_filter, BuiltinToolProvider.id.in_(wanted_ids))
.order_by(*order)
)
borrowed_providers = list(db.session.scalars(borrowed_query).all())
borrowed_ids = {p.id for p in borrowed_providers}
providers = visible_providers + borrowed_providers
if not providers:
return []
default_provider = providers[0]
default_provider.is_default = True
provider_controller = ToolManager.get_builtin_provider(default_provider.provider, tenant_id)
# Only the first visible row should be flagged is_default in the response.
if visible_providers:
visible_providers[0].is_default = True
provider_controller = ToolManager.get_builtin_provider(providers[0].provider, tenant_id)
credentials: list[ToolProviderCredentialApiEntity] = []
for provider in providers:
@ -359,17 +419,40 @@ class BuiltinToolManageService:
provider=provider,
credentials=dict(decrypt_credential),
)
# Attach visibility, creator, and partial member list to the response entity
vis = getattr(provider, "visibility", "all_team_members")
vis_str = vis.value if hasattr(vis, "value") else str(vis)
credential_entity.visibility = vis_str
credential_entity.created_by = getattr(provider, "user_id", "") or ""
if vis_str == "partial_members":
credential_entity.partial_member_list = list(
CredentialPermissionService.get_partial_member_list(
provider.id, CredPermType.BUILTIN_TOOL_PROVIDER
)
)
if provider.id in borrowed_ids:
credential_entity.from_other_member = True
credentials.append(credential_entity)
return credentials
@staticmethod
def get_builtin_tool_provider_credential_info(tenant_id: str, provider: str) -> ToolProviderCredentialInfoApiEntity:
def get_builtin_tool_provider_credential_info(
tenant_id: str,
provider: str,
user: Account | None = None,
include_credential_ids: list[str] | None = None,
) -> ToolProviderCredentialInfoApiEntity:
"""
get builtin tool provider credential info
"""
provider_controller = ToolManager.get_builtin_provider(provider, tenant_id)
supported_credential_types = provider_controller.get_supported_credential_types()
credentials = BuiltinToolManageService.get_builtin_tool_provider_credentials(tenant_id, provider)
credentials = BuiltinToolManageService.get_builtin_tool_provider_credentials(
tenant_id,
provider,
user=user,
include_credential_ids=include_credential_ids,
)
credential_info = ToolProviderCredentialInfoApiEntity(
supported_credential_types=supported_credential_types,
is_oauth_custom_client_enabled=BuiltinToolManageService.is_oauth_custom_client_enabled(tenant_id, provider),

View File

@ -3,7 +3,10 @@ import logging
import time as _time
import uuid
from collections.abc import Mapping
from typing import Any, TypedDict
from typing import TYPE_CHECKING, Any, TypedDict
if TYPE_CHECKING:
from models.account import Account
from sqlalchemy import delete, desc, func, select
from sqlalchemy.orm import Session, sessionmaker
@ -66,21 +69,37 @@ class TriggerProviderService:
@classmethod
def list_trigger_provider_subscriptions(
cls, tenant_id: str, provider_id: TriggerProviderID
cls,
tenant_id: str,
provider_id: TriggerProviderID,
user: "Account | None" = None,
) -> list[TriggerProviderSubscriptionApiEntity]:
"""List all trigger subscriptions for the current tenant"""
"""List all trigger subscriptions for the current tenant, filtered by visibility."""
from models.credential_permission import CredentialType as CredPermType
from services.credential_permission_service import CredentialPermissionService
subscriptions: list[TriggerProviderSubscriptionApiEntity] = []
workflows_in_use_map: dict[str, int] = {}
with Session(db.engine, expire_on_commit=False) as session:
# Get all subscriptions
subscriptions_db = session.scalars(
# Get all subscriptions with visibility filtering
query = (
select(TriggerSubscription)
.where(
TriggerSubscription.tenant_id == tenant_id,
TriggerSubscription.provider_id == str(provider_id),
)
.order_by(desc(TriggerSubscription.created_at))
).all()
)
if user is not None:
query = CredentialPermissionService.apply_visibility_filter(
query,
model_id_column=TriggerSubscription.id,
model_user_id_column=TriggerSubscription.user_id,
model_visibility_column=TriggerSubscription.visibility,
credential_type=CredPermType.TRIGGER_SUBSCRIPTION,
user=user,
)
subscriptions_db = session.scalars(query).all()
subscriptions = [subscription.to_api_entity() for subscription in subscriptions_db]
if not subscriptions:
return []

View File

@ -3,6 +3,7 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
@ -284,11 +285,12 @@ class TestBuiltinProviderApis:
api = ToolBuiltinProviderGetCredentialsApi()
method = unwrap(api.get)
mock_user = SimpleNamespace(id="user-1", is_admin_or_owner=False)
with (
app.test_request_context("/"),
patch(
"controllers.console.workspace.tool_providers.current_account_with_tenant",
return_value=(None, "t"),
return_value=(mock_user, "t"),
),
patch(
"controllers.console.workspace.tool_providers.BuiltinToolManageService.get_builtin_tool_provider_credentials",

View File

@ -81,7 +81,13 @@ def controller_module(monkeypatch: pytest.MonkeyPatch):
def _mock_account(user_id: str = "user-123") -> SimpleNamespace:
return SimpleNamespace(id=user_id, status="active", is_authenticated=True, current_tenant_id=None)
return SimpleNamespace(
id=user_id,
status="active",
is_authenticated=True,
current_tenant_id=None,
is_admin_or_owner=False,
)
def _set_current_account(
@ -149,6 +155,7 @@ def test_builtin_provider_add_passes_payload(
credentials={"api_key": "sk-test"},
name="MyTool",
api_type=controller_module.CredentialType.API_KEY,
visibility=None,
)
@ -197,7 +204,12 @@ def test_builtin_provider_credentials_get(app: Flask, controller_module, monkeyp
resp = controller_module.ToolBuiltinProviderGetCredentialsApi().get(provider="demo")
assert resp == [{"cred": 1}]
service_mock.assert_called_once_with(tenant_id="tenant-cred", provider_name="demo")
service_mock.assert_called_once_with(
tenant_id="tenant-cred",
provider_name="demo",
user=user,
include_credential_ids=None,
)
def test_api_provider_remote_schema_get(app: Flask, controller_module, monkeypatch: pytest.MonkeyPatch):

View File

@ -0,0 +1,103 @@
"""Unit tests for CredentialPermissionService.
Tests the visibility filtering logic, partial-member read path,
and admin bypass behavior.
"""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from sqlalchemy import select
from models.credential_permission import CredentialType
from services.credential_permission_service import CredentialPermissionService
@pytest.fixture
def tenant_id():
return str(uuid4())
@pytest.fixture
def user_id():
return str(uuid4())
@pytest.fixture
def other_user_id():
return str(uuid4())
@pytest.fixture
def credential_id():
return str(uuid4())
class TestGetPartialMemberList:
def test_returns_empty_when_no_permissions(self, credential_id):
with patch("services.credential_permission_service.db") as mock_db:
mock_db.session.scalars.return_value.all.return_value = []
result = CredentialPermissionService.get_partial_member_list(
credential_id, CredentialType.TRIGGER_SUBSCRIPTION
)
assert result == []
def test_returns_account_ids(self, credential_id, user_id, other_user_id):
with patch("services.credential_permission_service.db") as mock_db:
mock_db.session.scalars.return_value.all.return_value = [user_id, other_user_id]
result = CredentialPermissionService.get_partial_member_list(
credential_id, CredentialType.TRIGGER_SUBSCRIPTION
)
assert set(result) == {user_id, other_user_id}
class TestApplyVisibilityFilter:
"""Test the visibility filter logic using mock model columns."""
def _make_mock_columns(self):
"""Create mock model columns for testing."""
model_id = MagicMock(name="id_column")
model_user_id = MagicMock(name="user_id_column")
model_visibility = MagicMock(name="visibility_column")
return model_id, model_user_id, model_visibility
def _make_user(self, user_id: str, is_admin: bool):
return SimpleNamespace(id=user_id, is_admin_or_owner=is_admin)
def test_admin_gets_filtered_too(self, user_id):
"""Admin should NOT bypass visibility — personal credentials are private regardless of role."""
from models.trigger import TriggerSubscription
query = select(TriggerSubscription)
result = CredentialPermissionService.apply_visibility_filter(
query,
model_id_column=TriggerSubscription.id,
model_user_id_column=TriggerSubscription.user_id,
model_visibility_column=TriggerSubscription.visibility,
credential_type=CredentialType.TRIGGER_SUBSCRIPTION,
user=self._make_user(user_id, is_admin=True),
)
# No admin bypass: query should have WHERE clause
compiled = str(result.compile(compile_kwargs={"literal_binds": True}))
assert "WHERE" in compiled
def test_non_admin_adds_filter_on_real_model(self, user_id):
"""Non-admin should get a filtered query when using real SQLAlchemy columns."""
from models.trigger import TriggerSubscription
query = select(TriggerSubscription)
result = CredentialPermissionService.apply_visibility_filter(
query,
model_id_column=TriggerSubscription.id,
model_user_id_column=TriggerSubscription.user_id,
model_visibility_column=TriggerSubscription.visibility,
credential_type=CredentialType.TRIGGER_SUBSCRIPTION,
user=self._make_user(user_id, is_admin=False),
)
# The compiled SQL should include a WHERE clause referencing user_id and visibility
compiled = str(result.compile(compile_kwargs={"literal_binds": True}))
assert "WHERE" in compiled
assert "visibility" in compiled
assert "user_id" in compiled

View File

@ -1598,6 +1598,11 @@
"count": 1
}
},
"web/app/components/base/permission-selector/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/base/prompt-editor/index.stories.tsx": {
"no-console": {
"count": 1
@ -2715,9 +2720,6 @@
"web/app/components/plugins/plugin-auth/authorized/item.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/plugins/plugin-auth/hooks/use-get-api.ts": {
@ -2739,9 +2741,6 @@
},
"no-barrel-files/no-barrel-files": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/plugins/plugin-auth/utils.ts": {
@ -5152,7 +5151,7 @@
},
"web/models/datasets.ts": {
"erasable-syntax-only/enums": {
"count": 8
"count": 7
},
"ts/no-explicit-any": {
"count": 5

View File

@ -18,7 +18,7 @@ export type DatasetCreatePayload = {
external_knowledge_id?: string | null
indexing_technique?: string | null
name: string
permission?: DatasetPermissionEnum
permission?: PermissionEnum
provider?: string
}
@ -272,7 +272,7 @@ export type DatasetUpdatePayload = {
partial_member_list?: Array<{
[key: string]: string
}> | null
permission?: DatasetPermissionEnum
permission?: PermissionEnum
retrieval_model?: {
[key: string]: unknown
} | null
@ -544,7 +544,7 @@ export type DatasetListItemResponse = {
word_count: number
}
export type DatasetPermissionEnum = 'all_team_members' | 'only_me' | 'partial_members'
export type PermissionEnum = 'all_team_members' | 'only_me' | 'partial_members'
export type DatasetDocMetadataResponse = {
id: string

View File

@ -290,9 +290,11 @@ export const zUsageCheckResponse = z.object({
})
/**
* DatasetPermissionEnum
* PermissionEnum
*
* Shared permission levels for resources (datasets, credentials, etc.)
*/
export const zDatasetPermissionEnum = z.enum(['all_team_members', 'only_me', 'partial_members'])
export const zPermissionEnum = z.enum(['all_team_members', 'only_me', 'partial_members'])
/**
* DatasetCreatePayload
@ -303,7 +305,7 @@ export const zDatasetCreatePayload = z.object({
external_knowledge_id: z.string().nullish(),
indexing_technique: z.string().nullish(),
name: z.string().min(1).max(40),
permission: zDatasetPermissionEnum.optional(),
permission: zPermissionEnum.optional(),
provider: z.string().optional().default('vendor'),
})
@ -322,7 +324,7 @@ export const zDatasetUpdatePayload = z.object({
is_multimodal: z.boolean().nullish().default(false),
name: z.string().min(1).max(40).nullish(),
partial_member_list: z.array(z.record(z.string(), z.string())).nullish(),
permission: zDatasetPermissionEnum.optional(),
permission: zPermissionEnum.optional(),
retrieval_model: z.record(z.string(), z.unknown()).nullish(),
summary_index_setting: z.record(z.string(), z.unknown()).nullish(),
})

View File

@ -354,6 +354,7 @@ export type BuiltinToolAddPayload = {
}
name?: string | null
type: CredentialType
visibility?: string | null
}
export type BuiltinProviderDefaultCredentialPayload = {

View File

@ -704,6 +704,7 @@ export const zBuiltinToolAddPayload = z.object({
credentials: z.record(z.string(), z.unknown()),
name: z.string().max(30).nullish(),
type: zCredentialType,
visibility: z.string().nullish(),
})
/**

View File

@ -188,7 +188,7 @@ export type DatasetCreatePayload = {
external_knowledge_id?: string | null
indexing_technique?: 'economy' | 'high_quality' | null
name: string
permission?: DatasetPermissionEnum
permission?: PermissionEnum
provider?: string
retrieval_model?: RetrievalModel
summary_index_setting?: {
@ -350,8 +350,6 @@ export type DatasetMetadataResponse = {
type: string
}
export type DatasetPermissionEnum = 'all_team_members' | 'only_me' | 'partial_members'
export type DatasetRerankingModelResponse = {
reranking_model_name?: string | null
reranking_provider_name?: string | null
@ -395,7 +393,7 @@ export type DatasetUpdatePayload = {
partial_member_list?: Array<{
[key: string]: string
}> | null
permission?: DatasetPermissionEnum
permission?: PermissionEnum
retrieval_model?: RetrievalModel
}
@ -700,6 +698,8 @@ export type MetadataUpdatePayload = {
name: string
}
export type PermissionEnum = 'all_team_members' | 'only_me' | 'partial_members'
export type PipelineRunApiEntity = {
datasource_info_list: Array<{
[key: string]: unknown

View File

@ -350,11 +350,6 @@ export const zDatasetMetadataResponse = z.object({
type: z.string(),
})
/**
* DatasetPermissionEnum
*/
export const zDatasetPermissionEnum = z.enum(['all_team_members', 'only_me', 'partial_members'])
/**
* DatasetRerankingModelResponse
*/
@ -870,6 +865,13 @@ export const zMetadataUpdatePayload = z.object({
name: z.string(),
})
/**
* PermissionEnum
*
* Shared permission levels for resources (datasets, credentials, etc.)
*/
export const zPermissionEnum = z.enum(['all_team_members', 'only_me', 'partial_members'])
/**
* PipelineRunApiEntity
*/
@ -1225,7 +1227,7 @@ export const zDatasetCreatePayload = z.object({
external_knowledge_id: z.string().nullish(),
indexing_technique: z.enum(['economy', 'high_quality']).nullish(),
name: z.string().min(1).max(40),
permission: zDatasetPermissionEnum.optional(),
permission: zPermissionEnum.optional(),
provider: z.string().optional().default('vendor'),
retrieval_model: zRetrievalModel.optional(),
summary_index_setting: z.record(z.string(), z.unknown()).nullish(),
@ -1244,7 +1246,7 @@ export const zDatasetUpdatePayload = z.object({
indexing_technique: z.enum(['economy', 'high_quality']).nullish(),
name: z.string().min(1).max(40).nullish(),
partial_member_list: z.array(z.record(z.string(), z.string())).nullish(),
permission: zDatasetPermissionEnum.optional(),
permission: zPermissionEnum.optional(),
retrieval_model: zRetrievalModel.optional(),
})

View File

@ -0,0 +1,279 @@
import type { Member } from '@/models/common'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine, RiGroup2Line, RiLock2Line } from '@remixicon/react'
import { useDebounceFn } from 'ahooks'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { PermissionLevel } from '@/models/permission'
import MemberItem from './member-item'
import Item from './permission-item'
type PermissionSelectorProps = {
disabled?: boolean
permission?: PermissionLevel
value: string[]
memberList: Member[]
onChange: (permission?: PermissionLevel) => void
onMemberSelect: (v: string[]) => void
/** i18n namespace for label strings (defaults to datasetSettings for backward compat) */
i18nNamespace?: string
/**
* Hide the "Partial members" option. Useful for surfaces (e.g. plugin
* credential creation) where partial-member access is delegated to RBAC
* and the picker should only expose only_me / all_team_members.
*/
hidePartialMembers?: boolean
}
const PermissionSelector = ({
disabled,
permission,
value,
memberList,
onChange,
onMemberSelect,
i18nNamespace = 'datasetSettings',
hidePartialMembers = false,
}: PermissionSelectorProps) => {
const { t } = useTranslation()
const userProfile = useAppContextWithSelector(state => state.userProfile)
const [open, setOpen] = useState(false)
const [keywords, setKeywords] = useState('')
const [searchKeywords, setSearchKeywords] = useState('')
const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords)
}, { wait: 500 })
const handleKeywordsChange = (value: string) => {
setKeywords(value)
handleSearch()
}
const selectMember = useCallback((member: Member) => {
if (value.includes(member.id))
onMemberSelect(value.filter(v => v !== member.id))
else
onMemberSelect([...value, member.id])
}, [value, onMemberSelect])
const selectedMembers = useMemo(() => {
return [
userProfile,
...memberList.filter(member => member.id !== userProfile.id).filter(member => value.includes(member.id)),
]
}, [userProfile, value, memberList])
const showMe = useMemo(() => {
return userProfile.name.includes(searchKeywords) || userProfile.email.includes(searchKeywords)
}, [searchKeywords, userProfile])
const filteredMemberList = useMemo(() => {
return memberList.filter(member => (member.name.includes(searchKeywords) || member.email.includes(searchKeywords)) && member.id !== userProfile.id && ['owner', 'admin', 'editor', 'dataset_operator'].includes(member.role))
}, [memberList, searchKeywords, userProfile])
const onSelectOnlyMe = useCallback(() => {
onChange(PermissionLevel.onlyMe)
setOpen(false)
}, [onChange])
const onSelectAllMembers = useCallback(() => {
onChange(PermissionLevel.allTeamMembers)
setOpen(false)
}, [onChange])
const onSelectPartialMembers = useCallback(() => {
onChange(PermissionLevel.partialMembers)
onMemberSelect([userProfile.id])
}, [onChange, onMemberSelect, userProfile])
const isOnlyMe = permission === PermissionLevel.onlyMe
const isAllTeamMembers = permission === PermissionLevel.allTeamMembers
const isPartialMembers = permission === PermissionLevel.partialMembers
const selectedMemberNames = selectedMembers.map(member => member.name).join(', ')
return (
<Popover open={open} onOpenChange={setOpen}>
<div className="relative">
<PopoverTrigger
disabled={disabled}
render={(
<div
className={cn(
'flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt',
open && 'bg-state-base-hover-alt',
disabled && 'cursor-not-allowed! bg-components-input-bg-disabled! hover:bg-components-input-bg-disabled!',
)}
/>
)}
>
{
isOnlyMe && (
<>
<div className="flex size-6 shrink-0 items-center justify-center">
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" />
</div>
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
{t('form.permissionsOnlyMe', { ns: i18nNamespace })}
</div>
</>
)
}
{
isAllTeamMembers && (
<>
<div className="flex size-6 shrink-0 items-center justify-center">
<RiGroup2Line className="size-4 text-text-secondary" />
</div>
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
{t('form.permissionsAllMember', { ns: i18nNamespace })}
</div>
</>
)
}
{
isPartialMembers && (
<>
<div className="relative flex size-6 shrink-0 items-center justify-center">
{
selectedMembers.length === 1 && (
<Avatar
avatar={selectedMembers[0]!.avatar_url}
name={selectedMembers[0]!.name}
size="xs"
/>
)
}
{
selectedMembers.length >= 2 && (
<>
<Avatar
avatar={selectedMembers[0]!.avatar_url}
name={selectedMembers[0]!.name}
className="absolute top-0 left-0 z-0"
size="xxs"
/>
<Avatar
avatar={selectedMembers[1]!.avatar_url}
name={selectedMembers[1]!.name}
className="absolute right-0 bottom-0 z-10"
size="xxs"
/>
</>
)
}
</div>
<div
title={selectedMemberNames}
className="grow truncate p-1 system-sm-regular text-components-input-text-filled"
>
{selectedMemberNames}
</div>
</>
)
}
<RiArrowDownSLine
className={cn(
'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
open && 'text-text-secondary',
disabled && 'text-components-input-text-placeholder!',
)}
/>
</PopoverTrigger>
<PopoverContent placement="bottom-start" sideOffset={4} popupClassName="w-[480px] p-0">
<div className="p-1">
{/* Only me */}
<Item
leftIcon={
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} className="shrink-0" size="sm" />
}
text={t('form.permissionsOnlyMe', { ns: i18nNamespace })}
onClick={onSelectOnlyMe}
isSelected={isOnlyMe}
/>
{/* All team members */}
<Item
leftIcon={(
<div className="flex size-6 shrink-0 items-center justify-center">
<RiGroup2Line className="size-4 text-text-secondary" />
</div>
)}
text={t('form.permissionsAllMember', { ns: i18nNamespace })}
onClick={onSelectAllMembers}
isSelected={isAllTeamMembers}
/>
{/* Partial members */}
{!hidePartialMembers && (
<Item
leftIcon={(
<div className="flex size-6 shrink-0 items-center justify-center">
<RiLock2Line className="size-4 text-text-secondary" />
</div>
)}
text={t('form.permissionsInvitedMembers', { ns: i18nNamespace })}
onClick={onSelectPartialMembers}
isSelected={isPartialMembers}
/>
)}
</div>
{!hidePartialMembers && isPartialMembers && (
<div className="max-h-[360px] overflow-y-auto border-t border-divider-regular pr-1 pb-1 pl-1">
<div className="sticky top-0 left-0 z-10 bg-components-panel-on-panel-item-bg p-2 pb-1">
<Input
showLeftIcon
showClearIcon
value={keywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
</div>
<div className="flex flex-col p-1">
{showMe && (
<MemberItem
leftIcon={
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} className="shrink-0" size="sm" />
}
name={userProfile.name}
email={userProfile.email}
isSelected
isMe
i18nNamespace={i18nNamespace}
/>
)}
{filteredMemberList.map(member => (
<MemberItem
key={member.id}
leftIcon={
<Avatar avatar={member.avatar_url} name={member.name} className="shrink-0" size="sm" />
}
name={member.name}
email={member.email}
isSelected={value.includes(member.id)}
onClick={selectMember.bind(null, member)}
i18nNamespace={i18nNamespace}
/>
))}
{
!showMe && filteredMemberList.length === 0 && (
<div className="flex items-center justify-center px-1 py-6 text-center system-xs-regular whitespace-pre-wrap text-text-tertiary">
{t('form.onSearchResults', { ns: i18nNamespace })}
</div>
)
}
</div>
</div>
)}
</PopoverContent>
</div>
</Popover>
)
}
export default PermissionSelector

View File

@ -0,0 +1,49 @@
import { cn } from '@langgenius/dify-ui/cn'
import { RiCheckLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
type MemberItemProps = {
leftIcon: React.ReactNode
name: string
email: string
isSelected: boolean
isMe?: boolean
onClick?: () => void
i18nNamespace?: string
}
const MemberItem = ({
leftIcon,
name,
email,
isSelected,
isMe = false,
onClick,
i18nNamespace = 'datasetSettings',
}: MemberItemProps) => {
const { t } = useTranslation()
return (
<div
className="flex cursor-pointer items-center gap-2 rounded-lg py-1 pr-[10px] pl-2 hover:bg-state-base-hover"
onClick={onClick}
>
{leftIcon}
<div className="grow">
<div className="truncate system-sm-medium text-text-secondary">
{name}
{isMe && (
<span className="system-xs-regular text-text-tertiary">
{t('form.me', { ns: i18nNamespace })}
</span>
)}
</div>
<div className="truncate system-xs-regular text-text-tertiary">{email}</div>
</div>
{isSelected && <RiCheckLine className={cn('size-4 shrink-0 text-text-accent', isMe && 'opacity-30')} />}
</div>
)
}
export default React.memo(MemberItem)

View File

@ -0,0 +1,31 @@
import { RiCheckLine } from '@remixicon/react'
import * as React from 'react'
type PermissionItemProps = {
leftIcon: React.ReactNode
text: string
onClick: () => void
isSelected: boolean
}
const PermissionItem = ({
leftIcon,
text,
onClick,
isSelected,
}: PermissionItemProps) => {
return (
<div
className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1 hover:bg-state-base-hover"
onClick={onClick}
>
{leftIcon}
<div className="grow px-1 system-md-regular text-text-secondary">
{text}
</div>
{isSelected && <RiCheckLine className="size-4 text-text-accent" />}
</div>
)
}
export default React.memo(PermissionItem)

View File

@ -36,10 +36,14 @@ vi.mock('@/service/use-tools', () => ({
}))
const mockIsCurrentWorkspaceManager = vi.fn()
const mockUserProfile = { id: 'test-user', name: 'Test User', email: 'test@example.com', avatar_url: '' }
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(),
}),
// Item renders useAppContextWithSelector(state => state.userProfile)
useSelector: (selector: (state: { userProfile: typeof mockUserProfile }) => unknown) =>
selector({ userProfile: mockUserProfile }),
}))
vi.mock('@/hooks/use-oauth', () => ({

View File

@ -36,10 +36,15 @@ vi.mock('@/service/use-tools', () => ({
}))
const mockIsCurrentWorkspaceManager = vi.fn()
const mockUserProfile = { id: 'test-user', name: 'Test User', email: 'test@example.com', avatar_url: '' }
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(),
}),
// Item renders useAppContextWithSelector(state => state.userProfile) for the
// borrowed-row heuristic. Provide a minimal stub so the selector runs.
useSelector: (selector: (state: { userProfile: typeof mockUserProfile }) => unknown) =>
selector({ userProfile: mockUserProfile }),
}))
vi.mock('@/hooks/use-oauth', () => ({

View File

@ -77,6 +77,18 @@ vi.mock('@/app/components/base/form/types', () => ({
FormTypeEnum: { textInput: 'text-input' },
}))
// PermissionSelector (rendered for create mode) calls useMembers via TanStack Query.
// Stub it so tests don't need a QueryClientProvider wrapper.
vi.mock('@/service/use-common', () => ({
useMembers: () => ({ data: { accounts: [] } }),
}))
// PermissionSelector also reads userProfile from app-context.
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { userProfile: { id: string, name: string, email: string, avatar_url: string } }) => unknown) =>
selector({ userProfile: { id: 'test-user', name: 'Test User', email: 'test@example.com', avatar_url: '' } }),
}))
const basePayload = {
category: AuthCategory.tool,
provider: 'test-provider',

View File

@ -15,6 +15,13 @@ export type AddApiKeyButtonProps = {
disabled?: boolean
onUpdate?: () => void
formSchemas?: FormSchema[]
/**
* If provided, clicking the button calls this callback instead of mounting
* the modal inline. Use this when the button lives inside a Popover that
* would unmount the modal on outside-click; the parent should render the
* ApiKeyModal at a level above the Popover.
*/
onClick?: () => void
}
const AddApiKeyButton = ({
pluginPayload,
@ -23,25 +30,28 @@ const AddApiKeyButton = ({
disabled,
onUpdate,
formSchemas = [],
onClick,
}: AddApiKeyButtonProps) => {
const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false)
const [isApiKeyModalMounted, setIsApiKeyModalMounted] = useState(false)
const handleClick = onClick ?? (() => {
setIsApiKeyModalMounted(true)
setIsApiKeyModalOpen(true)
})
return (
<>
<Button
className="w-full"
variant={buttonVariant}
onClick={() => {
setIsApiKeyModalMounted(true)
setIsApiKeyModalOpen(true)
}}
onClick={handleClick}
disabled={disabled}
>
{buttonText}
</Button>
{
isApiKeyModalMounted && (
// Only mount the modal here when in uncontrolled mode (no onClick prop).
!onClick && isApiKeyModalMounted && (
<ApiKeyModal
open={isApiKeyModalOpen}
onOpenChange={setIsApiKeyModalOpen}

View File

@ -18,6 +18,9 @@ import { EncryptedBottom } from '@/app/components/base/encrypted-bottom'
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
import { FormTypeEnum } from '@/app/components/base/form/types'
import Loading from '@/app/components/base/loading'
import PermissionSelector from '@/app/components/base/permission-selector'
import { PermissionLevel } from '@/models/permission'
import { useMembers } from '@/service/use-common'
import { ReadmeEntrance } from '../../readme-panel/entrance'
import {
useAddPluginCredentialHook,
@ -56,6 +59,15 @@ const ApiKeyModal = ({
setDoingAction(value)
}, [])
const { data = [], isLoading } = useGetPluginCredentialSchemaHook(pluginPayload, CredentialTypeEnum.API_KEY)
const [permission, setPermission] = useState<PermissionLevel | undefined>(
(editValues?.__visibility__ as PermissionLevel) ?? PermissionLevel.allTeamMembers,
)
const [selectedMemberIDs, setSelectedMemberIDs] = useState<string[]>(
(editValues?.__partial_member_list__ as string[]) ?? [],
)
// Only need member list when creating (the permission selector is hidden on edit).
const { data: membersData } = useMembers()
const memberList = membersData?.accounts ?? []
const mergedData = useMemo(() => {
if (formSchemasFromProps?.length)
return formSchemasFromProps
@ -98,10 +110,14 @@ const ApiKeyModal = ({
const {
__name__,
__credential_id__,
__visibility__,
__partial_member_list__,
__created_by__,
...restValues
} = values
handleSetDoingAction(true)
// Visibility is settable only at creation. On edit we don't send it.
if (editValues) {
await updatePluginCredential({
credentials: restValues,
@ -110,10 +126,17 @@ const ApiKeyModal = ({
})
}
else {
const permissionPayload = {
visibility: permission,
...(permission === PermissionLevel.partialMembers
? { partial_member_list: selectedMemberIDs.map(id => ({ user_id: id })) }
: {}),
}
await addPluginCredential({
credentials: restValues,
type: CredentialTypeEnum.API_KEY,
name: __name__ || '',
...permissionPayload,
})
}
toast.success(t('api.actionSuccess', { ns: 'common' }))
@ -125,7 +148,7 @@ const ApiKeyModal = ({
finally {
handleSetDoingAction(false)
}
}, [addPluginCredential, onClose, onOpenChange, onUpdate, updatePluginCredential, t, editValues, handleSetDoingAction])
}, [addPluginCredential, onClose, onOpenChange, onUpdate, updatePluginCredential, t, editValues, handleSetDoingAction, permission, selectedMemberIDs])
const isDisabled = disabled || isLoading || doingAction
const handleOpenChange = useCallback((nextOpen: boolean) => {
@ -176,6 +199,22 @@ const ApiKeyModal = ({
/>
)
}
{!isLoading && !editValues && (
<div className="mt-4 px-1">
<div className="mb-1 system-sm-semibold text-text-secondary">
{t('auth.whoCanUse', { ns: 'plugin' })}
</div>
<PermissionSelector
disabled={disabled}
permission={permission}
value={selectedMemberIDs}
memberList={memberList}
onChange={v => setPermission(v)}
onMemberSelect={setSelectedMemberIDs}
hidePartialMembers
/>
</div>
)}
</div>
<div className="flex shrink-0 justify-between p-6 pt-5">
<div />

View File

@ -20,6 +20,13 @@ type AuthorizeProps = {
disabled?: boolean
onUpdate?: () => void
notAllowCustomCredential?: boolean
/**
* If provided, the API-key button delegates modal-opening to the parent
* instead of rendering it inline. Used when this Authorize is mounted
* inside a Popover whose outside-click handler would otherwise unmount
* the modal.
*/
onApiKeyClick?: () => void
}
const Authorize = ({
pluginPayload,
@ -30,6 +37,7 @@ const Authorize = ({
disabled,
onUpdate,
notAllowCustomCredential,
onApiKeyClick,
}: AuthorizeProps) => {
const { t } = useTranslation()
const oAuthButtonProps: AddOAuthButtonProps = useMemo(() => {
@ -57,14 +65,16 @@ const Authorize = ({
pluginPayload,
buttonVariant: 'secondary',
buttonText: !canOAuth ? t('auth.useApiAuth', { ns: 'plugin' }) : t('auth.addApi', { ns: 'plugin' }),
onClick: onApiKeyClick,
}
}
return {
pluginPayload,
buttonText: !canOAuth ? t('auth.useApiAuth', { ns: 'plugin' }) : t('auth.addApi', { ns: 'plugin' }),
buttonVariant: !canOAuth ? 'primary' : 'secondary-accent',
onClick: onApiKeyClick,
}
}, [canOAuth, theme, pluginPayload, t])
}, [canOAuth, theme, pluginPayload, t, onApiKeyClick])
const OAuthButton = useMemo(() => {
const Item = (

View File

@ -37,7 +37,7 @@ const AuthorizedInNode = ({
disabled,
invalidPluginCredentialInfo,
notAllowCustomCredential,
} = usePluginAuth(pluginPayload, true)
} = usePluginAuth(pluginPayload, true, credentialId ? [credentialId] : undefined)
const renderTrigger = useCallback((open?: boolean) => {
let label = ''
let removed = false

View File

@ -1,9 +1,18 @@
import type { Credential } from '../../types'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CredentialTypeEnum } from '../../types'
import Item from '../item'
// Item uses useAppContextWithSelector(state => state.userProfile) for the
// borrowed-row heuristic; provide a minimal mock so the selector resolves.
const mockUserProfile = { id: 'test-user', name: 'Test User', email: 'test@example.com', avatar_url: '' }
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { userProfile: typeof mockUserProfile }) => unknown) =>
selector({ userProfile: mockUserProfile }),
}))
// ==================== Test Utilities ====================
const createCredential = (overrides: Partial<Credential> = {}): Credential => ({
@ -53,15 +62,17 @@ describe('Item Component', () => {
render(<Item credential={credential} />)
expect(screen.getByText('Enterprise')).toBeInTheDocument()
expect(screen.getByText('plugin.auth.enterprise')).toBeInTheDocument()
})
it('should not render enterprise badge when from_enterprise is false', () => {
const credential = createCredential({ from_enterprise: false })
it('should not render personal/shared badge — the Personal/Shared tag was removed per product feedback', () => {
const credential = createCredential({ from_enterprise: false, visibility: 'only_me' })
render(<Item credential={credential} />)
expect(screen.queryByText('Enterprise')).not.toBeInTheDocument()
expect(screen.queryByText('plugin.auth.personal')).not.toBeInTheDocument()
expect(screen.queryByText('plugin.auth.shared')).not.toBeInTheDocument()
expect(screen.queryByText('plugin.auth.enterprise')).not.toBeInTheDocument()
})
it('should render selected icon when showSelectedIcon is true and credential is selected', () => {

View File

@ -142,6 +142,13 @@ const Authorized = ({
if (!open)
pendingOperationCredentialIdRef.current = null
}, [])
// Lifted state for the "+ Add API Key" modal so it isn't unmounted when the
// popover closes due to outside-click detection on the modal's portal.
const [isAddApiKeyOpen, setIsAddApiKeyOpen] = useState(false)
const handleAddApiKeyClick = useCallback(() => {
setMergedIsOpen(false)
setIsAddApiKeyOpen(true)
}, [setMergedIsOpen])
const handleRemove = useCallback(() => {
setDeleteCredentialId(pendingOperationCredentialIdRef.current)
}, [])
@ -334,6 +341,7 @@ const Authorized = ({
canApiKey={canApiKey}
disabled={disabled}
onUpdate={onUpdate}
onApiKeyClick={handleAddApiKeyClick}
/>
</div>
</>
@ -371,6 +379,18 @@ const Authorized = ({
/>
)
}
{
isAddApiKeyOpen && (
<ApiKeyModal
open={isAddApiKeyOpen}
onOpenChange={setIsAddApiKeyOpen}
pluginPayload={pluginPayload}
onClose={() => setIsAddApiKeyOpen(false)}
disabled={disabled || doingAction}
onUpdate={onUpdate}
/>
)
}
</>
)
}

View File

@ -8,6 +8,7 @@ import {
RiDeleteBinLine,
RiEditLine,
RiEqualizer2Line,
RiInformationLine,
} from '@remixicon/react'
import {
memo,
@ -18,13 +19,14 @@ import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge'
import Input from '@/app/components/base/input'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { CredentialTypeEnum } from '../types'
type ItemProps = {
credential: Credential
disabled?: boolean
onDelete?: (id: string) => void
onEdit?: (id: string, values: Record<string, any>) => void
onEdit?: (id: string, values: Record<string, unknown>) => void
onSetDefault?: (id: string) => void
onRename?: (payload: {
credential_id: string
@ -57,6 +59,18 @@ const Item = ({
const [renaming, setRenaming] = useState(false)
const [renameValue, setRenameValue] = useState(credential.name)
const isOAuth = credential.credential_type === CredentialTypeEnum.OAUTH2
const isPersonal = credential.visibility === 'only_me'
const userProfile = useAppContextWithSelector(state => state.userProfile)
// Borrowed-from-teammate: the backend explicitly flagged this row as another member's
// only_me credential, returned only because the current node still references it.
// Fallback heuristic (created_by mismatch on a selected row) is kept for backends
// that don't yet emit the flag.
const isSelected = showSelectedIcon && selectedCredentialId === credential.id
const isConfiguredByOther
= !!credential.created_by && !!userProfile?.id && credential.created_by !== userProfile.id
const isBorrowed
= !!credential.from_other_member || (isSelected && isConfiguredByOther && isPersonal)
const showSwitchAwayHint = isBorrowed
const showAction = useMemo(() => {
return !(disableRename && disableEdit && disableDelete && disableSetDefault)
}, [disableRename, disableEdit, disableDelete, disableSetDefault])
@ -147,9 +161,25 @@ const Item = ({
)
}
{
credential.from_enterprise && (
showSwitchAwayHint && (
<Tooltip>
<TooltipTrigger
render={(
<div className="ml-2 flex shrink-0 cursor-help items-center text-text-tertiary">
<RiInformationLine className="size-4" />
</div>
)}
/>
<TooltipContent>
{t('auth.onlyAtCreationHintTooltip', { ns: 'plugin' })}
</TooltipContent>
</Tooltip>
)
}
{
!showSwitchAwayHint && credential.from_enterprise && (
<Badge className="shrink-0">
Enterprise
{t('auth.enterprise', { ns: 'plugin' })}
</Badge>
)
}
@ -157,7 +187,7 @@ const Item = ({
showAction && !renaming && (
<div className="ml-2 hidden shrink-0 items-center group-hover:flex">
{
!credential.is_default && !disableSetDefault && !credential.not_allowed_to_use && (
!credential.is_default && !disableSetDefault && !credential.not_allowed_to_use && !isBorrowed && (
<Button
size="small"
disabled={disabled}
@ -171,7 +201,7 @@ const Item = ({
)
}
{
!disableRename && !credential.from_enterprise && !credential.not_allowed_to_use && (
!disableRename && !credential.from_enterprise && !credential.not_allowed_to_use && !isBorrowed && (
<Tooltip>
<TooltipTrigger
render={(
@ -194,7 +224,7 @@ const Item = ({
)
}
{
!isOAuth && !disableEdit && !credential.from_enterprise && !credential.not_allowed_to_use && (
!isOAuth && !disableEdit && !credential.from_enterprise && !credential.not_allowed_to_use && !isBorrowed && (
<Tooltip>
<TooltipTrigger
render={(
@ -223,7 +253,7 @@ const Item = ({
)
}
{
!disableDelete && !credential.from_enterprise && (
!disableDelete && !credential.from_enterprise && !isBorrowed && (
<Tooltip>
<TooltipTrigger
render={(

View File

@ -16,9 +16,21 @@ import {
import { useInvalidToolsByType } from '@/service/use-tools'
import { useGetApi } from './use-get-api'
export const useGetPluginCredentialInfoHook = (pluginPayload: PluginPayload, enable?: boolean) => {
export const useGetPluginCredentialInfoHook = (
pluginPayload: PluginPayload,
enable?: boolean,
includeCredentialIds?: string[],
) => {
const apiMap = useGetApi(pluginPayload)
return useGetPluginCredentialInfo(enable ? apiMap.getCredentialInfo : '')
const ids = (includeCredentialIds ?? []).filter(Boolean)
let url = enable ? apiMap.getCredentialInfo : ''
if (url && ids.length > 0) {
const qs = new URLSearchParams()
for (const id of ids)
qs.append('include_credential_ids', id)
url = url + (url.includes('?') ? '&' : '?') + qs.toString()
}
return useGetPluginCredentialInfo(url)
}
export const useDeletePluginCredentialHook = (pluginPayload: PluginPayload) => {

View File

@ -6,8 +6,12 @@ import {
useInvalidPluginCredentialInfoHook,
} from './use-credential'
export const usePluginAuth = (pluginPayload: PluginPayload, enable?: boolean) => {
const { data } = useGetPluginCredentialInfoHook(pluginPayload, enable)
export const usePluginAuth = (
pluginPayload: PluginPayload,
enable?: boolean,
includeCredentialIds?: string[],
) => {
const { data } = useGetPluginCredentialInfoHook(pluginPayload, enable, includeCredentialIds)
const { isCurrentWorkspaceManager } = useAppContext()
const isAuthorized = !!data?.credentials.length
const canOAuth = data?.supported_credential_types.includes(CredentialTypeEnum.OAUTH2)

View File

@ -37,7 +37,7 @@ const PluginAuthInAgent = ({
disabled,
invalidPluginCredentialInfo,
notAllowCustomCredential,
} = usePluginAuth(pluginPayload, true)
} = usePluginAuth(pluginPayload, true, credentialId ? [credentialId] : undefined)
const extraAuthorizationItems: Credential[] = [
{

View File

@ -29,8 +29,18 @@ export type Credential = {
provider: string
credential_type?: CredentialTypeEnum
is_default: boolean
credentials?: Record<string, any>
credentials?: Record<string, unknown>
isWorkspaceDefault?: boolean
from_enterprise?: boolean
not_allowed_to_use?: boolean
visibility?: string
created_by?: string
partial_member_list?: string[]
/**
* True when the backend returned this credential only because the current node
* still references it, but the visibility filter would normally hide it
* (another member's `only_me` credential). The row renders the "切换后不可再选回"
* hint and locks rename/edit/delete/set-default actions.
*/
from_other_member?: boolean
}

View File

@ -21,18 +21,24 @@
"auth.customCredentialUnavailable": "Custom credentials currently unavailable",
"auth.default": "Default",
"auth.emptyAuth": "Please configure authentication",
"auth.enterprise": "Enterprise",
"auth.oauthClient": "OAuth Client",
"auth.oauthClientSettings": "OAuth Client Settings",
"auth.onlyAtCreationHint": "Cannot be selected again after switching",
"auth.onlyAtCreationHintTooltip": "Configured by other members · Cannot be selected again after switching.",
"auth.personal": "Personal",
"auth.saveAndAuth": "Save and Authorize",
"auth.saveOnly": "Save only",
"auth.setDefault": "Set as default",
"auth.setupOAuth": "Setup OAuth Client",
"auth.shared": "Shared",
"auth.unavailable": "Unavailable",
"auth.useApi": "Use API Key",
"auth.useApiAuth": "API Key Authorization Configuration",
"auth.useApiAuthDesc": "After configuring credentials, all members within the workspace can use this tool when orchestrating applications.",
"auth.useOAuth": "Use OAuth",
"auth.useOAuthAuth": "Use OAuth Authorization",
"auth.whoCanUse": "Who can use",
"auth.workspaceDefault": "Workspace Default",
"autoUpdate.automaticUpdates": "Automatic updates",
"autoUpdate.changeTimezone": "To change time zone, go to <setTimezone>Settings</setTimezone>",

View File

@ -21,18 +21,24 @@
"auth.customCredentialUnavailable": "自定义凭据当前不可用",
"auth.default": "默认",
"auth.emptyAuth": "请配置凭据",
"auth.enterprise": "企业",
"auth.oauthClient": "OAuth 客户端",
"auth.oauthClientSettings": "OAuth 客户端设置",
"auth.onlyAtCreationHint": "切换后不可再选回",
"auth.onlyAtCreationHintTooltip": "由其他成员配置 · 切换后不可再选回",
"auth.personal": "个人",
"auth.saveAndAuth": "保存并授权",
"auth.saveOnly": "仅保存",
"auth.setDefault": "设为默认",
"auth.setupOAuth": "设置 OAuth 客户端",
"auth.shared": "团队共享",
"auth.unavailable": "不可用",
"auth.useApi": "使用 API Key",
"auth.useApiAuth": "API Key 授权配置",
"auth.useApiAuthDesc": "配置凭据后,工作区内的所有成员在编排应用时都可以使用此工具。",
"auth.useOAuth": "使用 OAuth",
"auth.useOAuthAuth": "使用 OAuth 授权",
"auth.whoCanUse": "谁可以使用",
"auth.workspaceDefault": "工作区默认",
"autoUpdate.automaticUpdates": "自动更新",
"autoUpdate.changeTimezone": "要更改时区,请前往<setTimezone>设置</setTimezone>",

View File

@ -8,6 +8,7 @@ import type { AppIconType, AppModeEnum, RetrievalConfig, TransferMethod } from '
import type { SegmentImportStatus } from '@/types/dataset'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { ExternalKnowledgeBase, General, ParentChild, Qa } from '@/app/components/base/icons/src/public/knowledge/dataset-card'
import { PermissionLevel } from './permission'
export enum DataSourceType {
FILE = 'upload_file',
@ -15,11 +16,10 @@ export enum DataSourceType {
WEB = 'website_crawl',
}
export enum DatasetPermission {
onlyMe = 'only_me',
allTeamMembers = 'all_team_members',
partialMembers = 'partial_members',
}
// Re-export PermissionLevel as DatasetPermission for backward compatibility
export const DatasetPermission = PermissionLevel
export type DatasetPermission = PermissionLevel
export enum ChunkingMode {
text = 'text_model', // General text

11
web/models/permission.ts Normal file
View File

@ -0,0 +1,11 @@
/**
* Shared permission levels for resources (datasets, credentials, etc.).
* Mirrors PermissionEnum from api/models/enums.py.
*/
export const PermissionLevel = {
onlyMe: 'only_me',
allTeamMembers: 'all_team_members',
partialMembers: 'partial_members',
} as const
export type PermissionLevel = typeof PermissionLevel[keyof typeof PermissionLevel]

View File

@ -52,6 +52,8 @@ export const useAddPluginCredential = (
credentials: Record<string, any>
type: CredentialTypeEnum
name?: string
visibility?: string
partial_member_list?: Array<{ user_id: string }>
}) => {
return post(url, { body: params })
},
@ -66,6 +68,8 @@ export const useUpdatePluginCredential = (
credential_id: string
credentials?: Record<string, any>
name?: string
visibility?: string
partial_member_list?: Array<{ user_id: string }>
}) => {
return post(url, { body: params })
},