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 # Build stage — pinned to the build host's arch so cross-compilation
FROM golang:1.24-alpine AS builder # 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 # Build arguments
ARG VERSION=dev ARG VERSION=dev
@@ -29,13 +34,13 @@ COPY . .
# Generate protobuf code # Generate protobuf code
RUN make proto-gen RUN make proto-gen
# Build binary with version information # Build binary with version information, cross-compiling to the target arch.
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \ 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}" \ -ldflags "-X main.Version=${VERSION} -X main.GitCommit=${GIT_COMMIT} -X main.BuildTime=${BUILD_TIME} ${BUILD_FLAGS}" \
-o server ./cmd/server -o server ./cmd/server
# Runtime stage # Runtime stage
FROM alpine:latest FROM alpine:3.21
# Install runtime dependencies # Install runtime dependencies
RUN apk --no-cache add ca-certificates tzdata RUN apk --no-cache add ca-certificates tzdata
@@ -64,5 +69,5 @@ USER notifier
EXPOSE 8080 50051 9090 8081 EXPOSE 8080 50051 9090 8081
# Run server (defaults to both REST and gRPC) # 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"] 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 # Variables
REGISTRY ?=
IMAGE ?= notifier
PLATFORMS ?= linux/amd64,linux/arm64
PROTO_DIR=api/grpc PROTO_DIR=api/grpc
PROTO_FILE=$(PROTO_DIR)/notifier.proto PROTO_FILE=$(PROTO_DIR)/notifier.proto
PROTO_OUT=$(PROTO_DIR)/pb PROTO_OUT=$(PROTO_DIR)/pb
@@ -9,8 +12,8 @@ GO_FILES=$(shell find . -type f -name '*.go' -not -path "./vendor/*" -not -path
# Build information # Build information
VERSION ?= dev VERSION ?= dev
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") 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') BUILD_TIME := $(shell date -u "+%Y-%m-%d_%H:%M:%S_UTC" 2>/dev/null || echo unknown)
# Base LDFLAGS (version info only) # Base LDFLAGS (version info only)
LDFLAGS_BASE := -X main.Version=$(VERSION) -X main.GitCommit=$(GIT_COMMIT) -X main.BuildTime=$(BUILD_TIME) 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!" @echo "All quality checks passed!"
# Build Docker image (production - optimized) # 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: docker-build:
@echo "Building Docker image (production - optimized)..."
@echo "Version: $(VERSION)" @echo "Version: $(VERSION)"
@echo "Git Commit: $(GIT_COMMIT)" @echo "Git Commit: $(GIT_COMMIT)"
@echo "Build Time: $(BUILD_TIME)" @echo "Build Time: $(BUILD_TIME)"
ifeq ($(strip $(REGISTRY)),)
@echo "Building single-arch local Docker image..."
docker build \ docker build \
--build-arg VERSION=$(VERSION) \ --build-arg VERSION=$(VERSION) \
--build-arg GIT_COMMIT=$(GIT_COMMIT) \ --build-arg GIT_COMMIT=$(GIT_COMMIT) \
--build-arg BUILD_TIME=$(BUILD_TIME) \ --build-arg BUILD_TIME=$(BUILD_TIME) \
--build-arg BUILD_FLAGS="-s -w" \ --build-arg BUILD_FLAGS="-s -w" \
-t notifier:latest . -t $(IMAGE):latest .
@echo "Docker image built successfully (production - optimized)" @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) # Build Docker image with debug symbols (development)
docker-build-dev: docker-build-dev:
@@ -166,6 +188,14 @@ docker-build-dev:
-t notifier:latest-dev . -t notifier:latest-dev .
@echo "Docker image built successfully (development)" @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 # Run Docker container
docker-run: docker-run:
@echo "Running Docker container..." @echo "Running Docker container..."
@@ -212,5 +242,15 @@ help:
@echo " deps - Install Go dependencies" @echo " deps - Install Go dependencies"
@echo "" @echo ""
@echo "Docker:" @echo "Docker:"
@echo " docker-build - Build Docker image" @echo " docker-build - Build Docker image"
@echo " docker-run - Run Docker container" @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:** **Run in different modes:**
```bash ```bash
./bin/server # Both REST and gRPC (default) ./bin/server # Both REST and gRPC (default)
SERVER_MODE=rest ./bin/server # REST only NOTIFIER_SERVER_MODE=rest ./bin/server # REST only
SERVER_MODE=grpc ./bin/server # gRPC only NOTIFIER_SERVER_MODE=grpc ./bin/server # gRPC only
``` ```
### 2. Send Your First Notification ### 2. Send Your First Notification
@@ -284,15 +284,32 @@ Format: `NOTIFIER_<SECTION>_<KEY>` (use `_` for nested keys)
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
| `GET` | `/health` | Health check | | `GET` | `/health` | Health check (always unauthenticated) |
| `POST` | `/api/v1/notifications` | Send single notification | | `POST` | `/api/v1/notifications` | Send single notification (returns `202 Accepted`) |
| `POST` | `/api/v1/notifications/batch` | Send multiple notifications | | `POST` | `/api/v1/notifications/batch` | Send multiple notifications (returns `202 Accepted`) |
| `GET` | `/api/v1/notifications` | List notifications (with filters) | | `GET` | `/api/v1/notifications` | List notifications (with filters) |
| `GET` | `/api/v1/notifications/{id}` | Get notification by ID | | `GET` | `/api/v1/notifications/{id}` | Get notification by ID |
| `DELETE` | `/api/v1/notifications/{id}` | Cancel pending notification | | `DELETE` | `/api/v1/notifications/{id}` | Cancel pending notification |
| `POST` | `/api/v1/notifications/{id}/retry` | Retry failed 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 | | `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 ### Request Format
```json ```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" 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 ## 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:** **Generate Go code:**
```bash ```bash
@@ -363,17 +401,55 @@ make proto-gen
protoc --go_out=. --go-grpc_out=. api/grpc/notifier.proto 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 ## Deployment
### Docker ### Docker
**Build:** **Build (single-arch, local):**
```bash ```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:** **Run:**
```bash ```bash
docker run -d \ docker run -d \
@@ -390,10 +466,10 @@ docker run -d \
docker run -d -p 8080:8080 -p 50051:50051 notifier:latest docker run -d -p 8080:8080 -p 50051:50051 notifier:latest
# REST only # 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 # 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:** **Docker Compose:**
@@ -485,17 +561,20 @@ notifier/
│ │ └── notifier.proto # gRPC service definition │ │ └── notifier.proto # gRPC service definition
│ └── rest/ │ └── rest/
│ ├── handlers.go # HTTP handlers │ ├── handlers.go # HTTP handlers
│ ├── keys.go # API key management handlers
│ ├── router.go # Route configuration │ ├── router.go # Route configuration
│ └── types.go # Request/response types │ └── types.go # Request/response types
├── cmd/ ├── cmd/
│ └── server/main.go # Unified server (configurable mode) │ └── server/main.go # Unified server (configurable mode)
├── internal/ ├── internal/
│ ├── auth/ # API key auth, RBAC, key store, bootstrap
│ ├── config/ │ ├── config/
│ │ └── config.go # Configuration management │ │ └── config.go # Configuration management
│ ├── domain/ │ ├── domain/
│ │ ├── notification.go # Core types │ │ ├── notification.go # Core types
│ │ ├── notifier.go # Notifier interface │ │ ├── notifier.go # Notifier interface
│ │ └── queue.go # Queue interface │ │ └── queue.go # Queue interface
│ ├── logging/ # Structured logger
│ ├── notifier/ │ ├── notifier/
│ │ ├── notifier.go # Factory & base │ │ ├── notifier.go # Factory & base
│ │ ├── smtp.go # Email notifier │ │ ├── smtp.go # Email notifier
@@ -513,8 +592,7 @@ notifier/
│ ├── ingress.yaml │ ├── ingress.yaml
│ ├── hpa.yaml │ ├── hpa.yaml
│ └── kustomization.yaml │ └── kustomization.yaml
├── docs/ ├── docs/ # Guides & references (see docs/INDEX.md)
│ └── NTFY_GUIDE.md # Ntfy integration guide
├── config.yaml # Default configuration ├── config.yaml # Default configuration
├── docker-compose.yaml ├── docker-compose.yaml
├── Dockerfile ├── Dockerfile
@@ -541,7 +619,7 @@ make lint # Run golangci-lint
make check # Run fmt-check + vet + mod verify make check # Run fmt-check + vet + mod verify
make qa # Run all quality checks make qa # Run all quality checks
make proto-gen # Generate protobuf code 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 clean # Clean build artifacts
make help # Show all available targets 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 - [QUICKSTART.md](QUICKSTART.md) - Quick start guide with examples
- [ARCHITECTURE.md](ARCHITECTURE.md) - Architecture and design details - [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 - [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 ## Testing
```bash ```bash
@@ -589,7 +675,9 @@ go test -cover ./...
go test ./internal/notifier/... 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 ## Monitoring & Operations
@@ -647,27 +735,29 @@ The server handles `SIGINT` and `SIGTERM` gracefully:
### Completed ✅ ### Completed ✅
- [x] Core notification system - [x] Core notification system
- [x] REST API (fully functional) - [x] REST API (fully functional)
- [x] gRPC API (protobuf defined) - [x] gRPC API (fully functional, with reflection)
- [x] Local queue with workers - [x] Local queue with workers
- [x] Multiple notifiers (SMTP, Slack, Ntfy, Stdout) - [x] Multiple notifiers (SMTP, Slack, Ntfy, Stdout)
- [x] Priority and retry logic - [x] Priority and retry logic
- [x] Batch operations - [x] Batch operations
- [x] API key authentication with RBAC and runtime key management
- [x] CORS support
- [x] Notification retention/cleanup
- [x] Docker support - [x] Docker support
- [x] Kubernetes manifests with HPA - [x] Kubernetes manifests with HPA
- [x] Health checks and stats - [x] Health checks and stats
- [x] Configuration management - [x] Configuration management
- [x] Unit test suite (race detector)
### Planned 🚧 ### Planned 🚧
- [ ] gRPC handler implementation
- [ ] Kafka queue adapter - [ ] Kafka queue adapter
- [ ] Database persistence (PostgreSQL) - [ ] Database-backed notification persistence (PostgreSQL)
- [ ] Notification templates - [ ] Notification templates
- [ ] Webhook callbacks - [ ] Webhook callbacks
- [ ] Authentication/Authorization (API keys, OAuth) - [ ] OAuth authentication
- [ ] Rate limiting (per client, per notifier) - [ ] Per-notifier rate limiting
- [ ] Prometheus metrics - [ ] Prometheus metrics endpoint
- [ ] OpenTelemetry tracing - [ ] OpenTelemetry tracing
- [ ] Comprehensive test suite
- [ ] Circuit breakers for notifiers - [ ] Circuit breakers for notifiers
- [ ] Dead letter queue - [ ] Dead letter queue
- [ ] Admin dashboard - [ ] Admin dashboard
+8 -9
View File
@@ -8,6 +8,8 @@ import (
pb "github.com/igodwin/notifier/api/grpc/pb" pb "github.com/igodwin/notifier/api/grpc/pb"
"github.com/igodwin/notifier/internal/domain" "github.com/igodwin/notifier/internal/domain"
"github.com/igodwin/notifier/internal/logging" "github.com/igodwin/notifier/internal/logging"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb" "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), Priority: domain.Priority(req.Priority),
Subject: req.Subject, Subject: req.Subject,
Body: req.Body, Body: req.Body,
HTMLBody: req.HtmlBody,
ContentType: contentType, ContentType: contentType,
Recipients: req.Recipients, Recipients: req.Recipients,
CC: req.Cc, CC: req.Cc,
@@ -82,12 +85,7 @@ func (h *NotifierHandler) SendNotification(ctx context.Context, req *pb.SendNoti
if err != nil { if err != nil {
h.logger.Errorf("gRPC: Failed to send notification - type=%s, account=%s, error=%v", h.logger.Errorf("gRPC: Failed to send notification - type=%s, account=%s, error=%v",
req.Type, req.Account, err) req.Type, req.Account, err)
return &pb.SendNotificationResponse{ return nil, status.Errorf(codes.Internal, "failed to send notification: %v", err)
Result: &pb.NotificationResult{
Success: false,
Error: err.Error(),
},
}, nil
} }
// Log success // 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) { func (h *NotifierHandler) GetNotification(ctx context.Context, req *pb.GetNotificationRequest) (*pb.GetNotificationResponse, error) {
notification, err := h.service.GetNotification(ctx, req.Id) notification, err := h.service.GetNotification(ctx, req.Id)
if err != nil { if err != nil {
return nil, err return nil, status.Errorf(codes.NotFound, "notification not found: %v", err)
} }
return &pb.GetNotificationResponse{ return &pb.GetNotificationResponse{
@@ -154,7 +152,7 @@ func (h *NotifierHandler) ListNotifications(ctx context.Context, req *pb.ListNot
notifications, err := h.service.ListNotifications(ctx, filter) notifications, err := h.service.ListNotifications(ctx, filter)
if err != nil { if err != nil {
return nil, err return nil, status.Errorf(codes.Internal, "failed to list notifications: %v", err)
} }
protoNotifications := make([]*pb.Notification, len(notifications)) 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) notifiers, err := h.service.GetNotifiers(ctx)
if err != nil { if err != nil {
h.logger.Errorf("gRPC: Failed to get notifiers - error=%v", err) 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 // Convert domain notifiers to proto notifiers
@@ -371,6 +369,7 @@ func convertDomainToProtoNotification(notif *domain.Notification) *pb.Notificati
Status: convertDomainToProtoStatus(notif.Status), Status: convertDomainToProtoStatus(notif.Status),
Subject: notif.Subject, Subject: notif.Subject,
Body: notif.Body, Body: notif.Body,
HtmlBody: notif.HTMLBody,
Recipients: notif.Recipients, Recipients: notif.Recipients,
Metadata: convertInterfaceMapToString(notif.Metadata), Metadata: convertInterfaceMapToString(notif.Metadata),
CreatedAt: timestamppb.New(notif.CreatedAt), CreatedAt: timestamppb.New(notif.CreatedAt),
+4 -2
View File
@@ -81,7 +81,8 @@ message Notification {
NotificationStatus status = 5; NotificationStatus status = 5;
string subject = 6; string subject = 6;
string body = 7; 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 recipients = 8;
repeated string cc = 16; // Carbon copy recipients (email only) repeated string cc = 16; // Carbon copy recipients (email only)
repeated string bcc = 17; // Blind carbon copy recipients (email only) repeated string bcc = 17; // Blind carbon copy recipients (email only)
@@ -111,13 +112,14 @@ message SendNotificationRequest {
Priority priority = 3; Priority priority = 3;
string subject = 4; string subject = 4;
string body = 5; 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 recipients = 6;
repeated string cc = 10; // Carbon copy recipients (email only) repeated string cc = 10; // Carbon copy recipients (email only)
repeated string bcc = 11; // Blind carbon copy recipients (email only) repeated string bcc = 11; // Blind carbon copy recipients (email only)
map<string, string> metadata = 7; map<string, string> metadata = 7;
google.protobuf.Timestamp scheduled_for = 8; google.protobuf.Timestamp scheduled_for = 8;
int32 max_retries = 9; 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 // SendNotificationResponse returns the result of sending a notification
+19 -17
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/gorilla/mux"
"github.com/igodwin/notifier/internal/auth" "github.com/igodwin/notifier/internal/auth"
"github.com/igodwin/notifier/internal/logging" "github.com/igodwin/notifier/internal/logging"
) )
@@ -76,7 +77,7 @@ func (h *KeyManagementHandler) CreateKey(w http.ResponseWriter, r *http.Request)
ctx := r.Context() ctx := r.Context()
// Check authorization - must have admin role // Check authorization - must have admin role
authCtx, ok := ctx.Value("auth").(*auth.AuthContext) authCtx, ok := auth.GetAuthContext(ctx)
if !ok || !h.hasRole(authCtx, "admin") { if !ok || !h.hasRole(authCtx, "admin") {
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required") h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
return 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) { func (h *KeyManagementHandler) ListKeys(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
authCtx, ok := ctx.Value("auth").(*auth.AuthContext) authCtx, ok := auth.GetAuthContext(ctx)
if !ok { if !ok {
h.respondError(w, http.StatusUnauthorized, "Unauthorized", "") h.respondError(w, http.StatusUnauthorized, "Unauthorized", "")
return return
@@ -193,24 +194,25 @@ type RevokeKeyRequest struct {
} }
// RevokeKey deactivates an API key // RevokeKey deactivates an API key
// DELETE /api/v1/admin/keys/:key // DELETE /api/v1/admin/keys/:name
// Requires: admin role // Requires: admin role
func (h *KeyManagementHandler) RevokeKey(w http.ResponseWriter, r *http.Request) { func (h *KeyManagementHandler) RevokeKey(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
authCtx, ok := ctx.Value("auth").(*auth.AuthContext) authCtx, ok := auth.GetAuthContext(ctx)
if !ok || !h.hasRole(authCtx, "admin") { if !ok || !h.hasRole(authCtx, "admin") {
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required") h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
return return
} }
// Extract key from path parameter // Extract key name from path parameter (not the raw key, to avoid leaking secrets in URLs)
keyStr := strings.TrimPrefix(r.URL.Path, "/api/v1/admin/keys/") vars := mux.Vars(r)
keyName := vars["name"]
var req RevokeKeyRequest var req RevokeKeyRequest
_ = json.NewDecoder(r.Body).Decode(&req) // Ignore decode errors, reason is optional _ = 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 err != nil {
if strings.Contains(err.Error(), "not found") { if strings.Contains(err.Error(), "not found") {
h.respondError(w, http.StatusNotFound, "Key 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 // 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 // Requires: admin role
func (h *KeyManagementHandler) RotateKey(w http.ResponseWriter, r *http.Request) { func (h *KeyManagementHandler) RotateKey(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
authCtx, ok := ctx.Value("auth").(*auth.AuthContext) authCtx, ok := auth.GetAuthContext(ctx)
if !ok || !h.hasRole(authCtx, "admin") { if !ok || !h.hasRole(authCtx, "admin") {
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required") h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
return return
} }
oldKeyStr := strings.TrimPrefix(r.URL.Path, "/api/v1/admin/keys/") vars := mux.Vars(r)
oldKeyStr = strings.TrimSuffix(oldKeyStr, "/rotate") _ = vars["name"] // Key name from URL (rotation not yet implemented)
var req RotateKeyRequest var req RotateKeyRequest
_ = json.NewDecoder(r.Body).Decode(&req) _ = json.NewDecoder(r.Body).Decode(&req)
@@ -265,19 +267,19 @@ type GetAuditLogResponse struct {
} }
// GetAuditLog retrieves the audit log for a key // 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 // Requires: admin role
func (h *KeyManagementHandler) GetAuditLog(w http.ResponseWriter, r *http.Request) { func (h *KeyManagementHandler) GetAuditLog(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
authCtx, ok := ctx.Value("auth").(*auth.AuthContext) authCtx, ok := auth.GetAuthContext(ctx)
if !ok || !h.hasRole(authCtx, "admin") { if !ok || !h.hasRole(authCtx, "admin") {
h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required") h.respondError(w, http.StatusForbidden, "Insufficient permissions", "admin role required")
return return
} }
keyStr := strings.TrimPrefix(r.URL.Path, "/api/v1/admin/keys/") vars := mux.Vars(r)
keyStr = strings.TrimSuffix(keyStr, "/audit") keyName := vars["name"]
limit := 100 limit := 100
if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 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 { if err != nil {
h.logger.Errorf("Failed to get audit log: %v", err) h.logger.Errorf("Failed to get audit log: %v", err)
h.respondError(w, http.StatusInternalServerError, "Failed to get audit log", err.Error()) 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{ resp := GetAuditLogResponse{
Key: "nk_" + keyStr[len(keyStr)-4:], Key: keyName,
AuditLog: logs, AuditLog: logs,
} }
+17 -4
View File
@@ -86,20 +86,33 @@ func NewRouterWithAuthAndKeyStore(service domain.NotificationService, logger *lo
keyHandler := NewKeyManagementHandler(keyStore, logger) keyHandler := NewKeyManagementHandler(keyStore, logger)
v1.HandleFunc("/admin/keys", keyHandler.CreateKey).Methods(http.MethodPost) v1.HandleFunc("/admin/keys", keyHandler.CreateKey).Methods(http.MethodPost)
v1.HandleFunc("/admin/keys", keyHandler.ListKeys).Methods(http.MethodGet) v1.HandleFunc("/admin/keys", keyHandler.ListKeys).Methods(http.MethodGet)
v1.HandleFunc("/admin/keys/{key}", keyHandler.RevokeKey).Methods(http.MethodDelete) v1.HandleFunc("/admin/keys/{name}", keyHandler.RevokeKey).Methods(http.MethodDelete)
v1.HandleFunc("/admin/keys/{key}/rotate", keyHandler.RotateKey).Methods(http.MethodPost) v1.HandleFunc("/admin/keys/{name}/rotate", keyHandler.RotateKey).Methods(http.MethodPost)
v1.HandleFunc("/admin/keys/{key}/audit", keyHandler.GetAuditLog).Methods(http.MethodGet) v1.HandleFunc("/admin/keys/{name}/audit", keyHandler.GetAuditLog).Methods(http.MethodGet)
} }
// Health check route (no auth required) // Health check route (no auth required)
router.HandleFunc("/health", handler.HealthCheck).Methods(http.MethodGet) router.HandleFunc("/health", handler.HealthCheck).Methods(http.MethodGet)
// Middleware - logging and CORS // Middleware - logging, request size limit, and CORS
router.Use(loggingMiddleware) router.Use(loggingMiddleware)
v1.Use(maxBodySizeMiddleware(1 << 20)) // 1 MB limit on API request bodies
return router 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 // loggingMiddleware logs incoming requests
func loggingMiddleware(next http.Handler) http.Handler { func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+17 -3
View File
@@ -2,6 +2,7 @@ package rest
import ( import (
"fmt" "fmt"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -15,7 +16,8 @@ type SendNotificationRequest struct {
Priority int `json:"priority,omitempty"` Priority int `json:"priority,omitempty"`
Subject string `json:"subject"` Subject string `json:"subject"`
Body string `json:"body"` 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"` Recipients []string `json:"recipients"`
CC []string `json:"cc,omitempty"` // Carbon copy recipients (email only) CC []string `json:"cc,omitempty"` // Carbon copy recipients (email only)
BCC []string `json:"bcc,omitempty"` // Blind 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") 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 return nil
} }
@@ -52,8 +62,9 @@ func (r *SendNotificationRequest) ToNotification() *domain.Notification {
} }
// Convert content type, defaulting to text // Convert content type, defaulting to text
contentType := domain.ContentType(r.ContentType) // Normalize to lowercase to handle case-insensitive input (e.g., "HTML" -> "html")
if contentType == "" { contentType := domain.ContentType(strings.ToLower(r.ContentType))
if contentType == "" || contentType != domain.ContentTypeHTML {
contentType = domain.ContentTypeText contentType = domain.ContentTypeText
} }
@@ -65,6 +76,7 @@ func (r *SendNotificationRequest) ToNotification() *domain.Notification {
Status: domain.StatusPending, Status: domain.StatusPending,
Subject: r.Subject, Subject: r.Subject,
Body: r.Body, Body: r.Body,
HTMLBody: r.HTMLBody,
ContentType: contentType, ContentType: contentType,
Recipients: r.Recipients, Recipients: r.Recipients,
CC: r.CC, CC: r.CC,
@@ -101,6 +113,7 @@ type Notification struct {
Status string `json:"status"` Status string `json:"status"`
Subject string `json:"subject"` Subject string `json:"subject"`
Body string `json:"body"` Body string `json:"body"`
HTMLBody string `json:"html_body,omitempty"`
ContentType string `json:"content_type,omitempty"` ContentType string `json:"content_type,omitempty"`
Recipients []string `json:"recipients"` Recipients []string `json:"recipients"`
CC []string `json:"cc,omitempty"` CC []string `json:"cc,omitempty"`
@@ -124,6 +137,7 @@ func NotificationFromDomain(n *domain.Notification) Notification {
Status: string(n.Status), Status: string(n.Status),
Subject: n.Subject, Subject: n.Subject,
Body: n.Body, Body: n.Body,
HTMLBody: n.HTMLBody,
ContentType: string(n.ContentType), ContentType: string(n.ContentType),
Recipients: n.Recipients, Recipients: n.Recipients,
CC: n.CC, CC: n.CC,
+1 -1
View File
@@ -103,7 +103,7 @@ func main() {
if err != nil { if err != nil {
logger.Fatalf("Failed to create database key store: %v", err) 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 { } else {
logger.Warn("No database configured for authentication - API keys will only be stored in memory") 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 // CheckRateLimit checks if a key has exceeded its rate limit
func (s *APIKeyStore) CheckRateLimit(keyStr string) (bool, error) { func (s *APIKeyStore) CheckRateLimit(keyStr string) (bool, error) {
s.mu.Lock() // Look up key and limiter under the store lock, then release it
defer s.mu.Unlock() // before acquiring the per-key limiter lock to avoid nested locking.
s.mu.RLock()
key, exists := s.keys[keyStr] key, exists := s.keys[keyStr]
if !exists { if !exists {
s.mu.RUnlock()
return false, fmt.Errorf("invalid API key") return false, fmt.Errorf("invalid API key")
} }
// Unlimited rate limit // Unlimited rate limit
if key.RateLimit <= 0 { if key.RateLimit <= 0 {
s.mu.RUnlock()
return true, nil return true, nil
} }
limiter, exists := s.rateLimits[keyStr] limiter, exists := s.rateLimits[keyStr]
if !exists { if !exists {
s.mu.RUnlock()
return false, fmt.Errorf("rate limiter not found") return false, fmt.Errorf("rate limiter not found")
} }
s.mu.RUnlock()
// Now lock only the per-key rate limiter
limiter.mu.Lock() limiter.mu.Lock()
defer limiter.mu.Unlock() defer limiter.mu.Unlock()
@@ -206,13 +211,16 @@ func (s *APIKeyStore) ListKeys(clientID string) []*APIKey {
return keys return keys
} }
// authContextKey is an unexported type for context keys to avoid collisions.
type authContextKey struct{}
// ContextWithAuth adds auth context to a request context // ContextWithAuth adds auth context to a request context
func ContextWithAuth(ctx context.Context, auth *AuthContext) context.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 // GetAuthContext retrieves auth context from a request context
func GetAuthContext(ctx context.Context) (*AuthContext, bool) { func GetAuthContext(ctx context.Context) (*AuthContext, bool) {
auth, ok := ctx.Value("auth").(*AuthContext) auth, ok := ctx.Value(authContextKey{}).(*AuthContext)
return auth, ok return auth, ok
} }
+22 -11
View File
@@ -34,21 +34,32 @@ func (a *NotifierAuthz) IsAuthorized(auth *AuthContext, notificationType domain.
key := makeAuthzKey(notificationType, account) key := makeAuthzKey(notificationType, account)
allowedRoles, exists := a.rules[key] allowedRoles, exists := a.rules[key]
// If no specific rule is registered, allow all authenticated users // If RBAC is enabled (at least one rule exists), restrict access:
if !exists { // - Notifiers with explicit rules: check if user has allowed roles
return true // - Notifiers without rules: deny access (must be explicitly allowed)
} if a.HasRules() {
if !exists {
// Check if any of the user's roles is in the allowed roles // RBAC is enabled but this notifier has no rule - deny access
for _, userRole := range auth.Roles { return false
for _, allowedRole := range allowedRoles { }
if userRole == allowedRole { // Check if any of the user's roles is in the allowed roles
return true 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 // 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() 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 // Custom errors
var ( var (
ErrKeyNotFound = fmt.Errorf("API key not found") 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) 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 // Close closes the database connection
func (h *HybridKeyStore) Close() error { func (h *HybridKeyStore) Close() error {
return h.db.Close() return h.db.Close()
+44
View File
@@ -418,6 +418,9 @@ func (c *Config) Sanitize() map[string]interface{} {
// Sanitize auth config // Sanitize auth config
sanitized["auth"] = map[string]interface{}{ sanitized["auth"] = map[string]interface{}{
"enabled": c.Auth.Enabled, "enabled": c.Auth.Enabled,
"database": map[string]interface{}{
"url": SanitizeDatabaseURL(c.Auth.Database.URL),
},
"bootstrap": map[string]interface{}{ "bootstrap": map[string]interface{}{
"enabled": c.Auth.Bootstrap.Enabled, "enabled": c.Auth.Bootstrap.Enabled,
"admin_key_file": c.Auth.Bootstrap.AdminKeyFileName, "admin_key_file": c.Auth.Bootstrap.AdminKeyFileName,
@@ -438,6 +441,47 @@ func (c *Config) Sanitize() map[string]interface{} {
return sanitized 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 // 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 { func (c *Config) GetDefaultAccount(notifierType domain.NotificationType) string {
switch notifierType { 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 is the main content of the notification
Body string `json:"body"` Body string `json:"body"`
// ContentType specifies the format of the body (text or html) // HTMLBody is an optional HTML body for email notifications. If non-empty, the email is
// Defaults to "text" if not specified. HTML is auto-detected if body starts with < or contains HTML tags. // 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"` ContentType ContentType `json:"content_type,omitempty"`
// Recipients contains the target addresses (email, slack channel, ntfy topic, etc.) // 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 }, 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 // Send sends a notification via ntfy
func (n *NtfyNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) { func (n *NtfyNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
if err := ValidateContext(ctx); err != nil { 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(fmt.Sprintf("Subject: %s\r\n", notification.Subject))
builder.WriteString("MIME-Version: 1.0\r\n") builder.WriteString("MIME-Version: 1.0\r\n")
// Auto-detect HTML if content type not set switch {
contentType := notification.ContentType case notification.HTMLBody != "":
if contentType == "" { // Caller provided distinct plain-text and HTML versions: send multipart/alternative
contentType = detectContentType(notification.Body) // using Body verbatim as text/plain and HTMLBody as text/html (no auto-strip).
} s.buildMultipartMessage(&builder, notification.Body, notification.HTMLBody)
case isHTMLContent(notification):
// Build message based on content type // Legacy path (deprecated): Body itself is HTML. Auto-derive a plain-text fallback.
if contentType == domain.ContentTypeHTML { s.buildMultipartMessage(&builder, htmlToPlainText(notification.Body), notification.Body)
// Send multipart/alternative with both text and HTML default:
s.buildMultipartMessage(&builder, notification)
} else {
// Send plain text only
builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
builder.WriteString("\r\n") builder.WriteString("\r\n")
builder.WriteString(notification.Body) builder.WriteString(notification.Body)
@@ -165,31 +162,38 @@ func (s *SMTPNotifier) buildMessage(notification *domain.Notification) string {
return builder.String() return builder.String()
} }
// buildMultipartMessage builds a multipart/alternative email with both text and HTML versions // isHTMLContent reports whether the notification's Body should be treated as HTML
func (s *SMTPNotifier) buildMultipartMessage(builder *strings.Builder, notification *domain.Notification) { // under the legacy content_type path.
// Generate a unique boundary 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() boundary := generateBoundary()
builder.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary)) builder.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
builder.WriteString("\r\n") builder.WriteString("\r\n")
// Plain text version (auto-generated from HTML)
builder.WriteString(fmt.Sprintf("--%s\r\n", boundary)) builder.WriteString(fmt.Sprintf("--%s\r\n", boundary))
builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
builder.WriteString("Content-Transfer-Encoding: 7bit\r\n") builder.WriteString("Content-Transfer-Encoding: 7bit\r\n")
builder.WriteString("\r\n") builder.WriteString("\r\n")
builder.WriteString(htmlToPlainText(notification.Body)) builder.WriteString(plainText)
builder.WriteString("\r\n\r\n") builder.WriteString("\r\n\r\n")
// HTML version
builder.WriteString(fmt.Sprintf("--%s\r\n", boundary)) builder.WriteString(fmt.Sprintf("--%s\r\n", boundary))
builder.WriteString("Content-Type: text/html; charset=UTF-8\r\n") builder.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
builder.WriteString("Content-Transfer-Encoding: 7bit\r\n") builder.WriteString("Content-Transfer-Encoding: 7bit\r\n")
builder.WriteString("\r\n") builder.WriteString("\r\n")
builder.WriteString(notification.Body) builder.WriteString(htmlBody)
builder.WriteString("\r\n\r\n") builder.WriteString("\r\n\r\n")
// End boundary
builder.WriteString(fmt.Sprintf("--%s--\r\n", 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 // Send the notification
result, err := notifier.Send(ctx, notification) result, err := notifier.Send(ctx, notification)
if err != nil || !result.Success { if err != nil || result == nil || !result.Success {
notification.RetryCount++ notification.RetryCount++
notification.LastError = result.Error if result != nil {
notification.LastError = result.Error
}
if err != nil { if err != nil {
notification.LastError = err.Error() notification.LastError = err.Error()
} }
@@ -276,6 +278,16 @@ func (s *NotificationService) processNotification(ctx context.Context, msg *doma
// Send queues a notification for delivery // Send queues a notification for delivery
func (s *NotificationService) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) { 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 // Store the notification
s.storeNotification(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) { func (s *NotificationService) SendBatch(ctx context.Context, notifications []*domain.Notification) ([]*domain.NotificationResult, error) {
results := make([]*domain.NotificationResult, 0, len(notifications)) 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 // Store all notifications
for _, notification := range notifications { for _, notification := range notifications {
s.storeNotification(notification) 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 // GetNotifiers returns information about available notifiers, filtered by authorization if auth context is provided
func (s *NotificationService) GetNotifiers(ctx context.Context) (*domain.NotifiersResponse, error) { func (s *NotificationService) GetNotifiers(ctx context.Context) (*domain.NotifiersResponse, error) {
// Extract auth context from request context if available // Extract auth context from request context if available
var authCtx *auth.AuthContext authCtx, _ := auth.GetAuthContext(ctx)
if authVal := ctx.Value("auth"); authVal != nil {
if ac, ok := authVal.(*auth.AuthContext); ok {
authCtx = ac
}
}
supportedTypes := s.factory.SupportedTypes() supportedTypes := s.factory.SupportedTypes()
notifiers := make([]domain.NotifierInfo, 0, len(supportedTypes)) notifiers := make([]domain.NotifierInfo, 0, len(supportedTypes))
@@ -510,6 +524,30 @@ func (s *NotificationService) updateNotification(notification *domain.Notificati
s.notifications[notification.ID] = notification 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 // matchesFilter checks if a notification matches the filter
func (s *NotificationService) matchesFilter(notification *domain.Notification, filter *domain.NotificationFilter) bool { func (s *NotificationService) matchesFilter(notification *domain.Notification, filter *domain.NotificationFilter) bool {
if filter == nil { if filter == nil {
+5 -1
View File
@@ -50,6 +50,8 @@ spec:
readOnly: true readOnly: true
- name: queue-storage - name: queue-storage
mountPath: /var/lib/notifier mountPath: /var/lib/notifier
- name: tmp
mountPath: /tmp
resources: resources:
requests: requests:
cpu: 100m cpu: 100m
@@ -77,7 +79,7 @@ spec:
runAsNonRoot: true runAsNonRoot: true
runAsUser: 1000 runAsUser: 1000
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
readOnlyRootFilesystem: false readOnlyRootFilesystem: true
capabilities: capabilities:
drop: drop:
- ALL - ALL
@@ -87,6 +89,8 @@ spec:
name: notifier-config name: notifier-config
- name: queue-storage - name: queue-storage
emptyDir: {} emptyDir: {}
- name: tmp
emptyDir: {}
restartPolicy: Always restartPolicy: Always
--- ---
apiVersion: v1 apiVersion: v1