From 7fd801c1ebc3c9b47e7fb95fec08317865f0d338 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Tue, 21 Apr 2026 15:41:45 +0600 Subject: [PATCH] feat: add audit logging for all admin actions and admin audit page Log every admin-panel action (user activate/deactivate, team BYOC toggle, team delete, template delete, build create/cancel) to the audit_logs table under PlatformTeamID with scope "admin". Add GET /v1/admin/audit-logs endpoint and /admin/audit frontend page with infinite scroll and hierarchical filters. Expose audit.Entry + Log() for cloud repo extensibility. Fix seed_platform_team down-migration FK violation by deleting dependent rows before the team row. --- .../20260412213141_seed_platform_team.sql | 6 + frontend/src/lib/api/admin-audit.ts | 21 + .../src/lib/components/AdminSidebar.svelte | 6 +- frontend/src/routes/admin/audit/+page.svelte | 600 ++++++++++++++++++ internal/api/handlers_audit.go | 109 ++-- internal/api/handlers_builds.go | 19 +- internal/api/handlers_team.go | 4 + internal/api/handlers_users.go | 22 +- internal/api/server.go | 7 +- pkg/audit/logger.go | 174 +++++ 10 files changed, 917 insertions(+), 51 deletions(-) create mode 100644 frontend/src/lib/api/admin-audit.ts create mode 100644 frontend/src/routes/admin/audit/+page.svelte diff --git a/db/migrations/20260412213141_seed_platform_team.sql b/db/migrations/20260412213141_seed_platform_team.sql index 751a0cc..8b41ec6 100644 --- a/db/migrations/20260412213141_seed_platform_team.sql +++ b/db/migrations/20260412213141_seed_platform_team.sql @@ -9,4 +9,10 @@ VALUES ('00000000-0000-0000-0000-000000000000', 'Platform', 'platform') ON CONFLICT (id) DO NOTHING; -- +goose Down +-- Delete dependent rows that reference the platform team via foreign keys. +-- Order matters: children before parent. +DELETE FROM sandboxes WHERE team_id = '00000000-0000-0000-0000-000000000000'; +DELETE FROM team_api_keys WHERE team_id = '00000000-0000-0000-0000-000000000000'; +DELETE FROM users_teams WHERE team_id = '00000000-0000-0000-0000-000000000000'; +DELETE FROM hosts WHERE team_id = '00000000-0000-0000-0000-000000000000'; DELETE FROM teams WHERE id = '00000000-0000-0000-0000-000000000000'; diff --git a/frontend/src/lib/api/admin-audit.ts b/frontend/src/lib/api/admin-audit.ts new file mode 100644 index 0000000..4a038ea --- /dev/null +++ b/frontend/src/lib/api/admin-audit.ts @@ -0,0 +1,21 @@ +import { apiFetch, type ApiResult } from '$lib/api/client'; +import type { AuditLog, AuditListResponse } from '$lib/api/audit'; + +export type { AuditLog, AuditListResponse }; + +export async function listAdminAuditLogs(params?: { + before?: string; + before_id?: string; + resource_types?: string[]; + actions?: string[]; + limit?: number; +}): Promise> { + const q = new URLSearchParams(); + if (params?.before) q.set('before', params.before); + if (params?.before_id) q.set('before_id', params.before_id); + params?.resource_types?.forEach((t) => q.append('resource_type', t)); + params?.actions?.forEach((a) => q.append('action', a)); + if (params?.limit != null) q.set('limit', String(params.limit)); + const qs = q.toString(); + return apiFetch('GET', `/api/v1/admin/audit-logs${qs ? '?' + qs : ''}`); +} diff --git a/frontend/src/lib/components/AdminSidebar.svelte b/frontend/src/lib/components/AdminSidebar.svelte index e7421b0..ee60c9c 100644 --- a/frontend/src/lib/components/AdminSidebar.svelte +++ b/frontend/src/lib/components/AdminSidebar.svelte @@ -13,7 +13,8 @@ IconChevron, IconShield, IconMembers, - IconUser + IconUser, + IconAudit } from './icons'; let { collapsed = $bindable(false) }: { collapsed: boolean } = $props(); @@ -26,7 +27,8 @@ const managementItems: NavItem[] = [ { label: 'Users', icon: IconUser, href: '/admin/users' }, - { label: 'Teams', icon: IconMembers, href: '/admin/teams' } + { label: 'Teams', icon: IconMembers, href: '/admin/teams' }, + { label: 'Audit', icon: IconAudit, href: '/admin/audit' } ]; const platformItems: NavItem[] = [ diff --git a/frontend/src/routes/admin/audit/+page.svelte b/frontend/src/routes/admin/audit/+page.svelte new file mode 100644 index 0000000..81e1524 --- /dev/null +++ b/frontend/src/routes/admin/audit/+page.svelte @@ -0,0 +1,600 @@ + + + + Wrenn Admin — Audit Logs + + +
+ + +
+

+ Audit Logs +

+

+ Platform-wide activity log for all admin actions. +

+
+
+ + +
+
+ + +
+ + + {#if filterDropdownOpen} +
+ {#each RESOURCES as r} + {@const rState = getResourceCheckState(r)} + {@const actions = ACTIONS_BY_RESOURCE[r]} + + + + + + {#each actions as a} + {@const checked = selectedActions.get(r)?.has(a) ?? false} + + {/each} + + + {#if r !== RESOURCES[RESOURCES.length - 1]} +
+ {/if} + {/each} +
+ {/if} +
+ +
+ + + {#if activeFilterCount > 0} +
+ {#each RESOURCES as r} + {#if (selectedActions.get(r)?.size ?? 0) > 0} + + {tagLabel(r)} + + + {/if} + {/each} + +
+ {/if} +
+ + +
+ + {#if error} +
+ {error} + +
+ {/if} + + {#if loading} +
+
+ + + + Loading events... +
+
+ {:else if logs.length === 0} + +
+
+
+
+ + + +
+
+

+ {activeFilterCount > 0 ? 'No matching events' : 'No activity yet'} +

+

+ {activeFilterCount > 0 + ? 'Try adjusting or clearing the filters.' + : 'Admin events will appear here as actions are taken.'} +

+ {#if activeFilterCount > 0} + + {/if} +
+ {:else} + +
+ + +
+
Time
+
Actor
+
Event
+
+ + + {#each logs as log, i (log.id)} + {@const ts = formatEventDate(log.created_at)} +
+ +
+ + +
+ +
+ {ts.date} + {ts.time} +
+ + +
+
+ + {@html renderDeleted(actorLabel(log))} + + {#if log.actor_type === 'api_key'} + key + {/if} +
+
+ + +
+

{@html renderDeleted(describeEvent(log))}

+ {#if log.resource_id} + {log.resource_id} + {/if} +
+
+
+ {/each} +
+ + +
+ {#if loadingMore} +
+ + + + Loading more... +
+ {:else if !hasMore} +

+ {logs.length} {logs.length === 1 ? 'event' : 'events'} total +

+ {/if} +
+ {/if} + +
+
+ + diff --git a/internal/api/handlers_audit.go b/internal/api/handlers_audit.go index feaebb7..436b6fb 100644 --- a/internal/api/handlers_audit.go +++ b/internal/api/handlers_audit.go @@ -35,64 +35,38 @@ type auditLogResponse struct { 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()) +// parseAuditParams extracts common query parameters for audit log listing. +func parseAuditParams(r *http.Request) (before time.Time, beforeID pgtype.UUID, limit int, err error) { + limit = 50 - // 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") + n, parseErr := strconv.Atoi(s) + if parseErr != nil || n < 1 { + err = parseErr return } limit = n } - // Parse ?before_id cursor (UUID). - var beforeID pgtype.UUID if s := r.URL.Query().Get("before_id"); s != "" { - parsed, err := id.ParseAuditLogID(s) + beforeID, err = id.ParseAuditLogID(s) if err != nil { - writeError(w, http.StatusBadRequest, "invalid_request", "before_id must be a valid audit log ID") return } - beforeID = parsed } - 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: beforeID, - Limit: limit, - }) - if err != nil { - writeError(w, http.StatusInternalServerError, "db_error", "failed to list audit logs") - return - } + return +} +// writeAuditResponse serializes audit entries into a paginated JSON response. +func writeAuditResponse(w http.ResponseWriter, entries []service.AuditEntry) { items := make([]auditLogResponse, len(entries)) for i, e := range entries { items[i] = auditLogResponse{ @@ -120,6 +94,67 @@ func (h *auditHandler) List(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } +// 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()) + + before, beforeID, limit, err := parseAuditParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid query parameters") + return + } + + 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: beforeID, + Limit: limit, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to list audit logs") + return + } + + writeAuditResponse(w, entries) +} + +// AdminList handles GET /v1/admin/audit-logs. +// Returns audit logs for the platform team (team 0) with both team and admin scopes. +// Uses the same query params as List. +func (h *auditHandler) AdminList(w http.ResponseWriter, r *http.Request) { + before, beforeID, limit, err := parseAuditParams(r) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid query parameters") + return + } + + entries, err := h.svc.List(r.Context(), service.AuditListParams{ + TeamID: id.PlatformTeamID, + AdminScoped: true, + ResourceTypes: parseMultiParam(r.URL.Query()["resource_type"]), + Actions: parseMultiParam(r.URL.Query()["action"]), + Before: before, + BeforeID: beforeID, + Limit: limit, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to list audit logs") + return + } + + writeAuditResponse(w, entries) +} + // 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. diff --git a/internal/api/handlers_builds.go b/internal/api/handlers_builds.go index 5228420..7dbbb86 100644 --- a/internal/api/handlers_builds.go +++ b/internal/api/handlers_builds.go @@ -13,6 +13,8 @@ import ( "github.com/go-chi/chi/v5" "git.omukk.dev/wrenn/wrenn/internal/layout" + "git.omukk.dev/wrenn/wrenn/pkg/audit" + "git.omukk.dev/wrenn/wrenn/pkg/auth" "git.omukk.dev/wrenn/wrenn/pkg/db" "git.omukk.dev/wrenn/wrenn/pkg/id" "git.omukk.dev/wrenn/wrenn/pkg/lifecycle" @@ -22,13 +24,14 @@ import ( ) type buildHandler struct { - svc *service.BuildService - db *db.Queries - pool *lifecycle.HostClientPool + svc *service.BuildService + db *db.Queries + pool *lifecycle.HostClientPool + audit *audit.AuditLogger } -func newBuildHandler(svc *service.BuildService, db *db.Queries, pool *lifecycle.HostClientPool) *buildHandler { - return &buildHandler{svc: svc, db: db, pool: pool} +func newBuildHandler(svc *service.BuildService, db *db.Queries, pool *lifecycle.HostClientPool, al *audit.AuditLogger) *buildHandler { + return &buildHandler{svc: svc, db: db, pool: pool, audit: al} } type createBuildRequest struct { @@ -187,6 +190,8 @@ func (h *buildHandler) Create(w http.ResponseWriter, r *http.Request) { return } + ac := auth.MustFromContext(r.Context()) + h.audit.LogBuildCreate(r.Context(), ac, build.ID, req.Name) writeJSON(w, http.StatusCreated, buildToResponse(build)) } @@ -305,6 +310,8 @@ func (h *buildHandler) DeleteTemplate(w http.ResponseWriter, r *http.Request) { return } + ac := auth.MustFromContext(r.Context()) + h.audit.LogTemplateDelete(r.Context(), ac, name) w.WriteHeader(http.StatusNoContent) } @@ -323,5 +330,7 @@ func (h *buildHandler) Cancel(w http.ResponseWriter, r *http.Request) { return } + ac := auth.MustFromContext(r.Context()) + h.audit.LogBuildCancel(r.Context(), ac, buildID) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/api/handlers_team.go b/internal/api/handlers_team.go index bfbe76c..b9dfe7b 100644 --- a/internal/api/handlers_team.go +++ b/internal/api/handlers_team.go @@ -392,6 +392,7 @@ func (h *teamHandler) Leave(w http.ResponseWriter, r *http.Request) { // SetBYOC handles PUT /v1/admin/teams/{id}/byoc (admin only). // Enables or disables the BYOC feature flag for a team. func (h *teamHandler) SetBYOC(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) teamIDStr := chi.URLParam(r, "id") teamID, err := id.ParseTeamID(teamIDStr) @@ -414,6 +415,7 @@ func (h *teamHandler) SetBYOC(w http.ResponseWriter, r *http.Request) { return } + h.audit.LogTeamSetBYOC(r.Context(), ac, teamID, req.Enabled) w.WriteHeader(http.StatusNoContent) } @@ -484,6 +486,7 @@ func (h *teamHandler) AdminListTeams(w http.ResponseWriter, r *http.Request) { // AdminDeleteTeam handles DELETE /v1/admin/teams/{id} // Soft-deletes a team and destroys all its active sandboxes. func (h *teamHandler) AdminDeleteTeam(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) teamIDStr := chi.URLParam(r, "id") teamID, err := id.ParseTeamID(teamIDStr) @@ -498,5 +501,6 @@ func (h *teamHandler) AdminDeleteTeam(w http.ResponseWriter, r *http.Request) { return } + h.audit.LogTeamDelete(r.Context(), ac, teamID) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/api/handlers_users.go b/internal/api/handlers_users.go index f8a8b67..1a82653 100644 --- a/internal/api/handlers_users.go +++ b/internal/api/handlers_users.go @@ -9,6 +9,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" + "git.omukk.dev/wrenn/wrenn/pkg/audit" "git.omukk.dev/wrenn/wrenn/pkg/auth" "git.omukk.dev/wrenn/wrenn/pkg/db" "git.omukk.dev/wrenn/wrenn/pkg/id" @@ -16,12 +17,13 @@ import ( ) type usersHandler struct { - db *db.Queries - svc *service.UserService + db *db.Queries + svc *service.UserService + audit *audit.AuditLogger } -func newUsersHandler(db *db.Queries, svc *service.UserService) *usersHandler { - return &usersHandler{db: db, svc: svc} +func newUsersHandler(db *db.Queries, svc *service.UserService, al *audit.AuditLogger) *usersHandler { + return &usersHandler{db: db, svc: svc, audit: al} } // Search handles GET /v1/users/search?email= @@ -140,11 +142,23 @@ func (h *usersHandler) SetUserActive(w http.ResponseWriter, r *http.Request) { newStatus = "disabled" } + // Look up user email for audit log before changing status. + user, err := h.db.GetUserByID(r.Context(), userID) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "user not found") + return + } + if err := h.svc.SetUserStatus(r.Context(), userID, newStatus); err != nil { httpStatus, code, msg := serviceErrToHTTP(err) writeError(w, httpStatus, code, msg) return } + if req.Active { + h.audit.LogUserActivate(r.Context(), ac, userID, user.Email) + } else { + h.audit.LogUserDeactivate(r.Context(), ac, userID, user.Email) + } w.WriteHeader(http.StatusNoContent) } diff --git a/internal/api/server.go b/internal/api/server.go index 25d1e2f..ced39a5 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -32,7 +32,7 @@ type Server struct { } // New constructs the chi router and registers all routes. -// Extensions are called after core routes are registered, allowing enterprise +// Extensions are called after core routes are registered, allowing cloud // or third-party code to add routes and middleware. func New( queries *db.Queries, @@ -85,12 +85,12 @@ func New( apiKeys := newAPIKeyHandler(apiKeySvc, al) hostH := newHostHandler(hostSvc, queries, al) teamH := newTeamHandler(teamSvc, al, mailer) - usersH := newUsersHandler(queries, userSvc) + usersH := newUsersHandler(queries, userSvc, al) auditH := newAuditHandler(auditSvc) statsH := newStatsHandler(statsSvc) usageH := newUsageHandler(usageSvc) metricsH := newSandboxMetricsHandler(queries, pool) - buildH := newBuildHandler(buildSvc, queries, pool) + buildH := newBuildHandler(buildSvc, queries, pool, al) channelH := newChannelHandler(channelSvc, al) ptyH := newPtyHandler(queries, pool, jwtSecret) processH := newProcessHandler(queries, pool, jwtSecret) @@ -255,6 +255,7 @@ func New( r.Delete("/teams/{id}", teamH.AdminDeleteTeam) r.Get("/users", usersH.AdminListUsers) r.Put("/users/{id}/active", usersH.SetUserActive) + r.Get("/audit-logs", auditH.AdminList) r.Get("/templates", buildH.ListTemplates) r.Delete("/templates/{name}", buildH.DeleteTemplate) r.Post("/builds", buildH.Create) diff --git a/pkg/audit/logger.go b/pkg/audit/logger.go index 66c217e..08f86e6 100644 --- a/pkg/audit/logger.go +++ b/pkg/audit/logger.go @@ -82,6 +82,53 @@ func marshalMeta(meta map[string]any) []byte { return b } +// Entry describes a single audit log event. Extensions (e.g. the cloud repo) +// use this with AuditLogger.Log to record custom events without modifying the +// OSS typed methods. +type Entry struct { + TeamID pgtype.UUID + ActorType string // "user", "api_key", "system" + ActorID string // prefixed ID string; empty for system + ActorName string // human-readable; empty for system + ResourceType string + ResourceID string // prefixed ID or name; empty when not applicable + Action string + Scope string // "team" or "admin" + Status string // "success", "info", "warning", "error" + Metadata map[string]any +} + +// Log writes a custom audit log entry. This is the extension point for the +// cloud repo to record events with resource types and actions not covered by +// the typed helpers (LogSandboxCreate, etc.). Fire-and-forget like all other +// audit methods. +func (l *AuditLogger) Log(ctx context.Context, e Entry) { + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: e.TeamID, + ActorType: e.ActorType, + ActorID: optText(e.ActorID), + ActorName: e.ActorName, + ResourceType: e.ResourceType, + ResourceID: optText(e.ResourceID), + Action: e.Action, + Scope: e.Scope, + Status: e.Status, + Metadata: MarshalMeta(e.Metadata), + }) +} + +// ActorFromContext extracts actor fields from an auth.AuthContext for use in +// custom audit entries. Returns actor_type, actor_id, and actor_name. +func ActorFromContext(ac auth.AuthContext) (actorType, actorID, actorName string) { + return actorFields(ac) +} + +// MarshalMeta serializes metadata to JSON bytes. Returns "{}" for nil/empty maps. +func MarshalMeta(meta map[string]any) []byte { + return marshalMeta(meta) +} + // optText returns a valid pgtype.Text if s is non-empty, otherwise an invalid (NULL) one. func optText(s string) pgtype.Text { if s == "" { @@ -507,6 +554,133 @@ func (l *AuditLogger) LogHostDelete(ctx context.Context, ac auth.AuthContext, ho }) } +// --- User events (scope: admin) --- + +func (l *AuditLogger) LogUserActivate(ctx context.Context, ac auth.AuthContext, userID pgtype.UUID, email string) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: id.PlatformTeamID, + ActorType: actorType, + ActorID: optText(actorID), + ActorName: actorName, + ResourceType: "user", + ResourceID: optText(id.FormatUserID(userID)), + Action: "activate", + Scope: "admin", + Status: "success", + Metadata: marshalMeta(map[string]any{"email": email}), + }) +} + +func (l *AuditLogger) LogUserDeactivate(ctx context.Context, ac auth.AuthContext, userID pgtype.UUID, email string) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: id.PlatformTeamID, + ActorType: actorType, + ActorID: optText(actorID), + ActorName: actorName, + ResourceType: "user", + ResourceID: optText(id.FormatUserID(userID)), + Action: "deactivate", + Scope: "admin", + Status: "warning", + Metadata: marshalMeta(map[string]any{"email": email}), + }) +} + +// --- Team admin events (scope: admin) --- + +func (l *AuditLogger) LogTeamSetBYOC(ctx context.Context, ac auth.AuthContext, teamID pgtype.UUID, enabled bool) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: id.PlatformTeamID, + ActorType: actorType, + ActorID: optText(actorID), + ActorName: actorName, + ResourceType: "team", + ResourceID: optText(id.FormatTeamID(teamID)), + Action: "set_byoc", + Scope: "admin", + Status: "info", + Metadata: marshalMeta(map[string]any{"enabled": enabled}), + }) +} + +func (l *AuditLogger) LogTeamDelete(ctx context.Context, ac auth.AuthContext, teamID pgtype.UUID) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: id.PlatformTeamID, + ActorType: actorType, + ActorID: optText(actorID), + ActorName: actorName, + ResourceType: "team", + ResourceID: optText(id.FormatTeamID(teamID)), + Action: "delete", + Scope: "admin", + Status: "warning", + Metadata: []byte("{}"), + }) +} + +// --- Template events (scope: admin) --- + +func (l *AuditLogger) LogTemplateDelete(ctx context.Context, ac auth.AuthContext, name string) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: id.PlatformTeamID, + ActorType: actorType, + ActorID: optText(actorID), + ActorName: actorName, + ResourceType: "template", + ResourceID: optText(name), + Action: "delete", + Scope: "admin", + Status: "warning", + Metadata: []byte("{}"), + }) +} + +// --- Build events (scope: admin) --- + +func (l *AuditLogger) LogBuildCreate(ctx context.Context, ac auth.AuthContext, buildID pgtype.UUID, name string) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: id.PlatformTeamID, + ActorType: actorType, + ActorID: optText(actorID), + ActorName: actorName, + ResourceType: "build", + ResourceID: optText(id.FormatBuildID(buildID)), + Action: "create", + Scope: "admin", + Status: "success", + Metadata: marshalMeta(map[string]any{"name": name}), + }) +} + +func (l *AuditLogger) LogBuildCancel(ctx context.Context, ac auth.AuthContext, buildID pgtype.UUID) { + actorType, actorID, actorName := actorFields(ac) + l.write(ctx, db.InsertAuditLogParams{ + ID: id.NewAuditLogID(), + TeamID: id.PlatformTeamID, + ActorType: actorType, + ActorID: optText(actorID), + ActorName: actorName, + ResourceType: "build", + ResourceID: optText(id.FormatBuildID(buildID)), + Action: "cancel", + Scope: "admin", + Status: "warning", + Metadata: []byte("{}"), + }) +} + // LogHostMarkedDown records a system-initiated host status transition to unreachable. // Scoped to "team" so BYOC team members can see when their hosts go down. func (l *AuditLogger) LogHostMarkedDown(ctx context.Context, teamID, hostID pgtype.UUID) {