jsonfile
The JSON file backend allows Radiator to authenticate users against a JSON formatted text file.
The JSON file can be queried using the jsonpath filter to extract
user credentials and attributes.
The jsonfile JSON parser supports the JSON5 superset of JSON, allowing comments, trailing commas, and other usability improvements.
Here's an example configuration of a JSON file backend with comments explaining each statement:
jsonfile "JSON_FILE" {
# Path to the JSON file containing user data.
filename "users.json5";
# Enable file monitoring for changes. Defaults to true.
monitor true;
# Alternatively it is possible to define the JSON content directly in the configuration.
# Not allowed if filename is used.
content """
{
"users": {
// Comments are allowed in JSON
"alice": {
"username": "alice",
"password": "{argon2}$argon2id$v=19$m=19456,t=2,p=1$56MJ6kkHsbicXkvq6+r5dA$zY5kHLjEfJET8VT7hFV+uHcxgTE8w66Z4dYwwbZtdxw",
"groups": ["admin", "user"]
},
}, // dangling comma is allowed
}
""";
# At least one query block must be defined
query "FIND_USER" {
# Mapping provides access to a single variable, `doc` which is the parsed JSON document.
mapping {
user.username = doc | jsonpath("$.users['%{aaa.identity}'].username");
user.password = doc | jsonpath("$.users['%{aaa.identity}'].password");
user.group = doc | jsonpath("$.users['%{aaa.identity}'].groups[*]");
# It is also possible to extract the user data object and filter it further in the policy.
vars.full_userdata = doc | jsonpath("$.users['%{aaa.identity}']");
}
}
# And inside your backend action you need to give the name and query
# to use. For example:
authentication {
backend {
name "JSON_FILE";
query "FIND_USER";
}
}
}
Passwords in the JSON file can use secure hashing algorithms like Argon2 (shown in the example above with {argon2} prefix). See the Password Hashing article for details on supported algorithms and how to generate password hashes.
Mapping with JSONPath Selector
The mapping block accepts an optional JSONPath expression argument. When provided, the mapping is executed against each matching value in turn, with doc bound to the full parsed JSON document and item bound to the current value.
query "QUERY_NAME" {
mapping "<jsonpath_expression>" {
# 'doc' refers to the full document
# 'item' refers to the current value matched by the expression
target = item | jsonpath("$.field");
}
}
Use case: Simplifying user lookups
Consider a JSON file with a users array where each entry has a username
field:
{
"users": [
{
"username": "alice",
"password": "alice_password",
"groups": ["admin", "users"]
},
{
"username": "bob",
"password": "bob_password",
"groups": ["guest", "users"]
}
]
}
Without the mapping argument, the JSONPath filter expression must be repeated in every field mapping:
query "FIND_USER" {
mapping {
user.username = doc | jsonpath("$.users[?(@.username == '%{aaa.identity}')].username");
user.password = doc | jsonpath("$.users[?(@.username == '%{aaa.identity}')].password");
user.group = doc | jsonpath("$.users[?(@.username == '%{aaa.identity}')].groups[*]");
}
}
With the mapping argument, the filter is applied once and the inner mappings become simpler:
query "FIND_USER" {
mapping "$.users[?(@.username == '%{aaa.identity}')]" {
user.username = item | jsonpath("$.username") | uppercase;
user.group = item | jsonpath("$.groups[*]");
user.password = item | jsonpath("$.password");
}
}
Use doc in the same mapping when you need to reference the full document while
also iterating over the selected value.
Use case: Populating ipmap from a JSON file
The ipmap backend requires the
mapping block to execute repeatedly - once per map entry - because each
execution commits one network prefix to the map. Without the mapping argument, a
jsonfile query produces only a single mapping execution over the whole document,
making it impossible to populate multiple ipmap entries.
The mapping argument solves this by iterating over an array of objects. Each matched element triggers a separate mapping execution, which is exactly what ipmap population requires:
jsonfile "CLIENTS" {
filename "clients.json5";
query "POPULATE_IPMAP" {
mapping "$.clients[*]" {
ipmap.network = item | jsonpath("$.ip");
ipmap.value = item | jsonpath("$.name");
}
}
}
ipmap "IPMAP" {
@populate {
backend {
name "CLIENTS";
query "POPULATE_IPMAP";
}
}
query "CHECK_NAS" {
lookup radius.request.attr.NAS-IP-Address;
mapping {
vars.network_name = value;
}
}
}
With a JSON file such as:
{
"clients": [
{ "ip": "192.0.2.0/24", "name": "internal-network" },
{ "ip": "198.51.100.0/24", "name": "guest-network" }
]
}
Each element in the clients array produces one ipmap entry. The ipmap backend
cannot be populated from a jsonfile without using the mapping argument because
ipmap requires repeating mapping executions to commit multiple entries.
Rejecting
The backend rejection behavior depends on whether a mapping argument is used:
- Without a mapping argument: The backend rejects when all mapping
expressions return
none. If at least one expression returns a value, the backend accepts. - With a mapping argument: The backend rejects when the JSONPath selector expression matches zero elements in the document. If the selector matches at least one element, the inner mappings execute for each matched element and the backend accepts regardless of whether the inner expressions produce values.
When the backend rejects, Radiator sets aaa.reason to a descriptive message
that includes the backend name and query name.
Automatic monitoring of changes
When enabling monitor true Radiator checks for changes in the file and reloads it. This happens usually within one second of last change. If the file changes to invalid JSON, the backend will keep serving the last valid content (until a restart).
The JSON file is read into memory resulting in high performance lookups.
On a typical computer read times of the json file is over 100k lines per second. Processing of requests does not stop while a file is being read in, but one core is consumed for reading the file.
Radiator monitors the whole directory where the file is located to detect addition of a file to be able detect when the file is created and not just modified. Writing logs or editing files in the directory being watched will have a performance impact. It is recommended to separate these activities to different directories and to use mv or move to move the final file to production directory.
Do not modify/replace the file while it is being read. Prefer batching (aggregated) updates at regular intervals over frequent small changes for more consistent and reliable performance.