Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions pkg/dotc1z/engine/pebble/digest.go
Original file line number Diff line number Diff line change
Expand Up @@ -494,12 +494,10 @@ type DigestRoot struct {
// computeBucketDigest would read that absence as "zero records" — the
// false-clean trap dirtyPartitionBuckets' doc comment describes.
func (e *Engine) getPartitionDigestRoot(spec digestIndexSpec, partition string) (DigestRoot, bool, error) {
if e.grantDigestBuildPending.Load() {
// An interrupted digest build's half-committed nodes may be
// durable while its hash index never ingested; until the pending
// state is consumed (a writable Open drops it; a read-only open
// cannot), no stored root may be trusted — report "never built",
// which every consumer already treats as "recalculate".
if e.grantDigestStateUntrusted() {
// See grantDigestStateUntrusted: no stored root may be trusted
// while either flag is set — report "never built", which every
// consumer already treats as "recalculate".
return DigestRoot{}, false, nil
}
val, closer, err := e.db.Get(encodeDigestNodeKey(spec.indexID, partition, digestLevelRoot, nil))
Expand Down
6 changes: 4 additions & 2 deletions pkg/dotc1z/engine/pebble/digest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,12 @@ func sealGrantDigests(t testing.TB, e *Engine) {
// keyspace but is a single fold-of-everything summary the seal build
// writes once per file, not a per-partition node, so counting it here
// would throw off every existing "N nodes for this one entitlement"
// assertion by a constant +1.
// assertion by a constant +1. The ABI stamp is excluded structurally:
// the node bounds end at the DigestMetaIndexID sub-range it lives in.
func digestNodeCount(t testing.TB, e *Engine) int {
t.Helper()
n := countKeyRangeTest(t, e, DigestLowerBound(), DigestUpperBound())
lo, hi := rawdb.DigestNodeKeyspaceBounds()
n := countKeyRangeTest(t, e, lo, hi)
if _, ok, err := e.GetGrantDigestGlobalRoot(context.Background()); err != nil {
t.Fatalf("GetGrantDigestGlobalRoot: %v", err)
} else if ok {
Expand Down
50 changes: 50 additions & 0 deletions pkg/dotc1z/engine/pebble/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,17 @@ type Engine struct {
// built" instead of trusting nodes a crashed build half-committed.
grantDigestBuildPending atomic.Bool

// grantDigestAbiStale is the read-only-open counterpart of the ABI
// check in verifyGrantDigestABI: true when the file holds digest
// nodes whose stamp (rawdb.GrantDigestABIStampKey) does not name
// the current GrantDigestABIVersion — state built by different hash
// code, e.g. a file sealed by an older SDK. A writable Open drops
// such state instead of setting this, so on a writable engine it is
// always false; on a read-only engine it makes the digest root
// getters report "never built" (the same fail-safe shape as
// grantDigestBuildPending above), and consumers recalculate.
grantDigestAbiStale atomic.Bool

// test holds every test-only injection seam, sequestered on one
// field so hooks don't accumulate on the production struct. All
// zero in production; see testSeams (test_seams.go).
Expand Down Expand Up @@ -307,6 +318,18 @@ func Open(ctx context.Context, dir string, opts ...Option) (*Engine, error) {
_ = e.Close()
return nil, err
}
// Enforce the digest ABI contract: digest nodes not certified by a
// stamp naming the CURRENT GrantDigestABIVersion were computed by
// different hash code and must never be trusted or extended — a
// writable open drops them wholesale (the next EndSync's existing
// digests-absent path rebuilds everything at the current ABI); a
// read-only open flags them so the root getters report "never
// built". Runs after the probe so it sees post-marker-recovery
// presence, and its own drop re-falses the flag.
if err := e.verifyGrantDigestABI(ctx, o.readOnly); err != nil {
_ = e.Close()
return nil, err
}
// Arm the mutation-path source-scope index obligations iff the file
// actually holds by_source_scope entries (bounded seeks, same
// contract as the digest probe): scope-free stores keep the exact
Expand Down Expand Up @@ -572,6 +595,33 @@ func (e *Engine) IsFreshSync() bool {
// See WithGrantDigestIndex.
func (e *Engine) GrantDigestIndexEnabled() bool { return e.opts.grantDigestIndex }

// GrantDigestsPresent reports whether this engine currently holds ANY
// grant-digest state (nodes + the by_entitlement_principal_hash index
// beneath them) — the same Open-probed flag the record write paths
// gate their per-write invalidation obligation on. Exported for
// callers outside this package that need to tell "no digest state at
// all" apart from "digest state present but stale/invalidated" (e.g.
// the compactor's fold, deciding whether a byte-copied base needs a
// one-time digest build).
func (e *Engine) GrantDigestsPresent() bool { return e.db.GrantDigestsPresent() }

// grantDigestStateUntrusted reports whether NO stored grant-digest
// state — digest nodes, the whole-file root, or the
// by_entitlement_principal_hash index beneath them — may be trusted
// right now. Both flags it OR's together mean this by construction:
// grantDigestBuildPending means an interrupted build may have left
// digest nodes durable while the hash index under them never finished
// ingesting; grantDigestAbiStale means the nodes and hash-index
// content hashes were computed by a different hash ABI (a read-only
// open of a file whose stamp doesn't name the current
// GrantDigestABIVersion). Either way, every getter and on-demand fold
// over that state must report "not built" rather than trust or
// recompute from it — see getPartitionDigestRoot,
// GetGrantDigestGlobalRoot, and ComputeEntitlementBucketDigest.
func (e *Engine) grantDigestStateUntrusted() bool {
return e.grantDigestBuildPending.Load() || e.grantDigestAbiStale.Load()
}

// takeFreshGrantsEmpty / takeFreshResourcesEmpty return true
// exactly once per fresh sync, for the first PutXxxRecords call
// of that type after
Expand Down
130 changes: 123 additions & 7 deletions pkg/dotc1z/engine/pebble/grant_digest.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (

"github.com/cespare/xxhash/v2"
"github.com/cockroachdb/pebble/v2"
"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
"go.uber.org/zap"

v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3"
Expand Down Expand Up @@ -55,13 +57,102 @@ var grantDigestSpec = digestIndexSpec{
// ABI bump (a change to either hash's input framing) makes stored
// manifest roots computed under different versions incomparable by
// construction, rather than silently comparing unrelated hash schemes.
// Bump alongside any index-migration version bump that touches these
// hashes (see index_migrations.go).
//
// Enforcement on the stored state itself is the durable ABI stamp
// (rawdb.GrantDigestABIStampKey), written alongside every global-root
// write and checked once per Open (verifyGrantDigestABI): digest state
// whose stamp does not name this constant is dropped (writable open)
// or reported "never built" (read-only open), so a bump here is
// sufficient by itself to force every previously-sealed file's digest
// state to be rebuilt in full at the new ABI on its next writable use.
Comment on lines +61 to +67

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: "a bump here is sufficient by itself" holds only against stamp-aware binaries. Already-shipped pre-stamp SDKs never write GrantDigestABIStampKey, and the paths they use to mutate digest state (stageGrantDigestInvalidation / InvalidateGrantDigestPartitions + recomputeGrantDigestGlobalRootLocked) delete only the touched partitions and the global root — the stamp key survives. So after a bump to 2: a v2 binary stamps a file at 2, an older pre-stamp SDK opens it writable and rewrites some partitions at ABI 1 while leaving stamp == 2, and the next v2 binary trusts mixed-ABI state. That is the same "old binaries re-pollute after the fact" failure the PR uses to argue against an index migration. Worth stating the residual limitation here (and in index_migrations.go), or encoding the ABI into the digest index/key prefix so old and new state cannot share a keyspace. Medium confidence; no impact today.

// A file with digest nodes but NO stamp was sealed by an SDK that
// predates the stamp; those builds all hashed at version 1, so absence
// reads as grantDigestABIVersionUnstamped and is current for as long
// as this constant stays 1 — introducing the stamp costs no rebuild.
// No index-migration entry is needed — see the note on digest-ABI
// handling in index_migrations.go.
//
// Exported so consumers of GrantContentHash / GrantDigestAccumulator
// can check a stored root's abi_version before comparing.
const GrantDigestABIVersion uint32 = 1

// grantDigestABIVersionUnstamped is the ABI version a file with digest
// nodes but no stamp key is read as: every SDK build that predates the
// stamp hashed at version 1. Fixed forever — it describes shipped
// history, not the current ABI, and must not move when
// GrantDigestABIVersion does.
const grantDigestABIVersionUnstamped uint32 = 1

// grantDigestABIStampValue is the ABI stamp's stored value: the
// current GrantDigestABIVersion, uint32 BE (the index-migration
// applied-version encoding).
func grantDigestABIStampValue() []byte {
var buf [4]byte
binary.BigEndian.PutUint32(buf[:], GrantDigestABIVersion)
return buf[:]
}

// verifyGrantDigestABI is the Open-time half of the ABI stamp contract
// (rawdb.GrantDigestABIStampKey; the write half is every global-root
// write site). If the file holds digest nodes (per the just-probed
// presence flag) whose stamped ABI version (readGrantDigestABIStamp)
// is not the current GrantDigestABIVersion, that state was computed by
// different hash code: a writable open restores the always-safe
// "digests absent" state — the next EndSync's existing digests-absent
// path (RepairMissingGrantDigests delegating to BuildGrantDigests)
// then rebuilds everything, hash rows and nodes and manifest root
// alike, at the current ABI. A read-only open cannot drop; it sets
// grantDigestAbiStale, which makes the digest root getters report
// "never built" (present-means-exact consumers recalculate — never a
// wrong answer, mirroring grantDigestBuildPending).
//
// A stale or orphaned stamp over an EMPTY node keyspace is left alone:
// with no nodes there is nothing to trust, and every build rewrites the
// stamp on its completion side (the fold's opening DeleteRange erases
// it first).
func (e *Engine) verifyGrantDigestABI(ctx context.Context, readOnly bool) error {
if !e.db.GrantDigestsPresent() {
return nil
}
stamped, err := e.readGrantDigestABIStamp()
if err != nil {
return err
}
if stamped == GrantDigestABIVersion {
return nil
}
if readOnly {
e.grantDigestAbiStale.Store(true)
return nil
}
ctxzap.Extract(ctx).Warn("pebble: grant digest state was built under a different hash ABI; dropping it — the next EndSync rebuilds it from scratch",
zap.Uint32("stamped_abi", stamped),
zap.Uint32("current_abi", GrantDigestABIVersion))
return e.dropAllGrantDigestStateLocked()
}

// readGrantDigestABIStamp returns the ABI version the file's digest
// state is stamped with. A missing stamp key reads as
// grantDigestABIVersionUnstamped (the pre-stamp SDKs all hashed at
// version 1); a malformed value reads as 0, which no real ABI version
// is, so it can never pass as current. Only meaningful when digest
// nodes are present — with none, there is no state for the stamp to
// describe.
func (e *Engine) readGrantDigestABIStamp() (uint32, error) {
val, closer, err := e.db.Get(rawdb.GrantDigestABIStampKey())
if err != nil {
if errors.Is(err, pebble.ErrNotFound) {
return grantDigestABIVersionUnstamped, nil
}
return 0, err
}
defer closer.Close()
if len(val) != 4 {
return 0, nil
}
return binary.BigEndian.Uint32(val), nil
}

// The whole-file grant digest root's node-key level lives in
// internal/keys (rawdb.DigestLevelGlobalRoot, consumed by
// rawdb.GlobalGrantDigestNodeKey): the XOR fold of every
Expand All @@ -85,7 +176,8 @@ func digestPartitionForEntitlement(id entitlementIdentity) string {
// ABI: the two hash definitions below are part of the stored format.
// Two SDK builds must hash identical grants identically or the digest
// comparison reads "everything differs"; changing either input framing
// requires an index-migration bump (index_migrations.go).
// requires a GrantDigestABIVersion bump (which the durable ABI stamp
// then enforces at Open — no index migration is involved).

// grantPrincipalBucketHash64 is the bucket address for a principal:
// xxHash64 over the ENCODED principal segments
Expand All @@ -110,7 +202,7 @@ func grantPrincipalBucketHash64(encodedPrincipalSegments []byte) uint64 {
// ScanEntitlementGrantBucket).
//
// ABI: the stored truncation width, pinned to GrantDigestABIVersion. It may
// only grow, and only under an index-migration bump — which is why it is a
// only grow, and only under a GrantDigestABIVersion bump — which is why it is a
// named constant rather than a literal in PrincipalBucketHash's signature:
// widening the addressable bucket space must not change that signature.
const DigestBucketHashBits = digestBucketHashLen * 8
Expand Down Expand Up @@ -146,7 +238,7 @@ const DigestBucketHashBits = digestBucketHashLen * 8
//
// ABI: pinned to GrantDigestABIVersion alongside GrantContentHash. Two
// SDK builds must place the same principal in the same bucket, so the
// input framing changes only under an index-migration bump.
// input framing changes only under a GrantDigestABIVersion bump.
func PrincipalBucketHash(principalRT, principalID string) uint64 {
enc := codec.AppendTupleStrings(make([]byte, 0, 64), principalRT, principalID)
return grantPrincipalBucketHash64(enc)
Expand Down Expand Up @@ -437,10 +529,12 @@ func (e *Engine) GetEntitlementDigestRoot(ctx context.Context, id entitlementIde
// invalidation paths that drop any per-entitlement root — see
// stageGrantDigestInvalidation and the Drop* functions below.
func (e *Engine) GetGrantDigestGlobalRoot(ctx context.Context) (DigestRoot, bool, error) {
if e.grantDigestBuildPending.Load() {
if e.grantDigestStateUntrusted() {
// Same guard as getPartitionDigestRoot: a global root committed
// by an interrupted build must read as absent, not certify a
// hash index that was never ingested.
// hash index that was never ingested — and one computed under a
// different hash ABI (read-only open of an old file) must read
// as absent rather than compare hashes from another scheme.
return DigestRoot{}, false, nil
}
val, closer, err := e.db.Get(rawdb.GlobalGrantDigestNodeKey())
Expand Down Expand Up @@ -470,7 +564,18 @@ func (e *Engine) GetGrantDigestGlobalRoot(ctx context.Context) (DigestRoot, bool
// absent index range and returns {0, 0} — "zero grants", not "unknown".
// Never use it as a fallback for a missing root; see
// GetEntitlementDigestRoot and computeBucketDigest's precondition.
//
// Gated on grantDigestStateUntrusted: while either flag is set, the
// hash index this folds may be half-built (grantDigestBuildPending) or
// hold content hashes from a different ABI (grantDigestAbiStale), so
// folding it directly — unlike getPartitionDigestRoot, this method has
// no stored-root check of its own to lean on — would return digests
// derived from untrustworthy content. Report the same {0, 0} "not
// built / absent" shape a never-built partition already produces.
func (e *Engine) ComputeEntitlementBucketDigest(ctx context.Context, id entitlementIdentity, bucket DigestBucket) ([]byte, int64, error) {
if e.grantDigestStateUntrusted() {
return make([]byte, hashLen), 0, nil
}
return e.computeBucketDigest(ctx, grantDigestSpec, digestPartitionForEntitlement(id), bucket)
}

Expand All @@ -489,6 +594,17 @@ func (e *Engine) DirtyEntitlementBuckets(ctx context.Context, other *Engine, id
// The primary key is reconstructed from each index key by byte splice
// (no decode); the point Get per entry is the cost of MATERIALIZING a
// changed grant, not of finding it. Orphan index entries are skipped.
//
// Deliberately NOT gated by grantDigestStateUntrusted, unlike
// getPartitionDigestRoot / GetGrantDigestGlobalRoot /
// ComputeEntitlementBucketDigest: a bucket's MEMBERSHIP is the
// principal bucket hash over the encoded principal identity, frozen by
// the v3 key encoding and untouched by any GrantDigestABIVersion bump
// (only the stored CONTENT hashes and digest nodes are ABI-dependent —
// see grantContentHash64 vs grantPrincipalBucketHash64). So a stale
// file's bucket placement is still exact, and this keeps yielding the
// grants a caller already knows to be dirty even on a read-only open
// over a stale ABI stamp.
Comment on lines +598 to +607

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: this rationale contradicts GrantDigestABIVersion's own definition. That constant is documented as "the version of the content-hash / bucket-hash definitions below (grantContentHash64, grantPrincipalBucketHash64)" and PrincipalBucketHash's comment (edited in this PR, line ~258) says its "input framing changes only under a GrantDigestABIVersion bump" — so a bump can change bucket placement, and then the stored index keys' bucket bytes are from the old scheme while bucketBounds derives the range from the caller's new-scheme bucket. On a stale read-only open this yields a silently wrong grant subset with no error. Either narrow the ungating to "bumps that only change the content hash" (and say a bucket-hash bump must gate this too), or gate on grantDigestAbiStale and rely on the root getters reporting not-built. Medium confidence; no impact while the constant is 1.

func (e *Engine) IterateGrantsByEntitlementBucket(ctx context.Context, id entitlementIdentity, bucket DigestBucket, yield func(*v3.GrantRecord) bool) error {
lower, upper := grantDigestSpec.bucketBounds(digestPartitionForEntitlement(id), bucket)
iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: lower, UpperBound: upper})
Expand Down
Loading
Loading