From 8d97e60b9a61113ac14c4e666e1f687435f0a1c7 Mon Sep 17 00:00:00 2001 From: Pramitha Jayasooriya Date: Wed, 15 Apr 2026 23:38:34 +0530 Subject: [PATCH 1/5] feat: implement tenant service with CRUD operations and health check - Add handler for tenant service with routes for health check, create, list, get, update, and delete tenants. - Implement PostgreSQL repository for tenant management, including methods for creating, retrieving, listing, updating, and soft-deleting tenants. - Create unit tests for handler methods to ensure proper functionality and error handling. - Define repository interface for abstraction and ease of testing. --- .claude/settings.json | 8 + docs/on-prem/docker-compose.yml | 24 +- go.work | 6 +- services/tenant-service/cmd/server/main.go | 161 +++++-- services/tenant-service/go.mod | 12 +- services/tenant-service/go.sum | 19 + .../tenant-service/internal/domain/tenant.go | 193 ++++++++ .../internal/domain/tenant_test.go | 247 +++++++++++ .../internal/handler/handler.go | 278 ++++++++++++ .../internal/handler/handler_test.go | 413 ++++++++++++++++++ .../internal/repository/postgres.go | 337 ++++++++++++++ .../internal/repository/repository.go | 78 ++++ 12 files changed, 1724 insertions(+), 52 deletions(-) create mode 100644 .claude/settings.json create mode 100644 services/tenant-service/go.sum create mode 100644 services/tenant-service/internal/domain/tenant.go create mode 100644 services/tenant-service/internal/domain/tenant_test.go create mode 100644 services/tenant-service/internal/handler/handler.go create mode 100644 services/tenant-service/internal/handler/handler_test.go create mode 100644 services/tenant-service/internal/repository/postgres.go create mode 100644 services/tenant-service/internal/repository/repository.go 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/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..2af066d --- /dev/null +++ b/services/tenant-service/go.sum @@ -0,0 +1,19 @@ +github.com/davecgh/go-spew v1.1.0/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/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +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= +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/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +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= 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 +} From 43bd63ed8433c92e3535c4ebaad53ab123384aed Mon Sep 17 00:00:00 2001 From: Pramitha Jayasooriya Date: Thu, 16 Apr 2026 10:28:10 +0530 Subject: [PATCH 2/5] feat(tenant-service): wire pgx/v5 dependencies and run go mod tidy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds github.com/jackc/pgx/v5 v5.6.0 as a direct dependency of tenant-service (the implementation was already committed) and updates go.sum + go.work.sum with the verified checksums. • go.mod: pgx/v5 v5.6.0 direct; pgpassfile, pgservicefile, crypto as indirect; golang.org/x/text pinned to 0.14.0; go directive kept at 1.23.0 to stay consistent with the workspace and CI toolchain • go.sum / go.work.sum: generated by go mod tidy after merging the go-common/logger package (feat/go-common-logger) into dev so the workspace can resolve all imports Co-Authored-By: Claude Sonnet 4.6 --- go.work.sum | 16 ++++++++++++++++ services/tenant-service/go.mod | 2 ++ services/tenant-service/go.sum | 17 +++++++++++++---- 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 go.work.sum diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..acfa1ce --- /dev/null +++ b/go.work.sum @@ -0,0 +1,16 @@ +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +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= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/services/tenant-service/go.mod b/services/tenant-service/go.mod index 177c6aa..85315cc 100644 --- a/services/tenant-service/go.mod +++ b/services/tenant-service/go.mod @@ -10,7 +10,9 @@ require ( require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sync v0.1.0 // indirect golang.org/x/text v0.14.0 // indirect ) diff --git a/services/tenant-service/go.sum b/services/tenant-service/go.sum index 2af066d..6cae11d 100644 --- a/services/tenant-service/go.sum +++ b/services/tenant-service/go.sum @@ -1,19 +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/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= -github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +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/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +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= From f50f544dc18f02d9c9b0fcf87365e3e4019e37c0 Mon Sep 17 00:00:00 2001 From: Pramitha Jayasooriya Date: Thu, 16 Apr 2026 10:51:58 +0530 Subject: [PATCH 3/5] feat(auth-service): implement API key issuance, validation, and Redis cache - domain/apikey.go: APIKey aggregate, CreateParams, Environment, KeyStatus, sentinel errors (ErrNotFound, ErrInvalidKey, ErrExpiredKey) - keygen/keygen.go: crypto/rand key generation (sf_live_/sf_test_ prefix + 64 hex chars), SHA-256 hashing; raw key never persisted - repository/repository.go: Repository interface (Create, GetByID, List, Revoke, GetByHash, UpdateLastUsed, Ping) - repository/postgres.go: pgx/v5 implementation; array scan for module_scope; pgtype.Timestamptz for nullable timestamps - cache/cache.go: Cache interface + RedisCache (JSON-serialised APIKey with 5-minute TTL) + NoopCache for tests - handler/handler.go: POST /v1/keys, GET /v1/keys, GET /v1/keys/{id}, DELETE /v1/keys/{id}, POST /v1/keys/validate, GET /health; validate uses Redis fast-path with Postgres fallback; last_used_at updated asynchronously - handler/handler_test.go: 15 tests with in-memory fakeRepo + NoopCache - packages/go-common/tenant: export DefaultTenant sentinel and NewContext helper so handlers and tests can detect/inject tenant IDs without HTTP Co-Authored-By: Claude Sonnet 4.6 --- packages/go-common/tenant/context.go | 15 + services/auth-service/cmd/server/main.go | 187 +++++++- services/auth-service/internal/cache/cache.go | 130 ++++++ .../auth-service/internal/domain/apikey.go | 103 +++++ .../auth-service/internal/handler/handler.go | 363 +++++++++++++++ .../internal/handler/handler_test.go | 425 ++++++++++++++++++ .../auth-service/internal/keygen/keygen.go | 89 ++++ .../internal/keygen/keygen_test.go | 109 +++++ .../internal/repository/postgres.go | 244 ++++++++++ .../internal/repository/repository.go | 59 +++ 10 files changed, 1701 insertions(+), 23 deletions(-) create mode 100644 services/auth-service/internal/cache/cache.go create mode 100644 services/auth-service/internal/domain/apikey.go create mode 100644 services/auth-service/internal/handler/handler.go create mode 100644 services/auth-service/internal/handler/handler_test.go create mode 100644 services/auth-service/internal/keygen/keygen.go create mode 100644 services/auth-service/internal/keygen/keygen_test.go create mode 100644 services/auth-service/internal/repository/postgres.go create mode 100644 services/auth-service/internal/repository/repository.go 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/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< (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 +} From c92372892173790595000a764fd5444242da6374 Mon Sep 17 00:00:00 2001 From: Pramitha Jayasooriya Date: Thu, 16 Apr 2026 10:52:07 +0530 Subject: [PATCH 4/5] feat(auth-service): wire pgx/v5 and go-redis/v9 dependencies - go.mod: add jackc/pgx/v5 v5.6.0 and redis/go-redis/v9 v9.5.1 as direct deps - go.sum / go.work.sum: updated checksums Co-Authored-By: Claude Sonnet 4.6 --- go.work.sum | 16 +++++++-------- services/auth-service/go.mod | 17 +++++++++++++++- services/auth-service/go.sum | 38 ++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 10 deletions(-) create mode 100644 services/auth-service/go.sum diff --git a/go.work.sum b/go.work.sum index acfa1ce..48398c1 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,16 +1,14 @@ -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -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/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/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 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= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/services/auth-service/go.mod b/services/auth-service/go.mod index 3b9baab..b0def95 100644 --- a/services/auth-service/go.mod +++ b/services/auth-service/go.mod @@ -2,6 +2,21 @@ module github.com/SoftLaneIT/serviceforge/services/auth-service go 1.23.0 -require github.com/SoftLaneIT/serviceforge/packages/go-common v0.0.0 +require ( + github.com/SoftLaneIT/serviceforge/packages/go-common v0.0.0 + github.com/jackc/pgx/v5 v5.6.0 + github.com/redis/go-redis/v9 v9.5.1 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/text v0.14.0 // indirect +) replace github.com/SoftLaneIT/serviceforge/packages/go-common => ../../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= From b6476dc4ea0fa6d5ae0144c0f0a647564b28a489 Mon Sep 17 00:00:00 2001 From: Pramitha Jayasooriya Date: Thu, 16 Apr 2026 10:55:28 +0530 Subject: [PATCH 5/5] feat(api-gateway): implement real reverse proxy with auth middleware and rate limiter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registry/registry.go: static service registry loaded from env vars (AUTH_SERVICE_URL, TENANT_SERVICE_URL, BOOKING_SERVICE_URL, CONFIG_SERVICE_URL) - proxy/proxy.go: httputil.ReverseProxy wrapper; strips Authorization header before forwarding, structured JSON 502 on upstream failure - middleware/auth.go: Bearer-token validation via auth-service POST /v1/keys/validate; injects X-Tenant-ID and X-Key-Environment on success - middleware/ratelimit.go: per-tenant fixed-window rate limiter (sync.Map, configurable limit/window via RATE_LIMIT env, Phase 1 in-memory) - cmd/server/main.go: wires all components; authenticated routes: /v1/bookings/* → booking-service, /v1/config/* → config-service; admin routes (no auth): /v1/tenants/*, /v1/keys/*; graceful shutdown Co-Authored-By: Claude Sonnet 4.6 --- services/api-gateway/cmd/server/main.go | 163 +++++++++++++++--- .../api-gateway/internal/middleware/auth.go | 129 ++++++++++++++ .../internal/middleware/ratelimit.go | 93 ++++++++++ services/api-gateway/internal/proxy/proxy.go | 66 +++++++ .../api-gateway/internal/registry/registry.go | 74 ++++++++ 5 files changed, 502 insertions(+), 23 deletions(-) create mode 100644 services/api-gateway/internal/middleware/auth.go create mode 100644 services/api-gateway/internal/middleware/ratelimit.go create mode 100644 services/api-gateway/internal/proxy/proxy.go create mode 100644 services/api-gateway/internal/registry/registry.go 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] +}