Skip to main content
Version: 3.0

Auth Policy I18N Guide

This guide shows the complete localization path for policy-selected authentication response messages.

Use this when a custom auth.policy rule should select a stable translation key, keep deterministic fallback text, and let HTTP, gRPC, or IdP response rendering choose the final language.

Mental Model​

Policy evaluation stays locale-neutral:

  1. Lua, built-in checks, backends, request headers, or gRPC metadata emit policy facts.
  2. auth.policy.policies selects the decision, response marker, optional response_language, and response_message.
  3. The response boundary resolves i18n_key into rendered text.
  4. Missing translations return the configured fallback text.

Language preference never changes the selected policy decision.

Minimal Localized Response​

auth:
policy:
attribute_exports:
- name: account_status
attribute: accountStatus
type: string
sensitivity: internal

policies:
- name: deny_company_locked_account
stage: auth_decision
operations: [authenticate]
if:
attribute: auth.subject.attribute.account_status
detail: value
eq: locked
then:
decision: deny
reason: company_account_locked
response_marker: auth.response.fail
response_message:
from: i18n
i18n_key: auth.policy.company.account_locked
fallback: "Login failed because the account is locked."

Rules:

  • from: i18n requires i18n_key and fallback text.
  • i18n_key is stable machine-readable policy metadata.
  • fallback is the response text when no translation exists.
  • Do not use text, attribute, or detail together with from: i18n.
  • Existing from: default, from: literal, and from: attribute_detail behavior is unchanged.

Literal Response Language​

Use response_language.from: literal when the rule itself knows the response language:

then:
decision: deny
reason: company_account_locked
response_marker: auth.response.fail
response_language:
from: literal
language: de
response_message:
from: i18n
i18n_key: auth.policy.company.account_locked
fallback: "Login failed because the account is locked."

The value must be a BCP 47 language tag. Policy-selected language takes precedence over HTTP Accept-Language and gRPC accept-language, but IdP URL or cookie language selection remains more explicit than policy language.

Header-Selected Language​

Use auth.policy.request_headers to expose only a trusted, bounded header:

auth:
policy:
attribute_exports:
- name: account_status
attribute: accountStatus
type: string
sensitivity: internal

request_headers:
- header: X-Company-Language
attribute: request.header.company_language
visibility: public
normalize:
trim: true
case: lower
max_length: 16

policies:
- name: deny_locked_account_with_header_language
stage: auth_decision
operations: [authenticate]
if:
attribute: auth.subject.attribute.account_status
detail: value
eq: locked
then:
decision: deny
reason: company_account_locked
response_marker: auth.response.fail
response_language:
from: attribute
attribute: request.header.company_language
fallback: en
response_message:
from: i18n
i18n_key: auth.policy.company.account_locked
fallback: "Login failed because the account is locked."

HTTP response behavior:

  • Incoming Accept-Language is used when no policy language exists.
  • Policy-selected response_language overrides Accept-Language.
  • The existing status-message field or status header contains rendered text.
  • Content-Language is emitted when localization selected a language.

gRPC Metadata-Selected Language​

Use auth.policy.request_metadata for gRPC metadata facts:

auth:
policy:
attribute_exports:
- name: account_status
attribute: accountStatus
type: string
sensitivity: internal

request_metadata:
- key: x-company-language
attribute: request.metadata.company_language
visibility: public
normalize:
trim: true
case: lower
max_length: 16

policies:
- name: deny_locked_account_with_metadata_language
stage: auth_decision
operations: [authenticate]
if:
attribute: auth.subject.attribute.account_status
detail: value
eq: locked
then:
decision: deny
reason: company_account_locked
response_marker: auth.response.fail
response_language:
from: attribute
attribute: request.metadata.company_language
fallback: en
response_message:
from: i18n
i18n_key: auth.policy.company.account_locked
fallback: "Login failed because the account is locked."

gRPC response behavior:

  • Incoming metadata accept-language is used when no policy language exists.
  • Policy-selected response_language overrides incoming accept-language.
  • The existing status_message protobuf field contains rendered text.
  • Response metadata includes content-language when localization selected a language.
  • The protobuf response does not expose i18n_key.

Lua Migration Pattern​

Keep Lua-owned text as fallback, and emit stable facts for policy:

local policy = require("nauthilus_policy")

if account_status == "locked" then
nauthilus_builtin.status_message_set("Login failed because the account is locked.")
policy.emit_attribute({
id = "lua.company.account_status",
value = "locked",
})
end

Register the attribute during policy snapshot build:

/etc/nauthilus/policy/attributes.lua
nauthilus_policy.register_attribute({
id = "lua.company.account_status",
stage = "subject_analysis",
operations = { "authenticate" },
category = "subject",
type = "string",
description = "Company account status selected by Lua",
})

