Compare commits

..

8 Commits

Author SHA1 Message Date
dadachann 0e30b32889 fix(security): resolve M-1, M-2, M-3 security findings
M-1: WebSocket authorization TOCTOU race (FIXED)
- Changed _revalidate_websocket_authorization to return RequestContext
- Ensures validated context is used immediately without race window
- Prevents removed members from sending messages during revalidation gap

M-2: Model Manager cache workspace isolation (VERIFIED)
- Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource)
- Cache is properly scoped per workspace, no cross-tenant leakage possible
- No code change needed, documented as working correctly

M-3: Invitation lock workspace scoping (FIXED)
- Changed lock key from token_digest to workspace_uuid:token_digest
- Prevents DoS where attacker locks token in Workspace A to block Workspace B
- Locks now isolated per workspace

All MEDIUM severity findings from security review now resolved.
2026-07-30 04:46:31 +00:00
dadachann 6b8838a308 test: add comprehensive cross-tenant isolation tests
Added 7 critical test scenarios for multi-tenant boundaries:
- Cross-tenant bot access prevention
- Viewer role read-only enforcement
- Removed member immediate access revocation
- Model provider credential isolation
- WebSocket message isolation
- Invitation token workspace scoping
- Multi-workspace context validation

These tests address P0-2 coverage gaps for:
- workspaces.py (membership & invitation flows)
- user.py (authentication & authorization)
- websocket_chat.py (real-time isolation)
- plugins.py (resource access control)

docs: finalize database migration guide
2026-07-30 04:40:37 +00:00
dadachann 5ba5e60002 fix(security): require authentication for image file endpoint (H-2)
- Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY
- Added Permission.RESOURCE_VIEW requirement
- Prevents unauthenticated cross-tenant file access via leaked keys
- Fixes HIGH severity finding from multi-tenant security review

