mirror of
https://github.com/langgenius/dify.git
synced 2026-07-19 16:44:00 -04:00
Merge branch 'main' into zhaohao1004/archive-db-resilience
This commit is contained in:
+217
-69
@@ -1,12 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import click
|
||||
from sqlalchemy import select
|
||||
|
||||
from configs import dify_config
|
||||
from core.db.session_factory import session_factory
|
||||
from models import TenantAccountJoin, TenantAccountRole
|
||||
from services.enterprise.rbac_service import ListOption, RBACService
|
||||
|
||||
_LEGACY_ROLE_TO_BUILTIN_TAG = {
|
||||
TenantAccountRole.OWNER.value: "owner",
|
||||
TenantAccountRole.ADMIN.value: "admin",
|
||||
TenantAccountRole.EDITOR.value: "editor",
|
||||
TenantAccountRole.NORMAL.value: "normal",
|
||||
TenantAccountRole.DATASET_OPERATOR.value: "dataset_operator",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_builtin_role_ids(tenant_id: str, operator_account_id: str) -> dict[str, str]:
|
||||
"""Resolve every legacy workspace role to the current tenant's builtin RBAC role id.
|
||||
|
||||
The migration replays the old `TenantAccountJoin.role` values onto the
|
||||
RBAC member-role binding API. Builtin RBAC roles are tenant-scoped and
|
||||
identified by runtime ids, so the command must look them up per tenant.
|
||||
"""
|
||||
roles = RBACService.Roles.list(
|
||||
tenant_id=tenant_id,
|
||||
account_id=operator_account_id,
|
||||
options=ListOption(page_number=1, results_per_page=100),
|
||||
).data
|
||||
role_id_by_tag = {
|
||||
role.role_tag: role.id
|
||||
for role in roles
|
||||
if role.is_builtin and role.category == "global_system_default" and role.role_tag
|
||||
}
|
||||
resolved: dict[str, str] = {}
|
||||
for legacy_role, expected_builtin_tag in _LEGACY_ROLE_TO_BUILTIN_TAG.items():
|
||||
role_id = role_id_by_tag.get(expected_builtin_tag)
|
||||
if expected_builtin_tag == "dataset_operator" and not dify_config.DATASET_OPERATOR_ENABLED:
|
||||
continue
|
||||
if not role_id:
|
||||
raise ValueError(f"Builtin RBAC role not found for tenant={tenant_id}, legacy_role={legacy_role}")
|
||||
resolved[legacy_role] = role_id
|
||||
return resolved
|
||||
|
||||
|
||||
def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_role: str) -> str:
|
||||
"""Resolve a legacy workspace role to the current tenant's builtin RBAC role id.
|
||||
@@ -15,26 +55,86 @@ def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_ro
|
||||
RBAC member-role binding API. Builtin RBAC roles are tenant-scoped and
|
||||
identified by runtime ids, so the command must look them up per tenant.
|
||||
"""
|
||||
expected_builtin_tag = {
|
||||
TenantAccountRole.OWNER.value: "owner",
|
||||
TenantAccountRole.ADMIN.value: "admin",
|
||||
TenantAccountRole.EDITOR.value: "editor",
|
||||
TenantAccountRole.NORMAL.value: "normal",
|
||||
TenantAccountRole.DATASET_OPERATOR.value: "dataset_operator",
|
||||
}.get(legacy_role)
|
||||
if not expected_builtin_tag:
|
||||
if legacy_role not in _LEGACY_ROLE_TO_BUILTIN_TAG:
|
||||
raise ValueError(f"Unsupported legacy workspace role: {legacy_role}")
|
||||
|
||||
roles = RBACService.Roles.list(
|
||||
return _resolve_builtin_role_ids(tenant_id, operator_account_id)[legacy_role]
|
||||
|
||||
|
||||
def _iter_tenant_member_batches(
|
||||
tenant_id: str | None,
|
||||
*,
|
||||
db_batch_size: int,
|
||||
api_batch_size: int,
|
||||
) -> Iterator[tuple[str, str, list[tuple[str, str]]]]:
|
||||
"""Yield legacy member roles in tenant-scoped API-sized batches.
|
||||
|
||||
Rows are projected to primitive values and streamed from the database, so
|
||||
the command never materializes every TenantAccountJoin ORM object. The
|
||||
iterator only keeps one tenant's API-sized batches in memory while it
|
||||
finds that tenant's owner account.
|
||||
"""
|
||||
with session_factory.create_session() as session:
|
||||
stmt = (
|
||||
select(TenantAccountJoin.tenant_id, TenantAccountJoin.account_id, TenantAccountJoin.role)
|
||||
.order_by(TenantAccountJoin.tenant_id.asc(), TenantAccountJoin.id.asc())
|
||||
.execution_options(yield_per=db_batch_size)
|
||||
)
|
||||
if tenant_id:
|
||||
stmt = stmt.where(TenantAccountJoin.tenant_id == tenant_id)
|
||||
|
||||
current_tenant_id: str | None = None
|
||||
owner_account_id: str | None = None
|
||||
batches: list[list[tuple[str, str]]] = []
|
||||
batch: list[tuple[str, str]] = []
|
||||
|
||||
def flush_current_tenant() -> Iterator[tuple[str, str, list[tuple[str, str]]]]:
|
||||
if current_tenant_id is None:
|
||||
return
|
||||
if batch:
|
||||
batches.append(batch.copy())
|
||||
if not owner_account_id:
|
||||
raise ValueError(f"Workspace owner not found for tenant={current_tenant_id}")
|
||||
for item in batches:
|
||||
yield current_tenant_id, owner_account_id, item
|
||||
|
||||
for row in session.execute(stmt):
|
||||
workspace_id = str(row.tenant_id)
|
||||
if current_tenant_id is not None and workspace_id != current_tenant_id:
|
||||
yield from flush_current_tenant()
|
||||
owner_account_id = None
|
||||
batches = []
|
||||
batch = []
|
||||
current_tenant_id = workspace_id
|
||||
account_id = str(row.account_id)
|
||||
role = str(row.role)
|
||||
if role == TenantAccountRole.OWNER.value:
|
||||
owner_account_id = account_id
|
||||
batch.append((account_id, role))
|
||||
if len(batch) >= api_batch_size:
|
||||
batches.append(batch)
|
||||
batch = []
|
||||
|
||||
yield from flush_current_tenant()
|
||||
|
||||
|
||||
def _member_already_has_role(current_roles_by_account_id: dict[str, set[str]], account_id: str, role_id: str) -> bool:
|
||||
return current_roles_by_account_id.get(account_id) == {role_id}
|
||||
|
||||
|
||||
def _replace_member_role(
|
||||
tenant_id: str,
|
||||
operator_account_id: str,
|
||||
member_account_id: str,
|
||||
role_id: str,
|
||||
) -> str:
|
||||
RBACService.MemberRoles.replace(
|
||||
tenant_id=tenant_id,
|
||||
account_id=operator_account_id,
|
||||
options=ListOption(page_number=1, results_per_page=100),
|
||||
).data
|
||||
for role in roles:
|
||||
if role.is_builtin and role.category == "global_system_default" and role.role_tag == expected_builtin_tag:
|
||||
return str(role.id)
|
||||
|
||||
raise ValueError(f"Builtin RBAC role not found for tenant={tenant_id}, legacy_role={legacy_role}")
|
||||
member_account_id=member_account_id,
|
||||
role_ids=[role_id],
|
||||
)
|
||||
return member_account_id
|
||||
|
||||
|
||||
@click.command(
|
||||
@@ -42,7 +142,16 @@ def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_ro
|
||||
)
|
||||
@click.option("--tenant-id", help="Only migrate a single workspace.")
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Preview the migration without writing RBAC bindings.")
|
||||
def migrate_member_roles_to_rbac(tenant_id: str | None, dry_run: bool) -> None:
|
||||
@click.option("--db-batch-size", default=5000, show_default=True, help="Rows fetched per database batch.")
|
||||
@click.option("--api-batch-size", default=200, show_default=True, help="Members checked per RBAC batch_get call.")
|
||||
@click.option("--workers", default=1, show_default=True, help="Concurrent member role replace calls per tenant batch.")
|
||||
def migrate_member_roles_to_rbac(
|
||||
tenant_id: str | None,
|
||||
dry_run: bool,
|
||||
db_batch_size: int,
|
||||
api_batch_size: int,
|
||||
workers: int,
|
||||
) -> None:
|
||||
"""Backfill RBAC member-role bindings from legacy `TenantAccountJoin.role` data.
|
||||
|
||||
This is an offline migration command for workspaces that already have
|
||||
@@ -50,63 +159,102 @@ def migrate_member_roles_to_rbac(tenant_id: str | None, dry_run: bool) -> None:
|
||||
member-role binding store.
|
||||
"""
|
||||
click.echo(click.style("Starting RBAC member-role migration.", fg="green"))
|
||||
if workers < 1:
|
||||
raise click.BadParameter("workers must be >= 1", param_hint="--workers")
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(TenantAccountJoin).order_by(TenantAccountJoin.tenant_id.asc(), TenantAccountJoin.id.asc())
|
||||
if tenant_id:
|
||||
stmt = stmt.where(TenantAccountJoin.tenant_id == tenant_id)
|
||||
tenant_count = 0
|
||||
scanned_count = 0
|
||||
skipped_count = 0
|
||||
migrated_count = 0
|
||||
current_tenant_id: str | None = None
|
||||
role_ids_by_legacy_role: dict[str, str] = {}
|
||||
|
||||
joins = list(session.scalars(stmt).all())
|
||||
for workspace_id, owner_account_id, batch in _iter_tenant_member_batches(
|
||||
tenant_id,
|
||||
db_batch_size=db_batch_size,
|
||||
api_batch_size=api_batch_size,
|
||||
):
|
||||
scanned_count += len(batch)
|
||||
if workspace_id != current_tenant_id:
|
||||
tenant_count += 1
|
||||
current_tenant_id = workspace_id
|
||||
role_ids_by_legacy_role = _resolve_builtin_role_ids(workspace_id, owner_account_id)
|
||||
click.echo(f"tenant={workspace_id}")
|
||||
|
||||
if not joins:
|
||||
current_roles_by_account_id: dict[str, set[str]] = {}
|
||||
if not dry_run:
|
||||
current_roles = RBACService.MemberRoles.batch_get(
|
||||
tenant_id=workspace_id,
|
||||
account_id=owner_account_id,
|
||||
member_account_ids=[account_id for account_id, _ in batch],
|
||||
)
|
||||
current_roles_by_account_id = {
|
||||
item.account_id: {str(role.id) for role in item.roles} for item in current_roles
|
||||
}
|
||||
|
||||
replace_jobs: list[tuple[str, str]] = []
|
||||
for member_account_id, legacy_role in batch:
|
||||
resolved_role_id = role_ids_by_legacy_role.get(legacy_role)
|
||||
if not resolved_role_id:
|
||||
raise ValueError(f"Unsupported legacy workspace role: {legacy_role}")
|
||||
|
||||
if dry_run:
|
||||
click.echo(
|
||||
f"tenant={workspace_id} member={member_account_id} "
|
||||
f"legacy_role={legacy_role} -> rbac_role_id={resolved_role_id}"
|
||||
)
|
||||
continue
|
||||
|
||||
if _member_already_has_role(current_roles_by_account_id, member_account_id, resolved_role_id):
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
replace_jobs.append((member_account_id, resolved_role_id))
|
||||
|
||||
if replace_jobs:
|
||||
if workers == 1:
|
||||
for member_account_id, resolved_role_id in replace_jobs:
|
||||
_replace_member_role(workspace_id, owner_account_id, member_account_id, resolved_role_id)
|
||||
migrated_count += 1
|
||||
else:
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
futures = [
|
||||
executor.submit(
|
||||
_replace_member_role,
|
||||
workspace_id,
|
||||
owner_account_id,
|
||||
member_account_id,
|
||||
resolved_role_id,
|
||||
)
|
||||
for member_account_id, resolved_role_id in replace_jobs
|
||||
]
|
||||
for future in as_completed(futures):
|
||||
future.result()
|
||||
migrated_count += 1
|
||||
|
||||
if scanned_count % 10000 == 0:
|
||||
click.echo(
|
||||
f"progress scanned={scanned_count} migrated={migrated_count} skipped={skipped_count}",
|
||||
err=True,
|
||||
)
|
||||
|
||||
if scanned_count == 0:
|
||||
click.echo(click.style("No workspace members found for migration.", fg="yellow"))
|
||||
return
|
||||
|
||||
owner_account_by_tenant: dict[str, str] = {}
|
||||
resolved_role_ids: dict[tuple[str, str], str] = {}
|
||||
migrated_count = 0
|
||||
|
||||
for join in joins:
|
||||
workspace_id = str(join.tenant_id)
|
||||
member_account_id = str(join.account_id)
|
||||
legacy_role = str(join.role)
|
||||
|
||||
if workspace_id not in owner_account_by_tenant:
|
||||
owner_join = next(
|
||||
(
|
||||
item
|
||||
for item in joins
|
||||
if str(item.tenant_id) == workspace_id and str(item.role) == TenantAccountRole.OWNER.value
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not owner_join:
|
||||
raise ValueError(f"Workspace owner not found for tenant={workspace_id}")
|
||||
owner_account_by_tenant[workspace_id] = str(owner_join.account_id)
|
||||
|
||||
operator_account_id = owner_account_by_tenant[workspace_id]
|
||||
cache_key = (workspace_id, legacy_role)
|
||||
if cache_key not in resolved_role_ids:
|
||||
resolved_role_ids[cache_key] = _resolve_builtin_role_id(workspace_id, operator_account_id, legacy_role)
|
||||
|
||||
resolved_role_id = resolved_role_ids[cache_key]
|
||||
click.echo(
|
||||
f"tenant={workspace_id} member={member_account_id} "
|
||||
f"legacy_role={legacy_role} -> rbac_role_id={resolved_role_id}"
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
RBACService.MemberRoles.replace(
|
||||
tenant_id=workspace_id,
|
||||
account_id=operator_account_id,
|
||||
member_account_id=member_account_id,
|
||||
role_ids=[resolved_role_id],
|
||||
)
|
||||
migrated_count += 1
|
||||
|
||||
if dry_run:
|
||||
click.echo(click.style("Dry run completed. No RBAC bindings were written.", fg="yellow"))
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Dry run completed. Scanned {scanned_count} members across {tenant_count} tenants. "
|
||||
"No RBAC bindings were written.",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
else:
|
||||
click.echo(click.style(f"RBAC member-role migration completed. Migrated {migrated_count} members.", fg="green"))
|
||||
click.echo(
|
||||
click.style(
|
||||
f"RBAC member-role migration completed. Scanned {scanned_count} members across {tenant_count} tenants, "
|
||||
f"migrated {migrated_count}, skipped {skipped_count} already up-to-date.",
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractContextManager, ExitStack
|
||||
from types import TracebackType
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import has_request_context, request
|
||||
|
||||
from core.mcp.client.sse_client import sse_client
|
||||
from core.mcp.client.streamable_client import streamablehttp_client
|
||||
from core.mcp.error import MCPConnectionError
|
||||
@@ -23,10 +26,22 @@ class MCPClient:
|
||||
sse_read_timeout: float | None = None,
|
||||
):
|
||||
self.server_url = server_url
|
||||
self.headers = headers or {}
|
||||
self.headers = headers.copy() if headers else {}
|
||||
self.timeout = timeout
|
||||
self.sse_read_timeout = sse_read_timeout
|
||||
|
||||
# Substitute placeholders with incoming request headers if in a request context
|
||||
if has_request_context() and self.headers:
|
||||
pattern = re.compile(r"\{\{\s*request\.headers?\.(.+?)\s*\}\}", re.IGNORECASE)
|
||||
for key, value in list(self.headers.items()):
|
||||
if isinstance(value, str):
|
||||
|
||||
def replace_func(match):
|
||||
header_name = match.group(1)
|
||||
return request.headers.get(header_name, "")
|
||||
|
||||
self.headers[key] = pattern.sub(replace_func, value)
|
||||
|
||||
# Initialize session and client objects
|
||||
self._session: ClientSession | None = None
|
||||
self._exit_stack = ExitStack()
|
||||
|
||||
+48
-27
@@ -45,34 +45,52 @@ def upgrade():
|
||||
# PostgreSQL 18's `uuidv7` function. This capability is rarely needed in practice, as IDs can be
|
||||
# generated and controlled within the application layer.
|
||||
conn = op.get_bind()
|
||||
|
||||
if _is_pg(conn):
|
||||
# PostgreSQL: Create uuidv7 functions
|
||||
op.execute(sa.text(r"""
|
||||
/* Main function to generate a uuidv7 value with millisecond precision */
|
||||
CREATE FUNCTION uuidv7() RETURNS uuid
|
||||
AS
|
||||
$$
|
||||
-- Replace the first 48 bits of a uuidv4 with the current
|
||||
-- number of milliseconds since 1970-01-01 UTC
|
||||
-- and set the "ver" field to 7 by setting additional bits
|
||||
SELECT encode(
|
||||
set_bit(
|
||||
set_bit(
|
||||
overlay(uuid_send(gen_random_uuid()) placing
|
||||
substring(int8send((extract(epoch from clock_timestamp()) * 1000)::bigint) from
|
||||
3)
|
||||
from 1 for 6),
|
||||
52, 1),
|
||||
53, 1), 'hex')::uuid;
|
||||
$$ LANGUAGE SQL VOLATILE PARALLEL SAFE;
|
||||
|
||||
COMMENT ON FUNCTION uuidv7 IS
|
||||
'Generate a uuid-v7 value with a 48-bit timestamp (millisecond precision) and 74 bits of randomness';
|
||||
if _is_pg(conn):
|
||||
# PostgreSQL: Create uuidv7 functions.
|
||||
# PostgreSQL 18 ships a native pg_catalog.uuidv7(), so only create our own
|
||||
# implementation when the server does not already provide one. Otherwise the
|
||||
# CREATE FUNCTION below and the unqualified COMMENT statement collide with the
|
||||
# built-in and the migration fails.
|
||||
#
|
||||
# The existence check is done server-side via a DO block rather than
|
||||
# conn.execute().scalar() because the latter returns None in offline
|
||||
# migration mode (no real database connection), causing an AttributeError.
|
||||
op.execute(sa.text(r"""
|
||||
DO $do$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_proc p
|
||||
JOIN pg_namespace n ON p.pronamespace = n.oid
|
||||
WHERE p.proname = 'uuidv7' AND n.nspname = 'pg_catalog'
|
||||
) THEN
|
||||
/* Main function to generate a uuidv7 value with millisecond precision */
|
||||
CREATE FUNCTION public.uuidv7() RETURNS uuid
|
||||
AS
|
||||
$func$
|
||||
-- Replace the first 48 bits of a uuidv4 with the current
|
||||
-- number of milliseconds since 1970-01-01 UTC
|
||||
-- and set the "ver" field to 7 by setting additional bits
|
||||
SELECT encode(
|
||||
set_bit(
|
||||
set_bit(
|
||||
overlay(uuid_send(gen_random_uuid()) placing
|
||||
substring(int8send((extract(epoch from clock_timestamp()) * 1000)::bigint) from
|
||||
3)
|
||||
from 1 for 6),
|
||||
52, 1),
|
||||
53, 1), 'hex')::uuid;
|
||||
$func$ LANGUAGE SQL VOLATILE PARALLEL SAFE;
|
||||
|
||||
COMMENT ON FUNCTION public.uuidv7 IS
|
||||
'Generate a uuid-v7 value with a 48-bit timestamp (millisecond precision) and 74 bits of randomness';
|
||||
END IF;
|
||||
END
|
||||
$do$;
|
||||
"""))
|
||||
|
||||
op.execute(sa.text(r"""
|
||||
CREATE FUNCTION uuidv7_boundary(timestamptz) RETURNS uuid
|
||||
CREATE FUNCTION public.uuidv7_boundary(timestamptz) RETURNS uuid
|
||||
AS
|
||||
$$
|
||||
/* uuid fields: version=0b0111, variant=0b10 */
|
||||
@@ -83,7 +101,7 @@ SELECT encode(
|
||||
'hex')::uuid;
|
||||
$$ LANGUAGE SQL STABLE STRICT PARALLEL SAFE;
|
||||
|
||||
COMMENT ON FUNCTION uuidv7_boundary(timestamptz) IS
|
||||
COMMENT ON FUNCTION public.uuidv7_boundary(timestamptz) IS
|
||||
'Generate a non-random uuidv7 with the given timestamp (first 48 bits) and all random bits to 0. As the smallest possible uuidv7 for that timestamp, it may be used as a boundary for partitions.';
|
||||
"""
|
||||
))
|
||||
@@ -95,7 +113,10 @@ def downgrade():
|
||||
conn = op.get_bind()
|
||||
|
||||
if _is_pg(conn):
|
||||
op.execute(sa.text("DROP FUNCTION uuidv7"))
|
||||
op.execute(sa.text("DROP FUNCTION uuidv7_boundary"))
|
||||
# IF EXISTS keeps the downgrade a no-op on PostgreSQL 18, where the native
|
||||
# pg_catalog.uuidv7() was kept and no public.uuidv7() was created. Scoping the
|
||||
# drop to the public schema avoids touching the built-in.
|
||||
op.execute(sa.text("DROP FUNCTION IF EXISTS public.uuidv7()"))
|
||||
op.execute(sa.text("DROP FUNCTION IF EXISTS public.uuidv7_boundary(timestamptz)"))
|
||||
else:
|
||||
pass
|
||||
|
||||
@@ -43,6 +43,57 @@ class TestMCPClient:
|
||||
assert client.timeout is None
|
||||
assert client.sse_read_timeout is None
|
||||
|
||||
def test_init_with_dynamic_request_headers(self):
|
||||
"""Test client initialization with dynamic request headers."""
|
||||
from flask import Flask
|
||||
|
||||
app = Flask("test")
|
||||
with app.test_request_context(headers={"X-Custom-Auth": "my-secret-token", "X-User-Id": "user123"}):
|
||||
client = MCPClient(
|
||||
server_url="http://test.example.com",
|
||||
headers={
|
||||
"Authorization": "Bearer {{request.headers.X-Custom-Auth}}",
|
||||
"X-Request-User": "{{request.header.X-User-Id}}",
|
||||
"X-Static-Header": "static-val",
|
||||
},
|
||||
)
|
||||
|
||||
assert client.headers == {
|
||||
"Authorization": "Bearer my-secret-token",
|
||||
"X-Request-User": "user123",
|
||||
"X-Static-Header": "static-val",
|
||||
}
|
||||
|
||||
def test_init_with_dynamic_request_headers_missing(self):
|
||||
"""Test client initialization with dynamic request headers that are missing."""
|
||||
from flask import Flask
|
||||
|
||||
app = Flask("test")
|
||||
with app.test_request_context(headers={}):
|
||||
client = MCPClient(
|
||||
server_url="http://test.example.com",
|
||||
headers={
|
||||
"Authorization": "Bearer {{request.headers.X-Custom-Auth}}",
|
||||
},
|
||||
)
|
||||
|
||||
assert client.headers == {
|
||||
"Authorization": "Bearer ",
|
||||
}
|
||||
|
||||
def test_init_with_dynamic_request_headers_no_context(self):
|
||||
"""Test client initialization with dynamic request headers but no request context."""
|
||||
client = MCPClient(
|
||||
server_url="http://test.example.com",
|
||||
headers={
|
||||
"Authorization": "Bearer {{request.headers.X-Custom-Auth}}",
|
||||
},
|
||||
)
|
||||
|
||||
assert client.headers == {
|
||||
"Authorization": "Bearer {{request.headers.X-Custom-Auth}}",
|
||||
}
|
||||
|
||||
@patch("core.mcp.mcp_client.streamablehttp_client")
|
||||
@patch("core.mcp.mcp_client.ClientSession")
|
||||
def test_initialize_with_mcp_url(self, mock_client_session, mock_streamable_client):
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Tests for the uuidv7 SQL migration's PostgreSQL 18 compatibility guard.
|
||||
|
||||
The migration file name is not a valid Python identifier (it starts with a date and
|
||||
contains hyphens), so it is loaded directly from its path. The ``models`` import at the
|
||||
top of the migration is stubbed because the migration never uses it during
|
||||
``upgrade()``/``downgrade()`` and pulling in the real package would require a full app
|
||||
context.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
MIGRATION_PATH = (
|
||||
Path(__file__).resolve().parents[3]
|
||||
/ "migrations"
|
||||
/ "versions"
|
||||
/ "2025_07_02_2332-1c9ba48be8e4_add_uuidv7_function_in_sql.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_migration():
|
||||
# The migration does `import models as models` but never references it, so a stub is
|
||||
# enough and keeps the test free of any database/app configuration.
|
||||
sys.modules.setdefault("models", types.ModuleType("models"))
|
||||
spec = importlib.util.spec_from_file_location("uuidv7_pg18_migration_under_test", MIGRATION_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _make_bind(dialect_name):
|
||||
bind = mock.MagicMock()
|
||||
bind.dialect.name = dialect_name
|
||||
return bind
|
||||
|
||||
|
||||
def _executed_sql(fake_op):
|
||||
return [str(call.args[0]) for call in fake_op.execute.call_args_list]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def migration():
|
||||
return _load_migration()
|
||||
|
||||
|
||||
def test_upgrade_creates_both_functions_when_native_uuidv7_absent(migration):
|
||||
# PostgreSQL 13 to 17: no native pg_catalog.uuidv7(), so both functions are created.
|
||||
# The DO block contains the CREATE FUNCTION guarded by IF NOT EXISTS, and
|
||||
# uuidv7_boundary is created unconditionally.
|
||||
bind = _make_bind("postgresql")
|
||||
with mock.patch.object(migration, "op") as fake_op:
|
||||
fake_op.get_bind.return_value = bind
|
||||
migration.upgrade()
|
||||
|
||||
sql = _executed_sql(fake_op)
|
||||
assert any("CREATE FUNCTION public.uuidv7()" in stmt for stmt in sql)
|
||||
assert any("CREATE FUNCTION public.uuidv7_boundary(timestamptz)" in stmt for stmt in sql)
|
||||
|
||||
|
||||
def test_upgrade_skips_uuidv7_but_keeps_boundary_when_native_present(migration):
|
||||
# PostgreSQL 18: native pg_catalog.uuidv7() exists, so the DO block must guard
|
||||
# the CREATE FUNCTION with an IF NOT EXISTS check against pg_catalog.
|
||||
# uuidv7_boundary is still missing and has to be created unconditionally.
|
||||
bind = _make_bind("postgresql")
|
||||
with mock.patch.object(migration, "op") as fake_op:
|
||||
fake_op.get_bind.return_value = bind
|
||||
migration.upgrade()
|
||||
|
||||
sql = _executed_sql(fake_op)
|
||||
# The DO block must contain the pg_catalog existence check.
|
||||
do_block = next((stmt for stmt in sql if "DO $do$" in stmt), None)
|
||||
assert do_block is not None
|
||||
assert "pg_catalog" in do_block
|
||||
assert "uuidv7" in do_block
|
||||
assert "IF NOT EXISTS" in do_block
|
||||
# uuidv7_boundary is always created (not guarded by the DO block).
|
||||
assert any("CREATE FUNCTION public.uuidv7_boundary(timestamptz)" in stmt for stmt in sql)
|
||||
|
||||
|
||||
def test_upgrade_is_noop_on_non_postgres(migration):
|
||||
bind = _make_bind("sqlite")
|
||||
with mock.patch.object(migration, "op") as fake_op:
|
||||
fake_op.get_bind.return_value = bind
|
||||
migration.upgrade()
|
||||
|
||||
fake_op.execute.assert_not_called()
|
||||
|
||||
|
||||
def test_downgrade_uses_if_exists_and_public_schema(migration):
|
||||
bind = _make_bind("postgresql")
|
||||
with mock.patch.object(migration, "op") as fake_op:
|
||||
fake_op.get_bind.return_value = bind
|
||||
migration.downgrade()
|
||||
|
||||
sql = _executed_sql(fake_op)
|
||||
assert "DROP FUNCTION IF EXISTS public.uuidv7()" in sql
|
||||
assert "DROP FUNCTION IF EXISTS public.uuidv7_boundary(timestamptz)" in sql
|
||||
|
||||
|
||||
def test_downgrade_is_noop_on_non_postgres(migration):
|
||||
bind = _make_bind("sqlite")
|
||||
with mock.patch.object(migration, "op") as fake_op:
|
||||
fake_op.get_bind.return_value = bind
|
||||
migration.downgrade()
|
||||
|
||||
fake_op.execute.assert_not_called()
|
||||
+5
-1
@@ -13,6 +13,8 @@ import { Input } from '@langgenius/dify-ui/input'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { getDocLanguage } from '@/i18n-config/language'
|
||||
import PermissionPicker from './permission-picker'
|
||||
|
||||
export type PermissionSetModalMode = 'create' | 'edit' | 'view'
|
||||
@@ -42,6 +44,8 @@ const PermissionSetModalBody = ({
|
||||
onSubmit,
|
||||
}: PermissionSetModalBodyProps) => {
|
||||
const { t } = useTranslation()
|
||||
const locale = useLocale()
|
||||
const docLanguage = getDocLanguage(locale)
|
||||
const [name, setName] = useState(initialValues?.name ?? '')
|
||||
const [description, setDescription] = useState(initialValues?.description ?? '')
|
||||
const [permissionKeys, setPermissionKeys] = useState<string[]>(initialValues?.permissionKeys ?? [])
|
||||
@@ -122,7 +126,7 @@ const PermissionSetModalBody = ({
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-divider-subtle px-6 py-4">
|
||||
<a
|
||||
href="https://docs.dify.ai/"
|
||||
href={`https://enterprise-docs.dify.ai/${docLanguage}/3.11.x/use/workspace/permission-reference`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 system-xs-medium text-text-accent hover:underline"
|
||||
|
||||
@@ -13,6 +13,8 @@ import { Input } from '@langgenius/dify-ui/input'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { getDocLanguage } from '@/i18n-config/language'
|
||||
import PermissionField from './permission-field'
|
||||
|
||||
export type RoleModalMode = 'create' | 'view' | 'edit'
|
||||
@@ -39,6 +41,8 @@ const RoleModal = ({
|
||||
onSubmit,
|
||||
}: RoleModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const locale = useLocale()
|
||||
const docLanguage = getDocLanguage(locale)
|
||||
const [name, setName] = useState(role?.name ?? '')
|
||||
const [desc, setDesc] = useState(role?.description ?? '')
|
||||
const [permissionKeys, setPermissionKeys] = useState<string[]>(role?.permission_keys ?? [])
|
||||
@@ -116,7 +120,7 @@ const RoleModal = ({
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-divider-subtle px-6 py-4">
|
||||
<a
|
||||
href="https://docs.dify.ai/"
|
||||
href={`https://enterprise-docs.dify.ai/${docLanguage}/3.11.x/use/workspace/permission-reference`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 system-xs-medium text-text-accent hover:underline"
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "إدارة المقتطفات",
|
||||
"tool.manage": "إدارة الأدوات",
|
||||
"workspace.member.manage": "إدارة الأعضاء",
|
||||
"workspace.role.manage": "إدارة أذونات الأدوار وقواعد الوصول إلى الموارد"
|
||||
"workspace.role.manage": "إدارة الأدوار ومجموعات الأذونات"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "Snippets verwalten",
|
||||
"tool.manage": "Tools verwalten",
|
||||
"workspace.member.manage": "Mitglieder verwalten",
|
||||
"workspace.role.manage": "Rollenberechtigungen und Ressourcenzugriffsregeln verwalten"
|
||||
"workspace.role.manage": "Rollen und Berechtigungssätze verwalten"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "Manage snippets",
|
||||
"tool.manage": "Manage tools",
|
||||
"workspace.member.manage": "Manage members",
|
||||
"workspace.role.manage": "Manage role permissions and resource access rules"
|
||||
"workspace.role.manage": "Manage roles and permission sets"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "Gestionar fragmentos",
|
||||
"tool.manage": "Gestionar herramientas",
|
||||
"workspace.member.manage": "Gestionar miembros",
|
||||
"workspace.role.manage": "Gestionar permisos de roles y reglas de acceso a recursos"
|
||||
"workspace.role.manage": "Gestionar roles y conjuntos de permisos"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "مدیریت قطعهکدها",
|
||||
"tool.manage": "مدیریت ابزارها",
|
||||
"workspace.member.manage": "مدیریت اعضا",
|
||||
"workspace.role.manage": "مدیریت مجوزهای نقش و قوانین دسترسی به منابع"
|
||||
"workspace.role.manage": "مدیریت نقشها و مجموعههای مجوز"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "Gérer les extraits",
|
||||
"tool.manage": "Gérer les outils",
|
||||
"workspace.member.manage": "Gérer les membres",
|
||||
"workspace.role.manage": "Gérer les autorisations de rôles et les règles d'accès aux ressources"
|
||||
"workspace.role.manage": "Gérer les rôles et les ensembles d'autorisations"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "स्निपेट प्रबंधित करें",
|
||||
"tool.manage": "टूल प्रबंधित करें",
|
||||
"workspace.member.manage": "सदस्य प्रबंधित करें",
|
||||
"workspace.role.manage": "भूमिका अनुमतियाँ और संसाधन एक्सेस नियम प्रबंधित करें"
|
||||
"workspace.role.manage": "भूमिकाएँ और अनुमति सेट प्रबंधित करें"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "Kelola snippet",
|
||||
"tool.manage": "Kelola alat",
|
||||
"workspace.member.manage": "Kelola anggota",
|
||||
"workspace.role.manage": "Kelola izin peran dan aturan akses sumber daya"
|
||||
"workspace.role.manage": "Kelola peran dan set izin"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "Gestisci snippet",
|
||||
"tool.manage": "Gestisci strumenti",
|
||||
"workspace.member.manage": "Gestisci i membri",
|
||||
"workspace.role.manage": "Gestisci i permessi dei ruoli e le regole di accesso alle risorse"
|
||||
"workspace.role.manage": "Gestisci ruoli e set di autorizzazioni"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "スニペットを管理",
|
||||
"tool.manage": "ツールを管理",
|
||||
"workspace.member.manage": "メンバーを管理",
|
||||
"workspace.role.manage": "ロール権限とリソースアクセスルールを管理"
|
||||
"workspace.role.manage": "ロールと権限セットを管理"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "스니펫 관리",
|
||||
"tool.manage": "도구 관리",
|
||||
"workspace.member.manage": "멤버 관리",
|
||||
"workspace.role.manage": "역할 권한 및 리소스 접근 규칙 관리"
|
||||
"workspace.role.manage": "역할 및 권한 세트 관리"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "Snippets beheren",
|
||||
"tool.manage": "Tools beheren",
|
||||
"workspace.member.manage": "Leden beheren",
|
||||
"workspace.role.manage": "Rolrechten en regels voor resourcetoegang beheren"
|
||||
"workspace.role.manage": "Rollen en machtigingensets beheren"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "Zarządzaj fragmentami kodu",
|
||||
"tool.manage": "Zarządzaj narzędziami",
|
||||
"workspace.member.manage": "Zarządzaj członkami",
|
||||
"workspace.role.manage": "Zarządzaj uprawnieniami ról i regułami dostępu do zasobów"
|
||||
"workspace.role.manage": "Zarządzaj rolami i zestawami uprawnień"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "Gerenciar snippets",
|
||||
"tool.manage": "Gerenciar ferramentas",
|
||||
"workspace.member.manage": "Gerenciar membros",
|
||||
"workspace.role.manage": "Gerenciar permissões de função e regras de acesso a recursos"
|
||||
"workspace.role.manage": "Gerenciar funções e conjuntos de permissões"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "Gestionează fragmente",
|
||||
"tool.manage": "Gestionează instrumente",
|
||||
"workspace.member.manage": "Gestionează membrii",
|
||||
"workspace.role.manage": "Gestionează permisiunile rolurilor și regulile de acces la resurse"
|
||||
"workspace.role.manage": "Gestionează rolurile și seturile de permisiuni"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "Управление сниппетами",
|
||||
"tool.manage": "Управление инструментами",
|
||||
"workspace.member.manage": "Управление участниками",
|
||||
"workspace.role.manage": "Управление правами ролей и правилами доступа к ресурсам"
|
||||
"workspace.role.manage": "Управление ролями и наборами разрешений"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "Upravljanje izsekov",
|
||||
"tool.manage": "Upravljanje orodij",
|
||||
"workspace.member.manage": "Upravljanje članov",
|
||||
"workspace.role.manage": "Upravljanje dovoljenj vlog in pravil za dostop do virov"
|
||||
"workspace.role.manage": "Upravljanje vlog in naborov dovoljenj"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "จัดการสนิปเปต",
|
||||
"tool.manage": "จัดการเครื่องมือ",
|
||||
"workspace.member.manage": "จัดการสมาชิก",
|
||||
"workspace.role.manage": "จัดการสิทธิ์บทบาทและกฎการเข้าถึงทรัพยากร"
|
||||
"workspace.role.manage": "จัดการบทบาทและชุดสิทธิ์"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "Snippet'leri yönet",
|
||||
"tool.manage": "Araçları yönet",
|
||||
"workspace.member.manage": "Üyeleri yönet",
|
||||
"workspace.role.manage": "Rol izinlerini ve kaynak erişim kurallarını yönet"
|
||||
"workspace.role.manage": "Rolleri ve izin setlerini yönet"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "Керування фрагментами",
|
||||
"tool.manage": "Керування інструментами",
|
||||
"workspace.member.manage": "Керування учасниками",
|
||||
"workspace.role.manage": "Керування дозволами ролей та правилами доступу до ресурсів"
|
||||
"workspace.role.manage": "Керування ролями та наборами дозволів"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "Quản lý đoạn mã",
|
||||
"tool.manage": "Quản lý công cụ",
|
||||
"workspace.member.manage": "Quản lý thành viên",
|
||||
"workspace.role.manage": "Quản lý quyền vai trò và quy tắc truy cập tài nguyên"
|
||||
"workspace.role.manage": "Quản lý vai trò và bộ quyền"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "管理 Snippets",
|
||||
"tool.manage": "管理工具",
|
||||
"workspace.member.manage": "管理成员",
|
||||
"workspace.role.manage": "管理角色权限与访问权限规则"
|
||||
"workspace.role.manage": "管理角色与权限集"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"snippets.management": "管理 Snippets",
|
||||
"tool.manage": "管理工具",
|
||||
"workspace.member.manage": "管理成員",
|
||||
"workspace.role.manage": "管理角色權限與訪問權限規則"
|
||||
"workspace.role.manage": "管理角色與權限集"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user