Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 30 additions & 9 deletions internal/api/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
11 changes: 6 additions & 5 deletions internal/api/handlers_template.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
}

Expand Down Expand Up @@ -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))
}

Expand Down
10 changes: 8 additions & 2 deletions internal/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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()
Expand All @@ -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()
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/api/reaper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/api/streaming.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
109 changes: 109 additions & 0 deletions internal/integration/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
Loading