forked from wrenn/wrenn
feat: add notification channels with provider integrations and retry
Implement a channels system for notifying teams via external providers
(Discord, Slack, Teams, Google Chat, Telegram, Matrix, webhook) when
lifecycle events occur (capsule/template/host state changes).
- Channel CRUD API under /v1/channels (JWT-only auth)
- Test endpoint to verify config before saving (POST /v1/channels/test)
- Secret rotation endpoint (PUT /v1/channels/{id}/config)
- AES-256-GCM encryption for provider secrets (WRENN_ENCRYPTION_KEY)
- Redis stream event publishing from audit logger
- Background dispatcher with consumer group and retry (10s, 30s)
- Webhook delivery with HMAC-SHA256 signing (X-WRENN-SIGNATURE)
- shoutrrr integration for chat providers
- Secrets never exposed in API responses
This commit is contained in:
@ -9,6 +9,7 @@ import (
|
||||
|
||||
"git.omukk.dev/wrenn/sandbox/internal/auth"
|
||||
"git.omukk.dev/wrenn/sandbox/internal/db"
|
||||
"git.omukk.dev/wrenn/sandbox/internal/events"
|
||||
"git.omukk.dev/wrenn/sandbox/internal/id"
|
||||
)
|
||||
|
||||
@ -16,14 +17,38 @@ import (
|
||||
// All methods are fire-and-forget: failures are logged via slog and never
|
||||
// propagated to the caller.
|
||||
type AuditLogger struct {
|
||||
db *db.Queries
|
||||
db *db.Queries
|
||||
pub events.EventPublisher // optional — nil disables event publishing
|
||||
}
|
||||
|
||||
// New constructs an AuditLogger.
|
||||
// New constructs an AuditLogger without event publishing.
|
||||
func New(queries *db.Queries) *AuditLogger {
|
||||
return &AuditLogger{db: queries}
|
||||
}
|
||||
|
||||
// NewWithPublisher constructs an AuditLogger that also publishes channel events.
|
||||
func NewWithPublisher(queries *db.Queries, pub events.EventPublisher) *AuditLogger {
|
||||
return &AuditLogger{db: queries, pub: pub}
|
||||
}
|
||||
|
||||
// publish sends an event to the notification stream if a publisher is configured.
|
||||
func (l *AuditLogger) publish(ctx context.Context, e events.Event) {
|
||||
if l.pub != nil {
|
||||
l.pub.Publish(ctx, e)
|
||||
}
|
||||
}
|
||||
|
||||
// actorToEvent converts auth context fields to an events.Actor.
|
||||
func actorToEvent(ac auth.AuthContext) events.Actor {
|
||||
at, aid, aname := actorFields(ac)
|
||||
return events.Actor{Type: events.ActorKind(at), ID: aid, Name: aname}
|
||||
}
|
||||
|
||||
// systemActor returns an events.Actor for system-initiated events.
|
||||
func systemActor() events.Actor {
|
||||
return events.Actor{Type: events.ActorSystem}
|
||||
}
|
||||
|
||||
// actorFields extracts actor_type, actor_id, and actor_name from an AuthContext.
|
||||
// actor_id is stored as a prefixed string in the TEXT column.
|
||||
func actorFields(ac auth.AuthContext) (actorType, actorID, actorName string) {
|
||||
@ -82,6 +107,13 @@ func (l *AuditLogger) LogSandboxCreate(ctx context.Context, ac auth.AuthContext,
|
||||
Status: "success",
|
||||
Metadata: marshalMeta(map[string]any{"template": template}),
|
||||
})
|
||||
l.publish(ctx, events.Event{
|
||||
Event: events.CapsuleCreated,
|
||||
Timestamp: events.Now(),
|
||||
TeamID: id.FormatTeamID(ac.TeamID),
|
||||
Actor: actorToEvent(ac),
|
||||
Resource: events.Resource{ID: id.FormatSandboxID(sandboxID), Type: "sandbox"},
|
||||
})
|
||||
}
|
||||
|
||||
func (l *AuditLogger) LogSandboxPause(ctx context.Context, ac auth.AuthContext, sandboxID pgtype.UUID) {
|
||||
@ -99,6 +131,13 @@ func (l *AuditLogger) LogSandboxPause(ctx context.Context, ac auth.AuthContext,
|
||||
Status: "success",
|
||||
Metadata: []byte("{}"),
|
||||
})
|
||||
l.publish(ctx, events.Event{
|
||||
Event: events.CapsulePaused,
|
||||
Timestamp: events.Now(),
|
||||
TeamID: id.FormatTeamID(ac.TeamID),
|
||||
Actor: actorToEvent(ac),
|
||||
Resource: events.Resource{ID: id.FormatSandboxID(sandboxID), Type: "sandbox"},
|
||||
})
|
||||
}
|
||||
|
||||
// LogSandboxAutoPause records a system-initiated auto-pause (TTL or host reconciler).
|
||||
@ -116,6 +155,13 @@ func (l *AuditLogger) LogSandboxAutoPause(ctx context.Context, teamID, sandboxID
|
||||
Status: "info",
|
||||
Metadata: []byte("{}"),
|
||||
})
|
||||
l.publish(ctx, events.Event{
|
||||
Event: events.CapsulePaused,
|
||||
Timestamp: events.Now(),
|
||||
TeamID: id.FormatTeamID(teamID),
|
||||
Actor: systemActor(),
|
||||
Resource: events.Resource{ID: id.FormatSandboxID(sandboxID), Type: "sandbox"},
|
||||
})
|
||||
}
|
||||
|
||||
func (l *AuditLogger) LogSandboxResume(ctx context.Context, ac auth.AuthContext, sandboxID pgtype.UUID) {
|
||||
@ -133,6 +179,13 @@ func (l *AuditLogger) LogSandboxResume(ctx context.Context, ac auth.AuthContext,
|
||||
Status: "success",
|
||||
Metadata: []byte("{}"),
|
||||
})
|
||||
l.publish(ctx, events.Event{
|
||||
Event: events.CapsuleRunning,
|
||||
Timestamp: events.Now(),
|
||||
TeamID: id.FormatTeamID(ac.TeamID),
|
||||
Actor: actorToEvent(ac),
|
||||
Resource: events.Resource{ID: id.FormatSandboxID(sandboxID), Type: "sandbox"},
|
||||
})
|
||||
}
|
||||
|
||||
func (l *AuditLogger) LogSandboxDestroy(ctx context.Context, ac auth.AuthContext, sandboxID pgtype.UUID) {
|
||||
@ -150,6 +203,13 @@ func (l *AuditLogger) LogSandboxDestroy(ctx context.Context, ac auth.AuthContext
|
||||
Status: "warning",
|
||||
Metadata: []byte("{}"),
|
||||
})
|
||||
l.publish(ctx, events.Event{
|
||||
Event: events.CapsuleDestroyed,
|
||||
Timestamp: events.Now(),
|
||||
TeamID: id.FormatTeamID(ac.TeamID),
|
||||
Actor: actorToEvent(ac),
|
||||
Resource: events.Resource{ID: id.FormatSandboxID(sandboxID), Type: "sandbox"},
|
||||
})
|
||||
}
|
||||
|
||||
// --- Snapshot events (scope: team) ---
|
||||
@ -169,6 +229,13 @@ func (l *AuditLogger) LogSnapshotCreate(ctx context.Context, ac auth.AuthContext
|
||||
Status: "success",
|
||||
Metadata: []byte("{}"),
|
||||
})
|
||||
l.publish(ctx, events.Event{
|
||||
Event: events.SnapshotCreated,
|
||||
Timestamp: events.Now(),
|
||||
TeamID: id.FormatTeamID(ac.TeamID),
|
||||
Actor: actorToEvent(ac),
|
||||
Resource: events.Resource{ID: name, Type: "snapshot"},
|
||||
})
|
||||
}
|
||||
|
||||
func (l *AuditLogger) LogSnapshotDelete(ctx context.Context, ac auth.AuthContext, name string) {
|
||||
@ -186,6 +253,13 @@ func (l *AuditLogger) LogSnapshotDelete(ctx context.Context, ac auth.AuthContext
|
||||
Status: "warning",
|
||||
Metadata: []byte("{}"),
|
||||
})
|
||||
l.publish(ctx, events.Event{
|
||||
Event: events.SnapshotDeleted,
|
||||
Timestamp: events.Now(),
|
||||
TeamID: id.FormatTeamID(ac.TeamID),
|
||||
Actor: actorToEvent(ac),
|
||||
Resource: events.Resource{ID: name, Type: "snapshot"},
|
||||
})
|
||||
}
|
||||
|
||||
// --- Team events (scope: team) ---
|
||||
@ -387,6 +461,13 @@ func (l *AuditLogger) LogHostMarkedDown(ctx context.Context, teamID, hostID pgty
|
||||
Status: "error",
|
||||
Metadata: []byte("{}"),
|
||||
})
|
||||
l.publish(ctx, events.Event{
|
||||
Event: events.HostDown,
|
||||
Timestamp: events.Now(),
|
||||
TeamID: id.FormatTeamID(teamID),
|
||||
Actor: systemActor(),
|
||||
Resource: events.Resource{ID: id.FormatHostID(hostID), Type: "host"},
|
||||
})
|
||||
}
|
||||
|
||||
// LogHostMarkedUp records a system-initiated host status transition back to online.
|
||||
@ -408,4 +489,11 @@ func (l *AuditLogger) LogHostMarkedUp(ctx context.Context, teamID, hostID pgtype
|
||||
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"},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user