From 0df192821034475a11c5191bcfb5bacbedb41117 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Sat, 6 Jun 2026 11:09:29 +0530 Subject: [PATCH] fix(resource): emit metadata.resource_id on pause/resume audit rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/handlers/resource.go | 23 ++++++++ internal/handlers/resource_pause_test.go | 72 ++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/internal/handlers/resource.go b/internal/handlers/resource.go index 24132866..88c58c1e 100644 --- a/internal/handlers/resource.go +++ b/internal/handlers/resource.go @@ -661,7 +661,20 @@ func (h *ResourceHandler) Pause(c *fiber.Ctx) error { h.rdb.Del(ctx, fmt.Sprintf("res:%s", token.String())) // Best-effort audit event. Failure must not block the response. + // + // metadata.resource_id is REQUIRED here (not just the ResourceID column): + // the dashboard's per-resource AuditPanel (instanode-web fetchResourceAudit) + // filters the team audit window by `metadata.resource_id`, and the JSON + // serializer (auditEventToMap) surfaces ONLY the JSONB metadata — the + // ResourceID column is never echoed into the wire `metadata`. Omitting it + // (as this site did before) made resource.paused rows invisible in the + // resource's Audit tab even though the column was set. Mirrors the + // resource_id-in-metadata convention of emitResourceReadAudit / backup. safego.Go("resource.bg", func() { + metaBlob, _ := json.Marshal(map[string]string{ + "resource_id": resource.ID.String(), + "resource_type": resource.ResourceType, + }) _ = models.InsertAuditEvent(context.Background(), h.db, models.AuditEvent{ TeamID: teamID, Actor: "agent", @@ -669,6 +682,7 @@ func (h *ResourceHandler) Pause(c *fiber.Ctx) error { ResourceType: resource.ResourceType, ResourceID: uuid.NullUUID{UUID: resource.ID, Valid: true}, Summary: "paused " + resource.ResourceType + " " + token.String()[:8] + "", + Metadata: metaBlob, }) }) @@ -779,7 +793,15 @@ func (h *ResourceHandler) Resume(c *fiber.Ctx) error { h.rdb.Del(ctx, fmt.Sprintf("res:%s", token.String())) + // metadata.resource_id REQUIRED — see the matching comment in Pause: the + // dashboard AuditPanel filters on metadata.resource_id and the wire + // serializer never echoes the ResourceID column, so without this the + // resource.resumed row would not appear in the resource's Audit tab. safego.Go("resource.bg", func() { + metaBlob, _ := json.Marshal(map[string]string{ + "resource_id": resource.ID.String(), + "resource_type": resource.ResourceType, + }) _ = models.InsertAuditEvent(context.Background(), h.db, models.AuditEvent{ TeamID: teamID, Actor: "agent", @@ -787,6 +809,7 @@ func (h *ResourceHandler) Resume(c *fiber.Ctx) error { ResourceType: resource.ResourceType, ResourceID: uuid.NullUUID{UUID: resource.ID, Valid: true}, Summary: "resumed " + resource.ResourceType + " " + token.String()[:8] + "", + Metadata: metaBlob, }) }) diff --git a/internal/handlers/resource_pause_test.go b/internal/handlers/resource_pause_test.go index ffa9a0d1..48dde31b 100644 --- a/internal/handlers/resource_pause_test.go +++ b/internal/handlers/resource_pause_test.go @@ -19,10 +19,12 @@ import ( "context" "database/sql" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -343,3 +345,73 @@ func TestPausedStorageStillCountsTowardQuota(t *testing.T) { assert.Equal(t, int64(500*1024*1024), total, "deleted rows must be excluded from storage sum") } + +// auditMetadataResourceID polls audit_log for the newest row of `kind` in the +// team and returns the value of metadata->>'resource_id' (empty string when +// the key is absent). The audit insert runs in a best-effort goroutine +// (safego.Go), so callers wrap this in require.Eventually. +func auditMetadataResourceID(t *testing.T, db *sql.DB, teamID, kind string) (found bool, resourceID string) { + t.Helper() + var rid sql.NullString + err := db.QueryRowContext(context.Background(), ` + SELECT metadata->>'resource_id' + FROM audit_log + WHERE team_id = $1::uuid AND kind = $2 + ORDER BY created_at DESC + LIMIT 1 + `, teamID, kind).Scan(&rid) + if errors.Is(err, sql.ErrNoRows) { + return false, "" + } + require.NoError(t, err) + return true, rid.String +} + +// TestPauseResource_EmitsMetadataResourceID is the regression guard for the +// AuditPanel-invisibility bug (BUGHUNT 2026-06-06): the resource.paused audit +// row set the ResourceID column but NOT metadata.resource_id. The dashboard's +// per-resource AuditPanel filters the team audit window by +// `metadata.resource_id`, and auditEventToMap serialises ONLY the JSONB +// metadata (the ResourceID column is never echoed onto the wire), so a paused +// resource's most important state-change event never appeared in its Audit tab. +// +// This asserts the row's metadata->>'resource_id' equals the resource UUID, so +// the row survives the UI's client-side filter. Pairs with the resume guard +// below; if a future edit drops Metadata from either emit site, one of these +// fails. +func TestPauseResource_EmitsMetadataResourceID(t *testing.T) { + fix := setupPauseFixture(t, "pro", "postgres") + + resp := doPauseOrResume(t, fix.app, fix.jwt, "pause", fix.resourceToken) + resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + require.Eventually(t, func() bool { + found, rid := auditMetadataResourceID(t, fix.db, fix.teamID, "resource.paused") + return found && rid == fix.resourceID + }, 3*time.Second, 50*time.Millisecond, + "resource.paused audit row must carry metadata.resource_id == resource UUID "+ + "so the dashboard AuditPanel (filters on metadata.resource_id) shows it") +} + +// TestResumeResource_EmitsMetadataResourceID — the resume-side twin of the +// guard above. +func TestResumeResource_EmitsMetadataResourceID(t *testing.T) { + fix := setupPauseFixture(t, "pro", "postgres") + + // Pause first to reach a paused state, then resume. + resp := doPauseOrResume(t, fix.app, fix.jwt, "pause", fix.resourceToken) + resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + resp = doPauseOrResume(t, fix.app, fix.jwt, "resume", fix.resourceToken) + resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + require.Eventually(t, func() bool { + found, rid := auditMetadataResourceID(t, fix.db, fix.teamID, "resource.resumed") + return found && rid == fix.resourceID + }, 3*time.Second, 50*time.Millisecond, + "resource.resumed audit row must carry metadata.resource_id == resource UUID "+ + "so the dashboard AuditPanel shows it") +}