Files
pad/internal/server/relation_hydration.go
T
xarmian 0f5b567fe7 fix(server): lineage and closure name only items the caller may see (#1743)
* fix(server): lineage and closure name only items the caller may see (BUG-3366)

Dave's ruling on TASK-3345 #4: HIDE. Parent title/ref and derived-closure
related items authorized the other item by its COLLECTION, which is
navigation-lenient for item grants, so an item-grant guest was shown
ungranted parents and superseders in a granted item's collection. They now
use the per-item policy relation_targets uses (itemVisibleFor, shared and
fail-closed), for restricted callers in the list, single-item and closure
paths. TestProjectChangelogEndpoint_GuestParentFilter_ItemGrantOnlyCollection
is flipped: an ungranted parent is not matchable, a granted one is.

* fix(server): redact an ungranted legacy relation target by title too (BUG-3366)

Codex r1: a target with item_number NULL hydrates with a title and no ref,
and redaction keyed on the ref alone, so an item-grant guest saw an
ungranted legacy target's title through a granted item's relation field.
2026-10-02 18:49:07 -04:00

167 lines
6.6 KiB
Go

package server
import (
"net/http"
"github.com/PerpetualSoftware/pad/internal/models"
)
// hydrateRelationTargets fills `relation_targets` on a batch of items
// (PLAN-2857 U6): for each `relation` field the collection declares, the item
// the stored id names, as {id, ref, title}.
//
// It is the READ-side counterpart to the write-side resolver, and it inherits
// that resolver's division of labour: the store answers what the items say, and
// this layer — the one that knows who is asking — decides what may be said back.
//
// TWO THINGS COLLAPSE A TARGET TO ID-ONLY, and neither omits the key:
//
// - the value resolves to nothing live in this workspace (dangling, or an id
// naming another workspace, which the store declines to resolve);
// - the requester cannot see the target ITEM.
//
// THAT SECOND CHECK IS PER-ITEM, NOT PER-COLLECTION, and the difference is a
// leak rather than a nicety. `store.VisibleCollectionIDs` is deliberately
// NAV-LENIENT: it folds in a collection reachable only through an ITEM-level
// grant "so the collection appears in navigation", and leaves item-level
// filtering to the handlers — requireCollectionFullyVisible's comment says so,
// and exists because BUG-1920 walked into it. Filtering hydration on that set
// would hand a member with a grant on ONE item in a collection the ref and
// title of EVERY item any relation points at there. `checkItemVisible` is the
// authority, and it is asked once per DISTINCT TARGET, not once per item.
//
// Omitting the key instead would say "this item has no relation here", which is
// a different statement and a false one. An id-only entry says the true thing:
// the value is stored, and there is nothing more this requester may be told.
// The write side already collapses `wrong_collection` to `not_found` for an
// invisible target; hydrating that item's ref and title on the read would hand
// back exactly what that collapse withholds.
//
// BEST-EFFORT. A failure here leaves items unhydrated rather than failing the
// read — the same posture enrichItemsWithParent takes for parent info, and for
// the same reason: `relation_targets` is additive convenience, and a list read
// that 500s because a convenience field could not be built is a worse answer
// than one without it.
func (s *Server) hydrateRelationTargets(r *http.Request, workspaceID string, items []models.Item) {
if len(items) == 0 || r == nil {
return
}
schemas, err := s.relationSchemasForWorkspace(workspaceID)
if err != nil || len(schemas) == 0 {
return
}
targets, err := s.store.HydrateRelationTargetsQ(s.store.DB(), workspaceID, items, schemas)
if err != nil || len(targets) == 0 {
return
}
visible := s.itemVisibleFor(r, workspaceID)
for i := range items {
perField, ok := targets[items[i].ID]
if !ok {
continue
}
out := make(map[string]models.RelationTargetSet, len(perField))
// The visibility redaction is PER TARGET, so it applies element-wise to
// a `multi_relation` list (U4). A list where one element is visible and
// another is not must redact only the second — redacting the whole list
// would withhold what the caller may see, and redacting none would
// disclose what they may not.
redact := func(target models.RelationTarget) models.RelationTarget {
// Ref OR Title: a legacy target (item_number NULL) resolves with a
// title and no ref, and keying on the ref alone let its title through
// (BUG-3366, codex r1).
if (target.Ref != "" || target.Title != "") && !visible(target.ID) {
// Resolved, but not for these eyes. Keep the id — the value IS
// stored — and drop what would disclose the target.
return models.RelationTarget{ID: target.ID}
}
return target
}
for key, set := range perField {
switch {
case set.List != nil:
list := make([]models.RelationTarget, len(set.List))
for j, target := range set.List {
list[j] = redact(target)
}
out[key] = models.NewRelationTargetList(list)
case set.One != nil:
out[key] = models.NewRelationTargetSet(redact(*set.One))
}
}
items[i].RelationTargets = out
}
}
// itemVisibleFor returns the per-item visibility decision for the caller of
// r, for items of workspaceID: the policy every enrichment that NAMES another
// item applies to that item (relation targets, and since BUG-3366 lineage and
// closure). Collection visibility is not enough there: it is navigation-
// lenient for item grants, so an item-grant guest would be shown the title
// and ref of ungranted items in a granted item's collection.
//
// One decision per DISTINCT id, memoized for the life of the returned
// function, so a list page of 50 items sharing one parent asks once. A lookup
// that fails, or finds nothing, is NOT visible: fail closed.
func (s *Server) itemVisibleFor(r *http.Request, workspaceID string) func(id string) bool {
// workspaceRole(r) is the right role here, unlike in the cross-workspace
// copy: every item being enriched belongs to the workspace in the URL,
// which is the workspace that role was stashed for.
user, role, bearer := currentUser(r), workspaceRole(r), isBearerAuth(r)
allowed := map[string]bool{}
return func(id string) bool {
if seen, done := allowed[id]; done {
return seen
}
item, err := s.store.GetItem(id)
if err != nil || item == nil || item.WorkspaceID != workspaceID {
allowed[id] = false
return false
}
seen, err := s.checkItemVisible(workspaceID, item, user, role, bearer)
if err != nil {
// Fail CLOSED. An error here is not a licence to disclose.
seen = false
}
allowed[id] = seen
return seen
}
}
// relationSchemasForWorkspace maps collection ID -> schema for every collection
// in the workspace that DECLARES a relation field.
//
// Narrowed to relation-declaring collections on purpose: the map is handed to
// the store's hydrator, which skips a collection it cannot find, so including
// the rest would only make a larger map for it to walk past. A collection with
// no relation field contributes no work either way.
func (s *Server) relationSchemasForWorkspace(workspaceID string) (map[string]models.CollectionSchema, error) {
colls, err := s.store.ListCollections(workspaceID)
if err != nil {
return nil, err
}
out := map[string]models.CollectionSchema{}
for i := range colls {
if colls[i].Schema == "" {
continue
}
var schema models.CollectionSchema
if err := models.UnmarshalItemFieldSchema([]byte(colls[i].Schema), &schema); err != nil {
// A schema that will not parse is a different defect, reported
// elsewhere; hydration declines to be the second voice.
continue
}
for _, def := range schema.Fields {
if def.IsRelation() {
out[colls[i].ID] = schema
break
}
}
}
return out, nil
}