Settings & Field-Level Encryption
The Setting entity is a builtin key-value store for application configuration. Settings can store simple values (strings, numbers, booleans) or complex objects — and individual fields within those objects can be encrypted at rest.
Basic Usage
import { SettingService } from '@generated/datalayer/services';
import { systemAuth } from '@core/auth/service-auth';
const svc = new SettingService(systemAuth());
// Simple key-value
await svc.set('site.name', 'My App');
await svc.set('items.perPage', 25, { dataType: 'number' });
const name = await svc.get('site.name'); // "My App"
const perPage = await svc.get('items.perPage'); // 25
Complex Objects with Encrypted Fields
Settings can store objects where specific fields are encrypted using AES-256-CBC — the same algorithm used by encryptedKey entity fields.
Declaring a Field Schema
The fieldSchema property declares types for each field in the value object. Fields marked as encryptedKey are encrypted at rest:
await svc.set('dynamics365-fo', {
serverUrl: 'https://mycompany.operations.dynamics.com',
clientId: 'abc-123-def',
clientSecret: 'super-secret-value',
tenantId: 'tenant-xyz'
}, {
dataType: 'object',
category: 'integrations',
fieldSchema: {
serverUrl: 'string',
clientId: 'string',
clientSecret: 'encryptedKey',
tenantId: 'string'
}
});
What Gets Stored in MongoDB
{
"key": "dynamics365-fo",
"dataType": "object",
"category": "integrations",
"value": "{\"serverUrl\":\"https://mycompany.operations.dynamics.com\",\"clientId\":\"abc-123-def\",\"clientSecret\":\"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6:e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2\",\"tenantId\":\"tenant-xyz\"}",
"fieldSchema": {
"serverUrl": "string",
"clientId": "string",
"clientSecret": "encryptedKey",
"tenantId": "string"
}
}
Only clientSecret is encrypted. serverUrl, clientId, and tenantId stay readable — visible for debugging without the encryption key.
Reading Encrypted Settings
Server-Side (Automatic Decryption)
get() and getAll() automatically decrypt encryptedKey fields:
const config = await svc.get('dynamics365-fo');
// → { serverUrl: "https://...", clientId: "abc", clientSecret: "super-secret-value", tenantId: "xyz" }
const secret = config.clientSecret; // plaintext — use for API calls
getDecrypted() is an alias for get() that makes the intent explicit:
const config = await svc.getDecrypted('dynamics365-fo');
API Response (Masked)
list() and getById() mask encrypted fields in API responses:
curl "http://localhost:5173/api/v1/settings?category=integrations" \
-H "Authorization: Bearer $TOKEN"
{
"data": {
"items": [{
"key": "dynamics365-fo",
"value": "{\"serverUrl\":\"https://...\",\"clientId\":\"abc\",\"clientSecret\":\"[ENCRYPTED]\",\"tenantId\":\"xyz\"}",
"fieldSchema": { "serverUrl": "string", "clientSecret": "encryptedKey", "..." }
}]
}
}
The raw ciphertext never appears in API responses. Clients see [ENCRYPTED] for encrypted fields and real values for everything else.
Encryption Key
Set RADISH_ENCRYPTION_KEY in .env:
# Generate a secure key:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Set in .env:
RADISH_ENCRYPTION_KEY=your_64_char_hex_key_here
The same key is used for encryptedKey entity fields and Setting field encryption. If the key is missing and an encrypted field is accessed, the service throws a clear error with generation instructions.
API Reference
set(key, value, options?)
Create or update a setting.
| Option | Type | Description |
|---|---|---|
userId | string | Set for user-scoped settings (omit for system) |
tenantId | string | Set for tenant-scoped settings (scope becomes 'tenant') |
category | string | Grouping category (default: "general") |
dataType | string | Value type: string, number, boolean, object, array |
fieldSchema | object | Per-field type declarations (enables encryption) |
description | string | Human-readable description |
When tenantId is provided (without userId), the setting is stored with scope: 'tenant'.
Writes are authorised per scope, not uniformly:
| scope | required |
|---|---|
system | system:admin — global configuration, shared by every tenant |
tenant | ADMIN within the tenant |
user | signed in |
A tenant admin can configure their own organisation without holding a global permission —
requiring system:admin for both is what previously forced that, and let a tenant admin
edit global config.
The tenant is also forced to the request's active tenant. Passing an explicit
tenantId for a different tenant requires system:admin; otherwise a tenant admin could
write settings into someone else's tenant.
get(key, userId?, tenantId?)
Get a setting value. Decrypts encryptedKey fields automatically.
When tenantId is provided, uses cascade resolution: user -> tenant -> system (most specific scope wins). Without tenantId, performs a direct scope lookup — backwards-compatible with existing calls.
// Direct lookup (original behavior)
const name = await svc.get('site.name'); // system scope
const theme = await svc.get('theme', userId); // user scope
// Cascade lookup (tenant-aware)
const limit = await svc.get('api.rateLimit', undefined, 'tenant-abc');
// Checks: tenant-abc -> system. Returns tenant override if it exists.
const limit = await svc.get('api.rateLimit', userId, 'tenant-abc');
// Checks: user -> tenant-abc -> system. Most specific wins.
getDecrypted(key, userId?, tenantId?)
Alias for get(). Use when the intent is specifically credential retrieval. Supports the same tenant cascade.
const config = await svc.getDecrypted('smtp', undefined, 'tenant-abc');
// Cascade: tenant -> system, with encrypted fields decrypted
getAll(userId?, tenantId?)
Get all settings as a key-value map. Decrypts encrypted fields.
When tenantId is provided, merges settings with cascade resolution: system settings first, overlaid by tenant settings, overlaid by user settings. More specific scopes override less specific for the same key.
Without tenantId, returns settings for the requested scope only — backwards-compatible.
// All system settings (admin only)
const all = await svc.getAll();
// All user settings
const prefs = await svc.getAll(userId);
// Merged: system + tenant + user (most specific wins per key)
const merged = await svc.getAll(userId, 'tenant-abc');
removeByKey(key, userId?, tenantId?)
Delete a setting by key. When tenantId is provided (without userId), targets the tenant-scoped setting. Tenant and system settings require admin permission.
list(criteria?)
List settings for API responses. Masks encrypted fields as [ENCRYPTED].
getById(id)
Get a setting by document ID. Masks encrypted fields.
Scoping
Settings support three scopes with a cascade:
| Scope | Access | Use Case |
|---|---|---|
system | Admin only | App-wide configuration |
tenant | Tenant admins | Tenant-specific overrides |
user | Per-user | User preferences |
The scope cascade resolves user → tenant → system — the most specific scope wins. If no user-scoped setting exists, the service falls back to the tenant setting, then to the system setting.
// System setting (requires ADMIN role)
await svc.set('api.rateLimit', 100, { dataType: 'number' });
// Tenant setting (scoped to a specific tenant)
await svc.set('api.rateLimit', 200, { dataType: 'number', tenantId: 'tenant-abc' });
// User setting (scoped to the authenticated user)
await svc.set('theme', 'dark', { userId: currentUserId });
const theme = await svc.get('theme', currentUserId); // "dark"
Tenant Scoping
The tenantId field partitions settings by tenant. It matches the User.tenantId type (string) used by tenant-scoped entities elsewhere in Radish.
The cascade only activates when tenantId is passed to get(), getDecrypted(), or getAll(). Existing calls without tenantId behave identically to before tenant scoping was implemented — no migration needed.
// Set a tenant-specific API key
await svc.set('stripe-config', { publishableKey: 'pk_live_tenant1' }, {
dataType: 'object',
tenantId: 'tenant-abc',
fieldSchema: { publishableKey: 'string' }
});
// Read with cascade — checks tenant-abc first, falls back to system
const config = await svc.get('stripe-config', undefined, 'tenant-abc');
// Read without cascade — direct system lookup (backwards-compatible)
const systemConfig = await svc.get('stripe-config');
The unique index is [key, scope, tenantId, userId] (sparse) — the same key can exist once per scope/tenant/user combination.
Reading Settings from Internal Code
getSystemSetting(key)
For pre-auth contexts (login flows, background jobs, webhooks, seed scripts) where no user session exists, use getSystemSetting():
import { getSystemSetting } from '@generated/datalayer/services';
// Decrypts encryptedKey fields automatically
const ldapConfig = await getSystemSetting('ldap');
// → { url: "ldap://...", bindDN: "cn=...", bindPassword: "plaintext" }
This routes through SettingService(systemAuth()).getDecrypted() — it handles both encrypted and plaintext settings transparently. Apps that haven't upgraded to encrypting their settings work identically.
getTenantSetting(key, tenantId, userId?)
For background code that needs tenant-scoped settings without a user session (tenant-specific cron jobs, webhook handlers scoped to a tenant):
import { getTenantSetting } from '@generated/datalayer/services';
// Tenant cascade: tenant → system
const smtpConfig = await getTenantSetting('smtp-config', 'tenant-abc');
// Full cascade: user → tenant → system
const smtpConfig = await getTenantSetting('smtp-config', 'tenant-abc', 'user-123');
Like getSystemSetting, this routes through systemAuth() and decrypts encryptedKey fields automatically.
Never read Settings via
SettingRepodirectly — the repo returns raw ciphertext for encrypted fields. The ciphertext looks like a plausible credential string but isn't one, causing silent failures (e.g., LDAP binds with the ciphertext as the password -> "invalid credentials" with no hint it's a config problem).
Migration from encrypted Boolean
The encrypted boolean field on settings is deprecated. It was a metadata flag that did not perform actual encryption.
Before (broken):
await svc.set('apiSecret', 'my-secret', { encrypted: true });
// ❌ Value stored as plaintext despite the flag
After (working):
await svc.set('apiSecret', { value: 'my-secret' }, {
dataType: 'object',
fieldSchema: { value: 'encryptedKey' }
});
// ✅ Value actually encrypted with AES-256-CBC
For simple single-value secrets, wrap in an object with one encryptedKey field.
Gotcha: Mongoose Reserved Names
The field is named fieldSchema, not schema. Mongoose reserves schema as a built-in document property — using it as a field name causes doc.schema to return the Mongoose Schema object instead of the stored value.
Other Mongoose reserved names to avoid on any entity: collection, db, errors, isNew, modelName.
Related Documentation
- Field Types — encryptedKey — Entity-level encryption
- Services — SettingService in the service layer
- Access Control — Permission model for settings