Then map the stable fact to an i18n key in YAML:

auth:
policy:
registry_scripts:
- /etc/nauthilus/policy/attributes.lua

policies:
- name: deny_lua_company_locked_account
stage: auth_decision
operations: [authenticate]
require_checks: [lua_subject_company_account]
if:
attribute: lua.company.account_status
eq: locked
then:
decision: deny
reason: company_account_locked
response_marker: auth.response.fail
response_message:
from: i18n
i18n_key: auth.policy.company.account_locked
fallback: "Login failed because the account is locked."

Do not make free-text Lua status messages the source of i18n keys. Lua emits facts; policy chooses keys.

Lua-Selected Response Language​

Lua can emit a language candidate as another registered policy attribute:

local policy = require("nauthilus_policy")

if preferred_language ~= "" then
policy.emit_attribute({
id = "lua.company.preferred_language",
value = preferred_language,
})
end

Registry entry:

nauthilus_policy.register_attribute({
id = "lua.company.preferred_language",
stage = "subject_analysis",
operations = { "authenticate" },
category = "subject",
type = "string",
description = "Preferred response language selected by Lua",
})

Policy rule:

then:
decision: deny
response_marker: auth.response.fail
response_language:
from: attribute
attribute: lua.company.preferred_language
fallback: en
response_message:
from: i18n
i18n_key: auth.policy.company.account_locked
fallback: "Login failed because the account is locked."

Invalid runtime language tags are ignored and fall through to the next language preference source.

Deployment Catalog Overlay​

Deployment-owned translations belong outside Nauthilus system resource files. Register them during startup Lua:

nauthilus.yml
auth:
backends:
lua:
backend:
default:
init_script_paths:
- /etc/nauthilus/policy/i18n.lua
/etc/nauthilus/policy/i18n.lua
local i18n = require("nauthilus_i18n")

i18n.register_catalog({
language = "en",
namespace = "company",
entries = {
["auth.policy.company.account_locked"] = "Login failed because the account is locked.",
["auth.policy.company.account_unpaid"] = "Login failed because open payments exist and the account is locked.",
},
})

Equivalent deployment-owned JSON material can look like this:

policy-en.json
{
"auth.policy.company.account_locked": "Login failed because the account is locked.",
"auth.policy.company.account_unpaid": "Login failed because open payments exist and the account is locked."
}

Catalog semantics:

  • System catalog entries are loaded first.
  • Deployment overlays are merged after the system catalog in deterministic order.
  • Deployment overlays may override system keys.
  • Overrides are logged with language, key, namespace, and previous namespace.
  • The effective catalog is frozen before request-time processing.
  • Reload builds the next effective catalog first and activates it atomically only after success.
  • Failed reload keeps the previous effective catalog active.

Request-Time Lua Localization​

Use nauthilus_i18n.get_localized({ ... }) only for Lua-owned logs, notices, or fallback strings:

local i18n = require("nauthilus_i18n")

local localized = i18n.get_localized({
i18n_key = "auth.policy.company.account_locked",
fallback = "Login failed because the account is locked.",
language = "en",
})

nauthilus_builtin.custom_log_add("company_message", localized.message)
nauthilus_builtin.custom_log_add("company_message_localized", localized.localized)

get_localized accepts exactly one table and returns exactly one table with message, language, localized, i18n_key, and fallback_used. It is read-only and cannot mutate catalogs.

Fallback Behavior​

Fallback text is used when:

  • response_message has no i18n_key;
  • the resolver is unavailable for a response surface;
  • the requested key is missing in the selected catalog;
  • the selected language is unsupported and no later preference can resolve it;
  • a request-time Lua get_localized call cannot resolve the key.

Fallback does not change the selected decision, response marker, FSM marker, or obligations. It only determines the rendered message text.

Mocking Checklist​

Use mocks and fakes for focused tests:

TopicTest seam
Policy configTable-driven compiler tests for valid and invalid response_message, response_language, request_headers, and request_metadata.
ResolverFake catalog or fake resolver for selected language, missing key, fallback, and max-length behavior.
HTTPhttptest requests with explicit Accept-Language, allowlisted headers, and a fake resolver.
gRPCHandler or in-memory gRPC tests with incoming metadata and a fake auth outcome.
IdPMock auth outcomes carrying fallback text, optional i18n_key, and optional policy language.
Lua policy--test-lua fixtures or hermetic Lua states that assert nauthilus_policy.emit_attribute(...).
Lua i18nFake resolver and fake startup catalog collector for get_localized and register_catalog.
ReloadFake catalog sessions that prove atomic activation and failed-reload rollback.

Do not add documentation-only example keys to server/resources/*.json. Tests can use fake catalogs or deployment-style fixtures for auth.policy.company.* keys.