Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e84b9c3ad | |||
| f060c09668 | |||
| fa813c011c | |||
| ac3ba35736 | |||
| a35b3e6283 | |||
| c04db89633 | |||
| 298c960808 | |||
| 71b02758d7 | |||
| 4e594d3a7d | |||
| 5eaf6fe6fb |
+11
-6
@@ -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"]
|
||||
|
||||
@@ -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))"
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.)
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user