Skip to main content

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

Further Reading