diff --git a/internal/api/handlers.go b/internal/api/handlers.go index b3e2102..b7ba022 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -99,7 +99,7 @@ const asyncTimeout = 5 * time.Second // context.WithoutCancel so the goroutine is not killed when the HTTP response // completes, but the trace/span context is kept so the async write appears in // the same request trace as the synchronous work. -func (h *Handlers) logSandboxActivity(reqCtx context.Context, sandboxID, teamID uuid.UUID, category, action, status string, sandboxName *string, durationMs *int32, metadata []byte) { +func (h *Handlers) logSandboxActivity(reqCtx context.Context, sandboxID, teamID uuid.UUID, actorID *uuid.UUID, category, action, status string, sandboxName *string, durationMs *int32, metadata []byte) { asyncCtx := context.WithoutCancel(reqCtx) go func() { ctx, cancel := context.WithTimeout(asyncCtx, asyncTimeout) @@ -108,6 +108,7 @@ func (h *Handlers) logSandboxActivity(reqCtx context.Context, sandboxID, teamID SandboxID: pgtype.UUID{Bytes: sandboxID, Valid: true}, ResourceType: "sandbox", TeamID: teamID, + ActorID: actorUUID(actorID), Category: category, Action: action, Status: &status, @@ -123,7 +124,7 @@ func (h *Handlers) logSandboxActivity(reqCtx context.Context, sandboxID, teamID // logTemplateActivity writes a template-scoped activity record. Same async // semantics as logSandboxActivity. -func (h *Handlers) logTemplateActivity(reqCtx context.Context, templateID, teamID uuid.UUID, category, action, status string, metadata []byte) { +func (h *Handlers) logTemplateActivity(reqCtx context.Context, templateID, teamID uuid.UUID, actorID *uuid.UUID, category, action, status string, metadata []byte) { asyncCtx := context.WithoutCancel(reqCtx) go func() { ctx, cancel := context.WithTimeout(asyncCtx, asyncTimeout) @@ -132,6 +133,7 @@ func (h *Handlers) logTemplateActivity(reqCtx context.Context, templateID, teamI TemplateID: pgtype.UUID{Bytes: templateID, Valid: true}, ResourceType: "template", TeamID: teamID, + ActorID: actorUUID(actorID), Category: category, Action: action, Status: &status, @@ -143,6 +145,13 @@ func (h *Handlers) logTemplateActivity(reqCtx context.Context, templateID, teamI }() } +func actorUUID(actorID *uuid.UUID) pgtype.UUID { + if actorID == nil { + return pgtype.UUID{} + } + return pgtype.UUID{Bytes: *actorID, Valid: true} +} + // loadActiveSandbox fetches a sandbox by ID, verifies team ownership, and // requires that it is in the `active` state. Non-active sandboxes (paused, // pausing, failed, etc.) are rejected — callers must resume the sandbox @@ -381,7 +390,7 @@ func (h *Handlers) resumePausedSandbox(c *gin.Context, sandbox *db.Sandbox, team sandbox.MemoryMib = int32(actualMemMiB) sandbox.IpAddress = ipAddr - h.logSandboxActivity(c.Request.Context(), sandboxID, teamID, "sandbox", "resumed", "success", &sandbox.Name, nil, nil) + h.logSandboxActivity(c.Request.Context(), sandboxID, teamID, actorIDFromContext(c), "sandbox", "resumed", "success", &sandbox.Name, nil, nil) return true } @@ -475,6 +484,18 @@ func teamIDFromContext(c *gin.Context) (uuid.UUID, error) { return id, nil } +func actorIDFromContext(c *gin.Context) *uuid.UUID { + raw, ok := c.Get("actor_id") + if !ok { + return nil + } + id, ok := raw.(uuid.UUID) + if !ok { + return nil + } + return &id +} + // --------------------------------------------------------------------------- // Sandbox lifecycle // --------------------------------------------------------------------------- @@ -596,7 +617,7 @@ func (h *Handlers) DeleteSandbox(c *gin.Context) { h.gcOldBuildArtifacts(c.Request.Context(), sandbox) // Async activity log. - h.logSandboxActivity(c.Request.Context(), sandboxID, teamID, "sandbox", "deleted", "success", &sandbox.Name, nil, nil) + h.logSandboxActivity(c.Request.Context(), sandboxID, teamID, actorIDFromContext(c), "sandbox", "deleted", "success", &sandbox.Name, nil, nil) c.Status(http.StatusNoContent) } @@ -1354,7 +1375,7 @@ func (h *Handlers) CreateSandbox(c *gin.Context) { "template_id": fromTemplateID, }) } - h.logSandboxActivity(c.Request.Context(), sandbox.ID, teamID, "sandbox", "started", "success", &sandbox.Name, nil, createdMeta) + h.logSandboxActivity(c.Request.Context(), sandbox.ID, teamID, actorIDFromContext(c), "sandbox", "started", "success", &sandbox.Name, nil, createdMeta) sandbox.Status = db.SandboxStatusActive resp := h.sandboxToResponseWithToken(sandbox) @@ -1497,7 +1518,7 @@ func (h *Handlers) PauseSandbox(c *gin.Context) { } // Async observability. - h.logSandboxActivity(c.Request.Context(), sandboxID, teamID, "sandbox", "paused", "success", &sandbox.Name, nil, nil) + h.logSandboxActivity(c.Request.Context(), sandboxID, teamID, actorIDFromContext(c), "sandbox", "paused", "success", &sandbox.Name, nil, nil) c.Status(http.StatusNoContent) } @@ -1579,7 +1600,7 @@ func (h *Handlers) ExecSandbox(c *gin.Context) { "exit_code": exitCode, "duration_ms": durationMs, }) - h.logSandboxActivity(c.Request.Context(), sandbox.ID, sandbox.TeamID, "exec", "executed", "success", &sandbox.Name, &durationMs, metadata) + h.logSandboxActivity(c.Request.Context(), sandbox.ID, sandbox.TeamID, actorIDFromContext(c), "exec", "executed", "success", &sandbox.Name, &durationMs, metadata) c.JSON(http.StatusOK, gin.H{ "stdout": stdout, @@ -1715,7 +1736,7 @@ func (h *Handlers) PatchSandbox(c *gin.Context) { log.Warn().Err(err).Str("sandbox_id", sandboxID.String()).Msg("DB UpdateSandboxNetworkConfig failed (rules applied, persistence failed)") } - h.logSandboxActivity(c.Request.Context(), sandbox.ID, teamID, "network", "updated", "success", &sandbox.Name, nil, networkConfig) + h.logSandboxActivity(c.Request.Context(), sandbox.ID, teamID, actorIDFromContext(c), "network", "updated", "success", &sandbox.Name, nil, networkConfig) } if body.Metadata != nil { @@ -1735,7 +1756,7 @@ func (h *Handlers) PatchSandbox(c *gin.Context) { return } - h.logSandboxActivity(c.Request.Context(), sandbox.ID, teamID, "sandbox", "metadata_updated", "success", &sandbox.Name, nil, nil) + h.logSandboxActivity(c.Request.Context(), sandbox.ID, teamID, actorIDFromContext(c), "sandbox", "metadata_updated", "success", &sandbox.Name, nil, nil) } c.Status(http.StatusNoContent) diff --git a/internal/api/handlers_template.go b/internal/api/handlers_template.go index 871ccec..af07cb0 100644 --- a/internal/api/handlers_template.go +++ b/internal/api/handlers_template.go @@ -522,8 +522,9 @@ func (h *Handlers) CreateTemplate(c *gin.Context) { } c.JSON(http.StatusAccepted, respBody) - h.logTemplateActivity(c.Request.Context(), row.ID, teamID, "template", "created", "success", nil) - h.logTemplateActivity(c.Request.Context(), row.ID, teamID, "template", "build_started", "success", + actorID := actorIDFromContext(c) + h.logTemplateActivity(c.Request.Context(), row.ID, teamID, actorID, "template", "created", "success", nil) + h.logTemplateActivity(c.Request.Context(), row.ID, teamID, actorID, "template", "build_started", "success", buildMetadata(row.BuildID)) } @@ -673,7 +674,7 @@ func (h *Handlers) DeleteTemplate(c *gin.Context) { } c.Status(http.StatusNoContent) - h.logTemplateActivity(c.Request.Context(), tplID, teamID, "template", "deleted", "success", nil) + h.logTemplateActivity(c.Request.Context(), tplID, teamID, actorIDFromContext(c), "template", "deleted", "success", nil) // Drop the on-disk snapshot + rootfs. Safe because SoftDeleteTemplateIfUnused // blocks while any build is in flight, so no template-builder is @@ -773,7 +774,7 @@ func (h *Handlers) CreateTemplateBuild(c *gin.Context) { } c.JSON(http.StatusCreated, toBuildResponse(build)) - h.logTemplateActivity(c.Request.Context(), tplID, teamID, "template", "build_started", "success", + h.logTemplateActivity(c.Request.Context(), tplID, teamID, actorIDFromContext(c), "template", "build_started", "success", buildMetadata(build.ID)) } @@ -879,7 +880,7 @@ func (h *Handlers) CancelTemplateBuild(c *gin.Context) { return } c.Status(http.StatusNoContent) - h.logTemplateActivity(c.Request.Context(), tplID, teamID, "template", "build_cancelled", "success", + h.logTemplateActivity(c.Request.Context(), tplID, teamID, actorIDFromContext(c), "template", "build_cancelled", "success", buildMetadata(buildID)) } diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 1cecc9d..0bf8ee8 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -8,6 +8,8 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -29,10 +31,11 @@ func APIKeyAuth(pool *pgxpool.Pool) gin.HandlerFunc { keyHash := hex.EncodeToString(hash[:]) var id, teamID string + var createdBy pgtype.UUID err := pool.QueryRow(c.Request.Context(), - "SELECT id, team_id FROM api_key WHERE key_hash = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())", + "SELECT id, team_id, created_by FROM api_key WHERE key_hash = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())", keyHash, - ).Scan(&id, &teamID) + ).Scan(&id, &teamID, &createdBy) if err != nil { respondErrorMsg(c, "auth_failed", "Invalid or missing X-API-Key header.", http.StatusUnauthorized) c.Abort() @@ -51,6 +54,9 @@ func APIKeyAuth(pool *pgxpool.Pool) gin.HandlerFunc { c.Set("api_key_id", id) c.Set("team_id", teamID) + if createdBy.Valid { + c.Set("actor_id", uuid.UUID(createdBy.Bytes)) + } c.Next() } } diff --git a/internal/api/reaper.go b/internal/api/reaper.go index be56159..3209786 100644 --- a/internal/api/reaper.go +++ b/internal/api/reaper.go @@ -188,7 +188,7 @@ func (h *Handlers) pauseExpired(ctx context.Context, sbx db.ClaimExpiredSandboxe } l.Info().Msg("reaper: sandbox paused due to timeout") - h.logSandboxActivity(ctx, sbx.ID, sbx.TeamID, "sandbox", "timeout_paused", "success", &sbx.Name, nil, nil) + h.logSandboxActivity(ctx, sbx.ID, sbx.TeamID, nil, "sandbox", "timeout_paused", "success", &sbx.Name, nil, nil) } // rollbackPausedVM is the saga compensation for a failed pause. The VM is diff --git a/internal/api/streaming.go b/internal/api/streaming.go index 95d2c48..d30d35c 100644 --- a/internal/api/streaming.go +++ b/internal/api/streaming.go @@ -128,5 +128,5 @@ func (h *Handlers) ExecSandboxStream(c *gin.Context) { "exit_code": lastExitCode, "duration_ms": durationMs, }) - h.logSandboxActivity(c.Request.Context(), sandbox.ID, sandbox.TeamID, "exec", "executed", actStatus, &sandbox.Name, &durationMs, metadata) + h.logSandboxActivity(c.Request.Context(), sandbox.ID, sandbox.TeamID, actorIDFromContext(c), "exec", "executed", actStatus, &sandbox.Name, &durationMs, metadata) } diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go index 22875c0..82e568a 100644 --- a/internal/integration/integration_test.go +++ b/internal/integration/integration_test.go @@ -228,6 +228,43 @@ func seedTeamAndKey(t *testing.T) (uuid.UUID, string) { return team.ID, rawKey } +// seedTeamKeyAndProfile is like seedTeamAndKey but also seeds a profile row +// and sets api_key.created_by to it. Returns (teamID, rawKey, profileID). +func seedTeamKeyAndProfile(t *testing.T) (uuid.UUID, string, uuid.UUID) { + t.Helper() + ctx := context.Background() + + team, err := testQueries.CreateTeam(ctx, "team-"+uuid.New().String()[:8]) + if err != nil { + t.Fatalf("seedTeamKeyAndProfile: create team: %v", err) + } + + profileID := uuid.New() + if _, err := testPool.Exec(ctx, + `INSERT INTO profile (id, email) VALUES ($1, $2)`, + profileID, fmt.Sprintf("test-%s@example.com", profileID.String()[:8]), + ); err != nil { + t.Fatalf("seedTeamKeyAndProfile: insert profile: %v", err) + } + + rawKey := "sk-test-" + uuid.New().String() + hash := sha256.Sum256([]byte(rawKey)) + keyHash := hex.EncodeToString(hash[:]) + + _, err = testQueries.CreateAPIKeyV2(ctx, db.CreateAPIKeyV2Params{ + TeamID: team.ID, + KeyHash: keyHash, + Name: "test-key", + Scopes: []string{}, + CreatedBy: pgtype.UUID{Bytes: profileID, Valid: true}, + }) + if err != nil { + t.Fatalf("seedTeamKeyAndProfile: create api key: %v", err) + } + + return team.ID, rawKey, profileID +} + // newRouter builds a router scoped to the current test. Using t.Context() // ensures the rate limiter's cleanup goroutine exits when the test ends, // preventing goroutine leaks across hundreds of test invocations. @@ -891,6 +928,78 @@ func TestIntegration_ActivityLog_PauseRecorded(t *testing.T) { } } +func TestIntegration_ActivityLog_ActorAttributedToKeyCreator(t *testing.T) { + ctx := context.Background() + _, apiKey, profileID := seedTeamKeyAndProfile(t) + r := newRouter(t) + + cw := do(r, "POST", "/sandboxes", apiKey, `{"name":"actor-box"}`) + if cw.Code != http.StatusCreated { + t.Fatalf("create: %d %s", cw.Code, cw.Body.String()) + } + sid := mustJSON(t, cw)["id"].(string) + sandboxID, _ := uuid.Parse(sid) + + time.Sleep(100 * time.Millisecond) + activities, err := testQueries.ListActivityBySandbox(ctx, db.ListActivityBySandboxParams{ + SandboxID: pgtype.UUID{Bytes: sandboxID, Valid: true}, + Limit: 20, + }) + if err != nil { + t.Fatalf("list activities: %v", err) + } + + var found bool + for _, a := range activities { + if a.Category == "sandbox" && a.Action == "started" { + found = true + if !a.ActorID.Valid { + t.Errorf("actor_id = NULL, want %s", profileID) + } else if uuid.UUID(a.ActorID.Bytes) != profileID { + t.Errorf("actor_id = %s, want %s", uuid.UUID(a.ActorID.Bytes), profileID) + } + } + } + if !found { + t.Error("no 'sandbox/started' activity record after create") + } +} + +func TestIntegration_ActivityLog_ActorNullWhenKeyHasNoCreator(t *testing.T) { + ctx := context.Background() + _, apiKey := seedTeamAndKey(t) + r := newRouter(t) + + cw := do(r, "POST", "/sandboxes", apiKey, `{"name":"null-actor-box"}`) + if cw.Code != http.StatusCreated { + t.Fatalf("create: %d %s", cw.Code, cw.Body.String()) + } + sid := mustJSON(t, cw)["id"].(string) + sandboxID, _ := uuid.Parse(sid) + + time.Sleep(100 * time.Millisecond) + activities, err := testQueries.ListActivityBySandbox(ctx, db.ListActivityBySandboxParams{ + SandboxID: pgtype.UUID{Bytes: sandboxID, Valid: true}, + Limit: 20, + }) + if err != nil { + t.Fatalf("list activities: %v", err) + } + + var found bool + for _, a := range activities { + if a.Category == "sandbox" && a.Action == "started" { + found = true + if a.ActorID.Valid { + t.Errorf("actor_id = %s, want NULL", uuid.UUID(a.ActorID.Bytes)) + } + } + } + if !found { + t.Error("no 'sandbox/started' activity record after create") + } +} + // --------------------------------------------------------------------------- // Concurrent sandbox creation via API // ---------------------------------------------------------------------------