diff --git a/VERSION_CP b/VERSION_CP index d917d3e..b1e80bb 100644 --- a/VERSION_CP +++ b/VERSION_CP @@ -1 +1 @@ -0.1.2 +0.1.3 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/db/migrations/embed.go b/db/migrations/embed.go index d5bbfcb..8fd901b 100644 --- a/db/migrations/embed.go +++ b/db/migrations/embed.go @@ -1,5 +1,5 @@ // Package migrations embeds the SQL migration files so that external modules -// (such as the enterprise edition) can access them programmatically. +// (such as the cloud edition) can access them programmatically. package migrations import "embed" diff --git a/db/queries/audit.sql b/db/queries/audit.sql index 9250db7..d1c6b55 100644 --- a/db/queries/audit.sql +++ b/db/queries/audit.sql @@ -2,6 +2,15 @@ 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: AnonymizeAuditLogsByUserID :exec +UPDATE audit_logs +SET actor_name = CASE WHEN actor_id = $1 THEN 'deleted-user' ELSE actor_name END, + actor_id = CASE WHEN actor_id = $1 THEN NULL ELSE actor_id END, + resource_id = CASE WHEN resource_type = 'member' AND resource_id = $1 THEN NULL ELSE resource_id END, + metadata = CASE WHEN resource_type = 'member' AND resource_id = $1 AND metadata ? 'email' THEN metadata - 'email' ELSE metadata END +WHERE actor_id = $1 + OR (resource_type = 'member' AND resource_id = $1); + -- name: ListAuditLogs :many SELECT * FROM audit_logs WHERE team_id = $1 diff --git a/db/queries/users.sql b/db/queries/users.sql index eb41d00..81d3fe2 100644 --- a/db/queries/users.sql +++ b/db/queries/users.sql @@ -91,8 +91,8 @@ WHERE ut.user_id = $1 WHERE ut2.team_id = ut.team_id AND ut2.user_id <> $1 ); --- name: HardDeleteExpiredUsers :exec -DELETE FROM users WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '15 days'; +-- name: ListExpiredSoftDeletedUsers :many +SELECT id, email FROM users WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '15 days'; -- name: HardDeleteUser :exec DELETE FROM users WHERE id = $1; 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/frontend/src/routes/admin/hosts/+page.svelte b/frontend/src/routes/admin/hosts/+page.svelte index 30b3ad5..ae7109b 100644 --- a/frontend/src/routes/admin/hosts/+page.svelte +++ b/frontend/src/routes/admin/hosts/+page.svelte @@ -454,12 +454,9 @@ onkeydown={(e) => { if (e.key === 'Escape' && !creating) showCreate = false; }} >
- -
-

Add Platform Host @@ -534,12 +531,9 @@
- -
-
@@ -607,12 +601,9 @@ onkeydown={(e) => { if (e.key === 'Escape' && !deleting) deleteTarget = null; }} >
- -
-

Delete Host diff --git a/frontend/src/routes/auth/github/callback/+page.svelte b/frontend/src/routes/auth/github/callback/+page.svelte index 6ede837..17409b3 100644 --- a/frontend/src/routes/auth/github/callback/+page.svelte +++ b/frontend/src/routes/auth/github/callback/+page.svelte @@ -1,12 +1,18 @@ -
-

Signing you in...

