Settings Management
Settings provide schema-driven, type-safe configuration for applications with built-in validation, encryption, caching, and audit logging capabilities.
Overview
The settings system allows applications to define configuration schemas in their manifests, with the platform handling validation, secure storage, and access control. Settings support various data types, validation rules, default values, and automatic encryption for sensitive values.
Core Concepts
Setting Definition
A setting definition declares the schema for a configuration value:
Key: api_key
Data Type: STRING_REQUIRED
Sensitive: true
Default Value: null
Validation: { minLength: 32, maxLength: 64 }
Description: "API key for external service"
Setting Value
The actual configured value for a setting:
App ID: de.easy-m.todos
Key: api_key
Value: "sk_live_..." (encrypted)
Updated By: user:alice
Updated At: 2025-01-15T10:30:00Z
Data Types
Settings support rich data types with optional/required variants:
Code Reference: pkg/v2/domain/settings/data_type.go
Primitive Types:
- STRING / STRING_REQUIRED
- INT / INT_REQUIRED
- FLOAT / FLOAT_REQUIRED
- BOOL / BOOL_REQUIRED
Array Types:
- STRING_ARRAY
- INT_ARRAY
- FLOAT_ARRAY
- BOOL_ARRAY
Structured Types:
- JSON / JSON_REQUIRED
- KVPAIR_ARRAY (key-value pairs)
Registration Flow
Settings are registered when an app's manifest is processed:
Code Reference: pkg/v2/application/settings/settings_service.go:30
Manifest Declaration
Apps declare settings in their manifest:
defineManifest()
.name('de.easy-m.todos')
.settings(settings => settings
.define('api_key', {
dataType: 'STRING_REQUIRED',
sensitive: true,
description: 'API key for external service',
validation: {
minLength: 32,
maxLength: 64,
pattern: '^sk_[a-z]+_[A-Za-z0-9]{32}$'
}
})
.define('max_items', {
dataType: 'INT',
defaultValue: 100,
validation: {
min: 1,
max: 1000
}
})
.define('feature_flags', {
dataType: 'JSON',
defaultValue: { enableBeta: false },
uiHints: {
inputType: 'json-editor'
}
})
)
.build();
Registration Process
1. Marketplace.RegisterApp receives manifest
↓
2. Settings service extracts SettingsSchema from manifest
↓
3. Convert protobuf definitions to domain entities
↓
4. Validate schema (data types, validation rules, defaults)
↓
5. Store definitions in database
↓
6. Store default values (encrypted if sensitive)
↓
7. Publish settings.registered event
↓
8. Settings ready for use
Code Reference: pkg/v2/application/settings/settings_service.go:83
Proto to Domain Conversion
The service converts protobuf definitions to domain entities:
func (s *settingsService) convertProtoToDefinitions(
appID uuid.UUID,
schema *pb.SettingsSchema,
) ([]*settings.SettingDefinition, error) {
// Convert each protobuf definition
for _, pbDef := range schema.Definitions {
def := settings.NewSettingDefinition(
appID,
pbDef.Key,
dataType,
pbDef.Required,
).WithDescription(pbDef.Description).
WithSensitive(pbDef.Sensitive)
// Add default value, validation, UI hints
// ...
}
}
Update Flow
Settings values can be updated via gRPC or GraphQL APIs:
Code Reference: pkg/v2/application/settings/settings_service.go:160
Update Process
1. Client calls Settings.UpdateSettings(values)
↓
2. Permission check: Can requester update settings?
↓
3. Load current definitions and values
↓
4. Validate new values against schema
↓
5. Store updated values (encrypt if sensitive)
↓
6. Log change to audit log
↓
7. Publish settings.updated event
↓
8. Invalidate cache
↓
9. Return success with changed keys
Validation
Values are validated against their definitions:
// Invalid: Type mismatch
await updateSettings({
max_items: "not a number" // ❌ Expected INT
});
// Invalid: Constraint violation
await updateSettings({
max_items: 5000 // ❌ Exceeds max: 1000
});
// Valid
await updateSettings({
max_items: 250 // ✓ Within range [1, 1000]
});
Validation errors return structured feedback:
{
"errors": [
{
"key": "api_key",
"message": "Value does not match pattern",
"code": "VALIDATION_ERROR"
}
]
}
Encryption
Sensitive settings are automatically encrypted at rest:
Code Reference: pkg/v2/infrastructure/encryption/aes_encryptor.go:29
AES-256-GCM Encryption
type AESEncryptor struct {
cipher cipher.AEAD
}
// Encrypt using AES-256-GCM with random nonce
func (e *AESEncryptor) Encrypt(plaintext string) (string, error) {
nonce := make([]byte, e.cipher.NonceSize())
io.ReadFull(rand.Reader, nonce)
ciphertext := e.cipher.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
Key Features:
- Algorithm: AES-256-GCM (authenticated encryption)
- Key Size: 32 bytes (256 bits)
- Nonce: Random, unique per encryption
- Encoding: Base64 for storage
- Authentication: GCM provides integrity verification
Encryption Key Management
The encryption key is configured via environment variable:
APPSERVER_SETTINGS_ENCRYPTION_KEY=<32-byte-base64-key>
Important: This key must be:
- Exactly 32 bytes
- Kept secret
- Backed up securely
- Rotated periodically (requires decryption and re-encryption)
Storage Format
Encrypted values are stored as JSON strings in PostgreSQL JSONB:
-- Sensitive setting
{
"value": "\"AgFj2k...base64...==\""
-- JSON-wrapped encrypted base64 string
}
-- Non-sensitive setting
{
"value": "{\"enableBeta\":false}"
-- Plain JSON value
}
Code Reference: pkg/v2/infrastructure/storage/postgres/settings_repository.go:43
Decryption on Retrieval
Values are automatically decrypted when retrieved:
func (r *SettingsRepository) GetValue(
ctx context.Context,
appID uuid.UUID,
key string,
) (*settings.SettingValue, error) {
// ... load from database
if def.Sensitive {
// Unmarshal JSON wrapper
var encryptedStr string
json.Unmarshal([]byte(valueStr), &encryptedStr)
// Decrypt
decrypted, err := r.encryptor.Decrypt(encryptedStr)
valueStr = decrypted
}
// Unmarshal actual value
json.Unmarshal([]byte(valueStr), &val.Value)
return val, nil
}
Masking Sensitive Values
Sensitive settings are masked when exposed via APIs:
Code Reference: pkg/v2/application/settings/settings_service.go:683
Masking Behavior
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
}
}
// Mask sensitive values
for _, val := range values {
if sensitiveKeys[val.Key] {
val.Value = "********"
val.IsMasked = true
}
}
return masked
}
API Responses
{
"definitions": [...],
"values": [
{
"key": "api_key",
"value": "********",
"isMasked": true,
"updatedAt": "2025-01-15T10:30:00Z"
},
{
"key": "max_items",
"value": 100,
"isMasked": false,
"updatedAt": "2025-01-15T10:30:00Z"
}
]
}
Internal Access
Authorized services can access unmasked values:
func (s *settingsService) GetSettingsInternal(
ctx context.Context,
query GetSettingsInternalQuery,
) (*GetSettingsInternalResponse, error) {
// Authorization check
authorizedTypes := map[string]bool{
"service": true,
"admin": true,
"worker": true,
}
if !authorizedTypes[query.RequesterType] {
return nil, fmt.Errorf("unauthorized")
}
// Audit log access to sensitive values
if query.IncludeSensitive {
s.logger.Warn("sensitive settings accessed",
telemetry.String("requester_id", query.RequesterID.String()))
}
// Return unmasked if authorized
return values, nil
}
Caching Strategy
Settings are aggressively cached to minimize database queries:
Code Reference: pkg/v2/application/settings/cache.go:12
In-Memory Cache
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 Behavior
Get Settings:
├─ Check cache
│ ├─ Hit: Return cached values (< 1ms)
│ └─ Miss: Query database (~10ms)
├─ Store in cache
└─ Return to caller
Update Settings:
├─ Store in database
├─ Invalidate cache
└─ Publish event for distributed invalidation
Cache TTL
Default TTL is configurable (typically 5-15 minutes):
cache := NewSettingsCache(10 * time.Minute)
Automatic Cleanup
The cache automatically removes expired entries:
func (c *SettingsCache) cleanupLoop() {
ticker := time.NewTicker(c.ttl / 2)
defer ticker.Stop()
for range ticker.C {
c.cleanup() // Remove expired entries
}
}
Event-Based Cache Invalidation
Cache is invalidated via event bus for distributed consistency:
Code Reference: pkg/v2/application/settings/settings_service.go:47
Event Subscription
func NewSettingsService(...) Service {
// Subscribe to settings events
eventBus.Subscribe(event.EventTypeSettingsUpdated, func(ctx context.Context, evt event.Event) error {
var payload event.SettingsUpdatedPayload
json.Unmarshal(evt.Payload(), &payload)
// Invalidate cache across all instances
svc.cache.Invalidate(payload.AppID)
logger.Debug("cache invalidated via event",
telemetry.String("app_id", payload.AppID.String()))
return nil
})
eventBus.Subscribe(event.EventTypeSettingsDeleted, ...)
return svc
}
Event Flow
Instance A: Event Bus: Instance B:
Update Settings
↓
Store in DB
↓
Invalidate local cache
↓
Publish settings.updated ───→ Distribute event ───→ Receive event
↓
Invalidate local cache
This ensures all appserver instances have consistent cache state.
Audit Logging
All setting changes are logged for audit purposes:
Code Reference: pkg/v2/infrastructure/storage/postgres/settings_repository.go:421
Change Logging
func (r *SettingsRepository) LogChange(
ctx context.Context,
appID uuid.UUID,
key string,
oldValue, newValue interface{},
changedBy *uuid.UUID,
) error {
// Encrypt old/new values if sensitive
// Store in app_setting_changes table
}
Audit Log Schema
CREATE TABLE appserver.app_setting_changes (
id UUID PRIMARY KEY,
app_id UUID NOT NULL,
key VARCHAR(255) NOT NULL,
old_value JSONB,
new_value JSONB,
changed_by UUID,
changed_at TIMESTAMP NOT NULL,
FOREIGN KEY (app_id) REFERENCES appserver.apps(id)
);
Sensitive Value Handling
Audit logs encrypt sensitive values:
{
"appId": "uuid",
"key": "api_key",
"oldValue": "\"encrypted_base64_string\"",
"newValue": "\"encrypted_base64_string\"",
"changedBy": "user:alice",
"changedAt": "2025-01-15T10:30:00Z"
}
Validation Rules
Settings support comprehensive validation:
String Validation
{
dataType: 'STRING_REQUIRED',
validation: {
minLength: 8,
maxLength: 100,
pattern: '^[a-zA-Z0-9_-]+$',
enum: ['option1', 'option2', 'option3']
}
}
Numeric Validation
{
dataType: 'INT_REQUIRED',
validation: {
min: 0,
max: 100
}
}
Custom JSON Schema
{
dataType: 'JSON',
validation: {
// Custom JSON schema validation
schema: {
type: 'object',
properties: {
enableBeta: { type: 'boolean' },
threshold: { type: 'number', minimum: 0 }
},
required: ['enableBeta']
}
}
}
UI Hints
Settings can include hints for UI rendering:
{
key: 'description',
dataType: 'STRING',
uiHints: {
inputType: 'textarea',
multiline: true,
placeholder: 'Enter description...',
helpText: 'Describe your configuration'
}
}
{
key: 'priority',
dataType: 'STRING',
uiHints: {
inputType: 'select',
options: [
{ label: 'Low', value: 'low' },
{ label: 'High', value: 'high' }
]
}
}
GraphQL Integration
Settings are exposed via GraphQL for frontend access:
Query
query GetAppSettings($appId: UUID!) {
app(id: $appId) {
settings {
definitions {
key
dataType
required
sensitive
description
validation
uiHints
}
values {
key
value
isMasked
updatedAt
updatedBy
}
}
}
}
Mutation
mutation UpdateSettings($appId: UUID!, $settings: [SettingInput!]!) {
updateSettings(appId: $appId, settings: $settings) {
success
changedKeys
errors {
key
message
code
}
}
}
Best Practices
For App Developers
Mark Sensitive Settings:
.define('api_key', {
dataType: 'STRING_REQUIRED',
sensitive: true // Always mark secrets as sensitive
})
Provide Good Defaults:
.define('timeout_seconds', {
dataType: 'INT',
defaultValue: 30, // Sensible default
validation: { min: 1, max: 300 }
})
Add Descriptions and Help Text:
.define('webhook_url', {
dataType: 'STRING',
description: 'URL for webhook notifications',
uiHints: {
placeholder: 'https://example.com/webhook',
helpText: 'Must be a valid HTTPS URL'
}
})
Validate Thoroughly:
.define('email', {
dataType: 'STRING_REQUIRED',
validation: {
pattern: '^[^@]+@[^@]+\\.[^@]+$', // Email regex
maxLength: 255
}
})
For Platform Operators
Secure Encryption Key:
- Store in secure vault (HashiCorp Vault, AWS Secrets Manager)
- Rotate periodically
- Never commit to source control
Monitor Cache Hit Rate:
Settings cache hit rate: 95%+ (target)
Set Appropriate TTL:
Development: 1 minute
Production: 10-15 minutes
Review Audit Logs:
- Monitor sensitive setting access
- Alert on unauthorized access attempts
- Retain logs for compliance
Error Scenarios
Validation Failure
Problem: Setting value fails validation
Behavior:
- UpdateSettings returns validation error
- No values are updated
- Structured errors returned to caller
Solution:
- Review validation rules
- Correct value format
- Check data type match
Missing Required Settings
Problem: Required setting has no value
Behavior:
- ValidateRequiredSettings returns error
- App cannot complete installation
- Missing keys listed in error
Solution:
- Provide default value in manifest
- Prompt user during installation
- Use optional fields where appropriate
Encryption Key Mismatch
Problem: Encryption key changed without re-encryption
Behavior:
- Decryption fails on GetValue
- ErrDecryptionFailed returned
- Sensitive values inaccessible
Solution:
- Keep encryption key consistent
- Implement key rotation procedure
- Decrypt and re-encrypt all values if key changes
Related Concepts
- Application Manifest - Declaring settings schema
- Permission Model - Settings access control
- GraphQL API & Subscriptions - GraphQL settings API
- Event-Driven Architecture - Settings events
- Caching Strategy - Multi-level caching details
- Developer Platform & SDK - SDK settings helpers
Further Reading
- Getting Started: Building Apps - Defining settings in your app
- Features: Settings Service - Detailed usage examples