package schemas import ( "database/sql/driver" "fmt" "os" "strconv" "strings" "github.com/bytedance/sonic" ) // EnvVar is a wrapper around a value that can be sourced from an environment variable. type EnvVar struct { Val string `json:"value"` EnvVar string `json:"env_var"` FromEnv bool `json:"from_env"` } // NewEnvVar creates a new EnvValue from a string. func NewEnvVar(value string) *EnvVar { // Cleanup string if required // Use strconv.Unquote to properly handle JSON string escape sequences // This converts "\"{\\\"key\\\":\\\"value\\\"}\"" to "{\"key\":\"value\"}" val := value if unquoted, err := strconv.Unquote(value); err == nil { val = unquoted } // Here we will need to check if the incoming data is a valid JSON object // If it's a valid JSON object and follows the EnvVar schema, then we will unmarshal it into an EnvVar object if sonic.Valid([]byte(value)) { valueNode, _ := sonic.Get([]byte(val), "value") envNode, _ := sonic.Get([]byte(val), "env_var") if valueNode.Exists() && envNode.Exists() { // Use a type alias to avoid infinite recursion (alias doesn't inherit methods) type envVarAlias EnvVar var envVar envVarAlias if err := sonic.Unmarshal([]byte(value), &envVar); err == nil { e := &EnvVar{ Val: envVar.Val, FromEnv: envVar.FromEnv, EnvVar: envVar.EnvVar, } // Here we will check if the Val starts with env and is same as the EnvVar if strings.HasPrefix(e.Val, "env.") && e.Val == e.EnvVar { e.Val = "" // Load the environment variable value envValue, ok := os.LookupEnv(strings.TrimPrefix(e.EnvVar, "env.")) if ok { e.Val = envValue } e.FromEnv = true } return e } } } if envKey, ok := strings.CutPrefix(val, "env."); ok { if envValue, ok := os.LookupEnv(envKey); ok { return &EnvVar{ Val: envValue, FromEnv: true, EnvVar: val, } } return &EnvVar{ Val: "", FromEnv: true, EnvVar: val, } } return &EnvVar{ Val: val, FromEnv: false, EnvVar: "", } } // IsRedacted returns true if the value is redacted. func (e *EnvVar) IsRedacted() bool { if e.Val == "" && !e.FromEnv { return false } // Check if it's an environment variable reference if e.FromEnv { return true } if len(e.Val) <= 8 { return strings.Count(e.Val, "*") == len(e.Val) } // Check for exact redaction pattern: 4 chars + 24 asterisks + 4 chars if len(e.Val) == 32 { middle := e.Val[4:28] if middle == strings.Repeat("*", 24) { return true } } // Check if its string if e.Val == "" { return true } return false } // Equals checks if two SecretKeys are equal. func (e *EnvVar) Equals(other *EnvVar) bool { if e == nil && other == nil { return true } if e == nil || other == nil { return false } return e.Val == other.Val && e.EnvVar == other.EnvVar && e.FromEnv == other.FromEnv } // Redacted returns a new SecretKey with the value redacted. func (e *EnvVar) Redacted() *EnvVar { if e == nil { return nil } if e.Val == "" { return &EnvVar{ Val: "", FromEnv: e.FromEnv, EnvVar: e.EnvVar, } } // If key is 8 characters or less, just return all asterisks if len(e.Val) <= 8 { return &EnvVar{ Val: strings.Repeat("*", len(e.Val)), FromEnv: e.FromEnv, EnvVar: e.EnvVar, } } // Show first 4 and last 4 characters, replace middle with asterisks prefix := e.Val[:4] suffix := e.Val[len(e.Val)-4:] middle := strings.Repeat("*", 24) return &EnvVar{ Val: prefix + middle + suffix, FromEnv: e.FromEnv, EnvVar: e.EnvVar, } } // MarshalJSON serializes the EnvVar to JSON. // SECURITY: When the value was sourced from an environment variable, the resolved // value is automatically redacted before being serialized. This ensures that secrets // injected via env vars are never leaked through any JSON API response, regardless // of whether the surrounding code remembered to call Redacted() explicitly. // // Plain (non-env) values are still emitted as-is — callers that want to mask those // must continue using Redacted() at the field level (this matches the existing // per-provider redaction logic). // // This does NOT affect: // - GORM persistence (uses the Value() driver method, not JSON) // - Encryption (operates on the Val field directly) // - Internal LLM request paths (use GetValue() directly) func (e EnvVar) MarshalJSON() ([]byte, error) { type envVarAlias EnvVar out := envVarAlias(e) if e.FromEnv { // Redact the resolved value but keep the env var reference and from_env flag // so the UI still knows which env var backs this field. redacted := e.Redacted() if redacted != nil { out = envVarAlias(*redacted) } } return sonic.Marshal(out) } // UnmarshalJSON unmarshals the value from JSON. func (e *EnvVar) UnmarshalJSON(data []byte) error { // This is always going to be value // Here we will first considering this as value // if it has env. then we will process it and set the FromEnv to true // if it doesn't have env. then we will set the FromEnv to false // if it has env. then we will process it and set the FromEnv to true val := string(data) // Cleanup string if required // Use strconv.Unquote to properly handle JSON string escape sequences // This converts "\"{\\\"key\\\":\\\"value\\\"}\"" to "{\"key\":\"value\"}" if unquoted, err := strconv.Unquote(val); err == nil { val = unquoted } // Here we will need to check if the incoming data is a valid JSON object // If it's a valid JSON object and follows the EnvVar schema, then we will unmarshal it into an EnvVar object if sonic.Valid(data) { valueNode, _ := sonic.Get(data, "value") envNode, _ := sonic.Get(data, "env_var") if valueNode.Exists() && envNode.Exists() { // Use a type alias to avoid infinite recursion (alias doesn't inherit methods) type envVarAlias EnvVar var envVar envVarAlias if err := sonic.Unmarshal(data, &envVar); err == nil { e.Val = envVar.Val e.FromEnv = envVar.FromEnv e.EnvVar = envVar.EnvVar // Here we will check if the Val starts with env and is same as the EnvVar if strings.HasPrefix(e.Val, "env.") && e.Val == e.EnvVar { e.Val = "" // Load the environment variable value envValue, ok := os.LookupEnv(strings.TrimPrefix(e.EnvVar, "env.")) if ok { e.Val = envValue } e.FromEnv = true } return nil } // Else the value is JSON, so we will treat this as a normal value } } if envKey, ok := strings.CutPrefix(val, "env."); ok { if envValue, ok := os.LookupEnv(envKey); ok { e.Val = envValue e.FromEnv = true e.EnvVar = val return nil } e.Val = "" e.FromEnv = true e.EnvVar = val return nil } e.Val = val e.FromEnv = false e.EnvVar = "" return nil } // String returns the value as a string. func (e *EnvVar) String() string { return e.Val } // Scan scans the value from the database. func (e *EnvVar) Scan(value any) error { if value == nil { e.Val = "" e.FromEnv = false e.EnvVar = "" return nil } switch v := value.(type) { case []byte: return e.Scan(string(v)) case string: // Cleanup string if required // The string may have "\"env.TEST\"", "env.TEST" or "env.TEST\"", we need to clean it up to "env.TEST" val := strings.Trim(v, "\"") if envKey, ok := strings.CutPrefix(val, "env."); ok { if envValue, ok := os.LookupEnv(envKey); ok { e.Val = envValue e.FromEnv = true e.EnvVar = val return nil } e.Val = "" e.FromEnv = true e.EnvVar = val return nil } e.Val = val e.FromEnv = false e.EnvVar = "" return nil } return fmt.Errorf("failed to scan value: %v", value) } // Value implements driver.Valuer for database storage. // It stores the original env reference (e.g., "env.API_KEY") if FromEnv is true, // otherwise stores the raw value. func (e EnvVar) Value() (driver.Value, error) { if e.FromEnv { return e.EnvVar, nil } return e.Val, nil } // IsFromEnv returns true if the value is sourced from an environment variable. func (e *EnvVar) IsFromEnv() bool { return e.FromEnv } // IsSet returns true if the EnvVar has a resolved value or an environment variable reference. // This should be used instead of GetValue() != "" when checking whether a field was configured, // because env var references may have an empty Val before resolution (e.g., when the env var // is not available in the current environment). func (e *EnvVar) IsSet() bool { if e == nil { return false } return e.Val != "" || e.EnvVar != "" } // GetValue returns the value. func (e *EnvVar) GetValue() string { if e == nil { return "" } return e.Val } // GetValuePtr returns a pointer to the value. func (e *EnvVar) GetValuePtr() *string { if e == nil { return nil } return &e.Val } // CoerceInt coerces value to int func (e *EnvVar) CoerceInt(defaultValue int) int { if e == nil { return defaultValue } val, err := strconv.Atoi(e.GetValue()) if err != nil { return defaultValue } return val } // CoerceBool coerces value to bool func (e *EnvVar) CoerceBool(defaultValue bool) bool { if e == nil { return defaultValue } val, err := strconv.ParseBool(e.GetValue()) if err != nil { return defaultValue } return val } // IsDefined returns true if the EnvVar has a source (static value or env key) func (e *EnvVar) IsDefined() bool { if e == nil { return false } if e.IsFromEnv() { return e.EnvVar != "" } return e.Val != "" }