Skip to content

Commit 47e9313

Browse files
fix(resource): emit metadata.resource_id on pause/resume audit rows (#270)
The resource.paused / resource.resumed audit events set only the ResourceID column, not metadata.resource_id. The dashboard per-resource AuditPanel (instanode-web fetchResourceAudit) filters the team audit window client-side by metadata.resource_id, and the JSON serializer (auditEventToMap) surfaces ONLY the JSONB metadata — the ResourceID column is never echoed onto the wire. Result: a resource's two most important state-change events were invisible in its Audit tab even though the rows existed. Add resource_id (+ resource_type) to both emit sites, matching the existing convention in emitResourceReadAudit and emitBackupAudit. Also restores the server-side resource-ownership OR branch in ListAuditEventsForCustomerExport, which keys on metadata->>'resource_id' for cross-actor events on a team's resource. Regression guards: TestPauseResource_EmitsMetadataResourceID + TestResumeResource_EmitsMetadataResourceID assert the row's metadata.resource_id equals the resource UUID (verified to fail without the fix). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 021bb7e commit 47e9313

2 files changed

Lines changed: 95 additions & 0 deletions

File tree

internal/handlers/resource.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,14 +661,28 @@ func (h *ResourceHandler) Pause(c *fiber.Ctx) error {
661661
h.rdb.Del(ctx, fmt.Sprintf("res:%s", token.String()))
662662

663663
// Best-effort audit event. Failure must not block the response.
664+
//
665+
// metadata.resource_id is REQUIRED here (not just the ResourceID column):
666+
// the dashboard's per-resource AuditPanel (instanode-web fetchResourceAudit)
667+
// filters the team audit window by `metadata.resource_id`, and the JSON
668+
// serializer (auditEventToMap) surfaces ONLY the JSONB metadata — the
669+
// ResourceID column is never echoed into the wire `metadata`. Omitting it
670+
// (as this site did before) made resource.paused rows invisible in the
671+
// resource's Audit tab even though the column was set. Mirrors the
672+
// resource_id-in-metadata convention of emitResourceReadAudit / backup.
664673
safego.Go("resource.bg", func() {
674+
metaBlob, _ := json.Marshal(map[string]string{
675+
"resource_id": resource.ID.String(),
676+
"resource_type": resource.ResourceType,
677+
})
665678
_ = models.InsertAuditEvent(context.Background(), h.db, models.AuditEvent{
666679
TeamID: teamID,
667680
Actor: "agent",
668681
Kind: "resource.paused",
669682
ResourceType: resource.ResourceType,
670683
ResourceID: uuid.NullUUID{UUID: resource.ID, Valid: true},
671684
Summary: "paused <strong>" + resource.ResourceType + "</strong> <code>" + token.String()[:8] + "</code>",
685+
Metadata: metaBlob,
672686
})
673687
})
674688

@@ -779,14 +793,23 @@ func (h *ResourceHandler) Resume(c *fiber.Ctx) error {
779793

780794
h.rdb.Del(ctx, fmt.Sprintf("res:%s", token.String()))
781795

796+
// metadata.resource_id REQUIRED — see the matching comment in Pause: the
797+
// dashboard AuditPanel filters on metadata.resource_id and the wire
798+
// serializer never echoes the ResourceID column, so without this the
799+
// resource.resumed row would not appear in the resource's Audit tab.
782800
safego.Go("resource.bg", func() {
801+
metaBlob, _ := json.Marshal(map[string]string{
802+
"resource_id": resource.ID.String(),
803+
"resource_type": resource.ResourceType,
804+
})
783805
_ = models.InsertAuditEvent(context.Background(), h.db, models.AuditEvent{
784806
TeamID: teamID,
785807
Actor: "agent",
786808
Kind: "resource.resumed",
787809
ResourceType: resource.ResourceType,
788810
ResourceID: uuid.NullUUID{UUID: resource.ID, Valid: true},
789811
Summary: "resumed <strong>" + resource.ResourceType + "</strong> <code>" + token.String()[:8] + "</code>",
812+
Metadata: metaBlob,
790813
})
791814
})
792815

internal/handlers/resource_pause_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@ import (
1919
"context"
2020
"database/sql"
2121
"encoding/json"
22+
"errors"
2223
"net/http"
2324
"net/http/httptest"
2425
"strings"
2526
"testing"
27+
"time"
2628

2729
"github.com/stretchr/testify/assert"
2830
"github.com/stretchr/testify/require"
@@ -343,3 +345,73 @@ func TestPausedStorageStillCountsTowardQuota(t *testing.T) {
343345
assert.Equal(t, int64(500*1024*1024), total,
344346
"deleted rows must be excluded from storage sum")
345347
}
348+
349+
// auditMetadataResourceID polls audit_log for the newest row of `kind` in the
350+
// team and returns the value of metadata->>'resource_id' (empty string when
351+
// the key is absent). The audit insert runs in a best-effort goroutine
352+
// (safego.Go), so callers wrap this in require.Eventually.
353+
func auditMetadataResourceID(t *testing.T, db *sql.DB, teamID, kind string) (found bool, resourceID string) {
354+
t.Helper()
355+
var rid sql.NullString
356+
err := db.QueryRowContext(context.Background(), `
357+
SELECT metadata->>'resource_id'
358+
FROM audit_log
359+
WHERE team_id = $1::uuid AND kind = $2
360+
ORDER BY created_at DESC
361+
LIMIT 1
362+
`, teamID, kind).Scan(&rid)
363+
if errors.Is(err, sql.ErrNoRows) {
364+
return false, ""
365+
}
366+
require.NoError(t, err)
367+
return true, rid.String
368+
}
369+
370+
// TestPauseResource_EmitsMetadataResourceID is the regression guard for the
371+
// AuditPanel-invisibility bug (BUGHUNT 2026-06-06): the resource.paused audit
372+
// row set the ResourceID column but NOT metadata.resource_id. The dashboard's
373+
// per-resource AuditPanel filters the team audit window by
374+
// `metadata.resource_id`, and auditEventToMap serialises ONLY the JSONB
375+
// metadata (the ResourceID column is never echoed onto the wire), so a paused
376+
// resource's most important state-change event never appeared in its Audit tab.
377+
//
378+
// This asserts the row's metadata->>'resource_id' equals the resource UUID, so
379+
// the row survives the UI's client-side filter. Pairs with the resume guard
380+
// below; if a future edit drops Metadata from either emit site, one of these
381+
// fails.
382+
func TestPauseResource_EmitsMetadataResourceID(t *testing.T) {
383+
fix := setupPauseFixture(t, "pro", "postgres")
384+
385+
resp := doPauseOrResume(t, fix.app, fix.jwt, "pause", fix.resourceToken)
386+
resp.Body.Close()
387+
require.Equal(t, http.StatusOK, resp.StatusCode)
388+
389+
require.Eventually(t, func() bool {
390+
found, rid := auditMetadataResourceID(t, fix.db, fix.teamID, "resource.paused")
391+
return found && rid == fix.resourceID
392+
}, 3*time.Second, 50*time.Millisecond,
393+
"resource.paused audit row must carry metadata.resource_id == resource UUID "+
394+
"so the dashboard AuditPanel (filters on metadata.resource_id) shows it")
395+
}
396+
397+
// TestResumeResource_EmitsMetadataResourceID — the resume-side twin of the
398+
// guard above.
399+
func TestResumeResource_EmitsMetadataResourceID(t *testing.T) {
400+
fix := setupPauseFixture(t, "pro", "postgres")
401+
402+
// Pause first to reach a paused state, then resume.
403+
resp := doPauseOrResume(t, fix.app, fix.jwt, "pause", fix.resourceToken)
404+
resp.Body.Close()
405+
require.Equal(t, http.StatusOK, resp.StatusCode)
406+
407+
resp = doPauseOrResume(t, fix.app, fix.jwt, "resume", fix.resourceToken)
408+
resp.Body.Close()
409+
require.Equal(t, http.StatusOK, resp.StatusCode)
410+
411+
require.Eventually(t, func() bool {
412+
found, rid := auditMetadataResourceID(t, fix.db, fix.teamID, "resource.resumed")
413+
return found && rid == fix.resourceID
414+
}, 3*time.Second, 50*time.Millisecond,
415+
"resource.resumed audit row must carry metadata.resource_id == resource UUID "+
416+
"so the dashboard AuditPanel shows it")
417+
}

0 commit comments

Comments
 (0)