10 Commits

Author SHA1 Message Date
igodwin 3e84b9c3ad build(notifier): support multi-arch image builds
Build on the native host arch (--platform=$BUILDPLATFORM) and cross-compile
the static binary per target via buildx-provided TARGETOS/TARGETARCH, so
`make docker-build` with REGISTRY set produces linux/amd64 + linux/arm64
images without emulating the Go toolchain under QEMU. Also correct the CMD
comment to the real env var (NOTIFIER_SERVER_MODE).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:19:56 -07:00
igodwin f060c09668 docs(notifier): refresh API reference and fix env/registry examples
Document the previously-missing endpoints (GET /api/v1/notifiers and the
/api/v1/admin/keys management routes), mark the gRPC API and API-key auth
as implemented, and add an Authentication section plus links to the docs
guides. Fix incorrect examples: the env prefix is NOTIFIER_ (so
NOTIFIER_SERVER_MODE, not SERVER_MODE) and the multi-arch build var is
REGISTRY (not DOCKER_REGISTRY).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:19:56 -07:00
igodwin fa813c011c build: route docker-build through buildx for multi-arch + push
Setting REGISTRY now switches `make docker-build` from a single-arch
local build to a multi-arch (linux/amd64+linux/arm64) buildx build
that pushes $REGISTRY/$IMAGE:$VERSION and :latest. The redundant
docker-buildx target is removed. Bump the builder base image to
golang:1.25-alpine so protoc-gen-go-grpc@latest installs cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 00:07:59 -07:00
igodwin ac3ba35736 Add html_body field for multipart email notifications
Callers can now supply a plain-text Body alongside an HTML html_body;
the SMTP sender emits multipart/alternative using both verbatim instead
of auto-stripping HTML to derive the plain-text fallback. The legacy
content_type=HTML path is preserved (deprecated) for existing callers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 23:59:12 -07:00
igodwin a35b3e6283 Fix nil pointer panic when notifier Send returns nil result
Guard against nil result before accessing result.Error in processNotification,
and add NtfyNotifier.Validate override so DefaultTopic is considered before
rejecting notifications with zero recipients.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 01:06:27 -07:00
egodwin c04db89633 Add multi-architecture Docker build support via buildx
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 15:27:48 -07:00
igodwin 298c960808 Fix 8 high-severity audit findings across security, Go, API, and container domains
- Use typed context key for auth context to prevent collisions (auth.go)
- Eliminate nested locking in CheckRateLimit to prevent potential deadlock (auth.go)
- Add 1MB request body size limit middleware to prevent DoS (router.go)
- Return proper gRPC status codes instead of nil errors on failures (handler.go)
- Use key name instead of raw API key in admin URL paths to prevent secret leakage (keys.go, router.go, keystore_db.go, keystore_hybrid.go)
- Enforce RBAC authorization in service Send/SendBatch for both REST and gRPC (service.go)
- Pin runtime Docker image to alpine:3.21 for reproducible builds (Dockerfile)
- Enable readOnlyRootFilesystem with /tmp emptyDir in k8s deployment (deployment.yaml)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 20:17:51 -07:00
igodwin 71b02758d7 Fix HTML content type handling with case-insensitive validation
- Normalize content_type to lowercase before processing to handle case-insensitive input (e.g., "HTML", "Html")
- Add validation in Validate() to ensure only valid content types are accepted
- Return descriptive error message if invalid content type is provided
- Fix condition in ToNotification() to properly detect and default content type
- Update SMTP notifier auto-detection to work correctly when content type is not explicitly set

Issues fixed:
1. Dynamically detecting content type now works correctly (was always defaulting to "text")
2. Client can now specify content type in request as "HTML", "html", or "Html" - all work
3. Invalid content types are rejected with clear error messages
4. Auto-detection still works if neither explicit type nor valid HTML markers are found

Example scenarios:
- No content_type field: auto-detects based on body (checks for <, <html, <!DOCTYPE, <p>, <div>, <br>)
- content_type: "html": sends as HTML with multipart/alternative
- content_type: "HTML": normalized to "html", sends as HTML
- content_type: "invalid": returns validation error
- content_type: "text": explicitly sends as plain text, skips auto-detection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 01:49:38 -07:00
igodwin 4e594d3a7d Fix RBAC authorization logic to only return notifiers with matching roles
- Change IsAuthorized() to use deny-by-default when RBAC is enabled
- If ANY authorization rules are configured, only notifiers with explicit allowed_roles are accessible
- Notifiers without rules are denied access when RBAC is active
- If NO rules are configured, maintain open access for backward compatibility
- Add HasRules() helper method to check if RBAC is enabled

This fixes the issue where notifiers WITHOUT allowed_roles were being returned instead of
the notifiers WITH matching allowed_roles. Now when RBAC is configured:
- Only notifiers with explicit rules that match the user's roles are returned
- All other notifiers are hidden from the client

Example: If only email has allowed_roles=['admin'] and user has role 'admin':
- OLD: email ✓, stdout ✓ (WRONG - stdout should be hidden)
- NEW: email ✓, stdout ✗ (CORRECT - only email is returned)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 01:43:18 -07:00
igodwin 5eaf6fe6fb Redact database URL passwords from logs
- Add SanitizeDatabaseURL() function to config package that redacts passwords from database connection URLs
- Handles various URL formats: postgresql, mysql, etc.
- Correctly handles passwords containing special characters including @ symbols by using LastIndex
- Update startup logging in cmd/server/main.go to use sanitized database URL
- Add comprehensive tests covering various URL formats and edge cases

