Files
LangBot/tests/integration/api/test_totp_workspace_scope.py
T
Hyu e14e277424 fix(api): scope TOTP oversight to the caller's workspace
The second-factor listing was instance-wide and the three mutating endpoints never checked that the target Account belongs to the caller's Workspace, so an owner or admin could see and reset another tenant's account. The listing now resolves the Workspace members and the mutations answer 404 for a foreign account, which does not disclose that it exists.

Also fixes the TOTP write path on Postgres: six write points passed timezone-aware datetimes into timestamp-without-time-zone columns, which asyncpg rejects, so every enroll, confirm, revoke and recovery-code consumption raised a 500 there. SQLite tolerated the same values.

The panel derives its oversight view from the API authorization instead of the length of the account list, which would have hidden it in a single-member Workspace.
2026-09-28 22:38:30 +08:00

124 lines
4.4 KiB
Python

"""Workspace scoping for the owner/admin second-factor oversight endpoints.
An owner or admin answers for the Accounts of its own Workspace. The oversight
endpoints must never list, revoke, or re-bind another tenant's Account.
"""
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.user import UserRouterGroup
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
pytestmark = pytest.mark.integration
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
MEMBER_UUID = 'member-account'
FOREIGN_UUID = 'foreign-account'
def _access(account_uuid: str) -> SimpleNamespace:
return SimpleNamespace(
workspace=SimpleNamespace(uuid=WORKSPACE_UUID),
membership=SimpleNamespace(
uuid=f'membership-{account_uuid}',
account_uuid=account_uuid,
role='owner',
projection_revision=1,
),
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
)
async def _resolve(account_uuid: str, _workspace_uuid: str | None) -> SimpleNamespace:
# The collaboration service hides Accounts that are not members here.
if account_uuid == FOREIGN_UUID:
raise WorkspaceNotFoundError('Workspace not found')
return _access(account_uuid)
@pytest.fixture
async def totp_admin_api():
accounts = {'member-token': SimpleNamespace(uuid=MEMBER_UUID, user='member@example.com')}
application = Mock()
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
application.instance_config.data = {'system': {'allow_modify_login_info': True}}
application.persistence_mgr = SimpleNamespace(tenant_uow=None)
application.user_service.get_authenticated_account = AsyncMock(
side_effect=lambda token: accounts[token]
)
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
side_effect=_resolve
)
application.workspace_collaboration_service.list_members = AsyncMock(
return_value=[SimpleNamespace(membership=SimpleNamespace(account_uuid=MEMBER_UUID))]
)
application.totp_service.list_account_states = AsyncMock(
return_value=[{'account_uuid': MEMBER_UUID, 'user': 'member', 'enabled': False}]
)
application.totp_service.revoke_for_account = AsyncMock(return_value=True)
application.totp_service.get_account = AsyncMock(
return_value=SimpleNamespace(uuid=FOREIGN_UUID, user='foreign@example.com')
)
application.totp_service.begin_enrollment = AsyncMock()
application.totp_service.confirm_enrollment = AsyncMock()
quart_app = quart.Quart(__name__)
router = UserRouterGroup(application, quart_app)
await router.initialize()
return application, quart_app.test_client()
def _headers() -> dict[str, str]:
return {'Authorization': 'Bearer member-token', 'X-Workspace-Id': WORKSPACE_UUID}
async def test_account_list_covers_only_the_callers_workspace(totp_admin_api):
application, client = totp_admin_api
response = await client.get('/api/v1/user/totp/accounts', headers=_headers())
assert response.status_code == 200
payload = await response.get_json()
assert [item['account_uuid'] for item in payload['data']['accounts']] == [MEMBER_UUID]
application.totp_service.list_account_states.assert_awaited_once_with(
account_uuids=[MEMBER_UUID]
)
async def test_foreign_account_cannot_be_revoked(totp_admin_api):
application, client = totp_admin_api
response = await client.delete(
f'/api/v1/user/totp/accounts/{FOREIGN_UUID}',
headers=_headers(),
)
assert response.status_code == 404
assert (await response.get_json())['code'] == 'account_not_found'
application.totp_service.revoke_for_account.assert_not_awaited()
async def test_foreign_account_cannot_be_re_bound(totp_admin_api):
application, client = totp_admin_api
enroll = await client.post(
f'/api/v1/user/totp/accounts/{FOREIGN_UUID}/enroll',
headers=_headers(),
)
confirm = await client.post(
f'/api/v1/user/totp/accounts/{FOREIGN_UUID}/enroll/confirm',
json={'code': '123456'},
headers=_headers(),
)
assert enroll.status_code == 404
assert confirm.status_code == 404
application.totp_service.begin_enrollment.assert_not_awaited()
application.totp_service.confirm_enrollment.assert_not_awaited()