Settings Service
The settings service provides schema-driven, encrypted configuration management with rich validation rules and UI hints for automated form generation.
Overview
Based on pkg/v2/application/settings/, the settings service provides:
- Schema Definition: Type-safe settings with validation rules
- AES-256-GCM Encryption: Transparent encryption for sensitive data
- Rich Validation: Type checking, length, patterns, enums, min/max
- UI Hints: Schema-driven form generation metadata
- Multi-Tenant Isolation: App-based partitioning with audit trail
- Distributed Caching: In-memory cache with TTL and event-driven invalidation
- Automatic Masking: Sensitive values masked in public APIs
Service Architecture
Based on pkg/v2/application/settings/settings_service.go:
Service Interface
type Service interface {
RegisterSettings(ctx context.Context, cmd *RegisterSettingsCommand) (*RegisterSettingsResponse, error)
UpdateSettings(ctx context.Context, cmd *UpdateSettingsCommand) (*UpdateSettingsResponse, error)
GetSettings(ctx context.Context, query *GetSettingsQuery) (*GetSettingsResponse, error)
GetSettingValue(ctx context.Context, query *GetSettingValueQuery) (*GetSettingValueResponse, error)
ValidateRequiredSettings(ctx context.Context, appID uuid.UUID) error
DeleteSettings(ctx context.Context, cmd *DeleteSettingsCommand) error
GetSettingsInternal(ctx context.Context, query *GetSettingsInternalQuery) (*GetSettingsResponse, error)
}
Core Components
type settingsService struct {
settingsRepo repository.SettingsRepository
appRepo repository.AppRepository
eventBus event.Bus
cache *SettingsCache
logger telemetry.Logger
}
Schema Definition and Validation
Based on pkg/v2/domain/settings/setting_definition.go:
SettingDefinition Structure
type SettingDefinition struct {
ID uuid.UUID
AppID uuid.UUID
Key string
DataType SettingDataType
Required bool
DefaultValue interface{}
Validation *ValidationRules
Description string
Sensitive bool
UIHints *UIHints
CreatedAt time.Time
}
Builder Pattern
def := settings.NewSettingDefinition(appID, "api_key", settings.DataTypeStringRequired).
WithDefaultValue("").
WithValidation(&settings.ValidationRules{
MinLength: ptr(8),
MaxLength: ptr(64),
Pattern: ptr("^[A-Za-z0-9_-]+$"),
}).
WithDescription("API key for external service").
WithSensitive(true).
WithUIHints(settings.NewUIHints().
WithInputType("password").
WithPlaceholder("Enter your API key").
WithHelpText("Contact support to obtain an API key"),
)
Validation Lifecycle
From setting_definition.go:71-102:
func (d *SettingDefinition) Validate() error {
// 1. Key validation
// 2. DataType validation
// 3. Required field enforcement
// 4. Regex pattern validation
// 5. Default value validation against rules
}
Data Types and Type System
Based on pkg/v2/domain/settings/data_type.go:
Complete Type System
const (
DataTypeString = "STRING"
DataTypeStringRequired = "STRING_REQUIRED"
DataTypeInt = "INT"
DataTypeIntRequired = "INT_REQUIRED"
DataTypeFloat = "FLOAT"
DataTypeFloatRequired = "FLOAT_REQUIRED"
DataTypeBool = "BOOL"
DataTypeBoolRequired = "BOOL_REQUIRED"
DataTypeStringArray = "STRING_ARRAY"
DataTypeIntArray = "INT_ARRAY"
DataTypeFloatArray = "FLOAT_ARRAY"
DataTypeBoolArray = "BOOL_ARRAY"
DataTypeJSON = "JSON"
DataTypeJSONRequired = "JSON_REQUIRED"
DataTypeKVPairArray = "KVPAIR_ARRAY"
)
Type Helper Methods
// Returns true for *_REQUIRED types
IsRequired() bool
// Strips REQUIRED suffix
BaseType() string
// Validates type is in allowed set
IsValid() bool
Validation Rules
Based on pkg/v2/domain/settings/validation_rules.go:
Rules Structure
type ValidationRules struct {
MinLength *int // String minimum length
MaxLength *int // String maximum length
Min *int64 // Number minimum value
Max *int64 // Number maximum value
Pattern *string // Regex pattern for strings
Enum []string // Allowed values
}
Pattern Validation
From validation_rules.go:56-65:
- Compiles and validates regex patterns at definition time
- Prevents invalid patterns from being stored
Validator Implementation
Based on pkg/v2/domain/settings/validator.go:
Three-Phase Validation:
-
Type Validation (lines 39-162):
- Handles nullable types
- JSON number coercion to integers
- Array element validation
- KVPairArray validation (expects objects with "key" and "value" fields)
-
Required Validation (lines 26-29):
- Checks if required field is empty
- Uses
isEmpty()helper for various types
-
Rule Application (lines 164-251):
- String: length, pattern, enum
- Integer: min/max values
- Float: min/max values with type coercion
Example Validation
validator := settings.NewSettingValidator(def)
if err := validator.Validate(value); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
Encryption/Decryption
Based on pkg/v2/infrastructure/encryption/aes_encryptor.go:
AES-256-GCM Strategy
type AESEncryptor struct {
cipher cipher.AEAD
}
// Constructor validates key is exactly 32 bytes (256-bit)
func NewAESEncryptor(key []byte) (*AESEncryptor, error) {
if len(key) != 32 {
return nil, ErrInvalidKey
}
// Create GCM cipher
}
Encryption Process
From aes_encryptor.go:49-62:
func (e *AESEncryptor) Encrypt(plaintext string) (string, error) {
if plaintext == "" {
return "", nil // No encryption for empty strings
}
// Generate random nonce
nonce := make([]byte, e.cipher.NonceSize())
io.ReadFull(rand.Reader, nonce)
// Seal: nonce + ciphertext
ciphertext := e.cipher.Seal(nonce, nonce, []byte(plaintext), nil)
// Base64 encode for storage
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
Decryption Process
From aes_encryptor.go:64-87:
func (e *AESEncryptor) Decrypt(ciphertextBase64 string) (string, error) {
if ciphertextBase64 == "" {
return "", nil
}
// Decode base64
ciphertext := base64.StdEncoding.DecodeString(ciphertextBase64)
// Extract nonce and ciphertext
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
// Open (decrypt + verify)
plaintext := e.cipher.Open(nil, nonce, ciphertext, nil)
return string(plaintext), nil
}
Key Features
- Random Nonce: Same plaintext produces different ciphertexts
- Authenticated Encryption: GCM mode provides integrity checking
- Base64 Encoding: Storage-compatible format
- NoOpEncryptor: Available for non-sensitive settings (testing)
Settings Storage and Retrieval
Based on pkg/v2/infrastructure/storage/postgres/settings_repository.go:
Repository Interface
From pkg/v2/domain/repository/settings.go:
type SettingsRepository interface {
StoreDefinitions(ctx context.Context, definitions [...]) error
GetDefinitions(ctx context.Context, appID uuid.UUID) ([...], error)
GetDefinition(ctx context.Context, appID uuid.UUID, key string) ([...], error)
StoreValue(ctx context.Context, value [...]) error
StoreValues(ctx context.Context, values [...]) error
GetValue(ctx context.Context, appID uuid.UUID, key string) ([...], error)
GetValues(ctx context.Context, appID uuid.UUID) ([...], error)
LogChange(ctx context.Context, appID uuid.UUID, key, oldValue, newValue, changedBy) error
DeleteSettings(ctx context.Context, appID uuid.UUID) error
}
Definition Storage
From settings_repository.go:34-142:
INSERT INTO appserver.app_setting_definitions
(id, app_id, key, data_type, required, default_value, validation_rules,
description, sensitive, ui_hints, created_at)
Sensitive Default Values: Encrypted before storage
if def.Sensitive {
encrypted, _ := r.encryptor.Encrypt(string(jsonBytes))
// Wrap as JSON string for JSONB storage
}
Value Storage with Upsert
From settings_repository.go:205-280:
INSERT INTO appserver.app_setting_values (id, app_id, key, value, updated_by, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (app_id, key) DO UPDATE SET
value = EXCLUDED.value,
updated_by = EXCLUDED.updated_by,
updated_at = EXCLUDED.updated_at
Automatic Encryption Logic:
- Get definitions to identify sensitive fields
- Only encrypt values marked as sensitive
- Wrap encrypted base64 as JSON string for JSONB storage
- JSON marshaling → encryption → JSON wrapping → storage
Value Retrieval with Decryption
From settings_repository.go:282-340:
func (r *SettingsRepository) GetValue(ctx, appID, key) {
// Get definition to check if sensitive
def, _ := r.GetDefinition(ctx, appID, key)
if def.Sensitive {
// Unmarshal JSON wrapper
var encryptedStr string
json.Unmarshal([]byte(valueStr), &encryptedStr)
// Decrypt
decrypted, _ := r.encryptor.Decrypt(encryptedStr)
valueStr = decrypted
}
// Deserialize JSON value
json.Unmarshal([]byte(valueStr), &val.Value)
}
Audit Logging
From settings_repository.go:420-500:
- Logs all setting changes with old/new values
- Encrypts sensitive values in audit log
- Stores in
app_setting_changestable with timestamp and changer ID
Schema-Driven Form Generation
Based on pkg/v2/domain/settings/ui_hints.go:
UIHints Structure
type UIHints struct {
InputType *string // text, password, textarea, select, number
Placeholder *string // Placeholder text
HelpText *string // Help text
Multiline *bool // For text fields
Step *int // For number inputs
Options []string // For select inputs
}
Builder Pattern
hints := settings.NewUIHints().
WithInputType("password").
WithPlaceholder("Enter API key").
WithHelpText("Your secret API key").
WithOptions([]string{"option1", "option2"})
Protobuf Conversion
From settings_service.go:612-636:
if pbDef.UiHints != nil {
hints := settings.NewUIHints()
if pbDef.UiHints.InputType != nil {
hints.WithInputType(*pbDef.UiHints.InputType)
}
if pbDef.UiHints.Options != nil {
hints.WithOptions(pbDef.UiHints.Options)
}
def.WithUIHints(hints)
}
Frontend Integration
The frontend can automatically generate forms using hints:
// Example auto-generated form
{
key: "api_key",
type: "password",
placeholder: "Enter your API key",
helpText: "Contact support to obtain an API key",
required: true,
validation: {
minLength: 8,
maxLength: 64,
pattern: "^[A-Za-z0-9_-]+$"
}
}
Multi-Tenant Isolation
Isolation Strategy
1. App-Based Partitioning:
- All methods require
appID uuid.UUIDparameter - No cross-app data access possible at service level
- Repository queries filtered by
WHERE app_id = $1
2. Example Isolation (settings_service.go:298):
definitions, err := s.settingsRepo.GetDefinitions(ctx, query.AppID)
3. Cache Isolation (cache.go:27):
type SettingsCache struct {
entries map[uuid.UUID]*CacheEntry // Keyed by AppID
}
4. Event-Based Invalidation (settings_service.go:46-61):
- Listens to
EventTypeSettingsUpdatedwithAppIDin payload - Invalidates only specific app's cache:
svc.cache.Invalidate(payload.AppID)
5. Audit Trail Isolation (settings_repository.go:480):
INSERT INTO appserver.app_setting_changes
(id, app_id, key, old_value, new_value, changed_by, changed_at)
Distributed Caching
Based on pkg/v2/application/settings/cache.go:
Cache Design
type SettingsCache struct {
mu sync.RWMutex
entries map[uuid.UUID]*CacheEntry
ttl time.Duration
}
type CacheEntry struct {
Definitions []*settings.SettingDefinition
Values []*settings.SettingValue
CachedAt time.Time
}
Cache Operations
1. Get with Expiration Check (lines 44-60):
func (c *SettingsCache) Get(appID uuid.UUID) (*CacheEntry, bool) {
entry, exists := c.entries[appID]
if !exists {
return nil, false
}
// Check if expired
if entry.IsExpired(c.ttl) {
return nil, false // Treat expired as miss
}
return entry, true
}
2. Automatic Cleanup Loop (lines 91-110):
- Background goroutine runs cleanup at
ttl/2intervals - Removes all expired entries
- Prevents memory leaks
3. Cache Hit Path (settings_service.go:280-290):
if entry, found := s.cache.Get(query.AppID); found {
// Mask sensitive values before returning
maskedValues := s.maskSensitiveValues(entry.Definitions, entry.Values)
return &GetSettingsResponse{
Definitions: entry.Definitions,
Values: maskedValues,
}, nil
}
4. Cache Population (settings_service.go:317-319):
// Store unmasked values in cache (for internal use)
s.cache.Set(query.AppID, definitions, values)
5. Invalidation Strategy:
- Event-driven: Subscribe to
EventTypeSettingsUpdatedandEventTypeSettingsDeleted - Manual: Called after
UpdateSettings()andDeleteSettings() - Ensures consistency across distributed instances
Sensitive Data Masking
Based on settings_service.go:682-720:
Masking Implementation
func (s *settingsService) maskSensitiveValues(
definitions []*settings.SettingDefinition,
values []*settings.SettingValue,
) []*settings.SettingValue {
// Build map of sensitive keys
sensitiveKeys := make(map[string]bool)
for _, def := range definitions {
if def.Sensitive {
sensitiveKeys[def.Key] = true
}
}
// Clone and mask values
masked := make([]*settings.SettingValue, len(values))
for i, val := range values {
if sensitiveKeys[val.Key] {
masked[i].Value = "********"
masked[i].IsMasked = true
} else {
masked[i].Value = val.Value
masked[i].IsMasked = false
}
}
return masked
}
Masking Applied At
- Public API response (
GetSettings()) - Per-setting response (
GetSettingValueResponse) - Cache hits (re-masked each time)
Internal Access Override
From settings_service.go:467-553:
func (s *settingsService) GetSettingsInternal(...) {
// Authorization: Only allow service, admin, worker types
authorizedTypes := map[string]bool{
"service": true,
"admin": true,
"worker": true,
}
if !authorizedTypes[query.RequesterType] {
return nil, fmt.Errorf("unauthorized...")
}
// Audit log sensitive access
if query.IncludeSensitive {
s.logger.Warn("sensitive settings accessed by internal service",
telemetry.String("requester_type", query.RequesterType),
telemetry.Int("sensitive_count", sensitiveCount),
)
}
// Return unmasked if requested
if query.IncludeSensitive {
// Return actual decrypted values from repository
}
}
Domain Events
Based on pkg/v2/domain/event/settings_events.go:
Event Types
const (
EventTypeSettingsRegistered = "settings.registered"
EventTypeSettingsUpdated = "settings.updated"
EventTypeSettingsValidationFailed = "settings.validation_failed"
EventTypeSettingsDeleted = "settings.deleted"
)
Event Payloads
1. SettingsRegisteredPayload:
{
AppID: uuid.UUID
AppName: string
DefinitionCount: int
}
2. SettingsUpdatedPayload:
{
AppID: uuid.UUID
AppName: string
ChangedKeys: []string
UpdatedBy: *uuid.UUID
}
3. SettingsValidationFailedPayload:
{
AppID: uuid.UUID
AppName: string
Errors: []ValidationError // With Key, Message
}
4. SettingsDeletedPayload:
{
AppID: uuid.UUID
AppName: string
}
Event Publishing
From settings_service.go:254-261:
for _, evt := range agg.GetChanges() {
if err := s.eventBus.Publish(ctx, evt); err != nil {
s.logger.Warn("failed to publish event",
telemetry.Error(err),
telemetry.String("event_type", evt.EventType()),
)
}
}
Error Handling
Based on pkg/v2/application/settings/errors.go:
ValidationError Type
From errors.go:17-40:
type ValidationError struct {
Errors []SettingError
}
type SettingError struct {
Key string
Message string
Code string
}
Error Flow
From settings_service.go:195-222:
if err := agg.UpdateSettings(ctx, cmd.Settings, cmd.UpdatedBy); err != nil {
if errors.Is(err, settings.ErrValidationFailed) {
// Extract validation errors from aggregate
validationErrors := agg.GetValidationErrors()
var settingErrors []SettingError
for _, ve := range validationErrors {
settingErrors = append(settingErrors, SettingError{
Key: ve.Key,
Message: ve.Message,
Code: "VALIDATION_ERROR",
})
}
// Return structured error for gRPC layer
return nil, &ValidationError{
Errors: settingErrors,
}
}
}
Usage Example
Registering Settings Schema
schema := &pb.SettingsSchema{
Definitions: []*pb.SettingDefinition{
{
Key: "database_url",
DataType: "STRING_REQUIRED",
Description: "PostgreSQL connection URL",
Sensitive: true,
UiHints: &pb.UIHints{
InputType: ptr("password"),
Placeholder: ptr("postgresql://user:pass@host:5432/db"),
HelpText: ptr("Full database connection URL"),
},
},
{
Key: "max_connections",
DataType: "INT",
DefaultValue: "10",
Description: "Maximum database connections",
ValidationRules: &pb.ValidationRules{
Min: ptr(int64(1)),
Max: ptr(int64(100)),
},
UiHints: &pb.UIHints{
InputType: ptr("number"),
Step: ptr(1),
},
},
},
}
cmd := &RegisterSettingsCommand{
AppID: appID,
AppName: "my-app",
SettingsSchema: schema,
}
resp, err := settingsService.RegisterSettings(ctx, cmd)
Updating Settings
cmd := &UpdateSettingsCommand{
AppID: appID,
AppName: "my-app",
Settings: map[string]interface{}{
"database_url": "postgresql://user:pass@localhost:5432/mydb",
"max_connections": 25,
},
UpdatedBy: &userID,
}
resp, err := settingsService.UpdateSettings(ctx, cmd)
Retrieving Settings
query := &GetSettingsQuery{
AppID: appID,
}
resp, err := settingsService.GetSettings(ctx, query)
// resp.Values will have sensitive values masked ("********")
Summary Table
| Aspect | Technology | Details |
|---|---|---|
| Encryption | AES-256-GCM | Random nonce per message, Base64 encoded |
| Caching | In-memory with TTL | Background cleanup at TTL/2 |
| Validation | Custom validator | Type, required, length, pattern, enum, min/max |
| Storage | PostgreSQL JSONB | Atomic transactions, upsert on conflict |
| Masking | Field-level | "********" for sensitive, IsMasked flag |
| Tenancy | App-based | All queries filtered by AppID |
| Events | Event sourcing | 4 event types with structured payloads |
| UI Hints | Schema-driven | InputType, placeholder, help text, options |
Key Files
| File | Purpose |
|---|---|
settings_service.go | Service orchestration and public API |
setting_definition.go | Schema definition with builder pattern |
validator.go | Three-phase validation logic |
validation_rules.go | Rule structures and pattern validation |
ui_hints.go | Form generation metadata |
cache.go | In-memory cache with TTL |
aes_encryptor.go | AES-256-GCM encryption implementation |
settings_repository.go | PostgreSQL persistence with encryption |
settings_aggregate.go | Event sourcing aggregate |
settings_events.go | Domain event definitions |
Related Topics
- Marketplace Feature - Settings registered during app registration
- Event-Driven Architecture - Event patterns
- Platform Architecture - Application layer
- Node.js SDK - Settings schema declaration
- Frontend SDK - Settings UI integration