package auth import ( "context" "errors" "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" "git.sendico.io/tech/sxchnge/bff/pkg/merrors" "git.sendico.io/tech/sxchnge/bff/pkg/model" "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) // Patch applies a partial update after verifying ActionUpdate. func (db *ScopedDBImp[T]) Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error { db.DBImp.Logger.Debug("attempting to patch scoped object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) if err := denyImmutablePatch(patch); err != nil { return err } if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionUpdate); err != nil { return err } if err := db.DBImp.Patch(ctx, objectRef, patch); err != nil { db.DBImp.Logger.Warn("failed to patch scoped object", zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) return err } return nil } // SetArchived patches the isArchived field after verifying ActionUpdate. // // organizationRef is accepted for interface compatibility but not used for // enforcement — ownership is read from the DB via LoadAndEnforce. // cascade is accepted but not applied; resources that require cascading archived // status must implement cascade logic in a dedicated wrapper. func (db *ScopedDBImp[T]) SetArchived(ctx context.Context, accountRef, _ bson.ObjectID, objectRef bson.ObjectID, isArchived, _ bool) error { db.DBImp.Logger.Debug("attempting to change archive status of scoped object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", isArchived)) if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionUpdate); err != nil { return err } patch := repository.Patch().Set(repository.IsArchivedField(), isArchived) if err := db.DBImp.Patch(ctx, objectRef, patch); err != nil { db.DBImp.Logger.Warn("failed to change archive status of scoped object", zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) return err } return nil } // ListIDs returns the authorized subset of IDs matching query for the given // action. Each candidate is checked via LoadAndEnforce; unauthorized IDs are // silently skipped. Non-access-denied errors abort the call. func (db *ScopedDBImp[T]) ListIDs(ctx context.Context, action model.Action, accountRef bson.ObjectID, query builder.Query) ([]bson.ObjectID, error) { db.DBImp.Logger.Debug("attempting to list scoped object IDs", mzap.AccRef(accountRef), zap.String("collection", db.Collection)) rawIDs, err := db.DBImp.ListIDs(ctx, query) if err != nil { db.DBImp.Logger.Warn("failed to list raw object IDs", zap.Error(err), mzap.AccRef(accountRef), zap.String("collection", db.Collection)) return nil, err } allowed := make([]bson.ObjectID, 0, len(rawIDs)) for _, id := range rawIDs { if _, authErr := db.loadAndEnforce(ctx, accountRef, id, action); authErr == nil { allowed = append(allowed, id) } else if !errors.Is(authErr, merrors.ErrAccessDenied) { db.DBImp.Logger.Warn("enforcer error during ListIDs", zap.Error(authErr), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", id)) return nil, authErr } // ErrAccessDenied: silently skip } db.DBImp.Logger.Debug("completed ListIDs", mzap.AccRef(accountRef), zap.Int("total", len(rawIDs)), zap.Int("allowed", len(allowed))) return allowed, nil } // PatchMany applies patch to all resources in query that accountRef is // authorized to update. Authorization is resolved via ListIDs; the write is a // single bulk operation using originalQuery AND _id IN (authorizedIDs) so // records that leave the original query between the read and write are not // inadvertently mutated. func (db *ScopedDBImp[T]) PatchMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query, patch builder.Patch) (int, error) { db.DBImp.Logger.Debug("attempting to patch many scoped objects", mzap.AccRef(accountRef), zap.String("collection", db.Collection)) if err := denyImmutablePatch(patch); err != nil { return 0, err } ids, err := db.ListIDs(ctx, model.ActionUpdate, accountRef, query) if err != nil { return 0, err } if len(ids) == 0 { return 0, nil } values := make([]any, len(ids)) for i, id := range ids { values[i] = id } idFilter := repository.Query().In(repository.IDField(), values...) finalQuery := idFilter if query != nil { finalQuery = query.And(idFilter) } modified, err := db.DBImp.PatchMany(ctx, finalQuery, patch) if err != nil { db.DBImp.Logger.Warn("failed to patch many scoped objects", zap.Error(err), mzap.AccRef(accountRef)) return 0, err } db.DBImp.Logger.Debug("patched many scoped objects", mzap.AccRef(accountRef), zap.Int("count", modified)) return modified, nil }