Skip to main content

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.

OptionTypeDescription
userIdstringSet for user-scoped settings (omit for system)
tenantIdstringSet for tenant-scoped settings (scope becomes 'tenant')
categorystringGrouping category (default: "general")
dataTypestringValue type: string, number, boolean, object, array
fieldSchemaobjectPer-field type declarations (enables encryption)
descriptionstringHuman-readable description

When tenantId is provided (without userId), the setting is stored with scope: 'tenant'.

Writes are authorised per scope, not uniformly:

scoperequired
systemsystem:admin — global configuration, shared by every tenant
tenantADMIN within the tenant
usersigned 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:

ScopeAccessUse Case
systemAdmin onlyApp-wide configuration
tenantTenant adminsTenant-specific overrides
userPer-userUser 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 SettingRepo directly — 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.