This ensures sensitive database credentials are not exposed in application logs.
2025-10-31 00:40:33 -07:00
20 changed files with 585 additions and 120 deletions
+11 -6
View File
@@ -1,5 +1,10 @@
# Build stage
FROM golang:1.24-alpine AS builder
# Build stage — pinned to the build host's arch so cross-compilation
# via GOOS/GOARCH is fast and avoids QEMU emulation of the toolchain.
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
# Platform args populated by buildx for each target in a multi-platform build.
ARG TARGETOS
ARG TARGETARCH
# Build arguments
ARG VERSION=dev
@@ -29,13 +34,13 @@ COPY . .
# Generate protobuf code
RUN make proto-gen
# Build binary with version information
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \
# Build binary with version information, cross-compiling to the target arch.
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -installsuffix cgo \
-ldflags "-X main.Version=${VERSION} -X main.GitCommit=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME} ${BUILD_FLAGS}" \
-o server ./cmd/server
# Runtime stage
FROM alpine:latest
FROM alpine:3.21
# Install runtime dependencies
RUN apk --no-cache add ca-certificates tzdata
@@ -64,5 +69,5 @@ USER notifier
EXPOSE 8080 50051 9090 8081
# Run server (defaults to both REST and gRPC)
# Override mode with environment variable: -e SERVER_MODE=rest or -e SERVER_MODE=grpc
# Override mode with environment variable: -e NOTIFIER_SERVER_MODE=rest or -e NOTIFIER_SERVER_MODE=grpc
CMD ["/app/server"]
+47 -7
View File
@@ -1,6 +1,9 @@
.PHONY: proto proto-gen proto-clean deps build build-dev run run-grpc run-rest test lint fmt vet check docker-build docker-build-dev docker-run clean help
.PHONY: proto proto-gen proto-clean deps build build-dev run run-grpc run-rest test lint fmt vet check docker-build docker-build-dev docker-buildx-setup docker-run clean help
# Variables
REGISTRY ?=
IMAGE ?= notifier
PLATFORMS ?= linux/amd64,linux/arm64
PROTO_DIR=api/grpc
PROTO_FILE=$(PROTO_DIR)/notifier.proto
PROTO_OUT=$(PROTO_DIR)/pb
@@ -9,8 +12,8 @@ GO_FILES=$(shell find . -type f -name '*.go' -not -path "./vendor/*" -not -path
# Build information
VERSION ?= dev
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S_UTC')
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
BUILD_TIME := $(shell date -u "+%Y-%m-%d_%H:%M:%S_UTC" 2>/dev/null || echo unknown)
# Base LDFLAGS (version info only)
LDFLAGS_BASE := -X main.Version=$(VERSION) -X main.GitCommit=$(GIT_COMMIT) -X main.BuildTime=$(BUILD_TIME)
@@ -139,18 +142,37 @@ qa: fmt vet lint test
@echo "All quality checks passed!"
# Build Docker image (production - optimized)
# When REGISTRY is set, builds a multi-arch image and pushes
# $(REGISTRY)/$(IMAGE):$(VERSION) and :latest.
# Otherwise builds a single-arch local image tagged $(IMAGE):latest.
docker-build:
@echo "Building Docker image (production - optimized)..."
@echo "Version: $(VERSION)"
@echo "Git Commit: $(GIT_COMMIT)"
@echo "Build Time: $(BUILD_TIME)"
ifeq ($(strip $(REGISTRY)),)
@echo "Building single-arch local Docker image..."
docker build \
--build-arg VERSION=$(VERSION) \
--build-arg GIT_COMMIT=$(GIT_COMMIT) \
--build-arg BUILD_TIME=$(BUILD_TIME) \
--build-arg BUILD_FLAGS="-s -w" \
-t notifier:latest .
-t $(IMAGE):latest .
@echo "Docker image built successfully (production - optimized)"
else
@$(MAKE) docker-buildx-setup
@echo "Building multi-arch image ($(PLATFORMS)) and pushing to $(REGISTRY)/$(IMAGE):$(VERSION)..."
docker buildx build \
--platform $(PLATFORMS) \
--build-arg VERSION=$(VERSION) \
--build-arg GIT_COMMIT=$(GIT_COMMIT) \
--build-arg BUILD_TIME=$(BUILD_TIME) \
--build-arg BUILD_FLAGS="-s -w" \
--provenance=false \
--tag $(REGISTRY)/$(IMAGE):$(VERSION) \
--tag $(REGISTRY)/$(IMAGE):latest \
--push .
@echo "Multi-arch image pushed successfully"
endif
# Build Docker image with debug symbols (development)
docker-build-dev:
@@ -166,6 +188,14 @@ docker-build-dev:
-t notifier:latest-dev .
@echo "Docker image built successfully (development)"
# Set up Docker buildx builder for multi-arch builds
docker-buildx-setup:
@echo "Setting up Docker buildx builder..."
@docker buildx inspect multiarch > /dev/null 2>&1 || docker buildx create --name multiarch --driver docker-container
@docker buildx use multiarch
@docker buildx inspect multiarch --bootstrap
@echo "Buildx builder ready"
# Run Docker container
docker-run:
@echo "Running Docker container..."
@@ -212,5 +242,15 @@ help:
@echo " deps - Install Go dependencies"
@echo ""
@echo "Docker:"
@echo " docker-build - Build Docker image"
@echo " docker-run - Run Docker container"
@echo " docker-build - Build Docker image"
@echo " - default: single-arch local image"
@echo " - with REGISTRY set: multi-arch (amd64+arm64) build + push"
@echo " docker-build-dev - Build Docker image with debug symbols"
@echo " docker-buildx-setup - Set up buildx builder for multi-arch"
@echo " docker-run - Run Docker container locally"
@echo ""
@echo "Variables:"
@echo " VERSION - Image version tag (default: dev)"
@echo " REGISTRY - Registry prefix for docker-build (e.g. registry.example.com/org)"
@echo " IMAGE - Image name (default: notifier)"
@echo " PLATFORMS - Build platforms (default: $(PLATFORMS))"
+114 -24
View File
@@ -56,9 +56,9 @@ Server starts with:
**Run in different modes:**
```bash
./bin/server # Both REST and gRPC (default)
SERVER_MODE=rest ./bin/server # REST only
SERVER_MODE=grpc ./bin/server # gRPC only
./bin/server # Both REST and gRPC (default)
NOTIFIER_SERVER_MODE=rest ./bin/server # REST only
NOTIFIER_SERVER_MODE=grpc ./bin/server # gRPC only
```
### 2. Send Your First Notification
@@ -284,15 +284,32 @@ Format: `NOTIFIER_<SECTION>_<KEY>` (use `_` for nested keys)
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/health` | Health check |
| `POST` | `/api/v1/notifications` | Send single notification |
| `POST` | `/api/v1/notifications/batch` | Send multiple notifications |
| `GET` | `/health` | Health check (always unauthenticated) |
| `POST` | `/api/v1/notifications` | Send single notification (returns `202 Accepted`) |
| `POST` | `/api/v1/notifications/batch` | Send multiple notifications (returns `202 Accepted`) |
| `GET` | `/api/v1/notifications` | List notifications (with filters) |
| `GET` | `/api/v1/notifications/{id}` | Get notification by ID |
| `DELETE` | `/api/v1/notifications/{id}` | Cancel pending notification |
| `POST` | `/api/v1/notifications/{id}/retry` | Retry failed notification |
| `GET` | `/api/v1/notifiers` | List configured notifier types and accounts |
| `GET` | `/api/v1/stats` | Get service statistics |
When `auth.enabled` is set, all `/api/v1` routes require an API key (see
[Authentication](#authentication)); `/health` is always open.
#### Admin — API Key Management
Registered only when authentication **and** a key store are configured. See
[docs/KEY_MANAGEMENT.md](docs/KEY_MANAGEMENT.md) for the full workflow.
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v1/admin/keys` | Create an API key |
| `GET` | `/api/v1/admin/keys` | List API keys |
| `DELETE` | `/api/v1/admin/keys/{name}` | Revoke an API key |
| `POST` | `/api/v1/admin/keys/{name}/rotate` | Rotate an API key |
| `GET` | `/api/v1/admin/keys/{name}/audit` | Get a key's audit log |
### Request Format
```json
@@ -352,9 +369,30 @@ curl "http://localhost:8080/api/v1/notifications?type=email&status=sent&limit=10
curl "http://localhost:8080/api/v1/notifications?status=failed&limit=20"
```
Supported query parameters: `type` and `status` (both repeatable),
`recipient` (repeatable), `limit`, and `offset`.
## gRPC API
The gRPC service mirrors the REST API with full feature parity. See [api/grpc/notifier.proto](api/grpc/notifier.proto) for definitions.
The gRPC service mirrors the REST API with full feature parity. The service is
`notifier.v1.NotifierService` (RPCs: `SendNotification`, `SendBatchNotifications`,
`GetNotification`, `ListNotifications`, `CancelNotification`, `RetryNotification`,
`GetStats`, `GetNotifiers`, `HealthCheck`). See
[api/grpc/notifier.proto](api/grpc/notifier.proto) for the full definitions.
Server [reflection](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md)
is enabled, so tools like `grpcurl` work without a local copy of the proto:
```bash
# List methods
grpcurl -plaintext localhost:50051 list notifier.v1.NotifierService
# Call GetNotifiers
grpcurl -plaintext localhost:50051 notifier.v1.NotifierService/GetNotifiers
```
When `auth.enabled` is set, pass the API key as metadata
(`-H "authorization: Bearer <key>"`); see [docs/AUTH.md](docs/AUTH.md).
**Generate Go code:**
```bash
@@ -363,17 +401,55 @@ make proto-gen
protoc --go_out=. --go-grpc_out=. api/grpc/notifier.proto
```
**Note:** gRPC server is running but handler implementation is pending. Protobuf definitions are complete.
## Authentication
Authentication is **disabled by default** (`auth.enabled: false`). When enabled,
the service uses API keys with optional role-based access control (RBAC) over
both the REST and gRPC APIs:
```yaml
auth:
enabled: true
default_rate_limit: 100 # requests per minute per key
bootstrap:
enabled: true # mint an admin key on first start
print_to_stdout: false
kubernetes_secret_name: notifier-admin-key
kubernetes_secret_key: admin-key
```
- **REST:** send `Authorization: Bearer <key>` (or `X-API-Key: <key>`).
- **gRPC:** send an `authorization: Bearer <key>` metadata header.
- Keys can be managed at runtime via the [admin endpoints](#admin--api-key-management).
- An admin key can be bootstrapped to stdout, a file, or a Kubernetes Secret.
See the guides for details:
- [docs/AUTH_QUICK_START.md](docs/AUTH_QUICK_START.md) — 5-minute setup
- [docs/AUTH.md](docs/AUTH.md) — full authentication & client examples
- [docs/KEY_MANAGEMENT.md](docs/KEY_MANAGEMENT.md) — key lifecycle (create/rotate/revoke/audit)
- [docs/RBAC.md](docs/RBAC.md) — role-based access to notifier accounts
## Deployment
### Docker
**Build:**
**Build (single-arch, local):**
```bash
docker build -t notifier:latest .
make docker-build
```
**Build and push a multi-arch image (linux/amd64 + linux/arm64):**
```bash
# Pushes $REGISTRY/$IMAGE:$VERSION and :latest.
# Requires Docker buildx (bundled with Docker Desktop; on Linux also run
# `docker run --rm --privileged tonistiigi/binfmt --install all` once to
# register the QEMU emulators).
REGISTRY=registry.example.com/org VERSION=v0.1.3 make docker-build
```
Override `IMAGE` (default `notifier`) or `PLATFORMS` (default
`linux/amd64,linux/arm64`) as needed.
**Run:**
```bash
docker run -d \
@@ -390,10 +466,10 @@ docker run -d \
docker run -d -p 8080:8080 -p 50051:50051 notifier:latest
# REST only
docker run -d -p 8080:8080 -e SERVER_MODE=rest notifier:latest
docker run -d -p 8080:8080 -e NOTIFIER_SERVER_MODE=rest notifier:latest
# gRPC only
docker run -d -p 50051:50051 -e SERVER_MODE=grpc notifier:latest
docker run -d -p 50051:50051 -e NOTIFIER_SERVER_MODE=grpc notifier:latest
```
**Docker Compose:**
@@ -485,17 +561,20 @@ notifier/
│ │ └── notifier.proto # gRPC service definition
│ └── rest/
│ ├── handlers.go # HTTP handlers
│ ├── keys.go # API key management handlers
│ ├── router.go # Route configuration
│ └── types.go # Request/response types
├── cmd/
│ └── server/main.go # Unified server (configurable mode)
├── internal/
│ ├── auth/ # API key auth, RBAC, key store, bootstrap
│ ├── config/
│ │ └── config.go # Configuration management
│ ├── domain/
│ │ ├── notification.go # Core types
│ │ ├── notifier.go # Notifier interface
│ │ └── queue.go # Queue interface
│ ├── logging/ # Structured logger
│ ├── notifier/
│ │ ├── notifier.go # Factory & base
│ │ ├── smtp.go # Email notifier
@@ -513,8 +592,7 @@ notifier/
│ ├── ingress.yaml
│ ├── hpa.yaml
│ └── kustomization.yaml
├── docs/
│ └── NTFY_GUIDE.md # Ntfy integration guide
├── docs/ # Guides & references (see docs/INDEX.md)
├── config.yaml # Default configuration
├── docker-compose.yaml
├── Dockerfile
@@ -541,7 +619,7 @@ make lint # Run golangci-lint
make check # Run fmt-check + vet + mod verify
make qa # Run all quality checks
make proto-gen # Generate protobuf code
make docker-build # Build Docker image
make docker-build # Build Docker image (multi-arch + push when REGISTRY is set)
make clean # Clean build artifacts
make help # Show all available targets
```
@@ -573,9 +651,17 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed guide.
- [QUICKSTART.md](QUICKSTART.md) - Quick start guide with examples
- [ARCHITECTURE.md](ARCHITECTURE.md) - Architecture and design details
- [docs/NTFY_GUIDE.md](docs/NTFY_GUIDE.md) - Ntfy integration guide
- [docs/INDEX.md](docs/INDEX.md) - Full documentation index
- [api/grpc/notifier.proto](api/grpc/notifier.proto) - gRPC API definition
**Guides:**
- [docs/AUTH.md](docs/AUTH.md) / [docs/AUTH_QUICK_START.md](docs/AUTH_QUICK_START.md) - Authentication & RBAC
- [docs/KEY_MANAGEMENT.md](docs/KEY_MANAGEMENT.md) - API key lifecycle
- [docs/EMAIL_GUIDE.md](docs/EMAIL_GUIDE.md) - Email/SMTP setup
- [docs/NTFY_GUIDE.md](docs/NTFY_GUIDE.md) - Ntfy integration
- [docs/TLS_SECURITY.md](docs/TLS_SECURITY.md) - TLS configuration
- [docs/CORS_KUBERNETES_GUIDE.md](docs/CORS_KUBERNETES_GUIDE.md) - CORS in Kubernetes
## Testing
```bash
@@ -589,7 +675,9 @@ go test -cover ./...
go test ./internal/notifier/...
```
**Current Status:** Core implementation complete, tests pending.
**Current Status:** Core implementation complete with unit tests across the
config, service, notifier, and auth packages. Run `make test` (race detector
enabled) or `make test-coverage` for an HTML report.
## Monitoring & Operations
@@ -647,27 +735,29 @@ The server handles `SIGINT` and `SIGTERM` gracefully:
### Completed ✅
- [x] Core notification system
- [x] REST API (fully functional)
- [x] gRPC API (protobuf defined)
- [x] gRPC API (fully functional, with reflection)
- [x] Local queue with workers
- [x] Multiple notifiers (SMTP, Slack, Ntfy, Stdout)
- [x] Priority and retry logic
- [x] Batch operations
- [x] API key authentication with RBAC and runtime key management
- [x] CORS support
- [x] Notification retention/cleanup
- [x] Docker support
- [x] Kubernetes manifests with HPA
- [x] Health checks and stats
- [x] Configuration management
- [x] Unit test suite (race detector)
### Planned 🚧
- [ ] gRPC handler implementation
- [ ] Kafka queue adapter
- [ ] Database persistence (PostgreSQL)
- [ ] Database-backed notification persistence (PostgreSQL)
- [ ] Notification templates
- [ ] Webhook callbacks
- [ ] Authentication/Authorization (API keys, OAuth)
- [ ] Rate limiting (per client, per notifier)
- [ ] Prometheus metrics
- [ ] OAuth authentication
- [ ] Per-notifier rate limiting
- [ ] Prometheus metrics endpoint
- [ ] OpenTelemetry tracing
- [ ] Comprehensive test suite
- [ ] Circuit breakers for notifiers
- [ ] Dead letter queue
- [ ] Admin dashboard
+8 -9
View File
@@ -8,6 +8,8 @@ import (
pb "github.com/igodwin/notifier/api/grpc/pb"
"github.com/igodwin/notifier/internal/domain"
"github.com/igodwin/notifier/internal/logging"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -64,6 +66,7 @@ func (h *NotifierHandler) SendNotification(ctx context.Context, req *pb.SendNoti
Priority: domain.Priority(req.Priority),
Subject: req.Subject,
Body: req.Body,
HTMLBody: req.HtmlBody,
ContentType: contentType,
Recipients: req.Recipients,
CC: req.Cc,
@@ -82,12 +85,7 @@ func (h *NotifierHandler) SendNotification(ctx context.Context, req *pb.SendNoti
if err != nil {
h.logger.Errorf("gRPC: Failed to send notification - type=%s, account=%s, error=%v",
req.Type, req.Account, err)
return &pb.SendNotificationResponse{
Result: &pb.NotificationResult{
Success: false,
Error: err.Error(),
},
}, nil
return nil, status.Errorf(codes.Internal, "failed to send notification: %v", err)
}
// Log success
@@ -139,7 +137,7 @@ func (h *NotifierHandler) SendBatchNotifications(ctx context.Context, req *pb.Se
func (h *NotifierHandler) GetNotification(ctx context.Context, req *pb.GetNotificationRequest) (*pb.GetNotificationResponse, error) {
notification, err := h.service.GetNotification(ctx, req.Id)
if err != nil {
return nil, err
return nil, status.Errorf(codes.NotFound, "notification not found: %v", err)
}
return &pb.GetNotificationResponse{
@@ -154,7 +152,7 @@ func (h *NotifierHandler) ListNotifications(ctx context.Context, req *pb.ListNot
notifications, err := h.service.ListNotifications(ctx, filter)
if err != nil {
return nil, err
return nil, status.Errorf(codes.Internal, "failed to list notifications: %v", err)
}
protoNotifications := make([]*pb.Notification, len(notifications))
@@ -230,7 +228,7 @@ func (h *NotifierHandler) GetNotifiers(ctx context.Context, req *pb.GetNotifiers
notifiers, err := h.service.GetNotifiers(ctx)
if err != nil {
h.logger.Errorf("gRPC: Failed to get notifiers - error=%v", err)
return nil, err
return nil, status.Errorf(codes.Internal, "failed to get notifiers: %v", err)
}
// Convert domain notifiers to proto notifiers
@@ -371,6 +369,7 @@ func convertDomainToProtoNotification(notif *domain.Notification) *pb.Notificati
Status: convertDomainToProtoStatus(notif.Status),
Subject: notif.Subject,
Body: notif.Body,
HtmlBody: notif.HTMLBody,
Recipients: notif.Recipients,
Metadata: convertInterfaceMapToString(notif.Metadata),
CreatedAt: timestamppb.New(notif.CreatedAt),
+4 -2
View File
@@ -81,7 +81,8 @@ message Notification {
NotificationStatus status = 5;
string subject = 6;
string body = 7;
ContentType content_type = 18; // Format of the body (text or html)
ContentType content_type = 18 [deprecated = true]; // Deprecated: use html_body instead. Format of the body (text or html).
string html_body = 19; // Optional HTML body for email; if set, sends multipart/alternative with body as text/plain and html_body as text/html. Ignored for non-email types.
repeated string recipients = 8;
repeated string cc = 16; // Carbon copy recipients (email only)
repeated string bcc = 17; // Blind carbon copy recipients (email only)
@@ -111,13 +112,14 @@ message SendNotificationRequest {
Priority priority = 3;
string subject = 4;
string body = 5;
ContentType content_type = 12; // Format of the body (text or html) - auto-detected if not specified
ContentType content_type = 12 [deprecated = true]; // Deprecated: use html_body instead. Format of the body (text or html) - auto-detected if not specified.
repeated string recipients = 6;
repeated string cc = 10; // Carbon copy recipients (email only)
repeated string bcc = 11; // Blind carbon copy recipients (email only)
map<string, string> metadata = 7;
google.protobuf.Timestamp scheduled_for = 8;
int32 max_retries = 9;
string html_body = 13; // Optional HTML body for email; if set, sends multipart/alternative with body as text/plain and html_body as text/html. Ignored for non-email types.
}
// SendNotificationResponse returns the result of sending a notification
+19 -17
View File
@@ -8,6 +8,7 @@ import (
"strings"
"time"
"github.com/gorilla/mux"
"github.com/igodwin/notifier/internal/auth"
"github.com/igodwin/notifier/internal/logging"
)
@@ -76,7 +77,7 @@ func (h *KeyManagementHandler) CreateKey(w http.ResponseWriter, r *http.Request)
ctx := r.Context()
// Check authorization - must have admin role
authCtx, ok := ctx.Value("auth").(*auth.AuthContext)
authCtx, ok := auth.GetAuthContext(ctx)
if !ok || !h.hasRole(authCtx, "admin") {
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
return
@@ -143,7 +144,7 @@ func (h *KeyManagementHandler) CreateKey(w http.ResponseWriter, r *http.Request)
func (h *KeyManagementHandler) ListKeys(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
authCtx, ok := ctx.Value("auth").(*auth.AuthContext)
authCtx, ok := auth.GetAuthContext(ctx)
if !ok {
h.respondError(w, http.StatusUnauthorized, "Unauthorized", "")
return
@@ -193,24 +194,25 @@ type RevokeKeyRequest struct {
}
// RevokeKey deactivates an API key
// DELETE /api/v1/admin/keys/:key
// DELETE /api/v1/admin/keys/:name
// Requires: admin role
func (h *KeyManagementHandler) RevokeKey(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
authCtx, ok := ctx.Value("auth").(*auth.AuthContext)
authCtx, ok := auth.GetAuthContext(ctx)
if !ok || !h.hasRole(authCtx, "admin") {
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
return
}
// Extract key from path parameter
keyStr := strings.TrimPrefix(r.URL.Path, "/api/v1/admin/keys/")
// Extract key name from path parameter (not the raw key, to avoid leaking secrets in URLs)
vars := mux.Vars(r)
keyName := vars["name"]
var req RevokeKeyRequest
_ = json.NewDecoder(r.Body).Decode(&req) // Ignore decode errors, reason is optional
err := h.keyStore.DeactivateKey(ctx, keyStr, authCtx.ClientID)
err := h.keyStore.DeactivateKeyByName(ctx, keyName, authCtx.ClientID)
if err != nil {
if strings.Contains(err.Error(), "not found") {
h.respondError(w, http.StatusNotFound, "Key not found", "")
@@ -231,19 +233,19 @@ type RotateKeyRequest struct {
}
// RotateKey creates a new API key to replace the old one
// POST /api/v1/admin/keys/:key/rotate
// POST /api/v1/admin/keys/:name/rotate
// Requires: admin role
func (h *KeyManagementHandler) RotateKey(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
authCtx, ok := ctx.Value("auth").(*auth.AuthContext)
authCtx, ok := auth.GetAuthContext(ctx)
if !ok || !h.hasRole(authCtx, "admin") {
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
return
}
oldKeyStr := strings.TrimPrefix(r.URL.Path, "/api/v1/admin/keys/")
oldKeyStr = strings.TrimSuffix(oldKeyStr, "/rotate")
vars := mux.Vars(r)
_ = vars["name"] // Key name from URL (rotation not yet implemented)
var req RotateKeyRequest
_ = json.NewDecoder(r.Body).Decode(&req)
@@ -265,19 +267,19 @@ type GetAuditLogResponse struct {
}
// GetAuditLog retrieves the audit log for a key
// GET /api/v1/admin/keys/:key/audit
// GET /api/v1/admin/keys/:name/audit
// Requires: admin role
func (h *KeyManagementHandler) GetAuditLog(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
authCtx, ok := ctx.Value("auth").(*auth.AuthContext)
authCtx, ok := auth.GetAuthContext(ctx)
if !ok || !h.hasRole(authCtx, "admin") {
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
return
}
keyStr := strings.TrimPrefix(r.URL.Path, "/api/v1/admin/keys/")
keyStr = strings.TrimSuffix(keyStr, "/audit")
vars := mux.Vars(r)
keyName := vars["name"]
limit := 100
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
@@ -286,7 +288,7 @@ func (h *KeyManagementHandler) GetAuditLog(w http.ResponseWriter, r *http.Reques
}
}
logs, err := h.keyStore.GetAuditLog(ctx, keyStr, limit)
logs, err := h.keyStore.GetAuditLogByName(ctx, keyName, limit)
if err != nil {
h.logger.Errorf("Failed to get audit log: %v", err)
h.respondError(w, http.StatusInternalServerError, "Failed to get audit log", err.Error())
@@ -294,7 +296,7 @@ func (h *KeyManagementHandler) GetAuditLog(w http.ResponseWriter, r *http.Reques
}
resp := GetAuditLogResponse{
Key: "nk_" + keyStr[len(keyStr)-4:],
Key: keyName,
AuditLog: logs,
}
+17 -4
View File
@@ -86,20 +86,33 @@ func NewRouterWithAuthAndKeyStore(service domain.NotificationService, logger *lo
keyHandler := NewKeyManagementHandler(keyStore, logger)
v1.HandleFunc("/admin/keys", keyHandler.CreateKey).Methods(http.MethodPost)
v1.HandleFunc("/admin/keys", keyHandler.ListKeys).Methods(http.MethodGet)
v1.HandleFunc("/admin/keys/{key}", keyHandler.RevokeKey).Methods(http.MethodDelete)
v1.HandleFunc("/admin/keys/{key}/rotate", keyHandler.RotateKey).Methods(http.MethodPost)
v1.HandleFunc("/admin/keys/{key}/audit", keyHandler.GetAuditLog).Methods(http.MethodGet)
v1.HandleFunc("/admin/keys/{name}", keyHandler.RevokeKey).Methods(http.MethodDelete)
v1.HandleFunc("/admin/keys/{name}/rotate", keyHandler.RotateKey).Methods(http.MethodPost)
v1.HandleFunc("/admin/keys/{name}/audit", keyHandler.GetAuditLog).Methods(http.MethodGet)
}
// Health check route (no auth required)
router.HandleFunc("/health", handler.HealthCheck).Methods(http.MethodGet)
// Middleware - logging and CORS
// Middleware - logging, request size limit, and CORS
router.Use(loggingMiddleware)
v1.Use(maxBodySizeMiddleware(1 << 20)) // 1 MB limit on API request bodies
return router
}
// maxBodySizeMiddleware limits the size of incoming request bodies to prevent DoS.
func maxBodySizeMiddleware(maxBytes int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
}
next.ServeHTTP(w, r)
})
}
}
// loggingMiddleware logs incoming requests
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+17 -3
View File
@@ -2,6 +2,7 @@ package rest
import (
"fmt"
"strings"
"time"
"github.com/google/uuid"
@@ -15,7 +16,8 @@ type SendNotificationRequest struct {
Priority int `json:"priority,omitempty"`
Subject string `json:"subject"`
Body string `json:"body"`
ContentType string `json:"content_type,omitempty"` // "text" or "html" - auto-detected if not specified
HTMLBody string `json:"html_body,omitempty"` // Optional HTML body for email; if set, sends multipart/alternative.
ContentType string `json:"content_type,omitempty"` // Deprecated: prefer html_body. "text" or "html".
Recipients []string `json:"recipients"`
CC []string `json:"cc,omitempty"` // Carbon copy recipients (email only)
BCC []string `json:"bcc,omitempty"` // Blind carbon copy recipients (email only)
@@ -41,6 +43,14 @@ func (r *SendNotificationRequest) Validate() error {
return fmt.Errorf("body is required")
}
// Validate content type if specified (must be "text" or "html", case-insensitive)
if r.ContentType != "" {
contentTypeLower := strings.ToLower(r.ContentType)
if contentTypeLower != "text" && contentTypeLower != "html" {
return fmt.Errorf("invalid content_type: must be 'text' or 'html' (got %q)", r.ContentType)
}
}
return nil
}
@@ -52,8 +62,9 @@ func (r *SendNotificationRequest) ToNotification() *domain.Notification {
}
// Convert content type, defaulting to text
contentType := domain.ContentType(r.ContentType)
if contentType == "" {
// Normalize to lowercase to handle case-insensitive input (e.g., "HTML" -> "html")
contentType := domain.ContentType(strings.ToLower(r.ContentType))
if contentType == "" || contentType != domain.ContentTypeHTML {
contentType = domain.ContentTypeText
}
@@ -65,6 +76,7 @@ func (r *SendNotificationRequest) ToNotification() *domain.Notification {
Status: domain.StatusPending,
Subject: r.Subject,
Body: r.Body,
HTMLBody: r.HTMLBody,
ContentType: contentType,
Recipients: r.Recipients,
CC: r.CC,
@@ -101,6 +113,7 @@ type Notification struct {
Status string `json:"status"`
Subject string `json:"subject"`
Body string `json:"body"`
HTMLBody string `json:"html_body,omitempty"`
ContentType string `json:"content_type,omitempty"`
Recipients []string `json:"recipients"`
CC []string `json:"cc,omitempty"`
@@ -124,6 +137,7 @@ func NotificationFromDomain(n *domain.Notification) Notification {
Status: string(n.Status),
Subject: n.Subject,
Body: n.Body,
HTMLBody: n.HTMLBody,
ContentType: string(n.ContentType),
Recipients: n.Recipients,
CC: n.CC,
+1 -1
View File
@@ -103,7 +103,7 @@ func main() {
if err != nil {
logger.Fatalf("Failed to create database key store: %v", err)
}
logger.Infof("Connected to authentication database: %s", cfg.Auth.Database.URL)
logger.Infof("Connected to authentication database: %s", config.SanitizeDatabaseURL(cfg.Auth.Database.URL))
} else {
logger.Warn("No database configured for authentication - API keys will only be stored in memory")
}
+13 -5
View File
@@ -115,24 +115,29 @@ func (s *APIKeyStore) ValidateKey(keyStr string) (*APIKey, error) {
// 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()
// Look up key and limiter under the store lock, then release it
// before acquiring the per-key limiter lock to avoid nested locking.
s.mu.RLock()
key, exists := s.keys[keyStr]
if !exists {
s.mu.RUnlock()
return false, fmt.Errorf("invalid API key")
}
// Unlimited rate limit
if key.RateLimit <= 0 {
s.mu.RUnlock()
return true, nil
}
limiter, exists := s.rateLimits[keyStr]
if !exists {
s.mu.RUnlock()
return false, fmt.Errorf("rate limiter not found")
}
s.mu.RUnlock()
// Now lock only the per-key rate limiter
limiter.mu.Lock()
defer limiter.mu.Unlock()
@@ -206,13 +211,16 @@ func (s *APIKeyStore) ListKeys(clientID string) []*APIKey {
return keys
}
// authContextKey is an unexported type for context keys to avoid collisions.
type authContextKey struct{}
// ContextWithAuth adds auth context to a request context
func ContextWithAuth(ctx context.Context, auth *AuthContext) context.Context {
return context.WithValue(ctx, "auth", auth)
return context.WithValue(ctx, authContextKey{}, auth)
}
// GetAuthContext retrieves auth context from a request context
func GetAuthContext(ctx context.Context) (*AuthContext, bool) {
auth, ok := ctx.Value("auth").(*AuthContext)
auth, ok := ctx.Value(authContextKey{}).(*AuthContext)
return auth, ok
}
+22 -11
View File
@@ -34,21 +34,32 @@ func (a *NotifierAuthz) IsAuthorized(auth *AuthContext, notificationType domain.
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
// If RBAC is enabled (at least one rule exists), restrict access:
// - Notifiers with explicit rules: check if user has allowed roles
// - Notifiers without rules: deny access (must be explicitly allowed)
if a.HasRules() {
if !exists {
// RBAC is enabled but this notifier has no rule - deny access
return false
}
// 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
}
return false
// If no rules are registered at all, allow all authenticated users (open access)
return true
}
// HasRules returns true if any authorization rules have been registered
func (a *NotifierAuthz) HasRules() bool {
return len(a.rules) > 0
}
// GetAllowedRoles returns the allowed roles for a notifier
+83
View File
@@ -347,6 +347,89 @@ func (ks *KeyStoreDB) GetAuditLog(ctx context.Context, keyStr string, limit int)
return logs, rows.Err()
}
// GetKeyByName retrieves an API key by its name
func (ks *KeyStoreDB) GetKeyByName(ctx context.Context, name string) (*APIKey, error) {
query := `
SELECT key, name, client_id, roles, created_at, last_used_at, expires_at, is_active, rate_limit
FROM api_keys
WHERE name = $1
`
var key APIKey
var roles []string
err := ks.db.QueryRowContext(ctx, query, name).Scan(
&key.Key,
&key.Name,
&key.ClientID,
pq.Array(&roles),
&key.CreatedAt,
&key.LastUsedAt,
&key.ExpiresAt,
&key.IsActive,
&key.RateLimit,
)
if err == sql.ErrNoRows {
return nil, ErrKeyNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get key by name: %w", err)
}
key.Roles = roles
return &key, nil
}
// DeactivateKeyByName disables an API key by its name
func (ks *KeyStoreDB) DeactivateKeyByName(ctx context.Context, name string, deactivatedBy string) error {
// First get the key to find its raw key for cache invalidation and audit
key, err := ks.GetKeyByName(ctx, name)
if err != nil {
return err
}
return ks.DeactivateKey(ctx, key.Key, deactivatedBy)
}
// GetAuditLogByName retrieves audit log entries for a key identified by name
func (ks *KeyStoreDB) GetAuditLogByName(ctx context.Context, name string, limit int) ([]map[string]interface{}, error) {
query := `
SELECT al.action, al.performed_by, al.performed_at, al.details
FROM api_key_audit_log al
JOIN api_keys ak ON al.key_id = ak.id
WHERE ak.name = $1
ORDER BY al.performed_at DESC
LIMIT $2
`
rows, err := ks.db.QueryContext(ctx, query, name, limit)
if err != nil {
return nil, fmt.Errorf("failed to get audit log: %w", err)
}
defer rows.Close()
var logs []map[string]interface{}
for rows.Next() {
var action, performedBy, details string
var performedAt time.Time
err := rows.Scan(&action, &performedBy, &performedAt, &details)
if err != nil {
return nil, err
}
logs = append(logs, map[string]interface{}{
"action": action,
"performed_by": performedBy,
"performed_at": performedAt,
"details": details,
})
}
return logs, rows.Err()
}
// Custom errors
var (
ErrKeyNotFound = fmt.Errorf("API key not found")
+26
View File
@@ -136,6 +136,32 @@ func (h *HybridKeyStore) GetAuditLog(ctx context.Context, keyStr string, limit i
return h.db.GetAuditLog(ctx, keyStr, limit)
}
// DeactivateKeyByName deactivates a key by its name (avoids exposing raw key in URLs)
func (h *HybridKeyStore) DeactivateKeyByName(ctx context.Context, name string, deactivatedBy string) error {
h.mu.Lock()
defer h.mu.Unlock()
// Look up the key by name in DB to get the raw key for cache invalidation
key, err := h.db.GetKeyByName(ctx, name)
if err != nil {
return err
}
// Remove from cache
h.cache.mu.Lock()
delete(h.cache.keys, key.Key)
delete(h.cache.rateLimits, key.Key)
h.cache.mu.Unlock()
// Deactivate in database
return h.db.DeactivateKey(ctx, key.Key, deactivatedBy)
}
// GetAuditLogByName retrieves audit log for a key identified by name
func (h *HybridKeyStore) GetAuditLogByName(ctx context.Context, name string, limit int) ([]map[string]interface{}, error) {
return h.db.GetAuditLogByName(ctx, name, limit)
}
// Close closes the database connection
func (h *HybridKeyStore) Close() error {
return h.db.Close()
+44
View File
@@ -418,6 +418,9 @@ func (c *Config) Sanitize() map[string]interface{} {
// Sanitize auth config
sanitized["auth"] = map[string]interface{}{
"enabled": c.Auth.Enabled,
"database": map[string]interface{}{
"url": SanitizeDatabaseURL(c.Auth.Database.URL),
},
"bootstrap": map[string]interface{}{
"enabled": c.Auth.Bootstrap.Enabled,
"admin_key_file": c.Auth.Bootstrap.AdminKeyFileName,
@@ -438,6 +441,47 @@ func (c *Config) Sanitize() map[string]interface{} {
return sanitized
}
// SanitizeDatabaseURL redacts the password from a database connection URL
// Handles formats like: postgresql://user:password@host:port/database
// Also handles passwords containing @ characters by finding the last @
func SanitizeDatabaseURL(dbURL string) string {
if dbURL == "" {
return ""
}
// Find the protocol (e.g., "postgresql://", "mysql://")
protocolIdx := strings.Index(dbURL, "://")
if protocolIdx == -1 {
return dbURL
}
protocol := dbURL[:protocolIdx+3]
remaining := dbURL[protocolIdx+3:]
// Find the LAST @ symbol that separates credentials from host
// (to handle passwords that may contain @ characters)
atIdx := strings.LastIndex(remaining, "@")
if atIdx == -1 {
// No credentials in the URL
return dbURL
}
// Extract credentials part and check if there's a password
credentials := remaining[:atIdx]
hostPart := remaining[atIdx:]
// Check if there's a colon (indicating a password)
colonIdx := strings.Index(credentials, ":")
if colonIdx == -1 {
// No password, just username
return protocol + credentials + hostPart
}
// Extract username and redact password
username := credentials[:colonIdx]
return protocol + username + ":***REDACTED***" + hostPart
}
// GetDefaultAccount returns the default account name for a notifier type, or the first account if no default is set
func (c *Config) GetDefaultAccount(notifierType domain.NotificationType) string {
switch notifierType {
+63
View File
@@ -0,0 +1,63 @@
package config
import (
"testing"
)
func TestSanitizeDatabaseURL(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "PostgreSQL with password",
input: "postgresql://user:password@localhost:5432/dbname",
expected: "postgresql://user:***REDACTED***@localhost:5432/dbname",
},
{
name: "PostgreSQL without password",
input: "postgresql://user@localhost:5432/dbname",
expected: "postgresql://user@localhost:5432/dbname",
},
{
name: "MySQL with special characters in password",
input: "mysql://root:SuperSecret123!@db.example.com:3306/mydb",
expected: "mysql://root:***REDACTED***@db.example.com:3306/mydb",
},
{
name: "Empty URL",
input: "",
expected: "",
},
{
name: "Invalid URL without protocol",
input: "invalid-url",
expected: "invalid-url",
},
{
name: "URL without credentials",
input: "postgresql://localhost:5432/dbname",
expected: "postgresql://localhost:5432/dbname",
},
{
name: "PostgreSQL with password containing colons",
input: "postgresql://user:pass:word@localhost:5432/dbname",
expected: "postgresql://user:***REDACTED***@localhost:5432/dbname",
},
{
name: "PostgreSQL with complex hostname and port",
input: "postgresql://admin:p@ssw0rd!@db-prod.example.com:5432/production",
expected: "postgresql://admin:***REDACTED***@db-prod.example.com:5432/production",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SanitizeDatabaseURL(tt.input)
if result != tt.expected {
t.Errorf("SanitizeDatabaseURL(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
+7 -2
View File
@@ -68,8 +68,13 @@ type Notification struct {
// Body is the main content of the notification
Body string `json:"body"`
// ContentType specifies the format of the body (text or html)
// Defaults to "text" if not specified. HTML is auto-detected if body starts with < or contains HTML tags.
// HTMLBody is an optional HTML body for email notifications. If non-empty, the email is
// sent as multipart/alternative with Body as text/plain and HTMLBody as text/html.
// Ignored for non-email notification types.
HTMLBody string `json:"html_body,omitempty"`
// ContentType specifies the format of the body (text or html).
// Deprecated: prefer setting HTMLBody alongside a plain-text Body.
ContentType ContentType `json:"content_type,omitempty"`
// Recipients contains the target addresses (email, slack channel, ntfy topic, etc.)
+14
View File
@@ -181,6 +181,20 @@ func createNtfyHTTPClient(config *NtfyConfig) (*http.Client, error) {
}, nil
}
// Validate overrides BaseNotifier.Validate to allow 0 recipients when a DefaultTopic is configured.
func (n *NtfyNotifier) Validate(notification *domain.Notification) error {
if notification == nil {
return fmt.Errorf("notification is nil")
}
if notification.Type != domain.TypeNtfy {
return fmt.Errorf("notification type mismatch: expected %s, got %s", domain.TypeNtfy, notification.Type)
}
if len(notification.Recipients) == 0 && n.config.DefaultTopic == "" {
return fmt.Errorf("notification has no recipients and no default topic is configured")
}
return nil
}
// Send sends a notification via ntfy
func (n *NtfyNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
if err := ValidateContext(ctx); err != nil {
+24 -20
View File
@@ -145,18 +145,15 @@ func (s *SMTPNotifier) buildMessage(notification *domain.Notification) string {
builder.WriteString(fmt.Sprintf("Subject: %s\r\n", notification.Subject))
builder.WriteString("MIME-Version: 1.0\r\n")
// Auto-detect HTML if content type not set
contentType := notification.ContentType
if contentType == "" {
contentType = detectContentType(notification.Body)
}
// Build message based on content type
if contentType == domain.ContentTypeHTML {
// Send multipart/alternative with both text and HTML
s.buildMultipartMessage(&builder, notification)
} else {
// Send plain text only
switch {
case notification.HTMLBody != "":
// Caller provided distinct plain-text and HTML versions: send multipart/alternative
// using Body verbatim as text/plain and HTMLBody as text/html (no auto-strip).
s.buildMultipartMessage(&builder, notification.Body, notification.HTMLBody)
case isHTMLContent(notification):
// Legacy path (deprecated): Body itself is HTML. Auto-derive a plain-text fallback.
s.buildMultipartMessage(&builder, htmlToPlainText(notification.Body), notification.Body)
default:
builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
builder.WriteString("\r\n")
builder.WriteString(notification.Body)
@@ -165,31 +162,38 @@ func (s *SMTPNotifier) buildMessage(notification *domain.Notification) string {
return builder.String()
}
// buildMultipartMessage builds a multipart/alternative email with both text and HTML versions
func (s *SMTPNotifier) buildMultipartMessage(builder *strings.Builder, notification *domain.Notification) {
// Generate a unique boundary
// isHTMLContent reports whether the notification's Body should be treated as HTML
// under the legacy content_type path.
func isHTMLContent(notification *domain.Notification) bool {
contentType := notification.ContentType
if contentType == "" || contentType == "auto" {
contentType = detectContentType(notification.Body)
}
return contentType == domain.ContentTypeHTML
}
// buildMultipartMessage builds a multipart/alternative email with the given plain-text
// and HTML parts.
func (s *SMTPNotifier) buildMultipartMessage(builder *strings.Builder, plainText, htmlBody string) {
boundary := generateBoundary()
builder.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
builder.WriteString("\r\n")
// Plain text version (auto-generated from HTML)
builder.WriteString(fmt.Sprintf("--%s\r\n", boundary))
builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
builder.WriteString("Content-Transfer-Encoding: 7bit\r\n")
builder.WriteString("\r\n")
builder.WriteString(htmlToPlainText(notification.Body))
builder.WriteString(plainText)
builder.WriteString("\r\n\r\n")
// HTML version
builder.WriteString(fmt.Sprintf("--%s\r\n", boundary))
builder.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
builder.WriteString("Content-Transfer-Encoding: 7bit\r\n")
builder.WriteString("\r\n")
builder.WriteString(notification.Body)
builder.WriteString(htmlBody)
builder.WriteString("\r\n\r\n")
// End boundary
builder.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
}
+46 -8
View File
@@ -243,9 +243,11 @@ func (s *NotificationService) processNotification(ctx context.Context, msg *doma
// Send the notification
result, err := notifier.Send(ctx, notification)
if err != nil || !result.Success {
if err != nil || result == nil || !result.Success {
notification.RetryCount++
notification.LastError = result.Error
if result != nil {
notification.LastError = result.Error
}
if err != nil {
notification.LastError = err.Error()
}
@@ -276,6 +278,16 @@ func (s *NotificationService) processNotification(ctx context.Context, msg *doma
// Send queues a notification for delivery
func (s *NotificationService) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
// Enforce RBAC authorization if configured
if err := s.checkAuthorization(ctx, notification); err != nil {
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: false,
Error: err.Error(),
SentAt: time.Now(),
}, err
}
// Store the notification
s.storeNotification(notification)
@@ -301,6 +313,13 @@ func (s *NotificationService) Send(ctx context.Context, notification *domain.Not
func (s *NotificationService) SendBatch(ctx context.Context, notifications []*domain.Notification) ([]*domain.NotificationResult, error) {
results := make([]*domain.NotificationResult, 0, len(notifications))
// Enforce RBAC authorization for each notification
for _, notification := range notifications {
if err := s.checkAuthorization(ctx, notification); err != nil {
return nil, fmt.Errorf("authorization denied for notification type=%s account=%s: %w", notification.Type, notification.Account, err)
}
}
// Store all notifications
for _, notification := range notifications {
s.storeNotification(notification)
@@ -439,12 +458,7 @@ func (s *NotificationService) GetStats(ctx context.Context) (*domain.Notificatio
// GetNotifiers returns information about available notifiers, filtered by authorization if auth context is provided
func (s *NotificationService) GetNotifiers(ctx context.Context) (*domain.NotifiersResponse, error) {
// Extract auth context from request context if available
var authCtx *auth.AuthContext
if authVal := ctx.Value("auth"); authVal != nil {
if ac, ok := authVal.(*auth.AuthContext); ok {
authCtx = ac
}
}
authCtx, _ := auth.GetAuthContext(ctx)
supportedTypes := s.factory.SupportedTypes()
notifiers := make([]domain.NotifierInfo, 0, len(supportedTypes))
@@ -510,6 +524,30 @@ func (s *NotificationService) updateNotification(notification *domain.Notificati
s.notifications[notification.ID] = notification
}
// checkAuthorization verifies that the caller is authorized to send to the given notifier/account.
// Returns nil if authorized or if RBAC is not configured.
func (s *NotificationService) checkAuthorization(ctx context.Context, notification *domain.Notification) error {
if s.authz == nil || !s.authz.HasRules() {
return nil // RBAC not configured
}
authCtx, ok := auth.GetAuthContext(ctx)
if !ok {
return nil // No auth context (auth may be disabled)
}
account := notification.Account
if account == "" && s.accountResolver != nil {
account = s.accountResolver.GetDefaultAccount(notification.Type)
}
if !s.authz.IsAuthorized(authCtx, notification.Type, account) {
return fmt.Errorf("not authorized to send %s notifications to account %s", notification.Type, account)
}
return nil
}
// matchesFilter checks if a notification matches the filter
func (s *NotificationService) matchesFilter(notification *domain.Notification, filter *domain.NotificationFilter) bool {
if filter == nil {
+5 -1
View File
@@ -50,6 +50,8 @@ spec:
readOnly: true
- name: queue-storage
mountPath: /var/lib/notifier
- name: tmp
mountPath: /tmp
resources:
requests:
cpu: 100m
@@ -77,7 +79,7 @@ spec:
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
@@ -87,6 +89,8 @@ spec:
name: notifier-config
- name: queue-storage
emptyDir: {}
- name: tmp
emptyDir: {}
restartPolicy: Always
---
apiVersion: v1