feat(persistence/authz): PR6 T6.4 — check_access takes workspace_id

`ThreadMetaStore.check_access(thread_id, user_id, workspace_id, *,
require_existing)` is now a three-positional method. Cross-workspace is
denied unconditionally — even when the row exists and `user_id` matches
— so the decorator layer can convert the False into a **404** and never
leak the existence of a thread across tenants. Inside the workspace, the
existing legacy semantics still hold (NULL `row.user_id` stays
"shared in workspace", `require_existing` still gates the missing-row
path against ghost-row re-targeting).

`@require_permission(owner_check=True)` in `app/gateway/authz.py` now
reads the active workspace from `get_effective_workspace_id()` (set by
PR4 AuthMiddleware; falls back to "default" in no-auth dev) and passes
it through. The existing 404-not-403 mapping is unchanged.

Existing positional callers in `test_thread_meta_repo.py` and the
permissive mock in `test_threads_router.py` were updated for the new
arity. 58 thread_meta / router / memory tests stay green; 90 auth /
uploads / suggestions tests stay green.
This commit is contained in:
1445043649
2026-05-13 17:23:38 +08:00
parent 28ad6c2b0b
commit 05be7f9ad0
6 changed files with 88 additions and 41 deletions
@@ -116,10 +116,20 @@ class MemoryThreadMetaStore(ThreadMetaStore):
)
return [self._item_to_dict(item) for item in items]
async def check_access(self, thread_id: str, user_id: str, *, require_existing: bool = False) -> bool:
async def check_access(
self,
thread_id: str,
user_id: str,
workspace_id: str,
*,
require_existing: bool = False,
) -> bool:
item = await self._store.aget(THREADS_NS, thread_id)
if item is None:
return not require_existing
record_workspace_id = item.value.get("workspace_id")
if record_workspace_id is not None and record_workspace_id != workspace_id:
return False
record_user_id = item.value.get("user_id")
if record_user_id is None:
return True