1
0
forked from wrenn/wrenn
## What's new

Compliance, audit, and account lifecycle improvements — admin actions are now fully auditable, user data is properly anonymized on deletion, and OAuth signup flow gives users control over their profile.

### Audit

- Added audit logging for all admin actions (user activate/deactivate, team BYOC toggle, team delete, template delete, build create/cancel)
- Added admin audit page with infinite scroll and hierarchical filters
- Fixed audit log team assignment — admin/host actions now correctly land under PlatformTeamID
- Anonymize audit logs on user hard-delete (actor name, IDs, emails stripped)
- Deduplicated audit logger internals (665 → 374 lines, no behavior change)

### Authentication

- Separated GitHub OAuth login/signup flows — login no longer auto-creates accounts
- Added name confirmation dialog for new GitHub signups

### Account Lifecycle

- Email notification sent when account is permanently deleted after grace period
- Audit log anonymization tied to user purge (per-user transactional)

### UX

- Removed accent gradient bars from admin host dialogs (border + shadow only)
- Frontend renders deleted users as styled badge in audit log view

### Others

- Version bump
- Bug fixes

Reviewed-on: wrenn/wrenn#36
This commit is contained in:
2026-04-21 10:11:49 +00:00
parent 23dca7d9ff
commit 52ad21c339
25 changed files with 1200 additions and 443 deletions

View File

@ -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))
}

View File

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

View File

@ -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)
}
}
}

View File

@ -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)

View File

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