mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 14:00:40 +00:00
0f5b567fe7
* 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.
167 lines
6.6 KiB
Go
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
|
|
}
|