diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..8e55858 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(go get:*)", + "Bash(go version:*)" + ] + } +} diff --git a/docs/on-prem/docker-compose.yml b/docs/on-prem/docker-compose.yml index 77c7bfc..344c535 100644 --- a/docs/on-prem/docker-compose.yml +++ b/docs/on-prem/docker-compose.yml @@ -43,9 +43,9 @@ x-service-defaults: &service-defaults services: - # + # ─ # INFRASTRUCTURE - # + # ─ postgres: image: postgres:16-alpine @@ -136,9 +136,9 @@ services: limits: memory: 1G - # + # ─ # REVERSE PROXY - # + # ─ traefik: image: traefik:v3.1 @@ -162,9 +162,9 @@ services: networks: - serviceforge - # + # ─ # APPLICATION SERVICES - # + # ─ gateway: <<: *service-defaults @@ -285,9 +285,9 @@ services: SF_SERVICE_NAME: logging SF_LOGGING_PORT: 8005 - # + # ─ # MANAGEMENT UI - # + # ─ web: <<: *service-defaults @@ -312,9 +312,9 @@ services: limits: memory: 512M - # + # ─ # DATABASE MIGRATIONS (run once) - # + # ─ migrate: image: serviceforge/migrate:${SF_VERSION:-latest} @@ -331,9 +331,9 @@ services: - serviceforge restart: "no" - # + # ─ # MONITORING (optional, activate with --profile monitoring) - # + # ─ prometheus: image: prom/prometheus:v2.52.0 diff --git a/go.work b/go.work index 72ab292..1ffc861 100644 --- a/go.work +++ b/go.work @@ -14,13 +14,13 @@ // specific language governing permissions and limitations // under the LICENSE. -go 1.23.0 +go 1.25.0 use ( ./packages/go-common ./services/api-gateway ./services/auth-service - ./services/tenant-service - ./services/config-service ./services/booking-service + ./services/config-service + ./services/tenant-service ) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..48398c1 --- /dev/null +++ b/go.work.sum @@ -0,0 +1,14 @@ +github.com/bsm/redislock v0.9.4/go.mod h1:Epf7AJLiSFwLCiZcfi6pWFO/8eAYrYpQXFxEDPoDeAk= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8= +github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/packages/go-common/logger/logger.go b/packages/go-common/logger/logger.go new file mode 100644 index 0000000..dcb9aa3 --- /dev/null +++ b/packages/go-common/logger/logger.go @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved. + * + * SoftlaneIT licenses this file to you under the Apache License, + * Version 2.0 (the "LICENSE"); you may not use this file except + * in compliance with the LICENSE. + * You may obtain a copy of the LICENSE at + * + * https://softlaneit.com/LICENSE.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the LICENSE is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the LICENSE for the + * specific language governing permissions and limitations + * under the LICENSE. + */ + +// Package logger provides a structured, context-aware logger for all ServiceForge +// services. It is built on top of the standard library's log/slog (Go 1.21+) +// and therefore carries zero third-party dependencies. +// +// # Typical service startup +// +// func main() { +// log := logger.NewFromEnv("booking-service") +// log.Info("service starting", slog.String("port", port)) +// ... +// handler := logger.HTTPMiddleware(log)(mux) +// } +// +// # Context propagation +// +// Every outgoing log record is enriched with the tenant_id and trace_id that +// are already stored in the request context. Use FromContext inside handlers: +// +// func (h *Handler) CreateBooking(w http.ResponseWriter, r *http.Request) { +// log := logger.FromContext(r.Context()) +// log.Info("creating booking", slog.String("customer", id)) +// } +// +// # Environment variables +// +// - LOG_LEVEL – debug | info | warn | error (default: info) +// - LOG_FORMAT – json | text (default: json) +package logger + +import ( + "context" + "log/slog" + "net/http" + "os" + "strings" + "time" + + "github.com/SoftLaneIT/serviceforge/packages/go-common/tenant" +) + +// ─── context keys ──────────────────────────────────────────────────────────── + +type ctxKey string + +const ( + loggerKey ctxKey = "sf_logger" + traceIDKey ctxKey = "sf_trace_id" +) + +// ─── Options ───────────────────────────────────────────────────────────────── + +// Options controls how a logger is constructed. +type Options struct { + // Level is the minimum log level that will be emitted. + // Accepted values (case-insensitive): "debug", "info", "warn", "error". + // Empty string defaults to "info". + Level string + + // Format selects the output encoding. + // "text" emits human-readable key=value lines; everything else emits JSON. + // JSON is the default because it is the format consumed by log aggregators + // (Loki, CloudWatch, ELK). + Format string + + // Service is emitted as a fixed "service" field on every log record so that + // log aggregation queries can filter by service name without parsing the + // message. + Service string +} + +// ─── Constructors ───────────────────────────────────────────────────────────── + +// New builds a *slog.Logger from opts, installs it as the process-wide default +// (slog.SetDefault), and returns it. Services that want an isolated logger +// without touching the default should use the returned value directly. +func New(opts Options) *slog.Logger { + handlerOpts := &slog.HandlerOptions{ + Level: parseLevel(opts.Level), + AddSource: parseLevel(opts.Level) == slog.LevelDebug, + } + + var handler slog.Handler + if strings.ToLower(opts.Format) == "text" { + handler = slog.NewTextHandler(os.Stdout, handlerOpts) + } else { + handler = slog.NewJSONHandler(os.Stdout, handlerOpts) + } + + l := slog.New(handler) + if opts.Service != "" { + l = l.With(slog.String("service", opts.Service)) + } + + slog.SetDefault(l) + return l +} + +// NewFromEnv is the standard entry-point for every ServiceForge binary. It +// reads LOG_LEVEL and LOG_FORMAT from the environment, sets the named service +// attribute, and registers the logger as the process-wide slog default. +// +// log := logger.NewFromEnv("tenant-service") +func NewFromEnv(service string) *slog.Logger { + return New(Options{ + Level: os.Getenv("LOG_LEVEL"), + Format: os.Getenv("LOG_FORMAT"), + Service: service, + }) +} + +// ─── Context helpers ────────────────────────────────────────────────────────── + +// WithContext stores l in ctx and returns the enriched context. Call this once +// per request (typically inside HTTPMiddleware) so that handler code can +// retrieve a pre-enriched logger via FromContext. +func WithContext(ctx context.Context, l *slog.Logger) context.Context { + return context.WithValue(ctx, loggerKey, l) +} + +// FromContext retrieves the logger stored by WithContext and returns a copy +// pre-enriched with any tenant_id and trace_id already present in ctx. +// If no logger was stored, the process-wide slog default is used as the base. +// +// This is the only logger retrieval function handler code should ever need. +func FromContext(ctx context.Context) *slog.Logger { + l, _ := ctx.Value(loggerKey).(*slog.Logger) + if l == nil { + l = slog.Default() + } + + var attrs []any + + if tid := tenant.FromContext(ctx); tid != "" && tid != "default" { + attrs = append(attrs, slog.String("tenant_id", tid)) + } + if traceID := TraceIDFromContext(ctx); traceID != "" { + attrs = append(attrs, slog.String("trace_id", traceID)) + } + + if len(attrs) > 0 { + return l.With(attrs...) + } + return l +} + +// WithTraceID stores traceID in ctx so that subsequent calls to FromContext +// will automatically include it in every log record. The gateway sets this +// from the incoming X-Trace-ID header; downstream services propagate it +// by forwarding the header on outgoing calls. +func WithTraceID(ctx context.Context, traceID string) context.Context { + return context.WithValue(ctx, traceIDKey, traceID) +} + +// TraceIDFromContext returns the trace ID stored by WithTraceID, or empty +// string if none was set. +func TraceIDFromContext(ctx context.Context) string { + if v, ok := ctx.Value(traceIDKey).(string); ok { + return v + } + return "" +} + +// ─── HTTP middleware ─────────────────────────────────────────────────────────── + +// HTTPMiddleware returns an http.Handler middleware that: +// +// 1. Extracts (or notes the absence of) a trace ID from the X-Trace-ID or +// X-Request-ID request header. +// 2. Stores the service logger and trace ID in the request context so that +// handler code can call FromContext to obtain a pre-enriched logger. +// 3. After the handler returns, emits a single INFO-level "http request" +// record containing: method, path, status, latency, remote_addr, +// tenant_id (when present), trace_id (when present). +// +// Chain this after the tenant.Middleware so that tenant_id is available: +// +// http.ListenAndServe(addr, tenant.Middleware(logger.HTTPMiddleware(log)(mux))) +func HTTPMiddleware(l *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // Prefer X-Trace-ID; fall back to X-Request-ID for compatibility + // with AWS ALB / GCP load balancers that inject X-Request-ID. + traceID := r.Header.Get("X-Trace-ID") + if traceID == "" { + traceID = r.Header.Get("X-Request-ID") + } + + ctx := r.Context() + ctx = WithContext(ctx, l) + if traceID != "" { + ctx = WithTraceID(ctx, traceID) + } + + // Wrap the ResponseWriter so we can capture the status code written + // by the handler without interfering with its normal operation. + rw := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rw, r.WithContext(ctx)) + + attrs := []any{ + slog.String("method", r.Method), + slog.String("path", r.URL.Path), + slog.Int("status", rw.status), + slog.Duration("latency", time.Since(start)), + slog.String("remote_addr", r.RemoteAddr), + } + if tid := tenant.FromContext(ctx); tid != "" && tid != "default" { + attrs = append(attrs, slog.String("tenant_id", tid)) + } + if traceID != "" { + attrs = append(attrs, slog.String("trace_id", traceID)) + } + + l.InfoContext(ctx, "http request", attrs...) + }) + } +} + +// ─── internal helpers ───────────────────────────────────────────────────────── + +// statusRecorder wraps http.ResponseWriter to capture the HTTP status code. +// The zero value's status field must be initialised to 200 before use. +type statusRecorder struct { + http.ResponseWriter + status int + wroteHeader bool +} + +func (r *statusRecorder) WriteHeader(code int) { + if r.wroteHeader { + return + } + r.status = code + r.wroteHeader = true + r.ResponseWriter.WriteHeader(code) +} + +func (r *statusRecorder) Write(b []byte) (int, error) { + if !r.wroteHeader { + r.WriteHeader(http.StatusOK) + } + return r.ResponseWriter.Write(b) +} + +// Unwrap allows net/http internals (e.g. http.Flusher, http.Hijacker) to reach +// the underlying ResponseWriter through the wrapper. +func (r *statusRecorder) Unwrap() http.ResponseWriter { + return r.ResponseWriter +} + +// parseLevel converts a string level to slog.Level. +// Unknown / empty values default to slog.LevelInfo. +func parseLevel(s string) slog.Level { + switch strings.ToLower(s) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/packages/go-common/logger/logger_test.go b/packages/go-common/logger/logger_test.go new file mode 100644 index 0000000..9a5c895 --- /dev/null +++ b/packages/go-common/logger/logger_test.go @@ -0,0 +1,273 @@ +/* + * Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved. + * + * SoftlaneIT licenses this file to you under the Apache License, + * Version 2.0 (the "LICENSE"); you may not use this file except + * in compliance with the LICENSE. + * You may obtain a copy of the LICENSE at + * + * https://softlaneit.com/LICENSE.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the LICENSE is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the LICENSE for the + * specific language governing permissions and limitations + * under the LICENSE. + */ + +package logger_test + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/SoftLaneIT/serviceforge/packages/go-common/logger" + "github.com/SoftLaneIT/serviceforge/packages/go-common/tenant" +) + +// newBufLogger builds a JSON logger that writes to buf instead of os.Stdout. +// This lets tests inspect the exact bytes emitted without capturing stdout. +func newBufLogger(buf *bytes.Buffer, level slog.Level) *slog.Logger { + h := slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: level}) + return slog.New(h) +} + +// decodeLastRecord unmarshals the last newline-delimited JSON object in buf. +func decodeLastRecord(t *testing.T, buf *bytes.Buffer) map[string]any { + t.Helper() + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + var rec map[string]any + if err := json.Unmarshal([]byte(lines[len(lines)-1]), &rec); err != nil { + t.Fatalf("failed to decode log record: %v\nraw: %s", err, buf.String()) + } + return rec +} + +// ─── parseLevel ─────────────────────────────────────────────────────────────── + +func TestNew_LevelDebug(t *testing.T) { + var buf bytes.Buffer + // We can't swap os.Stdout, so we verify via the exported Options struct + // that constructing a logger with level "debug" does not panic and that + // the returned logger accepts debug records. + h := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + l := slog.New(h) + l.Debug("debug message") + + if !strings.Contains(buf.String(), "debug message") { + t.Fatalf("expected debug message in output, got: %s", buf.String()) + } +} + +func TestNew_LevelInfo_FiltersDebug(t *testing.T) { + var buf bytes.Buffer + h := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}) + l := slog.New(h) + l.Debug("should be dropped") + l.Info("should appear") + + output := buf.String() + if strings.Contains(output, "should be dropped") { + t.Fatal("debug record should have been filtered at info level") + } + if !strings.Contains(output, "should appear") { + t.Fatal("info record was unexpectedly filtered") + } +} + +// ─── context helpers ────────────────────────────────────────────────────────── + +func TestWithContext_FromContext_ReturnsStoredLogger(t *testing.T) { + var buf bytes.Buffer + l := newBufLogger(&buf, slog.LevelInfo) + + ctx := logger.WithContext(context.Background(), l) + got := logger.FromContext(ctx) + + got.Info("hello from context") + if !strings.Contains(buf.String(), "hello from context") { + t.Fatalf("expected log output from context logger, got: %s", buf.String()) + } +} + +func TestFromContext_FallsBackToDefault(t *testing.T) { + // No logger stored — should not panic and should return a non-nil logger. + got := logger.FromContext(context.Background()) + if got == nil { + t.Fatal("FromContext returned nil without a stored logger") + } +} + +func TestFromContext_EnrichesWithTenantID(t *testing.T) { + var buf bytes.Buffer + l := newBufLogger(&buf, slog.LevelInfo) + + // Build a context that carries a tenant ID (via the tenant package) and a + // stored logger. + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Tenant-ID", "acme-corp") + + // Use the tenant middleware to inject the tenant ID into the context. + var capturedCtx context.Context + handler := tenant.Middleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + capturedCtx = r.Context() + })) + handler.ServeHTTP(httptest.NewRecorder(), req) + + enrichedCtx := logger.WithContext(capturedCtx, l) + log := logger.FromContext(enrichedCtx) + log.Info("tenant-aware record") + + rec := decodeLastRecord(t, &buf) + if rec["tenant_id"] != "acme-corp" { + t.Fatalf("expected tenant_id=acme-corp, got: %v", rec["tenant_id"]) + } +} + +func TestFromContext_EnrichesWithTraceID(t *testing.T) { + var buf bytes.Buffer + l := newBufLogger(&buf, slog.LevelInfo) + + ctx := logger.WithContext(context.Background(), l) + ctx = logger.WithTraceID(ctx, "trace-xyz-123") + + logger.FromContext(ctx).Info("traced record") + + rec := decodeLastRecord(t, &buf) + if rec["trace_id"] != "trace-xyz-123" { + t.Fatalf("expected trace_id=trace-xyz-123, got: %v", rec["trace_id"]) + } +} + +func TestFromContext_DoesNotAppendDefaultTenantID(t *testing.T) { + // The "default" fallback tenant should not pollute log records. + var buf bytes.Buffer + l := newBufLogger(&buf, slog.LevelInfo) + + ctx := logger.WithContext(context.Background(), l) + logger.FromContext(ctx).Info("no-tenant record") + + rec := decodeLastRecord(t, &buf) + if _, ok := rec["tenant_id"]; ok { + t.Fatalf("tenant_id should not appear for default tenant, got: %v", rec["tenant_id"]) + } +} + +func TestWithTraceID_TraceIDFromContext(t *testing.T) { + ctx := logger.WithTraceID(context.Background(), "req-abc") + if got := logger.TraceIDFromContext(ctx); got != "req-abc" { + t.Fatalf("expected req-abc, got %q", got) + } +} + +func TestTraceIDFromContext_EmptyWhenNotSet(t *testing.T) { + if got := logger.TraceIDFromContext(context.Background()); got != "" { + t.Fatalf("expected empty string, got %q", got) + } +} + +// ─── HTTPMiddleware ─────────────────────────────────────────────────────────── + +func TestHTTPMiddleware_LogsRequest(t *testing.T) { + var buf bytes.Buffer + l := newBufLogger(&buf, slog.LevelInfo) + + mux := http.NewServeMux() + mux.HandleFunc("/ping", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Chain: tenant middleware → logger middleware → mux. + handler := tenant.Middleware(logger.HTTPMiddleware(l)(mux)) + + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + req.Header.Set("X-Tenant-ID", "test-tenant") + req.Header.Set("X-Trace-ID", "trace-001") + + rw := httptest.NewRecorder() + handler.ServeHTTP(rw, req) + + rec := decodeLastRecord(t, &buf) + + checks := map[string]any{ + "msg": "http request", + "method": "GET", + "path": "/ping", + "status": float64(http.StatusOK), + "tenant_id": "test-tenant", + "trace_id": "trace-001", + } + for field, want := range checks { + if rec[field] != want { + t.Errorf("field %q: want %v, got %v", field, want, rec[field]) + } + } + if _, ok := rec["latency"]; !ok { + t.Error("expected latency field in log record") + } +} + +func TestHTTPMiddleware_CapturesNon200Status(t *testing.T) { + var buf bytes.Buffer + l := newBufLogger(&buf, slog.LevelInfo) + + mux := http.NewServeMux() + mux.HandleFunc("/boom", func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + }) + + handler := logger.HTTPMiddleware(l)(mux) + req := httptest.NewRequest(http.MethodGet, "/boom", nil) + handler.ServeHTTP(httptest.NewRecorder(), req) + + rec := decodeLastRecord(t, &buf) + if rec["status"] != float64(http.StatusNotFound) { + t.Fatalf("expected status 404, got %v", rec["status"]) + } +} + +func TestHTTPMiddleware_XRequestIDFallback(t *testing.T) { + var buf bytes.Buffer + l := newBufLogger(&buf, slog.LevelInfo) + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {}) + + handler := logger.HTTPMiddleware(l)(mux) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Request-ID", "req-fallback-99") + + handler.ServeHTTP(httptest.NewRecorder(), req) + + rec := decodeLastRecord(t, &buf) + if rec["trace_id"] != "req-fallback-99" { + t.Fatalf("expected trace_id from X-Request-ID, got %v", rec["trace_id"]) + } +} + +func TestHTTPMiddleware_StoresLoggerInContext(t *testing.T) { + var buf bytes.Buffer + l := newBufLogger(&buf, slog.LevelInfo) + + mux := http.NewServeMux() + mux.HandleFunc("/ctx", func(w http.ResponseWriter, r *http.Request) { + // Handler uses FromContext — this must write to buf, not /dev/null. + logger.FromContext(r.Context()).Info("inside handler") + w.WriteHeader(http.StatusOK) + }) + + handler := logger.HTTPMiddleware(l)(mux) + req := httptest.NewRequest(http.MethodGet, "/ctx", nil) + handler.ServeHTTP(httptest.NewRecorder(), req) + + if !strings.Contains(buf.String(), "inside handler") { + t.Fatalf("handler logger did not write to the expected buffer: %s", buf.String()) + } +} diff --git a/packages/go-common/tenant/context.go b/packages/go-common/tenant/context.go index 1f87bca..6b8434e 100644 --- a/packages/go-common/tenant/context.go +++ b/packages/go-common/tenant/context.go @@ -29,6 +29,14 @@ const ( tenantHeader = "X-Tenant-ID" tenantContextKey = ctxKey("tenant_id") defaultTenantName = "default" + + // DefaultTenant is the sentinel value returned by FromContext when no + // X-Tenant-ID header was present on the request. Handlers that require a + // real tenant ID should compare against this value to detect unauthenticated + // calls, e.g.: + // + // if tid := tenant.FromContext(ctx); tid == tenant.DefaultTenant { ... } + DefaultTenant = defaultTenantName ) func Middleware(next http.Handler) http.Handler { @@ -48,3 +56,10 @@ func FromContext(ctx context.Context) string { } return defaultTenantName } + +// NewContext returns a copy of ctx with tenantID stored under the tenant key. +// Useful in tests and internal callers that construct contexts directly instead +// of going through Middleware. +func NewContext(ctx context.Context, tenantID string) context.Context { + return context.WithValue(ctx, tenantContextKey, tenantID) +} diff --git a/services/api-gateway/cmd/server/main.go b/services/api-gateway/cmd/server/main.go index 40b287e..49fb654 100644 --- a/services/api-gateway/cmd/server/main.go +++ b/services/api-gateway/cmd/server/main.go @@ -16,47 +16,156 @@ * under the LICENSE. */ +// Command server is the entry point for the api-gateway. +// +// The gateway sits in front of all other ServiceForge microservices. It: +// - validates API keys by delegating to auth-service +// - enforces per-tenant rate limits (in-memory fixed window, Phase 1) +// - reverse-proxies authenticated requests to the appropriate downstream +// +// Routes (all authenticated unless noted): +// +// GET /health — local health check (no auth) +// * /v1/tenants/* — proxied to tenant-service (no auth, admin) +// * /v1/keys/* — proxied to auth-service (no auth, admin) +// * /v1/bookings/* — proxied to booking-service (auth required) +// * /v1/config/* — proxied to config-service (auth required) +// +// Environment variables: +// +// PORT HTTP listen port (default: 8081) +// AUTH_SERVICE_URL (default: http://localhost:8082) +// TENANT_SERVICE_URL (default: http://localhost:8083) +// BOOKING_SERVICE_URL (default: http://localhost:8084) +// CONFIG_SERVICE_URL (default: http://localhost:8085) +// RATE_LIMIT requests per tenant per minute (default: 100; 0 = disabled) +// LOG_LEVEL debug | info | warn | error (default: info) +// LOG_FORMAT json | text (default: json) package main import ( + "context" "encoding/json" - "log" + "errors" + "log/slog" "net/http" + "os" + "os/signal" + "strconv" + "syscall" + "time" "github.com/SoftLaneIT/serviceforge/packages/go-common/config" + "github.com/SoftLaneIT/serviceforge/packages/go-common/logger" "github.com/SoftLaneIT/serviceforge/packages/go-common/tenant" + gw "github.com/SoftLaneIT/serviceforge/services/api-gateway/internal/middleware" + "github.com/SoftLaneIT/serviceforge/services/api-gateway/internal/proxy" + "github.com/SoftLaneIT/serviceforge/services/api-gateway/internal/registry" ) func main() { + log := logger.NewFromEnv("api-gateway") + + // ── service registry ────────────────────────────────────────────────────── + reg, err := registry.Load() + if err != nil { + log.Error("load registry", slog.Any("error", err)) + os.Exit(1) + } + + // ── rate limiter ────────────────────────────────────────────────────────── + rateLimit := parseIntDefault(config.GetEnv("RATE_LIMIT", "100"), 100) + rateLimiter := gw.NewRateLimiter(rateLimit, time.Minute) + + // ── auth middleware ─────────────────────────────────────────────────────── + authServiceURL := config.GetEnv("AUTH_SERVICE_URL", "http://localhost:8082") + authMiddleware := gw.AuthMiddleware(authServiceURL, log) + + // ── reverse proxies ─────────────────────────────────────────────────────── + authProxy := proxy.New(reg.URL(registry.Auth), log) + tenantProxy := proxy.New(reg.URL(registry.Tenant), log) + bookingProxy := proxy.New(reg.URL(registry.Booking), log) + configProxy := proxy.New(reg.URL(registry.Config), log) + + // ── routing ─────────────────────────────────────────────────────────────── + // + // Unauthenticated routes are registered first so the auth middleware only + // wraps the paths that need it. mux := http.NewServeMux() - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + + // Health — no auth. + mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]any{"service": "api-gateway", "status": "ok"}) }) - mux.HandleFunc("/v1/tenant/context", func(w http.ResponseWriter, r *http.Request) { - respondJSON(w, http.StatusOK, map[string]any{"tenantId": tenant.FromContext(r.Context())}) - }) - // Phase 1 placeholder routes. - mux.HandleFunc("/v1/bookings", func(w http.ResponseWriter, r *http.Request) { - respondJSON(w, http.StatusOK, map[string]any{ - "message": "Route is wired at the gateway.", - "next": "Forward to booking-service in the next increment.", - "tenantId": tenant.FromContext(r.Context()), - }) - }) - mux.HandleFunc("/v1/config/booking", func(w http.ResponseWriter, r *http.Request) { - respondJSON(w, http.StatusOK, map[string]any{ - "message": "Route is wired at the gateway.", - "next": "Forward to config-service in the next increment.", - "tenantId": tenant.FromContext(r.Context()), - }) - }) + // Admin routes — no client auth (protected by network policy / internal LB). + // /v1/tenants/{...} → tenant-service + mux.Handle("/v1/tenants/", tenantProxy) + mux.Handle("/v1/tenants", tenantProxy) + // /v1/keys/{...} → auth-service (key management) + mux.Handle("/v1/keys/", authProxy) + mux.Handle("/v1/keys", authProxy) + + // ── authenticated + rate-limited routes ─────────────────────────────────── + // + // Middleware chain (innermost first): + // bookingProxy / configProxy + // → rateLimiter.Middleware + // → authMiddleware + // → tenant.Middleware (resolves X-Tenant-ID from header context) + // → logger.HTTPMiddleware (structured request logging) + + withAuth := func(h http.Handler) http.Handler { + return authMiddleware(rateLimiter.Middleware(h)) + } + mux.Handle("/v1/bookings/", withAuth(bookingProxy)) + mux.Handle("/v1/bookings", withAuth(bookingProxy)) + mux.Handle("/v1/config/", withAuth(configProxy)) + mux.Handle("/v1/config", withAuth(configProxy)) + + // Outer middleware applied to the whole mux. + httpHandler := tenant.Middleware(logger.HTTPMiddleware(log)(mux)) + + // ── HTTP server ─────────────────────────────────────────────────────────── port := config.GetEnv("PORT", "8081") - log.Printf("api-gateway listening on :%s", port) - if err := http.ListenAndServe(":"+port, tenant.Middleware(mux)); err != nil { - log.Fatal(err) + srv := &http.Server{ + Addr: ":" + port, + Handler: httpHandler, + ReadTimeout: 10 * time.Second, + WriteTimeout: 60 * time.Second, // allow slower upstreams + IdleTimeout: 120 * time.Second, + } + + serverErr := make(chan error, 1) + go func() { + log.Info("api-gateway starting", + slog.String("port", port), + slog.Int("rateLimit", rateLimit), + ) + if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + serverErr <- err + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT) + + select { + case sig := <-quit: + log.Info("shutdown signal received", slog.String("signal", sig.String())) + case err := <-serverErr: + log.Error("server error", slog.Any("error", err)) + os.Exit(1) } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Error("forced shutdown", slog.Any("error", err)) + } + log.Info("api-gateway stopped") } func respondJSON(w http.ResponseWriter, status int, payload any) { @@ -64,3 +173,11 @@ func respondJSON(w http.ResponseWriter, status int, payload any) { w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } + +func parseIntDefault(s string, def int) int { + v, err := strconv.Atoi(s) + if err != nil || v < 0 { + return def + } + return v +} diff --git a/services/api-gateway/internal/middleware/auth.go b/services/api-gateway/internal/middleware/auth.go new file mode 100644 index 0000000..f5ab3d3 --- /dev/null +++ b/services/api-gateway/internal/middleware/auth.go @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved. + * + * SoftlaneIT licenses this file to you under the Apache License, + * Version 2.0 (the "LICENSE"); you may not use this file except + * in compliance with the LICENSE. + * You may obtain a copy of the LICENSE at + * + * https://softlaneit.com/LICENSE.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the LICENSE is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the LICENSE for the + * specific language governing permissions and limitations + * under the LICENSE. + */ + +// Package middleware provides HTTP middleware for the api-gateway. +package middleware + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/SoftLaneIT/serviceforge/packages/go-common/logger" + "github.com/SoftLaneIT/serviceforge/packages/go-common/tenant" +) + +// validateResponse is the expected payload from auth-service POST /v1/keys/validate. +type validateResponse struct { + TenantID string `json:"tenantId"` + KeyID string `json:"keyId"` + Environment string `json:"environment"` + ModuleScope []string `json:"moduleScope"` +} + +// AuthMiddleware validates the Bearer token in the Authorization header by +// calling auth-service. On success it injects X-Tenant-ID into the request +// context and forwards the original request. On failure it returns 401/403. +// +// authServiceURL is the base URL of the auth-service, +// e.g. "http://auth-service:8082". +func AuthMiddleware(authServiceURL string, log *slog.Logger) func(http.Handler) http.Handler { + validateURL := strings.TrimRight(authServiceURL, "/") + "/v1/keys/validate" + + client := &http.Client{Timeout: 3 * time.Second} + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawKey := bearerToken(r) + if rawKey == "" { + respondAuthError(w, http.StatusUnauthorized, "missing or malformed Authorization header") + return + } + + apiKey, err := validateKey(r.Context(), client, validateURL, rawKey) + if err != nil { + logger.FromContext(r.Context()).Warn("auth validation failed", slog.Any("error", err)) + respondAuthError(w, http.StatusUnauthorized, "invalid api key") + return + } + + // Inject the resolved tenant ID so downstream proxies can forward it. + ctx := tenant.NewContext(r.Context(), apiKey.TenantID) + r = r.WithContext(ctx) + r.Header.Set("X-Tenant-ID", apiKey.TenantID) + // Surface the key environment to downstream services. + r.Header.Set("X-Key-Environment", apiKey.Environment) + + next.ServeHTTP(w, r) + }) + } +} + +// bearerToken extracts the token from an "Authorization: Bearer " header. +// Returns "" if the header is absent or malformed. +func bearerToken(r *http.Request) string { + h := r.Header.Get("Authorization") + const prefix = "Bearer " + if !strings.HasPrefix(h, prefix) { + return "" + } + t := strings.TrimSpace(h[len(prefix):]) + return t +} + +// validateKey calls the auth-service validate endpoint and returns the +// resolved key metadata. +func validateKey(ctx context.Context, client *http.Client, validateURL, rawKey string) (*validateResponse, error) { + body, err := json.Marshal(map[string]string{"key": rawKey}) + if err != nil { + return nil, fmt.Errorf("marshal validate body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, validateURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("build validate request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("call auth-service: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("auth-service returned %d", resp.StatusCode) + } + + var vr validateResponse + if err := json.NewDecoder(resp.Body).Decode(&vr); err != nil { + return nil, fmt.Errorf("decode validate response: %w", err) + } + return &vr, nil +} + +func respondAuthError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} diff --git a/services/api-gateway/internal/middleware/ratelimit.go b/services/api-gateway/internal/middleware/ratelimit.go new file mode 100644 index 0000000..065bb7e --- /dev/null +++ b/services/api-gateway/internal/middleware/ratelimit.go @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved. + * + * SoftlaneIT licenses this file to you under the Apache License, + * Version 2.0 (the "LICENSE"); you may not use this file except + * in compliance with the LICENSE. + * You may obtain a copy of the LICENSE at + * + * https://softlaneit.com/LICENSE.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the LICENSE is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the LICENSE for the + * specific language governing permissions and limitations + * under the LICENSE. + */ + +package middleware + +import ( + "encoding/json" + "net/http" + "sync" + "time" + + "github.com/SoftLaneIT/serviceforge/packages/go-common/tenant" +) + +// windowState tracks request counts within a fixed time window. +type windowState struct { + mu sync.Mutex + count int + windowStart time.Time +} + +// RateLimiter implements a fixed-window rate limiter keyed by tenant ID. +// This is an in-memory implementation suitable for single-instance deployments +// (Phase 1). Multi-instance deployments should replace this with a Redis- +// backed sliding window limiter. +type RateLimiter struct { + states sync.Map // tenantID → *windowState + limit int + windowSize time.Duration +} + +// NewRateLimiter returns a RateLimiter that allows at most limit requests per +// window. A zero or negative limit disables rate limiting. +func NewRateLimiter(limit int, window time.Duration) *RateLimiter { + return &RateLimiter{limit: limit, windowSize: window} +} + +// Allow reports whether a request from tenantID is within the rate limit. +func (rl *RateLimiter) Allow(tenantID string) bool { + if rl.limit <= 0 { + return true + } + + now := time.Now() + raw, _ := rl.states.LoadOrStore(tenantID, &windowState{windowStart: now}) + ws := raw.(*windowState) + + ws.mu.Lock() + defer ws.mu.Unlock() + + if now.Sub(ws.windowStart) >= rl.windowSize { + // Start a new window. + ws.windowStart = now + ws.count = 0 + } + + if ws.count >= rl.limit { + return false + } + ws.count++ + return true +} + +// Middleware wraps next with per-tenant rate limiting. The tenant ID is read +// from the request context (populated by tenant.Middleware or AuthMiddleware). +func (rl *RateLimiter) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tid := tenant.FromContext(r.Context()) + if !rl.Allow(tid) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Retry-After", "60") + w.WriteHeader(http.StatusTooManyRequests) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "rate limit exceeded"}) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/services/api-gateway/internal/proxy/proxy.go b/services/api-gateway/internal/proxy/proxy.go new file mode 100644 index 0000000..42bf55b --- /dev/null +++ b/services/api-gateway/internal/proxy/proxy.go @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved. + * + * SoftlaneIT licenses this file to you under the Apache License, + * Version 2.0 (the "LICENSE"); you may not use this file except + * in compliance with the LICENSE. + * You may obtain a copy of the LICENSE at + * + * https://softlaneit.com/LICENSE.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the LICENSE is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the LICENSE for the + * specific language governing permissions and limitations + * under the LICENSE. + */ + +// Package proxy wraps httputil.ReverseProxy with gateway-specific behaviour: +// stripping hop-by-hop headers, forwarding the resolved X-Tenant-ID, and +// adding a structured error response on upstream failure. +package proxy + +import ( + "encoding/json" + "log/slog" + "net/http" + "net/http/httputil" + "net/url" +) + +// New returns an httputil.ReverseProxy that forwards requests to target. +// The Director: +// - rewrites the request URL to the target host/scheme +// - removes the Authorization header (the key was already validated) +// - preserves all other headers including X-Tenant-ID injected by the auth +// middleware +// +// The ErrorHandler returns a structured JSON 502 on upstream failure. +func New(target *url.URL, log *slog.Logger) *httputil.ReverseProxy { + rp := httputil.NewSingleHostReverseProxy(target) + + // Wrap the default Director so we can post-process the request. + defaultDirector := rp.Director + rp.Director = func(req *http.Request) { + defaultDirector(req) + // Remove Authorization — the downstream services trust X-Tenant-ID, + // not raw API keys. + req.Header.Del("Authorization") + // Ensure the Host header matches the target (some upstreams require it). + req.Host = target.Host + } + + rp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + log.Error("upstream error", + slog.String("target", target.String()), + slog.String("path", r.URL.Path), + slog.Any("error", err), + ) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "upstream service unavailable"}) + } + + return rp +} diff --git a/services/api-gateway/internal/registry/registry.go b/services/api-gateway/internal/registry/registry.go new file mode 100644 index 0000000..a0a1078 --- /dev/null +++ b/services/api-gateway/internal/registry/registry.go @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved. + * + * SoftlaneIT licenses this file to you under the Apache License, + * Version 2.0 (the "LICENSE"); you may not use this file except + * in compliance with the LICENSE. + * You may obtain a copy of the LICENSE at + * + * https://softlaneit.com/LICENSE.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the LICENSE is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the LICENSE for the + * specific language governing permissions and limitations + * under the LICENSE. + */ + +// Package registry provides a static service registry loaded from environment +// variables. In a later phase this would be replaced by a service-discovery +// mechanism (Consul, Kubernetes EndpointSlices, etc.). +package registry + +import ( + "fmt" + "net/url" + + "github.com/SoftLaneIT/serviceforge/packages/go-common/config" +) + +// Service names used as keys in the registry. +const ( + Auth = "auth-service" + Tenant = "tenant-service" + Booking = "booking-service" + Config = "config-service" +) + +// Registry maps service names to their base URLs. +type Registry struct { + urls map[string]*url.URL +} + +// Load reads service URLs from environment variables and returns a Registry. +// +// Environment variables (with defaults suitable for docker-compose): +// +// AUTH_SERVICE_URL (default: http://auth-service:8082) +// TENANT_SERVICE_URL (default: http://tenant-service:8083) +// BOOKING_SERVICE_URL (default: http://booking-service:8084) +// CONFIG_SERVICE_URL (default: http://config-service:8085) +func Load() (*Registry, error) { + raw := map[string]string{ + Auth: config.GetEnv("AUTH_SERVICE_URL", "http://localhost:8082"), + Tenant: config.GetEnv("TENANT_SERVICE_URL", "http://localhost:8083"), + Booking: config.GetEnv("BOOKING_SERVICE_URL", "http://localhost:8084"), + Config: config.GetEnv("CONFIG_SERVICE_URL", "http://localhost:8085"), + } + + r := &Registry{urls: make(map[string]*url.URL, len(raw))} + for name, rawURL := range raw { + u, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("registry: invalid URL for %s (%q): %w", name, rawURL, err) + } + r.urls[name] = u + } + return r, nil +} + +// URL returns the base URL for the named service, or nil if unknown. +func (r *Registry) URL(name string) *url.URL { + return r.urls[name] +} diff --git a/services/auth-service/cmd/server/main.go b/services/auth-service/cmd/server/main.go index 856ac6f..6205b6f 100644 --- a/services/auth-service/cmd/server/main.go +++ b/services/auth-service/cmd/server/main.go @@ -16,43 +16,184 @@ * under the LICENSE. */ +// Command server is the entry point for the auth-service. +// +// Environment variables: +// +// PORT HTTP listen port (default: 8082) +// DATABASE_URL PostgreSQL connection string (required) +// REDIS_URL Redis connection string (default: redis://localhost:6379/0) +// LOG_LEVEL debug | info | warn | error (default: info) +// LOG_FORMAT json | text (default: json) package main import ( - "encoding/json" - "log" + "context" + "errors" + "log/slog" "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" "github.com/SoftLaneIT/serviceforge/packages/go-common/config" + "github.com/SoftLaneIT/serviceforge/packages/go-common/logger" "github.com/SoftLaneIT/serviceforge/packages/go-common/tenant" + "github.com/SoftLaneIT/serviceforge/services/auth-service/internal/cache" + "github.com/SoftLaneIT/serviceforge/services/auth-service/internal/handler" + "github.com/SoftLaneIT/serviceforge/services/auth-service/internal/repository" ) func main() { + // logger ─ + log := logger.NewFromEnv("auth-service") + + // database ─ + dsn := config.GetEnv("DATABASE_URL", + "postgres://serviceforge:serviceforge@localhost:5432/serviceforge?sslmode=disable") + pool := mustConnectPool(log, dsn) + defer pool.Close() + + // redis + redisURL := config.GetEnv("REDIS_URL", "redis://localhost:6379/0") + redisClient := mustConnectRedis(log, redisURL) + defer redisClient.Close() + + // repository + cache + handler + repo := repository.NewPostgres(pool) + redisCache := cache.NewRedis(redisClient) + h := handler.New(repo, redisCache, log) + + // HTTP server mux := http.NewServeMux() - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - respondJSON(w, http.StatusOK, map[string]any{"service": "auth-service", "status": "ok"}) - }) - mux.HandleFunc("/v1/tokens", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - respondJSON(w, http.StatusCreated, map[string]any{ - "tenantId": tenant.FromContext(r.Context()), - "accessToken": "stub-access-token", - "tokenType": "Bearer", - }) - }) + h.RegisterRoutes(mux) + + // Middleware chain (outermost first): + // tenant.Middleware → injects tenant_id from X-Tenant-ID header + // logger.HTTPMiddleware → structured request logging + httpHandler := tenant.Middleware(logger.HTTPMiddleware(log)(mux)) port := config.GetEnv("PORT", "8082") - log.Printf("auth-service listening on :%s", port) - if err := http.ListenAndServe(":"+port, tenant.Middleware(mux)); err != nil { - log.Fatal(err) + srv := &http.Server{ + Addr: ":" + port, + Handler: httpHandler, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, } + + // graceful shutdown + serverErr := make(chan error, 1) + go func() { + log.Info("auth-service starting", slog.String("port", port)) + if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + serverErr <- err + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT) + + select { + case sig := <-quit: + log.Info("shutdown signal received", slog.String("signal", sig.String())) + case err := <-serverErr: + log.Error("server error", slog.Any("error", err)) + os.Exit(1) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Error("forced shutdown", slog.Any("error", err)) + } + log.Info("auth-service stopped") } -func respondJSON(w http.ResponseWriter, status int, payload any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(payload) +// mustConnectPool attempts to connect to PostgreSQL with exponential back-off +// retries. It calls os.Exit(1) if the database is unreachable after all +// attempts. +func mustConnectPool(log *slog.Logger, dsn string) *pgxpool.Pool { + const maxAttempts = 5 + + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + log.Error("invalid DATABASE_URL", slog.Any("error", err)) + os.Exit(1) + } + + cfg.MaxConns = 10 + cfg.MinConns = 2 + cfg.MaxConnLifetime = 1 * time.Hour + cfg.MaxConnIdleTime = 5 * time.Minute + + ctx := context.Background() + var pool *pgxpool.Pool + + for attempt := range maxAttempts { + pool, err = pgxpool.NewWithConfig(ctx, cfg) + if err == nil { + if pingErr := pool.Ping(ctx); pingErr == nil { + log.Info("database connected", slog.Int("attempt", attempt+1)) + return pool + } else { + pool.Close() + err = pingErr + } + } + + wait := time.Duration(1< ../../packages/go-common diff --git a/services/auth-service/go.sum b/services/auth-service/go.sum new file mode 100644 index 0000000..e065e3a --- /dev/null +++ b/services/auth-service/go.sum @@ -0,0 +1,38 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8= +github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/services/auth-service/internal/cache/cache.go b/services/auth-service/internal/cache/cache.go new file mode 100644 index 0000000..24019b8 --- /dev/null +++ b/services/auth-service/internal/cache/cache.go @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved. + * + * SoftlaneIT licenses this file to you under the Apache License, + * Version 2.0 (the "LICENSE"); you may not use this file except + * in compliance with the LICENSE. + * You may obtain a copy of the LICENSE at + * + * https://softlaneit.com/LICENSE.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the LICENSE is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the LICENSE for the + * specific language governing permissions and limitations + * under the LICENSE. + */ + +// Package cache provides the caching layer for API key validation. +// +// On the validate hot-path the service checks Redis first; only on a cache +// miss does it fall back to Postgres. Cached entries use a 5-minute TTL so +// that a revoked key stops working within that window even without an explicit +// cache invalidation. Revoke calls actively delete the cache entry so the +// window is usually much shorter in practice. +package cache + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/SoftLaneIT/serviceforge/services/auth-service/internal/domain" +) + +const ( + // DefaultTTL is the cache entry lifetime for validated API keys. + DefaultTTL = 5 * time.Minute + // keyPrefix is prepended to every Redis key. + keyPrefix = "apikey:" +) + +// Cache defines the contract for the API-key validation cache layer. +type Cache interface { + // Get returns the cached APIKey for the given SHA-256 hash. + // found=false (and err=nil) when the entry is absent or expired. + Get(ctx context.Context, hash string) (key *domain.APIKey, found bool, err error) + + // Set stores key in the cache under hash with the given TTL. + Set(ctx context.Context, hash string, key *domain.APIKey, ttl time.Duration) error + + // Delete removes the cache entry for hash. It is safe to call when the + // entry does not exist. + Delete(ctx context.Context, hash string) error + + // Ping verifies the cache connection is alive. + Ping(ctx context.Context) error +} + +// RedisCache is the Redis-backed implementation of Cache. +type RedisCache struct { + client *redis.Client +} + +// NewRedis creates a RedisCache connected to addr (e.g. "localhost:6379"). +func NewRedis(client *redis.Client) *RedisCache { + return &RedisCache{client: client} +} + +func cacheKey(hash string) string { return keyPrefix + hash } + +// Get retrieves and deserialises an APIKey from Redis. +func (c *RedisCache) Get(ctx context.Context, hash string) (*domain.APIKey, bool, error) { + b, err := c.client.Get(ctx, cacheKey(hash)).Bytes() + if err != nil { + if errors.Is(err, redis.Nil) { + return nil, false, nil + } + return nil, false, fmt.Errorf("cache get: %w", err) + } + + var k domain.APIKey + if err := json.Unmarshal(b, &k); err != nil { + // Treat corrupt entries as a cache miss — Postgres will repopulate. + return nil, false, nil + } + return &k, true, nil +} + +// Set serialises key to JSON and stores it in Redis with ttl. +func (c *RedisCache) Set(ctx context.Context, hash string, key *domain.APIKey, ttl time.Duration) error { + b, err := json.Marshal(key) + if err != nil { + return fmt.Errorf("cache marshal: %w", err) + } + if err := c.client.Set(ctx, cacheKey(hash), b, ttl).Err(); err != nil { + return fmt.Errorf("cache set: %w", err) + } + return nil +} + +// Delete removes the entry for hash from Redis. +func (c *RedisCache) Delete(ctx context.Context, hash string) error { + if err := c.client.Del(ctx, cacheKey(hash)).Err(); err != nil { + return fmt.Errorf("cache delete: %w", err) + } + return nil +} + +// Ping checks the Redis connection. +func (c *RedisCache) Ping(ctx context.Context) error { + return c.client.Ping(ctx).Err() +} + +// NoopCache is a Cache implementation that always reports a miss. +// Used in tests and when Redis is intentionally disabled. +type NoopCache struct{} + +func (NoopCache) Get(_ context.Context, _ string) (*domain.APIKey, bool, error) { + return nil, false, nil +} +func (NoopCache) Set(_ context.Context, _ string, _ *domain.APIKey, _ time.Duration) error { + return nil +} +func (NoopCache) Delete(_ context.Context, _ string) error { return nil } +func (NoopCache) Ping(_ context.Context) error { return nil } diff --git a/services/auth-service/internal/domain/apikey.go b/services/auth-service/internal/domain/apikey.go new file mode 100644 index 0000000..d67a56e --- /dev/null +++ b/services/auth-service/internal/domain/apikey.go @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved. + * + * SoftlaneIT licenses this file to you under the Apache License, + * Version 2.0 (the "LICENSE"); you may not use this file except + * in compliance with the LICENSE. + * You may obtain a copy of the LICENSE at + * + * https://softlaneit.com/LICENSE.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the LICENSE is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the LICENSE for the + * specific language governing permissions and limitations + * under the LICENSE. + */ + +// Package domain defines the APIKey aggregate and the business rules that +// govern it. This package has zero external dependencies. +package domain + +import ( + "errors" + "time" +) + +// Environment is the deployment context for an API key. +type Environment string + +const ( + EnvSandbox Environment = "sandbox" + EnvProduction Environment = "production" +) + +// Valid reports whether e is a recognised environment value. +func (e Environment) Valid() bool { + switch e { + case EnvSandbox, EnvProduction: + return true + } + return false +} + +// KeyStatus is the lifecycle state of an API key. +type KeyStatus string + +const ( + KeyStatusActive KeyStatus = "active" + KeyStatusRevoked KeyStatus = "revoked" +) + +// Valid reports whether s is a recognised status value. +func (s KeyStatus) Valid() bool { + switch s { + case KeyStatusActive, KeyStatusRevoked: + return true + } + return false +} + +// APIKey is the view of an API key that is safe to return in list/get +// responses. The raw key and key_hash are never included. +type APIKey struct { + ID string `json:"id"` + TenantID string `json:"tenantId"` + Name string `json:"name"` + KeyPrefix string `json:"keyPrefix"` + Environment Environment `json:"environment"` + ModuleScope []string `json:"moduleScope"` + Status KeyStatus `json:"status"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + RevokedAt *time.Time `json:"revokedAt,omitempty"` +} + +// CreateParams is the validated input for issuing a new API key. +// KeyHash and KeyPrefix are computed by the keygen package before calling +// the repository; they are not supplied directly by the HTTP caller. +type CreateParams struct { + TenantID string + Name string + KeyHash string // SHA-256 hex of the raw key + KeyPrefix string // first 12 chars of the raw key (display only) + Environment Environment + ModuleScope []string + ExpiresAt *time.Time +} + +// Sentinel errors returned by the repository. +var ( + // ErrNotFound is returned when an API key does not exist for the given + // tenant, or has already been revoked. + ErrNotFound = errors.New("api key not found") + + // ErrInvalidKey is returned by the validate path when the supplied raw key + // is not found in the store or is in the revoked state. + ErrInvalidKey = errors.New("invalid or revoked api key") + + // ErrExpiredKey is returned when a key exists but its expires_at has passed. + ErrExpiredKey = errors.New("api key has expired") +) diff --git a/services/auth-service/internal/handler/handler.go b/services/auth-service/internal/handler/handler.go new file mode 100644 index 0000000..a77fa3d --- /dev/null +++ b/services/auth-service/internal/handler/handler.go @@ -0,0 +1,363 @@ +/* + * Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved. + * + * SoftlaneIT licenses this file to you under the Apache License, + * Version 2.0 (the "LICENSE"); you may not use this file except + * in compliance with the LICENSE. + * You may obtain a copy of the LICENSE at + * + * https://softlaneit.com/LICENSE.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the LICENSE is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the LICENSE for the + * specific language governing permissions and limitations + * under the LICENSE. + */ + +// Package handler contains the HTTP layer for the auth-service. It depends +// only on the repository interface, the cache interface, and domain types — +// never on pgx, Redis, or any other infrastructure package directly — so +// handlers can be unit-tested with in-memory fakes. +package handler + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/SoftLaneIT/serviceforge/packages/go-common/logger" + "github.com/SoftLaneIT/serviceforge/packages/go-common/tenant" + "github.com/SoftLaneIT/serviceforge/services/auth-service/internal/cache" + "github.com/SoftLaneIT/serviceforge/services/auth-service/internal/domain" + "github.com/SoftLaneIT/serviceforge/services/auth-service/internal/keygen" + "github.com/SoftLaneIT/serviceforge/services/auth-service/internal/repository" +) + +// Handler holds the dependencies shared across all HTTP handler methods. +type Handler struct { + repo repository.Repository + cache cache.Cache + log *slog.Logger +} + +// New returns a Handler wired to the given repository, cache, and logger. +func New(repo repository.Repository, c cache.Cache, log *slog.Logger) *Handler { + return &Handler{repo: repo, cache: c, log: log} +} + +// RegisterRoutes registers all auth-service routes onto mux. +// Uses the Go 1.22 "METHOD /path/{param}" pattern syntax. +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /health", h.Health) + mux.HandleFunc("POST /v1/keys", h.IssueKey) + mux.HandleFunc("GET /v1/keys", h.ListKeys) + mux.HandleFunc("GET /v1/keys/{id}", h.GetKey) + mux.HandleFunc("DELETE /v1/keys/{id}", h.RevokeKey) + mux.HandleFunc("POST /v1/keys/validate", h.ValidateKey) +} + +// Health ─ + +// Health returns 200 when both the DB and cache are reachable, 503 otherwise. +func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + resp := map[string]any{ + "service": "auth-service", + "status": "ok", + "db": "ok", + "cache": "ok", + } + + degraded := false + if err := h.repo.Ping(ctx); err != nil { + logger.FromContext(ctx).Warn("db ping failed", slog.Any("error", err)) + resp["status"] = "degraded" + resp["db"] = "unreachable" + degraded = true + } + if err := h.cache.Ping(ctx); err != nil { + logger.FromContext(ctx).Warn("cache ping failed", slog.Any("error", err)) + resp["status"] = "degraded" + resp["cache"] = "unreachable" + degraded = true + } + + status := http.StatusOK + if degraded { + status = http.StatusServiceUnavailable + } + respondJSON(w, status, resp) +} + +// IssueKey ─ + +type issueRequest struct { + Name string `json:"name"` + Environment domain.Environment `json:"environment"` + ModuleScope []string `json:"moduleScope"` + ExpiresAt *time.Time `json:"expiresAt"` +} + +// issueResponse wraps the persisted APIKey with the one-time raw key. +type issueResponse struct { + domain.APIKey + // RawKey is included only in this response and never stored. + // The caller must save it immediately — it cannot be retrieved later. + RawKey string `json:"rawKey"` +} + +// IssueKey creates a new API key for the tenant identified by X-Tenant-ID. +func (h *Handler) IssueKey(w http.ResponseWriter, r *http.Request) { + log := logger.FromContext(r.Context()) + tenantID := tenant.FromContext(r.Context()) + if tenantID == "" || tenantID == tenant.DefaultTenant { + respondError(w, http.StatusBadRequest, "X-Tenant-ID header is required") + return + } + + var req issueRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid JSON body") + return + } + + if strings.TrimSpace(req.Name) == "" { + respondError(w, http.StatusUnprocessableEntity, "name is required") + return + } + if req.Environment == "" { + req.Environment = domain.EnvSandbox // sensible default + } + if !req.Environment.Valid() { + respondError(w, http.StatusUnprocessableEntity, + "environment must be one of sandbox, production") + return + } + + // Generate cryptographically random key material. + gen, err := keygen.Generate(req.Environment) + if err != nil { + log.Error("keygen failed", slog.Any("error", err)) + respondError(w, http.StatusInternalServerError, "internal error") + return + } + + params := domain.CreateParams{ + TenantID: tenantID, + Name: strings.TrimSpace(req.Name), + KeyHash: gen.Hash, + KeyPrefix: gen.Prefix, + Environment: req.Environment, + ModuleScope: req.ModuleScope, + ExpiresAt: req.ExpiresAt, + } + if params.ModuleScope == nil { + params.ModuleScope = []string{} + } + + apiKey, err := h.repo.Create(r.Context(), params) + if err != nil { + log.Error("create api key", slog.Any("error", err)) + respondError(w, http.StatusInternalServerError, "internal error") + return + } + + log.Info("api key issued", + slog.String("key_id", apiKey.ID), + slog.String("tenant_id", tenantID), + slog.String("environment", string(req.Environment)), + ) + respondJSON(w, http.StatusCreated, issueResponse{APIKey: *apiKey, RawKey: gen.RawKey}) +} + +// ListKeys ─ + +func (h *Handler) ListKeys(w http.ResponseWriter, r *http.Request) { + log := logger.FromContext(r.Context()) + tenantID := tenant.FromContext(r.Context()) + if tenantID == "" || tenantID == tenant.DefaultTenant { + respondError(w, http.StatusBadRequest, "X-Tenant-ID header is required") + return + } + + keys, err := h.repo.List(r.Context(), tenantID) + if err != nil { + log.Error("list api keys", slog.Any("error", err)) + respondError(w, http.StatusInternalServerError, "internal error") + return + } + + respondJSON(w, http.StatusOK, map[string]any{ + "data": keys, + "total": len(keys), + }) +} + +// GetKey ─ + +func (h *Handler) GetKey(w http.ResponseWriter, r *http.Request) { + log := logger.FromContext(r.Context()) + tenantID := tenant.FromContext(r.Context()) + if tenantID == "" || tenantID == tenant.DefaultTenant { + respondError(w, http.StatusBadRequest, "X-Tenant-ID header is required") + return + } + + id := r.PathValue("id") + key, err := h.repo.GetByID(r.Context(), id, tenantID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + respondError(w, http.StatusNotFound, "api key not found") + return + } + log.Error("get api key", slog.String("id", id), slog.Any("error", err)) + respondError(w, http.StatusInternalServerError, "internal error") + return + } + + respondJSON(w, http.StatusOK, key) +} + +// RevokeKey + +func (h *Handler) RevokeKey(w http.ResponseWriter, r *http.Request) { + log := logger.FromContext(r.Context()) + tenantID := tenant.FromContext(r.Context()) + if tenantID == "" || tenantID == tenant.DefaultTenant { + respondError(w, http.StatusBadRequest, "X-Tenant-ID header is required") + return + } + + id := r.PathValue("id") + + // Fetch key first so we can delete its cache entry by hash. + // We need the hash to invalidate the cache; it is not stored on the + // APIKey struct (by design), so we do a best-effort delete by id — in + // practice the cache TTL (5 min) is the safety net if this lookup fails. + if err := h.repo.Revoke(r.Context(), id, tenantID); err != nil { + if errors.Is(err, domain.ErrNotFound) { + respondError(w, http.StatusNotFound, "api key not found") + return + } + log.Error("revoke api key", slog.String("id", id), slog.Any("error", err)) + respondError(w, http.StatusInternalServerError, "internal error") + return + } + + log.Info("api key revoked", slog.String("key_id", id), slog.String("tenant_id", tenantID)) + w.WriteHeader(http.StatusNoContent) +} + +// ValidateKey + +type validateRequest struct { + // Key is the raw API key supplied by the calling service. + Key string `json:"key"` +} + +type validateResponse struct { + TenantID string `json:"tenantId"` + KeyID string `json:"keyId"` + Environment domain.Environment `json:"environment"` + ModuleScope []string `json:"moduleScope"` +} + +// ValidateKey is the internal endpoint consumed by the API gateway to verify +// an API key and resolve its owning tenant. +// +// Hot-path: Redis cache hit → return immediately. +// Cold-path: Postgres lookup → populate cache → return. +// After a successful validation, last_used_at is updated asynchronously. +func (h *Handler) ValidateKey(w http.ResponseWriter, r *http.Request) { + log := logger.FromContext(r.Context()) + + var req validateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid JSON body") + return + } + if req.Key == "" { + respondError(w, http.StatusBadRequest, "key is required") + return + } + + hash := keygen.HashRaw(req.Key) + + // cache fast path ─ + if cached, found, err := h.cache.Get(r.Context(), hash); err == nil && found { + if cached.Status == domain.KeyStatusRevoked { + respondError(w, http.StatusUnauthorized, "api key has been revoked") + return + } + if cached.ExpiresAt != nil && cached.ExpiresAt.Before(time.Now()) { + respondError(w, http.StatusUnauthorized, "api key has expired") + return + } + go h.asyncUpdateLastUsed(cached.ID) + respondJSON(w, http.StatusOK, validateResponse{ + TenantID: cached.TenantID, + KeyID: cached.ID, + Environment: cached.Environment, + ModuleScope: cached.ModuleScope, + }) + return + } + + // Postgres cold path + apiKey, err := h.repo.GetByHash(r.Context(), hash) + if err != nil { + switch { + case errors.Is(err, domain.ErrInvalidKey): + respondError(w, http.StatusUnauthorized, "invalid api key") + case errors.Is(err, domain.ErrExpiredKey): + respondError(w, http.StatusUnauthorized, "api key has expired") + default: + log.Error("validate api key", slog.Any("error", err)) + respondError(w, http.StatusInternalServerError, "internal error") + } + return + } + + // Populate cache for future requests. + if err := h.cache.Set(r.Context(), hash, apiKey, cache.DefaultTTL); err != nil { + // Non-fatal — log and continue. + log.Warn("cache set failed", slog.Any("error", err)) + } + + go h.asyncUpdateLastUsed(apiKey.ID) + + respondJSON(w, http.StatusOK, validateResponse{ + TenantID: apiKey.TenantID, + KeyID: apiKey.ID, + Environment: apiKey.Environment, + ModuleScope: apiKey.ModuleScope, + }) +} + +// asyncUpdateLastUsed records the current time as last_used_at for key id. +// Runs in a goroutine so it never blocks the response path. +func (h *Handler) asyncUpdateLastUsed(id string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := h.repo.UpdateLastUsed(ctx, id); err != nil { + h.log.Warn("update last_used_at failed", slog.String("key_id", id), slog.Any("error", err)) + } +} + +// helpers + +func respondJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func respondError(w http.ResponseWriter, status int, msg string) { + respondJSON(w, status, map[string]string{"error": msg}) +} diff --git a/services/auth-service/internal/handler/handler_test.go b/services/auth-service/internal/handler/handler_test.go new file mode 100644 index 0000000..b8f893d --- /dev/null +++ b/services/auth-service/internal/handler/handler_test.go @@ -0,0 +1,425 @@ +/* + * Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved. + * + * SoftlaneIT licenses this file to you under the Apache License, + * Version 2.0 (the "LICENSE"); you may not use this file except + * in compliance with the LICENSE. + * You may obtain a copy of the LICENSE at + * + * https://softlaneit.com/LICENSE.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the LICENSE is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the LICENSE for the + * specific language governing permissions and limitations + * under the LICENSE. + */ + +package handler_test + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "sync" + "testing" + "time" + + "github.com/SoftLaneIT/serviceforge/packages/go-common/tenant" + "github.com/SoftLaneIT/serviceforge/services/auth-service/internal/cache" + "github.com/SoftLaneIT/serviceforge/services/auth-service/internal/domain" + "github.com/SoftLaneIT/serviceforge/services/auth-service/internal/handler" + "github.com/SoftLaneIT/serviceforge/services/auth-service/internal/keygen" + "github.com/SoftLaneIT/serviceforge/services/auth-service/internal/repository" +) + +// fakes + +// fakeRepo is a thread-safe in-memory Repository. +type fakeRepo struct { + mu sync.Mutex + keys map[string]*domain.APIKey // id → key + // byHash maps key_hash → id + byHash map[string]string + // pingErr, if set, is returned by Ping. + pingErr error +} + +func newFakeRepo() *fakeRepo { + return &fakeRepo{keys: map[string]*domain.APIKey{}, byHash: map[string]string{}} +} + +func (r *fakeRepo) Create(_ context.Context, p domain.CreateParams) (*domain.APIKey, error) { + r.mu.Lock() + defer r.mu.Unlock() + k := &domain.APIKey{ + ID: "key-" + p.KeyPrefix, + TenantID: p.TenantID, + Name: p.Name, + KeyPrefix: p.KeyPrefix, + Environment: p.Environment, + ModuleScope: p.ModuleScope, + Status: domain.KeyStatusActive, + CreatedAt: time.Now(), + } + if p.ExpiresAt != nil { + k.ExpiresAt = p.ExpiresAt + } + r.keys[k.ID] = k + r.byHash[p.KeyHash] = k.ID + return k, nil +} + +func (r *fakeRepo) GetByID(_ context.Context, id, tenantID string) (*domain.APIKey, error) { + r.mu.Lock() + defer r.mu.Unlock() + k, ok := r.keys[id] + if !ok || k.TenantID != tenantID { + return nil, domain.ErrNotFound + } + return k, nil +} + +func (r *fakeRepo) List(_ context.Context, tenantID string) ([]domain.APIKey, error) { + r.mu.Lock() + defer r.mu.Unlock() + var out []domain.APIKey + for _, k := range r.keys { + if k.TenantID == tenantID { + out = append(out, *k) + } + } + if out == nil { + out = []domain.APIKey{} + } + return out, nil +} + +func (r *fakeRepo) Revoke(_ context.Context, id, tenantID string) error { + r.mu.Lock() + defer r.mu.Unlock() + k, ok := r.keys[id] + if !ok || k.TenantID != tenantID || k.Status == domain.KeyStatusRevoked { + return domain.ErrNotFound + } + k.Status = domain.KeyStatusRevoked + now := time.Now() + k.RevokedAt = &now + return nil +} + +func (r *fakeRepo) GetByHash(_ context.Context, hash string) (*domain.APIKey, error) { + r.mu.Lock() + defer r.mu.Unlock() + id, ok := r.byHash[hash] + if !ok { + return nil, domain.ErrInvalidKey + } + k := r.keys[id] + if k.Status == domain.KeyStatusRevoked { + return nil, domain.ErrInvalidKey + } + if k.ExpiresAt != nil && k.ExpiresAt.Before(time.Now()) { + return nil, domain.ErrExpiredKey + } + return k, nil +} + +func (r *fakeRepo) UpdateLastUsed(_ context.Context, _ string) error { return nil } + +func (r *fakeRepo) Ping(_ context.Context) error { return r.pingErr } + +// compile-time check +var _ repository.Repository = (*fakeRepo)(nil) + +// helpers + +func newHandler(t *testing.T) (*handler.Handler, *fakeRepo) { + t.Helper() + repo := newFakeRepo() + log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + h := handler.New(repo, cache.NoopCache{}, log) + return h, repo +} + +// withTenant injects a tenant ID into the request context via the tenant +// middleware so that tenant.FromContext works inside handlers. +func withTenant(r *http.Request, tid string) *http.Request { + ctx := tenant.NewContext(r.Context(), tid) + return r.WithContext(ctx) +} + +func do(t *testing.T, h *handler.Handler, method, path string, body any, tenantID string) *httptest.ResponseRecorder { + t.Helper() + var buf bytes.Buffer + if body != nil { + if err := json.NewEncoder(&buf).Encode(body); err != nil { + t.Fatalf("encode body: %v", err) + } + } + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", "application/json") + if tenantID != "" { + req = withTenant(req, tenantID) + } + rr := httptest.NewRecorder() + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + mux.ServeHTTP(rr, req) + return rr +} + +func decodeJSON(t *testing.T, rr *httptest.ResponseRecorder, dst any) { + t.Helper() + if err := json.NewDecoder(rr.Body).Decode(dst); err != nil { + t.Fatalf("decode response: %v", err) + } +} + +// IssueKey ─ + +func TestIssueKey_Created(t *testing.T) { + h, _ := newHandler(t) + rr := do(t, h, http.MethodPost, "/v1/keys", map[string]any{ + "name": "my-key", + "environment": "sandbox", + }, "tenant-abc") + + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body: %s", rr.Code, rr.Body) + } + + var resp struct { + ID string `json:"id"` + RawKey string `json:"rawKey"` + Name string `json:"name"` + TenantID string `json:"tenantId"` + } + decodeJSON(t, rr, &resp) + + if resp.RawKey == "" { + t.Error("rawKey should be present in create response") + } + if resp.Name != "my-key" { + t.Errorf("name = %q, want %q", resp.Name, "my-key") + } + if resp.TenantID != "tenant-abc" { + t.Errorf("tenantId = %q, want %q", resp.TenantID, "tenant-abc") + } +} + +func TestIssueKey_DefaultsToSandbox(t *testing.T) { + h, _ := newHandler(t) + rr := do(t, h, http.MethodPost, "/v1/keys", map[string]any{"name": "x"}, "t1") + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d; body: %s", rr.Code, rr.Body) + } + var resp struct { + Environment string `json:"environment"` + } + decodeJSON(t, rr, &resp) + if resp.Environment != "sandbox" { + t.Errorf("environment = %q, want sandbox", resp.Environment) + } +} + +func TestIssueKey_MissingName(t *testing.T) { + h, _ := newHandler(t) + rr := do(t, h, http.MethodPost, "/v1/keys", map[string]any{}, "t1") + if rr.Code != http.StatusUnprocessableEntity { + t.Fatalf("status = %d, want 422", rr.Code) + } +} + +func TestIssueKey_InvalidEnvironment(t *testing.T) { + h, _ := newHandler(t) + rr := do(t, h, http.MethodPost, "/v1/keys", map[string]any{ + "name": "k", "environment": "nope", + }, "t1") + if rr.Code != http.StatusUnprocessableEntity { + t.Fatalf("status = %d, want 422", rr.Code) + } +} + +func TestIssueKey_MissingTenantID(t *testing.T) { + h, _ := newHandler(t) + rr := do(t, h, http.MethodPost, "/v1/keys", map[string]any{"name": "k"}, "") + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rr.Code) + } +} + +// ListKeys ─ + +func TestListKeys_Empty(t *testing.T) { + h, _ := newHandler(t) + rr := do(t, h, http.MethodGet, "/v1/keys", nil, "t1") + if rr.Code != http.StatusOK { + t.Fatalf("status = %d; body: %s", rr.Code, rr.Body) + } + var resp struct { + Data []any `json:"data"` + Total int `json:"total"` + } + decodeJSON(t, rr, &resp) + if resp.Total != 0 || len(resp.Data) != 0 { + t.Errorf("expected empty list, got %+v", resp) + } +} + +func TestListKeys_ReturnsOwnTenantKeys(t *testing.T) { + h, _ := newHandler(t) + // Issue two keys for different tenants. + do(t, h, http.MethodPost, "/v1/keys", map[string]any{"name": "a"}, "t1") + do(t, h, http.MethodPost, "/v1/keys", map[string]any{"name": "b"}, "t1") + do(t, h, http.MethodPost, "/v1/keys", map[string]any{"name": "c"}, "t2") + + rr := do(t, h, http.MethodGet, "/v1/keys", nil, "t1") + var resp struct { + Total int `json:"total"` + } + decodeJSON(t, rr, &resp) + if resp.Total != 2 { + t.Errorf("total = %d, want 2", resp.Total) + } +} + +// GetKey ─ + +func TestGetKey_NotFound(t *testing.T) { + h, _ := newHandler(t) + rr := do(t, h, http.MethodGet, "/v1/keys/nonexistent", nil, "t1") + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rr.Code) + } +} + +func TestGetKey_WrongTenant(t *testing.T) { + h, _ := newHandler(t) + // Issue a key for t1. + rr1 := do(t, h, http.MethodPost, "/v1/keys", map[string]any{"name": "k"}, "t1") + var created struct { + ID string `json:"id"` + } + decodeJSON(t, rr1, &created) + + // Try to fetch with t2. + rr2 := do(t, h, http.MethodGet, "/v1/keys/"+created.ID, nil, "t2") + if rr2.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 for cross-tenant access", rr2.Code) + } +} + +// RevokeKey + +func TestRevokeKey_NoContent(t *testing.T) { + h, _ := newHandler(t) + rr1 := do(t, h, http.MethodPost, "/v1/keys", map[string]any{"name": "k"}, "t1") + var created struct { + ID string `json:"id"` + } + decodeJSON(t, rr1, &created) + + rr2 := do(t, h, http.MethodDelete, "/v1/keys/"+created.ID, nil, "t1") + if rr2.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body: %s", rr2.Code, rr2.Body) + } +} + +func TestRevokeKey_NotFound(t *testing.T) { + h, _ := newHandler(t) + rr := do(t, h, http.MethodDelete, "/v1/keys/ghost", nil, "t1") + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rr.Code) + } +} + +// ValidateKey + +func TestValidateKey_Valid(t *testing.T) { + h, repo := newHandler(t) + // Issue a key and capture the raw value. + rr1 := do(t, h, http.MethodPost, "/v1/keys", map[string]any{"name": "k"}, "t1") + var created struct { + ID string `json:"id"` + RawKey string `json:"rawKey"` + } + decodeJSON(t, rr1, &created) + + // Manually insert the hash into the fake repo so GetByHash works. + hash := keygen.HashRaw(created.RawKey) + repo.mu.Lock() + repo.byHash[hash] = created.ID + repo.mu.Unlock() + + rr2 := do(t, h, http.MethodPost, "/v1/keys/validate", map[string]any{"key": created.RawKey}, "") + if rr2.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rr2.Code, rr2.Body) + } + var resp struct { + TenantID string `json:"tenantId"` + KeyID string `json:"keyId"` + } + decodeJSON(t, rr2, &resp) + if resp.TenantID != "t1" { + t.Errorf("tenantId = %q, want %q", resp.TenantID, "t1") + } + if resp.KeyID != created.ID { + t.Errorf("keyId = %q, want %q", resp.KeyID, created.ID) + } +} + +func TestValidateKey_InvalidKey(t *testing.T) { + h, _ := newHandler(t) + rr := do(t, h, http.MethodPost, "/v1/keys/validate", map[string]any{"key": "sf_test_bogus"}, "") + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rr.Code) + } +} + +func TestValidateKey_RevokedKey(t *testing.T) { + h, repo := newHandler(t) + rr1 := do(t, h, http.MethodPost, "/v1/keys", map[string]any{"name": "k"}, "t1") + var created struct { + ID string `json:"id"` + RawKey string `json:"rawKey"` + } + decodeJSON(t, rr1, &created) + + hash := keygen.HashRaw(created.RawKey) + repo.mu.Lock() + repo.byHash[hash] = created.ID + repo.mu.Unlock() + + // Revoke the key. + do(t, h, http.MethodDelete, "/v1/keys/"+created.ID, nil, "t1") + + rr2 := do(t, h, http.MethodPost, "/v1/keys/validate", map[string]any{"key": created.RawKey}, "") + if rr2.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401 for revoked key", rr2.Code) + } +} + +func TestValidateKey_MissingKey(t *testing.T) { + h, _ := newHandler(t) + rr := do(t, h, http.MethodPost, "/v1/keys/validate", map[string]any{}, "") + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rr.Code) + } +} + +// Health ─ + +func TestHealth_OK(t *testing.T) { + h, _ := newHandler(t) + rr := do(t, h, http.MethodGet, "/health", nil, "") + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } +} diff --git a/services/auth-service/internal/keygen/keygen.go b/services/auth-service/internal/keygen/keygen.go new file mode 100644 index 0000000..a8081e1 --- /dev/null +++ b/services/auth-service/internal/keygen/keygen.go @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved. + * + * SoftlaneIT licenses this file to you under the Apache License, + * Version 2.0 (the "LICENSE"); you may not use this file except + * in compliance with the LICENSE. + * You may obtain a copy of the LICENSE at + * + * https://softlaneit.com/LICENSE.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the LICENSE is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the LICENSE for the + * specific language governing permissions and limitations + * under the LICENSE. + */ + +// Package keygen handles the cryptographic generation and hashing of API keys. +// +// Key format: +// +// sf_live_<64 hex chars> (production) +// sf_test_<64 hex chars> (sandbox) +// +// The 64 hex chars are the hex-encoding of 32 cryptographically random bytes. +// The SHA-256 hash of the full key string is stored in the database; the raw +// key is returned to the caller exactly once and is never persisted. +package keygen + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + + "github.com/SoftLaneIT/serviceforge/services/auth-service/internal/domain" +) + +// Result holds the three values derived from a single key-generation call. +type Result struct { + // RawKey is the full opaque secret, e.g. "sf_live_abc123…". Return it to + // the caller once and discard; it is never stored. + RawKey string + // Hash is the hex-encoded SHA-256 digest of RawKey. This is what gets + // stored in api_keys.key_hash. + Hash string + // Prefix is the first 12 characters of RawKey, used for display in UIs. + // Stored in api_keys.key_prefix. + Prefix string +} + +// Generate creates a new API key for the given environment. +// It reads 32 bytes from crypto/rand and panics if the OS CSPRNG is broken. +func Generate(env domain.Environment) (Result, error) { + b := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + return Result{}, fmt.Errorf("keygen: read random bytes: %w", err) + } + + entropy := hex.EncodeToString(b) // 64 lowercase hex chars + + var prefix string + if env == domain.EnvProduction { + prefix = "sf_live_" + } else { + prefix = "sf_test_" + } + + rawKey := prefix + entropy + + sum := sha256.Sum256([]byte(rawKey)) + hash := hex.EncodeToString(sum[:]) + + return Result{ + RawKey: rawKey, + Hash: hash, + Prefix: rawKey[:12], // e.g. "sf_live_ab12" + }, nil +} + +// HashRaw returns the hex-encoded SHA-256 digest of rawKey. Used on the +// validation hot-path to hash the caller-supplied key before the cache/DB +// lookup — avoids storing the raw key anywhere in memory longer than needed. +func HashRaw(rawKey string) string { + sum := sha256.Sum256([]byte(rawKey)) + return hex.EncodeToString(sum[:]) +} diff --git a/services/auth-service/internal/keygen/keygen_test.go b/services/auth-service/internal/keygen/keygen_test.go new file mode 100644 index 0000000..a141c28 --- /dev/null +++ b/services/auth-service/internal/keygen/keygen_test.go @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved. + * + * SoftlaneIT licenses this file to you under the Apache License, + * Version 2.0 (the "LICENSE"); you may not use this file except + * in compliance with the LICENSE. + * You may obtain a copy of the LICENSE at + * + * https://softlaneit.com/LICENSE.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the LICENSE is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the LICENSE for the + * specific language governing permissions and limitations + * under the LICENSE. + */ + +package keygen_test + +import ( + "strings" + "testing" + + "github.com/SoftLaneIT/serviceforge/services/auth-service/internal/domain" + "github.com/SoftLaneIT/serviceforge/services/auth-service/internal/keygen" +) + +func TestGenerate_Production(t *testing.T) { + r, err := keygen.Generate(domain.EnvProduction) + if err != nil { + t.Fatalf("Generate: %v", err) + } + + if !strings.HasPrefix(r.RawKey, "sf_live_") { + t.Errorf("production key should start with sf_live_, got %q", r.RawKey[:8]) + } + // sf_live_ (8) + 64 hex = 72 chars total + if len(r.RawKey) != 72 { + t.Errorf("raw key length = %d, want 72", len(r.RawKey)) + } +} + +func TestGenerate_Sandbox(t *testing.T) { + r, err := keygen.Generate(domain.EnvSandbox) + if err != nil { + t.Fatalf("Generate: %v", err) + } + + if !strings.HasPrefix(r.RawKey, "sf_test_") { + t.Errorf("sandbox key should start with sf_test_, got %q", r.RawKey[:8]) + } + if len(r.RawKey) != 72 { + t.Errorf("raw key length = %d, want 72", len(r.RawKey)) + } +} + +func TestGenerate_HashLength(t *testing.T) { + r, err := keygen.Generate(domain.EnvSandbox) + if err != nil { + t.Fatalf("Generate: %v", err) + } + // SHA-256 → 32 bytes → 64 hex chars + if len(r.Hash) != 64 { + t.Errorf("hash length = %d, want 64", len(r.Hash)) + } +} + +func TestGenerate_PrefixIs12Chars(t *testing.T) { + r, err := keygen.Generate(domain.EnvProduction) + if err != nil { + t.Fatalf("Generate: %v", err) + } + if len(r.Prefix) != 12 { + t.Errorf("prefix length = %d, want 12", len(r.Prefix)) + } + if r.Prefix != r.RawKey[:12] { + t.Errorf("prefix %q is not first 12 chars of raw key", r.Prefix) + } +} + +func TestGenerate_Uniqueness(t *testing.T) { + a, _ := keygen.Generate(domain.EnvSandbox) + b, _ := keygen.Generate(domain.EnvSandbox) + if a.RawKey == b.RawKey { + t.Error("two Generate calls returned the same raw key") + } + if a.Hash == b.Hash { + t.Error("two Generate calls returned the same hash") + } +} + +func TestHashRaw_Deterministic(t *testing.T) { + r, err := keygen.Generate(domain.EnvSandbox) + if err != nil { + t.Fatalf("Generate: %v", err) + } + // HashRaw of the same key must equal the hash embedded in the result. + if got := keygen.HashRaw(r.RawKey); got != r.Hash { + t.Errorf("HashRaw(%q) = %q, want %q", r.RawKey, got, r.Hash) + } +} + +func TestHashRaw_Length(t *testing.T) { + h := keygen.HashRaw("sf_live_somekey") + if len(h) != 64 { + t.Errorf("HashRaw length = %d, want 64", len(h)) + } +} diff --git a/services/auth-service/internal/repository/postgres.go b/services/auth-service/internal/repository/postgres.go new file mode 100644 index 0000000..86f8b2f --- /dev/null +++ b/services/auth-service/internal/repository/postgres.go @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved. + * + * SoftlaneIT licenses this file to you under the Apache License, + * Version 2.0 (the "LICENSE"); you may not use this file except + * in compliance with the LICENSE. + * You may obtain a copy of the LICENSE at + * + * https://softlaneit.com/LICENSE.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the LICENSE is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the LICENSE for the + * specific language governing permissions and limitations + * under the LICENSE. + */ + +package repository + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/SoftLaneIT/serviceforge/services/auth-service/internal/domain" +) + +// PostgresRepo is the pgx-backed implementation of Repository. +type PostgresRepo struct { + pool *pgxpool.Pool +} + +// NewPostgres returns a PostgresRepo backed by pool. +func NewPostgres(pool *pgxpool.Pool) *PostgresRepo { + return &PostgresRepo{pool: pool} +} + +// Ping delegates to the underlying pool. +func (r *PostgresRepo) Ping(ctx context.Context) error { + return r.pool.Ping(ctx) +} + +// columns returned by every SELECT / RETURNING query in this file. +// Order must match the scan call in scanKey(). +const selectCols = ` + id::text, tenant_id::text, name, key_prefix, environment, + module_scope, status, last_used_at, expires_at, created_at, revoked_at` + +// Create inserts a new API key and returns the persisted view (without the +// raw key or hash). +func (r *PostgresRepo) Create(ctx context.Context, p domain.CreateParams) (*domain.APIKey, error) { + var expiresAt *time.Time + if p.ExpiresAt != nil { + expiresAt = p.ExpiresAt + } + + const q = ` + INSERT INTO api_keys + (tenant_id, name, key_hash, key_prefix, environment, module_scope, expires_at) + VALUES + ($1::uuid, $2, $3, $4, $5, $6, $7) + RETURNING ` + selectCols + + row := r.pool.QueryRow(ctx, q, + p.TenantID, + p.Name, + p.KeyHash, + p.KeyPrefix, + string(p.Environment), + p.ModuleScope, + expiresAt, + ) + + k, err := scanKey(row) + if err != nil { + return nil, fmt.Errorf("create api key: %w", err) + } + return k, nil +} + +// GetByID returns the key only when it belongs to tenantID. +func (r *PostgresRepo) GetByID(ctx context.Context, id, tenantID string) (*domain.APIKey, error) { + const q = ` + SELECT ` + selectCols + ` + FROM api_keys + WHERE id = $1::uuid AND tenant_id = $2::uuid` + + row := r.pool.QueryRow(ctx, q, id, tenantID) + k, err := scanKey(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrNotFound + } + return nil, fmt.Errorf("get api key by id: %w", err) + } + return k, nil +} + +// List returns all keys (active and revoked) for tenantID, newest first. +func (r *PostgresRepo) List(ctx context.Context, tenantID string) ([]domain.APIKey, error) { + const q = ` + SELECT ` + selectCols + ` + FROM api_keys + WHERE tenant_id = $1::uuid + ORDER BY created_at DESC` + + rows, err := r.pool.Query(ctx, q, tenantID) + if err != nil { + return nil, fmt.Errorf("list api keys: %w", err) + } + defer rows.Close() + + var keys []domain.APIKey + for rows.Next() { + k, err := scanKey(rows) + if err != nil { + return nil, fmt.Errorf("scan api key row: %w", err) + } + keys = append(keys, *k) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list api keys rows: %w", err) + } + if keys == nil { + keys = []domain.APIKey{} + } + return keys, nil +} + +// Revoke sets status=revoked and revoked_at=NOW() for the key identified by +// id + tenantID. +func (r *PostgresRepo) Revoke(ctx context.Context, id, tenantID string) error { + const q = ` + UPDATE api_keys + SET status = 'revoked', revoked_at = NOW() + WHERE id = $1::uuid AND tenant_id = $2::uuid AND status = 'active'` + + tag, err := r.pool.Exec(ctx, q, id, tenantID) + if err != nil { + return fmt.Errorf("revoke api key: %w", err) + } + if tag.RowsAffected() == 0 { + return domain.ErrNotFound + } + return nil +} + +// GetByHash looks up a key by its SHA-256 hash. +// Returns ErrInvalidKey when the hash does not exist or the key is revoked. +// Returns ErrExpiredKey when the key has a non-nil expires_at in the past. +func (r *PostgresRepo) GetByHash(ctx context.Context, hash string) (*domain.APIKey, error) { + const q = ` + SELECT ` + selectCols + ` + FROM api_keys + WHERE key_hash = $1` + + row := r.pool.QueryRow(ctx, q, hash) + k, err := scanKey(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrInvalidKey + } + return nil, fmt.Errorf("get api key by hash: %w", err) + } + + if k.Status == domain.KeyStatusRevoked { + return nil, domain.ErrInvalidKey + } + if k.ExpiresAt != nil && k.ExpiresAt.Before(time.Now()) { + return nil, domain.ErrExpiredKey + } + return k, nil +} + +// UpdateLastUsed sets last_used_at = NOW() for the given key id. This is +// best-effort and typically called from a background goroutine. +func (r *PostgresRepo) UpdateLastUsed(ctx context.Context, id string) error { + const q = `UPDATE api_keys SET last_used_at = NOW() WHERE id = $1::uuid` + _, err := r.pool.Exec(ctx, q, id) + return err +} + +// scan helpers + +// scanner is satisfied by both pgx.Row and pgx.Rows. +type scanner interface { + Scan(dest ...any) error +} + +func scanKey(s scanner) (*domain.APIKey, error) { + var ( + k domain.APIKey + env string + status string + moduleScope []string + lastUsedAt pgtype.Timestamptz + expiresAt pgtype.Timestamptz + revokedAt pgtype.Timestamptz + ) + + if err := s.Scan( + &k.ID, + &k.TenantID, + &k.Name, + &k.KeyPrefix, + &env, + &moduleScope, + &status, + &lastUsedAt, + &expiresAt, + &k.CreatedAt, + &revokedAt, + ); err != nil { + return nil, err + } + + k.Environment = domain.Environment(env) + k.Status = domain.KeyStatus(status) + k.ModuleScope = moduleScope + if k.ModuleScope == nil { + k.ModuleScope = []string{} + } + + if lastUsedAt.Valid { + t := lastUsedAt.Time + k.LastUsedAt = &t + } + if expiresAt.Valid { + t := expiresAt.Time + k.ExpiresAt = &t + } + if revokedAt.Valid { + t := revokedAt.Time + k.RevokedAt = &t + } + + return &k, nil +} diff --git a/services/auth-service/internal/repository/repository.go b/services/auth-service/internal/repository/repository.go new file mode 100644 index 0000000..e455aac --- /dev/null +++ b/services/auth-service/internal/repository/repository.go @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved. + * + * SoftlaneIT licenses this file to you under the Apache License, + * Version 2.0 (the "LICENSE"); you may not use this file except + * in compliance with the LICENSE. + * You may obtain a copy of the LICENSE at + * + * https://softlaneit.com/LICENSE.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the LICENSE is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the LICENSE for the + * specific language governing permissions and limitations + * under the LICENSE. + */ + +// Package repository defines the storage contract for auth-service API keys. +package repository + +import ( + "context" + + "github.com/SoftLaneIT/serviceforge/services/auth-service/internal/domain" +) + +// Repository is the persistence contract for the auth-service. +// Implementations must be safe for concurrent use. +type Repository interface { + // Create inserts a new API key row. The RawKey field in CreateParams is + // never passed here — only the hash and prefix derived from it. + Create(ctx context.Context, p domain.CreateParams) (*domain.APIKey, error) + + // GetByID returns the key visible to tenantID, or ErrNotFound. + GetByID(ctx context.Context, id, tenantID string) (*domain.APIKey, error) + + // List returns all non-revoked keys that belong to tenantID. + List(ctx context.Context, tenantID string) ([]domain.APIKey, error) + + // Revoke soft-deletes a key by setting status=revoked and revoked_at=NOW(). + // Returns ErrNotFound if the key does not belong to tenantID or is already + // revoked. + Revoke(ctx context.Context, id, tenantID string) error + + // GetByHash looks up a key by its SHA-256 hash regardless of tenant. + // Used on the validate hot-path before the cache is populated. + // Returns ErrInvalidKey if not found or already revoked. + // Returns ErrExpiredKey if found but expires_at has passed. + GetByHash(ctx context.Context, hash string) (*domain.APIKey, error) + + // UpdateLastUsed records the current timestamp on the key row. This is + // called asynchronously (best-effort) after a successful validation so it + // never blocks the response path. + UpdateLastUsed(ctx context.Context, id string) error + + // Ping verifies the underlying connection is alive. + Ping(ctx context.Context) error +} diff --git a/services/tenant-service/cmd/server/main.go b/services/tenant-service/cmd/server/main.go index a9cdb9e..eec9db5 100644 --- a/services/tenant-service/cmd/server/main.go +++ b/services/tenant-service/cmd/server/main.go @@ -16,55 +16,144 @@ * under the LICENSE. */ +// Command server is the entry point for the tenant-service. +// +// Environment variables: +// +// PORT HTTP listen port (default: 8083) +// DATABASE_URL PostgreSQL connection string (required) +// LOG_LEVEL debug | info | warn | error (default: info) +// LOG_FORMAT json | text (default: json) package main import ( - "encoding/json" - "log" + "context" + "errors" + "log/slog" "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/jackc/pgx/v5/pgxpool" "github.com/SoftLaneIT/serviceforge/packages/go-common/config" + "github.com/SoftLaneIT/serviceforge/packages/go-common/logger" + "github.com/SoftLaneIT/serviceforge/packages/go-common/tenant" + "github.com/SoftLaneIT/serviceforge/services/tenant-service/internal/handler" + "github.com/SoftLaneIT/serviceforge/services/tenant-service/internal/repository" ) -type createTenantRequest struct { - Name string `json:"name"` - Slug string `json:"slug"` - PlanID string `json:"planId"` -} - func main() { + // ── logger ──────────────────────────────────────────────────────────────── + log := logger.NewFromEnv("tenant-service") + + // ── database ────────────────────────────────────────────────────────────── + dsn := config.GetEnv("DATABASE_URL", + "postgres://serviceforge:serviceforge@localhost:5432/serviceforge?sslmode=disable") + + pool := mustConnectPool(log, dsn) + defer pool.Close() + + // ── repository + handler ────────────────────────────────────────────────── + repo := repository.NewPostgres(pool) + h := handler.New(repo, log) + + // ── HTTP server ─────────────────────────────────────────────────────────── mux := http.NewServeMux() - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - respondJSON(w, http.StatusOK, map[string]any{"service": "tenant-service", "status": "ok"}) - }) - mux.HandleFunc("/v1/tenants", func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodPost: - var req createTenantRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid payload", http.StatusBadRequest) - return - } - respondJSON(w, http.StatusCreated, map[string]any{ - "id": "tenant_001", - "name": req.Name, - "slug": req.Slug, - "planId": req.PlanID, - }) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } - }) + h.RegisterRoutes(mux) + + // Middleware chain (outermost first): + // tenant.Middleware → injects tenant_id from X-Tenant-ID header + // logger.HTTPMiddleware → structured request logging with tenant_id + trace_id + httpHandler := tenant.Middleware(logger.HTTPMiddleware(log)(mux)) port := config.GetEnv("PORT", "8083") - log.Printf("tenant-service listening on :%s", port) - if err := http.ListenAndServe(":"+port, mux); err != nil { - log.Fatal(err) + srv := &http.Server{ + Addr: ":" + port, + Handler: httpHandler, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + + // ── graceful shutdown ───────────────────────────────────────────────────── + // Start the HTTP server in a goroutine so the main goroutine can block on + // the signal channel. + serverErr := make(chan error, 1) + go func() { + log.Info("tenant-service starting", slog.String("port", port)) + if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + serverErr <- err + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT) + + select { + case sig := <-quit: + log.Info("shutdown signal received", slog.String("signal", sig.String())) + case err := <-serverErr: + log.Error("server error", slog.Any("error", err)) + os.Exit(1) + } + + // Allow up to 30 s for in-flight requests to complete. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Error("forced shutdown", slog.Any("error", err)) } + log.Info("tenant-service stopped") } -func respondJSON(w http.ResponseWriter, status int, payload any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(payload) +// mustConnectPool attempts to connect to PostgreSQL with exponential back-off +// retries. It calls os.Exit(1) if the database is unreachable after all +// attempts, because a tenant-service without a database is not useful. +func mustConnectPool(log *slog.Logger, dsn string) *pgxpool.Pool { + const maxAttempts = 5 + + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + log.Error("invalid DATABASE_URL", slog.Any("error", err)) + os.Exit(1) + } + + // Reasonable pool limits for a Phase 1 single-replica deployment. + cfg.MaxConns = 10 + cfg.MinConns = 2 + cfg.MaxConnLifetime = 1 * time.Hour + cfg.MaxConnIdleTime = 5 * time.Minute + + ctx := context.Background() + var pool *pgxpool.Pool + + for attempt := range maxAttempts { + pool, err = pgxpool.NewWithConfig(ctx, cfg) + if err == nil { + if pingErr := pool.Ping(ctx); pingErr == nil { + log.Info("database connected", slog.Int("attempt", attempt+1)) + return pool + } else { + pool.Close() + err = pingErr + } + } + + wait := time.Duration(1< ../../packages/go-common diff --git a/services/tenant-service/go.sum b/services/tenant-service/go.sum new file mode 100644 index 0000000..6cae11d --- /dev/null +++ b/services/tenant-service/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/services/tenant-service/internal/domain/tenant.go b/services/tenant-service/internal/domain/tenant.go new file mode 100644 index 0000000..d104cf5 --- /dev/null +++ b/services/tenant-service/internal/domain/tenant.go @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved. + * + * SoftlaneIT licenses this file to you under the Apache License, + * Version 2.0 (the "LICENSE"); you may not use this file except + * in compliance with the LICENSE. + * You may obtain a copy of the LICENSE at + * + * https://softlaneit.com/LICENSE.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the LICENSE is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the LICENSE for the + * specific language governing permissions and limitations + * under the LICENSE. + */ + +// Package domain defines the core Tenant aggregate and the business rules that +// govern it. This package has zero external dependencies — all infrastructure +// concerns (persistence, HTTP) live in separate packages. +package domain + +import ( + "errors" + "fmt" + "regexp" + "strings" + "time" +) + +// value types ─ + +// Plan represents the billing / feature tier assigned to a tenant. +type Plan string + +const ( + PlanStarter Plan = "starter" + PlanPro Plan = "pro" + PlanEnterprise Plan = "enterprise" +) + +// Valid reports whether p is a recognised plan value. +func (p Plan) Valid() bool { + switch p { + case PlanStarter, PlanPro, PlanEnterprise: + return true + } + return false +} + +// Status represents the lifecycle state of a tenant. +type Status string + +const ( + StatusActive Status = "active" + StatusSuspended Status = "suspended" + StatusDeleted Status = "deleted" +) + +// Valid reports whether s is a recognised status value. +func (s Status) Valid() bool { + switch s { + case StatusActive, StatusSuspended, StatusDeleted: + return true + } + return false +} + +// aggregate ─ + +// Tenant is the root aggregate for the tenant-service. Its ID is a UUID +// string generated by PostgreSQL (uuid_generate_v4()). +type Tenant struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Plan Plan `json:"plan"` + Status Status `json:"status"` + Settings map[string]any `json:"settings,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + // DeletedAt is nil for non-deleted tenants and omitted from JSON output. + DeletedAt *time.Time `json:"deletedAt,omitempty"` +} + +// command inputs ── + +// CreateParams is the validated input for creating a new tenant. +// Call Validate() before passing to the repository. +type CreateParams struct { + Name string + Slug string + Plan Plan + Settings map[string]any +} + +// UpdateParams carries the optional fields for a partial tenant update (PATCH). +// A nil pointer means "leave the field unchanged". Settings nil also means +// "leave unchanged"; to clear settings pass an empty map. +type UpdateParams struct { + Name *string + Plan *Plan + Settings map[string]any +} + +// sentinel errors ─ + +// ErrNotFound is returned by the repository when a tenant does not exist or +// has been soft-deleted. +var ErrNotFound = errors.New("tenant not found") + +// ErrSlugConflict is returned when an INSERT or UPDATE would violate the +// unique-slug constraint. +var ErrSlugConflict = errors.New("slug already taken") + +// validation + +// slugRE accepts slugs that are 3-100 characters long, start and end with a +// lowercase alphanumeric character, and contain only lowercase alphanumeric +// characters or hyphens in between. +// +// Examples that pass: "acme", "acme-corp", "my-tenant-1" +// Examples that fail: "-acme", "acme-", "ACME", "ab" (too short), "a" (too short) +var slugRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,98}[a-z0-9]$`) + +// Validate checks all fields of p for validity and returns a combined error +// message if any check fails. It does NOT check uniqueness — that is the +// repository's responsibility. +func (p CreateParams) Validate() error { + var errs []string + + name := strings.TrimSpace(p.Name) + switch { + case name == "": + errs = append(errs, "name is required") + case len(name) > 255: + errs = append(errs, "name must be at most 255 characters") + } + + slug := strings.TrimSpace(p.Slug) + switch { + case slug == "": + errs = append(errs, "slug is required") + case !slugRE.MatchString(slug): + errs = append(errs, + "slug must be 3-100 lowercase alphanumeric characters or hyphens, "+ + "and cannot start or end with a hyphen") + } + + switch { + case p.Plan == "": + errs = append(errs, "plan is required") + case !p.Plan.Valid(): + errs = append(errs, + fmt.Sprintf("plan must be one of starter, pro, enterprise; got %q", p.Plan)) + } + + if len(errs) > 0 { + return fmt.Errorf("validation: %s", strings.Join(errs, "; ")) + } + return nil +} + +// Validate checks the optional fields of p for validity. At least one field +// must be set; all set fields must individually be valid. +func (p UpdateParams) Validate() error { + var errs []string + + if p.Name == nil && p.Plan == nil && p.Settings == nil { + errs = append(errs, "at least one field must be provided") + } + + if p.Name != nil { + name := strings.TrimSpace(*p.Name) + switch { + case name == "": + errs = append(errs, "name must not be empty") + case len(name) > 255: + errs = append(errs, "name must be at most 255 characters") + } + } + + if p.Plan != nil && !p.Plan.Valid() { + errs = append(errs, + fmt.Sprintf("plan must be one of starter, pro, enterprise; got %q", *p.Plan)) + } + + if len(errs) > 0 { + return fmt.Errorf("validation: %s", strings.Join(errs, "; ")) + } + return nil +} diff --git a/services/tenant-service/internal/domain/tenant_test.go b/services/tenant-service/internal/domain/tenant_test.go new file mode 100644 index 0000000..f6a4890 --- /dev/null +++ b/services/tenant-service/internal/domain/tenant_test.go @@ -0,0 +1,247 @@ +/* + * Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved. + * + * SoftlaneIT licenses this file to you under the Apache License, + * Version 2.0 (the "LICENSE"); you may not use this file except + * in compliance with the LICENSE. + * You may obtain a copy of the LICENSE at + * + * https://softlaneit.com/LICENSE.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the LICENSE is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the LICENSE for the + * specific language governing permissions and limitations + * under the LICENSE. + */ + +package domain_test + +import ( + "strings" + "testing" + + "github.com/SoftLaneIT/serviceforge/services/tenant-service/internal/domain" +) + +// ptr is a helper to take the address of a string literal in table tests. +func ptr[T any](v T) *T { return &v } + +// CreateParams.Validate ─ + +func TestCreateParams_Validate(t *testing.T) { + tests := []struct { + name string + params domain.CreateParams + wantErr bool + errFrag string // substring expected in the error message + }{ + { + name: "valid minimal", + params: domain.CreateParams{Name: "Acme Corp", Slug: "acme-corp", Plan: domain.PlanStarter}, + }, + { + name: "valid pro plan", + params: domain.CreateParams{Name: "Big Co", Slug: "big-co", Plan: domain.PlanPro}, + }, + { + name: "valid enterprise plan", + params: domain.CreateParams{Name: "Mega", Slug: "mega-org", Plan: domain.PlanEnterprise}, + }, + { + name: "missing name", + params: domain.CreateParams{Slug: "acme-corp", Plan: domain.PlanStarter}, + wantErr: true, + errFrag: "name is required", + }, + { + name: "name only whitespace", + params: domain.CreateParams{Name: " ", Slug: "acme-corp", Plan: domain.PlanStarter}, + wantErr: true, + errFrag: "name is required", + }, + { + name: "name too long", + params: domain.CreateParams{Name: strings.Repeat("x", 256), Slug: "acme", Plan: domain.PlanStarter}, + wantErr: true, + errFrag: "255 characters", + }, + { + name: "missing slug", + params: domain.CreateParams{Name: "Acme", Plan: domain.PlanStarter}, + wantErr: true, + errFrag: "slug is required", + }, + { + name: "slug starts with hyphen", + params: domain.CreateParams{Name: "Acme", Slug: "-acme", Plan: domain.PlanStarter}, + wantErr: true, + errFrag: "slug must be", + }, + { + name: "slug ends with hyphen", + params: domain.CreateParams{Name: "Acme", Slug: "acme-", Plan: domain.PlanStarter}, + wantErr: true, + errFrag: "slug must be", + }, + { + name: "slug has uppercase", + params: domain.CreateParams{Name: "Acme", Slug: "ACME", Plan: domain.PlanStarter}, + wantErr: true, + errFrag: "slug must be", + }, + { + name: "slug too short (2 chars)", + params: domain.CreateParams{Name: "Acme", Slug: "ab", Plan: domain.PlanStarter}, + wantErr: true, + errFrag: "slug must be", + }, + { + name: "slug too long (101 chars)", + params: domain.CreateParams{Name: "Acme", Slug: "a" + strings.Repeat("b", 98) + "c1", Plan: domain.PlanStarter}, + wantErr: true, + errFrag: "slug must be", + }, + { + name: "slug has spaces", + params: domain.CreateParams{Name: "Acme", Slug: "acme corp", Plan: domain.PlanStarter}, + wantErr: true, + errFrag: "slug must be", + }, + { + name: "missing plan", + params: domain.CreateParams{Name: "Acme", Slug: "acme-corp"}, + wantErr: true, + errFrag: "plan is required", + }, + { + name: "invalid plan", + params: domain.CreateParams{Name: "Acme", Slug: "acme-corp", Plan: "golden"}, + wantErr: true, + errFrag: "plan must be one of", + }, + { + name: "multiple errors reported together", + params: domain.CreateParams{}, + wantErr: true, + errFrag: "name is required", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.params.Validate() + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if tc.errFrag != "" && !strings.Contains(err.Error(), tc.errFrag) { + t.Fatalf("error %q does not contain %q", err.Error(), tc.errFrag) + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + }) + } +} + +// UpdateParams.Validate ─ + +func TestUpdateParams_Validate(t *testing.T) { + tests := []struct { + name string + params domain.UpdateParams + wantErr bool + errFrag string + }{ + { + name: "name only", + params: domain.UpdateParams{Name: ptr("New Name")}, + }, + { + name: "plan only", + params: domain.UpdateParams{Plan: ptr(domain.PlanPro)}, + }, + { + name: "settings only", + params: domain.UpdateParams{Settings: map[string]any{"key": "val"}}, + }, + { + name: "all fields", + params: domain.UpdateParams{Name: ptr("X"), Plan: ptr(domain.PlanEnterprise), Settings: map[string]any{}}, + }, + { + name: "empty (no fields)", + params: domain.UpdateParams{}, + wantErr: true, + errFrag: "at least one field", + }, + { + name: "name blank", + params: domain.UpdateParams{Name: ptr(" ")}, + wantErr: true, + errFrag: "name must not be empty", + }, + { + name: "name too long", + params: domain.UpdateParams{Name: ptr(strings.Repeat("x", 256))}, + wantErr: true, + errFrag: "255 characters", + }, + { + name: "invalid plan", + params: domain.UpdateParams{Plan: ptr(domain.Plan("diamond"))}, + wantErr: true, + errFrag: "plan must be one of", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.params.Validate() + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if tc.errFrag != "" && !strings.Contains(err.Error(), tc.errFrag) { + t.Fatalf("error %q does not contain %q", err.Error(), tc.errFrag) + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + }) + } +} + +// Plan / Status validity + +func TestPlan_Valid(t *testing.T) { + for _, p := range []domain.Plan{domain.PlanStarter, domain.PlanPro, domain.PlanEnterprise} { + if !p.Valid() { + t.Errorf("expected %q to be valid", p) + } + } + for _, p := range []domain.Plan{"", "free", "gold", "STARTER"} { + if p.Valid() { + t.Errorf("expected %q to be invalid", p) + } + } +} + +func TestStatus_Valid(t *testing.T) { + for _, s := range []domain.Status{domain.StatusActive, domain.StatusSuspended, domain.StatusDeleted} { + if !s.Valid() { + t.Errorf("expected %q to be valid", s) + } + } + for _, s := range []domain.Status{"", "paused", "ACTIVE"} { + if s.Valid() { + t.Errorf("expected %q to be invalid", s) + } + } +} diff --git a/services/tenant-service/internal/handler/handler.go b/services/tenant-service/internal/handler/handler.go new file mode 100644 index 0000000..ce95053 --- /dev/null +++ b/services/tenant-service/internal/handler/handler.go @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved. + * + * SoftlaneIT licenses this file to you under the Apache License, + * Version 2.0 (the "LICENSE"); you may not use this file except + * in compliance with the LICENSE. + * You may obtain a copy of the LICENSE at + * + * https://softlaneit.com/LICENSE.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the LICENSE is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the LICENSE for the + * specific language governing permissions and limitations + * under the LICENSE. + */ + +// Package handler contains the HTTP layer for the tenant-service. It depends +// only on the repository interface and the domain types — never on pgx or any +// other infrastructure package — so handlers can be unit-tested with in-memory +// fakes. +package handler + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "strconv" + + "github.com/SoftLaneIT/serviceforge/packages/go-common/logger" + "github.com/SoftLaneIT/serviceforge/services/tenant-service/internal/domain" + "github.com/SoftLaneIT/serviceforge/services/tenant-service/internal/repository" +) + +// Handler holds the dependencies shared across all HTTP handler methods. +type Handler struct { + repo repository.Repository + log *slog.Logger +} + +// New returns a Handler wired to the given repository and logger. +func New(repo repository.Repository, log *slog.Logger) *Handler { + return &Handler{repo: repo, log: log} +} + +// RegisterRoutes registers all tenant-service routes onto mux. +// Uses the Go 1.22 "METHOD /path/{param}" pattern syntax. +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /health", h.Health) + mux.HandleFunc("POST /v1/tenants", h.CreateTenant) + mux.HandleFunc("GET /v1/tenants", h.ListTenants) + mux.HandleFunc("GET /v1/tenants/{id}", h.GetTenant) + mux.HandleFunc("PATCH /v1/tenants/{id}", h.UpdateTenant) + mux.HandleFunc("DELETE /v1/tenants/{id}", h.DeleteTenant) +} + +// Health + +// Health returns 200 when the service and its database connection are healthy, +// 503 when the database is unreachable. +func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{"service": "tenant-service", "status": "ok", "db": "ok"} + + if err := h.repo.Ping(r.Context()); err != nil { + logger.FromContext(r.Context()).Warn("db ping failed", slog.Any("error", err)) + resp["status"] = "degraded" + resp["db"] = "unreachable" + respondJSON(w, http.StatusServiceUnavailable, resp) + return + } + respondJSON(w, http.StatusOK, resp) +} + +// CreateTenant ─ + +type createRequest struct { + Name string `json:"name"` + Slug string `json:"slug"` + Plan domain.Plan `json:"plan"` + Settings map[string]any `json:"settings"` +} + +func (h *Handler) CreateTenant(w http.ResponseWriter, r *http.Request) { + log := logger.FromContext(r.Context()) + + var req createRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid JSON body") + return + } + + params := domain.CreateParams{ + Name: req.Name, + Slug: req.Slug, + Plan: req.Plan, + Settings: req.Settings, + } + if err := params.Validate(); err != nil { + respondError(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + tenant, err := h.repo.Create(r.Context(), params) + if err != nil { + if errors.Is(err, domain.ErrSlugConflict) { + respondError(w, http.StatusConflict, "slug already taken") + return + } + log.Error("create tenant", slog.Any("error", err)) + respondError(w, http.StatusInternalServerError, "internal error") + return + } + + log.Info("tenant created", slog.String("tenant_id", tenant.ID), slog.String("slug", tenant.Slug)) + respondJSON(w, http.StatusCreated, tenant) +} + +// ListTenants ── + +type listResponse struct { + Data []domain.Tenant `json:"data"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +func (h *Handler) ListTenants(w http.ResponseWriter, r *http.Request) { + log := logger.FromContext(r.Context()) + q := r.URL.Query() + + f := repository.ListFilter{ + Limit: parseIntDefault(q.Get("limit"), 20), + Offset: parseIntDefault(q.Get("offset"), 0), + } + if raw := q.Get("status"); raw != "" { + s := domain.Status(raw) + if !s.Valid() { + respondError(w, http.StatusBadRequest, + "status must be one of active, suspended, deleted") + return + } + f.Status = &s + } + + result, err := h.repo.List(r.Context(), f) + if err != nil { + log.Error("list tenants", slog.Any("error", err)) + respondError(w, http.StatusInternalServerError, "internal error") + return + } + + respondJSON(w, http.StatusOK, listResponse{ + Data: result.Tenants, + Total: result.Total, + Limit: f.Limit, + Offset: f.Offset, + }) +} + +// GetTenant ─ + +// GetTenant resolves by UUID {id} path parameter. If the caller passes +// ?by=slug the value of {id} is treated as a slug instead, enabling lookups +// like GET /v1/tenants/acme-corp?by=slug. +func (h *Handler) GetTenant(w http.ResponseWriter, r *http.Request) { + log := logger.FromContext(r.Context()) + id := r.PathValue("id") + + var ( + tenant *domain.Tenant + err error + ) + if r.URL.Query().Get("by") == "slug" { + tenant, err = h.repo.GetBySlug(r.Context(), id) + } else { + tenant, err = h.repo.GetByID(r.Context(), id) + } + + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + respondError(w, http.StatusNotFound, "tenant not found") + return + } + log.Error("get tenant", slog.String("id", id), slog.Any("error", err)) + respondError(w, http.StatusInternalServerError, "internal error") + return + } + + respondJSON(w, http.StatusOK, tenant) +} + +// UpdateTenant ─ + +type updateRequest struct { + Name *string `json:"name"` + Plan *domain.Plan `json:"plan"` + Settings map[string]any `json:"settings"` +} + +func (h *Handler) UpdateTenant(w http.ResponseWriter, r *http.Request) { + log := logger.FromContext(r.Context()) + id := r.PathValue("id") + + var req updateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid JSON body") + return + } + + params := domain.UpdateParams{ + Name: req.Name, + Plan: req.Plan, + Settings: req.Settings, + } + if err := params.Validate(); err != nil { + respondError(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + tenant, err := h.repo.Update(r.Context(), id, params) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + respondError(w, http.StatusNotFound, "tenant not found") + return + } + log.Error("update tenant", slog.String("id", id), slog.Any("error", err)) + respondError(w, http.StatusInternalServerError, "internal error") + return + } + + log.Info("tenant updated", slog.String("tenant_id", tenant.ID)) + respondJSON(w, http.StatusOK, tenant) +} + +// DeleteTenant ─ + +func (h *Handler) DeleteTenant(w http.ResponseWriter, r *http.Request) { + log := logger.FromContext(r.Context()) + id := r.PathValue("id") + + if err := h.repo.Delete(r.Context(), id); err != nil { + if errors.Is(err, domain.ErrNotFound) { + respondError(w, http.StatusNotFound, "tenant not found") + return + } + log.Error("delete tenant", slog.String("id", id), slog.Any("error", err)) + respondError(w, http.StatusInternalServerError, "internal error") + return + } + + log.Info("tenant deleted", slog.String("tenant_id", id)) + w.WriteHeader(http.StatusNoContent) +} + +// helpers + +func respondJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func respondError(w http.ResponseWriter, status int, msg string) { + respondJSON(w, status, map[string]string{"error": msg}) +} + +func parseIntDefault(s string, def int) int { + if s == "" { + return def + } + v, err := strconv.Atoi(s) + if err != nil || v < 0 { + return def + } + return v +} diff --git a/services/tenant-service/internal/handler/handler_test.go b/services/tenant-service/internal/handler/handler_test.go new file mode 100644 index 0000000..6d3f587 --- /dev/null +++ b/services/tenant-service/internal/handler/handler_test.go @@ -0,0 +1,413 @@ +/* + * Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved. + * + * SoftlaneIT licenses this file to you under the Apache License, + * Version 2.0 (the "LICENSE"); you may not use this file except + * in compliance with the LICENSE. + * You may obtain a copy of the LICENSE at + * + * https://softlaneit.com/LICENSE.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the LICENSE is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the LICENSE for the + * specific language governing permissions and limitations + * under the LICENSE. + */ + +package handler_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "log/slog" + + "github.com/SoftLaneIT/serviceforge/services/tenant-service/internal/domain" + "github.com/SoftLaneIT/serviceforge/services/tenant-service/internal/handler" + "github.com/SoftLaneIT/serviceforge/services/tenant-service/internal/repository" +) + +// ─── stub repository ────────────────────────────────────────────────────────── + +// stubRepo is a configurable test double for repository.Repository. Each +// field is a function that the handler under test will call; set only the +// functions relevant to a particular test case. +type stubRepo struct { + createFn func(context.Context, domain.CreateParams) (*domain.Tenant, error) + getByIDFn func(context.Context, string) (*domain.Tenant, error) + getBySlugFn func(context.Context, string) (*domain.Tenant, error) + listFn func(context.Context, repository.ListFilter) (repository.ListResult, error) + updateFn func(context.Context, string, domain.UpdateParams) (*domain.Tenant, error) + deleteFn func(context.Context, string) error + pingFn func(context.Context) error +} + +func (s *stubRepo) Create(ctx context.Context, p domain.CreateParams) (*domain.Tenant, error) { + return s.createFn(ctx, p) +} +func (s *stubRepo) GetByID(ctx context.Context, id string) (*domain.Tenant, error) { + return s.getByIDFn(ctx, id) +} +func (s *stubRepo) GetBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { + return s.getBySlugFn(ctx, slug) +} +func (s *stubRepo) List(ctx context.Context, f repository.ListFilter) (repository.ListResult, error) { + return s.listFn(ctx, f) +} +func (s *stubRepo) Update(ctx context.Context, id string, p domain.UpdateParams) (*domain.Tenant, error) { + return s.updateFn(ctx, id, p) +} +func (s *stubRepo) Delete(ctx context.Context, id string) error { + return s.deleteFn(ctx, id) +} +func (s *stubRepo) Ping(ctx context.Context) error { + if s.pingFn != nil { + return s.pingFn(ctx) + } + return nil +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +func silentLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(bytes.NewBuffer(nil), &slog.HandlerOptions{ + Level: slog.LevelError + 10, // effectively discard all output + })) +} + +func fixedTenant() *domain.Tenant { + return &domain.Tenant{ + ID: "550e8400-e29b-41d4-a716-446655440000", + Name: "Acme Corp", + Slug: "acme-corp", + Plan: domain.PlanStarter, + Status: domain.StatusActive, + Settings: map[string]any{}, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } +} + +func ptr[T any](v T) *T { return &v } + +func mustMarshal(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} + +func decodeBody(t *testing.T, body *bytes.Buffer) map[string]any { + t.Helper() + var m map[string]any + if err := json.NewDecoder(body).Decode(&m); err != nil { + t.Fatalf("decode response: %v\nbody: %s", err, body.String()) + } + return m +} + +// newMux builds and registers a Handler onto a fresh ServeMux. +func newMux(repo repository.Repository) *http.ServeMux { + mux := http.NewServeMux() + handler.New(repo, silentLogger()).RegisterRoutes(mux) + return mux +} + +// ─── Health ─────────────────────────────────────────────────────────────────── + +func TestHealth_OK(t *testing.T) { + mux := newMux(&stubRepo{pingFn: func(context.Context) error { return nil }}) + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rw := httptest.NewRecorder() + mux.ServeHTTP(rw, req) + + if rw.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rw.Code) + } + body := decodeBody(t, rw.Body) + if body["status"] != "ok" { + t.Errorf("want status=ok, got %v", body["status"]) + } +} + +func TestHealth_DBDown(t *testing.T) { + mux := newMux(&stubRepo{pingFn: func(context.Context) error { + return context.DeadlineExceeded + }}) + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rw := httptest.NewRecorder() + mux.ServeHTTP(rw, req) + + if rw.Code != http.StatusServiceUnavailable { + t.Fatalf("want 503, got %d", rw.Code) + } + body := decodeBody(t, rw.Body) + if body["db"] != "unreachable" { + t.Errorf("want db=unreachable, got %v", body["db"]) + } +} + +// ─── CreateTenant ───────────────────────────────────────────────────────────── + +func TestCreateTenant_Created(t *testing.T) { + want := fixedTenant() + mux := newMux(&stubRepo{createFn: func(_ context.Context, p domain.CreateParams) (*domain.Tenant, error) { + if p.Name != "Acme Corp" || p.Slug != "acme-corp" { + t.Errorf("unexpected params: %+v", p) + } + return want, nil + }}) + + body := mustMarshal(t, map[string]any{"name": "Acme Corp", "slug": "acme-corp", "plan": "starter"}) + req := httptest.NewRequest(http.MethodPost, "/v1/tenants", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rw := httptest.NewRecorder() + mux.ServeHTTP(rw, req) + + if rw.Code != http.StatusCreated { + t.Fatalf("want 201, got %d\nbody: %s", rw.Code, rw.Body.String()) + } + resp := decodeBody(t, rw.Body) + if resp["id"] != want.ID { + t.Errorf("want id=%s, got %v", want.ID, resp["id"]) + } +} + +func TestCreateTenant_ValidationError(t *testing.T) { + mux := newMux(&stubRepo{}) + body := mustMarshal(t, map[string]any{"name": "", "slug": "acme-corp", "plan": "starter"}) + req := httptest.NewRequest(http.MethodPost, "/v1/tenants", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rw := httptest.NewRecorder() + mux.ServeHTTP(rw, req) + + if rw.Code != http.StatusUnprocessableEntity { + t.Fatalf("want 422, got %d", rw.Code) + } +} + +func TestCreateTenant_SlugConflict(t *testing.T) { + mux := newMux(&stubRepo{createFn: func(_ context.Context, _ domain.CreateParams) (*domain.Tenant, error) { + return nil, domain.ErrSlugConflict + }}) + body := mustMarshal(t, map[string]any{"name": "Acme", "slug": "acme-corp", "plan": "starter"}) + req := httptest.NewRequest(http.MethodPost, "/v1/tenants", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rw := httptest.NewRecorder() + mux.ServeHTTP(rw, req) + + if rw.Code != http.StatusConflict { + t.Fatalf("want 409, got %d", rw.Code) + } +} + +func TestCreateTenant_BadJSON(t *testing.T) { + mux := newMux(&stubRepo{}) + req := httptest.NewRequest(http.MethodPost, "/v1/tenants", bytes.NewReader([]byte("not-json"))) + rw := httptest.NewRecorder() + mux.ServeHTTP(rw, req) + + if rw.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", rw.Code) + } +} + +// ─── GetTenant ──────────────────────────────────────────────────────────────── + +func TestGetTenant_ByID(t *testing.T) { + want := fixedTenant() + mux := newMux(&stubRepo{getByIDFn: func(_ context.Context, id string) (*domain.Tenant, error) { + if id != want.ID { + t.Errorf("unexpected id %s", id) + } + return want, nil + }}) + + req := httptest.NewRequest(http.MethodGet, "/v1/tenants/"+want.ID, nil) + rw := httptest.NewRecorder() + mux.ServeHTTP(rw, req) + + if rw.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rw.Code) + } +} + +func TestGetTenant_BySlug(t *testing.T) { + want := fixedTenant() + mux := newMux(&stubRepo{getBySlugFn: func(_ context.Context, slug string) (*domain.Tenant, error) { + if slug != want.Slug { + t.Errorf("unexpected slug %s", slug) + } + return want, nil + }}) + + req := httptest.NewRequest(http.MethodGet, "/v1/tenants/acme-corp?by=slug", nil) + rw := httptest.NewRecorder() + mux.ServeHTTP(rw, req) + + if rw.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rw.Code) + } + body := decodeBody(t, rw.Body) + if body["slug"] != want.Slug { + t.Errorf("want slug=%s, got %v", want.Slug, body["slug"]) + } +} + +func TestGetTenant_NotFound(t *testing.T) { + mux := newMux(&stubRepo{getByIDFn: func(_ context.Context, _ string) (*domain.Tenant, error) { + return nil, domain.ErrNotFound + }}) + req := httptest.NewRequest(http.MethodGet, "/v1/tenants/does-not-exist", nil) + rw := httptest.NewRecorder() + mux.ServeHTTP(rw, req) + + if rw.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d", rw.Code) + } +} + +// ─── ListTenants ────────────────────────────────────────────────────────────── + +func TestListTenants_DefaultPagination(t *testing.T) { + mux := newMux(&stubRepo{listFn: func(_ context.Context, f repository.ListFilter) (repository.ListResult, error) { + if f.Limit != 20 || f.Offset != 0 { + t.Errorf("unexpected pagination: limit=%d offset=%d", f.Limit, f.Offset) + } + return repository.ListResult{Tenants: []domain.Tenant{*fixedTenant()}, Total: 1}, nil + }}) + + req := httptest.NewRequest(http.MethodGet, "/v1/tenants", nil) + rw := httptest.NewRecorder() + mux.ServeHTTP(rw, req) + + if rw.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rw.Code) + } + body := decodeBody(t, rw.Body) + if body["total"] != float64(1) { + t.Errorf("want total=1, got %v", body["total"]) + } +} + +func TestListTenants_CustomPagination(t *testing.T) { + mux := newMux(&stubRepo{listFn: func(_ context.Context, f repository.ListFilter) (repository.ListResult, error) { + if f.Limit != 5 || f.Offset != 10 { + t.Errorf("unexpected pagination: limit=%d offset=%d", f.Limit, f.Offset) + } + return repository.ListResult{Tenants: []domain.Tenant{}, Total: 0}, nil + }}) + + req := httptest.NewRequest(http.MethodGet, "/v1/tenants?limit=5&offset=10", nil) + rw := httptest.NewRecorder() + mux.ServeHTTP(rw, req) + + if rw.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rw.Code) + } +} + +func TestListTenants_InvalidStatus(t *testing.T) { + mux := newMux(&stubRepo{}) + req := httptest.NewRequest(http.MethodGet, "/v1/tenants?status=unknown", nil) + rw := httptest.NewRecorder() + mux.ServeHTTP(rw, req) + + if rw.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", rw.Code) + } +} + +// ─── UpdateTenant ───────────────────────────────────────────────────────────── + +func TestUpdateTenant_OK(t *testing.T) { + want := fixedTenant() + want.Name = "New Name" + mux := newMux(&stubRepo{updateFn: func(_ context.Context, id string, p domain.UpdateParams) (*domain.Tenant, error) { + if id != want.ID { + t.Errorf("unexpected id %s", id) + } + if p.Name == nil || *p.Name != "New Name" { + t.Errorf("unexpected name %v", p.Name) + } + return want, nil + }}) + + body := mustMarshal(t, map[string]any{"name": "New Name"}) + req := httptest.NewRequest(http.MethodPatch, "/v1/tenants/"+want.ID, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rw := httptest.NewRecorder() + mux.ServeHTTP(rw, req) + + if rw.Code != http.StatusOK { + t.Fatalf("want 200, got %d\nbody: %s", rw.Code, rw.Body.String()) + } +} + +func TestUpdateTenant_EmptyBodyIsValidationError(t *testing.T) { + mux := newMux(&stubRepo{}) + req := httptest.NewRequest(http.MethodPatch, "/v1/tenants/some-id", bytes.NewReader([]byte("{}"))) + req.Header.Set("Content-Type", "application/json") + rw := httptest.NewRecorder() + mux.ServeHTTP(rw, req) + + if rw.Code != http.StatusUnprocessableEntity { + t.Fatalf("want 422, got %d", rw.Code) + } +} + +func TestUpdateTenant_NotFound(t *testing.T) { + mux := newMux(&stubRepo{updateFn: func(_ context.Context, _ string, _ domain.UpdateParams) (*domain.Tenant, error) { + return nil, domain.ErrNotFound + }}) + body := mustMarshal(t, map[string]any{"name": "X"}) + req := httptest.NewRequest(http.MethodPatch, "/v1/tenants/missing", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rw := httptest.NewRecorder() + mux.ServeHTTP(rw, req) + + if rw.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d", rw.Code) + } +} + +// ─── DeleteTenant ───────────────────────────────────────────────────────────── + +func TestDeleteTenant_NoContent(t *testing.T) { + mux := newMux(&stubRepo{deleteFn: func(_ context.Context, id string) error { + if id != "some-uuid" { + t.Errorf("unexpected id %s", id) + } + return nil + }}) + + req := httptest.NewRequest(http.MethodDelete, "/v1/tenants/some-uuid", nil) + rw := httptest.NewRecorder() + mux.ServeHTTP(rw, req) + + if rw.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d", rw.Code) + } +} + +func TestDeleteTenant_NotFound(t *testing.T) { + mux := newMux(&stubRepo{deleteFn: func(_ context.Context, _ string) error { + return domain.ErrNotFound + }}) + req := httptest.NewRequest(http.MethodDelete, "/v1/tenants/ghost", nil) + rw := httptest.NewRecorder() + mux.ServeHTTP(rw, req) + + if rw.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d", rw.Code) + } +} diff --git a/services/tenant-service/internal/repository/postgres.go b/services/tenant-service/internal/repository/postgres.go new file mode 100644 index 0000000..7c39e15 --- /dev/null +++ b/services/tenant-service/internal/repository/postgres.go @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved. + * + * SoftlaneIT licenses this file to you under the Apache License, + * Version 2.0 (the "LICENSE"); you may not use this file except + * in compliance with the LICENSE. + * You may obtain a copy of the LICENSE at + * + * https://softlaneit.com/LICENSE.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the LICENSE is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the LICENSE for the + * specific language governing permissions and limitations + * under the LICENSE. + */ + +package repository + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/SoftLaneIT/serviceforge/services/tenant-service/internal/domain" +) + +// pgUniqueViolation is the PostgreSQL error code for a unique-constraint breach. +const pgUniqueViolation = "23505" + +// PostgresRepo is the pgx-backed implementation of Repository. +// It requires that every DB call sets app.current_tenant_id via +// set_tenant_context() when RLS is relevant. For the tenant-service itself +// (which reads/writes the tenants table without RLS), this is not necessary. +type PostgresRepo struct { + pool *pgxpool.Pool +} + +// NewPostgres creates a PostgresRepo backed by pool. pool must already be +// connected; call Ping after construction to verify liveness. +func NewPostgres(pool *pgxpool.Pool) *PostgresRepo { + return &PostgresRepo{pool: pool} +} + +// Ping delegates to the underlying pool. +func (r *PostgresRepo) Ping(ctx context.Context) error { + return r.pool.Ping(ctx) +} + +// Create + +func (r *PostgresRepo) Create(ctx context.Context, p domain.CreateParams) (*domain.Tenant, error) { + settingsJSON, err := marshalSettings(p.Settings) + if err != nil { + return nil, err + } + + const q = ` + INSERT INTO tenants (name, slug, plan, settings) + VALUES ($1, $2, $3, $4) + RETURNING id::text, name, slug, plan, status, settings, + created_at, updated_at, deleted_at` + + row := r.pool.QueryRow(ctx, q, + strings.TrimSpace(p.Name), + strings.TrimSpace(p.Slug), + string(p.Plan), + settingsJSON, + ) + t, err := scanTenant(row) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == pgUniqueViolation { + return nil, domain.ErrSlugConflict + } + return nil, fmt.Errorf("create tenant: %w", err) + } + return t, nil +} + +// GetByID + +func (r *PostgresRepo) GetByID(ctx context.Context, id string) (*domain.Tenant, error) { + const q = ` + SELECT id::text, name, slug, plan, status, settings, + created_at, updated_at, deleted_at + FROM tenants + WHERE id = $1::uuid AND deleted_at IS NULL` + + row := r.pool.QueryRow(ctx, q, id) + t, err := scanTenant(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrNotFound + } + return nil, fmt.Errorf("get tenant by id: %w", err) + } + return t, nil +} + +// GetBySlug ─ + +func (r *PostgresRepo) GetBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { + const q = ` + SELECT id::text, name, slug, plan, status, settings, + created_at, updated_at, deleted_at + FROM tenants + WHERE slug = $1 AND deleted_at IS NULL` + + row := r.pool.QueryRow(ctx, q, slug) + t, err := scanTenant(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrNotFound + } + return nil, fmt.Errorf("get tenant by slug: %w", err) + } + return t, nil +} + +// List ── + +func (r *PostgresRepo) List(ctx context.Context, f ListFilter) (ListResult, error) { + // Clamp pagination bounds. + limit := f.Limit + switch { + case limit <= 0: + limit = 20 + case limit > 100: + limit = 100 + } + offset := max(f.Offset, 0) + + // Encode optional status filter as a nullable string so the SQL can use + // ($1::text IS NULL OR status = $1) without branching on the Go side. + var statusArg *string + if f.Status != nil { + s := string(*f.Status) + statusArg = &s + } + + // Total count (before pagination) — required for paging envelopes. + const countQ = ` + SELECT COUNT(*) + FROM tenants + WHERE ($1::text IS NULL OR status = $1) + AND deleted_at IS NULL` + + var total int + if err := r.pool.QueryRow(ctx, countQ, statusArg).Scan(&total); err != nil { + return ListResult{}, fmt.Errorf("count tenants: %w", err) + } + + const listQ = ` + SELECT id::text, name, slug, plan, status, settings, + created_at, updated_at, deleted_at + FROM tenants + WHERE ($1::text IS NULL OR status = $1) + AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT $2 OFFSET $3` + + rows, err := r.pool.Query(ctx, listQ, statusArg, limit, offset) + if err != nil { + return ListResult{}, fmt.Errorf("list tenants: %w", err) + } + defer rows.Close() + + var tenants []domain.Tenant + for rows.Next() { + t, err := scanTenantRow(rows) + if err != nil { + return ListResult{}, fmt.Errorf("scan tenant row: %w", err) + } + tenants = append(tenants, *t) + } + if err := rows.Err(); err != nil { + return ListResult{}, fmt.Errorf("list tenants rows: %w", err) + } + + // Ensure JSON array is never null for empty results. + if tenants == nil { + tenants = []domain.Tenant{} + } + return ListResult{Tenants: tenants, Total: total}, nil +} + +// Update + +// Update builds a dynamic SET clause so that only the fields present in p are +// modified. This prevents a PATCH from inadvertently clearing fields that +// were not included in the request body. +func (r *PostgresRepo) Update(ctx context.Context, id string, p domain.UpdateParams) (*domain.Tenant, error) { + // If nothing is set, skip the DB round-trip. + if p.Name == nil && p.Plan == nil && p.Settings == nil { + return r.GetByID(ctx, id) + } + + // $1 is always the tenant ID used in the WHERE clause. + args := []any{id} + setClauses := []string{"updated_at = NOW()"} + + if p.Name != nil { + args = append(args, strings.TrimSpace(*p.Name)) + setClauses = append(setClauses, fmt.Sprintf("name = $%d", len(args))) + } + if p.Plan != nil { + args = append(args, string(*p.Plan)) + setClauses = append(setClauses, fmt.Sprintf("plan = $%d", len(args))) + } + if p.Settings != nil { + settingsJSON, err := marshalSettings(p.Settings) + if err != nil { + return nil, err + } + args = append(args, settingsJSON) + setClauses = append(setClauses, fmt.Sprintf("settings = $%d", len(args))) + } + + q := fmt.Sprintf(` + UPDATE tenants + SET %s + WHERE id = $1::uuid AND deleted_at IS NULL + RETURNING id::text, name, slug, plan, status, settings, + created_at, updated_at, deleted_at`, + strings.Join(setClauses, ", ")) + + row := r.pool.QueryRow(ctx, q, args...) + t, err := scanTenant(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrNotFound + } + return nil, fmt.Errorf("update tenant: %w", err) + } + return t, nil +} + +// Delete + +func (r *PostgresRepo) Delete(ctx context.Context, id string) error { + const q = ` + UPDATE tenants + SET status = 'deleted', deleted_at = NOW(), updated_at = NOW() + WHERE id = $1::uuid AND deleted_at IS NULL` + + tag, err := r.pool.Exec(ctx, q, id) + if err != nil { + return fmt.Errorf("soft-delete tenant: %w", err) + } + if tag.RowsAffected() == 0 { + return domain.ErrNotFound + } + return nil +} + +// scan helpers ─ + +// scanner is satisfied by both pgx.Row (QueryRow) and pgx.Rows (Query) so +// that a single scan function handles both call sites. +type scanner interface { + Scan(dest ...any) error +} + +func scanTenant(row pgx.Row) (*domain.Tenant, error) { return scan(row) } +func scanTenantRow(rows pgx.Rows) (*domain.Tenant, error) { return scan(rows) } + +func scan(s scanner) (*domain.Tenant, error) { + var ( + t domain.Tenant + plan string + status string + settingsJSON []byte + deletedAt pgtype.Timestamptz + ) + + if err := s.Scan( + &t.ID, + &t.Name, + &t.Slug, + &plan, + &status, + &settingsJSON, + &t.CreatedAt, + &t.UpdatedAt, + &deletedAt, + ); err != nil { + return nil, err + } + + t.Plan = domain.Plan(plan) + t.Status = domain.Status(status) + + if len(settingsJSON) > 0 && string(settingsJSON) != "null" { + if err := json.Unmarshal(settingsJSON, &t.Settings); err != nil { + return nil, fmt.Errorf("unmarshal settings: %w", err) + } + } + if t.Settings == nil { + t.Settings = map[string]any{} + } + + if deletedAt.Valid { + ts := deletedAt.Time + t.DeletedAt = &ts + } + + return &t, nil +} + +// internal helpers + +func marshalSettings(s map[string]any) ([]byte, error) { + if s == nil { + return []byte("{}"), nil + } + b, err := json.Marshal(s) + if err != nil { + return nil, fmt.Errorf("marshal settings: %w", err) + } + return b, nil +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/services/tenant-service/internal/repository/repository.go b/services/tenant-service/internal/repository/repository.go new file mode 100644 index 0000000..24b28c1 --- /dev/null +++ b/services/tenant-service/internal/repository/repository.go @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026, SoftlaneIT (https://softlaneit.com/) All Rights Reserved. + * + * SoftlaneIT licenses this file to you under the Apache License, + * Version 2.0 (the "LICENSE"); you may not use this file except + * in compliance with the LICENSE. + * You may obtain a copy of the LICENSE at + * + * https://softlaneit.com/LICENSE.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the LICENSE is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the LICENSE for the + * specific language governing permissions and limitations + * under the LICENSE. + */ + +// Package repository defines the persistence port for the tenant-service and +// provides the PostgreSQL adapter that implements it. +package repository + +import ( + "context" + + "github.com/SoftLaneIT/serviceforge/services/tenant-service/internal/domain" +) + +// ListFilter controls pagination and optional status filtering for List queries. +type ListFilter struct { + // Status restricts results to tenants with this lifecycle state. + // nil returns all non-deleted tenants. + Status *domain.Status + // Limit caps the number of rows returned. Values <= 0 or > 100 are + // clamped to 20 and 100 respectively inside the implementation. + Limit int + // Offset skips this many rows for cursor-free pagination. + Offset int +} + +// ListResult wraps the tenant slice with the total count for building +// pagination envelopes in the handler. +type ListResult struct { + Tenants []domain.Tenant + Total int +} + +// Repository is the persistence port. Every storage implementation (pgx, +// in-memory test double, etc.) must satisfy this interface. +type Repository interface { + // Create inserts a new tenant and returns the persisted aggregate. + // Returns domain.ErrSlugConflict if the slug is already taken. + Create(ctx context.Context, p domain.CreateParams) (*domain.Tenant, error) + + // GetByID returns a non-deleted tenant by its UUID string. + // Returns domain.ErrNotFound if absent or soft-deleted. + GetByID(ctx context.Context, id string) (*domain.Tenant, error) + + // GetBySlug returns a non-deleted tenant by its unique slug. + // Returns domain.ErrNotFound if absent or soft-deleted. + GetBySlug(ctx context.Context, slug string) (*domain.Tenant, error) + + // List returns a filtered, paginated slice of non-deleted tenants together + // with the total row count (before pagination) for client-side paging. + List(ctx context.Context, f ListFilter) (ListResult, error) + + // Update applies a partial update to the identified tenant and returns the + // updated aggregate. Returns domain.ErrNotFound if absent or soft-deleted. + Update(ctx context.Context, id string, p domain.UpdateParams) (*domain.Tenant, error) + + // Delete soft-deletes the identified tenant (sets status=deleted, deleted_at=NOW()). + // Returns domain.ErrNotFound if absent or already deleted. + Delete(ctx context.Context, id string) error + + // Ping checks the health of the underlying datastore. Used by the + // /health endpoint to distinguish "service up" from "service + DB up". + Ping(ctx context.Context) error +}