fix(tenancy): close isolation and permission gaps

This commit is contained in:
Junyan Qin
2026-07-20 04:02:24 +08:00
parent 18bcb41e9e
commit 90a9774882
30 changed files with 3294 additions and 1764 deletions
@@ -23,10 +23,20 @@ class ExtensionsRouterGroup(group.RouterGroup):
async def _(request_context: RequestContext) -> quart.Response:
if self.ap.plugin_connector.is_enable_plugin:
await self.ap.plugin_connector.require_workspace_context(request_context)
async def read_in_task_scope(operation):
tenant_scope = getattr(getattr(self.ap, 'persistence_mgr', None), 'tenant_scope', None)
if callable(tenant_scope):
async with tenant_scope(request_context.workspace_uuid):
return await operation()
return await operation()
plugins, mcp_servers, skills = await asyncio.gather(
self.ap.plugin_connector.list_plugins(),
self.ap.mcp_service.get_mcp_servers(request_context, contain_runtime_info=True),
self.ap.skill_service.list_skills(request_context),
read_in_task_scope(self.ap.plugin_connector.list_plugins),
read_in_task_scope(
lambda: self.ap.mcp_service.get_mcp_servers(request_context, contain_runtime_info=True)
),
read_in_task_scope(lambda: self.ap.skill_service.list_skills(request_context)),
return_exceptions=True,
)
@@ -34,6 +34,54 @@ EXTERNAL_PLUGIN_CREATION_FIELDS: dict[str, set[str] | None] = {
'langbot-team/FastGPTConnector': None, # all fields -> creation_settings
}
_INFORMATION_SCHEMA_TABLES = sqlalchemy.table(
'tables',
sqlalchemy.column('table_schema'),
sqlalchemy.column('table_name'),
schema='information_schema',
)
_SQLITE_MASTER = sqlalchemy.table(
'sqlite_master',
sqlalchemy.column('type'),
sqlalchemy.column('name'),
)
_LEGACY_KNOWLEDGE_BASE_BACKUP = sqlalchemy.table(
'knowledge_bases_backup',
sqlalchemy.column('uuid'),
sqlalchemy.column('name'),
sqlalchemy.column('description'),
sqlalchemy.column('emoji'),
sqlalchemy.column('embedding_model_uuid'),
sqlalchemy.column('top_k'),
sqlalchemy.column('created_at'),
sqlalchemy.column('updated_at'),
)
_LEGACY_EXTERNAL_KNOWLEDGE_BASE = sqlalchemy.table(
'external_knowledge_bases',
sqlalchemy.column('uuid'),
sqlalchemy.column('name'),
sqlalchemy.column('description'),
sqlalchemy.column('emoji'),
sqlalchemy.column('plugin_author'),
sqlalchemy.column('plugin_name'),
sqlalchemy.column('retriever_config'),
sqlalchemy.column('created_at'),
)
_CURRENT_KNOWLEDGE_BASE = sqlalchemy.table(
'knowledge_bases',
sqlalchemy.column('uuid'),
sqlalchemy.column('workspace_uuid'),
sqlalchemy.column('name'),
sqlalchemy.column('description'),
sqlalchemy.column('emoji'),
sqlalchemy.column('created_at'),
sqlalchemy.column('updated_at'),
sqlalchemy.column('knowledge_engine_plugin_id'),
sqlalchemy.column('collection_id'),
sqlalchemy.column('creation_settings'),
sqlalchemy.column('retrieval_settings'),
)
@group.group_class('knowledge/migration', '/api/v1/knowledge/migration')
class KnowledgeMigrationRouterGroup(group.RouterGroup):
@@ -87,16 +135,18 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
"""Check if a table exists."""
if self.ap.persistence_mgr.db.name == 'postgresql':
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);'
).bindparams(table_name=table_name)
sqlalchemy.select(_INFORMATION_SCHEMA_TABLES.c.table_name)
.where(_INFORMATION_SCHEMA_TABLES.c.table_schema == 'public')
.where(_INFORMATION_SCHEMA_TABLES.c.table_name == table_name)
.limit(1)
)
return result.scalar()
return result.first() is not None
else:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams(
table_name=table_name
)
sqlalchemy.select(_SQLITE_MASTER.c.name)
.where(_SQLITE_MASTER.c.type == 'table')
.where(_SQLITE_MASTER.c.name == table_name)
.limit(1)
)
return result.first() is not None
@@ -151,7 +201,10 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
has_external = await self._table_exists('external_knowledge_bases')
if has_external:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT DISTINCT plugin_author, plugin_name FROM external_knowledge_bases;')
sqlalchemy.select(
_LEGACY_EXTERNAL_KNOWLEDGE_BASE.c.plugin_author,
_LEGACY_EXTERNAL_KNOWLEDGE_BASE.c.plugin_name,
).distinct()
)
for row in result.fetchall():
plugin_author = row[0] or ''
@@ -222,9 +275,7 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
# Step 3: Restore internal knowledge bases from backup
task_context.trace('Restoring internal knowledge bases...', action='restore-internal')
if await self._table_exists('knowledge_bases_backup'):
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT * FROM knowledge_bases_backup;')
)
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(_LEGACY_KNOWLEDGE_BASE_BACKUP))
rows = result.fetchall()
columns = result.keys()
@@ -239,17 +290,15 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
created_at = row_dict.get('created_at')
updated_at = row_dict.get('updated_at')
# DB migration 20 created these columns as TEXT, while a fresh
# schema uses SQLAlchemy JSON. Keep the statement structured,
# but retain untyped bound values so both physical schemas and
# SQLite's string-valued legacy DATETIME rows remain valid.
creation_settings = json.dumps({'embedding_model_uuid': embedding_model_uuid})
retrieval_settings = json.dumps({'top_k': top_k})
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'INSERT INTO knowledge_bases '
'(uuid, workspace_uuid, name, description, emoji, created_at, updated_at, '
'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) '
'VALUES (:uuid, :workspace_uuid, :name, :description, :emoji, :created_at, :updated_at, '
':plugin_id, :collection_id, :creation_settings, :retrieval_settings);'
).bindparams(
sqlalchemy.insert(_CURRENT_KNOWLEDGE_BASE).values(
uuid=kb_uuid,
workspace_uuid=execution_context.workspace_uuid,
name=name,
@@ -257,7 +306,7 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
emoji=emoji,
created_at=created_at,
updated_at=updated_at,
plugin_id=LANGRAG_PLUGIN_ID,
knowledge_engine_plugin_id=LANGRAG_PLUGIN_ID,
collection_id=kb_uuid,
creation_settings=creation_settings,
retrieval_settings=retrieval_settings,
@@ -279,9 +328,7 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
# Step 4: Restore external knowledge bases
task_context.trace('Restoring external knowledge bases...', action='restore-external')
if has_external:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT * FROM external_knowledge_bases;')
)
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(_LEGACY_EXTERNAL_KNOWLEDGE_BASE))
rows = result.fetchall()
columns = result.keys()
@@ -324,13 +371,7 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
retrieval_settings_dict = {k: v for k, v in retriever_config.items() if k not in creation_fields}
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
'INSERT INTO knowledge_bases '
'(uuid, workspace_uuid, name, description, emoji, created_at, updated_at, '
'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) '
'VALUES (:uuid, :workspace_uuid, :name, :description, :emoji, :created_at, :updated_at, '
':plugin_id, :collection_id, :creation_settings, :retrieval_settings);'
).bindparams(
sqlalchemy.insert(_CURRENT_KNOWLEDGE_BASE).values(
uuid=kb_uuid,
workspace_uuid=execution_context.workspace_uuid,
name=name,
@@ -338,7 +379,7 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
emoji=emoji,
created_at=created_at,
updated_at=created_at,
plugin_id=external_plugin_id,
knowledge_engine_plugin_id=external_plugin_id,
collection_id=kb_uuid,
creation_settings=json.dumps(creation_settings_dict),
retrieval_settings=json.dumps(retrieval_settings_dict),
@@ -391,13 +432,13 @@ class KnowledgeMigrationRouterGroup(group.RouterGroup):
if needed:
if await self._table_exists('knowledge_bases_backup'):
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT COUNT(*) FROM knowledge_bases_backup;')
sqlalchemy.select(sqlalchemy.func.count()).select_from(_LEGACY_KNOWLEDGE_BASE_BACKUP)
)
internal_kb_count = result.scalar() or 0
if await self._table_exists('external_knowledge_bases'):
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text('SELECT COUNT(*) FROM external_knowledge_bases;')
sqlalchemy.select(sqlalchemy.func.count()).select_from(_LEGACY_EXTERNAL_KNOWLEDGE_BASE)
)
external_kb_count = result.scalar() or 0
@@ -26,7 +26,7 @@ def parse_iso_datetime(datetime_str: str | None) -> datetime.datetime | None:
@group.group_class('monitoring', '/api/v1/monitoring')
class MonitoringRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/overview', methods=['GET'], permission=Permission.AUDIT_VIEW)
@self.route('/overview', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_overview(request_context: RequestContext) -> str:
"""Get overview metrics"""
# Parse query parameters
@@ -49,7 +49,7 @@ class MonitoringRouterGroup(group.RouterGroup):
return self.success(data=metrics)
@self.route('/token-statistics', methods=['GET'], permission=Permission.AUDIT_VIEW)
@self.route('/token-statistics', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_token_statistics(request_context: RequestContext) -> str:
"""Get detailed token usage statistics (summary, per-model, timeseries)."""
bot_ids = quart.request.args.getlist('botId')
@@ -74,7 +74,7 @@ class MonitoringRouterGroup(group.RouterGroup):
return self.success(data=stats)
@self.route('/messages', methods=['GET'], permission=Permission.AUDIT_VIEW)
@self.route('/messages', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_messages(request_context: RequestContext) -> str:
"""Get message logs"""
# Parse query parameters
@@ -110,7 +110,7 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
@self.route('/llm-calls', methods=['GET'], permission=Permission.AUDIT_VIEW)
@self.route('/llm-calls', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_llm_calls(request_context: RequestContext) -> str:
"""Get LLM call records"""
# Parse query parameters
@@ -144,7 +144,7 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
@self.route('/tool-calls', methods=['GET'], permission=Permission.AUDIT_VIEW)
@self.route('/tool-calls', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_tool_calls(request_context: RequestContext) -> str:
"""Get tool call records"""
bot_ids = quart.request.args.getlist('botId')
@@ -178,7 +178,7 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
@self.route('/embedding-calls', methods=['GET'], permission=Permission.AUDIT_VIEW)
@self.route('/embedding-calls', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_embedding_calls(request_context: RequestContext) -> str:
"""Get embedding call records"""
# Parse query parameters
@@ -210,7 +210,7 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
@self.route('/sessions', methods=['GET'], permission=Permission.AUDIT_VIEW)
@self.route('/sessions', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_sessions(request_context: RequestContext) -> str:
"""Get session information"""
# Parse query parameters
@@ -251,7 +251,7 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
@self.route('/errors', methods=['GET'], permission=Permission.AUDIT_VIEW)
@self.route('/errors', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_errors(request_context: RequestContext) -> str:
"""Get error logs"""
# Parse query parameters
@@ -285,7 +285,7 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
@self.route('/data', methods=['GET'], permission=Permission.AUDIT_VIEW)
@self.route('/data', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_all_data(request_context: RequestContext) -> str:
"""Get all monitoring data in a single request"""
# Parse query parameters
@@ -393,7 +393,7 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.AUDIT_VIEW)
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_session_analysis(session_id: str, request_context: RequestContext) -> str:
"""Get detailed analysis for a specific session"""
analysis = await self.ap.monitoring_service.get_session_analysis(request_context, session_id)
@@ -402,7 +402,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# The frontend will handle the 'found: false' case
return self.success(data=analysis)
@self.route('/messages/<message_id>/details', methods=['GET'], permission=Permission.AUDIT_VIEW)
@self.route('/messages/<message_id>/details', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_message_details(message_id: str, request_context: RequestContext) -> str:
"""Get detailed information for a specific message"""
details = await self.ap.monitoring_service.get_message_details(request_context, message_id)
@@ -604,7 +604,7 @@ class MonitoringRouterGroup(group.RouterGroup):
return response, 200
@self.route('/feedback/stats', methods=['GET'], permission=Permission.AUDIT_VIEW)
@self.route('/feedback/stats', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_feedback_stats(request_context: RequestContext) -> str:
"""Get feedback statistics"""
# Parse query parameters
@@ -627,7 +627,7 @@ class MonitoringRouterGroup(group.RouterGroup):
return self.success(data=stats)
@self.route('/feedback', methods=['GET'], permission=Permission.AUDIT_VIEW)
@self.route('/feedback', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_feedback(request_context: RequestContext) -> str:
"""Get feedback list"""
# Parse query parameters
@@ -72,7 +72,7 @@ class BotsRouterGroup(group.RouterGroup):
'/<bot_uuid>/logs',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.AUDIT_VIEW,
permission=Permission.RESOURCE_VIEW,
)
async def _(bot_uuid: str, request_context: RequestContext) -> str:
json_data = await quart.request.json
@@ -87,22 +87,24 @@ class WorkspacesRouterGroup(group.RouterGroup):
}
)
@self.route('', methods=['GET', 'POST'], permission=Permission.WORKSPACE_VIEW)
async def _(request_context: RequestContext) -> typing.Any:
if quart.request.method == 'POST':
if self.ap.workspace_service.policy.multi_workspace_enabled:
return self.http_status(
409,
'control_plane_required',
'Cloud Workspaces are created by the SaaS control plane',
)
return self.http_status(403, 'edition_limit', 'This edition supports one Workspace per instance')
accesses = await self.ap.workspace_collaboration_service.list_account_workspaces(
request_context.account_uuid
)
@self.route('', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
async def _(user_email: str) -> typing.Any:
account = await self.ap.user_service.get_user_by_email(user_email)
if account is None:
return self.http_status(401, 'invalid_authentication', 'Account not found')
accesses = await self.ap.workspace_collaboration_service.list_account_workspaces(account.uuid)
return self.success(data={'workspaces': [_workspace_payload(access.workspace) for access in accesses]})
@self.route('', methods=['POST'], permission=Permission.WORKSPACE_VIEW)
async def _(request_context: RequestContext) -> typing.Any:
if self.ap.workspace_service.policy.multi_workspace_enabled:
return self.http_status(
409,
'control_plane_required',
'Cloud Workspaces are created by the SaaS control plane',
)
return self.http_status(403, 'edition_limit', 'This edition supports one Workspace per instance')
@self.route('/current', methods=['GET'], permission=Permission.WORKSPACE_VIEW)
async def _(request_context: RequestContext) -> typing.Any:
membership = quart.g.workspace_membership
@@ -270,9 +272,14 @@ class InvitationsRouterGroup(group.RouterGroup):
authorization = quart.request.headers.get('Authorization', '')
if authorization.startswith('Bearer '):
account = await self.ap.user_service.get_authenticated_account(authorization.removeprefix('Bearer '))
if isinstance(account, str):
account = await self.ap.user_service.get_user_by_email(account)
try:
account = await self.ap.user_service.get_authenticated_account(
authorization.removeprefix('Bearer ')
)
if isinstance(account, str):
account = await self.ap.user_service.get_user_by_email(account)
except Exception as exc:
return self._auth_error_response(exc)
if account is None:
return self.http_status(401, 'invalid_authentication', 'Account not found')
membership = await self.ap.workspace_collaboration_service.accept_invitation(
+4 -3
View File
@@ -35,6 +35,7 @@ from .tenant_uow import (
PersistenceScopeBoundary,
PersistenceScopeKind,
TenantScopeRequiredError,
TenantScopedAsyncSession,
TenantUnitOfWork,
)
@@ -1700,9 +1701,9 @@ class PersistenceManager:
calls retain normal ORM result semantics.
"""
await session.flush()
connection = await session.connection()
return await connection.execute(*args, **kwargs)
if not isinstance(session, TenantScopedAsyncSession):
raise TypeError('Scoped Core execution requires a TenantScopedAsyncSession')
return await session.execute_on_transaction_connection(*args, **kwargs)
def tenant_uow(self, workspace_uuid: str) -> TenantUnitOfWork:
return self._scoped_uow(PersistenceScope.workspace(workspace_uuid))
File diff suppressed because it is too large Load Diff