Files
pad/internal/server/authz_cross_workspace.go
T
xarmian f15ba86db0 docs(server): correct the cross-workspace authz re-check contract per final review
The helper's doc mandated that a mutating caller "re-apply the check"
inside its write transaction. Its only mutating consumer deliberately
does not, and is right not to: these functions read through s.store
rather than the caller's tx, so under READ COMMITTED the re-check would
judge locked resources against authorization state read at several
unsynchronised moments — reading as a write-time guarantee while
providing none.

State what a mutating caller actually owes (re-read the authorized
resource IDENTITY in-tx and refuse if it moved) and what it must not do,
so the contract and copyResourceInvariantPreCheck no longer disagree.

Found by the final full-diff Codex pass over PLAN-2357 (P1: a documented
write-time guard was in fact a TOCTOU check).

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-31 02:35:53 +00:00

866 lines
38 KiB
Go

package server
import (
"errors"
"net/http"
"github.com/PerpetualSoftware/pad/internal/models"
)
// Cross-workspace authorization (PLAN-2357 / TASK-2358, DR-10).
//
// ############################################################
// # requireEditPermission MUST NEVER be called with a #
// # workspace ID other than the one the current request's #
// # URL resolved to. Neither must requireItemVisible, #
// # requireMinRole, requireRole, or anything else that reads #
// # workspaceRole(r). #
// ############################################################
//
// requireEditPermission (server.go) accepts `workspaceID` as a
// parameter, so it LOOKS reusable for a second workspace. It is not.
// Its editor/owner fast path is `role := workspaceRole(r)` — and
// RequireWorkspaceAccess (middleware_auth.go) only ever populates
// that context value for the workspace named in the request URL.
// Hand it workspace B's ID and it happily applies the caller's
// workspace A role to workspace B. An editor in A with no membership
// at all in B sails straight through the fast path. That is
// privilege escalation, not a rough edge.
//
// The second half of the same trap: the OAuth/MCP consent allow-list
// is applied AUTOMATICALLY in exactly one place —
// RequireWorkspaceAccess's call to tokenAllowedWorkspaceMatches
// (middleware_auth.go), on the workspace in the URL. Every other
// surface that touches a workspace the URL didn't name has to call it
// by hand, and the ones that do (handlers_search.go's cross-workspace
// search, handlers_workspaces.go's restore, handlers_collab.go's
// item-ID-addressed WebSocket upgrade) are the precedent. Miss it and
// a token the user consented to workspace A alone reads and writes
// workspace B unchecked. The comparison is always against the SECOND
// workspace's CANONICAL slug — consent persists slugs, and the caller
// may have addressed the workspace by UUID.
//
// Everything in this file exists so callers never have to remember
// either of those. Use AuthorizeCrossWorkspaceRead /
// AuthorizeCrossWorkspaceEdit for any workspace that is not the
// request's own.
//
// The closest prior art is Store.GetCrossWorkspaceBacklinks
// (internal/store/wiki_links.go), which hand-rolls the same two
// things: the bearer-vs-cookie admin split (BUG-1616/1617) and the
// manual allow-list test.
//
// handlers_ref_resolver.go is NOT a template. It replays the
// membership logic but skips the allow-list entirely, and it is
// bearer-reachable (the route sits inside the full middleware stack,
// so PATs and CLI session bearers hit it) — so its consent gap is a
// real one, mitigated only by the route disclosing nothing but a
// redirect. Do not copy it for anything that returns data.
// CrossWorkspaceDenialReason explains why an authorization attempt
// against a second workspace failed. The empty value means "allowed".
//
// The reason exists so the CALLER decides the HTTP shape, deliberately
// — see the disclosure note on CrossWorkspaceAccess. Do not derive a
// status code from it ad hoc; use one of the Write* helpers, or add a
// new one here so the posture stays reviewable in one place.
type CrossWorkspaceDenialReason string
const (
// CrossWorkspaceAllowed is the zero value: no denial.
CrossWorkspaceAllowed CrossWorkspaceDenialReason = ""
// CrossWorkspaceInvalidScope means the caller handed in a scope
// that names nothing (the zero CrossWorkspaceScope). Programmer
// error; treated as a denial so the failure mode is closed rather
// than "workspace-level check only".
CrossWorkspaceInvalidScope CrossWorkspaceDenialReason = "invalid_scope"
// CrossWorkspaceWorkspaceNotFound means the slug/ID resolved to
// nothing the caller may address. Soft-deleted workspaces land
// here too — every resolveWorkspace path filters
// `deleted_at IS NULL`.
CrossWorkspaceWorkspaceNotFound CrossWorkspaceDenialReason = "workspace_not_found"
// CrossWorkspaceNoWorkspaceAccess means the workspace exists but
// the caller has neither membership nor grants in it.
CrossWorkspaceNoWorkspaceAccess CrossWorkspaceDenialReason = "no_workspace_access"
// CrossWorkspaceTokenNotAllowed means the caller is a genuine
// member of the target workspace, but the bearer token's consent
// allow-list does not include it. This is the denial a naive
// implementation misses.
CrossWorkspaceTokenNotAllowed CrossWorkspaceDenialReason = "token_not_allowed"
// CrossWorkspaceScopeMismatch means the scope's item or collection
// does not belong to the resolved workspace. Confused-deputy
// guard; always fail closed.
CrossWorkspaceScopeMismatch CrossWorkspaceDenialReason = "scope_mismatch"
// CrossWorkspaceCollectionNotVisible means the scope's collection
// is absent, soft-deleted, or hidden from the caller (DR-10a step
// 1).
CrossWorkspaceCollectionNotVisible CrossWorkspaceDenialReason = "collection_not_visible"
// CrossWorkspaceItemNotVisible means the scope's item is hidden
// from the caller (DR-10b step 1).
CrossWorkspaceItemNotVisible CrossWorkspaceDenialReason = "item_not_visible"
// CrossWorkspaceInsufficientPermission means the caller can see
// the scoped resource but may not write it (DR-10a step 2 /
// DR-10b step 2).
CrossWorkspaceInsufficientPermission CrossWorkspaceDenialReason = "insufficient_permission"
// CrossWorkspaceLookupFailed means a store lookup errored. Always
// a denial — fail closed.
CrossWorkspaceLookupFailed CrossWorkspaceDenialReason = "lookup_failed"
)
// CrossWorkspaceAccess is the verdict from AuthorizeCrossWorkspaceRead
// / AuthorizeCrossWorkspaceEdit.
//
// Disclosure posture (PLAN-2357). A caller with no access to workspace
// B must not be able to tell "B does not exist" from "B exists and you
// cannot see it". That is why Reason distinguishes them but the
// response writers do not: WriteHidden collapses every denial to an
// identical 404, and WriteDenied collapses absence and forbidden-ness
// to an identical 403. Read paths should use WriteHidden. Reason is
// for logs, metrics and tests — resist the urge to branch a response
// body on it.
//
// NEVER PUT THIS STRUCT IN A RESPONSE. On a denial it holds exactly
// the things the disclosure rule forbids surfacing: a resolved
// Workspace (with its ID, slug and name), a Role, and a Reason that
// separates "absent" from "forbidden". Every field carries `json:"-"`
// so a stray json.Marshal emits `{}` rather than the leak, but the tag
// is a backstop, not permission — render denials through WriteHidden
// or WriteDenied, and keep Workspace/Role/Reason for server-side logs.
type CrossWorkspaceAccess struct {
// Allowed is the only field a caller should gate behavior on.
Allowed bool `json:"-"`
// Reason is CrossWorkspaceAllowed when Allowed, otherwise the
// specific denial. See the disclosure note above before using it
// to shape a response.
Reason CrossWorkspaceDenialReason `json:"-"`
// Workspace is the resolved target workspace, or nil when
// resolution itself failed. Populated even on later denials so
// callers can log the canonical slug — server-side only.
Workspace *models.Workspace `json:"-"`
// Role is the caller's effective role in the TARGET workspace
// ("owner" / "editor" / "viewer" / "guest"), derived fresh from
// membership + grants — never from workspaceRole(r). Empty when
// the caller has no role there.
Role string `json:"-"`
// Err carries the underlying store error for
// CrossWorkspaceLookupFailed. Nil otherwise.
Err error `json:"-"`
// collectionScoped records that the verdict was computed against a
// CrossWorkspaceCollectionScope. It exists so WriteCollectionNotFound
// can REFUSE to collapse a denial it was not built for, rather than
// trusting its doc comment — see that method. Unexported and never
// serialized; nothing outside this file sets it.
collectionScoped bool
}
// WorkspaceID is a nil-safe accessor for the resolved workspace's ID.
func (a CrossWorkspaceAccess) WorkspaceID() string {
if a.Workspace == nil {
return ""
}
return a.Workspace.ID
}
// WorkspaceSlug is a nil-safe accessor for the resolved workspace's
// canonical slug — the value the token allow-list was tested against.
func (a CrossWorkspaceAccess) WorkspaceSlug() string {
if a.Workspace == nil {
return ""
}
return a.Workspace.Slug
}
// WriteHidden writes the non-disclosing denial: an identical 404 for
// every failure mode, so absence and forbidden-ness are
// indistinguishable. This is the default posture for read paths —
// notably the forward-pointer gate, where even a differently-shaped
// response reveals that a destination exists.
//
// subject names the thing the caller asked for ("Item", "Workspace",
// …) and MUST NOT be derived from the target workspace's state; pass a
// constant.
//
// Internal errors are reported as 404 too, matching
// refResolverNotFound: a 500 here would be a usable oracle only in
// contrived cases, but the contract is cheaper to keep total. The
// error is still returned in Err for the caller to log.
func (a CrossWorkspaceAccess) WriteHidden(w http.ResponseWriter, subject string) {
if subject == "" {
subject = "Resource"
}
writeError(w, http.StatusNotFound, "not_found", subject+" not found")
}
// WriteDenied writes the acknowledging denial, for paths where the
// caller supplied the target slug themselves and a bare 404 would be
// unhelpful — the copy endpoint, primarily.
//
// It still refuses to distinguish "workspace absent" from "workspace
// forbidden": both are 403 with the same message. The only branch is
// the token allow-list, which reports itself because (a) it is the
// caller's OWN consent scope, not information about the target, (b)
// the middleware's primary path already emits exactly that message,
// and (c) it is only ever reported to a caller who genuinely has a
// role in the target — the authorize helpers rank
// CrossWorkspaceNoWorkspaceAccess ahead of it precisely so that
// message can never confirm the existence of a workspace the caller
// is a stranger to.
//
// Lookup failures are a 500 here rather than another 403. That is an
// observable difference, and it can only occur once resolution has
// already succeeded — so it does weakly imply the target exists. Two
// reasons it stays: an attacker cannot induce a store failure on
// demand, and silently laundering 500s into 403s would make a broken
// database look like a permissions problem. It is a real trade, so
// treat it as a PRECONDITION of choosing this writer: only call
// WriteDenied on a path where the caller supplied the target
// themselves. Everywhere else — anything a caller could use to probe
// for workspaces — use WriteHidden, which has no such variant.
func (a CrossWorkspaceAccess) WriteDenied(w http.ResponseWriter) {
switch a.Reason {
case CrossWorkspaceLookupFailed:
writeInternalError(w, a.Err)
case CrossWorkspaceTokenNotAllowed:
writeError(w, http.StatusForbidden, "permission_denied",
"Token is not authorized for this workspace")
default:
writeError(w, http.StatusForbidden, "forbidden",
"You do not have access to the requested workspace")
}
}
// crossWorkspaceCollectionNotFoundCode / Message are the ONE answer given
// for a destination collection that is absent, soft-deleted, in another
// workspace, or simply hidden from the caller. Exported as constants so a
// handler's own "GetCollectionBySlug returned nil" branch and this writer
// cannot drift apart — if the two ever produce different bytes, the
// difference is the disclosure.
const (
crossWorkspaceCollectionNotFoundCode = "collection_not_found"
crossWorkspaceCollectionNotFoundMessage = "Destination collection not found"
)
// WriteCollectionNotFound is the denial writer for a
// CrossWorkspaceCollectionScope check on a workspace the caller has
// ALREADY been authorized into (i.e. a WriteDenied on a
// CrossWorkspaceWorkspaceOnlyScope has already passed).
//
// It exists because PLAN-2357's DR-15 requires the copy preflight to
// distinguish "destination workspace not accessible" (403) from
// "collection not found" (404) — and the naive way to do that leaks. If
// an absent collection 404s while a collection that exists but is hidden
// 403s, a restricted member of workspace B can enumerate the collections
// they were deliberately excluded from, one slug at a time. So:
//
// - collection absent, soft-deleted, foreign to the workspace, or
// hidden from the caller → an identical 404 collection_not_found,
// byte-for-byte the same response the handler emits when
// GetCollectionBySlug returns nil;
// - anything else (no workspace access, token not allow-listed, lookup
// failure) → delegated to WriteDenied unchanged.
//
// PRECONDITION, same as WriteDenied's: only call this where the caller
// supplied the destination themselves. It acknowledges the workspace, and
// that acknowledgement is only safe because the workspace-level check ran
// first and already decided the caller belongs there.
//
// The other half of the precondition IS enforced: the collapse only fires
// on a verdict produced by a CrossWorkspaceCollectionScope. Handed a
// verdict from an item or workspace-only scope it falls through to
// WriteDenied, so a future caller cannot use it to turn a normally hidden
// denial into the distinctive collection_not_found response for a
// collection the caller never named (Codex round 7).
//
// Note the asymmetry with the SOURCE side, which uses WriteHidden: the
// source item is addressed through the request's own workspace URL, so a
// 404 is the natural shape there and there is no destination the caller
// named to acknowledge.
func (a CrossWorkspaceAccess) WriteCollectionNotFound(w http.ResponseWriter) {
if !a.collectionScoped {
a.WriteDenied(w)
return
}
switch a.Reason {
case CrossWorkspaceCollectionNotVisible, CrossWorkspaceScopeMismatch:
writeError(w, http.StatusNotFound,
crossWorkspaceCollectionNotFoundCode, crossWorkspaceCollectionNotFoundMessage)
default:
a.WriteDenied(w)
}
}
// CrossWorkspaceScope names WHAT is being reached in the target
// workspace. It is a required argument, and its zero value is invalid.
//
// This is deliberate. Pad's role checks answer a WORKSPACE-level
// question, but almost every real authorization question is
// collection- or item-level (PLAN-2357 hit the same trap three times:
// DR-10a on the destination, DR-10b on the source, and again on the
// forward-pointer read). Requiring an explicit scope means a caller
// cannot fall into a workspace-level-only check by omission — they
// have to write CrossWorkspaceWorkspaceOnlyScope() and read its
// warning first.
//
// Construct with CrossWorkspaceItemScope, CrossWorkspaceCollectionScope
// or CrossWorkspaceWorkspaceOnlyScope.
type CrossWorkspaceScope struct {
item *models.Item
collectionID string
workspaceLevelOnly bool
}
// CrossWorkspaceItemScope scopes the check to one already-loaded item.
// Collection visibility is checked first and item-level grants second,
// in that order, by checkItemVisible — the same rule set the
// middleware-gated handlers use.
//
// Pass the item you already resolved; the helper deliberately does not
// re-resolve it, so it cannot accidentally apply a different lookup
// path than the caller did. It MUST be a fully populated item — ID,
// WorkspaceID and CollectionID are all required and are checked
// against the resolved target workspace. Soft-deleted (archived) items
// are permitted: reading an archived source item is the point of the
// forward pointer.
func CrossWorkspaceItemScope(item *models.Item) CrossWorkspaceScope {
return CrossWorkspaceScope{item: item}
}
// CrossWorkspaceCollectionScope scopes the check to one collection —
// creating into it, or disclosing its schema.
//
// Visibility here is FULL-collection-access semantics, deliberately
// stricter than the nav-lenient VisibleCollectionIDs set: a caller
// whose only claim on the collection is an item-level grant inside it
// does not qualify. That is the DR-10a hole — VisibleCollectionIDs
// folds item-grant collections in "so the collection appears in
// navigation", and ResolveUserPermission's own restricted-member gate
// consults that same lenient set, so a restricted editor with one
// stray item grant would otherwise be cleared to create into a
// collection deliberately hidden from them.
func CrossWorkspaceCollectionScope(collectionID string) CrossWorkspaceScope {
return CrossWorkspaceScope{collectionID: collectionID}
}
// CrossWorkspaceWorkspaceOnlyScope requests a WORKSPACE-LEVEL CHECK
// ONLY, which is almost never sufficient on its own.
//
// It answers "does this caller have any role in workspace B, and does
// their token allow B" — nothing more. It does NOT answer whether they
// may see or write any particular collection or item there. A
// restricted member, or a guest holding one unrelated item grant, will
// pass this and still have no right to touch the resource you are
// about to touch.
//
// Legitimate uses are narrow: an early reject before the collection or
// item is known, or a check whose resource is genuinely the workspace
// itself. If you are about to read or write a collection or an item,
// you want CrossWorkspaceCollectionScope or CrossWorkspaceItemScope
// instead — and passing this and stopping is the exact mistake DR-10a,
// DR-10b and the forward-pointer leak all describe.
func CrossWorkspaceWorkspaceOnlyScope() CrossWorkspaceScope {
return CrossWorkspaceScope{workspaceLevelOnly: true}
}
func (sc CrossWorkspaceScope) valid() bool {
switch {
case sc.workspaceLevelOnly:
return sc.item == nil && sc.collectionID == ""
case sc.item != nil:
return sc.collectionID == ""
case sc.collectionID != "":
return true
default:
return false
}
}
// itemID / collectionID feed ResolveUserPermission's resolution order.
func (sc CrossWorkspaceScope) itemID() string {
if sc.item == nil {
return ""
}
return sc.item.ID
}
func (sc CrossWorkspaceScope) permCollectionID() string {
if sc.item != nil {
return sc.item.CollectionID
}
return sc.collectionID
}
// AuthorizeCrossWorkspaceRead decides whether the caller may READ the
// scoped resource in a workspace OTHER than the request's own.
//
// workspaceSlugOrID may be either form; it is resolved the same way
// RequireWorkspaceAccess resolves the URL slug, and the token
// allow-list is then tested against the resolved CANONICAL slug (the
// supplied value may have been a UUID).
//
// Order of evaluation, all of it fail-closed:
//
// 1. scope is well-formed;
// 2. workspace resolves and is not soft-deleted;
// 3. caller's role in THAT workspace, from membership and grants,
// honoring the bearer-vs-cookie admin split (BUG-1616/1617);
// 4. token consent allow-list against that workspace's slug;
// 5. the scoped visibility check — collection-then-item for an item
// scope, full-collection-access for a collection scope.
//
// NOT ATOMIC. The verdict describes the world at the instant it was
// computed, against the item you handed in. Nothing here takes a lock
// or a transaction, and every input can change underneath you: the
// item can be moved into a hidden collection or archived, the
// collection can be soft-deleted, the caller's membership or grants
// can be revoked, the workspace itself can be soft-deleted. For a read
// path a stale verdict is a much smaller problem, but do not cache one
// across requests.
//
// WHAT A MUTATING CALLER OWES, stated precisely, because the obvious
// phrasing — "re-apply this check inside your write transaction" — is
// wrong and was corrected here after PLAN-2357's final review.
//
// A mutating caller must re-read the RESOURCE IDENTITY it authorized
// (the item and the collections) inside its write transaction and
// refuse if any of it moved, because those are the inputs whose change
// turns an authorized request into a different, unauthorized one. See
// copyResourceInvariantPreCheck in handlers_items_copy.go for the
// worked example, and UpdateItemWithPreCheck / MoveItemWithPreCheck
// for the established shape.
//
// It must NOT re-run this helper inside the transaction. These
// functions read through s.store rather than the caller's tx, so under
// Postgres READ COMMITTED each statement takes its own snapshot: the
// "re-check" would judge locked resources against authorization state
// read at several unsynchronised moments, and membership can still be
// revoked and committed between those reads and the caller's COMMIT.
// It would read as a write-time guarantee while providing none, which
// is worse for the product than the honest boundary. The honest
// boundary is that authorization is evaluated once, at the start of a
// request measured in milliseconds — the same window every other write
// path in Pad has.
//
// See the file header for why requireEditPermission and friends cannot
// be used here.
func (s *Server) AuthorizeCrossWorkspaceRead(r *http.Request, workspaceSlugOrID string, scope CrossWorkspaceScope) CrossWorkspaceAccess {
return s.authorizeCrossWorkspace(r, workspaceSlugOrID, scope, false)
}
// AuthorizeCrossWorkspaceEdit decides whether the caller may WRITE the
// scoped resource in a workspace OTHER than the request's own.
//
// It runs every step AuthorizeCrossWorkspaceRead runs, in the same
// order, and only then evaluates write permission — visibility first,
// permission second (DR-10a / DR-10b). Never reorder those: the
// permission check alone clears a restricted member for a collection
// they were never allowed to see.
//
// The four-step ordering DR-10b specifies for a cross-workspace copy
// composes out of two calls — and the SOURCE verdict must be handled
// before the destination is touched at all:
//
// src := s.AuthorizeCrossWorkspaceEdit(r, srcWS, CrossWorkspaceItemScope(item))
// if !src.Allowed {
// src.WriteHidden(w, "Item")
// return
// }
// dst := s.AuthorizeCrossWorkspaceEdit(r, dstWS, CrossWorkspaceCollectionScope(collID))
// if !dst.Allowed {
// dst.WriteDenied(w)
// return
// }
//
// which is source-item-visible → source-edit → destination-collection-
// visible → destination-collection-edit. The early return is part of
// the ordering, not style: evaluating the destination after a failed
// source check builds a verdict about workspace B for a caller who was
// not even allowed to read the source, and anything that verdict
// reaches — a log line, an error message, a timing difference — is a
// disclosure the source check was supposed to have prevented.
//
// Both calls apply to a dry-run as much as to the mutation; a preflight
// that confirms a hidden item exists, or echoes a hidden collection's
// schema, is itself the leak.
//
// NOT ATOMIC — see AuthorizeCrossWorkspaceRead. This is a PRE-check,
// not a write barrier. Treat an allow as "proceed to the transaction",
// never as "the write is now authorized": between this call and the
// insert, the destination collection can be soft-deleted (CreateItem
// does not reject that), the source item can be moved or archived, and
// the caller's membership or grants can be revoked. Re-read and
// re-check inside the write transaction.
//
// See the file header for why requireEditPermission cannot be used
// here.
func (s *Server) AuthorizeCrossWorkspaceEdit(r *http.Request, workspaceSlugOrID string, scope CrossWorkspaceScope) CrossWorkspaceAccess {
return s.authorizeCrossWorkspace(r, workspaceSlugOrID, scope, true)
}
func crossWorkspaceDeny(reason CrossWorkspaceDenialReason, ws *models.Workspace, role string, err error) CrossWorkspaceAccess {
return CrossWorkspaceAccess{Reason: reason, Workspace: ws, Role: role, Err: err}
}
func (s *Server) authorizeCrossWorkspace(r *http.Request, workspaceSlugOrID string, scope CrossWorkspaceScope, needEdit bool) CrossWorkspaceAccess {
// Stamped onto every verdict below, allow or deny, so
// WriteCollectionNotFound can tell a collection-scoped denial from any
// other kind. Done in one place, on the way out, rather than at each
// return — a missed stamp would silently re-open the hole the flag
// exists to close.
verdict := func(a CrossWorkspaceAccess) CrossWorkspaceAccess {
a.collectionScoped = scope.collectionID != ""
return a
}
return verdict(s.authorizeCrossWorkspaceInner(r, workspaceSlugOrID, scope, needEdit))
}
func (s *Server) authorizeCrossWorkspaceInner(r *http.Request, workspaceSlugOrID string, scope CrossWorkspaceScope, needEdit bool) CrossWorkspaceAccess {
// 1. Scope well-formedness. A zero CrossWorkspaceScope names
// nothing; refuse rather than silently degrade to a
// workspace-level check.
if !scope.valid() {
return crossWorkspaceDeny(CrossWorkspaceInvalidScope, nil, "",
errors.New("cross-workspace authorization called with an empty or ambiguous scope"))
}
if workspaceSlugOrID == "" {
return crossWorkspaceDeny(CrossWorkspaceWorkspaceNotFound, nil, "", nil)
}
user := currentUser(r)
isBearer := isBearerAuth(r)
// 2. Resolve the target. resolveWorkspace applies the same ACL
// scoping RequireWorkspaceAccess applies to the URL slug, and
// every path it takes (GetWorkspaceByID, GetWorkspaceBySlug,
// GetWorkspacesBySlugForUser) filters `deleted_at IS NULL`, so a
// soft-deleted target resolves to nil → denied. Note the
// UUID branch is NOT ACL-scoped; the role check below is what
// actually authorizes, exactly as in the middleware.
ws, err := s.resolveWorkspace(workspaceSlugOrID, user)
if err != nil {
return crossWorkspaceDeny(CrossWorkspaceLookupFailed, nil, "", err)
}
if ws == nil {
return crossWorkspaceDeny(CrossWorkspaceWorkspaceNotFound, nil, "", nil)
}
// 3. Role in the TARGET workspace, computed fresh. Never
// workspaceRole(r) — that is workspace A's answer.
role, err := s.crossWorkspaceRole(r, ws, user, isBearer)
if err != nil {
return crossWorkspaceDeny(CrossWorkspaceLookupFailed, ws, "", err)
}
// 4. Token consent allow-list, against the CANONICAL slug.
//
// Evaluated here but reported AFTER the role check, so a
// "token not authorized" response can only ever reach a caller
// who actually has a role in the target. Otherwise a
// bearer-borne platform admin — for whom resolveWorkspace does a
// global slug lookup — could use the distinct message to
// confirm the existence of a workspace they never joined.
tokenAllowed := tokenAllowedWorkspaceMatches(r.Context(), ws.Slug)
if role == "" {
s.recordMCPAuthzDenial(r, "not_a_member")
return crossWorkspaceDeny(CrossWorkspaceNoWorkspaceAccess, ws, "", nil)
}
if !tokenAllowed {
s.recordMCPAuthzDenial(r, "workspace_not_in_allowlist")
return crossWorkspaceDeny(CrossWorkspaceTokenNotAllowed, ws, role, nil)
}
// 5. Scoped visibility. Workspace-level rights are not sufficient
// and this is where that is enforced.
switch {
case scope.item != nil:
item := scope.item
// Confused-deputy guard: the item must actually live in the
// workspace we just authorized, and it must be fully
// identified. Without this a caller could pass workspace B
// (which they can write) alongside an item from workspace C
// — and a sparse or hand-built models.Item with an empty
// WorkspaceID or CollectionID would sail through both the
// visibility filter (isCollectionVisible short-circuits on an
// unrestricted caller) and ResolveUserPermission (which falls
// back to the workspace membership role when collectionID is
// empty). Every field is required; no "unset means it's fine"
// branch (Codex round 1 P1).
if item.ID == "" || item.WorkspaceID != ws.ID || item.CollectionID == "" {
return crossWorkspaceDeny(CrossWorkspaceScopeMismatch, ws, role, nil)
}
// The item's collection must exist, live in the same
// workspace, and not be archived. checkItemVisible does NOT
// establish any of that — soft-deleting a collection leaves
// its items in place and neither GetItem nor
// VisibleCollectionIDs filters on the collection's deleted_at,
// so an item under an archived collection would otherwise pass
// (Codex round 2 P1).
//
// Only the caller-independent facts, though: the item scope
// deliberately does NOT apply the collection scope's
// full-collection-access rule. A guest whose sole claim is an
// item grant SHOULD be able to read that one item — that is
// what item grants mean — while still being barred from
// operating on the collection as a whole. checkItemVisible
// below is what draws that line.
if _, deny, ok := s.crossWorkspaceLiveCollection(ws, role, item.CollectionID); !ok {
return deny
}
visible, vErr := s.checkItemVisible(ws.ID, item, user, role, isBearer)
if vErr != nil {
return crossWorkspaceDeny(CrossWorkspaceLookupFailed, ws, role, vErr)
}
if !visible {
return crossWorkspaceDeny(CrossWorkspaceItemNotVisible, ws, role, nil)
}
case scope.collectionID != "":
coll, deny, ok := s.crossWorkspaceLiveCollection(ws, role, scope.collectionID)
if !ok {
return deny
}
visible, vErr := s.checkCollectionFullyVisible(r, ws.ID, coll.ID)
if vErr != nil {
return crossWorkspaceDeny(CrossWorkspaceLookupFailed, ws, role, vErr)
}
if !visible {
return crossWorkspaceDeny(CrossWorkspaceCollectionNotVisible, ws, role, nil)
}
}
if !needEdit {
return CrossWorkspaceAccess{Allowed: true, Workspace: ws, Role: role}
}
// 6. Write permission, only after visibility passed.
allowed, pErr := s.crossWorkspaceEditAllowed(ws, user, isBearer, role, scope)
if pErr != nil {
return crossWorkspaceDeny(CrossWorkspaceLookupFailed, ws, role, pErr)
}
if !allowed {
return crossWorkspaceDeny(CrossWorkspaceInsufficientPermission, ws, role, nil)
}
return CrossWorkspaceAccess{Allowed: true, Workspace: ws, Role: role}
}
// crossWorkspaceLiveCollection loads the scope's collection and
// establishes the caller-independent facts: it exists, it is not
// soft-deleted, and it belongs to the resolved workspace.
//
// Returns (verdict, false) on a denial and (coll, true) otherwise.
// Both scopes go through it, so neither can be satisfied by a
// collection that is archived or lives somewhere else.
func (s *Server) crossWorkspaceLiveCollection(ws *models.Workspace, role, collectionID string) (*models.Collection, CrossWorkspaceAccess, bool) {
coll, err := s.store.GetCollection(collectionID)
if err != nil {
return nil, crossWorkspaceDeny(CrossWorkspaceLookupFailed, ws, role, err), false
}
// GetCollection filters soft-deleted rows, so nil covers "absent"
// and "archived" alike — both are unreachable.
if coll == nil {
return nil, crossWorkspaceDeny(CrossWorkspaceCollectionNotVisible, ws, role, nil), false
}
if coll.WorkspaceID != ws.ID {
return nil, crossWorkspaceDeny(CrossWorkspaceScopeMismatch, ws, role, nil), false
}
return coll, CrossWorkspaceAccess{}, true
}
// crossWorkspaceRole derives the caller's role in an arbitrary
// workspace, reproducing RequireWorkspaceAccess's rules without the
// request-scoped context value that middleware populates. Returns ""
// when the caller has no role there. Errors are real store failures —
// callers must treat them as denials.
//
// Governing rule: this must never grant MORE than the front door. If
// RequireWorkspaceAccess would 403 a caller at
// /api/v1/workspaces/{target}, reaching the same workspace sideways
// through a cross-workspace operation must fail too. The branches
// below therefore track middleware_auth.go's order exactly:
//
// 1. fresh install (UserCount == 0, no user) → "owner", because the
// whole instance is open until the first admin exists;
// 2. legacy workspace-scoped API token (no user, token pinned to a
// workspace) → "editor", but ONLY for the workspace it is pinned
// to. For any other workspace the token is a stranger — the
// correct answer for a cross-workspace reach;
// 3. platform admin over a COOKIE session → "owner" on every
// workspace. Over any bearer surface (CLI, PAT, MCP) the bypass is
// suppressed and the admin falls through to membership
// (BUG-1616/1617);
// 4. membership row → its role;
// 5. bearer-borne platform admin who is not a member → "", with NO
// grants fallback (BUG-1616/1617);
// 6. non-member holding any grant → "guest";
// 7. otherwise "".
//
// Note this deliberately does NOT reuse resolverWorkspaceRole
// (handlers_ref_resolver.go), even though the two are close. That
// helper short-circuits to "owner" on ws.OwnerID for every auth
// surface (BUG-1618, a deliberate widening for the cookie-only
// redirect route). RequireWorkspaceAccess has no such exception —
// ownership and membership are separate persisted state, and the
// middleware requires the membership row — so honoring the shortcut
// here would let a bearer token reach a workspace the front door
// refuses. Codex round 2 P1.
func (s *Server) crossWorkspaceRole(r *http.Request, ws *models.Workspace, user *models.User, isBearer bool) (string, error) {
if user == nil {
count, err := s.store.UserCount()
if err != nil {
return "", err
}
if count == 0 {
return "owner", nil
}
if tokenWsID := tokenWorkspaceID(r); tokenWsID != "" && tokenWsID == ws.ID {
return "editor", nil
}
return "", nil
}
if user.Role == "admin" && !isBearer {
return "owner", nil
}
member, err := s.store.GetWorkspaceMember(ws.ID, user.ID)
if err != nil {
return "", err
}
if member != nil {
return member.Role, nil
}
// Bearer-borne platform admin who isn't a member gets NO
// grant-based fallback — the membership-only stance
// (BUG-1616/1617). Without this a single stray collection or item
// grant would yield "guest" below, and checkItemVisible's own
// admin bypass would then widen that back out.
if user.Role == "admin" && isBearer {
return "", nil
}
hasGrants, err := s.store.UserHasGrantsInWorkspace(ws.ID, user.ID)
if err != nil {
return "", err
}
if hasGrants {
return "guest", nil
}
return "", nil
}
// crossWorkspaceEditAllowed answers the write half, and only ever runs
// after the scoped visibility check has already passed.
//
// It reproduces requireEditPermission's decision — editor/owner base
// role wins, otherwise fall back to ResolveUserPermission so grants can
// override an insufficient role — with exactly two substitutions, which
// are the whole point of this file:
//
// - `role` is derived for the TARGET workspace by crossWorkspaceRole,
// never read from workspaceRole(r);
// - it runs only AFTER the scoped visibility check.
//
// That ordering is what makes the base-role branch safe. DR-10a's
// escalation is the fast path used ALONE: an editor whose membership is
// collection_access="specific" passing the role check for a collection
// hidden from them. authorizeCrossWorkspace has already applied the
// collection- or item-scoped visibility filter by the time we get here,
// so the base role is being applied to a resource the caller is
// established to be able to see — the same composition handleMoveItem
// uses (requireItemVisible then requireEditPermission).
//
// Grants are treated as widening only, never narrowing. Delegating
// unconditionally to ResolveUserPermission would be tempting — it is
// the resource-scoped answer — but it resolves item and collection
// grants BEFORE membership, so an incidental `view` grant would demote
// a member whose base role is editor or owner and deny an edit the
// front door allows (Codex round 5). Fail-closed, but wrong, and
// divergence from requireEditPermission in either direction is how this
// helper eventually rots.
//
// Two bypasses ResolveUserPermission cannot express are handled here:
//
// - tokenized nil-user roles ("owner" on a fresh install, "editor"
// for a legacy workspace-pinned token) — crossWorkspaceRole only
// hands those out when the caller is genuinely authorized, and
// ResolveUserPermission has no user ID to work with;
// - the platform-admin bypass, which is cookie-session only. A
// bearer-borne admin falls through and is judged on their actual
// membership.
func (s *Server) crossWorkspaceEditAllowed(ws *models.Workspace, user *models.User, isBearer bool, role string, scope CrossWorkspaceScope) (bool, error) {
if user == nil {
// Only reachable for the synthesized roles above; a nil user
// with no role was already denied.
return role == "owner" || role == "editor", nil
}
if user.Role == "admin" && !isBearer {
return true, nil
}
// Base-role branch, mirroring requireEditPermission. "guest" is
// excluded: a guest has no role-based permission at all, only
// grants.
if role != "guest" && roleLevel(role) >= roleLevel("editor") {
return true, nil
}
perm, err := s.store.ResolveUserPermission(ws.ID, user.ID, scope.itemID(), scope.permCollectionID())
if err != nil {
return false, err
}
return permissionLevel(perm) >= permissionLevel("edit"), nil
}
// checkCollectionFullyVisible is the bool-returning core of
// requireCollectionFullyVisible — same rules, no response writing, and
// a workspace ID that need not be the request's own. See
// requireCollectionFullyVisible for why the full-access narrowing is
// stricter than plain visibleCollectionIDs.
//
// Both of its inputs are workspace-explicit: visibleCollectionIDs and
// guestResourceFilter read the request only for the current user and
// the bearer flag, never for a workspace-scoped context value.
func (s *Server) checkCollectionFullyVisible(r *http.Request, workspaceID, collectionID string) (bool, error) {
visibleIDs, err := s.visibleCollectionIDs(r, workspaceID)
if err != nil {
return false, err
}
if visibleIDs != nil {
// Restricted caller: when any item-level grant is in play,
// narrow from the nav-lenient set to full-access collections
// only, so an item-grant-only collection cannot qualify for a
// collection-wide operation.
fullCollIDs, grantedItemIDs, gErr := s.guestResourceFilter(r, workspaceID)
if gErr != nil {
return false, gErr
}
if len(grantedItemIDs) > 0 {
// isCollectionVisible reads nil as "unrestricted", so a
// caller whose full-access set is genuinely empty — a guest
// whose only claim is an item grant — must be handed an
// empty NON-NIL slice, not nil. ResolveBacklinksVisibility
// already guarantees that, but the guarantee lives two
// packages away and inverting it here would silently grant
// every collection in the workspace. Re-assert it locally
// (Codex round 3 P1: reported as a live bug on the
// assumption the guarantee wasn't there; it is, and
// TestCrossWorkspace_GuestItemGrantOnly pins the behavior —
// but the sentinel flip is too dangerous to leave implicit).
if fullCollIDs == nil {
fullCollIDs = []string{}
}
visibleIDs = fullCollIDs
}
}
return isCollectionVisible(collectionID, visibleIDs), nil
}