fix(resources): enforce visibility on list + get (private invisible to non-owner)

回退 5483d19 的 workspace-wide 改动。用户真实语义:
- owner 永远可见自己的资源(含 private)
- 非 owner 只见别人 visibility in {workspace, public} 的资源
- admin 全部可见

can_view 与 list_resources 共享同一谓词。list 端点恢复
owner == me OR visibility in {workspace, public} 的过滤,
admin 跳过。get_resource / download_url / jupyter-relative-path
/ delete_resource 全部经 get_visible_resource → can_view,自然
收敛到同一语义。补 1 个 owner-看自己-private 测试,4 个
visibility 测试保持。
This commit is contained in:
tao.chen
2026-08-21 12:46:09 +08:00
parent 5483d19dbd
commit 69a9a48a0b
2 changed files with 42 additions and 22 deletions
+18 -14
View File
@@ -20,7 +20,7 @@ from common.storage.schemas import (
DownloadUrlRequest, DownloadUrlRequest,
) )
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
from sqlalchemy import func, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from backend.dependencies import ( from backend.dependencies import (
@@ -148,10 +148,14 @@ def resource_payload(
def can_view(resource: DataResources, context: RequestContext) -> bool: def can_view(resource: DataResources, context: RequestContext) -> bool:
# 2026-08-11: 同一 workspace 内成员可以查看彼此的资源(含 private); # 同一 workspace 内owner 永远可见自己的资源(含 private);
# 保留 visibility 字段仅作为上传/绑定时的语义标签,不再影响读取。 # 其他成员只见 visibility in {workspace, public} 的资源;
# 还原: 加回 owner_user_id 检查 # admin 全部可见
if resource.owner_user_id == context.user.user_id:
return True return True
if resource.visibility in {"workspace", "public"}:
return True
return context.is_admin
# 根据当前脚本位置计算资源的相对路径,便于 Notebook 中用相对路径读取文件。 # 根据当前脚本位置计算资源的相对路径,便于 Notebook 中用相对路径读取文件。
@@ -413,16 +417,16 @@ async def list_resources(
escape="\\", escape="\\",
), ),
) )
# 2026-08-11: 临时取消"用户间目录互相不可见"约束 # 只返回 owner 自己的资源(含 private),或 visibility 为
# 列表接口现在返回 workspace 内全部 active 资源(不再按 owner / visibility 过滤) # workspace/public 的其他成员资源;A 的 private 资源对非 owner 不可见
# 还原: 删除下面这段注释,恢复原来的 if not context.is_admin: ... 块 # admin 跳过过滤,全部可见
# if not context.is_admin: if not context.is_admin:
# statement = statement.where( statement = statement.where(
# or_( or_(
# DataResources.owner_user_id == context.user.user_id, DataResources.owner_user_id == context.user.user_id,
# DataResources.visibility.in_(["workspace", "public"]), DataResources.visibility.in_(["workspace", "public"]),
# ) )
# ) )
if visibility: if visibility:
if visibility not in {"private", "workspace", "public"}: if visibility not in {"private", "workspace", "public"}:
raise HTTPException( raise HTTPException(
+23 -7
View File
@@ -420,13 +420,14 @@ def _resource_ctx(workspace_id: str = "W001") -> SimpleNamespace:
request_id="test", request_id="test",
user=SimpleNamespace(user_id="U001"), user=SimpleNamespace(user_id="U001"),
workspace=SimpleNamespace(workspace_id=workspace_id), workspace=SimpleNamespace(workspace_id=workspace_id),
is_admin=False,
) )
class TestCanViewWorkspaceWideVisibility: class TestCanViewVisibility:
"""can_view is workspace-wide: any workspace member may view any active """同一 workspace 内:owner 永远可见自己的资源(含 private);
resource, matching list_resources since 2026-08-11. visibility is only a 其他成员只见 visibility in {workspace, public} 的资源;
semantic tag on upload/bind and no longer gates reads.""" admin 全部可见。与 list_resources 的 SQL 谓词一致。"""
@staticmethod @staticmethod
def _viewer() -> SimpleNamespace: def _viewer() -> SimpleNamespace:
@@ -437,9 +438,15 @@ class TestCanViewWorkspaceWideVisibility:
is_admin=False, is_admin=False,
) )
@staticmethod
def _owner() -> SimpleNamespace:
ctx = TestCanViewVisibility._viewer()
ctx.user = SimpleNamespace(user_id="U001") # the owner
return ctx
@staticmethod @staticmethod
def _admin() -> SimpleNamespace: def _admin() -> SimpleNamespace:
ctx = TestCanViewWorkspaceWideVisibility._viewer() ctx = TestCanViewVisibility._viewer()
ctx.is_admin = True ctx.is_admin = True
return ctx return ctx
@@ -449,13 +456,22 @@ class TestCanViewWorkspaceWideVisibility:
res.visibility = visibility res.visibility = visibility
return res return res
def test_workspace_member_can_view_others_private_resource(self) -> None: def test_owner_can_view_own_private_resource(self) -> None:
assert (
can_view(
self._resource("private"),
self._owner(),
)
is True
)
def test_workspace_member_cannot_view_others_private_resource(self) -> None:
assert ( assert (
can_view( can_view(
self._resource("private"), self._resource("private"),
self._viewer(), self._viewer(),
) )
is True is False
) )
def test_workspace_member_can_view_others_workspace_resource(self) -> None: def test_workspace_member_can_view_others_workspace_resource(self) -> None: