HH-439: harden production artifact pipeline (#86)
* HH-439: harden production artifact pipeline * fix(HH-439): address production compose review * fix(HH-439): preserve previous JWT secrets in production --------- Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
@@ -1,36 +0,0 @@
|
||||
# GoChat Production Environment Overrides
|
||||
# Reference: Chatwoot config/environments/production.rb
|
||||
|
||||
server:
|
||||
mode: "release"
|
||||
cors:
|
||||
# PRODUCTION: Must specify exact origins or wildcard patterns.
|
||||
# NEVER leave empty in production — empty + non-debug = no CORS allowed.
|
||||
allowed_origins:
|
||||
- "https://app.yourdomain.com"
|
||||
- "https://admin.yourdomain.com"
|
||||
- "*.yourdomain.com" # matches any subdomain
|
||||
allow_credentials: true # needed for JWT cookie-based auth
|
||||
max_age: 86400
|
||||
|
||||
jwt:
|
||||
allow_insecure_header_auth: false
|
||||
|
||||
database:
|
||||
dsn: "postgres://gochat:CHANGE_ME@localhost:5432/gochat_production?sslmode=require"
|
||||
pool_max: 20
|
||||
log_level: "warn"
|
||||
|
||||
log:
|
||||
level: "info"
|
||||
format: "json"
|
||||
|
||||
worker:
|
||||
concurrency: 10
|
||||
redis_stream_prefix: "gochat:jobs"
|
||||
redis_consumer_group: "gochat-workers"
|
||||
redis_block_timeout_s: 5
|
||||
redis_sweep_interval_s: 30
|
||||
|
||||
redis:
|
||||
channel_prefix: "gochat_production"
|
||||
@@ -0,0 +1,38 @@
|
||||
# GoChat production overrides. Secrets and public origins must come from the environment.
|
||||
server:
|
||||
mode: "release"
|
||||
cors:
|
||||
allowed_origins: ["https://CHANGE_ME.example.com"]
|
||||
allow_credentials: true
|
||||
|
||||
database:
|
||||
dsn: "postgres://gochat:CHANGE_ME@postgres:5432/gochat_production?sslmode=disable"
|
||||
run_migrations: true
|
||||
migrations_path: "/app/migrations"
|
||||
|
||||
redis:
|
||||
dsn: "redis://:CHANGE_ME@redis:6379"
|
||||
|
||||
jwt:
|
||||
secret: "CHANGE_ME"
|
||||
allow_insecure_header_auth: false
|
||||
|
||||
search:
|
||||
engine: "meilisearch"
|
||||
host: "http://meilisearch:7700"
|
||||
api_key: "CHANGE_ME"
|
||||
|
||||
log:
|
||||
level: "info"
|
||||
format: "json"
|
||||
|
||||
worker:
|
||||
concurrency: 10
|
||||
redis_stream_prefix: "gochat:jobs"
|
||||
redis_consumer_group: "gochat-workers"
|
||||
redis_block_timeout_s: 5
|
||||
redis_sweep_interval_s: 30
|
||||
|
||||
storage:
|
||||
provider: "local"
|
||||
local_path: "/app/storage/uploads"
|
||||
@@ -157,9 +157,9 @@ type LogConfig struct {
|
||||
Format string `mapstructure:"format"` // json, text
|
||||
}
|
||||
|
||||
// SAMLConfig and LDAPConfig removed — only OIDC is supported for enterprise SSO.
|
||||
// SAMLConfig and LDAPConfig removed — only OIDC is supported for enterprise SSO.
|
||||
|
||||
// OIDCConfig holds OIDC/OAuth2 enterprise authentication configuration.
|
||||
// OIDCConfig holds OIDC/OAuth2 enterprise authentication configuration.
|
||||
// Reference: M13 §4.3 — OIDC (OpenID Connect) provider integration.
|
||||
// Supports Google Workspace, Auth0, Keycloak, Azure AD and any OIDC-compliant IdP.
|
||||
// Per-account OIDC settings override these defaults (stored in DB).
|
||||
@@ -460,6 +460,10 @@ func (r *ConfigReloader) Stop() {
|
||||
//
|
||||
// config/environments/development.rb overrides config/application.rb defaults.
|
||||
func LoadWithEnv(env string) (*Config, error) {
|
||||
if env == "prod" {
|
||||
return nil, fmt.Errorf("GOCHAT_ENV=prod is unsupported; use GOCHAT_ENV=production")
|
||||
}
|
||||
|
||||
v := viper.New()
|
||||
|
||||
// Env key replacer: GOCHAT_DATABASE_DSN → database.dsn
|
||||
@@ -473,6 +477,7 @@ func LoadWithEnv(env string) (*Config, error) {
|
||||
"GOCHAT_SERVER_HOST": "server.host",
|
||||
"GOCHAT_SERVER_PORT": "server.port",
|
||||
"GOCHAT_SERVER_MODE": "server.mode",
|
||||
"GOCHAT_SERVER_CORS_ALLOWED_ORIGINS": "server.cors.allowed_origins",
|
||||
"GOCHAT_DATABASE_DSN": "database.dsn",
|
||||
"GOCHAT_DATABASE_MAX_IDLE_CONNS": "database.max_idle_conns",
|
||||
"GOCHAT_DATABASE_MAX_OPEN_CONNS": "database.max_open_conns",
|
||||
@@ -547,11 +552,7 @@ func LoadWithEnv(env string) (*Config, error) {
|
||||
|
||||
// Overlay environment-specific config: config.{env}.yaml
|
||||
if env != "" && env != "default" {
|
||||
overlayEnv := env
|
||||
if env == "production" {
|
||||
overlayEnv = "prod"
|
||||
}
|
||||
envFile := fmt.Sprintf("config.%s.yaml", overlayEnv)
|
||||
envFile := fmt.Sprintf("config.%s.yaml", env)
|
||||
// Search in the same directory as the base config
|
||||
baseConfigPath := v.ConfigFileUsed()
|
||||
if baseConfigPath != "" {
|
||||
@@ -562,6 +563,8 @@ func LoadWithEnv(env string) (*Config, error) {
|
||||
return nil, fmt.Errorf("env config merge failed (%s): %w", env, err)
|
||||
}
|
||||
applogger.L().Infof("Merged env config overlay: %s", envConfigPath)
|
||||
} else if env == "production" {
|
||||
return nil, fmt.Errorf("production config overlay not found: %s", envConfigPath)
|
||||
} else {
|
||||
applogger.L().Warnf("Env config file not found: %s (continuing with base config)", envConfigPath)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -127,13 +129,13 @@ func TestValidate_ReleaseJWTSecurity(t *testing.T) {
|
||||
validSecret := "6vG3uP9qL2mR8xK5nD7sF4hJ1cB0wZyE"
|
||||
base := func() *Config {
|
||||
return &Config{
|
||||
Server: ServerConfig{Port: 8080, Mode: "release"},
|
||||
Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db"},
|
||||
Redis: RedisConfig{DSN: "redis://localhost:6379"},
|
||||
Server: ServerConfig{Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}},
|
||||
Database: DatabaseConfig{DSN: "postgres://user:database-secret@postgres:5432/db?sslmode=disable"},
|
||||
Redis: RedisConfig{DSN: "redis://:redis-secret@redis:6379"},
|
||||
JWT: JWTConfig{Secret: validSecret},
|
||||
Log: LogConfig{Level: "info"},
|
||||
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 1, SweepIntervalS: 1},
|
||||
Search: SearchConfig{Engine: "meilisearch", Host: "http://localhost:7700"},
|
||||
Search: SearchConfig{Engine: "meilisearch", Host: "http://localhost:7700", APIKey: "search-secret-123"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +243,91 @@ func TestValidate_SearchDBFallbackRejectedInRelease(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "release mode requires meilisearch")
|
||||
}
|
||||
|
||||
func TestValidate_ReleaseRejectsPlaceholders(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}},
|
||||
Database: DatabaseConfig{DSN: "postgres://gochat:CHANGE_ME@postgres:5432/gochat"},
|
||||
Redis: RedisConfig{DSN: "redis://:secret@redis:6379"},
|
||||
JWT: JWTConfig{Secret: "production-jwt-secret-at-least-32-characters"},
|
||||
Log: LogConfig{Level: "info"},
|
||||
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30},
|
||||
Search: SearchConfig{Engine: "meilisearch", Host: "http://meilisearch:7700", APIKey: "search-secret", TimeoutSeconds: 5},
|
||||
}
|
||||
|
||||
err := Validate(cfg)
|
||||
assert.ErrorContains(t, err, "database password")
|
||||
}
|
||||
|
||||
func TestValidate_ReleaseRejectsShortSearchKey(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}},
|
||||
Database: DatabaseConfig{DSN: "postgres://gochat:database-secret@postgres:5432/gochat?sslmode=disable"},
|
||||
Redis: RedisConfig{DSN: "redis://:redis-secret@redis:6379"},
|
||||
JWT: JWTConfig{Secret: "production-jwt-secret-at-least-32-characters"},
|
||||
Log: LogConfig{Level: "info"},
|
||||
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30},
|
||||
Search: SearchConfig{Engine: "meilisearch", Host: "http://meilisearch:7700", APIKey: "123456789012345", TimeoutSeconds: 5},
|
||||
}
|
||||
|
||||
assert.ErrorContains(t, Validate(cfg), "search API key must be at least 16 bytes")
|
||||
}
|
||||
|
||||
func TestValidate_ReleaseDatabaseTLS(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}},
|
||||
Redis: RedisConfig{DSN: "redis://:redis-secret@redis:6379"},
|
||||
JWT: JWTConfig{Secret: "6vG3uP9qL2mR8xK5nD7sF4hJ1cB0wZyE"},
|
||||
Log: LogConfig{Level: "info"},
|
||||
Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30},
|
||||
Search: SearchConfig{Engine: "meilisearch", Host: "http://meilisearch:7700", APIKey: "search-secret-123", TimeoutSeconds: 5},
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
dsn string
|
||||
wantErr bool
|
||||
}{
|
||||
{"external disable", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=disable", true},
|
||||
{"external missing sslmode", "postgres://gochat:database-secret@db.example.test:5432/gochat", true},
|
||||
{"external require", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=require", false},
|
||||
{"built-in compose disable", "postgres://gochat:database-secret@postgres:5432/gochat?sslmode=disable", false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg.Database.DSN = tt.dsn
|
||||
if tt.wantErr {
|
||||
assert.ErrorContains(t, Validate(cfg), "production database DSN must use sslmode")
|
||||
} else {
|
||||
assert.NoError(t, Validate(cfg))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithEnv_ProductionRequiresOverlay(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
require.NoError(t, os.Mkdir(filepath.Join(tmpDir, "configs"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "configs", "config.yaml"), []byte("server:\n mode: debug\n"), 0o644))
|
||||
t.Chdir(tmpDir)
|
||||
|
||||
_, err := LoadWithEnv("production")
|
||||
assert.ErrorContains(t, err, "production config overlay not found")
|
||||
}
|
||||
|
||||
func TestLoadWithEnv_ProductionOverlay(t *testing.T) {
|
||||
t.Setenv("GOCHAT_DATABASE_DSN", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=require")
|
||||
t.Setenv("GOCHAT_REDIS_DSN", "redis://:redis-secret@redis:6379")
|
||||
t.Setenv("GOCHAT_JWT_SECRET", "production-jwt-secret-at-least-32-characters")
|
||||
t.Setenv("GOCHAT_SEARCH_API_KEY", "search-secret-123")
|
||||
t.Setenv("GOCHAT_SERVER_CORS_ALLOWED_ORIGINS", "https://chat.example.test")
|
||||
t.Chdir("../..")
|
||||
|
||||
cfg, err := LoadWithEnv("production")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "release", cfg.Server.Mode)
|
||||
assert.Equal(t, []string{"https://chat.example.test"}, cfg.Server.CORS.AllowedOrigins)
|
||||
assert.NoError(t, Validate(cfg))
|
||||
}
|
||||
|
||||
func TestDatabaseConfig_MigrateDSN(t *testing.T) {
|
||||
cfg := DatabaseConfig{
|
||||
DSN: "postgres://gochat:secret@localhost:5432/gochat_db?sslmode=disable",
|
||||
|
||||
@@ -111,9 +111,39 @@ func Validate(cfg *Config) error {
|
||||
return fmt.Errorf("search.timeout_seconds must be >= 0")
|
||||
}
|
||||
|
||||
if cfg.Server.Mode == "release" {
|
||||
if len(cfg.Server.CORS.AllowedOrigins) == 0 || containsPlaceholder(strings.Join(cfg.Server.CORS.AllowedOrigins, ",")) {
|
||||
return fmt.Errorf("production CORS origins are required and must not contain placeholders")
|
||||
}
|
||||
if dbURL.User == nil || dbURL.User.Username() == "" {
|
||||
return fmt.Errorf("production database credentials are required")
|
||||
}
|
||||
if password, ok := dbURL.User.Password(); !ok || password == "" || containsPlaceholder(password) {
|
||||
return fmt.Errorf("production database password is required and must not contain placeholders")
|
||||
}
|
||||
sslMode := dbURL.Query().Get("sslmode")
|
||||
if !(dbURL.Hostname() == "postgres" && sslMode == "disable") && sslMode != "require" && sslMode != "verify-ca" && sslMode != "verify-full" {
|
||||
return fmt.Errorf("production database DSN must use sslmode=require, verify-ca, or verify-full (sslmode=disable is only allowed for the built-in postgres service)")
|
||||
}
|
||||
if redisURL.User == nil {
|
||||
return fmt.Errorf("production Redis credentials are required")
|
||||
}
|
||||
if password, ok := redisURL.User.Password(); !ok || password == "" || containsPlaceholder(password) {
|
||||
return fmt.Errorf("production Redis password is required and must not contain placeholders")
|
||||
}
|
||||
if len(cfg.Search.APIKey) < 16 || containsPlaceholder(cfg.Search.APIKey) {
|
||||
return fmt.Errorf("production search API key must be at least 16 bytes and must not contain placeholders")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsPlaceholder(value string) bool {
|
||||
value = strings.ToLower(value)
|
||||
return strings.Contains(value, "change_me") || strings.Contains(value, "change-me") || strings.Contains(value, "changeme")
|
||||
}
|
||||
|
||||
func validateProductionJWTSecret(name, secret string) error {
|
||||
secret = strings.TrimSpace(secret)
|
||||
if len([]byte(secret)) < 32 {
|
||||
|
||||
Reference in New Issue
Block a user