{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,