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
@@ -87,32 +87,36 @@ class ThreadMetaRepository(ThreadMetaStore):
return None
return self._row_to_dict(row)
async def check_access(self, thread_id: str, user_id: str, *, require_existing: bool = False) -> bool:
"""Check if ``user_id`` has access to ``thread_id``.
async def check_access(
self,
thread_id: str,
user_id: str,
workspace_id: str,
*,
require_existing: bool = False,
) -> bool:
"""Check if ``user_id`` in ``workspace_id`` has access to ``thread_id``.
Two modes — one row, two distinct semantics depending on what
the caller is about to do:
Three filters layered, from outside in:
- ``require_existing=False`` (default, permissive):
Returns True for: row missing (untracked legacy thread),
``row.user_id`` is None (shared / pre-auth data),
or ``row.user_id == user_id``. Use for **read-style**
decorators where treating an untracked thread as accessible
preserves backward-compat.
- ``require_existing=True`` (strict):
Returns True **only** when the row exists AND
(``row.user_id == user_id`` OR ``row.user_id is None``).
Use for **destructive / mutating** decorators (DELETE, PATCH,
state-update) so a thread that has *already been deleted*
cannot be re-targeted by any caller — closing the
delete-idempotence cross-user gap where the row vanishing
made every other user appear to "own" it.
- Cross-workspace is **always** denied (returns False), even when
the row exists and ``user_id`` matches. The decorator layer
converts a False into a 404 so cross-tenant access never leaks
the existence of a thread.
- Missing row honours ``require_existing``: False by default
(permissive — untracked legacy threads still readable), True
for destructive routes (DELETE / PATCH) so a re-targeted ghost
row cannot be claimed.
- Within the workspace, ``row.user_id IS NULL`` keeps the legacy
"shared / pre-auth" semantics — readable by anyone in the
workspace. ``row.user_id == user_id`` is the normal case.
"""
async with self._sf() as session:
row = await session.get(ThreadMetaRow, thread_id)
if row is None:
return not require_existing
if row.workspace_id is not None and row.workspace_id != workspace_id:
return False
if row.user_id is None:
return True
return row.user_id == user_id