mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-21 20:06:06 +00:00
fix(tenancy): close isolation and permission gaps
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user