1
0
forked from wrenn/wrenn

Add audit log infrastructure and GET /v1/audit-logs endpoint

Introduces an append-only audit trail for all user and system actions:
sandbox lifecycle (create/pause/resume/destroy/auto-pause), snapshots,
team rename, API key create/revoke, member add/remove/leave/role_update,
and BYOC host add/delete/marked_down/marked_up.

- New audit_logs table (migration) with team_id, actor, resource,
  action, scope (team|admin), status (success|info|warning|error),
  metadata, and created_at
- AuditLogger (internal/audit) with named fire-and-forget methods per
  event; system actor used for background events (HostMonitor, TTL reaper)
- GET /v1/audit-logs: JWT-only, cursor pagination (max 200), multi-value
  filters for resource_type and action (comma-sep or repeated params);
  members see team-scoped events only, admins/owners see all
- AuthContext extended with APIKeyID + APIKeyName so API key requests
  record meaningful actor identity
- HostMonitor wired with AuditLogger for auto-pause and host marked_down
This commit is contained in:
2026-03-25 05:15:16 +06:00
parent 9878156798
commit 1be30034bd
21 changed files with 938 additions and 43 deletions

View File

@ -14,6 +14,7 @@ import (
"github.com/redis/go-redis/v9"
"git.omukk.dev/wrenn/sandbox/internal/api"
"git.omukk.dev/wrenn/sandbox/internal/audit"
"git.omukk.dev/wrenn/sandbox/internal/auth/oauth"
"git.omukk.dev/wrenn/sandbox/internal/config"
"git.omukk.dev/wrenn/sandbox/internal/db"
@ -90,7 +91,7 @@ func main() {
srv := api.New(queries, hostPool, hostScheduler, pool, rdb, []byte(cfg.JWTSecret), oauthRegistry, cfg.OAuthRedirectURL)
// Start host monitor (passive + active reconciliation every 30s).
monitor := api.NewHostMonitor(queries, hostPool, 30*time.Second)
monitor := api.NewHostMonitor(queries, hostPool, audit.New(queries), 30*time.Second)
monitor.Start(ctx)
httpServer := &http.Server{

View File

@ -0,0 +1,28 @@
-- +goose Up
CREATE TABLE audit_logs (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL,
actor_type TEXT NOT NULL, -- 'user', 'api_key', 'system'
actor_id TEXT, -- user_id or api_key_id; NULL for system
actor_name TEXT, -- display name snapshotted at write time; NULL for system
resource_type TEXT NOT NULL, -- 'sandbox', 'snapshot', 'team', 'api_key', 'member', 'host'
resource_id TEXT, -- primary ID of the affected resource; NULL when not applicable
action TEXT NOT NULL, -- 'create', 'pause', 'resume', 'destroy', 'delete', 'rename',
-- 'revoke', 'add', 'remove', 'leave', 'role_update',
-- 'marked_down', 'marked_up'
scope TEXT NOT NULL, -- 'team' or 'admin'
status TEXT NOT NULL, -- 'success', 'info', 'warning', 'error'
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Primary access pattern: team feed sorted newest-first with cursor pagination.
CREATE INDEX idx_audit_logs_team_time ON audit_logs (team_id, created_at DESC);
-- Secondary index: filtered by resource_type and action within a team.
CREATE INDEX idx_audit_logs_team_resource ON audit_logs (team_id, resource_type, action, created_at DESC);
-- +goose Down
DROP TABLE audit_logs;

View File

@ -0,0 +1,14 @@
-- name: InsertAuditLog :exec
INSERT INTO audit_logs (id, team_id, actor_type, actor_id, actor_name, resource_type, resource_id, action, scope, status, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);
-- name: ListAuditLogs :many
SELECT * FROM audit_logs
WHERE team_id = $1
AND scope = ANY($2::text[])
AND (cardinality($3::text[]) = 0 OR resource_type = ANY($3::text[]))
AND (cardinality($4::text[]) = 0 OR action = ANY($4::text[]))
AND ($5::timestamptz IS NULL OR created_at < $5
OR (created_at = $5 AND id < $6))
ORDER BY created_at DESC, id DESC
LIMIT $7;

View File

@ -6,6 +6,7 @@ import (
"github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/sandbox/internal/audit"
"git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/service"
@ -13,10 +14,11 @@ import (
type apiKeyHandler struct {
svc *service.APIKeyService
audit *audit.AuditLogger
}
func newAPIKeyHandler(svc *service.APIKeyService) *apiKeyHandler {
return &apiKeyHandler{svc: svc}
func newAPIKeyHandler(svc *service.APIKeyService, al *audit.AuditLogger) *apiKeyHandler {
return &apiKeyHandler{svc: svc, audit: al}
}
type createAPIKeyRequest struct {
@ -91,6 +93,7 @@ func (h *apiKeyHandler) Create(w http.ResponseWriter, r *http.Request) {
resp := apiKeyToResponse(result.Row)
resp.Key = &result.Plaintext
h.audit.LogAPIKeyCreate(r.Context(), ac, result.Row.ID, result.Row.Name)
writeJSON(w, http.StatusCreated, resp)
}
@ -122,5 +125,6 @@ func (h *apiKeyHandler) Delete(w http.ResponseWriter, r *http.Request) {
return
}
h.audit.LogAPIKeyRevoke(r.Context(), ac, keyID)
w.WriteHeader(http.StatusNoContent)
}

View File

@ -0,0 +1,134 @@
package api
import (
"net/http"
"strconv"
"strings"
"time"
"git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/service"
)
type auditHandler struct {
svc *service.AuditService
}
func newAuditHandler(svc *service.AuditService) *auditHandler {
return &auditHandler{svc: svc}
}
type auditLogResponse struct {
ID string `json:"id"`
ActorType string `json:"actor_type"`
ActorID string `json:"actor_id,omitempty"`
ActorName string `json:"actor_name,omitempty"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id,omitempty"`
Action string `json:"action"`
Scope string `json:"scope"`
Status string `json:"status"`
Metadata map[string]any `json:"metadata,omitempty"`
CreatedAt string `json:"created_at"`
}
// List handles GET /v1/audit-logs.
// Query params:
// - before: RFC3339 timestamp cursor (exclusive); omit to start from latest
// - limit: page size, default 50, max 200
// - resource_type: filter by resource type (sandbox, snapshot, team, api_key, member, host)
// - action: filter by action verb
//
// Members see only team-scoped events; admins/owners see all.
func (h *auditHandler) List(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
// Parse ?before cursor.
var before time.Time
if s := r.URL.Query().Get("before"); s != "" {
var err error
before, err = time.Parse(time.RFC3339, s)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "before must be an RFC3339 timestamp")
return
}
}
// Parse ?limit.
limit := 50
if s := r.URL.Query().Get("limit"); s != "" {
n, err := strconv.Atoi(s)
if err != nil || n < 1 {
writeError(w, http.StatusBadRequest, "invalid_request", "limit must be a positive integer")
return
}
limit = n
}
entries, err := h.svc.List(r.Context(), service.AuditListParams{
TeamID: ac.TeamID,
AdminScoped: ac.Role == "owner" || ac.Role == "admin",
ResourceTypes: parseMultiParam(r.URL.Query()["resource_type"]),
Actions: parseMultiParam(r.URL.Query()["action"]),
Before: before,
BeforeID: r.URL.Query().Get("before_id"),
Limit: limit,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list audit logs")
return
}
items := make([]auditLogResponse, len(entries))
for i, e := range entries {
items[i] = auditLogResponse{
ID: e.ID,
ActorType: e.ActorType,
ActorID: e.ActorID,
ActorName: e.ActorName,
ResourceType: e.ResourceType,
ResourceID: e.ResourceID,
Action: e.Action,
Scope: e.Scope,
Status: e.Status,
Metadata: e.Metadata,
CreatedAt: e.CreatedAt.UTC().Format(time.RFC3339),
}
}
resp := map[string]any{"items": items}
if len(items) > 0 {
last := entries[len(entries)-1]
resp["next_before"] = last.CreatedAt.UTC().Format(time.RFC3339)
resp["next_before_id"] = last.ID
}
writeJSON(w, http.StatusOK, resp)
}
// parseMultiParam flattens repeated params and comma-separated values into a
// single deduplicated slice. Empty strings are dropped. Returns nil (no filter)
// when no values are present.
//
// Both ?resource_type=sandbox&resource_type=snapshot
// and ?resource_type=sandbox,snapshot are accepted.
func parseMultiParam(values []string) []string {
if len(values) == 0 {
return nil
}
seen := make(map[string]struct{})
var out []string
for _, v := range values {
for _, part := range strings.Split(v, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if _, ok := seen[part]; !ok {
seen[part] = struct{}{}
out = append(out, part)
}
}
}
return out
}

View File

@ -2,11 +2,13 @@ package api
import (
"errors"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/sandbox/internal/audit"
"git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/service"
@ -15,10 +17,11 @@ import (
type hostHandler struct {
svc *service.HostService
queries *db.Queries
audit *audit.AuditLogger
}
func newHostHandler(svc *service.HostService, queries *db.Queries) *hostHandler {
return &hostHandler{svc: svc, queries: queries}
func newHostHandler(svc *service.HostService, queries *db.Queries, al *audit.AuditLogger) *hostHandler {
return &hostHandler{svc: svc, queries: queries, audit: al}
}
// Request/response types.
@ -50,10 +53,6 @@ type deletePreviewResponse struct {
SandboxIDs []string `json:"sandbox_ids"`
}
type hasSandboxesErrorResponse struct {
SandboxIDs []string `json:"sandbox_ids"`
}
type registerHostRequest struct {
Token string `json:"token"`
Arch string `json:"arch,omitempty"`
@ -166,6 +165,10 @@ func (h *hostHandler) Create(w http.ResponseWriter, r *http.Request) {
return
}
// Log audit for the owning team (BYOC hosts have a team; shared hosts use caller's team).
hostTeamID := result.Host.TeamID.String
h.audit.LogHostCreate(r.Context(), ac, result.Host.ID, hostTeamID)
writeJSON(w, http.StatusCreated, createHostResponse{
Host: hostToResponse(result.Host),
RegistrationToken: result.RegistrationToken,
@ -257,8 +260,15 @@ func (h *hostHandler) Delete(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
force := r.URL.Query().Get("force") == "true"
// Fetch host before deletion to capture team_id for audit.
deletedHost, hostErr := h.queries.GetHost(r.Context(), hostID)
if hostErr != nil {
slog.Warn("audit: could not fetch host before delete", "host_id", hostID, "error", hostErr)
}
err := h.svc.Delete(r.Context(), hostID, ac.UserID, ac.TeamID, h.isAdmin(r, ac.UserID), force)
if err == nil {
h.audit.LogHostDelete(r.Context(), ac, hostID, deletedHost.TeamID.String)
w.WriteHeader(http.StatusNoContent)
return
}
@ -347,12 +357,20 @@ func (h *hostHandler) Heartbeat(w http.ResponseWriter, r *http.Request) {
return
}
// Capture pre-heartbeat status to detect unreachable → online transition.
prevHost, _ := h.queries.GetHost(r.Context(), hc.HostID)
if err := h.svc.Heartbeat(r.Context(), hc.HostID); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
return
}
// Log marked_up if the host just recovered from unreachable.
if prevHost.Status == "unreachable" {
h.audit.LogHostMarkedUp(r.Context(), prevHost.TeamID.String, hc.HostID)
}
w.WriteHeader(http.StatusNoContent)
}

View File

@ -7,6 +7,7 @@ import (
"github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/sandbox/internal/audit"
"git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/service"
@ -14,10 +15,11 @@ import (
type sandboxHandler struct {
svc *service.SandboxService
audit *audit.AuditLogger
}
func newSandboxHandler(svc *service.SandboxService) *sandboxHandler {
return &sandboxHandler{svc: svc}
func newSandboxHandler(svc *service.SandboxService, al *audit.AuditLogger) *sandboxHandler {
return &sandboxHandler{svc: svc, audit: al}
}
type createSandboxRequest struct {
@ -97,6 +99,7 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
return
}
h.audit.LogSandboxCreate(r.Context(), ac, sb.ID, sb.Template)
writeJSON(w, http.StatusCreated, sandboxToResponse(sb))
}
@ -143,6 +146,7 @@ func (h *sandboxHandler) Pause(w http.ResponseWriter, r *http.Request) {
return
}
h.audit.LogSandboxPause(r.Context(), ac, sandboxID)
writeJSON(w, http.StatusOK, sandboxToResponse(sb))
}
@ -158,6 +162,7 @@ func (h *sandboxHandler) Resume(w http.ResponseWriter, r *http.Request) {
return
}
h.audit.LogSandboxResume(r.Context(), ac, sandboxID)
writeJSON(w, http.StatusOK, sandboxToResponse(sb))
}
@ -186,5 +191,6 @@ func (h *sandboxHandler) Destroy(w http.ResponseWriter, r *http.Request) {
return
}
h.audit.LogSandboxDestroy(r.Context(), ac, sandboxID)
w.WriteHeader(http.StatusNoContent)
}

View File

@ -12,6 +12,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/audit"
"git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
@ -25,10 +26,11 @@ type snapshotHandler struct {
svc *service.TemplateService
db *db.Queries
pool *lifecycle.HostClientPool
audit *audit.AuditLogger
}
func newSnapshotHandler(svc *service.TemplateService, db *db.Queries, pool *lifecycle.HostClientPool) *snapshotHandler {
return &snapshotHandler{svc: svc, db: db, pool: pool}
func newSnapshotHandler(svc *service.TemplateService, db *db.Queries, pool *lifecycle.HostClientPool, al *audit.AuditLogger) *snapshotHandler {
return &snapshotHandler{svc: svc, db: db, pool: pool, audit: al}
}
// deleteSnapshotBroadcast attempts to delete snapshot files on all online hosts.
@ -180,6 +182,7 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
return
}
h.audit.LogSnapshotCreate(r.Context(), ac, req.Name)
writeJSON(w, http.StatusCreated, templateToResponse(tmpl))
}
@ -227,5 +230,6 @@ func (h *snapshotHandler) Delete(w http.ResponseWriter, r *http.Request) {
return
}
h.audit.LogSnapshotDelete(r.Context(), ac, name)
w.WriteHeader(http.StatusNoContent)
}

View File

@ -1,12 +1,14 @@
package api
import (
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"git.omukk.dev/wrenn/sandbox/internal/audit"
"git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/service"
@ -14,10 +16,11 @@ import (
type teamHandler struct {
svc *service.TeamService
audit *audit.AuditLogger
}
func newTeamHandler(svc *service.TeamService) *teamHandler {
return &teamHandler{svc: svc}
func newTeamHandler(svc *service.TeamService, al *audit.AuditLogger) *teamHandler {
return &teamHandler{svc: svc, audit: al}
}
// teamResponse is the JSON shape for a team.
@ -179,12 +182,19 @@ func (h *teamHandler) Rename(w http.ResponseWriter, r *http.Request) {
}
req.Name = strings.TrimSpace(req.Name)
// Fetch old name for audit log before renaming.
oldTeam, err := h.svc.GetTeam(r.Context(), teamID)
if err != nil {
slog.Warn("audit: could not fetch old team name for rename log", "team_id", teamID, "error", err)
}
if err := h.svc.RenameTeam(r.Context(), teamID, ac.UserID, req.Name); err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
return
}
h.audit.LogTeamRename(r.Context(), ac, teamID, oldTeam.Name, req.Name)
w.WriteHeader(http.StatusNoContent)
}
@ -257,6 +267,7 @@ func (h *teamHandler) AddMember(w http.ResponseWriter, r *http.Request) {
return
}
h.audit.LogMemberAdd(r.Context(), ac, member.UserID, member.Email, member.Role)
writeJSON(w, http.StatusCreated, memberInfoToResponse(member))
}
@ -276,6 +287,7 @@ func (h *teamHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
return
}
h.audit.LogMemberRemove(r.Context(), ac, targetUserID)
w.WriteHeader(http.StatusNoContent)
}
@ -303,6 +315,7 @@ func (h *teamHandler) UpdateMemberRole(w http.ResponseWriter, r *http.Request) {
return
}
h.audit.LogMemberRoleUpdate(r.Context(), ac, targetUserID, req.Role)
w.WriteHeader(http.StatusNoContent)
}
@ -321,6 +334,7 @@ func (h *teamHandler) Leave(w http.ResponseWriter, r *http.Request) {
return
}
h.audit.LogMemberLeave(r.Context(), ac)
w.WriteHeader(http.StatusNoContent)
}

View File

@ -5,11 +5,12 @@ import (
"log/slog"
"time"
"connectrpc.com/connect"
"git.omukk.dev/wrenn/sandbox/internal/audit"
"git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/lifecycle"
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
"connectrpc.com/connect"
)
// unreachableThreshold is how long a host can go without a heartbeat before
@ -27,14 +28,16 @@ const unreachableThreshold = 90 * time.Second
type HostMonitor struct {
db *db.Queries
pool *lifecycle.HostClientPool
audit *audit.AuditLogger
interval time.Duration
}
// NewHostMonitor creates a HostMonitor.
func NewHostMonitor(queries *db.Queries, pool *lifecycle.HostClientPool, interval time.Duration) *HostMonitor {
func NewHostMonitor(queries *db.Queries, pool *lifecycle.HostClientPool, al *audit.AuditLogger, interval time.Duration) *HostMonitor {
return &HostMonitor{
db: queries,
pool: pool,
audit: al,
interval: interval,
}
}
@ -87,6 +90,7 @@ func (m *HostMonitor) checkHost(ctx context.Context, host db.Host) {
if err := m.db.MarkSandboxesMissingByHost(ctx, host.ID); err != nil {
slog.Warn("host monitor: failed to mark sandboxes missing", "host_id", host.ID, "error", err)
}
m.audit.LogHostMarkedDown(ctx, host.TeamID.String, host.ID)
return
}
@ -170,7 +174,9 @@ func (m *HostMonitor) checkHost(ctx context.Context, host db.Host) {
}
var toPause, toStop []string
sbTeamID := make(map[string]string, len(runningSandboxes))
for _, sb := range runningSandboxes {
sbTeamID[sb.ID] = sb.TeamID
if _, ok := alive[sb.ID]; ok {
continue
}
@ -189,6 +195,9 @@ func (m *HostMonitor) checkHost(ctx context.Context, host db.Host) {
}); err != nil {
slog.Warn("host monitor: failed to mark paused", "host_id", host.ID, "error", err)
}
for _, sbID := range toPause {
m.audit.LogSandboxAutoPause(ctx, sbTeamID[sbID], sbID)
}
}
if len(toStop) > 0 {
slog.Info("host monitor: marking orphaned sandboxes stopped", "host_id", host.ID, "count", len(toStop))

View File

@ -27,7 +27,11 @@ func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler
slog.Warn("failed to update api key last_used", "key_id", row.ID, "error", err)
}
ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{TeamID: row.TeamID})
ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{
TeamID: row.TeamID,
APIKeyID: row.ID,
APIKeyName: row.Name,
})
next.ServeHTTP(w, r.WithContext(ctx))
return
}

View File

@ -9,6 +9,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
"git.omukk.dev/wrenn/sandbox/internal/audit"
"git.omukk.dev/wrenn/sandbox/internal/auth/oauth"
"git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/lifecycle"
@ -44,19 +45,23 @@ func New(
templateSvc := &service.TemplateService{DB: queries}
hostSvc := &service.HostService{DB: queries, Redis: rdb, JWT: jwtSecret, Pool: pool}
teamSvc := &service.TeamService{DB: queries, Pool: pgPool, HostPool: pool}
auditSvc := &service.AuditService{DB: queries}
sandbox := newSandboxHandler(sandboxSvc)
al := audit.New(queries)
sandbox := newSandboxHandler(sandboxSvc, al)
exec := newExecHandler(queries, pool)
execStream := newExecStreamHandler(queries, pool)
files := newFilesHandler(queries, pool)
filesStream := newFilesStreamHandler(queries, pool)
snapshots := newSnapshotHandler(templateSvc, queries, pool)
snapshots := newSnapshotHandler(templateSvc, queries, pool, al)
authH := newAuthHandler(queries, pgPool, jwtSecret)
oauthH := newOAuthHandler(queries, pgPool, jwtSecret, oauthRegistry, oauthRedirectURL)
apiKeys := newAPIKeyHandler(apiKeySvc)
hostH := newHostHandler(hostSvc, queries)
teamH := newTeamHandler(teamSvc)
apiKeys := newAPIKeyHandler(apiKeySvc, al)
hostH := newHostHandler(hostSvc, queries, al)
teamH := newTeamHandler(teamSvc, al)
usersH := newUsersHandler(teamSvc)
auditH := newAuditHandler(auditSvc)
// OpenAPI spec and docs.
r.Get("/openapi.yaml", serveOpenAPI)
@ -156,6 +161,9 @@ func New(
})
})
// JWT-authenticated: audit log.
r.With(requireJWT(jwtSecret)).Get("/v1/audit-logs", auditH.List)
// Platform admin routes — require JWT + DB-validated admin status.
r.Route("/v1/admin", func(r chi.Router) {
r.Use(requireJWT(jwtSecret))

403
internal/audit/logger.go Normal file
View File

@ -0,0 +1,403 @@
package audit
import (
"context"
"encoding/json"
"log/slog"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
)
// AuditLogger writes audit log entries for user-initiated and system events.
// All methods are fire-and-forget: failures are logged via slog and never
// propagated to the caller.
type AuditLogger struct {
db *db.Queries
}
// New constructs an AuditLogger.
func New(queries *db.Queries) *AuditLogger {
return &AuditLogger{db: queries}
}
// actorFields extracts actor_type, actor_id, and actor_name from an AuthContext.
func actorFields(ac auth.AuthContext) (actorType string, actorID pgtype.Text, actorName pgtype.Text) {
if ac.UserID != "" {
return "user",
pgtype.Text{String: ac.UserID, Valid: true},
pgtype.Text{String: ac.Name, Valid: ac.Name != ""}
}
if ac.APIKeyID != "" {
return "api_key",
pgtype.Text{String: ac.APIKeyID, Valid: true},
pgtype.Text{String: ac.APIKeyName, Valid: true}
}
return "system", pgtype.Text{}, pgtype.Text{}
}
func (l *AuditLogger) write(ctx context.Context, p db.InsertAuditLogParams) {
if err := l.db.InsertAuditLog(ctx, p); err != nil {
slog.Warn("audit: failed to write log entry",
"action", p.Action,
"resource_type", p.ResourceType,
"team_id", p.TeamID,
"error", err,
)
}
}
func marshalMeta(meta map[string]any) []byte {
if len(meta) == 0 {
return []byte("{}")
}
b, err := json.Marshal(meta)
if err != nil {
return []byte("{}")
}
return b
}
// --- Sandbox events (scope: team) ---
func (l *AuditLogger) LogSandboxCreate(ctx context.Context, ac auth.AuthContext, sandboxID, template string) {
actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: ac.TeamID,
ActorType: actorType,
ActorID: actorID,
ActorName: actorName,
ResourceType: "sandbox",
ResourceID: pgtype.Text{String: sandboxID, Valid: true},
Action: "create",
Scope: "team",
Status: "success",
Metadata: marshalMeta(map[string]any{"template": template}),
})
}
func (l *AuditLogger) LogSandboxPause(ctx context.Context, ac auth.AuthContext, sandboxID string) {
actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: ac.TeamID,
ActorType: actorType,
ActorID: actorID,
ActorName: actorName,
ResourceType: "sandbox",
ResourceID: pgtype.Text{String: sandboxID, Valid: true},
Action: "pause",
Scope: "team",
Status: "success",
Metadata: []byte("{}"),
})
}
// LogSandboxAutoPause records a system-initiated auto-pause (TTL or host reconciler).
func (l *AuditLogger) LogSandboxAutoPause(ctx context.Context, teamID, sandboxID string) {
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: teamID,
ActorType: "system",
ActorID: pgtype.Text{},
ActorName: pgtype.Text{},
ResourceType: "sandbox",
ResourceID: pgtype.Text{String: sandboxID, Valid: true},
Action: "pause",
Scope: "team",
Status: "info",
Metadata: []byte("{}"),
})
}
func (l *AuditLogger) LogSandboxResume(ctx context.Context, ac auth.AuthContext, sandboxID string) {
actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: ac.TeamID,
ActorType: actorType,
ActorID: actorID,
ActorName: actorName,
ResourceType: "sandbox",
ResourceID: pgtype.Text{String: sandboxID, Valid: true},
Action: "resume",
Scope: "team",
Status: "success",
Metadata: []byte("{}"),
})
}
func (l *AuditLogger) LogSandboxDestroy(ctx context.Context, ac auth.AuthContext, sandboxID string) {
actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: ac.TeamID,
ActorType: actorType,
ActorID: actorID,
ActorName: actorName,
ResourceType: "sandbox",
ResourceID: pgtype.Text{String: sandboxID, Valid: true},
Action: "destroy",
Scope: "team",
Status: "warning",
Metadata: []byte("{}"),
})
}
// --- Snapshot events (scope: team) ---
func (l *AuditLogger) LogSnapshotCreate(ctx context.Context, ac auth.AuthContext, name string) {
actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: ac.TeamID,
ActorType: actorType,
ActorID: actorID,
ActorName: actorName,
ResourceType: "snapshot",
ResourceID: pgtype.Text{String: name, Valid: true},
Action: "create",
Scope: "team",
Status: "success",
Metadata: []byte("{}"),
})
}
func (l *AuditLogger) LogSnapshotDelete(ctx context.Context, ac auth.AuthContext, name string) {
actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: ac.TeamID,
ActorType: actorType,
ActorID: actorID,
ActorName: actorName,
ResourceType: "snapshot",
ResourceID: pgtype.Text{String: name, Valid: true},
Action: "delete",
Scope: "team",
Status: "warning",
Metadata: []byte("{}"),
})
}
// --- Team events (scope: team) ---
func (l *AuditLogger) LogTeamRename(ctx context.Context, ac auth.AuthContext, teamID, oldName, newName string) {
actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: ac.TeamID,
ActorType: actorType,
ActorID: actorID,
ActorName: actorName,
ResourceType: "team",
ResourceID: pgtype.Text{String: teamID, Valid: true},
Action: "rename",
Scope: "team",
Status: "info",
Metadata: marshalMeta(map[string]any{"old_name": oldName, "new_name": newName}),
})
}
// --- API key events (scope: team) ---
func (l *AuditLogger) LogAPIKeyCreate(ctx context.Context, ac auth.AuthContext, keyID, keyName string) {
actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: ac.TeamID,
ActorType: actorType,
ActorID: actorID,
ActorName: actorName,
ResourceType: "api_key",
ResourceID: pgtype.Text{String: keyID, Valid: true},
Action: "create",
Scope: "team",
Status: "success",
Metadata: marshalMeta(map[string]any{"name": keyName}),
})
}
func (l *AuditLogger) LogAPIKeyRevoke(ctx context.Context, ac auth.AuthContext, keyID string) {
actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: ac.TeamID,
ActorType: actorType,
ActorID: actorID,
ActorName: actorName,
ResourceType: "api_key",
ResourceID: pgtype.Text{String: keyID, Valid: true},
Action: "revoke",
Scope: "team",
Status: "warning",
Metadata: []byte("{}"),
})
}
// --- Member events (scope: admin) ---
func (l *AuditLogger) LogMemberAdd(ctx context.Context, ac auth.AuthContext, targetUserID, targetEmail, role string) {
actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: ac.TeamID,
ActorType: actorType,
ActorID: actorID,
ActorName: actorName,
ResourceType: "member",
ResourceID: pgtype.Text{String: targetUserID, Valid: true},
Action: "add",
Scope: "admin",
Status: "success",
Metadata: marshalMeta(map[string]any{"email": targetEmail, "role": role}),
})
}
func (l *AuditLogger) LogMemberRemove(ctx context.Context, ac auth.AuthContext, targetUserID string) {
actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: ac.TeamID,
ActorType: actorType,
ActorID: actorID,
ActorName: actorName,
ResourceType: "member",
ResourceID: pgtype.Text{String: targetUserID, Valid: true},
Action: "remove",
Scope: "admin",
Status: "warning",
Metadata: []byte("{}"),
})
}
func (l *AuditLogger) LogMemberLeave(ctx context.Context, ac auth.AuthContext) {
actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: ac.TeamID,
ActorType: actorType,
ActorID: actorID,
ActorName: actorName,
ResourceType: "member",
ResourceID: pgtype.Text{String: ac.UserID, Valid: ac.UserID != ""},
Action: "leave",
Scope: "admin",
Status: "info",
Metadata: []byte("{}"),
})
}
func (l *AuditLogger) LogMemberRoleUpdate(ctx context.Context, ac auth.AuthContext, targetUserID, newRole string) {
actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: ac.TeamID,
ActorType: actorType,
ActorID: actorID,
ActorName: actorName,
ResourceType: "member",
ResourceID: pgtype.Text{String: targetUserID, Valid: true},
Action: "role_update",
Scope: "admin",
Status: "info",
Metadata: marshalMeta(map[string]any{"new_role": newRole}),
})
}
// --- Host events (scope: admin) ---
func (l *AuditLogger) LogHostCreate(ctx context.Context, ac auth.AuthContext, hostID, teamID string) {
actorType, actorID, actorName := actorFields(ac)
// For shared hosts with no owning team, use the caller's team.
logTeamID := teamID
if logTeamID == "" {
logTeamID = ac.TeamID
}
if logTeamID == "" {
return
}
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: logTeamID,
ActorType: actorType,
ActorID: actorID,
ActorName: actorName,
ResourceType: "host",
ResourceID: pgtype.Text{String: hostID, Valid: true},
Action: "create",
Scope: "admin",
Status: "success",
Metadata: []byte("{}"),
})
}
func (l *AuditLogger) LogHostDelete(ctx context.Context, ac auth.AuthContext, hostID, teamID string) {
actorType, actorID, actorName := actorFields(ac)
logTeamID := teamID
if logTeamID == "" {
logTeamID = ac.TeamID
}
if logTeamID == "" {
return
}
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: logTeamID,
ActorType: actorType,
ActorID: actorID,
ActorName: actorName,
ResourceType: "host",
ResourceID: pgtype.Text{String: hostID, Valid: true},
Action: "delete",
Scope: "admin",
Status: "warning",
Metadata: []byte("{}"),
})
}
// LogHostMarkedDown records a system-initiated host status transition to unreachable.
// teamID must be non-empty (BYOC hosts only); shared hosts are not logged.
func (l *AuditLogger) LogHostMarkedDown(ctx context.Context, teamID, hostID string) {
if teamID == "" {
return
}
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: teamID,
ActorType: "system",
ActorID: pgtype.Text{},
ActorName: pgtype.Text{},
ResourceType: "host",
ResourceID: pgtype.Text{String: hostID, Valid: true},
Action: "marked_down",
Scope: "admin",
Status: "error",
Metadata: []byte("{}"),
})
}
// LogHostMarkedUp records a system-initiated host status transition back to online.
// teamID must be non-empty (BYOC hosts only); shared hosts are not logged.
func (l *AuditLogger) LogHostMarkedUp(ctx context.Context, teamID, hostID string) {
if teamID == "" {
return
}
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: teamID,
ActorType: "system",
ActorID: pgtype.Text{},
ActorName: pgtype.Text{},
ResourceType: "host",
ResourceID: pgtype.Text{String: hostID, Valid: true},
Action: "marked_up",
Scope: "admin",
Status: "success",
Metadata: []byte("{}"),
})
}

