Add API token auth and issues doc
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// APIKeyStore manages API keys with rate limiting
|
||||
type APIKeyStore struct {
|
||||
mu sync.RWMutex
|
||||
keys map[string]*APIKey
|
||||
rateLimits map[string]*RateLimiter
|
||||
}
|
||||
|
||||
// APIKey represents an API key with metadata
|
||||
type APIKey struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
ClientID string `json:"client_id"`
|
||||
Roles []string `json:"roles"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
RateLimit int `json:"rate_limit"` // requests per minute, 0 = unlimited
|
||||
}
|
||||
|
||||
// RateLimiter tracks rate limiting for a key
|
||||
type RateLimiter struct {
|
||||
maxRequests int
|
||||
window time.Duration
|
||||
resetTime time.Time
|
||||
count int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// AuthContext holds auth information attached to request context
|
||||
type AuthContext struct {
|
||||
APIKey *APIKey
|
||||
ClientID string
|
||||
Roles []string
|
||||
}
|
||||
|
||||
// NewAPIKeyStore creates a new API key store
|
||||
func NewAPIKeyStore() *APIKeyStore {
|
||||
return &APIKeyStore{
|
||||
keys: make(map[string]*APIKey),
|
||||
rateLimits: make(map[string]*RateLimiter),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateKey generates a new API key
|
||||
func (s *APIKeyStore) CreateKey(clientID string, roles []string, rateLimit int, expiresIn *time.Duration) (*APIKey, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Generate random key
|
||||
keyBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(keyBytes); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate key: %w", err)
|
||||
}
|
||||
key := "nk_" + hex.EncodeToString(keyBytes)
|
||||
|
||||
now := time.Now().UTC()
|
||||
apiKey := &APIKey{
|
||||
Key: key,
|
||||
ClientID: clientID,
|
||||
Roles: roles,
|
||||
CreatedAt: now,
|
||||
IsActive: true,
|
||||
RateLimit: rateLimit,
|
||||
Name: fmt.Sprintf("%s-%d", clientID, now.Unix()),
|
||||
}
|
||||
|
||||
if expiresIn != nil {
|
||||
expiresAt := now.Add(*expiresIn)
|
||||
apiKey.ExpiresAt = &expiresAt
|
||||
}
|
||||
|
||||
s.keys[key] = apiKey
|
||||
s.rateLimits[key] = &RateLimiter{
|
||||
maxRequests: rateLimit,
|
||||
window: time.Minute,
|
||||
resetTime: time.Now().Add(time.Minute),
|
||||
count: 0,
|
||||
}
|
||||
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
// ValidateKey checks if an API key is valid and returns the key metadata
|
||||
func (s *APIKeyStore) ValidateKey(keyStr string) (*APIKey, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
key, exists := s.keys[keyStr]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("invalid API key")
|
||||
}
|
||||
|
||||
if !key.IsActive {
|
||||
return nil, fmt.Errorf("API key is inactive")
|
||||
}
|
||||
|
||||
if key.ExpiresAt != nil && time.Now().After(*key.ExpiresAt) {
|
||||
return nil, fmt.Errorf("API key has expired")
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// CheckRateLimit checks if a key has exceeded its rate limit
|
||||
func (s *APIKeyStore) CheckRateLimit(keyStr string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
key, exists := s.keys[keyStr]
|
||||
if !exists {
|
||||
return false, fmt.Errorf("invalid API key")
|
||||
}
|
||||
|
||||
// Unlimited rate limit
|
||||
if key.RateLimit <= 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
limiter, exists := s.rateLimits[keyStr]
|
||||
if !exists {
|
||||
return false, fmt.Errorf("rate limiter not found")
|
||||
}
|
||||
|
||||
limiter.mu.Lock()
|
||||
defer limiter.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
if now.After(limiter.resetTime) {
|
||||
limiter.count = 0
|
||||
limiter.resetTime = now.Add(limiter.window)
|
||||
}
|
||||
|
||||
if limiter.count >= limiter.maxRequests {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
limiter.count++
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// UpdateLastUsed updates the last used timestamp for a key
|
||||
func (s *APIKeyStore) UpdateLastUsed(keyStr string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
key, exists := s.keys[keyStr]
|
||||
if !exists {
|
||||
return fmt.Errorf("invalid API key")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
key.LastUsedAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeactivateKey deactivates an API key
|
||||
func (s *APIKeyStore) DeactivateKey(keyStr string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
key, exists := s.keys[keyStr]
|
||||
if !exists {
|
||||
return fmt.Errorf("invalid API key")
|
||||
}
|
||||
|
||||
key.IsActive = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetKey retrieves key metadata (for management purposes)
|
||||
func (s *APIKeyStore) GetKey(keyStr string) (*APIKey, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
key, exists := s.keys[keyStr]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("key not found")
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// ListKeys lists all API keys for a client
|
||||
func (s *APIKeyStore) ListKeys(clientID string) []*APIKey {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var keys []*APIKey
|
||||
for _, key := range s.keys {
|
||||
if key.ClientID == clientID {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// ContextWithAuth adds auth context to a request context
|
||||
func ContextWithAuth(ctx context.Context, auth *AuthContext) context.Context {
|
||||
return context.WithValue(ctx, "auth", auth)
|
||||
}
|
||||
|
||||
// GetAuthContext retrieves auth context from a request context
|
||||
func GetAuthContext(ctx context.Context) (*AuthContext, bool) {
|
||||
auth, ok := ctx.Value("auth").(*AuthContext)
|
||||
return auth, ok
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/igodwin/notifier/internal/domain"
|
||||
)
|
||||
|
||||
// NotifierAuthz manages authorization rules for notifiers
|
||||
type NotifierAuthz struct {
|
||||
// Map of "type:account" -> allowed roles
|
||||
rules map[string][]string
|
||||
}
|
||||
|
||||
// NewNotifierAuthz creates a new notifier authorization manager
|
||||
func NewNotifierAuthz() *NotifierAuthz {
|
||||
return &NotifierAuthz{
|
||||
rules: make(map[string][]string),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRule registers authorization rule for a notifier type and account
|
||||
func (a *NotifierAuthz) RegisterRule(notificationType domain.NotificationType, account string, allowedRoles []string) {
|
||||
key := makeAuthzKey(notificationType, account)
|
||||
a.rules[key] = allowedRoles
|
||||
}
|
||||
|
||||
// IsAuthorized checks if an auth context is authorized to use a specific notifier
|
||||
func (a *NotifierAuthz) IsAuthorized(auth *AuthContext, notificationType domain.NotificationType, account string) bool {
|
||||
if auth == nil || len(auth.Roles) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
key := makeAuthzKey(notificationType, account)
|
||||
allowedRoles, exists := a.rules[key]
|
||||
|
||||
// If no specific rule is registered, allow all authenticated users
|
||||
if !exists {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if any of the user's roles is in the allowed roles
|
||||
for _, userRole := range auth.Roles {
|
||||
for _, allowedRole := range allowedRoles {
|
||||
if userRole == allowedRole {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetAllowedRoles returns the allowed roles for a notifier
|
||||
func (a *NotifierAuthz) GetAllowedRoles(notificationType domain.NotificationType, account string) []string {
|
||||
key := makeAuthzKey(notificationType, account)
|
||||
return a.rules[key]
|
||||
}
|
||||
|
||||
// SetAllowedRoles sets the allowed roles for a notifier
|
||||
func (a *NotifierAuthz) SetAllowedRoles(notificationType domain.NotificationType, account string, allowedRoles []string) {
|
||||
key := makeAuthzKey(notificationType, account)
|
||||
a.rules[key] = allowedRoles
|
||||
}
|
||||
|
||||
// makeAuthzKey creates a compound key from notification type and account
|
||||
func makeAuthzKey(notificationType domain.NotificationType, account string) string {
|
||||
if account == "" {
|
||||
return string(notificationType)
|
||||
}
|
||||
return fmt.Sprintf("%s:%s", notificationType, account)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/igodwin/notifier/internal/logging"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// GRPCAuthMiddleware provides authentication for gRPC APIs
|
||||
type GRPCAuthMiddleware struct {
|
||||
store *APIKeyStore
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewGRPCAuthMiddleware creates a new gRPC auth middleware
|
||||
func NewGRPCAuthMiddleware(store *APIKeyStore, logger *logging.Logger) *GRPCAuthMiddleware {
|
||||
return &GRPCAuthMiddleware{
|
||||
store: store,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// UnaryInterceptor returns a unary server interceptor for gRPC authentication
|
||||
func (m *GRPCAuthMiddleware) UnaryInterceptor() grpc.UnaryServerInterceptor {
|
||||
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
|
||||
// Extract API key from metadata
|
||||
apiKey := m.extractAPIKey(ctx)
|
||||
if apiKey == "" {
|
||||
m.logger.Warnf("gRPC: Missing API key in request for method=%s", info.FullMethod)
|
||||
return nil, status.Error(codes.Unauthenticated, "Missing or invalid Authorization header")
|
||||
}
|
||||
|
||||
// Validate API key
|
||||
key, err := m.store.ValidateKey(apiKey)
|
||||
if err != nil {
|
||||
m.logger.Warnf("gRPC: Invalid API key for method=%s - error=%v", info.FullMethod, err)
|
||||
return nil, status.Error(codes.Unauthenticated, "Invalid API key")
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
allowed, err := m.store.CheckRateLimit(apiKey)
|
||||
if err != nil || !allowed {
|
||||
m.logger.Warnf("gRPC: Rate limit exceeded for client=%s method=%s", key.ClientID, info.FullMethod)
|
||||
return nil, status.Error(codes.ResourceExhausted, "Rate limit exceeded")
|
||||
}
|
||||
|
||||
// Update last used timestamp
|
||||
if err := m.store.UpdateLastUsed(apiKey); err != nil {
|
||||
m.logger.Errorf("gRPC: Failed to update last used time for client=%s - error=%v", key.ClientID, err)
|
||||
}
|
||||
|
||||
// Create auth context and attach to request
|
||||
authCtx := &AuthContext{
|
||||
APIKey: key,
|
||||
ClientID: key.ClientID,
|
||||
Roles: key.Roles,
|
||||
}
|
||||
|
||||
// Add auth context to request context
|
||||
newCtx := ContextWithAuth(ctx, authCtx)
|
||||
m.logger.Debugf("gRPC: Authenticated request from client=%s method=%s with roles=%v", key.ClientID, info.FullMethod, key.Roles)
|
||||
|
||||
return handler(newCtx, req)
|
||||
}
|
||||
}
|
||||
|
||||
// StreamInterceptor returns a stream server interceptor for gRPC authentication
|
||||
func (m *GRPCAuthMiddleware) StreamInterceptor() grpc.StreamServerInterceptor {
|
||||
return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
|
||||
// Extract API key from metadata
|
||||
apiKey := m.extractAPIKey(ss.Context())
|
||||
if apiKey == "" {
|
||||
m.logger.Warnf("gRPC: Missing API key in stream for method=%s", info.FullMethod)
|
||||
return status.Error(codes.Unauthenticated, "Missing or invalid Authorization header")
|
||||
}
|
||||
|
||||
// Validate API key
|
||||
key, err := m.store.ValidateKey(apiKey)
|
||||
if err != nil {
|
||||
m.logger.Warnf("gRPC: Invalid API key for stream method=%s - error=%v", info.FullMethod, err)
|
||||
return status.Error(codes.Unauthenticated, "Invalid API key")
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
allowed, err := m.store.CheckRateLimit(apiKey)
|
||||
if err != nil || !allowed {
|
||||
m.logger.Warnf("gRPC: Rate limit exceeded for client=%s stream method=%s", key.ClientID, info.FullMethod)
|
||||
return status.Error(codes.ResourceExhausted, "Rate limit exceeded")
|
||||
}
|
||||
|
||||
// Update last used timestamp
|
||||
if err := m.store.UpdateLastUsed(apiKey); err != nil {
|
||||
m.logger.Errorf("gRPC: Failed to update last used time for client=%s - error=%v", key.ClientID, err)
|
||||
}
|
||||
|
||||
// Create auth context and attach to request
|
||||
authCtx := &AuthContext{
|
||||
APIKey: key,
|
||||
ClientID: key.ClientID,
|
||||
Roles: key.Roles,
|
||||
}
|
||||
|
||||
// Add auth context to request context
|
||||
newCtx := ContextWithAuth(ss.Context(), authCtx)
|
||||
m.logger.Debugf("gRPC: Authenticated stream from client=%s method=%s with roles=%v", key.ClientID, info.FullMethod, key.Roles)
|
||||
|
||||
// Create wrapped server stream with new context
|
||||
wrappedStream := &wrappedServerStream{ServerStream: ss, ctx: newCtx}
|
||||
return handler(srv, wrappedStream)
|
||||
}
|
||||
}
|
||||
|
||||
// wrappedServerStream wraps grpc.ServerStream to override context
|
||||
type wrappedServerStream struct {
|
||||
grpc.ServerStream
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (w *wrappedServerStream) Context() context.Context {
|
||||
return w.ctx
|
||||
}
|
||||
|
||||
// extractAPIKey extracts API key from gRPC metadata
|
||||
func (m *GRPCAuthMiddleware) extractAPIKey(ctx context.Context) string {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Try authorization header first
|
||||
if authHeaders := md.Get("authorization"); len(authHeaders) > 0 {
|
||||
authHeader := authHeaders[0]
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
|
||||
return parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Try x-api-key header
|
||||
if keyHeaders := md.Get("x-api-key"); len(keyHeaders) > 0 {
|
||||
return keyHeaders[0]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/igodwin/notifier/internal/logging"
|
||||
)
|
||||
|
||||
// RESTAuthMiddleware provides authentication for REST APIs
|
||||
type RESTAuthMiddleware struct {
|
||||
store *APIKeyStore
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewRESTAuthMiddleware creates a new REST auth middleware
|
||||
func NewRESTAuthMiddleware(store *APIKeyStore, logger *logging.Logger) *RESTAuthMiddleware {
|
||||
return &RESTAuthMiddleware{
|
||||
store: store,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware returns an HTTP middleware function
|
||||
func (m *RESTAuthMiddleware) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract API key from Authorization header or X-API-Key header
|
||||
apiKey := m.extractAPIKey(r)
|
||||
if apiKey == "" {
|
||||
m.logger.Warnf("REST: Missing API key in request from %s", r.RemoteAddr)
|
||||
http.Error(w, "Missing or invalid Authorization header", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate API key
|
||||
key, err := m.store.ValidateKey(apiKey)
|
||||
if err != nil {
|
||||
m.logger.Warnf("REST: Invalid API key from %s - error=%v", r.RemoteAddr, err)
|
||||
http.Error(w, "Invalid API key", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
allowed, err := m.store.CheckRateLimit(apiKey)
|
||||
if err != nil || !allowed {
|
||||
m.logger.Warnf("REST: Rate limit exceeded for key=%s from %s", key.ClientID, r.RemoteAddr)
|
||||
w.Header().Set("Retry-After", "60")
|
||||
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
// Update last used timestamp
|
||||
if err := m.store.UpdateLastUsed(apiKey); err != nil {
|
||||
m.logger.Errorf("REST: Failed to update last used time for key=%s - error=%v", key.ClientID, err)
|
||||
}
|
||||
|
||||
// Create auth context and attach to request
|
||||
authCtx := &AuthContext{
|
||||
APIKey: key,
|
||||
ClientID: key.ClientID,
|
||||
Roles: key.Roles,
|
||||
}
|
||||
|
||||
// Add auth context to request context
|
||||
ctx := ContextWithAuth(r.Context(), authCtx)
|
||||
m.logger.Debugf("REST: Authenticated request from client=%s with roles=%v", key.ClientID, key.Roles)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// extractAPIKey extracts API key from Authorization header or X-API-Key header
|
||||
func (m *RESTAuthMiddleware) extractAPIKey(r *http.Request) string {
|
||||
// Try Authorization header first (Bearer token)
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader != "" {
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
|
||||
return parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Try X-API-Key header
|
||||
if apiKey := r.Header.Get("X-API-Key"); apiKey != "" {
|
||||
return apiKey
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type Config struct {
|
||||
Logging LoggingConfig `mapstructure:"logging"`
|
||||
Metrics MetricsConfig `mapstructure:"metrics"`
|
||||
HealthCheck HealthCheckConfig `mapstructure:"health_check"`
|
||||
Auth AuthConfig `mapstructure:"auth"`
|
||||
ConfigFile string `mapstructure:"-"` // Path to config file used (not from config)
|
||||
}
|
||||
|
||||
@@ -61,6 +62,12 @@ type HealthCheckConfig struct {
|
||||
Interval int `mapstructure:"interval"` // seconds
|
||||
}
|
||||
|
||||
// AuthConfig contains authentication and authorization configuration
|
||||
type AuthConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"` // Enable API key authentication
|
||||
DefaultRateLimit int `mapstructure:"default_rate_limit"` // Default rate limit in requests/minute (0 = unlimited)
|
||||
}
|
||||
|
||||
// Load loads configuration from file and environment variables
|
||||
// Returns the loaded config and the path to the config file that was used
|
||||
func Load(configPath string) (*Config, error) {
|
||||
@@ -161,6 +168,10 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("health_check.path", "/health")
|
||||
v.SetDefault("health_check.interval", 30)
|
||||
|
||||
// Auth defaults
|
||||
v.SetDefault("auth.enabled", false) // Authentication disabled by default
|
||||
v.SetDefault("auth.default_rate_limit", 100) // 100 requests per minute default
|
||||
|
||||
// Notifier defaults
|
||||
v.SetDefault("notifiers.stdout", true)
|
||||
// Note: SMTP, Slack, and Ntfy now use named instances (maps)
|
||||
|
||||
@@ -35,6 +35,9 @@ type NtfyConfig struct {
|
||||
|
||||
// Default marks this instance as default
|
||||
Default bool `mapstructure:"default"`
|
||||
|
||||
// AllowedRoles are roles allowed to use this notifier (empty = all authenticated)
|
||||
AllowedRoles []string `mapstructure:"allowed_roles"`
|
||||
}
|
||||
|
||||
// NtfyNotifier sends notifications via ntfy.sh
|
||||
|
||||
@@ -13,13 +13,14 @@ import (
|
||||
|
||||
// SlackConfig contains Slack webhook configuration
|
||||
type SlackConfig struct {
|
||||
WebhookURL string `mapstructure:"webhook_url"`
|
||||
Token string `mapstructure:"token"`
|
||||
Channel string `mapstructure:"channel"`
|
||||
Username string `mapstructure:"username"`
|
||||
IconEmoji string `mapstructure:"icon_emoji"`
|
||||
Webhooks map[string]string `mapstructure:"webhooks"` // Channel-specific webhooks
|
||||
Default bool `mapstructure:"default"` // Mark this instance as default
|
||||
WebhookURL string `mapstructure:"webhook_url"`
|
||||
Token string `mapstructure:"token"`
|
||||
Channel string `mapstructure:"channel"`
|
||||
Username string `mapstructure:"username"`
|
||||
IconEmoji string `mapstructure:"icon_emoji"`
|
||||
Webhooks map[string]string `mapstructure:"webhooks"` // Channel-specific webhooks
|
||||
Default bool `mapstructure:"default"` // Mark this instance as default
|
||||
AllowedRoles []string `mapstructure:"allowed_roles"` // Roles allowed to use this notifier (empty = all authenticated)
|
||||
}
|
||||
|
||||
// SlackNotifier sends notifications to Slack
|
||||
|
||||
@@ -16,14 +16,15 @@ import (
|
||||
|
||||
// SMTPConfig contains SMTP server configuration
|
||||
type SMTPConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
From string `mapstructure:"from"`
|
||||
FromName string `mapstructure:"from_name"` // Optional display name for From header
|
||||
UseTLS bool `mapstructure:"use_tls"`
|
||||
Default bool `mapstructure:"default"` // Mark this instance as default
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
From string `mapstructure:"from"`
|
||||
FromName string `mapstructure:"from_name"` // Optional display name for From header
|
||||
UseTLS bool `mapstructure:"use_tls"`
|
||||
Default bool `mapstructure:"default"` // Mark this instance as default
|
||||
AllowedRoles []string `mapstructure:"allowed_roles"` // Roles allowed to use this notifier (empty = all authenticated)
|
||||
}
|
||||
|
||||
// SMTPNotifier sends notifications via email using SMTP
|
||||
|
||||
Reference in New Issue
Block a user