Open the database, and prove secure_delete reaches every connection
§13 step 2, Task 2, second half. - internal/home/db.go: RegisterSecureDelete, guarded by a sync.Once because sqlite's hook list is process-global. - cmd/home/main.go and newTestApp: register the hook, open the handle, apply BootSchema, blank DBPath before Serve so Serve does not open a second handle against the same file. - App now holds *db.DB. - newTestApp becomes a struct rather than a widening tuple. Later tasks add the LinkStore and the signin.Flow, and each would otherwise be a signature change rippling through every test already written. Why a driver hook and not an Exec, since this was the plan's riskiest assumption and it holds: secure_delete is per connection. An Exec after opening sets it on whichever pooled connection served that call, on no other, and on none opened later — and rastrillo/db.Open builds two pools. Reading the pragma back on that same connection passes while proving nothing. rastrillo/db.Open builds its own DSN (busy_timeout, journal_mode, foreign_keys) and passes nothing else through, so there is no way to set this as a DSN pragma without changing rastrillo. Verified at source, not assumed: sqlite.RegisterConnectionHook is exported at package level (sqlite.go:785), not only as a method on an unexported driver. It is registered before db.Open, because Open calls Ping, which opens the writer pool's single long-lived connection there and then. A hook registered afterwards misses that connection forever, and it is the only one that performs DELETEs. The test asserts the writer AND a reader, and it was verified to fail: flipping the pragma to OFF reports 0 on both pools. Three database-level tests beyond the plan, each covering something the static gate cannot see: that both table sets exist after the merge, and that the database as actually built has no address column anywhere — gate 2's schema half reads this repo's .sql files, so a column arriving from a dependency's migrations would be invisible to it. The runtime check is a deliberately separate implementation from the static one: two independent checks that agree are worth more than one shared helper that can be wrong in a single place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E4uVkB6xnZJYuxTooPiUn4
8 files changed,
+263
−18
cmd/home/main.go+28 −1go.mod+2 −2internal/home/app.go+6 −5internal/home/app_test.go+2 −2internal/home/db.go+48 −0internal/home/db_test.go+137 −0internal/home/logs_test.go+1 −1internal/home/testapp_test.go+39 −7
diff --git a/cmd/home/main.go b/cmd/home/main.go| index 93f9641..205fbb6 100644 |
| --- a/cmd/home/main.go |
| +++ b/cmd/home/main.go |
| @@ -30,11 +30,14 @@ |
| package main |
| import ( |
| + "context" |
| "log/slog" |
| "net/http" |
| "os" |
| "github.com/carlosframework/rastrillo" |
| + rdb "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/migrate" |
| "amadan.net/oficina/home/internal/home" |
| ) |
| @@ -62,7 +65,27 @@ func main() { |
| os.Exit(1) |
| } |
| - app := home.New(secrets) |
| + // Before db.Open, never after: Open calls Ping, which opens the |
| + // writer pool's single long-lived connection there and then, and a |
| + // hook registered afterwards misses it forever. That connection is |
| + // the only one that performs DELETEs. |
| + home.RegisterSecureDelete() |
| + |
| + d, err := rdb.Open(opts.DBPath, logger) |
| + if err != nil { |
| + logger.Error("open db", "err", err) |
| + os.Exit(1) |
| + } |
| + defer d.Close() |
| + |
| + // Ledgered, once at boot, forward-only. A boot that cannot reconcile |
| + // its schema refuses rather than guessing, same as a failed open. |
| + if _, err := migrate.Apply(context.Background(), d, home.BootSchema); err != nil { |
| + logger.Error("migrate", "err", err) |
| + os.Exit(1) |
| + } |
| + |
| + app := home.New(secrets, d) |
| // rastrillo.Options.Mux wants a *http.ServeMux; the app's own |
| // router is a chi.Router (an http.Handler), so it mounts under one. |
| @@ -70,6 +93,10 @@ func main() { |
| mux.Handle("/", app.Router()) |
| opts.Mux = mux |
| + // Blanked because this file opened the handle itself: Serve would |
| + // otherwise open a second one against the same file. |
| + opts.DBPath = "" |
| + |
| if err := rastrillo.Serve(opts); err != nil { |
| logger.Error("serve", "err", err) |
| os.Exit(1) |
diff --git a/go.mod b/go.mod| index abc1c89..55af060 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -6,6 +6,8 @@ require ( |
| github.com/carlosframework/rastrillo v0.22.0 |
| github.com/go-chi/chi/v5 v5.3.2 |
| golang.org/x/crypto v0.55.0 |
| + gorm.io/plugin/dbresolver v1.6.2 |
| + modernc.org/sqlite v1.55.0 |
| ) |
| require ( |
| @@ -19,9 +21,7 @@ require ( |
| golang.org/x/sys v0.47.0 // indirect |
| golang.org/x/text v0.41.0 // indirect |
| gorm.io/gorm v1.31.2 // indirect |
| - gorm.io/plugin/dbresolver v1.6.2 // indirect |
| modernc.org/libc v1.74.1 // indirect |
| modernc.org/mathutil v1.7.1 // indirect |
| modernc.org/memory v1.11.0 // indirect |
| - modernc.org/sqlite v1.55.0 // indirect |
| ) |
diff --git a/internal/home/app.go b/internal/home/app.go| index 94b8f23..918cfef 100644 |
| --- a/internal/home/app.go |
| +++ b/internal/home/app.go |
| @@ -14,17 +14,18 @@ package home |
| import ( |
| "net/http" |
| + "github.com/carlosframework/rastrillo/db" |
| "github.com/go-chi/chi/v5" |
| ) |
| // App holds the wiring. One per process. |
| // |
| -// It holds no database handle yet: storage arrives with the schema in |
| -// the next step of the plan. What it does hold is the validated |
| -// Secrets, because every value this app will persist is sealed on the |
| -// way in and there is no code path that should reach for a raw key. |
| +// It holds the validated Secrets because every value this app persists |
| +// is sealed on the way in, and the database handle because that is what |
| +// it is sealed into. Nothing here reaches for a raw key. |
| type App struct { |
| secrets Secrets |
| + db *db.DB |
| } |
| // New builds an App on validated secrets. |
| @@ -34,7 +35,7 @@ type App struct { |
| // addresses in the clear, which is the failure this whole step exists |
| // to make impossible. Callers get their Secrets from LoadSecrets, which |
| // refuses rather than degrades. |
| -func New(secrets Secrets) *App { return &App{secrets: secrets} } |
| +func New(secrets Secrets, d *db.DB) *App { return &App{secrets: secrets, db: d} } |
| // Router returns the mounted handler. |
| func (a *App) Router() http.Handler { |
diff --git a/internal/home/app_test.go b/internal/home/app_test.go| index e2b3b25..76b5755 100644 |
| --- a/internal/home/app_test.go |
| +++ b/internal/home/app_test.go |
| @@ -10,7 +10,7 @@ import ( |
| // HTML — the placeholder content itself is not asserted here so this |
| // test does not need updating every time the wording changes. |
| func TestIndex(t *testing.T) { |
| - app, _ := newTestApp(t) |
| + app := newTestApp(t) |
| req := httptest.NewRequest(http.MethodGet, "/", nil) |
| rec := httptest.NewRecorder() |
| app.Router().ServeHTTP(rec, req) |
| @@ -28,7 +28,7 @@ func TestIndex(t *testing.T) { |
| // ahead of this one and wins (see the comment on Router in app.go); this |
| // test only proves the app's router itself is wired and reachable. |
| func TestHealthz(t *testing.T) { |
| - app, _ := newTestApp(t) |
| + app := newTestApp(t) |
| req := httptest.NewRequest(http.MethodGet, "/healthz", nil) |
| rec := httptest.NewRecorder() |
| app.Router().ServeHTTP(rec, req) |
diff --git a/internal/home/db.go b/internal/home/db.go| new file mode 100644 |
| index 0000000..8d3ae18 |
| --- /dev/null |
| +++ b/internal/home/db.go |
| @@ -0,0 +1,48 @@ |
| +package home |
| + |
| +import ( |
| + "context" |
| + "sync" |
| + |
| + "modernc.org/sqlite" |
| +) |
| + |
| +// secureDeleteOnce guards the connection hook registration. The hook |
| +// list is process-global — sqlite.RegisterConnectionHook appends to the |
| +// package's single driver — so registering per call would run the pragma |
| +// once per registration on every new connection, forever. |
| +var secureDeleteOnce sync.Once |
| + |
| +// RegisterSecureDelete turns on PRAGMA secure_delete for every |
| +// connection this process opens. |
| +// |
| +// It matters because deleting a row does not remove its bytes from a |
| +// file that is continuously replicated (§5.1): without it, a redeemed |
| +// magic link's sealed row is gone from the table and still present in |
| +// the file, and in every WAL frame Litestream has already shipped. |
| +// |
| +// Three things make this the shape it is, and each was a way to get it |
| +// wrong: |
| +// |
| +// The pragma is PER CONNECTION. An Exec after opening sets it on |
| +// whichever pooled connection served that call and on no other, and on |
| +// none opened later. A test that reads the pragma back on that same |
| +// connection passes while proving nothing. |
| +// |
| +// rastrillo/db.Open builds its own DSN — busy_timeout, journal_mode and |
| +// foreign_keys — and passes nothing else through, so there is no way to |
| +// add secure_delete as a DSN pragma without changing rastrillo. Hence |
| +// the driver hook. |
| +// |
| +// It must be registered BEFORE db.Open. Open calls Ping, which opens the |
| +// writer pool's single long-lived connection there and then; a hook |
| +// registered afterwards misses that connection forever, and it is the |
| +// only one that performs DELETEs. |
| +func RegisterSecureDelete() { |
| + secureDeleteOnce.Do(func() { |
| + sqlite.RegisterConnectionHook(func(conn sqlite.ExecQuerierContext, _ string) error { |
| + _, err := conn.ExecContext(context.Background(), "PRAGMA secure_delete=ON", nil) |
| + return err |
| + }) |
| + }) |
| +} |
diff --git a/internal/home/db_test.go b/internal/home/db_test.go| new file mode 100644 |
| index 0000000..aed3685 |
| --- /dev/null |
| +++ b/internal/home/db_test.go |
| @@ -0,0 +1,137 @@ |
| +package home |
| + |
| +import ( |
| + "testing" |
| + |
| + "gorm.io/plugin/dbresolver" |
| +) |
| + |
| +// TestSecureDeleteIsOnEveryConnection is the point of the connection |
| +// hook, and the reason it is a hook at all. |
| +// |
| +// It asserts on the writer AND on a reader. The pragma is per |
| +// connection, and rastrillo/db.Open builds two pools — a writer capped |
| +// at one, and readers. A test that checked only whichever connection it |
| +// happened to get would pass while the other pool had no pragma set, |
| +// which is the worst kind of green. The writer is the connection that |
| +// matters most, because it is the only one that performs DELETEs, and it |
| +// is also the one opened during Open's Ping, before any Exec a caller |
| +// could run. |
| +func TestSecureDeleteIsOnEveryConnection(t *testing.T) { |
| + app := newTestApp(t) |
| + |
| + var writer int |
| + if err := app.db.Writer().QueryRow("PRAGMA secure_delete").Scan(&writer); err != nil { |
| + t.Fatalf("read secure_delete on the writer: %v", err) |
| + } |
| + if writer != 1 { |
| + t.Errorf("secure_delete on the writer = %d, want 1", writer) |
| + } |
| + |
| + var reader int |
| + if err := app.db.G.Clauses(dbresolver.Read).Raw("PRAGMA secure_delete").Scan(&reader).Error; err != nil { |
| + t.Fatalf("read secure_delete on a reader: %v", err) |
| + } |
| + if reader != 1 { |
| + t.Errorf("secure_delete on a reader = %d, want 1", reader) |
| + } |
| +} |
| + |
| +// The schema applied at boot must be the one the app declares, and it |
| +// must contain the sessions tables as well as this app's own. A merge |
| +// that silently dropped one would show up here and nowhere else until |
| +// the first sign-in. |
| +func TestBootSchemaCreatesBothTableSets(t *testing.T) { |
| + app := newTestApp(t) |
| + |
| + for _, table := range []string{"people", "sessions"} { |
| + var name string |
| + err := app.db.Writer(). |
| + QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", table). |
| + Scan(&name) |
| + if err != nil { |
| + t.Errorf("table %q was not created: %v", table, err) |
| + } |
| + } |
| +} |
| + |
| +// There is no address column, and this asserts it against the database |
| +// that was actually built rather than against the migration text. Gate |
| +// 2's schema half reads the .sql files; this reads sqlite_master, so a |
| +// column arriving from a dependency's migrations — rastrillo/auth's, if |
| +// anybody ever merged its schema — is caught too. |
| +func TestNoAddressColumnInTheBuiltDatabase(t *testing.T) { |
| + app := newTestApp(t) |
| + |
| + rows, err := app.db.Writer().Query("SELECT name FROM sqlite_master WHERE type='table'") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer rows.Close() |
| + |
| + var tables []string |
| + for rows.Next() { |
| + var name string |
| + if err := rows.Scan(&name); err != nil { |
| + t.Fatal(err) |
| + } |
| + tables = append(tables, name) |
| + } |
| + if err := rows.Err(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + for _, table := range tables { |
| + cols, err := app.db.Writer().Query("SELECT name FROM pragma_table_info(?)", table) |
| + if err != nil { |
| + t.Fatalf("pragma_table_info(%q): %v", table, err) |
| + } |
| + for cols.Next() { |
| + var col string |
| + if err := cols.Scan(&col); err != nil { |
| + cols.Close() |
| + t.Fatal(err) |
| + } |
| + if addressishColumn(col) { |
| + t.Errorf("table %q has column %q, which names an address", table, col) |
| + } |
| + } |
| + cols.Close() |
| + } |
| +} |
| + |
| +// addressishColumn is the runtime twin of gate 2's static matcher. It is |
| +// deliberately a separate, simpler implementation: two independent |
| +// checks that agree are worth more than one shared helper that could be |
| +// wrong in one place. |
| +func addressishColumn(col string) bool { |
| + for _, part := range splitIdent(col) { |
| + switch part { |
| + case "address", "email", "mail", "recipient": |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +func splitIdent(s string) []string { |
| + var out []string |
| + cur := "" |
| + for _, r := range s { |
| + if r == '_' || r == '-' { |
| + if cur != "" { |
| + out = append(out, cur) |
| + } |
| + cur = "" |
| + continue |
| + } |
| + if r >= 'A' && r <= 'Z' { |
| + r += 'a' - 'A' |
| + } |
| + cur += string(r) |
| + } |
| + if cur != "" { |
| + out = append(out, cur) |
| + } |
| + return out |
| +} |
diff --git a/internal/home/logs_test.go b/internal/home/logs_test.go| index 2ddc2c3..5a1d493 100644 |
| --- a/internal/home/logs_test.go |
| +++ b/internal/home/logs_test.go |
| @@ -59,7 +59,7 @@ func TestNoContentInLogs(t *testing.T) { |
| log.SetFlags(prevFlags) |
| }) |
| - app, _ := newTestApp(t) |
| + app := newTestApp(t) |
| router := app.Router() |
| routes, ok := router.(chi.Routes) |
diff --git a/internal/home/testapp_test.go b/internal/home/testapp_test.go| index 1c2acb6..1e2f85e 100644 |
| --- a/internal/home/testapp_test.go |
| +++ b/internal/home/testapp_test.go |
| @@ -2,9 +2,13 @@ package home |
| import ( |
| "context" |
| + "path/filepath" |
| "sync" |
| "testing" |
| + "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/migrate" |
| + |
| "amadan.net/oficina/home/internal/home/seal" |
| ) |
| @@ -68,14 +72,42 @@ func testSecrets(t *testing.T) (Secrets, *captureMailer) { |
| }, mailer |
| } |
| -// newTestApp builds an App on test secrets. |
| +// testApp is an App plus the handles a test needs to look behind it: the |
| +// mail it would have sent, and the database file itself, which gate 1 |
| +// scans for plaintext. |
| +// |
| +// A struct rather than a widening tuple: later steps add the LinkStore |
| +// and the signin.Flow, and every one of those would otherwise be a |
| +// signature change rippling through every test already written. |
| +type testApp struct { |
| + *App |
| + mailer *captureMailer |
| + db *db.DB |
| + dbPath string |
| +} |
| + |
| +// newTestApp builds an App on test secrets against a fresh database. |
| // |
| -// It starts small and grows. Later steps of the plan add the database |
| -// and its migrations, the LinkStore, and the signin.Flow; each extends |
| -// this helper rather than replacing it, so every test written before |
| -// then keeps compiling. |
| -func newTestApp(t *testing.T) (*App, *captureMailer) { |
| +// The connection hook is registered before db.Open, exactly as main does |
| +// and for the same reason: Open pings, which opens the writer pool's one |
| +// long-lived connection, and a hook registered after that misses the |
| +// only connection that ever deletes anything. |
| +func newTestApp(t *testing.T) *testApp { |
| t.Helper() |
| secrets, mailer := testSecrets(t) |
| - return New(secrets), mailer |
| + |
| + RegisterSecureDelete() |
| + |
| + dbPath := filepath.Join(t.TempDir(), "home.db") |
| + d, err := db.Open(dbPath, quietLogger()) |
| + if err != nil { |
| + t.Fatalf("db.Open: %v", err) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + |
| + if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil { |
| + t.Fatalf("migrate.Apply: %v", err) |
| + } |
| + |
| + return &testApp{App: New(secrets, d), mailer: mailer, db: d, dbPath: dbPath} |
| } |