Auth Policy Configuration Guide
This guide explains how to write Nauthilus auth policies by hand. It starts with the mental model, then shows how to define operation scope, auth-state guards, check start order, and final decisions with explicit policy checks and rules.
Use this guide when you want to make your authentication flow explicit in auth.policy.
For the complete field reference, see Auth Policy Reference.
The Short Version
A policy-controlled Lua setup has two parts. First, define the script entries:
auth:
policy:
attribute_sources:
lua:
environment:
- name: geoip
script_path: /etc/nauthilus/lua/environment/geoip.lua
- name: policy_gate
script_path: /etc/nauthilus/lua/environment/policy_gate.lua
Then define when and in which order the scripts run:
auth:
policy:
checks:
- name: lua_environment_geoip
type: lua.environment
stage: pre_auth
operations: [authenticate, lookup_identity]
config_ref: auth.policy.attribute_sources.lua.environment.geoip
output: checks.lua_environment_geoip
- name: lua_environment_policy_gate
type: lua.environment
stage: pre_auth
operations: [authenticate, lookup_identity]
after: [lua_environment_geoip]
config_ref: auth.policy.attribute_sources.lua.environment.policy_gate
output: checks.lua_environment_policy_gate
Decisions live in auth.policy.policies:
auth:
policy:
policies:
- name: deny_policy_gate
stage: pre_auth
operations: [authenticate, lookup_identity]
require_checks: [lua_environment_policy_gate]
if:
attribute: auth.lua.environment.policy_gate.triggered
is: true
then:
decision: deny
reason: policy_gate_triggered
response_marker: auth.response.fail
response_message:
from: attribute_detail
attribute: auth.lua.environment.policy_gate.triggered
detail: status_message
fallback: "Invalid login or password"
Mental Model
Think in three layers.
Mechanism configuration defines what exists:
auth.policy.attribute_sources.lua.environment
auth.policy.attribute_sources.lua.subject
auth.controls.brute_force
auth.controls.rbl
auth.backends.ldap
auth.backends.lua.backend
Policy checks define which facts are produced for which operation and stage:
auth.policy.checks
Policy rules define how those facts become permit, deny, tempfail, or neutral:
auth.policy.policies
Lua can still contain complex logic, Redis lookups, HTTP calls, LDAP lookups through existing Lua APIs, or deployment-specific checks. The YAML policy should not do that work. Lua emits typed facts, and YAML decides what those facts mean.
The model is inspired by OASIS XACML 3.0: Nauthilus keeps fact collection, policy decision, and enforcement separate. It is not XACML-compatible syntax; it uses Nauthilus YAML, fixed auth stages, response markers, FSM markers, obligations, and advice.
Understand FSM Markers
FSM means finite-state machine. For an administrator, it is the request-state path that Nauthilus records from parse through pre-auth, backend or account-provider evaluation, and the final decision.
You usually do not need to write FSM markers by hand. Write the policy decision and the response_marker; Nauthilus derives the normal fsm_event_marker from the stage and decision.
| Rule context | Decision | Derived FSM marker | Practical meaning |
|---|---|---|---|
pre_auth | neutral | auth.fsm.event.pre_auth_ok | Continue to the next auth phase; this is not a login success. |
pre_auth | deny | auth.fsm.event.pre_auth_deny | Block before backend auth. |
pre_auth | tempfail | auth.fsm.event.pre_auth_tempfail | Temporary failure before backend auth. |
pre_auth | permit | not allowed | Pre-auth cannot grant final success. |
auth_decision | permit | auth.fsm.event.auth_permit | Final success for the active operation. |
auth_decision | deny | auth.fsm.event.auth_deny | Final denial for the active operation. |
auth_decision | tempfail | auth.fsm.event.auth_tempfail | Final temporary failure for the active operation. |
auth_decision | neutral | none | Evaluation continues; without a later permit, final enforcement denies. |
The important distinction is:
decisionis the policy result.response_markeris the client-visible response class.fsm_event_markeris the internal request-state event used for enforcement, reports, logs, metrics, and traces.
Normal rule:
then:
decision: deny
reason: policy_gate_triggered
response_marker: auth.response.fail
If this rule is in pre_auth, Nauthilus derives auth.fsm.event.pre_auth_deny. If the same decision is in auth_decision, it derives auth.fsm.event.auth_deny.
Final FSM markers are operation-specific. auth.fsm.event.auth_permit means password-auth success for authenticate, identity-found success for lookup_identity, and account-list success for list_accounts.
Set fsm_event_marker explicitly only when you need a more specific state path than the default decision mapping, for example empty credentials or an intentional pre-auth abort:
auth:
policy:
policies:
- name: deny_empty_password
stage: auth_decision
operations: [authenticate]
if:
attribute: auth.backend.empty_password
is: true
then:
decision: deny
reason: empty_password
fsm_event_marker: auth.fsm.event.auth_empty_pass
response_marker: auth.response.fail
Do not use terminal state names such as auth_ok, auth_fail, or auth_tempfail in policy YAML. Policies reference FSM event markers such as auth.fsm.event.auth_deny; Nauthilus applies those events and reaches terminal states internally.
Two common gotchas:
neutraldoes not meanpermit. It means "this rule did not deny or tempfail the request".permitis not allowed inpre_auth. Onlyauth_decisioncan grant final success.
Fact Categories in Plain Language
Policy attributes are grouped into categories. You do not need to put the category into an if condition; it is registry metadata that helps humans, reports, and future tooling understand where a fact belongs.
| Category | Think of it as | Common examples |
|---|---|---|
environment | Things around the request. | client IP, protocol, time, TLS status, RBL score, relay-domain result, brute-force state |
subject | Things about the user or account. | backend authentication result, identity found, exported LDAP/Lua backend attributes, account lock state |
resource | Things about a requested resource or resource-producing operation. | account-provider completion and account-list count |
action | The requested action. | Nauthilus currently uses request.operation for this instead of many action attributes. |
system | Internal/system facts reserved for registry and tooling use. | currently not needed in normal hand-written policies |
environment does not mean shell environment variables. It means "request surroundings". subject means "the identity/account side". A backend field such as accountStatus is a subject attribute only after you explicitly export it through auth.policy.attribute_exports.
Use Request Facts
YAML policies do not dereference the Lua request object. Use the registered policy attributes instead:
| Policy attribute | Lua-style field this is easy to confuse with | Use for |
|---|---|---|
request.client.ip | request.client_ip | CIDR checks, client-IP allow/deny decisions, network sets |
request.client.ip.present | no direct Lua field | fail-closed checks when the client IP is missing or invalid |
request.client.ip.trusted | no direct Lua field | source trust checks before a policy or scheduler guard relies on the IP |
request.client.ip.source | no direct Lua field | reporting and source-specific policy logic |
request.protocol | request.protocol | Protocol-specific behavior |
request.operation | no direct Lua field | authenticate, lookup_identity, or list_accounts decisions |
request.time.now | no direct Lua field | Time-window checks |
request.transport.kind | no direct Lua field | Distinguishing HTTP, gRPC, mail protocol, IdP, hook, or internal execution |
request.listener.name | no direct Lua field | Listener-scoped policies and scheduler guards |
request.connection.tls | no direct Lua field | Transport-derived TLS state without depending on a check result |
request.initiator.kind | no direct Lua field | External user traffic, backend health checks, internal service calls, or unknown callers |
request.http.route | no direct Lua field | Normalized HTTP route scoping without raw path or query input |
request.grpc.method | no direct Lua field | gRPC method scoping |
request.idp.client_id | no direct Lua field | Optional IdP/OIDC scoping when combined with trusted server-derived facts |
request.saml.sp_entity_id | no direct Lua field | Optional SAML scoping when combined with trusted server-derived facts |
For policy decisions, request.client.ip is useful when the source is part of the decision. For scheduler guards, combine it with request.client.ip.present and request.client.ip.trusted so missing, invalid, or untrusted IP data fails closed and the check still runs. Empty IP is not loopback, and loopback is not a universal security boundary.
CIDR decision example:
auth:
policy:
sets:
networks:
trusted_clients:
- 10.0.0.0/8
- 192.168.0.0/16
- 2001:db8::/32
policies:
- name: permit_trusted_network
stage: pre_auth
if:
attribute: request.client.ip
cidr_contains: "@network.trusted_clients"
then:
decision: neutral
reason: trusted_network
The same network set can be used by a scheduler guard, but the effect is different: a policy rule decides neutral, deny, permit, or tempfail; a scheduler guard only decides whether a selected check adapter runs.
Protocol example:
if:
attribute: request.protocol
eq: imap
Hostname fields from Lua request tables, such as request.client_host, are not built-in policy facts. If a policy needs them, emit a registered Lua policy attribute or export a selected backend result attribute.
Trusted proxy or client hints can become policy facts only through explicit allowlists. This keeps secrets, cookies, and high-cardinality metadata out of reports by default.
HTTP header allowlist:
auth:
policy:
request_headers:
- header: X-Company-Language
attribute: request.header.company_language
visibility: public
normalize:
trim: true
case: lower
max_length: 16
gRPC metadata allowlist:
auth:
policy:
request_metadata:
- key: x-company-language
attribute: request.metadata.company_language
visibility: public
normalize:
trim: true
case: lower
max_length: 16
HTTP header names are matched case-insensitively. gRPC metadata keys must be lowercase. Attribute IDs must use the correct prefix, either request.header.* or request.metadata.*, and must be unique across both allowlists.
Policy-Owned Scheduling
Lua mechanism entries define scripts. They do not define scheduling. Keep execution selection in auth.policy.checks so the policy snapshot has one authoritative operation plan.
The three scheduling controls are:
operations: selectsauthenticate,lookup_identity, orlist_accounts.run_if.auth_state: narrows execution by authenticated, unauthenticated, or any state.after: defines start order inside the compiled check plan.skip_if: attaches namedauth.policy.scheduler_guardsthat may skip a selected check before its adapter starts.
Scheduler guards reduce check coverage. Use them only for explicit operational exemptions, and keep final authorization in auth.policy.policies.
Start With the Default
If you do not need custom policy behavior, you can omit auth.policy entirely or keep the default shape:
auth:
policy:
mode: enforce
default_policy: standard_auth
standard_auth preserves default Nauthilus behavior as the built-in default policy set.
If you only want to control which checks run, where Lua scripts run, and how checks are ordered, configure checks only. standard_auth remains the decision authority and consumes the emitted facts.
Add policies only when you want to write custom decision rules.
The exact authority rules are:
- An omitted
auth.policyblock behaves likemode: enforcewithdefault_policy: standard_auth. - A non-empty
auth.policyblock does not automatically enable custom policy authority. - Configured
checks,sets,registry_scripts,attribute_sources, andobligation_targetscan be used whilestandard_authstill decides the result. - Custom authority starts only when
auth.policy.policiescontains a rule for the current operation and stage. standard_authis not merged into that same custom operation/stage rule list. It remains the default for stages that have no matching custom rules.
For example, if you configure only auth_decision policies, those rules own the final password-auth decision in mode: enforce, but pre_auth still uses standard_auth. If you configure only pre_auth policies, those rules own pre-auth decisions, but the final auth_decision stage still uses standard_auth.
Configure Scheduling Without Custom Decisions
This shape controls Lua execution while leaving decisions to standard_auth:
auth:
policy:
mode: enforce
default_policy: standard_auth
checks:
- name: lua_environment_geoip
type: lua.environment
stage: pre_auth
operations: [authenticate, lookup_identity]
config_ref: auth.policy.attribute_sources.lua.environment.geoip
- name: lua_environment_policy_gate
type: lua.environment
stage: pre_auth
operations: [authenticate, lookup_identity]
after: [lua_environment_geoip]
config_ref: auth.policy.attribute_sources.lua.environment.policy_gate
- name: lua_subject_context_seed
type: lua.subject
stage: subject_analysis
run_if:
auth_state: authenticated
config_ref: auth.policy.attribute_sources.lua.subject.context_seed
- name: lua_subject_billing_lock
type: lua.subject
stage: subject_analysis
after: [lua_subject_context_seed]
run_if:
auth_state: authenticated
config_ref: auth.policy.attribute_sources.lua.subject.billing_lock
The check plan is authoritative for the configured Lua script family in the active operation and stage. A script without a matching check does not run in that request plan. If you configure no checks for a Lua script family, Nauthilus uses the built-in default scheduling for that family.
For Lua environment and subject sources, standard_auth maps the generated Lua facts to the built-in result:
auth.lua.environment.<name>.errorselects a temporary failure.auth.lua.environment.<name>.triggeredselects a deny.auth.lua.environment.<name>.abortskips the remaining pre-auth checks for that stage.auth.lua.subject.<name>.errorselects a temporary failure.auth.lua.subject.<name>.rejectedselects a deny.
Policy Scheduling Goals
| Goal | Policy expression |
|---|---|
| Run for normal password auth | omit operations or set operations: [authenticate] |
| Run for HTTP no-auth or gRPC lookup | include lookup_identity in operations |
| Run only after backend auth succeeded | run_if.auth_state: authenticated |
| Run only after backend auth failed or is unauthenticated | run_if.auth_state: unauthenticated |
| Run in both authenticated and unauthenticated states | omit run_if or use run_if.auth_state: any |
| Run script B after script A | put after: [check_for_script_a] on script B's check |
| Skip a selected check for a trusted operational source or window | put skip_if: [guard_name] on the check and define guard_name under auth.policy.scheduler_guards |
| Depend on a script's fact in a rule | put that check in require_checks |
| Send Lua status message to the client | select the generated status_message attribute detail in response_message |
after and require_checks are different:
afteris check execution order.require_checksis policy applicability and validation.
Do not use require_checks as a scheduler. It never causes a check to run.
When skip_if skips a check, that check does not satisfy require_checks. A policy rule that requires the skipped check is non-applicable, not false, and later rules can still match.
Skip Selected Checks With Scheduler Guards
Scheduler guards live under auth.policy.scheduler_guards and are attached to individual checks with skip_if.
Each guard has a name, an if condition, and optional on_missing_attribute.
The guard name is the map key used by skip_if. The if condition must use request attributes only, because it runs before check adapters can emit facts. on_missing_attribute defaults to run, and run is currently the only supported value. This means that if any attribute referenced anywhere in the guard is missing, the guard does not skip the check; the check runs.
Scheduler guards support only the operators that are safe at check-scheduler time: exists, is, eq, ne, in, not_in, cidr_contains, and within_time_window. They do not support rule-only operators such as matches, list containment operators, or numeric comparisons. A guard that uses a client-controlled value such as an allowlisted header, gRPC metadata, OIDC client ID, or SAML SP entity ID must combine it with a server-derived fact such as trusted client IP, listener name, transport kind, or TLS state.
Use a source guard when a trusted operational source should skip one expensive or inappropriate pre-auth check:
auth:
policy:
sets:
networks:
pre_auth_exempt_sources:
- 127.0.0.0/8
- ::1
- 192.0.2.10/32
scheduler_guards:
pre_auth_exempt_source:
on_missing_attribute: run
if:
all:
- attribute: request.client.ip.present
is: true
- attribute: request.client.ip.trusted
is: true
- attribute: request.client.ip
cidr_contains: "@network.pre_auth_exempt_sources"
checks:
- name: rbl
type: builtin.rbl
stage: pre_auth
operations: [authenticate, lookup_identity]
skip_if: [pre_auth_exempt_source]
config_ref: auth.controls.rbl
output: checks.rbl
This does not permit the request. It only records rbl as skipped when the source guard matches. Any policy rule with require_checks: [rbl] is non-applicable for that request, and a later rule without that requirement may still match.
Use a time-window guard when a check should be skipped only during a documented window:
auth:
policy:
sets:
time_windows:
pre_auth_exempt_windows:
timezone: Europe/Berlin
days: [sunday]
intervals:
- start: "02:00"
end: "04:00"
scheduler_guards:
pre_auth_exempt_window:
on_missing_attribute: run
if:
attribute: request.time.now
within_time_window: "@time_window.pre_auth_exempt_windows"
checks:
- name: rbl
type: builtin.rbl
stage: pre_auth
operations: [authenticate]
skip_if: [pre_auth_exempt_window]
config_ref: auth.controls.rbl
output: checks.rbl
request.time.now is captured once per request, so all guard and policy checks use the same timestamp.
If a check has an after dependency on another check that can be skipped, copy the same guard to the dependent check:
checks:
- name: lua_environment_context
type: lua.environment
stage: pre_auth
skip_if: [pre_auth_exempt_source]
config_ref: auth.policy.attribute_sources.lua.environment.context
- name: lua_environment_policy_gate
type: lua.environment
stage: pre_auth
after: [lua_environment_context]
skip_if: [pre_auth_exempt_source]
config_ref: auth.policy.attribute_sources.lua.environment.policy_gate
This keeps the check graph deterministic. If the dependency can be skipped but the dependent check cannot, Nauthilus rejects the policy snapshot.
Migrate Implicit Loopback Skips
Older deployments may have relied on implicit loopback behavior in pre-auth controls. In the policy-controlled model, loopback, monitoring clients, backend health checks, and internal-looking callers do not get a hidden bypass. If a deployment still needs a loopback or monitoring-source exemption, make it explicit and attach it only to the checks that should be skipped.
Before migration, the exemption was implicit in mechanism code. After migration, it is visible in auth.policy:
auth:
policy:
sets:
networks:
pre_auth_exempt_sources:
- 127.0.0.0/8
- ::1
scheduler_guards:
pre_auth_exempt_source:
on_missing_attribute: run
if:
all:
- attribute: request.client.ip.present
is: true
- attribute: request.client.ip.trusted
is: true
- attribute: request.client.ip
cidr_contains: "@network.pre_auth_exempt_sources"
checks:
- name: tls_encryption
type: builtin.tls_encryption
stage: pre_auth
operations: [authenticate, lookup_identity]
skip_if: [pre_auth_exempt_source]
config_ref: auth.controls.tls_encryption
output: checks.tls_encryption
- name: rbl
type: builtin.rbl
stage: pre_auth
operations: [authenticate, lookup_identity]
skip_if: [pre_auth_exempt_source]
config_ref: auth.controls.rbl
output: checks.rbl
Review each check separately. It may be reasonable to skip RBL for a local health probe while still running TLS, Lua environment, relay-domain, or brute-force checks for the same source. Do not treat loopback as a general proof of safety; use listener identity, transport trust, and caller authentication wherever they describe the deployment better than an IP range.
Step 1: Define Lua Attribute Sources
Keep only the script identity and script path on environment and subject sources.
auth:
controls:
enabled:
- lua
policy:
attribute_sources:
lua:
environment:
- name: geoip
script_path: /etc/nauthilus/lua/environment/geoip.lua
- name: policy_gate
script_path: /etc/nauthilus/lua/environment/policy_gate.lua
subject:
- name: context_seed
script_path: /etc/nauthilus/lua/subject/context_seed.lua
- name: billing_lock
script_path: /etc/nauthilus/lua/subject/billing_lock.lua
These entries do not decide when they run. The policy check plan does.
Step 2: Add the Policy Header
auth:
policy:
mode: enforce
default_policy: standard_auth
registry_scripts: []
report:
enabled: false
include_fsm: true
include_checks: true
include_attributes: false
Use mode: observe first if you want to compare custom policy output against standard_auth without changing production decisions.
The report block is for diagnostics, not enforcement. Nauthilus builds request-local policy report data while evaluating a request; enabling report output lets you inspect the selected checks, attributes, policies, final decision, and observe-mode comparison in a redacted shape. It does not add fields to auth responses and it does not change permit, deny, tempfail, or neutral.
Use this while developing or debugging a policy:
auth:
policy:
mode: observe
report:
enabled: true
include_fsm: true
include_checks: true
include_attributes: true
Then turn include_attributes off again for normal operation unless you actively need emitted facts in report output. Redaction still applies, but attribute-heavy reports are larger and easier to over-collect.
Step 3: Define Checks
Create one check per built-in mechanism or named Lua script that your policies need.
auth:
policy:
checks:
- name: brute_force
type: builtin.brute_force
stage: pre_auth
config_ref: auth.controls.brute_force
output: checks.brute_force
- name: tls_encryption
type: builtin.tls_encryption
stage: pre_auth
operations: [authenticate, lookup_identity]
config_ref: auth.controls.tls_encryption
output: checks.tls_encryption
- name: lua_environment_geoip
type: lua.environment
stage: pre_auth
operations: [authenticate, lookup_identity]
config_ref: auth.policy.attribute_sources.lua.environment.geoip
output: checks.lua_environment_geoip
- name: lua_environment_policy_gate
type: lua.environment
stage: pre_auth
operations: [authenticate, lookup_identity]
after: [lua_environment_geoip]
config_ref: auth.policy.attribute_sources.lua.environment.policy_gate
output: checks.lua_environment_policy_gate
The check name can be your own stable name. The converter uses names such as lua_environment_<script> and lua_subject_<script> because they are predictable.
Step 4: Select Operations
Use operations to define which request operation may execute a check.
Run a check for normal password authentication and identity lookup:
operations: [authenticate, lookup_identity]
Run a check only for normal password authentication:
operations: [authenticate]
Because authenticate is the default, you may omit operations for auth-only checks.
Step 5: Select Auth-State Scheduling
Use run_if.auth_state only for structural check scheduling.
Run only after a successful backend result:
run_if:
auth_state: authenticated
Run only when the request is unauthenticated:
run_if:
auth_state: unauthenticated
Run in either state:
run_if:
auth_state: any
Or omit run_if, because any is the default.
Do not put business facts into run_if. For account lock state, country, risk level, group membership, RBL result, or billing state, emit or use attributes and write an if condition.
Combine Conditions in if
Each policy has one if tree and one then block. Nesting happens inside if; then is a single decision output, not another condition branch.
Use all, any, and not to combine facts:
if:
all:
- any:
- attribute: auth.rbl.threshold_reached
is: true
- attribute: auth.brute_force.triggered
is: true
- not:
any:
- attribute: auth.rbl.soft_allowlisted
is: true
- attribute: auth.relay_domain.soft_allowlisted
is: true
This reads as: deny only when either RBL or brute force is active, unless one of the relevant allowlist facts suppresses the decision.
Each condition object must contain exactly one expression node. Do not put attribute next to all, any, not, or always in the same object.
An attribute comparison is a leaf node and must contain exactly one operator. Do not write one condition with both eq and ne, or with gte and lte. Use all when a value must satisfy multiple comparisons:
if:
all:
- attribute: auth.rbl.score
gte: 5
- attribute: auth.rbl.score
lte: 20
For different outcomes, write multiple policies in the order they should win.
Choose the then Output
Every policy rule has one if tree and one then block. The if tree answers "does this rule match?" The then block answers "what does that match do?"
Most rules only need a decision, a stable internal reason, and sometimes a response marker:
then:
decision: deny
reason: billing_locked
response_marker: auth.response.fail
Use the then keys this way:
| Key | Use it when |
|---|---|
decision | Always. It is the actual policy effect: neutral, deny, permit, or tempfail. |
reason | You want a stable internal reason for logs, reports, metrics, traces, or follow-up context. |
response_marker | You need a specific response class, such as auth.response.tempfail.no_tls; otherwise the normal marker is derived from decision. |
response_message | You need a specific client-visible message inside the selected response class. |
response_language | A localized response_message.from: i18n should prefer a policy-selected language before transport language headers. |
fsm_event_marker | You need a specific FSM path such as auth.fsm.event.auth_empty_pass; otherwise the normal marker is derived from stage and decision. |
outcome_marker | You need a stable namespaced outcome label for reports or tooling. |
obligations | The selected decision must run registered enforcement work, such as synchronous Lua action dispatch, brute-force updates, or Lua POST-Action enqueueing. |
advice | You want non-binding context for reporting or follow-up handling. |
control.skip_remaining_stage_checks | A neutral pre-auth decision should stop later pre-auth checks without granting success. |
The common decision rules are:
| Stage | Good decisions | Avoid |
|---|---|---|
pre_auth | neutral, deny, tempfail | permit, because pre-auth cannot authenticate a user. |
auth_decision | permit, deny, tempfail | treating neutral as success. |
response_message has three forms:
then:
decision: deny
response_marker: auth.response.fail
response_message:
from: default
then:
decision: deny
response_marker: auth.response.fail
response_message:
from: literal
text: "Account temporarily locked"
then:
decision: deny
response_marker: auth.response.fail
response_message:
from: i18n
i18n_key: auth.policy.company.account_locked
fallback: "Login failed because the account is locked."
The i18n form selects a stable catalog key and keeps the fallback text with the decision. It does not localize inside policy evaluation. HTTP, gRPC, and IdP response rendering resolve the key at the response boundary.
Policy can also select the response language:
then:
decision: deny
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."
Or it can read a language tag from a public policy attribute:
then:
decision: deny
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."
Language selection only matters when the final response message has an i18n_key. The attribute_detail form works only for registered public string details with purpose: response_message. Lua may emit such a candidate, but YAML must explicitly select it before it becomes client-visible.
Use obligations sparingly and only with registered IDs:
then:
decision: deny
reason: brute_force_reject
obligations:
- id: auth.obligation.brute_force.update
- id: auth.obligation.lua_action.dispatch
args:
action: brute_force
- id: auth.obligation.lua_post_action.enqueue
args:
action: brute_force
Advice is softer than an obligation. It can enrich diagnostics or follow-up context, but it must not be required for correctness:
then:
decision: deny
reason: blocked_country
advice:
- id: auth.advice.audit_reason
args:
reason: blocked_country
For pre-auth abort-style behavior, use neutral plus stage-local control:
then:
decision: neutral
reason: control_aborted
control:
skip_remaining_stage_checks: true
That skips remaining checks in the current pre-auth stage. It does not permit the request and it does not skip final auth_decision.
Understand Actions and POST-Actions
Do not treat every Lua side effect as an implicit mechanism behavior. Synchronous Lua action dispatch and Lua POST-Action enqueueing are policy-owned obligations in policy-authoritative paths.
The script definitions stay under auth.policy.obligation_targets.lua.actions. The selected policy decision decides whether an existing action runs:
| Action surface | Config action type | Obligation |
|---|---|---|
| Synchronous Lua action | brute_force, lua, tls_encryption, relay_domains, or rbl | auth.obligation.lua_action.dispatch |
| Lua POST-Action | post | auth.obligation.lua_post_action.enqueue |
Use auth.obligation.lua_action.dispatch with args.action set to one of brute_force, lua, tls_encryption, relay_domains, or rbl. For action: lua, also pass args.environment when you need environment-specific reports or learning context. The optional args.wait defaults to true; the current runtime preserves synchronous wait behavior.
For example, a custom RBL rejection that should keep the configured synchronous RBL action must say so:
then:
decision: deny
reason: rbl_reject
response_marker: auth.response.fail
obligations:
- id: auth.obligation.lua_action.dispatch
args:
action: rbl
For brute-force denials, add all three side-effect obligations if your custom policy replaces the built-in rule:
then:
decision: deny
reason: brute_force_reject
response_marker: auth.response.fail
obligations:
- id: auth.obligation.brute_force.update
- id: auth.obligation.lua_action.dispatch
args:
action: brute_force
- id: auth.obligation.lua_post_action.enqueue
args:
action: brute_force
This mirrors the built-in standard_auth brute-force denial. Without these obligations, a custom policy can still deny or tempfail, but it will not dispatch the synchronous Lua action, update brute-force state, or enqueue the POST-Action.
In mode: observe, custom policy obligations are reported but not executed. That keeps observe mode safe: no custom synchronous Lua action dispatch, no custom POST-Action enqueueing, no custom brute-force counter updates, and no custom learning side effects.
Step 6: Define Start Order with after
Use after when one check needs request-local state or emitted facts from another check before it can run.
First define the scripts:
auth:
policy:
attribute_sources:
lua:
subject:
- name: context_seed
script_path: /etc/nauthilus/lua/subject/context_seed.lua
- name: billing_lock
script_path: /etc/nauthilus/lua/subject/billing_lock.lua
Then define the check order:
auth:
policy:
checks:
- name: lua_subject_context_seed
type: lua.subject
stage: subject_analysis
run_if:
auth_state: authenticated
config_ref: auth.policy.attribute_sources.lua.subject.context_seed
output: checks.lua_subject_context_seed
- name: lua_subject_billing_lock
type: lua.subject
stage: subject_analysis
after: [lua_subject_context_seed]
run_if:
auth_state: authenticated
config_ref: auth.policy.attribute_sources.lua.subject.billing_lock
output: checks.lua_subject_billing_lock
after references check names, not script names. Dependencies must be scheduler-compatible: the dependency must be in the same stage and must cover the dependent check's operations and auth-state guard.
Step 7: Write Policies for Pre-Auth Environment Sources
A Lua environment source can emit generated attributes:
auth.lua.environment.<name>.triggeredauth.lua.environment.<name>.abortauth.lua.environment.<name>.error
Use those attributes in policies.
auth:
policy:
policies:
- name: geoip_environment_error
stage: pre_auth
operations: [authenticate, lookup_identity]
require_checks: [lua_environment_geoip]
if:
attribute: auth.lua.environment.geoip.error
is: true
then:
decision: tempfail
reason: geoip_error
response_marker: auth.response.tempfail
- name: geoip_environment_deny
stage: pre_auth
operations: [authenticate, lookup_identity]
require_checks: [lua_environment_geoip]
if:
attribute: auth.lua.environment.geoip.triggered
is: true
then:
decision: deny
reason: geoip_blocked
response_marker: auth.response.fail
response_message:
from: attribute_detail
attribute: auth.lua.environment.geoip.triggered
detail: status_message
fallback: "Invalid login or password"
For a Lua environment source that aborts later pre-auth checks but does not deny:
- name: geoip_environment_abort
stage: pre_auth
operations: [authenticate, lookup_identity]
require_checks: [lua_environment_geoip]
if:
attribute: auth.lua.environment.geoip.abort
is: true
then:
decision: neutral
reason: geoip_aborted_pre_auth
control:
skip_remaining_stage_checks: true
Do not use permit in pre_auth. Pre-auth controls can deny, tempfail, or let the request continue.
Step 8: Write Policies for Backend and Lua Subject Sources
Backends produce final auth facts:
auth.authenticatedauth.identity.foundauth.backend.tempfailauth.backend.empty_usernameauth.backend.empty_password
Lua subject sources produce generated attributes:
auth.lua.subject.<name>.rejectedauth.lua.subject.<name>.error
Example:
auth:
policy:
policies:
- name: billing_subject_error
stage: auth_decision
require_checks: [lua_subject_billing_lock]
if:
attribute: auth.lua.subject.billing_lock.error
is: true
then:
decision: tempfail
reason: billing_subject_error
response_marker: auth.response.tempfail
- name: billing_subject_reject
stage: auth_decision
require_checks: [lua_subject_billing_lock]
if:
attribute: auth.lua.subject.billing_lock.rejected
is: true
then:
decision: deny
reason: billing_locked
response_marker: auth.response.fail
response_message:
from: attribute_detail
attribute: auth.lua.subject.billing_lock.rejected
detail: status_message
fallback: "Invalid login or password"
- name: backend_tempfail
stage: auth_decision
operations: [authenticate, lookup_identity]
if:
attribute: auth.backend.tempfail
is: true
then:
decision: tempfail
reason: backend_tempfail
response_marker: auth.response.tempfail
- name: auth_success
stage: auth_decision
operations: [authenticate]
if:
attribute: auth.authenticated
is: true
then:
decision: permit
reason: auth_success
response_marker: auth.response.ok
- name: auth_failure
stage: auth_decision
operations: [authenticate]
if:
attribute: auth.authenticated
is: false
then:
decision: deny
reason: auth_failure
response_marker: auth.response.fail
- name: default_deny
stage: auth_decision
operations: [authenticate, lookup_identity, list_accounts]
if:
always: true
then:
decision: deny
reason: default_deny
response_marker: auth.response.fail
Keep a default-deny rule last in custom final-auth policy sets.
Step 9: Add Lookup and Account Listing
For no-auth identity lookup:
- name: lookup_identity_success
stage: auth_decision
operations: [lookup_identity]
if:
attribute: auth.identity.found
is: true
then:
decision: permit
reason: lookup_identity_success
response_marker: auth.response.ok
- name: lookup_identity_failure
stage: auth_decision
operations: [lookup_identity]
if:
attribute: auth.identity.found
is: false
then:
decision: deny
reason: lookup_identity_failure
response_marker: auth.response.fail
For account listing, define the account-provider check:
- name: account_provider
type: backend.account_provider
stage: account_provider
operations: [list_accounts]
config_ref: auth.backends
output: checks.account_provider
Then add final decisions:
- name: list_accounts_success
stage: auth_decision
operations: [list_accounts]
require_checks: [account_provider]
if:
attribute: auth.account_provider.completed
is: true
then:
decision: permit
reason: list_accounts_success
response_marker: auth.response.list_accounts.ok
- name: list_accounts_tempfail
stage: auth_decision
operations: [list_accounts]
require_checks: [account_provider]
if:
attribute: auth.account_provider.tempfail
is: true
then:
decision: tempfail
reason: list_accounts_tempfail
response_marker: auth.response.tempfail
The account list itself is response data. It is not exposed as a policy attribute.
If account listing should be available only from a bounded service source, write that as a final policy restriction rather than a scheduler guard. Scheduler guards skip checks; they are not final authorization decisions.
auth:
policy:
sets:
networks:
account_listing_sources:
- 192.0.2.10/32
policies:
- name: deny_list_accounts_untrusted_source
stage: auth_decision
operations: [list_accounts]
if:
not:
all:
- attribute: request.client.ip.present
is: true
- attribute: request.client.ip.trusted
is: true
- attribute: request.client.ip
cidr_contains: "@network.account_listing_sources"
then:
decision: deny
reason: list_accounts_untrusted_source
response_marker: auth.response.fail
- name: list_accounts_success
stage: auth_decision
operations: [list_accounts]
require_checks: [account_provider]
if:
attribute: auth.account_provider.completed
is: true
then:
decision: permit
reason: list_accounts_success
response_marker: auth.response.list_accounts.ok
Put the source restriction before the success rule. A missing or untrusted IP fails closed into the deny rule. Existing caller-auth requirements, backchannel credentials, and gRPC scopes still apply before policy evaluation.
Model Multiple Brute-Force Buckets
Brute-force protection can have more than one configured bucket. Nauthilus exposes both global summary facts and generated per-bucket facts so a policy can combine bucket states explicitly.
Bucket names are normalized into policy identifier segments. For example:
| Bucket name | Policy segment |
|---|---|
IMAP Short | imap_short |
SMTP/Auth Burst | smtp_auth_burst |
24h | b_24h |
If two bucket names normalize to the same segment, the policy snapshot is rejected. This keeps generated policy attributes stable.
auth:
controls:
brute_force:
buckets:
- name: "IMAP Short"
period: 10m
ban_time: 1h
cidr: 32
ipv4: true
ipv6: false
failed_requests: 5
- name: "IMAP Long"
period: 24h
ban_time: 8h
cidr: 24
ipv4: true
ipv6: false
failed_requests: 25
policy:
checks:
- name: brute_force
type: builtin.brute_force
stage: pre_auth
config_ref: auth.controls.brute_force
policies:
- name: imap_bucket_pressure
stage: pre_auth
require_checks: [brute_force]
if:
all:
- attribute: auth.brute_force.bucket.imap_short.ratio
gte: 0.8
- attribute: auth.brute_force.bucket.imap_long.already_banned
is: false
- attribute: auth.brute_force.rwp.active
is: false
then:
decision: deny
reason: brute_force_bucket_pressure
response_marker: auth.response.fail
- name: imap_multi_bucket_repeat
stage: pre_auth
require_checks: [brute_force]
if:
any:
- attribute: auth.brute_force.bucket.imap_short.over_limit
is: true
- all:
- attribute: auth.brute_force.bucket.imap_long.ratio
gte: 0.7
- attribute: auth.brute_force.bucket.imap_short.repeating
is: true
then:
decision: deny
reason: brute_force_repeating_bucket_state
response_marker: auth.response.fail
The most useful global attributes are:
auth.brute_force.triggered: the final built-in block status.auth.brute_force.repeating: whether any selected bucket indicates a repeating state.auth.brute_force.rwp.active: whether the repeating-wrong-password protection tolerated the attempt and prevented bucket increments.auth.brute_force.rwp.enforce_bucket_update: inverse ofrwp.active; useful when a policy should only act on attempts that update buckets.auth.brute_force.toleration.active: whether reputation toleration currently applies.auth.brute_force.toleration.mode:static,adaptive, ordisabled.auth.brute_force.toleration.suppressed_block: whether toleration prevented a brute-force block after a bucket matched.auth.brute_force.bucket.matched_count: number of buckets that matched the request context.auth.brute_force.bucket.triggered_count: number of buckets that are over limit or already banned.auth.brute_force.bucket.max_ratio: highest bucket fill ratio for the request.
Model RBL Facts
RBL checks expose aggregate score facts and generated per-list facts. List names are normalized just like brute-force bucket names.
auth:
controls:
rbl:
threshold: 5
lists:
- name: "Zen Spamhaus"
rbl: zen.spamhaus.org
return_codes: [127.0.0.2]
ipv4: true
ipv6: false
weight: 5
policy:
checks:
- name: rbl
type: builtin.rbl
stage: pre_auth
operations: [authenticate, lookup_identity]
config_ref: auth.controls.rbl
policies:
- name: deny_zen_match
stage: pre_auth
operations: [authenticate, lookup_identity]
require_checks: [rbl]
if:
any:
- attribute: auth.rbl.threshold_reached
is: true
- all:
- attribute: auth.rbl.list.zen_spamhaus.listed
is: true
- attribute: auth.rbl.score
gte: 5
then:
decision: deny
reason: rbl_match
response_marker: auth.response.fail
Useful RBL attributes:
auth.rbl.score: aggregate score for the request.auth.rbl.threshold: configured rejection threshold.auth.rbl.matched_count: number of matched lists.auth.rbl.matched_lists: string list of matched RBL names.auth.rbl.allow_failure_error_count: lookup errors ignored because the list allows failure.auth.rbl.effective_error: lookup error that affects the decision.auth.rbl.soft_allowlistedandauth.rbl.ip_allowlisted: whitelist handling.auth.rbl.list.<list>.listed,.weight,.error,.allow_failure: per-list facts.
Model Relay-Domain Facts
Relay-domain checks expose the parsed domain value, the static-domain match state, and soft-allowlist state.
auth:
policy:
checks:
- name: relay_domains
type: builtin.relay_domains
stage: pre_auth
config_ref: auth.controls.relay_domains
policies:
- name: reject_external_relay_domain
stage: pre_auth
require_checks: [relay_domains]
if:
all:
- attribute: auth.relay_domain.present
is: true
- attribute: auth.relay_domain.known
is: false
- attribute: auth.relay_domain.soft_allowlisted
is: false
then:
decision: deny
reason: relay_domain_rejected
response_marker: auth.response.fail
Useful relay-domain attributes:
auth.relay_domain.value: parsed domain from the username.auth.relay_domain.present: username contained a valid domain part.auth.relay_domain.known: the domain matched the configured static domain list.auth.relay_domain.rejected: the built-in relay-domain control rejected the request.auth.relay_domain.static_match: a static domain matched.auth.relay_domain.soft_allowlisted: soft allowlist suppressed the check.auth.relay_domain.configured_count: number of configured static domains.
Export Backend Attributes
Use backend attribute exports when LDAP or Lua backends return account facts that should drive policy decisions.
auth:
policy:
attribute_exports:
- name: account_status
attribute: accountStatus
type: string
- name: entitlements
attribute: entitlements
type: string_list
checks:
- name: ldap_backend
type: backend.ldap
stage: auth_backend
operations: [authenticate, lookup_identity]
config_ref: auth.backends.ldap
policies:
- name: deny_locked_account
stage: auth_decision
operations: [authenticate, lookup_identity]
if:
attribute: auth.subject.attribute.account_status
detail: value
eq: locked
then:
decision: deny
reason: account_locked
response_marker: auth.response.fail
- name: permit_imap_entitlement
stage: auth_decision
operations: [authenticate]
if:
all:
- attribute: auth.authenticated
is: true
- attribute: auth.subject.attribute.entitlements
detail: values
contains: imap
then:
decision: permit
reason: imap_entitled
response_marker: auth.response.ok
The generated auth.subject.attribute.<name> attribute is a boolean presence fact. Scalar exports put the typed value into the value detail. string_list exports put the list into the values detail. Export only fields you actually want as policy material; backend attributes are otherwise kept out of the policy registry.
Complete Example
This example expresses a common setup explicitly:
- brute force blocks normal password auth
- TLS is required for password auth and identity lookup
- two Lua environment sources run before backend auth, with one ordered after the other
- LDAP backend facts decide final auth and lookup behavior
- two Lua subject sources run after successful backend auth, ordered through
after - account listing is permitted only when the account provider completes
auth:
controls:
enabled:
- brute_force
- tls_encryption
- lua
brute_force:
protocols: [imap, smtp, submission]
buckets:
- name: login_rule
period: 10m
ban_time: 4h
cidr: 24
ipv4: true
ipv6: false
failed_requests: 5
tls_encryption:
allow_cleartext_networks:
- 127.0.0.0/8
backends:
order: [cache, ldap]
ldap:
default:
server_uri:
- ldapi:///
search: []
policy:
mode: enforce
default_policy: standard_auth
registry_scripts: []
attribute_sources:
lua:
environment:
- name: geoip
script_path: /etc/nauthilus/lua/environment/geoip.lua
- name: policy_gate
script_path: /etc/nauthilus/lua/environment/policy_gate.lua
subject:
- name: context_seed
script_path: /etc/nauthilus/lua/subject/context_seed.lua
- name: billing_lock
script_path: /etc/nauthilus/lua/subject/billing_lock.lua
report:
enabled: false
include_fsm: true
include_checks: true
include_attributes: false
checks:
- name: brute_force
type: builtin.brute_force
stage: pre_auth
config_ref: auth.controls.brute_force
output: checks.brute_force
- name: tls_encryption
type: builtin.tls_encryption
stage: pre_auth
operations: [authenticate, lookup_identity]
config_ref: auth.controls.tls_encryption
output: checks.tls_encryption
- name: lua_environment_geoip
type: lua.environment
stage: pre_auth
operations: [authenticate, lookup_identity]
config_ref: auth.policy.attribute_sources.lua.environment.geoip
output: checks.lua_environment_geoip
- name: lua_environment_policy_gate
type: lua.environment
stage: pre_auth
operations: [authenticate, lookup_identity]
after: [lua_environment_geoip]
config_ref: auth.policy.attribute_sources.lua.environment.policy_gate
output: checks.lua_environment_policy_gate
- name: ldap_backend
type: backend.ldap
stage: auth_backend
operations: [authenticate, lookup_identity]
config_ref: auth.backends.ldap
output: checks.ldap_backend
- name: lua_subject_context_seed
type: lua.subject
stage: subject_analysis
run_if:
auth_state: authenticated
config_ref: auth.policy.attribute_sources.lua.subject.context_seed
output: checks.lua_subject_context_seed
- name: lua_subject_billing_lock
type: lua.subject
stage: subject_analysis
after: [lua_subject_context_seed]
run_if:
auth_state: authenticated
config_ref: auth.policy.attribute_sources.lua.subject.billing_lock
output: checks.lua_subject_billing_lock
- name: account_provider
type: backend.account_provider
stage: account_provider
operations: [list_accounts]
config_ref: auth.backends
output: checks.account_provider
policies:
- name: brute_force_deny
stage: pre_auth
require_checks: [brute_force]
if:
attribute: auth.brute_force.triggered
is: true
then:
decision: deny
reason: brute_force
response_marker: auth.response.fail
obligations:
- id: auth.obligation.brute_force.update
- name: tls_required
stage: pre_auth
operations: [authenticate, lookup_identity]
require_checks: [tls_encryption]
if:
attribute: auth.tls.secure
is: false
then:
decision: tempfail
reason: no_tls
response_marker: auth.response.tempfail.no_tls
- name: policy_gate_deny
stage: pre_auth
operations: [authenticate, lookup_identity]
require_checks: [lua_environment_policy_gate]
if:
attribute: auth.lua.environment.policy_gate.triggered
is: true
then:
decision: deny
reason: policy_gate
response_marker: auth.response.fail
response_message:
from: attribute_detail
attribute: auth.lua.environment.policy_gate.triggered
detail: status_message
fallback: "Invalid login or password"
- name: backend_tempfail
stage: auth_decision
operations: [authenticate, lookup_identity]
if:
attribute: auth.backend.tempfail
is: true
then:
decision: tempfail
reason: backend_tempfail
response_marker: auth.response.tempfail
- name: billing_lock_deny
stage: auth_decision
require_checks: [lua_subject_billing_lock]
if:
attribute: auth.lua.subject.billing_lock.rejected
is: true
then:
decision: deny
reason: billing_locked
response_marker: auth.response.fail
response_message:
from: attribute_detail
attribute: auth.lua.subject.billing_lock.rejected
detail: status_message
fallback: "Invalid login or password"
- name: auth_success
stage: auth_decision
operations: [authenticate]
if:
attribute: auth.authenticated
is: true
then:
decision: permit
reason: auth_success
response_marker: auth.response.ok
- name: auth_failure
stage: auth_decision
operations: [authenticate]
if:
attribute: auth.authenticated
is: false
then:
decision: deny
reason: auth_failure
response_marker: auth.response.fail
- name: lookup_success
stage: auth_decision
operations: [lookup_identity]
if:
attribute: auth.identity.found
is: true
then:
decision: permit
reason: lookup_success
response_marker: auth.response.ok
- name: lookup_failure
stage: auth_decision
operations: [lookup_identity]
if:
attribute: auth.identity.found
is: false
then:
decision: deny
reason: lookup_failure
response_marker: auth.response.fail
- name: list_accounts_tempfail
stage: auth_decision
operations: [list_accounts]
require_checks: [account_provider]
if:
attribute: auth.account_provider.tempfail
is: true
then:
decision: tempfail
reason: list_accounts_tempfail
response_marker: auth.response.tempfail
- name: list_accounts_success
stage: auth_decision
operations: [list_accounts]
require_checks: [account_provider]
if:
attribute: auth.account_provider.completed
is: true
then:
decision: permit
reason: list_accounts_success
response_marker: auth.response.list_accounts.ok
- name: default_deny
stage: auth_decision
operations: [authenticate, lookup_identity, list_accounts]
if:
always: true
then:
decision: deny
reason: default_deny
response_marker: auth.response.fail
This is a template. Keep only the checks and rules that match the mechanisms in your deployment.
Custom Lua Attributes
Generated Lua environment and subject source attributes are enough for trigger, abort, reject, error, and status-message behavior. Use a registry script when Lua needs to expose custom facts.
Policy registry script:
nauthilus_policy.register_attribute({
id = "lua.risk.high",
stage = "pre_auth",
operations = { "authenticate", "lookup_identity" },
category = "environment",
type = "bool",
description = "The request has high local risk",
details = {
source = "string",
status_message = {
type = "string",
sensitivity = "public",
purpose = "response_message",
max_length = 256,
},
},
})
Config:
auth:
policy:
registry_scripts:
- /etc/nauthilus/policy/attributes.lua
Policy:
- name: deny_high_risk
stage: pre_auth
operations: [authenticate, lookup_identity]
require_checks: [lua_environment_geoip]
if:
attribute: lua.risk.high
is: true
then:
decision: deny
reason: high_risk
response_marker: auth.response.fail
response_message:
from: attribute_detail
attribute: lua.risk.high
detail: status_message
fallback: "Invalid login or password"
Runtime Lua must emit only attributes already registered in the active snapshot.
Lua Migration for Localized Responses
Keep nauthilus_builtin.status_message_set(...) as fallback text, and emit stable policy facts with nauthilus_policy.emit_attribute(...). Do not derive localization keys from free-text Lua status messages.
Policy registry script:
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",
})
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",
})
Subject source excerpt:
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",
})
if preferred_language ~= "" then
policy.emit_attribute({
id = "lua.company.preferred_language",
value = preferred_language,
})
end
end
Policy mapping:
auth:
policy:
policies:
- name: deny_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_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."
This keeps Lua in the fact-emitter role. The YAML policy decides which fact becomes a denial, which catalog key is used, and whether the Lua-selected language may override Accept-Language or gRPC accept-language.
Testing a Policy Change
- Start with
mode: observefor non-trivial custom policies. - Enable reports only where you need diagnostics.
- Validate the config.
- Inspect
-noutput for the effective policy. - Switch to
mode: enforcewhen the observed decisions match your intent.
Commands:
nauthilus --config /etc/nauthilus/nauthilus.yml --config-check
nauthilus -n --config /etc/nauthilus/nauthilus.yml
nauthilus -d
For localized response policies, keep the tests narrow and mocked:
- Use fake catalogs or a fake localization resolver for missing-key, selected-language, and fallback cases.
- Use request fixtures for
Accept-Language,Content-Language, gRPCaccept-language, and gRPCcontent-language. - Use explicit header and metadata fixtures for
auth.policy.request_headersandauth.policy.request_metadata; assert that non-allowlisted values are absent. - Use mocked auth outcomes for HTTP, gRPC, and IdP rendering tests instead of real Redis, LDAP, or backend authentication.
- Use
--test-luafixtures or hermetic Lua states fornauthilus_policy.emit_attribute(...)andnauthilus_i18nAPI shape tests. - Keep documentation example keys such as
auth.policy.company.*out of production resource JSON files.
Troubleshooting
Unknown keys on Lua environment or subject source entries:
- Keep Lua script entries to their supported fields such as
nameandscript_path. - Express operation scope, auth-state scheduling, and start order in
auth.policy.checks.
require_checks references an unknown check:
- Check the exact
nameinauth.policy.checks. require_checksuses check names, not attribute IDs and not script paths.
Same-stage attribute validation fails:
- Add the producing check to
require_checks. - Same-stage facts require explicit producer declaration so the scheduler and rule dependency are obvious.
after is not scheduler-compatible:
- Keep dependencies in the same stage.
- Make sure the dependency covers all operations and auth-state guards of the dependent check.
Response-message selection fails:
- Use
from: literalfor static messages. - Use
from: attribute_detailonly for registered public response-message string details such as generated Luastatus_message.
A final policy set denies unexpectedly:
- Remember that
auth_decisionis deny-biased. - Add a final success rule before the final default-deny rule.
- Keep
default_denylast.