View File

@ -14,6 +14,8 @@ type AuthContext struct {
Name string // empty when authenticated via API key
Role string // owner, admin, or member; empty when authenticated via API key
IsAdmin bool // platform-level admin; always false when authenticated via API key
APIKeyID string // populated when authenticated via API key; empty for JWT auth
APIKeyName string // display name of the key, snapshotted at auth time; empty for JWT auth
}
// WithAuthContext returns a new context with the given AuthContext.

111
internal/db/audit.sql.go Normal file
View File

@ -0,0 +1,111 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: audit.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const insertAuditLog = `-- name: InsertAuditLog :exec
INSERT INTO audit_logs (id, team_id, actor_type, actor_id, actor_name, resource_type, resource_id, action, scope, status, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`
type InsertAuditLogParams struct {
ID string `json:"id"`
TeamID string `json:"team_id"`
ActorType string `json:"actor_type"`
ActorID pgtype.Text `json:"actor_id"`
ActorName pgtype.Text `json:"actor_name"`
ResourceType string `json:"resource_type"`
ResourceID pgtype.Text `json:"resource_id"`
Action string `json:"action"`
Scope string `json:"scope"`
Status string `json:"status"`
Metadata []byte `json:"metadata"`
}
func (q *Queries) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) error {
_, err := q.db.Exec(ctx, insertAuditLog,
arg.ID,
arg.TeamID,
arg.ActorType,
arg.ActorID,
arg.ActorName,
arg.ResourceType,
arg.ResourceID,
arg.Action,
arg.Scope,
arg.Status,
arg.Metadata,
)
return err
}
const listAuditLogs = `-- name: ListAuditLogs :many
SELECT id, team_id, actor_type, actor_id, actor_name, resource_type, resource_id, action, scope, status, metadata, created_at FROM audit_logs
WHERE team_id = $1
AND scope = ANY($2::text[])
AND (cardinality($3::text[]) = 0 OR resource_type = ANY($3::text[]))
AND (cardinality($4::text[]) = 0 OR action = ANY($4::text[]))
AND ($5::timestamptz IS NULL OR created_at < $5
OR (created_at = $5 AND id < $6))
ORDER BY created_at DESC, id DESC
LIMIT $7
`
type ListAuditLogsParams struct {
TeamID string `json:"team_id"`
Column2 []string `json:"column_2"`
Column3 []string `json:"column_3"`
Column4 []string `json:"column_4"`
Column5 pgtype.Timestamptz `json:"column_5"`
ID string `json:"id"`
Limit int32 `json:"limit"`
}
func (q *Queries) ListAuditLogs(ctx context.Context, arg ListAuditLogsParams) ([]AuditLog, error) {
rows, err := q.db.Query(ctx, listAuditLogs,
arg.TeamID,
arg.Column2,
arg.Column3,
arg.Column4,
arg.Column5,
arg.ID,
arg.Limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []AuditLog
for rows.Next() {
var i AuditLog
if err := rows.Scan(
&i.ID,
&i.TeamID,
&i.ActorType,
&i.ActorID,
&i.ActorName,
&i.ResourceType,
&i.ResourceID,
&i.Action,
&i.Scope,
&i.Status,
&i.Metadata,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@ -583,10 +583,13 @@ WHERE id = $1
`
// Updates last_heartbeat_at and transitions unreachable hosts back to online.
// Returns 0 if no host was found (deleted).
// Returns 0 if no host was found (deleted), which the caller treats as 404.
func (q *Queries) UpdateHostHeartbeatAndStatus(ctx context.Context, id string) (int64, error) {
result, err := q.db.Exec(ctx, updateHostHeartbeatAndStatus, id)
return result.RowsAffected(), err
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const updateHostStatus = `-- name: UpdateHostStatus :exec

View File

@ -15,6 +15,21 @@ type AdminPermission struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type AuditLog struct {
ID string `json:"id"`
TeamID string `json:"team_id"`
ActorType string `json:"actor_type"`
ActorID pgtype.Text `json:"actor_id"`
ActorName pgtype.Text `json:"actor_name"`
ResourceType string `json:"resource_type"`
ResourceID pgtype.Text `json:"resource_id"`
Action string `json:"action"`
Scope string `json:"scope"`
Status string `json:"status"`
Metadata []byte `json:"metadata"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type Host struct {
ID string `json:"id"`
Type string `json:"type"`

View File

@ -73,6 +73,11 @@ func NewRefreshTokenID() string {
return "hrt-" + hex8()
}
// NewAuditLogID generates a new audit log ID in the format "log-" + 8 hex chars.
func NewAuditLogID() string {
return "log-" + hex8()
}
// NewRefreshToken generates a 64-char hex token (32 bytes of entropy) for use as a host refresh token.
func NewRefreshToken() string {
b := make([]byte, 32)

112
internal/service/audit.go Normal file
View File

@ -0,0 +1,112 @@
package service
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/db"
)
const auditMaxLimit = 200
// AuditEntry is a single audit log record returned by List.
type AuditEntry struct {
ID string
TeamID string
ActorType string
ActorID string // empty for system
ActorName string // empty for system
ResourceType string
ResourceID string // empty when not applicable
Action string
Scope string
Status string // 'success', 'info', 'warning', 'error'
Metadata map[string]any
CreatedAt time.Time
}
// AuditListParams controls the ListAuditLogs query.
type AuditListParams struct {
TeamID string
AdminScoped bool // true → include admin-scoped events; false → team-scoped only
ResourceTypes []string // empty = no filter; multiple values = OR match
Actions []string // empty = no filter; multiple values = OR match
Before time.Time // zero = no cursor (start from latest)
BeforeID string // tie-breaker: id of the last item at the Before timestamp; empty = no tie-break
Limit int // clamped to auditMaxLimit by the handler
}
// AuditService provides the read side of the audit log.
type AuditService struct {
DB *db.Queries
}
// List returns a page of audit log entries for the given team.
func (s *AuditService) List(ctx context.Context, p AuditListParams) ([]AuditEntry, error) {
limit := p.Limit
if limit <= 0 {
limit = 50
}
if limit > auditMaxLimit {
limit = auditMaxLimit
}
scopes := []string{"team"}
if p.AdminScoped {
scopes = append(scopes, "admin")
}
var before pgtype.Timestamptz
if !p.Before.IsZero() {
before = pgtype.Timestamptz{Time: p.Before, Valid: true}
}
resourceTypes := p.ResourceTypes
if resourceTypes == nil {
resourceTypes = []string{}
}
actions := p.Actions
if actions == nil {
actions = []string{}
}
rows, err := s.DB.ListAuditLogs(ctx, db.ListAuditLogsParams{
TeamID: p.TeamID,
Column2: scopes,
Column3: resourceTypes,
Column4: actions,
Column5: before,
ID: p.BeforeID,
Limit: int32(limit),
})
if err != nil {
return nil, fmt.Errorf("list audit logs: %w", err)
}
entries := make([]AuditEntry, len(rows))
for i, row := range rows {
var meta map[string]any
if len(row.Metadata) > 0 {
_ = json.Unmarshal(row.Metadata, &meta)
}
entries[i] = AuditEntry{
ID: row.ID,
TeamID: row.TeamID,
ActorType: row.ActorType,
ActorID: row.ActorID.String,
ActorName: row.ActorName.String,
ResourceType: row.ResourceType,
ResourceID: row.ResourceID.String,
Action: row.Action,
Scope: row.Scope,
Status: row.Status,
Metadata: meta,
CreatedAt: row.CreatedAt.Time,
}
}
return entries, nil
}