From eadb2ffe85c43cf6fc741c9823cd28eedb4a844c Mon Sep 17 00:00:00 2001 From: Chia Date: Wed, 5 Aug 2026 22:01:29 +1200 Subject: feat: harden prepaid billing and commercial operations --- internal/config/config.go | 289 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 238 insertions(+), 51 deletions(-) (limited to 'internal/config/config.go') diff --git a/internal/config/config.go b/internal/config/config.go index 376cf77..a67f221 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "net" "net/url" "os" "strings" @@ -26,12 +27,27 @@ type Config struct { } type ServerConfig struct { - Address string `json:"-"` - AddressEnv string `json:"address_env"` - MaxBodyBytes int64 `json:"max_body_bytes"` - ReadHeaderTimeoutSecs int `json:"read_header_timeout_seconds"` - IdleTimeoutSecs int `json:"idle_timeout_seconds"` - ShutdownTimeoutSecs int `json:"shutdown_timeout_seconds"` + Address string `json:"-"` + AddressEnv string `json:"address_env"` + SplitListeners bool `json:"split_listeners"` + PublicAddressEnv string `json:"public_address_env"` + AdminAddressEnv string `json:"admin_address_env"` + WebhookAddressEnv string `json:"webhook_address_env"` + OperationsAddressEnv string `json:"operations_address_env"` + TrustedProxyCIDRsEnv string `json:"trusted_proxy_cidrs_env"` + RequireHTTPSEnv string `json:"require_https_env"` + DeploymentRegionEnv string `json:"deployment_region_env"` + PublicAddress string `json:"-"` + AdminAddress string `json:"-"` + WebhookAddress string `json:"-"` + OperationsAddress string `json:"-"` + TrustedProxyCIDRs []string `json:"-"` + RequireHTTPS bool `json:"-"` + DeploymentRegion string `json:"-"` + MaxBodyBytes int64 `json:"max_body_bytes"` + ReadHeaderTimeoutSecs int `json:"read_header_timeout_seconds"` + IdleTimeoutSecs int `json:"idle_timeout_seconds"` + ShutdownTimeoutSecs int `json:"shutdown_timeout_seconds"` } type AuthConfig struct { @@ -40,45 +56,55 @@ type AuthConfig struct { } type ControlPlaneConfig struct { - Enabled bool `json:"enabled"` - DatabaseURLEnv string `json:"database_url_env"` - RedisURLEnv string `json:"redis_url_env"` - CredentialKeyEnv string `json:"credential_key_env"` - RedisChannel string `json:"redis_channel"` - SnapshotCacheKey string `json:"snapshot_cache_key"` - ReloadIntervalSeconds int `json:"reload_interval_seconds"` - AutoMigrate bool `json:"auto_migrate"` - DatabaseURL string `json:"-"` - RedisURL string `json:"-"` - CredentialKey string `json:"-"` + Enabled bool `json:"enabled"` + DatabaseURLEnv string `json:"database_url_env"` + RedisURLEnv string `json:"redis_url_env"` + CredentialKeyEnv string `json:"credential_key_env"` + PreviousCredentialKeysEnv string `json:"previous_credential_keys_env"` + RedisChannel string `json:"redis_channel"` + SnapshotCacheKey string `json:"snapshot_cache_key"` + ReloadIntervalSeconds int `json:"reload_interval_seconds"` + AutoMigrate bool `json:"auto_migrate"` + DatabaseURL string `json:"-"` + RedisURL string `json:"-"` + CredentialKey string `json:"-"` + PreviousCredentialKeys []string `json:"-"` } type AdminConfig struct { - Enabled bool `json:"enabled"` - TokenEnv string `json:"token_env"` - BasePath string `json:"base_path"` - RegistrationEnabled bool `json:"registration_enabled"` - SessionTTLHours int `json:"session_ttl_hours"` - PublicURL string `json:"-"` - PublicURLEnv string `json:"public_url_env"` - Mail MailConfig `json:"mail"` - WebAuthn WebAuthnConfig `json:"webauthn"` - Token string `json:"-"` + Enabled bool `json:"enabled"` + TokenEnv string `json:"token_env"` + BasePath string `json:"base_path"` + RegistrationEnabled bool `json:"registration_enabled"` + SessionTTLHours int `json:"session_ttl_hours"` + AuditRetentionDays int `json:"audit_retention_days"` + SecurityRetentionDays int `json:"security_retention_days"` + PublicURL string `json:"-"` + PublicURLEnv string `json:"public_url_env"` + Mail MailConfig `json:"mail"` + WebAuthn WebAuthnConfig `json:"webauthn"` + Token string `json:"-"` } type MailConfig struct { - Enabled bool `json:"enabled"` - FromName string `json:"from_name"` - TLSMode string `json:"tls_mode"` - FromAddressEnv string `json:"from_address_env"` - SMTPAddressEnv string `json:"smtp_address_env"` - SMTPUsernameEnv string `json:"smtp_username_env"` - SMTPPasswordEnv string `json:"smtp_password_env"` - SMTPImplicitTLS bool `json:"smtp_implicit_tls"` - FromAddress string `json:"-"` - SMTPAddress string `json:"-"` - SMTPUsername string `json:"-"` - SMTPPassword string `json:"-"` + Enabled bool `json:"enabled"` + FromName string `json:"from_name"` + TLSMode string `json:"tls_mode"` + FromAddressEnv string `json:"from_address_env"` + SMTPAddressEnv string `json:"smtp_address_env"` + SMTPUsernameEnv string `json:"smtp_username_env"` + SMTPPasswordEnv string `json:"smtp_password_env"` + FeedbackSecretEnv string `json:"feedback_secret_env"` + LowBalanceMicros int64 `json:"low_balance_micros"` + SpendAnomalyMultiplier int64 `json:"spend_anomaly_multiplier"` + SpendAnomalyMinMicros int64 `json:"spend_anomaly_min_micros"` + NotificationIntervalSeconds int `json:"notification_interval_seconds"` + SMTPImplicitTLS bool `json:"smtp_implicit_tls"` + FromAddress string `json:"-"` + SMTPAddress string `json:"-"` + SMTPUsername string `json:"-"` + SMTPPassword string `json:"-"` + FeedbackSecret string `json:"-"` } type WebAuthnConfig struct { @@ -134,19 +160,29 @@ type BillingConfig struct { DefaultMaxOutputTokens int64 `json:"default_max_output_tokens"` MinTopUpMinor int64 `json:"min_top_up_minor"` MaxTopUpMinor int64 `json:"max_top_up_minor"` + SettlementSpoolPathEnv string `json:"settlement_spool_path_env"` + SettlementSpoolPath string `json:"-"` Stripe StripeConfig `json:"stripe"` } type StripeConfig struct { - Enabled bool `json:"enabled"` - APIKeyEnv string `json:"api_key_env"` - WebhookSecretEnv string `json:"webhook_secret_env"` - SuccessURL string `json:"-"` - CancelURL string `json:"-"` - SuccessURLEnv string `json:"success_url_env"` - CancelURLEnv string `json:"cancel_url_env"` - APIKey string `json:"-"` - WebhookSecret string `json:"-"` + Enabled bool `json:"enabled"` + APIKeyEnv string `json:"api_key_env"` + WebhookSecretEnv string `json:"webhook_secret_env"` + SuccessURLEnv string `json:"success_url_env"` + CancelURLEnv string `json:"cancel_url_env"` + PortalReturnURLEnv string `json:"portal_return_url_env"` + AutomaticTaxEnabledEnv string `json:"automatic_tax_enabled_env"` + TaxRegistrationConfirmedEnv string `json:"tax_registration_confirmed_env"` + ProductTaxCodeEnv string `json:"product_tax_code_env"` + SuccessURL string `json:"-"` + CancelURL string `json:"-"` + PortalReturnURL string `json:"-"` + APIKey string `json:"-"` + WebhookSecret string `json:"-"` + AutomaticTaxEnabled bool `json:"-"` + TaxRegistrationConfirmed bool `json:"-"` + ProductTaxCode string `json:"-"` } func Load(path string) (Config, error) { @@ -186,6 +222,27 @@ func applyDefaults(cfg *Config) { if cfg.Server.Address == "" { cfg.Server.Address = ":8080" } + if cfg.Server.PublicAddressEnv == "" { + cfg.Server.PublicAddressEnv = "AIGW_PUBLIC_ADDRESS" + } + if cfg.Server.AdminAddressEnv == "" { + cfg.Server.AdminAddressEnv = "AIGW_ADMIN_ADDRESS" + } + if cfg.Server.WebhookAddressEnv == "" { + cfg.Server.WebhookAddressEnv = "AIGW_WEBHOOK_ADDRESS" + } + if cfg.Server.OperationsAddressEnv == "" { + cfg.Server.OperationsAddressEnv = "AIGW_OPERATIONS_ADDRESS" + } + if cfg.Server.TrustedProxyCIDRsEnv == "" { + cfg.Server.TrustedProxyCIDRsEnv = "AIGW_TRUSTED_PROXY_CIDRS" + } + if cfg.Server.RequireHTTPSEnv == "" { + cfg.Server.RequireHTTPSEnv = "AIGW_REQUIRE_HTTPS" + } + if cfg.Server.DeploymentRegionEnv == "" { + cfg.Server.DeploymentRegionEnv = "AIGW_DEPLOYMENT_REGION" + } if cfg.Server.MaxBodyBytes == 0 { cfg.Server.MaxBodyBytes = 16 << 20 } @@ -210,6 +267,9 @@ func applyDefaults(cfg *Config) { if cfg.ControlPlane.CredentialKeyEnv == "" { cfg.ControlPlane.CredentialKeyEnv = "AIGW_CREDENTIAL_KEY" } + if cfg.ControlPlane.PreviousCredentialKeysEnv == "" { + cfg.ControlPlane.PreviousCredentialKeysEnv = "AIGW_CREDENTIAL_PREVIOUS_KEYS" + } if cfg.ControlPlane.RedisChannel == "" { cfg.ControlPlane.RedisChannel = "aigw:control:changed" } @@ -228,6 +288,12 @@ func applyDefaults(cfg *Config) { if cfg.Admin.SessionTTLHours == 0 { cfg.Admin.SessionTTLHours = 12 } + if cfg.Admin.AuditRetentionDays == 0 { + cfg.Admin.AuditRetentionDays = 2555 + } + if cfg.Admin.SecurityRetentionDays == 0 { + cfg.Admin.SecurityRetentionDays = 30 + } if cfg.Admin.PublicURLEnv == "" { cfg.Admin.PublicURLEnv = "AIGW_PUBLIC_URL" } @@ -249,6 +315,21 @@ func applyDefaults(cfg *Config) { if cfg.Admin.Mail.SMTPPasswordEnv == "" { cfg.Admin.Mail.SMTPPasswordEnv = "AIGW_SMTP_PASSWORD" } + if cfg.Admin.Mail.FeedbackSecretEnv == "" { + cfg.Admin.Mail.FeedbackSecretEnv = "AIGW_MAIL_FEEDBACK_SECRET" + } + if cfg.Admin.Mail.LowBalanceMicros == 0 { + cfg.Admin.Mail.LowBalanceMicros = 5_000_000 + } + if cfg.Admin.Mail.SpendAnomalyMultiplier == 0 { + cfg.Admin.Mail.SpendAnomalyMultiplier = 3 + } + if cfg.Admin.Mail.SpendAnomalyMinMicros == 0 { + cfg.Admin.Mail.SpendAnomalyMinMicros = 10_000_000 + } + if cfg.Admin.Mail.NotificationIntervalSeconds == 0 { + cfg.Admin.Mail.NotificationIntervalSeconds = 300 + } if cfg.Admin.WebAuthn.RPDisplayName == "" { cfg.Admin.WebAuthn.RPDisplayName = "AIGW Console" } @@ -285,6 +366,9 @@ func applyDefaults(cfg *Config) { if cfg.Billing.MinTopUpMinor == 0 { cfg.Billing.MinTopUpMinor = 500 } + if cfg.Billing.SettlementSpoolPathEnv == "" { + cfg.Billing.SettlementSpoolPathEnv = "AIGW_SETTLEMENT_SPOOL_PATH" + } if cfg.Billing.Stripe.APIKeyEnv == "" { cfg.Billing.Stripe.APIKeyEnv = "AIGW_STRIPE_API_KEY" } @@ -297,6 +381,18 @@ func applyDefaults(cfg *Config) { if cfg.Billing.Stripe.CancelURLEnv == "" { cfg.Billing.Stripe.CancelURLEnv = "AIGW_STRIPE_CANCEL_URL" } + if cfg.Billing.Stripe.PortalReturnURLEnv == "" { + cfg.Billing.Stripe.PortalReturnURLEnv = "AIGW_STRIPE_PORTAL_RETURN_URL" + } + if cfg.Billing.Stripe.AutomaticTaxEnabledEnv == "" { + cfg.Billing.Stripe.AutomaticTaxEnabledEnv = "AIGW_STRIPE_AUTOMATIC_TAX_ENABLED" + } + if cfg.Billing.Stripe.TaxRegistrationConfirmedEnv == "" { + cfg.Billing.Stripe.TaxRegistrationConfirmedEnv = "AIGW_STRIPE_TAX_REGISTRATION_CONFIRMED" + } + if cfg.Billing.Stripe.ProductTaxCodeEnv == "" { + cfg.Billing.Stripe.ProductTaxCodeEnv = "AIGW_STRIPE_PRODUCT_TAX_CODE" + } for i := range cfg.Models { for j := range cfg.Models[i].Routes { if cfg.Models[i].Routes[j].Weight == 0 { @@ -310,10 +406,40 @@ func resolveSecrets(cfg *Config) error { if value := strings.TrimSpace(os.Getenv(cfg.Server.AddressEnv)); value != "" { cfg.Server.Address = value } + if cfg.Server.SplitListeners { + if err := resolveRequiredEnv(&cfg.Server.PublicAddress, cfg.Server.PublicAddressEnv, "server.public_address"); err != nil { + return err + } + if err := resolveRequiredEnv(&cfg.Server.AdminAddress, cfg.Server.AdminAddressEnv, "server.admin_address"); err != nil { + return err + } + if err := resolveRequiredEnv(&cfg.Server.WebhookAddress, cfg.Server.WebhookAddressEnv, "server.webhook_address"); err != nil { + return err + } + if err := resolveRequiredEnv(&cfg.Server.OperationsAddress, cfg.Server.OperationsAddressEnv, "server.operations_address"); err != nil { + return err + } + } + for _, cidr := range strings.Split(os.Getenv(cfg.Server.TrustedProxyCIDRsEnv), ",") { + if cidr = strings.TrimSpace(cidr); cidr != "" { + cfg.Server.TrustedProxyCIDRs = append(cfg.Server.TrustedProxyCIDRs, cidr) + } + } + var err error + cfg.Server.RequireHTTPS, err = envBool(cfg.Server.RequireHTTPSEnv) + if err != nil { + return err + } + cfg.Server.DeploymentRegion = strings.ToLower(strings.TrimSpace(os.Getenv(cfg.Server.DeploymentRegionEnv))) if cfg.ControlPlane.Enabled { cfg.ControlPlane.DatabaseURL = os.Getenv(cfg.ControlPlane.DatabaseURLEnv) cfg.ControlPlane.RedisURL = os.Getenv(cfg.ControlPlane.RedisURLEnv) cfg.ControlPlane.CredentialKey = os.Getenv(cfg.ControlPlane.CredentialKeyEnv) + for _, value := range strings.Split(os.Getenv(cfg.ControlPlane.PreviousCredentialKeysEnv), ",") { + if value = strings.TrimSpace(value); value != "" { + cfg.ControlPlane.PreviousCredentialKeys = append(cfg.ControlPlane.PreviousCredentialKeys, value) + } + } } if cfg.Admin.Enabled { cfg.Admin.Token = os.Getenv(cfg.Admin.TokenEnv) @@ -325,6 +451,7 @@ func resolveSecrets(cfg *Config) error { cfg.Admin.Mail.SMTPAddress = strings.TrimSpace(os.Getenv(cfg.Admin.Mail.SMTPAddressEnv)) cfg.Admin.Mail.SMTPUsername = os.Getenv(cfg.Admin.Mail.SMTPUsernameEnv) cfg.Admin.Mail.SMTPPassword = os.Getenv(cfg.Admin.Mail.SMTPPasswordEnv) + cfg.Admin.Mail.FeedbackSecret = os.Getenv(cfg.Admin.Mail.FeedbackSecretEnv) } if cfg.Admin.WebAuthn.Enabled { cfg.Admin.WebAuthn.RPID = strings.TrimSpace(os.Getenv(cfg.Admin.WebAuthn.RPIDEnv)) @@ -344,6 +471,22 @@ func resolveSecrets(cfg *Config) error { if err := resolveRequiredEnv(&cfg.Billing.Stripe.CancelURL, cfg.Billing.Stripe.CancelURLEnv, "billing.stripe.cancel_url"); err != nil { return err } + if err := resolveRequiredEnv(&cfg.Billing.Stripe.PortalReturnURL, cfg.Billing.Stripe.PortalReturnURLEnv, "billing.stripe.portal_return_url"); err != nil { + return err + } + var err error + cfg.Billing.Stripe.AutomaticTaxEnabled, err = envBool(cfg.Billing.Stripe.AutomaticTaxEnabledEnv) + if err != nil { + return err + } + cfg.Billing.Stripe.TaxRegistrationConfirmed, err = envBool(cfg.Billing.Stripe.TaxRegistrationConfirmedEnv) + if err != nil { + return err + } + cfg.Billing.Stripe.ProductTaxCode = strings.TrimSpace(os.Getenv(cfg.Billing.Stripe.ProductTaxCodeEnv)) + } + if cfg.Billing.Enabled { + cfg.Billing.SettlementSpoolPath = strings.TrimSpace(os.Getenv(cfg.Billing.SettlementSpoolPathEnv)) } for i := range cfg.Providers { provider := &cfg.Providers[i] @@ -364,6 +507,17 @@ func resolveSecrets(cfg *Config) error { return nil } +func envBool(name string) (bool, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" || strings.EqualFold(value, "false") || value == "0" { + return false, nil + } + if strings.EqualFold(value, "true") || value == "1" { + return true, nil + } + return false, fmt.Errorf("environment variable %s must be true/false or 1/0", name) +} + func resolveRequiredEnv(target *string, environment, field string) error { if environment == "" { return nil @@ -383,6 +537,23 @@ func Validate(cfg Config) error { if cfg.Observability.UsageBuffer < 1 { return errors.New("observability.usage_buffer must be positive") } + if cfg.Server.SplitListeners { + seen := map[string]string{} + for name, address := range map[string]string{"public": cfg.Server.PublicAddress, "admin": cfg.Server.AdminAddress, "webhook": cfg.Server.WebhookAddress, "operations": cfg.Server.OperationsAddress} { + if strings.TrimSpace(address) == "" { + return fmt.Errorf("server %s listener address is empty", name) + } + if previous, ok := seen[address]; ok { + return fmt.Errorf("server %s and %s listeners must use different addresses", previous, name) + } + seen[address] = name + } + } + for _, cidr := range cfg.Server.TrustedProxyCIDRs { + if _, _, err := net.ParseCIDR(cidr); err != nil { + return fmt.Errorf("invalid trusted proxy CIDR %q", cidr) + } + } if cfg.ControlPlane.Enabled { if cfg.ControlPlane.DatabaseURL == "" { @@ -408,6 +579,9 @@ func Validate(cfg Config) error { if cfg.Admin.SessionTTLHours < 1 || cfg.Admin.SessionTTLHours > 720 { return errors.New("admin.session_ttl_hours must be between 1 and 720") } + if cfg.Admin.AuditRetentionDays < 30 || cfg.Admin.SecurityRetentionDays < 1 { + return errors.New("admin audit retention must be at least 30 days and security retention at least 1 day") + } publicURL, err := url.Parse(cfg.Admin.PublicURL) if err != nil || publicURL.Host == "" || (publicURL.Scheme != "http" && publicURL.Scheme != "https") { return errors.New("admin.public_url must resolve from an environment variable to an absolute http(s) URL") @@ -419,6 +593,13 @@ func Validate(cfg Config) error { if (cfg.Admin.Mail.SMTPUsername == "") != (cfg.Admin.Mail.SMTPPassword == "") { return errors.New("admin.mail SMTP username and password must both be set or both be empty") } + if cfg.Admin.Mail.FeedbackSecret != "" && len(cfg.Admin.Mail.FeedbackSecret) < 32 { + return errors.New("admin.mail feedback secret must contain at least 32 characters") + } + if cfg.Admin.Mail.LowBalanceMicros < 0 || cfg.Admin.Mail.SpendAnomalyMultiplier < 2 || + cfg.Admin.Mail.SpendAnomalyMinMicros < 0 || cfg.Admin.Mail.NotificationIntervalSeconds < 30 { + return errors.New("admin.mail notification thresholds are invalid") + } switch cfg.Admin.Mail.TLSMode { case "starttls", "tls", "none": default: @@ -453,6 +634,9 @@ func Validate(cfg Config) error { if cfg.Billing.MinTopUpMinor < 1 || cfg.Billing.MaxTopUpMinor < cfg.Billing.MinTopUpMinor { return errors.New("billing top-up bounds are invalid") } + if strings.TrimSpace(cfg.Billing.SettlementSpoolPath) == "" { + return fmt.Errorf("billing: environment variable %s is empty; durable settlement fallback is required", cfg.Billing.SettlementSpoolPathEnv) + } if cfg.Billing.Stripe.Enabled { if cfg.Billing.Stripe.APIKey == "" { return fmt.Errorf("billing.stripe: environment variable %s is empty", cfg.Billing.Stripe.APIKeyEnv) @@ -460,14 +644,17 @@ func Validate(cfg Config) error { if cfg.Billing.Stripe.WebhookSecret == "" { return fmt.Errorf("billing.stripe: environment variable %s is empty", cfg.Billing.Stripe.WebhookSecretEnv) } - if strings.TrimSpace(cfg.Billing.Stripe.SuccessURL) == "" || strings.TrimSpace(cfg.Billing.Stripe.CancelURL) == "" { - return errors.New("billing.stripe.success_url and cancel_url are required") + if strings.TrimSpace(cfg.Billing.Stripe.SuccessURL) == "" || strings.TrimSpace(cfg.Billing.Stripe.CancelURL) == "" || strings.TrimSpace(cfg.Billing.Stripe.PortalReturnURL) == "" { + return errors.New("billing.stripe success, cancel, and portal return URLs are required") } - for name, value := range map[string]string{"success_url": cfg.Billing.Stripe.SuccessURL, "cancel_url": cfg.Billing.Stripe.CancelURL} { + for name, value := range map[string]string{"success_url": cfg.Billing.Stripe.SuccessURL, "cancel_url": cfg.Billing.Stripe.CancelURL, "portal_return_url": cfg.Billing.Stripe.PortalReturnURL} { parsed, err := url.Parse(value) if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { return fmt.Errorf("billing.stripe.%s must be an absolute http(s) URL", name) } + if cfg.Billing.Stripe.AutomaticTaxEnabled && (!cfg.Billing.Stripe.TaxRegistrationConfirmed || cfg.Billing.Stripe.ProductTaxCode == "") { + return errors.New("Stripe automatic tax requires an explicit confirmed registration and product tax code") + } } } } -- cgit v1.2.3