Files
dify/api/services/tools/builder_credential_reference.py
Charles Yao 231e423431 Integrate validated Builder backend testing and CI repairs
Track backend integration, Ruff cleanup and migration reconciliation in ENG-1271. Retain required copy contracts without implementing frontend interactions.
2026-10-20 15:40:06 -07:00

326 lines
13 KiB
Python

"""Metadata-only selection and revisions for one explicit Builder tool credential.
``BuiltinToolManageService.resolve_builder_reference`` owns this companion and
is its only caller. It reads stored rows in the caller's session and never
decrypts, validates, refreshes or invokes anything. Revisions are
domain-separated HMAC-SHA256 over the exact stored metadata, including
ciphertext, so a same-timestamp rotation still changes them; the HMAC reveals
nothing about its inputs. ``is_default`` is deliberately excluded: changing the
workspace default never switches an explicitly pinned credential.
Access follows the native tenant- and actor-scoped credential policy in the
caller's session: the owner, legacy NULL-owner and all-team rows are eligible;
another member's ``only_me`` row never is; ``partial_members`` needs the acting member's active
builtin-tool grant in this tenant. That effective access is bound into the
opaque ``CredentialReference.revision`` together with the row primitive, so a
revoke-then-regrant changes it while other members' grants do not. Later phase
validation compares that full reference revision, never the row primitive.
OAuth client precedence mirrors ``BuiltinToolManageService.get_oauth_client``:
an enabled tenant client first, otherwise the system client only for a plugin
with positive server-resolved verification. Anything else needs setup.
"""
import hashlib
import hmac
import uuid
from dataclasses import dataclass, field
from typing import Literal
from pydantic import ValidationError
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
from core.dify_builder.models import Actor
from core.dify_builder.real_call_consent import CredentialReference
from core.plugin.entities.plugin_daemon import CredentialType
from models.credential_permission import CredentialPermission
from models.credential_permission import CredentialType as CredentialPermissionType
from models.enums import PermissionEnum
from models.provider_ids import ToolProviderID
from models.tools import BuiltinToolProvider, ToolOAuthSystemClient, ToolOAuthTenantClient
from services.dify_builder.application.execution_policy import BuilderExecutionPolicyError, canonical_json
OAuthClientSource = Literal["tenant", "system", "missing"]
AccessBasis = Literal["owner", "legacy_shared", "all_team", "partial_grant"]
_CREDENTIAL_DOMAIN = b"dify-builder/credential-reference/credential/v1\x00"
_REFERENCE_DOMAIN = b"dify-builder/credential-reference/reference/v1\x00"
_CLIENT_DOMAIN = b"dify-builder/credential-reference/oauth-client/v1\x00"
_CLIENT_SOURCES = frozenset({"tenant", "system", "missing"})
def _keyed(domain: bytes, value: dict[str, object], key: bytes) -> str:
if type(key) is not bytes or not key:
raise BuilderExecutionPolicyError("invalid_revision_key")
return hmac.new(key, domain + canonical_json(value), hashlib.sha256).hexdigest()
def credential_revision(row: BuiltinToolProvider, *, key: bytes) -> str:
"""Opaque revision of every stored field that changes what the credential is or who may use it."""
return _keyed(
_CREDENTIAL_DOMAIN,
{
"id": row.id,
"tenant_id": row.tenant_id,
"provider": row.provider,
"owner": row.user_id,
# StrEnum columns render their stored value.
"visibility": str(row.visibility),
"alias": row.name,
"credential_type": str(row.credential_type),
"expires_at": row.expires_at,
"encrypted_credentials": row.encrypted_credentials,
},
key,
)
def oauth_client_revision(
*,
source: OAuthClientSource,
row_id: str | None,
encrypted_params: str | None,
enabled: bool,
tenant_id: str,
plugin_id: str,
provider: str,
key: bytes,
) -> str:
"""Opaque revision of the selected OAuth client row, or of its absence (``missing``)."""
if source not in _CLIENT_SOURCES:
raise BuilderExecutionPolicyError("invalid_oauth_client_source")
return _keyed(
_CLIENT_DOMAIN,
{
"source": source,
"row_id": row_id,
"encrypted_params": encrypted_params,
"enabled": enabled,
"tenant_id": tenant_id,
"plugin_id": plugin_id,
"provider": provider,
},
key,
)
@dataclass(frozen=True, slots=True)
class GrantSnapshot:
"""Current metadata of one applicable partial-member grant row."""
id: str
credential_id: str
credential_type: str
account_id: str
tenant_id: str
has_permission: bool
created_at: str | None
@dataclass(frozen=True, slots=True)
class CredentialAccessSnapshot:
"""Why the acting member may use the row now; private, bound into the reference revision."""
basis: AccessBasis
account_id: str
tenant_id: str
grants: tuple[GrantSnapshot, ...]
@property
def grant_ids(self) -> tuple[str, ...]:
return tuple(grant.id for grant in self.grants)
def _effective_access(db: Session, row: BuiltinToolProvider, actor: Actor) -> CredentialAccessSnapshot:
"""Native documented access for the acting member, read in the caller's session."""
grants: tuple[GrantSnapshot, ...] = ()
if row.user_id == actor.account_id:
basis: AccessBasis = "owner"
elif row.user_id is None:
basis = "legacy_shared"
elif str(row.visibility) == PermissionEnum.ALL_TEAM:
basis = "all_team"
elif str(row.visibility) == PermissionEnum.PARTIAL_TEAM:
rows = db.scalars(
select(CredentialPermission)
.where(
CredentialPermission.credential_id == row.id,
CredentialPermission.credential_type == CredentialPermissionType.BUILTIN_TOOL_PROVIDER,
CredentialPermission.account_id == actor.account_id,
CredentialPermission.tenant_id == actor.tenant_id,
CredentialPermission.has_permission.is_(True),
)
.order_by(CredentialPermission.id)
).all()
if not rows:
raise BuilderExecutionPolicyError("credential_reference_unavailable")
basis = "partial_grant"
grants = tuple(
GrantSnapshot(
id=grant.id,
credential_id=grant.credential_id,
credential_type=grant.credential_type,
account_id=grant.account_id,
tenant_id=grant.tenant_id,
has_permission=grant.has_permission,
created_at=grant.created_at.isoformat() if grant.created_at is not None else None,
)
for grant in rows
)
else:
# Another member's only_me (even with a stale grant) or an unknown visibility.
raise BuilderExecutionPolicyError("credential_reference_unavailable")
return CredentialAccessSnapshot(basis=basis, account_id=actor.account_id, tenant_id=actor.tenant_id, grants=grants)
def _reference_revision(row: BuiltinToolProvider, access: CredentialAccessSnapshot, *, key: bytes) -> str:
"""Opaque revision of the row primitive and the actor's effective access to it."""
return _keyed(
_REFERENCE_DOMAIN,
{
"credential": credential_revision(row, key=key),
"access": {
"basis": access.basis,
"account_id": access.account_id,
"tenant_id": access.tenant_id,
"grants": [
{
"id": grant.id,
"credential_id": grant.credential_id,
"credential_type": grant.credential_type,
"account_id": grant.account_id,
"tenant_id": grant.tenant_id,
"has_permission": grant.has_permission,
"created_at": grant.created_at,
}
for grant in access.grants
],
},
},
key,
)
@dataclass(frozen=True, slots=True)
class OAuthClientSnapshot:
"""Exact selected client row for later exact resolution; ciphertext never renders."""
source: OAuthClientSource
row_id: str | None
encrypted_params: str | None = field(repr=False)
enabled: bool
@dataclass(frozen=True, slots=True)
class BuilderCredentialSnapshot:
"""Private ephemeral native-owner selection. Only ``reference`` may leave the owner."""
reference: CredentialReference
encrypted_credentials: str | None = field(repr=False)
client: OAuthClientSnapshot
access: CredentialAccessSnapshot
def _select_client(
db: Session, *, tenant_id: str, provider: ToolProviderID, verified_plugin: bool
) -> OAuthClientSnapshot:
tenant_client = db.scalar(
select(ToolOAuthTenantClient).where(
ToolOAuthTenantClient.tenant_id == tenant_id,
ToolOAuthTenantClient.plugin_id == provider.plugin_id,
ToolOAuthTenantClient.provider == provider.provider_name,
ToolOAuthTenantClient.enabled.is_(True),
)
)
if tenant_client is not None:
# An enabled tenant client wins even when unusable; never fall back past it.
if not tenant_client.encrypted_oauth_params:
raise BuilderExecutionPolicyError("needs_setup")
return OAuthClientSnapshot("tenant", tenant_client.id, tenant_client.encrypted_oauth_params, True)
# Only positive server-resolved verification makes the system client eligible.
if verified_plugin is True:
system_client = db.scalar(
select(ToolOAuthSystemClient).where(
ToolOAuthSystemClient.plugin_id == provider.plugin_id,
ToolOAuthSystemClient.provider == provider.provider_name,
)
)
if system_client is not None and system_client.encrypted_oauth_params:
return OAuthClientSnapshot("system", system_client.id, system_client.encrypted_oauth_params, True)
raise BuilderExecutionPolicyError("needs_setup")
def _is_canonical_uuid(value: object) -> bool:
"""Exactly the lowercase hyphenated form ``str(uuid4())`` stores; no braces, URN, case or whitespace."""
if type(value) is not str or len(value) != 36:
return False
try:
return str(uuid.UUID(value)) == value
except ValueError:
return False
def select_builder_credential(
db: Session,
*,
actor: Actor,
provider_id: str,
credential_id: str,
verified_plugin: bool,
revision_key: bytes,
) -> BuilderCredentialSnapshot:
"""Select exactly ``credential_id`` as the acting member sees it, in the caller's session."""
if type(revision_key) is not bytes or not revision_key:
raise BuilderExecutionPolicyError("invalid_revision_key")
# The row ID is a native PostgreSQL UUID: anything but the canonical stored
# form would reach SQL as a DataError, so refuse it before any query.
if not _is_canonical_uuid(credential_id):
raise BuilderExecutionPolicyError("credential_reference_unavailable")
query = select(BuiltinToolProvider).where(
BuiltinToolProvider.id == credential_id,
BuiltinToolProvider.tenant_id == actor.tenant_id,
)
row = db.scalar(query)
if row is None:
raise BuilderExecutionPolicyError("credential_reference_unavailable")
access = _effective_access(db, row, actor)
try:
provider = ToolProviderID(row.provider)
except (ValueError, NotFound):
raise BuilderExecutionPolicyError("credential_reference_unavailable") from None
# Full canonical equality; never a short-name fallback.
if str(provider) != provider_id:
raise BuilderExecutionPolicyError("credential_reference_unavailable")
if str(row.credential_type) == CredentialType.OAUTH2:
client = _select_client(db, tenant_id=actor.tenant_id, provider=provider, verified_plugin=verified_plugin)
else:
client = OAuthClientSnapshot("missing", None, None, False)
try:
reference = CredentialReference(
credential_id=row.id,
provider_id=provider_id,
alias=row.name,
credential_type=str(row.credential_type),
visibility=str(row.visibility),
revision=_reference_revision(row, access, key=revision_key),
oauth_client_revision=oauth_client_revision(
source=client.source,
row_id=client.row_id,
encrypted_params=client.encrypted_params,
enabled=client.enabled,
tenant_id=actor.tenant_id,
plugin_id=provider.plugin_id,
provider=provider.provider_name,
key=revision_key,
),
expires_at=row.expires_at,
)
except ValidationError:
raise BuilderExecutionPolicyError("credential_reference_unavailable") from None
return BuilderCredentialSnapshot(
reference=reference, encrypted_credentials=row.encrypted_credentials, client=client, access=access
)