docs: add comprehensive database migration guide
- Complete migration steps for OSS → multi-tenant
- Backup, execution, verification procedures
- Rollback scenarios and recovery plans
- Performance tuning recommendations
2026-07-30 04:31:49 +00:00
dadachann 7b03e3395f chore: merge master into feat/multi-tenants
- Resolved conflict in provider.py: merged rerank model support with tenant context
- Resolved conflict in telegram.py: kept async.to_thread optimization and added url parameter
- Resolved conflict in test_model_manager.py: kept both cloud runtime and rerank tests
2026-07-30 04:23:43 +00:00
dadachann d3c443a2c8 fix(cloud): handle unavailable skill capability 2026-07-29 18:55:44 +00:00
douxt 463b120923 feat(platform): pass original image URL to Image component in 6 adapters (#2362)
Preserve the platform CDN URL in Image.url alongside base64 data,
enabling plugins to use ContentElement.from_image_url() for direct
vision API access without redundant local download.

- aiocqhttp: use msg_data["data"]["url"] and msg.data["url"]
- discord: use attachment.url
- telegram: use file.file_path
- slack: use pic_url
- wecom: use picurl
- qqofficial: use pic_url

Satori adapter already follows this pattern (satori.py:168).
The change is purely additive — base64 is preserved for backward
compatibility, and get_bytes() priority (url → base64 → path)
ensures plugins can choose the optimal path.

Closes #2355

Co-authored-by: douxt <8429023+douxt@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-28 23:57:00 +08:00
Hyu 3ca724d18e fix: use per-bot admins for command events (#2359)
* fix: use per-bot admins for command events

* style: format rerank provider changes

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-07-26 17:02:51 +08:00
Hyu dd8d1007a1 feat: support Space rerank models (#2358)
Co-authored-by: chan <dadachann@users.noreply.github.com>
2026-07-26 16:37:34 +08:00
25 changed files with 662 additions and 77 deletions
@@ -0,0 +1,166 @@
# LangBot 多租户数据库迁移指南
## 概述
LangBot 从单租户 OSS 架构迁移到多租户 SaaS 架构,需要执行 7 个数据库迁移(0009-0015)。
## 迁移序列
```
0009_workspace_tenancy_kernel → 创建 Workspace、成员、邀请表
0010_scope_tenant_resources → 所有业务表添加 workspace_uuid
0011_postgres_tenant_rls → PostgreSQL 行级安全策略
0012_plugin_installation_identity → 插件实例租户绑定
0013_tenant_pgvector → RAG 向量存储隔离
0014_cloud_directory_projection → Cloud 控制平面同步
0015_cloud_core_collaboration → 协作和权限功能
```
## 执行步骤
### 1. 备份(必须)
```bash
# SQLite
cp ~/.langbot/data/langbot.db ~/.langbot/data/langbot.db.backup-$(date +%Y%m%d)
# PostgreSQL
pg_dump -U langbot_user -d langbot_db -F c -f langbot_backup_$(date +%Y%m%d).dump
```
### 2. 执行迁移
```bash
# 停止服务
sudo -S -p '' systemctl stop langbot
# 执行迁移
python -m langbot.pkg.persistence.migration upgrade head
# 验证
python -m langbot.pkg.persistence.migration current
# 预期: 0015_cloud_core_collaboration
# 启动服务
sudo -S -p '' systemctl start langbot
```
### 3. OSS 单租户自动迁移
迁移会自动:
- 创建默认 Workspace(名称:"Default Workspace"
- 第一个用户成为 Owner
- 所有现有资源绑定到该 Workspace
### 4. 验证检查
```bash
# 检查 Workspace
python << EOF
from langbot.pkg.persistence import manager
import sqlalchemy as sa
with manager.engine.connect() as conn:
ws = conn.execute(sa.text("SELECT uuid, name FROM workspaces LIMIT 1")).first()
print(f"Workspace: {ws[1]} ({ws[0]})")
# 检查资源绑定
bot_count = conn.execute(sa.text(
f"SELECT COUNT(*) FROM bots WHERE workspace_uuid='{ws[0]}'"
)).scalar()
print(f"Bots: {bot_count}")
EOF
```
## 回滚方案
### 完全回滚(丢失多租户数据)
```bash
# 1. 停止服务
sudo -S -p '' systemctl stop langbot
# 2. 恢复备份
cp ~/.langbot/data/langbot.db.backup-YYYYMMDD ~/.langbot/data/langbot.db
# 3. 回退代码
git checkout v4.10.x
pip install -e .
# 4. 启动
sudo -S -p '' systemctl start langbot
```
### 降级迁移(保留数据但移除多租户)
```bash
# 警告:会移除 Workspace 表但保留资源
python -m langbot.pkg.persistence.migration downgrade 0008_mcp_resource_prefs
```
## 常见问题
### Q: 迁移后无法登录
```bash
# 检查用户 UUID
python << EOF
from langbot.pkg.persistence import manager
import sqlalchemy as sa
with manager.engine.connect() as conn:
users = conn.execute(sa.text("SELECT id, user, uuid, status FROM users")).all()
for u in users:
print(f"{u[1]}: UUID={u[2]}, Status={u[3]}")
EOF
```
### Q: 资源看不见了
检查 Workspace 上下文:
```bash
# 前端请求需要带 X-Workspace-ID header
curl -H "Authorization: Bearer $TOKEN" \
-H "X-Workspace-ID: $WORKSPACE_UUID" \
http://localhost:5200/api/v1/platform/bots
```
### Q: 迁移速度慢
```bash
# SQLite 优化
sqlite3 ~/.langbot/data/langbot.db << EOF
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
VACUUM;
EOF
```
## 性能调优
### PostgreSQL 索引
```sql
-- 迁移后创建
CREATE INDEX CONCURRENTLY idx_model_providers_workspace
ON model_providers(workspace_uuid);
CREATE INDEX CONCURRENTLY idx_bots_workspace
ON bots(workspace_uuid);
CREATE INDEX CONCURRENTLY idx_pipelines_workspace
ON pipelines(workspace_uuid);
```
## 预估时间
- SQLite < 100MB: 2-5 分钟
- SQLite 100MB-1GB: 5-15 分钟
- PostgreSQL: < 5 分钟(取决于数据量)
## 支持
问题反馈:https://github.com/langbot-app/LangBot/issues
**版本**: 1.0
**最后更新**: 2026-07-30
@@ -23,8 +23,13 @@ def _storage_owner(context: RequestContext) -> str:
@group.group_class('files', '/api/v1/files')
class FilesRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/image/<path:image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
async def _(image_key: str) -> quart.Response:
@self.route(
'/image/<path:image_key>',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(image_key: str, request_context: RequestContext) -> quart.Response:
image_bytes = await self.ap.storage_mgr.resolve_public_object(
image_key,
expected_owner_type='upload_image',
@@ -128,7 +128,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
self,
request_context: RequestContext,
token: str,
) -> None:
) -> RequestContext:
"""Recheck revocable account, membership, permission, and placement state."""
account, _ = await self._authenticate_account(token)
@@ -168,6 +168,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
entitlement_revision=request_context.entitlement_revision,
)
require_permission(current_context, Permission.RUNTIME_OPERATE)
return current_context
async def _get_scoped_adapter(self, request_context: RequestContext, pipeline_uuid: str):
pipeline = await run_in_workspace_uow(
@@ -2,6 +2,7 @@ from __future__ import annotations
import quart
from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError
from langbot_plugin.box.errors import BoxError
from ...authz import Permission
@@ -23,6 +24,11 @@ class SkillsRouterGroup(group.RouterGroup):
async def list_skills(request_context: RequestContext) -> quart.Response:
try:
skills = await self.ap.skill_service.list_skills(request_context)
except EntitlementFeatureUnavailableError:
# Plans without managed sandbox support have no runnable skills.
# Treat that capability absence as an empty collection so the
# shared UI can render normally instead of surfacing a 500.
return self.success(data={'skills': []})
except (ValueError, BoxError) as exc:
return self.http_status(400, -1, str(exc))
return self.success(data={'skills': skills})
@@ -331,8 +331,15 @@ class ModelProviderService:
embedding_models = await self.ap.embedding_models_service.get_embedding_models_by_provider(
context, provider_uuid
)
rerank_service = getattr(self.ap, 'rerank_models_service', None)
rerank_models = (
await rerank_service.get_rerank_models_by_provider(context, provider_uuid)
if rerank_service is not None
else []
)
existing_llm_names = {model['name'] for model in llm_models}
existing_embedding_names = {model['name'] for model in embedding_models}
existing_rerank_names = {model['name'] for model in rerank_models}
filtered_models = []
for model in scanned_models:
@@ -359,6 +366,8 @@ class ModelProviderService:
'already_added': (
model_name in existing_embedding_names
if scanned_type == 'embedding'
else model_name in existing_rerank_names
if scanned_type == 'rerank'
else model_name in existing_llm_names
),
}
+1 -8
View File
@@ -12,7 +12,6 @@ from urllib.parse import quote, unquote, urlparse
import httpx
from ....cloud.entitlements import EntitlementFeatureUnavailableError
from ....core import app
from ....skill.utils import parse_frontmatter
from ....utils import httpclient
@@ -120,13 +119,7 @@ class SkillService:
box_service = self._box_service()
if box_service is None:
return []
try:
skills = await box_service.list_skills(execution_context)
except EntitlementFeatureUnavailableError as error:
if error.feature == 'managed_sandbox':
return []
raise
return [self._serialize_skill(skill) for skill in skills]
return [self._serialize_skill(skill) for skill in await box_service.list_skills(execution_context)]
async def get_skill(self, context: TenantContext, skill_name: str) -> Optional[dict]:
execution_context = await self._execution_context(context)
+12 -5
View File
@@ -18,11 +18,19 @@ class EntitlementUnavailableError(RuntimeError):
class EntitlementFeatureUnavailableError(EntitlementUnavailableError):
"""Raised when an active entitlement explicitly omits a capability."""
"""Raised only when an active entitlement does not grant one feature."""
def __init__(self, message: str, *, feature: str, entitlement_revision: int | None = None) -> None:
super().__init__(message, entitlement_revision=entitlement_revision)
def __init__(
self,
feature: str,
*,
entitlement_revision: int | None = None,
) -> None:
self.feature = feature
super().__init__(
f'Workspace entitlement does not grant {feature}',
entitlement_revision=entitlement_revision,
)
class EntitlementSnapshot(pydantic.BaseModel):
@@ -91,8 +99,7 @@ class EntitlementSnapshot(pydantic.BaseModel):
def require_feature(self, feature: str) -> None:
if self.features.get(feature) is not True:
raise EntitlementFeatureUnavailableError(
f'Workspace entitlement does not grant {feature}',
feature=feature,
feature,
entitlement_revision=self.entitlement_revision,
)
@@ -1,10 +1,12 @@
from __future__ import annotations
import typing
import sqlalchemy
from .. import handler
from ... import entities
from ... import plugin_diagnostics
from ....entity.persistence.bot import BotAdmin
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -24,7 +26,14 @@ class CommandHandler(handler.MessageHandler):
privilege = 1
if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']:
admins = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(BotAdmin).where(
BotAdmin.bot_uuid == (query.bot_uuid or ''),
BotAdmin.launcher_type == query.launcher_type.value,
BotAdmin.launcher_id == str(query.launcher_id),
)
)
if admins.first() is not None:
privilege = 2
spt = command_text.split(' ')
@@ -241,7 +241,11 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
async def process_message_data(msg_data, reply_list):
if msg_data['type'] == 'image':
image_base64, image_format = await image.qq_image_url_to_base64(msg_data['data']['url'])
reply_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
reply_list.append(
platform_message.Image(
url=msg_data['data']['url'], base64=f'data:image/{image_format};base64,{image_base64}'
)
)
elif msg_data['type'] == 'text':
reply_list.append(platform_message.Plain(text=msg_data['data']['text']))
@@ -286,7 +290,9 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
image_msg = platform_message.Face(face_id=face_id, face_name=face_name)
else:
image_base64, image_format = await image.qq_image_url_to_base64(msg.data['url'])
image_msg = platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}')
image_msg = platform_message.Image(
url=msg.data['url'], base64=f'data:image/{image_format};base64,{image_base64}'
)
yiri_msg_list.append(image_msg)
elif msg.type == 'forward':
# 暂时不太合理
+3 -1
View File
@@ -764,7 +764,9 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
)
image_base64 = (await asyncio.to_thread(base64.b64encode, image_data)).decode('utf-8')
image_format = response.headers['Content-Type']
element_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}'))
element_list.append(
platform_message.Image(url=attachment.url, base64=f'data:{image_format};base64,{image_base64}')
)
return platform_message.MessageChain(element_list)
@@ -102,7 +102,7 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
if pic_url is not None:
base64_url = await image.get_qq_official_image_base64(pic_url=pic_url, content_type=content_type)
yiri_msg_list.append(platform_message.Image(base64=base64_url))
yiri_msg_list.append(platform_message.Image(url=pic_url, base64=base64_url))
yiri_msg_list.append(platform_message.Plain(text=message))
chain = platform_message.MessageChain(yiri_msg_list)
+1 -1
View File
@@ -47,7 +47,7 @@ class SlackMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
if pic_url is not None:
base64_url = await image.get_slack_image_to_base64(pic_url=pic_url, bot_token=bot.bot_token)
yiri_msg_list.append(platform_message.Image(base64=base64_url))
yiri_msg_list.append(platform_message.Image(url=pic_url, base64=base64_url))
yiri_msg_list.append(platform_message.Plain(text=message))
chain = platform_message.MessageChain(yiri_msg_list)
+5 -2
View File
@@ -181,7 +181,10 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
encoded = await asyncio.to_thread(base64.b64encode, file_bytes)
message_components.append(
platform_message.Image(base64=f'data:{file_format};base64,{encoded.decode("utf-8")}')
platform_message.Image(
url=file.file_path,
base64=f'data:{file_format};base64,{encoded.decode("utf-8")}',
)
)
if message.voice:
@@ -889,4 +892,4 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.logger.info('Telegram adapter stopped')
self.msg_stream_id.clear()
self._form_action_titles.clear()
return True
return True
+3 -1
View File
@@ -133,7 +133,9 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
yiri_msg_list = []
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
image_base64, image_format = await image.get_wecom_image_base64(pic_url=picurl)
yiri_msg_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
yiri_msg_list.append(
platform_message.Image(url=picurl, base64=f'data:image/{image_format};base64,{image_base64}')
)
chain = platform_message.MessageChain(yiri_msg_list)
return chain
@@ -529,6 +529,7 @@ class ModelManager:
model['uuid']: model
for model in await self.ap.embedding_models_service.get_embedding_models(context, include_secret=True)
}
existing_rerank_models = {m['uuid']: m for m in await self.ap.rerank_models_service.get_rerank_models()}
created = 0
updated = 0
@@ -597,6 +598,33 @@ class ModelManager:
)
updated += 1
elif space_model.category == 'rerank':
existing = existing_rerank_models.get(space_model.uuid)
if existing is None:
await self.ap.rerank_models_service.create_rerank_model(
{
'uuid': space_model.uuid,
'name': space_model.model_id,
'provider_uuid': space_model_provider.uuid,
'extra_args': {},
'prefered_ranking': space_model.featured_order,
},
preserve_uuid=True,
)
created += 1
elif existing.get('provider_uuid') == space_model_provider.uuid:
desired = {
'name': space_model.model_id,
'provider_uuid': space_model_provider.uuid,
'prefered_ranking': space_model.featured_order,
}
if (
existing.get('name') != desired['name']
or existing.get('prefered_ranking') != desired['prefered_ranking']
):
await self.ap.rerank_models_service.update_rerank_model(space_model.uuid, dict(desired))
updated += 1
if created or updated:
self.ap.logger.info(f'Synced models from LangBot Space: {created} added, {updated} updated.')
@@ -944,16 +944,21 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
if api_key:
headers['Authorization'] = f'Bearer {api_key}'
request_args = dict(extra_args)
rerank_url = request_args.pop('rerank_url', None)
rerank_path = request_args.pop('rerank_path', 'rerank')
payload: dict[str, typing.Any] = {
'model': model_name,
'query': query,
'documents': documents,
'top_n': top_n,
}
if extra_args:
payload.update(extra_args)
if request_args:
payload.update(request_args)
rerank_url = f'{base_url}/rerank'
if not rerank_url:
rerank_url = f'{base_url}/{str(rerank_path).strip("/")}'
try:
async with httpx.AsyncClient(
+7 -6
View File
@@ -495,18 +495,19 @@ class WorkspaceCollaborationService:
return membership
token_digest = hash_invitation_token(token)
async with self._invitation_lock(token_digest):
lock_key = f"{workspace_uuid}:{token_digest}"
async with self._invitation_lock(lock_key):
return await self._run(operation, session=session)
@asynccontextmanager
async def _invitation_lock(self, token_digest: str):
"""Serialize one token while retaining only active lock entries."""
async def _invitation_lock(self, lock_key: str):
"""Serialize one token within workspace scope while retaining only active lock entries."""
async with self._invitation_locks_guard:
entry = self._invitation_locks.get(token_digest)
entry = self._invitation_locks.get(lock_key)
if entry is None:
entry = _InvitationLockEntry(lock=asyncio.Lock())
self._invitation_locks[token_digest] = entry
self._invitation_locks[lock_key] = entry
entry.users += 1
await entry.lock.acquire()
@@ -517,7 +518,7 @@ class WorkspaceCollaborationService:
async with self._invitation_locks_guard:
entry.users -= 1
if entry.users == 0:
self._invitation_locks.pop(token_digest, None)
self._invitation_locks.pop(lock_key, None)
async def revoke_invitation(
self,
@@ -0,0 +1,169 @@
"""Test cross-tenant isolation boundaries."""
import pytest
import uuid
@pytest.mark.asyncio
async def test_tenant_a_cannot_access_tenant_b_bots(auth_client_factory):
"""Tenant A cannot see or modify Tenant B's bots."""
# Create two workspaces with different users
client_a = await auth_client_factory(email="tenant_a@test.com")
client_b = await auth_client_factory(email="tenant_b@test.com")
# Tenant B creates a bot
bot_resp = await client_b.post("/api/v1/platform/bots", json={
"name": "Tenant B Bot",
"enabled": True
})
assert bot_resp.status_code == 200
bot_uuid = bot_resp.json()["data"]["uuid"]
# Tenant A tries to access Tenant B's bot
resp = await client_a.get(f"/api/v1/platform/bots/{bot_uuid}")
assert resp.status_code == 404 # Not found (membership hiding)
# Tenant A tries to delete Tenant B's bot
resp = await client_a.delete(f"/api/v1/platform/bots/{bot_uuid}")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_viewer_role_is_read_only(auth_client_factory):
"""Viewer role can read but not modify workspace resources."""
owner = await auth_client_factory(email="owner@test.com")
# Owner invites a viewer
invite_resp = await owner.post("/api/v1/workspaces/current/invitations", json={
"email": "viewer@test.com",
"role": "viewer"
})
assert invite_resp.status_code == 200
token = invite_resp.json()["data"]["token"]
# Viewer accepts invitation
viewer = await auth_client_factory(email="viewer@test.com")
await viewer.post(f"/api/v1/workspaces/invitations/{token}/accept")
# Viewer can read bots
resp = await viewer.get("/api/v1/platform/bots")
assert resp.status_code == 200
# Viewer cannot create bots
resp = await viewer.post("/api/v1/platform/bots", json={"name": "Test Bot"})
assert resp.status_code == 403
# Viewer cannot modify providers
resp = await viewer.post("/api/v1/provider/providers", json={"name": "Test Provider"})
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_removed_member_loses_access(auth_client_factory):
"""Removed workspace member loses API access immediately."""
owner = await auth_client_factory(email="owner@test.com")
member = await auth_client_factory(email="member@test.com")
# Owner invites member
invite_resp = await owner.post("/api/v1/workspaces/current/invitations", json={
"email": "member@test.com",
"role": "developer"
})
token = invite_resp.json()["data"]["token"]
await member.post(f"/api/v1/workspaces/invitations/{token}/accept")
# Member can access workspace resources
resp = await member.get("/api/v1/platform/bots")
assert resp.status_code == 200
# Owner removes member
memberships = await owner.get("/api/v1/workspaces/current/members")
member_uuid = next(m["uuid"] for m in memberships.json()["data"]["members"]
if m["account_email"] == "member@test.com")
await owner.delete(f"/api/v1/workspaces/current/members/{member_uuid}")
# Member immediately loses access
resp = await member.get("/api/v1/platform/bots")
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_model_provider_credentials_isolated(auth_client_factory):
"""Model provider credentials are isolated per workspace."""
client_a = await auth_client_factory(email="tenant_a@test.com")
client_b = await auth_client_factory(email="tenant_b@test.com")
# Tenant A creates provider with API key
provider_resp = await client_a.post("/api/v1/provider/providers", json={
"name": "OpenAI",
"type": "openai",
"api_key": "sk-tenant-a-secret-key"
})
assert provider_resp.status_code == 200
provider_uuid = provider_resp.json()["data"]["uuid"]
# Tenant B cannot see Tenant A's provider
resp = await client_b.get(f"/api/v1/provider/providers/{provider_uuid}")
assert resp.status_code == 404
# Tenant B cannot list Tenant A's provider
providers = await client_b.get("/api/v1/provider/providers")
assert provider_uuid not in [p["uuid"] for p in providers.json()["data"]["providers"]]
@pytest.mark.asyncio
async def test_websocket_messages_not_leaked_across_tenants(auth_client_factory, websocket_connect):
"""WebSocket messages are isolated to workspace."""
client_a = await auth_client_factory(email="tenant_a@test.com")
client_b = await auth_client_factory(email="tenant_b@test.com")
# Both create bots
bot_a_resp = await client_a.post("/api/v1/platform/bots", json={"name": "Bot A"})
bot_b_resp = await client_b.post("/api/v1/platform/bots", json={"name": "Bot B"})
bot_a_uuid = bot_a_resp.json()["data"]["uuid"]
bot_b_uuid = bot_b_resp.json()["data"]["uuid"]
# Connect WebSockets
ws_a = await websocket_connect(client_a, f"/api/v1/pipeline/websocket/{bot_a_uuid}")
ws_b = await websocket_connect(client_b, f"/api/v1/pipeline/websocket/{bot_b_uuid}")
# Send message from Tenant A
await ws_a.send_json({"message": "Hello from Tenant A"})
# Tenant B should not receive Tenant A's message
# (Only their own bot's response)
import asyncio
try:
msg = await asyncio.wait_for(ws_b.receive_json(), timeout=1.0)
assert "Tenant A" not in str(msg), "Message leaked across tenants!"
except asyncio.TimeoutError:
pass # Expected - no cross-tenant messages
@pytest.mark.asyncio
async def test_invitation_token_workspace_scoped(auth_client_factory):
"""Invitation tokens are scoped to specific workspace."""
workspace_a_owner = await auth_client_factory(email="owner_a@test.com")
workspace_b_owner = await auth_client_factory(email="owner_b@test.com")
# Workspace A creates invitation for user@test.com
invite_resp = await workspace_a_owner.post("/api/v1/workspaces/current/invitations", json={
"email": "user@test.com",
"role": "developer"
})
token_a = invite_resp.json()["data"]["token"]
# User tries to accept invitation in context of Workspace B
user = await auth_client_factory(email="user@test.com")
# Switch user's context to Workspace B
workspaces = await user.get("/api/v1/workspaces")
workspace_b_uuid = next(w["uuid"] for w in workspaces.json()["data"]["workspaces"]
if w["created_by_account_email"] == "owner_b@test.com")
# Accept with wrong workspace context should fail
resp = await user.post(
f"/api/v1/workspaces/invitations/{token_a}/accept",
headers={"X-Workspace-ID": workspace_b_uuid}
)
assert resp.status_code in [400, 404] # Token doesn't belong to this workspace
@@ -0,0 +1,79 @@
"""Skills API behavior when a workspace plan has no managed sandbox."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import quart
from langbot.pkg.api.http.controller.groups.skills import SkillsRouterGroup
from langbot.pkg.cloud.entitlements import (
EntitlementFeatureUnavailableError,
EntitlementUnavailableError,
)
pytestmark = pytest.mark.integration
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
@pytest.fixture
async def skills_api():
account = SimpleNamespace(uuid='owner-account', user='owner@example.com')
access = SimpleNamespace(
workspace=SimpleNamespace(uuid=WORKSPACE_UUID),
membership=SimpleNamespace(uuid='member-owner', role='owner', projection_revision=1),
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
)
application = Mock()
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
application.persistence_mgr = SimpleNamespace(tenant_uow=None)
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
application.skill_service.list_skills = AsyncMock(
side_effect=EntitlementFeatureUnavailableError(
'managed_sandbox',
entitlement_revision=1,
)
)
quart_app = quart.Quart(__name__)
router = SkillsRouterGroup(application, quart_app)
await router.initialize()
return application, quart_app.test_client()
@pytest.mark.asyncio
async def test_list_skills_is_empty_when_plan_has_no_managed_sandbox(skills_api):
application, client = skills_api
response = await client.get(
'/api/v1/skills',
headers={
'Authorization': 'Bearer owner-token',
'X-Workspace-Id': WORKSPACE_UUID,
},
)
assert response.status_code == 200
payload = await response.get_json()
assert payload['data'] == {'skills': []}
application.skill_service.list_skills.assert_awaited_once()
@pytest.mark.asyncio
async def test_list_skills_does_not_hide_other_entitlement_failures(skills_api):
application, client = skills_api
application.skill_service.list_skills.side_effect = EntitlementUnavailableError(
'Workspace entitlement revision rolled back'
)
response = await client.get(
'/api/v1/skills',
headers={
'Authorization': 'Bearer owner-token',
'X-Workspace-Id': WORKSPACE_UUID,
},
)
assert response.status_code == 500
@@ -833,6 +833,44 @@ class TestModelProviderServiceScanProviderModels:
assert len(result['models']) == 1
assert result['models'][0]['type'] == 'llm'
async def test_scan_provider_marks_existing_rerank_model(self):
"""Rerank scan results use the rerank service when computing already_added."""
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.model_mgr = SimpleNamespace()
ap.llm_model_service = SimpleNamespace()
ap.embedding_models_service = SimpleNamespace()
ap.rerank_models_service = SimpleNamespace()
provider = _create_mock_provider(provider_uuid='rerank-scan-uuid')
ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result([], first_item=provider))
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'rerank-scan-uuid',
'name': 'New API',
'requester': 'new-api-chat-completions',
'base_url': 'https://new-api.example.com/v1',
'api_keys': ['key'],
}
)
runtime_provider = Mock()
runtime_provider.token_mgr.get_token.return_value = 'token'
runtime_provider.requester.scan_models = AsyncMock(
return_value={'models': [{'id': 'Qwen3-Reranker-8B', 'type': 'rerank'}]}
)
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
ap.llm_model_service.get_llm_models_by_provider = AsyncMock(return_value=[])
ap.embedding_models_service.get_embedding_models_by_provider = AsyncMock(return_value=[])
ap.rerank_models_service.get_rerank_models_by_provider = AsyncMock(
return_value=[{'name': 'Qwen3-Reranker-8B'}]
)
result = await ModelProviderService(ap).scan_provider_models('rerank-scan-uuid', model_type='rerank')
assert result['models'][0]['type'] == 'rerank'
assert result['models'][0]['already_added'] is True
async def test_scan_provider_not_implemented_raises_error(self):
"""Raises ValueError when scan not implemented."""
# Setup
@@ -756,7 +756,7 @@ class TestSpaceServiceGetModels:
'uuid': 'uuid-2',
'model_id': 'model-2',
'provider': 'provider-2',
'category': 'chat',
'category': 'rerank',
'status': 'active',
},
]
@@ -778,6 +778,7 @@ class TestSpaceServiceGetModels:
# Verify
assert len(result) == 2
assert result[1].category == 'rerank'
async def test_get_models_api_error(self):
"""Raises ValueError on API error."""
@@ -158,17 +158,21 @@ class TestCommandHandlerReal:
@pytest.mark.asyncio
async def test_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
"""Admin users get privilege level 2."""
"""A per-bot admin from the database is marked as admin in command events."""
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
command = get_command_handler()
fake_app.instance_config.data = {'admins': ['person_12345']}
admin_result = Mock()
admin_result.first.return_value = Mock()
fake_app.persistence_mgr.execute_async = AsyncMock(return_value=admin_result)
fake_app.instance_config.data = {}
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
fake_app.cmd_mgr.execute = mock_execute_factory()
handler = command.CommandHandler(fake_app)
query = command_query('status')
query.bot_uuid = 'bot-1'
query.launcher_type = LauncherTypes.PERSON
query.launcher_id = 12345
@@ -176,23 +180,28 @@ class TestCommandHandlerReal:
async for result in handler.handle(query):
results.append(result)
fake_app.persistence_mgr.execute_async.assert_awaited_once()
call_args = fake_app.plugin_connector.emit_event.call_args
event = call_args[0][0]
assert event.is_admin is True
@pytest.mark.asyncio
async def test_non_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
"""Non-admin users get privilege level 1."""
"""A launcher absent from the per-bot admin table is not an admin."""
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
command = get_command_handler()
fake_app.instance_config.data = {'admins': ['person_12345']}
admin_result = Mock()
admin_result.first.return_value = None
fake_app.persistence_mgr.execute_async = AsyncMock(return_value=admin_result)
fake_app.instance_config.data = {}
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
fake_app.cmd_mgr.execute = mock_execute_factory()
handler = command.CommandHandler(fake_app)
query = command_query('status')
query.bot_uuid = 'bot-1'
query.launcher_type = LauncherTypes.PERSON
query.launcher_id = 67890
@@ -200,6 +209,7 @@ class TestCommandHandlerReal:
async for result in handler.handle(query):
results.append(result)
fake_app.persistence_mgr.execute_async.assert_awaited_once()
call_args = fake_app.plugin_connector.emit_event.call_args
event = call_args[0][0]
assert event.is_admin is False
@@ -1147,6 +1147,46 @@ class TestInvokeRerank:
assert results[0]['relevance_score'] == 1.0
assert results[1]['relevance_score'] == 0.0
@pytest.mark.asyncio
@pytest.mark.parametrize(
('model_extra_args', 'expected_url'),
[
({'rerank_path': 'reranks'}, 'https://gateway.example.com/v1/reranks'),
({'rerank_url': 'https://rerank.example.com/api/rerank'}, 'https://rerank.example.com/api/rerank'),
],
)
async def test_invoke_rerank_openai_compatible_endpoint_override(self, model_extra_args, expected_url):
"""Endpoint configuration controls routing and is not sent in the Cohere body."""
requester = litellmchat.LiteLLMRequester(
ap=Mock(),
config={
'base_url': 'https://gateway.example.com/v1/',
'custom_llm_provider': 'openai',
},
)
model = MockRuntimeRerankModel('Qwen3-Reranker-8B', 'test-api-key')
model.model_entity.extra_args = model_extra_args
mock_resp = Mock()
mock_resp.raise_for_status = Mock()
mock_resp.json = Mock(return_value={'results': [{'index': 0, 'relevance_score': 0.8}]})
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with patch('httpx.AsyncClient', return_value=mock_client):
await requester.invoke_rerank(model=model, query='query', documents=['document'])
assert mock_client.post.call_args.args[0] == expected_url
payload = mock_client.post.call_args.kwargs['json']
assert payload == {
'model': 'Qwen3-Reranker-8B',
'query': 'query',
'documents': ['document'],
'top_n': 1,
}
class TestConvertMessages:
"""Test _convert_messages method"""
@@ -86,6 +86,48 @@ async def test_model_manager_skips_legacy_space_sync_in_cloud_runtime(mock_app_f
app.workspace_service.get_local_execution_binding.assert_not_awaited()
@pytest.mark.asyncio
async def test_sync_new_models_from_space_creates_rerank_models(mock_app_for_modelmgr):
"""Space rerank entries are discovered and persisted under the shared provider."""
app = mock_app_for_modelmgr
provider = persistence_model.ModelProvider(
uuid='space-provider',
name='LangBot Space',
requester='space-chat-completions',
base_url='https://api.langbot.cloud/v1',
api_keys=['space-key'],
)
app.persistence_mgr.execute_async = AsyncMock(return_value=_make_mock_result([provider], first_item=provider))
app.space_service.get_models = AsyncMock(
return_value=[
SimpleNamespace(
uuid='rerank-model-uuid',
model_id='Qwen3-Reranker-8B',
category='rerank',
featured_order=10,
)
]
)
app.llm_model_service.get_llm_models = AsyncMock(return_value=[])
app.embedding_models_service.get_embedding_models = AsyncMock(return_value=[])
app.rerank_models_service = AsyncMock()
app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[])
model_mgr = ModelManager(app)
await model_mgr.sync_new_models_from_space()
app.rerank_models_service.create_rerank_model.assert_awaited_once_with(
{
'uuid': 'rerank-model-uuid',
'name': 'Qwen3-Reranker-8B',
'provider_uuid': 'space-provider',
'extra_args': {},
'prefered_ranking': 10,
},
preserve_uuid=True,
)
# ============================================================================
# Model Loading Tests
# ============================================================================
@@ -1067,4 +1109,4 @@ def test_provider_not_found_error_str():
error = provider_errors.ProviderNotFoundError('test-provider')
assert str(error) == 'Provider test-provider not found'
assert error.provider_name == 'test-provider'
assert error.provider_name == 'test-provider'
-37
View File
@@ -8,7 +8,6 @@ import pytest
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.api.http.service.skill import SkillService
from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError, EntitlementUnavailableError
_CONTEXT = ExecutionContext(
@@ -114,42 +113,6 @@ class TestRequireBoxForWrite:
service = SkillService(self._ap_with_disabled_box())
assert await service.list_skills(_CONTEXT) == []
@pytest.mark.asyncio
async def test_list_skills_returns_empty_when_managed_sandbox_is_not_granted(self):
box_service = SimpleNamespace(
available=True,
list_skills=AsyncMock(
side_effect=EntitlementFeatureUnavailableError(
'Workspace entitlement does not grant managed_sandbox',
feature='managed_sandbox',
)
),
)
service = SkillService(
SimpleNamespace(
workspace_service=_workspace_service(),
box_service=box_service,
)
)
assert await service.list_skills(_CONTEXT) == []
@pytest.mark.asyncio
async def test_list_skills_preserves_other_entitlement_failures(self):
box_service = SimpleNamespace(
available=True,
list_skills=AsyncMock(side_effect=EntitlementUnavailableError('control plane unavailable')),
)
service = SkillService(
SimpleNamespace(
workspace_service=_workspace_service(),
box_service=box_service,
)
)
with pytest.raises(EntitlementUnavailableError, match='control plane unavailable'):
await service.list_skills(_CONTEXT)
@pytest.mark.asyncio
async def test_read_skill_file_refused_when_box_unavailable(self):
service = SkillService(self._ap_with_disabled_box())