package database import ( "fmt" "log/slog" "gorm.io/gorm" "gorm.io/gorm/clause" ) // omitAssociationsCallback is the name the association guard is // registered under on a per-webhook database's create and update // callback chains. const omitAssociationsCallback = "webhooker:omit_associations" // omitAssociations makes every create and update issued against a // per-webhook database skip GORM's automatic association save. // // A per-webhook database holds the event tier only, but Delivery // declares belongs-to Event and Target and the delivery engine fills // both in memory before writing. Without this guard GORM upserts // those parent rows here on the delivery and retry write paths, // copying targets.config, which holds destination URLs and bearer // credentials, into the file most likely to be backed up or handed // to someone else. Registering the guard on the connection covers // every write path, including writes inside a transaction and write // paths added later. Every event-tier row this file holds is written // explicitly, so nothing depends on the automatic save. func omitAssociations(db *gorm.DB) error { omit := func(tx *gorm.DB) { tx.Statement.Omits = append( tx.Statement.Omits, clause.Associations, ) } err := db.Callback().Create(). Before("gorm:save_before_associations"). Register(omitAssociationsCallback, omit) if err != nil { return fmt.Errorf( "registering create association guard: %w", err, ) } err = db.Callback().Update(). Before("gorm:save_before_associations"). Register(omitAssociationsCallback, omit) if err != nil { return fmt.Errorf( "registering update association guard: %w", err, ) } return nil } // purgeTargetRows deletes target rows that an earlier build's // association upsert wrote into a per-webhook database. AutoMigrate // creates a targets table in every one of these files because // Delivery declares a belongs-to Target, but nothing in the event // tier may put rows in it. The rows it did put there are junk, not // history: they carry an empty webhook_id, and delivery rows resolve // their target against the main database, so nothing here refers to // them. // // It runs before every migration, so it is idempotent, and it is a // no-op on a database that has no targets table at all. // // The DELETE only unlinks the rows: modernc.org/sqlite leaves // secure_delete at SQLite's default of off, so the credential bytes // stay readable in the file's free pages and a backup of a swept file // would still hand them over. VACUUM rewrites the file without them. // It is gated on having actually deleted something, so a file that // was never leaked into, or that an earlier run already swept, does // not pay for a rewrite on every open. func purgeTargetRows( db *gorm.DB, log *slog.Logger, webhookID string, ) error { if !db.Migrator().HasTable("targets") { return nil } res := db.Exec("DELETE FROM targets") if res.Error != nil { return fmt.Errorf( "purging target rows from webhook database %s: %w", webhookID, res.Error, ) } if res.RowsAffected == 0 { return nil } err := db.Exec("VACUUM").Error if err != nil { // The rows are already gone, so a later open will not retry // this: the operator has to vacuum the file by hand or the // credentials stay recoverable in it. return fmt.Errorf( "purged %d leaked target rows from webhook database %s "+ "but vacuuming it failed, so the deleted target "+ "credentials are still recoverable from the file and "+ "it must be vacuumed by hand: %w", res.RowsAffected, webhookID, err, ) } log.Warn( "purged leaked target rows from per-webhook database", "webhook_id", webhookID, "rows", res.RowsAffected, ) return nil }