Skip to content
5 changes: 3 additions & 2 deletions internal/db/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,18 +94,19 @@ func (db *DB) basicImport(ctx context.Context, filepath string) (err error) {
return NewErrDocFromMap(err)
}

err = col.AddDocument(ctx, doc)
err = col.AddDocument(skipRelationValidationContext(ctx), doc)
if err != nil {
return NewErrDocAdd(err)
}

// add back the self referencing fields and update doc.
skipCtx := skipRelationValidationContext(ctx)
for k, v := range resetMap {
err := doc.Set(ctx, k, v)
if err != nil {
return NewErrDocUpdate(err)
}
err = col.UpdateDocument(ctx, doc)
err = col.UpdateDocument(skipCtx, doc)
if err != nil {
return NewErrDocUpdate(err)
}
Expand Down
14 changes: 14 additions & 0 deletions internal/db/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ import (
"github.com/sourcenetwork/defradb/internal/db/id"
)

type contextSkipRelationValidationKey struct{}

// skipRelationValidationContext returns a context that skips relation DocID
// validation during document saves. Used by backup import, which restores a
// known-consistent state whose cross-collection references may not yet exist.
func skipRelationValidationContext(ctx context.Context) context.Context {
return context.WithValue(ctx, contextSkipRelationValidationKey{}, true)
}

func shouldSkipRelationValidation(ctx context.Context) bool {
v, _ := ctx.Value(contextSkipRelationValidationKey{}).(bool)
return v
}

// InitContext returns a new context with all caches initialized and linked to
// the given transaction.
//
Expand Down
174 changes: 174 additions & 0 deletions internal/db/document.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ import (
"strings"

"github.com/sourcenetwork/corekv"
"github.com/sourcenetwork/immutable"

"github.com/sourcenetwork/defradb/acp/identity"
acpTypes "github.com/sourcenetwork/defradb/acp/types"
"github.com/sourcenetwork/defradb/client"
"github.com/sourcenetwork/defradb/client/options"
"github.com/sourcenetwork/defradb/client/request"
"github.com/sourcenetwork/defradb/errors"
"github.com/sourcenetwork/defradb/event"
"github.com/sourcenetwork/defradb/internal/core"
Expand Down Expand Up @@ -274,6 +276,10 @@ func (c *collection) add(
}
}

if err = c.validateRelationDocIDs(ctx, doc); err != nil {
return err
}

ctx = setContextDocEncryption(ctx, opt)

err = c.save(ctx, doc, true)
Expand Down Expand Up @@ -374,6 +380,10 @@ func (c *collection) update(
return client.ErrDocumentNotFoundOrNotAuthorized
}

if err = c.validateRelationDocIDs(ctx, doc); err != nil {
return err
}

err = c.setEmbedding(ctx, doc, false)
if err != nil {
return err
Expand Down Expand Up @@ -469,6 +479,156 @@ func (c *collection) validateEncryptedFields(ctx context.Context) error {
return nil
}

// validateRelationDocIDs checks that every dirty primary DocID relation field in doc
// points to an existing, non-deleted document in the correct target collection.
func (c *collection) validateRelationDocIDs(ctx context.Context, doc *client.Document) error {
if shouldSkipRelationValidation(ctx) {
return nil
}

for field, value := range doc.Values() {
if !value.IsDirty() {
continue
}

fieldName := field.Name()
fd, ok := c.Version().GetFieldByName(fieldName)
if !ok || fd.Kind != client.FieldKind_DocID || !fd.IsPrimary {
continue
}

docIDStr, ok := value.Value().(string)
if !ok || docIDStr == "" {
continue
}

targetDocID, err := client.NewDocIDFromString(docIDStr)
if err != nil {
// already validated by Set; skip
continue
}

objFieldName, ok := request.ToRelatedObjectName(fieldName)
if !ok {
continue
}

objFd, ok := c.Version().GetFieldByName(objFieldName)
if !ok {
continue
}

targetColVersion, found, err := description.GetRelatedCollection(
ctx, c.db.collectionRepository, c.Version(), objFd.Kind,
)
if err != nil {
return err
}
if !found {
continue
}

var targetCol *collection
var targetColName string

if targetColVersion.VersionID == c.Version().VersionID {
targetCol = c
} else {
targetCol, err = c.db.newCollection(targetColVersion, immutable.None[datastore.Txn]())
if err != nil {
return err
}
}
targetColName = targetColVersion.Name

primaryKey, err := targetCol.getPrimaryKeyFromDocID(ctx, targetDocID)
if err != nil {
return err
}

exists, err := targetCol.docExistsAndNotDeleted(ctx, primaryKey)
if err != nil {
return err
}
if !exists {
return NewErrRelationTargetNotFound(fieldName, docIDStr, targetColName)
}

// ACP: the caller must also be able to read the target document.
// We return the same "not found" error to avoid leaking existence of private documents.
hasAccess, err := targetCol.checkAccessOfDocWithACP(ctx, acpTypes.DocumentReadPerm, docIDStr)
if err != nil {
return err
}
if !hasAccess {
return NewErrRelationTargetNotFound(fieldName, docIDStr, targetColName)
}
}
return nil
}

// validateMergeRelationDocIDs is the P2P-merge variant of validateRelationDocIDs.
// It performs the same checks but treats a missing target document as a skip rather
// than an error, because the referenced document may simply not have arrived yet.
func (c *collection) validateMergeRelationDocIDs(ctx context.Context, doc *client.Document) error {
for field, value := range doc.Values() {
fieldName := field.Name()
fd, ok := c.Version().GetFieldByName(fieldName)
if !ok || fd.Kind != client.FieldKind_DocID || !fd.IsPrimary {
continue
}

docIDStr, ok := value.Value().(string)
if !ok || docIDStr == "" {
continue
}

targetDocID, err := client.NewDocIDFromString(docIDStr)
if err != nil {
continue
}

objFieldName, ok := request.ToRelatedObjectName(fieldName)
if !ok {
continue
}

objFd, ok := c.Version().GetFieldByName(objFieldName)
if !ok {
continue
}

targetColVersion, found, err := description.GetRelatedCollection(
ctx, c.db.collectionRepository, c.Version(), objFd.Kind,
)
if err != nil || !found {
continue
}

var targetCol *collection
if targetColVersion.VersionID == c.Version().VersionID {
targetCol = c
} else {
targetCol, err = c.db.newCollection(targetColVersion, immutable.None[datastore.Txn]())
if err != nil {
continue
}
}

primaryKey, err := targetCol.getPrimaryKeyFromDocID(ctx, targetDocID)
if err != nil {
continue
}

exists, err := targetCol.docExistsAndNotDeleted(ctx, primaryKey) //nolint:staticcheck
if err != nil || !exists {
// Skip: the referenced doc may not have arrived yet via P2P.
continue
}
}
return nil
}

// save saves the document state. save MUST not be called outside the `c.add`
// and `c.update` methods as we wrap the acp logic within those methods. Calling
// save elsewhere could cause the omission of acp checks.
Expand Down Expand Up @@ -760,6 +920,20 @@ func (c *collection) exists(
return true, false, nil
}

// docExistsAndNotDeleted checks the datastore directly for a document, bypassing ACP.
// Used for referential integrity validation where the caller may not have read permission
// on the referenced document.
func (c *collection) docExistsAndNotDeleted(ctx context.Context, primaryKey keys.PrimaryDataStoreKey) (bool, error) {
txn := datastore.CtxMustGetTxn(ctx)
val, err := txn.Datastore().Get(ctx, primaryKey)
if err != nil && errors.Is(err, corekv.ErrNotFound) {
return false, nil
} else if err != nil {
return false, NewErrGetDocStatus(err, primaryKey.DocID)
}
return !bytes.Equal(val, []byte{base.DeletedObjectMarker}), nil
}

func (c *collection) getPrimaryKeyFromDocID(
ctx context.Context,
docID client.DocID,
Expand Down
Loading