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..ac20d30 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 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/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/pkg/audit/logger.go b/pkg/audit/logger.go index e101b3c..66c217e 100644 --- a/pkg/audit/logger.go +++ b/pkg/audit/logger.go @@ -465,13 +465,10 @@ func (l *AuditLogger) LogMemberRoleUpdate(ctx context.Context, ac auth.AuthConte 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. + // BYOC hosts log to the owning team; shared hosts log to the platform team. logTeamID := teamID if !logTeamID.Valid { - logTeamID = ac.TeamID - } - if !logTeamID.Valid { - return + logTeamID = id.PlatformTeamID } l.write(ctx, db.InsertAuditLogParams{ ID: id.NewAuditLogID(), @@ -490,12 +487,10 @@ func (l *AuditLogger) LogHostCreate(ctx context.Context, ac auth.AuthContext, ho func (l *AuditLogger) LogHostDelete(ctx context.Context, ac auth.AuthContext, hostID, teamID pgtype.UUID) { actorType, actorID, actorName := actorFields(ac) + // BYOC hosts log to the owning team; shared hosts log to the platform team. logTeamID := teamID if !logTeamID.Valid { - logTeamID = ac.TeamID - } - if !logTeamID.Valid { - return + logTeamID = id.PlatformTeamID } l.write(ctx, db.InsertAuditLogParams{ ID: id.NewAuditLogID(), diff --git a/pkg/cpserver/run.go b/pkg/cpserver/run.go index d248a97..9066a8c 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,11 @@ 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. go func() { ticker := time.NewTicker(24 * time.Hour) defer ticker.Stop() @@ -197,10 +201,26 @@ 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 _, userID := range expired { + prefixedID := id.FormatUserID(userID) + 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, userID); err != nil { + slog.Error("account cleanup: failed to hard-delete user", "user_id", prefixedID, "error", err) + continue + } + 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..be898ea 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,30 @@ func (q *Queries) InsertUserOAuth(ctx context.Context, arg InsertUserOAuthParams return i, err } +const listExpiredSoftDeletedUsers = `-- name: ListExpiredSoftDeletedUsers :many +SELECT id FROM users WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '15 days' +` + +func (q *Queries) ListExpiredSoftDeletedUsers(ctx context.Context) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, listExpiredSoftDeletedUsers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []pgtype.UUID + for rows.Next() { + var id pgtype.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listUsersAdmin = `-- name: ListUsersAdmin :many SELECT u.id,