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
+10 -7
View File
@@ -268,24 +268,27 @@ def require_permission(
# Owner check for thread-specific resources.
#
# 2.0-rc moved thread metadata into the SQL persistence layer
# (``threads_meta`` table). We verify ownership via
# ``ThreadMetaStore.check_access``: it returns True for
# missing rows (untracked legacy thread) and for rows whose
# ``user_id`` is NULL (shared / pre-auth data), so this is
# strict-deny rather than strict-allow — only an *existing*
# row with a *different* user_id triggers 404.
# PR6: ``check_access`` now takes ``workspace_id`` as the third
# positional argument; cross-workspace always denies regardless
# of user_id match. We pull workspace_id from the contextvar
# AuthMiddleware sets per request (and fall back to "default"
# in no-auth dev mode so smoke flows keep working). Failures
# convert to **404**, not 403, so the response never leaks the
# existence of a thread that belongs to a different tenant.
if owner_check:
thread_id = kwargs.get("thread_id")
if thread_id is None:
raise ValueError("require_permission with owner_check=True requires 'thread_id' parameter")
from app.gateway.deps import get_thread_store
from deerflow.runtime.workspace_context import get_effective_workspace_id
workspace_id = get_effective_workspace_id()
thread_store = get_thread_store(request)
allowed = await thread_store.check_access(
thread_id,
str(auth.user.id),
workspace_id,
require_existing=require_existing,
)
if not allowed: