console: audit every db door GET, ranged ones included
The door wrote its db-read row only for a read from offset 0, so listing the coordinates and fetching each file with Range: bytes=1- returned nearly every byte with no row. Every successful GET now writes one; a ranged read names the byte it started from.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+43
−8
internal/console/apidb.go+11 −8internal/console/apidb_test.go+32 −0
diff --git a/internal/console/apidb.go b/internal/console/apidb.go| index 4f4cd8542..5013973c7 100644 |
| --- a/internal/console/apidb.go |
| +++ b/internal/console/apidb.go |
| @@ -304,14 +304,17 @@ func (s *Server) handleAPIDBGet(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| defer rc.Close() |
| - // One row per file opened from its start; a resumed read of the same |
| - // file is the same open continuing, not a second one. |
| - if offset == 0 { |
| - s.auditActivity(g.acct, g.app, g.actor, Activity{ |
| - Action: dbAuditAction, TargetType: "instance", Target: g.host, |
| - Detail: fmt.Sprintf("read database replica file level %d, transactions %d to %d (cli)", level, minTXID, maxTXID), |
| - }) |
| - } |
| + // One row per successful GET, ranged or not. A ranged read is still a |
| + // read of the file: skipping it would let a caller list the coordinates |
| + // and fetch every file from byte 1 with no row written. A resumed read |
| + // says where it started. |
| + detail := fmt.Sprintf("read database replica file level %d, transactions %d to %d (cli)", level, minTXID, maxTXID) |
| + if offset > 0 { |
| + detail = fmt.Sprintf("read database replica file level %d, transactions %d to %d, from byte %d (cli)", level, minTXID, maxTXID, offset) |
| + } |
| + s.auditActivity(g.acct, g.app, g.actor, Activity{ |
| + Action: dbAuditAction, TargetType: "instance", Target: g.host, Detail: detail, |
| + }) |
| w.Header().Set("Content-Type", "application/octet-stream") |
| w.Header().Set("Cache-Control", "no-store") |
| status := http.StatusOK |
diff --git a/internal/console/apidb_test.go b/internal/console/apidb_test.go| index 7131e4704..4aec2ddd3 100644 |
| --- a/internal/console/apidb_test.go |
| +++ b/internal/console/apidb_test.go |
| @@ -579,3 +579,35 @@ func TestAPIDBRestoresThroughTheDoor(t *testing.T) { |
| t.Fatalf("restored %d rows, txid %d at %v; want %d rows and an as-of", n, got.TXID, got.At, 3*dbdoortest.Rows) |
| } |
| } |
| + |
| +// A ranged GET is still a read of the file. Listing the coordinates and |
| +// fetching each one with Range: bytes=1- returns all but one byte of every |
| +// file, so every successful GET writes a row, and a resumed one says where |
| +// it started. |
| +func TestAPIDBRangedGetIsAudited(t *testing.T) { |
| + fx := dbFixture(t) |
| + fx.replicate(t, dbTestHost, 1) |
| + f := listFiles(t, lsfile.NewReplicaClient(filepath.Join(fx.root, "instances", dbTestHost)), 0)[0] |
| + req, _ := http.NewRequest(http.MethodGet, fx.url(dbdoor.GetPath("bab", "titogo", dbTestHost, 0, uint64(f.MinTXID), uint64(f.MaxTXID))), nil) |
| + req.Header.Set("Authorization", "Bearer "+fx.member) |
| + req.Header.Set("Range", "bytes=1-") |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + io.Copy(io.Discard, res.Body) |
| + res.Body.Close() |
| + if res.StatusCode != http.StatusPartialContent { |
| + t.Fatalf("ranged get = %d", res.StatusCode) |
| + } |
| + evs, err := fx.st.Events(OpsAccountID, "titogo", 10) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, e := range evs { |
| + if e.Action == "db-read" && e.Target == dbTestHost && strings.Contains(e.Detail, "byte 1") { |
| + return |
| + } |
| + } |
| + t.Fatalf("a ranged read wrote no db-read row naming its start: %+v", evs) |
| +} |