mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
10a1683b83
* test(server): the epoch tracks the live collection set; the guard ignores deleted collections' children (BUG-3428 phase 2, red) * fix: the access epoch fingerprints the live collection set; the index and delta drop deleted collections' rows; the open-children guard ignores them (BUG-3428 phase 2) * test(server): an item door's epoch is never newer than its rows (BUG-3428 phase 2, codex r1, red) * fix(server): item doors read the live collection set before their rows, so a response's epoch is never newer than its rows (BUG-3428 phase 2, codex r1) * test(server): a grant-filtered delta's epoch is never newer than its rows (BUG-3428 phase 2, codex r2, red) * fix(server): the delta door resolves its live grant set before its rows too (BUG-3428 phase 2, codex r2)
128 lines
5.6 KiB
Go
128 lines
5.6 KiB
Go
package server
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"sort"
|
|
)
|
|
|
|
// accessEpochUnrestricted is the epoch for a caller with no collection
|
|
// filtering at all (owner, editor with "all" access, cookie-session admin).
|
|
//
|
|
// It is a SENTINEL rather than a hash of the empty set, because the empty set
|
|
// is a real and opposite state: a restricted member with zero visible
|
|
// collections. Hashing both to the same value would make the widest and the
|
|
// narrowest access indistinguishable — a caller whose access was revoked down
|
|
// to nothing would carry the same epoch as an owner, and a promotion from
|
|
// nothing to everything would signal no change at all.
|
|
const accessEpochUnrestricted = "all"
|
|
|
|
// computeAccessEpoch fingerprints the caller's EFFECTIVE visible resource set
|
|
// so a client can tell that the set changed without being told what it is.
|
|
//
|
|
// IDEA-2898. The delta stream (/items-changes) can only express changes to
|
|
// ROWS. A revocation that writes no item — the ordinary shape of revocation —
|
|
// therefore produces no signal at all, and a client's warm local index goes on
|
|
// serving rows for a collection the caller can no longer see. This value is
|
|
// that missing signal: the client compares it against the one it stored and,
|
|
// on a change, runs the authoritative resync it already implements.
|
|
//
|
|
// Inputs are the two lists the item doors already resolve before writing their
|
|
// response, so this costs no additional query:
|
|
//
|
|
// - visibleCollectionIDs: nil means "no filtering" (see the sentinel above);
|
|
// a non-nil slice, possibly empty, is the caller's collection-level set.
|
|
// - grantedItemIDs: the caller's item-level grants, empty for most callers.
|
|
//
|
|
// The hash is over a CANONICAL form — both lists sorted, and the two lists
|
|
// separated by a byte that cannot occur in an ID — so a set that has not
|
|
// changed cannot produce a different epoch just because the store returned its
|
|
// rows in a different order. An epoch that flapped on ordering alone would
|
|
// trigger a full resync per poll, which is the one failure mode that would
|
|
// make this worse than the defect it closes.
|
|
//
|
|
// The value is derived only from the caller's OWN access and is opaque: it
|
|
// names no collection and no item, and two callers with different access
|
|
// cannot learn anything about each other from it.
|
|
func computeAccessEpoch(visibleCollectionIDs, grantedItemIDs []string) string {
|
|
if visibleCollectionIDs == nil {
|
|
// Grants only narrow within a filtered set; an unrestricted caller
|
|
// cannot also be item-filtered, and the doors never pass both.
|
|
return accessEpochUnrestricted
|
|
}
|
|
h := sha256.New()
|
|
writeSortedIDs(h, visibleCollectionIDs)
|
|
// Separator: a newline cannot appear in a UUID, so ["a","b"]+[] and
|
|
// ["a"]+["b"] cannot collide.
|
|
_, _ = h.Write([]byte("\n--\n"))
|
|
writeSortedIDs(h, grantedItemIDs)
|
|
return hex.EncodeToString(h.Sum(nil))[:16]
|
|
}
|
|
|
|
// effectiveAccessEpoch is the ONE definition of a caller's epoch, used by
|
|
// /items-index, /items-changes and the SSE revalidation tick (BUG-3347). It
|
|
// hashes the collection set the item doors actually FILTER BY, which is not
|
|
// always the nav-visible set:
|
|
//
|
|
// - With item grants, the doors swap their collection filter to
|
|
// guestResourceFilter's fullCollIDs. That set drops a whole-collection
|
|
// grant on a soft-deleted collection (BUG-3333), while the nav set keeps
|
|
// it so the grant stays revocable. Hashing the nav set left the epoch
|
|
// unchanged when that collection was deleted, so a warm client was never
|
|
// told to drop its cached rows.
|
|
// - Without item grants, the doors filter by the nav set, and so does this.
|
|
//
|
|
// grantedItemIDs must be the LIVE grant set (guestResourceFilter, not the
|
|
// include-deleted variant): see the review-round-2 note on /items-changes.
|
|
//
|
|
// liveCollectionIDs is the workspace's live collection set (BUG-3428 phase 2,
|
|
// lead ruling: option A). Deleting a collection writes no item rows, so the
|
|
// epoch must move on its own: an unrestricted caller's epoch is the "all"
|
|
// sentinel plus a hash of the live set, and a restricted caller's collection
|
|
// set is intersected with it, because the item doors leave a soft-deleted
|
|
// collection's rows out for everyone. A collection create moves it too, which
|
|
// costs one resync per client and was ruled acceptable.
|
|
func effectiveAccessEpoch(visibleCollectionIDs, fullCollIDs, grantedItemIDs, liveCollectionIDs []string) string {
|
|
if visibleCollectionIDs == nil && len(grantedItemIDs) == 0 {
|
|
h := sha256.New()
|
|
writeSortedIDs(h, liveCollectionIDs)
|
|
return accessEpochUnrestricted + ":" + hex.EncodeToString(h.Sum(nil))[:16]
|
|
}
|
|
coll := visibleCollectionIDs
|
|
if len(grantedItemIDs) > 0 {
|
|
// Item grants make the caller filtered whatever the collection list
|
|
// says; nil must not read as the unrestricted sentinel.
|
|
coll = fullCollIDs
|
|
}
|
|
return computeAccessEpoch(intersectIDs(coll, liveCollectionIDs), grantedItemIDs)
|
|
}
|
|
|
|
// intersectIDs returns the ids in a that are also in b, never nil.
|
|
func intersectIDs(a, b []string) []string {
|
|
in := make(map[string]bool, len(b))
|
|
for _, id := range b {
|
|
in[id] = true
|
|
}
|
|
out := []string{}
|
|
for _, id := range a {
|
|
if in[id] {
|
|
out = append(out, id)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// writeSortedIDs feeds ids to h in sorted order, one per line. The input slice
|
|
// is COPIED before sorting: the callers pass slices they go on to use for the
|
|
// query itself, and reordering those under them would be an invisible
|
|
// side effect of computing a fingerprint.
|
|
func writeSortedIDs(h interface{ Write([]byte) (int, error) }, ids []string) {
|
|
sorted := make([]string, len(ids))
|
|
copy(sorted, ids)
|
|
sort.Strings(sorted)
|
|
for _, id := range sorted {
|
|
_, _ = h.Write([]byte(id))
|
|
_, _ = h.Write([]byte("\n"))
|
|
}
|
|
}
|