-
+{#if showConfirmDialog} +
+
+
+

Almost there

+

+ We pulled your details from GitHub. Change your display name if you'd like. +

+ +
+ +
+ +
+
+ +
+ +
+
+ + +
+ +
+
+ +
+ +
+
+
+ + {#if nameError} +

{nameError}

+ {/if} + + +
+ +
+
+
+
+{:else} +
+

Signing you in...

+
+{/if} diff --git a/frontend/src/routes/dashboard/audit/+page.svelte b/frontend/src/routes/dashboard/audit/+page.svelte index 82430c7..3ea8eb4 100644 --- a/frontend/src/routes/dashboard/audit/+page.svelte +++ b/frontend/src/routes/dashboard/audit/+page.svelte @@ -192,8 +192,15 @@ // ─── UI helpers ─────────────────────────────────────────────────────────── + const DELETED_BADGE = '\x00DELETED\x00'; + const deletedBadgeHtml = 'deleted-user'; + + function renderDeleted(text: string): string { + return text.replaceAll(DELETED_BADGE, deletedBadgeHtml); + } + function describeEvent(log: AuditLog): string { - const actor = log.actor_name || (log.actor_type === 'system' ? 'System' : 'Unknown'); + const actor = log.actor_name === 'deleted-user' ? DELETED_BADGE : (log.actor_name || (log.actor_type === 'system' ? 'System' : 'Unknown')); const meta = (log.metadata ?? {}) as Record; switch (`${log.resource_type}:${log.action}`) { case 'sandbox:create': return `${actor} created a capsule`; @@ -205,8 +212,8 @@ case 'team:rename': return `${actor} renamed the team from "${meta.old_name}" to "${meta.new_name}"`; case 'api_key:create': return `${actor} created API key "${meta.name}"`; case 'api_key:revoke': return `${actor} revoked an API key`; - case 'member:add': return `${actor} added ${meta.email} as ${meta.role}`; - case 'member:remove': return `${actor} removed ${meta.email ?? 'a member'}`; + case 'member:add': return `${actor} added ${meta.email ?? DELETED_BADGE} as ${meta.role}`; + case 'member:remove': return `${actor} removed ${meta.email ?? DELETED_BADGE}`; case 'member:leave': return `${actor} left the team`; case 'member:role_update': return `${actor} changed a member's role to ${meta.new_role}`; case 'host:create': return `${actor} registered a host`; @@ -219,6 +226,7 @@ function actorLabel(log: AuditLog): string { if (log.actor_type === 'system') return 'System'; + if (log.actor_name === 'deleted-user') return DELETED_BADGE; return log.actor_name ?? '—'; } @@ -498,7 +506,7 @@
- {actorLabel(log)} + {@html renderDeleted(actorLabel(log))} {#if log.actor_type === 'api_key'} key @@ -508,7 +516,7 @@
-

{describeEvent(log)}

+

{@html renderDeleted(describeEvent(log))}

{#if log.resource_id} {log.resource_id} {/if} @@ -567,4 +575,15 @@ .stripe-pulse { animation: stripePulse 2.5s ease-in-out infinite; } + + :global(.deleted-user-badge) { + display: inline; + padding: 1px 5px; + border-radius: 3px; + font-family: 'JetBrains Mono Variable', monospace; + font-size: var(--text-badge); + color: var(--color-red); + background: rgba(207, 129, 114, 0.12); + border: 1px solid rgba(207, 129, 114, 0.25); + } diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 18c55fa..afc00ab 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -29,6 +29,7 @@ access_denied: 'Access was denied by the provider', email_taken: 'An account with this email already exists', exchange_failed: 'Authentication failed — please try again', + no_account: 'No GitHub account connected — sign up instead', }; // Read OAuth error forwarded from /auth/github/callback @@ -259,7 +260,7 @@ diff --git a/internal/api/handlers_admin_capsules.go b/internal/api/handlers_admin_capsules.go index 13250e5..6a90d0b 100644 --- a/internal/api/handlers_admin_capsules.go +++ b/internal/api/handlers_admin_capsules.go @@ -55,6 +55,7 @@ func (h *adminCapsuleHandler) Create(w http.ResponseWriter, r *http.Request) { return } + ac.TeamID = id.PlatformTeamID h.audit.LogSandboxCreate(r.Context(), ac, sb.ID, sb.Template) writeJSON(w, http.StatusCreated, sandboxToResponse(sb)) } 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_oauth.go b/internal/api/handlers_oauth.go index 9c9a14a..bc1ae73 100644 --- a/internal/api/handlers_oauth.go +++ b/internal/api/handlers_oauth.go @@ -55,8 +55,14 @@ func (h *oauthHandler) Redirect(w http.ResponseWriter, r *http.Request) { return } - mac := computeHMAC(h.jwtSecret, state) - cookieVal := state + ":" + mac + // Persist intent (login|signup) in the state cookie so the callback can enforce it. + intent := r.URL.Query().Get("intent") + if intent != "signup" { + intent = "login" + } + + mac := computeHMAC(h.jwtSecret, state+":"+intent) + cookieVal := state + ":" + mac + ":" + intent http.SetCookie(w, &http.Cookie{ Name: "oauth_state", @@ -105,13 +111,17 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) { Secure: isSecure(r), }) - parts := strings.SplitN(stateCookie.Value, ":", 2) - if len(parts) != 2 { + parts := strings.SplitN(stateCookie.Value, ":", 3) + if len(parts) < 2 { redirectWithError(w, r, redirectBase, "invalid_state") return } nonce, expectedMAC := parts[0], parts[1] - if !hmac.Equal([]byte(computeHMAC(h.jwtSecret, nonce)), []byte(expectedMAC)) { + intent := "login" + if len(parts) == 3 && parts[2] == "signup" { + intent = "signup" + } + if !hmac.Equal([]byte(computeHMAC(h.jwtSecret, nonce+":"+intent)), []byte(expectedMAC)) { redirectWithError(w, r, redirectBase, "invalid_state") return } @@ -249,6 +259,12 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) { return } + // Block auto-registration when intent is login-only. + if intent == "login" { + redirectWithError(w, r, redirectBase, "no_account") + return + } + // New OAuth identity — check for email collision. existingUser, err := h.db.GetUserByEmail(ctx, email) if err == nil { @@ -365,6 +381,17 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) { return } + // Signal frontend that this is a new signup so it can show the name confirmation dialog. + http.SetCookie(w, &http.Cookie{ + Name: "wrenn_oauth_new_signup", + Value: "1", + Path: "/auth/", + MaxAge: 60, + HttpOnly: false, + SameSite: http.SameSiteLaxMode, + Secure: isSecure(r), + }) + redirectWithToken(w, r, redirectBase, token, id.FormatUserID(userID), id.FormatTeamID(teamID), email, profile.Name) } 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/openapi.yaml b/internal/api/openapi.yaml index 4cd6959..8d3861c 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -2,7 +2,7 @@ openapi: "3.1.0" info: title: Wrenn API description: MicroVM-based code execution platform API. - version: "0.1.2" + version: "0.1.3" servers: - url: http://localhost:8080 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 e101b3c..eb73d70 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 == "" { @@ -90,23 +137,42 @@ func optText(s string) pgtype.Text { return pgtype.Text{String: s, Valid: true} } +// --- Entry builders --- + +// newEntry builds an Entry from an auth context with explicit team and scope. +func newEntry(ac auth.AuthContext, teamID pgtype.UUID, scope, resourceType, resourceID, action, status string, meta map[string]any) Entry { + actorType, actorID, actorName := actorFields(ac) + return Entry{ + TeamID: teamID, + ActorType: actorType, + ActorID: actorID, + ActorName: actorName, + ResourceType: resourceType, + ResourceID: resourceID, + Action: action, + Scope: scope, + Status: status, + Metadata: meta, + } +} + +// newAdminEntry builds an Entry for platform-level admin actions (PlatformTeamID, scope "admin"). +func newAdminEntry(ac auth.AuthContext, resourceType, resourceID, action, status string, meta map[string]any) Entry { + return newEntry(ac, id.PlatformTeamID, "admin", resourceType, resourceID, action, status, meta) +} + +// resolveHostTeamID returns the owning team for BYOC hosts, or PlatformTeamID for shared hosts. +func resolveHostTeamID(teamID pgtype.UUID) pgtype.UUID { + if teamID.Valid { + return teamID + } + return id.PlatformTeamID +} + // --- Sandbox events (scope: team) --- func (l *AuditLogger) LogSandboxCreate(ctx context.Context, ac auth.AuthContext, sandboxID pgtype.UUID, template string) { - actorType, actorID, actorName := actorFields(ac) - l.write(ctx, db.InsertAuditLogParams{ - ID: id.NewAuditLogID(), - TeamID: ac.TeamID, - ActorType: actorType, - ActorID: optText(actorID), - ActorName: actorName, - ResourceType: "sandbox", - ResourceID: optText(id.FormatSandboxID(sandboxID)), - Action: "create", - Scope: "team", - Status: "success", - Metadata: marshalMeta(map[string]any{"template": template}), - }) + l.Log(ctx, newEntry(ac, ac.TeamID, "team", "sandbox", id.FormatSandboxID(sandboxID), "create", "success", map[string]any{"template": template})) l.publish(ctx, events.Event{ Event: events.CapsuleCreated, Timestamp: events.Now(), @@ -117,20 +183,7 @@ func (l *AuditLogger) LogSandboxCreate(ctx context.Context, ac auth.AuthContext, } func (l *AuditLogger) LogSandboxPause(ctx context.Context, ac auth.AuthContext, sandboxID pgtype.UUID) { - actorType, actorID, actorName := actorFields(ac) - l.write(ctx, db.InsertAuditLogParams{ - ID: id.NewAuditLogID(), - TeamID: ac.TeamID, - ActorType: actorType, - ActorID: optText(actorID), - ActorName: actorName, - ResourceType: "sandbox", - ResourceID: optText(id.FormatSandboxID(sandboxID)), - Action: "pause", - Scope: "team", - Status: "success", - Metadata: []byte("{}"), - }) + l.Log(ctx, newEntry(ac, ac.TeamID, "team", "sandbox", id.FormatSandboxID(sandboxID), "pause", "success", nil)) l.publish(ctx, events.Event{ Event: events.CapsulePaused, Timestamp: events.Now(), @@ -142,18 +195,10 @@ func (l *AuditLogger) LogSandboxPause(ctx context.Context, ac auth.AuthContext, // LogSandboxAutoPause records a system-initiated auto-pause (TTL or host reconciler). func (l *AuditLogger) LogSandboxAutoPause(ctx context.Context, teamID, sandboxID pgtype.UUID) { - l.write(ctx, db.InsertAuditLogParams{ - ID: id.NewAuditLogID(), - TeamID: teamID, - ActorType: "system", - ActorID: pgtype.Text{}, - ActorName: "", - ResourceType: "sandbox", - ResourceID: optText(id.FormatSandboxID(sandboxID)), - Action: "pause", - Scope: "team", - Status: "info", - Metadata: []byte("{}"), + l.Log(ctx, Entry{ + TeamID: teamID, ActorType: "system", + ResourceType: "sandbox", ResourceID: id.FormatSandboxID(sandboxID), + Action: "pause", Scope: "team", Status: "info", }) l.publish(ctx, events.Event{ Event: events.CapsulePaused, @@ -165,20 +210,7 @@ func (l *AuditLogger) LogSandboxAutoPause(ctx context.Context, teamID, sandboxID } func (l *AuditLogger) LogSandboxResume(ctx context.Context, ac auth.AuthContext, sandboxID pgtype.UUID) { - actorType, actorID, actorName := actorFields(ac) - l.write(ctx, db.InsertAuditLogParams{ - ID: id.NewAuditLogID(), - TeamID: ac.TeamID, - ActorType: actorType, - ActorID: optText(actorID), - ActorName: actorName, - ResourceType: "sandbox", - ResourceID: optText(id.FormatSandboxID(sandboxID)), - Action: "resume", - Scope: "team", - Status: "success", - Metadata: []byte("{}"), - }) + l.Log(ctx, newEntry(ac, ac.TeamID, "team", "sandbox", id.FormatSandboxID(sandboxID), "resume", "success", nil)) l.publish(ctx, events.Event{ Event: events.CapsuleRunning, Timestamp: events.Now(), @@ -189,20 +221,7 @@ func (l *AuditLogger) LogSandboxResume(ctx context.Context, ac auth.AuthContext, } func (l *AuditLogger) LogSandboxDestroy(ctx context.Context, ac auth.AuthContext, sandboxID pgtype.UUID) { - actorType, actorID, actorName := actorFields(ac) - l.write(ctx, db.InsertAuditLogParams{ - ID: id.NewAuditLogID(), - TeamID: ac.TeamID, - ActorType: actorType, - ActorID: optText(actorID), - ActorName: actorName, - ResourceType: "sandbox", - ResourceID: optText(id.FormatSandboxID(sandboxID)), - Action: "destroy", - Scope: "team", - Status: "warning", - Metadata: []byte("{}"), - }) + l.Log(ctx, newEntry(ac, ac.TeamID, "team", "sandbox", id.FormatSandboxID(sandboxID), "destroy", "warning", nil)) l.publish(ctx, events.Event{ Event: events.CapsuleDestroyed, Timestamp: events.Now(), @@ -215,20 +234,7 @@ func (l *AuditLogger) LogSandboxDestroy(ctx context.Context, ac auth.AuthContext // --- 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: optText(actorID), - ActorName: actorName, - ResourceType: "snapshot", - ResourceID: optText(name), - Action: "create", - Scope: "team", - Status: "success", - Metadata: []byte("{}"), - }) + l.Log(ctx, newEntry(ac, ac.TeamID, "team", "snapshot", name, "create", "success", nil)) l.publish(ctx, events.Event{ Event: events.SnapshotCreated, Timestamp: events.Now(), @@ -239,20 +245,7 @@ func (l *AuditLogger) LogSnapshotCreate(ctx context.Context, ac auth.AuthContext } 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: optText(actorID), - ActorName: actorName, - ResourceType: "snapshot", - ResourceID: optText(name), - Action: "delete", - Scope: "team", - Status: "warning", - Metadata: []byte("{}"), - }) + l.Log(ctx, newEntry(ac, ac.TeamID, "team", "snapshot", name, "delete", "warning", nil)) l.publish(ctx, events.Event{ Event: events.SnapshotDeleted, Timestamp: events.Now(), @@ -265,274 +258,96 @@ func (l *AuditLogger) LogSnapshotDelete(ctx context.Context, ac auth.AuthContext // --- Team events (scope: team) --- func (l *AuditLogger) LogTeamRename(ctx context.Context, ac auth.AuthContext, teamID pgtype.UUID, oldName, newName string) { - actorType, actorID, actorName := actorFields(ac) - l.write(ctx, db.InsertAuditLogParams{ - ID: id.NewAuditLogID(), - TeamID: ac.TeamID, - ActorType: actorType, - ActorID: optText(actorID), - ActorName: actorName, - ResourceType: "team", - ResourceID: optText(id.FormatTeamID(teamID)), - Action: "rename", - Scope: "team", - Status: "info", - Metadata: marshalMeta(map[string]any{"old_name": oldName, "new_name": newName}), - }) + l.Log(ctx, newEntry(ac, ac.TeamID, "team", "team", id.FormatTeamID(teamID), "rename", "info", map[string]any{"old_name": oldName, "new_name": newName})) } // --- Channel events (scope: team) --- func (l *AuditLogger) LogChannelCreate(ctx context.Context, ac auth.AuthContext, channelID pgtype.UUID, name, provider string) { - actorType, actorID, actorName := actorFields(ac) - l.write(ctx, db.InsertAuditLogParams{ - ID: id.NewAuditLogID(), - TeamID: ac.TeamID, - ActorType: actorType, - ActorID: optText(actorID), - ActorName: actorName, - ResourceType: "channel", - ResourceID: optText(id.FormatChannelID(channelID)), - Action: "create", - Scope: "team", - Status: "success", - Metadata: marshalMeta(map[string]any{"name": name, "provider": provider}), - }) + l.Log(ctx, newEntry(ac, ac.TeamID, "team", "channel", id.FormatChannelID(channelID), "create", "success", map[string]any{"name": name, "provider": provider})) } func (l *AuditLogger) LogChannelUpdate(ctx context.Context, ac auth.AuthContext, channelID pgtype.UUID) { - actorType, actorID, actorName := actorFields(ac) - l.write(ctx, db.InsertAuditLogParams{ - ID: id.NewAuditLogID(), - TeamID: ac.TeamID, - ActorType: actorType, - ActorID: optText(actorID), - ActorName: actorName, - ResourceType: "channel", - ResourceID: optText(id.FormatChannelID(channelID)), - Action: "update", - Scope: "team", - Status: "info", - Metadata: []byte("{}"), - }) + l.Log(ctx, newEntry(ac, ac.TeamID, "team", "channel", id.FormatChannelID(channelID), "update", "info", nil)) } func (l *AuditLogger) LogChannelRotateConfig(ctx context.Context, ac auth.AuthContext, channelID pgtype.UUID) { - actorType, actorID, actorName := actorFields(ac) - l.write(ctx, db.InsertAuditLogParams{ - ID: id.NewAuditLogID(), - TeamID: ac.TeamID, - ActorType: actorType, - ActorID: optText(actorID), - ActorName: actorName, - ResourceType: "channel", - ResourceID: optText(id.FormatChannelID(channelID)), - Action: "rotate_config", - Scope: "team", - Status: "info", - Metadata: []byte("{}"), - }) + l.Log(ctx, newEntry(ac, ac.TeamID, "team", "channel", id.FormatChannelID(channelID), "rotate_config", "info", nil)) } func (l *AuditLogger) LogChannelDelete(ctx context.Context, ac auth.AuthContext, channelID pgtype.UUID) { - actorType, actorID, actorName := actorFields(ac) - l.write(ctx, db.InsertAuditLogParams{ - ID: id.NewAuditLogID(), - TeamID: ac.TeamID, - ActorType: actorType, - ActorID: optText(actorID), - ActorName: actorName, - ResourceType: "channel", - ResourceID: optText(id.FormatChannelID(channelID)), - Action: "delete", - Scope: "team", - Status: "warning", - Metadata: []byte("{}"), - }) + l.Log(ctx, newEntry(ac, ac.TeamID, "team", "channel", id.FormatChannelID(channelID), "delete", "warning", nil)) } // --- API key events (scope: team) --- func (l *AuditLogger) LogAPIKeyCreate(ctx context.Context, ac auth.AuthContext, keyID pgtype.UUID, keyName string) { - actorType, actorID, actorName := actorFields(ac) - l.write(ctx, db.InsertAuditLogParams{ - ID: id.NewAuditLogID(), - TeamID: ac.TeamID, - ActorType: actorType, - ActorID: optText(actorID), - ActorName: actorName, - ResourceType: "api_key", - ResourceID: optText(id.FormatAPIKeyID(keyID)), - Action: "create", - Scope: "team", - Status: "success", - Metadata: marshalMeta(map[string]any{"name": keyName}), - }) + l.Log(ctx, newEntry(ac, ac.TeamID, "team", "api_key", id.FormatAPIKeyID(keyID), "create", "success", map[string]any{"name": keyName})) } func (l *AuditLogger) LogAPIKeyRevoke(ctx context.Context, ac auth.AuthContext, keyID pgtype.UUID) { - actorType, actorID, actorName := actorFields(ac) - l.write(ctx, db.InsertAuditLogParams{ - ID: id.NewAuditLogID(), - TeamID: ac.TeamID, - ActorType: actorType, - ActorID: optText(actorID), - ActorName: actorName, - ResourceType: "api_key", - ResourceID: optText(id.FormatAPIKeyID(keyID)), - Action: "revoke", - Scope: "team", - Status: "warning", - Metadata: []byte("{}"), - }) + l.Log(ctx, newEntry(ac, ac.TeamID, "team", "api_key", id.FormatAPIKeyID(keyID), "revoke", "warning", nil)) } // --- Member events (scope: admin) --- func (l *AuditLogger) LogMemberAdd(ctx context.Context, ac auth.AuthContext, targetUserID pgtype.UUID, targetEmail, role string) { - actorType, actorID, actorName := actorFields(ac) - l.write(ctx, db.InsertAuditLogParams{ - ID: id.NewAuditLogID(), - TeamID: ac.TeamID, - ActorType: actorType, - ActorID: optText(actorID), - ActorName: actorName, - ResourceType: "member", - ResourceID: optText(id.FormatUserID(targetUserID)), - Action: "add", - Scope: "admin", - Status: "success", - Metadata: marshalMeta(map[string]any{"email": targetEmail, "role": role}), - }) + l.Log(ctx, newEntry(ac, ac.TeamID, "admin", "member", id.FormatUserID(targetUserID), "add", "success", map[string]any{"email": targetEmail, "role": role})) } func (l *AuditLogger) LogMemberRemove(ctx context.Context, ac auth.AuthContext, targetUserID pgtype.UUID) { - actorType, actorID, actorName := actorFields(ac) - l.write(ctx, db.InsertAuditLogParams{ - ID: id.NewAuditLogID(), - TeamID: ac.TeamID, - ActorType: actorType, - ActorID: optText(actorID), - ActorName: actorName, - ResourceType: "member", - ResourceID: optText(id.FormatUserID(targetUserID)), - Action: "remove", - Scope: "admin", - Status: "warning", - Metadata: []byte("{}"), - }) + l.Log(ctx, newEntry(ac, ac.TeamID, "admin", "member", id.FormatUserID(targetUserID), "remove", "warning", nil)) } func (l *AuditLogger) LogMemberLeave(ctx context.Context, ac auth.AuthContext) { - actorType, actorID, actorName := actorFields(ac) resourceID := "" if ac.UserID.Valid { resourceID = id.FormatUserID(ac.UserID) } - l.write(ctx, db.InsertAuditLogParams{ - ID: id.NewAuditLogID(), - TeamID: ac.TeamID, - ActorType: actorType, - ActorID: optText(actorID), - ActorName: actorName, - ResourceType: "member", - ResourceID: optText(resourceID), - Action: "leave", - Scope: "admin", - Status: "info", - Metadata: []byte("{}"), - }) + l.Log(ctx, newEntry(ac, ac.TeamID, "admin", "member", resourceID, "leave", "info", nil)) } func (l *AuditLogger) LogMemberRoleUpdate(ctx context.Context, ac auth.AuthContext, targetUserID pgtype.UUID, newRole string) { - actorType, actorID, actorName := actorFields(ac) - l.write(ctx, db.InsertAuditLogParams{ - ID: id.NewAuditLogID(), - TeamID: ac.TeamID, - ActorType: actorType, - ActorID: optText(actorID), - ActorName: actorName, - ResourceType: "member", - ResourceID: optText(id.FormatUserID(targetUserID)), - Action: "role_update", - Scope: "admin", - Status: "info", - Metadata: marshalMeta(map[string]any{"new_role": newRole}), - }) + l.Log(ctx, newEntry(ac, ac.TeamID, "admin", "member", id.FormatUserID(targetUserID), "role_update", "info", map[string]any{"new_role": newRole})) } // --- Host events (scope: admin) --- +// LogHostCreate records a user-initiated host registration. +// BYOC hosts log to the owning team; shared hosts log to the platform team. func (l *AuditLogger) LogHostCreate(ctx context.Context, ac auth.AuthContext, hostID, teamID pgtype.UUID) { - actorType, actorID, actorName := actorFields(ac) - // For shared hosts with no owning team, use the caller's team. - logTeamID := teamID - if !logTeamID.Valid { - logTeamID = ac.TeamID - } - if !logTeamID.Valid { - return - } - l.write(ctx, db.InsertAuditLogParams{ - ID: id.NewAuditLogID(), - TeamID: logTeamID, - ActorType: actorType, - ActorID: optText(actorID), - ActorName: actorName, - ResourceType: "host", - ResourceID: optText(id.FormatHostID(hostID)), - Action: "create", - Scope: "admin", - Status: "success", - Metadata: []byte("{}"), - }) + l.Log(ctx, newEntry(ac, resolveHostTeamID(teamID), "admin", "host", id.FormatHostID(hostID), "create", "success", nil)) } +// LogHostDelete records a user-initiated host removal. +// BYOC hosts log to the owning team; shared hosts log to the platform team. func (l *AuditLogger) LogHostDelete(ctx context.Context, ac auth.AuthContext, hostID, teamID pgtype.UUID) { - actorType, actorID, actorName := actorFields(ac) - logTeamID := teamID - if !logTeamID.Valid { - logTeamID = ac.TeamID - } - if !logTeamID.Valid { - return - } - l.write(ctx, db.InsertAuditLogParams{ - ID: id.NewAuditLogID(), - TeamID: logTeamID, - ActorType: actorType, - ActorID: optText(actorID), - ActorName: actorName, - ResourceType: "host", - ResourceID: optText(id.FormatHostID(hostID)), - Action: "delete", - Scope: "admin", - Status: "warning", - Metadata: []byte("{}"), - }) + l.Log(ctx, newEntry(ac, resolveHostTeamID(teamID), "admin", "host", id.FormatHostID(hostID), "delete", "warning", nil)) } // 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) { + l.logSystemHostEvent(ctx, teamID, hostID, "marked_down", "error", events.HostDown) +} + +// LogHostMarkedUp records a system-initiated host status transition back to online. +// Scoped to "team" so BYOC team members can see when their hosts recover. +func (l *AuditLogger) LogHostMarkedUp(ctx context.Context, teamID, hostID pgtype.UUID) { + l.logSystemHostEvent(ctx, teamID, hostID, "marked_up", "success", events.HostUp) +} + +func (l *AuditLogger) logSystemHostEvent(ctx context.Context, teamID, hostID pgtype.UUID, action, status, ev string) { if !teamID.Valid { return } - l.write(ctx, db.InsertAuditLogParams{ - ID: id.NewAuditLogID(), - TeamID: teamID, - ActorType: "system", - ActorID: pgtype.Text{}, - ActorName: "", - ResourceType: "host", - ResourceID: optText(id.FormatHostID(hostID)), - Action: "marked_down", - Scope: "team", - Status: "error", - Metadata: []byte("{}"), + l.Log(ctx, Entry{ + TeamID: teamID, ActorType: "system", + ResourceType: "host", ResourceID: id.FormatHostID(hostID), + Action: action, Scope: "team", Status: status, }) l.publish(ctx, events.Event{ - Event: events.HostDown, + Event: ev, Timestamp: events.Now(), TeamID: id.FormatTeamID(teamID), Actor: systemActor(), @@ -540,30 +355,38 @@ func (l *AuditLogger) LogHostMarkedDown(ctx context.Context, teamID, hostID pgty }) } -// LogHostMarkedUp records a system-initiated host status transition back to online. -// Scoped to "team" so BYOC team members can see when their hosts recover. -func (l *AuditLogger) LogHostMarkedUp(ctx context.Context, teamID, hostID pgtype.UUID) { - if !teamID.Valid { - return - } - l.write(ctx, db.InsertAuditLogParams{ - ID: id.NewAuditLogID(), - TeamID: teamID, - ActorType: "system", - ActorID: pgtype.Text{}, - ActorName: "", - ResourceType: "host", - ResourceID: optText(id.FormatHostID(hostID)), - Action: "marked_up", - Scope: "team", - Status: "success", - Metadata: []byte("{}"), - }) - l.publish(ctx, events.Event{ - Event: events.HostUp, - Timestamp: events.Now(), - TeamID: id.FormatTeamID(teamID), - Actor: systemActor(), - Resource: events.Resource{ID: id.FormatHostID(hostID), Type: "host"}, - }) +// --- User events (scope: admin) --- + +func (l *AuditLogger) LogUserActivate(ctx context.Context, ac auth.AuthContext, userID pgtype.UUID, email string) { + l.Log(ctx, newAdminEntry(ac, "user", id.FormatUserID(userID), "activate", "success", map[string]any{"email": email})) +} + +func (l *AuditLogger) LogUserDeactivate(ctx context.Context, ac auth.AuthContext, userID pgtype.UUID, email string) { + l.Log(ctx, newAdminEntry(ac, "user", id.FormatUserID(userID), "deactivate", "warning", 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) { + l.Log(ctx, newAdminEntry(ac, "team", id.FormatTeamID(teamID), "set_byoc", "info", map[string]any{"enabled": enabled})) +} + +func (l *AuditLogger) LogTeamDelete(ctx context.Context, ac auth.AuthContext, teamID pgtype.UUID) { + l.Log(ctx, newAdminEntry(ac, "team", id.FormatTeamID(teamID), "delete", "warning", nil)) +} + +// --- Template events (scope: admin) --- + +func (l *AuditLogger) LogTemplateDelete(ctx context.Context, ac auth.AuthContext, name string) { + l.Log(ctx, newAdminEntry(ac, "template", name, "delete", "warning", nil)) +} + +// --- Build events (scope: admin) --- + +func (l *AuditLogger) LogBuildCreate(ctx context.Context, ac auth.AuthContext, buildID pgtype.UUID, name string) { + l.Log(ctx, newAdminEntry(ac, "build", id.FormatBuildID(buildID), "create", "success", map[string]any{"name": name})) +} + +func (l *AuditLogger) LogBuildCancel(ctx context.Context, ac auth.AuthContext, buildID pgtype.UUID) { + l.Log(ctx, newAdminEntry(ac, "build", id.FormatBuildID(buildID), "cancel", "warning", nil)) } diff --git a/pkg/cpextension/extension.go b/pkg/cpextension/extension.go index 7682aca..b0f456f 100644 --- a/pkg/cpextension/extension.go +++ b/pkg/cpextension/extension.go @@ -36,7 +36,7 @@ type ServerContext struct { Config config.Config } -// Extension allows enterprise (or any external) code to plug additional +// Extension allows cloud (or any external) code to plug additional // routes and background workers into the control plane without modifying // the core server. type Extension interface { diff --git a/pkg/cpserver/run.go b/pkg/cpserver/run.go index d248a97..58ef7f1 100644 --- a/pkg/cpserver/run.go +++ b/pkg/cpserver/run.go @@ -14,6 +14,8 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/redis/go-redis/v9" + "github.com/jackc/pgx/v5/pgtype" + "git.omukk.dev/wrenn/wrenn/internal/api" "git.omukk.dev/wrenn/wrenn/internal/email" "git.omukk.dev/wrenn/wrenn/pkg/audit" @@ -22,6 +24,7 @@ import ( "git.omukk.dev/wrenn/wrenn/pkg/channels" "git.omukk.dev/wrenn/wrenn/pkg/config" "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" "git.omukk.dev/wrenn/wrenn/pkg/lifecycle" "git.omukk.dev/wrenn/wrenn/pkg/logging" "git.omukk.dev/wrenn/wrenn/pkg/scheduler" @@ -185,10 +188,12 @@ func Run(opts ...Option) { channelDispatcher.Start(ctx) // Start host monitor (passive + active reconciliation every 30s). - monitor := api.NewHostMonitor(queries, hostPool, al, 30*time.Second) + monitor := api.NewHostMonitor(queries, hostPool, al, 15*time.Second) monitor.Start(ctx) // Hard-delete accounts that have been soft-deleted for more than 15 days (runs every 24h). + // Audit logs referencing deleted users are anonymized before the user row is removed. + // A notification email is sent to the user before their data is permanently removed. go func() { ticker := time.NewTicker(24 * time.Hour) defer ticker.Stop() @@ -197,10 +202,34 @@ func Run(opts ...Option) { case <-ctx.Done(): return case <-ticker.C: - if err := queries.HardDeleteExpiredUsers(ctx); err != nil { - slog.Error("account cleanup: failed to hard-delete expired users", "error", err) - } else { - slog.Info("account cleanup: hard-deleted expired users") + expired, err := queries.ListExpiredSoftDeletedUsers(ctx) + if err != nil { + slog.Error("account cleanup: failed to list expired users", "error", err) + continue + } + var deleted int + for _, row := range expired { + prefixedID := id.FormatUserID(row.ID) + if err := queries.AnonymizeAuditLogsByUserID(ctx, pgtype.Text{String: prefixedID, Valid: true}); err != nil { + slog.Error("account cleanup: failed to anonymize audit logs, skipping delete", "user_id", prefixedID, "error", err) + continue + } + if err := queries.HardDeleteUser(ctx, row.ID); err != nil { + slog.Error("account cleanup: failed to hard-delete user", "user_id", prefixedID, "error", err) + continue + } + if err := mailer.Send(ctx, row.Email, "Your Wrenn account has been deleted", email.EmailData{ + Message: "Your Wrenn account and all associated data have been permanently deleted. " + + "This action was taken automatically because your account was scheduled for deletion more than 15 days ago.\n\n" + + "If you believe this was done in error, please contact support.", + Closing: "Thank you for using Wrenn.", + }); err != nil { + slog.Warn("account cleanup: failed to send deletion notification", "email", row.Email, "error", err) + } + deleted++ + } + if len(expired) > 0 { + slog.Info("account cleanup: processed expired users", "total", len(expired), "deleted", deleted) } } } diff --git a/pkg/db/audit.sql.go b/pkg/db/audit.sql.go index 69b2b8c..3490769 100644 --- a/pkg/db/audit.sql.go +++ b/pkg/db/audit.sql.go @@ -11,6 +11,21 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const anonymizeAuditLogsByUserID = `-- name: AnonymizeAuditLogsByUserID :exec +UPDATE audit_logs +SET actor_name = CASE WHEN actor_id = $1 THEN 'deleted-user' ELSE actor_name END, + actor_id = CASE WHEN actor_id = $1 THEN NULL ELSE actor_id END, + resource_id = CASE WHEN resource_type = 'member' AND resource_id = $1 THEN NULL ELSE resource_id END, + metadata = CASE WHEN resource_type = 'member' AND resource_id = $1 AND metadata ? 'email' THEN metadata - 'email' ELSE metadata END +WHERE actor_id = $1 + OR (resource_type = 'member' AND resource_id = $1) +` + +func (q *Queries) AnonymizeAuditLogsByUserID(ctx context.Context, actorID pgtype.Text) error { + _, err := q.db.Exec(ctx, anonymizeAuditLogsByUserID, actorID) + return err +} + 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) diff --git a/pkg/db/users.sql.go b/pkg/db/users.sql.go index c48d9c9..b2d79e8 100644 --- a/pkg/db/users.sql.go +++ b/pkg/db/users.sql.go @@ -183,15 +183,6 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) return i, err } -const hardDeleteExpiredUsers = `-- name: HardDeleteExpiredUsers :exec -DELETE FROM users WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '15 days' -` - -func (q *Queries) HardDeleteExpiredUsers(ctx context.Context) error { - _, err := q.db.Exec(ctx, hardDeleteExpiredUsers) - return err -} - const hardDeleteUser = `-- name: HardDeleteUser :exec DELETE FROM users WHERE id = $1 ` @@ -334,6 +325,35 @@ func (q *Queries) InsertUserOAuth(ctx context.Context, arg InsertUserOAuthParams return i, err } +const listExpiredSoftDeletedUsers = `-- name: ListExpiredSoftDeletedUsers :many +SELECT id, email FROM users WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '15 days' +` + +type ListExpiredSoftDeletedUsersRow struct { + ID pgtype.UUID `json:"id"` + Email string `json:"email"` +} + +func (q *Queries) ListExpiredSoftDeletedUsers(ctx context.Context) ([]ListExpiredSoftDeletedUsersRow, error) { + rows, err := q.db.Query(ctx, listExpiredSoftDeletedUsers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListExpiredSoftDeletedUsersRow + for rows.Next() { + var i ListExpiredSoftDeletedUsersRow + if err := rows.Scan(&i.ID, &i.Email); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listUsersAdmin = `-- name: ListUsersAdmin :many SELECT u.id,