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 }