Configuration System
HR Studio 2 uses a four-level hierarchical configuration system. Every configuration variable is defined once in a central catalog and overridden selectively at each level.
For the full architecture reference, see config-system-architecture.
How it works — the four levels
Global → Tenant → Group → User
| Level | Set by | Scope |
|---|---|---|
| Global (bit 1) | SuperAdmin | All tenants |
| Tenant (bit 2) | TenantAdmin | All users of one tenant |
| Group (bit 4) | TenantAdmin | Employees in an HR group |
| User (bit 8) | User / TenantAdmin | One individual |
A key's cck_allowed_levels bitmask controls which levels may override it. For example, cck_allowed_levels = 3 (binary 0011) means only Global and Tenant may override it.
Adding a new config key
Adding a new config variable requires changes in four places:
1. Add a row to cloud.config_catalog
Create a new EF Core migration or insert the row in an existing data seed:
INSERT INTO cloud.config_catalog
(cck_code, cck_category, cck_label, cck_value_type, cck_default_value, cck_allowed_levels)
VALUES
('features.module.training', 'features', 'Training module enabled', 'bool', 'false', 3);
-- allowed_levels = 3 → Global (1) + Tenant (2)
Or via the API (SuperAdmin only):
POST /api/config/catalog/create
{
"code": "features.module.training",
"category": "features",
"label": "Training module enabled",
"valueType": "bool",
"defaultValue": "false",
"allowedLevels": 3
}
Key design questions to answer before adding a new key:
| Question | Why it matters |
|---|---|
| What is the right default value? | Used when no level overrides the key |
| Which levels should be able to override it? | Set the bitmask accordingly |
| Is this a security constraint? | If yes, add a global-only or constrained write rule in ConfigController.TenantSet |
| Does it need to stay server-side? | Set isSensitive = true — it will never be sent to the frontend |
| What type is the value? | bool, int, string, or json — determines conflict resolution and coercion |
| Is this about feature/module availability? | Do not use the config catalog. Feature availability is controlled by ten_license_config on cloud.tenants. Use useLicenseModule() on the frontend. |
2. Use it in the backend (if sensitive or server-side logic)
// Inject ConfigMergeService
var configMap = await configMerge.ResolveForUserAsync(userId, tenantId, ct);
var isEnabled = configMap.TryGetValue("features.module.training", out var v) && v == "true";
3. Use it in the frontend
After login, the full config map is in the Redux store. Use the hooks:
// Boolean feature flag
const hasTraining = useFeature("features.module.training");
// Typed value with fallback
const minLen = useConfig<number>("auth.password.min_length", 8);
// JSON value
const visibility = useConfig<Record<string, string[]>>("permissions.section_visibility", {});
4. Document it
Add the key to the catalog table in docs/config-system-design.md (repo root) and update the relevant API or architecture docs.
Frontend hooks
Status: pending reimplementation.
The config hooks listed below were designed for the original frontend and are pending reimplementation in the current Zono-based frontend. The config map is already delivered at login and stored in the Redux store (state.auth.config); direct store reads work in the meantime.
Planned hooks — to be implemented in frontend/src/hooks/:
// Read a typed value — T is inferred from the fallback
useConfig<T>(key: string, fallback: T): T
// Boolean shorthand for features.*
useFeature(key: string): boolean
// Full config map for admin UI
useAppConfig(): Record<string, string>
// License config
useLicense(): LicenseConfig | null
// Returns true if a specific module is licensed
useLicenseModule(module: string): boolean
// Re-fetch config without re-login (call after TenantAdmin changes settings)
useConfigRefresh(): () => Promise<void>
Until the hooks are reimplemented, read config values directly from the store:
const config = useAppSelector((s) => s.auth.config);
const timeoutMinutes = parseInt(config["ui.lock.timeout_minutes"] ?? "15", 10);
Example: feature-gating a route (planned)
import { useFeature } from "../hooks/useConfig";
import { Navigate } from "react-router-dom";
const PayrollPage = () => {
const hasPayroll = useFeature("features.module.payroll");
if (!hasPayroll) return <Navigate to="/dashboard" />;
return <PayrollDashboard />;
};
Example: reading a JSON config value (planned)
import { useConfig } from "../hooks/useConfig";
const SalarySection = ({ employeeId }: { employeeId: string }) => {
const visibility = useConfig<Record<string, string[]>>(
"permissions.section_visibility",
{}
);
const allowedRoles = visibility["employee.salary"] ?? [];
// ...
};
Group-level overrides
Group overrides apply to employees based on their HR attributes. The group types are:
group_type | Source | Example codes |
|---|---|---|
mansione | Job function | DEV, MGR |
qualifica | Qualification | JR, SR, LEAD |
sede_op | Operational site | MI, RM, REMOTE |
tipo_tempo | Working time type | FT, PT_50 |
gruppo_tipo | Custom grouping | tenant-defined |
static_list | Named employee list | tenant-defined |
dynamic_list | Filter-based list | tenant-defined |
Group memberships are resolved fresh at every login by IUserGroupMembershipProvider. When the HR module is active, this queries the HR schema. Until then, the default implementation returns no memberships.
Conflict resolution
When a user belongs to multiple groups that set different values for the same key, system.group_conflict_strategy decides:
| Strategy | bool | int | string | json |
|---|---|---|---|---|
restrictive (default) | AND | min | first alphabetically | intersection |
permissive | OR | max | last alphabetically | union |
Sensitive keys
If cck_is_sensitive = true, the key is computed and used server-side only. It is stripped from the config map before the response is sent to the client.
Use this for keys that should never be exposed to the browser — for example, internal API keys, IP allow-lists, or secret feature flags.
In-session config refresh
If a TenantAdmin changes a configuration value and you want the current user's config to update without forcing a re-login:
const refreshConfig = useConfigRefresh();
await refreshConfig(); // calls POST /api/config/user/resolve and updates Redux